Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions db.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ if (migrations.length > currentDbVersion) {
// getHarness(), which treats null as Claude.
if (!cols.has('runtime')) db.exec("ALTER TABLE session_cache ADD COLUMN runtime TEXT DEFAULT 'claude'");
if (!cols.has('sessionFile')) db.exec('ALTER TABLE session_cache ADD COLUMN sessionFile TEXT');
// Resume state for Claude's incremental parser. Add by column presence even
// when a parallel branch already advanced db_version. Existing rows keep
// their cache/search data and get a full read on their next modification.
// Raw timestamp bounds are separate from created/modified, whose fallback
// to file times must not become an accumulator value on a later append.
for (const col of [
'customTitle TEXT', 'textContent TEXT', 'headHash TEXT',
'indexedBytes INTEGER DEFAULT 0', 'firstTimestamp TEXT', 'lastTimestamp TEXT',
]) {
if (!cols.has(col.split(' ')[0])) db.exec(`ALTER TABLE session_cache ADD COLUMN ${col}`);
}
}

// --- FTS5 full-text search ---
Expand Down Expand Up @@ -206,17 +217,26 @@ const stmts = {
`),
// Session cache statements
cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'),
cacheGetAll: db.prepare('SELECT * FROM session_cache'),
// Frequent sidebar/title refreshes do not need the potentially large search
// text or parser state. Keep the harness and transcript-location fields.
cacheGetAll: db.prepare(`
SELECT sessionId, folder, projectPath, summary, firstPrompt, created, modified,
messageCount, slug, aiTitle, fileMtime, runtime, sessionFile
FROM session_cache
`),
cacheUpsert: db.prepare(`
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, fileMtime, runtime, sessionFile)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, fileMtime, runtime, sessionFile, customTitle, textContent, headHash, indexedBytes, firstTimestamp, lastTimestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sessionId) DO UPDATE SET
folder = excluded.folder, projectPath = excluded.projectPath,
summary = excluded.summary, firstPrompt = excluded.firstPrompt,
created = excluded.created, modified = excluded.modified,
messageCount = excluded.messageCount, slug = excluded.slug,
aiTitle = excluded.aiTitle, fileMtime = excluded.fileMtime,
runtime = excluded.runtime, sessionFile = excluded.sessionFile
runtime = excluded.runtime, sessionFile = excluded.sessionFile,
customTitle = excluded.customTitle, textContent = excluded.textContent,
headHash = excluded.headHash, indexedBytes = excluded.indexedBytes,
firstTimestamp = excluded.firstTimestamp, lastTimestamp = excluded.lastTimestamp
`),
cacheGetByFolder: db.prepare('SELECT sessionId, fileMtime FROM session_cache WHERE folder = ?'),
cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'),
Expand Down Expand Up @@ -304,7 +324,9 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => {
s.sessionId, s.folder, s.projectPath, s.summary,
s.firstPrompt, s.created, s.modified, s.messageCount || 0,
s.slug || null, s.aiTitle || null, s.fileMtime || null,
s.runtime || 'claude', s.sessionFile || null
s.runtime || 'claude', s.sessionFile || null,
s.customTitle || null, s.textContent || null, s.headHash || null,
s.indexedBytes || 0, s.firstTimestamp || null, s.lastTimestamp || null
);
}
});
Expand Down
200 changes: 128 additions & 72 deletions harnesses/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { scanLines } = require('../jsonl-scan');
const { encodeProjectPath } = require('../encode-project-path');

const id = 'claude';
Expand Down Expand Up @@ -98,17 +100,18 @@ function transcriptPath({ sessionId, folder, sessionFile }) {
// --- Project path derivation ---

function extractCwdFromJsonl(filePath) {
let cwd = null;
const readCwd = (line) => {
try {
const entry = JSON.parse(line);
if (entry.cwd) { cwd = entry.cwd; return false; }
} catch {}
};
try {
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
for (const line of lines) {
if (!line) continue;
try {
const parsed = JSON.parse(line);
if (parsed.cwd) return parsed.cwd;
} catch {}
}
} catch {}
return null;
const { tail } = scanLines(filePath, 0, readCwd);
if (!cwd && tail) readCwd(tail);
} catch { return null; }
return cwd;
}

/** The project a folder belongs to, read out of any transcript it contains. */
Expand Down Expand Up @@ -224,74 +227,127 @@ function matchesLaunch(signals, { forkFrom, spawnedAt }) {

// --- Transcript parsing ---

/** Parse a single .jsonl file into a session object (or null if invalid) */
function readSessionFile(filePath, folder, projectPath) {
const HEAD_BYTES = 4096; // guard window for append-only transcripts
const TEXT_CONTENT_CAP = 8000; // how much body text the search index keeps
const TEXT_LINE_CAP = 500;

function hashHead(fd, stat) {
const n = Math.min(HEAD_BYTES, stat.size);
if (n === 0) return '';
const buf = Buffer.allocUnsafe(n);
if (fs.readSync(fd, buf, 0, n, 0) !== n) throw new Error('JSONL head changed during read');
// Version the state: pre-harness rows lack raw timestamp accumulators.
// Include file identity so an atomic replacement with the same prefix resets.
return 'v2:' + crypto.createHash('sha1')
.update(`${stat.dev}:${stat.ino}:`).update(buf).digest('hex');
}

/** Accumulator. Every field is either a first-occurrence or a running total,
* which is what makes resuming mid-file valid. */
function emptyState() {
return { summary: '', messageCount: 0, textContent: '', slug: null, customTitle: null, aiTitle: null, firstTimestamp: null, lastTimestamp: null };
}

function stateFrom(prev) {
return {
summary: prev.summary || '',
messageCount: prev.messageCount || 0,
textContent: prev.textContent || '',
slug: prev.slug || null,
customTitle: prev.customTitle || null,
aiTitle: prev.aiTitle || null,
firstTimestamp: prev.firstTimestamp || null,
lastTimestamp: prev.lastTimestamp || null,
};
}

function applyLine(line, st) {
let entry;
try { entry = JSON.parse(line); } catch { return; }

if (entry.timestamp) {
if (!st.firstTimestamp || entry.timestamp < st.firstTimestamp) st.firstTimestamp = entry.timestamp;
if (!st.lastTimestamp || entry.timestamp > st.lastTimestamp) st.lastTimestamp = entry.timestamp;
}

if (entry.slug && !st.slug) st.slug = entry.slug;
if (entry.type === 'custom-title' && entry.customTitle) st.customTitle = entry.customTitle;
if (entry.type === 'ai-title' && entry.aiTitle) st.aiTitle = entry.aiTitle;

if (entry.type === 'user' || entry.type === 'assistant' ||
(entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'))) {
st.messageCount++;
}

const msg = entry.message;
const text = typeof msg === 'string' ? msg :
(typeof msg?.content === 'string' ? msg.content :
(msg?.content?.[0]?.text || ''));

if (!st.summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) {
// Skip local command messages (! prefix) — use the next real user message
if (text && !/<bash-input>|<bash-stdout>|<local-command-caveat>/.test(text)) {
// Use scheduled task name if present
const taskMatch = text.match(/<scheduled-task\s+name="([^"]+)"/);
st.summary = taskMatch ? 'Scheduled: ' + taskMatch[1] : text.slice(0, 120);
}
}

if (text && st.textContent.length < TEXT_CONTENT_CAP) {
st.textContent += text.slice(0, TEXT_LINE_CAP) + '\n';
}
}

/**
* Parse metadata in bounded chunks. A cached row lets append-only transcripts
* resume at the previous newline instead of re-reading their entire history.
* Head changes, replacement, truncation, or a changed file with no growth reset
* the accumulator. In-place edits beyond the head while also growing the file
* are outside this append-only contract; a full re-index is needed for those.
*/
function readSessionFile(filePath, folder, projectPath, prev = null) {
const sessionId = path.basename(filePath, '.jsonl');
let fd = null;
try {
const stat = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(Boolean);
let summary = '';
let messageCount = 0;
let textContent = '';
let slug = null;
let customTitle = null;
let aiTitle = null;
// Real conversation time bounds. Resuming a session appends untimestamped
// bookkeeping records (last-prompt, mode, ai-title, …) which bump the file's
// mtime without any actual activity, so mtime can't be the displayed time.
let firstTimestamp = null;
let lastTimestamp = null;
for (const line of lines) {
const entry = JSON.parse(line);
if (entry.timestamp) {
// ISO-8601 UTC strings — lexicographic comparison is chronological
if (!firstTimestamp || entry.timestamp < firstTimestamp) firstTimestamp = entry.timestamp;
if (!lastTimestamp || entry.timestamp > lastTimestamp) lastTimestamp = entry.timestamp;
}
if (entry.slug && !slug) slug = entry.slug;
if (entry.type === 'custom-title' && entry.customTitle) {
customTitle = entry.customTitle;
}
if (entry.type === 'ai-title' && entry.aiTitle) {
aiTitle = entry.aiTitle;
}
if (entry.type === 'user' || entry.type === 'assistant' ||
(entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'))) {
messageCount++;
}
const msg = entry.message;
const text = typeof msg === 'string' ? msg :
(typeof msg?.content === 'string' ? msg.content :
(msg?.content?.[0]?.text || ''));
if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) {
// Skip local command messages (! prefix) — use the next real user message
if (text && !/<bash-input>|<bash-stdout>|<local-command-caveat>/.test(text)) {
// Use scheduled task name if present
const taskMatch = text.match(/<scheduled-task\s+name="([^"]+)"/);
summary = taskMatch ? 'Scheduled: ' + taskMatch[1] : text.slice(0, 120);
}
}
if (text && textContent.length < 8000) {
textContent += text.slice(0, 500) + '\n';
}
}
if (!summary || messageCount < 1) return null;
fd = fs.openSync(filePath, 'r');
const stat = fs.fstatSync(fd);
const fileMtime = stat.mtime.toISOString();
const headHash = hashHead(fd, stat);
const canResume = !!prev && !!headHash
&& prev.runtime === id && prev.sessionId === sessionId
&& prev.sessionFile === filePath && prev.headHash === headHash
&& Number.isSafeInteger(prev.indexedBytes) && prev.indexedBytes > 0
&& prev.indexedBytes <= stat.size
&& (prev.indexedBytes < stat.size || prev.fileMtime === fileMtime);
const st = canResume ? stateFrom(prev) : emptyState();
const start = canResume ? prev.indexedBytes : 0;
// Hash and scan the same descriptor and only the size captured above.
// Concurrent appends are left for the next pass; read errors return null
// rather than saving partial metadata under a supposedly up-to-date mtime.
const { consumed, read, tail } = scanLines(fd, start, line => applyLine(line, st), stat.size);
if (tail) applyLine(tail, st);
const after = fs.fstatSync(fd);
if (after.size < stat.size || (after.size === stat.size && after.mtimeMs !== stat.mtimeMs)) return null;
if (!st.summary || st.messageCount < 1) return null;
return {
sessionId, folder, projectPath,
runtime: id,
sessionFile: filePath,
summary, firstPrompt: summary,
// created/modified are display+sort values from message timestamps;
// fileMtime is the cache-invalidation key (compared against stat.mtime
// in refreshFolder). Old transcripts without timestamps fall back to stat.
created: firstTimestamp || stat.birthtime.toISOString(),
modified: lastTimestamp || stat.mtime.toISOString(),
fileMtime: stat.mtime.toISOString(),
messageCount, textContent, slug, customTitle, aiTitle,
sessionId, folder, projectPath, runtime: id, sessionFile: filePath,
summary: st.summary, firstPrompt: st.summary,
created: st.firstTimestamp || stat.birthtime.toISOString(),
modified: st.lastTimestamp || fileMtime,
fileMtime,
messageCount: st.messageCount, textContent: st.textContent,
slug: st.slug, customTitle: st.customTitle, aiTitle: st.aiTitle,
firstTimestamp: st.firstTimestamp, lastTimestamp: st.lastTimestamp,
headHash,
// A complete JSON value without a newline is displayed but cannot be
// accumulated safely: re-read it next time instead of double-counting it.
indexedBytes: tail ? 0 : consumed,
bytesRead: read + Math.min(HEAD_BYTES, stat.size),
};
} catch {
return null;
} finally {
if (fd !== null) { try { fs.closeSync(fd); } catch {} }
}
}

Expand Down
3 changes: 2 additions & 1 deletion harnesses/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
// listTranscripts(dir) absolute transcript paths in a folder directory
// sessionIdFromPath(file) transcript path → session id, without reading it
// transcriptPath(row) cached row → absolute transcript path
// readSessionFile(file, folder, projectPath) → session row, or null
// readSessionFile(file, folder, projectPath, prev?) → session row, or null
// prev is optional cached parser state; harnesses may ignore it.
// titleIndexPath() optional external title-index path
// readSessionTitles() optional Map<sessionId, title> from that index
// buildLaunchArgs({ sessionId, isNew, options }) → argv after the binary
Expand Down
91 changes: 91 additions & 0 deletions jsonl-scan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const fs = require('fs');

// Session .jsonl files routinely reach tens or hundreds of MB. Reading one into
// a JS string costs ~2x its size in RAM (V8 stores non-latin1 text as UTF-16),
// so any code that wants the first line — or one field — must not use
// readFileSync. These two helpers are the supported way to walk such a file.

const CHUNK_BYTES = 256 * 1024;

/**
* Walk the complete lines of a file from `startByte`, holding at most one chunk
* (plus the current line) in memory.
*
* `onLine(line)` may return false to stop early — useful when the caller only
* needs the first line carrying some field.
*
* Returns:
* consumed — offset just past the last complete line (a resume point)
* read — bytes actually pulled off disk
* tail — trailing bytes with no newline; NOT counted in `consumed`,
* because a partial line will be re-read on the next pass
* stopped — whether onLine asked to stop
*
* Accepts a path or an already-open descriptor. Reads only through endByte
* (default: the size at entry), so appends cannot make a scan run forever.
* I/O errors propagate: callers must not persist a partial scan as complete.
*/
function scanLines(filePath, startByte, onLine, endByte) {
const ownsFd = typeof filePath !== 'number';
const fd = ownsFd ? fs.openSync(filePath, 'r') : filePath;
let consumed = startByte;
let read = 0;
try {
if (endByte === undefined) endByte = fs.fstatSync(fd).size;
let pending = [];
let pendingBytes = 0;
let pos = startByte;
while (pos < endByte) {
// Each chunk owns its bytes. Pending slices never alias a reused buffer.
const buf = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, endByte - pos));
const n = fs.readSync(fd, buf, 0, buf.length, pos);
if (n === 0) throw new Error('JSONL file truncated during scan');
read += n;
let from = 0;
let nl;
const data = buf.subarray(0, n);
while ((nl = data.indexOf(0x0A, from)) !== -1) {
const piece = data.subarray(from, nl);
// Concatenate once per line, avoiding quadratic copies of long lines.
const line = pendingBytes
? Buffer.concat([...pending, piece], pendingBytes + piece.length).toString('utf8')
: piece.toString('utf8');
pending = [];
pendingBytes = 0;
consumed = pos + nl + 1;
if (line && onLine(line) === false) {
return { consumed, read, tail: '', stopped: true };
}
from = nl + 1;
}
if (from < n) {
pending.push(data.subarray(from));
pendingBytes += n - from;
}
pos += n;
}
const tail = pendingBytes ? Buffer.concat(pending, pendingBytes).toString('utf8') : '';
return { consumed, read, tail, stopped: false };
} finally {
if (ownsFd) fs.closeSync(fd);
}
}

/** Read at most `maxBytes` from the start of a file. Returns '' on failure. */
function readHead(filePath, maxBytes) {
let fd = null;
try {
fd = fs.openSync(filePath, 'r');
const buf = Buffer.allocUnsafe(maxBytes);
const n = fs.readSync(fd, buf, 0, maxBytes, 0);
return buf.toString('utf8', 0, n);
} catch {
return '';
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch {}
}
}
}

module.exports = { scanLines, readHead };
Loading
Loading