dsh-continual-evolve 0.14.2 → 0.15.1
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 +35 -23
- package/README.zh.md +35 -23
- package/client/client.js +32 -43
- package/lib/card-routes.d.ts +3 -3
- package/lib/card-routes.js +38 -50
- package/lib/extraction-prompt.d.ts +52 -0
- package/lib/extraction-prompt.js +139 -0
- package/lib/extraction-surface.d.ts +66 -0
- package/lib/extraction-surface.js +186 -0
- package/lib/extraction.d.ts +56 -0
- package/lib/extraction.js +280 -0
- package/lib/import-md.d.ts +15 -0
- package/lib/import-md.js +140 -0
- package/lib/index.d.ts +56 -27
- package/lib/index.js +120 -65
- package/lib/memory-guide.d.ts +22 -14
- package/lib/memory-guide.js +33 -76
- package/lib/memory-rules.d.ts +56 -0
- package/lib/memory-rules.js +152 -0
- package/lib/memory-section.d.ts +26 -26
- package/lib/memory-section.js +48 -82
- package/lib/memory-snapshot.d.ts +43 -59
- package/lib/memory-snapshot.js +101 -141
- package/lib/memory-tools.d.ts +34 -0
- package/lib/memory-tools.js +142 -0
- package/lib/store-apply.d.ts +46 -0
- package/lib/store-apply.js +177 -0
- package/lib/store.d.ts +99 -0
- package/lib/store.js +241 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +8 -6
- package/lib/known-workspaces.d.ts +0 -22
- package/lib/known-workspaces.js +0 -42
- package/lib/workspace-catalog.d.ts +0 -30
- package/lib/workspace-catalog.js +0 -149
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { fieldGateReasons, isValidMemoryId, slugifyId, } from "./memory-rules.js";
|
|
2
|
+
function recordOf(row) {
|
|
3
|
+
return {
|
|
4
|
+
workspaceId: row.workspace_id,
|
|
5
|
+
id: row.id,
|
|
6
|
+
type: row.type,
|
|
7
|
+
title: row.title,
|
|
8
|
+
description: row.description,
|
|
9
|
+
body: row.body,
|
|
10
|
+
status: row.status,
|
|
11
|
+
sourceSession: row.source_session,
|
|
12
|
+
sourceSeqs: row.source_seqs,
|
|
13
|
+
sourceRun: row.source_run,
|
|
14
|
+
createdAt: row.created_at,
|
|
15
|
+
updatedAt: row.updated_at,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const INSERT_SQL = "INSERT INTO memories (workspace_id, id, type, title, description, body, status, source_session, source_seqs, source_run, created_at, updated_at) " +
|
|
19
|
+
"VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)";
|
|
20
|
+
const UPDATE_SQL = "UPDATE memories SET type = ?, title = ?, description = ?, body = ?, updated_at = ? WHERE workspace_id = ? AND id = ?";
|
|
21
|
+
const DELETE_SQL = "DELETE FROM memories WHERE workspace_id = ? AND id = ?";
|
|
22
|
+
const FTS_DROP_SQL = "DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories_fts WHERE workspace_id = ? AND id = ?)";
|
|
23
|
+
const FTS_INSERT_SQL = "INSERT INTO memories_fts (workspace_id, id, title, description, body) " +
|
|
24
|
+
"SELECT workspace_id, id, title, description, body FROM memories WHERE workspace_id = ? AND id = ?";
|
|
25
|
+
/** Re-sync the FTS row for one memory id from its table row (delete + insert). */
|
|
26
|
+
function syncFts(db, workspaceId, id) {
|
|
27
|
+
db.database.prepare(FTS_DROP_SQL).run(workspaceId, id);
|
|
28
|
+
db.database.prepare(FTS_INSERT_SQL).run(workspaceId, id);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validate and apply the whole batch atomically. A batch with any invalid
|
|
32
|
+
* proposal mutates nothing and still lands a `rejected` ledger row; an
|
|
33
|
+
* applied batch lands an `applied` row with full before/after snapshots.
|
|
34
|
+
*/
|
|
35
|
+
export function applyProposals(db, ctx, proposals) {
|
|
36
|
+
const started = Date.now();
|
|
37
|
+
const outcomes = [];
|
|
38
|
+
const files = [];
|
|
39
|
+
const runId = ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : undefined);
|
|
40
|
+
const get = db.database.prepare("SELECT * FROM memories WHERE workspace_id = ? AND id = ?");
|
|
41
|
+
const insert = db.database.prepare(INSERT_SQL);
|
|
42
|
+
const update = db.database.prepare(UPDATE_SQL);
|
|
43
|
+
const del = db.database.prepare(DELETE_SQL);
|
|
44
|
+
db.database.exec("BEGIN");
|
|
45
|
+
for (const proposal of proposals) {
|
|
46
|
+
const outcome = applyOne(db, ctx, proposal, { get, insert, update, del }, files);
|
|
47
|
+
outcomes.push(outcome);
|
|
48
|
+
if (!outcome.ok) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const applied = outcomes.every((outcome) => outcome.ok);
|
|
53
|
+
db.database.exec(applied ? "COMMIT" : "ROLLBACK");
|
|
54
|
+
const ledger = {
|
|
55
|
+
runId,
|
|
56
|
+
sessionId: ctx.sessionId,
|
|
57
|
+
workspaceId: ctx.workspaceId,
|
|
58
|
+
trigger: ctx.trigger,
|
|
59
|
+
decision: ctx.decision,
|
|
60
|
+
status: applied ? "applied" : "rejected",
|
|
61
|
+
durationMs: Date.now() - started,
|
|
62
|
+
model: ctx.model,
|
|
63
|
+
usage: ctx.usage,
|
|
64
|
+
files: applied ? files : [],
|
|
65
|
+
};
|
|
66
|
+
if (!applied) {
|
|
67
|
+
ledger.decision = `${ctx.decision ?? ""} first failure: ${outcomes.find((o) => !o.ok)?.reason ?? ""}`.trim();
|
|
68
|
+
}
|
|
69
|
+
writeLedger(db.database.prepare(LEDGER_SQL), ledger);
|
|
70
|
+
return { outcomes, applied };
|
|
71
|
+
}
|
|
72
|
+
const LEDGER_SQL = "INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
|
|
73
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
74
|
+
function writeLedger(stmt, entry) {
|
|
75
|
+
stmt.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.skipReason ?? null, entry.status ?? null, entry.durationMs ?? null, entry.model ?? null, entry.usage === undefined ? null : JSON.stringify(entry.usage), entry.files === undefined ? null : JSON.stringify(entry.files));
|
|
76
|
+
}
|
|
77
|
+
/** Validate one proposal and land it; appends the audit payload on success. */
|
|
78
|
+
function applyOne(db, ctx, proposal, stmts, files) {
|
|
79
|
+
const action = proposal.action;
|
|
80
|
+
if (action !== "create" && action !== "update" && action !== "delete") {
|
|
81
|
+
return fail(proposal, `unknown action ${String(action)}`);
|
|
82
|
+
}
|
|
83
|
+
const id = idFor(proposal);
|
|
84
|
+
if (!isValidMemoryId(id)) {
|
|
85
|
+
return fail(proposal, id.length === 0 ? "id missing and cannot be derived from the title" : `malformed id "${id}"`);
|
|
86
|
+
}
|
|
87
|
+
const existingRow = stmts.get.get(ctx.workspaceId, id);
|
|
88
|
+
const existing = existingRow === undefined ? undefined : recordOf(existingRow);
|
|
89
|
+
if (action === "delete") {
|
|
90
|
+
if (existing === undefined) {
|
|
91
|
+
return fail(proposal, `no memory "${id}" to delete`);
|
|
92
|
+
}
|
|
93
|
+
stmts.del.run(ctx.workspaceId, id);
|
|
94
|
+
syncFts(db, ctx.workspaceId, id);
|
|
95
|
+
files.push({ id, action, before: existing });
|
|
96
|
+
return { id, action, ok: true };
|
|
97
|
+
}
|
|
98
|
+
const fields = mergedFields(proposal, existing, action);
|
|
99
|
+
if ("reason" in fields) {
|
|
100
|
+
return fail(proposal, fields.reason);
|
|
101
|
+
}
|
|
102
|
+
const reasons = fieldGateReasons(fields.fields);
|
|
103
|
+
if (reasons.length > 0) {
|
|
104
|
+
return fail(proposal, reasons[0] ?? "invalid record");
|
|
105
|
+
}
|
|
106
|
+
if (action === "create") {
|
|
107
|
+
if (existing !== undefined) {
|
|
108
|
+
return fail(proposal, `memory "${id}" already exists — update it instead of creating a duplicate`);
|
|
109
|
+
}
|
|
110
|
+
const now = db.now();
|
|
111
|
+
stmts.insert.run(ctx.workspaceId, id, fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, ctx.sessionId ?? null, proposal.sourceSeqs ?? null, ctx.runId ?? (ctx.trigger === "explicit" ? "explicit" : null), now, now);
|
|
112
|
+
syncFts(db, ctx.workspaceId, id);
|
|
113
|
+
files.push({
|
|
114
|
+
id,
|
|
115
|
+
action,
|
|
116
|
+
after: recordOf(stmts.get.get(ctx.workspaceId, id)),
|
|
117
|
+
sourceSeqs: proposal.sourceSeqs,
|
|
118
|
+
});
|
|
119
|
+
return { id, action, ok: true };
|
|
120
|
+
}
|
|
121
|
+
// action === "update"
|
|
122
|
+
if (existing === undefined) {
|
|
123
|
+
return fail(proposal, `no memory "${id}" to update — create it instead`);
|
|
124
|
+
}
|
|
125
|
+
stmts.update.run(fields.fields.type, fields.fields.title, fields.fields.description, fields.fields.body, db.now(), ctx.workspaceId, id);
|
|
126
|
+
syncFts(db, ctx.workspaceId, id);
|
|
127
|
+
files.push({ id, action, before: existing, after: recordOf(stmts.get.get(ctx.workspaceId, id)) });
|
|
128
|
+
return { id, action, ok: true };
|
|
129
|
+
}
|
|
130
|
+
/** Failure outcome, carrying the proposal id when one was readable. */
|
|
131
|
+
function fail(proposal, reason) {
|
|
132
|
+
return { id: proposal.id ?? "", action: proposal.action, ok: false, reason };
|
|
133
|
+
}
|
|
134
|
+
/** The proposal id, derived from the title (slug, CJK-hash fallback) on create. */
|
|
135
|
+
function idFor(proposal) {
|
|
136
|
+
if (proposal.id !== undefined && proposal.id.trim().length > 0) {
|
|
137
|
+
return proposal.id.trim();
|
|
138
|
+
}
|
|
139
|
+
if (proposal.action === "create" && typeof proposal.title === "string") {
|
|
140
|
+
return slugifyId(proposal.title);
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The merged field values a create/update would write. Create needs all
|
|
146
|
+
* fields supplied; update merges over the stored record. A missing-required
|
|
147
|
+
* result carries a `reason` instead.
|
|
148
|
+
*/
|
|
149
|
+
function mergedFields(proposal, existing, action) {
|
|
150
|
+
const pick = (value, fallback) => typeof value === "string" ? value : fallback;
|
|
151
|
+
if (action === "create") {
|
|
152
|
+
if (typeof proposal.type !== "string" || typeof proposal.title !== "string" ||
|
|
153
|
+
typeof proposal.description !== "string" || typeof proposal.body !== "string") {
|
|
154
|
+
return { reason: "create requires id?, type, title, description, body" };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
fields: {
|
|
158
|
+
type: proposal.type,
|
|
159
|
+
title: proposal.title,
|
|
160
|
+
description: proposal.description,
|
|
161
|
+
body: proposal.body,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (existing === undefined) {
|
|
166
|
+
return { reason: "no memory to merge into" };
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
fields: {
|
|
170
|
+
type: (typeof proposal.type === "string" ? proposal.type : existing.type),
|
|
171
|
+
title: pick(proposal.title, existing.title),
|
|
172
|
+
description: pick(proposal.description, existing.description),
|
|
173
|
+
body: pick(proposal.body, existing.body),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=store-apply.js.map
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { MemoryProposal } from "./memory-rules.js";
|
|
2
|
+
import { type ProposalContext, type ProposalOutcome } from "./store-apply.js";
|
|
3
|
+
export type { MemoryProposal, ProposalContext, ProposalOutcome };
|
|
4
|
+
/** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
|
|
5
|
+
export declare class StoreUnavailableError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** State-table keys owned by the store (partitioned per workspace, `""` = global). */
|
|
8
|
+
export declare const CURSOR_KEY_PREFIX = "seq:";
|
|
9
|
+
export declare const PATROL_STATE_KEY = "patrol:last";
|
|
10
|
+
export declare const MD_IMPORT_STATE_KEY = "md-imported";
|
|
11
|
+
/** Global partition id for workspace-independent state rows. */
|
|
12
|
+
export declare const GLOBAL_WORKSPACE = "";
|
|
13
|
+
/** Preview size cap for the card's content endpoint (ZCode's viewer uses 5 MiB). */
|
|
14
|
+
export declare const MEMORY_FILE_PREVIEW_LIMIT: number;
|
|
15
|
+
/** One stored memory record (snake_case columns camelCased). */
|
|
16
|
+
export interface MemoryRecord {
|
|
17
|
+
workspaceId: string;
|
|
18
|
+
id: string;
|
|
19
|
+
type: "user" | "feedback" | "reference";
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
body: string;
|
|
23
|
+
status: "active" | "quarantined" | "archived";
|
|
24
|
+
/** Origin session id, `null` for imports. */
|
|
25
|
+
sourceSession: string | null;
|
|
26
|
+
/** Trajectory seq range the fact came from ("820-831"), `null` when unknown. */
|
|
27
|
+
sourceSeqs: string | null;
|
|
28
|
+
/** The extraction run that wrote it; `'explicit'` for tool writes. */
|
|
29
|
+
sourceRun: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
/** One ledger row (audit + rollback via embedded before/after snapshots). */
|
|
34
|
+
export interface LedgerEntry {
|
|
35
|
+
runId?: string | undefined;
|
|
36
|
+
sessionId?: string | undefined;
|
|
37
|
+
workspaceId?: string | undefined;
|
|
38
|
+
trigger: "turn-debounce" | "compaction" | "close-drain" | "explicit" | "import";
|
|
39
|
+
decision?: string | undefined;
|
|
40
|
+
skipReason?: string | undefined;
|
|
41
|
+
status?: string | undefined;
|
|
42
|
+
durationMs?: number | undefined;
|
|
43
|
+
model?: string | undefined;
|
|
44
|
+
usage?: unknown;
|
|
45
|
+
/** Applied/attempted mutations: `{id, action, before?, after?, sourceSeqs}`. */
|
|
46
|
+
files?: unknown;
|
|
47
|
+
}
|
|
48
|
+
/** Workspace rows the card's selector lists. */
|
|
49
|
+
export interface WorkspaceSummary {
|
|
50
|
+
workspaceId: string;
|
|
51
|
+
activeCount: number;
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
}
|
|
54
|
+
/** What a patrol pass found and did. */
|
|
55
|
+
export interface PatrolResult {
|
|
56
|
+
ts: string;
|
|
57
|
+
/** Orphan FTS rows (no matching memory) removed. */
|
|
58
|
+
orphanFtsRows: number;
|
|
59
|
+
/** Records quarantined by the secret screen, with the redacted reasons. */
|
|
60
|
+
quarantined: {
|
|
61
|
+
workspaceId: string;
|
|
62
|
+
id: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
/** The store surface every consumer (tool, extraction, card, injection) uses. */
|
|
67
|
+
export interface MemoryStore {
|
|
68
|
+
readonly path: string;
|
|
69
|
+
get(workspaceId: string, id: string): MemoryRecord | undefined;
|
|
70
|
+
/** Active records, injection order: feedback > user > reference, then newest. */
|
|
71
|
+
list(workspaceId: string): MemoryRecord[];
|
|
72
|
+
/** Every non-archived record regardless of status (the card's projection). */
|
|
73
|
+
listAll(workspaceId: string): MemoryRecord[];
|
|
74
|
+
/**
|
|
75
|
+
* Keyword search, workspace-scoped: whitespace-separated tokens are
|
|
76
|
+
* AND-combined — trigram FTS when every token is ≥3 chars, literal LIKE
|
|
77
|
+
* substrings otherwise, and a LIKE fallback whenever FTS finds nothing.
|
|
78
|
+
*/
|
|
79
|
+
search(workspaceId: string, query: string, limit?: number): MemoryRecord[];
|
|
80
|
+
/** The sole write gate: validate all proposals, apply atomically, ledger. */
|
|
81
|
+
applyProposals(ctx: ProposalContext, proposals: readonly MemoryProposal[]): ProposalOutcome[];
|
|
82
|
+
/** Ledger a run that produced no mutations (skips must be auditable too). */
|
|
83
|
+
logSkipped(entry: LedgerEntry): void;
|
|
84
|
+
cursor(workspaceId: string, sessionId: string): number | undefined;
|
|
85
|
+
setCursor(workspaceId: string, sessionId: string, seq: number): void;
|
|
86
|
+
state(workspaceId: string, key: string): string | undefined;
|
|
87
|
+
setState(workspaceId: string, key: string, value: string): void;
|
|
88
|
+
listWorkspaces(): WorkspaceSummary[];
|
|
89
|
+
/** Hygiene pass: orphan FTS cleanup + secret quarantine. */
|
|
90
|
+
patrol(): PatrolResult;
|
|
91
|
+
/** Ledger rows older than the cutoff, removed (the one unbounded table). */
|
|
92
|
+
pruneLedger(beforeIso: string): number;
|
|
93
|
+
close(): void;
|
|
94
|
+
}
|
|
95
|
+
/** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
|
|
96
|
+
export declare function defaultStorePath(): string;
|
|
97
|
+
/** Open (creating on first use) the central store. */
|
|
98
|
+
export declare function openMemoryStore(explicitPath?: string): Promise<MemoryStore>;
|
|
99
|
+
//# sourceMappingURL=store.d.ts.map
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The central SQLite memory store: one database file
|
|
3
|
+
* (`~/.dsh/evolve/memory.db`, rows partitioned by workspace path) holding
|
|
4
|
+
* every memory record, its FTS index, the extraction ledger, and per-session
|
|
5
|
+
* extraction cursors. The plugin's code is the literal sole writer — models
|
|
6
|
+
* propose, `applyProposals` gates and lands. See ADR
|
|
7
|
+
* `2026-10-06-sqlite-single-store`.
|
|
8
|
+
*
|
|
9
|
+
* Requires `node:sqlite` (Node ≥ 22.5; the desktop host's EnsureNode runtime
|
|
10
|
+
* guarantees v26). Unavailability is a typed error the entry point degrades
|
|
11
|
+
* on — injection and tooling switch off, the host keeps running.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { secretLeakReason } from "./memory-rules.js";
|
|
17
|
+
import { applyProposals } from "./store-apply.js";
|
|
18
|
+
/** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
|
|
19
|
+
export class StoreUnavailableError extends Error {
|
|
20
|
+
}
|
|
21
|
+
/** State-table keys owned by the store (partitioned per workspace, `""` = global). */
|
|
22
|
+
export const CURSOR_KEY_PREFIX = "seq:";
|
|
23
|
+
export const PATROL_STATE_KEY = "patrol:last";
|
|
24
|
+
export const MD_IMPORT_STATE_KEY = "md-imported";
|
|
25
|
+
/** Global partition id for workspace-independent state rows. */
|
|
26
|
+
export const GLOBAL_WORKSPACE = "";
|
|
27
|
+
/** Preview size cap for the card's content endpoint (ZCode's viewer uses 5 MiB). */
|
|
28
|
+
export const MEMORY_FILE_PREVIEW_LIMIT = 5 * 1024 * 1024;
|
|
29
|
+
const DDL = `
|
|
30
|
+
PRAGMA journal_mode=WAL;
|
|
31
|
+
PRAGMA busy_timeout=5000;CREATE TABLE IF NOT EXISTS memories (
|
|
32
|
+
workspace_id TEXT NOT NULL,
|
|
33
|
+
id TEXT NOT NULL,
|
|
34
|
+
type TEXT NOT NULL CHECK (type IN ('user','feedback','reference')),
|
|
35
|
+
title TEXT NOT NULL,
|
|
36
|
+
description TEXT NOT NULL,
|
|
37
|
+
body TEXT NOT NULL CHECK (length(body) <= 65536),
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
39
|
+
CHECK (status IN ('active','quarantined','archived')),
|
|
40
|
+
source_session TEXT,
|
|
41
|
+
source_seqs TEXT,
|
|
42
|
+
source_run TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL,
|
|
45
|
+
PRIMARY KEY (workspace_id, id)
|
|
46
|
+
) STRICT;
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_mem_ws_status ON memories(workspace_id, status);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_mem_ws_updated ON memories(workspace_id, updated_at);
|
|
49
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
50
|
+
workspace_id, id, title, description, body, tokenize='trigram');
|
|
51
|
+
-- FTS rows are synced in code by applyProposals (plain DELETE + INSERT), not
|
|
52
|
+
-- by triggers: node:sqlite rejects the FTS5 'delete'-command INSERT that
|
|
53
|
+
-- AFTER DELETE/UPDATE triggers would need (SQL logic error, verified on
|
|
54
|
+
-- SQLite 3.53.4). The sole-writer design makes code-side sync reliable;
|
|
55
|
+
-- patrol() reconciles any residue.
|
|
56
|
+
CREATE TABLE IF NOT EXISTS extraction_log (
|
|
57
|
+
run_id TEXT, ts TEXT NOT NULL, session_id TEXT, workspace_id TEXT,
|
|
58
|
+
trigger TEXT CHECK (trigger IN ('turn-debounce','compaction','close-drain','explicit','import')),
|
|
59
|
+
decision TEXT, skip_reason TEXT,
|
|
60
|
+
status TEXT, duration_ms INTEGER, model TEXT, usage TEXT,
|
|
61
|
+
files TEXT
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
64
|
+
workspace_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
65
|
+
PRIMARY KEY (workspace_id, key)
|
|
66
|
+
) STRICT;
|
|
67
|
+
`;
|
|
68
|
+
/** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
|
|
69
|
+
export function defaultStorePath() {
|
|
70
|
+
const home = process.env.DSH_HOME?.trim();
|
|
71
|
+
return resolve(home && home.length > 0 ? home : join(homedir(), ".dsh"), "evolve", "memory.db");
|
|
72
|
+
}
|
|
73
|
+
function recordOf(row) {
|
|
74
|
+
return {
|
|
75
|
+
workspaceId: row.workspace_id,
|
|
76
|
+
id: row.id,
|
|
77
|
+
type: row.type,
|
|
78
|
+
title: row.title,
|
|
79
|
+
description: row.description,
|
|
80
|
+
body: row.body,
|
|
81
|
+
status: row.status,
|
|
82
|
+
sourceSession: row.source_session,
|
|
83
|
+
sourceSeqs: row.source_seqs,
|
|
84
|
+
sourceRun: row.source_run,
|
|
85
|
+
createdAt: row.created_at,
|
|
86
|
+
updatedAt: row.updated_at,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const LIST_ORDER = "ORDER BY CASE type WHEN 'feedback' THEN 0 WHEN 'user' THEN 1 ELSE 2 END, updated_at DESC";
|
|
90
|
+
/** Quote one token as an FTS5 phrase (trigram needs ≥3 chars to match). */
|
|
91
|
+
function ftsPhrase(token) {
|
|
92
|
+
return `"${token.replaceAll('"', '""')}"`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Split a query into keyword tokens on ASCII or full-width whitespace. Each
|
|
96
|
+
* token is matched on its own and AND-combined: trigram FTS can only match
|
|
97
|
+
* ≥3-char runs, so a keyword pair like `中文 交接` is unrepresentable as one
|
|
98
|
+
* FTS phrase and silently returns nothing under a phrase match.
|
|
99
|
+
*/
|
|
100
|
+
function queryTokens(query) {
|
|
101
|
+
return query.split(/[\s\u3000]+/).filter((token) => token.length > 0);
|
|
102
|
+
}
|
|
103
|
+
/** `%token%` with LIKE metacharacters escaped — keywords match literally. */
|
|
104
|
+
function likePattern(token) {
|
|
105
|
+
return `%${token.replaceAll(/[\\%_]/g, "\\$&")}%`;
|
|
106
|
+
}
|
|
107
|
+
/** Open (creating on first use) the central store. */
|
|
108
|
+
export async function openMemoryStore(explicitPath) {
|
|
109
|
+
const path = explicitPath === undefined ? defaultStorePath() : resolve(explicitPath);
|
|
110
|
+
let database;
|
|
111
|
+
try {
|
|
112
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
113
|
+
// The parent directory must exist before SQLite opens the file — a
|
|
114
|
+
// fresh `~/.dsh` has no `evolve/` yet.
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
database = new DatabaseSync(path);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new StoreUnavailableError(`node:sqlite unavailable (Node ≥ 22.5 required) — memory store disabled: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
}
|
|
121
|
+
database.exec(DDL);
|
|
122
|
+
const stmts = {
|
|
123
|
+
get: database.prepare("SELECT * FROM memories WHERE workspace_id = ? AND id = ?"),
|
|
124
|
+
list: database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' ${LIST_ORDER}`),
|
|
125
|
+
listAll: database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status != 'archived' ${LIST_ORDER}`),
|
|
126
|
+
fts: database.prepare(`SELECT m.* FROM memories m WHERE (m.workspace_id, m.id) IN (
|
|
127
|
+
SELECT workspace_id, id FROM memories_fts WHERE memories_fts MATCH ? AND workspace_id = ?
|
|
128
|
+
) AND m.status = 'active' ${LIST_ORDER} LIMIT ?`),
|
|
129
|
+
stateGet: database.prepare("SELECT value FROM state WHERE workspace_id = ? AND key = ?"),
|
|
130
|
+
stateSet: database.prepare("INSERT INTO state (workspace_id, key, value) VALUES (?, ?, ?) " +
|
|
131
|
+
"ON CONFLICT (workspace_id, key) DO UPDATE SET value = excluded.value"),
|
|
132
|
+
cursorGet: database.prepare("SELECT CAST(value AS INTEGER) AS seq FROM state WHERE workspace_id = ? AND key = ?"),
|
|
133
|
+
workspaces: database.prepare("SELECT workspace_id, COUNT(*) AS n, MAX(updated_at) AS u FROM memories WHERE status = 'active' GROUP BY workspace_id ORDER BY u DESC"),
|
|
134
|
+
ledger: database.prepare("INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
|
|
135
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
|
136
|
+
prune: database.prepare("DELETE FROM extraction_log WHERE ts < ?"),
|
|
137
|
+
ftsOrphans: database.prepare("SELECT f.rowid AS rid FROM memories_fts f LEFT JOIN memories m ON m.workspace_id = f.workspace_id AND m.id = f.id WHERE m.id IS NULL"),
|
|
138
|
+
ftsDrop: database.prepare("DELETE FROM memories_fts WHERE rowid = ?"),
|
|
139
|
+
activeRows: database.prepare("SELECT * FROM memories WHERE status = 'active'"),
|
|
140
|
+
quarantine: database.prepare("UPDATE memories SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
|
|
141
|
+
};
|
|
142
|
+
// LIKE fallback: one OR-triplet per token, every token required. Statements
|
|
143
|
+
// are cached by token count — the query set repeats, the counts don't grow.
|
|
144
|
+
const likeStmts = new Map();
|
|
145
|
+
const likeSearch = (workspaceId, tokens, limit) => {
|
|
146
|
+
let stmt = likeStmts.get(tokens.length);
|
|
147
|
+
if (stmt === undefined) {
|
|
148
|
+
const clause = tokens
|
|
149
|
+
.map(() => "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')")
|
|
150
|
+
.join(" AND ");
|
|
151
|
+
stmt = database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' AND ${clause} ${LIST_ORDER} LIMIT ?`);
|
|
152
|
+
likeStmts.set(tokens.length, stmt);
|
|
153
|
+
}
|
|
154
|
+
const patterns = tokens.flatMap((token) => {
|
|
155
|
+
const pattern = likePattern(token);
|
|
156
|
+
return [pattern, pattern, pattern];
|
|
157
|
+
});
|
|
158
|
+
return stmt.all(workspaceId, ...patterns, limit).map(recordOf);
|
|
159
|
+
};
|
|
160
|
+
const store = {
|
|
161
|
+
path,
|
|
162
|
+
get(workspaceId, id) {
|
|
163
|
+
const row = stmts.get.get(workspaceId, id);
|
|
164
|
+
return row === undefined ? undefined : recordOf(row);
|
|
165
|
+
},
|
|
166
|
+
list(workspaceId) {
|
|
167
|
+
return stmts.list.all(workspaceId).map(recordOf);
|
|
168
|
+
},
|
|
169
|
+
listAll(workspaceId) {
|
|
170
|
+
return stmts.listAll.all(workspaceId).map(recordOf);
|
|
171
|
+
},
|
|
172
|
+
search(workspaceId, query, limit = 20) {
|
|
173
|
+
const tokens = queryTokens(query);
|
|
174
|
+
if (tokens.length === 0) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
if (tokens.every((token) => token.length >= 3)) {
|
|
178
|
+
const hits = stmts.fts.all(tokens.map(ftsPhrase).join(" AND "), workspaceId, limit);
|
|
179
|
+
if (hits.length > 0) {
|
|
180
|
+
return hits.map(recordOf);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return likeSearch(workspaceId, tokens, limit);
|
|
184
|
+
},
|
|
185
|
+
applyProposals(ctx, proposals) {
|
|
186
|
+
return applyProposals({ database, now: () => new Date().toISOString() }, ctx, proposals).outcomes;
|
|
187
|
+
},
|
|
188
|
+
logSkipped(entry) {
|
|
189
|
+
writeLedger(stmts.ledger, entry);
|
|
190
|
+
},
|
|
191
|
+
cursor(workspaceId, sessionId) {
|
|
192
|
+
const row = stmts.cursorGet.get(workspaceId, CURSOR_KEY_PREFIX + sessionId);
|
|
193
|
+
return row?.seq === null || row === undefined ? undefined : row.seq;
|
|
194
|
+
},
|
|
195
|
+
setCursor(workspaceId, sessionId, seq) {
|
|
196
|
+
stmts.stateSet.run(workspaceId, CURSOR_KEY_PREFIX + sessionId, String(seq));
|
|
197
|
+
},
|
|
198
|
+
state(workspaceId, key) {
|
|
199
|
+
const row = stmts.stateGet.get(workspaceId, key);
|
|
200
|
+
return row?.value;
|
|
201
|
+
},
|
|
202
|
+
setState(workspaceId, key, value) {
|
|
203
|
+
stmts.stateSet.run(workspaceId, key, value);
|
|
204
|
+
},
|
|
205
|
+
listWorkspaces() {
|
|
206
|
+
return stmts.workspaces.all().map((row) => ({
|
|
207
|
+
workspaceId: row.workspace_id,
|
|
208
|
+
activeCount: row.n,
|
|
209
|
+
updatedAt: row.u,
|
|
210
|
+
}));
|
|
211
|
+
},
|
|
212
|
+
patrol() {
|
|
213
|
+
const result = { ts: new Date().toISOString(), orphanFtsRows: 0, quarantined: [] };
|
|
214
|
+
for (const row of stmts.ftsOrphans.all()) {
|
|
215
|
+
stmts.ftsDrop.run(row.rid);
|
|
216
|
+
result.orphanFtsRows += 1;
|
|
217
|
+
}
|
|
218
|
+
for (const row of stmts.activeRows.all()) {
|
|
219
|
+
const reason = secretLeakReason(`${row.title}\n${row.description}\n${row.body}`);
|
|
220
|
+
if (reason !== undefined) {
|
|
221
|
+
stmts.quarantine.run(row.workspace_id, row.id);
|
|
222
|
+
result.quarantined.push({ workspaceId: row.workspace_id, id: row.id, reason });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
stmts.stateSet.run(GLOBAL_WORKSPACE, PATROL_STATE_KEY, JSON.stringify(result));
|
|
226
|
+
return result;
|
|
227
|
+
},
|
|
228
|
+
pruneLedger(beforeIso) {
|
|
229
|
+
return Number(stmts.prune.run(beforeIso).changes);
|
|
230
|
+
},
|
|
231
|
+
close() {
|
|
232
|
+
database.close();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
return store;
|
|
236
|
+
}
|
|
237
|
+
/** JSON-encode the optional ledger fields and insert the row (never throws past a warn). */
|
|
238
|
+
function writeLedger(stmt, entry) {
|
|
239
|
+
stmt.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.skipReason ?? null, entry.status ?? null, entry.durationMs ?? null, entry.model ?? null, entry.usage === undefined ? null : JSON.stringify(entry.usage), entry.files === undefined ? null : JSON.stringify(entry.files));
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=store.js.map
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Workspace Memory",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Central SQLite memory store partitioned by workspace: session-start index injection, gated explicit writes and proposal-based extraction."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-continual-evolve",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ZCode-style workspace memory for DeepSeek Harness:
|
|
3
|
+
"version": "0.15.1",
|
|
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": {
|
|
7
7
|
"type": "git",
|
|
@@ -53,18 +53,20 @@
|
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"build": "tsc -p tsconfig.json",
|
|
57
|
-
"prepare": "
|
|
56
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
57
|
+
"prepare": "npm run build",
|
|
58
|
+
"prepack": "npm run build && npm run check:pack",
|
|
58
59
|
"dev": "tsc -p tsconfig.json --watch",
|
|
59
60
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
|
|
60
61
|
"test": "vitest run",
|
|
61
62
|
"test:coverage": "vitest run --coverage",
|
|
62
63
|
"test:watch": "vitest",
|
|
63
64
|
"lint": "oxlint src test client",
|
|
64
|
-
"clean": "
|
|
65
|
+
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
|
|
65
66
|
"check:docs": "tsx scripts/run-gates.ts docs",
|
|
66
67
|
"check:handoff": "tsx scripts/verify-handoff-structure.ts --self-test && tsx scripts/verify-handoff-structure.ts",
|
|
67
|
-
"check:governance": "tsx scripts/verify-governance.ts"
|
|
68
|
+
"check:governance": "tsx scripts/verify-governance.ts",
|
|
69
|
+
"check:pack": "tsx scripts/verify-pack-contents.ts"
|
|
68
70
|
},
|
|
69
71
|
"peerDependencies": {
|
|
70
72
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/** One known workspace: the resolved root and when it was last served. */
|
|
2
|
-
export interface KnownWorkspace {
|
|
3
|
-
root: string;
|
|
4
|
-
/** ISO timestamp of the most recent section assembly seen for this root. */
|
|
5
|
-
lastSeen: string;
|
|
6
|
-
}
|
|
7
|
-
/** The allowlist the card routes read through. */
|
|
8
|
-
export interface KnownWorkspaces {
|
|
9
|
-
/**
|
|
10
|
-
* Record (or refresh) a workspace root. A relative or blank cwd is a
|
|
11
|
-
* silent no-op — the caller is the injection path, where a bad cwd
|
|
12
|
-
* already renders an empty section and must not turn into a throw.
|
|
13
|
-
*/
|
|
14
|
-
remember(cwd: string): void;
|
|
15
|
-
/** Known roots, most recently served first. */
|
|
16
|
-
list(): KnownWorkspace[];
|
|
17
|
-
}
|
|
18
|
-
/** How many recent workspaces the card tracks. */
|
|
19
|
-
export declare const DEFAULT_KNOWN_WORKSPACE_LIMIT = 8;
|
|
20
|
-
/** Create the in-memory allowlist; `limit` is the LRU bound. */
|
|
21
|
-
export declare function createKnownWorkspaces(limit?: number): KnownWorkspaces;
|
|
22
|
-
//# sourceMappingURL=known-workspaces.d.ts.map
|
package/lib/known-workspaces.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The workspace roots the card may look at, in memory only.
|
|
3
|
-
*
|
|
4
|
-
* Populated as a side effect of section assembly (the plugin already knows
|
|
5
|
-
* each session's cwd there), this list is the card's read allowlist: the
|
|
6
|
-
* memory API refuses any root the running process has not served a session
|
|
7
|
-
* in, so the endpoint cannot be used to probe the filesystem. Bounded LRU,
|
|
8
|
-
* never persisted — a restart starts empty and one session in a workspace
|
|
9
|
-
* puts it back. No second store on disk.
|
|
10
|
-
*/
|
|
11
|
-
import { isAbsolute, resolve } from "node:path";
|
|
12
|
-
/** How many recent workspaces the card tracks. */
|
|
13
|
-
export const DEFAULT_KNOWN_WORKSPACE_LIMIT = 8;
|
|
14
|
-
/** Create the in-memory allowlist; `limit` is the LRU bound. */
|
|
15
|
-
export function createKnownWorkspaces(limit = DEFAULT_KNOWN_WORKSPACE_LIMIT) {
|
|
16
|
-
const max = Math.max(1, limit);
|
|
17
|
-
// Insertion order encodes recency: the most recently served root is last.
|
|
18
|
-
const recent = new Map();
|
|
19
|
-
return {
|
|
20
|
-
remember(cwd) {
|
|
21
|
-
const trimmed = cwd.trim();
|
|
22
|
-
if (trimmed.length === 0 || !isAbsolute(trimmed)) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
recent.delete(resolve(trimmed));
|
|
26
|
-
recent.set(resolve(trimmed), new Date().toISOString());
|
|
27
|
-
while (recent.size > max) {
|
|
28
|
-
const oldest = recent.keys().next();
|
|
29
|
-
if (oldest.done === true) {
|
|
30
|
-
break;
|
|
31
|
-
}
|
|
32
|
-
recent.delete(oldest.value);
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
list() {
|
|
36
|
-
return [...recent.entries()]
|
|
37
|
-
.reverse()
|
|
38
|
-
.map(([root, lastSeen]) => ({ root, lastSeen }));
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
//# sourceMappingURL=known-workspaces.js.map
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { KnownWorkspaces } from "./known-workspaces.js";
|
|
2
|
-
/** One workspace the card knows: canonical root plus its display label. */
|
|
3
|
-
export interface CatalogEntry {
|
|
4
|
-
root: string;
|
|
5
|
-
/** Display title from the registry, else the directory name. */
|
|
6
|
-
label: string;
|
|
7
|
-
}
|
|
8
|
-
/** The read fence (`known`) and the listed set (`list`). */
|
|
9
|
-
export interface WorkspaceCatalog {
|
|
10
|
-
/** Every workspace the card may read, in registry order. */
|
|
11
|
-
known(): CatalogEntry[];
|
|
12
|
-
/** The known workspaces that currently have a memory directory. */
|
|
13
|
-
list(): CatalogEntry[];
|
|
14
|
-
}
|
|
15
|
-
/** The slice of `ctx.workspaceRegistry` this plugin consumes. */
|
|
16
|
-
export interface WorkspaceRegistryLike {
|
|
17
|
-
list(): unknown;
|
|
18
|
-
}
|
|
19
|
-
/** Where the catalog gets its workspace records, in precedence order. */
|
|
20
|
-
export interface WorkspaceCatalogOptions {
|
|
21
|
-
/** Live registry lookup; undefined when the host exposes no registry. */
|
|
22
|
-
registry?: (() => WorkspaceRegistryLike | undefined) | undefined;
|
|
23
|
-
/** The roots this process served — the last-resort source. */
|
|
24
|
-
served: KnownWorkspaces;
|
|
25
|
-
/** Harness home; enables the `storages/workspace.json` fallback when set. */
|
|
26
|
-
homeDir?: string | undefined;
|
|
27
|
-
}
|
|
28
|
-
/** Create the catalog over the given sources. */
|
|
29
|
-
export declare function createWorkspaceCatalog(options: WorkspaceCatalogOptions): WorkspaceCatalog;
|
|
30
|
-
//# sourceMappingURL=workspace-catalog.d.ts.map
|