@vale-dinap/lucidly 0.0.0-stage → 0.2.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.
@@ -0,0 +1,367 @@
1
+ import { PLANNING_TOOLS, summarizeTool } from "./claude-tools.js";
2
+ const REJECTED = /doesn't want to (?:proceed|take this action)|user rejected|tool use was rejected|permission to use .* (?:was )?denied/i;
3
+ function blockText(content) {
4
+ if (typeof content === 'string')
5
+ return content;
6
+ if (!Array.isArray(content))
7
+ return content == null ? '' : JSON.stringify(content);
8
+ return content
9
+ .map((b) => (b?.type === 'text' ? b.text : b?.type === 'image' ? '[image]' : typeof b === 'string' ? b : ''))
10
+ .filter(Boolean)
11
+ .join('\n');
12
+ }
13
+ const TAG = /<\/?[a-z][a-z_-]*(?:\s[^>]*)?\/?>/g;
14
+ /** Turn a raw user message into prompt text, or null when it is harness noise. */
15
+ export function cleanPrompt(s) {
16
+ let t = s
17
+ .replace(/<(system-reminder|ide_opened_file|ide_selection|ide_diagnostics)>[\s\S]*?<\/\1>/g, '')
18
+ .trim();
19
+ if (/^<(local-command-stdout|local-command-stderr|local-command-caveat|bash-stdout|bash-stderr|task-notification)>/.test(t))
20
+ return null;
21
+ const cmd = /<command-name>([\s\S]*?)<\/command-name>/.exec(t);
22
+ if (cmd) {
23
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t)?.[1]?.trim() ?? '';
24
+ return `${cmd[1].trim()} ${args}`.trim();
25
+ }
26
+ const bash = /^<bash-input>([\s\S]*?)<\/bash-input>/.exec(t);
27
+ if (bash)
28
+ return `! ${bash[1].trim()}`;
29
+ if (/<pasted_content[\s>]/.test(t)) {
30
+ const own = t.replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content>/g, ' ').replace(TAG, '').trim();
31
+ if (own)
32
+ return own;
33
+ const inner = /<pasted_content[^>]*>([\s\S]*?)(?:<\/pasted_content>|$)/.exec(t)?.[1] ?? '';
34
+ const line = inner.split('\n').find((l) => l.trim() && !/^-{3,}$/.test(l.trim()));
35
+ return line ? `[pasted] ${line.trim()}` : '[pasted content]';
36
+ }
37
+ t = t.replace(TAG, '').trim();
38
+ return t || null;
39
+ }
40
+ /** Prompt text for the detail view: harness markup removed, pasted content kept. */
41
+ export function displayPrompt(s) {
42
+ const cmd = /<command-name>[\s\S]*?<\/command-name>/.test(s) ? cleanPrompt(s) : null;
43
+ if (cmd)
44
+ return cmd;
45
+ return s
46
+ .replace(/<(system-reminder|ide_opened_file|ide_selection|ide_diagnostics)>[\s\S]*?<\/\1>/g, '')
47
+ .replace(/<pasted_content[^>]*>/g, '──── pasted ────\n')
48
+ .replace(/<\/pasted_content>/g, '\n────────────────')
49
+ .replace(TAG, '')
50
+ .trim();
51
+ }
52
+ /** Harness notifications injected as user messages (background task finished...). */
53
+ export function harnessNotice(s) {
54
+ const m = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/.exec(s);
55
+ if (!m)
56
+ return null;
57
+ const summary = /<summary>([\s\S]*?)<\/summary>/.exec(m[1])?.[1]?.trim();
58
+ const status = /<status>([\s\S]*?)<\/status>/.exec(m[1])?.[1]?.trim();
59
+ return summary ?? `Background task ${status ?? 'update'}`;
60
+ }
61
+ function mapStatus(s) {
62
+ return s === 'completed' ? 'completed' : s === 'in_progress' ? 'in_progress' : 'pending';
63
+ }
64
+ function toHunks(sp) {
65
+ const out = [];
66
+ for (const h of sp) {
67
+ if (!h || !Array.isArray(h.lines))
68
+ continue;
69
+ out.push({
70
+ oldStart: Number(h.oldStart) || 0,
71
+ oldLines: Number(h.oldLines) || 0,
72
+ newStart: Number(h.newStart) || 0,
73
+ newLines: Number(h.newLines) || 0,
74
+ lines: h.lines.map(String),
75
+ });
76
+ }
77
+ return out.length ? out : undefined;
78
+ }
79
+ export class ClaudeCodeAdapter {
80
+ name = 'claude-code';
81
+ seen = new Set();
82
+ metaSent = false;
83
+ cwdSent = false;
84
+ /** TaskCreate callId → subject, and task id → subject. */
85
+ taskCalls = new Map();
86
+ taskIds = new Map();
87
+ planCalls = new Set();
88
+ /** Background subagent calls still running, by tool_use id. */
89
+ bgCalls = new Set();
90
+ /** Message ids whose usage was already counted (a message spans several records). */
91
+ counted = new Set();
92
+ /** Usage of the latest message, held until its content blocks have been emitted. */
93
+ pendingUsage = null;
94
+ /** @param scope set for subagent transcripts: the callId of the spawning Agent call. */
95
+ scope;
96
+ constructor(scope) {
97
+ this.scope = scope;
98
+ }
99
+ push(input) {
100
+ const rec = input;
101
+ if (!rec || typeof rec !== 'object')
102
+ return [];
103
+ if (rec.uuid) {
104
+ if (this.seen.has(rec.uuid))
105
+ return [];
106
+ this.seen.add(rec.uuid);
107
+ }
108
+ const out = [];
109
+ if (this.pendingUsage && (rec.type !== 'assistant' || rec.message?.id !== this.pendingUsage.id))
110
+ out.push(...this.flush());
111
+ const ts = rec.timestamp;
112
+ const emit = (e) => {
113
+ if (this.scope)
114
+ e.scope = this.scope;
115
+ if (!e.ts && ts)
116
+ e.ts = ts;
117
+ out.push(e);
118
+ };
119
+ // The first records (queue operations, snapshots) often carry a session id but no cwd:
120
+ // announce the run once, and the working folder as soon as a record has it.
121
+ if (!this.scope && !this.metaSent && (rec.cwd || rec.sessionId || rec.session_id)) {
122
+ this.metaSent = true;
123
+ this.cwdSent = !!rec.cwd;
124
+ emit({ type: 'run.meta', agent: 'Claude Code', cwd: rec.cwd, sessionId: rec.sessionId ?? rec.session_id });
125
+ }
126
+ else if (!this.scope && !this.cwdSent && rec.cwd) {
127
+ this.cwdSent = true;
128
+ emit({ type: 'run.meta', cwd: rec.cwd });
129
+ }
130
+ switch (rec.type) {
131
+ case 'ai-title':
132
+ case 'summary':
133
+ if (!this.scope && (rec.aiTitle || rec.summary))
134
+ emit({ type: 'run.meta', title: rec.aiTitle ?? rec.summary });
135
+ break;
136
+ case 'user':
137
+ if (!rec.isSidechain || this.scope)
138
+ this.onUser(rec, emit);
139
+ break;
140
+ case 'queue-operation':
141
+ // Background work finishing is queued for the agent as a task notification.
142
+ if (!this.scope && typeof rec.content === 'string')
143
+ this.onQueued(rec.content, emit);
144
+ break;
145
+ case 'assistant':
146
+ if (!rec.isSidechain || this.scope) {
147
+ this.onAssistant(rec, emit);
148
+ this.noteUsage(rec);
149
+ }
150
+ break;
151
+ case 'system':
152
+ this.onSystem(rec, emit);
153
+ break;
154
+ case 'result':
155
+ // stream-json end of a run
156
+ if (rec.is_error)
157
+ emit({ type: 'notice', level: 'error', text: `Run ended with an error (${rec.subtype ?? 'error'})`, raw: rec.result });
158
+ emit({ type: 'turn.end' });
159
+ break;
160
+ }
161
+ return out;
162
+ }
163
+ /** Emits the held usage event. Sources call this at the end of each batch of lines. */
164
+ flush() {
165
+ const p = this.pendingUsage;
166
+ this.pendingUsage = null;
167
+ return p ? [p.ev] : [];
168
+ }
169
+ noteUsage(rec) {
170
+ const m = rec.message;
171
+ const u = m?.usage;
172
+ if (!u || !m.model || m.model === '<synthetic>' || rec.isApiErrorMessage)
173
+ return;
174
+ const id = m.id ?? rec.uuid;
175
+ if (!id || this.counted.has(id))
176
+ return;
177
+ this.counted.add(id);
178
+ const ev = {
179
+ type: 'usage',
180
+ model: String(m.model),
181
+ input: Number(u.input_tokens) || 0,
182
+ output: Number(u.output_tokens) || 0,
183
+ cacheRead: Number(u.cache_read_input_tokens) || 0,
184
+ cacheWrite: Number(u.cache_creation_input_tokens) || 0,
185
+ };
186
+ if (this.scope)
187
+ ev.scope = this.scope;
188
+ if (rec.timestamp)
189
+ ev.ts = rec.timestamp;
190
+ this.pendingUsage = { id, ev };
191
+ }
192
+ onUser(rec, emit) {
193
+ if (rec.isMeta)
194
+ return;
195
+ // The summary itself is not a prompt; the compact_boundary record already marks the event.
196
+ if (rec.isCompactSummary || rec.isVisibleInTranscriptOnly)
197
+ return;
198
+ const content = rec.message?.content;
199
+ const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
200
+ const texts = [];
201
+ // Claude Code attaches the edit's real diff (with file line numbers) to the record.
202
+ const results = blocks.filter((b) => b.type === 'tool_result').length;
203
+ const sp = rec.toolUseResult?.structuredPatch;
204
+ const patch = results === 1 && Array.isArray(sp) && sp.length ? toHunks(sp) : undefined;
205
+ for (const b of blocks) {
206
+ if (b.type === 'tool_result') {
207
+ const id = b.tool_use_id;
208
+ const output = blockText(b.content);
209
+ if (this.taskCalls.has(id)) {
210
+ const m = /#?(\d+)/.exec(output);
211
+ if (m)
212
+ this.taskIds.set(m[1], this.taskCalls.get(id));
213
+ }
214
+ if (this.planCalls.has(id))
215
+ continue;
216
+ const tr = rec.toolUseResult;
217
+ const fo = tr?.usage?.output_tokens;
218
+ if (results === 1 && tr?.agentId && typeof fo === 'number') {
219
+ // The subagent's own transcript may hold a placeholder for its last response's output.
220
+ emit({ type: 'usage', scope: id, final: true, model: String(tr.resolvedModel ?? ''), input: 0, output: fo, cacheRead: 0, cacheWrite: 0 });
221
+ }
222
+ const rejected = !!b.is_error && REJECTED.test(output);
223
+ if (!b.is_error && (tr?.isAsync || tr?.status === 'async_launched')) {
224
+ this.bgCalls.add(id);
225
+ emit({ type: 'tool.end', callId: id, ok: true, output, background: true });
226
+ continue;
227
+ }
228
+ emit({ type: 'tool.end', callId: id, ok: !b.is_error, rejected, output, ...(patch && !b.is_error ? { patch } : {}) });
229
+ }
230
+ else if (b.type === 'text') {
231
+ texts.push(b.text);
232
+ }
233
+ else if (b.type === 'image') {
234
+ texts.push('[image]');
235
+ }
236
+ }
237
+ if (texts.length === 0)
238
+ return;
239
+ const raw = texts.join('\n');
240
+ if (/^\s*\[Request interrupted by user/.test(raw)) {
241
+ emit({ type: 'turn.end', interrupted: true });
242
+ return;
243
+ }
244
+ const notice = harnessNotice(raw);
245
+ if (notice)
246
+ this.onQueued(raw, emit);
247
+ if (notice) {
248
+ emit({ type: 'notice', level: 'info', text: notice, raw });
249
+ return;
250
+ }
251
+ const text = cleanPrompt(raw);
252
+ if (text)
253
+ emit({ type: 'user.prompt', text, raw: displayPrompt(raw) || text });
254
+ }
255
+ /** A task notification for a background subagent we saw start: it finished (or failed). */
256
+ onQueued(text, emit) {
257
+ const m = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/.exec(text);
258
+ if (!m)
259
+ return;
260
+ const id = /<tool-use-id>([\s\S]*?)<\/tool-use-id>/.exec(m[1])?.[1]?.trim();
261
+ if (!id || !this.bgCalls.has(id))
262
+ return;
263
+ const status = /<status>([\s\S]*?)<\/status>/.exec(m[1])?.[1]?.trim() ?? '';
264
+ if (status === 'running')
265
+ return;
266
+ this.bgCalls.delete(id);
267
+ const summary = /<summary>([\s\S]*?)<\/summary>/.exec(m[1])?.[1]?.trim();
268
+ const result = /<result>([\s\S]*?)<\/result>/.exec(m[1])?.[1]?.trim();
269
+ const ok = status === 'completed';
270
+ emit({ type: 'tool.end', callId: id, ok, output: [summary, result].filter(Boolean).join('\n\n') || `Background agent ${status}` });
271
+ }
272
+ onAssistant(rec, emit) {
273
+ const content = rec.message?.content;
274
+ if (!Array.isArray(content))
275
+ return;
276
+ if (rec.isApiErrorMessage || rec.message?.model === '<synthetic>') {
277
+ // Harness-generated, not the model speaking (API errors, connection failures).
278
+ const text = blockText(content).trim();
279
+ if (text && text !== 'No response requested.')
280
+ emit({ type: 'notice', level: 'error', text, blocking: !!rec.isApiErrorMessage });
281
+ return;
282
+ }
283
+ for (const b of content) {
284
+ if (b.type === 'text') {
285
+ const t = String(b.text ?? '');
286
+ if (t.trim() && t.trim() !== 'No response requested.')
287
+ emit({ type: 'agent.text', text: t });
288
+ }
289
+ else if (b.type === 'thinking') {
290
+ if (b.thinking?.trim())
291
+ emit({ type: 'agent.thinking', text: b.thinking });
292
+ }
293
+ else if (b.type === 'tool_use') {
294
+ this.onToolUse(b, emit);
295
+ }
296
+ }
297
+ }
298
+ onToolUse(b, emit) {
299
+ const name = b.name;
300
+ const input = b.input ?? {};
301
+ if (PLANNING_TOOLS.has(name)) {
302
+ this.planCalls.add(b.id);
303
+ const raw = JSON.stringify(input, null, 2);
304
+ if (name === 'TodoWrite' && Array.isArray(input.todos)) {
305
+ const items = input.todos.map((t) => ({
306
+ id: t.id ? String(t.id) : undefined,
307
+ title: String(t.content ?? t.subject ?? ''),
308
+ status: mapStatus(t.status),
309
+ }));
310
+ emit({ type: 'plan.set', source: 'TodoWrite', replace: true, items, raw });
311
+ }
312
+ else if (name === 'TaskCreate') {
313
+ const title = String(input.subject ?? input.description ?? 'Task');
314
+ this.taskCalls.set(b.id, title);
315
+ emit({ type: 'plan.set', source: 'Tasks', replace: false, items: [{ title, status: 'pending' }], raw });
316
+ }
317
+ else if (name === 'TaskUpdate') {
318
+ const id = String(input.taskId ?? input.id ?? '');
319
+ const title = this.taskIds.get(id);
320
+ if (input.status === 'deleted')
321
+ emit({ type: 'plan.update', title, deleted: true, raw });
322
+ else
323
+ emit({ type: 'plan.update', title, status: input.status ? mapStatus(input.status) : undefined, raw });
324
+ }
325
+ else if (name === 'ExitPlanMode' && typeof input.plan === 'string') {
326
+ // A plan-mode plan is prose; let the engine extract steps from it like any text plan.
327
+ emit({ type: 'agent.text', text: input.plan });
328
+ }
329
+ return;
330
+ }
331
+ const s = summarizeTool(name, input);
332
+ emit({
333
+ type: 'tool.start',
334
+ callId: b.id,
335
+ tool: name,
336
+ kind: s.kind,
337
+ label: s.label,
338
+ detail: s.detail,
339
+ command: s.command,
340
+ files: s.files,
341
+ spawnsAgent: s.spawnsAgent,
342
+ raw: s.raw,
343
+ });
344
+ }
345
+ onSystem(rec, emit) {
346
+ const sub = rec.subtype ?? '';
347
+ const content = typeof rec.content === 'string' ? rec.content : '';
348
+ // (stream-json's `init` record is covered by the generic first-record run.meta in push())
349
+ if (sub === 'turn_duration') {
350
+ emit({ type: 'turn.end' });
351
+ }
352
+ else if (sub === 'compact_boundary') {
353
+ emit({ type: 'notice', level: 'info', text: 'Context compacted' });
354
+ }
355
+ else if (sub.startsWith('model_refusal') || sub === 'refusal') {
356
+ const why = rec.apiRefusalExplanation ? `: ${rec.apiRefusalExplanation}` : '';
357
+ emit({ type: 'notice', level: 'error', text: `Request refused by the model${why}`, blocking: true });
358
+ }
359
+ else if (sub === 'api_error') {
360
+ emit({ type: 'notice', level: 'error', text: content || 'API error', raw: JSON.stringify(rec.error ?? rec, null, 2) });
361
+ }
362
+ else if (sub === 'informational' && content) {
363
+ const limit = /usage limit|rate limit|limit reached/i.test(content);
364
+ emit({ type: 'notice', level: limit ? 'warning' : 'info', text: content, blocking: limit });
365
+ }
366
+ }
367
+ }
@@ -0,0 +1,123 @@
1
+ import { firstLine, truncate } from "../core/text.js";
2
+ const str = (v) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v));
3
+ export function baseName(p) {
4
+ const parts = p.split(/[\\/]/).filter(Boolean);
5
+ return parts[parts.length - 1] ?? p;
6
+ }
7
+ function host(url) {
8
+ try {
9
+ const u = new URL(url);
10
+ return truncate(u.host + (u.pathname === '/' ? '' : u.pathname), 60);
11
+ }
12
+ catch {
13
+ return truncate(url, 60);
14
+ }
15
+ }
16
+ function pretty(input) {
17
+ try {
18
+ return JSON.stringify(input, null, 2);
19
+ }
20
+ catch {
21
+ return String(input);
22
+ }
23
+ }
24
+ function mcpName(name) {
25
+ const m = /^mcp__(.+?)__(.+)$/.exec(name);
26
+ if (!m)
27
+ return null;
28
+ const server = m[1].replace(/^claude_ai_/, '').replace(/[_-]+/g, ' ');
29
+ return `${server} › ${m[2].replace(/_/g, ' ')}`;
30
+ }
31
+ /**
32
+ * The part of a shell command worth showing: drops leading `cd dir &&`, `VAR=x;`,
33
+ * `set -e;` style preambles so "cd /long/path && npm test" reads as "npm test".
34
+ */
35
+ export function essentialCommand(cmd) {
36
+ let c = cmd.trim();
37
+ const preamble = /^(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)|set\s+-\S+|export\s+[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*))\s*(?:&&|;|\n)\s*/;
38
+ for (let i = 0; i < 5; i++) {
39
+ const m = preamble.exec(c);
40
+ if (!m || m[0].length >= c.length)
41
+ break;
42
+ c = c.slice(m[0].length);
43
+ }
44
+ return c;
45
+ }
46
+ export function summarizeTool(name, input) {
47
+ const file = str(input.file_path ?? input.notebook_path ?? input.path);
48
+ switch (name) {
49
+ case 'Read':
50
+ return { kind: 'read', label: `Read ${baseName(file)}`, files: [file], raw: `Read ${file}` };
51
+ case 'Write':
52
+ return {
53
+ kind: 'write',
54
+ label: `Write ${baseName(file)}`,
55
+ files: [file],
56
+ raw: `Write ${file}\n\n${str(input.content)}`,
57
+ };
58
+ case 'Edit':
59
+ case 'MultiEdit':
60
+ case 'NotebookEdit': {
61
+ const edits = Array.isArray(input.edits) ? input.edits : [input];
62
+ const diff = edits
63
+ .map((e) => {
64
+ const oldS = str(e.old_string);
65
+ const newS = str(e.new_string ?? e.new_source);
66
+ return `${oldS.split('\n').map((l) => '- ' + l).join('\n')}\n${newS.split('\n').map((l) => '+ ' + l).join('\n')}`;
67
+ })
68
+ .join('\n\n');
69
+ return { kind: 'edit', label: `Edit ${baseName(file)}`, files: [file], raw: `Edit ${file}\n\n${diff}` };
70
+ }
71
+ case 'Bash':
72
+ case 'PowerShell': {
73
+ const cmd = str(input.command);
74
+ const desc = str(input.description);
75
+ const short = firstLine(essentialCommand(cmd), 80);
76
+ return {
77
+ kind: 'command',
78
+ label: desc ? truncate(desc, 80) : `$ ${short}`,
79
+ detail: `$ ${short}`,
80
+ command: short,
81
+ raw: `${name === 'PowerShell' ? 'PS>' : '$'} ${cmd}`,
82
+ };
83
+ }
84
+ case 'Grep':
85
+ return {
86
+ kind: 'search',
87
+ label: `Search “${truncate(str(input.pattern), 50)}”`,
88
+ detail: str(input.path) || undefined,
89
+ raw: pretty(input),
90
+ };
91
+ case 'Glob':
92
+ return { kind: 'search', label: `Find ${truncate(str(input.pattern), 60)}`, raw: pretty(input) };
93
+ case 'WebSearch':
94
+ return { kind: 'web', label: `Web search: ${truncate(str(input.query), 60)}`, raw: pretty(input) };
95
+ case 'WebFetch':
96
+ return { kind: 'web', label: `Fetch ${host(str(input.url))}`, detail: str(input.prompt) || undefined, raw: pretty(input) };
97
+ case 'Agent':
98
+ case 'Task': {
99
+ const desc = str(input.description) || str(input.subagent_type) || 'Subagent';
100
+ return {
101
+ kind: 'agent',
102
+ label: `Subagent: ${truncate(desc, 70)}`,
103
+ detail: str(input.subagent_type) || undefined,
104
+ spawnsAgent: true,
105
+ raw: str(input.prompt) || pretty(input),
106
+ };
107
+ }
108
+ case 'AskUserQuestion': {
109
+ const qs = Array.isArray(input.questions) ? input.questions : [];
110
+ const q = qs[0] ? str(qs[0].question) : 'Question';
111
+ return { kind: 'ask', label: `Ask: ${truncate(q, 80)}`, raw: pretty(input) };
112
+ }
113
+ case 'Skill':
114
+ return { kind: 'other', label: `Skill: ${str(input.skill)}`, raw: pretty(input) };
115
+ case 'ToolSearch':
116
+ return { kind: 'other', label: 'Load tools', detail: str(input.query), raw: pretty(input) };
117
+ default: {
118
+ return { kind: 'other', label: mcpName(name) ?? name, raw: pretty(input) };
119
+ }
120
+ }
121
+ }
122
+ /** Tools that express a plan rather than an action. */
123
+ export const PLANNING_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'ExitPlanMode']);
@@ -0,0 +1,106 @@
1
+ import { truncate } from "../core/text.js";
2
+ import { baseName } from "./claude-tools.js";
3
+ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g;
4
+ const COMMAND = /^\s*(?:\$|>|PS[^>]*>|Running:?|Executing:?|Run:)\s+(.+)$/;
5
+ const FILE_OP = /^\s*(Reading|Read|Opening|Editing|Edited|Updating|Updated|Writing|Wrote|Creating|Created)\s+(?:file\s+)?[`'"]?([^\s`'"]+\.[A-Za-z0-9]{1,8})[`'"]?\s*$/i;
6
+ const ERROR = /^\s*(?:error\b|fatal\b|traceback\b|exception\b|panic\b|✗|✘|×\s|FAIL(?:ED)?\b|npm ERR!)|exit(?:ed with)? code [1-9]/i;
7
+ const PROMPT = /^\s*(?:user|you|human|prompt)\s*[:>]\s*(.+)$/i;
8
+ function fileKind(verb) {
9
+ const v = verb.toLowerCase();
10
+ if (v.startsWith('read') || v.startsWith('open'))
11
+ return 'read';
12
+ if (v.startsWith('writ') || v.startsWith('wrote') || v.startsWith('creat'))
13
+ return 'write';
14
+ return 'edit';
15
+ }
16
+ export class TextAdapter {
17
+ name = 'text';
18
+ buffer = '';
19
+ paragraph = [];
20
+ open = null;
21
+ n = 0;
22
+ push(input) {
23
+ this.buffer += String(input).replace(ANSI, '');
24
+ const lines = this.buffer.split(/\r?\n/);
25
+ this.buffer = lines.pop() ?? '';
26
+ const out = [];
27
+ for (const l of lines)
28
+ this.line(l, out);
29
+ return out;
30
+ }
31
+ flush() {
32
+ const out = [];
33
+ if (this.buffer)
34
+ this.line(this.buffer, out);
35
+ this.buffer = '';
36
+ this.flushParagraph(out);
37
+ this.closeTool(out);
38
+ out.push({ type: 'turn.end', ts: new Date().toISOString() });
39
+ return out;
40
+ }
41
+ line(raw, out) {
42
+ const ts = new Date().toISOString();
43
+ const line = raw.replace(/\s+$/, '');
44
+ const prompt = PROMPT.exec(line);
45
+ const cmd = COMMAND.exec(line);
46
+ const file = FILE_OP.exec(line);
47
+ if (prompt) {
48
+ this.flushParagraph(out);
49
+ this.closeTool(out);
50
+ out.push({ type: 'user.prompt', text: prompt[1], ts, raw: line });
51
+ return;
52
+ }
53
+ if (cmd) {
54
+ this.flushParagraph(out);
55
+ this.closeTool(out);
56
+ const callId = `text-${++this.n}`;
57
+ const command = cmd[1].trim();
58
+ out.push({ type: 'tool.start', callId, tool: 'shell', kind: 'command', label: `$ ${truncate(command, 80)}`, command, ts, raw: line });
59
+ this.open = { callId, output: [], failed: false };
60
+ return;
61
+ }
62
+ if (file) {
63
+ this.flushParagraph(out);
64
+ this.closeTool(out);
65
+ const callId = `text-${++this.n}`;
66
+ const kind = fileKind(file[1]);
67
+ const verb = kind === 'read' ? 'Read' : kind === 'write' ? 'Write' : 'Edit';
68
+ out.push({ type: 'tool.start', callId, tool: 'file', kind, label: `${verb} ${baseName(file[2])}`, files: [file[2]], ts, raw: line });
69
+ out.push({ type: 'tool.end', callId, ok: true, ts });
70
+ return;
71
+ }
72
+ if (this.open) {
73
+ // Command output continues until a blank line followed by prose, or the next action.
74
+ if (line.trim() === '' && this.open.output.length > 0) {
75
+ this.closeTool(out);
76
+ return;
77
+ }
78
+ if (ERROR.test(line))
79
+ this.open.failed = true;
80
+ this.open.output.push(raw);
81
+ return;
82
+ }
83
+ if (line.trim() === '') {
84
+ this.flushParagraph(out);
85
+ return;
86
+ }
87
+ if (ERROR.test(line)) {
88
+ this.flushParagraph(out);
89
+ out.push({ type: 'notice', level: 'error', text: truncate(line.trim(), 160), ts, raw: line });
90
+ return;
91
+ }
92
+ this.paragraph.push(line);
93
+ }
94
+ flushParagraph(out) {
95
+ if (!this.paragraph.length)
96
+ return;
97
+ out.push({ type: 'agent.text', text: this.paragraph.join('\n'), ts: new Date().toISOString() });
98
+ this.paragraph = [];
99
+ }
100
+ closeTool(out) {
101
+ if (!this.open)
102
+ return;
103
+ out.push({ type: 'tool.end', callId: this.open.callId, ok: !this.open.failed, output: this.open.output.join('\n'), ts: new Date().toISOString() });
104
+ this.open = null;
105
+ }
106
+ }