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/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
- const summaryText = buildContextGcSummary(report, safeContext, { turns, narrative });
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
+ }
@@ -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: engram_recall <query> | engram_detail <id> | esr_task (multi-step work → create now) | esr_node (recurring domain object → create now) | esr_link (relate nodes/tasks)");
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