dsh-continual-evolve 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/README.zh.md +13 -9
- package/client/client.js +181 -16
- package/lib/card-routes.d.ts +2 -0
- package/lib/card-routes.js +66 -1
- package/lib/extraction.d.ts +5 -2
- package/lib/extraction.js +84 -7
- package/lib/index.d.ts +4 -2
- package/lib/index.js +7 -2
- package/lib/memory-snapshot.d.ts +52 -0
- package/lib/memory-snapshot.js +100 -4
- package/lib/skill-rules.d.ts +73 -0
- package/lib/skill-rules.js +158 -0
- package/lib/skill-tools.d.ts +9 -0
- package/lib/skill-tools.js +198 -0
- package/lib/store.d.ts +73 -5
- package/lib/store.js +504 -6
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
255
|
-
// string; comparing them keeps the
|
|
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 = {
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-continual-evolve",
|
|
3
|
-
"version": "0.15.
|
|
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": {
|