dsh-continual-evolve 0.15.4 → 0.15.6

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,11 +10,14 @@
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";
20
+ import { normalizeUsage, sumUsage, zeroUsage } from "./token-usage.js";
18
21
  /** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
19
22
  export class StoreUnavailableError extends Error {
20
23
  }
@@ -72,8 +75,47 @@ CREATE TABLE IF NOT EXISTS extraction_log (
72
75
  CREATE TABLE IF NOT EXISTS state (
73
76
  workspace_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
74
77
  PRIMARY KEY (workspace_id, key)
78
+ );
79
+ CREATE TABLE IF NOT EXISTS skills (
80
+ workspace_id TEXT NOT NULL,
81
+ id TEXT NOT NULL,
82
+ title TEXT NOT NULL,
83
+ description TEXT NOT NULL,
84
+ body TEXT NOT NULL CHECK (length(body) <= 65536),
85
+ status TEXT NOT NULL DEFAULT 'active'
86
+ CHECK (status IN ('active','quarantined','archived')),
87
+ source_session TEXT,
88
+ source_seqs TEXT,
89
+ source_run TEXT,
90
+ created_at TEXT NOT NULL,
91
+ updated_at TEXT NOT NULL,
92
+ PRIMARY KEY (workspace_id, id)
93
+ ) STRICT;
94
+ CREATE INDEX IF NOT EXISTS idx_skill_ws_status ON skills(workspace_id, status);
95
+ CREATE INDEX IF NOT EXISTS idx_skill_ws_updated ON skills(workspace_id, updated_at);
96
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
97
+ workspace_id, id, title, description, body, tokenize='trigram');
98
+ -- Like memories_fts, synced in code by the skill apply path (plain DELETE +
99
+ -- INSERT), never by triggers, for the same node:sqlite FTS5 reason.
100
+ CREATE TABLE IF NOT EXISTS skill_log (
101
+ run_id TEXT, ts TEXT NOT NULL, session_id TEXT, workspace_id TEXT,
102
+ trigger TEXT, decision TEXT, status TEXT, files TEXT, warnings TEXT
103
+ );
104
+ -- Skill usage counters (V2 usage loop): one aggregate row per skill, never a
105
+ -- per-hit ledger, so the table cannot grow without bound and needs no prune.
106
+ CREATE TABLE IF NOT EXISTS skill_usage (
107
+ workspace_id TEXT NOT NULL,
108
+ skill_id TEXT NOT NULL,
109
+ scope TEXT NOT NULL CHECK (scope IN ('project','global')),
110
+ use_count INTEGER NOT NULL DEFAULT 0,
111
+ last_used_at TEXT,
112
+ PRIMARY KEY (workspace_id, skill_id, scope)
75
113
  ) STRICT;
76
114
  `;
115
+ /** The empty audit view: no rows, no signal — never a failure. */
116
+ export function zeroReviewSummary() {
117
+ return { applied: 0, rejected: 0, skipped: 0, failed: 0, topSkips: [], tokens: zeroUsage() };
118
+ }
77
119
  /** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
78
120
  export function defaultStorePath() {
79
121
  const home = process.env.DSH_HOME?.trim();
@@ -95,6 +137,21 @@ function recordOf(row) {
95
137
  updatedAt: row.updated_at,
96
138
  };
97
139
  }
140
+ function skillRecordOf(row) {
141
+ return {
142
+ workspaceId: row.workspace_id,
143
+ id: row.id,
144
+ title: row.title,
145
+ description: row.description,
146
+ body: row.body,
147
+ status: row.status,
148
+ sourceSession: row.source_session,
149
+ sourceSeqs: row.source_seqs,
150
+ sourceRun: row.source_run,
151
+ createdAt: row.created_at,
152
+ updatedAt: row.updated_at,
153
+ };
154
+ }
98
155
  const LIST_ORDER = "ORDER BY CASE type WHEN 'feedback' THEN 0 WHEN 'user' THEN 1 ELSE 2 END, updated_at DESC";
99
156
  /** Quote one token as an FTS5 phrase (trigram needs ≥3 chars to match). */
100
157
  function ftsPhrase(token) {
@@ -113,6 +170,83 @@ function queryTokens(query) {
113
170
  function likePattern(token) {
114
171
  return `%${token.replaceAll(/[\\%_]/g, "\\$&")}%`;
115
172
  }
173
+ /** The materialized directory for one skill partition (global vs project). */
174
+ function skillRootOf(workspaceId) {
175
+ return workspaceId === "" ? globalSkillsDir() : projectSkillsDir(workspaceId);
176
+ }
177
+ /** Read a materialized file, undefined when missing or unreadable. */
178
+ function readSkillFile(root, id) {
179
+ let dir;
180
+ try {
181
+ dir = skillDir(root, id);
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ try {
187
+ return readFileSync(join(dir, "SKILL.md"), "utf8");
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
193
+ /**
194
+ * Write one active skill row as its SKILL.md (atomic tmp + rename so the
195
+ * host watcher never sees a partial file). Returns materialization warnings —
196
+ * the row already landed, so the caller surfaces them instead of failing.
197
+ */
198
+ function writeSkillFile(root, record) {
199
+ const rendered = renderSkillMarkdown(record);
200
+ const warnings = validateRenderedSkill(record).map((problem) => ({ id: record.id, warning: `skill ${record.id}: ${problem}` }));
201
+ let dir;
202
+ try {
203
+ dir = skillDir(root, record.id);
204
+ }
205
+ catch (error) {
206
+ return [{ id: record.id, warning: `skill ${record.id}: ${error instanceof Error ? error.message : String(error)}` }];
207
+ }
208
+ try {
209
+ mkdirSync(dir, { recursive: true });
210
+ const temp = join(dir, `SKILL.md.${process.pid}.tmp`);
211
+ writeFileSync(temp, rendered, "utf8");
212
+ renameSync(temp, join(dir, "SKILL.md"));
213
+ }
214
+ catch (error) {
215
+ warnings.push({ id: record.id, warning: `skill ${record.id}: file write failed, patrol will retry: ${error instanceof Error ? error.message : String(error)}` });
216
+ }
217
+ return warnings;
218
+ }
219
+ /**
220
+ * Remove one skill's materialized directory after its row left the active
221
+ * set. Only files carrying the generated marker are removed — a hand-written
222
+ * skill that happens to share the directory is left in place with a warning.
223
+ */
224
+ function removeSkillFile(root, id) {
225
+ let dir;
226
+ try {
227
+ dir = skillDir(root, id);
228
+ }
229
+ catch (error) {
230
+ return [{ id, warning: `skill ${id}: ${error instanceof Error ? error.message : String(error)}` }];
231
+ }
232
+ let current;
233
+ try {
234
+ current = readFileSync(join(dir, "SKILL.md"), "utf8");
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ if (!current.includes(SKILL_GENERATED_MARKER)) {
240
+ return [{ id, warning: `skill ${id}: ${join(dir, "SKILL.md")} is hand-written, left in place` }];
241
+ }
242
+ try {
243
+ rmSync(dir, { recursive: true, force: true });
244
+ }
245
+ catch (error) {
246
+ return [{ id, warning: `skill ${id}: file removal failed, patrol will retry: ${error instanceof Error ? error.message : String(error)}` }];
247
+ }
248
+ return [];
249
+ }
116
250
  /** Open (creating on first use) the central store. */
117
251
  export async function openMemoryStore(explicitPath) {
118
252
  const path = explicitPath === undefined ? defaultStorePath() : resolve(explicitPath);
@@ -145,6 +279,8 @@ export async function openMemoryStore(explicitPath) {
145
279
  "WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
146
280
  recentMemory: database.prepare("SELECT workspace_id, MAX(updated_at) AS ts FROM memories " +
147
281
  "WHERE status = 'active' AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
282
+ recentSkill: database.prepare("SELECT workspace_id, MAX(ts) AS ts FROM skill_log " +
283
+ "WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
148
284
  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
285
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
150
286
  latestSkip: database.prepare("SELECT rowid AS rid, skip_reason AS reason, status FROM extraction_log " +
@@ -155,6 +291,30 @@ export async function openMemoryStore(explicitPath) {
155
291
  ftsDrop: database.prepare("DELETE FROM memories_fts WHERE rowid = ?"),
156
292
  activeRows: database.prepare("SELECT * FROM memories WHERE status = 'active'"),
157
293
  quarantine: database.prepare("UPDATE memories SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
294
+ skillGet: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND id = ?"),
295
+ skillList: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND status = 'active' ORDER BY updated_at DESC"),
296
+ skillListAll: database.prepare("SELECT * FROM skills WHERE workspace_id = ? AND status != 'archived' ORDER BY updated_at DESC"),
297
+ skillFts: database.prepare(`SELECT s.* FROM skills s WHERE (s.workspace_id, s.id) IN (
298
+ SELECT workspace_id, id FROM skills_fts WHERE skills_fts MATCH ? AND workspace_id = ?
299
+ ) AND s.status = 'active' ORDER BY updated_at DESC LIMIT ?`),
300
+ skillInsert: database.prepare("INSERT INTO skills (workspace_id, id, title, description, body, status, source_session, source_seqs, source_run, created_at, updated_at) " +
301
+ "VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)"),
302
+ skillUpdate: database.prepare("UPDATE skills SET title = ?, description = ?, body = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"),
303
+ skillArchive: database.prepare("UPDATE skills SET status = 'archived', updated_at = ? WHERE workspace_id = ? AND id = ?"),
304
+ skillDelete: database.prepare("DELETE FROM skills WHERE workspace_id = ? AND id = ?"),
305
+ skillSameBody: database.prepare("SELECT id FROM skills WHERE workspace_id = ? AND status = 'active' AND body = ? LIMIT 1"),
306
+ skillLog: database.prepare("INSERT INTO skill_log (run_id, ts, session_id, workspace_id, trigger, decision, status, files, warnings) " +
307
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"),
308
+ skillPrune: database.prepare("DELETE FROM skill_log WHERE ts < ?"),
309
+ 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"),
310
+ skillFtsDrop: database.prepare("DELETE FROM skills_fts WHERE rowid = ?"),
311
+ skillActiveRows: database.prepare("SELECT * FROM skills WHERE status = 'active'"),
312
+ skillQuarantine: database.prepare("UPDATE skills SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
313
+ 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"),
314
+ reviewRows: database.prepare("SELECT status, skip_reason, usage FROM extraction_log WHERE workspace_id = ? AND ts >= ?"),
315
+ skillUseGet: database.prepare("SELECT use_count, last_used_at FROM skill_usage WHERE workspace_id = ? AND skill_id = ? AND scope = ?"),
316
+ skillUseUpsert: database.prepare("INSERT INTO skill_usage (workspace_id, skill_id, scope, use_count, last_used_at) VALUES (?, ?, ?, 1, ?) " +
317
+ "ON CONFLICT (workspace_id, skill_id, scope) DO UPDATE SET use_count = use_count + 1, last_used_at = excluded.last_used_at"),
158
318
  };
159
319
  // LIKE fallback: one OR-triplet per token, every token required. Statements
160
320
  // are cached by token count — the query set repeats, the counts don't grow.
@@ -195,6 +355,261 @@ export async function openMemoryStore(explicitPath) {
195
355
  stmts.bumpSkip.run(new Date().toISOString(), latest.rid);
196
356
  return true;
197
357
  };
358
+ // Skill LIKE fallback mirrors the memory one: one OR-triplet per token,
359
+ // every token required, statements cached by token count.
360
+ const skillLikeStmts = new Map();
361
+ const skillLikeSearch = (workspaceId, tokens, limit) => {
362
+ let stmt = skillLikeStmts.get(tokens.length);
363
+ if (stmt === undefined) {
364
+ const clause = tokens
365
+ .map(() => "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')")
366
+ .join(" AND ");
367
+ stmt = database.prepare(`SELECT * FROM skills WHERE workspace_id = ? AND status = 'active' AND ${clause} ORDER BY updated_at DESC LIMIT ?`);
368
+ skillLikeStmts.set(tokens.length, stmt);
369
+ }
370
+ const patterns = tokens.flatMap((token) => {
371
+ const pattern = likePattern(token);
372
+ return [pattern, pattern, pattern];
373
+ });
374
+ return stmt.all(workspaceId, ...patterns, limit).map(skillRecordOf);
375
+ };
376
+ /** Re-sync the FTS row for one skill id from its table row (delete + insert). */
377
+ const skillFtsSync = (workspaceId, id) => {
378
+ database.prepare("DELETE FROM skills_fts WHERE rowid IN (SELECT rowid FROM skills_fts WHERE workspace_id = ? AND id = ?)").run(workspaceId, id);
379
+ 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);
380
+ };
381
+ const writeSkillLedger = (entry) => {
382
+ 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));
383
+ };
384
+ /** The proposal id, derived from the title (slug, CJK-hash fallback) on create. */
385
+ const skillIdFor = (proposal) => {
386
+ if (proposal.id !== undefined && proposal.id.trim().length > 0) {
387
+ return proposal.id.trim();
388
+ }
389
+ if (proposal.action === "create" && typeof proposal.title === "string") {
390
+ return slugifySkillId(proposal.title);
391
+ }
392
+ return "";
393
+ };
394
+ /** The scope update/delete targets (`global` only when asked for). */
395
+ const skillScopeOf = (proposal) => {
396
+ if (proposal.scope !== undefined && proposal.scope !== "project" && proposal.scope !== "global") {
397
+ return { reason: `scope must be "project" or "global", got "${proposal.scope}"` };
398
+ }
399
+ return proposal.scope ?? "project";
400
+ };
401
+ /** The partition a scope names: global rows live in `""`, project rows in the context workspace. */
402
+ const skillPartitionOf = (ctx, scope) => (scope === "global" ? "" : ctx.workspaceId);
403
+ const skillFail = (proposal, reason) => ({
404
+ id: proposal.id ?? "",
405
+ action: proposal.action,
406
+ ok: false,
407
+ reason,
408
+ });
409
+ /** The merged field values a skill create/update would write. */
410
+ const mergedSkillFields = (proposal, existing, action) => {
411
+ const pick = (value, fallback) => (typeof value === "string" ? value : fallback);
412
+ if (action === "create") {
413
+ if (typeof proposal.title !== "string" || typeof proposal.description !== "string" || typeof proposal.body !== "string") {
414
+ return { reason: "create requires id?, title, description, body" };
415
+ }
416
+ return { fields: { title: proposal.title, description: proposal.description, body: proposal.body } };
417
+ }
418
+ if (existing === undefined) {
419
+ return { reason: "no skill to merge into" };
420
+ }
421
+ return {
422
+ fields: {
423
+ title: pick(proposal.title, existing.title),
424
+ description: pick(proposal.description, existing.description),
425
+ body: pick(proposal.body, existing.body),
426
+ },
427
+ };
428
+ };
429
+ const identicalSkill = (a, fields) => a.title === fields.title && a.description === fields.description && a.body === fields.body;
430
+ /** Validate one skill proposal and land it; file work happens post-commit via intents. */
431
+ const applySkillOne = (ctx, proposal, now, files, intents) => {
432
+ const action = proposal.action;
433
+ if (action !== "create" && action !== "update" && action !== "delete" && action !== "move-to-global") {
434
+ return skillFail(proposal, `unknown action ${String(action)}`);
435
+ }
436
+ const id = skillIdFor(proposal);
437
+ if (!isValidMemoryId(id)) {
438
+ return skillFail(proposal, id.length === 0 ? "id missing and cannot be derived from the title" : `malformed id "${id}"`);
439
+ }
440
+ if (action === "create") {
441
+ if (ctx.workspaceId === "") {
442
+ return skillFail(proposal, "skills are created in a project — move one to global explicitly afterwards");
443
+ }
444
+ const merged = mergedSkillFields(proposal, undefined, "create");
445
+ if ("reason" in merged) {
446
+ return skillFail(proposal, merged.reason);
447
+ }
448
+ const reasons = skillFieldGateReasons(merged.fields);
449
+ if (reasons.length > 0) {
450
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
451
+ }
452
+ const projectRow = stmts.skillGet.get(ctx.workspaceId, id);
453
+ if (projectRow !== undefined && projectRow.status !== "archived") {
454
+ return skillFail(proposal, `skill "${id}" already exists in this project — update it instead of creating a duplicate`);
455
+ }
456
+ const globalRow = stmts.skillGet.get("", id);
457
+ if (globalRow !== undefined && globalRow.status !== "archived") {
458
+ const global = skillRecordOf(globalRow);
459
+ if (identicalSkill(global, merged.fields)) {
460
+ return skillFail(proposal, `identical global skill "${id}" already exists — call it directly, nothing to create`);
461
+ }
462
+ return skillFail(proposal, `global skill "${id}" exists with different content — rename yours, or delete the global row first`);
463
+ }
464
+ const sameBody = stmts.skillSameBody.get(ctx.workspaceId, merged.fields.body);
465
+ if (sameBody !== undefined && sameBody.id !== id) {
466
+ return skillFail(proposal, `identical skill "${sameBody.id}" already exists in this project — reuse it instead of duplicating`);
467
+ }
468
+ const sameGlobalBody = stmts.skillSameBody.get("", merged.fields.body);
469
+ if (sameGlobalBody !== undefined) {
470
+ return skillFail(proposal, `identical global skill "${sameGlobalBody.id}" already exists — call it directly instead of duplicating`);
471
+ }
472
+ 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);
473
+ skillFtsSync(ctx.workspaceId, id);
474
+ files.push({ id, action, scope: "project", after: skillRecordOf(stmts.skillGet.get(ctx.workspaceId, id)), sourceSeqs: proposal.sourceSeqs });
475
+ intents.push({ partition: ctx.workspaceId, id, op: "write" });
476
+ return { id, action, ok: true };
477
+ }
478
+ if (action === "move-to-global") {
479
+ const projectRow = stmts.skillGet.get(ctx.workspaceId, id);
480
+ if (projectRow === undefined || projectRow.status === "archived") {
481
+ return skillFail(proposal, `no project skill "${id}" to move — create it in the project first`);
482
+ }
483
+ const project = skillRecordOf(projectRow);
484
+ if (project.status !== "active") {
485
+ return skillFail(proposal, `project skill "${id}" is ${project.status} — only active skills move to global`);
486
+ }
487
+ const globalRow = stmts.skillGet.get("", id);
488
+ if (globalRow !== undefined && globalRow.status !== "archived") {
489
+ return skillFail(proposal, `global skill "${id}" already exists — delete it first, or keep the project copy shadowing it`);
490
+ }
491
+ const reasons = skillFieldGateReasons({ title: project.title, description: project.description, body: project.body });
492
+ if (reasons.length > 0) {
493
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
494
+ }
495
+ stmts.skillInsert.run("", id, project.title, project.description, project.body, project.sourceSession, project.sourceSeqs, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), now, now);
496
+ skillFtsSync("", id);
497
+ stmts.skillArchive.run(now, ctx.workspaceId, id);
498
+ skillFtsSync(ctx.workspaceId, id);
499
+ files.push({ id, action, scope: "project", before: project });
500
+ files.push({ id, action, scope: "global", after: skillRecordOf(stmts.skillGet.get("", id)) });
501
+ intents.push({ partition: "", id, op: "write" });
502
+ intents.push({ partition: ctx.workspaceId, id, op: "remove" });
503
+ return { id, action, ok: true };
504
+ }
505
+ const scope = skillScopeOf(proposal);
506
+ if (typeof scope !== "string") {
507
+ return skillFail(proposal, scope.reason);
508
+ }
509
+ const partition = skillPartitionOf(ctx, scope);
510
+ const existingRow = stmts.skillGet.get(partition, id);
511
+ const existing = existingRow === undefined || existingRow.status === "archived" ? undefined : skillRecordOf(existingRow);
512
+ if (action === "delete") {
513
+ if (existing === undefined) {
514
+ return skillFail(proposal, `no ${scope} skill "${id}" to delete`);
515
+ }
516
+ stmts.skillDelete.run(partition, id);
517
+ skillFtsSync(partition, id);
518
+ files.push({ id, action, scope, before: existing });
519
+ intents.push({ partition, id, op: "remove" });
520
+ return { id, action, ok: true };
521
+ }
522
+ // action === "update"
523
+ if (existing === undefined) {
524
+ return skillFail(proposal, `no ${scope} skill "${id}" to update — create it in the project instead`);
525
+ }
526
+ const merged = mergedSkillFields(proposal, existing, "update");
527
+ if ("reason" in merged) {
528
+ return skillFail(proposal, merged.reason);
529
+ }
530
+ const reasons = skillFieldGateReasons(merged.fields);
531
+ if (reasons.length > 0) {
532
+ return skillFail(proposal, reasons[0] ?? "invalid skill");
533
+ }
534
+ stmts.skillUpdate.run(merged.fields.title, merged.fields.description, merged.fields.body, now, partition, id);
535
+ skillFtsSync(partition, id);
536
+ files.push({ id, action, scope, before: existing, after: skillRecordOf(stmts.skillGet.get(partition, id)) });
537
+ intents.push({ partition, id, op: "write" });
538
+ return { id, action, ok: true };
539
+ };
540
+ /**
541
+ * Bring every managed SKILL.md back in line with the store: rewrite
542
+ * missing or drifted files, drop orphan managed files. Only files
543
+ * carrying the generated marker are ever written or removed.
544
+ */
545
+ const reconcileSkillFiles = (result) => {
546
+ const active = stmts.skillActiveRows.all();
547
+ for (const row of active) {
548
+ const record = skillRecordOf(row);
549
+ const root = skillRootOf(record.workspaceId);
550
+ let current;
551
+ try {
552
+ current = readSkillFile(root, record.id);
553
+ }
554
+ catch {
555
+ current = undefined;
556
+ }
557
+ if (current === renderSkillMarkdown(record)) {
558
+ continue;
559
+ }
560
+ try {
561
+ const problems = writeSkillFile(root, record);
562
+ if (problems.length === 0) {
563
+ result.skillFilesReconciled += 1;
564
+ }
565
+ else {
566
+ result.skillFileErrors += 1;
567
+ }
568
+ }
569
+ catch {
570
+ result.skillFileErrors += 1;
571
+ }
572
+ }
573
+ const roots = new Map();
574
+ for (const row of active) {
575
+ roots.set(skillRootOf(row.workspace_id), row.workspace_id);
576
+ }
577
+ roots.set(skillRootOf(""), "");
578
+ for (const [root, partition] of roots) {
579
+ let names;
580
+ try {
581
+ names = readdirSync(root, { withFileTypes: true })
582
+ .filter((entry) => entry.isDirectory())
583
+ .map((entry) => entry.name);
584
+ }
585
+ catch {
586
+ continue;
587
+ }
588
+ for (const name of names) {
589
+ let content;
590
+ try {
591
+ content = readFileSync(join(root, name, "SKILL.md"), "utf8");
592
+ }
593
+ catch {
594
+ continue;
595
+ }
596
+ if (!content.includes(SKILL_GENERATED_MARKER)) {
597
+ continue;
598
+ }
599
+ const row = stmts.skillGet.get(partition, name);
600
+ if (row !== undefined && row.status === "active") {
601
+ continue;
602
+ }
603
+ try {
604
+ rmSync(join(root, name), { recursive: true, force: true });
605
+ result.skillOrphanFilesRemoved += 1;
606
+ }
607
+ catch {
608
+ result.skillFileErrors += 1;
609
+ }
610
+ }
611
+ }
612
+ };
198
613
  const store = {
199
614
  path,
200
615
  get(workspaceId, id) {
@@ -251,9 +666,9 @@ export async function openMemoryStore(explicitPath) {
251
666
  }));
252
667
  },
253
668
  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()];
669
+ // All instants are ISO-8601 UTC, so the newer one is the greater
670
+ // string; comparing them keeps the three sources one ordering.
671
+ const rows = [stmts.recentLedger.get(), stmts.recentMemory.get(), stmts.recentSkill.get()];
257
672
  let best;
258
673
  for (const row of rows) {
259
674
  const workspaceId = row?.workspace_id;
@@ -268,7 +683,16 @@ export async function openMemoryStore(explicitPath) {
268
683
  return best?.workspaceId;
269
684
  },
270
685
  patrol() {
271
- const result = { ts: new Date().toISOString(), orphanFtsRows: 0, quarantined: [] };
686
+ const result = {
687
+ ts: new Date().toISOString(),
688
+ orphanFtsRows: 0,
689
+ quarantined: [],
690
+ skillOrphanFtsRows: 0,
691
+ skillQuarantined: [],
692
+ skillFilesReconciled: 0,
693
+ skillOrphanFilesRemoved: 0,
694
+ skillFileErrors: 0,
695
+ };
272
696
  for (const row of stmts.ftsOrphans.all()) {
273
697
  stmts.ftsDrop.run(row.rid);
274
698
  result.orphanFtsRows += 1;
@@ -280,15 +704,174 @@ export async function openMemoryStore(explicitPath) {
280
704
  result.quarantined.push({ workspaceId: row.workspace_id, id: row.id, reason });
281
705
  }
282
706
  }
707
+ for (const row of stmts.skillFtsOrphans.all()) {
708
+ stmts.skillFtsDrop.run(row.rid);
709
+ result.skillOrphanFtsRows += 1;
710
+ }
711
+ for (const row of stmts.skillActiveRows.all()) {
712
+ const reason = secretLeakReason(`${row.title}\n${row.description}\n${row.body}`);
713
+ if (reason !== undefined) {
714
+ stmts.skillQuarantine.run(row.workspace_id, row.id);
715
+ result.skillQuarantined.push({ workspaceId: row.workspace_id, id: row.id, reason });
716
+ // A quarantined skill must stop loading immediately: drop
717
+ // its managed file (hand-written files stay, with an error
718
+ // counted so the residue stays visible).
719
+ for (const warning of removeSkillFile(skillRootOf(row.workspace_id), row.id)) {
720
+ void warning;
721
+ result.skillFileErrors += 1;
722
+ }
723
+ }
724
+ }
725
+ reconcileSkillFiles(result);
283
726
  stmts.stateSet.run(GLOBAL_WORKSPACE, PATROL_STATE_KEY, JSON.stringify(result));
284
727
  return result;
285
728
  },
286
729
  pruneLedger(beforeIso) {
287
- return Number(stmts.prune.run(beforeIso).changes);
730
+ const memory = Number(stmts.prune.run(beforeIso).changes);
731
+ const skills = Number(stmts.skillPrune.run(beforeIso).changes);
732
+ return memory + skills;
288
733
  },
289
734
  close() {
290
735
  database.close();
291
736
  },
737
+ getSkill(workspaceId, id) {
738
+ const row = stmts.skillGet.get(workspaceId, id);
739
+ return row === undefined ? undefined : skillRecordOf(row);
740
+ },
741
+ listSkills(workspaceId) {
742
+ return stmts.skillList.all(workspaceId).map(skillRecordOf);
743
+ },
744
+ listAllSkills(workspaceId) {
745
+ return stmts.skillListAll.all(workspaceId).map(skillRecordOf);
746
+ },
747
+ searchSkills(workspaceId, query, limit = 20) {
748
+ const tokens = queryTokens(query);
749
+ if (tokens.length === 0) {
750
+ return [];
751
+ }
752
+ if (tokens.every((token) => token.length >= 3)) {
753
+ const hits = stmts.skillFts.all(tokens.map(ftsPhrase).join(" AND "), workspaceId, limit);
754
+ if (hits.length > 0) {
755
+ return hits.map(skillRecordOf);
756
+ }
757
+ }
758
+ return skillLikeSearch(workspaceId, tokens, limit);
759
+ },
760
+ applySkillProposals(ctx, proposals) {
761
+ const outcomes = [];
762
+ const files = [];
763
+ const intents = [];
764
+ const runId = ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : undefined);
765
+ const now = new Date().toISOString();
766
+ database.exec("BEGIN");
767
+ for (const proposal of proposals) {
768
+ const outcome = applySkillOne(ctx, proposal, now, files, intents);
769
+ outcomes.push(outcome);
770
+ if (!outcome.ok) {
771
+ break;
772
+ }
773
+ }
774
+ const applied = outcomes.every((outcome) => outcome.ok);
775
+ database.exec(applied ? "COMMIT" : "ROLLBACK");
776
+ // The database is the source of truth: files sync after the commit
777
+ // and never fail the batch — patrol retries whatever refuses.
778
+ const warnings = [];
779
+ if (applied) {
780
+ for (const intent of intents) {
781
+ const root = skillRootOf(intent.partition);
782
+ const row = stmts.skillGet.get(intent.partition, intent.id);
783
+ if (intent.op === "write") {
784
+ if (row === undefined || row.status !== "active") {
785
+ continue;
786
+ }
787
+ warnings.push(...writeSkillFile(root, skillRecordOf(row)));
788
+ }
789
+ else {
790
+ warnings.push(...removeSkillFile(root, intent.id));
791
+ }
792
+ }
793
+ }
794
+ const decision = applied ? ctx.decision : `${ctx.decision ?? ""} first failure: ${outcomes.find((o) => !o.ok)?.reason ?? ""}`.trim();
795
+ writeSkillLedger({
796
+ runId, sessionId: ctx.sessionId, workspaceId: ctx.workspaceId, trigger: ctx.trigger,
797
+ decision, status: applied ? "applied" : "rejected",
798
+ files: applied ? files : [], warnings,
799
+ });
800
+ return { outcomes, warnings };
801
+ },
802
+ listSkillWorkspaces() {
803
+ return stmts.skillWorkspaces.all().map((row) => ({
804
+ workspaceId: row.workspace_id,
805
+ activeCount: row.n,
806
+ updatedAt: row.u,
807
+ }));
808
+ },
809
+ recordSkillUse(workspaceId, skillId, scope) {
810
+ try {
811
+ stmts.skillUseUpsert.run(workspaceId, skillId, scope, new Date().toISOString());
812
+ }
813
+ catch {
814
+ // Accounting never fails the read it rides on.
815
+ }
816
+ },
817
+ getSkillUse(workspaceId, skillId, scope) {
818
+ try {
819
+ const row = stmts.skillUseGet.get(workspaceId, skillId, scope);
820
+ if (row === undefined) {
821
+ return undefined;
822
+ }
823
+ return { useCount: row.use_count, lastUsedAt: row.last_used_at };
824
+ }
825
+ catch {
826
+ return undefined;
827
+ }
828
+ },
829
+ reviewSummary(workspaceId, since) {
830
+ try {
831
+ const rows = stmts.reviewRows.all(workspaceId, since ?? "");
832
+ const summary = zeroReviewSummary();
833
+ const skips = new Map();
834
+ for (const row of rows) {
835
+ if (row.status === "applied") {
836
+ summary.applied += 1;
837
+ }
838
+ else if (row.status === "rejected") {
839
+ summary.rejected += 1;
840
+ }
841
+ else if (row.status === "failed") {
842
+ summary.failed += 1;
843
+ }
844
+ else {
845
+ summary.skipped += 1;
846
+ }
847
+ if (row.status !== "applied" && row.status !== "rejected" && typeof row.skip_reason === "string" && row.skip_reason.length > 0) {
848
+ skips.set(row.skip_reason, (skips.get(row.skip_reason) ?? 0) + 1);
849
+ }
850
+ if (typeof row.usage === "string" && row.usage.length > 0) {
851
+ try {
852
+ const normalized = normalizeUsage(JSON.parse(row.usage));
853
+ if (normalized !== null) {
854
+ summary.tokens = sumUsage(summary.tokens, normalized);
855
+ }
856
+ }
857
+ catch {
858
+ // One corrupt payload must not void the whole audit.
859
+ }
860
+ }
861
+ }
862
+ summary.topSkips = [...skips.entries()]
863
+ .sort((a, b) => b[1] - a[1])
864
+ .slice(0, 3)
865
+ .map(([reason, count]) => ({
866
+ reason: reason.length > 80 ? `${reason.slice(0, 80)}…` : reason,
867
+ count,
868
+ }));
869
+ return summary;
870
+ }
871
+ catch {
872
+ return zeroReviewSummary();
873
+ }
874
+ },
292
875
  };
293
876
  return store;
294
877
  }