@maci0/dsh-ponytail 0.0.0-stage → 0.18.3
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
package/lib/modes.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ponytail's level model: the accepted levels, their normalization, the
|
|
3
|
+
* mode-specific filter over the `ponytail` skill body, and the instruction
|
|
4
|
+
* block the plugin injects into the system prompt.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the reference implementation's `hooks/ponytail-config.js` and
|
|
7
|
+
* `hooks/ponytail-instructions.js` (MIT, © DietrichGebert) so the DSH port
|
|
8
|
+
* speaks the same lite/full/ultra/review vocabulary and filters the same rows.
|
|
9
|
+
* The one addition is `resolveDefaultMode`'s `configured` source, which lets a
|
|
10
|
+
* deployment set the default from this plugin's own config field.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-ponytail/modes
|
|
13
|
+
*/
|
|
14
|
+
/** Levels that change the always-on ruleset and may be persisted as a default. */
|
|
15
|
+
export const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
|
|
16
|
+
/** Every accepted level; `review` is session-only and never a valid default. */
|
|
17
|
+
export const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
|
18
|
+
/** Level used when neither config nor environment sets one. */
|
|
19
|
+
export const DEFAULT_MODE = 'full';
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a value to one of `values`, case- and whitespace-insensitively.
|
|
22
|
+
* @param values - the accepted levels.
|
|
23
|
+
* @param value - candidate level from a config field or a command.
|
|
24
|
+
* @returns the canonical level, or `undefined` when unrecognized.
|
|
25
|
+
*/
|
|
26
|
+
function normalize(values, value) {
|
|
27
|
+
if (typeof value !== 'string')
|
|
28
|
+
return undefined;
|
|
29
|
+
const normalized = value.trim().toLowerCase();
|
|
30
|
+
return values.find((mode) => mode === normalized);
|
|
31
|
+
}
|
|
32
|
+
/** Normalize a value to a level that may be persisted as a default. */
|
|
33
|
+
export function normalizeMode(value) {
|
|
34
|
+
return normalize(RUNTIME_MODES, value);
|
|
35
|
+
}
|
|
36
|
+
/** Normalize a value to any accepted level, including the session-only `review`. */
|
|
37
|
+
export function normalizeCommandMode(value) {
|
|
38
|
+
return normalize(VALID_MODES, value);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether a whole message is a deactivation command.
|
|
42
|
+
*
|
|
43
|
+
* "stop ponytail" / "normal mode" turn ponytail off, but only as a standalone
|
|
44
|
+
* command: matching the phrase anywhere in a message turned it off mid-task for
|
|
45
|
+
* ordinary requests like "add a normal mode toggle", so the whole trimmed
|
|
46
|
+
* message must be the command, ignoring case and trailing punctuation.
|
|
47
|
+
* @param text - user message text.
|
|
48
|
+
* @returns whether the message is the deactivation command.
|
|
49
|
+
*/
|
|
50
|
+
export function isDeactivationCommand(text) {
|
|
51
|
+
const normalized = String(text ?? '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
|
|
52
|
+
return normalized === 'stop ponytail' || normalized === 'normal mode';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the level a fresh process starts in.
|
|
56
|
+
*
|
|
57
|
+
* Only runtime levels count, so a stray `review` can never become the default.
|
|
58
|
+
* @param configured - the row's `defaultMode` field, when the row carries one.
|
|
59
|
+
* @returns the resolved startup level.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveDefaultMode(configured) {
|
|
62
|
+
return normalizeMode(configured) ?? DEFAULT_MODE;
|
|
63
|
+
}
|
|
64
|
+
/** The two mode-keyed line shapes, hoisted so the filter does not recompile them. */
|
|
65
|
+
const TABLE_LABEL = /^\|\s*\*\*(.+?)\*\*\s*\|/;
|
|
66
|
+
const EXAMPLE_LABEL = /^-\s*([^:]+):\s*"/;
|
|
67
|
+
/** Opening or closing marker of a fenced code block, with its run of backticks or tildes. */
|
|
68
|
+
const FENCE = /^\s*(`{3,}|~{3,})/;
|
|
69
|
+
/**
|
|
70
|
+
* Mark every line that belongs to a fenced code block.
|
|
71
|
+
*
|
|
72
|
+
* Fenced text is literal, so a mode-shaped line inside it is an example of the
|
|
73
|
+
* syntax, not a ruleset row: the filter must leave it alone. Fences nest by
|
|
74
|
+
* length, so a run closes only on the same character with an equal or longer
|
|
75
|
+
* run; an unterminated fence runs to the end of the body, as CommonMark reads
|
|
76
|
+
* it.
|
|
77
|
+
* @param lines - the body, split into lines.
|
|
78
|
+
* @returns one flag per line, `true` inside a fence.
|
|
79
|
+
*/
|
|
80
|
+
function fencedLines(lines) {
|
|
81
|
+
const inside = lines.map(() => false);
|
|
82
|
+
let open;
|
|
83
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
84
|
+
const marker = FENCE.exec(lines[index] ?? '')?.[1];
|
|
85
|
+
if (marker === undefined) {
|
|
86
|
+
inside[index] = open !== undefined;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// A marker that matches the opening run closes it; a shorter or
|
|
90
|
+
// differently-charactered one is content inside the open fence.
|
|
91
|
+
if (open === undefined)
|
|
92
|
+
open = marker;
|
|
93
|
+
else if (marker[0] === open[0] && marker.length >= open.length)
|
|
94
|
+
open = undefined;
|
|
95
|
+
inside[index] = true;
|
|
96
|
+
}
|
|
97
|
+
return inside;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drop the intensity-table rows and worked examples that belong to other
|
|
101
|
+
* levels.
|
|
102
|
+
*
|
|
103
|
+
* Only the intensity table rows and worked examples are mode-specific, and both
|
|
104
|
+
* are keyed by a level name. A bullet whose label is not a level (e.g.
|
|
105
|
+
* "No unrequested abstractions: ...") is a normal rule and stays verbatim; the
|
|
106
|
+
* quoted-value requirement on examples is what keeps a rule that merely starts
|
|
107
|
+
* with a level word from being dropped in every other mode. A fenced code block
|
|
108
|
+
* is literal text, so nothing in it is dropped either.
|
|
109
|
+
* @param body - markdown of the `ponytail` skill, frontmatter already removed.
|
|
110
|
+
* @param mode - the level to keep.
|
|
111
|
+
* @returns the body with other levels' rows and examples removed.
|
|
112
|
+
*/
|
|
113
|
+
export function filterSkillBodyForMode(body, mode) {
|
|
114
|
+
const effective = normalizeMode(mode) ?? DEFAULT_MODE;
|
|
115
|
+
const lines = String(body ?? '').split(/\r?\n/);
|
|
116
|
+
const fenced = fencedLines(lines);
|
|
117
|
+
return lines
|
|
118
|
+
.filter((line, index) => {
|
|
119
|
+
if (fenced[index] === true)
|
|
120
|
+
return true;
|
|
121
|
+
// Both labels start their line, so one character rules out the common
|
|
122
|
+
// prose line before either regex runs. Same rows, same output.
|
|
123
|
+
const head = line.charCodeAt(0);
|
|
124
|
+
if (head !== 0x7c && head !== 0x2d)
|
|
125
|
+
return true;
|
|
126
|
+
const tableLabel = TABLE_LABEL.exec(line);
|
|
127
|
+
if (tableLabel?.[1] !== undefined) {
|
|
128
|
+
const labelMode = normalizeMode(tableLabel[1].trim());
|
|
129
|
+
if (labelMode !== undefined)
|
|
130
|
+
return labelMode === effective;
|
|
131
|
+
}
|
|
132
|
+
const exampleLabel = EXAMPLE_LABEL.exec(line);
|
|
133
|
+
if (exampleLabel?.[1] !== undefined) {
|
|
134
|
+
const labelMode = normalizeMode(exampleLabel[1].trim());
|
|
135
|
+
if (labelMode !== undefined)
|
|
136
|
+
return labelMode === effective;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
})
|
|
140
|
+
.join('\n');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Build the exact text the system prompt carries for one level.
|
|
144
|
+
* @param input - the active level and the skill body.
|
|
145
|
+
* @returns the instruction block, or `''` when the level is `off`.
|
|
146
|
+
*/
|
|
147
|
+
export function buildModeInstructions(input) {
|
|
148
|
+
const { mode } = input;
|
|
149
|
+
if (mode === 'off')
|
|
150
|
+
return '';
|
|
151
|
+
if (mode === 'review') {
|
|
152
|
+
return ('PONYTAIL MODE ACTIVE — level: review. Behavior defined by the `ponytail-review`' +
|
|
153
|
+
' skill; load it with the skill tool.');
|
|
154
|
+
}
|
|
155
|
+
const effective = normalizeMode(mode) ?? DEFAULT_MODE;
|
|
156
|
+
return 'PONYTAIL MODE ACTIVE — level: ' + effective + '\n\n' +
|
|
157
|
+
filterSkillBodyForMode(input.skillBody, effective);
|
|
158
|
+
}
|
package/lib/skills.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled ponytail skills as a `ctx.skills` provider.
|
|
3
|
+
*
|
|
4
|
+
* Skills are read from this package's `skills/<name>/SKILL.md`, so the same
|
|
5
|
+
* files stay the single source of truth for both the always-on ruleset (which
|
|
6
|
+
* filters the `ponytail` body per level) and the on-demand skills.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-ponytail/skills
|
|
9
|
+
*/
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, dirname, join } from 'node:path';
|
|
12
|
+
import { BUNDLED_SKILL_RANK, isSkillName, } from '@deepseek-ai/dsh-skill';
|
|
13
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
14
|
+
/** Provider name inside the skill registry. */
|
|
15
|
+
const PROVIDER_NAME = 'ponytail';
|
|
16
|
+
/** Instruction file every skill directory must carry. */
|
|
17
|
+
const INSTRUCTION_FILE = 'SKILL.md';
|
|
18
|
+
/**
|
|
19
|
+
* Frontmatter keys that project onto a summary field rather than provider
|
|
20
|
+
* metadata. `disable-model-invocation`/`user-invocable` resolve into
|
|
21
|
+
* `invocation`, so they never ride along as provider-specific keys.
|
|
22
|
+
*/
|
|
23
|
+
const INVOCATION_KEYS = new Set([
|
|
24
|
+
'name',
|
|
25
|
+
'description',
|
|
26
|
+
'whenToUse',
|
|
27
|
+
'disable-model-invocation',
|
|
28
|
+
'user-invocable',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Read one frontmatter value as a trimmed string.
|
|
32
|
+
* @param value - YAML-parsed value, of any shape.
|
|
33
|
+
* @returns the trimmed string, or `undefined` when the value is missing or a collection.
|
|
34
|
+
*/
|
|
35
|
+
function scalar(value) {
|
|
36
|
+
if (typeof value === 'string')
|
|
37
|
+
return value.trim();
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read and parse one skill file. Shared by discovery and direct loads so a
|
|
45
|
+
* single file enforces the name/description/frontmatter rules everywhere.
|
|
46
|
+
* @param path - absolute path of the `SKILL.md` file.
|
|
47
|
+
* @param entryName - directory name fallback when frontmatter omits `name`.
|
|
48
|
+
* @param onWarn - optional non-fatal problem sink.
|
|
49
|
+
* @returns the parsed skill, or `undefined` with a warning when invalid.
|
|
50
|
+
*/
|
|
51
|
+
async function readSkillFile(path, onWarn, entryName) {
|
|
52
|
+
let source;
|
|
53
|
+
try {
|
|
54
|
+
source = await readFile(path, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// A directory without an instruction file is a broken skill, not an empty
|
|
58
|
+
// one: `discoverSkills` promises this reaches the same sink as every other
|
|
59
|
+
// skipped skill, so the install can say why a skill is missing.
|
|
60
|
+
onWarn?.(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
// `parseFrontmatter` surfaces a malformed block as empty data, never a throw.
|
|
64
|
+
const parsed = await parseFrontmatter(source);
|
|
65
|
+
const fallback = entryName ?? basename(path);
|
|
66
|
+
const name = scalar(parsed.data['name']) ?? fallback;
|
|
67
|
+
const description = scalar(parsed.data['description']) ?? '';
|
|
68
|
+
const whenToUse = scalar(parsed.data['whenToUse']);
|
|
69
|
+
if (!isSkillName(name)) {
|
|
70
|
+
onWarn?.(`skipping ${path}: "${name}" is not a valid kebab-case skill name`);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
if (description === '') {
|
|
74
|
+
onWarn?.(`skipping ${path}: frontmatter has no description`);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const metadata = {};
|
|
78
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
79
|
+
if (INVOCATION_KEYS.has(key))
|
|
80
|
+
continue;
|
|
81
|
+
metadata[key] = value;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
...(whenToUse === undefined ? {} : { whenToUse }),
|
|
86
|
+
description,
|
|
87
|
+
// Only the literal `true` disables the model surface and only the literal
|
|
88
|
+
// `false` disables the human one: an absent or malformed flag keeps the
|
|
89
|
+
// permissive default, so a typo advertises a skill instead of hiding it.
|
|
90
|
+
invocation: {
|
|
91
|
+
modelInvocable: parsed.data['disable-model-invocation'] !== true,
|
|
92
|
+
userInvocable: parsed.data['user-invocable'] !== false,
|
|
93
|
+
},
|
|
94
|
+
content: parsed.body.trim(),
|
|
95
|
+
metadata,
|
|
96
|
+
path,
|
|
97
|
+
directory: dirname(path),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read every valid skill directory under `skillsDir`.
|
|
102
|
+
*
|
|
103
|
+
* A missing directory, a directory without `SKILL.md`, a file with unreadable
|
|
104
|
+
* frontmatter, and a file with a missing description are reported through
|
|
105
|
+
* `onWarn` and skipped: one broken file must not cost the catalog its other
|
|
106
|
+
* skills.
|
|
107
|
+
* @param skillsDir - directory holding one subdirectory per skill.
|
|
108
|
+
* @param onWarn - optional non-fatal problem sink.
|
|
109
|
+
* @returns the parsed skills, sorted by name.
|
|
110
|
+
*/
|
|
111
|
+
export async function discoverSkills(skillsDir, onWarn) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
onWarn?.(`cannot read skills directory ${skillsDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const skills = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!entry.isDirectory())
|
|
123
|
+
continue;
|
|
124
|
+
const path = join(skillsDir, entry.name, INSTRUCTION_FILE);
|
|
125
|
+
const skill = await readSkillFile(path, onWarn, entry.name);
|
|
126
|
+
if (skill !== undefined)
|
|
127
|
+
skills.push(skill);
|
|
128
|
+
}
|
|
129
|
+
return skills.sort((left, right) => left.name.localeCompare(right.name));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Build the provider the skill registry mounts.
|
|
133
|
+
* @param options - skills directory and the non-fatal problem sink.
|
|
134
|
+
* @returns a provider whose candidates are summaries and whose bodies come from disk.
|
|
135
|
+
*/
|
|
136
|
+
export function createSkillProvider(options) {
|
|
137
|
+
const summaryOf = (skill) => ({
|
|
138
|
+
path: skill.path,
|
|
139
|
+
name: skill.name,
|
|
140
|
+
description: skill.description,
|
|
141
|
+
...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
|
|
142
|
+
invocation: skill.invocation,
|
|
143
|
+
source: 'bundled',
|
|
144
|
+
provider: PROVIDER_NAME,
|
|
145
|
+
resourceBase: { kind: 'directory', path: skill.directory },
|
|
146
|
+
});
|
|
147
|
+
/**
|
|
148
|
+
* Discovery result per skills directory, keyed by directory.
|
|
149
|
+
*
|
|
150
|
+
* A packaged `skills/` tree is immutable in place, so a discovery that
|
|
151
|
+
* completed has nothing that could invalidate it: the entry is bounded by the
|
|
152
|
+
* number of directories this provider was built for, which is one. A call
|
|
153
|
+
* that was aborted is never stored, so it keeps re-reading the tree.
|
|
154
|
+
*/
|
|
155
|
+
const catalogs = new Map();
|
|
156
|
+
const discoverOnce = (signal) => {
|
|
157
|
+
const cached = catalogs.get(options.skillsDir);
|
|
158
|
+
if (cached !== undefined)
|
|
159
|
+
return cached;
|
|
160
|
+
let complete = true;
|
|
161
|
+
const pending = discoverSkills(options.skillsDir, (message) => {
|
|
162
|
+
complete = false;
|
|
163
|
+
options.onWarn?.(message);
|
|
164
|
+
}).then((skills) => ({ skills, complete }));
|
|
165
|
+
// Store after the read settles, and only while the caller still wants it:
|
|
166
|
+
// an aborted call must read the tree on its next attempt.
|
|
167
|
+
pending.then(({ complete }) => { if (complete && signal?.aborted !== true)
|
|
168
|
+
catalogs.set(options.skillsDir, pending); }, () => { });
|
|
169
|
+
return pending;
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
name: PROVIDER_NAME,
|
|
173
|
+
// The packaged skills are immutable in place, so there is nothing to
|
|
174
|
+
// invalidate and no watcher to own. `list`/`get` honor the caller's abort
|
|
175
|
+
// signal only at their own await boundaries: a caller that aborts mid-read
|
|
176
|
+
// gets no candidates rather than a later answer it stopped waiting for.
|
|
177
|
+
async list(lookup = {}) {
|
|
178
|
+
if (lookup.signal?.aborted)
|
|
179
|
+
return [];
|
|
180
|
+
const { skills, complete } = await discoverOnce(lookup.signal);
|
|
181
|
+
if (lookup.signal?.aborted)
|
|
182
|
+
return [];
|
|
183
|
+
const candidates = skills.map((skill) => ({
|
|
184
|
+
...summaryOf(skill),
|
|
185
|
+
rank: BUNDLED_SKILL_RANK,
|
|
186
|
+
locator: skill.path,
|
|
187
|
+
metadata: skill.metadata,
|
|
188
|
+
}));
|
|
189
|
+
return complete ? candidates : { candidates, complete: false };
|
|
190
|
+
},
|
|
191
|
+
async get(candidate, lookup = {}) {
|
|
192
|
+
if (typeof candidate.locator !== 'string')
|
|
193
|
+
return undefined;
|
|
194
|
+
if (lookup.signal?.aborted)
|
|
195
|
+
return undefined;
|
|
196
|
+
// Read the locator directly: one file instead of a full re-discovery.
|
|
197
|
+
// The directory name is the same fallback discovery used, so a skill
|
|
198
|
+
// whose frontmatter omits `name` loads under the name `list` reported.
|
|
199
|
+
// The name check keeps a stale candidate (path reused by another skill)
|
|
200
|
+
// from loading under the wrong identity.
|
|
201
|
+
const skill = await readSkillFile(candidate.locator, options.onWarn, basename(dirname(candidate.locator)));
|
|
202
|
+
if (lookup.signal?.aborted)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (skill === undefined || skill.name !== candidate.name)
|
|
205
|
+
return undefined;
|
|
206
|
+
return { ...summaryOf(skill), content: skill.content, metadata: skill.metadata };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/** Parsed frontmatter plus the markdown body that follows it. */
|
|
21
|
+
interface Frontmatter {
|
|
22
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
23
|
+
/** Everything after the closing delimiter, or the whole source when absent. */
|
|
24
|
+
readonly body: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Split a document into its frontmatter block and the body that follows it.
|
|
28
|
+
*
|
|
29
|
+
* Pure text, no parsing: the body is the same either way, so a caller that
|
|
30
|
+
* needs only the body (the always-on ruleset, read once at mount) never
|
|
31
|
+
* touches a parser at all.
|
|
32
|
+
* @param source - full file contents.
|
|
33
|
+
* @returns the block's lines and the remaining body.
|
|
34
|
+
*/
|
|
35
|
+
export declare function splitFrontmatter(source: string): {
|
|
36
|
+
readonly block: string;
|
|
37
|
+
readonly body: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Parse leading YAML frontmatter from a markdown document.
|
|
41
|
+
* @param source - full file contents.
|
|
42
|
+
* @returns the parsed keys and the remaining body.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseFrontmatter(source: string): Promise<Frontmatter>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
import type { SkillCandidate, SkillDefinition, SkillLookupOptions, SkillProvider, SkillProviderObservation } from '@deepseek-ai/dsh-skill';
|
|
13
|
+
import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
|
|
14
|
+
/** Disposer returned by every host registration. */
|
|
15
|
+
type Disposable = () => void;
|
|
16
|
+
/** One contributed system-prompt section. */
|
|
17
|
+
export interface PromptSectionContribution {
|
|
18
|
+
/** Unique section name across the composition. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/** Ascending concatenation position. */
|
|
21
|
+
readonly order: number;
|
|
22
|
+
/** Static text, or a provider evaluated at each assembly (empty text is dropped). */
|
|
23
|
+
readonly text: string | ((context: unknown) => string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A {@link SkillProvider} whose lookup may be omitted by direct callers.
|
|
27
|
+
*/
|
|
28
|
+
export type SkillProviderLike = Omit<SkillProvider, 'list' | 'get'> & {
|
|
29
|
+
list(options?: SkillLookupOptions): Promise<readonly SkillCandidate[] | SkillProviderObservation>;
|
|
30
|
+
get(candidate: SkillCandidate, options?: SkillLookupOptions): Promise<SkillDefinition | undefined>;
|
|
31
|
+
};
|
|
32
|
+
/** Invocation handed to a registered human command. */
|
|
33
|
+
export interface CommandInvocationLike {
|
|
34
|
+
/** Text following the command name, including separator whitespace. */
|
|
35
|
+
readonly rawInput: string;
|
|
36
|
+
}
|
|
37
|
+
/** Direct-UI outcome of a human command. */
|
|
38
|
+
export type CommandResultLike = {
|
|
39
|
+
readonly kind: 'success';
|
|
40
|
+
readonly text?: string;
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: 'error';
|
|
43
|
+
readonly text: string;
|
|
44
|
+
};
|
|
45
|
+
/** A plugin-owned human command. */
|
|
46
|
+
export interface CommandDefinitionLike {
|
|
47
|
+
/** Lowercase command name without the leading slash. */
|
|
48
|
+
readonly name: string;
|
|
49
|
+
/** Summary used in discovery UI. */
|
|
50
|
+
readonly description: string;
|
|
51
|
+
/** Optional free-form input hint. */
|
|
52
|
+
readonly input?: {
|
|
53
|
+
readonly hint: string;
|
|
54
|
+
};
|
|
55
|
+
/** Execute against the receiving agent without a model message. */
|
|
56
|
+
handler(invocation: CommandInvocationLike): CommandResultLike | Promise<CommandResultLike>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The slice of a durable session message the deactivation watcher reads.
|
|
60
|
+
*
|
|
61
|
+
* `source.kind === 'user'` is what separates the human's own words from the
|
|
62
|
+
* context the harness injects into the same event stream (skill bodies,
|
|
63
|
+
* references, replayed history).
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionMessageLike {
|
|
66
|
+
/** Content blocks; only `text` blocks carry words. */
|
|
67
|
+
readonly content?: readonly {
|
|
68
|
+
readonly type?: string;
|
|
69
|
+
readonly text?: string;
|
|
70
|
+
}[] | undefined;
|
|
71
|
+
/** Provenance of the message. */
|
|
72
|
+
readonly source?: {
|
|
73
|
+
readonly kind?: string;
|
|
74
|
+
} | undefined;
|
|
75
|
+
}
|
|
76
|
+
/** One durable session event, as `session/event` delivers it. */
|
|
77
|
+
export interface SessionEventLike {
|
|
78
|
+
/** Event discriminator, e.g. `user/message`. */
|
|
79
|
+
readonly type?: string;
|
|
80
|
+
/** Event payload; a {@link SessionMessageLike} for `user/message`. */
|
|
81
|
+
readonly data?: unknown;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Structural view of the Cordis context the plugin uses.
|
|
85
|
+
*
|
|
86
|
+
* Members are only reached inside the matching `inject` callback, where the
|
|
87
|
+
* host guarantees the service is present, or through {@link HostContext.get}
|
|
88
|
+
* at the use site.
|
|
89
|
+
*/
|
|
90
|
+
export interface HostContext {
|
|
91
|
+
/** Run `callback` once the named services are available; the return is a fiber. */
|
|
92
|
+
inject(dependencies: readonly string[], callback: (scope: HostContext) => void): unknown;
|
|
93
|
+
/** Bind a registration's disposer to the calling fiber. */
|
|
94
|
+
effect(callback: () => Disposable, label?: string): unknown;
|
|
95
|
+
/** Query a mounted service, or `undefined` while none is mounted. */
|
|
96
|
+
get(service: string): unknown;
|
|
97
|
+
/** Subscribe to a host event; the returned disposer removes the listener. */
|
|
98
|
+
on(event: 'session/event', listener: (session: unknown, event: SessionEventLike) => void): Disposable;
|
|
99
|
+
on(event: 'loader/volatile-update', listener: () => void): Disposable;
|
|
100
|
+
/** Owning fiber, present once the loader mounted this plugin. */
|
|
101
|
+
readonly fiber?: {
|
|
102
|
+
readonly entry?: {
|
|
103
|
+
readonly options?: {
|
|
104
|
+
readonly id?: string;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
readonly systemPrompt: {
|
|
109
|
+
section(section: PromptSectionContribution): Disposable;
|
|
110
|
+
};
|
|
111
|
+
readonly skills: {
|
|
112
|
+
registerProvider(create: () => SkillProviderLike): Disposable;
|
|
113
|
+
};
|
|
114
|
+
readonly tools: {
|
|
115
|
+
register(definition: ToolDefinition): Disposable;
|
|
116
|
+
};
|
|
117
|
+
readonly commands: {
|
|
118
|
+
register(definition: CommandDefinitionLike): Disposable;
|
|
119
|
+
};
|
|
120
|
+
readonly webServer: WebServerLike;
|
|
121
|
+
readonly connection: ConnectionLike;
|
|
122
|
+
}
|
|
123
|
+
/** The slice of an incoming HTTP request the level route reads. */
|
|
124
|
+
export interface RequestLike {
|
|
125
|
+
readonly method?: string | undefined;
|
|
126
|
+
readonly headers: object | undefined;
|
|
127
|
+
}
|
|
128
|
+
/** The slice of an HTTP response the level route writes. */
|
|
129
|
+
export interface ResponseLike {
|
|
130
|
+
statusCode: number;
|
|
131
|
+
setHeader(name: string, value: string): void;
|
|
132
|
+
end(body?: string): void;
|
|
133
|
+
}
|
|
134
|
+
/** The slice of the `webServer` service this plugin registers on. */
|
|
135
|
+
export interface WebServerLike {
|
|
136
|
+
/** Register one route; a duplicate path throws. */
|
|
137
|
+
register(route: {
|
|
138
|
+
readonly kind: 'exact';
|
|
139
|
+
readonly path: string;
|
|
140
|
+
handler(req: RequestLike, res: ResponseLike): void | Promise<void>;
|
|
141
|
+
}): Disposable;
|
|
142
|
+
}
|
|
143
|
+
/** The composition's trust fence for HTTP requests. */
|
|
144
|
+
export interface ConnectionLike {
|
|
145
|
+
/** The rejection status for an untrusted or unauthenticated request, else `undefined`. */
|
|
146
|
+
requestRejection(request: {
|
|
147
|
+
readonly headers: object | undefined;
|
|
148
|
+
}): 401 | 403 | undefined;
|
|
149
|
+
}
|
|
150
|
+
/** The slice of the settings service this plugin uses. */
|
|
151
|
+
export interface SettingsServiceLike {
|
|
152
|
+
/**
|
|
153
|
+
* Merge fields into one profile entry. `ns` is the entry id.
|
|
154
|
+
* @param ns - profile entry id.
|
|
155
|
+
* @param patch - fields to write.
|
|
156
|
+
*/
|
|
157
|
+
update(ns: string, patch: Record<string, unknown>): Promise<void>;
|
|
158
|
+
}
|
|
159
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
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 type { Volatile } from '@deepseek-ai/cordis';
|
|
22
|
+
import z from '@deepseek-ai/schemastery';
|
|
23
|
+
import { type RuntimeMode } from './modes.ts';
|
|
24
|
+
import type { HostContext } from './host.ts';
|
|
25
|
+
/** Plugin name as it appears in the loader. */
|
|
26
|
+
export declare const name = "ponytail";
|
|
27
|
+
/**
|
|
28
|
+
* Route the browser half reads for the level in use and its source. The card
|
|
29
|
+
* and the chip cannot see a session-local level (`review`, or one the settings
|
|
30
|
+
* document refused), so they ask the host instead of the settings document.
|
|
31
|
+
*/
|
|
32
|
+
export declare const LEVEL_ROUTE = "/ponytail/level";
|
|
33
|
+
/**
|
|
34
|
+
* Configuration accepted from this plugin's row in a profile patch.
|
|
35
|
+
*
|
|
36
|
+
* The level is defaulted in the schema below, so the loader fills an absent
|
|
37
|
+
* `defaultMode` with `full` before {@link apply} runs; an invalid value still
|
|
38
|
+
* fails at load, because the union rejects it. The field is volatile, so the
|
|
39
|
+
* value arrives as a stable reference the plugin reads with `.get()`.
|
|
40
|
+
*/
|
|
41
|
+
export interface Config {
|
|
42
|
+
/** Startup level. The schema default fills `full`. */
|
|
43
|
+
readonly defaultMode: Volatile<RuntimeMode>;
|
|
44
|
+
}
|
|
45
|
+
/** Row schema: an absent level is filled by the loader before `apply`. */
|
|
46
|
+
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
47
|
+
defaultMode: z<"full" | "lite" | "off" | "ultra", "full" | "lite" | "off" | "ultra", "volatile-defined">;
|
|
48
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
49
|
+
defaultMode: z<"full" | "lite" | "off" | "ultra", "full" | "lite" | "off" | "ultra", "volatile-defined">;
|
|
50
|
+
}>>, "plain">;
|
|
51
|
+
/**
|
|
52
|
+
* Mount the plugin.
|
|
53
|
+
* @param ctx - the host context.
|
|
54
|
+
* @param config - the schema-resolved row; the loader always passes one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function apply(ctx: HostContext, config: Config): void;
|