@maci0/dsh-ponytail 0.0.0-stage → 0.18.2
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/LICENSE +22 -0
- package/README.md +184 -2
- package/cordis.patch.yml +15 -0
- package/icon.svg +5 -0
- package/lib/client.js +376 -0
- package/lib/frontmatter.js +285 -0
- package/lib/host.js +12 -0
- package/lib/index.js +375 -0
- package/lib/modes.js +158 -0
- package/lib/skills.js +209 -0
- package/lib/types/frontmatter.d.ts +45 -0
- package/lib/types/host.d.ts +159 -0
- package/lib/types/index.d.ts +56 -0
- package/lib/types/modes.d.ts +75 -0
- package/lib/types/skills.d.ts +58 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +112 -4
- package/skills/ponytail/SKILL.md +124 -0
- package/skills/ponytail-audit/SKILL.md +41 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +49 -0
- package/skills/ponytail-help/SKILL.md +65 -0
- package/skills/ponytail-review/SKILL.md +57 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML-frontmatter reader for the bundled `SKILL.md` files.
|
|
3
|
+
*
|
|
4
|
+
* A frontmatter block is dominated by one shape: a flat mapping of
|
|
5
|
+
* `key: value` entries carrying plain, quoted, or block scalars. This module
|
|
6
|
+
* reads that shape by hand, because the alternative (handing every block to
|
|
7
|
+
* `yaml`) pulls the whole parser into the boot path of the plugin, where it is
|
|
8
|
+
* the single largest cost of mounting. Anything the reader cannot prove it
|
|
9
|
+
* would transcribe exactly is handed to `yaml`, the same parser the upstream
|
|
10
|
+
* filesystem provider uses, through a dynamic `import`, so this reader accepts
|
|
11
|
+
* exactly what the registry accepts and the parser is loaded only by a document
|
|
12
|
+
* that actually needs it: plain scalars, quoted scalars, folded (`>`, `>-`) and
|
|
13
|
+
* literal (`|`, `|-`) block scalars, and nested maps. A missing or malformed
|
|
14
|
+
* block yields no keys and leaves the whole source as the body rather than
|
|
15
|
+
* throwing: `discoverSkills` reports the consequence (no description) and
|
|
16
|
+
* keeps every other skill.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-ponytail/frontmatter
|
|
19
|
+
*/
|
|
20
|
+
const FRONTMATTER_BLOCK = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
21
|
+
/**
|
|
22
|
+
* A mapping key this reader can prove `yaml` resolves to the same string: a
|
|
23
|
+
* letter or underscore first, because a leading digit or sign could resolve to
|
|
24
|
+
* a number, and none of the indicator characters that would open a tag,
|
|
25
|
+
* anchor, alias, flow node, or quoted key.
|
|
26
|
+
*/
|
|
27
|
+
const PLAIN_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*):(?:[ \t](.*))?$/;
|
|
28
|
+
/**
|
|
29
|
+
* Code points JavaScript's `trim`/`trimStart` strip but YAML counts as content:
|
|
30
|
+
* a block scalar's indentation is measured from them here, so a block holding
|
|
31
|
+
* one cannot be read line by line and goes to `yaml`.
|
|
32
|
+
*/
|
|
33
|
+
const JS_ONLY_SPACE = /[\u000B\u000C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/;
|
|
34
|
+
/**
|
|
35
|
+
* The plain-scalar spellings `yaml` resolves to something other than their own
|
|
36
|
+
* text. Only the YAML 1.2 core schema spellings: `yes`, `on`, `y`, and `no`
|
|
37
|
+
* are plain strings there, and every numeric, timestamp, and `.inf`/`.nan`
|
|
38
|
+
* form starts with a character this pattern excludes.
|
|
39
|
+
*/
|
|
40
|
+
const TYPED_WORDS = new Map([
|
|
41
|
+
['null', null],
|
|
42
|
+
['Null', null],
|
|
43
|
+
['NULL', null],
|
|
44
|
+
['true', true],
|
|
45
|
+
['True', true],
|
|
46
|
+
['TRUE', true],
|
|
47
|
+
['false', false],
|
|
48
|
+
['False', false],
|
|
49
|
+
['FALSE', false],
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Plain scalars this reader returns verbatim. The leading character rules out
|
|
53
|
+
* every typed form above; the character set excludes the indicators that change
|
|
54
|
+
* how the line is read (`:` opening a nested value, `#` after a space opening
|
|
55
|
+
* a comment, tabs, quotes, and flow brackets), so a match is always literal
|
|
56
|
+
* text.
|
|
57
|
+
*/
|
|
58
|
+
const PLAIN_TEXT = /^[A-Za-z_][A-Za-z0-9 _.'()/,-]*$/;
|
|
59
|
+
/**
|
|
60
|
+
* Block scalar header: the style indicator and the optional strip flag. The
|
|
61
|
+
* keep indicator (`+`, and any explicit indentation digit) is left out on
|
|
62
|
+
* purpose: a header this pattern rejects falls through to `yaml`, which is the
|
|
63
|
+
* only way to prove what a keep-chomped body keeps.
|
|
64
|
+
*/
|
|
65
|
+
const BLOCK_HEADER = /^([|>])(-)?$/;
|
|
66
|
+
/** A complete single-line single-quoted scalar, with `''` as the only escape. */
|
|
67
|
+
const SINGLE_QUOTED = /^'((?:[^']|'')*)'$/;
|
|
68
|
+
/** A complete single-line double-quoted scalar with no escape to decode. */
|
|
69
|
+
const DOUBLE_QUOTED = /^"([^"\\]*)"$/;
|
|
70
|
+
/**
|
|
71
|
+
* The real parser, loaded by the first document this reader cannot prove.
|
|
72
|
+
* Cached after the first load: a rejected load is dropped so a later document
|
|
73
|
+
* can try again instead of inheriting one transient failure.
|
|
74
|
+
*/
|
|
75
|
+
let yaml;
|
|
76
|
+
function loadYaml() {
|
|
77
|
+
yaml ??= import('yaml').catch((error) => {
|
|
78
|
+
yaml = undefined;
|
|
79
|
+
throw error;
|
|
80
|
+
});
|
|
81
|
+
return yaml;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Split a document into its frontmatter block and the body that follows it.
|
|
85
|
+
*
|
|
86
|
+
* Pure text, no parsing: the body is the same either way, so a caller that
|
|
87
|
+
* needs only the body (the always-on ruleset, read once at mount) never
|
|
88
|
+
* touches a parser at all.
|
|
89
|
+
* @param source - full file contents.
|
|
90
|
+
* @returns the block's lines and the remaining body.
|
|
91
|
+
*/
|
|
92
|
+
export function splitFrontmatter(source) {
|
|
93
|
+
const text = source.replace(/^\uFEFF/, '');
|
|
94
|
+
const match = FRONTMATTER_BLOCK.exec(text);
|
|
95
|
+
if (match === null)
|
|
96
|
+
return { block: '', body: text };
|
|
97
|
+
// The body keeps its own bytes apart from line terminators, which are
|
|
98
|
+
// normalized to `\n`.
|
|
99
|
+
return { block: match[1] ?? '', body: text.slice(match[0].length).replace(/\r\n/g, '\n') };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse leading YAML frontmatter from a markdown document.
|
|
103
|
+
* @param source - full file contents.
|
|
104
|
+
* @returns the parsed keys and the remaining body.
|
|
105
|
+
*/
|
|
106
|
+
export async function parseFrontmatter(source) {
|
|
107
|
+
const { block, body } = splitFrontmatter(source);
|
|
108
|
+
return { data: await parseBlock(block), body };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Parse one frontmatter block, tolerating a malformed or non-mapping one.
|
|
112
|
+
* @returns the parsed mapping, or an empty one.
|
|
113
|
+
*/
|
|
114
|
+
async function parseBlock(block) {
|
|
115
|
+
if (block.trim() === '')
|
|
116
|
+
return {};
|
|
117
|
+
const flat = readFlatBlock(block);
|
|
118
|
+
if (flat !== undefined)
|
|
119
|
+
return flat;
|
|
120
|
+
try {
|
|
121
|
+
const { parse } = await loadYaml();
|
|
122
|
+
const data = parse(block);
|
|
123
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A malformed block is a skipped skill, not a failed mount.
|
|
129
|
+
}
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read the flat-mapping shape this module understands.
|
|
134
|
+
*
|
|
135
|
+
* Every construct that is not a top-level `key: value` entry with a scalar
|
|
136
|
+
* value (an indented line, an unindented scalar, a duplicate key (which
|
|
137
|
+
* `yaml` rejects), a `__proto__` key (which would set a prototype), a carriage
|
|
138
|
+
* return anywhere (a line break to `yaml`, and one the delimiter regex never
|
|
139
|
+
* promised to place), or a value shape below)
|
|
140
|
+
* returns `undefined` so the caller hands the whole block to `yaml`.
|
|
141
|
+
*/
|
|
142
|
+
function readFlatBlock(block) {
|
|
143
|
+
// Refused even for `\r\n`: the reader can prove where it splits the block,
|
|
144
|
+
// but not that `yaml` breaks every carriage return in the same place.
|
|
145
|
+
if (block.includes('\r'))
|
|
146
|
+
return undefined;
|
|
147
|
+
// `trimStart` would measure a block scalar's indentation through these, and
|
|
148
|
+
// YAML would not: any of them means the real parser decides.
|
|
149
|
+
if (JS_ONLY_SPACE.test(block))
|
|
150
|
+
return undefined;
|
|
151
|
+
// A tab is never indentation, and inside a folded body YAML treats a
|
|
152
|
+
// tab-leading content line as more indented: not this reader's to fold.
|
|
153
|
+
if (block.includes('\t'))
|
|
154
|
+
return undefined;
|
|
155
|
+
const lines = block.split(/\r\n|\n/);
|
|
156
|
+
const data = {};
|
|
157
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
158
|
+
const line = lines[index] ?? '';
|
|
159
|
+
if (line === '')
|
|
160
|
+
continue;
|
|
161
|
+
if (line.startsWith(' ') || line.startsWith('\t'))
|
|
162
|
+
return undefined;
|
|
163
|
+
const entry = PLAIN_KEY.exec(line);
|
|
164
|
+
if (entry === null)
|
|
165
|
+
return undefined;
|
|
166
|
+
const key = entry[1] ?? '';
|
|
167
|
+
// `yaml` rejects a repeated key, resolves a typed word to something other
|
|
168
|
+
// than its own text, and would set a prototype on `__proto__`; the real
|
|
169
|
+
// parser has to say so for all three.
|
|
170
|
+
if (key === '__proto__' || TYPED_WORDS.has(key) || Object.hasOwn(data, key))
|
|
171
|
+
return undefined;
|
|
172
|
+
// Trailing white space is separation, not content.
|
|
173
|
+
const value = (entry[2] ?? '').replace(/[ \t]+$/, '');
|
|
174
|
+
if (value === '') {
|
|
175
|
+
data[key] = null;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const header = BLOCK_HEADER.exec(value);
|
|
179
|
+
if (header !== null) {
|
|
180
|
+
const scalar = readBlockScalar(lines, index + 1, header[1] === '|', header[2] === '-');
|
|
181
|
+
if (scalar === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
data[key] = scalar.text;
|
|
184
|
+
index = scalar.next - 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (value.startsWith("'")) {
|
|
188
|
+
const quoted = SINGLE_QUOTED.exec(value);
|
|
189
|
+
if (quoted === null)
|
|
190
|
+
return undefined;
|
|
191
|
+
data[key] = (quoted[1] ?? '').replace(/''/g, "'");
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (value.startsWith('"')) {
|
|
195
|
+
const quoted = DOUBLE_QUOTED.exec(value);
|
|
196
|
+
if (quoted === null)
|
|
197
|
+
return undefined;
|
|
198
|
+
data[key] = quoted[1] ?? '';
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (TYPED_WORDS.has(value)) {
|
|
202
|
+
data[key] = TYPED_WORDS.get(value);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!PLAIN_TEXT.test(value))
|
|
206
|
+
return undefined;
|
|
207
|
+
data[key] = value;
|
|
208
|
+
}
|
|
209
|
+
return data;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Count the spaces a line starts with. YAML indentation is spaces; a tab or a
|
|
213
|
+
* code point JS would treat as blank is not this reader's to interpret.
|
|
214
|
+
*/
|
|
215
|
+
function leadingSpaces(line) {
|
|
216
|
+
let count = 0;
|
|
217
|
+
while (count < line.length && line.charCodeAt(count) === 32)
|
|
218
|
+
count += 1;
|
|
219
|
+
return count;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Read one block scalar body.
|
|
223
|
+
*
|
|
224
|
+
* Only the shape `yaml` folds without surprises is accepted: every content line
|
|
225
|
+
* indented by the same positive number of spaces, no leading blank line, no
|
|
226
|
+
* tab in the indentation, and no line that is only white space (which keeps its
|
|
227
|
+
* own bytes instead of folding). Trailing blank lines are chomped away by both
|
|
228
|
+
* `|` and `>` without the `+` indicator, so they are dropped here.
|
|
229
|
+
* @param literal - `true` for `|`, `false` for `>`.
|
|
230
|
+
* @param stripped - `true` for the `-` chomping indicator.
|
|
231
|
+
* @returns the scalar text and the index after its body, or `undefined` when
|
|
232
|
+
* `yaml` must decide.
|
|
233
|
+
*/
|
|
234
|
+
function readBlockScalar(lines, start, literal, stripped) {
|
|
235
|
+
let next = start;
|
|
236
|
+
while (next < lines.length) {
|
|
237
|
+
const line = lines[next] ?? '';
|
|
238
|
+
if (line !== '' && !line.startsWith(' '))
|
|
239
|
+
break;
|
|
240
|
+
next += 1;
|
|
241
|
+
}
|
|
242
|
+
const body = lines.slice(start, next);
|
|
243
|
+
while (body.length > 0 && body[body.length - 1] === '')
|
|
244
|
+
body.pop();
|
|
245
|
+
const first = body[0];
|
|
246
|
+
if (first === undefined)
|
|
247
|
+
return { text: '', next };
|
|
248
|
+
// Indentation is spaces only: a tab, or anything `yaml` counts as content,
|
|
249
|
+
// means this reader cannot prove where the body starts.
|
|
250
|
+
const indent = leadingSpaces(first);
|
|
251
|
+
if (indent === 0)
|
|
252
|
+
return undefined;
|
|
253
|
+
const content = [];
|
|
254
|
+
for (const line of body) {
|
|
255
|
+
if (line === '') {
|
|
256
|
+
content.push('');
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const lead = leadingSpaces(line);
|
|
260
|
+
if (lead !== indent || line.length === indent)
|
|
261
|
+
return undefined;
|
|
262
|
+
content.push(line.slice(indent));
|
|
263
|
+
}
|
|
264
|
+
let text = '';
|
|
265
|
+
if (literal)
|
|
266
|
+
text = content.join('\n');
|
|
267
|
+
else {
|
|
268
|
+
// Folding replaces the break between two content lines with a space, and
|
|
269
|
+
// every run of `blank` blank lines between them with that many newlines.
|
|
270
|
+
let blanks = 0;
|
|
271
|
+
for (const line of content) {
|
|
272
|
+
if (line === '') {
|
|
273
|
+
blanks += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (text !== '')
|
|
277
|
+
text += blanks === 0 ? ' ' : '\n'.repeat(blanks);
|
|
278
|
+
blanks = 0;
|
|
279
|
+
text += line;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (text !== '' && !stripped)
|
|
283
|
+
text += '\n';
|
|
284
|
+
return { text, next };
|
|
285
|
+
}
|
package/lib/host.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the DeepSeek Harness host surface this plugin uses, declared
|
|
3
|
+
* structurally.
|
|
4
|
+
*
|
|
5
|
+
* These interfaces describe the exact contracts the plugin calls; the host
|
|
6
|
+
* types remain authoritative, and every service is reached through
|
|
7
|
+
* `ctx.inject([...])`, so a composition that does not mount one simply omits
|
|
8
|
+
* that capability.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-ponytail/host
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ponytail: Ponytail, lazy senior dev mode, as a DeepSeek Harness plugin.
|
|
3
|
+
*
|
|
4
|
+
* Four capabilities, all mounted through public Cordis extension points:
|
|
5
|
+
*
|
|
6
|
+
* - the bundled skills (`ponytail`, `-review`, `-audit`, `-debt`, `-gain`,
|
|
7
|
+
* `-help`) become one `ctx.skills` provider;
|
|
8
|
+
* - while a level other than `off` is active, the mode-filtered ruleset is
|
|
9
|
+
* contributed to the system prompt on every assembly;
|
|
10
|
+
* - the level is switchable from the model (`ponytail` tool) and the human
|
|
11
|
+
* (`/ponytail` command);
|
|
12
|
+
* - the `ponytail` settings namespace makes the level persistent and pairs with
|
|
13
|
+
* this package's browser half, which renders the card in the Web client's
|
|
14
|
+
* Plugins page, on the ponytail row's Configure control.
|
|
15
|
+
*
|
|
16
|
+
* Skill content is adapted from the reference implementation
|
|
17
|
+
* (https://github.com/DietrichGebert/ponytail, MIT, © DietrichGebert).
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-ponytail
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import z from '@deepseek-ai/schemastery';
|
|
25
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
26
|
+
import { buildModeInstructions, DEFAULT_MODE, isDeactivationCommand, normalizeCommandMode, normalizeMode, resolveDefaultMode, RUNTIME_MODES, VALID_MODES, } from './modes.js';
|
|
27
|
+
import { createSkillProvider } from './skills.js';
|
|
28
|
+
import { splitFrontmatter } from './frontmatter.js';
|
|
29
|
+
/** Plugin name as it appears in the loader. */
|
|
30
|
+
export const name = 'ponytail';
|
|
31
|
+
/**
|
|
32
|
+
* Route the browser half reads for the level in use and its source. The card
|
|
33
|
+
* and the chip cannot see a session-local level (`review`, or one the settings
|
|
34
|
+
* document refused), so they ask the host instead of the settings document.
|
|
35
|
+
*/
|
|
36
|
+
export const LEVEL_ROUTE = '/ponytail/level';
|
|
37
|
+
/** Row schema: an absent level is filled by the loader before `apply`. */
|
|
38
|
+
export const Config = z.object({
|
|
39
|
+
defaultMode: z.union([...RUNTIME_MODES]).default(DEFAULT_MODE).volatile(),
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Mount the plugin.
|
|
43
|
+
* @param ctx - the host context.
|
|
44
|
+
* @param config - the schema-resolved row; the loader always passes one.
|
|
45
|
+
*/
|
|
46
|
+
export function apply(ctx, config) {
|
|
47
|
+
// `<package>/skills`, resolved from this module's own location.
|
|
48
|
+
const skillsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
49
|
+
const startup = resolveDefaultMode(config.defaultMode.get());
|
|
50
|
+
// Parsed once, at load: the ruleset is filtered per assembly, so the
|
|
51
|
+
// frontmatter must not have to be re-read for every request. A missing body
|
|
52
|
+
// means a broken install: fail while loading rather than injecting a silently
|
|
53
|
+
// truncated ruleset.
|
|
54
|
+
const skillBody = splitFrontmatter(readFileSync(join(skillsDir, 'ponytail', 'SKILL.md'), 'utf8')).body.trimStart();
|
|
55
|
+
/**
|
|
56
|
+
* The mode-filtered ruleset, keyed by level.
|
|
57
|
+
*
|
|
58
|
+
* The body above is parsed once and never re-read, so the filter's result is
|
|
59
|
+
* a pure function of the level: at most one entry per accepted level, filled
|
|
60
|
+
* on first use and never invalidated, because nothing that feeds it can
|
|
61
|
+
* change while the plugin is mounted.
|
|
62
|
+
*/
|
|
63
|
+
const instructionsByMode = new Map();
|
|
64
|
+
const modeInstructions = (mode) => {
|
|
65
|
+
let text = instructionsByMode.get(mode);
|
|
66
|
+
if (text === undefined) {
|
|
67
|
+
text = buildModeInstructions({ mode, skillBody });
|
|
68
|
+
instructionsByMode.set(mode, text);
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
};
|
|
72
|
+
const warn = (message) => {
|
|
73
|
+
console.warn(`[ponytail] ${message}`);
|
|
74
|
+
};
|
|
75
|
+
/** Session-local level, used when the profile write cannot hold the level. */
|
|
76
|
+
let override;
|
|
77
|
+
let modeGeneration = 0;
|
|
78
|
+
/** The row's live level; updates are committed into the same reference. */
|
|
79
|
+
const configuredMode = () => normalizeMode(config.defaultMode.get());
|
|
80
|
+
const activeMode = () => override ?? configuredMode() ?? startup;
|
|
81
|
+
/** The mounted settings service, or `undefined` while none is attached. */
|
|
82
|
+
const settingsService = () => {
|
|
83
|
+
const service = ctx.get('settings');
|
|
84
|
+
return service === undefined || service === null ? undefined : service;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Persist a level through the settings document; false when it cannot hold it.
|
|
88
|
+
*
|
|
89
|
+
* The service is queried at the use site rather than captured from the
|
|
90
|
+
* `inject` callback: the callback's fiber disposes when the settings service
|
|
91
|
+
* unloads, and a captured reference would then let a later write reach a
|
|
92
|
+
* detached service.
|
|
93
|
+
* @param next - the level to commit.
|
|
94
|
+
* @param signal - caller cancellation; an abort during the write stops the
|
|
95
|
+
* wait, and the write itself still lands.
|
|
96
|
+
* @returns whether the document accepted the level.
|
|
97
|
+
*/
|
|
98
|
+
const persist = async (next, signal) => {
|
|
99
|
+
signal?.throwIfAborted();
|
|
100
|
+
const settings = settingsService();
|
|
101
|
+
const id = entryId(ctx);
|
|
102
|
+
if (settings === undefined || id === undefined || normalizeMode(next) === undefined)
|
|
103
|
+
return false;
|
|
104
|
+
try {
|
|
105
|
+
await abortable(settings.update(id, { defaultMode: next }), signal);
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
warn(`could not persist level "${next}": ${error instanceof Error ? error.message : String(error)}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const setMode = async (next, signal) => {
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
const started = ++modeGeneration;
|
|
118
|
+
const previous = activeMode();
|
|
119
|
+
const persisted = await persist(next, signal);
|
|
120
|
+
// A refused older request cannot restore a level the human already ended.
|
|
121
|
+
if (started === modeGeneration)
|
|
122
|
+
override = persisted ? undefined : next;
|
|
123
|
+
const mode = activeMode();
|
|
124
|
+
return { previous, mode, changed: mode !== previous };
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Turn the level off because the human's own message was a deactivation
|
|
128
|
+
* command.
|
|
129
|
+
*
|
|
130
|
+
* The override is set before the settings write is awaited: the durable
|
|
131
|
+
* `user/message` event arrives before the turn's prompt is assembled, and
|
|
132
|
+
* awaiting the document would let that same turn assemble with the ruleset
|
|
133
|
+
* still injected: the one turn the user just asked to end. A committed
|
|
134
|
+
* document then becomes the source of truth again, so the card and the
|
|
135
|
+
* prompt cannot disagree.
|
|
136
|
+
*/
|
|
137
|
+
const deactivateFromMessage = () => {
|
|
138
|
+
if (activeMode() === 'off')
|
|
139
|
+
return;
|
|
140
|
+
const started = ++modeGeneration;
|
|
141
|
+
override = 'off';
|
|
142
|
+
void persist('off').then((persisted) => {
|
|
143
|
+
if (persisted && started === modeGeneration)
|
|
144
|
+
override = undefined;
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
ctx.on('loader/volatile-update', () => {
|
|
148
|
+
modeGeneration += 1;
|
|
149
|
+
override = undefined;
|
|
150
|
+
});
|
|
151
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
152
|
+
scope.systemPrompt.section({
|
|
153
|
+
name: 'ponytail',
|
|
154
|
+
order: 700, // after the persona prefix, before tool guidance
|
|
155
|
+
// Evaluated at each assembly, so a level change lands on the next request.
|
|
156
|
+
// `off` returns empty text, which assembly drops.
|
|
157
|
+
text: () => modeInstructions(activeMode()),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
ctx.inject(['skills'], (scope) => {
|
|
161
|
+
scope.skills.registerProvider(() => createSkillProvider({ skillsDir, onWarn: warn }));
|
|
162
|
+
});
|
|
163
|
+
ctx.inject(['tools'], (scope) => {
|
|
164
|
+
scope.tools.register(createModeTool(activeMode, setMode));
|
|
165
|
+
});
|
|
166
|
+
ctx.inject(['commands'], (scope) => {
|
|
167
|
+
scope.commands.register({
|
|
168
|
+
name: 'ponytail',
|
|
169
|
+
description: '✂ Set the ponytail level (lite, full, ultra, review, off) or report the current one.',
|
|
170
|
+
input: { hint: 'lite | full | ultra | review | off' },
|
|
171
|
+
handler: async (invocation) => handleModeCommand(invocation, activeMode, setMode),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
ctx.inject(['webServer', 'connection'], (scope) => {
|
|
175
|
+
scope.effect(() => scope.webServer.register({
|
|
176
|
+
kind: 'exact',
|
|
177
|
+
path: LEVEL_ROUTE,
|
|
178
|
+
handler: (req, res) => {
|
|
179
|
+
const rejection = scope.connection.requestRejection(req);
|
|
180
|
+
if (rejection !== undefined) {
|
|
181
|
+
res.statusCode = rejection;
|
|
182
|
+
res.end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (req.method !== undefined && req.method !== 'GET') {
|
|
186
|
+
res.statusCode = 405;
|
|
187
|
+
res.setHeader('allow', 'GET');
|
|
188
|
+
res.end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
res.statusCode = 200;
|
|
192
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
193
|
+
res.setHeader('cache-control', 'no-store');
|
|
194
|
+
res.end(JSON.stringify({ mode: activeMode(), source: override === undefined ? 'settings' : 'session' }));
|
|
195
|
+
},
|
|
196
|
+
}), `ponytail: GET ${LEVEL_ROUTE}`);
|
|
197
|
+
});
|
|
198
|
+
// "stop ponytail" / "normal mode" typed as an ordinary message, given the
|
|
199
|
+
// same effect as `/ponytail off`. The command path is unaffected: this only
|
|
200
|
+
// claims messages that are exactly the command and come from the human.
|
|
201
|
+
ctx.on('session/event', (_session, event) => {
|
|
202
|
+
if (event.type !== 'user/message')
|
|
203
|
+
return;
|
|
204
|
+
const text = userMessageText(event.data);
|
|
205
|
+
if (text === undefined || !isDeactivationCommand(text))
|
|
206
|
+
return;
|
|
207
|
+
deactivateFromMessage();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Read the plain text of a genuine user message.
|
|
212
|
+
*
|
|
213
|
+
* Injected context (skill bodies, file references, replayed history) rides the
|
|
214
|
+
* same event stream, so a message only counts when the harness marks it as the
|
|
215
|
+
* user's own; an injected instruction that happened to read "normal mode" must
|
|
216
|
+
* never toggle the level.
|
|
217
|
+
* @param data - the `user/message` event payload.
|
|
218
|
+
* @returns the concatenated text blocks, or `undefined` when this is not the
|
|
219
|
+
* human's own text.
|
|
220
|
+
*/
|
|
221
|
+
function userMessageText(data) {
|
|
222
|
+
if (data === null || typeof data !== 'object')
|
|
223
|
+
return undefined;
|
|
224
|
+
const message = data;
|
|
225
|
+
if (message.source?.kind !== 'user')
|
|
226
|
+
return undefined;
|
|
227
|
+
if (!Array.isArray(message.content))
|
|
228
|
+
return undefined;
|
|
229
|
+
const text = message.content
|
|
230
|
+
.map((block) => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
|
|
231
|
+
.join('\n');
|
|
232
|
+
return text.trim() === '' ? undefined : text;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Await `work`, settling early when `signal` aborts.
|
|
236
|
+
*
|
|
237
|
+
* The settings write is not interruptible from here, so the abandoned promise
|
|
238
|
+
* still settles on its own; only its rejection is absorbed, and the tool call
|
|
239
|
+
* returns before the write it no longer waits for.
|
|
240
|
+
* @param work - the in-flight write.
|
|
241
|
+
* @param signal - caller cancellation.
|
|
242
|
+
* @returns the write's result once it settles.
|
|
243
|
+
*/
|
|
244
|
+
function abortable(work, signal) {
|
|
245
|
+
if (signal === undefined || signal.aborted)
|
|
246
|
+
return work;
|
|
247
|
+
return new Promise((resolve, reject) => {
|
|
248
|
+
const onAbort = () => reject(new Error('aborted'));
|
|
249
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
250
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Build the model-facing level tool.
|
|
255
|
+
* @param getMode - reads the active level.
|
|
256
|
+
* @param setMode - applies and persists a level.
|
|
257
|
+
* @returns the registered tool definition.
|
|
258
|
+
*/
|
|
259
|
+
function createModeTool(getMode, setMode) {
|
|
260
|
+
return defineTool({
|
|
261
|
+
name: 'ponytail',
|
|
262
|
+
// The `enum` below already names every level, and the injected ruleset
|
|
263
|
+
// explains what each one does; repeating both here only costs tokens.
|
|
264
|
+
description: 'Set or report the ponytail level, which governs how much code is written. '
|
|
265
|
+
+ 'The level persists in the user settings document. '
|
|
266
|
+
+ 'Call with no arguments to report the current level.',
|
|
267
|
+
parameters: {
|
|
268
|
+
mode: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
enum: [...VALID_MODES],
|
|
271
|
+
description: 'Level to activate. Omit to report the current level.',
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
output: {
|
|
275
|
+
schema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
additionalProperties: false,
|
|
278
|
+
properties: {
|
|
279
|
+
mode: { type: 'string', enum: [...VALID_MODES], required: true },
|
|
280
|
+
previous: { type: 'string', enum: [...VALID_MODES], required: true },
|
|
281
|
+
changed: { type: 'boolean', required: true },
|
|
282
|
+
active: { type: 'boolean', required: true },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
render: (_args, value) => [{ type: 'text', text: renderModeResult(value) }],
|
|
286
|
+
},
|
|
287
|
+
async execute(args, exec) {
|
|
288
|
+
const requested = readModeArgument(args);
|
|
289
|
+
const previous = getMode();
|
|
290
|
+
if (requested === undefined) {
|
|
291
|
+
return { mode: previous, previous, changed: false, active: previous !== 'off' };
|
|
292
|
+
}
|
|
293
|
+
const applied = await setMode(requested, exec.signal);
|
|
294
|
+
return {
|
|
295
|
+
mode: applied.mode,
|
|
296
|
+
previous: applied.previous,
|
|
297
|
+
changed: applied.changed,
|
|
298
|
+
active: applied.mode !== 'off',
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Read the optional `mode` argument.
|
|
305
|
+
*
|
|
306
|
+
* `defineTool` already rejected a value outside the enum, so an unrecognized
|
|
307
|
+
* value never reaches the body and reads as a status query.
|
|
308
|
+
* @param args - losslessly snapshotted model arguments.
|
|
309
|
+
* @returns the requested level, or `undefined` for a status query.
|
|
310
|
+
*/
|
|
311
|
+
function readModeArgument(args) {
|
|
312
|
+
if (args === null || typeof args !== 'object')
|
|
313
|
+
return undefined;
|
|
314
|
+
const raw = args['mode'];
|
|
315
|
+
if (raw === undefined || raw === null || raw === '')
|
|
316
|
+
return undefined;
|
|
317
|
+
return normalizeCommandMode(raw);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Phrase one level outcome. Shared by the model-facing tool and the human
|
|
321
|
+
* command, which report the same three transitions.
|
|
322
|
+
* @param mode - the level now active.
|
|
323
|
+
* @param previous - the level before the call.
|
|
324
|
+
* @param changed - whether the call moved the level.
|
|
325
|
+
* @returns the sentence both surfaces start from.
|
|
326
|
+
*/
|
|
327
|
+
function modeSentence(mode, previous, changed) {
|
|
328
|
+
if (!changed)
|
|
329
|
+
return `Ponytail level: ${mode}.`;
|
|
330
|
+
return mode === 'off'
|
|
331
|
+
? `Ponytail off (was ${previous}). Normal behavior.`
|
|
332
|
+
: `Ponytail level: ${mode} (was ${previous}).`;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Render the canonical tool value for the model.
|
|
336
|
+
* @param value - the canonical value returned by `execute`.
|
|
337
|
+
* @returns model-facing prose.
|
|
338
|
+
*/
|
|
339
|
+
function renderModeResult(value) {
|
|
340
|
+
const record = (value ?? {});
|
|
341
|
+
const mode = typeof record['mode'] === 'string' ? record['mode'] : 'unknown';
|
|
342
|
+
const previous = typeof record['previous'] === 'string' ? record['previous'] : mode;
|
|
343
|
+
const changed = record['changed'] === true;
|
|
344
|
+
const active = record['active'] === true;
|
|
345
|
+
if (!changed && !active)
|
|
346
|
+
return 'Ponytail is off. Normal behavior.';
|
|
347
|
+
const sentence = modeSentence(mode, previous, changed);
|
|
348
|
+
return active ? `${sentence} The ruleset is injected into every request.` : sentence;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Handle the human `/ponytail [level]` command.
|
|
352
|
+
* @param invocation - the command invocation.
|
|
353
|
+
* @param getMode - reads the active level.
|
|
354
|
+
* @param setMode - applies and persists a level.
|
|
355
|
+
* @returns the direct-UI result.
|
|
356
|
+
*/
|
|
357
|
+
async function handleModeCommand(invocation, getMode, setMode) {
|
|
358
|
+
const input = invocation.rawInput.trim().toLowerCase();
|
|
359
|
+
if (input === '')
|
|
360
|
+
return { kind: 'success', text: modeSentence(getMode(), getMode(), false) };
|
|
361
|
+
const requested = isDeactivationCommand(input) ? 'off' : normalizeCommandMode(input);
|
|
362
|
+
if (requested === undefined) {
|
|
363
|
+
return {
|
|
364
|
+
kind: 'error',
|
|
365
|
+
text: `Unknown ponytail level "${invocation.rawInput.trim()}". Use one of: ${VALID_MODES.join(', ')}.`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const { previous, mode, changed } = await setMode(requested);
|
|
369
|
+
return { kind: 'success', text: modeSentence(mode, previous, changed) };
|
|
370
|
+
}
|
|
371
|
+
/** Profile entry id of this plugin, when the loader mounted it. */
|
|
372
|
+
function entryId(ctx) {
|
|
373
|
+
const id = ctx.fiber?.entry?.options?.id;
|
|
374
|
+
return typeof id === 'string' ? id : undefined;
|
|
375
|
+
}
|