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/README.md +155 -39
- package/README.zh.md +119 -36
- package/cordis.patch.yml +18 -5
- package/docs/CONTEXT-GC-GUIDE.zh.md +35 -6
- package/docs/PROPOSAL-evolution.zh.md +25 -1
- package/docs/RELEASE-NOTES-v0.7.0.md +57 -0
- package/docs/RELEASE-NOTES-v0.7.1.md +88 -0
- package/lib/anchor.js +124 -0
- package/lib/boundary-prune.js +27 -0
- package/lib/client.js +7 -2
- package/lib/compaction.js +11 -0
- package/lib/context-gc.js +47 -0
- package/lib/entity-page.js +200 -0
- package/lib/episode.js +163 -0
- package/lib/index-block.js +74 -4
- package/lib/index.js +136 -9
- package/lib/settings.js +6 -0
- package/lib/store.js +48 -5
- package/lib/tools.js +25 -3
- package/lib/web-provision.js +324 -91
- package/package.json +1 -1
- package/scripts/dsh-engram.mjs +196 -53
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 {
|
|
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
|
-
|
|
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
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
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 = {
|
|
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 = {
|
|
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
|
-
:
|
|
494
|
-
? now() +
|
|
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
|
|
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
|
|
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(
|
|
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`);
|