dsh-engram 0.2.0 → 0.3.2
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 +115 -5
- package/README.zh.md +60 -4
- package/lib/api.js +91 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +163 -27
- package/lib/index.js +43 -1
- package/lib/mental.js +170 -0
- package/lib/obs.js +71 -0
- package/lib/rerank.js +50 -0
- package/lib/settings.js +3 -0
- package/lib/store.js +330 -32
- package/lib/tools.js +349 -62
- package/lib/trigger.js +379 -0
- package/lib/usage.js +224 -60
- package/lib/util.js +107 -0
- package/package.json +3 -7
package/lib/settings.js
CHANGED
|
@@ -22,6 +22,7 @@ export const SETTINGS_NS = settingsNamespace(SETTINGS_NS_NAME);
|
|
|
22
22
|
export const SETTINGS_KEYS = [
|
|
23
23
|
"autoCapture",
|
|
24
24
|
"sessionSearch",
|
|
25
|
+
"maxRecallPerSession",
|
|
25
26
|
"autoCapturePerSession",
|
|
26
27
|
"indexMaxLines",
|
|
27
28
|
"indexMaxChars",
|
|
@@ -48,6 +49,7 @@ export function makeSettingsSchema(defaults) {
|
|
|
48
49
|
return z.object({
|
|
49
50
|
autoCapture: z.boolean().default(defaults.autoCapture),
|
|
50
51
|
sessionSearch: z.boolean().default(defaults.sessionSearch),
|
|
52
|
+
maxRecallPerSession: z.natural().min(1).max(10).default(defaults.maxRecallPerSession),
|
|
51
53
|
autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
|
|
52
54
|
indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
|
|
53
55
|
indexMaxChars: z.natural().max(4000).default(defaults.indexMaxChars),
|
|
@@ -57,6 +59,7 @@ export function makeSettingsSchema(defaults) {
|
|
|
57
59
|
maxMemoriesPerWorkspace: z.natural().max(10000).default(defaults.maxMemoriesPerWorkspace),
|
|
58
60
|
gcEnabled: z.boolean().default(defaults.gcEnabled),
|
|
59
61
|
gcStableRetentionDays: z.natural().max(3650).default(defaults.gcStableRetentionDays),
|
|
62
|
+
verifyArtifact: z.boolean().default(defaults.verifyArtifact).description("ESR 闭环时校验 artifact 路径真实存在"),
|
|
60
63
|
// Security knob, not rendered in the card: hostnames allowed past the
|
|
61
64
|
// loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
|
|
62
65
|
trustedHosts: z.array(z.string()).default([]),
|
package/lib/store.js
CHANGED
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
20
22
|
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
21
|
-
import { hashText, byRecency, uuid, now,
|
|
23
|
+
import { hashText, byRecency, uuid, now, truncate as truncateText, bm25Rank, tokenize as tokenizeLocal, dedupeBySession } from "./util.js";
|
|
24
|
+
import { deriveObservations } from "./obs.js";
|
|
22
25
|
|
|
23
26
|
export const DOMAIN_VERSION = 1;
|
|
24
27
|
|
|
@@ -52,6 +55,10 @@ const MemorySchema = z.object({
|
|
|
52
55
|
updatedAt: z.number().int().nonnegative(),
|
|
53
56
|
/** Recall hit count — used to promote low-signal auto-captures into the index. */
|
|
54
57
|
hits: z.number().int().nonnegative().default(0),
|
|
58
|
+
/** Last recall/access touch (epoch ms); null = never accessed. Retention signal for GC. */
|
|
59
|
+
lastAccessAt: z.number().int().nonnegative().nullable().default(null),
|
|
60
|
+
/** Optional file anchor (absolute path). Sharpens error revival and recall display. */
|
|
61
|
+
filePath: z.string().nullable().default(null),
|
|
55
62
|
/** 0..1 static signal; index threshold is minIndexSignal. */
|
|
56
63
|
signal: z.number().min(0).max(1).default(0.5),
|
|
57
64
|
status: z.enum(["active", "archived"]).default("active"),
|
|
@@ -78,6 +85,12 @@ const TaskSchema = z.object({
|
|
|
78
85
|
/** Set by GC when a stable task passed its retention window. Archived tasks
|
|
79
86
|
* leave the [ESR] surface but stay retrievable (id + name kept). */
|
|
80
87
|
archivedAt: z.number().int().nullable().default(null),
|
|
88
|
+
/** Dependency edges (Beads-inspired mini graph): blocks = this task waits on
|
|
89
|
+
* target, parent-of = target is this task's child, relates-to = no blocking. */
|
|
90
|
+
deps: z.array(z.object({ id: z.string().min(1), kind: z.enum(["blocks", "relates-to", "parent-of"]).default("blocks") })).default([]),
|
|
91
|
+
/** Claimer (agent id) — ownership fence for concurrent agents. */
|
|
92
|
+
assignee: z.string().nullable().default(null),
|
|
93
|
+
claimedAt: z.number().int().nullable().default(null),
|
|
81
94
|
});
|
|
82
95
|
|
|
83
96
|
const LinkSchema = z.object({
|
|
@@ -123,10 +136,71 @@ const UsageSchema = z.object({
|
|
|
123
136
|
updatedAt: z.number().int().nonnegative(),
|
|
124
137
|
});
|
|
125
138
|
|
|
139
|
+
const ObservationSchema = z.object({
|
|
140
|
+
/** Stable id (table key), obs_<...>. */
|
|
141
|
+
id: z.string().min(1),
|
|
142
|
+
workspace: z.string().min(1),
|
|
143
|
+
/** Consolidated belief text (refined, never overwritten). */
|
|
144
|
+
text: z.string().min(1),
|
|
145
|
+
/** belief | pattern (derived from the triggering memory kind, no LLM). */
|
|
146
|
+
kind: z.enum(["belief", "pattern"]).default("belief"),
|
|
147
|
+
/** Unique supporting memory ids + their count. */
|
|
148
|
+
proof: z.object({ count: z.number().int().nonnegative(), sources: z.array(z.string()).default([]) }).default({ count: 0, sources: [] }),
|
|
149
|
+
span: z.object({ first_seen_at: z.number().int().nonnegative(), last_seen_at: z.number().int().nonnegative() }),
|
|
150
|
+
/** Opposite-polarity repeats weaken the belief. */
|
|
151
|
+
negations: z.number().int().nonnegative().default(0),
|
|
152
|
+
/** new | strengthening | stable | weakening | stale (algorithmic). */
|
|
153
|
+
trend: z.enum(["new", "strengthening", "stable", "weakening", "stale"]).default("new"),
|
|
154
|
+
tags: z.array(z.string()).default([]),
|
|
155
|
+
entity: z.string().nullable().default(null),
|
|
156
|
+
updated_at: z.number().int().nonnegative(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const ModelSchema = z.object({
|
|
160
|
+
/** Workspace key ("" = global model). Reportedly any non-string crept in
|
|
161
|
+
* once (URLSearchParams via an old readQuery misuse) and broke open — so
|
|
162
|
+
* the shape is strictly string, and getModel/putModel normalize below. */
|
|
163
|
+
ws: z.string(),
|
|
164
|
+
/** Precomputed standing answer (markdown), zero-LLM aggregate. */
|
|
165
|
+
content: z.string().min(1),
|
|
166
|
+
generated_at: z.number().int().nonnegative(),
|
|
167
|
+
/** Bumped by any esr_* write; getModel recomputes when dirty. */
|
|
168
|
+
dirty: z.boolean().default(false),
|
|
169
|
+
/** Hash of the aggregation inputs (catches bumps we missed). */
|
|
170
|
+
sources_hash: z.string().default(""),
|
|
171
|
+
});
|
|
172
|
+
|
|
126
173
|
const GlobalSchema = z.object({
|
|
127
174
|
initialized: z.boolean(),
|
|
128
175
|
});
|
|
129
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Deterministic repeat-failure detector: two error texts share the memory if
|
|
179
|
+
* they overlap on >= 2 tokens AND the intersection covers >= 60% of the
|
|
180
|
+
* smaller token set (CJK bigrams + ASCII words). This drives the "failure
|
|
181
|
+
* memory revival" path — a recurring failure re-warms its old memory instead
|
|
182
|
+
* of piling up duplicates (PROJECTMEM's warn-before-repeat, done with zero
|
|
183
|
+
* LLM).
|
|
184
|
+
*/
|
|
185
|
+
const REPEAT_OVERLAP = 0.6;
|
|
186
|
+
/** Looser threshold when the SAME filePath anchors both errors — a same-file
|
|
187
|
+
* re-run that drifts in wording is still the same root cause. */
|
|
188
|
+
const SAME_PATH_OVERLAP = 0.35;
|
|
189
|
+
|
|
190
|
+
function repeatOverlapRatio(previous, incoming) {
|
|
191
|
+
const a = new Set(tokenizeLocal(String(previous)));
|
|
192
|
+
const b = new Set(tokenizeLocal(String(incoming)));
|
|
193
|
+
let inter = 0;
|
|
194
|
+
for (const t of a) if (b.has(t)) inter += 1;
|
|
195
|
+
if (inter < 2) return 0;
|
|
196
|
+
const min = Math.min(a.size, b.size);
|
|
197
|
+
return min > 0 ? inter / min : 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function repeatOverlap(previous, incoming) {
|
|
201
|
+
return repeatOverlapRatio(previous, incoming) >= REPEAT_OVERLAP;
|
|
202
|
+
}
|
|
203
|
+
|
|
130
204
|
/** Identity validation (mirrors shipped defineDomain; fails loud at load). */
|
|
131
205
|
export const engramDomainSpec = defineDomain({
|
|
132
206
|
name: "dsh_engram",
|
|
@@ -138,6 +212,8 @@ export const engramDomainSpec = defineDomain({
|
|
|
138
212
|
links: domainTable(LinkSchema),
|
|
139
213
|
entities: domainTable(EntitySchema),
|
|
140
214
|
usage: domainTable(UsageSchema),
|
|
215
|
+
observations: domainTable(ObservationSchema),
|
|
216
|
+
models: domainTable(ModelSchema),
|
|
141
217
|
},
|
|
142
218
|
});
|
|
143
219
|
|
|
@@ -165,6 +241,8 @@ export async function openEngramDomain(facility) {
|
|
|
165
241
|
const links = opened.table("links");
|
|
166
242
|
const entities = opened.table("entities");
|
|
167
243
|
const usage = opened.table("usage");
|
|
244
|
+
const observations = opened.table("observations");
|
|
245
|
+
const models = opened.table("models");
|
|
168
246
|
|
|
169
247
|
const allMemories = (workspace) =>
|
|
170
248
|
[...memories.entries()]
|
|
@@ -222,16 +300,7 @@ export async function openEngramDomain(facility) {
|
|
|
222
300
|
if (kind) pool = pool.filter((m) => m.kind === kind);
|
|
223
301
|
if (status) pool = pool.filter((m) => m.status === status);
|
|
224
302
|
if (q !== void 0 && String(q).trim().length > 0) {
|
|
225
|
-
|
|
226
|
-
const tokens = tokenizeLocal(trimmed);
|
|
227
|
-
const phraseBoost = /\s/.test(trimmed);
|
|
228
|
-
const folded = trimmed.toLowerCase();
|
|
229
|
-
return pool
|
|
230
|
-
.map((m) => ({ m, score: scoreRecordLocal(m, tokens, folded, phraseBoost) }))
|
|
231
|
-
.filter((e) => e.score > 0)
|
|
232
|
-
.sort((a, b) => b.score - a.score || byRecency(a.m, b.m))
|
|
233
|
-
.slice(0, bounded)
|
|
234
|
-
.map((e) => e.m);
|
|
303
|
+
return bm25Rank(pool, String(q).trim(), bounded);
|
|
235
304
|
}
|
|
236
305
|
return pool.sort(byRecency).slice(0, bounded);
|
|
237
306
|
},
|
|
@@ -243,7 +312,8 @@ export async function openEngramDomain(facility) {
|
|
|
243
312
|
let taskCount = 0;
|
|
244
313
|
let linkCount = 0;
|
|
245
314
|
let nodeCount = 0;
|
|
246
|
-
|
|
315
|
+
let observationCount = 0;
|
|
316
|
+
const zeroCounts = () => ({ memories: 0, tasks: 0, links: 0, nodes: 0, observations: 0 });
|
|
247
317
|
for (const [, m] of memories.entries()) {
|
|
248
318
|
const ws = m.workspace;
|
|
249
319
|
workspaces[ws] ??= zeroCounts();
|
|
@@ -270,7 +340,13 @@ export async function openEngramDomain(facility) {
|
|
|
270
340
|
workspaces[e.workspace].nodes += 1;
|
|
271
341
|
nodeCount += 1;
|
|
272
342
|
}
|
|
273
|
-
|
|
343
|
+
for (const [, m] of memories.entries()) {
|
|
344
|
+
if (m.status !== "active" || (m.hits ?? 0) < 2) continue;
|
|
345
|
+
workspaces[m.workspace] ??= zeroCounts();
|
|
346
|
+
workspaces[m.workspace].observations += 1;
|
|
347
|
+
observationCount += 1;
|
|
348
|
+
}
|
|
349
|
+
return { workspaces, kinds, totals: { memories: memoryCount, tasks: taskCount, links: linkCount, nodes: nodeCount, observations: observationCount } };
|
|
274
350
|
},
|
|
275
351
|
/** Hard delete (GUI only; provenance stays in the session log). */
|
|
276
352
|
async deleteMemory(workspace, id) {
|
|
@@ -279,9 +355,11 @@ export async function openEngramDomain(facility) {
|
|
|
279
355
|
await memories.delete(id);
|
|
280
356
|
return true;
|
|
281
357
|
},
|
|
358
|
+
|
|
282
359
|
/**
|
|
283
360
|
* Store one memory. Exact-duplicate text in the same workspace refreshes
|
|
284
|
-
* `updatedAt` (and returns the existing id) instead of piling up
|
|
361
|
+
* `updatedAt` (and returns the existing id) instead of piling up, and a
|
|
362
|
+
* near-repeat of a past ERROR re-warms that entry (failure revival).
|
|
285
363
|
*/
|
|
286
364
|
async storeMemory(entry, config) {
|
|
287
365
|
const text = String(entry.text).trim();
|
|
@@ -291,9 +369,29 @@ export async function openEngramDomain(facility) {
|
|
|
291
369
|
}
|
|
292
370
|
const dup = allMemories(entry.workspace).find((m) => hashText(m.text) === hashText(text) && m.kind === (entry.kind ?? "fact"));
|
|
293
371
|
if (dup !== void 0) {
|
|
294
|
-
|
|
372
|
+
// Repeat of the exact same failure is itself a signal: refresh recency
|
|
373
|
+
// AND climb the hit counter (errors only) so the [ENGRAM] index can
|
|
374
|
+
// eventually surface it via promoteHits.
|
|
375
|
+
const next = { ...dup, updatedAt: now(), ...(dup.kind === "error" ? { hits: (dup.hits ?? 0) + 1 } : {}) };
|
|
295
376
|
await memories.put(dup.id, next);
|
|
296
|
-
return { stored: next, duplicated: true, id: dup.id };
|
|
377
|
+
return { stored: next, duplicated: true, id: dup.id, revived: dup.kind === "error" };
|
|
378
|
+
}
|
|
379
|
+
if ((entry.kind ?? "fact") === "error") {
|
|
380
|
+
// Failure-memory revival: a near-repeat of a past error re-warms the
|
|
381
|
+
// existing entry (recency + hits) and does not create a new row, so
|
|
382
|
+
// recurring failures stay one point of truth and climb toward an
|
|
383
|
+
// [ENGRAM] line instead of flooding the pool.
|
|
384
|
+
for (const prev of allMemories(entry.workspace)) {
|
|
385
|
+
if (prev.kind !== "error" || hashText(prev.text) === hashText(text)) continue;
|
|
386
|
+
const ratio = repeatOverlapRatio(prev.text, text);
|
|
387
|
+
const samePath =
|
|
388
|
+
prev.filePath !== null && entry.filePath !== null && prev.filePath === entry.filePath;
|
|
389
|
+
if (!samePath && ratio < REPEAT_OVERLAP) continue;
|
|
390
|
+
if (samePath && ratio < SAME_PATH_OVERLAP) continue;
|
|
391
|
+
const revived = { ...prev, updatedAt: now(), hits: (prev.hits ?? 0) + 1, filePath: prev.filePath ?? entry.filePath ?? null };
|
|
392
|
+
await memories.put(prev.id, revived);
|
|
393
|
+
return { stored: revived, duplicated: true, id: prev.id, revived: true };
|
|
394
|
+
}
|
|
297
395
|
}
|
|
298
396
|
if (allMemories(entry.workspace).length >= config.maxMemoriesPerWorkspace) {
|
|
299
397
|
throw new EngramError("ENGRAM_CAP_EXCEEDED", `workspace already holds ${config.maxMemoriesPerWorkspace} active memories (the cap)`);
|
|
@@ -316,6 +414,8 @@ export async function openEngramDomain(facility) {
|
|
|
316
414
|
createdAt: now(),
|
|
317
415
|
updatedAt: now(),
|
|
318
416
|
hits: 0,
|
|
417
|
+
lastAccessAt: null,
|
|
418
|
+
filePath: entry.filePath ?? null,
|
|
319
419
|
signal: typeof entry.signal === "number" ? Math.max(0, Math.min(1, entry.signal)) : 0.5,
|
|
320
420
|
status: "active",
|
|
321
421
|
expiresAt,
|
|
@@ -326,25 +426,27 @@ export async function openEngramDomain(facility) {
|
|
|
326
426
|
async touchMemory(workspace, id) {
|
|
327
427
|
const m = memories.get(id);
|
|
328
428
|
if (m === void 0 || m.workspace !== workspace || !notExpiredNow(m)) return void 0;
|
|
329
|
-
const next = { ...m, hits: (m.hits ?? 0) + 1, updatedAt: m.updatedAt };
|
|
429
|
+
const next = { ...m, hits: (m.hits ?? 0) + 1, lastAccessAt: now(), updatedAt: m.updatedAt };
|
|
330
430
|
await memories.put(id, next);
|
|
331
431
|
return next;
|
|
332
432
|
},
|
|
333
|
-
/**
|
|
334
|
-
|
|
433
|
+
/** Pure access touch: updates lastAccessAt only (retention signal) without
|
|
434
|
+
* touching the proof-count `hits`. Deterministic, non-appending. */
|
|
435
|
+
async markAccessed(workspace, id, at = now()) {
|
|
436
|
+
const m = memories.get(id);
|
|
437
|
+
if (m === void 0 || m.workspace !== workspace || !notExpiredNow(m)) return void 0;
|
|
438
|
+
const next = { ...m, lastAccessAt: at, updatedAt: m.updatedAt };
|
|
439
|
+
await memories.put(id, next);
|
|
440
|
+
return next;
|
|
441
|
+
},
|
|
442
|
+
/** Deterministic BM25 recall (tag-exact/fuzzy boosts, phrase boost); recency ties. */
|
|
443
|
+
recall(workspace, query, limit = 20, { maxPerSession } = {}) {
|
|
335
444
|
const active = allMemories(workspace).filter(notExpiredNow);
|
|
336
445
|
const bounded = Math.max(1, Math.min(50, Math.trunc(limit) || 20));
|
|
337
446
|
if (query !== void 0 && String(query).trim().length > 0) {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const folded = trimmed.toLowerCase();
|
|
342
|
-
return active
|
|
343
|
-
.map((m) => ({ m, score: scoreRecordLocal(m, tokens, folded, phraseBoost) }))
|
|
344
|
-
.filter((e) => e.score > 0)
|
|
345
|
-
.sort((a, b) => b.score - a.score || byRecency(a.m, b.m))
|
|
346
|
-
.slice(0, bounded)
|
|
347
|
-
.map((e) => e.m);
|
|
447
|
+
const ranked = bm25Rank(active, String(query).trim(), bounded);
|
|
448
|
+
if (maxPerSession === void 0 || maxPerSession === null) return ranked;
|
|
449
|
+
return dedupeBySession(ranked, maxPerSession); // 候选① per-session cap
|
|
348
450
|
}
|
|
349
451
|
return active.sort(byRecency).slice(0, bounded);
|
|
350
452
|
},
|
|
@@ -394,6 +496,156 @@ export async function openEngramDomain(facility) {
|
|
|
394
496
|
await tasks.put(task.id, task);
|
|
395
497
|
return task;
|
|
396
498
|
},
|
|
499
|
+
/**
|
|
500
|
+
* Single source of truth for the ESR evidence gate, shared by the esr_close
|
|
501
|
+
* tool and the web API so both never drift.
|
|
502
|
+
*
|
|
503
|
+
* Reuses what DSH already knows: the workspace key IS the session cwd, so a
|
|
504
|
+
* relative artifact resolves against it and is checked against the host's
|
|
505
|
+
* real filesystem when `config.verifyArtifact` is on (URLs and explicit
|
|
506
|
+
* `force` skip the disk check). Empty gates are still gaps regardless.
|
|
507
|
+
*
|
|
508
|
+
* @returns {{ gaps: string[], artifactReason?: string }} gaps (artifact /
|
|
509
|
+
* evaluation / memory_ref) plus a human reason when the artifact was
|
|
510
|
+
* provided but could not be confirmed on disk.
|
|
511
|
+
*/
|
|
512
|
+
evidenceGate(workspace, task, config = {}) {
|
|
513
|
+
const gaps = [];
|
|
514
|
+
let artifactReason;
|
|
515
|
+
const artifact = typeof task.artifact === "string" ? task.artifact.trim() : "";
|
|
516
|
+
if (artifact.length === 0) {
|
|
517
|
+
gaps.push("artifact");
|
|
518
|
+
} else if (config.verifyArtifact !== false && !/^https?:\/\//i.test(artifact)) {
|
|
519
|
+
const resolved = path.isAbsolute(artifact) ? artifact : path.resolve(workspace, artifact);
|
|
520
|
+
try {
|
|
521
|
+
if (!existsSync(resolved)) artifactReason = `artifact not found on disk: ${resolved}`;
|
|
522
|
+
} catch {
|
|
523
|
+
artifactReason = `artifact unverifiable: ${resolved}`;
|
|
524
|
+
}
|
|
525
|
+
if (artifactReason !== void 0) gaps.push("artifact");
|
|
526
|
+
}
|
|
527
|
+
const evaluation = typeof task.evaluation === "string" ? task.evaluation.trim() : "";
|
|
528
|
+
if (evaluation.length === 0) gaps.push("evaluation");
|
|
529
|
+
if (!Array.isArray(task.memoryRefs) || task.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
530
|
+
return { gaps, artifactReason };
|
|
531
|
+
},
|
|
532
|
+
/** Outgoing dependency edges of a task. */
|
|
533
|
+
taskDeps(workspace, id) {
|
|
534
|
+
const t = tasks.get(id);
|
|
535
|
+
return t === void 0 || t.workspace !== workspace ? [] : (t.deps ?? []);
|
|
536
|
+
},
|
|
537
|
+
/**
|
|
538
|
+
* Add a dependency edge with self-reference + directed-cycle guards.
|
|
539
|
+
* `blocks`/`parent-of` edges participate in blocking; `relates-to` never does.
|
|
540
|
+
*/
|
|
541
|
+
async addDep(workspace, id, depId, kind = "blocks") {
|
|
542
|
+
const t = tasks.get(id);
|
|
543
|
+
const d = tasks.get(depId);
|
|
544
|
+
if (t === void 0 || t.workspace !== workspace) throw new EngramError("ENGRAM_NOT_FOUND", `no task "${id}" in this workspace`);
|
|
545
|
+
if (d === void 0 || d.workspace !== workspace) throw new EngramError("ENGRAM_NOT_FOUND", `no dep task "${depId}" in this workspace`);
|
|
546
|
+
if (id === depId) throw new EngramError("ENGRAM_INVALID_ARGS", "a task cannot depend on itself");
|
|
547
|
+
const deps = t.deps ?? [];
|
|
548
|
+
if (deps.some((x) => x.id === depId && x.kind === kind)) return t;
|
|
549
|
+
if (this.reaches(workspace, depId, id)) {
|
|
550
|
+
throw new EngramError("ENGRAM_CYCLE", `adding ${depId} as a dep of ${id} would form a cycle`);
|
|
551
|
+
}
|
|
552
|
+
const next = { ...t, deps: [...deps, { id: depId, kind }], updatedAt: now() };
|
|
553
|
+
await tasks.put(id, next);
|
|
554
|
+
return next;
|
|
555
|
+
},
|
|
556
|
+
/** Directed reachability over blocking edges (blocks + parent-of). */
|
|
557
|
+
reaches(workspace, from, target) {
|
|
558
|
+
const seen = new Set();
|
|
559
|
+
const walk = (cur) => {
|
|
560
|
+
if (cur === target) return true;
|
|
561
|
+
if (seen.has(cur)) return false;
|
|
562
|
+
seen.add(cur);
|
|
563
|
+
const node = tasks.get(cur);
|
|
564
|
+
for (const dep of node?.deps ?? []) {
|
|
565
|
+
if (dep.kind === "relates-to") continue;
|
|
566
|
+
if (walk(dep.id)) return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
};
|
|
570
|
+
return walk(from);
|
|
571
|
+
},
|
|
572
|
+
/** Closed = stable or archived (blocker released). */
|
|
573
|
+
taskClosed(id) {
|
|
574
|
+
const t = tasks.get(id);
|
|
575
|
+
return t === void 0 || t.state === "stable" || (t.archivedAt !== null && t.archivedAt !== void 0);
|
|
576
|
+
},
|
|
577
|
+
/**
|
|
578
|
+
* Derived blocked state (Beads BlockedStateInvariant, read-time fixpoint):
|
|
579
|
+
* blocked exactly when a blocks/parent-of dep is not closed.
|
|
580
|
+
*/
|
|
581
|
+
isBlocked(workspace, id) {
|
|
582
|
+
const t = tasks.get(id);
|
|
583
|
+
if (t === void 0 || t.workspace !== workspace) return false;
|
|
584
|
+
if (this.taskClosed(id)) return false;
|
|
585
|
+
for (const dep of t.deps ?? []) {
|
|
586
|
+
if (dep.kind === "relates-to") continue;
|
|
587
|
+
if (!this.taskClosed(dep.id)) return true;
|
|
588
|
+
}
|
|
589
|
+
return false;
|
|
590
|
+
},
|
|
591
|
+
/** Claimable queue: active/draft, no open blocker, nobody claimed it. */
|
|
592
|
+
readyTasks(workspace, limit = 10) {
|
|
593
|
+
return [...tasks.entries()]
|
|
594
|
+
.map(([, t]) => t)
|
|
595
|
+
.filter((t) => t.workspace === workspace && (t.state === "active" || t.state === "draft") && (t.archivedAt === null || t.archivedAt === void 0))
|
|
596
|
+
.filter((t) => !this.isBlocked(workspace, t.id))
|
|
597
|
+
.filter((t) => t.assignee === null || t.assignee === void 0)
|
|
598
|
+
.sort(byRecency)
|
|
599
|
+
.slice(0, Math.max(1, Math.min(100, limit)));
|
|
600
|
+
},
|
|
601
|
+
/** Atomic claim with an anti-yank fence: foreign holder refuses unless forced. */
|
|
602
|
+
async claimTask(workspace, id, agent, { force = false } = {}) {
|
|
603
|
+
const t = tasks.get(id);
|
|
604
|
+
if (t === void 0 || t.workspace !== workspace) throw new EngramError("ENGRAM_NOT_FOUND", `no task "${id}" in this workspace`);
|
|
605
|
+
if (this.taskClosed(id)) throw new EngramError("ENGRAM_INVALID_ARGS", `task ${id} is already closed and cannot be claimed`);
|
|
606
|
+
if (t.assignee !== null && t.assignee !== void 0 && t.assignee !== agent && !force) {
|
|
607
|
+
throw new EngramError("ENGRAM_CONFLICT", `task ${id} is claimed by ${t.assignee}`);
|
|
608
|
+
}
|
|
609
|
+
const next = { ...t, assignee: agent, claimedAt: now(), state: t.state === "draft" ? "active" : t.state, updatedAt: now() };
|
|
610
|
+
await tasks.put(id, next);
|
|
611
|
+
return next;
|
|
612
|
+
},
|
|
613
|
+
/** Release ownership; foreign holder needs force. */
|
|
614
|
+
async unclaimTask(workspace, id, agent, { force = false } = {}) {
|
|
615
|
+
const t = tasks.get(id);
|
|
616
|
+
if (t === void 0 || t.workspace !== workspace) throw new EngramError("ENGRAM_NOT_FOUND", `no task "${id}" in this workspace`);
|
|
617
|
+
if (t.assignee !== null && t.assignee !== void 0 && t.assignee !== agent && !force) {
|
|
618
|
+
throw new EngramError("ENGRAM_CONFLICT", `task ${id} is claimed by ${t.assignee}`);
|
|
619
|
+
}
|
|
620
|
+
const next = { ...t, assignee: null, claimedAt: null, updatedAt: now() };
|
|
621
|
+
await tasks.put(id, next);
|
|
622
|
+
return next;
|
|
623
|
+
},
|
|
624
|
+
/**
|
|
625
|
+
* Reversible compaction (Beads compactor.go, rule-only, zero LLM): when a
|
|
626
|
+
* task transitions to stable with a long description, keep a rule summary
|
|
627
|
+
* in `summary` and archive the full original text in `snapshot` — the kanban
|
|
628
|
+
* reads the summary, the original stays retrievable. No-op for shorts.
|
|
629
|
+
*/
|
|
630
|
+
compactOnClose(task) {
|
|
631
|
+
const desc = (task.description ?? "").trim();
|
|
632
|
+
const already = (task.summary ?? null) !== null || (task.snapshot ?? null) !== null;
|
|
633
|
+
if (desc.length <= 240 || already) {
|
|
634
|
+
return { ...task, summary: task.summary ?? null, snapshot: task.snapshot ?? null };
|
|
635
|
+
}
|
|
636
|
+
const firstLine = (desc.split("\n")[0] ?? "").trim();
|
|
637
|
+
const summary = (firstLine.length > 0 ? firstLine : desc).slice(0, 140);
|
|
638
|
+
return {
|
|
639
|
+
...task,
|
|
640
|
+
summary,
|
|
641
|
+
snapshot: {
|
|
642
|
+
description: desc,
|
|
643
|
+
artifact: task.artifact ?? null,
|
|
644
|
+
evaluation: task.evaluation ?? null,
|
|
645
|
+
memoryRefs: (task.memoryRefs ?? []).slice(),
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
},
|
|
397
649
|
/** Cap is enforced by the tools layer (needs config); expose the count here. */
|
|
398
650
|
activeTaskCount(workspace) {
|
|
399
651
|
return activeTasks(workspace).length;
|
|
@@ -409,8 +661,9 @@ export async function openEngramDomain(facility) {
|
|
|
409
661
|
* working-set-protected, archive-only (never hard-deletes — everything
|
|
410
662
|
* archived stays re-fetchable, honouring pointer-salience):
|
|
411
663
|
* - TTL-expired memories beyond their `expiresAt` are archived;
|
|
412
|
-
* - over-cap workspaces evict the lowest-
|
|
413
|
-
* (hits asc, signal asc, age asc)
|
|
664
|
+
* - over-cap workspaces evict the lowest-retention non-protected memories
|
|
665
|
+
* (lastAccess asc with createdAt fallback, hits asc, signal asc, age asc)
|
|
666
|
+
* until at the cap;
|
|
414
667
|
* - stable tasks past `config.gcStableRetentionDays` become `archived`;
|
|
415
668
|
* - links whose BOTH endpoints are gone (no active memory entity, no
|
|
416
669
|
* surviving task id) are removed (dangling graph edges).
|
|
@@ -475,9 +728,19 @@ export async function openEngramDomain(facility) {
|
|
|
475
728
|
const survivable = activeMem.length - report.archivedMemories.filter((e) => e.workspace === ws).length;
|
|
476
729
|
if (survivable > cap) {
|
|
477
730
|
const over = survivable - cap;
|
|
731
|
+
// Retention-first eviction (candidate ②): least-recently-accessed
|
|
732
|
+
// non-protected memories go first (fallback createdAt for never-accessed),
|
|
733
|
+
// then hits/signal/createdAt — identical to the pre-retention order for
|
|
734
|
+
// memories that never had lastAccessAt, so old data is untouched.
|
|
478
735
|
const evict = candidates
|
|
479
736
|
.slice()
|
|
480
|
-
.sort(
|
|
737
|
+
.sort(
|
|
738
|
+
(a, b) =>
|
|
739
|
+
(a.lastAccessAt ?? a.createdAt ?? 0) - (b.lastAccessAt ?? b.createdAt ?? 0) ||
|
|
740
|
+
(a.hits ?? 0) - (b.hits ?? 0) ||
|
|
741
|
+
(a.signal ?? 0) - (b.signal ?? 0) ||
|
|
742
|
+
(a.createdAt ?? 0) - (b.createdAt ?? 0),
|
|
743
|
+
)
|
|
481
744
|
.slice(0, over);
|
|
482
745
|
for (const m of evict) {
|
|
483
746
|
if (!dryRun) await memories.put(m.id, { ...m, status: "archived", updatedAt: t0 });
|
|
@@ -513,6 +776,22 @@ export async function openEngramDomain(facility) {
|
|
|
513
776
|
addLink(link) {
|
|
514
777
|
return links.put(link.id, link);
|
|
515
778
|
},
|
|
779
|
+
/** Idempotent variant: skip when the same (source, relation, target) edge
|
|
780
|
+
* already exists in the workspace — used by auto-wiring paths so repeated
|
|
781
|
+
* calls never duplicate a relation. */
|
|
782
|
+
async addLinkOnce(link) {
|
|
783
|
+
const dup = [...links.entries()]
|
|
784
|
+
.map(([, l]) => l)
|
|
785
|
+
.some(
|
|
786
|
+
(l) =>
|
|
787
|
+
l.workspace === link.workspace &&
|
|
788
|
+
l.source === link.source &&
|
|
789
|
+
l.target === link.target &&
|
|
790
|
+
(l.relation ?? "") === (link.relation ?? ""),
|
|
791
|
+
);
|
|
792
|
+
if (!dup) await links.put(link.id, link);
|
|
793
|
+
return !dup;
|
|
794
|
+
},
|
|
516
795
|
linksFor(workspace, entity) {
|
|
517
796
|
return [...links.entries()]
|
|
518
797
|
.map(([, l]) => l)
|
|
@@ -527,6 +806,25 @@ export async function openEngramDomain(facility) {
|
|
|
527
806
|
const e = entities.get(id);
|
|
528
807
|
if (e === void 0 || e.workspace !== workspace) return void 0;
|
|
529
808
|
return e;
|
|
809
|
+
},
|
|
810
|
+
/** Evidence-grounded beliefs for the workspace — derived, not persisted.
|
|
811
|
+
* A memory with hits>=2 IS an observation; proof = hits, span = created→updated. */
|
|
812
|
+
listObservations(workspace) {
|
|
813
|
+
return deriveObservations(allMemories(workspace));
|
|
814
|
+
},
|
|
815
|
+
/** Precomputed standing-answer cache (mental model). */
|
|
816
|
+
getModel(ws) {
|
|
817
|
+
return models.get(typeof ws === "string" ? ws : "");
|
|
818
|
+
},
|
|
819
|
+
async putModel(ws, row) {
|
|
820
|
+
const key = typeof ws === "string" ? ws : "";
|
|
821
|
+
const rec = { ...row, ws: typeof ws === "string" ? ws : "" };
|
|
822
|
+
await models.put(key, rec);
|
|
823
|
+
return rec;
|
|
824
|
+
},
|
|
825
|
+
async markModelDirty(ws) {
|
|
826
|
+
const cur = models.get(ws);
|
|
827
|
+
if (cur !== void 0) await models.put(ws, { ...cur, dirty: true });
|
|
530
828
|
},
|
|
531
829
|
listEntities(workspace) {
|
|
532
830
|
return allEntities(workspace).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|