dsh-continual-evolve 0.15.4 → 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/store.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * guarantees v26). Unavailability is a typed error the entry point degrades
11
11
  * on — injection and tooling switch off, the host keeps running.
12
12
  */
13
- import { mkdirSync } from "node:fs";
13
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { secretLeakReason } from "./memory-rules.js";
17
+ import { globalSkillsDir, projectSkillsDir, renderSkillMarkdown, SKILL_GENERATED_MARKER, skillDir, skillFieldGateReasons, slugifySkillId, validateRenderedSkill, } from "./skill-rules.js";
18
+ import { isValidMemoryId } from "./memory-rules.js";
17
19
  import { applyProposals } from "./store-apply.js";
18
20
  /** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
19
21
  export class StoreUnavailableError extends Error {
@@ -72,7 +74,32 @@ CREATE TABLE IF NOT EXISTS extraction_log (
72
74
  CREATE TABLE IF NOT EXISTS state (
73
75
  workspace_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
74
76
  PRIMARY KEY (workspace_id, key)
77
+ );
78
+ CREATE TABLE IF NOT EXISTS skills (
79
+ workspace_id TEXT NOT NULL,
80
+ id TEXT NOT NULL,
81
+ title TEXT NOT NULL,
82
+ description TEXT NOT NULL,
83
+ body TEXT NOT NULL CHECK (length(body) <= 65536),
84
+ status TEXT NOT NULL DEFAULT 'active'
85
+ CHECK (status IN ('active','quarantined','archived')),
86
+ source_session TEXT,
87
+ source_seqs TEXT,
88
+ source_run TEXT,
89
+ created_at TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL,
91
+ PRIMARY KEY (workspace_id, id)
75
92
  ) STRICT;
93
+ CREATE INDEX IF NOT EXISTS idx_skill_ws_status ON skills(workspace_id, status);
94
+ CREATE INDEX IF NOT EXISTS idx_skill_ws_updated ON skills(workspace_id, updated_at);
95
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
96
+ workspace_id, id, title, description, body, tokenize='trigram');
97
+ -- Like memories_fts, synced in code by the skill apply path (plain DELETE +
98
+ -- INSERT), never by triggers, for the same node:sqlite FTS5 reason.
99
+ CREATE TABLE IF NOT EXISTS skill_log (
100
+ run_id TEXT, ts TEXT NOT NULL, session_id TEXT, workspace_id TEXT,
101
+ trigger TEXT, decision TEXT, status TEXT, files TEXT, warnings TEXT
102
+ );
76
103
  `;
77
104
  /** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
78
105
  export function defaultStorePath() {
@@ -95,6 +122,21 @@ function recordOf(row) {
95
122
  updatedAt: row.updated_at,
96
123
  };
97
124
  }
125
+ function skillRecordOf(row) {
126
+ return {
127
+ workspaceId: row.workspace_id,
128
+ id: row.id,
129
+ title: row.title,
130
+ description: row.description,
131
+ body: row.body,
132
+ status: row.status,
133
+ sourceSession: row.source_session,
134
+ sourceSeqs: row.source_seqs,
135
+ sourceRun: row.source_run,
136
+ createdAt: row.created_at,
137
+ updatedAt: row.updated_at,
138
+ };
139
+ }
98
140
  const LIST_ORDER = "ORDER BY CASE type WHEN 'feedback' THEN 0 WHEN 'user' THEN 1 ELSE 2 END, updated_at DESC";
99
141
  /** Quote one token as an FTS5 phrase (trigram needs ≥3 chars to match). */
100
142
  function ftsPhrase(token) {
@@ -113,6 +155,83 @@ function queryTokens(query) {
113
155
  function likePattern(token) {
114
156
  return `%${token.replaceAll(/[\\%_]/g, "\\$&")}%`;
115
157
  }
158
+ /** The materialized directory for one skill partition (global vs project). */
159
+ function skillRootOf(workspaceId) {
160
+ return workspaceId === "" ? globalSkillsDir() : projectSkillsDir(workspaceId);
161
+ }
162
+ /** Read a materialized file, undefined when missing or unreadable. */
163
+ function readSkillFile(root, id) {
164
+ let dir;
165
+ try {
166
+ dir = skillDir(root, id);
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ try {
172
+ return readFileSync(join(dir, "SKILL.md"), "utf8");
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ /**
179
+ * Write one active skill row as its SKILL.md (atomic tmp + rename so the
180
+ * host watcher never sees a partial file). Returns materialization warnings —
181
+ * the row already landed, so the caller surfaces them instead of failing.
182
+ */
183
+ function writeSkillFile(root, record) {
184
+ const rendered = renderSkillMarkdown(record);
185
+ const warnings = validateRenderedSkill(record).map((problem) => ({ id: record.id, warning: `skill ${record.id}: ${problem}` }));
186
+ let dir;
187
+ try {
188
+ dir = skillDir(root, record.id);
189
+ }
190
+ catch (error) {
191
+ return [{ id: record.id, warning: `skill ${record.id}: ${error instanceof Error ? error.message : String(error)}` }];
192
+ }
193
+ try {
194
+ mkdirSync(dir, { recursive: true });
195
+ const temp = join(dir, `SKILL.md.${process.pid}.tmp`);
196
+ writeFileSync(temp, rendered, "utf8");
197
+ renameSync(temp, join(dir, "SKILL.md"));
198
+ }
199
+ catch (error) {
200
+ warnings.push({ id: record.id, warning: `skill ${record.id}: file write failed, patrol will retry: ${error instanceof Error ? error.message : String(error)}` });
201
+ }
202
+ return warnings;
203
+ }
204
+ /**
205
+ * Remove one skill's materialized directory after its row left the active
206
+ * set. Only files carrying the generated marker are removed — a hand-written
207
+ * skill that happens to share the directory is left in place with a warning.
208
+ */
209
+ function removeSkillFile(root, id) {
210
+ let dir;
211
+ try {
212
+ dir = skillDir(root, id);
213
+ }
214
+ catch (error) {
215
+ return [{ id, warning: `skill ${id}: ${error instanceof Error ? error.message : String(error)}` }];
216
+ }
217
+ let current;
218
+ try {
219
+ current = readFileSync(join(dir, "SKILL.md"), "utf8");
220
+ }
221
+ catch {
222
+ return [];
223
+ }
224
+ if (!current.includes(SKILL_GENERATED_MARKER)) {
225
+ return [{ id, warning: `skill ${id}: ${join(dir, "SKILL.md")} is hand-written, left in place` }];
226
+ }
227
+ try {
228
+ rmSync(dir, { recursive: true, force: true });
229
+ }
230
+ catch (error) {
231
+ return [{ id, warning: `skill ${id}: file removal failed, patrol will retry: ${error instanceof Error ? error.message : String(error)}` }];
232
+ }
233
+ return [];
234
+ }
116
235
  /** Open (creating on first use) the central store. */
117
236
  export async function openMemoryStore(explicitPath) {
118
237
  const path = explicitPath === undefined ? defaultStorePath() : resolve(explicitPath);
@@ -145,6 +264,8 @@ export async function openMemoryStore(explicitPath) {
145
264
  "WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
146
265
  recentMemory: database.prepare("SELECT workspace_id, MAX(updated_at) AS ts FROM memories " +
147
266
  "WHERE status = 'active' AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
267
+ recentSkill: database.prepare("SELECT workspace_id, MAX(ts) AS ts FROM skill_log " +
268
+ "WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
148
269
  ledger: database.prepare("INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
149
270
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
150
271
  latestSkip: database.prepare("SELECT rowid AS rid, skip_reason AS reason, status FROM extraction_log " +
@@ -155,6 +276,26 @@ export async function openMemoryStore(explicitPath) {
155
276
  ftsDrop: database.prepare("DELETE FROM memories_fts WHERE rowid = ?"),
156
277
  activeRows: database.prepare("SELECT * FROM memories WHERE status = 'active'"),
157
278
  quarantine: database.prepare("UPDATE memories SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
279
+ skillGet: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND id = ?"),
280
+ skillList: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND status = 'active' ORDER BY updated_at DESC"),
281
+ skillListAll: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND status != 'archived' ORDER BY updated_at DESC"),
282
+ skillFts: database.prepare(`SELECT s.* FROM skills s WHERE (s.workspace_id, s.id) IN (
283
+ SELECT workspace_id, id FROM skills_fts WHERE skills_fts MATCH ? AND workspace_id = ?
284
+ ) AND s.status = 'active' ORDER BY updated_at DESC LIMIT ?`),
285
+ skillInsert: database.prepare("INSERT INTO skills (workspace_id, id, title, description, body, status, source_session, source_seqs, source_run, created_at, updated_at) " +
286
+ "VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)"),
287
+ skillUpdate: database.prepare("UPDATE skills SET title = ?, description = ?, body = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"),
288
+ skillArchive: database.prepare("UPDATE skills SET status = 'archived', updated_at = ? WHERE workspace_id = ? AND id = ?"),
289
+ skillDelete: database.prepare("DELETE FROM skills WHERE workspace_id = ? AND id = ?"),
290
+ skillSameBody: database.prepare("SELECT id FROM skills WHERE workspace_id = ? AND status = 'active' AND body = ? LIMIT 1"),
291
+ skillLog: database.prepare("INSERT INTO skill_log (run_id, ts, session_id, workspace_id, trigger, decision, status, files, warnings) " +
292
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"),
293
+ skillPrune: database.prepare("DELETE FROM skill_log WHERE ts < ?"),
294
+ skillFtsOrphans: database.prepare("SELECT f.rowid AS rid FROM skills_fts f LEFT JOIN skills m ON m.workspace_id = f.workspace_id AND m.id = f.id WHERE m.id IS NULL"),
295
+ skillFtsDrop: database.prepare("DELETE FROM skills_fts WHERE rowid = ?"),
296
+ skillActiveRows: database.prepare("SELECT * FROM skills WHERE status = 'active'"),
297
+ skillQuarantine: database.prepare("UPDATE skills SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
298
+ skillWorkspaces: database.prepare("SELECT workspace_id, COUNT(*) AS n, MAX(updated_at) AS u FROM skills WHERE status = 'active' GROUP BY workspace_id ORDER BY u DESC"),
158
299
  };
159
300
  // LIKE fallback: one OR-triplet per token, every token required. Statements
160
301
  // are cached by token count — the query set repeats, the counts don't grow.
@@ -195,6 +336,261 @@ export async function openMemoryStore(explicitPath) {
195
336
  stmts.bumpSkip.run(new Date().toISOString(), latest.rid);
196
337
  return true;
197
338
  };
339
+ // Skill LIKE fallback mirrors the memory one: one OR-triplet per token,
340
+ // every token required, statements cached by token count.
341
+ const skillLikeStmts = new Map();
342
+ const skillLikeSearch = (workspaceId, tokens, limit) => {
343
+ let stmt = skillLikeStmts.get(tokens.length);
344
+ if (stmt === undefined) {
345
+ const clause = tokens
346
+ .map(() => "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')")
347
+ .join(" AND ");
348
+ stmt = database.prepare(`SELECT * FROM skills WHERE workspace_id = ? AND status = 'active' AND ${clause} ORDER BY updated_at DESC LIMIT ?`);
349
+ skillLikeStmts.set(tokens.length, stmt);
350
+ }
351
+ const patterns = tokens.flatMap((token) => {
352
+ const pattern = likePattern(token);
353
+ return [pattern, pattern, pattern];
354
+ });
355
+ return stmt.all(workspaceId, ...patterns, limit).map(skillRecordOf);
356
+ };
357
+ /** Re-sync the FTS row for one skill id from its table row (delete + insert). */
358
+ const skillFtsSync = (workspaceId, id) => {
359
+ database.prepare("DELETE FROM skills_fts WHERE rowid IN (SELECT rowid FROM skills_fts WHERE workspace_id = ? AND id = ?)").run(workspaceId, id);
360
+ database.prepare("INSERT INTO skills_fts (workspace_id, id, title, description, body) SELECT workspace_id, id, title, description, body FROM skills WHERE workspace_id = ? AND id = ?").run(workspaceId, id);
361
+ };
362
+ const writeSkillLedger = (entry) => {
363
+ stmts.skillLog.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.status ?? null, entry.files === undefined ? null : JSON.stringify(entry.files), entry.warnings === undefined ? null : JSON.stringify(entry.warnings));
364
+ };
365
+ /** The proposal id, derived from the title (slug, CJK-hash fallback) on create. */
366
+ const skillIdFor = (proposal) => {
367
+ if (proposal.id !== undefined && proposal.id.trim().length > 0) {
368
+ return proposal.id.trim();
369
+ }
370
+ if (proposal.action === "create" && typeof proposal.title === "string") {
371
+ return slugifySkillId(proposal.title);
372
+ }
373
+ return "";
374
+ };
375
+ /** The scope update/delete targets (`global` only when asked for). */
376
+ const skillScopeOf = (proposal) => {
377
+ if (proposal.scope !== undefined && proposal.scope !== "project" && proposal.scope !== "global") {
378
+ return { reason: `scope must be "project" or "global", got "${proposal.scope}"` };
379
+ }
380
+ return proposal.scope ?? "project";
381
+ };
382
+ /** The partition a scope names: global rows live in `""`, project rows in the context workspace. */
383
+ const skillPartitionOf = (ctx, scope) => (scope === "global" ? "" : ctx.workspaceId);
384
+ const skillFail = (proposal, reason) => ({
385
+ id: proposal.id ?? "",
386
+ action: proposal.action,
387
+ ok: false,
388
+ reason,
389
+ });
390
+ /** The merged field values a skill create/update would write. */
391
+ const mergedSkillFields = (proposal, existing, action) => {
392
+ const pick = (value, fallback) => (typeof value === "string" ? value : fallback);
393
+ if (action === "create") {
394
+ if (typeof proposal.title !== "string" || typeof proposal.description !== "string" || typeof proposal.body !== "string") {
395
+ return { reason: "create requires id?, title, description, body" };
396
+ }
397
+ return { fields: { title: proposal.title, description: proposal.description, body: proposal.body } };
398
+ }
399
+ if (existing === undefined) {
400
+ return { reason: "no skill to merge into" };
401
+ }
402
+ return {
403
+ fields: {
404
+ title: pick(proposal.title, existing.title),
405
+ description: pick(proposal.description, existing.description),
406
+ body: pick(proposal.body, existing.body),
407
+ },
408
+ };
409
+ };
410
+ const identicalSkill = (a, fields) => a.title === fields.title && a.description === fields.description && a.body === fields.body;
411
+ /** Validate one skill proposal and land it; file work happens post-commit via intents. */
412
+ const applySkillOne = (ctx, proposal, now, files, intents) => {
413
+ const action = proposal.action;
414
+ if (action !== "create" && action !== "update" && action !== "delete" && action !== "move-to-global") {
415
+ return skillFail(proposal, `unknown action ${String(action)}`);
416
+ }
417
+ const id = skillIdFor(proposal);
418
+ if (!isValidMemoryId(id)) {
419
+ return skillFail(proposal, id.length === 0 ? "id missing and cannot be derived from the title" : `malformed id "${id}"`);
420
+ }
421
+ if (action === "create") {
422
+ if (ctx.workspaceId === "") {
423
+ return skillFail(proposal, "skills are created in a project — move one to global explicitly afterwards");
424
+ }
425
+ const merged = mergedSkillFields(proposal, undefined, "create");
426
+ if ("reason" in merged) {
427
+ return skillFail(proposal, merged.reason);
428
+ }
429
+ const reasons = skillFieldGateReasons(merged.fields);
430
+ if (reasons.length > 0) {
431
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
432
+ }
433
+ const projectRow = stmts.skillGet.get(ctx.workspaceId, id);
434
+ if (projectRow !== undefined && projectRow.status !== "archived") {
435
+ return skillFail(proposal, `skill "${id}" already exists in this project — update it instead of creating a duplicate`);
436
+ }
437
+ const globalRow = stmts.skillGet.get("", id);
438
+ if (globalRow !== undefined && globalRow.status !== "archived") {
439
+ const global = skillRecordOf(globalRow);
440
+ if (identicalSkill(global, merged.fields)) {
441
+ return skillFail(proposal, `identical global skill "${id}" already exists — call it directly, nothing to create`);
442
+ }
443
+ return skillFail(proposal, `global skill "${id}" exists with different content — rename yours, or delete the global row first`);
444
+ }
445
+ const sameBody = stmts.skillSameBody.get(ctx.workspaceId, merged.fields.body);
446
+ if (sameBody !== undefined && sameBody.id !== id) {
447
+ return skillFail(proposal, `identical skill "${sameBody.id}" already exists in this project — reuse it instead of duplicating`);
448
+ }
449
+ const sameGlobalBody = stmts.skillSameBody.get("", merged.fields.body);
450
+ if (sameGlobalBody !== undefined) {
451
+ return skillFail(proposal, `identical global skill "${sameGlobalBody.id}" already exists — call it directly instead of duplicating`);
452
+ }
453
+ stmts.skillInsert.run(ctx.workspaceId, id, merged.fields.title, merged.fields.description, merged.fields.body, ctx.sessionId ?? null, proposal.sourceSeqs ?? null, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), now, now);
454
+ skillFtsSync(ctx.workspaceId, id);
455
+ files.push({ id, action, scope: "project", after: skillRecordOf(stmts.skillGet.get(ctx.workspaceId, id)), sourceSeqs: proposal.sourceSeqs });
456
+ intents.push({ partition: ctx.workspaceId, id, op: "write" });
457
+ return { id, action, ok: true };
458
+ }
459
+ if (action === "move-to-global") {
460
+ const projectRow = stmts.skillGet.get(ctx.workspaceId, id);
461
+ if (projectRow === undefined || projectRow.status === "archived") {
462
+ return skillFail(proposal, `no project skill "${id}" to move — create it in the project first`);
463
+ }
464
+ const project = skillRecordOf(projectRow);
465
+ if (project.status !== "active") {
466
+ return skillFail(proposal, `project skill "${id}" is ${project.status} — only active skills move to global`);
467
+ }
468
+ const globalRow = stmts.skillGet.get("", id);
469
+ if (globalRow !== undefined && globalRow.status !== "archived") {
470
+ return skillFail(proposal, `global skill "${id}" already exists — delete it first, or keep the project copy shadowing it`);
471
+ }
472
+ const reasons = skillFieldGateReasons({ title: project.title, description: project.description, body: project.body });
473
+ if (reasons.length > 0) {
474
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
475
+ }
476
+ stmts.skillInsert.run("", id, project.title, project.description, project.body, project.sourceSession, project.sourceSeqs, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), now, now);
477
+ skillFtsSync("", id);
478
+ stmts.skillArchive.run(now, ctx.workspaceId, id);
479
+ skillFtsSync(ctx.workspaceId, id);
480
+ files.push({ id, action, scope: "project", before: project });
481
+ files.push({ id, action, scope: "global", after: skillRecordOf(stmts.skillGet.get("", id)) });
482
+ intents.push({ partition: "", id, op: "write" });
483
+ intents.push({ partition: ctx.workspaceId, id, op: "remove" });
484
+ return { id, action, ok: true };
485
+ }
486
+ const scope = skillScopeOf(proposal);
487
+ if (typeof scope !== "string") {
488
+ return skillFail(proposal, scope.reason);
489
+ }
490
+ const partition = skillPartitionOf(ctx, scope);
491
+ const existingRow = stmts.skillGet.get(partition, id);
492
+ const existing = existingRow === undefined || existingRow.status === "archived" ? undefined : skillRecordOf(existingRow);
493
+ if (action === "delete") {
494
+ if (existing === undefined) {
495
+ return skillFail(proposal, `no ${scope} skill "${id}" to delete`);
496
+ }
497
+ stmts.skillDelete.run(partition, id);
498
+ skillFtsSync(partition, id);
499
+ files.push({ id, action, scope, before: existing });
500
+ intents.push({ partition, id, op: "remove" });
501
+ return { id, action, ok: true };
502
+ }
503
+ // action === "update"
504
+ if (existing === undefined) {
505
+ return skillFail(proposal, `no ${scope} skill "${id}" to update — create it in the project instead`);
506
+ }
507
+ const merged = mergedSkillFields(proposal, existing, "update");
508
+ if ("reason" in merged) {
509
+ return skillFail(proposal, merged.reason);
510
+ }
511
+ const reasons = skillFieldGateReasons(merged.fields);
512
+ if (reasons.length > 0) {
513
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
514
+ }
515
+ stmts.skillUpdate.run(merged.fields.title, merged.fields.description, merged.fields.body, now, partition, id);
516
+ skillFtsSync(partition, id);
517
+ files.push({ id, action, scope, before: existing, after: skillRecordOf(stmts.skillGet.get(partition, id)) });
518
+ intents.push({ partition, id, op: "write" });
519
+ return { id, action, ok: true };
520
+ };
521
+ /**
522
+ * Bring every managed SKILL.md back in line with the store: rewrite
523
+ * missing or drifted files, drop orphan managed files. Only files
524
+ * carrying the generated marker are ever written or removed.
525
+ */
526
+ const reconcileSkillFiles = (result) => {
527
+ const active = stmts.skillActiveRows.all();
528
+ for (const row of active) {
529
+ const record = skillRecordOf(row);
530
+ const root = skillRootOf(record.workspaceId);
531
+ let current;
532
+ try {
533
+ current = readSkillFile(root, record.id);
534
+ }
535
+ catch {
536
+ current = undefined;
537
+ }
538
+ if (current === renderSkillMarkdown(record)) {
539
+ continue;
540
+ }
541
+ try {
542
+ const problems = writeSkillFile(root, record);
543
+ if (problems.length === 0) {
544
+ result.skillFilesReconciled += 1;
545
+ }
546
+ else {
547
+ result.skillFileErrors += 1;
548
+ }
549
+ }
550
+ catch {
551
+ result.skillFileErrors += 1;
552
+ }
553
+ }
554
+ const roots = new Map();
555
+ for (const row of active) {
556
+ roots.set(skillRootOf(row.workspace_id), row.workspace_id);
557
+ }
558
+ roots.set(skillRootOf(""), "");
559
+ for (const [root, partition] of roots) {
560
+ let names;
561
+ try {
562
+ names = readdirSync(root, { withFileTypes: true })
563
+ .filter((entry) => entry.isDirectory())
564
+ .map((entry) => entry.name);
565
+ }
566
+ catch {
567
+ continue;
568
+ }
569
+ for (const name of names) {
570
+ let content;
571
+ try {
572
+ content = readFileSync(join(root, name, "SKILL.md"), "utf8");
573
+ }
574
+ catch {
575
+ continue;
576
+ }
577
+ if (!content.includes(SKILL_GENERATED_MARKER)) {
578
+ continue;
579
+ }
580
+ const row = stmts.skillGet.get(partition, name);
581
+ if (row !== undefined && row.status === "active") {
582
+ continue;
583
+ }
584
+ try {
585
+ rmSync(join(root, name), { recursive: true, force: true });
586
+ result.skillOrphanFilesRemoved += 1;
587
+ }
588
+ catch {
589
+ result.skillFileErrors += 1;
590
+ }
591
+ }
592
+ }
593
+ };
198
594
  const store = {
199
595
  path,
200
596
  get(workspaceId, id) {
@@ -251,9 +647,9 @@ export async function openMemoryStore(explicitPath) {
251
647
  }));
252
648
  },
253
649
  mostRecentlyActiveWorkspace() {
254
- // Both instants are ISO-8601 UTC, so the newer one is the greater
255
- // string; comparing them keeps the two sources one ordering.
256
- const rows = [stmts.recentLedger.get(), stmts.recentMemory.get()];
650
+ // All instants are ISO-8601 UTC, so the newer one is the greater
651
+ // string; comparing them keeps the three sources one ordering.
652
+ const rows = [stmts.recentLedger.get(), stmts.recentMemory.get(), stmts.recentSkill.get()];
257
653
  let best;
258
654
  for (const row of rows) {
259
655
  const workspaceId = row?.workspace_id;
@@ -268,7 +664,16 @@ export async function openMemoryStore(explicitPath) {
268
664
  return best?.workspaceId;
269
665
  },
270
666
  patrol() {
271
- const result = { ts: new Date().toISOString(), orphanFtsRows: 0, quarantined: [] };
667
+ const result = {
668
+ ts: new Date().toISOString(),
669
+ orphanFtsRows: 0,
670
+ quarantined: [],
671
+ skillOrphanFtsRows: 0,
672
+ skillQuarantined: [],
673
+ skillFilesReconciled: 0,
674
+ skillOrphanFilesRemoved: 0,
675
+ skillFileErrors: 0,
676
+ };
272
677
  for (const row of stmts.ftsOrphans.all()) {
273
678
  stmts.ftsDrop.run(row.rid);
274
679
  result.orphanFtsRows += 1;
@@ -280,15 +685,108 @@ export async function openMemoryStore(explicitPath) {
280
685
  result.quarantined.push({ workspaceId: row.workspace_id, id: row.id, reason });
281
686
  }
282
687
  }
688
+ for (const row of stmts.skillFtsOrphans.all()) {
689
+ stmts.skillFtsDrop.run(row.rid);
690
+ result.skillOrphanFtsRows += 1;
691
+ }
692
+ for (const row of stmts.skillActiveRows.all()) {
693
+ const reason = secretLeakReason(`${row.title}\n${row.description}\n${row.body}`);
694
+ if (reason !== undefined) {
695
+ stmts.skillQuarantine.run(row.workspace_id, row.id);
696
+ result.skillQuarantined.push({ workspaceId: row.workspace_id, id: row.id, reason });
697
+ // A quarantined skill must stop loading immediately: drop
698
+ // its managed file (hand-written files stay, with an error
699
+ // counted so the residue stays visible).
700
+ for (const warning of removeSkillFile(skillRootOf(row.workspace_id), row.id)) {
701
+ void warning;
702
+ result.skillFileErrors += 1;
703
+ }
704
+ }
705
+ }
706
+ reconcileSkillFiles(result);
283
707
  stmts.stateSet.run(GLOBAL_WORKSPACE, PATROL_STATE_KEY, JSON.stringify(result));
284
708
  return result;
285
709
  },
286
710
  pruneLedger(beforeIso) {
287
- return Number(stmts.prune.run(beforeIso).changes);
711
+ const memory = Number(stmts.prune.run(beforeIso).changes);
712
+ const skills = Number(stmts.skillPrune.run(beforeIso).changes);
713
+ return memory + skills;
288
714
  },
289
715
  close() {
290
716
  database.close();
291
717
  },
718
+ getSkill(workspaceId, id) {
719
+ const row = stmts.skillGet.get(workspaceId, id);
720
+ return row === undefined ? undefined : skillRecordOf(row);
721
+ },
722
+ listSkills(workspaceId) {
723
+ return stmts.skillList.all(workspaceId).map(skillRecordOf);
724
+ },
725
+ listAllSkills(workspaceId) {
726
+ return stmts.skillListAll.all(workspaceId).map(skillRecordOf);
727
+ },
728
+ searchSkills(workspaceId, query, limit = 20) {
729
+ const tokens = queryTokens(query);
730
+ if (tokens.length === 0) {
731
+ return [];
732
+ }
733
+ if (tokens.every((token) => token.length >= 3)) {
734
+ const hits = stmts.skillFts.all(tokens.map(ftsPhrase).join(" AND "), workspaceId, limit);
735
+ if (hits.length > 0) {
736
+ return hits.map(skillRecordOf);
737
+ }
738
+ }
739
+ return skillLikeSearch(workspaceId, tokens, limit);
740
+ },
741
+ applySkillProposals(ctx, proposals) {
742
+ const outcomes = [];
743
+ const files = [];
744
+ const intents = [];
745
+ const runId = ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : undefined);
746
+ const now = new Date().toISOString();
747
+ database.exec("BEGIN");
748
+ for (const proposal of proposals) {
749
+ const outcome = applySkillOne(ctx, proposal, now, files, intents);
750
+ outcomes.push(outcome);
751
+ if (!outcome.ok) {
752
+ break;
753
+ }
754
+ }
755
+ const applied = outcomes.every((outcome) => outcome.ok);
756
+ database.exec(applied ? "COMMIT" : "ROLLBACK");
757
+ // The database is the source of truth: files sync after the commit
758
+ // and never fail the batch — patrol retries whatever refuses.
759
+ const warnings = [];
760
+ if (applied) {
761
+ for (const intent of intents) {
762
+ const root = skillRootOf(intent.partition);
763
+ const row = stmts.skillGet.get(intent.partition, intent.id);
764
+ if (intent.op === "write") {
765
+ if (row === undefined || row.status !== "active") {
766
+ continue;
767
+ }
768
+ warnings.push(...writeSkillFile(root, skillRecordOf(row)));
769
+ }
770
+ else {
771
+ warnings.push(...removeSkillFile(root, intent.id));
772
+ }
773
+ }
774
+ }
775
+ const decision = applied ? ctx.decision : `${ctx.decision ?? ""} first failure: ${outcomes.find((o) => !o.ok)?.reason ?? ""}`.trim();
776
+ writeSkillLedger({
777
+ runId, sessionId: ctx.sessionId, workspaceId: ctx.workspaceId, trigger: ctx.trigger,
778
+ decision, status: applied ? "applied" : "rejected",
779
+ files: applied ? files : [], warnings,
780
+ });
781
+ return { outcomes, warnings };
782
+ },
783
+ listSkillWorkspaces() {
784
+ return stmts.skillWorkspaces.all().map((row) => ({
785
+ workspaceId: row.workspace_id,
786
+ activeCount: row.n,
787
+ updatedAt: row.u,
788
+ }));
789
+ },
292
790
  };
293
791
  return store;
294
792
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Workspace Memory",
4
- "description": "Central SQLite memory store partitioned by workspace: session-start index injection, gated explicit writes and proposal-based extraction."
4
+ "description": "Central SQLite memory and skill store partitioned by workspace: session-start index injection, gated explicit writes and proposal-based extraction, skills materialized as SKILL.md."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "工作区记忆",
4
- "description": "中央 SQLite 记忆库,按工作区分区:开局注入索引、显式写入与提案制提取全部过代码门禁。"
4
+ "description": "中央 SQLite 记忆与技能库,按工作区分区:开局注入索引、显式写入与提案制提取全部过代码门禁,技能物化为 SKILL.md。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "ZCode-style workspace memory for DeepSeek Harness: one central SQLite store partitioned by workspace, index injection, and sole-writer gated writes.",
5
5
  "license": "MIT",
6
6
  "repository": {