dsh-engram 0.5.1 → 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/README.md +118 -39
- package/README.zh.md +101 -40
- package/docs/CONTEXT-GC-GUIDE.zh.md +15 -9
- package/docs/PROPOSAL-evolution.zh.md +684 -0
- package/docs/RELEASE-NOTES-v0.6.0.md +64 -0
- package/lib/api.js +5 -0
- package/lib/boundary-prune.js +27 -1
- package/lib/client.js +88 -2
- package/lib/index.js +165 -18
- package/lib/jit.js +224 -0
- package/lib/ledger.js +458 -0
- package/lib/settings.js +12 -4
- package/package.json +1 -1
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
|
+
}
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Ledger — the accounting layer of the "context portfolio"
|
|
3
|
+
* (docs/PROPOSAL-evolution.zh.md §4 v0.6-A).
|
|
4
|
+
*
|
|
5
|
+
* Every engram mechanism either spends context (injected index / recall / ESR
|
|
6
|
+
* blocks, held in the prompt prefix for the rest of the session) or saves it
|
|
7
|
+
* (pruned / compacted tool results that stop replaying). The ledger turns that
|
|
8
|
+
* into one auditable number per session, workspace and day:
|
|
9
|
+
*
|
|
10
|
+
* net = avoided − injected − rewriteChars × (1 − cacheDiscount)
|
|
11
|
+
*
|
|
12
|
+
* where `rewriteChars` is the prefix size invalidated by a prune (KV cache
|
|
13
|
+
* break). `cacheDiscount` is the fraction a cached prefix would have cost
|
|
14
|
+
* (0.1 = 10%, the conservative L0 assumption), so the penalty is the
|
|
15
|
+
* non-discounted remainder.
|
|
16
|
+
*
|
|
17
|
+
* Zero LLM, deterministic, fail-contained. Two layers:
|
|
18
|
+
* - live per-session accumulator, ticked by `assistant/message` events
|
|
19
|
+
* - durable daily rollup at `~/.dsh/engram/ledger.json`, delta-flushed so a
|
|
20
|
+
* restart loses at most `flushEvery` requests of accounting
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT here: model-quality deltas, actual provider billing, and
|
|
23
|
+
* compaction savings whose before/after size the host does not expose yet.
|
|
24
|
+
* Those stay labelled assumptions, never invented numbers.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
const FILE_NAME = "ledger.json";
|
|
32
|
+
/** Matches the estimate used by the GUI (`chars / 4`). */
|
|
33
|
+
const CHARS_PER_TOKEN = 4;
|
|
34
|
+
const DEFAULT_CACHE_DISCOUNT = 0.1;
|
|
35
|
+
const DEFAULT_FLUSH_EVERY = 20;
|
|
36
|
+
|
|
37
|
+
/** Default ledger file under the engram home. */
|
|
38
|
+
export function ledgerFile(home = path.join(os.homedir(), ".dsh", "engram")) {
|
|
39
|
+
return path.join(home, FILE_NAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Local YYYY-MM-DD (calendar days for the user, mirrors usage.dayKey). */
|
|
43
|
+
export function ledgerDayKey(now = Date.now()) {
|
|
44
|
+
const d = new Date(now);
|
|
45
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
46
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clampDiscount(value) {
|
|
50
|
+
if (!Number.isFinite(value)) return DEFAULT_CACHE_DISCOUNT;
|
|
51
|
+
return Math.min(1, Math.max(0, value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function count(value) {
|
|
55
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyBucket() {
|
|
59
|
+
return {
|
|
60
|
+
sessions: 0,
|
|
61
|
+
requests: 0,
|
|
62
|
+
injectedChars: 0,
|
|
63
|
+
avoidedChars: 0,
|
|
64
|
+
rewriteCount: 0,
|
|
65
|
+
rewriteChars: 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function addBucket(target, delta) {
|
|
70
|
+
target.sessions += count(delta.sessions);
|
|
71
|
+
target.requests += count(delta.requests);
|
|
72
|
+
target.injectedChars += count(delta.injectedChars);
|
|
73
|
+
target.avoidedChars += count(delta.avoidedChars);
|
|
74
|
+
target.rewriteCount += count(delta.rewriteCount);
|
|
75
|
+
target.rewriteChars += count(delta.rewriteChars);
|
|
76
|
+
return target;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function deltaOf(session) {
|
|
80
|
+
const f = session.flushed;
|
|
81
|
+
return {
|
|
82
|
+
requests: session.requests - f.requests,
|
|
83
|
+
injectedChars: session.injectedChars - f.injectedChars,
|
|
84
|
+
avoidedChars: session.avoidedChars - f.avoidedChars,
|
|
85
|
+
rewriteCount: session.rewriteCount - f.rewriteCount,
|
|
86
|
+
rewriteChars: session.rewriteChars - f.rewriteChars,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isEmptyDelta(delta) {
|
|
91
|
+
return (
|
|
92
|
+
delta.requests === 0 &&
|
|
93
|
+
delta.injectedChars === 0 &&
|
|
94
|
+
delta.avoidedChars === 0 &&
|
|
95
|
+
delta.rewriteCount === 0 &&
|
|
96
|
+
delta.rewriteChars === 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Finalize one bucket with the net-token arithmetic (pure). */
|
|
101
|
+
export function finalizeLedgerBucket(bucket, cacheDiscount = DEFAULT_CACHE_DISCOUNT) {
|
|
102
|
+
const discount = clampDiscount(cacheDiscount);
|
|
103
|
+
const injectedChars = count(bucket.injectedChars);
|
|
104
|
+
const avoidedChars = count(bucket.avoidedChars);
|
|
105
|
+
const rewriteChars = count(bucket.rewriteChars);
|
|
106
|
+
const netChars = avoidedChars - injectedChars - Math.round(rewriteChars * (1 - discount));
|
|
107
|
+
return {
|
|
108
|
+
sessions: count(bucket.sessions),
|
|
109
|
+
requests: count(bucket.requests),
|
|
110
|
+
injectedChars,
|
|
111
|
+
avoidedChars,
|
|
112
|
+
rewriteCount: count(bucket.rewriteCount),
|
|
113
|
+
rewriteChars,
|
|
114
|
+
injectedTokens: Math.ceil(injectedChars / CHARS_PER_TOKEN),
|
|
115
|
+
avoidedTokens: Math.ceil(avoidedChars / CHARS_PER_TOKEN),
|
|
116
|
+
netChars,
|
|
117
|
+
netTokens: Math.round(netChars / CHARS_PER_TOKEN),
|
|
118
|
+
rewritesCostChars: Math.round(rewriteChars * (1 - discount)),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readPersisted(file) {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
125
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
126
|
+
if (parsed.version !== 1) return null;
|
|
127
|
+
if (parsed.workspaces === null || typeof parsed.workspaces !== "object") return null;
|
|
128
|
+
return parsed;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Atomic-ish write: tmp + rename, so a crash never leaves a torn file. */
|
|
135
|
+
function writeJsonAtomic(file, payload) {
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
138
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
139
|
+
fs.writeFileSync(tmp, `${JSON.stringify(payload)}\n`, "utf8");
|
|
140
|
+
fs.renameSync(tmp, file);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
// best-effort accounting — never throw into the session pipeline
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function contentChars(content) {
|
|
149
|
+
if (!Array.isArray(content)) return 0;
|
|
150
|
+
let chars = 0;
|
|
151
|
+
for (const block of content) {
|
|
152
|
+
if (block === null || typeof block !== "object") continue;
|
|
153
|
+
if (typeof block.text === "string") chars += [...block.text].length;
|
|
154
|
+
}
|
|
155
|
+
return chars;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Estimate the live prefix size a prune invalidates (in Unicode code points).
|
|
160
|
+
*
|
|
161
|
+
* Walks the session surface through the same accessors the host pruner uses
|
|
162
|
+
* (`surface.nodes` → `eventAt` → `deriveEventMessage`). Fail-contained: an
|
|
163
|
+
* unknown session shape yields 0, which makes the ledger conservative about
|
|
164
|
+
* claiming savings.
|
|
165
|
+
*/
|
|
166
|
+
export function estimatePrefixChars(session) {
|
|
167
|
+
try {
|
|
168
|
+
const seqs = session?.surface?.nodes;
|
|
169
|
+
if (seqs === void 0 || seqs === null) return 0;
|
|
170
|
+
if (typeof session.eventAt !== "function" || typeof session.deriveEventMessage !== "function") return 0;
|
|
171
|
+
let chars = 0;
|
|
172
|
+
for (const seq of [...seqs]) {
|
|
173
|
+
const event = session.eventAt(seq);
|
|
174
|
+
if (event === void 0 || event === null) continue;
|
|
175
|
+
const message = session.deriveEventMessage(event);
|
|
176
|
+
chars += contentChars(message?.content);
|
|
177
|
+
}
|
|
178
|
+
return chars;
|
|
179
|
+
} catch {
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create the in-memory ledger + durable rollup writer.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} [opts]
|
|
188
|
+
* @param {string} [opts.home] engram home (defaults to ~/.dsh/engram)
|
|
189
|
+
* @param {number} [opts.cacheDiscount] cached-prefix price share (0..1)
|
|
190
|
+
* @param {number} [opts.flushEvery] requests between durable flushes
|
|
191
|
+
* @param {boolean} [opts.persist] false = pure in-memory (tests, dry runs)
|
|
192
|
+
* @param {() => number} [opts.now] clock injection for tests
|
|
193
|
+
* @param {object} [opts.log] optional logger (warn only)
|
|
194
|
+
*/
|
|
195
|
+
export function createLedger({
|
|
196
|
+
home = path.join(os.homedir(), ".dsh", "engram"),
|
|
197
|
+
cacheDiscount = DEFAULT_CACHE_DISCOUNT,
|
|
198
|
+
flushEvery = DEFAULT_FLUSH_EVERY,
|
|
199
|
+
persist = true,
|
|
200
|
+
now = Date.now,
|
|
201
|
+
log = void 0,
|
|
202
|
+
} = {}) {
|
|
203
|
+
const file = ledgerFile(home);
|
|
204
|
+
const discount = clampDiscount(cacheDiscount);
|
|
205
|
+
const every = Number.isFinite(flushEvery) && flushEvery > 0 ? Math.trunc(flushEvery) : 0;
|
|
206
|
+
const stored = persist ? readPersisted(file) : null;
|
|
207
|
+
const state = stored ?? { version: 1, updatedAt: 0, workspaces: {} };
|
|
208
|
+
const sessions = new Map();
|
|
209
|
+
|
|
210
|
+
function ensure(sessionId, workspace) {
|
|
211
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return null;
|
|
212
|
+
let session = sessions.get(sessionId);
|
|
213
|
+
if (session === void 0) {
|
|
214
|
+
session = {
|
|
215
|
+
sessionId,
|
|
216
|
+
workspace: typeof workspace === "string" ? workspace : "",
|
|
217
|
+
indexChars: 0,
|
|
218
|
+
esrChars: 0,
|
|
219
|
+
jitChars: 0,
|
|
220
|
+
requests: 0,
|
|
221
|
+
injectedChars: 0,
|
|
222
|
+
avoidedChars: 0,
|
|
223
|
+
prunedChars: 0,
|
|
224
|
+
compactedChars: 0,
|
|
225
|
+
rewriteCount: 0,
|
|
226
|
+
rewriteChars: 0,
|
|
227
|
+
flushed: { requests: 0, injectedChars: 0, avoidedChars: 0, rewriteCount: 0, rewriteChars: 0 },
|
|
228
|
+
firstFlush: false,
|
|
229
|
+
};
|
|
230
|
+
sessions.set(sessionId, session);
|
|
231
|
+
} else if (typeof workspace === "string" && workspace.length > 0) {
|
|
232
|
+
session.workspace = workspace;
|
|
233
|
+
}
|
|
234
|
+
return session;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bucketFor(workspace, day) {
|
|
238
|
+
const ws = typeof workspace === "string" && workspace.length > 0 ? workspace : "(unknown)";
|
|
239
|
+
const workspaces = state.workspaces;
|
|
240
|
+
if (workspaces[ws] === void 0 || workspaces[ws] === null || typeof workspaces[ws] !== "object") {
|
|
241
|
+
workspaces[ws] = {};
|
|
242
|
+
}
|
|
243
|
+
const days = workspaces[ws];
|
|
244
|
+
if (days[day] === void 0 || days[day] === null || typeof days[day] !== "object") {
|
|
245
|
+
days[day] = emptyBucket();
|
|
246
|
+
} else {
|
|
247
|
+
days[day] = { ...emptyBucket(), ...days[day] };
|
|
248
|
+
}
|
|
249
|
+
return days[day];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function flushSession(session) {
|
|
253
|
+
if (session === null || session === void 0) return false;
|
|
254
|
+
const delta = deltaOf(session);
|
|
255
|
+
if (isEmptyDelta(delta)) return false;
|
|
256
|
+
try {
|
|
257
|
+
const day = ledgerDayKey(now());
|
|
258
|
+
const bucket = bucketFor(session.workspace, day);
|
|
259
|
+
if (session.firstFlush === false) {
|
|
260
|
+
bucket.sessions += 1;
|
|
261
|
+
session.firstFlush = true;
|
|
262
|
+
}
|
|
263
|
+
addBucket(bucket, delta);
|
|
264
|
+
session.flushed = {
|
|
265
|
+
requests: session.requests,
|
|
266
|
+
injectedChars: session.injectedChars,
|
|
267
|
+
avoidedChars: session.avoidedChars,
|
|
268
|
+
rewriteCount: session.rewriteCount,
|
|
269
|
+
rewriteChars: session.rewriteChars,
|
|
270
|
+
};
|
|
271
|
+
state.updatedAt = now();
|
|
272
|
+
if (persist) writeJsonAtomic(file, state);
|
|
273
|
+
return true;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
log?.warn?.(`engram ledger flush failed: ${String(error)}`);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function flush() {
|
|
281
|
+
for (const session of sessions.values()) flushSession(session);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
/** Record the latest injected block size for one component. */
|
|
286
|
+
noteInjection(sessionId, workspace, component, chars) {
|
|
287
|
+
try {
|
|
288
|
+
const session = ensure(sessionId, workspace);
|
|
289
|
+
if (session === null) return;
|
|
290
|
+
const size = count(chars);
|
|
291
|
+
if (component === "esr") session.esrChars = size;
|
|
292
|
+
else if (component === "jit") session.jitChars = size;
|
|
293
|
+
else session.indexChars = size;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
log?.warn?.(`engram ledger noteInjection failed: ${String(error)}`);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/** One model request completed: charge injected chars, credit savings. */
|
|
300
|
+
tick(sessionId, workspace) {
|
|
301
|
+
try {
|
|
302
|
+
const session = ensure(sessionId, workspace);
|
|
303
|
+
if (session === null) return;
|
|
304
|
+
session.requests += 1;
|
|
305
|
+
session.injectedChars += session.indexChars + session.esrChars + session.jitChars;
|
|
306
|
+
session.avoidedChars += session.prunedChars + session.compactedChars;
|
|
307
|
+
if (every > 0 && session.requests % every === 0) flushSession(session);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
log?.warn?.(`engram ledger tick failed: ${String(error)}`);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/** A prune fired: its removed chars stop replaying from the next request. */
|
|
314
|
+
notePrune(sessionId, workspace, { charsRemoved = 0, rewriteChars = 0 } = {}) {
|
|
315
|
+
try {
|
|
316
|
+
const session = ensure(sessionId, workspace);
|
|
317
|
+
if (session === null) return;
|
|
318
|
+
session.prunedChars += count(charsRemoved);
|
|
319
|
+
session.rewriteCount += 1;
|
|
320
|
+
session.rewriteChars += count(rewriteChars);
|
|
321
|
+
flushSession(session);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
log?.warn?.(`engram ledger notePrune failed: ${String(error)}`);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/** A compaction replaced history: the saved chars stop replaying too. */
|
|
328
|
+
noteCompaction(sessionId, workspace, { charsSaved = 0 } = {}) {
|
|
329
|
+
try {
|
|
330
|
+
const session = ensure(sessionId, workspace);
|
|
331
|
+
if (session === null) return;
|
|
332
|
+
session.compactedChars += count(charsSaved);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
log?.warn?.(`engram ledger noteCompaction failed: ${String(error)}`);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A prefix rewrite that is not a prune (e.g. a JIT push appended to the
|
|
340
|
+
* prompt): charge the full prefix invalidation, credit nothing.
|
|
341
|
+
*/
|
|
342
|
+
noteRewrite(sessionId, workspace, { chars = 0 } = {}) {
|
|
343
|
+
try {
|
|
344
|
+
const session = ensure(sessionId, workspace);
|
|
345
|
+
if (session === null) return;
|
|
346
|
+
session.rewriteCount += 1;
|
|
347
|
+
session.rewriteChars += count(chars);
|
|
348
|
+
flushSession(session);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
log?.warn?.(`engram ledger noteRewrite failed: ${String(error)}`);
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/** Session ended: fold the tail into the durable rollup and forget it. */
|
|
355
|
+
dispose(sessionId) {
|
|
356
|
+
try {
|
|
357
|
+
const session = sessions.get(sessionId);
|
|
358
|
+
if (session === void 0) return;
|
|
359
|
+
flushSession(session);
|
|
360
|
+
sessions.delete(sessionId);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
log?.warn?.(`engram ledger dispose failed: ${String(error)}`);
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
flush,
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Read the ledger: persisted rollup + unflushed live deltas. Pure (never
|
|
370
|
+
* mutates state), so /stats can call it on every request.
|
|
371
|
+
*/
|
|
372
|
+
summary() {
|
|
373
|
+
const byWorkspace = {};
|
|
374
|
+
const byDay = {};
|
|
375
|
+
|
|
376
|
+
// persisted floor
|
|
377
|
+
for (const [ws, days] of Object.entries(state.workspaces ?? {})) {
|
|
378
|
+
if (days === null || typeof days !== "object") continue;
|
|
379
|
+
const wsBucket = (byWorkspace[ws] ??= emptyBucket());
|
|
380
|
+
for (const [day, bucket] of Object.entries(days)) {
|
|
381
|
+
if (bucket === null || typeof bucket !== "object") continue;
|
|
382
|
+
addBucket(wsBucket, bucket);
|
|
383
|
+
const dayBucket = (byDay[day] ??= emptyBucket());
|
|
384
|
+
addBucket(dayBucket, bucket);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// live, not-yet-flushed tail
|
|
389
|
+
const today = ledgerDayKey(now());
|
|
390
|
+
for (const session of sessions.values()) {
|
|
391
|
+
const delta = deltaOf(session);
|
|
392
|
+
if (isEmptyDelta(delta)) continue;
|
|
393
|
+
const ws = session.workspace.length > 0 ? session.workspace : "(unknown)";
|
|
394
|
+
const wsBucket = (byWorkspace[ws] ??= emptyBucket());
|
|
395
|
+
if (session.firstFlush === false) wsBucket.sessions += 1;
|
|
396
|
+
addBucket(wsBucket, delta);
|
|
397
|
+
const dayBucket = (byDay[today] ??= emptyBucket());
|
|
398
|
+
if (session.firstFlush === false) dayBucket.sessions += 1;
|
|
399
|
+
addBucket(dayBucket, delta);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const totals = emptyBucket();
|
|
403
|
+
for (const bucket of Object.values(byWorkspace)) addBucket(totals, bucket);
|
|
404
|
+
|
|
405
|
+
const finalizedWorkspaces = {};
|
|
406
|
+
for (const [ws, bucket] of Object.entries(byWorkspace)) {
|
|
407
|
+
finalizedWorkspaces[ws] = finalizeLedgerBucket(bucket, discount);
|
|
408
|
+
}
|
|
409
|
+
const finalizedDays = Object.entries(byDay)
|
|
410
|
+
.map(([day, bucket]) => ({ day, ...finalizeLedgerBucket(bucket, discount) }))
|
|
411
|
+
.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0));
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
version: 1,
|
|
415
|
+
updatedAt: state.updatedAt ?? 0,
|
|
416
|
+
charsPerToken: CHARS_PER_TOKEN,
|
|
417
|
+
cacheDiscount: discount,
|
|
418
|
+
openSessions: sessions.size,
|
|
419
|
+
totals: finalizeLedgerBucket(totals, discount),
|
|
420
|
+
byWorkspace: finalizedWorkspaces,
|
|
421
|
+
byDay: finalizedDays,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Read the durable ledger without creating a live instance (GUI / tests). */
|
|
428
|
+
export function readLedger({ home, cacheDiscount = DEFAULT_CACHE_DISCOUNT } = {}) {
|
|
429
|
+
const stored = readPersisted(ledgerFile(home));
|
|
430
|
+
const workspaces = {};
|
|
431
|
+
const days = {};
|
|
432
|
+
if (stored !== null) {
|
|
433
|
+
for (const [ws, wsDays] of Object.entries(stored.workspaces)) {
|
|
434
|
+
if (wsDays === null || typeof wsDays !== "object") continue;
|
|
435
|
+
const wsBucket = (workspaces[ws] ??= emptyBucket());
|
|
436
|
+
for (const [day, bucket] of Object.entries(wsDays)) {
|
|
437
|
+
if (bucket === null || typeof bucket !== "object") continue;
|
|
438
|
+
addBucket(wsBucket, bucket);
|
|
439
|
+
addBucket((days[day] ??= emptyBucket()), bucket);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const totals = emptyBucket();
|
|
444
|
+
for (const bucket of Object.values(workspaces)) addBucket(totals, bucket);
|
|
445
|
+
return {
|
|
446
|
+
version: 1,
|
|
447
|
+
updatedAt: stored?.updatedAt ?? 0,
|
|
448
|
+
charsPerToken: CHARS_PER_TOKEN,
|
|
449
|
+
cacheDiscount: clampDiscount(cacheDiscount),
|
|
450
|
+
totals: finalizeLedgerBucket(totals, cacheDiscount),
|
|
451
|
+
byWorkspace: Object.fromEntries(
|
|
452
|
+
Object.entries(workspaces).map(([ws, bucket]) => [ws, finalizeLedgerBucket(bucket, cacheDiscount)]),
|
|
453
|
+
),
|
|
454
|
+
byDay: Object.entries(days)
|
|
455
|
+
.map(([day, bucket]) => ({ day, ...finalizeLedgerBucket(bucket, cacheDiscount) }))
|
|
456
|
+
.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0)),
|
|
457
|
+
};
|
|
458
|
+
}
|