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/README.md +14 -10
- package/README.zh.md +14 -10
- package/client/client.js +229 -16
- package/lib/card-routes.d.ts +2 -0
- package/lib/card-routes.js +74 -1
- package/lib/index.d.ts +4 -2
- package/lib/index.js +7 -2
- package/lib/memory-snapshot.d.ts +59 -1
- package/lib/memory-snapshot.js +126 -5
- package/lib/skill-rules.d.ts +79 -0
- package/lib/skill-rules.js +166 -0
- package/lib/skill-tools.d.ts +9 -0
- package/lib/skill-tools.js +205 -0
- package/lib/store.d.ts +112 -6
- package/lib/store.js +589 -6
- package/lib/token-usage.d.ts +30 -0
- package/lib/token-usage.js +58 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
255
|
-
// string; comparing them keeps the
|
|
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 = {
|
|
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
|
-
|
|
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
|
}
|