dsh-engram 0.6.0 → 0.7.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
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
23
23
  import { hashText, byRecency, uuid, now, truncate as truncateText, bm25Rank, tokenize as tokenizeLocal, dedupeBySession } from "./util.js";
24
24
  import { deriveObservations } from "./obs.js";
25
+ import { makeFileAnchor } from "./anchor.js";
25
26
  import { redactText } from "./redact.js";
26
27
 
27
28
  export const DOMAIN_VERSION = 1;
@@ -36,8 +37,21 @@ const MEMORY_KINDS = [
36
37
  "insight",
37
38
  "handoff",
38
39
  "task",
40
+ "episode",
39
41
  ];
40
42
 
43
+ const AnchorSchema = z.object({
44
+ /** Absolute file path the claim was written against. */
45
+ path: z.string().min(1),
46
+ /** Stat identity at write time — cheap revalidation (no read). */
47
+ mtimeMs: z.number().nonnegative(),
48
+ size: z.number().int().nonnegative(),
49
+ /** sha256[0..32] for files under the hash cap; null for large files. */
50
+ hash: z.string().nullable().default(null),
51
+ /** When the anchor was captured (epoch ms). */
52
+ at: z.number().int().nonnegative(),
53
+ });
54
+
41
55
  const MemorySchema = z.object({
42
56
  /** Stable id (table key). */
43
57
  id: z.string().min(1),
@@ -61,6 +75,8 @@ const MemorySchema = z.object({
61
75
  lastAccessAt: z.number().int().nonnegative().nullable().default(null),
62
76
  /** Optional file anchor (absolute path). Sharpens error revival and recall display. */
63
77
  filePath: z.string().nullable().default(null),
78
+ /** Revalidatable stat/hash identity of `filePath` at write time (v0.7-C). */
79
+ anchor: AnchorSchema.nullable().default(null),
64
80
  /** 0..1 static signal; index threshold is minIndexSignal. */
65
81
  signal: z.number().min(0).max(1).default(0.5),
66
82
  status: z.enum(["active", "archived"]).default("active"),
@@ -448,6 +464,12 @@ export async function openEngramDomain(facility) {
448
464
  if ([...text].length > config.maxMemoryChars) {
449
465
  throw new EngramError("ENGRAM_CAP_EXCEEDED", `memory text is ${[...text].length} characters, over the cap of ${config.maxMemoryChars}`);
450
466
  }
467
+ const kind = entry.kind ?? "fact";
468
+ const anchor = entry.anchor !== void 0
469
+ ? entry.anchor
470
+ : (typeof entry.filePath === "string" && entry.filePath.length > 0
471
+ ? makeFileAnchor(entry.filePath, { baseDir: entry.workspace, now: now() })
472
+ : null);
451
473
  const all = allMemories(entry.workspace);
452
474
  let supersedes = validateRelationTarget(entry.workspace, entry.supersedes ?? null, "supersedes", all);
453
475
  const contradicts = validateRelationTarget(entry.workspace, entry.contradicts ?? null, "contradicts", all);
@@ -456,7 +478,12 @@ export async function openEngramDomain(facility) {
456
478
  // Repeat of the exact same failure is itself a signal: refresh recency
457
479
  // AND climb the hit counter (errors only) so the [ENGRAM] index can
458
480
  // eventually surface it via promoteHits.
459
- const next = { ...dup, updatedAt: now(), ...(dup.kind === "error" ? { hits: (dup.hits ?? 0) + 1 } : {}) };
481
+ const next = {
482
+ ...dup,
483
+ updatedAt: now(),
484
+ ...(dup.kind === "error" ? { hits: (dup.hits ?? 0) + 1 } : {}),
485
+ ...(anchor !== null ? { anchor, filePath: dup.filePath ?? entry.filePath ?? null } : {}),
486
+ };
460
487
  await memories.put(dup.id, next);
461
488
  return { stored: next, duplicated: true, id: dup.id, revived: dup.kind === "error" };
462
489
  }
@@ -472,7 +499,13 @@ export async function openEngramDomain(facility) {
472
499
  prev.filePath !== null && entry.filePath !== null && prev.filePath === entry.filePath;
473
500
  if (!samePath && ratio < REPEAT_OVERLAP) continue;
474
501
  if (samePath && ratio < SAME_PATH_OVERLAP) continue;
475
- const revived = { ...prev, updatedAt: now(), hits: (prev.hits ?? 0) + 1, filePath: prev.filePath ?? entry.filePath ?? null };
502
+ const revived = {
503
+ ...prev,
504
+ updatedAt: now(),
505
+ hits: (prev.hits ?? 0) + 1,
506
+ filePath: prev.filePath ?? entry.filePath ?? null,
507
+ ...(anchor !== null ? { anchor } : {}),
508
+ };
476
509
  await memories.put(prev.id, revived);
477
510
  return { stored: revived, duplicated: true, id: prev.id, revived: true };
478
511
  }
@@ -487,16 +520,25 @@ export async function openEngramDomain(facility) {
487
520
  const auto = autoSupersedeCandidate(all, entry, text);
488
521
  if (auto !== null) supersedes = auto.id;
489
522
  }
523
+ // Layered TTL (v0.7-C): user rules and proven procedures are permanent;
524
+ // session episodes are short-lived; errors age out faster than facts.
525
+ const DAY_MS = 24 * 60 * 60 * 1000;
526
+ const ttlDays = (() => {
527
+ if (kind === "preference" || kind === "procedure") return 0;
528
+ if (kind === "episode") return config.episodeTtlDays ?? 14;
529
+ if (kind === "error") return config.errorTtlDays ?? 60;
530
+ return config.expireDays ?? 180;
531
+ })();
490
532
  const expiresAt =
491
533
  entry.expiresAt !== void 0 && entry.expiresAt !== null
492
534
  ? entry.expiresAt
493
- : config.expireDays > 0
494
- ? now() + config.expireDays * 24 * 60 * 60 * 1000
535
+ : ttlDays > 0
536
+ ? now() + ttlDays * DAY_MS
495
537
  : null;
496
538
  const stored = {
497
539
  id: uuid(),
498
540
  workspace: entry.workspace,
499
- kind: entry.kind ?? "fact",
541
+ kind,
500
542
  text,
501
543
  tags: entry.tags ?? [],
502
544
  entity: entry.entity ?? null,
@@ -507,6 +549,7 @@ export async function openEngramDomain(facility) {
507
549
  hits: 0,
508
550
  lastAccessAt: null,
509
551
  filePath: entry.filePath ?? null,
552
+ anchor: anchor ?? null,
510
553
  signal: typeof entry.signal === "number" ? Math.max(0, Math.min(1, entry.signal)) : 0.5,
511
554
  status: "active",
512
555
  supersedes,
package/lib/tools.js CHANGED
@@ -19,6 +19,7 @@ import { z } from "zod";
19
19
  import { basename } from "node:path";
20
20
  import { EngramError, hasCode } from "./store.js";
21
21
  import { buildEsrStatusView } from "./index-block.js";
22
+ import { buildEntityPage, renderEntityPage } from "./entity-page.js";
22
23
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
23
24
  import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
24
25
 
@@ -285,19 +286,40 @@ export function registerTools(ctx, service) {
285
286
  // ── engram_detail ───────────────────────────────────────────────────
286
287
  registerTool({
287
288
  name: "engram_detail",
288
- description: "Full record of one memory id: text, tags, entity, signal, hits, session#seq provenance.",
289
+ description: "Full record of one memory id, or an entity page (truth/timeline/open loops/conflicts/relations) when `entity` is given.",
289
290
  parameters: {
290
291
  type: "object",
291
292
  properties: {
292
293
  id: { type: "string", description: "Memory id (e.g. the #xxxxx marker in the [ENGRAM] index is the first 8 chars)" },
294
+ entity: { type: "string", description: "Entity id or name — returns the full page (truth / timeline / open loops / conflicts / relations)" },
293
295
  },
294
- required: ["id"],
295
296
  },
296
297
  output: TEXT_OUTPUT,
297
298
  execute: async (raw, exec) => {
298
- const args = parse(z.object({ id: z.string().min(1) }), raw, "engram_detail");
299
+ const args = parse(
300
+ z.object({ id: z.string().optional(), entity: z.string().optional() }),
301
+ raw,
302
+ "engram_detail",
303
+ );
299
304
  const caller = callerOf(exec);
300
305
  const domain = await requireDomain(service);
306
+ if (typeof args.entity === "string" && args.entity.trim().length > 0) {
307
+ const page = buildEntityPage(domain, caller.cwd, args.entity);
308
+ const registered = (domain.listEntities?.(caller.cwd) ?? []).some(
309
+ (entity) => entity.id === page?.entity?.id || entity.name === page?.entity?.name,
310
+ );
311
+ if (page === null || (!registered && page.counts.memories === 0 && page.counts.openTasks === 0)) {
312
+ const names = (domain.listEntities?.(caller.cwd) ?? []).slice(0, 8).map((entity) => entity.name);
313
+ throw new EngramError(
314
+ "ENGRAM_NOT_FOUND",
315
+ `no entity "${args.entity}" in this workspace${names.length > 0 ? ` (known: ${names.join(", ")})` : ""}`,
316
+ );
317
+ }
318
+ return renderEntityPage(page);
319
+ }
320
+ if (typeof args.id !== "string" || args.id.length === 0) {
321
+ throw new EngramError("ENGRAM_INVALID_ARGS", "engram_detail requires `id` or `entity`");
322
+ }
301
323
  const id = resolveId(domain, caller.cwd, args.id);
302
324
  const m = domain.getMemory(caller.cwd, id);
303
325
  if (m === void 0) throw new EngramError("ENGRAM_NOT_FOUND", `no active memory "${args.id}" in this workspace`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {