ticlawk 0.1.12-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +426 -0
  3. package/agent-freeway.mjs +2 -0
  4. package/assets/ticlawk-concept.svg +137 -0
  5. package/bin/agent-freeway.mjs +4 -0
  6. package/bin/ticlawk.mjs +594 -0
  7. package/cc-watcher.mjs +3 -0
  8. package/package.json +72 -0
  9. package/scripts/postinstall.mjs +61 -0
  10. package/src/adapters/telegram/index.mjs +359 -0
  11. package/src/adapters/ticlawk/api.mjs +360 -0
  12. package/src/adapters/ticlawk/cards.mjs +149 -0
  13. package/src/adapters/ticlawk/credentials.mjs +25 -0
  14. package/src/adapters/ticlawk/index.mjs +1229 -0
  15. package/src/adapters/ticlawk/wake-client.mjs +204 -0
  16. package/src/core/adapter-registry.mjs +50 -0
  17. package/src/core/argv.mjs +38 -0
  18. package/src/core/bindings/store.mjs +81 -0
  19. package/src/core/bus.mjs +91 -0
  20. package/src/core/config.mjs +203 -0
  21. package/src/core/daemon-install.mjs +246 -0
  22. package/src/core/diagnostics.mjs +79 -0
  23. package/src/core/events/worker-events.mjs +80 -0
  24. package/src/core/executables.mjs +106 -0
  25. package/src/core/host-id.mjs +48 -0
  26. package/src/core/http.mjs +65 -0
  27. package/src/core/logger.mjs +34 -0
  28. package/src/core/media/inbound.mjs +127 -0
  29. package/src/core/media/outbound.mjs +163 -0
  30. package/src/core/profiles.mjs +173 -0
  31. package/src/core/runtime-contract.mjs +68 -0
  32. package/src/core/runtime-env.mjs +9 -0
  33. package/src/core/runtime-registry.mjs +93 -0
  34. package/src/core/runtime-support.mjs +197 -0
  35. package/src/core/setup-readiness.mjs +86 -0
  36. package/src/core/store/json-file-store.mjs +47 -0
  37. package/src/core/ticlawk-control.mjs +92 -0
  38. package/src/core/uninstall.mjs +142 -0
  39. package/src/core/update-state.mjs +62 -0
  40. package/src/core/update.mjs +178 -0
  41. package/src/runtimes/claude-code/index.mjs +363 -0
  42. package/src/runtimes/claude-code/session.mjs +388 -0
  43. package/src/runtimes/claude-code/transcripts.mjs +206 -0
  44. package/src/runtimes/codex/index.mjs +306 -0
  45. package/src/runtimes/codex/session.mjs +750 -0
  46. package/src/runtimes/openclaw/gateway.mjs +269 -0
  47. package/src/runtimes/openclaw/identity.mjs +34 -0
  48. package/src/runtimes/openclaw/index.mjs +228 -0
  49. package/src/runtimes/openclaw/inflight.mjs +46 -0
  50. package/src/runtimes/openclaw/target.mjs +57 -0
  51. package/src/runtimes/opencode/index.mjs +318 -0
  52. package/src/runtimes/opencode/session.mjs +413 -0
  53. package/src/runtimes/pi/index.mjs +287 -0
  54. package/src/runtimes/pi/session.mjs +423 -0
  55. package/ticlawk.mjs +260 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Claude Code local session helpers.
3
+ *
4
+ * Pure functions and constants for working with `claude` CLI sessions:
5
+ * project directory encoding, transcript path resolution, spawning
6
+ * `claude -p` for prompt delivery and session creation.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { debugLog, debugError } from '../../core/logger.mjs';
13
+ import { buildRuntimeEnv } from '../../core/runtime-env.mjs';
14
+ import { getRuntimeExecutableConfig } from '../../core/config.mjs';
15
+ import { getExecutableVersion, isExecutablePath, resolveExecutable } from '../../core/executables.mjs';
16
+
17
+ export const CC_PROJECTS_DIR = process.env.CC_PROJECTS_DIR || `${homedir()}/.claude/projects`;
18
+ // Only watch sessions active in last 24h.
19
+ export const CC_MAX_AGE_MS = 24 * 60 * 60 * 1000;
20
+ export const DEFAULT_CC_EXEC_TIMEOUT_MS = 7200 * 1000;
21
+ export const DEFAULT_CC_COMMAND = 'claude';
22
+
23
+ export function resolveClaudePath(preferredPath = null) {
24
+ return resolveExecutable({
25
+ command: DEFAULT_CC_COMMAND,
26
+ preferredPath,
27
+ configuredPath: getRuntimeExecutableConfig('claude_code'),
28
+ envKey: 'CLAUDE_CODE_BIN',
29
+ });
30
+ }
31
+
32
+ export function requireClaudePath(preferredPath = null) {
33
+ const requested = String(preferredPath || '').trim();
34
+ if (requested && requested.includes('/') && !isExecutablePath(requested)) {
35
+ throw new Error(`Claude Code binary is no longer available at: ${requested}. Reconnect this Claude Code agent or set CLAUDE_CODE_BIN / \`ticlawk config set runtimes.claude_code.path <path>\`.`);
36
+ }
37
+ const claudePath = resolveClaudePath(preferredPath);
38
+ if (claudePath) return claudePath;
39
+ throw new Error('Claude Code CLI not found. Install Claude Code, ensure it is on PATH, or set CLAUDE_CODE_BIN / `ticlawk config set runtimes.claude_code.path <path>`.');
40
+ }
41
+
42
+ export function getClaudeCodeRuntimeHealth(preferredPath = null) {
43
+ const claudePath = resolveClaudePath(preferredPath);
44
+ return {
45
+ available: Boolean(claudePath),
46
+ path: claudePath,
47
+ version: getExecutableVersion(claudePath),
48
+ };
49
+ }
50
+
51
+ export function prettyProjectName(encoded) {
52
+ // "-home-wei-Projects-myapp" → "myapp"
53
+ const parts = encoded.replace(/^-/, '').split('-');
54
+ // Find the last meaningful segment (skip home, user, Projects)
55
+ const skip = new Set(['home', 'wei', 'Projects']);
56
+ const meaningful = parts.filter(p => !skip.has(p));
57
+ return meaningful.join('-') || encoded;
58
+ }
59
+
60
+ export function encodeClaudeProjectDir(projectDir) {
61
+ return `-${String(projectDir || '').replace(/^\/+/, '').replace(/\//g, '-')}`;
62
+ }
63
+
64
+ export function getClaudeTranscriptPath(projectDir, sessionId) {
65
+ return join(CC_PROJECTS_DIR, encodeClaudeProjectDir(projectDir), `${sessionId}.jsonl`);
66
+ }
67
+
68
+ // Try to recover a human-readable error string from `claude -p
69
+ // --output-format json` stdout when the child exits non-zero. The
70
+ // stream prints jsonl events; on failure the last `result` event
71
+ // (or any event with `is_error: true`) usually carries the message.
72
+ function extractCCError(stdout) {
73
+ if (!stdout) return null;
74
+ const lines = stdout.split('\n').map(l => l.trim()).filter(Boolean);
75
+ for (let i = lines.length - 1; i >= 0; i--) {
76
+ let parsed;
77
+ try { parsed = JSON.parse(lines[i]); } catch { continue; }
78
+ if (parsed?.is_error && typeof parsed.result === 'string') return parsed.result;
79
+ if (typeof parsed?.error === 'string') return parsed.error;
80
+ if (typeof parsed?.error?.message === 'string') return parsed.error.message;
81
+ if (parsed?.type === 'result' && typeof parsed.result === 'string') return parsed.result;
82
+ }
83
+ // Fallback: raw last line
84
+ return lines[lines.length - 1] || null;
85
+ }
86
+
87
+ function extractCCResultPayload(stdout) {
88
+ if (!stdout) return null;
89
+ const lines = stdout.split('\n').map(l => l.trim()).filter(Boolean);
90
+ for (let i = lines.length - 1; i >= 0; i--) {
91
+ try {
92
+ const parsed = JSON.parse(lines[i]);
93
+ if (parsed?.type === 'result') return parsed;
94
+ if (typeof parsed?.result === 'string') return parsed;
95
+ } catch {}
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function extractCCAssistantText(payload) {
101
+ const content = payload?.message?.content;
102
+ if (!Array.isArray(content)) return '';
103
+ return content
104
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
105
+ .map((block) => block.text)
106
+ .join('');
107
+ }
108
+
109
+ export function runCCPrompt({ sessionId, projectDir, message, claudePath = null, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS) }) {
110
+ const args = sessionId
111
+ ? ['-p', message, '--resume', sessionId, '--dangerously-skip-permissions', '--output-format', 'json']
112
+ : ['-p', message, '--dangerously-skip-permissions', '--output-format', 'json'];
113
+
114
+ return new Promise((resolve, reject) => {
115
+ const startedAt = Date.now();
116
+ const claudeCommand = requireClaudePath(claudePath);
117
+ const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(), stdio: ['ignore', 'pipe', 'ignore'] });
118
+ let stdout = '';
119
+ let settled = false;
120
+
121
+ const settle = (fn, value) => {
122
+ if (settled) return;
123
+ settled = true;
124
+ if (timeout) clearTimeout(timeout);
125
+ fn(value);
126
+ };
127
+
128
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
129
+
130
+ let timeout = null;
131
+ if (timeoutMs > 0) {
132
+ timeout = setTimeout(() => { child.kill('SIGTERM'); }, timeoutMs);
133
+ timeout.unref();
134
+ }
135
+
136
+ child.on('error', (err) => {
137
+ const wrapped = new Error(err.message || 'claude spawn failed');
138
+ wrapped.info = {
139
+ ok: false,
140
+ code: null,
141
+ signal: null,
142
+ durationMs: Date.now() - startedAt,
143
+ kind: 'spawn-failed',
144
+ error: err.message,
145
+ };
146
+ settle(reject, wrapped);
147
+ });
148
+
149
+ child.on('exit', (code, signal) => {
150
+ const durationMs = Date.now() - startedAt;
151
+ if (signal) {
152
+ const wrapped = new Error(extractCCError(stdout) || `claude killed by ${signal}`);
153
+ wrapped.info = {
154
+ ok: false,
155
+ code: null,
156
+ signal,
157
+ durationMs,
158
+ kind: 'killed',
159
+ errorMessage: extractCCError(stdout),
160
+ };
161
+ settle(reject, wrapped);
162
+ return;
163
+ }
164
+ if (code !== 0) {
165
+ const wrapped = new Error(extractCCError(stdout) || `claude exited with code ${code}`);
166
+ wrapped.info = {
167
+ ok: false,
168
+ code,
169
+ signal: null,
170
+ durationMs,
171
+ kind: 'exit-error',
172
+ errorMessage: extractCCError(stdout),
173
+ };
174
+ settle(reject, wrapped);
175
+ return;
176
+ }
177
+
178
+ const payload = extractCCResultPayload(stdout);
179
+ if (!payload || typeof payload.result !== 'string') {
180
+ const wrapped = new Error('claude result missing from stdout');
181
+ wrapped.info = {
182
+ ok: false,
183
+ code: 0,
184
+ signal: null,
185
+ durationMs,
186
+ kind: 'invalid-output',
187
+ errorMessage: wrapped.message,
188
+ };
189
+ settle(reject, wrapped);
190
+ return;
191
+ }
192
+
193
+ settle(resolve, {
194
+ sessionId: typeof payload.session_id === 'string' ? payload.session_id : sessionId || null,
195
+ text: payload.result,
196
+ durationMs,
197
+ stopReason: payload.stop_reason || null,
198
+ raw: payload,
199
+ });
200
+ });
201
+ });
202
+ }
203
+
204
+ export function streamCCPrompt({
205
+ sessionId,
206
+ projectDir,
207
+ message,
208
+ claudePath = null,
209
+ timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS),
210
+ onEvent,
211
+ }) {
212
+ const args = sessionId
213
+ ? ['-p', message, '--resume', sessionId, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions']
214
+ : ['-p', message, '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--dangerously-skip-permissions'];
215
+
216
+ return new Promise((resolve, reject) => {
217
+ const startedAt = Date.now();
218
+ const claudeCommand = requireClaudePath(claudePath);
219
+ const child = spawn(claudeCommand, args, { cwd: projectDir, env: buildRuntimeEnv(), stdio: ['ignore', 'pipe', 'ignore'] });
220
+ let stdout = '';
221
+ let buffer = '';
222
+ let settled = false;
223
+ let seenTurnStart = false;
224
+ let activeSessionId = sessionId || null;
225
+ let finalText = '';
226
+ let eventChain = Promise.resolve();
227
+
228
+ const emit = (event) => {
229
+ if (typeof onEvent !== 'function') return;
230
+ eventChain = eventChain
231
+ .then(() => onEvent(event))
232
+ .catch(() => {});
233
+ return eventChain;
234
+ };
235
+
236
+ const settle = (fn, value) => {
237
+ if (settled) return;
238
+ settled = true;
239
+ if (timeout) clearTimeout(timeout);
240
+ eventChain
241
+ .catch(() => {})
242
+ .finally(() => fn(value));
243
+ };
244
+
245
+ const parseLine = (line) => {
246
+ let parsed;
247
+ try { parsed = JSON.parse(line); } catch { return; }
248
+
249
+ if (typeof parsed?.session_id === 'string') {
250
+ activeSessionId = parsed.session_id;
251
+ }
252
+
253
+ if (!seenTurnStart && activeSessionId && (parsed?.type === 'system' || parsed?.type === 'stream_event')) {
254
+ seenTurnStart = true;
255
+ emit({ type: 'turn.started', sessionId: activeSessionId });
256
+ }
257
+
258
+ if (parsed?.type === 'stream_event' && parsed.event?.type === 'content_block_delta') {
259
+ const deltaText = parsed.event?.delta?.text;
260
+ if (typeof deltaText === 'string' && deltaText) {
261
+ finalText += deltaText;
262
+ emit({ type: 'message.delta', sessionId: activeSessionId, text: deltaText });
263
+ }
264
+ return;
265
+ }
266
+
267
+ if (parsed?.type === 'assistant') {
268
+ const assistantText = extractCCAssistantText(parsed);
269
+ if (assistantText) finalText = assistantText;
270
+ return;
271
+ }
272
+
273
+ if (parsed?.type === 'result' && typeof parsed.result === 'string') {
274
+ finalText = parsed.result;
275
+ }
276
+ };
277
+
278
+ child.stdout.on('data', (chunk) => {
279
+ const text = chunk.toString('utf8');
280
+ stdout += text;
281
+ buffer += text;
282
+ const lines = buffer.split('\n');
283
+ buffer = lines.pop() || '';
284
+ for (const line of lines) {
285
+ const trimmed = line.trim();
286
+ if (!trimmed) continue;
287
+ parseLine(trimmed);
288
+ }
289
+ });
290
+
291
+ let timeout = null;
292
+ if (timeoutMs > 0) {
293
+ timeout = setTimeout(() => { child.kill('SIGTERM'); }, timeoutMs);
294
+ timeout.unref();
295
+ }
296
+
297
+ child.on('error', (err) => {
298
+ const wrapped = new Error(err.message || 'claude spawn failed');
299
+ wrapped.info = {
300
+ ok: false,
301
+ code: null,
302
+ signal: null,
303
+ durationMs: Date.now() - startedAt,
304
+ kind: 'spawn-failed',
305
+ error: err.message,
306
+ };
307
+ settle(reject, wrapped);
308
+ });
309
+
310
+ child.on('exit', (code, signal) => {
311
+ const durationMs = Date.now() - startedAt;
312
+ const trailing = buffer.trim();
313
+ if (trailing) parseLine(trailing);
314
+ if (signal) {
315
+ const wrapped = new Error(extractCCError(stdout) || `claude killed by ${signal}`);
316
+ wrapped.info = {
317
+ ok: false,
318
+ code: null,
319
+ signal,
320
+ durationMs,
321
+ kind: 'killed',
322
+ errorMessage: extractCCError(stdout),
323
+ };
324
+ settle(reject, wrapped);
325
+ return;
326
+ }
327
+ if (code !== 0) {
328
+ const wrapped = new Error(extractCCError(stdout) || `claude exited with code ${code}`);
329
+ wrapped.info = {
330
+ ok: false,
331
+ code,
332
+ signal: null,
333
+ durationMs,
334
+ kind: 'exit-error',
335
+ errorMessage: extractCCError(stdout),
336
+ };
337
+ settle(reject, wrapped);
338
+ return;
339
+ }
340
+ const payload = extractCCResultPayload(stdout);
341
+ if (payload?.result && typeof payload.result === 'string') {
342
+ finalText = payload.result;
343
+ }
344
+ if (!finalText) {
345
+ const wrapped = new Error('claude result missing from stdout');
346
+ wrapped.info = {
347
+ ok: false,
348
+ code: 0,
349
+ signal: null,
350
+ durationMs,
351
+ kind: 'invalid-output',
352
+ errorMessage: wrapped.message,
353
+ };
354
+ settle(reject, wrapped);
355
+ return;
356
+ }
357
+ settle(resolve, {
358
+ sessionId: activeSessionId || (typeof payload?.session_id === 'string' ? payload.session_id : sessionId || null),
359
+ text: finalText,
360
+ durationMs,
361
+ stopReason: payload?.stop_reason || null,
362
+ raw: payload || null,
363
+ });
364
+ });
365
+ });
366
+ }
367
+
368
+ export async function createCCSession({ projectDir, message, claudePath = null, timeoutMs = Number(process.env.CC_EXEC_TIMEOUT_MS || DEFAULT_CC_EXEC_TIMEOUT_MS) }) {
369
+ const result = await runCCPrompt({ projectDir, message, claudePath, timeoutMs });
370
+ if (!result?.sessionId) {
371
+ const wrapped = new Error('claude session_id missing from output');
372
+ wrapped.info = {
373
+ ok: false,
374
+ code: 0,
375
+ signal: null,
376
+ durationMs: result?.durationMs || 0,
377
+ kind: 'invalid-output',
378
+ errorMessage: wrapped.message,
379
+ };
380
+ throw wrapped;
381
+ }
382
+ return {
383
+ sessionId: String(result.sessionId),
384
+ resultText: typeof result.text === 'string' ? result.text : '',
385
+ durationMs: result.durationMs || 0,
386
+ raw: result.raw || null,
387
+ };
388
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Claude Code transcript helpers.
3
+ *
4
+ * Discovers Claude Code sessions and exposes transcript utilities. Runtime
5
+ * code should import this module directly; the root cc-watcher.mjs file is
6
+ * kept as a compatibility re-export for older deep imports.
7
+ */
8
+
9
+ import { readFileSync, statSync, watch } from 'node:fs';
10
+ import { readdir } from 'node:fs/promises';
11
+ import { join, basename } from 'node:path';
12
+
13
+ /**
14
+ * Scan a .claude/projects/ directory for session .jsonl files.
15
+ * Returns an array of { sessionId, project, path, projectDir }.
16
+ */
17
+ export async function discoverSessions(claudeProjectsDir) {
18
+ const sessions = [];
19
+
20
+ let projectDirs;
21
+ try {
22
+ projectDirs = await readdir(claudeProjectsDir);
23
+ } catch {
24
+ return sessions;
25
+ }
26
+
27
+ for (const projName of projectDirs) {
28
+ const projPath = join(claudeProjectsDir, projName);
29
+
30
+ let files;
31
+ try {
32
+ files = await readdir(projPath);
33
+ } catch {
34
+ continue;
35
+ }
36
+
37
+ for (const file of files) {
38
+ if (!file.endsWith('.jsonl')) continue;
39
+ if (file.includes('subagent')) continue;
40
+
41
+ const filePath = join(projPath, file);
42
+ try {
43
+ statSync(filePath);
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ const sessionId = basename(file, '.jsonl');
49
+
50
+ let projectDir = null;
51
+ try {
52
+ const content = readFileSync(filePath, 'utf8');
53
+ for (const line of content.split('\n')) {
54
+ if (!line.trim()) continue;
55
+ const entry = JSON.parse(line);
56
+ if (entry.cwd) {
57
+ projectDir = entry.cwd;
58
+ break;
59
+ }
60
+ }
61
+ } catch { /* ignore */ }
62
+
63
+ if (!projectDir) {
64
+ projectDir = '/' + projName.replace(/^-/, '').replace(/-/g, '/');
65
+ }
66
+
67
+ sessions.push({
68
+ sessionId,
69
+ project: projName,
70
+ path: filePath,
71
+ projectDir,
72
+ });
73
+ }
74
+ }
75
+
76
+ return sessions;
77
+ }
78
+
79
+ /**
80
+ * Watch a transcript .jsonl file for new assistant messages.
81
+ * Calls onMessage(msg) when a new assistant message is detected.
82
+ * msg = { type: 'assistant', text: string, raw: object }
83
+ *
84
+ * Returns a cleanup function to stop watching.
85
+ */
86
+ export function watchTranscript(transcriptPath, onMessage) {
87
+ let lastSize = 0;
88
+
89
+ try {
90
+ lastSize = statSync(transcriptPath).size;
91
+ } catch {
92
+ // File might not exist yet.
93
+ }
94
+
95
+ const checkForNew = () => {
96
+ let currentSize;
97
+ try {
98
+ currentSize = statSync(transcriptPath).size;
99
+ } catch {
100
+ return;
101
+ }
102
+
103
+ if (currentSize <= lastSize) return;
104
+
105
+ const buf = readFileSync(transcriptPath);
106
+ const newContent = buf.subarray(lastSize).toString('utf8');
107
+ lastSize = currentSize;
108
+
109
+ const lines = newContent.split('\n').filter(l => l.trim());
110
+ for (const line of lines) {
111
+ let entry;
112
+ try {
113
+ entry = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ if (entry.type !== 'assistant' && entry.type !== 'user') continue;
119
+
120
+ const content = entry.message?.content;
121
+ let text = '';
122
+ let toolNames = [];
123
+ if (typeof content === 'string') {
124
+ text = content;
125
+ } else if (Array.isArray(content)) {
126
+ text = content
127
+ .filter(c => c.type === 'text')
128
+ .map(c => c.text)
129
+ .join('\n');
130
+ toolNames = content
131
+ .filter(c => c.type === 'tool_use')
132
+ .map(c => c.name);
133
+ }
134
+
135
+ const stopReason = entry.message?.stop_reason;
136
+
137
+ if (entry.type === 'assistant' && toolNames.length > 0 && stopReason === 'tool_use') {
138
+ Promise.resolve(onMessage({ type: 'tool_use', text: '', toolNames, raw: entry })).catch((err) => {
139
+ console.error('[cc-watcher] onMessage failed:', err.message);
140
+ });
141
+ continue;
142
+ }
143
+
144
+ if (!text) continue;
145
+
146
+ Promise.resolve(onMessage({ type: entry.type, text, raw: entry })).catch((err) => {
147
+ console.error('[cc-watcher] onMessage failed:', err.message);
148
+ });
149
+ }
150
+ };
151
+
152
+ let watcher;
153
+ try {
154
+ watcher = watch(transcriptPath, { persistent: false }, () => {
155
+ checkForNew();
156
+ });
157
+ } catch {
158
+ // fs.watch not available; polling below still covers updates.
159
+ }
160
+
161
+ const interval = setInterval(checkForNew, 2000);
162
+
163
+ return () => {
164
+ clearInterval(interval);
165
+ watcher?.close();
166
+ };
167
+ }
168
+
169
+ function extractText(entry) {
170
+ const content = entry.message?.content;
171
+ if (typeof content === 'string') return content;
172
+ if (Array.isArray(content)) {
173
+ return content.filter(c => c.type === 'text').map(c => c.text).join('\n');
174
+ }
175
+ return '';
176
+ }
177
+
178
+ /**
179
+ * Read transcript and return all user/assistant messages.
180
+ * Each entry: { type: 'user'|'assistant', text: string, timestamp: number }
181
+ */
182
+ export function readTranscript(transcriptPath) {
183
+ let content;
184
+ try {
185
+ content = readFileSync(transcriptPath, 'utf8');
186
+ } catch {
187
+ return [];
188
+ }
189
+
190
+ const messages = [];
191
+ for (const line of content.split('\n')) {
192
+ if (!line.trim()) continue;
193
+ let entry;
194
+ try { entry = JSON.parse(line); } catch { continue; }
195
+
196
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
197
+
198
+ const text = extractText(entry);
199
+ if (!text) continue;
200
+
201
+ const timestamp = entry.message?.timestamp || entry.timestamp || 0;
202
+ messages.push({ type: entry.type, text, timestamp });
203
+ }
204
+
205
+ return messages;
206
+ }