File size: 7,042 Bytes
a070805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
import {
  AUTOMATION_CATALOG,
  type RecommendedAutomation,
} from "@openhands/extensions/automations";
import {
  SKILLS_CATALOG,
  type SkillCatalogEntry,
} from "@openhands/extensions/skills";
import type { LucideIcon } from "lucide-react";
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
import { interpolateValues } from "#/manifests/manifest-template";
import type { Automation } from "#/types/automation";

/**
 * Reading a published automation catalog entry.
 *
 * The catalog states each thing once and leaves the rest to be derived, so an
 * entry no longer carries its own launch command or a flat list of required
 * integration ids. This module is the single place that performs those
 * derivations, so the components consuming the catalog stay unaware of how it
 * is shaped.
 */

const SKILL_BY_NAME = new Map<string, SkillCatalogEntry>(
  SKILLS_CATALOG.map((skill) => [skill.name, skill]),
);

/**
 * The glyph a card shows in place of its integration logos, or null when it
 * has none to show and the logos speak for it.
 *
 * An entry naming the service it talks to is recognised by that service's
 * logo; one whose identity is the work itself declares a slug instead. The
 * lookup is a guard as much as a mapping — the catalog is published elsewhere,
 * so a slug this host has no artwork for falls back rather than rendering
 * nothing.
 */
export function getAutomationIcon(
  automation: RecommendedAutomation,
): LucideIcon | null {
  const slug = automation.icon;
  if (!slug) return null;
  return (
    (MANIFEST_ICON_BY_SLUG as Record<string, LucideIcon | undefined>)[slug] ??
    null
  );
}

/** Every integration the entry declares, in declaration order. */
export function getIntegrationIds(automation: RecommendedAutomation): string[] {
  return Object.keys(automation.requires.integrations);
}

/**
 * The integrations that gate this automation. One marked `required: false` can
 * be connected later, during setup, so it is still worth showing on the card
 * but must never stand between the user and a launch.
 */
export function getRequiredIntegrationIds(
  automation: RecommendedAutomation,
): string[] {
  return Object.entries(automation.requires.integrations)
    .filter(([, requirement]) => requirement.required !== false)
    .map(([id]) => id);
}

/**
 * The command that invokes this automation's skill, or null when that skill is
 * invoked by description instead.
 *
 * The command is declared once, in the owning skill's own `triggers`; the
 * catalog names that skill only where it differs from the entry id.
 */
export function findAutomationCommand(
  automation: Pick<RecommendedAutomation, "id" | "skill">,
): string | null {
  const skill = SKILL_BY_NAME.get(automation.skill ?? automation.id);
  return skill?.triggers[0] ?? null;
}

/**
 * What launching an automation card hands to the agent.
 *
 * The resolved command is passed through as-is: API routing (host, auth) is
 * discovered by the agent at runtime from `<RUNTIME_SERVICES>` in the system
 * prompt, and the skills themselves carry the instructions for reading that
 * block. An automation whose skill declares no command has the request spelled
 * out instead. Both are instructions to the agent rather than user-facing UI
 * copy, so they are intentionally in English and not localized.
 */
export function getAutomationLaunchPrompt(
  automation: RecommendedAutomation,
): string {
  return (
    findAutomationCommand(automation) ??
    `Set up the ${automation.name} automation`
  );
}

const CATALOG_ENTRY_BY_ID = new Map<string, RecommendedAutomation>(
  AUTOMATION_CATALOG.map((entry) => [entry.id, entry]),
);

/** Rejects markup the same way the catalog schema's copy rule does. */
const MARKUP_PATTERN = /<[A-Za-z/!]/;

/** Every `{{` an impact phrase opens must be the count placeholder. */
const FOREIGN_PLACEHOLDER_PATTERN = /\{\{(?!count\}\})/;

const COUNT_PLACEHOLDER = "{{count}}";

interface AutomationImpactCopy {
  one: string;
  other: string;
}

function isImpactPhrase(value: unknown): value is string {
  return (
    typeof value === "string" &&
    value.length > 0 &&
    !MARKUP_PATTERN.test(value) &&
    !FOREIGN_PLACEHOLDER_PATTERN.test(value)
  );
}

/**
 * The entry's `impact` value statement, or null when it declares none this
 * host can honor. The catalog is published elsewhere, so the field is read
 * off the raw entry and validated here rather than trusted from the package
 * types — which also keeps this module compiling against a pinned package
 * that predates the field. A basis other than `completed-runs` is a counter
 * this host does not know how to compute, so it renders nothing rather than
 * a number the phrase was not written for.
 */
function readImpactCopy(
  entry: RecommendedAutomation,
): AutomationImpactCopy | null {
  const { impact } = entry as unknown as Record<string, unknown>;
  if (typeof impact !== "object" || impact === null) return null;
  const { basis, one, other } = impact as Record<string, unknown>;
  if (basis !== "completed-runs") return null;
  if (!isImpactPhrase(one) || !isImpactPhrase(other)) return null;
  if (!other.includes(COUNT_PLACEHOLDER)) return null;
  return { one, other };
}

/**
 * The catalog entry an installed automation was created from, via the
 * `template` provenance its setup stored — the only join back to the catalog.
 * Null for automations without provenance: imports, agent-built ones, and
 * entries whose setup publishes no version.
 */
function getAutomationTemplateEntry(
  automation: Automation,
): RecommendedAutomation | null {
  const metadata = automation.preset_metadata;
  if (typeof metadata !== "object" || metadata === null) return null;
  const { template } = metadata as Record<string, unknown>;
  if (typeof template !== "object" || template === null) return null;
  const { id } = template as Record<string, unknown>;
  if (typeof id !== "string" || id.length === 0) return null;
  return CATALOG_ENTRY_BY_ID.get(id) ?? null;
}

/**
 * The value statement an automation card shows for its completed runs, or
 * null when there is nothing defensible to say: no template provenance, no
 * honored `impact` declaration, an unknown count (older service with more
 * history than the sample), or a count of zero — absence, never a zero-value
 * claim. Provenance persists across prompt edits, so a reworked automation
 * keeps its template's phrase; the phrases are run-shaped, which keeps them
 * true for as long as the automation runs at all.
 */
export function resolveAutomationImpactStatement(
  automation: Automation,
  completedTotal: number | null,
): string | null {
  if (completedTotal === null || completedTotal < 1) return null;
  const entry = getAutomationTemplateEntry(automation);
  if (!entry) return null;
  const copy = readImpactCopy(entry);
  if (!copy) return null;
  const phrase = completedTotal === 1 ? copy.one : copy.other;
  return interpolateValues(phrase, { count: completedTotal.toLocaleString() });
}