feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+793
View File
@@ -0,0 +1,793 @@
/**
* @file TranscriptCache class for efficient extraction of token usage and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const {
bucketKey,
emptyBucket,
extractUsageFields,
normalizeSpeed,
normalizeGeo,
normalizeTier,
accumulateBucket,
} = require("./token-usage");
const MAX_CACHE_ENTRIES = 200;
// Marker text Claude Code writes into the transcript when a turn is cancelled
// by the user (Esc). The synthetic entry is `type:"user"` and also carries an
// `interruptedMessageId` field; we accept either signal so detection survives
// minor format drift. No hook fires on interrupt, so this is the only on-disk
// evidence the watchdog can use to un-stick a session left in "working".
const INTERRUPT_RE = /\[Request interrupted by user/i;
// True when the transcript's tail is a user-interrupt that was never followed
// by real turn activity (a new prompt or model output). Both timestamps come
// from Claude Code's clock, so the comparison is immune to the server/transcript
// skew that breaks a sub-second pre-output Esc. `>=` so an interrupt that ties
// the last activity (interrupt written in the same instant) still counts.
function computePendingInterrupt(lastInterruptTs, lastTurnTs) {
if (!lastInterruptTs) return false;
if (!lastTurnTs) return true;
return lastInterruptTs >= lastTurnTs;
}
function hasInterruptText(message) {
if (!message || typeof message !== "object") return false;
const c = message.content;
if (typeof c === "string") return INTERRUPT_RE.test(c);
if (Array.isArray(c)) {
for (const block of c) {
if (block && typeof block.text === "string" && INTERRUPT_RE.test(block.text)) return true;
}
}
return false;
}
// Hard cap on the length of each per-entry growable array (turnDurations,
// errors, compaction.entries, usageExtras.{service_tiers,speeds,inference_geos}).
// Past this point we keep the *tail* — the most recent N items — so the
// cache reflects current state. Older items are NOT lost from the system:
// they are already persisted to the events table by routes/hooks.js, with
// dedup logic that prevents re-insertion when the cache re-reads them.
// Configurable via TRANSCRIPT_CACHE_MAX_ARRAY_LEN env var.
const MAX_ARRAY_LEN = (() => {
const raw = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10);
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
})();
// Watermark for in-flight trimming during _consumeLine. We trim back to
// MAX_ARRAY_LEN whenever an array reaches 2*MAX_ARRAY_LEN, so a full-file
// parse cannot accumulate an unbounded transient before _finalizeState runs.
// Amortized O(N): each item is touched by a splice at most ~once.
const PARSE_TRIM_WATERMARK = MAX_ARRAY_LEN * 2;
// Cap on the captured first-user-message text. 500 chars matches the task
// truncation the hook ingestor already applies to subagent prompts, so the
// descriptor can be reused verbatim as an agent task downstream.
const FIRST_USER_MESSAGE_MAX_LEN = 500;
// Synthetic user entries whose text is CLI plumbing, not something the human
// typed: local slash-command invocations/output and the caveat preamble
// Claude Code writes before locally-generated messages. These must never
// become a session descriptor.
const SYNTHETIC_USER_TEXT_RE =
/^<(?:command-name|command-message|local-command-stdout|local-command-caveat)>/;
/**
* Extract the human-typed text of a user transcript entry, or null when the
* entry is not a real prompt: tool-result entries, meta/caveat lines, local
* slash-command plumbing, compact summaries, and user-interrupt markers are
* all skipped. Shared with scripts/import-history.js so imported and live
* sessions derive the identical descriptor.
*/
function extractFirstUserText(entry) {
if (entry.isMeta || entry.isCompactSummary) return null;
if (entry.interruptedMessageId != null || hasInterruptText(entry.message)) return null;
const msg = entry.message;
if (!msg || typeof msg !== "object" || msg.role !== "user") return null;
const content = msg.content;
let text = null;
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
// Tool-result entries are `role:"user"` too — skip any entry carrying a
// tool_result block rather than mining text out of a mixed payload.
if (content.some((b) => b && b.type === "tool_result")) return null;
text = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join(" ");
}
if (typeof text !== "string") return null;
// Collapse newlines/runs of whitespace so the descriptor reads as one line.
text = text.replace(/\s+/g, " ").trim();
if (!text || SYNTHETIC_USER_TEXT_RE.test(text)) return null;
return text.length > FIRST_USER_MESSAGE_MAX_LEN
? text.slice(0, FIRST_USER_MESSAGE_MAX_LEN)
: text;
}
class TranscriptCache {
constructor(maxEntries = MAX_CACHE_ENTRIES) {
this._cache = new Map();
this._maxEntries = maxEntries;
this._hits = 0;
this._misses = 0;
}
/**
* Extract token usage and compaction data from a JSONL transcript file.
* Uses stat-based caching with incremental reads for append-only growth.
* Returns null if file doesn't exist or has no data.
*/
extract(transcriptPath) {
if (!transcriptPath) return null;
try {
let stat;
try {
stat = fs.statSync(transcriptPath);
} catch {
return null;
}
const key = transcriptPath;
const cached = this._cache.get(key);
// Cache hit: file unchanged (same mtime + size)
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
this._hits++;
return cached.result;
}
this._misses++;
// File shrunk or first read → full re-read
if (!cached || stat.size < cached.bytesRead) {
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// File grew → incremental read from last position
if (stat.size > cached.bytesRead) {
const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
if (incremental) {
const merged = this._merge(cached, incremental);
const hasTokens = Object.keys(merged.tokensByModel).length > 0;
const hasTurnDurations = merged.turnDurations && merged.turnDurations.length > 0;
const hasUsageExtras =
merged.usageExtras &&
(merged.usageExtras.service_tiers.length > 0 ||
merged.usageExtras.speeds.length > 0 ||
merged.usageExtras.inference_geos.length > 0);
const result = {
tokensByModel: hasTokens ? merged.tokensByModel : null,
compaction: merged.compaction,
errors: merged.errors,
turnDurations: hasTurnDurations ? merged.turnDurations : null,
thinkingBlockCount: merged.thinkingBlockCount || 0,
usageExtras: hasUsageExtras ? merged.usageExtras : null,
latestModel: merged.latestModel || null,
customTitle: merged.customTitle || null,
aiTitle: merged.aiTitle || null,
firstUserMessage: merged.firstUserMessage || null,
lastInterruptTs: merged.lastInterruptTs || null,
lastTurnTs: merged.lastTurnTs || null,
pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
};
if (
!result.tokensByModel &&
!result.compaction &&
!result.errors &&
!result.turnDurations &&
!result.thinkingBlockCount &&
!result.usageExtras &&
!result.latestModel &&
!result.customTitle &&
!result.aiTitle &&
!result.firstUserMessage &&
!result.lastInterruptTs &&
!result.lastTurnTs
) {
this._set(key, {
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
result: null,
});
return null;
}
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
}
// Only whitespace/newlines appended
this._set(key, {
...cached,
mtimeMs: stat.mtimeMs,
size: stat.size,
bytesRead: stat.size,
});
return cached.result;
}
// Same size, different mtime — content may have been rewritten (compaction)
const result = this._fullRead(transcriptPath);
this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
return result;
} catch {
return null;
}
}
/**
* Extract only compaction entries from a JSONL file.
* Replacement for findCompactionsInFile — uses the same cache, no duplicate reads.
*/
extractCompactions(transcriptPath) {
const result = this.extract(transcriptPath);
if (!result || !result.compaction) return [];
return result.compaction.entries.map((e) => ({ ...e }));
}
/**
* Full re-read using chunked streaming. Avoids materializing the whole file
* as a single JS string, so files larger than V8's max string length
* (~512 MiB on 64-bit Node) parse without aborting the process with
* "FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal".
*/
_fullRead(filePath) {
let size;
try {
size = fs.statSync(filePath).size;
} catch {
return null;
}
return this._streamRange(filePath, 0, size);
}
/**
* Sync chunked range reader + line parser.
* Reads [startOffset, endOffset) in fixed-size chunks, splits on 0x0A bytes,
* decodes each complete line as UTF-8 (safe: 0x0A never appears inside a
* UTF-8 multibyte sequence), and feeds it to _consumeLine. Partial trailing
* bytes between chunks are held in a byte buffer so multibyte characters
* straddling a chunk boundary are not corrupted. Never builds a string
* larger than a single line, so V8 string-length limits cannot be hit.
*/
_streamRange(filePath, startOffset, endOffset) {
const state = this._initParseState();
if (endOffset <= startOffset) return this._finalizeState(state);
const CHUNK = 4 * 1024 * 1024; // 4 MiB
const MAX_PENDING = 64 * 1024 * 1024; // hard cap on a single line
const buf = Buffer.allocUnsafe(CHUNK);
let pending = null; // bytes of partial trailing line not yet terminated by \n
let pendingLen = 0;
let pos = startOffset;
let fd;
try {
try {
fd = fs.openSync(filePath, "r");
} catch {
return this._finalizeState(state);
}
while (pos < endOffset) {
const want = Math.min(CHUNK, endOffset - pos);
let got;
try {
got = fs.readSync(fd, buf, 0, want, pos);
} catch {
break;
}
if (got <= 0) break;
pos += got;
let lineStart = 0;
for (let i = 0; i < got; i++) {
if (buf[i] !== 0x0a) continue;
let line;
if (pendingLen) {
const need = pendingLen + (i - lineStart);
const lineBuf = Buffer.allocUnsafe(need);
pending.copy(lineBuf, 0, 0, pendingLen);
buf.copy(lineBuf, pendingLen, lineStart, i);
line = lineBuf.toString("utf8");
pending = null;
pendingLen = 0;
} else {
line = buf.toString("utf8", lineStart, i);
}
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1); // strip CR
}
if (line) this._consumeLine(line, state);
lineStart = i + 1;
}
if (lineStart < got) {
const tailLen = got - lineStart;
const newLen = pendingLen + tailLen;
if (newLen > MAX_PENDING) {
// Pathological single line — drop accumulated bytes and skip
// forward to the next newline rather than OOM. Loss is bounded
// to one malformed line.
pending = null;
pendingLen = 0;
} else {
if (!pending) {
pending = Buffer.allocUnsafe(Math.max(newLen, 8192));
} else if (pending.length < newLen) {
const grow = Buffer.allocUnsafe(Math.max(newLen, pending.length * 2));
pending.copy(grow, 0, 0, pendingLen);
pending = grow;
}
buf.copy(pending, pendingLen, lineStart, got);
pendingLen = newLen;
}
}
}
if (pendingLen) {
let line = pending.toString("utf8", 0, pendingLen);
if (line.length && line.charCodeAt(line.length - 1) === 13) {
line = line.slice(0, -1);
}
if (line) this._consumeLine(line, state);
}
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
return this._finalizeState(state);
}
_initParseState() {
return {
tokensByModel: {},
compaction: null,
errors: [],
turnDurations: [],
thinkingBlockCount: 0,
usageExtras: {
service_tiers: new Set(),
speeds: new Set(),
inference_geos: new Set(),
},
// Track the model of the most recent assistant entry. JSONL is
// append-only and parsed in file order, so the last value seen here is
// the user's *current* model — used downstream to keep session.model in
// sync when the user invokes /model mid-session.
latestModel: null,
// Track the latest human-readable session title. Two sources, both
// append-only metadata lines: `custom-title` (explicit /rename, claude
// -n, picker Ctrl+R) and `ai-title` (auto-generated / plan-accept).
// Last value wins. Used downstream to keep session.name in sync in real
// time — custom titles take precedence over ai titles.
customTitle: null,
aiTitle: null,
// First real user prompt of the session (tool-result / meta / command
// entries skipped), whitespace-collapsed and length-capped. Used
// downstream as a fallback descriptor for placeholder-named sessions
// and their main agent — first value wins (it describes what the
// session set out to do), unlike the last-wins titles above.
firstUserMessage: null,
// Timestamps (ISO 8601, all from Claude Code's clock) used to recover a
// turn cancelled with no hook. `lastInterruptTs` is the most recent
// user-interrupt (Esc) entry; `lastTurnTs` is the most recent real turn
// activity (assistant output or a genuine user prompt). Comparing the
// two — both same-clock — tells us whether the transcript TAIL is an
// unrecovered interrupt. This holds even when Esc is pressed before any
// output (a sub-second interrupt), which a server-vs-transcript clock
// comparison cannot, since the UserPromptSubmit event is stamped later.
lastInterruptTs: null,
lastTurnTs: null,
};
}
_consumeLine(line, state) {
if (!line) return;
let entry;
try {
entry = JSON.parse(line);
} catch {
return;
}
// Session title metadata lines — sparse, no usage payload. Capture the
// latest value of each kind (append-only → last wins) and bail early.
if (entry.type === "custom-title") {
if (typeof entry.customTitle === "string" && entry.customTitle.trim()) {
state.customTitle = entry.customTitle;
}
return;
}
if (entry.type === "ai-title") {
if (typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
state.aiTitle = entry.aiTitle;
}
return;
}
// User-interrupt (Esc) marker. No hook fires for cancellation, so capture
// the timestamp here for the watchdog to move a stuck session back to
// waiting-for-input. The entry carries no usage/model, so return early.
if (
entry.type === "user" &&
(entry.interruptedMessageId != null || hasInterruptText(entry.message))
) {
if (entry.timestamp) state.lastInterruptTs = entry.timestamp;
return;
}
// Real turn activity — assistant output or a genuine (non-interrupt) user
// prompt. Tracking its latest timestamp lets _finalizeState decide whether
// a later interrupt was superseded by the user resuming (new prompt /
// model output) or is still the unrecovered tail of the transcript.
if ((entry.type === "assistant" || entry.type === "user") && entry.timestamp) {
if (!state.lastTurnTs || entry.timestamp > state.lastTurnTs)
state.lastTurnTs = entry.timestamp;
}
// First real user prompt — captured once (first wins; the file is parsed
// in order). extractFirstUserText filters out tool-result, meta, and
// slash-command plumbing entries so only human-typed text qualifies.
if (state.firstUserMessage === null && entry.type === "user") {
const firstText = extractFirstUserText(entry);
if (firstText) state.firstUserMessage = firstText;
}
if (entry.isCompactSummary) {
if (!state.compaction) state.compaction = { count: 0, entries: [] };
state.compaction.count++;
state.compaction.entries.push({
uuid: entry.uuid || null,
timestamp: entry.timestamp || null,
});
if (state.compaction.entries.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.compaction.entries);
}
}
if (entry.type === "system" && entry.subtype === "turn_duration" && entry.durationMs) {
const turnTs = entry.timestamp
? typeof entry.timestamp === "number"
? new Date(entry.timestamp).toISOString()
: entry.timestamp
: null;
state.turnDurations.push({ durationMs: entry.durationMs, timestamp: turnTs });
if (state.turnDurations.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.turnDurations);
}
}
const msg = entry.message || entry;
if (msg.type === "error" && msg.error) {
state.errors.push({
type: msg.error.type || "unknown_error",
message: msg.error.message || "Unknown API error",
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
if (entry.isApiErrorMessage) {
const errContent = Array.isArray(entry.message?.content) ? entry.message.content : [];
const errText = errContent[0]?.text ? errContent[0].text.slice(0, 500) : "Unknown error";
state.errors.push({
type: entry.error || "unknown_error",
message: errText,
timestamp: entry.timestamp || null,
});
if (state.errors.length >= PARSE_TRIM_WATERMARK) {
this._trimArray(state.errors);
}
return;
}
const model = msg.model;
if (!model || model === "<synthetic>" || !msg.usage) return;
state.latestModel = model;
// Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
// apply fast-mode, data-residency, and Batch modifiers per bucket. The value
// carries those dimensions so the DB writer can key the row correctly.
const speed = normalizeSpeed(msg.usage);
const geo = normalizeGeo(msg.usage);
const tier = normalizeTier(msg.usage);
const key = bucketKey(model, speed, geo, tier);
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
if (msg.usage.inference_geo && msg.usage.inference_geo !== "not_available") {
state.usageExtras.inference_geos.add(msg.usage.inference_geo);
}
const msgContent = msg.content || [];
if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "thinking") state.thinkingBlockCount++;
}
}
}
_finalizeState(state) {
const hasTokens = Object.keys(state.tokensByModel).length > 0;
const hasErrors = state.errors.length > 0;
const hasTurnDurations = state.turnDurations.length > 0;
const hasUsageExtras =
state.usageExtras.service_tiers.size > 0 ||
state.usageExtras.speeds.size > 0 ||
state.usageExtras.inference_geos.size > 0;
if (
!hasTokens &&
!state.compaction &&
!hasErrors &&
!hasTurnDurations &&
!state.thinkingBlockCount &&
!hasUsageExtras &&
!state.latestModel &&
!state.customTitle &&
!state.aiTitle &&
!state.firstUserMessage &&
!state.lastInterruptTs &&
!state.lastTurnTs
) {
return null;
}
this._trimArray(state.errors);
this._trimArray(state.turnDurations);
if (state.compaction) this._trimArray(state.compaction.entries);
// usageExtras are accumulated as Sets and serialized as arrays here, with
// the same MAX_ARRAY_LEN tail cap applied via _capArrayFromSet.
const serializedExtras = hasUsageExtras
? {
service_tiers: this._capArrayFromSet(state.usageExtras.service_tiers),
speeds: this._capArrayFromSet(state.usageExtras.speeds),
inference_geos: this._capArrayFromSet(state.usageExtras.inference_geos),
}
: null;
return {
tokensByModel: hasTokens ? state.tokensByModel : null,
compaction: state.compaction,
errors: hasErrors ? state.errors : null,
turnDurations: hasTurnDurations ? state.turnDurations : null,
thinkingBlockCount: state.thinkingBlockCount,
usageExtras: serializedExtras,
latestModel: state.latestModel,
customTitle: state.customTitle,
aiTitle: state.aiTitle,
firstUserMessage: state.firstUserMessage,
lastInterruptTs: state.lastInterruptTs,
lastTurnTs: state.lastTurnTs,
pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
};
}
/**
* Parse an in-memory JSONL string. Retained for callers that already have
* the content as a string. Internal extraction paths now use _streamRange
* directly to avoid the V8 string-length limit on multi-hundred-MiB files.
*/
_parseContent(content) {
const state = this._initParseState();
let start = 0;
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) !== 10) continue;
let line = content.slice(start, i);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
start = i + 1;
}
if (start < content.length) {
let line = content.slice(start);
if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
if (line) this._consumeLine(line, state);
}
return this._finalizeState(state);
}
_merge(cached, incremental) {
const tokensByModel = cached.result?.tokensByModel
? this._cloneTokens(cached.result.tokensByModel)
: {};
if (incremental && incremental.tokensByModel) {
for (const [key, tokens] of Object.entries(incremental.tokensByModel)) {
if (!tokensByModel[key]) {
tokensByModel[key] = emptyBucket(tokens.model, tokens.speed, tokens.geo, tokens.tier);
}
accumulateBucket(tokensByModel[key], tokens);
}
}
let compaction = cached.result?.compaction
? this._cloneCompaction(cached.result.compaction)
: null;
if (incremental && incremental.compaction) {
if (!compaction) compaction = { count: 0, entries: [] };
compaction.count += incremental.compaction.count;
compaction.entries.push(...incremental.compaction.entries);
this._trimArray(compaction.entries);
}
let errors = cached.result?.errors ? [...cached.result.errors] : null;
if (incremental && incremental.errors) {
if (!errors) errors = [];
errors.push(...incremental.errors);
this._trimArray(errors);
}
let turnDurations = cached.result?.turnDurations ? [...cached.result.turnDurations] : null;
if (incremental && incremental.turnDurations) {
if (!turnDurations) turnDurations = [];
turnDurations.push(...incremental.turnDurations);
this._trimArray(turnDurations);
}
const thinkingBlockCount =
(cached.result?.thinkingBlockCount || 0) + (incremental?.thinkingBlockCount || 0);
let usageExtras = cached.result?.usageExtras
? this._cloneUsageExtras(cached.result.usageExtras)
: null;
if (incremental && incremental.usageExtras) {
if (!usageExtras) {
usageExtras = { service_tiers: [], speeds: [], inference_geos: [] };
}
// Merge and deduplicate
const merged = {
service_tiers: new Set([
...usageExtras.service_tiers,
...incremental.usageExtras.service_tiers,
]),
speeds: new Set([...usageExtras.speeds, ...incremental.usageExtras.speeds]),
inference_geos: new Set([
...usageExtras.inference_geos,
...incremental.usageExtras.inference_geos,
]),
};
usageExtras = {
service_tiers: this._capArrayFromSet(merged.service_tiers),
speeds: this._capArrayFromSet(merged.speeds),
inference_geos: this._capArrayFromSet(merged.inference_geos),
};
}
// JSONL is append-only and parsed in order, so the incremental block's
// latestModel (when present) is the newest reading — fall back to the
// previously-cached value when the new chunk had no assistant entries.
const latestModel =
(incremental && incremental.latestModel) || cached.result?.latestModel || null;
// Same append-only logic for the session titles: the newest title line in
// the incremental chunk wins, else keep what was cached.
const customTitle =
(incremental && incremental.customTitle) || cached.result?.customTitle || null;
const aiTitle = (incremental && incremental.aiTitle) || cached.result?.aiTitle || null;
// First user message is first-wins (the opposite of the titles): the
// cached value was parsed from earlier in the file, so it stays; the
// incremental chunk only fills it when nothing was captured before.
const firstUserMessage =
cached.result?.firstUserMessage || (incremental && incremental.firstUserMessage) || null;
// Append-only: a newer interrupt / turn-activity timestamp in the
// incremental chunk supersedes the cached one, otherwise keep what was
// already known. pendingInterrupt is derived from the two by the caller.
const lastInterruptTs =
(incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;
return {
tokensByModel,
compaction,
errors,
turnDurations,
thinkingBlockCount,
usageExtras,
latestModel,
customTitle,
aiTitle,
firstUserMessage,
lastInterruptTs,
lastTurnTs,
};
}
_cloneTokens(tokensByModel) {
if (!tokensByModel) return null;
const clone = {};
for (const [model, t] of Object.entries(tokensByModel)) {
clone[model] = { ...t };
}
return clone;
}
_cloneCompaction(compaction) {
if (!compaction) return null;
return { count: compaction.count, entries: compaction.entries.map((e) => ({ ...e })) };
}
_cloneUsageExtras(extras) {
if (!extras) return null;
return {
service_tiers: [...(extras.service_tiers || [])],
speeds: [...(extras.speeds || [])],
inference_geos: [...(extras.inference_geos || [])],
};
}
/** Set cache entry with LRU eviction when at capacity */
_set(key, entry) {
// Delete first so re-insertion moves key to end of Map iteration order
this._cache.delete(key);
this._cache.set(key, entry);
// Evict oldest entries (first in Map iteration order) if over limit
while (this._cache.size > this._maxEntries) {
const oldest = this._cache.keys().next().value;
this._cache.delete(oldest);
}
}
/** Trim an array in-place to keep only the last `maxLen` items. No-op on falsy. */
_trimArray(arr, maxLen = MAX_ARRAY_LEN) {
if (!arr || !Array.isArray(arr) || arr.length <= maxLen) return;
arr.splice(0, arr.length - maxLen);
}
/** Convert Set to array with the same MAX_ARRAY_LEN tail cap. */
_capArrayFromSet(set) {
const arr = [...set];
this._trimArray(arr);
return arr;
}
/** Number of entries currently cached */
get size() {
return this._cache.size;
}
/** Remove a specific path from cache */
invalidate(transcriptPath) {
this._cache.delete(transcriptPath);
}
/** Clear all cached entries */
clear() {
this._cache.clear();
}
/** Return cache stats for diagnostics */
stats() {
const total = this._hits + this._misses;
return {
size: this._cache.size,
maxSize: this._maxEntries,
hits: this._hits,
misses: this._misses,
hitRate: total > 0 ? +((this._hits / total) * 100).toFixed(1) : 0,
keys: [...this._cache.keys()],
};
}
}
module.exports = TranscriptCache;
module.exports.extractFirstUserText = extractFirstUserText;