osborn 0.1.6 → 0.5.3

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.
@@ -0,0 +1,775 @@
1
+ /**
2
+ * session-access.ts — Programmatic access to Claude Agent SDK session artifacts
3
+ *
4
+ * Given a session ID and project directory, resolves all related files:
5
+ * conversations, sub-agents, tool results, plans, todos, tasks, file history.
6
+ *
7
+ * The JSONL files contain FULL, untruncated content — no caps on tool results,
8
+ * file reads, or assistant reasoning.
9
+ *
10
+ *
11
+ * ═══════════════════════════════════════════════════════════════════════════════
12
+ * CLAUDE SESSION STORAGE — DIRECTORY MAP & RELATIONSHIP RULES
13
+ * ═══════════════════════════════════════════════════════════════════════════════
14
+ *
15
+ * All session data lives under a single root directory:
16
+ * claudeDir = CLAUDE_CONFIG_DIR env var || ~/.claude
17
+ *
18
+ * The "project slug" is the project's absolute path with "/" replaced by "-":
19
+ * /Users/foo/my-project → -Users-foo-my-project
20
+ *
21
+ *
22
+ * ── DIRECTORY STRUCTURE ─────────────────────────────────────────────────────
23
+ *
24
+ * {claudeDir}/
25
+ * ├── settings.json Global settings (MCP servers, etc.)
26
+ * ├── history.jsonl Top-level conversation index
27
+ * │
28
+ * ├── projects/{projectSlug}/ ALL SESSION DATA lives here
29
+ * │ ├── {sessionId}.jsonl Main conversation (1 file per session)
30
+ * │ ├── {sessionId}/
31
+ * │ │ ├── subagents/
32
+ * │ │ │ └── agent-{hash}.jsonl Sub-agent conversations (one per Task tool call)
33
+ * │ │ └── tool-results/
34
+ * │ │ └── {hash}.txt Large tool outputs (exceeding inline threshold)
35
+ * │ ├── .session-meta.json Maps sessionId → {agentMode, lastUpdated, projectPath}
36
+ * │ └── memory/MEMORY.md Persistent project memory
37
+ * │
38
+ * ├── plans/ GLOBAL — not per-session
39
+ * │ └── {slug}.md Plan files (linked to sessions via slug field)
40
+ * │
41
+ * ├── todos/
42
+ * │ └── {sessionId}-agent-{sessionId}.json Todo items per session
43
+ * │
44
+ * ├── tasks/{sessionId}/ Only exists if TaskCreate was used
45
+ * │ ├── .lock
46
+ * │ └── .highwatermark Task ID counter
47
+ * │
48
+ * ├── file-history/{sessionId}/ Versioned backups of edited files
49
+ * │ └── {hash}@v{N} e.g., 65c16cbbfa80f250@v3
50
+ * │
51
+ * ├── debug/ Debug logs
52
+ * ├── shell-snapshots/ Terminal snapshots
53
+ * ├── paste-cache/ Paste history
54
+ * └── statsig/ Analytics
55
+ *
56
+ *
57
+ * ── GIVEN A SESSION ID, HOW TO FIND EVERYTHING ─────────────────────────────
58
+ *
59
+ * You need TWO inputs: sessionId + projectDir (the working directory).
60
+ * From those, everything is deterministic:
61
+ *
62
+ * projectSlug = projectDir.replace(/\//g, '-')
63
+ * base = {claudeDir}/projects/{projectSlug}
64
+ *
65
+ * RELIABLE (deterministic paths):
66
+ * ┌──────────────────┬────────────────────────────────────────────────────────┐
67
+ * │ Artifact │ Path │
68
+ * ├──────────────────┼────────────────────────────────────────────────────────┤
69
+ * │ Conversation │ {base}/{sessionId}.jsonl (always) │
70
+ * │ Sub-agents │ {base}/{sessionId}/subagents/agent-*.jsonl (glob) │
71
+ * │ Tool result cache│ {base}/{sessionId}/tool-results/*.txt (glob) │
72
+ * │ Todos │ {claudeDir}/todos/{sessionId}-agent-{sessionId}.json │
73
+ * │ Tasks │ {claudeDir}/tasks/{sessionId}/ (may not exist) │
74
+ * │ File history │ {claudeDir}/file-history/{sessionId}/ (may not exist) │
75
+ * └──────────────────┴────────────────────────────────────────────────────────┘
76
+ *
77
+ * UNRELIABLE (requires JSONL parsing):
78
+ * ┌──────────────────┬────────────────────────────────────────────────────────┐
79
+ * │ Plan file │ Read JSONL → extract `slug` field → │
80
+ * │ │ {claudeDir}/plans/{slug}.md │
81
+ * │ │ ⚠ Slug is NOT unique per session (10+ sessions can │
82
+ * │ │ share the same slug). Slug is sometimes null. │
83
+ * │ │ Fallback: search JSONL for Write tool calls targeting │
84
+ * │ │ ~/.claude/plans/*.md to find the actual plan file. │
85
+ * ├──────────────────┼────────────────────────────────────────────────────────┤
86
+ * │ Session metadata │ {base}/.session-meta.json │
87
+ * │ │ ⚠ May be in a DIFFERENT project slug if the agent's │
88
+ * │ │ cwd differs from the project root (e.g., running │
89
+ * │ │ from /project/agent/ puts metadata in │
90
+ * │ │ -project-agent/ not -project/). │
91
+ * └──────────────────┴────────────────────────────────────────────────────────┘
92
+ *
93
+ *
94
+ * ── JSONL LINE SHAPES (each line is one JSON object) ───────────────────────
95
+ *
96
+ * type: "file-history-snapshot" (usually line 1)
97
+ * { type, messageId, snapshot: { trackedFileBackups: { [path]: { backupFileName, version } }, timestamp } }
98
+ *
99
+ * type: "user" (user message)
100
+ * { type: "user", sessionId, slug, cwd, version, gitBranch, uuid, parentUuid, timestamp,
101
+ * message: { role: "user", content: [{ type: "text", text: "..." }] } }
102
+ *
103
+ * type: "user" (tool_result — SDK wraps results in a user message)
104
+ * { type: "user", uuid, parentUuid, timestamp,
105
+ * message: { role: "user", content: [{ type: "tool_result", tool_use_id, content: "FULL output" }] },
106
+ * toolUseResult: { type: "text", file?: { filePath, content: "FULL FILE CONTENT" } } }
107
+ *
108
+ * type: "assistant" (with thinking + tool_use)
109
+ * { type: "assistant", sessionId, uuid, parentUuid, timestamp, requestId,
110
+ * message: { model, id, role: "assistant",
111
+ * content: [
112
+ * { type: "thinking", thinking: "full reasoning..." },
113
+ * { type: "text", text: "visible response" },
114
+ * { type: "tool_use", id, name, input: {...} }
115
+ * ],
116
+ * usage: { input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens }
117
+ * } }
118
+ *
119
+ * type: "progress" (hook events)
120
+ * { type: "progress", parentUuid, parentToolUseID, toolUseID, timestamp,
121
+ * data: { type: "hook_progress", hookEvent, hookName, command } }
122
+ *
123
+ *
124
+ * ── HOW ARTIFACTS RELATE TO EACH OTHER ─────────────────────────────────────
125
+ *
126
+ * Messages link via parentUuid chain (forms a linked list of the conversation).
127
+ *
128
+ * Sub-agents: Parent session sees Task tool_use + tool_result.
129
+ * The sub-agent's FULL work is in its own JSONL file under subagents/.
130
+ * The "queue-operation" events in the main JSONL map task_id to sub-agent filenames.
131
+ *
132
+ * Tool result cache: When a tool result exceeds the inline threshold, the SDK
133
+ * stores the full output in {sessionId}/tool-results/{hash}.txt and references
134
+ * it from the JSONL. The JSONL tool_result content may be truncated while the
135
+ * .txt file has the full content.
136
+ *
137
+ * Plans: Stored GLOBALLY in {claudeDir}/plans/{slug}.md — NOT per-session.
138
+ * The "slug" field in JSONL events is the link. Multiple sessions can share
139
+ * the same slug. A session's plan is found by: read JSONL → find slug → open
140
+ * plans/{slug}.md. If slug is null, search for Write calls to plans/*.md.
141
+ *
142
+ * Todos: One file per session-agent pair. May be empty [].
143
+ * Path is deterministic from sessionId alone.
144
+ *
145
+ * File history: Versioned backups of files the agent edited during the session.
146
+ * Only exists if Write/Edit tools were used.
147
+ *
148
+ * Session metadata (.session-meta.json): A SINGLE file per project slug that
149
+ * maps ALL session IDs → { agentMode, editMode, lastUpdated, projectPath }.
150
+ * Careful: the cwd when the agent ran determines which project slug folder
151
+ * the metadata ends up in.
152
+ *
153
+ *
154
+ * ── KEY FACTS FOR BUILDING NEW FUNCTIONS ───────────────────────────────────
155
+ *
156
+ * 1. JSONL content is FULL and UNTRUNCATED — complete file reads, bash outputs,
157
+ * web search results, thinking blocks, assistant reasoning. No caps.
158
+ *
159
+ * 2. The SDK and CLI share the SAME storage. Sessions created by the SDK can
160
+ * be resumed via CLI and vice versa.
161
+ *
162
+ * 3. Storage location is controlled ONLY by CLAUDE_CONFIG_DIR env var + the
163
+ * project path slug derived from cwd. There is no sessionDir or outputPath
164
+ * option in the SDK.
165
+ *
166
+ * 4. JSONL files are written INCREMENTALLY by the SDK — new lines are appended.
167
+ * This enables the watchSessionFile() pattern for real-time tailing.
168
+ *
169
+ * 5. All functions accept SessionAccessOptions with optional claudeDir to
170
+ * override the default path resolution.
171
+ *
172
+ * 6. The projectSlug encoding is simple: replace all "/" with "-".
173
+ * /Users/newupgrade/Desktop/Developer/osborn → -Users-newupgrade-Desktop-Developer-osborn
174
+ *
175
+ * ═══════════════════════════════════════════════════════════════════════════════
176
+ */
177
+ import { existsSync, readFileSync, readdirSync, statSync, watch } from 'fs';
178
+ import { homedir } from 'os';
179
+ import { join, basename } from 'path';
180
+ // ============================================================
181
+ // PATH RESOLUTION
182
+ // ============================================================
183
+ function resolveClaudeDir(opts) {
184
+ return opts?.claudeDir || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
185
+ }
186
+ function projectPathToSlug(projectPath) {
187
+ return projectPath.replace(/\//g, '-');
188
+ }
189
+ /**
190
+ * Resolve all artifact paths for a session.
191
+ * Does NOT read any files — just computes paths and checks existence.
192
+ *
193
+ * @param sessionId - The session UUID
194
+ * @param projectDir - The project working directory (e.g., /Users/.../osborn)
195
+ * @param opts.claudeDir - Override the claude directory path
196
+ */
197
+ export function getSessionPaths(sessionId, projectDir, opts) {
198
+ const claudeDir = resolveClaudeDir(opts);
199
+ const slug = projectPathToSlug(projectDir);
200
+ const projectsDir = join(claudeDir, 'projects', slug);
201
+ const conversationPath = join(projectsDir, `${sessionId}.jsonl`);
202
+ const sessionDir = join(projectsDir, sessionId);
203
+ // Glob sub-agents
204
+ const subagentsDir = join(sessionDir, 'subagents');
205
+ let subagents = [];
206
+ if (existsSync(subagentsDir)) {
207
+ subagents = readdirSync(subagentsDir)
208
+ .filter(f => f.endsWith('.jsonl'))
209
+ .map(f => join(subagentsDir, f));
210
+ }
211
+ // Glob tool results
212
+ const toolResultsDir = join(sessionDir, 'tool-results');
213
+ let toolResults = [];
214
+ if (existsSync(toolResultsDir)) {
215
+ toolResults = readdirSync(toolResultsDir)
216
+ .filter(f => f.endsWith('.txt'))
217
+ .map(f => join(toolResultsDir, f));
218
+ }
219
+ return {
220
+ conversation: conversationPath,
221
+ sessionDir,
222
+ subagents,
223
+ toolResults,
224
+ todos: join(claudeDir, 'todos', `${sessionId}-agent-${sessionId}.json`),
225
+ tasks: join(claudeDir, 'tasks', sessionId),
226
+ fileHistory: join(claudeDir, 'file-history', sessionId),
227
+ debugLog: join(claudeDir, 'debug', `${sessionId}.txt`),
228
+ exists: existsSync(conversationPath),
229
+ };
230
+ }
231
+ // ============================================================
232
+ // JSONL PARSING
233
+ // ============================================================
234
+ /**
235
+ * Parse a single JSONL line into a structured SessionMessage.
236
+ */
237
+ function parseLine(raw) {
238
+ const type = raw.type;
239
+ if (type === 'file-history-snapshot') {
240
+ return { type: 'file-history-snapshot', raw, timestamp: raw.snapshot?.timestamp };
241
+ }
242
+ if (type === 'progress') {
243
+ return {
244
+ type: 'progress',
245
+ raw,
246
+ uuid: raw.uuid,
247
+ parentUuid: raw.parentUuid,
248
+ timestamp: raw.timestamp,
249
+ };
250
+ }
251
+ if (type === 'user') {
252
+ const content = raw.message?.content;
253
+ if (Array.isArray(content) && content.length > 0) {
254
+ // Check if it's a tool_result
255
+ if (content[0]?.type === 'tool_result') {
256
+ const tr = content[0];
257
+ return {
258
+ type: 'tool_result',
259
+ raw,
260
+ uuid: raw.uuid,
261
+ parentUuid: raw.parentUuid,
262
+ timestamp: raw.timestamp,
263
+ sessionId: raw.sessionId,
264
+ slug: raw.slug,
265
+ toolUseId: tr.tool_use_id,
266
+ toolResultContent: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
267
+ toolUseResult: raw.toolUseResult,
268
+ };
269
+ }
270
+ // Regular user message
271
+ const texts = [];
272
+ for (const block of content) {
273
+ if (block?.type === 'text' && block.text) {
274
+ texts.push(block.text);
275
+ }
276
+ }
277
+ return {
278
+ type: 'user',
279
+ raw,
280
+ role: 'user',
281
+ text: texts.join('\n') || undefined,
282
+ uuid: raw.uuid,
283
+ parentUuid: raw.parentUuid,
284
+ timestamp: raw.timestamp,
285
+ sessionId: raw.sessionId,
286
+ slug: raw.slug,
287
+ };
288
+ }
289
+ }
290
+ if (type === 'assistant') {
291
+ const content = raw.message?.content;
292
+ const texts = [];
293
+ const toolUses = [];
294
+ if (Array.isArray(content)) {
295
+ for (const block of content) {
296
+ if (block?.type === 'text' && block.text) {
297
+ texts.push(block.text);
298
+ }
299
+ if (block?.type === 'thinking' && block.thinking) {
300
+ texts.push(`[thinking] ${block.thinking}`);
301
+ }
302
+ if (block?.type === 'tool_use') {
303
+ toolUses.push({ name: block.name, id: block.id, input: block.input });
304
+ }
305
+ }
306
+ }
307
+ // If it has tool_use blocks, return those (there may also be text)
308
+ if (toolUses.length > 0) {
309
+ // Return the first tool_use (caller can access raw for all)
310
+ return {
311
+ type: 'tool_use',
312
+ raw,
313
+ uuid: raw.uuid,
314
+ parentUuid: raw.parentUuid,
315
+ timestamp: raw.timestamp,
316
+ sessionId: raw.sessionId,
317
+ slug: raw.slug,
318
+ text: texts.join('\n') || undefined,
319
+ toolName: toolUses[0].name,
320
+ toolId: toolUses[0].id,
321
+ toolInput: toolUses[0].input,
322
+ };
323
+ }
324
+ return {
325
+ type: 'assistant',
326
+ raw,
327
+ role: 'assistant',
328
+ text: texts.join('\n') || undefined,
329
+ uuid: raw.uuid,
330
+ parentUuid: raw.parentUuid,
331
+ timestamp: raw.timestamp,
332
+ sessionId: raw.sessionId,
333
+ slug: raw.slug,
334
+ };
335
+ }
336
+ return { type: 'other', raw, timestamp: raw.timestamp };
337
+ }
338
+ /**
339
+ * Read and parse a JSONL file into structured messages.
340
+ */
341
+ function readJsonl(filePath) {
342
+ if (!existsSync(filePath))
343
+ return [];
344
+ try {
345
+ const content = readFileSync(filePath, 'utf-8');
346
+ const lines = content.trim().split('\n').filter(Boolean);
347
+ const messages = [];
348
+ for (const line of lines) {
349
+ try {
350
+ const raw = JSON.parse(line);
351
+ messages.push(parseLine(raw));
352
+ }
353
+ catch {
354
+ // Skip malformed lines
355
+ }
356
+ }
357
+ return messages;
358
+ }
359
+ catch {
360
+ return [];
361
+ }
362
+ }
363
+ /**
364
+ * Read raw JSON objects from a JSONL file — no parsing into SessionMessage.
365
+ * Returns the actual JSON as-is so you can inspect the full object shapes.
366
+ */
367
+ export function readRawJsonl(filePath) {
368
+ if (!existsSync(filePath))
369
+ return [];
370
+ try {
371
+ const content = readFileSync(filePath, 'utf-8');
372
+ const lines = content.trim().split('\n').filter(Boolean);
373
+ const objects = [];
374
+ for (const line of lines) {
375
+ try {
376
+ objects.push(JSON.parse(line));
377
+ }
378
+ catch {
379
+ // Skip malformed lines
380
+ }
381
+ }
382
+ return objects;
383
+ }
384
+ catch {
385
+ return [];
386
+ }
387
+ }
388
+ // ============================================================
389
+ // PUBLIC API
390
+ // ============================================================
391
+ /**
392
+ * Read the full conversation history from a session.
393
+ * Returns structured messages in chronological order.
394
+ *
395
+ * @param sessionId - The session UUID
396
+ * @param projectDir - The project working directory (e.g., /Users/.../osborn)
397
+ * @param opts.lastN - Only return the last N messages (default: all)
398
+ * @param opts.types - Filter by message type (default: all types)
399
+ * @param opts.claudeDir - Override the claude directory path
400
+ */
401
+ export function readSessionHistory(sessionId, projectDir, opts) {
402
+ const paths = getSessionPaths(sessionId, projectDir, { claudeDir: opts?.claudeDir });
403
+ let messages = readJsonl(paths.conversation);
404
+ if (opts?.types) {
405
+ messages = messages.filter(m => opts.types.includes(m.type));
406
+ }
407
+ if (opts?.lastN) {
408
+ messages = messages.slice(-opts.lastN);
409
+ }
410
+ return messages;
411
+ }
412
+ /**
413
+ * Get sub-agent transcripts for a session.
414
+ * Each sub-agent has its own JSONL file with a full conversation.
415
+ */
416
+ export function getSubagentTranscripts(sessionId, projectDir, opts) {
417
+ const paths = getSessionPaths(sessionId, projectDir, opts);
418
+ return paths.subagents.map(filePath => {
419
+ const filename = basename(filePath, '.jsonl');
420
+ const taskId = filename.replace('agent-', '');
421
+ const stats = statSync(filePath);
422
+ const messages = readJsonl(filePath);
423
+ return {
424
+ taskId,
425
+ filePath,
426
+ fileSize: stats.size,
427
+ messages,
428
+ };
429
+ });
430
+ }
431
+ /**
432
+ * Get the raw JSON objects from the main session JSONL file.
433
+ * Returns the actual JSON as written by the SDK — no transformation.
434
+ * Use this when you need the full object shapes for inspection.
435
+ *
436
+ * @param lastN - Only return the last N lines (default: all)
437
+ */
438
+ export function getRawSessionJsonl(sessionId, projectDir, opts) {
439
+ const paths = getSessionPaths(sessionId, projectDir, opts);
440
+ let lines = readRawJsonl(paths.conversation);
441
+ if (opts?.lastN) {
442
+ lines = lines.slice(-opts.lastN);
443
+ }
444
+ return lines;
445
+ }
446
+ /**
447
+ * Get the main agent transcript AND all sub-agent transcripts together.
448
+ * This is the primary function for accessing what Claude is doing —
449
+ * the agent's own conversation plus every sub-agent it spawned.
450
+ *
451
+ * The agent transcript contains: user messages, assistant reasoning,
452
+ * tool_use calls, tool_result responses, thinking blocks, progress events.
453
+ *
454
+ * Sub-agent transcripts contain the same structure but for each Task
455
+ * tool invocation (Explore agents, Plan agents, etc.).
456
+ */
457
+ export function getSessionTranscripts(sessionId, projectDir, opts) {
458
+ const paths = getSessionPaths(sessionId, projectDir, opts);
459
+ // Read the main agent transcript
460
+ const agentMessages = readJsonl(paths.conversation);
461
+ const agentRawLines = readRawJsonl(paths.conversation);
462
+ const agentFileSize = existsSync(paths.conversation)
463
+ ? statSync(paths.conversation).size
464
+ : 0;
465
+ // Read all sub-agent transcripts
466
+ const subagents = getSubagentTranscripts(sessionId, projectDir, opts);
467
+ return {
468
+ agent: {
469
+ sessionId,
470
+ filePath: paths.conversation,
471
+ fileSize: agentFileSize,
472
+ messages: agentMessages,
473
+ rawLines: agentRawLines,
474
+ },
475
+ subagents,
476
+ };
477
+ }
478
+ /**
479
+ * Find the plan file associated with a session.
480
+ * Plans are linked via the `slug` field in JSONL events.
481
+ *
482
+ * Falls back to searching for Write tool calls targeting ~/.claude/plans/
483
+ * if slug is not found.
484
+ */
485
+ export function getSessionPlan(sessionId, projectDir, opts) {
486
+ const paths = getSessionPaths(sessionId, projectDir, opts);
487
+ if (!paths.exists)
488
+ return null;
489
+ const messages = readJsonl(paths.conversation);
490
+ const claudeDir = resolveClaudeDir(opts);
491
+ // Strategy 1: Extract slug from messages
492
+ for (const msg of messages) {
493
+ if (msg.slug && msg.slug !== 'null') {
494
+ const planPath = join(claudeDir, 'plans', `${msg.slug}.md`);
495
+ if (existsSync(planPath)) {
496
+ try {
497
+ const content = readFileSync(planPath, 'utf-8');
498
+ return { slug: msg.slug, planPath, content, exists: true };
499
+ }
500
+ catch {
501
+ return { slug: msg.slug, planPath, content: '', exists: true };
502
+ }
503
+ }
504
+ // Slug found but plan file doesn't exist
505
+ return { slug: msg.slug, planPath, content: '', exists: false };
506
+ }
507
+ }
508
+ // Strategy 2: Search for Write tool calls targeting plans/
509
+ for (const msg of messages) {
510
+ if (msg.type === 'tool_use' && msg.toolName === 'Write') {
511
+ const filePath = msg.toolInput?.file_path;
512
+ if (filePath && filePath.includes('.claude/plans/')) {
513
+ const slug = basename(filePath, '.md');
514
+ if (existsSync(filePath)) {
515
+ try {
516
+ const content = readFileSync(filePath, 'utf-8');
517
+ return { slug, planPath: filePath, content, exists: true };
518
+ }
519
+ catch {
520
+ return { slug, planPath: filePath, content: '', exists: true };
521
+ }
522
+ }
523
+ return { slug, planPath: filePath, content: '', exists: false };
524
+ }
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+ /**
530
+ * Get recent tool use/result pairs from a session.
531
+ * Returns matched pairs of (tool_use → tool_result) in chronological order.
532
+ *
533
+ * @param lastN - Number of recent pairs to return (default: 10, 0 = all)
534
+ * @param opts.toolNameFilter - Optional: only return results from these tool names (e.g., ['Read', 'WebSearch'])
535
+ */
536
+ export function getRecentToolResults(sessionId, projectDir, lastN = 10, opts) {
537
+ const messages = readSessionHistory(sessionId, projectDir, { claudeDir: opts?.claudeDir });
538
+ // Build a map of tool_use by tool ID
539
+ const toolUseMap = new Map();
540
+ const results = [];
541
+ const toolFilter = opts?.toolNameFilter?.map(t => t.toLowerCase());
542
+ for (const msg of messages) {
543
+ if (msg.type === 'tool_use' && msg.toolId) {
544
+ toolUseMap.set(msg.toolId, msg);
545
+ }
546
+ if (msg.type === 'tool_result' && msg.toolUseId) {
547
+ const toolUse = toolUseMap.get(msg.toolUseId);
548
+ if (toolUse) {
549
+ // Apply tool name filter if provided
550
+ if (toolFilter && !toolFilter.includes(toolUse.toolName.toLowerCase())) {
551
+ continue;
552
+ }
553
+ results.push({
554
+ toolName: toolUse.toolName,
555
+ toolId: toolUse.toolId,
556
+ toolInput: toolUse.toolInput || {},
557
+ resultContent: msg.toolResultContent || '',
558
+ toolUseResult: msg.toolUseResult,
559
+ timestamp: toolUse.timestamp,
560
+ });
561
+ }
562
+ }
563
+ }
564
+ return lastN > 0 ? results.slice(-lastN) : results;
565
+ }
566
+ /**
567
+ * Watch a session JSONL file for new entries.
568
+ * Calls back with each new parsed entry as it's appended.
569
+ *
570
+ * Returns the fs.FSWatcher (call .close() to stop watching).
571
+ */
572
+ export function watchSessionFile(sessionId, projectDir, callback, opts) {
573
+ const paths = getSessionPaths(sessionId, projectDir, opts);
574
+ if (!paths.exists)
575
+ return null;
576
+ let lastSize = 0;
577
+ try {
578
+ lastSize = statSync(paths.conversation).size;
579
+ }
580
+ catch {
581
+ return null;
582
+ }
583
+ const watcher = watch(paths.conversation, (eventType) => {
584
+ if (eventType !== 'change')
585
+ return;
586
+ try {
587
+ const currentSize = statSync(paths.conversation).size;
588
+ if (currentSize <= lastSize)
589
+ return;
590
+ // Read the full file and extract only the new bytes
591
+ const fullContent = readFileSync(paths.conversation, 'utf-8');
592
+ const newPart = fullContent.substring(lastSize);
593
+ lastSize = currentSize;
594
+ const newLines = newPart.trim().split('\n').filter(Boolean);
595
+ for (const line of newLines) {
596
+ try {
597
+ const raw = JSON.parse(line);
598
+ callback(parseLine(raw));
599
+ }
600
+ catch {
601
+ // Skip malformed
602
+ }
603
+ }
604
+ }
605
+ catch {
606
+ // File access error
607
+ }
608
+ });
609
+ return watcher;
610
+ }
611
+ /**
612
+ * Get the session slug (human-readable name) from a session.
613
+ */
614
+ export function getSessionSlug(sessionId, projectDir, opts) {
615
+ const messages = readSessionHistory(sessionId, projectDir, { lastN: 5, claudeDir: opts?.claudeDir });
616
+ for (const msg of messages) {
617
+ if (msg.slug && msg.slug !== 'null') {
618
+ return msg.slug;
619
+ }
620
+ }
621
+ return null;
622
+ }
623
+ /**
624
+ * Get todos for a session.
625
+ */
626
+ export function getSessionTodos(sessionId, opts) {
627
+ const todosPath = join(resolveClaudeDir(opts), 'todos', `${sessionId}-agent-${sessionId}.json`);
628
+ if (!existsSync(todosPath))
629
+ return [];
630
+ try {
631
+ return JSON.parse(readFileSync(todosPath, 'utf-8'));
632
+ }
633
+ catch {
634
+ return [];
635
+ }
636
+ }
637
+ /**
638
+ * Get the text-only conversation (user messages + assistant text responses).
639
+ * Useful for building context for the fast brain.
640
+ *
641
+ * @param lastN - Number of exchanges to return
642
+ * @param maxCharsPerMessage - Max chars per message (0 = no limit)
643
+ */
644
+ export function getConversationText(sessionId, projectDir, lastN = 30, maxCharsPerMessage = 0, opts) {
645
+ const messages = readSessionHistory(sessionId, projectDir, {
646
+ types: ['user', 'assistant'],
647
+ claudeDir: opts?.claudeDir,
648
+ });
649
+ const exchanges = messages
650
+ .filter(m => m.text)
651
+ .map(m => ({
652
+ role: m.role || m.type,
653
+ text: maxCharsPerMessage > 0 ? m.text.substring(0, maxCharsPerMessage) : m.text,
654
+ }));
655
+ return exchanges.slice(-lastN);
656
+ }
657
+ /**
658
+ * Search the session JSONL for entries matching a keyword (case-insensitive).
659
+ * Searches across text, toolResultContent, toolName, and toolInput.
660
+ * Returns matching entries with 500-char excerpts around the match.
661
+ *
662
+ * @param keyword - The search keyword (case-insensitive)
663
+ * @param opts.maxResults - Maximum number of results to return (default: 20)
664
+ */
665
+ export function searchSessionJsonl(sessionId, projectDir, keyword, opts) {
666
+ const messages = readSessionHistory(sessionId, projectDir, { claudeDir: opts?.claudeDir });
667
+ const maxResults = opts?.maxResults || 20;
668
+ const results = [];
669
+ const lowerKeyword = keyword.toLowerCase();
670
+ for (const msg of messages) {
671
+ if (results.length >= maxResults)
672
+ break;
673
+ // Search in text content
674
+ if (msg.text && msg.text.toLowerCase().includes(lowerKeyword)) {
675
+ const idx = msg.text.toLowerCase().indexOf(lowerKeyword);
676
+ const start = Math.max(0, idx - 100);
677
+ const end = Math.min(msg.text.length, idx + keyword.length + 400);
678
+ const excerpt = (start > 0 ? '...' : '') + msg.text.substring(start, end) + (end < msg.text.length ? '...' : '');
679
+ results.push({ type: msg.type, text: excerpt, timestamp: msg.timestamp });
680
+ continue;
681
+ }
682
+ // Search in tool result content
683
+ if (msg.toolResultContent && msg.toolResultContent.toLowerCase().includes(lowerKeyword)) {
684
+ const idx = msg.toolResultContent.toLowerCase().indexOf(lowerKeyword);
685
+ const start = Math.max(0, idx - 100);
686
+ const end = Math.min(msg.toolResultContent.length, idx + keyword.length + 400);
687
+ const excerpt = `[tool_result] ` + (start > 0 ? '...' : '') + msg.toolResultContent.substring(start, end) + (end < msg.toolResultContent.length ? '...' : '');
688
+ results.push({ type: msg.type, text: excerpt, timestamp: msg.timestamp });
689
+ continue;
690
+ }
691
+ // Search in tool name
692
+ if (msg.toolName && msg.toolName.toLowerCase().includes(lowerKeyword)) {
693
+ const inputPreview = msg.toolInput ? JSON.stringify(msg.toolInput).substring(0, 200) : '';
694
+ results.push({ type: msg.type, text: `[${msg.toolName}: ${inputPreview}]`, timestamp: msg.timestamp });
695
+ continue;
696
+ }
697
+ // Search in tool input
698
+ if (msg.toolInput) {
699
+ const inputStr = JSON.stringify(msg.toolInput);
700
+ if (inputStr.toLowerCase().includes(lowerKeyword)) {
701
+ results.push({ type: msg.type, text: `[${msg.toolName || 'tool'}: ${inputStr.substring(0, 500)}]`, timestamp: msg.timestamp });
702
+ }
703
+ }
704
+ }
705
+ return results;
706
+ }
707
+ /**
708
+ * Get session stats: message counts, tool usage breakdown, data sizes.
709
+ * Helps the fast brain decide how much data to read and which tools to query.
710
+ */
711
+ export function getSessionStats(sessionId, projectDir, opts) {
712
+ const paths = getSessionPaths(sessionId, projectDir, opts);
713
+ if (!paths.exists)
714
+ return null;
715
+ const messages = readJsonl(paths.conversation);
716
+ const toolBreakdown = {};
717
+ let userMessages = 0;
718
+ let assistantMessages = 0;
719
+ let toolUseCount = 0;
720
+ let toolResultCount = 0;
721
+ for (const msg of messages) {
722
+ if (msg.type === 'user')
723
+ userMessages++;
724
+ else if (msg.type === 'assistant')
725
+ assistantMessages++;
726
+ else if (msg.type === 'tool_use') {
727
+ toolUseCount++;
728
+ const name = msg.toolName || 'unknown';
729
+ toolBreakdown[name] = (toolBreakdown[name] || 0) + 1;
730
+ }
731
+ else if (msg.type === 'tool_result')
732
+ toolResultCount++;
733
+ }
734
+ return {
735
+ totalMessages: messages.length,
736
+ userMessages,
737
+ assistantMessages,
738
+ toolUseCount,
739
+ toolResultCount,
740
+ toolBreakdown,
741
+ subagentCount: paths.subagents.length,
742
+ fileSizeBytes: existsSync(paths.conversation) ? statSync(paths.conversation).size : 0,
743
+ firstTimestamp: messages.find(m => m.timestamp)?.timestamp,
744
+ lastTimestamp: [...messages].reverse().find(m => m.timestamp)?.timestamp,
745
+ };
746
+ }
747
+ /**
748
+ * Get a quick summary of a session: slug, message count, timestamps, tools used.
749
+ */
750
+ export function getSessionSummary(sessionId, projectDir, opts) {
751
+ const paths = getSessionPaths(sessionId, projectDir, opts);
752
+ if (!paths.exists)
753
+ return null;
754
+ const messages = readJsonl(paths.conversation);
755
+ const userMessages = messages.filter(m => m.type === 'user');
756
+ const assistantMessages = messages.filter(m => m.type === 'assistant');
757
+ const toolUses = messages.filter(m => m.type === 'tool_use');
758
+ const uniqueTools = Array.from(new Set(toolUses.map(t => t.toolName).filter(Boolean)));
759
+ const firstTimestamp = messages.find(m => m.timestamp)?.timestamp;
760
+ const lastTimestamp = [...messages].reverse().find(m => m.timestamp)?.timestamp;
761
+ const slug = messages.find(m => m.slug && m.slug !== 'null')?.slug;
762
+ return {
763
+ sessionId,
764
+ slug,
765
+ messageCount: messages.length,
766
+ userMessages: userMessages.length,
767
+ assistantMessages: assistantMessages.length,
768
+ toolUseCount: toolUses.length,
769
+ uniqueTools,
770
+ subagentCount: paths.subagents.length,
771
+ firstTimestamp,
772
+ lastTimestamp,
773
+ fileSize: existsSync(paths.conversation) ? statSync(paths.conversation).size : 0,
774
+ };
775
+ }