dsh-engram 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js CHANGED
@@ -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, tokenize as tokenizeLocal, scoreRecord as scoreRecordLocal, truncate as truncateText } from "./util.js";
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
- const trimmed = String(q).trim();
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
- const zeroCounts = () => ({ memories: 0, tasks: 0, links: 0, nodes: 0 });
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
- return { workspaces, kinds, totals: { memories: memoryCount, tasks: taskCount, links: linkCount, nodes: nodeCount } };
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
- const next = { ...dup, updatedAt: now() };
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
- /** Deterministic recall: tag-exact > text-substring > fuzzy, recency ties. */
334
- recall(workspace, query, limit = 20) {
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 trimmed = String(query).trim();
339
- const tokens = tokenizeLocal(trimmed);
340
- const phraseBoost = /\s/.test(trimmed);
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-value non-protected memories
413
- * (hits asc, signal asc, age asc) until at the cap;
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((a, b) => (a.hits ?? 0) - (b.hits ?? 0) || (a.signal ?? 0) - (b.signal ?? 0) || (a.createdAt ?? 0) - (b.createdAt ?? 0))
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 });
@@ -527,6 +790,25 @@ export async function openEngramDomain(facility) {
527
790
  const e = entities.get(id);
528
791
  if (e === void 0 || e.workspace !== workspace) return void 0;
529
792
  return e;
793
+ },
794
+ /** Evidence-grounded beliefs for the workspace — derived, not persisted.
795
+ * A memory with hits>=2 IS an observation; proof = hits, span = created→updated. */
796
+ listObservations(workspace) {
797
+ return deriveObservations(allMemories(workspace));
798
+ },
799
+ /** Precomputed standing-answer cache (mental model). */
800
+ getModel(ws) {
801
+ return models.get(typeof ws === "string" ? ws : "");
802
+ },
803
+ async putModel(ws, row) {
804
+ const key = typeof ws === "string" ? ws : "";
805
+ const rec = { ...row, ws: typeof ws === "string" ? ws : "" };
806
+ await models.put(key, rec);
807
+ return rec;
808
+ },
809
+ async markModelDirty(ws) {
810
+ const cur = models.get(ws);
811
+ if (cur !== void 0) await models.put(ws, { ...cur, dirty: true });
530
812
  },
531
813
  listEntities(workspace) {
532
814
  return allEntities(workspace).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));