@chatpanel/events 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -43,4 +43,6 @@ export { createManifest, ManifestError, SOURCES } from './manifest.js';
43
43
  export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
44
44
  export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
45
45
  export { compileQuery, findMatches, matchIndexFor, expandReplacement, replaceMatch, replaceAll, replaceAllInRange, MAX_MATCHES } from './text-search.js';
46
+ export { SKILL_MANIFEST_VERSION, SKILL_CONTEXTS, SKILL_HISTORY_SCOPES, SKILL_MCP_MODES, SKILL_TRUST, SKILL_FILE_KINDS, SKILL_UPCASTERS, SkillManifestError, isSafeSkillPath, originOf, trustOf, skillFiles, needsBridge, declaredAccess, originLabel, sameSkillOrigin, skillIsStale, validateSkill, upcastSkill, upcastSkills, normalizeSkill } from './skill-manifest.js';
47
+ export { SKILL_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, skillVarGuidance, SkillVarError } from './skill-vars.js';
46
48
  export { outlineOf, parseListItem, continueList, indentSelection, toggleWrap, toggleLinePrefix, toggleTask, toggleLink, docStats, selectionStats } from './markdown-authoring.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.4.0",
4
- "description": "The canonical ChatPanel event-log and capability contracts typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
3
+ "version": "0.6.0",
4
+ "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -25,6 +25,8 @@
25
25
  "./router.js": "./router.js",
26
26
  "./rules.js": "./rules.js",
27
27
  "./search-engines.js": "./search-engines.js",
28
+ "./skill-manifest.js": "./skill-manifest.js",
29
+ "./skill-vars.js": "./skill-vars.js",
28
30
  "./sources-retrieval.js": "./sources-retrieval.js",
29
31
  "./sources.js": "./sources.js",
30
32
  "./store.js": "./store.js",
@@ -35,6 +37,8 @@
35
37
  "./upcast.js": "./upcast.js"
36
38
  },
37
39
  "files": [
40
+ "LICENSE",
41
+ "README.md",
38
42
  "adapters.js",
39
43
  "capability.js",
40
44
  "citations.js",
@@ -55,6 +59,8 @@
55
59
  "router.js",
56
60
  "rules.js",
57
61
  "search-engines.js",
62
+ "skill-manifest.js",
63
+ "skill-vars.js",
58
64
  "sources-retrieval.js",
59
65
  "sources.js",
60
66
  "store.js",
@@ -62,9 +68,7 @@
62
68
  "tool-groups.js",
63
69
  "tool-need.js",
64
70
  "trajectory.js",
65
- "upcast.js",
66
- "LICENSE",
67
- "README.md"
71
+ "upcast.js"
68
72
  ],
69
73
  "scripts": {
70
74
  "test": "node --test tests/*.test.js"
@@ -0,0 +1,330 @@
1
+ // skill-manifest.js — what a skill IS, for every client that stores or runs one.
2
+ //
3
+ // A skill was a settings record: a prompt plus a few dropdowns, normalized by whichever
4
+ // client happened to save it. That was survivable while every skill was hand-written by
5
+ // the person running it. It stops being survivable the moment a skill can ARRIVE from
6
+ // somewhere — a hub, a repo, another agent's skill directory — because the questions
7
+ // change from "what does this prompt say" to "where did it come from, what may it reach,
8
+ // and has anything checked it".
9
+ //
10
+ // So this module owns the record. Not the storage (a platform question), not the UI (a
11
+ // platform question), not the fetching (F6 S3) — the shape, its evolution, and the two
12
+ // derivations no client may compute for itself:
13
+ //
14
+ // • TRUST IS DERIVED, NEVER DECLARED. A skill cannot say it is trusted. `trustOf()`
15
+ // reads provenance: shipped by us -> 'built-in', fetched -> 'community', neither ->
16
+ // 'user'. A stored `trust` field is stripped on normalize, and `builtin` is forced
17
+ // false whenever an origin is present — otherwise "trusted" would be a string an
18
+ // importer sets.
19
+ // • DECLARED ACCESS IS COMPUTED FROM THE RECORD. Everything a skill can reach is
20
+ // readable before it runs, which is what makes load-time approval possible (P10).
21
+ // If a reviewer's summary were assembled by the install screen, the install screen
22
+ // would be the security boundary.
23
+ //
24
+ // Versioning follows the log's discipline: additive only, absence means the previous
25
+ // default, and the upcast chain exists from the start so adding v3 does not mean
26
+ // rewriting every reader.
27
+
28
+ import { DATA_SCOPES } from './capability.js';
29
+
30
+ export class SkillManifestError extends Error {
31
+ constructor(code, message) { super(message); this.name = 'SkillManifestError'; this.code = code; }
32
+ }
33
+
34
+ /** Schema version of the RECORD. Distinct from `version`, which is the author's semver. */
35
+ export const SKILL_MANIFEST_VERSION = 2;
36
+
37
+ export const SKILL_CONTEXTS = Object.freeze(['auto', 'page', 'selection', 'tabs', 'none']);
38
+ export const SKILL_HISTORY_SCOPES = Object.freeze(['none', 'chats', 'meetings', 'all']);
39
+ export const SKILL_MCP_MODES = Object.freeze(['none', 'selected', 'default']);
40
+
41
+ /**
42
+ * Derived, never stored. 'community' is deliberately the level for a skill fetched from
43
+ * a vendor repo with a famous name — only what we ship went through our review.
44
+ */
45
+ export const SKILL_TRUST = Object.freeze(['built-in', 'user', 'community']);
46
+
47
+ /**
48
+ * The directories a package may carry, matching the agentskills.io layout. `scripts` is
49
+ * the tier-3 one: it cannot run in a browser extension and must not be offered as if it
50
+ * could (see `needsBridge`).
51
+ */
52
+ export const SKILL_FILE_KINDS = Object.freeze(['references', 'scripts', 'assets', 'templates', 'examples']);
53
+
54
+ const str = (v) => typeof v === 'string' && v.length > 0;
55
+
56
+ // A slash command is typed by a human and matched case-insensitively against a stored
57
+ // string; anything outside this grammar either cannot be typed or collides with the
58
+ // leading-slash parse.
59
+ const COMMAND = /^[a-z0-9][a-z0-9_-]*$/;
60
+
61
+ /**
62
+ * A file path inside a package directory.
63
+ *
64
+ * The check is a security boundary, not tidiness: these strings become filesystem paths
65
+ * in the bridge's skill store, and a package is authored by a stranger. Rejected are
66
+ * absolute paths, any `..` segment, backslashes (a Windows separator that a POSIX
67
+ * `split('/')` would not see as one), leading/trailing whitespace and control characters.
68
+ * Allowing one of them is a directory-traversal write on someone's machine.
69
+ */
70
+ export function isSafeSkillPath(p) {
71
+ if (!str(p) || p.length > 255) return false;
72
+ if (p !== p.trim()) return false;
73
+ if (p.includes('\\')) return false;
74
+ // eslint-disable-next-line no-control-regex
75
+ if (/[\u0000-\u001f\u007f]/.test(p)) return false;
76
+ if (p.startsWith('/') || /^[a-z]:/i.test(p)) return false;
77
+ const parts = p.split('/');
78
+ if (parts.some((seg) => seg === '' || seg === '.' || seg === '..')) return false;
79
+ return true;
80
+ }
81
+
82
+ /** Did this skill come from somewhere, or did the user write it? */
83
+ export function originOf(skill) {
84
+ const o = skill?.origin;
85
+ return o && typeof o === 'object' && str(o.source) && str(o.id) ? o : null;
86
+ }
87
+
88
+ /**
89
+ * Derived from provenance, never read from the record. A skill that arrived from outside
90
+ * is 'community' whoever published it; 'built-in' means we shipped it.
91
+ */
92
+ export function trustOf(skill) {
93
+ if (originOf(skill)) return 'community';
94
+ return skill?.builtin ? 'built-in' : 'user';
95
+ }
96
+
97
+ /** The files a package carries, per directory, already filtered to safe paths. */
98
+ export function skillFiles(skill) {
99
+ const files = skill?.files;
100
+ const out = {};
101
+ if (!files || typeof files !== 'object') return out;
102
+ for (const kind of SKILL_FILE_KINDS) {
103
+ const list = Array.isArray(files[kind]) ? files[kind].filter(isSafeSkillPath) : [];
104
+ if (list.length) out[kind] = [...new Set(list)];
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * True when running this skill fully needs a host that can execute code — i.e. the
111
+ * bridge. The extension may still store, show and run the PROMPT half; what it may not
112
+ * do is pretend the scripts ran.
113
+ */
114
+ export function needsBridge(skill) {
115
+ return (skillFiles(skill).scripts || []).length > 0;
116
+ }
117
+
118
+ /**
119
+ * Everything this skill can reach, computed from the record. This is what an install
120
+ * review, the Plugins lens and an admin export all read — one derivation, so the three
121
+ * cannot disagree about what was approved.
122
+ */
123
+ export function declaredAccess(skill = {}) {
124
+ const files = skillFiles(skill);
125
+ const mcpMode = SKILL_MCP_MODES.includes(skill.mcpMode) ? skill.mcpMode : 'none';
126
+ const context = SKILL_CONTEXTS.includes(skill.context) ? skill.context : 'auto';
127
+ const history = SKILL_HISTORY_SCOPES.includes(skill.historyContext) ? skill.historyContext : 'none';
128
+ const reads = new Set(Array.isArray(skill.reads) ? skill.reads.filter((r) => DATA_SCOPES.includes(r)) : []);
129
+ // The dropdowns ARE access statements; a record that declared `reads` separately from
130
+ // them could claim less than it takes.
131
+ if (context !== 'none') reads.add('page');
132
+ if (history === 'chats' || history === 'all') reads.add('chats');
133
+ if (history === 'meetings' || history === 'all') reads.add('meetings');
134
+ return {
135
+ trust: trustOf(skill),
136
+ reads: [...reads].sort(),
137
+ page: context,
138
+ history,
139
+ mcp: mcpMode,
140
+ mcpServerIds: mcpMode === 'selected' && Array.isArray(skill.mcpServerIds) ? [...skill.mcpServerIds] : [],
141
+ sources: Array.isArray(skill.sources) ? [...new Set(skill.sources.filter(str))].sort() : [],
142
+ surfaces: Array.isArray(skill.surfaces) ? [...new Set(skill.surfaces.filter(str))].sort() : [],
143
+ scripts: files.scripts || [],
144
+ needsBridge: needsBridge(skill),
145
+ meeting: !!skill.meeting,
146
+ };
147
+ }
148
+
149
+ /** Human label for where a skill came from, for the surfaces that must show provenance. */
150
+ export function originLabel(skill) {
151
+ const o = originOf(skill);
152
+ if (!o) return trustOf(skill) === 'built-in' ? 'Built-in' : 'Written here';
153
+ return o.id.length > 48 ? `${o.source} · …${o.id.slice(-40)}` : `${o.source} · ${o.id}`;
154
+ }
155
+
156
+ /** Two records describing the same upstream skill — for update checks and dedupe. */
157
+ export function sameSkillOrigin(a, b) {
158
+ const x = originOf(a);
159
+ const y = originOf(b);
160
+ return !!x && !!y && x.source === y.source && x.id === y.id;
161
+ }
162
+
163
+ /**
164
+ * Has upstream changed since this was installed? `null` when unanswerable (no origin, or
165
+ * nothing was hashed) — which callers must treat as "do not claim it is current" rather
166
+ * than as "up to date".
167
+ */
168
+ export function skillIsStale(skill, upstreamHash) {
169
+ const o = originOf(skill);
170
+ if (!o || !str(o.hash) || !str(upstreamHash)) return null;
171
+ return o.hash !== upstreamHash;
172
+ }
173
+
174
+ /**
175
+ * Validate a skill DECLARATION — the static surface approved before anything runs.
176
+ * Throws on the first problem, like `validateCapability`.
177
+ */
178
+ export function validateSkill(skill) {
179
+ if (!skill || typeof skill !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
180
+ if (!str(skill.id)) throw new SkillManifestError('SHAPE', 'skill.id required');
181
+ if (!str(skill.name)) throw new SkillManifestError('SHAPE', `skill '${skill.id}': name required`);
182
+ if (skill.prompt != null && typeof skill.prompt !== 'string') {
183
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': prompt must be a string`);
184
+ }
185
+ if (skill.command != null && skill.command !== '' && !COMMAND.test(String(skill.command))) {
186
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': command must match ${COMMAND}`);
187
+ }
188
+ for (const [field, allowed] of [
189
+ ['context', SKILL_CONTEXTS], ['historyContext', SKILL_HISTORY_SCOPES], ['mcpMode', SKILL_MCP_MODES],
190
+ ]) {
191
+ if (skill[field] != null && !allowed.includes(skill[field])) {
192
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be one of ${allowed}`);
193
+ }
194
+ }
195
+ if (skill.reads != null) {
196
+ if (!Array.isArray(skill.reads) || !skill.reads.every((r) => DATA_SCOPES.includes(r))) {
197
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': reads must be within ${DATA_SCOPES}`);
198
+ }
199
+ }
200
+ for (const field of ['sources', 'surfaces']) {
201
+ if (skill[field] != null && (!Array.isArray(skill[field]) || !skill[field].every(str))) {
202
+ throw new SkillManifestError('SHAPE', `skill '${skill.id}': ${field} must be an array of ids`);
203
+ }
204
+ }
205
+ if (skill.origin != null) {
206
+ const o = skill.origin;
207
+ if (typeof o !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': origin must be an object`);
208
+ // Without a source and an id it cannot be re-fetched or compared, which is the whole
209
+ // reason the field exists; a half-origin is worse than none because it looks answered.
210
+ if (!str(o.source) || !str(o.id)) {
211
+ throw new SkillManifestError('ORIGIN', `skill '${skill.id}': origin needs both source and id`);
212
+ }
213
+ }
214
+ if (skill.files != null) {
215
+ if (typeof skill.files !== 'object') throw new SkillManifestError('SHAPE', `skill '${skill.id}': files must be an object`);
216
+ for (const kind of Object.keys(skill.files)) {
217
+ if (!SKILL_FILE_KINDS.includes(kind)) {
218
+ throw new SkillManifestError('FILES', `skill '${skill.id}': unknown file kind '${kind}'`);
219
+ }
220
+ const list = skill.files[kind];
221
+ if (!Array.isArray(list) || !list.every(str)) {
222
+ throw new SkillManifestError('FILES', `skill '${skill.id}': files.${kind} must be an array of paths`);
223
+ }
224
+ const bad = list.find((p) => !isSafeSkillPath(p));
225
+ if (bad) throw new SkillManifestError('PATH', `skill '${skill.id}': unsafe path in files.${kind}: ${JSON.stringify(bad)}`);
226
+ }
227
+ }
228
+ // Files without an origin means someone hand-wrote a package record; that is allowed,
229
+ // but scripts without an origin cannot be scanned against anything, and an unscannable
230
+ // script is exactly what the admission gate exists to refuse.
231
+ if (!originOf(skill) && (skill.files?.scripts || []).length) {
232
+ throw new SkillManifestError('ORIGIN', `skill '${skill.id}': scripts require an origin to be scannable`);
233
+ }
234
+ return skill;
235
+ }
236
+
237
+ /**
238
+ * v(n) -> v(n+1). Each MUST be total: it may not throw for any record of its version.
239
+ *
240
+ * v1 -> v2 stamps the version and nothing else, on purpose. Every field F6 adds is
241
+ * absence-means-the-old-default, so there is nothing to fill in — the same discipline as
242
+ * storing which plugins are DISABLED rather than the full state, so a field added later
243
+ * needs no migration.
244
+ */
245
+ export const SKILL_UPCASTERS = Object.freeze({
246
+ 1: (s) => ({ ...s, v: SKILL_MANIFEST_VERSION }),
247
+ });
248
+
249
+ /** Carry a stored skill forward to the current schema. Pure; never mutates the input. */
250
+ export function upcastSkill(stored) {
251
+ if (!stored || typeof stored !== 'object') throw new SkillManifestError('SHAPE', 'skill must be an object');
252
+ // A record written before the version existed is v1 by definition.
253
+ let s = typeof stored.v === 'number' ? stored : { ...stored, v: 1 };
254
+ let guard = 0;
255
+ while (s.v < SKILL_MANIFEST_VERSION) {
256
+ const step = SKILL_UPCASTERS[s.v];
257
+ if (!step) throw new SkillManifestError('UPCAST', `no upcaster from v${s.v}`);
258
+ s = step(s);
259
+ if (++guard > 64) throw new SkillManifestError('UPCAST', 'upcaster chain did not terminate');
260
+ }
261
+ if (s.v > SKILL_MANIFEST_VERSION) {
262
+ throw new SkillManifestError('UPCAST', `skill is v${s.v}; this reader only knows v${SKILL_MANIFEST_VERSION}`);
263
+ }
264
+ return s;
265
+ }
266
+
267
+ export function upcastSkills(stored) {
268
+ return (Array.isArray(stored) ? stored : []).map(upcastSkill);
269
+ }
270
+
271
+ /**
272
+ * Coerce a record into the canonical shape a writer stores. Total and forgiving — this
273
+ * runs on save, where throwing would cost a user their edit; `validateSkill` is the
274
+ * strict gate, used where a record ARRIVES.
275
+ *
276
+ * The security-relevant coercions are the ones that cannot be left to a caller:
277
+ * `trust` is never persisted, `builtin` cannot survive an origin, and an unsafe file
278
+ * path is dropped rather than stored and rejected later.
279
+ */
280
+ export function normalizeSkill(skill) {
281
+ if (!skill || typeof skill !== 'object') return skill;
282
+ const out = { ...upcastSkill(skill) };
283
+
284
+ // Skills predate the enabled flag — absence means enabled.
285
+ out.enabled = out.enabled !== false;
286
+
287
+ const mode = String(out.mcpMode || 'none').toLowerCase();
288
+ out.mcpMode = SKILL_MCP_MODES.includes(mode) ? mode : 'none';
289
+ const ids = Array.isArray(out.mcpServerIds) ? out.mcpServerIds : [];
290
+ out.mcpServerIds = out.mcpMode === 'selected'
291
+ ? [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))]
292
+ : [];
293
+
294
+ // Derived, never stored: a record that could assert its own trust would make every
295
+ // check downstream a formality.
296
+ delete out.trust;
297
+
298
+ const origin = originOf(out);
299
+ if (origin) {
300
+ out.builtin = false; // "we shipped it" is not something an import may claim
301
+ out.origin = {
302
+ source: origin.source,
303
+ id: origin.id,
304
+ ...(str(origin.url) ? { url: origin.url } : {}),
305
+ ...(str(origin.hash) ? { hash: origin.hash } : {}),
306
+ ...(origin.scanned && typeof origin.scanned === 'object' ? { scanned: { ...origin.scanned } } : {}),
307
+ };
308
+ } else if (out.origin != null) {
309
+ delete out.origin; // a half-origin looks answered and is not
310
+ }
311
+
312
+ if (out.reads != null) {
313
+ out.reads = [...new Set((Array.isArray(out.reads) ? out.reads : []).filter((r) => DATA_SCOPES.includes(r)))].sort();
314
+ }
315
+ for (const field of ['sources', 'surfaces']) {
316
+ if (out[field] != null) {
317
+ out[field] = [...new Set((Array.isArray(out[field]) ? out[field] : []).filter(str))];
318
+ }
319
+ }
320
+
321
+ if (out.files != null) {
322
+ const files = skillFiles(out);
323
+ if (Object.keys(files).length) out.files = files;
324
+ else delete out.files;
325
+ }
326
+
327
+ if (out.version != null && !str(out.version)) delete out.version;
328
+
329
+ return out;
330
+ }
package/skill-vars.js ADDED
@@ -0,0 +1,237 @@
1
+ // skill-vars.js — the placeholders a skill prompt may carry, declared ONCE.
2
+ //
3
+ // A skill prompt can interpolate a few runtime values: what the user typed, the
4
+ // selection, the page URL/title, today's date. Until this module existed that set
5
+ // lived as a regex chain inside the extension's side panel, and nothing else knew
6
+ // it — so three things drifted apart:
7
+ //
8
+ // • the EDITOR advertised placeholders in a placeholder attribute,
9
+ // • the PANEL substituted a different set,
10
+ // • prompt-assist was told to "preserve any {{placeholders}} verbatim" without
11
+ // being told which ones exist — so the model invented {{content}}, guarded it,
12
+ // and the user shipped a prompt with a slot nothing would ever fill.
13
+ //
14
+ // That last failure is silent by construction: an unknown placeholder is just text,
15
+ // so the model receives the literal characters `{{content}}` and usually papers over
16
+ // it. The skill looks broken for a reason no surface names. Declaring the set once
17
+ // makes the same list authoritative for the editor's lint, the assist prompt, and
18
+ // substitution — a fourth client (mobile, the gateway, the bridge) inherits it
19
+ // rather than re-deriving it.
20
+ //
21
+ // Platform access is INJECTED, never imported: the page URL comes from `chrome.tabs`
22
+ // in the extension, from the request in the gateway, and from nothing at all in a
23
+ // batch run. This module knows the contract; the host knows how to satisfy it.
24
+
25
+ export class SkillVarError extends Error {
26
+ constructor(code, message) { super(message); this.name = 'SkillVarError'; this.code = code; }
27
+ }
28
+
29
+ /**
30
+ * The complete set. `source` says where the value comes from, which is what the
31
+ * lint and the assist guidance need to explain it:
32
+ * 'args' — the text the user supplied with the invocation
33
+ * 'resolver' — the host fills it (page URL, title, selection, date)
34
+ */
35
+ export const SKILL_VARS = Object.freeze([
36
+ Object.freeze({
37
+ name: 'input',
38
+ source: 'args',
39
+ labelled: true, // {{input:label}} — the label is a hint to the author, not sent
40
+ summary: 'What the user typed after the /command — or whatever is already in the composer when they pick the skill from the menu.',
41
+ }),
42
+ Object.freeze({
43
+ name: 'selection',
44
+ source: 'resolver',
45
+ summary: 'The text selected on the page right now.',
46
+ }),
47
+ Object.freeze({
48
+ name: 'url',
49
+ source: 'resolver',
50
+ summary: "The active tab's URL.",
51
+ }),
52
+ Object.freeze({
53
+ name: 'title',
54
+ source: 'resolver',
55
+ summary: "The active tab's title.",
56
+ }),
57
+ Object.freeze({
58
+ name: 'date',
59
+ source: 'resolver',
60
+ summary: "Today's date.",
61
+ }),
62
+ ]);
63
+
64
+ export const SKILL_VAR_NAMES = Object.freeze(SKILL_VARS.map((v) => v.name));
65
+
66
+ const BY_NAME = new Map(SKILL_VARS.map((v) => [v.name, v]));
67
+
68
+ export function skillVar(name) {
69
+ return BY_NAME.get(String(name || '').trim().toLowerCase()) || null;
70
+ }
71
+
72
+ // Any {{ token }}, with an optional :label. Deliberately permissive on what it
73
+ // CAPTURES — an unknown name has to be recognised as a placeholder before it can be
74
+ // reported as unknown. A pattern that only matched known names would make the
75
+ // {{content}} class of bug invisible all over again.
76
+ const TOKEN = /\{\{\s*([a-z_][a-z0-9_-]*)\s*(?::([^}]*))?\s*\}\}/gi;
77
+
78
+ /** The token pattern for one variable — `g` and `i`, fresh each call (no lastIndex sharing). */
79
+ export function skillVarPattern(name) {
80
+ const v = skillVar(name);
81
+ if (!v) throw new SkillVarError('UNKNOWN_VAR', `unknown skill variable '${name}'`);
82
+ return v.labelled
83
+ ? new RegExp(`\\{\\{\\s*${v.name}(?::[^}]*)?\\s*\\}\\}`, 'gi')
84
+ : new RegExp(`\\{\\{\\s*${v.name}\\s*\\}\\}`, 'gi');
85
+ }
86
+
87
+ /**
88
+ * Every placeholder in a prompt, in source order.
89
+ * -> [{ name, label, raw, index, known }]
90
+ */
91
+ export function parseSkillVars(text) {
92
+ const out = [];
93
+ const src = String(text || '');
94
+ TOKEN.lastIndex = 0;
95
+ let m = TOKEN.exec(src);
96
+ while (m) {
97
+ const name = m[1].toLowerCase();
98
+ out.push({
99
+ name,
100
+ label: (m[2] || '').trim(),
101
+ raw: m[0],
102
+ index: m.index,
103
+ known: BY_NAME.has(name),
104
+ });
105
+ m = TOKEN.exec(src);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Damerau-free Levenshtein, bounded by the short names we compare. Enough to turn
111
+ // {{content}} → {{input}}? No — those are not near neighbours, which is the point:
112
+ // a bad suggestion is worse than none, so `suggestSkillVar` falls back to the input
113
+ // slot only when the unknown name READS like a content slot.
114
+ function distance(a, b) {
115
+ const m = a.length;
116
+ const n = b.length;
117
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
118
+ for (let i = 1; i <= m; i += 1) {
119
+ const row = [i];
120
+ for (let j = 1; j <= n; j += 1) {
121
+ row[j] = Math.min(
122
+ prev[j] + 1,
123
+ row[j - 1] + 1,
124
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
125
+ );
126
+ }
127
+ prev = row;
128
+ }
129
+ return prev[n];
130
+ }
131
+
132
+ // Names a model reaches for when it invents a "put the user's text here" slot. These
133
+ // are NOT aliases — nothing substitutes them, because silently filling a placeholder
134
+ // the user never agreed to is how a prompt starts meaning something else. They only
135
+ // make the suggestion in the lint concrete.
136
+ const INPUT_SHAPED = new Set([
137
+ 'content', 'text', 'body', 'draft', 'document', 'doc', 'args', 'arguments',
138
+ 'query', 'question', 'task', 'request', 'prompt', 'message', 'user_input', 'userinput',
139
+ ]);
140
+
141
+ /** Nearest known variable for a misspelling or an invented name, or '' if none is close. */
142
+ export function suggestSkillVar(name) {
143
+ const q = String(name || '').trim().toLowerCase();
144
+ if (!q || BY_NAME.has(q)) return '';
145
+ if (INPUT_SHAPED.has(q)) return 'input';
146
+ let best = '';
147
+ let bestD = Infinity;
148
+ for (const known of SKILL_VAR_NAMES) {
149
+ const d = distance(q, known);
150
+ if (d < bestD) { bestD = d; best = known; }
151
+ }
152
+ // Two edits, or a third of a longer name — enough to catch a transposition
153
+ // ("titel") without turning an unrelated word into a confident wrong guess.
154
+ return bestD <= Math.max(2, Math.floor(q.length / 3)) ? best : '';
155
+ }
156
+
157
+ /**
158
+ * Static check of a prompt, for the skill editor.
159
+ * -> { known: [names], unknown: [{ name, raw, suggestion }], hasInput: boolean }
160
+ *
161
+ * `hasInput` is what decides whether the caller's text goes INTO the prompt or gets
162
+ * appended after it, so the editor can explain which of the two will happen.
163
+ */
164
+ export function lintSkillPrompt(text) {
165
+ const seen = parseSkillVars(text);
166
+ const known = [];
167
+ const unknown = [];
168
+ const dedupe = new Set();
169
+ for (const tok of seen) {
170
+ if (dedupe.has(tok.name)) continue;
171
+ dedupe.add(tok.name);
172
+ if (tok.known) known.push(tok.name);
173
+ else unknown.push({ name: tok.name, raw: tok.raw, suggestion: suggestSkillVar(tok.name) });
174
+ }
175
+ return { known, unknown, hasInput: dedupe.has('input') };
176
+ }
177
+
178
+ /**
179
+ * Fill a prompt's placeholders.
180
+ *
181
+ * @param text the skill prompt
182
+ * @param args the user's text, for {{input}}
183
+ * @param resolvers { selection, url, title, date } — each () => string | Promise<string>.
184
+ * A resolver is called ONLY when its variable actually appears, so a
185
+ * prompt without {{selection}} never pays for a tab read. A missing
186
+ * resolver, or one that throws, yields '' and is reported in `empty`
187
+ * rather than failing the turn.
188
+ *
189
+ * -> { text, filled: [names], empty: [names], unknown: [{ name, raw, suggestion }] }
190
+ *
191
+ * UNKNOWN PLACEHOLDERS ARE LEFT ALONE. Rewriting a user's prompt because we did not
192
+ * recognise a token would be a silent edit of the thing they wrote; reporting it lets
193
+ * the caller say so out loud, which is the actual fix.
194
+ */
195
+ export async function substituteSkillVars(text, { args = '', resolvers = {} } = {}) {
196
+ const src = String(text || '');
197
+ const lint = lintSkillPrompt(src);
198
+ if (!lint.known.length) return { text: src, filled: [], empty: [], unknown: lint.unknown };
199
+
200
+ const filled = [];
201
+ const empty = [];
202
+ let out = src;
203
+
204
+ for (const name of lint.known) {
205
+ let value = '';
206
+ if (name === 'input') {
207
+ value = args == null ? '' : String(args);
208
+ } else {
209
+ const resolve = resolvers[name];
210
+ if (typeof resolve === 'function') {
211
+ try {
212
+ value = (await resolve()) ?? '';
213
+ } catch {
214
+ value = ''; // a dead tab or a denied permission is an empty slot, not a failure
215
+ }
216
+ }
217
+ value = String(value);
218
+ }
219
+ if (value.trim()) filled.push(name);
220
+ else empty.push(name);
221
+ out = out.replace(skillVarPattern(name), () => value);
222
+ }
223
+
224
+ return { text: out, filled, empty, unknown: lint.unknown };
225
+ }
226
+
227
+ /**
228
+ * The sentence prompt-assist needs so the model stops inventing placeholders.
229
+ * Generated from SKILL_VARS so a variable added here reaches the assist prompt
230
+ * without anyone remembering to update a string.
231
+ */
232
+ export function skillVarGuidance() {
233
+ const list = SKILL_VARS.map((v) => `{{${v.name}}} (${v.summary})`).join(' ');
234
+ return `The prompt may use ONLY these placeholders, and they are filled at run time: ${list} `
235
+ + 'Preserve the ones already present verbatim, and never invent another — an unrecognised '
236
+ + `placeholder is sent to the model as literal text. For the user's own text use {{input}}.`;
237
+ }