dsh-continual-evolve 0.15.3 → 0.15.5

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/lib/extraction.js CHANGED
@@ -3,11 +3,57 @@ import { EXTRACTION_SYSTEM_PROMPT, extractionPrompt, manifestLineOf, parseExtrac
3
3
  import { cwdOf } from "./memory-section.js";
4
4
  /** Head length of a failed run's raw answer kept in the ledger `files` column. */
5
5
  const FAILURE_RAW_MAX = 2048;
6
+ /** Head length of an unexpected error's message/stack trace kept in the ledger `files` column. */
7
+ const UNEXPECTED_ERROR_RAW_MAX = 2048;
8
+ /** Head length of an error message embedded in a ledger `skip_reason`. */
9
+ const ERROR_REASON_MAX = 300;
10
+ /** Split an unexpected throw into a ledger-sized reason head and file body. */
11
+ function errorText(error) {
12
+ const message = error instanceof Error && error.message.length > 0 ? error.message : String(error);
13
+ const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : message;
14
+ return { message, detail: `${message}\n${stack}`.slice(0, UNEXPECTED_ERROR_RAW_MAX) };
15
+ }
6
16
  /**
7
- * Run one extraction pass for a target. Never throws: every terminal path
8
- * is either a landed application, a ledgered skip, or a ledgered failure.
17
+ * Run one extraction pass for a target. Never throws unless the ledger
18
+ * itself is unwritable: every terminal path is either a landed application,
19
+ * a ledgered skip, or a ledgered failure — anything that escapes the
20
+ * per-stage handling inside lands a failed `unexpected-error` ledger row
21
+ * here, so no failure ends as a scheduler warn without an account trace.
9
22
  */
10
23
  export async function runExtraction(deps, target, opts) {
24
+ const started = Date.now();
25
+ try {
26
+ return await runExtractionInner(deps, target, opts);
27
+ }
28
+ catch (error) {
29
+ const { message, detail } = errorText(error);
30
+ const reason = `unexpected-error: ${message.slice(0, ERROR_REASON_MAX)}`;
31
+ try {
32
+ deps.store.logSkipped({
33
+ sessionId: target.sessionId,
34
+ workspaceId: target.workspaceId,
35
+ trigger: opts.trigger,
36
+ skipReason: reason,
37
+ status: "failed",
38
+ model: target.model,
39
+ durationMs: Date.now() - started,
40
+ files: detail,
41
+ });
42
+ }
43
+ catch (ledgerError) {
44
+ // The ledger itself is down — nothing left to write to. Chain
45
+ // both errors so the scheduler's last-resort row keeps the
46
+ // triggering failure, not just the ledger fault.
47
+ throw new Error(`extraction failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
48
+ }
49
+ return { status: "failed", skipReason: reason };
50
+ }
51
+ }
52
+ /**
53
+ * The pipeline body: one bounded increment of one session, one LLM call, one
54
+ * gated transactional application, one ledger row, one cursor.
55
+ */
56
+ async function runExtractionInner(deps, target, opts) {
11
57
  const { store } = deps;
12
58
  const ledgerBase = {
13
59
  sessionId: target.sessionId,
@@ -37,8 +83,14 @@ export async function runExtraction(deps, target, opts) {
37
83
  all = Array.isArray(snapshot.events) ? snapshot.events : [];
38
84
  }
39
85
  catch (error) {
40
- store.logSkipped({ ...ledgerBase, skipReason: "surface-read-failed", status: "failed" });
41
- return { status: "failed", skipReason: `surface read failed: ${error instanceof Error ? error.message : String(error)}` };
86
+ const { message, detail } = errorText(error);
87
+ try {
88
+ store.logSkipped({ ...ledgerBase, skipReason: "surface-read-failed", status: "failed", files: detail });
89
+ }
90
+ catch (ledgerError) {
91
+ throw new Error(`surface read failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
92
+ }
93
+ return { status: "failed", skipReason: `surface read failed: ${message}` };
42
94
  }
43
95
  const events = eventsAfterCursor(all, cursor === undefined ? undefined : `seq:${cursor}`);
44
96
  const slice = evaluateSlice({ events, internalAgent: false });
@@ -72,8 +124,14 @@ export async function runExtraction(deps, target, opts) {
72
124
  usage = answer.usage;
73
125
  }
74
126
  catch (error) {
75
- store.logSkipped({ ...ledgerBase, skipReason: "llm-failed", status: "failed", durationMs: Date.now() - started });
76
- return { status: "failed", skipReason: `llm call failed: ${error instanceof Error ? error.message : String(error)}` };
127
+ const { message, detail } = errorText(error);
128
+ try {
129
+ store.logSkipped({ ...ledgerBase, skipReason: "llm-failed", status: "failed", durationMs: Date.now() - started, files: detail });
130
+ }
131
+ catch (ledgerError) {
132
+ throw new Error(`llm call failed (${message}); failure ledger write also failed (${errorText(ledgerError).message})`);
133
+ }
134
+ return { status: "failed", skipReason: `llm call failed: ${message}` };
77
135
  }
78
136
  const parsed = parseExtractionAnswer(text);
79
137
  if (parsed.decision === "error") {
@@ -213,7 +271,26 @@ export function createExtractionScheduler(host, deps) {
213
271
  provider: target.provider,
214
272
  model: target.model,
215
273
  }, { trigger }).catch((error) => {
216
- host.logger("continual-evolve").warn(`extraction run failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
274
+ // Last resort: runExtraction ledgers its own unexpected failures,
275
+ // so this fires only when even that failed (the ledger write
276
+ // threw) — retry one ledger row here before the warn, so the
277
+ // failure still has an account trace.
278
+ const { message, detail } = errorText(error);
279
+ try {
280
+ deps.store.logSkipped({
281
+ sessionId: target.sessionId,
282
+ workspaceId: cwdOf(target.agent),
283
+ trigger,
284
+ skipReason: `unexpected-error: ${message.slice(0, ERROR_REASON_MAX)}`,
285
+ status: "failed",
286
+ model: target.model,
287
+ files: detail,
288
+ });
289
+ }
290
+ catch {
291
+ // The ledger is down — the warn below is the last resort.
292
+ }
293
+ host.logger("continual-evolve").warn(`extraction run failed unexpectedly: ${message}`);
217
294
  }).finally(() => {
218
295
  slot.running = false;
219
296
  if (slot.pending !== null) {
package/lib/index.d.ts CHANGED
@@ -9,8 +9,10 @@
9
9
  *
10
10
  * - the session-start section (`memory-section.ts`): a store query rendered
11
11
  * as the injected index, frozen per session for the prompt cache;
12
- * - the model tools (`memory-tools.ts`): `memory_write` for explicit
13
- * "记住/忘掉" instructions, `memory_read` for bodies and search;
12
+ * - the model tools (`memory-tools.ts`, `skill-tools.ts`): `memory_write`
13
+ * for explicit "记住/忘掉" instructions, `memory_read` for bodies and
14
+ * search, plus `skill_write` (project-only creation, manual move to
15
+ * global) and `skill_read` (project shadowing global);
14
16
  * - the extraction scheduler (`extraction.ts`): turn-level, per-session
15
17
  * slots, single-flight per session; proposals land only through the
16
18
  * store's gates;
package/lib/index.js CHANGED
@@ -9,8 +9,10 @@
9
9
  *
10
10
  * - the session-start section (`memory-section.ts`): a store query rendered
11
11
  * as the injected index, frozen per session for the prompt cache;
12
- * - the model tools (`memory-tools.ts`): `memory_write` for explicit
13
- * "记住/忘掉" instructions, `memory_read` for bodies and search;
12
+ * - the model tools (`memory-tools.ts`, `skill-tools.ts`): `memory_write`
13
+ * for explicit "记住/忘掉" instructions, `memory_read` for bodies and
14
+ * search, plus `skill_write` (project-only creation, manual move to
15
+ * global) and `skill_read` (project shadowing global);
14
16
  * - the extraction scheduler (`extraction.ts`): turn-level, per-session
15
17
  * slots, single-flight per session; proposals land only through the
16
18
  * store's gates;
@@ -31,6 +33,7 @@ import { mountCardRoutes } from "./card-routes.js";
31
33
  import { createExtractionScheduler } from "./extraction.js";
32
34
  import { createFrozenMemorySection, cwdOf, DEFAULT_MAX_CHARS, DEFAULT_MEMORY_SECTION_ORDER, MEMORY_SECTION_NAME, memorySectionText, } from "./memory-section.js";
33
35
  import { registerMemoryTools } from "./memory-tools.js";
36
+ import { registerSkillTools } from "./skill-tools.js";
34
37
  import { importWorkspaceMd } from "./import-md.js";
35
38
  import { openMemoryStore } from "./store.js";
36
39
  import { newestRegistryPath } from "./workspace-hint.js";
@@ -80,8 +83,10 @@ function mountToolsWhenAvailable(host, store, state) {
80
83
  return;
81
84
  }
82
85
  registerMemoryTools(tools, store);
86
+ registerSkillTools(tools, store);
83
87
  state.toolsAvailable = true;
84
88
  host.logger("continual-evolve").info("memory_write and memory_read registered");
89
+ host.logger("continual-evolve").info("skill_write and skill_read registered");
85
90
  });
86
91
  }
87
92
  /**
@@ -63,4 +63,56 @@ export declare function listCardWorkspaces(store: MemoryStore): {
63
63
  root: string;
64
64
  label: string;
65
65
  }[];
66
+ /** One skill row the card renders (scope resolved: project shadows global). */
67
+ export interface SkillFileInfo {
68
+ id: string;
69
+ title: string;
70
+ description: string;
71
+ /** `project` or `global` — which partition the row was read from. */
72
+ scope: string;
73
+ /** Row status; quarantined rows render in the patrol section, not here. */
74
+ status: string;
75
+ /** Update time in epoch milliseconds (the card's "updated" line). */
76
+ updatedAt: number;
77
+ }
78
+ /** The full read-only skill snapshot the card renders for one workspace root. */
79
+ export interface SkillSnapshot {
80
+ root: string;
81
+ exists: boolean;
82
+ /** Set when the store query failed; null otherwise. */
83
+ readError: string | null;
84
+ /** Active row count (the number the card's stats line shows). */
85
+ fileCount: number;
86
+ /** Active rows, project first (the list the card renders). */
87
+ files: SkillFileInfo[];
88
+ /** Rows quarantined by patrol — displayed as 巡检异常, never in the list. */
89
+ quarantined: {
90
+ id: string;
91
+ scope: string;
92
+ reason: string;
93
+ }[];
94
+ }
95
+ /**
96
+ * The read-only skill snapshot for one workspace partition. Mirrors the
97
+ * memory snapshot's degrade contract: unknown partitions read `exists:
98
+ * false`, failing queries report `readError` instead of throwing.
99
+ */
100
+ export declare function skillSnapshot(store: MemoryStore, root: string): SkillSnapshot;
101
+ /** The content endpoint's outcomes for one skill body. */
102
+ export type SkillEntryContent = {
103
+ ok: true;
104
+ id: string;
105
+ scope: string;
106
+ content: string;
107
+ updatedAtMs: number;
108
+ } | {
109
+ ok: false;
110
+ reason: "absent" | "too-large";
111
+ };
112
+ /**
113
+ * Read one skill's body for the card's content preview — project shadowing
114
+ * global, keyed by id, never by file name. Bodies are schema-capped at 64KB,
115
+ * so `too-large` is dead code kept for the day the preview limit shrinks.
116
+ */
117
+ export declare function skillEntryContent(store: MemoryStore, root: string, id: string): SkillEntryContent;
66
118
  //# sourceMappingURL=memory-snapshot.d.ts.map
@@ -127,13 +127,109 @@ export function memoryEntryContent(store, root, id) {
127
127
  /** The workspace rows the card lists: active partitions whose directory survives. */
128
128
  export function listCardWorkspaces(store) {
129
129
  try {
130
- return store
131
- .listWorkspaces()
132
- .filter((workspace) => existsSync(workspace.workspaceId))
133
- .map((workspace) => ({ root: workspace.workspaceId, label: basename(workspace.workspaceId) }));
130
+ const seen = new Set();
131
+ const rows = [];
132
+ // One fence for every card route: partitions holding memories or
133
+ // skills. A skills-only workspace lists (its memory snapshot reads
134
+ // empty); global skills ride along with whichever workspace is open.
135
+ for (const workspace of [...store.listWorkspaces(), ...store.listSkillWorkspaces()]) {
136
+ if (workspace.workspaceId === "" || seen.has(workspace.workspaceId)) {
137
+ continue;
138
+ }
139
+ seen.add(workspace.workspaceId);
140
+ if (!existsSync(workspace.workspaceId)) {
141
+ continue;
142
+ }
143
+ rows.push({ root: workspace.workspaceId, label: basename(workspace.workspaceId) });
144
+ }
145
+ return rows;
134
146
  }
135
147
  catch {
136
148
  return [];
137
149
  }
138
150
  }
151
+ /** Project-first merge of one partition's skills with the global scope. */
152
+ function scopedSkills(store, root) {
153
+ const seen = new Set();
154
+ const merged = [];
155
+ for (const record of store.listAllSkills(root)) {
156
+ if (seen.has(record.id)) {
157
+ continue;
158
+ }
159
+ seen.add(record.id);
160
+ merged.push({ record, scope: "project" });
161
+ }
162
+ for (const record of store.listAllSkills("")) {
163
+ if (seen.has(record.id)) {
164
+ continue;
165
+ }
166
+ seen.add(record.id);
167
+ merged.push({ record, scope: "global" });
168
+ }
169
+ return merged;
170
+ }
171
+ function skillInfoOf(entry) {
172
+ return {
173
+ id: entry.record.id,
174
+ title: entry.record.title,
175
+ description: entry.record.description,
176
+ scope: entry.scope,
177
+ status: entry.record.status,
178
+ updatedAt: Date.parse(entry.record.updatedAt) || 0,
179
+ };
180
+ }
181
+ /**
182
+ * The read-only skill snapshot for one workspace partition. Mirrors the
183
+ * memory snapshot's degrade contract: unknown partitions read `exists:
184
+ * false`, failing queries report `readError` instead of throwing.
185
+ */
186
+ export function skillSnapshot(store, root) {
187
+ const absent = { root, exists: false, readError: null, fileCount: 0, files: [], quarantined: [] };
188
+ let entries;
189
+ try {
190
+ entries = scopedSkills(store, root);
191
+ if (entries.length === 0) {
192
+ return absent;
193
+ }
194
+ }
195
+ catch (error) {
196
+ return { ...absent, exists: true, readError: error instanceof Error ? error.message : String(error) };
197
+ }
198
+ return {
199
+ root,
200
+ exists: true,
201
+ readError: null,
202
+ fileCount: entries.filter((entry) => entry.record.status === "active").length,
203
+ files: entries.filter((entry) => entry.record.status === "active").map(skillInfoOf),
204
+ quarantined: entries
205
+ .filter((entry) => entry.record.status === "quarantined")
206
+ .map((entry) => ({ id: entry.record.id, scope: entry.scope, reason: quarantineReasonOf(store, entry.record) })),
207
+ };
208
+ }
209
+ /**
210
+ * Read one skill's body for the card's content preview — project shadowing
211
+ * global, keyed by id, never by file name. Bodies are schema-capped at 64KB,
212
+ * so `too-large` is dead code kept for the day the preview limit shrinks.
213
+ */
214
+ export function skillEntryContent(store, root, id) {
215
+ if (id.length === 0) {
216
+ return { ok: false, reason: "absent" };
217
+ }
218
+ let entries;
219
+ try {
220
+ entries = scopedSkills(store, root);
221
+ }
222
+ catch {
223
+ return { ok: false, reason: "absent" };
224
+ }
225
+ const hit = entries.find((entry) => entry.record.id === id);
226
+ if (hit === undefined) {
227
+ return { ok: false, reason: "absent" };
228
+ }
229
+ /* v8 ignore next 3 */
230
+ if (hit.record.body.length > MEMORY_FILE_PREVIEW_LIMIT) {
231
+ return { ok: false, reason: "too-large" };
232
+ }
233
+ return { ok: true, id: hit.record.id, scope: hit.scope, content: hit.record.body, updatedAtMs: Date.parse(hit.record.updatedAt) || 0 };
234
+ }
139
235
  //# sourceMappingURL=memory-snapshot.js.map
@@ -0,0 +1,73 @@
1
+ import { isValidMemoryId, slugifyId } from "./memory-rules.js";
2
+ export { isValidMemoryId as isValidSkillId, slugifyId as slugifySkillId };
3
+ /** Skill scopes: project rows live in their workspace partition, global rows in `""`. */
4
+ export declare const SKILL_SCOPES: readonly ["project", "global"];
5
+ export type SkillScope = (typeof SKILL_SCOPES)[number];
6
+ /** The global partition id (shared with the memory store's global state rows). */
7
+ export declare const SKILL_GLOBAL_WORKSPACE = "";
8
+ /** Max rendered frontmatter description length (loaders truncate anyway). */
9
+ export declare const MAX_SKILL_DESCRIPTION_LENGTH = 240;
10
+ /**
11
+ * Marker carried by every materialized file, right after the frontmatter.
12
+ * Patrol only touches files carrying it — hand-written skills in the same
13
+ * directories are never rewritten or removed.
14
+ */
15
+ export declare const SKILL_GENERATED_MARKER = "<!-- generated by dsh-continual-evolve \u2014 the central store is the source of truth; edit via skill_write, manual edits are reverted by patrol -->";
16
+ /** One proposed skill mutation — the closed shape the write paths speak. */
17
+ export interface SkillProposal {
18
+ action: "create" | "update" | "delete" | "move-to-global";
19
+ /** Required for update/delete/move-to-global; optional for create (derived from title). */
20
+ id?: string | undefined;
21
+ /** Which partition update/delete targets. Create always lands in the project. */
22
+ scope?: string | undefined;
23
+ title?: string | undefined;
24
+ description?: string | undefined;
25
+ body?: string | undefined;
26
+ /** Trajectory provenance ("820-831"), kept for parity with memory proposals. */
27
+ sourceSeqs?: string | undefined;
28
+ }
29
+ /** The merged field values a create/update would write (delete checks none). */
30
+ export interface SkillFields {
31
+ title: string;
32
+ description: string;
33
+ body: string;
34
+ }
35
+ /**
36
+ * Gates that depend only on the merged field values (not on store state).
37
+ * Runs the secret screen over the full visible text so a secret smuggled
38
+ * into any field blocks the write.
39
+ */
40
+ export declare function skillFieldGateReasons(fields: SkillFields): string[];
41
+ /**
42
+ * Render a skill as a discoverable SKILL.md document. The frontmatter
43
+ * description carries a routing hint — title plus the first meaningful body
44
+ * line — instead of the bare title: the skill catalog matches on
45
+ * description, and a title-only description gives loaders nothing to route
46
+ * on.
47
+ */
48
+ export declare function renderSkillMarkdown(skill: {
49
+ id: string;
50
+ title: string;
51
+ description: string;
52
+ body: string;
53
+ }): string;
54
+ /**
55
+ * Mechanical problems that would make the platform's skill loader ignore the
56
+ * rendered file. The file is still written, but the caller surfaces these:
57
+ * a silently ignored skill is worse than a noisy one.
58
+ */
59
+ export declare function validateRenderedSkill(skill: {
60
+ id: string;
61
+ description: string;
62
+ }): string[];
63
+ /** Where project skills materialize: the workspace's own discovered skill directory. */
64
+ export declare function projectSkillsDir(workspaceId: string): string;
65
+ /** Where global skills materialize: the host-wide discovered skill directory. */
66
+ export declare function globalSkillsDir(): string;
67
+ /**
68
+ * Resolve and defend the materialized directory for a skill id. Ids are
69
+ * kebab slugs by gate, but the containment check stays: a caller bug must
70
+ * never turn a skill write into a write elsewhere.
71
+ */
72
+ export declare function skillDir(root: string, id: string): string;
73
+ //# sourceMappingURL=skill-rules.d.ts.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The mechanical write gates every skill passes before the store accepts it
3
+ * — the same "模型提议,代码保证" boundary as memories, minus the
4
+ * taxonomy: skills carry no type enum and no feedback body contract. Both
5
+ * entry points (the explicit `skill_write` tool and the manual move to the
6
+ * global scope) funnel through these checks; nothing reaches the database or
7
+ * the materialized files without them.
8
+ *
9
+ * File rendering follows the previously validated shape (pre-teardown
10
+ * `skill-render.ts`, itself checked against the platform loader): YAML-ish
11
+ * frontmatter (`name` + `description` carrying a routing hint) over the body.
12
+ * The official skill-creator template is no longer shipped locally, so the
13
+ * live host preset skills (same frontmatter contract) are the format anchor.
14
+ */
15
+ import { homedir } from "node:os";
16
+ import { join, resolve, sep } from "node:path";
17
+ import { isValidMemoryId, MEMORY_BODY_LIMIT, MEMORY_DESCRIPTION_LIMIT, MEMORY_TITLE_LIMIT, secretLeakReason, slugifyId, } from "./memory-rules.js";
18
+ export { isValidMemoryId as isValidSkillId, slugifyId as slugifySkillId };
19
+ /** Skill scopes: project rows live in their workspace partition, global rows in `""`. */
20
+ export const SKILL_SCOPES = ["project", "global"];
21
+ /** The global partition id (shared with the memory store's global state rows). */
22
+ export const SKILL_GLOBAL_WORKSPACE = "";
23
+ /** Max rendered frontmatter description length (loaders truncate anyway). */
24
+ export const MAX_SKILL_DESCRIPTION_LENGTH = 240;
25
+ /**
26
+ * Marker carried by every materialized file, right after the frontmatter.
27
+ * Patrol only touches files carrying it — hand-written skills in the same
28
+ * directories are never rewritten or removed.
29
+ */
30
+ export const SKILL_GENERATED_MARKER = "<!-- generated by dsh-continual-evolve — the central store is the source of truth; edit via skill_write, manual edits are reverted by patrol -->";
31
+ /**
32
+ * Gates that depend only on the merged field values (not on store state).
33
+ * Runs the secret screen over the full visible text so a secret smuggled
34
+ * into any field blocks the write.
35
+ */
36
+ export function skillFieldGateReasons(fields) {
37
+ const reasons = [];
38
+ if (fields.title.trim().length === 0) {
39
+ reasons.push("title must not be empty");
40
+ }
41
+ else if (fields.title.length > MEMORY_TITLE_LIMIT) {
42
+ reasons.push(`title exceeds ${MEMORY_TITLE_LIMIT} characters`);
43
+ }
44
+ if (fields.description.trim().length === 0) {
45
+ reasons.push("description must not be empty — it is the skill trigger");
46
+ }
47
+ else if (fields.description.length > MEMORY_DESCRIPTION_LIMIT) {
48
+ reasons.push(`description exceeds ${MEMORY_DESCRIPTION_LIMIT} characters`);
49
+ }
50
+ if (fields.body.trim().length === 0) {
51
+ reasons.push("body must not be empty");
52
+ }
53
+ else if (fields.body.length > MEMORY_BODY_LIMIT) {
54
+ reasons.push(`body exceeds the ${MEMORY_BODY_LIMIT}-character cap`);
55
+ }
56
+ const leak = secretLeakReason(`${fields.title}\n${fields.description}\n${fields.body}`);
57
+ if (leak !== undefined) {
58
+ reasons.push(leak);
59
+ }
60
+ return reasons;
61
+ }
62
+ /** Collapse prose to one line for the frontmatter description. */
63
+ function oneLine(text) {
64
+ return text.replace(/\s+/g, " ").trim();
65
+ }
66
+ /**
67
+ * First content line usable as a routing hint: non-empty, not a Markdown
68
+ * heading, not a list marker, not frontmatter. Undefined when the body is
69
+ * effectively empty.
70
+ */
71
+ function routingHint(body) {
72
+ for (const rawLine of body.split("\n")) {
73
+ const line = rawLine.trim();
74
+ if (line.length === 0)
75
+ continue;
76
+ if (line.startsWith("#") || line.startsWith("---") || line.startsWith("-") || line.startsWith("*"))
77
+ continue;
78
+ return oneLine(line);
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * Render a skill as a discoverable SKILL.md document. The frontmatter
84
+ * description carries a routing hint — title plus the first meaningful body
85
+ * line — instead of the bare title: the skill catalog matches on
86
+ * description, and a title-only description gives loaders nothing to route
87
+ * on.
88
+ */
89
+ export function renderSkillMarkdown(skill) {
90
+ const hint = routingHint(skill.body);
91
+ const stated = oneLine(skill.description);
92
+ const base = oneLine(skill.title);
93
+ // Prefer the author's own description; fall back to title + hint exactly
94
+ // like the pre-teardown renderer so re-materialized rows stay byte-stable.
95
+ let description;
96
+ if (stated.length > 0) {
97
+ description = stated;
98
+ }
99
+ else if (base.length === 0) {
100
+ description = (hint ?? "").slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
101
+ }
102
+ else if (hint !== undefined && !base.toLowerCase().includes(hint.toLowerCase())) {
103
+ description = `${base} — use when: ${hint}`.slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
104
+ }
105
+ else {
106
+ description = base.slice(0, MAX_SKILL_DESCRIPTION_LENGTH);
107
+ }
108
+ return ["---", `name: ${skill.id}`, `description: ${description}`, "---", "", SKILL_GENERATED_MARKER, "", skill.body.trim(), ""].join("\n");
109
+ }
110
+ /**
111
+ * Mechanical problems that would make the platform's skill loader ignore the
112
+ * rendered file. The file is still written, but the caller surfaces these:
113
+ * a silently ignored skill is worse than a noisy one.
114
+ */
115
+ export function validateRenderedSkill(skill) {
116
+ const problems = [];
117
+ if (!isValidMemoryId(skill.id)) {
118
+ problems.push(`name "${skill.id}" is not kebab-case — the platform ignores the file`);
119
+ }
120
+ const description = oneLine(skill.description);
121
+ if (description.length === 0) {
122
+ problems.push("empty description — the catalog has nothing to route on");
123
+ }
124
+ else if (description.length > MAX_SKILL_DESCRIPTION_LENGTH) {
125
+ problems.push(`description exceeds ${MAX_SKILL_DESCRIPTION_LENGTH} characters — loaders truncate it`);
126
+ }
127
+ if (/[\r\n]/.test(skill.description)) {
128
+ problems.push("description spans lines — frontmatter must stay one line");
129
+ }
130
+ return problems;
131
+ }
132
+ /** The DSH home the global skills directory lives under (same rule as the store path). */
133
+ function dshHome() {
134
+ const home = process.env.DSH_HOME?.trim();
135
+ return home !== undefined && home.length > 0 ? home : join(homedir(), ".dsh");
136
+ }
137
+ /** Where project skills materialize: the workspace's own discovered skill directory. */
138
+ export function projectSkillsDir(workspaceId) {
139
+ return join(workspaceId, ".agents", "skills");
140
+ }
141
+ /** Where global skills materialize: the host-wide discovered skill directory. */
142
+ export function globalSkillsDir() {
143
+ return join(dshHome(), "skills");
144
+ }
145
+ /**
146
+ * Resolve and defend the materialized directory for a skill id. Ids are
147
+ * kebab slugs by gate, but the containment check stays: a caller bug must
148
+ * never turn a skill write into a write elsewhere.
149
+ */
150
+ export function skillDir(root, id) {
151
+ const base = resolve(root);
152
+ const dir = resolve(join(base, id));
153
+ if (dir !== base && !dir.startsWith(`${base}/`) && !dir.startsWith(`${base}${sep}`)) {
154
+ throw new Error(`skill path escapes skills root: ${dir}`);
155
+ }
156
+ return dir;
157
+ }
158
+ //# sourceMappingURL=skill-rules.js.map
@@ -0,0 +1,9 @@
1
+ import type { MemoryStore } from "./store.js";
2
+ import type { ToolsService, ToolDefinition } from "./memory-tools.js";
3
+ export type { ToolsService, ToolDefinition };
4
+ /**
5
+ * Register the skill tools and return a disposer that removes them (the
6
+ * host may return one; call only the functions among whatever comes back).
7
+ */
8
+ export declare function registerSkillTools(tools: ToolsService, store: MemoryStore): () => void;
9
+ //# sourceMappingURL=skill-tools.d.ts.map