@mengyuly/dsh-ponytail 0.3.4 → 0.4.1
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/CHANGELOG.md +56 -0
- package/README.md +82 -21
- package/README_EN.md +117 -54
- package/dist-provenance.json +26 -11
- package/lib/client.js +305 -0
- package/lib/index.js +1926 -1882
- package/package.json +22 -2
package/lib/index.js
CHANGED
|
@@ -1,984 +1,984 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
-
import z from "@deepseek-ai/schemastery";
|
|
4
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
//#region ../../llm/llm/src/brand.ts
|
|
8
|
-
/**
|
|
9
|
-
* Brand a message identifier.
|
|
10
|
-
* @param id - the opaque message identifier.
|
|
11
|
-
* @returns the same string, branded; no validation is performed.
|
|
12
|
-
*/
|
|
13
|
-
function MessageId(id) {
|
|
14
|
-
return id;
|
|
15
|
-
}
|
|
16
|
-
//#endregion
|
|
17
|
-
//#region ../../llm/llm/src/call-config.ts
|
|
18
|
-
/**
|
|
19
|
-
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
|
|
20
|
-
* so later mutation throws without imposing a JavaScript call-stack depth cap.
|
|
21
|
-
* {@link AbortSignal} objects are deliberately skipped because they are the
|
|
22
|
-
* request's live cancellation channel and freezing them breaks abort.
|
|
23
|
-
* @param value - the value to freeze in place.
|
|
24
|
-
* @returns the same value, frozen.
|
|
25
|
-
*/
|
|
26
|
-
function deepFreeze(value) {
|
|
27
|
-
const seen = /* @__PURE__ */ new WeakSet();
|
|
28
|
-
const pending = [{
|
|
29
|
-
kind: "visit",
|
|
30
|
-
node: value
|
|
31
|
-
}];
|
|
32
|
-
while (pending.length > 0) {
|
|
33
|
-
const task = pending.pop();
|
|
34
|
-
/* v8 ignore next -- the loop condition guarantees one pending task. */
|
|
35
|
-
if (task === void 0) continue;
|
|
36
|
-
if (task.kind === "property") {
|
|
37
|
-
pending.push({
|
|
38
|
-
kind: "visit",
|
|
39
|
-
node: task.source[task.key]
|
|
40
|
-
});
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
const node = task.node;
|
|
44
|
-
if (node === null || typeof node !== "object") continue;
|
|
45
|
-
if (node instanceof AbortSignal) continue;
|
|
46
|
-
if (seen.has(node)) continue;
|
|
47
|
-
seen.add(node);
|
|
48
|
-
Object.freeze(node);
|
|
49
|
-
const keys = Object.keys(node);
|
|
50
|
-
for (let index = keys.length - 1; index >= 0; index--) {
|
|
51
|
-
const key = keys[index];
|
|
52
|
-
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
|
53
|
-
if (key === void 0) continue;
|
|
54
|
-
pending.push({
|
|
55
|
-
kind: "property",
|
|
56
|
-
source: node,
|
|
57
|
-
key
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
63
|
-
//#endregion
|
|
64
|
-
//#region ../../llm/llm/src/message.ts
|
|
65
|
-
/** Message value types, identity, and immutable construction helpers. */
|
|
66
|
-
/**
|
|
67
|
-
* Detach and deep-freeze a message whose identity already exists.
|
|
68
|
-
* @param message - complete message, including its stable identity.
|
|
69
|
-
* @returns an immutable snapshot that preserves the identity.
|
|
70
|
-
*/
|
|
71
|
-
function freezeMessage(message) {
|
|
72
|
-
return deepFreeze(structuredClone(message));
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Create one identified message and freeze it before publication.
|
|
76
|
-
* @param input - complete role, content, and source for a new message.
|
|
77
|
-
* @returns an immutable message with a fresh stable identity.
|
|
78
|
-
*/
|
|
79
|
-
function createMessage(input) {
|
|
80
|
-
return freezeMessage({
|
|
81
|
-
...input,
|
|
82
|
-
id: MessageId(crypto.randomUUID())
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Create one identified user-role message and freeze it before publication.
|
|
87
|
-
* @param input - complete content and source for a new user message.
|
|
88
|
-
* @returns an immutable user message with a fresh stable identity.
|
|
89
|
-
*/
|
|
90
|
-
function createUserMessage(input) {
|
|
91
|
-
return createMessage({
|
|
92
|
-
...input,
|
|
93
|
-
role: "user"
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
//#endregion
|
|
97
|
-
//#region ../../util/timeout/src/index.ts
|
|
98
|
-
/** Largest delay Node schedules without clamping it to one millisecond. */
|
|
99
|
-
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
100
|
-
//#endregion
|
|
101
|
-
//#region ../../llm/llm/src/error.ts
|
|
102
|
-
/**
|
|
103
|
-
* Canonical provider-neutral code for a response that completed normally but
|
|
104
|
-
* carried no content blocks at all. Providers occasionally emit a degenerate
|
|
105
|
-
* completion (a terminal stop with zero output); adapters classify it as this
|
|
106
|
-
* failure instead of yielding an empty assistant message, because an empty
|
|
107
|
-
* message silently ends the turn with nothing for the user or the loop to act
|
|
108
|
-
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
|
109
|
-
* to repeat.
|
|
110
|
-
*/
|
|
111
|
-
const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
112
|
-
new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
113
|
-
new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
114
|
-
new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
115
|
-
//#endregion
|
|
116
|
-
//#region ../../llm/llm/src/retry-policy.ts
|
|
117
|
-
/**
|
|
118
|
-
* Provider-owned request-retry policy configuration and resolution.
|
|
119
|
-
*
|
|
120
|
-
* Adapters expose one resolved policy per registered provider route; the
|
|
121
|
-
* optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
|
|
122
|
-
*
|
|
123
|
-
* @module @deepseek-ai/dsh-llm/retry-policy
|
|
124
|
-
*/
|
|
125
|
-
const DEFAULT_MAX_RETRIES = 5;
|
|
126
|
-
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
127
|
-
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
128
|
-
const DEFAULT_JITTER_RATIO = .1;
|
|
129
|
-
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
130
|
-
EMPTY_RESPONSE_CODE,
|
|
131
|
-
"RATE_LIMIT",
|
|
132
|
-
"SERVER",
|
|
133
|
-
"TIMEOUT",
|
|
134
|
-
"TRANSPORT"
|
|
135
|
-
]);
|
|
136
|
-
const backoffSchema = z.object({
|
|
137
|
-
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
138
|
-
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
139
|
-
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
140
|
-
});
|
|
141
|
-
const normalPolicySchema = z.object({
|
|
142
|
-
mode: z.const("normal").required(),
|
|
143
|
-
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
144
|
-
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
145
|
-
backoff: backoffSchema
|
|
146
|
-
});
|
|
147
|
-
const alwaysPolicySchema = z.object({
|
|
148
|
-
mode: z.const("always").required(),
|
|
149
|
-
backoff: backoffSchema
|
|
150
|
-
});
|
|
151
|
-
z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
152
|
-
//#endregion
|
|
153
|
-
//#region ../../llm/llm/src/attribution.ts
|
|
154
|
-
/**
|
|
155
|
-
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
|
156
|
-
* adapters from drifting. See
|
|
157
|
-
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
|
158
|
-
*
|
|
159
|
-
* App-attribution vocabulary for provider requests.
|
|
160
|
-
* @module @deepseek-ai/dsh-llm/attribution
|
|
161
|
-
*/
|
|
162
|
-
const { version } = createRequire(import.meta.url)("../package.json");
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region ../../llm/llm/src/never.ts
|
|
165
|
-
/**
|
|
166
|
-
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
|
|
167
|
-
* new variant fails compilation at every required handler. Do not use it for declaration-merged
|
|
168
|
-
* unions such as session events or content blocks: handle known variants and explicitly fall
|
|
169
|
-
* through because plugins may add valid unknown cases.
|
|
170
|
-
* @module @deepseek-ai/dsh-llm/never
|
|
171
|
-
*/
|
|
172
|
-
/**
|
|
173
|
-
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
|
|
174
|
-
* a value that escaped its type throws with diagnostics at runtime.
|
|
175
|
-
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
|
176
|
-
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
|
177
|
-
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
|
178
|
-
*/
|
|
179
|
-
function assertNever(value, context) {
|
|
180
|
-
const rendered = JSON.stringify(value) ?? String(value);
|
|
181
|
-
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
182
|
-
}
|
|
183
|
-
//#endregion
|
|
184
|
-
//#region ../../core/scope/src/store.ts
|
|
185
|
-
/**
|
|
186
|
-
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
|
|
187
|
-
*
|
|
188
|
-
* Values are borrowed. Iterators are live within one nonempty table
|
|
189
|
-
* generation; draining the table detaches them from later insertions. Each
|
|
190
|
-
* successful insertion returns an idempotent undo for that exact entry.
|
|
191
|
-
*/
|
|
192
|
-
var NamedEntries = class {
|
|
193
|
-
duplicateError;
|
|
194
|
-
data = /* @__PURE__ */ new Map();
|
|
195
|
-
constructor(duplicateError) {
|
|
196
|
-
this.duplicateError = duplicateError;
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Insert one unique name.
|
|
200
|
-
* @param name - name unique within this table.
|
|
201
|
-
* @param value - borrowed value to retain.
|
|
202
|
-
* @returns an idempotent undo that removes only this insertion.
|
|
203
|
-
*/
|
|
204
|
-
insert(name, value) {
|
|
205
|
-
const data = this.data;
|
|
206
|
-
if (data.has(name)) throw this.duplicateError(name);
|
|
207
|
-
data.set(name, value);
|
|
208
|
-
let active = true;
|
|
209
|
-
return () => {
|
|
210
|
-
if (!active) return;
|
|
211
|
-
active = false;
|
|
212
|
-
data.delete(name);
|
|
213
|
-
if (data.size === 0 && this.data === data) this.data = /* @__PURE__ */ new Map();
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Read one named value.
|
|
218
|
-
* @param name - name to resolve.
|
|
219
|
-
* @returns the retained value, or `undefined` when absent.
|
|
220
|
-
*/
|
|
221
|
-
get(name) {
|
|
222
|
-
return this.data.get(name);
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Test one name for membership.
|
|
226
|
-
* @param name - name to test.
|
|
227
|
-
* @returns whether the table contains that name.
|
|
228
|
-
*/
|
|
229
|
-
has(name) {
|
|
230
|
-
return this.data.has(name);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Iterate live names in insertion order.
|
|
234
|
-
* @returns the native live key iterator.
|
|
235
|
-
*/
|
|
236
|
-
keys() {
|
|
237
|
-
return this.data.keys();
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Iterate live entries in insertion order.
|
|
241
|
-
* @returns the native live entry iterator.
|
|
242
|
-
*/
|
|
243
|
-
entries() {
|
|
244
|
-
return this.data.entries();
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Iterate live values in insertion order.
|
|
248
|
-
* @returns the native live value iterator.
|
|
249
|
-
*/
|
|
250
|
-
values() {
|
|
251
|
-
return this.data.values();
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Test whether this table has no entries.
|
|
255
|
-
* @returns whether the table is empty.
|
|
256
|
-
*/
|
|
257
|
-
isEmpty() {
|
|
258
|
-
return this.data.size === 0;
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
/**
|
|
262
|
-
* Own the global and exact-scope layers for one registry.
|
|
263
|
-
*
|
|
264
|
-
* Reads never create scoped layers. Registrations derive both visibility and
|
|
265
|
-
* effect ownership from the supplied Cordis context, collect undo before
|
|
266
|
-
* notification, and reclaim only a completely empty aggregate layer.
|
|
267
|
-
*/
|
|
268
|
-
var ScopedLayers = class {
|
|
269
|
-
createLayer;
|
|
270
|
-
onChange;
|
|
271
|
-
/** The eagerly constructed context-global layer. */
|
|
272
|
-
global;
|
|
273
|
-
scoped = /* @__PURE__ */ new Map();
|
|
274
|
-
constructor(createLayer, onChange) {
|
|
275
|
-
this.createLayer = createLayer;
|
|
276
|
-
this.onChange = onChange;
|
|
277
|
-
this.global = createLayer(void 0);
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Read an existing exact-scope overlay. Deliberately chain-blind: callers
|
|
281
|
-
* addressing one scope's OWN contributions (its restrictions, its guards)
|
|
282
|
-
* must not silently pick up an ancestor's — use {@link chainLayers} where
|
|
283
|
-
* inheritance is the point.
|
|
284
|
-
* @param scope - exact scope key; `undefined` denotes no overlay.
|
|
285
|
-
* @returns the existing scoped layer, or `undefined` without creating one.
|
|
286
|
-
*/
|
|
287
|
-
peek(scope) {
|
|
288
|
-
if (scope === void 0) return void 0;
|
|
289
|
-
return this.scoped.get(scope);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Existing overlays along the scope's parent chain ({@link scopeChainOf}),
|
|
293
|
-
* farthest ancestor first and the exact scope last, so a caller layering
|
|
294
|
-
* them in order gives the nearest scope the final word.
|
|
295
|
-
* @param scope - viewing scope, or `undefined` for no overlays.
|
|
296
|
-
* @returns the existing layers, nearest last; absent overlays are skipped.
|
|
297
|
-
*/
|
|
298
|
-
chainLayers(scope) {
|
|
299
|
-
const layers = [];
|
|
300
|
-
for (const key of scopeChainOf(scope).reverse()) {
|
|
301
|
-
const layer = this.scoped.get(key);
|
|
302
|
-
if (layer !== void 0) layers.push(layer);
|
|
303
|
-
}
|
|
304
|
-
return layers;
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Materialize global named entries followed by scope-chain shadows,
|
|
308
|
-
* farthest ancestor first, so the nearest scope's entry wins a name.
|
|
309
|
-
* @param scope - viewing scope, or `undefined` for the global view.
|
|
310
|
-
* @param pick - select the named table from a layer.
|
|
311
|
-
* @returns an insertion-ordered effective map.
|
|
312
|
-
*/
|
|
313
|
-
merge(scope, pick) {
|
|
314
|
-
const merged = new Map(pick(this.global).entries());
|
|
315
|
-
for (const layer of this.chainLayers(scope)) for (const [name, value] of pick(layer).entries()) merged.set(name, value);
|
|
316
|
-
return merged;
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Attach one synchronous layer mutation to its registration context.
|
|
320
|
-
* @param ctx - context that determines both scope visibility and effect ownership.
|
|
321
|
-
* @param action - atomic mutation returning its synchronous undo.
|
|
322
|
-
* @param options - Cordis effect label and optional change notification.
|
|
323
|
-
* @returns the exact disposer returned by `ctx.effect()`.
|
|
324
|
-
*/
|
|
325
|
-
effect(ctx, action, options) {
|
|
326
|
-
const scope = scopeOf(ctx);
|
|
327
|
-
const notify = options.notify ?? true;
|
|
328
|
-
return ctx.effect(function* () {
|
|
329
|
-
let layer;
|
|
330
|
-
let created = false;
|
|
331
|
-
if (scope === void 0) layer = this.global;
|
|
332
|
-
else {
|
|
333
|
-
const existing = this.scoped.get(scope);
|
|
334
|
-
if (existing === void 0) {
|
|
335
|
-
layer = this.createLayer(scope);
|
|
336
|
-
this.scoped.set(scope, layer);
|
|
337
|
-
created = true;
|
|
338
|
-
} else layer = existing;
|
|
339
|
-
}
|
|
340
|
-
let undo;
|
|
341
|
-
try {
|
|
342
|
-
undo = action(layer);
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (scope !== void 0 && created && layer.isEmpty()) this.scoped.delete(scope);
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
yield () => {
|
|
348
|
-
undo();
|
|
349
|
-
if (scope !== void 0 && layer.isEmpty()) this.scoped.delete(scope);
|
|
350
|
-
if (notify) this.onChange();
|
|
351
|
-
};
|
|
352
|
-
if (notify) this.onChange();
|
|
353
|
-
}.bind(this), options.label);
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
//#endregion
|
|
357
|
-
//#region ../../core/scope/src/index.ts
|
|
358
|
-
/** Context tag written by {@link createScope}. */
|
|
359
|
-
const kScope = Symbol("dsh.scope");
|
|
360
|
-
/**
|
|
361
|
-
* The enclosing scope of each key. One relation powers both directions of
|
|
362
|
-
* scope nesting: registration views inherit DOWN the chain (a child scope
|
|
363
|
-
* sees its ancestors' layers — {@link ScopedLayers}), and event admission
|
|
364
|
-
* extends UP it (a listener tagged with an ancestor receives events dispatched
|
|
365
|
-
* to a descendant key — {@link scopeTarget}).
|
|
366
|
-
*/
|
|
367
|
-
const scopeParents = /* @__PURE__ */ new WeakMap();
|
|
368
|
-
/**
|
|
369
|
-
* The chain from a key to its root ancestor.
|
|
370
|
-
* @param key - the starting key, or `undefined` for the empty chain.
|
|
371
|
-
* @returns keys nearest-first: `[key, parent, grandparent, …]`.
|
|
372
|
-
*/
|
|
373
|
-
function scopeChainOf(key) {
|
|
374
|
-
const chain = [];
|
|
375
|
-
for (let cursor = key; cursor !== void 0; cursor = scopeParents.get(cursor)) chain.push(cursor);
|
|
376
|
-
return chain;
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Read the nearest scope tag inherited by a context.
|
|
380
|
-
* @param ctx - context to inspect.
|
|
381
|
-
* @returns its scope key, or `undefined` for an unscoped context.
|
|
382
|
-
*/
|
|
383
|
-
function scopeOf(ctx) {
|
|
384
|
-
return ctx[kScope];
|
|
385
|
-
}
|
|
386
|
-
//#endregion
|
|
387
|
-
//#region ../../skill/skill/src/index.ts
|
|
388
|
-
/**
|
|
389
|
-
* Agent skill provider registry.
|
|
390
|
-
*
|
|
391
|
-
* This package owns the Service Definition role of the skill capability seam.
|
|
392
|
-
* Concrete
|
|
393
|
-
* providers such as `@deepseek-ai/dsh-skill-filesystem` decide where skills come
|
|
394
|
-
* from; this service only merges provider catalogs, resolves the winning skill
|
|
395
|
-
* for a name, and exposes the winning summaries and definitions to consumers.
|
|
396
|
-
*
|
|
397
|
-
* @module @deepseek-ai/dsh-skill
|
|
398
|
-
*/
|
|
399
|
-
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
400
|
-
const DEFAULT_COLLECT_CACHE_ENTRIES = 128;
|
|
401
|
-
const MAX_COLLECT_ATTEMPTS = 2;
|
|
402
|
-
const RUNTIME_PROVIDER = "runtime";
|
|
403
|
-
const RUNTIME_RANK = 250;
|
|
404
|
-
/**
|
|
405
|
-
* Return whether a string is a valid kebab-case skill name.
|
|
406
|
-
* @param name - candidate skill name to validate.
|
|
407
|
-
* @returns whether the name matches the public skill-name grammar.
|
|
408
|
-
*/
|
|
409
|
-
function isSkillName(name) {
|
|
410
|
-
return SKILL_NAME.test(name);
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Render one loaded skill for the model. The output is shared verbatim by the
|
|
414
|
-
* `skill` tool result and the user-explicit invocation injection, so the model
|
|
415
|
-
* sees one canonical `<skill_content>` shape on both paths. The name rides an
|
|
416
|
-
* escaped attribute; the body is embedded verbatim (skills are trusted local
|
|
417
|
-
* content, and user-supplied invocation text stays outside this wrapper).
|
|
418
|
-
* @param skill - name, provider, optional resource base, and body to render.
|
|
419
|
-
* @returns the complete model-facing `<skill_content>` block.
|
|
420
|
-
*/
|
|
421
|
-
function renderSkillContent(skill) {
|
|
422
|
-
const resourceHint = renderResourceHint(skill);
|
|
423
|
-
return [
|
|
424
|
-
`<skill_content name="${escapeAttr(skill.name)}">`,
|
|
425
|
-
"<skill_resources>",
|
|
426
|
-
...resourceHint,
|
|
427
|
-
"</skill_resources>",
|
|
428
|
-
"",
|
|
429
|
-
"<skill_instructions>",
|
|
430
|
-
skill.content,
|
|
431
|
-
"</skill_instructions>",
|
|
432
|
-
"</skill_content>"
|
|
433
|
-
].join("\n");
|
|
434
|
-
}
|
|
435
|
-
function renderResourceHint(skill) {
|
|
436
|
-
const base = skill.resourceBase;
|
|
437
|
-
if (base === void 0) return [`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, "Load referenced resources only as needed."];
|
|
438
|
-
switch (base.kind) {
|
|
439
|
-
case "directory": return [`Base directory for this skill: ${escapeText(base.path)}`, "Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed."];
|
|
440
|
-
case "url": return [`Base URL for this skill: ${escapeText(base.url)}`, "Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed."];
|
|
441
|
-
case "opaque": return [`Resources for this skill: ${escapeText(base.description)}`, "Load referenced resources only as needed."];
|
|
442
|
-
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
|
|
443
|
-
default: return assertNever(base, "SkillResourceBase.kind");
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
function escapeAttr(value) {
|
|
447
|
-
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<");
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* Escape model-facing prose embedded inside skill markup so provider-supplied
|
|
451
|
-
* text cannot open or close framing tags.
|
|
452
|
-
* @param value - raw prose to embed.
|
|
453
|
-
* @returns the escaped text.
|
|
454
|
-
*/
|
|
455
|
-
function escapeText(value) {
|
|
456
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
457
|
-
}
|
|
458
|
-
/** One scope's complete skill-registry contribution. */
|
|
459
|
-
var SkillLayer = class {
|
|
460
|
-
/** Providers registered through contexts carrying this scope, insertion-ordered. */
|
|
461
|
-
providers;
|
|
462
|
-
/** Runtime skills registered through contexts carrying this scope. */
|
|
463
|
-
runtime = /* @__PURE__ */ new Map();
|
|
464
|
-
constructor(scope) {
|
|
465
|
-
this.providers = new NamedEntries((name) => /* @__PURE__ */ new Error(scope === void 0 ? `a skill provider named "${name}" is already registered` : `a skill provider named "${name}" is already registered in this scope`));
|
|
466
|
-
}
|
|
467
|
-
/** Whether every contribution table in this aggregate layer is empty. */
|
|
468
|
-
isEmpty() {
|
|
469
|
-
return this.providers.isEmpty() && this.runtime.size === 0;
|
|
470
|
-
}
|
|
471
|
-
};
|
|
472
|
-
(class extends Service {
|
|
473
|
-
static Config = z.object({ collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES) });
|
|
474
|
-
collectCacheMaxEntries;
|
|
475
|
-
layers = new ScopedLayers((scope) => new SkillLayer(scope), () => {
|
|
476
|
-
this.invalidateCache();
|
|
477
|
-
});
|
|
478
|
-
collectCache = /* @__PURE__ */ new Map();
|
|
479
|
-
revision = 0;
|
|
480
|
-
nextProviderOrder = 0;
|
|
481
|
-
/** Stable identities for cache keys; scope keys are opaque identity-compared objects. */
|
|
482
|
-
scopeIds = /* @__PURE__ */ new WeakMap();
|
|
483
|
-
nextScopeId = 1;
|
|
484
|
-
constructor(ctx, config = {}) {
|
|
485
|
-
super(ctx, "skills");
|
|
486
|
-
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES;
|
|
487
|
-
assertPositiveInteger("collectCacheMaxEntries", this.collectCacheMaxEntries);
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Register a borrowed same-process provider synchronously during plugin
|
|
491
|
-
* apply, into the calling context's layer: a scoped context (an agent
|
|
492
|
-
* preset's standing mount) registers for that scope alone, an unscoped
|
|
493
|
-
* context registers globally. Duplicate names within one layer and reserved
|
|
494
|
-
* names throw; remote initialization belongs in `list()`. Fiber disposal
|
|
495
|
-
* unregisters the provider and invalidates catalog caches.
|
|
496
|
-
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
|
|
497
|
-
* @returns the exact Cordis effect disposer that unregisters this provider;
|
|
498
|
-
* composite effects may yield it directly to preserve teardown ordering.
|
|
499
|
-
*/
|
|
500
|
-
registerProvider(create) {
|
|
501
|
-
const lifecycle = new AbortController();
|
|
502
|
-
let registration;
|
|
503
|
-
let provider;
|
|
504
|
-
const control = {
|
|
505
|
-
signal: lifecycle.signal,
|
|
506
|
-
invalidate: () => {
|
|
507
|
-
const active = registration;
|
|
508
|
-
if (active !== void 0 && active.layer.providers.get(active.name)?.provider === provider) this.invalidateCache();
|
|
509
|
-
}
|
|
510
|
-
};
|
|
511
|
-
try {
|
|
512
|
-
provider = create(control);
|
|
513
|
-
const name = provider.name;
|
|
514
|
-
if (name === RUNTIME_PROVIDER) throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`);
|
|
515
|
-
const order = this.nextProviderOrder;
|
|
516
|
-
this.nextProviderOrder += 1;
|
|
517
|
-
return this.layers.effect(this.ctx, (layer) => {
|
|
518
|
-
const undo = layer.providers.insert(name, {
|
|
519
|
-
provider,
|
|
520
|
-
order
|
|
521
|
-
});
|
|
522
|
-
registration = {
|
|
523
|
-
layer,
|
|
524
|
-
name
|
|
525
|
-
};
|
|
526
|
-
return () => {
|
|
527
|
-
registration = void 0;
|
|
528
|
-
undo();
|
|
529
|
-
lifecycle.abort(/* @__PURE__ */ new Error(`skill provider "${name}" disposed`));
|
|
530
|
-
};
|
|
531
|
-
}, { label: "skills.registerProvider()" });
|
|
532
|
-
} catch (error) {
|
|
533
|
-
lifecycle.abort(error);
|
|
534
|
-
throw error;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* Register a borrowed readonly runtime skill into the calling context's
|
|
539
|
-
* layer. Project entries outrank runtime entries, which outrank user
|
|
540
|
-
* entries, within one layer. Same-name runtime entries in one layer are
|
|
541
|
-
* first-wins; a duplicate logs a warning and receives a no-op disposer so
|
|
542
|
-
* it cannot remove the winner.
|
|
543
|
-
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
|
|
544
|
-
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
|
|
545
|
-
*/
|
|
546
|
-
register(skill) {
|
|
547
|
-
validateRuntimeSkill(skill);
|
|
548
|
-
const scope = scopeOf(this.ctx);
|
|
549
|
-
const existingLayer = scope === void 0 ? this.layers.global : this.layers.peek(scope);
|
|
550
|
-
if (existingLayer !== void 0 && existingLayer.runtime.has(skill.name)) {
|
|
551
|
-
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`);
|
|
552
|
-
return () => {};
|
|
553
|
-
}
|
|
554
|
-
const definition = {
|
|
555
|
-
...skill,
|
|
556
|
-
invocation: skill.invocation ?? {
|
|
557
|
-
modelInvocable: true,
|
|
558
|
-
userInvocable: true
|
|
559
|
-
},
|
|
560
|
-
provider: skill.provider ?? RUNTIME_PROVIDER
|
|
561
|
-
};
|
|
562
|
-
return this.layers.effect(this.ctx, (layer) => {
|
|
563
|
-
layer.runtime.set(definition.name, definition);
|
|
564
|
-
return () => {
|
|
565
|
-
layer.runtime.delete(definition.name);
|
|
566
|
-
};
|
|
567
|
-
}, { label: "skills.register()" });
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* List invocation-neutral skill summaries for a workspace. Consumers apply
|
|
571
|
-
* model or user invocation policy at their operational boundary. Lookup
|
|
572
|
-
* options and provider candidates are readonly same-process values borrowed
|
|
573
|
-
* throughout discovery.
|
|
574
|
-
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
|
575
|
-
* @returns all sorted winning summaries.
|
|
576
|
-
*/
|
|
577
|
-
async list(options = {}) {
|
|
578
|
-
return (await this.snapshot(options)).skills;
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
|
|
582
|
-
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
|
583
|
-
* retry on their next request boundary.
|
|
584
|
-
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
|
585
|
-
* @returns sorted summaries plus discovery-completeness state.
|
|
586
|
-
*/
|
|
587
|
-
async snapshot(options = {}) {
|
|
588
|
-
const collected = await this.collect(options);
|
|
589
|
-
return {
|
|
590
|
-
skills: [...collected.entries.values()].map((entry) => toSummary(entry.candidate)).sort(compareSkillSummary),
|
|
591
|
-
complete: collected.cacheable
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Load and validate the winning candidate, passing its opaque discovery locator back to the
|
|
596
|
-
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
|
|
597
|
-
* loading so an uncooperative provider cannot hang the caller.
|
|
598
|
-
* @param name - kebab-case skill name.
|
|
599
|
-
* @param options - view options; `scope` selects the viewing agent's layers,
|
|
600
|
-
* `cwd` selects workspace-sensitive skills, and `signal` cancels work.
|
|
601
|
-
* @returns the full skill, including body content, or `undefined`.
|
|
602
|
-
*/
|
|
603
|
-
async get(name, options = {}) {
|
|
604
|
-
if (!isSkillName(name)) return void 0;
|
|
605
|
-
const collected = await this.collect(options);
|
|
606
|
-
throwIfAborted(options.signal);
|
|
607
|
-
const match = collected.entries.get(name);
|
|
608
|
-
if (match === void 0) return void 0;
|
|
609
|
-
const definition = await waitWithAbort(match.provider.get(match.candidate, options), options.signal);
|
|
610
|
-
if (definition === void 0) return void 0;
|
|
611
|
-
validateDefinition(definition);
|
|
612
|
-
if (definition.name !== match.candidate.name) {
|
|
613
|
-
this.invalidateEntry(match);
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
return definition;
|
|
617
|
-
}
|
|
618
|
-
async collect(options) {
|
|
619
|
-
throwIfAborted(options.signal);
|
|
620
|
-
let attempt = 1;
|
|
621
|
-
while (true) {
|
|
622
|
-
const revision = this.revision;
|
|
623
|
-
const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision);
|
|
624
|
-
const cached = this.collectCache.get(key);
|
|
625
|
-
if (cached !== void 0) return {
|
|
626
|
-
entries: cached,
|
|
627
|
-
cacheable: true
|
|
628
|
-
};
|
|
629
|
-
const result = await this.collectFresh(options);
|
|
630
|
-
throwIfAborted(options.signal);
|
|
631
|
-
if (revision !== this.revision) {
|
|
632
|
-
if (attempt < MAX_COLLECT_ATTEMPTS) {
|
|
633
|
-
attempt += 1;
|
|
634
|
-
continue;
|
|
635
|
-
}
|
|
636
|
-
return {
|
|
637
|
-
entries: result.entries,
|
|
638
|
-
cacheable: false
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
if (result.cacheable) {
|
|
642
|
-
this.collectCache.set(key, result.entries);
|
|
643
|
-
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
|
644
|
-
const oldest = this.collectCache.keys().next();
|
|
645
|
-
this.collectCache.delete(oldest.value);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
return result;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
async collectFresh(options) {
|
|
652
|
-
const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)];
|
|
653
|
-
const merged = /* @__PURE__ */ new Map();
|
|
654
|
-
let cacheable = true;
|
|
655
|
-
for (const layer of layers) {
|
|
656
|
-
const collected = await this.collectLayer(layer, options);
|
|
657
|
-
if (!collected.cacheable) cacheable = false;
|
|
658
|
-
for (const entry of collected.entries) merged.set(entry.candidate.name, entry);
|
|
659
|
-
}
|
|
660
|
-
return {
|
|
661
|
-
entries: merged,
|
|
662
|
-
cacheable
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
async collectLayer(layer, options) {
|
|
666
|
-
const collected = await this.listLayerCandidates(layer, options);
|
|
667
|
-
collected.entries.sort(compareIndexedCandidates);
|
|
668
|
-
const seen = /* @__PURE__ */ new Set();
|
|
669
|
-
const result = [];
|
|
670
|
-
for (const entry of collected.entries) {
|
|
671
|
-
const skill = entry.candidate;
|
|
672
|
-
if (seen.has(skill.name)) {
|
|
673
|
-
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`);
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
seen.add(skill.name);
|
|
677
|
-
result.push(entry);
|
|
678
|
-
}
|
|
679
|
-
return {
|
|
680
|
-
entries: result,
|
|
681
|
-
cacheable: collected.cacheable
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
async listLayerCandidates(layer, options) {
|
|
685
|
-
throwIfAborted(options.signal);
|
|
686
|
-
const candidates = [];
|
|
687
|
-
let cacheable = true;
|
|
688
|
-
let runtimeOrder = 0;
|
|
689
|
-
for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
|
690
|
-
candidates.push({
|
|
691
|
-
candidate: runtimeCandidate(skill),
|
|
692
|
-
provider: RUNTIME_SKILL_PROVIDER,
|
|
693
|
-
providerOrder: -1,
|
|
694
|
-
localOrder: runtimeOrder,
|
|
695
|
-
layer
|
|
696
|
-
});
|
|
697
|
-
runtimeOrder += 1;
|
|
698
|
-
}
|
|
699
|
-
for (const { provider, order } of [...layer.providers.values()]) {
|
|
700
|
-
let localOrder = 0;
|
|
701
|
-
let output;
|
|
702
|
-
try {
|
|
703
|
-
output = await waitWithAbort(provider.list(options), options.signal);
|
|
704
|
-
} catch (error) {
|
|
705
|
-
if (options.signal?.aborted === true) throw toError(options.signal.reason);
|
|
706
|
-
cacheable = false;
|
|
707
|
-
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`);
|
|
708
|
-
}
|
|
709
|
-
if (output === void 0) continue;
|
|
710
|
-
const observation = normalizeProviderObservation(output, provider.name);
|
|
711
|
-
if (!observation.complete) cacheable = false;
|
|
712
|
-
for (const candidate of observation.candidates) {
|
|
713
|
-
validateCandidate(candidate, provider.name);
|
|
714
|
-
candidates.push({
|
|
715
|
-
candidate,
|
|
716
|
-
provider,
|
|
717
|
-
providerOrder: order,
|
|
718
|
-
localOrder,
|
|
719
|
-
layer
|
|
720
|
-
});
|
|
721
|
-
localOrder += 1;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
return {
|
|
725
|
-
entries: candidates,
|
|
726
|
-
cacheable
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
invalidateCache() {
|
|
730
|
-
this.revision += 1;
|
|
731
|
-
this.collectCache.clear();
|
|
732
|
-
this.notifyChange();
|
|
733
|
-
}
|
|
734
|
-
/** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */
|
|
735
|
-
invalidateEntry(entry) {
|
|
736
|
-
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
|
|
737
|
-
if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache();
|
|
738
|
-
}
|
|
739
|
-
scopeId(key) {
|
|
740
|
-
let id = this.scopeIds.get(key);
|
|
741
|
-
if (id === void 0) {
|
|
742
|
-
id = this.nextScopeId;
|
|
743
|
-
this.nextScopeId += 1;
|
|
744
|
-
this.scopeIds.set(key, id);
|
|
745
|
-
}
|
|
746
|
-
return id;
|
|
747
|
-
}
|
|
748
|
-
collectCacheKey(cwd, chain, revision) {
|
|
749
|
-
return JSON.stringify({
|
|
750
|
-
cwd,
|
|
751
|
-
scopes: chain.map((key) => this.scopeId(key)),
|
|
752
|
-
revision
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
/** Notify catalog observers without making their refresh work load-bearing. */
|
|
756
|
-
notifyChange() {
|
|
757
|
-
for (const callback of this.ctx.events.dispatch("emit", ["skills/change"])) try {
|
|
758
|
-
const returned = callback();
|
|
759
|
-
Promise.resolve(returned).catch((error) => {
|
|
760
|
-
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`);
|
|
761
|
-
});
|
|
762
|
-
} catch (error) {
|
|
763
|
-
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
});
|
|
767
|
-
function normalizeProviderObservation(output, providerName) {
|
|
768
|
-
if (Array.isArray(output)) return {
|
|
769
|
-
candidates: output,
|
|
770
|
-
complete: true
|
|
771
|
-
};
|
|
772
|
-
if (output === null || typeof output !== "object") throw invalidProviderObservation(providerName);
|
|
773
|
-
const observation = output;
|
|
774
|
-
if (!Array.isArray(observation.candidates) || typeof observation.complete !== "boolean") throw invalidProviderObservation(providerName);
|
|
775
|
-
return observation;
|
|
776
|
-
}
|
|
777
|
-
function invalidProviderObservation(providerName) {
|
|
778
|
-
return /* @__PURE__ */ new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`);
|
|
779
|
-
}
|
|
780
|
-
const RUNTIME_SKILL_PROVIDER = {
|
|
781
|
-
name: RUNTIME_PROVIDER,
|
|
782
|
-
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
|
|
783
|
-
list() {
|
|
784
|
-
return Promise.resolve([]);
|
|
785
|
-
},
|
|
786
|
-
get(candidate) {
|
|
787
|
-
return Promise.resolve(candidate.locator);
|
|
788
|
-
}
|
|
789
|
-
};
|
|
790
|
-
function runtimeCandidate(skill) {
|
|
791
|
-
return {
|
|
792
|
-
name: skill.name,
|
|
793
|
-
description: skill.description,
|
|
794
|
-
...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
|
|
795
|
-
invocation: skill.invocation,
|
|
796
|
-
source: skill.source,
|
|
797
|
-
provider: skill.provider,
|
|
798
|
-
...skill.resourceBase !== void 0 ? { resourceBase: skill.resourceBase } : {},
|
|
799
|
-
rank: RUNTIME_RANK,
|
|
800
|
-
locator: skill,
|
|
801
|
-
...skill.path !== void 0 ? { path: skill.path } : {},
|
|
802
|
-
...skill.metadata !== void 0 ? { metadata: skill.metadata } : {}
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
function validateCandidate(candidate, providerName) {
|
|
806
|
-
if (typeof candidate.name !== "string") throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`);
|
|
807
|
-
if (!SKILL_NAME.test(candidate.name)) throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`);
|
|
808
|
-
if (typeof candidate.description !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`);
|
|
809
|
-
if (candidate.description.length === 0) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`);
|
|
810
|
-
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`);
|
|
811
|
-
if (candidate.whenToUse !== void 0 && typeof candidate.whenToUse !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`);
|
|
812
|
-
if (typeof candidate.source !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`);
|
|
813
|
-
if (typeof candidate.rank !== "number" || !Number.isFinite(candidate.rank)) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`);
|
|
814
|
-
if (typeof candidate.provider !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`);
|
|
815
|
-
if (candidate.provider !== providerName) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`);
|
|
816
|
-
if (candidate.path !== void 0 && typeof candidate.path !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`);
|
|
817
|
-
}
|
|
818
|
-
function validateRuntimeSkill(skill) {
|
|
819
|
-
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`);
|
|
820
|
-
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`);
|
|
821
|
-
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`);
|
|
822
|
-
}
|
|
823
|
-
/** Validate a definition loaded from a provider-controlled parser or remote source. */
|
|
824
|
-
function validateDefinition(skill) {
|
|
825
|
-
const name = skill.name;
|
|
826
|
-
const description = skill.description;
|
|
827
|
-
const whenToUse = skill.whenToUse;
|
|
828
|
-
const invocation = skill.invocation;
|
|
829
|
-
const source = skill.source;
|
|
830
|
-
const provider = skill.provider;
|
|
831
|
-
const content = skill.content;
|
|
832
|
-
const path = skill.path;
|
|
833
|
-
if (typeof name !== "string") throw new TypeError("loaded skill name must be a string");
|
|
834
|
-
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`);
|
|
835
|
-
if (typeof description !== "string") throw new TypeError(`loaded skill "${name}" description must be a string`);
|
|
836
|
-
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`);
|
|
837
|
-
validateInvocation(invocation, `loaded skill "${name}"`);
|
|
838
|
-
if (whenToUse !== void 0 && typeof whenToUse !== "string") throw new TypeError(`loaded skill "${name}" whenToUse must be a string`);
|
|
839
|
-
if (typeof source !== "string") throw new TypeError(`loaded skill "${name}" source must be a string`);
|
|
840
|
-
if (typeof provider !== "string") throw new TypeError(`loaded skill "${name}" provider must be a string`);
|
|
841
|
-
if (typeof content !== "string") throw new TypeError(`loaded skill "${name}" content must be a string`);
|
|
842
|
-
if (path !== void 0 && typeof path !== "string") throw new TypeError(`loaded skill "${name}" path must be a string`);
|
|
843
|
-
}
|
|
844
|
-
function toSummary(skill) {
|
|
845
|
-
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill;
|
|
846
|
-
return {
|
|
847
|
-
name,
|
|
848
|
-
description,
|
|
849
|
-
...whenToUse !== void 0 ? { whenToUse } : {},
|
|
850
|
-
invocation,
|
|
851
|
-
source,
|
|
852
|
-
provider,
|
|
853
|
-
...resourceBase !== void 0 ? { resourceBase } : {}
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
function validateInvocation(invocation, subject) {
|
|
857
|
-
if (invocation === void 0) return;
|
|
858
|
-
if (typeof invocation !== "object" || invocation === null || Array.isArray(invocation)) throw new TypeError(`${subject} with a non-object invocation policy`);
|
|
859
|
-
const policy = invocation;
|
|
860
|
-
if (typeof policy.modelInvocable !== "boolean") throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`);
|
|
861
|
-
if (typeof policy.userInvocable !== "boolean") throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`);
|
|
862
|
-
}
|
|
863
|
-
function compareSkillSummary(left, right) {
|
|
864
|
-
return compareCodePoints(left.name, right.name);
|
|
865
|
-
}
|
|
866
|
-
function compareCodePoints(left, right) {
|
|
867
|
-
if (left < right) return -1;
|
|
868
|
-
if (left > right) return 1;
|
|
869
|
-
return 0;
|
|
870
|
-
}
|
|
871
|
-
function compareIndexedCandidates(left, right) {
|
|
872
|
-
return left.candidate.rank - right.candidate.rank || left.providerOrder - right.providerOrder || left.localOrder - right.localOrder;
|
|
873
|
-
}
|
|
874
|
-
function assertPositiveInteger(name, value, minimum = 1) {
|
|
875
|
-
if (!Number.isInteger(value) || value < minimum) throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`);
|
|
876
|
-
}
|
|
877
|
-
function waitWithAbort(promise, signal) {
|
|
878
|
-
if (signal === void 0) return promise;
|
|
879
|
-
throwIfAborted(signal);
|
|
880
|
-
return new Promise((resolve, reject) => {
|
|
881
|
-
const cleanup = () => {
|
|
882
|
-
signal.removeEventListener("abort", onAbort);
|
|
883
|
-
};
|
|
884
|
-
const onAbort = () => {
|
|
885
|
-
cleanup();
|
|
886
|
-
reject(toError(signal.reason));
|
|
887
|
-
};
|
|
888
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
889
|
-
promise.then((value) => {
|
|
890
|
-
cleanup();
|
|
891
|
-
resolve(value);
|
|
892
|
-
}, (error) => {
|
|
893
|
-
cleanup();
|
|
894
|
-
reject(toError(error));
|
|
895
|
-
});
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
/** Throw a total Error for an already-aborted lookup. */
|
|
899
|
-
function throwIfAborted(signal) {
|
|
900
|
-
if (signal?.aborted === true) throw toError(signal.reason);
|
|
901
|
-
}
|
|
902
|
-
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
|
|
903
|
-
function toError(error) {
|
|
904
|
-
try {
|
|
905
|
-
if (error instanceof Error) return error;
|
|
906
|
-
} catch {}
|
|
907
|
-
return new Error(errorMessage(error));
|
|
908
|
-
}
|
|
909
|
-
/** Render an arbitrary provider failure without letting coercion escape containment. */
|
|
910
|
-
function errorMessage(error) {
|
|
911
|
-
try {
|
|
912
|
-
return String(error);
|
|
913
|
-
} catch {
|
|
914
|
-
return "[unrenderable thrown value]";
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
//#endregion
|
|
918
|
-
//#region lib/types/content.js
|
|
919
|
-
/**
|
|
920
|
-
* Ponytail skill bodies, ported from github.com/DietrichGebert/ponytail and
|
|
921
|
-
* lightly adapted to the DeepSeek Harness surface (slash commands and the
|
|
922
|
-
* `skill` tool). The `ponytail` skill is a mode-aware pointer card: the actual
|
|
923
|
-
* ruleset is injected per session as the mode-filtered `PONYTAIL MODE ACTIVE`
|
|
924
|
-
* section (see `instructions.ts`) and must not be duplicated here. The other
|
|
925
|
-
* five skills ship verbatim as runtime skills.
|
|
926
|
-
*
|
|
927
|
-
* @module @mengyuly/dsh-ponytail
|
|
928
|
-
*/
|
|
929
|
-
/** The always-on lazy-senior-dev ruleset: also registered as a loadable skill. */
|
|
930
|
-
const PONYTAIL_SKILL_BODY = `
|
|
931
|
-
You are the ponytail persona — the lazy senior developer. Your active ruleset
|
|
932
|
-
is ALREADY injected every turn as the "PONYTAIL MODE ACTIVE — level: <mode>"
|
|
933
|
-
system-prompt section, filtered to this session's intensity. Follow exactly
|
|
934
|
-
that section; do NOT reload, replace, or re-derive the ruleset from anywhere
|
|
935
|
-
else — the section is the single source of truth and it is mode-aware.
|
|
936
|
-
|
|
937
|
-
- Switch level: \`/ponytail lite|full|ultra|off\` (session-scoped)
|
|
938
|
-
- Query: \`/ponytail status\`
|
|
939
|
-
- Deactivate: "stop ponytail" / "normal mode"
|
|
940
|
-
- One-shot skills: \`/ponytail-review\`, \`/ponytail-audit\`, \`/ponytail-debt\`,
|
|
941
|
-
\`/ponytail-gain\`, \`/ponytail-help\`
|
|
942
|
-
- Reference: https://github.com/DietrichGebert/ponytail
|
|
943
|
-
`;
|
|
944
|
-
const PONYTAIL_DESCRIPTION = "Ponytail activation, modes, configuration, and help reference. The active ruleset is injected every turn by the system prompt; this skill is a pointer card. Use only when the user asks about Ponytail activation, modes, configuration, or help. Coding tasks already receive the active ruleset from the system prompt.";
|
|
945
|
-
const REVIEW_SKILL_BODY = `
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
//#region ../../llm/llm/src/brand.ts
|
|
8
|
+
/**
|
|
9
|
+
* Brand a message identifier.
|
|
10
|
+
* @param id - the opaque message identifier.
|
|
11
|
+
* @returns the same string, branded; no validation is performed.
|
|
12
|
+
*/
|
|
13
|
+
function MessageId(id) {
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region ../../llm/llm/src/call-config.ts
|
|
18
|
+
/**
|
|
19
|
+
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
|
|
20
|
+
* so later mutation throws without imposing a JavaScript call-stack depth cap.
|
|
21
|
+
* {@link AbortSignal} objects are deliberately skipped because they are the
|
|
22
|
+
* request's live cancellation channel and freezing them breaks abort.
|
|
23
|
+
* @param value - the value to freeze in place.
|
|
24
|
+
* @returns the same value, frozen.
|
|
25
|
+
*/
|
|
26
|
+
function deepFreeze(value) {
|
|
27
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
28
|
+
const pending = [{
|
|
29
|
+
kind: "visit",
|
|
30
|
+
node: value
|
|
31
|
+
}];
|
|
32
|
+
while (pending.length > 0) {
|
|
33
|
+
const task = pending.pop();
|
|
34
|
+
/* v8 ignore next -- the loop condition guarantees one pending task. */
|
|
35
|
+
if (task === void 0) continue;
|
|
36
|
+
if (task.kind === "property") {
|
|
37
|
+
pending.push({
|
|
38
|
+
kind: "visit",
|
|
39
|
+
node: task.source[task.key]
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const node = task.node;
|
|
44
|
+
if (node === null || typeof node !== "object") continue;
|
|
45
|
+
if (node instanceof AbortSignal) continue;
|
|
46
|
+
if (seen.has(node)) continue;
|
|
47
|
+
seen.add(node);
|
|
48
|
+
Object.freeze(node);
|
|
49
|
+
const keys = Object.keys(node);
|
|
50
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
51
|
+
const key = keys[index];
|
|
52
|
+
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
|
53
|
+
if (key === void 0) continue;
|
|
54
|
+
pending.push({
|
|
55
|
+
kind: "property",
|
|
56
|
+
source: node,
|
|
57
|
+
key
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region ../../llm/llm/src/message.ts
|
|
65
|
+
/** Message value types, identity, and immutable construction helpers. */
|
|
66
|
+
/**
|
|
67
|
+
* Detach and deep-freeze a message whose identity already exists.
|
|
68
|
+
* @param message - complete message, including its stable identity.
|
|
69
|
+
* @returns an immutable snapshot that preserves the identity.
|
|
70
|
+
*/
|
|
71
|
+
function freezeMessage(message) {
|
|
72
|
+
return deepFreeze(structuredClone(message));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Create one identified message and freeze it before publication.
|
|
76
|
+
* @param input - complete role, content, and source for a new message.
|
|
77
|
+
* @returns an immutable message with a fresh stable identity.
|
|
78
|
+
*/
|
|
79
|
+
function createMessage(input) {
|
|
80
|
+
return freezeMessage({
|
|
81
|
+
...input,
|
|
82
|
+
id: MessageId(crypto.randomUUID())
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Create one identified user-role message and freeze it before publication.
|
|
87
|
+
* @param input - complete content and source for a new user message.
|
|
88
|
+
* @returns an immutable user message with a fresh stable identity.
|
|
89
|
+
*/
|
|
90
|
+
function createUserMessage(input) {
|
|
91
|
+
return createMessage({
|
|
92
|
+
...input,
|
|
93
|
+
role: "user"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region ../../util/timeout/src/index.ts
|
|
98
|
+
/** Largest delay Node schedules without clamping it to one millisecond. */
|
|
99
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region ../../llm/llm/src/error.ts
|
|
102
|
+
/**
|
|
103
|
+
* Canonical provider-neutral code for a response that completed normally but
|
|
104
|
+
* carried no content blocks at all. Providers occasionally emit a degenerate
|
|
105
|
+
* completion (a terminal stop with zero output); adapters classify it as this
|
|
106
|
+
* failure instead of yielding an empty assistant message, because an empty
|
|
107
|
+
* message silently ends the turn with nothing for the user or the loop to act
|
|
108
|
+
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
|
109
|
+
* to repeat.
|
|
110
|
+
*/
|
|
111
|
+
const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
112
|
+
new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
113
|
+
new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
114
|
+
new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../../llm/llm/src/retry-policy.ts
|
|
117
|
+
/**
|
|
118
|
+
* Provider-owned request-retry policy configuration and resolution.
|
|
119
|
+
*
|
|
120
|
+
* Adapters expose one resolved policy per registered provider route; the
|
|
121
|
+
* optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
|
|
122
|
+
*
|
|
123
|
+
* @module @deepseek-ai/dsh-llm/retry-policy
|
|
124
|
+
*/
|
|
125
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
126
|
+
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
127
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
128
|
+
const DEFAULT_JITTER_RATIO = .1;
|
|
129
|
+
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
130
|
+
EMPTY_RESPONSE_CODE,
|
|
131
|
+
"RATE_LIMIT",
|
|
132
|
+
"SERVER",
|
|
133
|
+
"TIMEOUT",
|
|
134
|
+
"TRANSPORT"
|
|
135
|
+
]);
|
|
136
|
+
const backoffSchema = z.object({
|
|
137
|
+
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
138
|
+
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
139
|
+
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
140
|
+
});
|
|
141
|
+
const normalPolicySchema = z.object({
|
|
142
|
+
mode: z.const("normal").required(),
|
|
143
|
+
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
144
|
+
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
145
|
+
backoff: backoffSchema
|
|
146
|
+
});
|
|
147
|
+
const alwaysPolicySchema = z.object({
|
|
148
|
+
mode: z.const("always").required(),
|
|
149
|
+
backoff: backoffSchema
|
|
150
|
+
});
|
|
151
|
+
z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region ../../llm/llm/src/attribution.ts
|
|
154
|
+
/**
|
|
155
|
+
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
|
156
|
+
* adapters from drifting. See
|
|
157
|
+
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
|
158
|
+
*
|
|
159
|
+
* App-attribution vocabulary for provider requests.
|
|
160
|
+
* @module @deepseek-ai/dsh-llm/attribution
|
|
161
|
+
*/
|
|
162
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region ../../llm/llm/src/never.ts
|
|
165
|
+
/**
|
|
166
|
+
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
|
|
167
|
+
* new variant fails compilation at every required handler. Do not use it for declaration-merged
|
|
168
|
+
* unions such as session events or content blocks: handle known variants and explicitly fall
|
|
169
|
+
* through because plugins may add valid unknown cases.
|
|
170
|
+
* @module @deepseek-ai/dsh-llm/never
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
|
|
174
|
+
* a value that escaped its type throws with diagnostics at runtime.
|
|
175
|
+
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
|
176
|
+
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
|
177
|
+
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
|
178
|
+
*/
|
|
179
|
+
function assertNever(value, context) {
|
|
180
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
181
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region ../../core/scope/src/store.ts
|
|
185
|
+
/**
|
|
186
|
+
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
|
|
187
|
+
*
|
|
188
|
+
* Values are borrowed. Iterators are live within one nonempty table
|
|
189
|
+
* generation; draining the table detaches them from later insertions. Each
|
|
190
|
+
* successful insertion returns an idempotent undo for that exact entry.
|
|
191
|
+
*/
|
|
192
|
+
var NamedEntries = class {
|
|
193
|
+
duplicateError;
|
|
194
|
+
data = /* @__PURE__ */ new Map();
|
|
195
|
+
constructor(duplicateError) {
|
|
196
|
+
this.duplicateError = duplicateError;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Insert one unique name.
|
|
200
|
+
* @param name - name unique within this table.
|
|
201
|
+
* @param value - borrowed value to retain.
|
|
202
|
+
* @returns an idempotent undo that removes only this insertion.
|
|
203
|
+
*/
|
|
204
|
+
insert(name, value) {
|
|
205
|
+
const data = this.data;
|
|
206
|
+
if (data.has(name)) throw this.duplicateError(name);
|
|
207
|
+
data.set(name, value);
|
|
208
|
+
let active = true;
|
|
209
|
+
return () => {
|
|
210
|
+
if (!active) return;
|
|
211
|
+
active = false;
|
|
212
|
+
data.delete(name);
|
|
213
|
+
if (data.size === 0 && this.data === data) this.data = /* @__PURE__ */ new Map();
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Read one named value.
|
|
218
|
+
* @param name - name to resolve.
|
|
219
|
+
* @returns the retained value, or `undefined` when absent.
|
|
220
|
+
*/
|
|
221
|
+
get(name) {
|
|
222
|
+
return this.data.get(name);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Test one name for membership.
|
|
226
|
+
* @param name - name to test.
|
|
227
|
+
* @returns whether the table contains that name.
|
|
228
|
+
*/
|
|
229
|
+
has(name) {
|
|
230
|
+
return this.data.has(name);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Iterate live names in insertion order.
|
|
234
|
+
* @returns the native live key iterator.
|
|
235
|
+
*/
|
|
236
|
+
keys() {
|
|
237
|
+
return this.data.keys();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Iterate live entries in insertion order.
|
|
241
|
+
* @returns the native live entry iterator.
|
|
242
|
+
*/
|
|
243
|
+
entries() {
|
|
244
|
+
return this.data.entries();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Iterate live values in insertion order.
|
|
248
|
+
* @returns the native live value iterator.
|
|
249
|
+
*/
|
|
250
|
+
values() {
|
|
251
|
+
return this.data.values();
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Test whether this table has no entries.
|
|
255
|
+
* @returns whether the table is empty.
|
|
256
|
+
*/
|
|
257
|
+
isEmpty() {
|
|
258
|
+
return this.data.size === 0;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Own the global and exact-scope layers for one registry.
|
|
263
|
+
*
|
|
264
|
+
* Reads never create scoped layers. Registrations derive both visibility and
|
|
265
|
+
* effect ownership from the supplied Cordis context, collect undo before
|
|
266
|
+
* notification, and reclaim only a completely empty aggregate layer.
|
|
267
|
+
*/
|
|
268
|
+
var ScopedLayers = class {
|
|
269
|
+
createLayer;
|
|
270
|
+
onChange;
|
|
271
|
+
/** The eagerly constructed context-global layer. */
|
|
272
|
+
global;
|
|
273
|
+
scoped = /* @__PURE__ */ new Map();
|
|
274
|
+
constructor(createLayer, onChange) {
|
|
275
|
+
this.createLayer = createLayer;
|
|
276
|
+
this.onChange = onChange;
|
|
277
|
+
this.global = createLayer(void 0);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Read an existing exact-scope overlay. Deliberately chain-blind: callers
|
|
281
|
+
* addressing one scope's OWN contributions (its restrictions, its guards)
|
|
282
|
+
* must not silently pick up an ancestor's — use {@link chainLayers} where
|
|
283
|
+
* inheritance is the point.
|
|
284
|
+
* @param scope - exact scope key; `undefined` denotes no overlay.
|
|
285
|
+
* @returns the existing scoped layer, or `undefined` without creating one.
|
|
286
|
+
*/
|
|
287
|
+
peek(scope) {
|
|
288
|
+
if (scope === void 0) return void 0;
|
|
289
|
+
return this.scoped.get(scope);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Existing overlays along the scope's parent chain ({@link scopeChainOf}),
|
|
293
|
+
* farthest ancestor first and the exact scope last, so a caller layering
|
|
294
|
+
* them in order gives the nearest scope the final word.
|
|
295
|
+
* @param scope - viewing scope, or `undefined` for no overlays.
|
|
296
|
+
* @returns the existing layers, nearest last; absent overlays are skipped.
|
|
297
|
+
*/
|
|
298
|
+
chainLayers(scope) {
|
|
299
|
+
const layers = [];
|
|
300
|
+
for (const key of scopeChainOf(scope).reverse()) {
|
|
301
|
+
const layer = this.scoped.get(key);
|
|
302
|
+
if (layer !== void 0) layers.push(layer);
|
|
303
|
+
}
|
|
304
|
+
return layers;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Materialize global named entries followed by scope-chain shadows,
|
|
308
|
+
* farthest ancestor first, so the nearest scope's entry wins a name.
|
|
309
|
+
* @param scope - viewing scope, or `undefined` for the global view.
|
|
310
|
+
* @param pick - select the named table from a layer.
|
|
311
|
+
* @returns an insertion-ordered effective map.
|
|
312
|
+
*/
|
|
313
|
+
merge(scope, pick) {
|
|
314
|
+
const merged = new Map(pick(this.global).entries());
|
|
315
|
+
for (const layer of this.chainLayers(scope)) for (const [name, value] of pick(layer).entries()) merged.set(name, value);
|
|
316
|
+
return merged;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Attach one synchronous layer mutation to its registration context.
|
|
320
|
+
* @param ctx - context that determines both scope visibility and effect ownership.
|
|
321
|
+
* @param action - atomic mutation returning its synchronous undo.
|
|
322
|
+
* @param options - Cordis effect label and optional change notification.
|
|
323
|
+
* @returns the exact disposer returned by `ctx.effect()`.
|
|
324
|
+
*/
|
|
325
|
+
effect(ctx, action, options) {
|
|
326
|
+
const scope = scopeOf(ctx);
|
|
327
|
+
const notify = options.notify ?? true;
|
|
328
|
+
return ctx.effect(function* () {
|
|
329
|
+
let layer;
|
|
330
|
+
let created = false;
|
|
331
|
+
if (scope === void 0) layer = this.global;
|
|
332
|
+
else {
|
|
333
|
+
const existing = this.scoped.get(scope);
|
|
334
|
+
if (existing === void 0) {
|
|
335
|
+
layer = this.createLayer(scope);
|
|
336
|
+
this.scoped.set(scope, layer);
|
|
337
|
+
created = true;
|
|
338
|
+
} else layer = existing;
|
|
339
|
+
}
|
|
340
|
+
let undo;
|
|
341
|
+
try {
|
|
342
|
+
undo = action(layer);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (scope !== void 0 && created && layer.isEmpty()) this.scoped.delete(scope);
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
yield () => {
|
|
348
|
+
undo();
|
|
349
|
+
if (scope !== void 0 && layer.isEmpty()) this.scoped.delete(scope);
|
|
350
|
+
if (notify) this.onChange();
|
|
351
|
+
};
|
|
352
|
+
if (notify) this.onChange();
|
|
353
|
+
}.bind(this), options.label);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region ../../core/scope/src/index.ts
|
|
358
|
+
/** Context tag written by {@link createScope}. */
|
|
359
|
+
const kScope = Symbol("dsh.scope");
|
|
360
|
+
/**
|
|
361
|
+
* The enclosing scope of each key. One relation powers both directions of
|
|
362
|
+
* scope nesting: registration views inherit DOWN the chain (a child scope
|
|
363
|
+
* sees its ancestors' layers — {@link ScopedLayers}), and event admission
|
|
364
|
+
* extends UP it (a listener tagged with an ancestor receives events dispatched
|
|
365
|
+
* to a descendant key — {@link scopeTarget}).
|
|
366
|
+
*/
|
|
367
|
+
const scopeParents = /* @__PURE__ */ new WeakMap();
|
|
368
|
+
/**
|
|
369
|
+
* The chain from a key to its root ancestor.
|
|
370
|
+
* @param key - the starting key, or `undefined` for the empty chain.
|
|
371
|
+
* @returns keys nearest-first: `[key, parent, grandparent, …]`.
|
|
372
|
+
*/
|
|
373
|
+
function scopeChainOf(key) {
|
|
374
|
+
const chain = [];
|
|
375
|
+
for (let cursor = key; cursor !== void 0; cursor = scopeParents.get(cursor)) chain.push(cursor);
|
|
376
|
+
return chain;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Read the nearest scope tag inherited by a context.
|
|
380
|
+
* @param ctx - context to inspect.
|
|
381
|
+
* @returns its scope key, or `undefined` for an unscoped context.
|
|
382
|
+
*/
|
|
383
|
+
function scopeOf(ctx) {
|
|
384
|
+
return ctx[kScope];
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region ../../skill/skill/src/index.ts
|
|
388
|
+
/**
|
|
389
|
+
* Agent skill provider registry.
|
|
390
|
+
*
|
|
391
|
+
* This package owns the Service Definition role of the skill capability seam.
|
|
392
|
+
* Concrete
|
|
393
|
+
* providers such as `@deepseek-ai/dsh-skill-filesystem` decide where skills come
|
|
394
|
+
* from; this service only merges provider catalogs, resolves the winning skill
|
|
395
|
+
* for a name, and exposes the winning summaries and definitions to consumers.
|
|
396
|
+
*
|
|
397
|
+
* @module @deepseek-ai/dsh-skill
|
|
398
|
+
*/
|
|
399
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
400
|
+
const DEFAULT_COLLECT_CACHE_ENTRIES = 128;
|
|
401
|
+
const MAX_COLLECT_ATTEMPTS = 2;
|
|
402
|
+
const RUNTIME_PROVIDER = "runtime";
|
|
403
|
+
const RUNTIME_RANK = 250;
|
|
404
|
+
/**
|
|
405
|
+
* Return whether a string is a valid kebab-case skill name.
|
|
406
|
+
* @param name - candidate skill name to validate.
|
|
407
|
+
* @returns whether the name matches the public skill-name grammar.
|
|
408
|
+
*/
|
|
409
|
+
function isSkillName(name) {
|
|
410
|
+
return SKILL_NAME.test(name);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Render one loaded skill for the model. The output is shared verbatim by the
|
|
414
|
+
* `skill` tool result and the user-explicit invocation injection, so the model
|
|
415
|
+
* sees one canonical `<skill_content>` shape on both paths. The name rides an
|
|
416
|
+
* escaped attribute; the body is embedded verbatim (skills are trusted local
|
|
417
|
+
* content, and user-supplied invocation text stays outside this wrapper).
|
|
418
|
+
* @param skill - name, provider, optional resource base, and body to render.
|
|
419
|
+
* @returns the complete model-facing `<skill_content>` block.
|
|
420
|
+
*/
|
|
421
|
+
function renderSkillContent(skill) {
|
|
422
|
+
const resourceHint = renderResourceHint(skill);
|
|
423
|
+
return [
|
|
424
|
+
`<skill_content name="${escapeAttr(skill.name)}">`,
|
|
425
|
+
"<skill_resources>",
|
|
426
|
+
...resourceHint,
|
|
427
|
+
"</skill_resources>",
|
|
428
|
+
"",
|
|
429
|
+
"<skill_instructions>",
|
|
430
|
+
skill.content,
|
|
431
|
+
"</skill_instructions>",
|
|
432
|
+
"</skill_content>"
|
|
433
|
+
].join("\n");
|
|
434
|
+
}
|
|
435
|
+
function renderResourceHint(skill) {
|
|
436
|
+
const base = skill.resourceBase;
|
|
437
|
+
if (base === void 0) return [`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, "Load referenced resources only as needed."];
|
|
438
|
+
switch (base.kind) {
|
|
439
|
+
case "directory": return [`Base directory for this skill: ${escapeText(base.path)}`, "Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed."];
|
|
440
|
+
case "url": return [`Base URL for this skill: ${escapeText(base.url)}`, "Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed."];
|
|
441
|
+
case "opaque": return [`Resources for this skill: ${escapeText(base.description)}`, "Load referenced resources only as needed."];
|
|
442
|
+
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
|
|
443
|
+
default: return assertNever(base, "SkillResourceBase.kind");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function escapeAttr(value) {
|
|
447
|
+
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<");
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Escape model-facing prose embedded inside skill markup so provider-supplied
|
|
451
|
+
* text cannot open or close framing tags.
|
|
452
|
+
* @param value - raw prose to embed.
|
|
453
|
+
* @returns the escaped text.
|
|
454
|
+
*/
|
|
455
|
+
function escapeText(value) {
|
|
456
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
457
|
+
}
|
|
458
|
+
/** One scope's complete skill-registry contribution. */
|
|
459
|
+
var SkillLayer = class {
|
|
460
|
+
/** Providers registered through contexts carrying this scope, insertion-ordered. */
|
|
461
|
+
providers;
|
|
462
|
+
/** Runtime skills registered through contexts carrying this scope. */
|
|
463
|
+
runtime = /* @__PURE__ */ new Map();
|
|
464
|
+
constructor(scope) {
|
|
465
|
+
this.providers = new NamedEntries((name) => /* @__PURE__ */ new Error(scope === void 0 ? `a skill provider named "${name}" is already registered` : `a skill provider named "${name}" is already registered in this scope`));
|
|
466
|
+
}
|
|
467
|
+
/** Whether every contribution table in this aggregate layer is empty. */
|
|
468
|
+
isEmpty() {
|
|
469
|
+
return this.providers.isEmpty() && this.runtime.size === 0;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
(class extends Service {
|
|
473
|
+
static Config = z.object({ collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES) });
|
|
474
|
+
collectCacheMaxEntries;
|
|
475
|
+
layers = new ScopedLayers((scope) => new SkillLayer(scope), () => {
|
|
476
|
+
this.invalidateCache();
|
|
477
|
+
});
|
|
478
|
+
collectCache = /* @__PURE__ */ new Map();
|
|
479
|
+
revision = 0;
|
|
480
|
+
nextProviderOrder = 0;
|
|
481
|
+
/** Stable identities for cache keys; scope keys are opaque identity-compared objects. */
|
|
482
|
+
scopeIds = /* @__PURE__ */ new WeakMap();
|
|
483
|
+
nextScopeId = 1;
|
|
484
|
+
constructor(ctx, config = {}) {
|
|
485
|
+
super(ctx, "skills");
|
|
486
|
+
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES;
|
|
487
|
+
assertPositiveInteger("collectCacheMaxEntries", this.collectCacheMaxEntries);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Register a borrowed same-process provider synchronously during plugin
|
|
491
|
+
* apply, into the calling context's layer: a scoped context (an agent
|
|
492
|
+
* preset's standing mount) registers for that scope alone, an unscoped
|
|
493
|
+
* context registers globally. Duplicate names within one layer and reserved
|
|
494
|
+
* names throw; remote initialization belongs in `list()`. Fiber disposal
|
|
495
|
+
* unregisters the provider and invalidates catalog caches.
|
|
496
|
+
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
|
|
497
|
+
* @returns the exact Cordis effect disposer that unregisters this provider;
|
|
498
|
+
* composite effects may yield it directly to preserve teardown ordering.
|
|
499
|
+
*/
|
|
500
|
+
registerProvider(create) {
|
|
501
|
+
const lifecycle = new AbortController();
|
|
502
|
+
let registration;
|
|
503
|
+
let provider;
|
|
504
|
+
const control = {
|
|
505
|
+
signal: lifecycle.signal,
|
|
506
|
+
invalidate: () => {
|
|
507
|
+
const active = registration;
|
|
508
|
+
if (active !== void 0 && active.layer.providers.get(active.name)?.provider === provider) this.invalidateCache();
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
try {
|
|
512
|
+
provider = create(control);
|
|
513
|
+
const name = provider.name;
|
|
514
|
+
if (name === RUNTIME_PROVIDER) throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`);
|
|
515
|
+
const order = this.nextProviderOrder;
|
|
516
|
+
this.nextProviderOrder += 1;
|
|
517
|
+
return this.layers.effect(this.ctx, (layer) => {
|
|
518
|
+
const undo = layer.providers.insert(name, {
|
|
519
|
+
provider,
|
|
520
|
+
order
|
|
521
|
+
});
|
|
522
|
+
registration = {
|
|
523
|
+
layer,
|
|
524
|
+
name
|
|
525
|
+
};
|
|
526
|
+
return () => {
|
|
527
|
+
registration = void 0;
|
|
528
|
+
undo();
|
|
529
|
+
lifecycle.abort(/* @__PURE__ */ new Error(`skill provider "${name}" disposed`));
|
|
530
|
+
};
|
|
531
|
+
}, { label: "skills.registerProvider()" });
|
|
532
|
+
} catch (error) {
|
|
533
|
+
lifecycle.abort(error);
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Register a borrowed readonly runtime skill into the calling context's
|
|
539
|
+
* layer. Project entries outrank runtime entries, which outrank user
|
|
540
|
+
* entries, within one layer. Same-name runtime entries in one layer are
|
|
541
|
+
* first-wins; a duplicate logs a warning and receives a no-op disposer so
|
|
542
|
+
* it cannot remove the winner.
|
|
543
|
+
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
|
|
544
|
+
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
|
|
545
|
+
*/
|
|
546
|
+
register(skill) {
|
|
547
|
+
validateRuntimeSkill(skill);
|
|
548
|
+
const scope = scopeOf(this.ctx);
|
|
549
|
+
const existingLayer = scope === void 0 ? this.layers.global : this.layers.peek(scope);
|
|
550
|
+
if (existingLayer !== void 0 && existingLayer.runtime.has(skill.name)) {
|
|
551
|
+
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`);
|
|
552
|
+
return () => {};
|
|
553
|
+
}
|
|
554
|
+
const definition = {
|
|
555
|
+
...skill,
|
|
556
|
+
invocation: skill.invocation ?? {
|
|
557
|
+
modelInvocable: true,
|
|
558
|
+
userInvocable: true
|
|
559
|
+
},
|
|
560
|
+
provider: skill.provider ?? RUNTIME_PROVIDER
|
|
561
|
+
};
|
|
562
|
+
return this.layers.effect(this.ctx, (layer) => {
|
|
563
|
+
layer.runtime.set(definition.name, definition);
|
|
564
|
+
return () => {
|
|
565
|
+
layer.runtime.delete(definition.name);
|
|
566
|
+
};
|
|
567
|
+
}, { label: "skills.register()" });
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* List invocation-neutral skill summaries for a workspace. Consumers apply
|
|
571
|
+
* model or user invocation policy at their operational boundary. Lookup
|
|
572
|
+
* options and provider candidates are readonly same-process values borrowed
|
|
573
|
+
* throughout discovery.
|
|
574
|
+
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
|
575
|
+
* @returns all sorted winning summaries.
|
|
576
|
+
*/
|
|
577
|
+
async list(options = {}) {
|
|
578
|
+
return (await this.snapshot(options)).skills;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
|
|
582
|
+
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
|
583
|
+
* retry on their next request boundary.
|
|
584
|
+
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
|
585
|
+
* @returns sorted summaries plus discovery-completeness state.
|
|
586
|
+
*/
|
|
587
|
+
async snapshot(options = {}) {
|
|
588
|
+
const collected = await this.collect(options);
|
|
589
|
+
return {
|
|
590
|
+
skills: [...collected.entries.values()].map((entry) => toSummary(entry.candidate)).sort(compareSkillSummary),
|
|
591
|
+
complete: collected.cacheable
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Load and validate the winning candidate, passing its opaque discovery locator back to the
|
|
596
|
+
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
|
|
597
|
+
* loading so an uncooperative provider cannot hang the caller.
|
|
598
|
+
* @param name - kebab-case skill name.
|
|
599
|
+
* @param options - view options; `scope` selects the viewing agent's layers,
|
|
600
|
+
* `cwd` selects workspace-sensitive skills, and `signal` cancels work.
|
|
601
|
+
* @returns the full skill, including body content, or `undefined`.
|
|
602
|
+
*/
|
|
603
|
+
async get(name, options = {}) {
|
|
604
|
+
if (!isSkillName(name)) return void 0;
|
|
605
|
+
const collected = await this.collect(options);
|
|
606
|
+
throwIfAborted(options.signal);
|
|
607
|
+
const match = collected.entries.get(name);
|
|
608
|
+
if (match === void 0) return void 0;
|
|
609
|
+
const definition = await waitWithAbort(match.provider.get(match.candidate, options), options.signal);
|
|
610
|
+
if (definition === void 0) return void 0;
|
|
611
|
+
validateDefinition(definition);
|
|
612
|
+
if (definition.name !== match.candidate.name) {
|
|
613
|
+
this.invalidateEntry(match);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
return definition;
|
|
617
|
+
}
|
|
618
|
+
async collect(options) {
|
|
619
|
+
throwIfAborted(options.signal);
|
|
620
|
+
let attempt = 1;
|
|
621
|
+
while (true) {
|
|
622
|
+
const revision = this.revision;
|
|
623
|
+
const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision);
|
|
624
|
+
const cached = this.collectCache.get(key);
|
|
625
|
+
if (cached !== void 0) return {
|
|
626
|
+
entries: cached,
|
|
627
|
+
cacheable: true
|
|
628
|
+
};
|
|
629
|
+
const result = await this.collectFresh(options);
|
|
630
|
+
throwIfAborted(options.signal);
|
|
631
|
+
if (revision !== this.revision) {
|
|
632
|
+
if (attempt < MAX_COLLECT_ATTEMPTS) {
|
|
633
|
+
attempt += 1;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
entries: result.entries,
|
|
638
|
+
cacheable: false
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
if (result.cacheable) {
|
|
642
|
+
this.collectCache.set(key, result.entries);
|
|
643
|
+
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
|
644
|
+
const oldest = this.collectCache.keys().next();
|
|
645
|
+
this.collectCache.delete(oldest.value);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async collectFresh(options) {
|
|
652
|
+
const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)];
|
|
653
|
+
const merged = /* @__PURE__ */ new Map();
|
|
654
|
+
let cacheable = true;
|
|
655
|
+
for (const layer of layers) {
|
|
656
|
+
const collected = await this.collectLayer(layer, options);
|
|
657
|
+
if (!collected.cacheable) cacheable = false;
|
|
658
|
+
for (const entry of collected.entries) merged.set(entry.candidate.name, entry);
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
entries: merged,
|
|
662
|
+
cacheable
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async collectLayer(layer, options) {
|
|
666
|
+
const collected = await this.listLayerCandidates(layer, options);
|
|
667
|
+
collected.entries.sort(compareIndexedCandidates);
|
|
668
|
+
const seen = /* @__PURE__ */ new Set();
|
|
669
|
+
const result = [];
|
|
670
|
+
for (const entry of collected.entries) {
|
|
671
|
+
const skill = entry.candidate;
|
|
672
|
+
if (seen.has(skill.name)) {
|
|
673
|
+
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
seen.add(skill.name);
|
|
677
|
+
result.push(entry);
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
entries: result,
|
|
681
|
+
cacheable: collected.cacheable
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
async listLayerCandidates(layer, options) {
|
|
685
|
+
throwIfAborted(options.signal);
|
|
686
|
+
const candidates = [];
|
|
687
|
+
let cacheable = true;
|
|
688
|
+
let runtimeOrder = 0;
|
|
689
|
+
for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
|
690
|
+
candidates.push({
|
|
691
|
+
candidate: runtimeCandidate(skill),
|
|
692
|
+
provider: RUNTIME_SKILL_PROVIDER,
|
|
693
|
+
providerOrder: -1,
|
|
694
|
+
localOrder: runtimeOrder,
|
|
695
|
+
layer
|
|
696
|
+
});
|
|
697
|
+
runtimeOrder += 1;
|
|
698
|
+
}
|
|
699
|
+
for (const { provider, order } of [...layer.providers.values()]) {
|
|
700
|
+
let localOrder = 0;
|
|
701
|
+
let output;
|
|
702
|
+
try {
|
|
703
|
+
output = await waitWithAbort(provider.list(options), options.signal);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (options.signal?.aborted === true) throw toError(options.signal.reason);
|
|
706
|
+
cacheable = false;
|
|
707
|
+
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`);
|
|
708
|
+
}
|
|
709
|
+
if (output === void 0) continue;
|
|
710
|
+
const observation = normalizeProviderObservation(output, provider.name);
|
|
711
|
+
if (!observation.complete) cacheable = false;
|
|
712
|
+
for (const candidate of observation.candidates) {
|
|
713
|
+
validateCandidate(candidate, provider.name);
|
|
714
|
+
candidates.push({
|
|
715
|
+
candidate,
|
|
716
|
+
provider,
|
|
717
|
+
providerOrder: order,
|
|
718
|
+
localOrder,
|
|
719
|
+
layer
|
|
720
|
+
});
|
|
721
|
+
localOrder += 1;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
entries: candidates,
|
|
726
|
+
cacheable
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
invalidateCache() {
|
|
730
|
+
this.revision += 1;
|
|
731
|
+
this.collectCache.clear();
|
|
732
|
+
this.notifyChange();
|
|
733
|
+
}
|
|
734
|
+
/** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */
|
|
735
|
+
invalidateEntry(entry) {
|
|
736
|
+
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
|
|
737
|
+
if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache();
|
|
738
|
+
}
|
|
739
|
+
scopeId(key) {
|
|
740
|
+
let id = this.scopeIds.get(key);
|
|
741
|
+
if (id === void 0) {
|
|
742
|
+
id = this.nextScopeId;
|
|
743
|
+
this.nextScopeId += 1;
|
|
744
|
+
this.scopeIds.set(key, id);
|
|
745
|
+
}
|
|
746
|
+
return id;
|
|
747
|
+
}
|
|
748
|
+
collectCacheKey(cwd, chain, revision) {
|
|
749
|
+
return JSON.stringify({
|
|
750
|
+
cwd,
|
|
751
|
+
scopes: chain.map((key) => this.scopeId(key)),
|
|
752
|
+
revision
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/** Notify catalog observers without making their refresh work load-bearing. */
|
|
756
|
+
notifyChange() {
|
|
757
|
+
for (const callback of this.ctx.events.dispatch("emit", ["skills/change"])) try {
|
|
758
|
+
const returned = callback();
|
|
759
|
+
Promise.resolve(returned).catch((error) => {
|
|
760
|
+
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`);
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
function normalizeProviderObservation(output, providerName) {
|
|
768
|
+
if (Array.isArray(output)) return {
|
|
769
|
+
candidates: output,
|
|
770
|
+
complete: true
|
|
771
|
+
};
|
|
772
|
+
if (output === null || typeof output !== "object") throw invalidProviderObservation(providerName);
|
|
773
|
+
const observation = output;
|
|
774
|
+
if (!Array.isArray(observation.candidates) || typeof observation.complete !== "boolean") throw invalidProviderObservation(providerName);
|
|
775
|
+
return observation;
|
|
776
|
+
}
|
|
777
|
+
function invalidProviderObservation(providerName) {
|
|
778
|
+
return /* @__PURE__ */ new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`);
|
|
779
|
+
}
|
|
780
|
+
const RUNTIME_SKILL_PROVIDER = {
|
|
781
|
+
name: RUNTIME_PROVIDER,
|
|
782
|
+
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
|
|
783
|
+
list() {
|
|
784
|
+
return Promise.resolve([]);
|
|
785
|
+
},
|
|
786
|
+
get(candidate) {
|
|
787
|
+
return Promise.resolve(candidate.locator);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
function runtimeCandidate(skill) {
|
|
791
|
+
return {
|
|
792
|
+
name: skill.name,
|
|
793
|
+
description: skill.description,
|
|
794
|
+
...skill.whenToUse !== void 0 ? { whenToUse: skill.whenToUse } : {},
|
|
795
|
+
invocation: skill.invocation,
|
|
796
|
+
source: skill.source,
|
|
797
|
+
provider: skill.provider,
|
|
798
|
+
...skill.resourceBase !== void 0 ? { resourceBase: skill.resourceBase } : {},
|
|
799
|
+
rank: RUNTIME_RANK,
|
|
800
|
+
locator: skill,
|
|
801
|
+
...skill.path !== void 0 ? { path: skill.path } : {},
|
|
802
|
+
...skill.metadata !== void 0 ? { metadata: skill.metadata } : {}
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function validateCandidate(candidate, providerName) {
|
|
806
|
+
if (typeof candidate.name !== "string") throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`);
|
|
807
|
+
if (!SKILL_NAME.test(candidate.name)) throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`);
|
|
808
|
+
if (typeof candidate.description !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`);
|
|
809
|
+
if (candidate.description.length === 0) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`);
|
|
810
|
+
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`);
|
|
811
|
+
if (candidate.whenToUse !== void 0 && typeof candidate.whenToUse !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`);
|
|
812
|
+
if (typeof candidate.source !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`);
|
|
813
|
+
if (typeof candidate.rank !== "number" || !Number.isFinite(candidate.rank)) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`);
|
|
814
|
+
if (typeof candidate.provider !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`);
|
|
815
|
+
if (candidate.provider !== providerName) throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`);
|
|
816
|
+
if (candidate.path !== void 0 && typeof candidate.path !== "string") throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`);
|
|
817
|
+
}
|
|
818
|
+
function validateRuntimeSkill(skill) {
|
|
819
|
+
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`);
|
|
820
|
+
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`);
|
|
821
|
+
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`);
|
|
822
|
+
}
|
|
823
|
+
/** Validate a definition loaded from a provider-controlled parser or remote source. */
|
|
824
|
+
function validateDefinition(skill) {
|
|
825
|
+
const name = skill.name;
|
|
826
|
+
const description = skill.description;
|
|
827
|
+
const whenToUse = skill.whenToUse;
|
|
828
|
+
const invocation = skill.invocation;
|
|
829
|
+
const source = skill.source;
|
|
830
|
+
const provider = skill.provider;
|
|
831
|
+
const content = skill.content;
|
|
832
|
+
const path = skill.path;
|
|
833
|
+
if (typeof name !== "string") throw new TypeError("loaded skill name must be a string");
|
|
834
|
+
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`);
|
|
835
|
+
if (typeof description !== "string") throw new TypeError(`loaded skill "${name}" description must be a string`);
|
|
836
|
+
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`);
|
|
837
|
+
validateInvocation(invocation, `loaded skill "${name}"`);
|
|
838
|
+
if (whenToUse !== void 0 && typeof whenToUse !== "string") throw new TypeError(`loaded skill "${name}" whenToUse must be a string`);
|
|
839
|
+
if (typeof source !== "string") throw new TypeError(`loaded skill "${name}" source must be a string`);
|
|
840
|
+
if (typeof provider !== "string") throw new TypeError(`loaded skill "${name}" provider must be a string`);
|
|
841
|
+
if (typeof content !== "string") throw new TypeError(`loaded skill "${name}" content must be a string`);
|
|
842
|
+
if (path !== void 0 && typeof path !== "string") throw new TypeError(`loaded skill "${name}" path must be a string`);
|
|
843
|
+
}
|
|
844
|
+
function toSummary(skill) {
|
|
845
|
+
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill;
|
|
846
|
+
return {
|
|
847
|
+
name,
|
|
848
|
+
description,
|
|
849
|
+
...whenToUse !== void 0 ? { whenToUse } : {},
|
|
850
|
+
invocation,
|
|
851
|
+
source,
|
|
852
|
+
provider,
|
|
853
|
+
...resourceBase !== void 0 ? { resourceBase } : {}
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function validateInvocation(invocation, subject) {
|
|
857
|
+
if (invocation === void 0) return;
|
|
858
|
+
if (typeof invocation !== "object" || invocation === null || Array.isArray(invocation)) throw new TypeError(`${subject} with a non-object invocation policy`);
|
|
859
|
+
const policy = invocation;
|
|
860
|
+
if (typeof policy.modelInvocable !== "boolean") throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`);
|
|
861
|
+
if (typeof policy.userInvocable !== "boolean") throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`);
|
|
862
|
+
}
|
|
863
|
+
function compareSkillSummary(left, right) {
|
|
864
|
+
return compareCodePoints(left.name, right.name);
|
|
865
|
+
}
|
|
866
|
+
function compareCodePoints(left, right) {
|
|
867
|
+
if (left < right) return -1;
|
|
868
|
+
if (left > right) return 1;
|
|
869
|
+
return 0;
|
|
870
|
+
}
|
|
871
|
+
function compareIndexedCandidates(left, right) {
|
|
872
|
+
return left.candidate.rank - right.candidate.rank || left.providerOrder - right.providerOrder || left.localOrder - right.localOrder;
|
|
873
|
+
}
|
|
874
|
+
function assertPositiveInteger(name, value, minimum = 1) {
|
|
875
|
+
if (!Number.isInteger(value) || value < minimum) throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`);
|
|
876
|
+
}
|
|
877
|
+
function waitWithAbort(promise, signal) {
|
|
878
|
+
if (signal === void 0) return promise;
|
|
879
|
+
throwIfAborted(signal);
|
|
880
|
+
return new Promise((resolve, reject) => {
|
|
881
|
+
const cleanup = () => {
|
|
882
|
+
signal.removeEventListener("abort", onAbort);
|
|
883
|
+
};
|
|
884
|
+
const onAbort = () => {
|
|
885
|
+
cleanup();
|
|
886
|
+
reject(toError(signal.reason));
|
|
887
|
+
};
|
|
888
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
889
|
+
promise.then((value) => {
|
|
890
|
+
cleanup();
|
|
891
|
+
resolve(value);
|
|
892
|
+
}, (error) => {
|
|
893
|
+
cleanup();
|
|
894
|
+
reject(toError(error));
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/** Throw a total Error for an already-aborted lookup. */
|
|
899
|
+
function throwIfAborted(signal) {
|
|
900
|
+
if (signal?.aborted === true) throw toError(signal.reason);
|
|
901
|
+
}
|
|
902
|
+
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
|
|
903
|
+
function toError(error) {
|
|
904
|
+
try {
|
|
905
|
+
if (error instanceof Error) return error;
|
|
906
|
+
} catch {}
|
|
907
|
+
return new Error(errorMessage(error));
|
|
908
|
+
}
|
|
909
|
+
/** Render an arbitrary provider failure without letting coercion escape containment. */
|
|
910
|
+
function errorMessage(error) {
|
|
911
|
+
try {
|
|
912
|
+
return String(error);
|
|
913
|
+
} catch {
|
|
914
|
+
return "[unrenderable thrown value]";
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region lib/types/content.js
|
|
919
|
+
/**
|
|
920
|
+
* Ponytail skill bodies, ported from github.com/DietrichGebert/ponytail and
|
|
921
|
+
* lightly adapted to the DeepSeek Harness surface (slash commands and the
|
|
922
|
+
* `skill` tool). The `ponytail` skill is a mode-aware pointer card: the actual
|
|
923
|
+
* ruleset is injected per session as the mode-filtered `PONYTAIL MODE ACTIVE`
|
|
924
|
+
* section (see `instructions.ts`) and must not be duplicated here. The other
|
|
925
|
+
* five skills ship verbatim as runtime skills.
|
|
926
|
+
*
|
|
927
|
+
* @module @mengyuly/dsh-ponytail
|
|
928
|
+
*/
|
|
929
|
+
/** The always-on lazy-senior-dev ruleset: also registered as a loadable skill. */
|
|
930
|
+
const PONYTAIL_SKILL_BODY = `
|
|
931
|
+
You are the ponytail persona — the lazy senior developer. Your active ruleset
|
|
932
|
+
is ALREADY injected every turn as the "PONYTAIL MODE ACTIVE — level: <mode>"
|
|
933
|
+
system-prompt section, filtered to this session's intensity. Follow exactly
|
|
934
|
+
that section; do NOT reload, replace, or re-derive the ruleset from anywhere
|
|
935
|
+
else — the section is the single source of truth and it is mode-aware.
|
|
936
|
+
|
|
937
|
+
- Switch level: \`/ponytail lite|full|ultra|off\` (session-scoped)
|
|
938
|
+
- Query: \`/ponytail status\`
|
|
939
|
+
- Deactivate: "stop ponytail" / "normal mode"
|
|
940
|
+
- One-shot skills: \`/ponytail-review\`, \`/ponytail-audit\`, \`/ponytail-debt\`,
|
|
941
|
+
\`/ponytail-gain\`, \`/ponytail-help\`
|
|
942
|
+
- Reference: https://github.com/DietrichGebert/ponytail
|
|
943
|
+
`;
|
|
944
|
+
const PONYTAIL_DESCRIPTION = "Ponytail activation, modes, configuration, and help reference. The active ruleset is injected every turn by the system prompt; this skill is a pointer card. Use only when the user asks about Ponytail activation, modes, configuration, or help. Coding tasks already receive the active ruleset from the system prompt.";
|
|
945
|
+
const REVIEW_SKILL_BODY = `
|
|
946
946
|
Review the current diff for unnecessary complexity. Inspect affected callers
|
|
947
947
|
and tests first. Report only when code evidence shows the replacement preserves
|
|
948
948
|
required behavior.
|
|
949
|
-
|
|
950
|
-
## Format
|
|
951
|
-
|
|
949
|
+
|
|
950
|
+
## Format
|
|
951
|
+
|
|
952
952
|
\`<file>:L<start>-<end>: <tag> <what>. Replacement: <simpler form>. Evidence: <observable reason>.\`
|
|
953
|
-
|
|
954
|
-
Tags:
|
|
955
|
-
|
|
956
|
-
- \`delete:\` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
|
957
|
-
- \`stdlib:\` hand-rolled thing the standard library ships. Name the function.
|
|
958
|
-
- \`native:\` dependency or code doing what the platform already does. Name the feature.
|
|
959
|
-
- \`yagni:\` abstraction with one implementation, config nobody sets, layer with one caller.
|
|
960
|
-
- \`shrink:\` same logic, fewer lines. Show the shorter form.
|
|
961
|
-
|
|
953
|
+
|
|
954
|
+
Tags:
|
|
955
|
+
|
|
956
|
+
- \`delete:\` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
|
957
|
+
- \`stdlib:\` hand-rolled thing the standard library ships. Name the function.
|
|
958
|
+
- \`native:\` dependency or code doing what the platform already does. Name the feature.
|
|
959
|
+
- \`yagni:\` abstraction with one implementation, config nobody sets, layer with one caller.
|
|
960
|
+
- \`shrink:\` same logic, fewer lines. Show the shorter form.
|
|
961
|
+
|
|
962
962
|
Evidence must name an actual caller count, unused export, duplicate branch, or
|
|
963
963
|
equivalent stdlib/native behavior. A name that merely looks abstract is not
|
|
964
964
|
evidence. If preserving behavior is uncertain, omit the finding.
|
|
965
|
-
|
|
966
|
-
## Scoring
|
|
967
|
-
|
|
965
|
+
|
|
966
|
+
## Scoring
|
|
967
|
+
|
|
968
968
|
End with \`net: -<N> lines countable.\` only when concrete ranges make the total
|
|
969
969
|
countable; otherwise say \`net: uncounted.\` Nothing to cut: \`Lean already. Ship.\`
|
|
970
|
-
|
|
971
|
-
## Boundaries
|
|
972
|
-
|
|
973
|
-
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
|
974
|
-
and performance are explicitly out of scope. Route them to a normal review
|
|
975
|
-
pass, not this one. A single smoke test or \`assert\`-based
|
|
976
|
-
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
|
977
|
-
Does not apply the fixes, only lists them.
|
|
978
|
-
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
|
979
|
-
`;
|
|
980
|
-
const REVIEW_DESCRIPTION = "Code review focused exclusively on over-engineering. Finds what to delete: reinvented standard library, unneeded dependencies, speculative abstractions, dead flexibility. One line per finding: location, what to cut, what replaces it. Use when the user says \"review for over-engineering\", \"what can we delete\", \"is this over-engineered\", \"simplify review\", or invokes /ponytail-review. Complements correctness-focused review, this one only hunts complexity.";
|
|
981
|
-
const AUDIT_SKILL_BODY = `
|
|
970
|
+
|
|
971
|
+
## Boundaries
|
|
972
|
+
|
|
973
|
+
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
|
974
|
+
and performance are explicitly out of scope. Route them to a normal review
|
|
975
|
+
pass, not this one. A single smoke test or \`assert\`-based
|
|
976
|
+
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
|
977
|
+
Does not apply the fixes, only lists them.
|
|
978
|
+
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
|
979
|
+
`;
|
|
980
|
+
const REVIEW_DESCRIPTION = "Code review focused exclusively on over-engineering. Finds what to delete: reinvented standard library, unneeded dependencies, speculative abstractions, dead flexibility. One line per finding: location, what to cut, what replaces it. Use when the user says \"review for over-engineering\", \"what can we delete\", \"is this over-engineered\", \"simplify review\", or invokes /ponytail-review. Complements correctness-focused review, this one only hunts complexity.";
|
|
981
|
+
const AUDIT_SKILL_BODY = `
|
|
982
982
|
Audit repository complexity. Skip generated, vendored, dependency, and build
|
|
983
983
|
output. Inspect manifests, callers, and tests. Return the top 10 findings.
|
|
984
984
|
|
|
@@ -990,7 +990,7 @@ output. Inspect manifests, callers, and tests. Return the top 10 findings.
|
|
|
990
990
|
- \`verify-first\`: consumers may remain; name the check needed before deletion.
|
|
991
991
|
|
|
992
992
|
Tags:
|
|
993
|
-
|
|
993
|
+
|
|
994
994
|
- \`delete:\` dead code or speculative flexibility.
|
|
995
995
|
- \`stdlib:\` hand-rolled standard-library behavior.
|
|
996
996
|
- \`native:\` dependency or code replaced by a platform feature.
|
|
@@ -1000,434 +1000,440 @@ Tags:
|
|
|
1000
1000
|
Count only concrete ranges and manifest entries. End with
|
|
1001
1001
|
\`net: -<N> lines, -<M> deps countable.\` Use \`uncounted\` for either unknown.
|
|
1002
1002
|
Nothing to cut: \`Lean already. Ship.\`
|
|
1003
|
-
|
|
1004
|
-
## Boundaries
|
|
1005
|
-
|
|
1006
|
-
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
|
1007
|
-
and performance are explicitly out of scope. Route them to a normal review
|
|
1008
|
-
pass. Lists findings, applies nothing. One-shot.
|
|
1009
|
-
"stop ponytail-audit" or "normal mode" to revert.
|
|
1010
|
-
`;
|
|
1011
|
-
const AUDIT_DESCRIPTION = "Whole-repo audit for over-engineering. Like ponytail-review, but scans the entire codebase instead of a diff: a ranked list of what to delete, simplify, or replace with stdlib/native equivalents. Use when the user says \"audit this codebase\", \"audit for over-engineering\", \"what can I delete from this repo\", \"find bloat\", \"ponytail-audit\", or /ponytail-audit. One-shot report, does not apply fixes.";
|
|
1012
|
-
const DEBT_SKILL_BODY = `
|
|
1003
|
+
|
|
1004
|
+
## Boundaries
|
|
1005
|
+
|
|
1006
|
+
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
|
1007
|
+
and performance are explicitly out of scope. Route them to a normal review
|
|
1008
|
+
pass. Lists findings, applies nothing. One-shot.
|
|
1009
|
+
"stop ponytail-audit" or "normal mode" to revert.
|
|
1010
|
+
`;
|
|
1011
|
+
const AUDIT_DESCRIPTION = "Whole-repo audit for over-engineering. Like ponytail-review, but scans the entire codebase instead of a diff: a ranked list of what to delete, simplify, or replace with stdlib/native equivalents. Use when the user says \"audit this codebase\", \"audit for over-engineering\", \"what can I delete from this repo\", \"find bloat\", \"ponytail-audit\", or /ponytail-audit. One-shot report, does not apply fixes.";
|
|
1012
|
+
const DEBT_SKILL_BODY = `
|
|
1013
1013
|
Collect \`ponytail:\` shortcut comments into a debt ledger. Each should name its
|
|
1014
1014
|
ceiling and upgrade path.
|
|
1015
|
-
|
|
1016
|
-
## Scan
|
|
1017
|
-
|
|
1015
|
+
|
|
1016
|
+
## Scan
|
|
1017
|
+
|
|
1018
1018
|
Prefer ripgrep and exclude generated or dependency trees:
|
|
1019
1019
|
|
|
1020
|
-
\`rg -n --hidden --glob '
|
|
1020
|
+
\`rg -n --hidden --glob '!**/node_modules/**' --glob '!**/.git/**' --glob '!**/dist/**' --glob '!**/build/**' '(#|//|/[*]) ?ponytail:' .\`
|
|
1021
1021
|
|
|
1022
1022
|
If \`rg\` is unavailable, scan tracked files only:
|
|
1023
|
-
\`git grep -n -E '(
|
|
1024
|
-
|
|
1023
|
+
\`git grep -n -E '(#|//|/[*]) ?ponytail:' -- . ':(glob,exclude)**/node_modules/**' ':(glob,exclude)**/dist/**' ':(glob,exclude)**/build/**'\`
|
|
1024
|
+
|
|
1025
|
+
Adapt comment prefixes to the stack. Exclude other verified generated trees,
|
|
1026
|
+
but keep \`lib\` when it contains source.
|
|
1027
|
+
|
|
1025
1028
|
Each hit is one row; the comment prefix excludes prose mentions.
|
|
1026
|
-
|
|
1027
|
-
## Output
|
|
1028
|
-
|
|
1029
|
-
One row per marker, grouped by file:
|
|
1030
|
-
|
|
1031
|
-
\`<file>:<line>, <what was simplified>. ceiling: <the limit named>. upgrade: <the trigger to revisit>.\`
|
|
1032
|
-
|
|
1029
|
+
|
|
1030
|
+
## Output
|
|
1031
|
+
|
|
1032
|
+
One row per marker, grouped by file:
|
|
1033
|
+
|
|
1034
|
+
\`<file>:<line>, <what was simplified>. ceiling: <the limit named>. upgrade: <the trigger to revisit>.\`
|
|
1035
|
+
|
|
1033
1036
|
The convention is \`ponytail: <ceiling>, <upgrade path>\`; copy both from the
|
|
1034
1037
|
comment. Add an owner only when requested, using
|
|
1035
1038
|
\`git blame -L<line>,<line> <file>\`.
|
|
1036
|
-
|
|
1037
|
-
Flag the rot risk: any \`ponytail:\` comment that names no upgrade path or
|
|
1038
|
-
trigger gets a \`no-trigger\` tag, those are the ones that silently rot.
|
|
1039
|
-
|
|
1040
|
-
End with \`<N> markers, <M> with no trigger.\` Nothing found: \`No ponytail: debt. Clean ledger.\`
|
|
1041
|
-
|
|
1042
|
-
## Boundaries
|
|
1043
|
-
|
|
1044
|
-
Reads and reports only, changes nothing. To persist it, ask and it writes the
|
|
1045
|
-
ledger to a file (e.g. \`PONYTAIL-DEBT.md\`). One-shot. "stop ponytail-debt" or
|
|
1046
|
-
"normal mode" to revert.
|
|
1047
|
-
`;
|
|
1048
|
-
const DEBT_DESCRIPTION = "Harvest every `ponytail:` comment in the codebase into a debt ledger, so the deliberate shortcuts and deferrals ponytail leaves behind get tracked instead of rotting into \"later means never\". Use when the user says \"ponytail debt\", \"/ponytail-debt\", \"what did ponytail defer\", \"list the shortcuts\", \"ponytail ledger\", or \"what did we mark to do later\". One-shot report, changes nothing.";
|
|
1049
|
-
const GAIN_SKILL_BODY = `
|
|
1050
|
-
Display this scoreboard when invoked. One-shot: do NOT change mode, write flag
|
|
1051
|
-
files, or persist anything.
|
|
1052
|
-
|
|
1053
|
-
These are upstream Ponytail results, not measured guarantees for this DSH
|
|
1054
|
-
adapter.
|
|
1055
|
-
|
|
1056
|
-
Savings depend on model, workload, prompt caching, tool usage, and execution
|
|
1057
|
-
path. Already-minimal tasks may show little or no savings. Some reasoning
|
|
1058
|
-
models may become more expensive because prompt and reasoning overhead can
|
|
1059
|
-
exceed the saved output.
|
|
1060
|
-
|
|
1061
|
-
## 1. Upstream agentic reference
|
|
1062
|
-
|
|
1063
|
-
Real Claude Code sessions on real repositories; 12 feature tasks:
|
|
1064
|
-
|
|
1065
|
-
- Source LOC: ~\u221254%
|
|
1066
|
-
- Tokens: ~\u221222%
|
|
1067
|
-
- Cost: ~\u221220%
|
|
1068
|
-
- Time: ~\u221227%
|
|
1069
|
-
- Over-build tasks: \u221260\u201394%
|
|
1070
|
-
- Safety tests: 100%
|
|
1071
|
-
|
|
1072
|
-
## 2. Upstream single-shot reference
|
|
1073
|
-
|
|
1074
|
-
5 everyday tasks (email validator, debounce, CSV sum, countdown timer, rate
|
|
1075
|
-
limiter); 3 Claude models; single generation per task:
|
|
1076
|
-
|
|
1077
|
-
- Lines of code: \u221280\u201394%
|
|
1078
|
-
- Cost (Claude): \u221242\u201375%
|
|
1079
|
-
- Latency: ~3.1\u20135.8\u00d7 faster
|
|
1080
|
-
|
|
1081
|
-
## 3. DSH adapter status
|
|
1082
|
-
|
|
1083
|
-
Current DSH smoke tests provide directional evidence only. Stable token,
|
|
1084
|
-
cost, and latency savings have not been established.
|
|
1085
|
-
|
|
1086
|
-
See the repository's DSH smoke reports for limited, non-statistical
|
|
1087
|
-
directional evidence (docs/dsh-smoke-summary.md).
|
|
1088
|
-
|
|
1089
|
-
## 4. Honesty boundary
|
|
1090
|
-
|
|
1091
|
-
These are upstream benchmark medians, not this repo and not this DSH
|
|
1092
|
-
adapter. NEVER print a per-repo savings number ("you saved X lines/tokens
|
|
1093
|
-
here"): the unbuilt version was never written, so there is no real baseline
|
|
1094
|
-
to subtract from in a live repo. The only real per-repo figures come from
|
|
1095
|
-
\`/ponytail-debt\` (a counted ledger), and this card points there instead of
|
|
1096
|
-
inventing one. Never claim "Ponytail always saves tokens/cost" or that this
|
|
1097
|
-
adapter reproduces the upstream percentages. A missing cost figure (null) is
|
|
1098
|
-
not a zero cost.
|
|
1099
|
-
|
|
1100
|
-
## Boundaries
|
|
1101
|
-
|
|
1102
|
-
One-shot display. Edits nothing, changes no mode.
|
|
1103
|
-
"stop ponytail" or "normal mode": revert.
|
|
1104
|
-
`;
|
|
1105
|
-
const GAIN_DESCRIPTION = "Less unnecessary work; token, cost, and latency effects depend on model and workload. Upstream benchmark reference, not a DSH-adapter guarantee. One-shot display, not a persistent mode, and not a per-repo number. Trigger: /ponytail-gain, \"ponytail gain\", \"what does ponytail save\", \"show ponytail impact\", \"ponytail scoreboard\".";
|
|
1106
|
-
const HELP_SKILL_BODY = `
|
|
1039
|
+
|
|
1040
|
+
Flag the rot risk: any \`ponytail:\` comment that names no upgrade path or
|
|
1041
|
+
trigger gets a \`no-trigger\` tag, those are the ones that silently rot.
|
|
1042
|
+
|
|
1043
|
+
End with \`<N> markers, <M> with no trigger.\` Nothing found: \`No ponytail: debt. Clean ledger.\`
|
|
1044
|
+
|
|
1045
|
+
## Boundaries
|
|
1046
|
+
|
|
1047
|
+
Reads and reports only, changes nothing. To persist it, ask and it writes the
|
|
1048
|
+
ledger to a file (e.g. \`PONYTAIL-DEBT.md\`). One-shot. "stop ponytail-debt" or
|
|
1049
|
+
"normal mode" to revert.
|
|
1050
|
+
`;
|
|
1051
|
+
const DEBT_DESCRIPTION = "Harvest every `ponytail:` comment in the codebase into a debt ledger, so the deliberate shortcuts and deferrals ponytail leaves behind get tracked instead of rotting into \"later means never\". Use when the user says \"ponytail debt\", \"/ponytail-debt\", \"what did ponytail defer\", \"list the shortcuts\", \"ponytail ledger\", or \"what did we mark to do later\". One-shot report, changes nothing.";
|
|
1052
|
+
const GAIN_SKILL_BODY = `
|
|
1053
|
+
Display this scoreboard when invoked. One-shot: do NOT change mode, write flag
|
|
1054
|
+
files, or persist anything.
|
|
1055
|
+
|
|
1056
|
+
These are upstream Ponytail results, not measured guarantees for this DSH
|
|
1057
|
+
adapter.
|
|
1058
|
+
|
|
1059
|
+
Savings depend on model, workload, prompt caching, tool usage, and execution
|
|
1060
|
+
path. Already-minimal tasks may show little or no savings. Some reasoning
|
|
1061
|
+
models may become more expensive because prompt and reasoning overhead can
|
|
1062
|
+
exceed the saved output.
|
|
1063
|
+
|
|
1064
|
+
## 1. Upstream agentic reference
|
|
1065
|
+
|
|
1066
|
+
Real Claude Code sessions on real repositories; 12 feature tasks:
|
|
1067
|
+
|
|
1068
|
+
- Source LOC: ~\u221254%
|
|
1069
|
+
- Tokens: ~\u221222%
|
|
1070
|
+
- Cost: ~\u221220%
|
|
1071
|
+
- Time: ~\u221227%
|
|
1072
|
+
- Over-build tasks: \u221260\u201394%
|
|
1073
|
+
- Safety tests: 100%
|
|
1074
|
+
|
|
1075
|
+
## 2. Upstream single-shot reference
|
|
1076
|
+
|
|
1077
|
+
5 everyday tasks (email validator, debounce, CSV sum, countdown timer, rate
|
|
1078
|
+
limiter); 3 Claude models; single generation per task:
|
|
1079
|
+
|
|
1080
|
+
- Lines of code: \u221280\u201394%
|
|
1081
|
+
- Cost (Claude): \u221242\u201375%
|
|
1082
|
+
- Latency: ~3.1\u20135.8\u00d7 faster
|
|
1083
|
+
|
|
1084
|
+
## 3. DSH adapter status
|
|
1085
|
+
|
|
1086
|
+
Current DSH smoke tests provide directional evidence only. Stable token,
|
|
1087
|
+
cost, and latency savings have not been established.
|
|
1088
|
+
|
|
1089
|
+
See the repository's DSH smoke reports for limited, non-statistical
|
|
1090
|
+
directional evidence (docs/dsh-smoke-summary.md).
|
|
1091
|
+
|
|
1092
|
+
## 4. Honesty boundary
|
|
1093
|
+
|
|
1094
|
+
These are upstream benchmark medians, not this repo and not this DSH
|
|
1095
|
+
adapter. NEVER print a per-repo savings number ("you saved X lines/tokens
|
|
1096
|
+
here"): the unbuilt version was never written, so there is no real baseline
|
|
1097
|
+
to subtract from in a live repo. The only real per-repo figures come from
|
|
1098
|
+
\`/ponytail-debt\` (a counted ledger), and this card points there instead of
|
|
1099
|
+
inventing one. Never claim "Ponytail always saves tokens/cost" or that this
|
|
1100
|
+
adapter reproduces the upstream percentages. A missing cost figure (null) is
|
|
1101
|
+
not a zero cost.
|
|
1102
|
+
|
|
1103
|
+
## Boundaries
|
|
1104
|
+
|
|
1105
|
+
One-shot display. Edits nothing, changes no mode.
|
|
1106
|
+
"stop ponytail" or "normal mode": revert.
|
|
1107
|
+
`;
|
|
1108
|
+
const GAIN_DESCRIPTION = "Less unnecessary work; token, cost, and latency effects depend on model and workload. Upstream benchmark reference, not a DSH-adapter guarantee. One-shot display, not a persistent mode, and not a per-repo number. Trigger: /ponytail-gain, \"ponytail gain\", \"what does ponytail save\", \"show ponytail impact\", \"ponytail scoreboard\".";
|
|
1109
|
+
const HELP_SKILL_BODY = `
|
|
1107
1110
|
Display this one-shot reference card. Do not change mode or persist anything.
|
|
1108
|
-
|
|
1109
|
-
## Levels
|
|
1110
|
-
|
|
1111
|
-
| Level | Trigger | What change |
|
|
1112
|
-
|-------|---------|-------------|
|
|
1113
|
-
| **Lite** | \`/ponytail lite\` | Build what's asked, name the lazier alternative in one line. |
|
|
1114
|
-
| **Full** | \`/ponytail\` |
|
|
1115
|
-
| **Ultra** | \`/ponytail ultra\` | YAGNI extremist: deletion first, questions speculation — never cuts explicit requirements. |
|
|
1116
|
-
| **Off** | \`/ponytail off\` | Ponytail stops injecting its ruleset for this session. |
|
|
1117
|
-
|
|
1118
|
-
Level is session-scoped until changed.
|
|
1119
|
-
|
|
1120
|
-
## Choosing a level
|
|
1121
|
-
|
|
1122
|
-
- **Lite**: Use for small, explicit changes or when the implementation is
|
|
1123
|
-
already clear. Completes explicit requirements
|
|
1124
|
-
|
|
1125
|
-
Lite
|
|
1126
|
-
- **Full**: Use for new features, refactors, root-cause bug fixes, or tasks
|
|
1127
|
-
likely to invite unnecessary abstractions, dependencies, or custom
|
|
1128
|
-
components.
|
|
1129
|
-
Full:新功能、重构、根因修复、容易过度设计时使用。
|
|
1130
|
-
- **Ultra**: Use for deliberate code cleanup and over-engineering removal.
|
|
1131
|
-
It questions speculative scope, but never removes explicit requirements,
|
|
1132
|
-
security, validation, accessibility, or data-loss protection.
|
|
1133
|
-
Ultra:专门清理冗余和过度抽象时使用。
|
|
1134
|
-
- **Off**: Use when the task is non-coding, already fully specified, or when
|
|
1135
|
-
the fixed prompt overhead is not worthwhile.
|
|
1136
|
-
Off:非编码任务或已经明确到无需额外编码判断的任务。
|
|
1137
|
-
|
|
1138
|
-
Ponytail is not a guaranteed token-saving switch. It trades a small fixed
|
|
1139
|
-
prompt cost for a chance to reduce unnecessary work. Do not default every
|
|
1140
|
-
task to Ultra.
|
|
1141
|
-
|
|
1142
|
-
## Skills
|
|
1143
|
-
|
|
1144
|
-
| Skill | Trigger | What it does |
|
|
1145
|
-
|-------|---------|--------------|
|
|
1146
|
-
| **ponytail** | \`/ponytail\` | Lazy mode itself. Simplest solution that works. |
|
|
1147
|
-
| **ponytail-review** | \`/ponytail-review\` | Over-engineering review: \`L42: yagni: factory, one product. Inline.\` |
|
|
1148
|
-
| **ponytail-audit** | \`/ponytail-audit\` | Whole-repo over-engineering audit: ranked list of what to delete. |
|
|
1149
|
-
| **ponytail-debt** | \`/ponytail-debt\` | Harvest \`ponytail:\` shortcut comments into a tracked ledger. |
|
|
1150
|
-
| **ponytail-gain** | \`/ponytail-gain\` | Upstream benchmark reference: less unnecessary work; token/cost/latency effects depend on model and workload. |
|
|
1151
|
-
| **ponytail-help** | \`/ponytail-help\` | This card. |
|
|
1152
|
-
|
|
1111
|
+
|
|
1112
|
+
## Levels
|
|
1113
|
+
|
|
1114
|
+
| Level | Trigger | What change |
|
|
1115
|
+
|-------|---------|-------------|
|
|
1116
|
+
| **Lite** | \`/ponytail lite\` | Build what's asked, name the lazier alternative in one line. |
|
|
1117
|
+
| **Full** | \`/ponytail full\` | Enforce all seven rungs: YAGNI → existing code → stdlib → native → installed dependency → one line → minimum. Default. |
|
|
1118
|
+
| **Ultra** | \`/ponytail ultra\` | YAGNI extremist: deletion first, questions speculation — never cuts explicit requirements. |
|
|
1119
|
+
| **Off** | \`/ponytail off\` | Ponytail stops injecting its ruleset for this session. |
|
|
1120
|
+
|
|
1121
|
+
Level is session-scoped until changed.
|
|
1122
|
+
|
|
1123
|
+
## Choosing a level
|
|
1124
|
+
|
|
1125
|
+
- **Lite**: Use for small, explicit changes or when the implementation is
|
|
1126
|
+
already clear. Completes explicit requirements and names a simpler
|
|
1127
|
+
alternative in one line; the user chooses.
|
|
1128
|
+
Lite:完成明确需求,必须用一句话指出更简方案,由用户选择。
|
|
1129
|
+
- **Full**: Use for new features, refactors, root-cause bug fixes, or tasks
|
|
1130
|
+
likely to invite unnecessary abstractions, dependencies, or custom
|
|
1131
|
+
components.
|
|
1132
|
+
Full:新功能、重构、根因修复、容易过度设计时使用。
|
|
1133
|
+
- **Ultra**: Use for deliberate code cleanup and over-engineering removal.
|
|
1134
|
+
It questions speculative scope, but never removes explicit requirements,
|
|
1135
|
+
security, validation, accessibility, or data-loss protection.
|
|
1136
|
+
Ultra:专门清理冗余和过度抽象时使用。
|
|
1137
|
+
- **Off**: Use when the task is non-coding, already fully specified, or when
|
|
1138
|
+
the fixed prompt overhead is not worthwhile.
|
|
1139
|
+
Off:非编码任务或已经明确到无需额外编码判断的任务。
|
|
1140
|
+
|
|
1141
|
+
Ponytail is not a guaranteed token-saving switch. It trades a small fixed
|
|
1142
|
+
prompt cost for a chance to reduce unnecessary work. Do not default every
|
|
1143
|
+
task to Ultra.
|
|
1144
|
+
|
|
1145
|
+
## Skills
|
|
1146
|
+
|
|
1147
|
+
| Skill | Trigger | What it does |
|
|
1148
|
+
|-------|---------|--------------|
|
|
1149
|
+
| **ponytail** | \`/ponytail\` | Lazy mode itself. Simplest solution that works. |
|
|
1150
|
+
| **ponytail-review** | \`/ponytail-review\` | Over-engineering review: \`L42: yagni: factory, one product. Inline.\` |
|
|
1151
|
+
| **ponytail-audit** | \`/ponytail-audit\` | Whole-repo over-engineering audit: ranked list of what to delete. |
|
|
1152
|
+
| **ponytail-debt** | \`/ponytail-debt\` | Harvest \`ponytail:\` shortcut comments into a tracked ledger. |
|
|
1153
|
+
| **ponytail-gain** | \`/ponytail-gain\` | Upstream benchmark reference: less unnecessary work; token/cost/latency effects depend on model and workload. |
|
|
1154
|
+
| **ponytail-help** | \`/ponytail-help\` | This card. |
|
|
1155
|
+
|
|
1153
1156
|
## Deactivate
|
|
1154
1157
|
|
|
1155
1158
|
Say "stop ponytail", "normal mode", "停止 ponytail", or "正常模式".
|
|
1156
1159
|
\`/ponytail\` re-enables at the effective default (or \`full\` if it is \`off\`).
|
|
1157
1160
|
\`/ponytail reset\` clears the session override and follows the configured default.
|
|
1158
1161
|
\`/ponytail status\` reports mode and source; \`/ponytail off\` disables it.
|
|
1159
|
-
|
|
1160
|
-
## Configure Default Mode
|
|
1161
|
-
|
|
1162
|
+
|
|
1163
|
+
## Configure Default Mode
|
|
1164
|
+
|
|
1162
1165
|
The built-in fallback is \`full\`; check the effective mode with
|
|
1163
1166
|
\`/ponytail status\`. Change the default:
|
|
1164
|
-
|
|
1165
|
-
**Environment variable** (highest priority):
|
|
1166
|
-
\`\`\`bash
|
|
1167
|
-
export PONYTAIL_DEFAULT_MODE=ultra
|
|
1168
|
-
\`\`\`
|
|
1169
|
-
|
|
1170
|
-
**Config file** (\`~/.config/ponytail/config.json\`, Windows: \`%APPDATA%\\ponytail\\config.json\`):
|
|
1171
|
-
\`\`\`json
|
|
1172
|
-
{ "defaultMode": "lite" }
|
|
1173
|
-
\`\`\`
|
|
1174
|
-
|
|
1175
|
-
**Profile config** (per DSH profile, via the bundle row's \`config\` — e.g.
|
|
1176
|
-
\`tui\` → lite):
|
|
1177
|
-
|
|
1178
|
-
\`\`\`yaml
|
|
1179
|
-
- insert:
|
|
1180
|
-
- id: ponytail
|
|
1181
|
-
name: '@mengyuly/dsh-ponytail'
|
|
1182
|
-
config:
|
|
1183
|
-
defaultMode: lite
|
|
1184
|
-
\`\`\`
|
|
1185
|
-
|
|
1186
|
-
Set \`"off"\` to disable auto-activation on session start, activate manually
|
|
1187
|
-
with \`/ponytail\` when wanted. \`/ponytail default <mode>\` persists a new
|
|
1188
|
-
default
|
|
1189
|
-
profile \`defaultMode\` still outranks the saved value for new sessions.
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
"
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1167
|
+
|
|
1168
|
+
**Environment variable** (highest priority):
|
|
1169
|
+
\`\`\`bash
|
|
1170
|
+
export PONYTAIL_DEFAULT_MODE=ultra
|
|
1171
|
+
\`\`\`
|
|
1172
|
+
|
|
1173
|
+
**Config file** (\`~/.config/ponytail/config.json\`, Windows: \`%APPDATA%\\ponytail\\config.json\`):
|
|
1174
|
+
\`\`\`json
|
|
1175
|
+
{ "defaultMode": "lite" }
|
|
1176
|
+
\`\`\`
|
|
1177
|
+
|
|
1178
|
+
**Profile config** (per DSH profile, via the bundle row's \`config\` — e.g.
|
|
1179
|
+
\`tui\` → lite):
|
|
1180
|
+
|
|
1181
|
+
\`\`\`yaml
|
|
1182
|
+
- insert:
|
|
1183
|
+
- id: ponytail
|
|
1184
|
+
name: '@mengyuly/dsh-ponytail'
|
|
1185
|
+
config:
|
|
1186
|
+
defaultMode: lite
|
|
1187
|
+
\`\`\`
|
|
1188
|
+
|
|
1189
|
+
Set \`"off"\` to disable auto-activation on session start, activate manually
|
|
1190
|
+
with \`/ponytail\` when wanted. \`/ponytail default <mode>\` persists a new
|
|
1191
|
+
default through DSH Settings when available, otherwise the user config file; an exported \`PONYTAIL_DEFAULT_MODE\` or a
|
|
1192
|
+
profile \`defaultMode\` still outranks the saved value for new sessions.
|
|
1193
|
+
|
|
1194
|
+
Web Settings > Plugins > Ponytail: default mode, optional skills, panel reset.
|
|
1195
|
+
Existing session overrides stay intact. /ponytail help shows help without a model call.
|
|
1196
|
+
|
|
1197
|
+
Resolution: session override > env var > profile config > DSH settings > config file > \`full\`.
|
|
1198
|
+
|
|
1199
|
+
## More
|
|
1200
|
+
|
|
1201
|
+
Full docs + examples: https://github.com/DietrichGebert/ponytail
|
|
1202
|
+
`;
|
|
1203
|
+
const HELP_DESCRIPTION = "Quick-reference card for all ponytail modes, skills, and commands. One-shot display, not a persistent mode. Trigger: /ponytail-help, \"ponytail help\", \"what ponytail commands\", \"how do I use ponytail\".";
|
|
1204
|
+
/** Ordered set of runtime skills surfaced to the model catalog and `/` menu. */
|
|
1205
|
+
function ponytailSkills() {
|
|
1206
|
+
return [
|
|
1207
|
+
{
|
|
1208
|
+
name: "ponytail",
|
|
1209
|
+
source: "runtime",
|
|
1210
|
+
description: PONYTAIL_DESCRIPTION,
|
|
1211
|
+
whenToUse: "Use only when the user asks about Ponytail activation, modes, configuration, or help. Coding tasks already receive the active ruleset from the system prompt.",
|
|
1212
|
+
content: PONYTAIL_SKILL_BODY,
|
|
1213
|
+
invocation: {
|
|
1214
|
+
modelInvocable: false,
|
|
1215
|
+
userInvocable: true
|
|
1216
|
+
}
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
name: "ponytail-review",
|
|
1220
|
+
source: "runtime",
|
|
1221
|
+
description: REVIEW_DESCRIPTION,
|
|
1222
|
+
content: REVIEW_SKILL_BODY,
|
|
1223
|
+
invocation: {
|
|
1224
|
+
modelInvocable: true,
|
|
1225
|
+
userInvocable: true
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
{
|
|
1229
|
+
name: "ponytail-audit",
|
|
1230
|
+
source: "runtime",
|
|
1231
|
+
description: AUDIT_DESCRIPTION,
|
|
1232
|
+
content: AUDIT_SKILL_BODY,
|
|
1233
|
+
invocation: {
|
|
1234
|
+
modelInvocable: true,
|
|
1235
|
+
userInvocable: true
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
name: "ponytail-debt",
|
|
1240
|
+
source: "runtime",
|
|
1241
|
+
description: DEBT_DESCRIPTION,
|
|
1242
|
+
content: DEBT_SKILL_BODY,
|
|
1243
|
+
invocation: {
|
|
1244
|
+
modelInvocable: true,
|
|
1245
|
+
userInvocable: true
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
name: "ponytail-gain",
|
|
1250
|
+
source: "runtime",
|
|
1251
|
+
description: GAIN_DESCRIPTION,
|
|
1252
|
+
content: GAIN_SKILL_BODY,
|
|
1253
|
+
invocation: {
|
|
1254
|
+
modelInvocable: true,
|
|
1255
|
+
userInvocable: true
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
name: "ponytail-help",
|
|
1260
|
+
source: "runtime",
|
|
1261
|
+
description: HELP_DESCRIPTION,
|
|
1262
|
+
content: HELP_SKILL_BODY,
|
|
1263
|
+
invocation: {
|
|
1264
|
+
modelInvocable: true,
|
|
1265
|
+
userInvocable: true
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
];
|
|
1269
|
+
}
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region lib/types/modes.js
|
|
1272
|
+
/**
|
|
1273
|
+
* Ponytail mode resolution: the effective default comes from, in order, the
|
|
1274
|
+
* `PONYTAIL_DEFAULT_MODE` environment variable, the Cordis profile
|
|
1275
|
+
* `defaultMode`, the optional user config file
|
|
1276
|
+
* `~/.config/ponytail/config.json` (`defaultMode`), then `full`. Setting a
|
|
1277
|
+
* level via the `/ponytail` command is session-scoped and lives in an
|
|
1278
|
+
* in-memory, per-agent {@link ModeStore}.
|
|
1279
|
+
*
|
|
1280
|
+
* @module @mengyuly/dsh-ponytail
|
|
1281
|
+
*/
|
|
1282
|
+
const DEFAULT_MODE = "full";
|
|
1283
|
+
const RUNTIME_MODES = [
|
|
1284
|
+
"off",
|
|
1285
|
+
"lite",
|
|
1286
|
+
"full",
|
|
1287
|
+
"ultra"
|
|
1288
|
+
];
|
|
1289
|
+
/** Strip a UTF-8 BOM that Windows editors prepend before JSON.parse. */
|
|
1290
|
+
function stripBom(text) {
|
|
1291
|
+
return text.replace(/^\uFEFF/, "");
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Normalize free-form input to a runtime intensity. `null` for anything that
|
|
1295
|
+
* is not exactly `off`, `lite`, `full`, or `ultra`.
|
|
1296
|
+
*/
|
|
1297
|
+
function normalizeRuntimeMode(mode) {
|
|
1298
|
+
if (typeof mode !== "string") return null;
|
|
1299
|
+
const normalized = mode.trim().toLowerCase();
|
|
1300
|
+
return RUNTIME_MODES.includes(normalized) ? normalized : null;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1297
1303
|
* English and Chinese deactivation commands match only when the whole message is the command,
|
|
1298
|
-
* ignoring case and trailing punctuation. Matching the phrase anywhere would
|
|
1299
|
-
* turn ponytail off mid-task for ordinary requests like "add a normal mode
|
|
1300
|
-
* toggle".
|
|
1301
|
-
*/
|
|
1302
|
-
function isDeactivationCommand(text) {
|
|
1303
|
-
const normalized = (typeof text === "string" ? text : "").trim().toLowerCase().replace(/[\s.!?。?!]+$/, "");
|
|
1304
|
+
* ignoring case and trailing punctuation. Matching the phrase anywhere would
|
|
1305
|
+
* turn ponytail off mid-task for ordinary requests like "add a normal mode
|
|
1306
|
+
* toggle".
|
|
1307
|
+
*/
|
|
1308
|
+
function isDeactivationCommand(text) {
|
|
1309
|
+
const normalized = (typeof text === "string" ? text : "").trim().toLowerCase().replace(/[\s.!?。?!]+$/, "");
|
|
1304
1310
|
return normalized === "stop ponytail" || normalized === "normal mode" || normalized === "停止 ponytail" || normalized === "关闭 ponytail" || normalized === "普通模式" || normalized === "正常模式";
|
|
1305
|
-
}
|
|
1306
|
-
/** Config directory: `$XDG_CONFIG_HOME/ponytail`, `%APPDATA%\ponytail`, else `~/.config/ponytail`. */
|
|
1307
|
-
function configDir(env = process.env) {
|
|
1308
|
-
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "ponytail");
|
|
1309
|
-
if (process.platform === "win32") return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "ponytail");
|
|
1310
|
-
return join(homedir(), ".config", "ponytail");
|
|
1311
|
-
}
|
|
1312
|
-
/** Absolute path of the optional `config.json`. */
|
|
1313
|
-
function configPath(env = process.env) {
|
|
1314
|
-
return join(configDir(env), "config.json");
|
|
1315
|
-
}
|
|
1316
|
-
/**
|
|
1317
|
-
* Read the configured default with diagnostics. Priority:
|
|
1318
|
-
* `PONYTAIL_DEFAULT_MODE` → Cordis profile `defaultMode` → user config file →
|
|
1319
|
-
* `full`. A missing config file is normal and yields no issue; a broken one
|
|
1320
|
-
* yields the fallback mode plus one issue for the caller to warn about once.
|
|
1321
|
-
* @param env - the process environment to read.
|
|
1322
|
-
* @param profileMode - the validated Cordis profile `defaultMode`, or `null`
|
|
1323
|
-
* when the profile config is absent or invalid (invalid values are reported
|
|
1324
|
-
* by the caller; this function only consumes valid ones).
|
|
1325
|
-
*/
|
|
1326
|
-
function readDefaultModeInfo(env = process.env, profileMode = null) {
|
|
1327
|
-
const path = configPath(env);
|
|
1328
|
-
const envMode = normalizeRuntimeMode(env.PONYTAIL_DEFAULT_MODE);
|
|
1329
|
-
let configText;
|
|
1330
|
-
try {
|
|
1331
|
-
configText = readFileSync(path, "utf8");
|
|
1332
|
-
} catch (error) {
|
|
1333
|
-
if (error.code === "ENOENT") return { mode: envMode ?? profileMode ?? "full" };
|
|
1334
|
-
return {
|
|
1335
|
-
mode: envMode ?? profileMode ?? "full",
|
|
1336
|
-
issue: {
|
|
1337
|
-
kind: "read",
|
|
1338
|
-
detail: `${path}: ${error.message}`
|
|
1339
|
-
}
|
|
1340
|
-
};
|
|
1341
|
-
}
|
|
1342
|
-
let configIssue;
|
|
1343
|
-
let fromConfig = null;
|
|
1344
|
-
try {
|
|
1345
|
-
const parsed = JSON.parse(stripBom(configText));
|
|
1346
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) configIssue = {
|
|
1347
|
-
kind: "shape",
|
|
1348
|
-
detail: `${path}: root must be a JSON object`
|
|
1349
|
-
};
|
|
1350
|
-
else if ("defaultMode" in parsed) {
|
|
1351
|
-
fromConfig = normalizeRuntimeMode(parsed.defaultMode);
|
|
1352
|
-
if (!fromConfig) configIssue = {
|
|
1353
|
-
kind: "value",
|
|
1354
|
-
detail: `${path}: defaultMode is not lite|full|ultra|off`
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1357
|
-
} catch (error) {
|
|
1358
|
-
configIssue = {
|
|
1359
|
-
kind: "json",
|
|
1360
|
-
detail: `${path}: ${error.message}`
|
|
1361
|
-
};
|
|
1362
|
-
}
|
|
1363
|
-
if (envMode) return {
|
|
1364
|
-
mode: envMode,
|
|
1365
|
-
...configIssue ? { issue: configIssue } : {}
|
|
1366
|
-
};
|
|
1367
|
-
if (profileMode) return {
|
|
1368
|
-
mode: profileMode,
|
|
1369
|
-
...configIssue ? { issue: configIssue } : {}
|
|
1370
|
-
};
|
|
1371
|
-
if (configIssue) return {
|
|
1372
|
-
mode: DEFAULT_MODE,
|
|
1373
|
-
issue: configIssue
|
|
1374
|
-
};
|
|
1375
|
-
return { mode: fromConfig ?? "full" };
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* Read the configured default for this host: environment variable first, then
|
|
1379
|
-
* the Cordis profile `defaultMode`, then the user config file, then `full`.
|
|
1380
|
-
*/
|
|
1381
|
-
function readDefaultMode(env = process.env, profileMode = null) {
|
|
1382
|
-
return readDefaultModeInfo(env, profileMode).mode;
|
|
1383
|
-
}
|
|
1384
|
-
/**
|
|
1385
|
-
* Why a `saved` default is not the effective one — for the `/ponytail default`
|
|
1386
|
-
* result message. `null` means the saved value is effective.
|
|
1387
|
-
*/
|
|
1388
|
-
function defaultOverrideReason(env, profileMode) {
|
|
1389
|
-
if (normalizeRuntimeMode(env.PONYTAIL_DEFAULT_MODE)) return "PONYTAIL_DEFAULT_MODE";
|
|
1390
|
-
if (profileMode) return "profile configuration";
|
|
1391
|
-
return null;
|
|
1392
|
-
}
|
|
1393
|
-
/**
|
|
1394
|
-
* Persist a new default level to the config file, preserving other fields.
|
|
1395
|
-
* Returns the normalized mode, or `null` when the value is not a runtime mode.
|
|
1396
|
-
* Throws when the write itself fails, so callers never report success for a
|
|
1397
|
-
* file that was not written.
|
|
1398
|
-
*/
|
|
1399
|
-
function writeDefaultMode(mode, env = process.env) {
|
|
1400
|
-
const normalized = normalizeRuntimeMode(mode);
|
|
1401
|
-
if (!normalized) return null;
|
|
1402
|
-
const path = configPath(env);
|
|
1403
|
-
let config = {};
|
|
1404
|
-
try {
|
|
1405
|
-
const parsed = JSON.parse(stripBom(readFileSync(path, "utf8")));
|
|
1406
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) config = parsed;
|
|
1407
|
-
} catch {}
|
|
1408
|
-
config.defaultMode = normalized;
|
|
1409
|
-
const text = `${JSON.stringify(config, null, 2)}\n`;
|
|
1410
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1411
|
-
const temp = join(dirname(path), `.config-${process.pid}-${Date.now()}.tmp`);
|
|
1412
|
-
try {
|
|
1413
|
-
writeFileSync(temp, text, "utf8");
|
|
1414
|
-
renameSync(temp, path);
|
|
1415
|
-
} catch (error) {
|
|
1416
|
-
try {
|
|
1417
|
-
unlinkSync(temp);
|
|
1418
|
-
} catch {}
|
|
1419
|
-
throw new Error(`failed to write ${path}: ${error.message}`);
|
|
1420
|
-
}
|
|
1421
|
-
return normalized;
|
|
1422
|
-
}
|
|
1423
|
-
/**
|
|
1424
|
-
* Session-scoped live mode. The absence of an entry means "use the configured
|
|
1425
|
-
* default", which matches the upstream behavior where each session starts from
|
|
1426
|
-
* the default until the user switches it.
|
|
1427
|
-
*/
|
|
1428
|
-
var ModeStore = class {
|
|
1429
|
-
modes = /* @__PURE__ */ new Map();
|
|
1430
|
-
/** The mode in force for one agent, or the configured default. */
|
|
1311
|
+
}
|
|
1312
|
+
/** Config directory: `$XDG_CONFIG_HOME/ponytail`, `%APPDATA%\ponytail`, else `~/.config/ponytail`. */
|
|
1313
|
+
function configDir(env = process.env) {
|
|
1314
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "ponytail");
|
|
1315
|
+
if (process.platform === "win32") return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "ponytail");
|
|
1316
|
+
return join(homedir(), ".config", "ponytail");
|
|
1317
|
+
}
|
|
1318
|
+
/** Absolute path of the optional `config.json`. */
|
|
1319
|
+
function configPath(env = process.env) {
|
|
1320
|
+
return join(configDir(env), "config.json");
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Read the configured default with diagnostics. Priority:
|
|
1324
|
+
* `PONYTAIL_DEFAULT_MODE` → Cordis profile `defaultMode` → user config file →
|
|
1325
|
+
* `full`. A missing config file is normal and yields no issue; a broken one
|
|
1326
|
+
* yields the fallback mode plus one issue for the caller to warn about once.
|
|
1327
|
+
* @param env - the process environment to read.
|
|
1328
|
+
* @param profileMode - the validated Cordis profile `defaultMode`, or `null`
|
|
1329
|
+
* when the profile config is absent or invalid (invalid values are reported
|
|
1330
|
+
* by the caller; this function only consumes valid ones).
|
|
1331
|
+
*/
|
|
1332
|
+
function readDefaultModeInfo(env = process.env, profileMode = null) {
|
|
1333
|
+
const path = configPath(env);
|
|
1334
|
+
const envMode = normalizeRuntimeMode(env.PONYTAIL_DEFAULT_MODE);
|
|
1335
|
+
let configText;
|
|
1336
|
+
try {
|
|
1337
|
+
configText = readFileSync(path, "utf8");
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
if (error.code === "ENOENT") return { mode: envMode ?? profileMode ?? "full" };
|
|
1340
|
+
return {
|
|
1341
|
+
mode: envMode ?? profileMode ?? "full",
|
|
1342
|
+
issue: {
|
|
1343
|
+
kind: "read",
|
|
1344
|
+
detail: `${path}: ${error.message}`
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
let configIssue;
|
|
1349
|
+
let fromConfig = null;
|
|
1350
|
+
try {
|
|
1351
|
+
const parsed = JSON.parse(stripBom(configText));
|
|
1352
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) configIssue = {
|
|
1353
|
+
kind: "shape",
|
|
1354
|
+
detail: `${path}: root must be a JSON object`
|
|
1355
|
+
};
|
|
1356
|
+
else if ("defaultMode" in parsed) {
|
|
1357
|
+
fromConfig = normalizeRuntimeMode(parsed.defaultMode);
|
|
1358
|
+
if (!fromConfig) configIssue = {
|
|
1359
|
+
kind: "value",
|
|
1360
|
+
detail: `${path}: defaultMode is not lite|full|ultra|off`
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
configIssue = {
|
|
1365
|
+
kind: "json",
|
|
1366
|
+
detail: `${path}: ${error.message}`
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
if (envMode) return {
|
|
1370
|
+
mode: envMode,
|
|
1371
|
+
...configIssue ? { issue: configIssue } : {}
|
|
1372
|
+
};
|
|
1373
|
+
if (profileMode) return {
|
|
1374
|
+
mode: profileMode,
|
|
1375
|
+
...configIssue ? { issue: configIssue } : {}
|
|
1376
|
+
};
|
|
1377
|
+
if (configIssue) return {
|
|
1378
|
+
mode: DEFAULT_MODE,
|
|
1379
|
+
issue: configIssue
|
|
1380
|
+
};
|
|
1381
|
+
return { mode: fromConfig ?? "full" };
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Read the configured default for this host: environment variable first, then
|
|
1385
|
+
* the Cordis profile `defaultMode`, then the user config file, then `full`.
|
|
1386
|
+
*/
|
|
1387
|
+
function readDefaultMode(env = process.env, profileMode = null) {
|
|
1388
|
+
return readDefaultModeInfo(env, profileMode).mode;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Why a `saved` default is not the effective one — for the `/ponytail default`
|
|
1392
|
+
* result message. `null` means the saved value is effective.
|
|
1393
|
+
*/
|
|
1394
|
+
function defaultOverrideReason(env, profileMode) {
|
|
1395
|
+
if (normalizeRuntimeMode(env.PONYTAIL_DEFAULT_MODE)) return "PONYTAIL_DEFAULT_MODE";
|
|
1396
|
+
if (profileMode) return "profile configuration";
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Persist a new default level to the config file, preserving other fields.
|
|
1401
|
+
* Returns the normalized mode, or `null` when the value is not a runtime mode.
|
|
1402
|
+
* Throws when the write itself fails, so callers never report success for a
|
|
1403
|
+
* file that was not written.
|
|
1404
|
+
*/
|
|
1405
|
+
function writeDefaultMode(mode, env = process.env) {
|
|
1406
|
+
const normalized = normalizeRuntimeMode(mode);
|
|
1407
|
+
if (!normalized) return null;
|
|
1408
|
+
const path = configPath(env);
|
|
1409
|
+
let config = {};
|
|
1410
|
+
try {
|
|
1411
|
+
const parsed = JSON.parse(stripBom(readFileSync(path, "utf8")));
|
|
1412
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) config = parsed;
|
|
1413
|
+
} catch {}
|
|
1414
|
+
config.defaultMode = normalized;
|
|
1415
|
+
const text = `${JSON.stringify(config, null, 2)}\n`;
|
|
1416
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1417
|
+
const temp = join(dirname(path), `.config-${process.pid}-${Date.now()}.tmp`);
|
|
1418
|
+
try {
|
|
1419
|
+
writeFileSync(temp, text, "utf8");
|
|
1420
|
+
renameSync(temp, path);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
try {
|
|
1423
|
+
unlinkSync(temp);
|
|
1424
|
+
} catch {}
|
|
1425
|
+
throw new Error(`failed to write ${path}: ${error.message}`);
|
|
1426
|
+
}
|
|
1427
|
+
return normalized;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Session-scoped live mode. The absence of an entry means "use the configured
|
|
1431
|
+
* default", which matches the upstream behavior where each session starts from
|
|
1432
|
+
* the default until the user switches it.
|
|
1433
|
+
*/
|
|
1434
|
+
var ModeStore = class {
|
|
1435
|
+
modes = /* @__PURE__ */ new Map();
|
|
1436
|
+
/** The mode in force for one agent, or the configured default. */
|
|
1431
1437
|
modeFor(agentId, fallback) {
|
|
1432
1438
|
return this.modes.get(agentId) ?? fallback;
|
|
1433
1439
|
}
|
|
@@ -1436,506 +1442,544 @@ var ModeStore = class {
|
|
|
1436
1442
|
return this.modes.has(agentId);
|
|
1437
1443
|
}
|
|
1438
1444
|
/** Set the mode for one agent's session (session-scoped, survives until changed or disposal). */
|
|
1439
|
-
set(agentId, mode) {
|
|
1440
|
-
this.modes.set(agentId, mode);
|
|
1441
|
-
}
|
|
1442
|
-
/** Forget a session-scoped override so the next lookup returns the default. */
|
|
1443
|
-
clear(agentId) {
|
|
1444
|
-
this.modes.delete(agentId);
|
|
1445
|
-
}
|
|
1446
|
-
};
|
|
1447
|
-
/**
|
|
1448
|
-
* Compile `PONYTAIL_SUBAGENT_MATCHER` into a case-insensitive regex. An unset
|
|
1449
|
-
* matcher yields `null`; an invalid pattern stays fail-open (every agent gets
|
|
1450
|
-
* the ruleset) but is reported so the caller can warn exactly once.
|
|
1451
|
-
*/
|
|
1452
|
-
function compileSubagentMatcher(raw) {
|
|
1453
|
-
if (!raw) return {
|
|
1454
|
-
matcher: null,
|
|
1455
|
-
invalid: false
|
|
1456
|
-
};
|
|
1457
|
-
try {
|
|
1458
|
-
return {
|
|
1459
|
-
matcher: new RegExp(raw, "i"),
|
|
1460
|
-
invalid: false
|
|
1461
|
-
};
|
|
1462
|
-
} catch {
|
|
1463
|
-
return {
|
|
1464
|
-
matcher: null,
|
|
1465
|
-
invalid: true
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
/**
|
|
1470
|
-
* The stable per-session identity backing every mode override. DSH's `Agent`
|
|
1471
|
-
* type documents `id` as "the single identity shared with session", so the
|
|
1472
|
-
* agent id IS the SessionId: one entry per live session, never shared between
|
|
1473
|
-
* two sessions, and stable across the session's lifetime. Centralized so the
|
|
1474
|
-
* key choice lives in exactly one place.
|
|
1475
|
-
*/
|
|
1476
|
-
function sessionKey(agent) {
|
|
1477
|
-
return agent.id;
|
|
1478
|
-
}
|
|
1479
|
-
/** Whether a session is a subagent child (origin, or any delegation depth with no origin). */
|
|
1480
|
-
function isSubagentSession(header) {
|
|
1481
|
-
return header.origin === "subagent" || (header.delegationDepth ?? 0) > 0;
|
|
1482
|
-
}
|
|
1483
|
-
//#endregion
|
|
1484
|
-
//#region lib/types/instructions.js
|
|
1485
|
-
/**
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
*
|
|
1489
|
-
|
|
1490
|
-
* just in a table row.
|
|
1491
|
-
*
|
|
1492
|
-
* @module @mengyuly/dsh-ponytail
|
|
1493
|
-
*/
|
|
1494
|
-
/** Shared identity line, carried by every non-`off` mode. */
|
|
1495
|
-
const INTRO = "You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.";
|
|
1496
|
-
/**
|
|
1497
|
-
* Understanding-and-reuse baseline, identical in every non-`off` mode.
|
|
1498
|
-
*/
|
|
1445
|
+
set(agentId, mode) {
|
|
1446
|
+
this.modes.set(agentId, mode);
|
|
1447
|
+
}
|
|
1448
|
+
/** Forget a session-scoped override so the next lookup returns the default. */
|
|
1449
|
+
clear(agentId) {
|
|
1450
|
+
this.modes.delete(agentId);
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
/**
|
|
1454
|
+
* Compile `PONYTAIL_SUBAGENT_MATCHER` into a case-insensitive regex. An unset
|
|
1455
|
+
* matcher yields `null`; an invalid pattern stays fail-open (every agent gets
|
|
1456
|
+
* the ruleset) but is reported so the caller can warn exactly once.
|
|
1457
|
+
*/
|
|
1458
|
+
function compileSubagentMatcher(raw) {
|
|
1459
|
+
if (!raw) return {
|
|
1460
|
+
matcher: null,
|
|
1461
|
+
invalid: false
|
|
1462
|
+
};
|
|
1463
|
+
try {
|
|
1464
|
+
return {
|
|
1465
|
+
matcher: new RegExp(raw, "i"),
|
|
1466
|
+
invalid: false
|
|
1467
|
+
};
|
|
1468
|
+
} catch {
|
|
1469
|
+
return {
|
|
1470
|
+
matcher: null,
|
|
1471
|
+
invalid: true
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The stable per-session identity backing every mode override. DSH's `Agent`
|
|
1477
|
+
* type documents `id` as "the single identity shared with session", so the
|
|
1478
|
+
* agent id IS the SessionId: one entry per live session, never shared between
|
|
1479
|
+
* two sessions, and stable across the session's lifetime. Centralized so the
|
|
1480
|
+
* key choice lives in exactly one place.
|
|
1481
|
+
*/
|
|
1482
|
+
function sessionKey(agent) {
|
|
1483
|
+
return agent.id;
|
|
1484
|
+
}
|
|
1485
|
+
/** Whether a session is a subagent child (origin, or any delegation depth with no origin). */
|
|
1486
|
+
function isSubagentSession(header) {
|
|
1487
|
+
return header.origin === "subagent" || (header.delegationDepth ?? 0) > 0;
|
|
1488
|
+
}
|
|
1489
|
+
//#endregion
|
|
1490
|
+
//#region lib/types/instructions.js
|
|
1491
|
+
/** Shared identity line, carried by every non-`off` mode. */
|
|
1492
|
+
const INTRO = 'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.';
|
|
1493
|
+
/**
|
|
1494
|
+
* Understanding-and-reuse baseline, identical in every non-`off` mode.
|
|
1495
|
+
*/
|
|
1499
1496
|
const COMMON_RULES = [
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
].join(
|
|
1532
|
-
/**
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
const
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1497
|
+
'ACTIVE EVERY RESPONSE until the user changes mode or turns Ponytail off; uncertainty is not deactivation. Apply to coding tasks only, not general knowledge, prose, translation, or summaries.',
|
|
1498
|
+
'Before editing, define a concrete, observable done condition; preserve explicit acceptance criteria.',
|
|
1499
|
+
'Read the task and every touched file; trace the real flow end to end before choosing a rung. The ladder is a reflex, not a research project: investigate only what can change the solution, then stop.',
|
|
1500
|
+
'Resolve uncertainty with evidence from code, tools, or authoritative docs; never invent facts or checks.',
|
|
1501
|
+
'Reuse existing code, native features, or installed dependencies before custom code.',
|
|
1502
|
+
'Never add a dependency when a few lines suffice; deletion beats addition. No single-implementation interfaces, single-product factories, or configuration for fixed values unless requested.',
|
|
1503
|
+
'Before a bug fix, grep every caller of the function being changed; fix the shared root cause, not just the reported path.',
|
|
1504
|
+
'Between same-size stdlib options, choose the one correct on edge cases. Prefer boring over clever; no unrequested abstractions or scaffolding for later.',
|
|
1505
|
+
'Mark deliberate shortcuts with a known ceiling using a comment: ponytail: <ceiling>, <upgrade path>. Do not label every simplification as debt.',
|
|
1506
|
+
'Choose the smallest complete change compatible with existing contracts, touching the fewest files possible after understanding the problem; never trade a correct shared fix for a smaller local diff.',
|
|
1507
|
+
'If the user insists on the full version, implement it without re-arguing.',
|
|
1508
|
+
'Loop: inspect, change, run the narrowest relevant check, inspect the final diff. On failure, fix the cause. Do not weaken a test to pass.',
|
|
1509
|
+
'Report only verified results, checks run, and uncertainty. Code first; keep unrequested commentary to at most three short lines: what was skipped, when to add it. Give explicitly requested explanations in full; Ponytail governs what you build, not how you talk.',
|
|
1510
|
+
].join('\n');
|
|
1511
|
+
/**
|
|
1512
|
+
* The never-cut list. Every non-`off` mode keeps these; intensities tune how
|
|
1513
|
+
* aggressively code is minimized, never what may be dropped.
|
|
1514
|
+
*/
|
|
1515
|
+
const SAFETY_BOUNDARIES = [
|
|
1516
|
+
'Never cut, in any mode:',
|
|
1517
|
+
'- Input validation at trust boundaries.',
|
|
1518
|
+
'- Error handling that prevents data loss.',
|
|
1519
|
+
'- Security measures.',
|
|
1520
|
+
'- Accessibility basics.',
|
|
1521
|
+
'- Hardware calibration: preserve tuning for real clocks, sensors, and physical devices.',
|
|
1522
|
+
'- Explicit acceptance criteria the user asked for.',
|
|
1523
|
+
'- Understanding the problem and tracing the real flow first.',
|
|
1524
|
+
'- The real end-to-end data flow: no UI-only field, unused state, placeholder path, or disconnected payload.',
|
|
1525
|
+
'- Non-trivial logic (branches, loops, parsers, money/security paths) leaves one minimal runnable check: the smallest assert or test that catches breakage; no framework or fixtures, no per-function suites unless asked. Trivial one-liners need no extra test.',
|
|
1526
|
+
'- Root-cause fixes over symptom patches.',
|
|
1527
|
+
'- "Minimal diff" is not a substitute for "correct fix".',
|
|
1528
|
+
].join('\n');
|
|
1529
|
+
/** Lite: complete the explicit ask; reuse; suggest, do not challenge. */
|
|
1530
|
+
const LITE_RULES = [
|
|
1531
|
+
'Execute the direct request without ceremony; complete every explicit acceptance criterion.',
|
|
1532
|
+
'You must name a simpler alternative in one line; the user chooses. The ladder guides suggestions, not an override of the requested scope. If none works, say so rather than inventing one.',
|
|
1533
|
+
'Do not change the existing architecture merely to reduce line count.',
|
|
1534
|
+
'Keep the smallest reasonable validation for non-trivial changes.',
|
|
1535
|
+
].join('\n');
|
|
1536
|
+
/** Smallest complete end-to-end change: shared by Full and Ultra. */
|
|
1537
|
+
const E2E_RULES = [
|
|
1538
|
+
'Smallest complete end-to-end change:',
|
|
1539
|
+
'- Prefer the smallest complete end-to-end change compatible with the existing architecture, not merely the fewest lines in one file.',
|
|
1540
|
+
'- Before creating a component, abstraction, protocol, migration, transport format, storage format, or dependency, inspect the repository\u2019s existing path and preserve its current contract.',
|
|
1541
|
+
'- Do not redesign transport, storage, API shape, or persistence when the task only asks for a local UI or behavior change.',
|
|
1542
|
+
'- Prefer the smallest complete change across the real data flow: input \u2192 state \u2192 validation \u2192 payload \u2192 API \u2192 persistence \u2192 response/UI.',
|
|
1543
|
+
'- It does not mean every layer must change: the change must be complete across the layers it touches.',
|
|
1544
|
+
'- Do not leave a UI-only field, unused state, placeholder path, or disconnected payload merely because it produces a smaller diff.'
|
|
1545
|
+
].join('\n');
|
|
1546
|
+
/** The full ladder is retained in all active modes; intensity controls enforcement. */
|
|
1547
|
+
const LADDER_RULES = [
|
|
1548
|
+
'Seven-rung ladder (after comprehension):',
|
|
1549
|
+
'1. Does this need to exist at all? Skip speculative needs and explain in one line. (YAGNI)',
|
|
1550
|
+
'2. Does it already exist in this codebase? Reuse it.',
|
|
1551
|
+
'3. Does the standard library do it? Use it.',
|
|
1552
|
+
'4. Does a native platform feature cover it? Use it.',
|
|
1553
|
+
'5. Does an already-installed dependency solve it? Use it.',
|
|
1554
|
+
'6. Can one line correctly solve it? Use one line.',
|
|
1555
|
+
'7. Only then: write the minimum new implementation.',
|
|
1556
|
+
].join('\n');
|
|
1557
|
+
/** Full and Ultra enforce the ladder without blocking on defaultable questions. */
|
|
1558
|
+
const ENFORCED_LADDER_RULES = [
|
|
1559
|
+
'Use the seven-rung ladder; stop at the first rung that holds. If two rungs work, choose the earlier one and move on.',
|
|
1560
|
+
'For complex requests, deliver the simplest correct version within explicit requirements and question unnecessary complexity in the same response; state when to expand. Never stall on a defaultable answer.',
|
|
1561
|
+
].join('\n');
|
|
1562
|
+
/** Full: the seven-rung ladder plus the smallest complete end-to-end change. */
|
|
1563
|
+
const FULL_RULES = [
|
|
1564
|
+
ENFORCED_LADDER_RULES,
|
|
1565
|
+
'Default to the shortest correct implementation; prefer deletion and reuse. Fix root causes, not symptoms (one shared guard beats one per caller).',
|
|
1566
|
+
E2E_RULES,
|
|
1567
|
+
].join('\n');
|
|
1568
|
+
/** Ultra: deletion-first YAGNI; challenge speculation, never requirements. */
|
|
1569
|
+
const ULTRA_RULES = [
|
|
1570
|
+
ENFORCED_LADDER_RULES,
|
|
1571
|
+
'Require evidence before adding. Prefer deletion or reuse; challenge speculative features, caches, abstractions, configuration, migrations, and dependencies.',
|
|
1572
|
+
'Try a correct one-liner first; challenge the remaining complexity in the same response, without delaying delivery.',
|
|
1573
|
+
'Ultra is not refusal: explicit requirements and the safety boundaries remain mandatory.',
|
|
1574
|
+
E2E_RULES,
|
|
1575
|
+
].join('\n');
|
|
1576
|
+
const MODE_RULES = {
|
|
1577
|
+
lite: LITE_RULES,
|
|
1578
|
+
full: FULL_RULES,
|
|
1579
|
+
ultra: ULTRA_RULES,
|
|
1580
|
+
};
|
|
1581
|
+
const MODE_LABELS = {
|
|
1582
|
+
lite: 'Lite',
|
|
1583
|
+
full: 'Full',
|
|
1584
|
+
ultra: 'Ultra',
|
|
1585
|
+
};
|
|
1586
|
+
/** Compose the complete section text for one intensity. */
|
|
1587
|
+
function render(effective) {
|
|
1588
|
+
return [
|
|
1589
|
+
`PONYTAIL MODE ACTIVE — level: ${effective}`,
|
|
1590
|
+
'',
|
|
1591
|
+
INTRO,
|
|
1592
|
+
'',
|
|
1593
|
+
'## Common rules (all modes)',
|
|
1594
|
+
COMMON_RULES,
|
|
1595
|
+
'',
|
|
1596
|
+
'## Decision ladder (intensity below determines enforcement)',
|
|
1597
|
+
LADDER_RULES,
|
|
1598
|
+
'',
|
|
1599
|
+
'## Safety boundaries (never cut)',
|
|
1600
|
+
SAFETY_BOUNDARIES,
|
|
1601
|
+
'',
|
|
1602
|
+
`## ${MODE_LABELS[effective]} rules`,
|
|
1603
|
+
MODE_RULES[effective],
|
|
1604
|
+
].join('\n');
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* The injected ruleset for one intensity, composed from the structured
|
|
1608
|
+
* fragments above. Returns an empty string for `off` (ponytail contributes
|
|
1609
|
+
* nothing). Renders are pure per mode and cached so every turn's bytes stay
|
|
1610
|
+
* identical.
|
|
1611
|
+
*/
|
|
1612
|
+
function getPonytailInstructions(mode) {
|
|
1613
|
+
const effective = normalizeRuntimeMode(mode) ?? DEFAULT_MODE;
|
|
1614
|
+
if (effective === 'off')
|
|
1615
|
+
return '';
|
|
1616
|
+
const cached = instructionCache.get(effective);
|
|
1617
|
+
if (cached !== undefined)
|
|
1618
|
+
return cached;
|
|
1619
|
+
const rendered = render(effective);
|
|
1620
|
+
instructionCache.set(effective, rendered);
|
|
1621
|
+
return rendered;
|
|
1622
|
+
}
|
|
1623
|
+
/** Rendered rulesets are pure per mode; cache to keep every turn's bytes identical. */
|
|
1624
|
+
const instructionCache = new Map();
|
|
1625
|
+
//#endregion
|
|
1626
|
+
// Downstream entry compiled from src/index.ts.
|
|
1627
|
+
export const name = 'ponytail';
|
|
1628
|
+
export const inject = ['systemPrompt', 'skills'];
|
|
1629
|
+
/** Prompt-section order: after the deployment persona (0), before tool guidance (100–199). */
|
|
1630
|
+
const SECTION_ORDER = 40;
|
|
1631
|
+
/** Build the one text-line notification a mode switch leaves for the model. */
|
|
1632
|
+
function modeNotice(mode) {
|
|
1633
|
+
return mode === 'off' ? 'PONYTAIL MODE OFF' : `PONYTAIL MODE CHANGED — level: ${mode}`;
|
|
1634
|
+
}
|
|
1635
|
+
/** Extract the plain text of one user message (only its text blocks). */
|
|
1636
|
+
export function messageText(message) {
|
|
1637
|
+
const parts = [];
|
|
1638
|
+
for (const block of message.content) {
|
|
1639
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
1640
|
+
parts.push(block.text);
|
|
1641
|
+
}
|
|
1642
|
+
return parts.join('\n');
|
|
1643
|
+
}
|
|
1644
|
+
/** Whether any message in a claimed batch is exactly a deactivation command. */
|
|
1645
|
+
export function containsDeactivation(messages) {
|
|
1646
|
+
return messages.some(message => isDeactivationCommand(messageText(message)));
|
|
1647
|
+
}
|
|
1648
|
+
const OPTIONAL_SKILLS = ['ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help'];
|
|
1649
|
+
const PreferencesSchema = z.object({
|
|
1650
|
+
defaultMode: z.union(['inherit', 'off', 'lite', 'full', 'ultra']).default('inherit'),
|
|
1651
|
+
disabledSkills: z.array(z.union(OPTIONAL_SKILLS)).default([]),
|
|
1652
|
+
});
|
|
1653
|
+
const COMMAND_HELP = 'Ponytail: /ponytail lite|full|ultra|off switches this session; /ponytail status shows the mode and its source; /ponytail reset follows defaults; /ponytail default <mode> saves a default. Web: Settings → Plugins → Ponytail. Additional skills: /ponytail-review, /ponytail-audit, /ponytail-debt, /ponytail-gain, /ponytail-help.';
|
|
1654
|
+
/** Mode visible to one agent: its session override, else the configured default. */
|
|
1655
|
+
function modeFor(deps, agent) {
|
|
1656
|
+
return deps.store.modeFor(sessionKey(agent), deps.defaultMode());
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Queue one skill's full `<skill_content>` rendering as the model's next
|
|
1660
|
+
* ordinary turn, with the same user-explicit `skill-invocation` source the
|
|
1661
|
+
* built-in gesture boundary uses.
|
|
1662
|
+
*/
|
|
1663
|
+
async function queueSkill(deps, invocation, skill) {
|
|
1664
|
+
const loaded = await deps.ctx.skills.get(skill, {
|
|
1665
|
+
cwd: invocation.agent.session.header.cwd,
|
|
1666
|
+
signal: invocation.signal,
|
|
1667
|
+
});
|
|
1668
|
+
if (loaded === undefined) {
|
|
1669
|
+
return { kind: 'error', text: `skill "${skill}" is not available` };
|
|
1670
|
+
}
|
|
1671
|
+
const notes = invocation.rawInput.trim();
|
|
1672
|
+
const text = renderSkillContent(loaded) + (notes === '' ? '' : `\n\n${notes}`);
|
|
1673
|
+
invocation.agent.followup(createUserMessage({
|
|
1674
|
+
content: [{ type: 'text', text }],
|
|
1675
|
+
source: { kind: 'skill-invocation', name: skill, form: 'instructions' },
|
|
1676
|
+
}));
|
|
1677
|
+
return { kind: 'success', text: `Queued ${skill} for the agent.` };
|
|
1678
|
+
}
|
|
1679
|
+
function registerCommands(deps, commandCtx) {
|
|
1680
|
+
// State notices join the next step without waking an idle agent. Only
|
|
1681
|
+
// explicit skill invocations above request an ordinary model turn.
|
|
1682
|
+
commandCtx.commands.register({
|
|
1683
|
+
name: 'ponytail',
|
|
1684
|
+
description: 'Set or show Ponytail lazy senior dev intensity',
|
|
1685
|
+
input: { hint: '[help|status|reset|default <mode>|lite|full|ultra|off]' },
|
|
1686
|
+
handler: ({ agent, rawInput }) => {
|
|
1687
|
+
const input = rawInput.trim().toLowerCase();
|
|
1688
|
+
const [head, ...rest] = input.split(/\s+/).filter(Boolean);
|
|
1689
|
+
const partsHead = head ?? '';
|
|
1690
|
+
if (input === 'help')
|
|
1691
|
+
return { kind: 'success', text: COMMAND_HELP };
|
|
1692
|
+
// `/ponytail default <mode>` persists the default for future sessions.
|
|
1693
|
+
// The env var (and the profile config) still outrank the saved value, so
|
|
1694
|
+
// the effective default is recomputed after the write instead of
|
|
1695
|
+
// trusting the saved one.
|
|
1696
|
+
if (partsHead === 'default') {
|
|
1697
|
+
const settings = deps.settings();
|
|
1698
|
+
if (settings) {
|
|
1699
|
+
const target = normalizeRuntimeMode(rest[0]);
|
|
1700
|
+
if (!target || rest.length !== 1)
|
|
1701
|
+
return { kind: 'error', text: 'Usage: /ponytail default [lite|full|ultra|off]' };
|
|
1702
|
+
return settings.update({ defaultMode: target }).then(() => {
|
|
1703
|
+
const effective = deps.defaultMode();
|
|
1704
|
+
const reason = defaultOverrideReason(process.env, deps.profileMode);
|
|
1705
|
+
const notice = reason
|
|
1706
|
+
? `PONYTAIL DEFAULT SET — saved ${target}, effective ${effective} (${reason}).`
|
|
1707
|
+
: `PONYTAIL DEFAULT SET — new sessions start in ${target}.`;
|
|
1708
|
+
agent.inject(createUserMessage({ content: [{ type: 'text', text: notice }], source: { kind: 'plugin', plugin: name } }));
|
|
1709
|
+
return { kind: 'success', text: reason
|
|
1710
|
+
? `Saved default: ${target}. Effective default: ${effective}, overridden by ${reason}.`
|
|
1711
|
+
: `Ponytail default set — new sessions start in ${target}.` };
|
|
1712
|
+
}).catch(error => ({ kind: 'error', text: `Failed to save default: ${error.message}` }));
|
|
1713
|
+
}
|
|
1714
|
+
let written;
|
|
1715
|
+
try {
|
|
1716
|
+
written = writeDefaultMode(rest[0]);
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
return { kind: 'error', text: `Failed to save default: ${error.message}` };
|
|
1720
|
+
}
|
|
1721
|
+
if (!written) {
|
|
1722
|
+
return { kind: 'error', text: 'Usage: /ponytail default [lite|full|ultra|off]' };
|
|
1723
|
+
}
|
|
1724
|
+
const effective = readDefaultMode(process.env, deps.profileMode);
|
|
1725
|
+
deps.setDefault(effective);
|
|
1726
|
+
// Name the overriding source whenever one exists — even when the saved
|
|
1727
|
+
// value happens to coincide with it (env=full + `/ponytail default full`
|
|
1728
|
+
// must still tell the user the env var is what new sessions actually
|
|
1729
|
+
// follow, so a later env change does not surprise them).
|
|
1730
|
+
const reason = defaultOverrideReason(process.env, deps.profileMode);
|
|
1731
|
+
if (reason !== null) {
|
|
1732
|
+
agent.inject(createUserMessage({
|
|
1733
|
+
content: [{ type: 'text', text: `PONYTAIL DEFAULT SET — saved ${written}, effective ${effective} (${reason}).` }],
|
|
1734
|
+
source: { kind: 'plugin', plugin: name },
|
|
1735
|
+
}));
|
|
1736
|
+
return { kind: 'success', text: `Saved default: ${written}. Effective default: ${effective}, overridden by ${reason}.` };
|
|
1737
|
+
}
|
|
1738
|
+
agent.inject(createUserMessage({
|
|
1739
|
+
content: [{ type: 'text', text: `PONYTAIL DEFAULT SET — new sessions start in ${written}.` }],
|
|
1740
|
+
source: { kind: 'plugin', plugin: name },
|
|
1741
|
+
}));
|
|
1742
|
+
return { kind: 'success', text: `Ponytail default set — new sessions start in ${written}.` };
|
|
1743
|
+
}
|
|
1744
|
+
// `/ponytail status` is a pure query: report, never modify.
|
|
1745
|
+
if (input === 'status') {
|
|
1746
|
+
const current = modeFor(deps, agent);
|
|
1747
|
+
const source = deps.store.has(sessionKey(agent)) ? 'session override' : 'configured default';
|
|
1748
|
+
const disabled = deps.settings()?.get().disabledSkills ?? [];
|
|
1749
|
+
return { kind: 'success', text: `Ponytail mode: ${current} (${source}). Default: ${deps.defaultMode()} (${deps.defaultSource()}). Disabled skills: ${disabled.join(', ') || 'none'}. Use /ponytail help|reset|lite|full|ultra|off.` };
|
|
1750
|
+
}
|
|
1751
|
+
// `/ponytail reset` removes the session override and follows the
|
|
1752
|
+
// effective configured default again.
|
|
1753
|
+
if (input === 'reset') {
|
|
1754
|
+
const key = sessionKey(agent);
|
|
1755
|
+
const changed = deps.store.has(key);
|
|
1756
|
+
deps.store.clear(key);
|
|
1757
|
+
const current = deps.defaultMode();
|
|
1758
|
+
if (changed) {
|
|
1759
|
+
agent.inject(createUserMessage({
|
|
1760
|
+
content: [{ type: 'text', text: modeNotice(current) }],
|
|
1761
|
+
source: { kind: 'plugin', plugin: name },
|
|
1762
|
+
}));
|
|
1763
|
+
}
|
|
1764
|
+
return {
|
|
1765
|
+
kind: 'success',
|
|
1766
|
+
text: changed
|
|
1767
|
+
? `Ponytail session override cleared. Effective mode: ${current}.`
|
|
1768
|
+
: `Ponytail already follows the configured default: ${current}.`,
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
// Bare `/ponytail` reports the mode in force; when the session is off it
|
|
1772
|
+
// re-enables to the effective default (or `full` when that is off too).
|
|
1773
|
+
if (input === '') {
|
|
1774
|
+
const current = modeFor(deps, agent);
|
|
1775
|
+
const effectiveDefault = deps.defaultMode();
|
|
1776
|
+
if (current === 'off') {
|
|
1777
|
+
if (effectiveDefault === 'off') {
|
|
1778
|
+
deps.store.set(sessionKey(agent), 'full');
|
|
1779
|
+
agent.inject(createUserMessage({
|
|
1780
|
+
content: [{ type: 'text', text: 'PONYTAIL MODE CHANGED — level: full' }],
|
|
1781
|
+
source: { kind: 'plugin', plugin: name },
|
|
1782
|
+
}));
|
|
1783
|
+
return { kind: 'success', text: 'Ponytail re-enabled at full (the effective default is off).' };
|
|
1784
|
+
}
|
|
1785
|
+
deps.store.clear(sessionKey(agent));
|
|
1786
|
+
agent.inject(createUserMessage({
|
|
1787
|
+
content: [{ type: 'text', text: `PONYTAIL MODE ACTIVE — level: ${effectiveDefault}` }],
|
|
1788
|
+
source: { kind: 'plugin', plugin: name },
|
|
1789
|
+
}));
|
|
1790
|
+
return { kind: 'success', text: `Ponytail re-enabled. Effective default: ${effectiveDefault}.` };
|
|
1791
|
+
}
|
|
1792
|
+
agent.inject(createUserMessage({
|
|
1793
|
+
content: [{ type: 'text', text: `PONYTAIL MODE ACTIVE — level: ${current}` }],
|
|
1794
|
+
source: { kind: 'plugin', plugin: name },
|
|
1795
|
+
}));
|
|
1796
|
+
return { kind: 'success', text: `Ponytail mode: ${current}. Use /ponytail reset|lite|full|ultra|off.` };
|
|
1797
|
+
}
|
|
1798
|
+
const mode = normalizeRuntimeMode(input);
|
|
1799
|
+
if (!mode) {
|
|
1800
|
+
return { kind: 'error', text: 'Usage: /ponytail [status|reset|default <mode>|lite|full|ultra|off]' };
|
|
1801
|
+
}
|
|
1802
|
+
deps.store.set(sessionKey(agent), mode);
|
|
1803
|
+
agent.inject(createUserMessage({
|
|
1804
|
+
content: [{ type: 'text', text: modeNotice(mode) }],
|
|
1805
|
+
source: { kind: 'plugin', plugin: name },
|
|
1806
|
+
}));
|
|
1807
|
+
return { kind: 'success', text: mode === 'off' ? 'Ponytail mode off.' : `Ponytail mode set to ${mode}.` };
|
|
1808
|
+
},
|
|
1809
|
+
});
|
|
1810
|
+
for (const skill of ['ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help']) {
|
|
1811
|
+
commandCtx.commands.register({
|
|
1812
|
+
name: skill,
|
|
1813
|
+
description: descriptionFor(skill),
|
|
1814
|
+
input: { hint: '[notes]' },
|
|
1815
|
+
handler: invocation => queueSkill(deps, invocation, skill),
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
/** One-line command catalog copy, kept beside the skills for discovery parity. */
|
|
1820
|
+
function descriptionFor(skill) {
|
|
1821
|
+
switch (skill) {
|
|
1822
|
+
case 'ponytail-review': return 'Over-engineering review of the current changes';
|
|
1823
|
+
case 'ponytail-audit': return 'Whole-repo over-engineering audit (what can be deleted)';
|
|
1824
|
+
case 'ponytail-debt': return 'Harvest ponytail: comments into a tracked debt ledger';
|
|
1825
|
+
case 'ponytail-gain': return 'Show ponytail measured-impact scoreboard (less code, cost, time)';
|
|
1826
|
+
case 'ponytail-help': return 'Quick reference for ponytail levels, skills, and commands';
|
|
1827
|
+
default: return `Run the ${skill} skill`;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Register the always-on ruleset section, the runtime skills, the slash
|
|
1832
|
+
* commands, and the plain-text deactivation listener.
|
|
1833
|
+
*/
|
|
1834
|
+
export function apply(ctx, config = {}) {
|
|
1835
|
+
let preferences;
|
|
1836
|
+
// Cordis profile-level default (set per profile via the bundle row's
|
|
1837
|
+
// `config`). Read once at mount: cordis exposes no stable public config-
|
|
1838
|
+
// change event (only the internal update waterfall), so profile changes
|
|
1839
|
+
// apply on the next profile start — the user config file keeps its
|
|
1840
|
+
// hot-reload behavior.
|
|
1841
|
+
const profileMode = normalizeRuntimeMode(config.defaultMode);
|
|
1842
|
+
if (config.defaultMode !== undefined && profileMode === null) {
|
|
1843
|
+
ctx.logger.warn(`[ponytail] profile config defaultMode is not lite|full|ultra|off: ${JSON.stringify(config.defaultMode)}; falling back`);
|
|
1844
|
+
}
|
|
1845
|
+
// Process-level effective default, resolved lazily so a broken config warns
|
|
1846
|
+
// once (not per request); `/ponytail default` and the watcher refresh it.
|
|
1847
|
+
let defaultMode = null;
|
|
1848
|
+
let legacyDefaultSource = 'built-in fallback';
|
|
1849
|
+
const updateLegacySource = () => {
|
|
1850
|
+
try {
|
|
1851
|
+
const document = JSON.parse(readFileSync(configPath(), 'utf8').replace(/^\uFEFF/, ''));
|
|
1852
|
+
legacyDefaultSource = normalizeRuntimeMode(document?.defaultMode) ? 'user config.json' : 'built-in fallback';
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
legacyDefaultSource = 'built-in fallback';
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
const warned = new Set();
|
|
1859
|
+
const warnOnce = (key, message) => {
|
|
1860
|
+
if (warned.has(key))
|
|
1861
|
+
return;
|
|
1862
|
+
warned.add(key);
|
|
1863
|
+
ctx.logger.warn(`[ponytail] ${message}`);
|
|
1864
|
+
};
|
|
1865
|
+
const refreshDefault = () => {
|
|
1866
|
+
const resolution = readDefaultModeInfo(process.env, profileMode);
|
|
1867
|
+
if (resolution.issue) {
|
|
1868
|
+
warnOnce(`default:${resolution.issue.kind}`, `${resolution.issue.detail}; using ${resolution.mode}`);
|
|
1869
|
+
}
|
|
1870
|
+
defaultMode = resolution.mode;
|
|
1871
|
+
updateLegacySource();
|
|
1872
|
+
return defaultMode;
|
|
1873
|
+
};
|
|
1874
|
+
const readDefault = () => normalizeRuntimeMode(process.env.PONYTAIL_DEFAULT_MODE) ?? profileMode
|
|
1875
|
+
?? normalizeRuntimeMode(preferences?.get().defaultMode) ?? defaultMode ?? refreshDefault();
|
|
1876
|
+
const defaultSource = () => {
|
|
1877
|
+
const priority = defaultOverrideReason(process.env, profileMode);
|
|
1878
|
+
if (priority)
|
|
1879
|
+
return priority;
|
|
1880
|
+
if (normalizeRuntimeMode(preferences?.get().defaultMode))
|
|
1881
|
+
return 'DSH settings';
|
|
1882
|
+
if (defaultMode === null)
|
|
1883
|
+
refreshDefault();
|
|
1884
|
+
return legacyDefaultSource;
|
|
1885
|
+
};
|
|
1886
|
+
const setDefault = (mode) => { defaultMode = mode; updateLegacySource(); };
|
|
1887
|
+
const store = new ModeStore();
|
|
1888
|
+
const matcherResult = compileSubagentMatcher(process.env.PONYTAIL_SUBAGENT_MATCHER);
|
|
1889
|
+
const matcher = matcherResult.matcher;
|
|
1890
|
+
if (matcherResult.invalid) {
|
|
1891
|
+
warnOnce('matcher:invalid', 'PONYTAIL_SUBAGENT_MATCHER is not a valid regular expression; ignoring it (fail-open).');
|
|
1892
|
+
}
|
|
1893
|
+
// Hot-reload the config-file default: edits apply to sessions without an
|
|
1894
|
+
// override on their next request, no restart. A transiently invalid file
|
|
1895
|
+
// keeps the last known good default instead of snapping back to `full`.
|
|
1896
|
+
// Env-var changes still require a restart, since the environment is fixed
|
|
1897
|
+
// once the process starts.
|
|
1898
|
+
const configFile = configPath();
|
|
1899
|
+
const onConfigChange = () => {
|
|
1900
|
+
const resolution = readDefaultModeInfo(process.env, profileMode);
|
|
1901
|
+
if (resolution.issue) {
|
|
1902
|
+
warnOnce(`config:${resolution.issue.kind}`, `${resolution.issue.detail}; keeping the previous default`);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
defaultMode = resolution.mode;
|
|
1906
|
+
updateLegacySource();
|
|
1907
|
+
};
|
|
1908
|
+
watchFile(configFile, { interval: 1000 }, onConfigChange).unref();
|
|
1909
|
+
ctx.effect(() => () => { unwatchFile(configFile, onConfigChange); }, 'ponytail: config hot reload');
|
|
1910
|
+
// Session-scoped overrides live only as long as their session: an agent's
|
|
1911
|
+
// disposal releases its entry so a long-running host never accumulates
|
|
1912
|
+
// stale keys.
|
|
1913
|
+
ctx.on('agent/disposed', ({ agent }) => { store.clear(sessionKey(agent)); });
|
|
1914
|
+
// Always-on ruleset for the session's own agent. The built-in spawn subagent
|
|
1915
|
+
// tool runs fresh, isolated children that do not carry the persona; the
|
|
1916
|
+
// optional matcher suppresses the section for subagent sessions (those the
|
|
1917
|
+
// harness does surface it to) whose preset does not match.
|
|
1918
|
+
ctx.systemPrompt.section({
|
|
1919
|
+
name: 'ponytail',
|
|
1920
|
+
order: SECTION_ORDER,
|
|
1921
|
+
text: ({ agent }) => {
|
|
1922
|
+
if (agent && matcher && isSubagentSession(agent.session.header)) {
|
|
1923
|
+
const preset = agent.session.header.agentPreset;
|
|
1924
|
+
if (preset && !matcher.test(preset))
|
|
1925
|
+
return '';
|
|
1926
|
+
}
|
|
1927
|
+
const mode = agent ? store.modeFor(sessionKey(agent), readDefault()) : readDefault();
|
|
1928
|
+
return getPonytailInstructions(mode);
|
|
1929
|
+
},
|
|
1930
|
+
});
|
|
1931
|
+
// Re-register only affected one-shot skills; disabling them never removes
|
|
1932
|
+
// the always-on ruleset or the persona pointer.
|
|
1933
|
+
const registered = new Map();
|
|
1934
|
+
const refreshSkills = () => {
|
|
1935
|
+
const disabled = new Set(preferences?.get().disabledSkills ?? []);
|
|
1936
|
+
for (const skill of ponytailSkills()) {
|
|
1937
|
+
if (disabled.has(skill.name) && registered.has(skill.name)) {
|
|
1938
|
+
registered.get(skill.name)?.();
|
|
1939
|
+
registered.delete(skill.name);
|
|
1940
|
+
}
|
|
1941
|
+
else if (!disabled.has(skill.name) && !registered.has(skill.name)) {
|
|
1942
|
+
registered.set(skill.name, ctx.skills.register(skill));
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
refreshSkills();
|
|
1947
|
+
// Optional host settings: headless/older deployments keep their existing
|
|
1948
|
+
// file and command paths without acquiring a new required service.
|
|
1949
|
+
ctx.inject(['settings'], settingsCtx => {
|
|
1950
|
+
const settings = settingsCtx.settings;
|
|
1951
|
+
if (!settings || typeof settings.register !== 'function')
|
|
1952
|
+
return;
|
|
1953
|
+
preferences = settings.register('ponytail', PreferencesSchema, { applies: 'live' });
|
|
1954
|
+
refreshSkills();
|
|
1955
|
+
const scope = preferences;
|
|
1956
|
+
settingsCtx.effect(() => scope.watch(refreshSkills), 'ponytail: live skill preferences');
|
|
1957
|
+
settingsCtx.effect(() => () => { preferences = undefined; refreshSkills(); }, 'ponytail: preferences detach');
|
|
1958
|
+
});
|
|
1959
|
+
// Human slash commands; the child activates only when the TUI/web mounts a
|
|
1960
|
+
// command registry (headless automation never composes it).
|
|
1961
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
1962
|
+
registerCommands({ ctx, store, profileMode, defaultMode: readDefault, setDefault, defaultSource, settings: () => preferences }, commandCtx);
|
|
1963
|
+
});
|
|
1964
|
+
// Plain-text "stop ponytail" / "normal mode" deactivation, matched on the
|
|
1965
|
+
// whole message only, before the step's request derives.
|
|
1966
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
1967
|
+
const deactivated = containsDeactivation(payload.messages);
|
|
1968
|
+
if (deactivated)
|
|
1969
|
+
store.set(sessionKey(payload.agent), 'off');
|
|
1970
|
+
const decision = await next();
|
|
1971
|
+
if (deactivated && decision.kind === 'enter') {
|
|
1972
|
+
return {
|
|
1973
|
+
kind: 'enter',
|
|
1974
|
+
messages: [
|
|
1975
|
+
...decision.messages,
|
|
1976
|
+
createUserMessage({
|
|
1977
|
+
content: [{ type: 'text', text: 'PONYTAIL MODE OFF' }],
|
|
1978
|
+
source: { kind: 'plugin', plugin: name },
|
|
1979
|
+
}),
|
|
1980
|
+
],
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
return decision;
|
|
1984
|
+
});
|
|
1985
|
+
}
|