dsh-engram 0.5.1 → 0.7.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/README.md +180 -39
- package/README.zh.md +149 -40
- package/docs/CONTEXT-GC-GUIDE.zh.md +15 -9
- package/docs/PROPOSAL-evolution.zh.md +708 -0
- package/docs/RELEASE-NOTES-v0.6.0.md +64 -0
- package/docs/RELEASE-NOTES-v0.7.0.md +57 -0
- package/lib/anchor.js +124 -0
- package/lib/api.js +5 -0
- package/lib/boundary-prune.js +27 -1
- package/lib/client.js +92 -2
- package/lib/entity-page.js +200 -0
- package/lib/episode.js +163 -0
- package/lib/index-block.js +74 -4
- package/lib/index.js +247 -19
- package/lib/jit.js +224 -0
- package/lib/ledger.js +458 -0
- package/lib/settings.js +18 -4
- package/lib/store.js +48 -5
- package/lib/tools.js +25 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -27,7 +27,11 @@ 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 {
|
|
30
|
+
import { createLedger, estimatePrefixChars } from "./ledger.js";
|
|
31
|
+
import { createJit } from "./jit.js";
|
|
32
|
+
import { buildEpisode } from "./episode.js";
|
|
33
|
+
import { collectStaleAnchors } from "./anchor.js";
|
|
34
|
+
import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
31
35
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
32
36
|
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
33
37
|
import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
|
|
@@ -92,9 +96,11 @@ const DEFAULTS = {
|
|
|
92
96
|
gcStableRetentionDays: 120,
|
|
93
97
|
// Context GC — the auto-GC that REPLACES DSH's built-in lossy LLM-summary
|
|
94
98
|
// compaction (pi-esr CONTEXT_GC): mechanical eviction + re-fetch pointers,
|
|
95
|
-
// NOT the memory-panel GC above.
|
|
96
|
-
//
|
|
97
|
-
|
|
99
|
+
// NOT the memory-panel GC above. Opt-in since v0.6: rewriting host presets is
|
|
100
|
+
// the highest-maintenance action in the plugin (DSH broke the settings API
|
|
101
|
+
// twice), so the default is the host summarizer; `gcNarrative` off → fully
|
|
102
|
+
// mechanical. Turn this on to replace compaction with pointer summaries.
|
|
103
|
+
gcReplacesCompaction: false,
|
|
98
104
|
gcNarrative: true,
|
|
99
105
|
gcNarrativeMaxTokens: 1024,
|
|
100
106
|
gcNarrativeMaxChars: 4000,
|
|
@@ -109,6 +115,39 @@ const DEFAULTS = {
|
|
|
109
115
|
engramIndexOrder: 40,
|
|
110
116
|
esrMethodOrder: 40.5, // static ESR protocol section (byte-identical every turn)
|
|
111
117
|
esrOrder: 41,
|
|
118
|
+
// Context Ledger (PROPOSAL-evolution §4 v0.6-A): per-session accounting of
|
|
119
|
+
// injected block chars, pruned/compacted savings and prefix-rewrite penalty.
|
|
120
|
+
// In-memory + daily rollup at ~/.dsh/engram/ledger.json; never on the hot
|
|
121
|
+
// path of model calls (one integer add per request). `ledgerCacheDiscount`
|
|
122
|
+
// is the cached-prefix price share (0.1 = cache hit costs 10%, so a rewrite
|
|
123
|
+
// costs 90% of the prefix).
|
|
124
|
+
ledgerEnabled: true,
|
|
125
|
+
ledgerCacheDiscount: 0.1,
|
|
126
|
+
// JIT push (v0.6-C): append at most a few one-line, high-precision hints at
|
|
127
|
+
// deterministic events (error recurrence, file focus, goal completion).
|
|
128
|
+
// Append-only; every push pays a prefix rewrite in the ledger.
|
|
129
|
+
jitEnabled: true,
|
|
130
|
+
jitMaxLines: 3,
|
|
131
|
+
jitMaxLineChars: 220,
|
|
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,
|
|
112
151
|
// decision-point triggers (P0-A): promote a session plan to an ESR task
|
|
113
152
|
// when the pending-todo count is at least minTodosForPromote and exceeds
|
|
114
153
|
// active ESR tasks × todoPromoteRatio (default: pending > active).
|
|
@@ -134,7 +173,8 @@ const DEFAULTS = {
|
|
|
134
173
|
// validation + idempotent; a preset with a custom compaction layout — or
|
|
135
174
|
// none, like `minimal` — is never touched). Off = every preset keeps DSH's
|
|
136
175
|
// default LLM summarizer; the host plane (headless/TUI/base) is unaffected.
|
|
137
|
-
|
|
176
|
+
// Opt-in since v0.6: never rewrite presets unless the user asks.
|
|
177
|
+
autoWebCompaction: false,
|
|
138
178
|
// Memory-to-memory semantics (zero LLM): when ON, an entity-anchored write
|
|
139
179
|
// that carries a replacement/negation cue ("改用 / no longer / switched…")
|
|
140
180
|
// auto-marks the matching older same-entity+kind memory as superseded.
|
|
@@ -145,9 +185,13 @@ const DEFAULTS = {
|
|
|
145
185
|
// (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
|
|
146
186
|
// todo-progress boundaries cover 79% of replay exposure; the repayment
|
|
147
187
|
// gate below lifted cache-aware net savings 12–32% on every threshold.
|
|
148
|
-
// Default
|
|
149
|
-
// pruner service is feature-detected — absent ⇒ disabled with one log.
|
|
150
|
-
boundaryPrune:
|
|
188
|
+
// Default ON since v0.6 (PROPOSAL-evolution §3.5): `false` is the opt-out;
|
|
189
|
+
// the pruner service is feature-detected — absent ⇒ disabled with one log.
|
|
190
|
+
boundaryPrune: true,
|
|
191
|
+
// Adaptive off-switch (v0.6-B): the Context Ledger vetoes further fires in a
|
|
192
|
+
// workspace whose own accounting is net negative after real fires. Evidence
|
|
193
|
+
// can only turn pruning OFF — never on. `false` = repayment gate only.
|
|
194
|
+
boundaryPruneAdaptive: true,
|
|
151
195
|
// Repayment gate: prune only while the session is expected to run at
|
|
152
196
|
// least this many more requests (empirical workspace prior, see
|
|
153
197
|
// lib/boundary-prune.js). 50 = L0 sweep optimum.
|
|
@@ -170,9 +214,10 @@ const DEFAULTS = {
|
|
|
170
214
|
// append a `[middle archived: result_recall(<callId>) 可取]` handle tail to
|
|
171
215
|
// each pruned node; register `result_recall(callId, offset?)` for paged
|
|
172
216
|
// recall — the full ObservationPack pattern (SoL-Pi mech ③; paired
|
|
173
|
-
// EdgeBench −23.58%). Requires
|
|
174
|
-
// archive (L2 without L1 is a no-op with a log line). Default
|
|
175
|
-
|
|
217
|
+
// EdgeBench −23.58%). Requires boundary pruning to have anything to
|
|
218
|
+
// archive (L2 without L1 is a no-op with a log line). Default ON since v0.6:
|
|
219
|
+
// default pruning must be lossless (the archive is the recovery path).
|
|
220
|
+
resultPack: true,
|
|
176
221
|
// result_recall page budget (chars per page). 4000 fits a typical model
|
|
177
222
|
// context band without re-materializing huge results whole.
|
|
178
223
|
resultPackPageChars: 4000,
|
|
@@ -304,6 +349,55 @@ export function apply(ctx, config) {
|
|
|
304
349
|
// todo_write promote hook. Process-local; fed by a tools/result listener.
|
|
305
350
|
const recorder = makeTriggerRecorder(resolved);
|
|
306
351
|
|
|
352
|
+
// Context Ledger: created before the service object so /stats can read it.
|
|
353
|
+
// `ledgerEnabled:false` leaves a null instance (zero listeners, zero writes).
|
|
354
|
+
const ledger = resolved.ledgerEnabled === false
|
|
355
|
+
? null
|
|
356
|
+
: createLedger({
|
|
357
|
+
home: engramHome(),
|
|
358
|
+
cacheDiscount: resolved.ledgerCacheDiscount,
|
|
359
|
+
log,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Ledger-driven off-switch for BoundaryPrune (PROPOSAL-evolution v0.6-B):
|
|
363
|
+
// per-workspace, 30s-cached, reads the same summary /stats exposes. Evidence
|
|
364
|
+
// can only turn pruning OFF. No ledger → allow (repayment gate still runs).
|
|
365
|
+
const boundaryGate = (() => {
|
|
366
|
+
const cache = new Map();
|
|
367
|
+
const TTL_MS = 30_000;
|
|
368
|
+
return (workspace) => {
|
|
369
|
+
if (ledger === null) return true;
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
const hit = cache.get(workspace);
|
|
372
|
+
if (hit !== void 0 && now - hit.at < TTL_MS) return hit.ok;
|
|
373
|
+
let ok = true;
|
|
374
|
+
try {
|
|
375
|
+
const summary = ledger.summary();
|
|
376
|
+
const bucket = summary.byWorkspace[workspace] ?? summary.totals;
|
|
377
|
+
ok = !(bucket.rewriteCount >= 3 && bucket.netChars < 0);
|
|
378
|
+
} catch {
|
|
379
|
+
ok = true; // accounting must never disable the mechanism by accident
|
|
380
|
+
}
|
|
381
|
+
if (cache.size > 64) cache.clear();
|
|
382
|
+
cache.set(workspace, { at: now, ok });
|
|
383
|
+
return ok;
|
|
384
|
+
};
|
|
385
|
+
})();
|
|
386
|
+
|
|
387
|
+
// JIT push (v0.6-C): append-only hints, priced through the ledger.
|
|
388
|
+
const jit = resolved.jitEnabled === false
|
|
389
|
+
? null
|
|
390
|
+
: createJit({
|
|
391
|
+
maxLines: resolved.jitMaxLines,
|
|
392
|
+
maxLineChars: resolved.jitMaxLineChars,
|
|
393
|
+
log,
|
|
394
|
+
onPush: ({ session, workspace, chars }) => {
|
|
395
|
+
ledger?.noteRewrite(session?.id, workspace, {
|
|
396
|
+
chars: estimatePrefixChars(session) + chars,
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
307
401
|
const service = {
|
|
308
402
|
config: resolved,
|
|
309
403
|
getDomain,
|
|
@@ -319,6 +413,10 @@ export function apply(ctx, config) {
|
|
|
319
413
|
/** SoL-Pi activation telemetry (M15/C13): durable boundary-prune fires +
|
|
320
414
|
* [RECALL] injection funnel, read at request time (readPackTelemetry). */
|
|
321
415
|
packTelemetry: () => readPackTelemetry({ home: engramHome() }),
|
|
416
|
+
/** Context Ledger snapshot: spent vs saved context, net of cache rewrites. */
|
|
417
|
+
ledger: () => (ledger === null ? null : ledger.summary()),
|
|
418
|
+
/** JIT push counters (per process; /stats diagnostic surface). */
|
|
419
|
+
jitStats: () => (jit === null ? null : jit.stats()),
|
|
322
420
|
/** Last GC sweep totals (GUI overview observability). */
|
|
323
421
|
gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
|
|
324
422
|
/** Live Context GC status (API overview + durable status file). */
|
|
@@ -417,7 +515,21 @@ export function apply(ctx, config) {
|
|
|
417
515
|
let pendingRecall = false;
|
|
418
516
|
try {
|
|
419
517
|
const workspace = resolvePath(cwd);
|
|
518
|
+
// Anchor revalidation (v0.7-C): a bounded stat pass at session start.
|
|
519
|
+
// Stale rows are tagged (for the GUI/GC) and excluded from the render
|
|
520
|
+
// below via memoryIsStale — never deleted, still re-fetchable.
|
|
521
|
+
if (resolved.anchorRevalidate !== false) {
|
|
522
|
+
for (const row of collectStaleAnchors(opened, workspace, { limit: 40 })) {
|
|
523
|
+
void opened.tagMemory(workspace, row.id, "stale")?.catch?.(() => null);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
420
526
|
block = renderIndex(opened, workspace, cwd, resolved);
|
|
527
|
+
// Resume brief (v0.7-D): the newest recent episode, frozen with the
|
|
528
|
+
// [ENGRAM] block. Empty when there is no recent episode.
|
|
529
|
+
if (resolved.resumeBriefEnabled !== false) {
|
|
530
|
+
const resume = renderResumeBrief(opened, workspace, resolved);
|
|
531
|
+
if (resume.length > 0) block = `${block}\n${resume}`.trim();
|
|
532
|
+
}
|
|
421
533
|
// Host-side session-start auto-recall (方案 B): append the [RECALL]
|
|
422
534
|
// block keyed on the session's FIRST user message. The cache only freezes
|
|
423
535
|
// once there is a query to key on — if the user text isn't in the events
|
|
@@ -452,6 +564,7 @@ export function apply(ctx, config) {
|
|
|
452
564
|
// [RECALL] block.
|
|
453
565
|
if (pendingRecall) return block;
|
|
454
566
|
sessionCache.set(session, block);
|
|
567
|
+
ledger?.noteInjection(session.id, wk(cwd), "index", block.length);
|
|
455
568
|
return block;
|
|
456
569
|
};
|
|
457
570
|
|
|
@@ -500,9 +613,22 @@ export function apply(ctx, config) {
|
|
|
500
613
|
} catch (error) {
|
|
501
614
|
log?.warn?.(`engram esr render failed: ${String(error)}`);
|
|
502
615
|
}
|
|
616
|
+
if (block.length > 0) ledger?.noteInjection(session.id, wk(cwd), "esr", block.length);
|
|
503
617
|
return block;
|
|
504
618
|
};
|
|
505
619
|
|
|
620
|
+
// JIT block: append-only lines accumulated by lib/jit.js (never edited).
|
|
621
|
+
const jitFor = (agent) => {
|
|
622
|
+
const session = agent?.session;
|
|
623
|
+
if (session === void 0) return "";
|
|
624
|
+
const text = jit === null ? "" : jit.textFor(session);
|
|
625
|
+
const cwd = session.header?.cwd;
|
|
626
|
+
if (text.length > 0 && typeof cwd === "string" && cwd.length > 0) {
|
|
627
|
+
ledger?.noteInjection(session.id, wk(cwd), "jit", text.length);
|
|
628
|
+
}
|
|
629
|
+
return text;
|
|
630
|
+
};
|
|
631
|
+
|
|
506
632
|
ctx.effect(() => {
|
|
507
633
|
const disposers = [];
|
|
508
634
|
|
|
@@ -649,6 +775,18 @@ export function apply(ctx, config) {
|
|
|
649
775
|
});
|
|
650
776
|
disposers.push(() => indexDispose());
|
|
651
777
|
|
|
778
|
+
// JIT push block — empty until a deterministic event fires, then
|
|
779
|
+
// append-only (prefix changes at most once per push).
|
|
780
|
+
const jitDispose = systemPrompt.section({
|
|
781
|
+
name: "engram:jit",
|
|
782
|
+
order: resolved.engramJitOrder,
|
|
783
|
+
text: (assemblyContext) => {
|
|
784
|
+
const agent = assemblyContext?.agent;
|
|
785
|
+
return agent === void 0 ? "" : jitFor(agent);
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
disposers.push(() => jitDispose());
|
|
789
|
+
|
|
652
790
|
// Static ESR protocol (pi-esr rule 1): byte-identical every turn, so
|
|
653
791
|
// the model never depends on runtime nudges to know WHEN to use ESR.
|
|
654
792
|
const esrMethodDispose = systemPrompt.section({
|
|
@@ -677,6 +815,39 @@ export function apply(ctx, config) {
|
|
|
677
815
|
});
|
|
678
816
|
disposers.push(disposeTrigger);
|
|
679
817
|
|
|
818
|
+
// Context Ledger ticks: one model request completed → charge the injected
|
|
819
|
+
// blocks (held in the prefix) and credit pruned/compacted chars that would
|
|
820
|
+
// otherwise replay in this request. Session teardown folds the tail into
|
|
821
|
+
// the daily rollup. Both listeners are no-ops when the ledger is disabled.
|
|
822
|
+
if (ledger !== null) {
|
|
823
|
+
const disposeLedgerTick = ctx.on("session/event", (session, event) => {
|
|
824
|
+
if (event?.type !== "assistant/message") return;
|
|
825
|
+
ledger.tick(session?.id, wk(session?.header?.cwd));
|
|
826
|
+
});
|
|
827
|
+
disposers.push(disposeLedgerTick);
|
|
828
|
+
const disposeLedgerSession = ctx.on("session/disposed", (session) => {
|
|
829
|
+
ledger.dispose(session?.id);
|
|
830
|
+
});
|
|
831
|
+
disposers.push(disposeLedgerSession);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// JIT observers: tool results (error recurrence / file focus) and goal
|
|
835
|
+
// completion. `opened` may be undefined early — the observers no-op.
|
|
836
|
+
if (jit !== null) {
|
|
837
|
+
const disposeJitTool = ctx.on("tools/result", (exec, result) => {
|
|
838
|
+
jit.observeToolResult(exec, result, opened);
|
|
839
|
+
});
|
|
840
|
+
disposers.push(disposeJitTool);
|
|
841
|
+
const disposeJitEvent = ctx.on("session/event", (session, event) => {
|
|
842
|
+
jit.observeEvent(session, event, opened);
|
|
843
|
+
});
|
|
844
|
+
disposers.push(disposeJitEvent);
|
|
845
|
+
const disposeJitSession = ctx.on("session/disposed", (session) => {
|
|
846
|
+
jit.dispose(session?.id);
|
|
847
|
+
});
|
|
848
|
+
disposers.push(disposeJitSession);
|
|
849
|
+
}
|
|
850
|
+
|
|
680
851
|
// session-end todo auto-sink: a session whose plan still has pending todos
|
|
681
852
|
// lands them as ESR drafts at its teardown edge, so the plan survives the
|
|
682
853
|
// session (flip `autoSinkTodosOnEnd` off to opt out). Live-toggleable — the
|
|
@@ -709,6 +880,53 @@ export function apply(ctx, config) {
|
|
|
709
880
|
});
|
|
710
881
|
disposers.push(disposeSink);
|
|
711
882
|
|
|
883
|
+
// session-end episode digest (v0.7-A): one compact, zero-LLM record of the
|
|
884
|
+
// session's files/commands/failures/decisions/open loops. Never indexed;
|
|
885
|
+
// capped per workspace; the resume brief reads the newest recent one.
|
|
886
|
+
const disposeEpisode = ctx.on("session/disposed", (session) => {
|
|
887
|
+
if (resolved.episodeEnabled === false) return;
|
|
888
|
+
const cwd = session?.header?.cwd;
|
|
889
|
+
const sessionId = session?.id;
|
|
890
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
891
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
892
|
+
const episode = buildEpisode(session);
|
|
893
|
+
if (episode === null) return;
|
|
894
|
+
const workspace = wk(cwd);
|
|
895
|
+
const maxEpisodes = Number.isFinite(resolved.episodeMaxPerWorkspace)
|
|
896
|
+
? Math.max(1, Math.trunc(resolved.episodeMaxPerWorkspace))
|
|
897
|
+
: 20;
|
|
898
|
+
service
|
|
899
|
+
.getDomain()
|
|
900
|
+
.then(async (domain) => {
|
|
901
|
+
await domain.storeMemory(
|
|
902
|
+
{
|
|
903
|
+
workspace,
|
|
904
|
+
kind: "episode",
|
|
905
|
+
text: episode.text,
|
|
906
|
+
tags: episode.tags,
|
|
907
|
+
signal: episode.signal,
|
|
908
|
+
filePath: null,
|
|
909
|
+
sessionId,
|
|
910
|
+
seq: Array.isArray(session.events) ? session.events.length : 0,
|
|
911
|
+
},
|
|
912
|
+
resolved,
|
|
913
|
+
);
|
|
914
|
+
const episodes = domain
|
|
915
|
+
.listMemories(workspace, 200)
|
|
916
|
+
.filter((memory) => memory.kind === "episode");
|
|
917
|
+
for (const old of episodes.slice(maxEpisodes)) {
|
|
918
|
+
try {
|
|
919
|
+
await domain.archiveMemory(workspace, old.id);
|
|
920
|
+
} catch {
|
|
921
|
+
// best-effort retention
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
log?.info?.(`engram episode: stored ${episode.text.length} chars in ${workspace}`);
|
|
925
|
+
})
|
|
926
|
+
.catch((error) => log?.warn?.(`engram episode failed: ${String(error)}`));
|
|
927
|
+
});
|
|
928
|
+
disposers.push(disposeEpisode);
|
|
929
|
+
|
|
712
930
|
// zero-LLM auto-capture
|
|
713
931
|
if (resolved.autoCapture) {
|
|
714
932
|
// The capture handler needs a storeMemory face, but the domain opens
|
|
@@ -765,14 +983,23 @@ export function apply(ctx, config) {
|
|
|
765
983
|
// independent of `resultPack`: /stats must be able to prove the L1
|
|
766
984
|
// mechanism actually fires on the live profile.
|
|
767
985
|
onPruned: (session, report, boundary) => {
|
|
986
|
+
const workspace = wk(session?.header?.cwd);
|
|
768
987
|
recordBoundaryPruneFire({
|
|
769
988
|
home: engramHome(),
|
|
770
989
|
boundary,
|
|
771
990
|
report,
|
|
772
|
-
workspace
|
|
991
|
+
workspace,
|
|
992
|
+
});
|
|
993
|
+
// Ledger: the removed chars stop replaying from the next request;
|
|
994
|
+
// the prefix invalidation is priced with the pre-prune size (the
|
|
995
|
+
// surface is already rewritten when this hook runs).
|
|
996
|
+
const removed = Number.isFinite(report?.charsRemoved) ? report.charsRemoved : 0;
|
|
997
|
+
const prefixChars = estimatePrefixChars(session);
|
|
998
|
+
ledger?.notePrune(session?.id, workspace, {
|
|
999
|
+
charsRemoved: removed,
|
|
1000
|
+
rewriteChars: prefixChars + removed,
|
|
773
1001
|
});
|
|
774
1002
|
if (resolved.resultPack !== true) return;
|
|
775
|
-
const workspace = wk(session?.header?.cwd);
|
|
776
1003
|
archivePrunedResults({
|
|
777
1004
|
dir: l2Dir,
|
|
778
1005
|
session,
|
|
@@ -781,15 +1008,16 @@ export function apply(ctx, config) {
|
|
|
781
1008
|
log,
|
|
782
1009
|
});
|
|
783
1010
|
},
|
|
784
|
-
});
|
|
1011
|
+
}, { allowFire: boundaryGate });
|
|
785
1012
|
disposers.push(disposeBoundaryPrune);
|
|
786
1013
|
|
|
787
|
-
// ResultPack L2 tool (default
|
|
788
|
-
// pages back an archived middle. Independent of the memory
|
|
789
|
-
// archive lives on the filesystem. Registers only when
|
|
790
|
-
//
|
|
1014
|
+
// ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
|
|
1015
|
+
// offset?)` pages back an archived middle. Independent of the memory
|
|
1016
|
+
// domain — the archive lives on the filesystem. Registers only when
|
|
1017
|
+
// enabled; without boundaryPrune there is nothing to archive, so it
|
|
1018
|
+
// self-disables.
|
|
791
1019
|
if (resolved.resultPack === true) {
|
|
792
|
-
if (resolved.boundaryPrune
|
|
1020
|
+
if (resolved.boundaryPrune === false) {
|
|
793
1021
|
log?.warn?.("engram result-pack: requires boundaryPrune: true to prune middles — result_recall not registered");
|
|
794
1022
|
} else {
|
|
795
1023
|
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
|
+
}
|