@chatpanel/events 0.3.0 → 0.5.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 +1 -0
- package/package.json +7 -5
- package/router.js +52 -5
- package/skill-vars.js +237 -0
package/index.js
CHANGED
|
@@ -43,4 +43,5 @@ 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_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, skillVarGuidance, SkillVarError } from './skill-vars.js';
|
|
46
47
|
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
|
-
"description": "The canonical ChatPanel event-log and capability contracts
|
|
3
|
+
"version": "0.5.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,7 @@
|
|
|
25
25
|
"./router.js": "./router.js",
|
|
26
26
|
"./rules.js": "./rules.js",
|
|
27
27
|
"./search-engines.js": "./search-engines.js",
|
|
28
|
+
"./skill-vars.js": "./skill-vars.js",
|
|
28
29
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
29
30
|
"./sources.js": "./sources.js",
|
|
30
31
|
"./store.js": "./store.js",
|
|
@@ -35,6 +36,8 @@
|
|
|
35
36
|
"./upcast.js": "./upcast.js"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.md",
|
|
38
41
|
"adapters.js",
|
|
39
42
|
"capability.js",
|
|
40
43
|
"citations.js",
|
|
@@ -55,6 +58,7 @@
|
|
|
55
58
|
"router.js",
|
|
56
59
|
"rules.js",
|
|
57
60
|
"search-engines.js",
|
|
61
|
+
"skill-vars.js",
|
|
58
62
|
"sources-retrieval.js",
|
|
59
63
|
"sources.js",
|
|
60
64
|
"store.js",
|
|
@@ -62,9 +66,7 @@
|
|
|
62
66
|
"tool-groups.js",
|
|
63
67
|
"tool-need.js",
|
|
64
68
|
"trajectory.js",
|
|
65
|
-
"upcast.js"
|
|
66
|
-
"LICENSE",
|
|
67
|
-
"README.md"
|
|
69
|
+
"upcast.js"
|
|
68
70
|
],
|
|
69
71
|
"scripts": {
|
|
70
72
|
"test": "node --test tests/*.test.js"
|
package/router.js
CHANGED
|
@@ -144,6 +144,12 @@ const TIE_BAND = 0.10;
|
|
|
144
144
|
// worth a few seconds, which is the same reasoning the provider order already encodes.
|
|
145
145
|
const SECONDS_PER_UNIT_COST = 5;
|
|
146
146
|
|
|
147
|
+
// How much closer-to-home is worth in the balanced score. Lower is better, so a model on the
|
|
148
|
+
// user's own machine gets a discount and a third party pays full price. Deliberately gentle:
|
|
149
|
+
// a 30% edge decides between comparable candidates and loses to any real difference in speed,
|
|
150
|
+
// cost or quality — privacy that ELIMINATES is the reach ceiling, and it lives in route().
|
|
151
|
+
const REACH_WEIGHT = Object.freeze({ device: 0.7, trusted: 0.85, any: 1 });
|
|
152
|
+
|
|
147
153
|
/**
|
|
148
154
|
* The model name stripped of provider prefix and tag, so the SAME model matches across hosts:
|
|
149
155
|
* `deepseek-ai/DeepSeek-V4-Flash` and `deepseek/deepseek-v4-flash` are one model reached two
|
|
@@ -182,8 +188,21 @@ export function signalsFrom(request = {}) {
|
|
|
182
188
|
// short. The keyword heuristics are not, so those need enough surrounding text to be
|
|
183
189
|
// describing code rather than mentioning it: prose can say "import" or end a line with a
|
|
184
190
|
// semicolon without being a programming task.
|
|
191
|
+
// ASKING FOR CODE IS A CODING REQUEST, not only pasting some.
|
|
192
|
+
//
|
|
193
|
+
// This read the MATERIAL and nothing else, so "write a function that debounces this" —
|
|
194
|
+
// no fence, too short for the token heuristic — required no coding capability at all.
|
|
195
|
+
// That made the per-model Coding checkbox unreachable for most real coding requests:
|
|
196
|
+
// turning it off for one agent changed nothing, because nothing ever asked for it, and
|
|
197
|
+
// the agent kept being chosen. A lever that only moves on pasted code is a lever the
|
|
198
|
+
// user cannot see working.
|
|
199
|
+
//
|
|
200
|
+
// Deliberately a VERB NEXT TO A CODE NOUN rather than either alone: 'test' and 'api'
|
|
201
|
+
// appear in ordinary prose constantly, and a signal that fires on prose would put a
|
|
202
|
+
// quality floor on every message.
|
|
185
203
|
code: /```/.test(text)
|
|
186
|
-
|| (chars > 80 && /\bfunction\b|\bclass\b|=>|;\s*$|\bdef\b|\bimport\b|\bconst\b/m.test(text))
|
|
204
|
+
|| (chars > 80 && /\bfunction\b|\bclass\b|=>|;\s*$|\bdef\b|\bimport\b|\bconst\b/m.test(text))
|
|
205
|
+
|| /\b(write|fix|debug|refactor|implement|optimi[sz]e|review|explain|generate|add|update|port|migrate)\b[^.?!]{0,60}\b(code|function|method|class|script|bug|regex|query|unit ?test|module|component|endpoint|snippet|compiler?|stack ?trace|typescript|javascript|python|rust|golang|sql|css|html)\b/i.test(text),
|
|
187
206
|
complexity: (chars > 4000 || /```/.test(text)
|
|
188
207
|
|| (chars >= 200 && /\bstep by step\b|\bplan\b|\brefactor\b|\bmigrate\b|\banalyse|\banalyze/i.test(text)))
|
|
189
208
|
? 'high'
|
|
@@ -203,8 +222,20 @@ export function signalsFrom(request = {}) {
|
|
|
203
222
|
// bare, they demanded a word boundary immediately after the prefix, so "summarize this
|
|
204
223
|
// document" matched nothing and was classified as SMALL TALK. A prefix that can never
|
|
205
224
|
// fire is worse than an absent one — it reads as covered.
|
|
225
|
+
// ASKING A QUESTION ABOUT SOMETHING IS WORK. The list below was a list of things you DO,
|
|
226
|
+
// so a request to UNDERSTAND — "can you explain what this does", "why is this failing",
|
|
227
|
+
// "how does routing pick a model" — matched nothing and came back as small talk. That is
|
|
228
|
+
// not a cosmetic misfile: small talk is what makes preferenceFor ask for LATENCY, and the
|
|
229
|
+
// latency axis reads nothing but milliseconds, so a genuine question was routed for speed
|
|
230
|
+
// and went past a free local model to a third party. The verbs of explanation and of
|
|
231
|
+
// changing code belong here for the same reason 'summarise' does.
|
|
206
232
|
smalltalk: chars < 100 && !/```/.test(text)
|
|
207
|
-
&& !/\b(draw|click|open|fill|read|find|search|edit|write|create|update|delete|run|fix|change|add|remove|select|scroll|extract|summar\w*|analy\w*|check|review|list|show|go to|navigate|my|mine|this page|here|it|that)\b/i.test(text)
|
|
233
|
+
&& !/\b(draw|click|open|fill|read|find|search|edit|write|create|update|delete|run|fix|change|add|remove|select|scroll|extract|summar\w*|analy\w*|check|review|list|show|go to|navigate|my|mine|this page|here|it|that)\b/i.test(text)
|
|
234
|
+
&& !/\b(explain|describe|define|compare|translate|why|debug|refactor|implement|improve|optimi[sz]e|convert|calculate|generate|draft|rename|build|test|install|deploy|design|plan)\b/i.test(text)
|
|
235
|
+
// 'how' only when it opens a question about something — bare "how are you" is the
|
|
236
|
+
// pleasantry this whole test exists to catch, and putting it in the word list would
|
|
237
|
+
// have reclassified the one case everybody agrees on.
|
|
238
|
+
&& !/\bhow\s+(do|does|did|can|could|should|would|to|much|many|long)\b/i.test(text),
|
|
208
239
|
chars,
|
|
209
240
|
};
|
|
210
241
|
}
|
|
@@ -592,10 +623,26 @@ export function createModelRouter({ models = [], middleware = [], strategies = [
|
|
|
592
623
|
// zero — burying every model we have not benchmarked would make the router
|
|
593
624
|
// permanently prefer whatever it happened to measure first.
|
|
594
625
|
const q = Number.isFinite(m.quality) ? Math.max(0.1, m.quality) : 0.5;
|
|
595
|
-
|
|
596
|
-
|
|
626
|
+
// NEARER IS WORTH SOMETHING, and the trade-making axes are where it can be said.
|
|
627
|
+
//
|
|
628
|
+
// The provider order already claims it — "the user's own machine: no quota, no
|
|
629
|
+
// outage, no third party" is its first rung — but providerRank only arranges a
|
|
630
|
+
// near-tie, and a local model and a hosted one are almost never within the tie band.
|
|
631
|
+
// So every preference weighed time and money and ignored where the request goes, and
|
|
632
|
+
// a free model on the user's own machine lost to a third party over a latency figure
|
|
633
|
+
// that is itself a guess.
|
|
634
|
+
//
|
|
635
|
+
// A multiplier, not a term, and a small one: it shifts a close call and cannot rescue
|
|
636
|
+
// a model that is genuinely slower AND worse. Applied to 'latency', 'cost' and
|
|
637
|
+
// 'balanced' — all of which trade one number against another — and NOT to 'quality',
|
|
638
|
+
// because a nearer model is not a better one and saying so would invert that axis the
|
|
639
|
+
// way dividing by quality once inverted these. Reach remains a ceiling in route():
|
|
640
|
+
// this only orders survivors, it never admits a candidate the ceiling excluded.
|
|
641
|
+
const near = REACH_WEIGHT[m.reach] ?? 1;
|
|
642
|
+
if (prefer === 'latency') return m.latencyMs * busy * near;
|
|
643
|
+
if (prefer === 'cost') return m.costPer1k * busy * near;
|
|
597
644
|
if (prefer === 'quality') return busy / q;
|
|
598
|
-
return ((m.latencyMs / 1000) + m.costPer1k * SECONDS_PER_UNIT_COST) * busy / q;
|
|
645
|
+
return ((m.latencyMs / 1000) + m.costPer1k * SECONDS_PER_UNIT_COST) * busy * near / q;
|
|
599
646
|
};
|
|
600
647
|
// ORDER IS A SETTING, NOT A TIE-BREAK THAT NEVER FIRES.
|
|
601
648
|
//
|
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
|
+
}
|