dsh-engram 0.3.4 → 0.3.6
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 +283 -27
- package/README.zh.md +184 -16
- package/cordis.patch.yml +6 -0
- package/docs/CONTEXT-GC-GUIDE.zh.md +192 -0
- package/docs/PROPOSAL-agentmemory.zh.md +108 -0
- package/docs/PROPOSAL-beads.zh.md +81 -0
- package/docs/PROPOSAL-contextgc.zh.md +188 -0
- package/docs/PROPOSAL-esrtrigger.zh.md +199 -0
- package/docs/PROPOSAL-hindsight.zh.md +156 -0
- package/lib/api.js +76 -2
- package/lib/capture.js +132 -4
- package/lib/client.js +45 -2
- package/lib/compaction.js +2 -18
- package/lib/context-gc.js +234 -2
- package/lib/goal-capture.js +89 -0
- package/lib/index-block.js +96 -1
- package/lib/index.js +233 -5
- package/lib/redact.js +48 -0
- package/lib/session-cjk.js +159 -0
- package/lib/settings.js +13 -0
- package/lib/status.js +57 -0
- package/lib/store.js +411 -6
- package/lib/todo-sink.js +146 -0
- package/lib/tools.js +98 -40
- package/lib/trigger.js +10 -8
- package/lib/usage.js +147 -5
- package/lib/web-provision.js +430 -0
- package/package.json +13 -2
- package/scripts/dsh-engram.mjs +268 -0
- package/scripts/engram-web-compaction.mjs +7 -0
- package/scripts/prepublish-check.mjs +106 -0
- package/scripts/setup-links.mjs +241 -0
package/lib/context-gc.js
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
* with no re-fetchable backing) get a scoped LLM narrative — the safety net,
|
|
13
13
|
* configurable off for a pure-mechanical zero-LLM path.
|
|
14
14
|
*
|
|
15
|
-
* The 6 pi-esr GC constraints:
|
|
15
|
+
* The 6 pi-esr GC constraints (+ the harness shrink gate that keeps them legal):
|
|
16
16
|
* - indexed-output-evictable → pointer to `engram_detail(id)` / `engram_recall`
|
|
17
17
|
* - stable-task-evictable → pointer to the [ESR] block / `esr_ready`
|
|
18
18
|
* - working-set-protected → active tasks restated in the summary
|
|
19
19
|
* - pointer-salience → every evicted category has an explicit re-fetch call
|
|
20
20
|
* - no-provenance-no-evict → un-provenanced turns via scoped narrative or verbatim
|
|
21
|
+
* - shrink-gated → the framed checkpoint must be STRICTLY SMALLER
|
|
22
|
+
* than the evicted span (`fitSummaryToSpan`): the harness refuses and rolls
|
|
23
|
+
* back any summary that is not smaller, which otherwise blocks every auto
|
|
24
|
+
* compaction and pushes the session into the model-side context overflow.
|
|
25
|
+
* The pointer/narrative body is therefore fitted under the span with the
|
|
26
|
+
* host token meter, truncating tail detail (still durable in the log).
|
|
21
27
|
*
|
|
22
28
|
* Layout: the pure (zero-LLM, dependency-free) layer lives here; the DSH
|
|
23
29
|
* `CompactionEngine` binding is `loadContextGcEngine`, behind a guarded dynamic
|
|
@@ -67,6 +73,152 @@ export function blockText(content) {
|
|
|
67
73
|
return out.trim();
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The durable checkpoint framing the harness applies to a compaction summary
|
|
78
|
+
* before accepting it (`frameSummary()` in dsh-compaction-basic): the summary
|
|
79
|
+
* body is wrapped in a preamble + `<compacted-summary>` tags and the WHOLE
|
|
80
|
+
* framed user message must estimate strictly SMALLER than the evicted span,
|
|
81
|
+
* or the harness throws `summary is not smaller than the shadowed content`
|
|
82
|
+
* and rolls the compaction back. These constants mirror that framing so the
|
|
83
|
+
* Context GC can self-predict acceptance with the host token meter (verified
|
|
84
|
+
* to reproduce the harness's estimate exactly); a small safety margin absorbs
|
|
85
|
+
* any harness drift.
|
|
86
|
+
*/
|
|
87
|
+
const FRAME_PREAMBLE =
|
|
88
|
+
"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
|
|
89
|
+
const FRAME_OPEN_TAG = "<compacted-summary>";
|
|
90
|
+
const FRAME_CLOSE_TAG = "</compacted-summary>";
|
|
91
|
+
|
|
92
|
+
/** Best-effort token estimate of one derived message through the host meter. */
|
|
93
|
+
function estimateMessageTokens(meter, message) {
|
|
94
|
+
if (typeof meter?.estimateMessage !== "function") return 0;
|
|
95
|
+
try {
|
|
96
|
+
const tokens = meter.estimateMessage(message);
|
|
97
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
98
|
+
} catch {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Exact token estimate of the span a compaction is about to shadow: the sum of
|
|
105
|
+
* the evicted region's derived messages through the SAME token meter the
|
|
106
|
+
* harness compares against (verified to equal the harness's shadowed count).
|
|
107
|
+
* Returns 0 when no meter is available — callers then skip the shrink gate
|
|
108
|
+
* rather than guess.
|
|
109
|
+
* @param {object|undefined} meter - `ctx.tokenMeter` (host), or undefined.
|
|
110
|
+
* @param {Array<object>} messages - the region messages passed to `summarize`.
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
export function estimateShadowedTokens(meter, messages) {
|
|
114
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
115
|
+
let total = 0;
|
|
116
|
+
for (const message of list) total += estimateMessageTokens(meter, message);
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The harness-framed checkpoint user message for a summary body. */
|
|
121
|
+
function framedCheckpointMessage(bodyText) {
|
|
122
|
+
return {
|
|
123
|
+
role: "user",
|
|
124
|
+
content: [
|
|
125
|
+
{ type: "text", text: `${FRAME_PREAMBLE}\n\n${FRAME_OPEN_TAG}` },
|
|
126
|
+
{ type: "text", text: bodyText },
|
|
127
|
+
{ type: "text", text: FRAME_CLOSE_TAG },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Token estimate of the framed checkpoint the harness will generate for this
|
|
134
|
+
* body (mirrors `frameSummary` + `createUserMessage` + `estimateMessage`;
|
|
135
|
+
* verified byte-identical to the live meter). Without a meter, a conservative
|
|
136
|
+
* chars→tokens heuristic is used — divided by two so both CJK and latin
|
|
137
|
+
* over-estimate, biasing toward trimming more, never less.
|
|
138
|
+
* @param {object|undefined} meter - `ctx.tokenMeter`, or undefined.
|
|
139
|
+
* @param {string} bodyText - the summary body inside the checkpoint.
|
|
140
|
+
* @returns {number}
|
|
141
|
+
*/
|
|
142
|
+
export function estimateFramedTokens(meter, bodyText) {
|
|
143
|
+
if (typeof meter?.estimateMessage === "function") {
|
|
144
|
+
try {
|
|
145
|
+
const tokens = meter.estimateMessage(framedCheckpointMessage(bodyText));
|
|
146
|
+
if (Number.isFinite(tokens)) return Math.max(1, tokens);
|
|
147
|
+
} catch {
|
|
148
|
+
// fall through to the char heuristic
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const text = typeof bodyText === "string" ? bodyText : "";
|
|
152
|
+
return (
|
|
153
|
+
Math.ceil((FRAME_PREAMBLE.length + FRAME_OPEN_TAG.length + FRAME_CLOSE_TAG.length + text.length) / 2) + 8
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Truncation note appended when the checkpoint must shrink to fit the span. */
|
|
158
|
+
const FIT_SUFFIX = "\n_…(full detail stays re-fetchable via the engram/ESR tools; see the session log)_";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Shrink a Context GC summary body so the framed checkpoint stays strictly
|
|
162
|
+
* under the evicted span — the harness's hard shrink gate. Without this, a
|
|
163
|
+
* large verbatim/pointer body makes every automatic pressure / overflow
|
|
164
|
+
* compaction roll back (`summary is not smaller than the shadowed content`),
|
|
165
|
+
* the context never shrinks, and the session eventually hits the model-side
|
|
166
|
+
* context overflow.
|
|
167
|
+
*
|
|
168
|
+
* Truncation keeps the largest PREFIX that still fits — the pointer head and
|
|
169
|
+
* working set survive, only tail detail is cut — and appends a short note when
|
|
170
|
+
* the span is large enough to afford one. Exact under the host token meter;
|
|
171
|
+
* deterministic everywhere; degrades to an empty body for spans so small that
|
|
172
|
+
* the exact framing overhead does not fit (the stock LLM summarizer would be
|
|
173
|
+
* refused by the harness for those too).
|
|
174
|
+
*
|
|
175
|
+
* @param {string} bodyText - the full Context GC body.
|
|
176
|
+
* @param {number} shadowedTokens - evicted-span token estimate (0 = unknown).
|
|
177
|
+
* @param {{meter?: object, margin?: number}} [opts]
|
|
178
|
+
* @returns {{ text: string, framed: number, shadowed: number, trimmed: boolean }}
|
|
179
|
+
*/
|
|
180
|
+
export function fitSummaryToSpan(bodyText, shadowedTokens, opts = {}) {
|
|
181
|
+
const meter = opts?.meter;
|
|
182
|
+
const margin = opts?.margin ?? 4;
|
|
183
|
+
const budget = shadowedTokens - margin;
|
|
184
|
+
if (!Number.isFinite(shadowedTokens) || shadowedTokens <= 0 || budget <= 1) {
|
|
185
|
+
return {
|
|
186
|
+
text: bodyText,
|
|
187
|
+
framed: estimateFramedTokens(meter, bodyText),
|
|
188
|
+
shadowed: shadowedTokens,
|
|
189
|
+
trimmed: false,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const source = typeof bodyText === "string" && bodyText.length > 0 ? bodyText : "";
|
|
193
|
+
if (estimateFramedTokens(meter, source) < budget) {
|
|
194
|
+
return {
|
|
195
|
+
text: source,
|
|
196
|
+
framed: estimateFramedTokens(meter, source),
|
|
197
|
+
shadowed: shadowedTokens,
|
|
198
|
+
trimmed: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Largest prefix of the source that fits WITHOUT the note; the note is only
|
|
202
|
+
// re-added when the budget still affords it (the note itself is ~25 tokens).
|
|
203
|
+
let lo = 0;
|
|
204
|
+
let hi = source.length;
|
|
205
|
+
let best = 0;
|
|
206
|
+
while (lo <= hi) {
|
|
207
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
208
|
+
if (estimateFramedTokens(meter, source.slice(0, mid)) < budget) {
|
|
209
|
+
best = mid;
|
|
210
|
+
lo = mid + 1;
|
|
211
|
+
} else {
|
|
212
|
+
hi = mid - 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const prefix = source.slice(0, best);
|
|
216
|
+
const withNote = `${prefix}${FIT_SUFFIX}`;
|
|
217
|
+
const text = best > 0 && estimateFramedTokens(meter, withNote) < budget ? withNote : prefix;
|
|
218
|
+
const trimmed = text !== source;
|
|
219
|
+
return { text, framed: estimateFramedTokens(meter, text), shadowed: shadowedTokens, trimmed };
|
|
220
|
+
}
|
|
221
|
+
|
|
70
222
|
function addSet(set, value) {
|
|
71
223
|
const v = String(value ?? "").trim();
|
|
72
224
|
if (v.length > 0) set.add(v);
|
|
@@ -466,6 +618,57 @@ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode =
|
|
|
466
618
|
return loadContextGcEngine(ctx, config, deps);
|
|
467
619
|
}
|
|
468
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Mount the compaction engine onto `ctx` — THE shared entry both planes use
|
|
623
|
+
* (host plane: the main engram plugin's `apply`; preset plane: the
|
|
624
|
+
* `dsh-engram/compaction` row shown in an agent preset's `compaction` group).
|
|
625
|
+
*
|
|
626
|
+
* `config.gcReplacesCompaction === false` → mount the bare default LLM
|
|
627
|
+
* summarizer instead (keeps the `compaction` service alive after the profile
|
|
628
|
+
* patch disabled the base `compaction-basic` row); otherwise mount the
|
|
629
|
+
* ContextGcEngine override. `deps.readWorkspace(ws, report)` is how the engine
|
|
630
|
+
* reaches engram/ESR state for pointer classification — the caller supplies
|
|
631
|
+
* its own lazy-domain binding.
|
|
632
|
+
*
|
|
633
|
+
* Fully contained: an unimportable backend or a duplicate `compaction`
|
|
634
|
+
* provider (cordis refuses second registration) only warns and returns null —
|
|
635
|
+
* the host keeps whatever compaction it already has, never a crash.
|
|
636
|
+
*
|
|
637
|
+
* @returns {Promise<Function|null>} the mounted engine class, or null.
|
|
638
|
+
*/
|
|
639
|
+
export async function mountCompactionEngine(ctx, config = {}, deps = {}) {
|
|
640
|
+
const mode = config.gcReplacesCompaction === false ? "default" : "context-gc";
|
|
641
|
+
const engineDeps =
|
|
642
|
+
mode === "context-gc"
|
|
643
|
+
? {
|
|
644
|
+
narrativeEnabled: config.gcNarrative !== false,
|
|
645
|
+
narrativeMaxTokens: config.gcNarrativeMaxTokens ?? 1024,
|
|
646
|
+
narrativeMaxChars: config.gcNarrativeMaxChars ?? 4000,
|
|
647
|
+
...deps,
|
|
648
|
+
}
|
|
649
|
+
: {};
|
|
650
|
+
const Engine = await loadCompactionEngine(ctx, { auto: true }, engineDeps, { mode });
|
|
651
|
+
if (!Engine) {
|
|
652
|
+
ctx?.logger?.warn?.("engram context-gc: dsh-compaction-basic unavailable — no compaction engine");
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
try {
|
|
656
|
+
new Engine();
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// Duplicate `compaction` provider (base row not disabled) or constructor
|
|
659
|
+
// refusal: keep whatever is already registered, never crash the host.
|
|
660
|
+
ctx?.logger?.warn?.(
|
|
661
|
+
`engram context-gc mount skipped (${error instanceof Error ? error.message : String(error)}) — keeping the existing compaction service`,
|
|
662
|
+
);
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
ctx?.logger?.info?.(
|
|
666
|
+
`engram context-gc: compaction = ${mode === "context-gc" ? "Context GC (mechanical eviction + re-fetch pointers)" : "default LLM summarizer (gcReplacesCompaction:false)"}`,
|
|
667
|
+
);
|
|
668
|
+
return Engine;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
469
672
|
/**
|
|
470
673
|
* Load the DSH binding: a `ContextGcEngine extends BasicCompactionEngine`
|
|
471
674
|
* whose sole override is `summarize()`. Returns `null` when
|
|
@@ -542,9 +745,38 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
|
|
|
542
745
|
callLlm,
|
|
543
746
|
});
|
|
544
747
|
|
|
545
|
-
|
|
748
|
+
let summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
|
|
749
|
+
|
|
750
|
+
// The harness refuses any checkpoint whose framed token estimate is >=
|
|
751
|
+
// the evicted span (see fitSummaryToSpan). Fit the body under that span
|
|
752
|
+
// so automatic pressure / context-overflow compaction COMMITS instead of
|
|
753
|
+
// rolling back — otherwise the session only grows until the model-side
|
|
754
|
+
// context overflow fires.
|
|
755
|
+
const meter = this.ctx?.tokenMeter;
|
|
756
|
+
const shadowedTokens = estimateShadowedTokens(meter, messages);
|
|
757
|
+
const fit = fitSummaryToSpan(summaryText, shadowedTokens, { meter });
|
|
758
|
+
if (fit.trimmed && typeof this.ctx?.logger?.info === "function") {
|
|
759
|
+
this.ctx.logger.info(
|
|
760
|
+
`engram context-gc: checkpoint trimmed to fit the evicted span (${fit.framed} framed vs ${fit.shadowed} shadowed tokens)`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
summaryText = fit.text;
|
|
764
|
+
|
|
546
765
|
const result = { summary: [{ type: "text", text: summaryText }] };
|
|
547
766
|
if (narrative.llm && typeof narrative.llm === "object") Object.assign(result, narrative.llm);
|
|
767
|
+
// The harness's durable `compaction/summary` record ALWAYS carries
|
|
768
|
+
// `provider`/`model` (spread unconditionally; undefined values reject the
|
|
769
|
+
// whole event as non-JSON-serializable). The mechanical path makes no LLM
|
|
770
|
+
// call, so stamp the conversation's routed target — the same model the
|
|
771
|
+
// default summarizer would have used — as the record's provenance.
|
|
772
|
+
const target = this._narrativeTarget(agent);
|
|
773
|
+
if (target && typeof target.provider === "string" && typeof target.model === "string") {
|
|
774
|
+
result.provider = result.provider ?? target.provider;
|
|
775
|
+
result.model = result.model ?? target.model;
|
|
776
|
+
}
|
|
777
|
+
if (result.maxTokens === undefined && this.config && Number.isFinite(this.config.maxTokens)) {
|
|
778
|
+
result.maxTokens = this.config.maxTokens;
|
|
779
|
+
}
|
|
548
780
|
return result;
|
|
549
781
|
}
|
|
550
782
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: DSH goal-domain integration.
|
|
3
|
+
*
|
|
4
|
+
* The harness's goal service persists a durable `goal/change` session event on
|
|
5
|
+
* every goal mutation. This module listens for those events and turns the two
|
|
6
|
+
* terminal states into memories, so a completed / blocked goal survives the
|
|
7
|
+
* session — deterministic, zero LLM, purely mechanical:
|
|
8
|
+
*
|
|
9
|
+
* - complete → `handoff` memory (high signal, tag `goal`)
|
|
10
|
+
* - block → `error` memory (tag `goal` + `blocked`) — the blocker is a
|
|
11
|
+
* real, recall-worthy signal
|
|
12
|
+
*
|
|
13
|
+
* The goal snapshot fields are read verbatim; nothing here calls a model and
|
|
14
|
+
* the listener never throws into the session pipeline (fully contained).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { workspaceKey as wk } from "./util.js";
|
|
18
|
+
|
|
19
|
+
function isRecord(v) {
|
|
20
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure reducer: map a `goal/change` session-event payload to a memory entry,
|
|
25
|
+
* or null when the event is not a terminal goal state worth sedimenting.
|
|
26
|
+
* Deterministic and unit-testable without the harness.
|
|
27
|
+
*/
|
|
28
|
+
export function goalChangeToMemory(change) {
|
|
29
|
+
if (!isRecord(change) || change.kind !== "goal/change") return null;
|
|
30
|
+
if (change.operation !== "complete" && change.operation !== "block") return null;
|
|
31
|
+
const goal = change.goal;
|
|
32
|
+
if (!isRecord(goal) || typeof goal.objective !== "string") return null;
|
|
33
|
+
const objective = goal.objective.trim().slice(0, 400);
|
|
34
|
+
if (objective.length === 0) return null;
|
|
35
|
+
if (change.operation === "complete") {
|
|
36
|
+
return {
|
|
37
|
+
kind: "handoff",
|
|
38
|
+
text: `goal complete: ${objective}`,
|
|
39
|
+
tags: ["goal", "auto-captured", "complete"],
|
|
40
|
+
signal: 0.6,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const reason = typeof goal.blockedReason === "string" && goal.blockedReason.trim().length > 0
|
|
44
|
+
? ` — ${goal.blockedReason.trim().slice(0, 160)}`
|
|
45
|
+
: "";
|
|
46
|
+
return {
|
|
47
|
+
kind: "error",
|
|
48
|
+
text: `goal blocked: ${objective}${reason}`,
|
|
49
|
+
tags: ["goal", "auto-captured", "blocked"],
|
|
50
|
+
signal: 0.4,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install the `session/event` listener that auto-sediments terminal goals.
|
|
56
|
+
* `storeAdapter` is any { storeMemory(entry) -> Promise } face (the same lazy
|
|
57
|
+
* adapter auto-capture uses); it is resolved per event, so early events that
|
|
58
|
+
* beat the first domain open are simply dropped. Returns the disposer.
|
|
59
|
+
*/
|
|
60
|
+
export function installGoalCapture(ctx, storeAdapter, log) {
|
|
61
|
+
const onEvent = (session, event) => {
|
|
62
|
+
try {
|
|
63
|
+
if (event?.type !== "goal/change") return;
|
|
64
|
+
const entry = goalChangeToMemory(event.data);
|
|
65
|
+
if (entry === null) return;
|
|
66
|
+
const cwd = session?.header?.cwd;
|
|
67
|
+
const sessionId = session?.id;
|
|
68
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
69
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
70
|
+
storeAdapter
|
|
71
|
+
.storeMemory({
|
|
72
|
+
workspace: wk(cwd),
|
|
73
|
+
kind: entry.kind,
|
|
74
|
+
text: entry.text,
|
|
75
|
+
tags: entry.tags,
|
|
76
|
+
signal: entry.signal,
|
|
77
|
+
filePath: null,
|
|
78
|
+
sessionId,
|
|
79
|
+
seq: 0,
|
|
80
|
+
})
|
|
81
|
+
.then(() => {})
|
|
82
|
+
.catch((error) => log?.warn?.(`engram goal capture failed: ${String(error)}`));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// fully contained — a capture bug must never disturb the session pipeline
|
|
85
|
+
log?.warn?.(`engram goal capture threw: ${String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return ctx.on("session/event", onEvent);
|
|
89
|
+
}
|
package/lib/index-block.js
CHANGED
|
@@ -51,7 +51,11 @@ export function isProvenProcedure(m, config) {
|
|
|
51
51
|
*/
|
|
52
52
|
export function selectIndexMemories(store, workspace, config) {
|
|
53
53
|
const all = store.listMemories(workspace, 200);
|
|
54
|
+
// Stale truth (a newer memory marked itself as replacing that row) never
|
|
55
|
+
// earns an index line — the current statement is the one worth surfacing.
|
|
56
|
+
const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
|
|
54
57
|
return all
|
|
58
|
+
.filter((m) => !supersededBy.has(m.id))
|
|
55
59
|
.filter(
|
|
56
60
|
(m) =>
|
|
57
61
|
(isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
|
|
@@ -111,7 +115,7 @@ export function renderIndex(store, workspace, cwd, config) {
|
|
|
111
115
|
if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
|
|
112
116
|
for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
|
|
113
117
|
for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
|
|
114
|
-
lines.push("drill:
|
|
118
|
+
lines.push("drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)");
|
|
115
119
|
return enforceCharCap(lines, config.indexMaxChars);
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -129,6 +133,97 @@ function enforceCharCap(lines, maxChars) {
|
|
|
129
133
|
return joined;
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/** Text of the session's first user message (the opening instruction of the
|
|
137
|
+
* conversation). Walk `session.events` for the first `user/message` event and
|
|
138
|
+
* join its `data.content` text blocks; fall back to the first text-bearing
|
|
139
|
+
* event ("goal"/reminder start) guardedly. Returns "" when there is none.
|
|
140
|
+
* Pure, deterministic — the auto-recall (B) keys its query off this. */
|
|
141
|
+
export function firstUserMessageText(session) {
|
|
142
|
+
if (session === null || typeof session !== "object") return "";
|
|
143
|
+
const events = Array.isArray(session.events) ? session.events : [];
|
|
144
|
+
if (events.length === 0) return "";
|
|
145
|
+
const textOf = (data) => {
|
|
146
|
+
if (data === null || typeof data !== "object") return "";
|
|
147
|
+
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
148
|
+
if (blocks.length > 0) {
|
|
149
|
+
return blocks
|
|
150
|
+
.map((b) => (b !== null && typeof b === "object" && typeof b.text === "string" ? b.text : ""))
|
|
151
|
+
.join(" ")
|
|
152
|
+
.trim();
|
|
153
|
+
}
|
|
154
|
+
return typeof data.text === "string" ? data.text.trim() : "";
|
|
155
|
+
};
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (event === null || typeof event !== "object") continue;
|
|
158
|
+
if (event.type !== "user/message") continue;
|
|
159
|
+
const text = textOf(event.data);
|
|
160
|
+
if (text.length > 0) return text;
|
|
161
|
+
}
|
|
162
|
+
// Fallback: no user/message yet — first event carrying text (goal-driven,
|
|
163
|
+
// headless or reminder-seeded starts) still gives the recall a key.
|
|
164
|
+
for (const event of events) {
|
|
165
|
+
if (event === null || typeof event !== "object" || event.type === "user/message") continue;
|
|
166
|
+
const text = textOf(event.data);
|
|
167
|
+
if (text.length > 0) return text;
|
|
168
|
+
}
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Host-side session-start auto-recall (方案 B): at the first assembly of a new
|
|
174
|
+
* session, deterministically key a bounded BM25 recall off the session's first
|
|
175
|
+
* user message and inject the top hits as a `[RECALL]` block — so relevant
|
|
176
|
+
* memory reaches the model even when it never calls `engram_recall` on its own
|
|
177
|
+
* (the observed real-world gap). Pure rule, zero LLM, no `hits` bumps (a pure
|
|
178
|
+
* read). Falls back to the newest high-signal memories when the query is empty
|
|
179
|
+
* and `allowRecencyFallback` is on. Returns "" when nothing useful to show.
|
|
180
|
+
*/
|
|
181
|
+
export function renderStartRecall(store, workspace, config = {}, opts = {}) {
|
|
182
|
+
if (store === null || typeof store?.recall !== "function") return "";
|
|
183
|
+
const limit = Math.max(1, Math.min(8, Number.isFinite(config.autoRecallLimit) ? Math.trunc(config.autoRecallLimit) : 3));
|
|
184
|
+
const maxChars = Number.isFinite(config.autoRecallMaxChars) && config.autoRecallMaxChars > 0 ? Math.trunc(config.autoRecallMaxChars) : 700;
|
|
185
|
+
const rawQuery = String(opts.query ?? "").trim().slice(0, 300);
|
|
186
|
+
const query = rawQuery.length > 0 ? rawQuery : (opts.allowRecencyFallback ?? true) ? null : "";
|
|
187
|
+
const items = query === null
|
|
188
|
+
? (store.listMemories?.(workspace, limit) ?? []).slice(0, limit)
|
|
189
|
+
: store.recall(workspace, query, limit, { maxPerSession: limit });
|
|
190
|
+
const hits = Array.isArray(items) ? items.filter((m) => m !== null && m !== void 0) : [];
|
|
191
|
+
// "Few and precise" (agentmemory 候选①): never let a superseded stale row —
|
|
192
|
+
// which `recall` demotes to the tail of an unbounded list — occupy one of the
|
|
193
|
+
// ≤ N auto-injected slots. Same policy as the [ENGRAM] index. The stale row
|
|
194
|
+
// stays re-fetchable via engram_detail, just not auto-pushed.
|
|
195
|
+
const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
|
|
196
|
+
const fresh = hits.filter((m) => !supersededBy.has(m.id));
|
|
197
|
+
if (fresh.length === 0) return "";
|
|
198
|
+
|
|
199
|
+
const lines = [];
|
|
200
|
+
const source = query === null ? "newest memory" : "recall";
|
|
201
|
+
lines.push(`[RECALL] ${source} · ${fresh.length} hit(s)${query ? ` · first msg: ${escapeLt(truncate(query, 40))}` : " · no user text yet"}`);
|
|
202
|
+
for (const m of fresh) {
|
|
203
|
+
const px = prefixFor(m.kind);
|
|
204
|
+
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
205
|
+
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
206
|
+
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), 140);
|
|
207
|
+
lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push("context: use above · engram_detail <id> · engram_recall <query>");
|
|
210
|
+
return enforceStartRecallCap(lines, maxChars);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Keep the [RECALL] header + context tail, drop hits until under budget;
|
|
214
|
+
* hard-truncate with an ellipsis when even the shell exceeds the cap. */
|
|
215
|
+
function enforceStartRecallCap(lines, maxChars) {
|
|
216
|
+
let joined = lines.join("\n");
|
|
217
|
+
while (joined.length > maxChars && lines.length > 2) {
|
|
218
|
+
lines.splice(lines.length - 2, 1);
|
|
219
|
+
joined = lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
if (joined.length > maxChars) {
|
|
222
|
+
joined = `${joined.slice(0, Math.max(1, maxChars - 1))}…`;
|
|
223
|
+
}
|
|
224
|
+
return joined;
|
|
225
|
+
}
|
|
226
|
+
|
|
132
227
|
/**
|
|
133
228
|
* One-line escalation reminder from an arbitrary balance view
|
|
134
229
|
* ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
|