Skip to content

Commit 4fbeba6

Browse files
committed
fix(tokenizer): real Claude tokenizer now loads; accurate sidebar math and per-message cache
The previous `eval("require")` dynamic import of `ai-tokenizer` silently threw in Bun's ESM runtime, so every token count since the integration fell through to `Math.ceil(text.length / 3.5)`. Long sessions showed wildly inflated "Tool Defs + Overhead" residuals (91K vs real ~22K) and misattributed conversation tokens. Key changes: - `read-session-formatting.ts`: replace `eval("require")` fallback with a static ESM import of `ai-tokenizer` + `claudeEncoding`; no heuristic fallback anymore. - `rpc-handlers.ts`: switch 5 remaining chars/3.5 call sites in sidebar / status snapshots to `estimateTokens()` so compartment, fact, and memory token math subtracts from `conversationTokens` in the same units. - `transform.ts`: per-message token counter now covers text, reasoning, thinking, redacted_thinking, file (with real image token estimation), and all tool-call variants; persists `conversation_tokens` and `tool_call_tokens` separately so the sidebar splits "Tool Calls" (reducible) from "Tool Defs + Overhead" (residual). - `system-prompt-hash.ts`: self-heal `system_prompt_tokens` when stored value drifts >50 tokens from a fresh `estimateTokens()` count so existing sessions recover from the old heuristic without migration. - `storage-meta-*`: add `conversation_tokens` and `tool_call_tokens` columns via `ensureColumn`; include them in the `getOrCreateSessionMeta` SELECT and `SessionMetaRow` validator; keep `toSessionMeta` defensive with NULL fallbacks so upgraded DBs never crash. - `storage-db.ts`: add `healNullIntegerColumns` companion to `healNullTextColumns`; normalize NULL INTEGER columns (added via ensureColumn against pre-existing rows) to 0 at startup. Guards against the scheduler-reset cascade on old DBs. - `key-files/read-stats.ts` + `identify-key-files.ts`: byte-based `/3.5` estimate is kept only where the file content isn't loaded (pre-filter bucketing); marked `Intentional:` so audits don't re-raise. - Sidebar UI: rename "Tools" to "Tool Defs + Overhead" to reflect what the residual actually represents. Verified on a 340K-input live session: plugin-stored conv/tool/sys totals now match the Anthropic wire body within 0.1%. Tests updated to include the new columns in their seeded schemas.
1 parent 8d14328 commit 4fbeba6

23 files changed

Lines changed: 411 additions & 105 deletions

packages/plugin/src/features/magic-context/key-files/identify-key-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function getKeyFileCandidates(
9494
const maxPerFileTokens = Math.min(tokenBudget / 2, 5000);
9595
// Filter to files within the project directory — long-running sessions may have
9696
// read files from other repos, which should not be pinned as key files.
97-
const projectPrefix = projectDirectory ? projectDirectory.replace(/\/$/, "") + "/" : undefined;
97+
const projectPrefix = projectDirectory ? `${projectDirectory.replace(/\/$/, "")}/` : undefined;
9898
return stats.filter(
9999
(s) =>
100100
s.latestReadTokens > 0 &&

packages/plugin/src/features/magic-context/key-files/read-stats.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export function getSessionReadStats(
100100
spreadAcrossCompartments: 0, // TODO: compute from compartment boundaries if needed
101101
editCount: editCounts.get(row.file_path) ?? 0,
102102
latestReadBytes: row.latest_read_bytes ?? 0,
103+
// Rough estimate from byte size — we don't have the file content at
104+
// this layer. Used only for dreamer's key-file budget-fit filtering,
105+
// where approximate bucketing is sufficient. Intentional: bytes/3.5.
103106
latestReadTokens: Math.ceil((row.latest_read_bytes ?? 0) / 3.5),
104107
}));
105108
}

packages/plugin/src/features/magic-context/storage-db.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,83 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en
304304
// segments, since Anthropic's usage data rolls system + tools + messages
305305
// together into cache.write but we want to attribute them separately.
306306
ensureColumn(db, "session_meta", "conversation_tokens", "INTEGER DEFAULT 0");
307+
// Token estimate of tool-call parts (tool_use, tool_result, tool, tool-invocation)
308+
// inside messages. Separate from conversation_tokens so the sidebar can show an
309+
// actionable "Tool Calls" slice that users can reduce via ctx_reduce.
310+
ensureColumn(db, "session_meta", "tool_call_tokens", "INTEGER DEFAULT 0");
311+
312+
// One-time heal: when ensureColumn adds a new TEXT DEFAULT '' or
313+
// INTEGER DEFAULT N column, SQLite leaves pre-existing rows with NULL
314+
// instead of applying the DEFAULT. isSessionMetaRow used to check
315+
// `typeof === "string"` / `"number"` strictly (null fails), so rows
316+
// with NULL columns were rejected → getOrCreateSessionMeta returned
317+
// defaults (lastResponseTime=0, cacheTtl="5m") → scheduler always
318+
// returned "execute" → applyPendingOperations re-ran forever → each
319+
// execute pass mutated message content → sustained cache-bust cascade.
320+
// The defensive validator now accepts null, but we also heal the data
321+
// for good measure (belt and suspenders — failures here are non-fatal).
322+
healNullTextColumns(db);
323+
healNullIntegerColumns(db);
324+
}
325+
326+
function healNullTextColumns(db: Database): void {
327+
const columns: Array<[string, string]> = [
328+
["cache_ttl", ""],
329+
["last_nudge_band", ""],
330+
["last_transform_error", ""],
331+
["nudge_anchor_message_id", ""],
332+
["nudge_anchor_text", ""],
333+
["sticky_turn_reminder_text", ""],
334+
["sticky_turn_reminder_message_id", ""],
335+
["note_nudge_trigger_message_id", ""],
336+
["note_nudge_sticky_text", ""],
337+
["note_nudge_sticky_message_id", ""],
338+
["system_prompt_hash", ""],
339+
["stripped_placeholder_ids", ""],
340+
["memory_block_cache", ""],
341+
["compaction_marker_state", ""],
342+
["key_files", ""],
343+
];
344+
for (const [column, fallback] of columns) {
345+
try {
346+
db.prepare(`UPDATE session_meta SET ${column} = ? WHERE ${column} IS NULL`).run(
347+
fallback,
348+
);
349+
} catch (_error) {
350+
// Ignore — the column may not exist yet on a brand-new DB that
351+
// hasn't gone through all ensureColumn calls yet. The heal runs
352+
// again on next startup.
353+
}
354+
}
355+
}
356+
357+
function healNullIntegerColumns(db: Database): void {
358+
// INTEGER columns added via ensureColumn against pre-existing rows.
359+
// SQLite does not backfill the DEFAULT on ALTER TABLE, so old rows have
360+
// NULL. The validator tolerates null as of this release, but we still
361+
// normalize to 0 so subsequent reads from any path (including paths
362+
// that bypass toSessionMeta) see a well-formed row.
363+
const columns: Array<[string, number]> = [
364+
["times_execute_threshold_reached", 0],
365+
["compartment_in_progress", 0],
366+
["historian_failure_count", 0],
367+
["cleared_reasoning_through_tag", 0],
368+
["memory_block_count", 0],
369+
["system_prompt_tokens", 0],
370+
["conversation_tokens", 0],
371+
["tool_call_tokens", 0],
372+
["note_nudge_trigger_pending", 0],
373+
];
374+
for (const [column, fallback] of columns) {
375+
try {
376+
db.prepare(`UPDATE session_meta SET ${column} = ? WHERE ${column} IS NULL`).run(
377+
fallback,
378+
);
379+
} catch (_error) {
380+
// Same rationale as the text heal — swallow missing-column errors
381+
// on brand-new DBs; next startup reruns this.
382+
}
383+
}
307384
}
308385

309386
// Intentional: the definition regex allows single quotes and parens because SQLite column

packages/plugin/src/features/magic-context/storage-meta-session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { SessionMeta } from "./types";
1414
export function getOrCreateSessionMeta(db: Database, sessionId: string): SessionMeta {
1515
const result = db
1616
.prepare(
17-
"SELECT session_id, last_response_time, cache_ttl, counter, last_nudge_tokens, last_nudge_band, last_transform_error, is_subagent, last_context_percentage, last_input_tokens, times_execute_threshold_reached, compartment_in_progress, system_prompt_hash, system_prompt_tokens, cleared_reasoning_through_tag FROM session_meta WHERE session_id = ?",
17+
"SELECT session_id, last_response_time, cache_ttl, counter, last_nudge_tokens, last_nudge_band, last_transform_error, is_subagent, last_context_percentage, last_input_tokens, times_execute_threshold_reached, compartment_in_progress, system_prompt_hash, system_prompt_tokens, conversation_tokens, tool_call_tokens, cleared_reasoning_through_tag FROM session_meta WHERE session_id = ?",
1818
)
1919
.get(sessionId);
2020

packages/plugin/src/features/magic-context/storage-meta-shared.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface SessionMetaRow {
1919
system_prompt_hash: string | number;
2020
system_prompt_tokens: number;
2121
conversation_tokens: number;
22+
tool_call_tokens: number;
2223
cleared_reasoning_through_tag: number;
2324
}
2425

@@ -37,30 +38,54 @@ export const META_COLUMNS: Record<string, string> = {
3738
systemPromptHash: "system_prompt_hash",
3839
systemPromptTokens: "system_prompt_tokens",
3940
conversationTokens: "conversation_tokens",
41+
toolCallTokens: "tool_call_tokens",
4042
clearedReasoningThroughTag: "cleared_reasoning_through_tag",
4143
};
4244

4345
export const BOOLEAN_META_KEYS = new Set(["isSubagent", "compartmentInProgress"]);
4446

47+
// Defensive typeof checks: columns may be NULL in DB when a row was seeded
48+
// before a column was added with ensureColumn (SQLite sets existing rows to
49+
// NULL, not to the DEFAULT). Treat null as "absent/empty" rather than
50+
// rejecting the whole row — falling back to defaults silently loses the real
51+
// lastResponseTime, cacheTtl, lastContextPercentage, etc., causing the
52+
// scheduler to always return "execute" and pending ops to re-apply across
53+
// every turn (cache bust cascade).
54+
function isStringOrNull(value: unknown): boolean {
55+
return value === null || typeof value === "string";
56+
}
57+
58+
function isNumberOrNull(value: unknown): boolean {
59+
return value === null || typeof value === "number";
60+
}
61+
4562
export function isSessionMetaRow(row: unknown): row is SessionMetaRow {
4663
if (row === null || typeof row !== "object") return false;
4764
const r = row as Record<string, unknown>;
4865
return (
4966
typeof r.session_id === "string" &&
5067
typeof r.last_response_time === "number" &&
51-
typeof r.cache_ttl === "string" &&
68+
isStringOrNull(r.cache_ttl) &&
5269
typeof r.counter === "number" &&
5370
typeof r.last_nudge_tokens === "number" &&
54-
typeof r.last_nudge_band === "string" &&
55-
typeof r.last_transform_error === "string" &&
71+
isStringOrNull(r.last_nudge_band) &&
72+
isStringOrNull(r.last_transform_error) &&
5673
typeof r.is_subagent === "number" &&
5774
typeof r.last_context_percentage === "number" &&
5875
typeof r.last_input_tokens === "number" &&
59-
typeof r.times_execute_threshold_reached === "number" &&
60-
typeof r.compartment_in_progress === "number" &&
61-
(typeof r.system_prompt_hash === "string" || typeof r.system_prompt_hash === "number") &&
62-
typeof r.system_prompt_tokens === "number" &&
63-
typeof r.cleared_reasoning_through_tag === "number"
76+
// INTEGER columns added via ensureColumn: pre-existing rows get NULL
77+
// instead of DEFAULT. Strict typeof "number" would reject those rows
78+
// and trigger the scheduler-reset cascade described above. toSessionMeta
79+
// falls back to 0 for NULL.
80+
isNumberOrNull(r.times_execute_threshold_reached) &&
81+
isNumberOrNull(r.compartment_in_progress) &&
82+
(r.system_prompt_hash === null ||
83+
typeof r.system_prompt_hash === "string" ||
84+
typeof r.system_prompt_hash === "number") &&
85+
isNumberOrNull(r.system_prompt_tokens) &&
86+
isNumberOrNull(r.conversation_tokens) &&
87+
isNumberOrNull(r.tool_call_tokens) &&
88+
isNumberOrNull(r.cleared_reasoning_through_tag)
6489
);
6590
}
6691

@@ -81,6 +106,7 @@ export function getDefaultSessionMeta(sessionId: string): SessionMeta {
81106
systemPromptHash: "",
82107
systemPromptTokens: 0,
83108
conversationTokens: 0,
109+
toolCallTokens: 0,
84110
clearedReasoningThroughTag: 0,
85111
};
86112
}
@@ -110,26 +136,38 @@ export function ensureSessionMetaRow(db: Database, sessionId: string): void {
110136
}
111137

112138
export function toSessionMeta(row: SessionMetaRow): SessionMeta {
139+
// Defensive: NULL text columns (e.g. seeded rows pre-ensureColumn) must not
140+
// crash with `.length on null`. Treat null/empty as absent and map to the
141+
// SessionMeta representation.
142+
const nudgeBandRaw = typeof row.last_nudge_band === "string" ? row.last_nudge_band : "";
143+
const transformErrorRaw =
144+
typeof row.last_transform_error === "string" ? row.last_transform_error : "";
145+
const cacheTtlRaw =
146+
typeof row.cache_ttl === "string" && row.cache_ttl.length > 0 ? row.cache_ttl : "5m";
147+
const systemPromptHashRaw = row.system_prompt_hash == null ? "" : row.system_prompt_hash;
148+
// Defensive numeric fallbacks: when isSessionMetaRow accepts NULL for
149+
// INTEGER columns added via ensureColumn, the raw row may have `null`
150+
// here. Coerce to 0 so callers see a usable SessionMeta without having
151+
// to null-check every scalar field.
152+
const numOrZero = (value: unknown): number => (typeof value === "number" ? value : 0);
113153
return {
114154
sessionId: row.session_id,
115155
lastResponseTime: row.last_response_time,
116-
cacheTtl: row.cache_ttl,
156+
cacheTtl: cacheTtlRaw,
117157
counter: row.counter,
118158
lastNudgeTokens: row.last_nudge_tokens,
119159
lastNudgeBand:
120-
row.last_nudge_band.length > 0
121-
? (row.last_nudge_band as SessionMeta["lastNudgeBand"])
122-
: null,
123-
lastTransformError: row.last_transform_error.length > 0 ? row.last_transform_error : null,
160+
nudgeBandRaw.length > 0 ? (nudgeBandRaw as SessionMeta["lastNudgeBand"]) : null,
161+
lastTransformError: transformErrorRaw.length > 0 ? transformErrorRaw : null,
124162
isSubagent: row.is_subagent === 1,
125163
lastContextPercentage: row.last_context_percentage,
126164
lastInputTokens: row.last_input_tokens,
127-
timesExecuteThresholdReached: row.times_execute_threshold_reached,
165+
timesExecuteThresholdReached: numOrZero(row.times_execute_threshold_reached),
128166
compartmentInProgress: row.compartment_in_progress === 1,
129-
systemPromptHash: String(row.system_prompt_hash),
130-
systemPromptTokens: row.system_prompt_tokens,
131-
conversationTokens:
132-
typeof row.conversation_tokens === "number" ? row.conversation_tokens : 0,
133-
clearedReasoningThroughTag: row.cleared_reasoning_through_tag,
167+
systemPromptHash: String(systemPromptHashRaw),
168+
systemPromptTokens: numOrZero(row.system_prompt_tokens),
169+
conversationTokens: numOrZero(row.conversation_tokens),
170+
toolCallTokens: numOrZero(row.tool_call_tokens),
171+
clearedReasoningThroughTag: numOrZero(row.cleared_reasoning_through_tag),
134172
};
135173
}

packages/plugin/src/features/magic-context/storage-tags.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ function makeMemoryDatabase(): Database {
5555
historian_last_failure_at INTEGER DEFAULT NULL,
5656
system_prompt_hash INTEGER DEFAULT 0,
5757
system_prompt_tokens INTEGER DEFAULT 0,
58+
conversation_tokens INTEGER DEFAULT 0,
59+
tool_call_tokens INTEGER DEFAULT 0,
5860
cleared_reasoning_through_tag INTEGER DEFAULT 0
5961
);
6062
`);

packages/plugin/src/features/magic-context/storage.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,49 @@ describe("magic-context storage", () => {
390390
db.close(false);
391391
});
392392

393+
it("preserves numeric columns when text columns are NULL (regression: cache bust cascade)", () => {
394+
//#given
395+
// Simulates an older row seeded before ensureColumn added last_transform_error
396+
// with a DEFAULT. SQLite sets existing rows to NULL, not the default, so the
397+
// text column is NULL but the numeric columns (last_response_time,
398+
// last_context_percentage, etc.) carry real cumulative state.
399+
// Pre-fix: validator rejected the row, getOrCreateSessionMeta returned
400+
// defaults with lastResponseTime=0, scheduler thought TTL had elapsed on
401+
// every pass, applyPendingOperations re-ran forever, and each execute
402+
// mutation busted cache.
403+
const db = makeMemoryDatabase();
404+
const realResponseTime = 1_700_000_000_000;
405+
// First seed the row so subsequent UPDATE matches.
406+
db.prepare(
407+
`INSERT INTO session_meta (session_id, last_response_time, cache_ttl, counter)
408+
VALUES (?, ?, '59m', 42)`,
409+
).run("ses-nullish", realResponseTime);
410+
db.prepare(
411+
`UPDATE session_meta SET
412+
last_response_time = ?,
413+
cache_ttl = '59m',
414+
counter = 42,
415+
last_nudge_tokens = 100,
416+
last_context_percentage = 25.5,
417+
last_input_tokens = 250000,
418+
last_transform_error = NULL
419+
WHERE session_id = ?`,
420+
).run(realResponseTime, "ses-nullish");
421+
422+
//#when
423+
const meta = getOrCreateSessionMeta(db, "ses-nullish");
424+
425+
//#then — real cumulative state must survive, not be reset to defaults
426+
expect(meta.sessionId).toBe("ses-nullish");
427+
expect(meta.lastResponseTime).toBe(realResponseTime);
428+
expect(meta.cacheTtl).toBe("59m");
429+
expect(meta.counter).toBe(42);
430+
expect(meta.lastContextPercentage).toBe(25.5);
431+
expect(meta.lastInputTokens).toBe(250000);
432+
expect(meta.lastTransformError).toBeNull();
433+
db.close(false);
434+
});
435+
393436
it("getTopNBySize only returns tags with active status", () => {
394437
//#given
395438
const db = makeMemoryDatabase();

packages/plugin/src/features/magic-context/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface SessionMeta {
3535
systemPromptHash: string;
3636
systemPromptTokens: number;
3737
conversationTokens: number;
38+
toolCallTokens: number;
3839
clearedReasoningThroughTag: number;
3940
}
4041

packages/plugin/src/hooks/magic-context/command-handler.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ function createTestDb(): Database {
7070
historian_last_failure_at INTEGER DEFAULT NULL,
7171
system_prompt_hash INTEGER DEFAULT 0,
7272
system_prompt_tokens INTEGER DEFAULT 0,
73+
conversation_tokens INTEGER DEFAULT 0,
74+
tool_call_tokens INTEGER DEFAULT 0,
7375
cleared_reasoning_through_tag INTEGER DEFAULT 0
7476
);
7577
`);

packages/plugin/src/hooks/magic-context/compartment-runner.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,11 @@ describe("executeContextRecomp", () => {
384384
client,
385385
db,
386386
sessionId: "ses-recomp-full-state",
387-
historianChunkTokens: 7,
387+
// Budget sized so chunking packs ~2 messages per pass with the real
388+
// Claude tokenizer (ai-tokenizer). The previous value of 7 relied on
389+
// the `/3.5` heuristic fallback and no longer reproduces a 2-pass
390+
// split with accurate tokenization.
391+
historianChunkTokens: 13,
388392
directory: "/tmp",
389393
});
390394

0 commit comments

Comments
 (0)