@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.
@@ -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
+ }