dsh-engram 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -27,6 +27,8 @@ import { installPreferenceCapture } from "./preference.js";
27
27
  import { 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
+ import { createLedger, estimatePrefixChars } from "./ledger.js";
31
+ import { createJit } from "./jit.js";
30
32
  import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
31
33
  import { makeTriggerRecorder } from "./trigger.js";
32
34
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
@@ -92,9 +94,11 @@ const DEFAULTS = {
92
94
  gcStableRetentionDays: 120,
93
95
  // Context GC — the auto-GC that REPLACES DSH's built-in lossy LLM-summary
94
96
  // compaction (pi-esr CONTEXT_GC): mechanical eviction + re-fetch pointers,
95
- // NOT the memory-panel GC above. `gcReplacesCompaction` off → the host keeps
96
- // its default compaction backend; `gcNarrative` off → fully mechanical.
97
- gcReplacesCompaction: true,
97
+ // NOT the memory-panel GC above. Opt-in since v0.6: rewriting host presets is
98
+ // the highest-maintenance action in the plugin (DSH broke the settings API
99
+ // twice), so the default is the host summarizer; `gcNarrative` off → fully
100
+ // mechanical. Turn this on to replace compaction with pointer summaries.
101
+ gcReplacesCompaction: false,
98
102
  gcNarrative: true,
99
103
  gcNarrativeMaxTokens: 1024,
100
104
  gcNarrativeMaxChars: 4000,
@@ -109,6 +113,21 @@ const DEFAULTS = {
109
113
  engramIndexOrder: 40,
110
114
  esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
111
115
  esrOrder: 41,
116
+ // Context Ledger (PROPOSAL-evolution §4 v0.6-A): per-session accounting of
117
+ // injected block chars, pruned/compacted savings and prefix-rewrite penalty.
118
+ // In-memory + daily rollup at ~/.dsh/engram/ledger.json; never on the hot
119
+ // path of model calls (one integer add per request). `ledgerCacheDiscount`
120
+ // is the cached-prefix price share (0.1 = cache hit costs 10%, so a rewrite
121
+ // costs 90% of the prefix).
122
+ ledgerEnabled: true,
123
+ ledgerCacheDiscount: 0.1,
124
+ // JIT push (v0.6-C): append at most a few one-line, high-precision hints at
125
+ // deterministic events (error recurrence, file focus, goal completion).
126
+ // Append-only; every push pays a prefix rewrite in the ledger.
127
+ jitEnabled: true,
128
+ jitMaxLines: 3,
129
+ jitMaxLineChars: 220,
130
+ engramJitOrder: 40.75,
112
131
  // decision-point triggers (P0-A): promote a session plan to an ESR task
113
132
  // when the pending-todo count is at least minTodosForPromote and exceeds
114
133
  // active ESR tasks × todoPromoteRatio (default: pending > active).
@@ -134,7 +153,8 @@ const DEFAULTS = {
134
153
  // validation + idempotent; a preset with a custom compaction layout — or
135
154
  // none, like `minimal` — is never touched). Off = every preset keeps DSH's
136
155
  // default LLM summarizer; the host plane (headless/TUI/base) is unaffected.
137
- autoWebCompaction: true,
156
+ // Opt-in since v0.6: never rewrite presets unless the user asks.
157
+ autoWebCompaction: false,
138
158
  // Memory-to-memory semantics (zero LLM): when ON, an entity-anchored write
139
159
  // that carries a replacement/negation cue ("改用 / no longer / switched…")
140
160
  // auto-marks the matching older same-entity+kind memory as superseded.
@@ -145,9 +165,13 @@ const DEFAULTS = {
145
165
  // (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
146
166
  // todo-progress boundaries cover 79% of replay exposure; the repayment
147
167
  // gate below lifted cache-aware net savings 12–32% on every threshold.
148
- // Default OFF (SoL-Pi C13: a dormant mechanism costs nothing); the
149
- // pruner service is feature-detected — absent ⇒ disabled with one log.
150
- boundaryPrune: false,
168
+ // Default ON since v0.6 (PROPOSAL-evolution §3.5): `false` is the opt-out;
169
+ // the pruner service is feature-detected — absent ⇒ disabled with one log.
170
+ boundaryPrune: true,
171
+ // Adaptive off-switch (v0.6-B): the Context Ledger vetoes further fires in a
172
+ // workspace whose own accounting is net negative after real fires. Evidence
173
+ // can only turn pruning OFF — never on. `false` = repayment gate only.
174
+ boundaryPruneAdaptive: true,
151
175
  // Repayment gate: prune only while the session is expected to run at
152
176
  // least this many more requests (empirical workspace prior, see
153
177
  // lib/boundary-prune.js). 50 = L0 sweep optimum.
@@ -170,9 +194,10 @@ const DEFAULTS = {
170
194
  // append a `[middle archived: result_recall(<callId>) 可取]` handle tail to
171
195
  // each pruned node; register `result_recall(callId, offset?)` for paged
172
196
  // recall — the full ObservationPack pattern (SoL-Pi mech ③; paired
173
- // EdgeBench −23.58%). Requires `boundaryPrune: true` to have anything to
174
- // archive (L2 without L1 is a no-op with a log line). Default OFF (C13).
175
- resultPack: false,
197
+ // EdgeBench −23.58%). Requires boundary pruning to have anything to
198
+ // archive (L2 without L1 is a no-op with a log line). Default ON since v0.6:
199
+ // default pruning must be lossless (the archive is the recovery path).
200
+ resultPack: true,
176
201
  // result_recall page budget (chars per page). 4000 fits a typical model
177
202
  // context band without re-materializing huge results whole.
178
203
  resultPackPageChars: 4000,
@@ -304,6 +329,55 @@ export function apply(ctx, config) {
304
329
  // todo_write promote hook. Process-local; fed by a tools/result listener.
305
330
  const recorder = makeTriggerRecorder(resolved);
306
331
 
332
+ // Context Ledger: created before the service object so /stats can read it.
333
+ // `ledgerEnabled:false` leaves a null instance (zero listeners, zero writes).
334
+ const ledger = resolved.ledgerEnabled === false
335
+ ? null
336
+ : createLedger({
337
+ home: engramHome(),
338
+ cacheDiscount: resolved.ledgerCacheDiscount,
339
+ log,
340
+ });
341
+
342
+ // Ledger-driven off-switch for BoundaryPrune (PROPOSAL-evolution v0.6-B):
343
+ // per-workspace, 30s-cached, reads the same summary /stats exposes. Evidence
344
+ // can only turn pruning OFF. No ledger → allow (repayment gate still runs).
345
+ const boundaryGate = (() => {
346
+ const cache = new Map();
347
+ const TTL_MS = 30_000;
348
+ return (workspace) => {
349
+ if (ledger === null) return true;
350
+ const now = Date.now();
351
+ const hit = cache.get(workspace);
352
+ if (hit !== void 0 && now - hit.at < TTL_MS) return hit.ok;
353
+ let ok = true;
354
+ try {
355
+ const summary = ledger.summary();
356
+ const bucket = summary.byWorkspace[workspace] ?? summary.totals;
357
+ ok = !(bucket.rewriteCount >= 3 && bucket.netChars < 0);
358
+ } catch {
359
+ ok = true; // accounting must never disable the mechanism by accident
360
+ }
361
+ if (cache.size > 64) cache.clear();
362
+ cache.set(workspace, { at: now, ok });
363
+ return ok;
364
+ };
365
+ })();
366
+
367
+ // JIT push (v0.6-C): append-only hints, priced through the ledger.
368
+ const jit = resolved.jitEnabled === false
369
+ ? null
370
+ : createJit({
371
+ maxLines: resolved.jitMaxLines,
372
+ maxLineChars: resolved.jitMaxLineChars,
373
+ log,
374
+ onPush: ({ session, workspace, chars }) => {
375
+ ledger?.noteRewrite(session?.id, workspace, {
376
+ chars: estimatePrefixChars(session) + chars,
377
+ });
378
+ },
379
+ });
380
+
307
381
  const service = {
308
382
  config: resolved,
309
383
  getDomain,
@@ -319,6 +393,10 @@ export function apply(ctx, config) {
319
393
  /** SoL-Pi activation telemetry (M15/C13): durable boundary-prune fires +
320
394
  * [RECALL] injection funnel, read at request time (readPackTelemetry). */
321
395
  packTelemetry: () => readPackTelemetry({ home: engramHome() }),
396
+ /** Context Ledger snapshot: spent vs saved context, net of cache rewrites. */
397
+ ledger: () => (ledger === null ? null : ledger.summary()),
398
+ /** JIT push counters (per process; /stats diagnostic surface). */
399
+ jitStats: () => (jit === null ? null : jit.stats()),
322
400
  /** Last GC sweep totals (GUI overview observability). */
323
401
  gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
324
402
  /** Live Context GC status (API overview + durable status file). */
@@ -452,6 +530,7 @@ export function apply(ctx, config) {
452
530
  // [RECALL] block.
453
531
  if (pendingRecall) return block;
454
532
  sessionCache.set(session, block);
533
+ ledger?.noteInjection(session.id, wk(cwd), "index", block.length);
455
534
  return block;
456
535
  };
457
536
 
@@ -500,9 +579,22 @@ export function apply(ctx, config) {
500
579
  } catch (error) {
501
580
  log?.warn?.(`engram esr render failed: ${String(error)}`);
502
581
  }
582
+ if (block.length > 0) ledger?.noteInjection(session.id, wk(cwd), "esr", block.length);
503
583
  return block;
504
584
  };
505
585
 
586
+ // JIT block: append-only lines accumulated by lib/jit.js (never edited).
587
+ const jitFor = (agent) => {
588
+ const session = agent?.session;
589
+ if (session === void 0) return "";
590
+ const text = jit === null ? "" : jit.textFor(session);
591
+ const cwd = session.header?.cwd;
592
+ if (text.length > 0 && typeof cwd === "string" && cwd.length > 0) {
593
+ ledger?.noteInjection(session.id, wk(cwd), "jit", text.length);
594
+ }
595
+ return text;
596
+ };
597
+
506
598
  ctx.effect(() => {
507
599
  const disposers = [];
508
600
 
@@ -649,6 +741,18 @@ export function apply(ctx, config) {
649
741
  });
650
742
  disposers.push(() => indexDispose());
651
743
 
744
+ // JIT push block — empty until a deterministic event fires, then
745
+ // append-only (prefix changes at most once per push).
746
+ const jitDispose = systemPrompt.section({
747
+ name: "engram:jit",
748
+ order: resolved.engramJitOrder,
749
+ text: (assemblyContext) => {
750
+ const agent = assemblyContext?.agent;
751
+ return agent === void 0 ? "" : jitFor(agent);
752
+ },
753
+ });
754
+ disposers.push(() => jitDispose());
755
+
652
756
  // Static ESR protocol (pi-esr rule 1): byte-identical every turn, so
653
757
  // the model never depends on runtime nudges to know WHEN to use ESR.
654
758
  const esrMethodDispose = systemPrompt.section({
@@ -677,6 +781,39 @@ export function apply(ctx, config) {
677
781
  });
678
782
  disposers.push(disposeTrigger);
679
783
 
784
+ // Context Ledger ticks: one model request completed → charge the injected
785
+ // blocks (held in the prefix) and credit pruned/compacted chars that would
786
+ // otherwise replay in this request. Session teardown folds the tail into
787
+ // the daily rollup. Both listeners are no-ops when the ledger is disabled.
788
+ if (ledger !== null) {
789
+ const disposeLedgerTick = ctx.on("session/event", (session, event) => {
790
+ if (event?.type !== "assistant/message") return;
791
+ ledger.tick(session?.id, wk(session?.header?.cwd));
792
+ });
793
+ disposers.push(disposeLedgerTick);
794
+ const disposeLedgerSession = ctx.on("session/disposed", (session) => {
795
+ ledger.dispose(session?.id);
796
+ });
797
+ disposers.push(disposeLedgerSession);
798
+ }
799
+
800
+ // JIT observers: tool results (error recurrence / file focus) and goal
801
+ // completion. `opened` may be undefined early — the observers no-op.
802
+ if (jit !== null) {
803
+ const disposeJitTool = ctx.on("tools/result", (exec, result) => {
804
+ jit.observeToolResult(exec, result, opened);
805
+ });
806
+ disposers.push(disposeJitTool);
807
+ const disposeJitEvent = ctx.on("session/event", (session, event) => {
808
+ jit.observeEvent(session, event, opened);
809
+ });
810
+ disposers.push(disposeJitEvent);
811
+ const disposeJitSession = ctx.on("session/disposed", (session) => {
812
+ jit.dispose(session?.id);
813
+ });
814
+ disposers.push(disposeJitSession);
815
+ }
816
+
680
817
  // session-end todo auto-sink: a session whose plan still has pending todos
681
818
  // lands them as ESR drafts at its teardown edge, so the plan survives the
682
819
  // session (flip `autoSinkTodosOnEnd` off to opt out). Live-toggleable — the
@@ -765,14 +902,23 @@ export function apply(ctx, config) {
765
902
  // independent of `resultPack`: /stats must be able to prove the L1
766
903
  // mechanism actually fires on the live profile.
767
904
  onPruned: (session, report, boundary) => {
905
+ const workspace = wk(session?.header?.cwd);
768
906
  recordBoundaryPruneFire({
769
907
  home: engramHome(),
770
908
  boundary,
771
909
  report,
772
- workspace: wk(session?.header?.cwd),
910
+ workspace,
911
+ });
912
+ // Ledger: the removed chars stop replaying from the next request;
913
+ // the prefix invalidation is priced with the pre-prune size (the
914
+ // surface is already rewritten when this hook runs).
915
+ const removed = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
916
+ const prefixChars = estimatePrefixChars(session);
917
+ ledger?.notePrune(session?.id, workspace, {
918
+ charsRemoved: removed,
919
+ rewriteChars: prefixChars + removed,
773
920
  });
774
921
  if (resolved.resultPack !== true) return;
775
- const workspace = wk(session?.header?.cwd);
776
922
  archivePrunedResults({
777
923
  dir: l2Dir,
778
924
  session,
@@ -781,15 +927,16 @@ export function apply(ctx, config) {
781
927
  log,
782
928
  });
783
929
  },
784
- });
930
+ }, { allowFire: boundaryGate });
785
931
  disposers.push(disposeBoundaryPrune);
786
932
 
787
- // ResultPack L2 tool (default OFF with L1): `result_recall(callId, offset?)`
788
- // pages back an archived middle. Independent of the memory domain — the
789
- // archive lives on the filesystem. Registers only when enabled; without
790
- // boundayPrune there is nothing to archive, so it self-disables.
933
+ // ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
934
+ // offset?)` pages back an archived middle. Independent of the memory
935
+ // domain — the archive lives on the filesystem. Registers only when
936
+ // enabled; without boundaryPrune there is nothing to archive, so it
937
+ // self-disables.
791
938
  if (resolved.resultPack === true) {
792
- if (resolved.boundaryPrune !== true) {
939
+ if (resolved.boundaryPrune === false) {
793
940
  log?.warn?.("engram result-pack: requires boundaryPrune: true to prune middles — result_recall not registered");
794
941
  } else {
795
942
  const pageChars = Number.isFinite(resolved.resultPackPageChars)
package/lib/jit.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * dsh-engram JIT push (PROPOSAL-evolution §4 v0.6-C).
3
+ *
4
+ * The measured failure mode: pull-only recall is dormant — the model rarely
5
+ * calls `engram_recall` on its own (24 calls / 8 details across 112 real
6
+ * sessions). JIT flips the direction: when a deterministic, high-precision
7
+ * event makes a known memory immediately relevant, append ONE short line to
8
+ * the prompt at that moment:
9
+ *
10
+ * - error recurrence — the same `cmd:` signature fails a second time, and a
11
+ * prior error/procedure memory already exists for it;
12
+ * - file focus — the same file is edited twice in a row, and a memory is
13
+ * anchored to that file (preference > fact/decision);
14
+ * - goal completion — a goal completes/blocks while active ESR tasks remain.
15
+ *
16
+ * Invariants:
17
+ * - append-only per session: a pushed line is never edited or withdrawn, so
18
+ * the prefix changes at most once per push (the ledger prices the rewrite);
19
+ * - no hit, no line (silence beats noise);
20
+ * - hard budget: `maxLines` per session, `maxLineChars` per line;
21
+ * - pure rules, zero LLM, fail-contained.
22
+ *
23
+ * The resume brief (§v0.7) is deliberately not here yet.
24
+ */
25
+
26
+ import { cmdTag } from "./capture.js";
27
+ import { shortId, workspaceKey as wk } from "./util.js";
28
+
29
+ const EDIT_TOOLS = new Set(["write", "str_replace_editor", "edit"]);
30
+ const RUN_TOOLS = new Set(["bash", "shell", "exec"]);
31
+
32
+ function clip(text, max) {
33
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
34
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
35
+ }
36
+
37
+ function basename(p) {
38
+ const parts = String(p ?? "").split("/");
39
+ return parts[parts.length - 1] || String(p ?? "");
40
+ }
41
+
42
+ function dirname(p) {
43
+ const parts = String(p ?? "").split("/");
44
+ parts.pop();
45
+ return parts.join("/");
46
+ }
47
+
48
+ function isMemory(row) {
49
+ return row !== null && typeof row === "object" && typeof row.id === "string";
50
+ }
51
+
52
+ /**
53
+ * @param {object} [opts]
54
+ * @param {number} [opts.maxLines] hard cap per session (default 3)
55
+ * @param {number} [opts.maxLineChars] hard cap per line (default 220)
56
+ * @param {(push: {sessionId: string, workspace: string, session: object, line: string, chars: number, kind: string}) => void} [opts.onPush]
57
+ * @param {object} [opts.log]
58
+ */
59
+ export function createJit({ maxLines = 3, maxLineChars = 220, onPush = null, log = void 0 } = {}) {
60
+ const lineCap = Number.isFinite(maxLines) && maxLines >= 0 ? Math.trunc(maxLines) : 3;
61
+ const charCap = Number.isFinite(maxLineChars) && maxLineChars > 0 ? Math.trunc(maxLineChars) : 220;
62
+ const states = new Map();
63
+ const totals = { pushes: 0, byKind: { error: 0, focus: 0, goal: 0 } };
64
+
65
+ function stateFor(sessionId, workspace) {
66
+ let state = states.get(sessionId);
67
+ if (state === void 0) {
68
+ state = {
69
+ sessionId,
70
+ workspace: typeof workspace === "string" ? workspace : "",
71
+ lines: [],
72
+ seen: new Set(),
73
+ failures: new Map(),
74
+ lastFile: "",
75
+ sameFile: 0,
76
+ focusSent: new Set(),
77
+ goalSent: false,
78
+ };
79
+ states.set(sessionId, state);
80
+ } else if (typeof workspace === "string" && workspace.length > 0) {
81
+ state.workspace = workspace;
82
+ }
83
+ return state;
84
+ }
85
+
86
+ function push(state, session, kind, line) {
87
+ if (lineCap === 0 || state.lines.length >= lineCap) return false;
88
+ const text = clip(line, charCap);
89
+ if (text.length === 0 || state.seen.has(text)) return false;
90
+ state.lines.push(text);
91
+ state.seen.add(text);
92
+ totals.pushes += 1;
93
+ totals.byKind[kind] = (totals.byKind[kind] ?? 0) + 1;
94
+ try {
95
+ onPush?.({
96
+ sessionId: state.sessionId,
97
+ workspace: state.workspace,
98
+ session,
99
+ kind,
100
+ line: text,
101
+ chars: text.length,
102
+ });
103
+ } catch (error) {
104
+ log?.warn?.(`engram jit onPush failed: ${String(error)}`);
105
+ }
106
+ return true;
107
+ }
108
+
109
+ function failLine(state, session, domain, tag) {
110
+ const rows = domain?.searchMemories?.({ workspace: state.workspace, tag, status: "active", limit: 8 }) ?? [];
111
+ const memory = rows.find((row) => isMemory(row) && row.kind === "procedure" && /^fixed:/.test(row.text ?? ""))
112
+ ?? rows.find((row) => isMemory(row) && row.kind === "error");
113
+ if (memory === void 0) return false;
114
+ return push(
115
+ state,
116
+ session,
117
+ "error",
118
+ `[JIT] ${clip(tag, 40)} failed again ×2 — ${memory.kind} #${shortId(memory.id)}: ${clip(memory.text, 92)} · engram_detail ${memory.id}`,
119
+ );
120
+ }
121
+
122
+ function focusLine(state, session, domain, path) {
123
+ if (state.focusSent.has(path)) return false;
124
+ state.focusSent.add(path);
125
+ const rows = domain?.listMemories?.(state.workspace, 300) ?? [];
126
+ const dirTag = `dir:${dirname(path)}`;
127
+ const anchored = rows.filter(
128
+ (row) => isMemory(row)
129
+ && row.status === "active"
130
+ && (row.filePath === path || (Array.isArray(row.tags) && row.tags.includes(dirTag))),
131
+ );
132
+ const memory = anchored.find((row) => row.kind === "preference")
133
+ ?? anchored.find((row) => ["fact", "decision", "procedure"].includes(row.kind));
134
+ if (memory === void 0) return false;
135
+ return push(
136
+ state,
137
+ session,
138
+ "focus",
139
+ `[JIT] focus ${clip(basename(path), 36)} ×2 — ${memory.kind} #${shortId(memory.id)}: ${clip(memory.text, 92)} · engram_detail ${memory.id}`,
140
+ );
141
+ }
142
+
143
+ return {
144
+ /** Observe a tool result; may push an error-recurrence or file-focus line. */
145
+ observeToolResult(exec, result, domain) {
146
+ try {
147
+ const session = exec?.agent?.session;
148
+ const sessionId = session?.id;
149
+ const cwd = session?.header?.cwd;
150
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
151
+ if (typeof cwd !== "string" || cwd.length === 0) return;
152
+ const state = stateFor(sessionId, wk(cwd));
153
+ const name = String(exec?.name ?? "").toLowerCase();
154
+ const args = exec?.arguments ?? {};
155
+
156
+ if (RUN_TOOLS.has(name) && result?.isError === true && typeof args.command === "string") {
157
+ const tag = cmdTag(args.command);
158
+ if (tag === null) return;
159
+ const count = (state.failures.get(tag) ?? 0) + 1;
160
+ state.failures.set(tag, count);
161
+ if (count === 2) failLine(state, session, domain, tag);
162
+ return;
163
+ }
164
+
165
+ if (EDIT_TOOLS.has(name) && result?.isError !== true) {
166
+ const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
167
+ if (path.length === 0) return;
168
+ if (state.lastFile === path) state.sameFile += 1;
169
+ else {
170
+ state.lastFile = path;
171
+ state.sameFile = 1;
172
+ }
173
+ if (state.sameFile === 2) focusLine(state, session, domain, path);
174
+ }
175
+ } catch (error) {
176
+ log?.warn?.(`engram jit tool observe threw: ${String(error)}`);
177
+ }
178
+ },
179
+
180
+ /** Observe a session event; may push a goal-completion line. */
181
+ observeEvent(session, event, domain) {
182
+ try {
183
+ if (event?.type !== "goal/change") return;
184
+ const data = event?.data;
185
+ if (data === null || typeof data !== "object") return;
186
+ if (data.operation !== "complete" && data.operation !== "block") return;
187
+ const goal = data.goal;
188
+ if (goal === null || typeof goal !== "object") return;
189
+ const sessionId = session?.id;
190
+ const cwd = session?.header?.cwd;
191
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
192
+ if (typeof cwd !== "string" || cwd.length === 0) return;
193
+ const state = stateFor(sessionId, wk(cwd));
194
+ if (state.goalSent) return;
195
+ const tasks = domain?.listTasks?.(state.workspace) ?? [];
196
+ const active = tasks.filter((task) => task?.state === "active");
197
+ if (active.length === 0) return;
198
+ const shown = active.slice(0, 2).map((task) => `${task.id} ${clip(task.name, 48)}`).join("; ");
199
+ const more = active.length > 2 ? ` +${active.length - 2}` : "";
200
+ state.goalSent = true;
201
+ push(state, session, "goal", `[JIT] goal ${data.operation} — open: ${shown}${more} · esr_status`);
202
+ } catch (error) {
203
+ log?.warn?.(`engram jit event observe threw: ${String(error)}`);
204
+ }
205
+ },
206
+
207
+ /** The frozen JIT block for this session ("" when nothing was pushed). */
208
+ textFor(session) {
209
+ const sessionId = session?.id;
210
+ if (typeof sessionId !== "string") return "";
211
+ return states.get(sessionId)?.lines.join("\n") ?? "";
212
+ },
213
+
214
+ /** Drop per-session state at teardown. */
215
+ dispose(sessionId) {
216
+ states.delete(sessionId);
217
+ },
218
+
219
+ /** Push counters for /stats + diagnostics. */
220
+ stats() {
221
+ return { ...totals, byKind: { ...totals.byKind }, sessions: states.size };
222
+ },
223
+ };
224
+ }