dsh-engram 0.6.0 → 0.7.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/lib/index.js CHANGED
@@ -24,12 +24,14 @@ import { registerTools } from "./tools.js";
24
24
  import { makeCaptureHandler } from "./capture.js";
25
25
  import { installGoalCapture } from "./goal-capture.js";
26
26
  import { installPreferenceCapture } from "./preference.js";
27
- import { installBoundaryPrune } from "./boundary-prune.js";
27
+ import { boundaryPruneAvailability, installBoundaryPrune } from "./boundary-prune.js";
28
28
  import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
29
29
  import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
30
30
  import { createLedger, estimatePrefixChars } from "./ledger.js";
31
31
  import { createJit } from "./jit.js";
32
- import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
32
+ import { buildEpisode } from "./episode.js";
33
+ import { collectStaleAnchors } from "./anchor.js";
34
+ import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
33
35
  import { makeTriggerRecorder } from "./trigger.js";
34
36
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
35
37
  import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
@@ -128,6 +130,24 @@ const DEFAULTS = {
128
130
  jitMaxLines: 3,
129
131
  jitMaxLineChars: 220,
130
132
  engramJitOrder: 40.75,
133
+ // Session episode (v0.7-A): one compact, zero-LLM digest at teardown; feeds
134
+ // the resume brief and never enters the [ENGRAM] index.
135
+ episodeEnabled: true,
136
+ episodeMaxPerWorkspace: 20,
137
+ // Resume brief (v0.7-D): newest recent episode injected at session start.
138
+ resumeBriefEnabled: true,
139
+ resumeBriefMaxAgeDays: 7,
140
+ resumeBriefMaxChars: 240,
141
+ // Entity pages (v0.7-B): one-line cards in the index, full page on drill.
142
+ entityCardsEnabled: true,
143
+ entityCardsMax: 2,
144
+ entityCardChars: 160,
145
+ // Anchor revalidation + layered TTL (v0.7-C): file-anchored memories are
146
+ // re-stat'ed at session start; changed/missing sources are tagged `stale`
147
+ // and dropped from index/truth (never deleted).
148
+ anchorRevalidate: true,
149
+ episodeTtlDays: 14,
150
+ errorTtlDays: 60,
131
151
  // decision-point triggers (P0-A): promote a session plan to an ESR task
132
152
  // when the pending-todo count is at least minTodosForPromote and exceeds
133
153
  // active ESR tasks × todoPromoteRatio (default: pending > active).
@@ -339,6 +359,27 @@ export function apply(ctx, config) {
339
359
  log,
340
360
  });
341
361
 
362
+ // Expose the ledger to the per-session web plane. Agent presets mount
363
+ // `dsh-engram/compaction` in their own isolate realm, so a closure from this
364
+ // apply() is unreachable there; a plain (non-isolated) service value is the
365
+ // bridge: the row credits compaction savings through it and `ledger` stays
366
+ // the single writer of the durable rollup.
367
+ if (ledger !== null) {
368
+ try {
369
+ // `ctx.provide` is fiber-scoped (cordis removes the service when this
370
+ // plugin unloads), so the returned disposer needs no bookkeeping here.
371
+ ctx.provide?.("engramLedger", {
372
+ noteCompaction: (sessionId, workspace, opts) => ledger.noteCompaction(sessionId, workspace, opts),
373
+ notePrune: (sessionId, workspace, opts) => ledger.notePrune(sessionId, workspace, opts),
374
+ noteRewrite: (sessionId, workspace, opts) => ledger.noteRewrite(sessionId, workspace, opts),
375
+ summary: () => ledger.summary(),
376
+ flush: () => ledger.flush(),
377
+ });
378
+ } catch (error) {
379
+ log?.warn?.(`engram ledger service unavailable: ${String(error?.message ?? error)}`);
380
+ }
381
+ }
382
+
342
383
  // Ledger-driven off-switch for BoundaryPrune (PROPOSAL-evolution v0.6-B):
343
384
  // per-workspace, 30s-cached, reads the same summary /stats exposes. Evidence
344
385
  // can only turn pruning OFF. No ledger → allow (repayment gate still runs).
@@ -402,7 +443,9 @@ export function apply(ctx, config) {
402
443
  /** Live Context GC status (API overview + durable status file). */
403
444
  compactionStatus: {
404
445
  host: "pending",
405
- web: { action: "pending", presets: [] },
446
+ // `disabled` (not `pending`) when the web opt-in is off: the status file
447
+ // must not look like a wiring run that never finished.
448
+ web: resolved.autoWebCompaction === false ? { action: "disabled", presets: [] } : { action: "pending", presets: [] },
406
449
  config: { autoWebCompaction: resolved.autoWebCompaction, gcReplacesCompaction: resolved.gcReplacesCompaction, gcNarrative: resolved.gcNarrative },
407
450
  },
408
451
  /** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
@@ -495,7 +538,21 @@ export function apply(ctx, config) {
495
538
  let pendingRecall = false;
496
539
  try {
497
540
  const workspace = resolvePath(cwd);
541
+ // Anchor revalidation (v0.7-C): a bounded stat pass at session start.
542
+ // Stale rows are tagged (for the GUI/GC) and excluded from the render
543
+ // below via memoryIsStale — never deleted, still re-fetchable.
544
+ if (resolved.anchorRevalidate !== false) {
545
+ for (const row of collectStaleAnchors(opened, workspace, { limit: 40 })) {
546
+ void opened.tagMemory(workspace, row.id, "stale")?.catch?.(() => null);
547
+ }
548
+ }
498
549
  block = renderIndex(opened, workspace, cwd, resolved);
550
+ // Resume brief (v0.7-D): the newest recent episode, frozen with the
551
+ // [ENGRAM] block. Empty when there is no recent episode.
552
+ if (resolved.resumeBriefEnabled !== false) {
553
+ const resume = renderResumeBrief(opened, workspace, resolved);
554
+ if (resume.length > 0) block = `${block}\n${resume}`.trim();
555
+ }
499
556
  // Host-side session-start auto-recall (方案 B): append the [RECALL]
500
557
  // block keyed on the session's FIRST user message. The cache only freezes
501
558
  // once there is a query to key on — if the user text isn't in the events
@@ -632,6 +689,16 @@ export function apply(ctx, config) {
632
689
  return null;
633
690
  }
634
691
  },
692
+ // Context Ledger credit: the Context GC engine measures the span it
693
+ // replaced with a checkpoint (`span − summary` chars) and reports it
694
+ // here; the ledger stops the "savings are always 0" asymmetry.
695
+ onCompacted: ({ session, workspace, charsSaved }) => {
696
+ try {
697
+ ledger?.noteCompaction(session?.id, workspace ?? wk(session?.header?.cwd), { charsSaved });
698
+ } catch (error) {
699
+ log?.warn?.(`engram ledger compaction credit failed: ${String(error)}`);
700
+ }
701
+ },
635
702
  });
636
703
  disposers.push(() => {
637
704
  // The engine's service registration is fiber-scoped and auto-removed on
@@ -646,6 +713,10 @@ export function apply(ctx, config) {
646
713
  let hostSettled = false;
647
714
  let webSettled = resolved.autoWebCompaction === false;
648
715
  let statusTimer = void 0;
716
+ // Authoritative L1 availability, captured where BoundaryPrune is actually
717
+ // installed (the pruner may land after the first status flush on a slow
718
+ // boot; the live check is only a fallback).
719
+ let boundaryPruneState = void 0;
649
720
  const flushStatus = () => {
650
721
  if (!hostSettled || !webSettled) return;
651
722
  if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
@@ -653,6 +724,9 @@ export function apply(ctx, config) {
653
724
  planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
654
725
  host: service.compactionStatus.host,
655
726
  web: service.compactionStatus.web,
727
+ // L1 availability is the missing credit path behind a "节省 always 0"
728
+ // ledger: record whether BoundaryPrune can actually fire here.
729
+ boundaryPrune: boundaryPruneState ?? boundaryPruneAvailability(ctx, resolved),
656
730
  config: service.compactionStatus.config,
657
731
  configSource: {
658
732
  autoWebCompaction: resolved.autoWebCompaction,
@@ -679,12 +753,15 @@ export function apply(ctx, config) {
679
753
  // inside the agent preset's own realm, which a profile patch cannot reach.
680
754
  // When the `agentPresets` service is present, swap the stock
681
755
  // `compaction-basic` row for `dsh-engram/compaction` in every stock-layout
682
- // preset — the default one plus the whole roster (shipped + user roots) —
683
- // so a session composed from any preset gets Context GC. Surgical,
684
- // idempotent, backed up, validated; a preset with a custom compaction
685
- // layout (or none) is never touched. The row's engine config is derived
686
- // from the settings knobs (`gcReplacesCompaction` / `gcNarrative`), so
687
- // changing them re-provisions the web rows on the next boot. Flip
756
+ // preset. The row is located structurally (indentation-agnostic) inside the
757
+ // `compaction` group, so both preset generations are covered: legacy
758
+ // compositions with `preset.path` (DSH ≤ 0.1.x) and the declarative
759
+ // `@deepseek-ai/dsh-web-app/presets/<id>.patch.yml` files (DSH ≥ 0.2.0,
760
+ // where the row sits at 14 spaces and the id maps to the shipped file).
761
+ // Surgical, idempotent, backed up, validated; a preset with a custom
762
+ // compaction layout (or none) is never touched. The row's engine config is
763
+ // derived from the settings knobs (`gcReplacesCompaction` / `gcNarrative`),
764
+ // so changing them re-provisions the web rows on the next boot. Flip
688
765
  // `autoWebCompaction:false` to stop auto-wiring on later boots (it does
689
766
  // not un-wire already-wired presets — use `npm run web-compaction:revert`
690
767
  // for that). Best-effort: a failure only warns.
@@ -846,6 +923,53 @@ export function apply(ctx, config) {
846
923
  });
847
924
  disposers.push(disposeSink);
848
925
 
926
+ // session-end episode digest (v0.7-A): one compact, zero-LLM record of the
927
+ // session's files/commands/failures/decisions/open loops. Never indexed;
928
+ // capped per workspace; the resume brief reads the newest recent one.
929
+ const disposeEpisode = ctx.on("session/disposed", (session) => {
930
+ if (resolved.episodeEnabled === false) return;
931
+ const cwd = session?.header?.cwd;
932
+ const sessionId = session?.id;
933
+ if (typeof cwd !== "string" || cwd.length === 0) return;
934
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
935
+ const episode = buildEpisode(session);
936
+ if (episode === null) return;
937
+ const workspace = wk(cwd);
938
+ const maxEpisodes = Number.isFinite(resolved.episodeMaxPerWorkspace)
939
+ ? Math.max(1, Math.trunc(resolved.episodeMaxPerWorkspace))
940
+ : 20;
941
+ service
942
+ .getDomain()
943
+ .then(async (domain) => {
944
+ await domain.storeMemory(
945
+ {
946
+ workspace,
947
+ kind: "episode",
948
+ text: episode.text,
949
+ tags: episode.tags,
950
+ signal: episode.signal,
951
+ filePath: null,
952
+ sessionId,
953
+ seq: Array.isArray(session.events) ? session.events.length : 0,
954
+ },
955
+ resolved,
956
+ );
957
+ const episodes = domain
958
+ .listMemories(workspace, 200)
959
+ .filter((memory) => memory.kind === "episode");
960
+ for (const old of episodes.slice(maxEpisodes)) {
961
+ try {
962
+ await domain.archiveMemory(workspace, old.id);
963
+ } catch {
964
+ // best-effort retention
965
+ }
966
+ }
967
+ log?.info?.(`engram episode: stored ${episode.text.length} chars in ${workspace}`);
968
+ })
969
+ .catch((error) => log?.warn?.(`engram episode failed: ${String(error)}`));
970
+ });
971
+ disposers.push(disposeEpisode);
972
+
849
973
  // zero-LLM auto-capture
850
974
  if (resolved.autoCapture) {
851
975
  // The capture handler needs a storeMemory face, but the domain opens
@@ -929,6 +1053,9 @@ export function apply(ctx, config) {
929
1053
  },
930
1054
  }, { allowFire: boundaryGate });
931
1055
  disposers.push(disposeBoundaryPrune);
1056
+ // BoundaryPrune is installed here, so this is the moment L1 provably can
1057
+ // (or cannot) fire — record it for the durable status snapshot.
1058
+ boundaryPruneState = boundaryPruneAvailability(ctx, resolved);
932
1059
 
933
1060
  // ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
934
1061
  // offset?)` pages back an archived middle. Independent of the memory
package/lib/settings.js CHANGED
@@ -70,6 +70,9 @@ export const SETTINGS_KEYS = [
70
70
  "ledgerEnabled",
71
71
  "ledgerCacheDiscount",
72
72
  "jitEnabled",
73
+ "episodeEnabled",
74
+ "resumeBriefEnabled",
75
+ "entityCardsEnabled",
73
76
  "trustedHosts",
74
77
  ];
75
78
 
@@ -115,6 +118,9 @@ export function makeSettingsSchema(defaults) {
115
118
  ledgerEnabled: z.boolean().default(defaults.ledgerEnabled).description("上下文账本:按会话统计注入成本、剪枝/压缩节省与前缀重写罚金,输出净 token(/stats.ledger 与概览);关掉=不记账、零写入"),
116
119
  ledgerCacheDiscount: z.number().min(0).max(1).default(defaults.ledgerCacheDiscount).description("账本缓存折价(0..1):被剪枝前缀的重写按 (1−该系数) 计罚金;0.1 = 缓存命中只付 10%,重写付 90%(L0 保守假设)"),
117
120
  jitEnabled: z.boolean().default(defaults.jitEnabled).description("JIT 推送:错误复发 / 文件焦点 / goal 完成时把已有记忆压成一行推进上下文(append-only、每会话最多 3 行、无命中沉默;每次推送按前缀重写计入账本)"),
121
+ episodeEnabled: z.boolean().default(defaults.episodeEnabled).description("会话 episode:会话结束时用零 LLM 规则生成一条紧凑纪要(文件/命令/失败→修复/决策/未闭环),不进 [ENGRAM] 索引,供 resume brief 与实体页使用"),
122
+ resumeBriefEnabled: z.boolean().default(defaults.resumeBriefEnabled).description("恢复简报:新会话启动时注入最近 7 天的最新 episode(≤240 字符,含 engram_detail 指针),让隔天继续的项目不用重新交代背景"),
123
+ entityCardsEnabled: z.boolean().default(defaults.entityCardsEnabled).description("实体卡:[ENGRAM] 索引为活跃实体(有锚定记忆或未闭环任务)渲染一行「当前真相 + 打开任务 + engram_detail(entity=…) 指针」,替代平铺记忆行;最多 2 张"),
118
124
  // Security knob, not rendered in the card: hostnames allowed past the
119
125
  // loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
120
126
  trustedHosts: z.array(z.string()).default([]),
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`);