clideck 1.27.0 → 1.29.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.
package/transcript.js CHANGED
@@ -1,34 +1,48 @@
1
1
  const { appendFile, writeFileSync, mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync } = require('fs');
2
2
  const { join, basename } = require('path');
3
3
  const { DATA_DIR } = require('./paths');
4
+ const builder = require('./transcript-builder');
5
+ const parser = require('./transcript-parser');
4
6
 
5
7
  const DIR = join(DATA_DIR, 'transcripts');
6
8
  const ANSI_RE = /\x1b[\[\]()#;?]*[0-9;]*[a-zA-Z@`~]|\x1b\].*?(?:\x07|\x1b\\)|\x1b.|\r|\x07/g;
7
9
  const MAX_CACHE = 50 * 1024;
10
+ const LEGACY_SUFFIXES = ['-parsed.jsonl', '.screen'];
8
11
 
9
12
  const inputBuf = {};
10
13
  const outputBuf = {};
11
14
  const cache = {};
12
15
  const prefixes = {};
16
+ const entriesById = {};
13
17
  const userTexts = {}; // sessionId → [text, ...] — user prompts for parser matching
18
+ const finalizePreset = {};
19
+ const lastAgentText = {};
14
20
  let broadcast = null;
15
21
  let notifyPlugin = null;
16
22
 
23
+ function tlog(id, msg) {
24
+ // console.log(`[transcript:${id.slice(0,8)}] ${msg}`);
25
+ }
26
+
27
+ function clog(id, msg) {
28
+ if (finalizePreset[id] !== 'claude-code') return;
29
+ // console.log(`[claude:transcript:${id.slice(0,8)}] ${msg}`);
30
+ }
31
+
17
32
  function init(bc, validIds, pluginNotify) {
18
33
  broadcast = bc;
19
34
  notifyPlugin = pluginNotify || null;
20
35
  if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
21
- // Clean up orphaned .screen files (no matching .jsonl or session)
22
- for (const file of readdirSync(DIR).filter(f => f.endsWith('.screen'))) {
23
- const id = basename(file, '.screen');
24
- if (!existsSync(fpath(id)) && (!validIds || !validIds.has(id))) { try { unlinkSync(join(DIR, file)); } catch {} }
36
+ for (const file of readdirSync(DIR).filter(f => LEGACY_SUFFIXES.some(s => f.endsWith(s)))) {
37
+ try { unlinkSync(join(DIR, file)); } catch {}
25
38
  }
26
39
  for (const file of readdirSync(DIR).filter(f => f.endsWith('.jsonl'))) {
27
40
  const id = basename(file, '.jsonl');
28
41
  if (validIds && !validIds.has(id)) { try { unlinkSync(join(DIR, file)); } catch {} continue; }
29
42
  try {
30
- const lines = readFileSync(join(DIR, file), 'utf8').trim().split('\n');
31
- cache[id] = lines.map(l => { try { return JSON.parse(l).text; } catch { return ''; } }).join('\n');
43
+ const lines = readFileSync(join(DIR, file), 'utf8').trim().split('\n').filter(Boolean);
44
+ entriesById[id] = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
45
+ cache[id] = entriesById[id].map(e => e.text).join('\n');
32
46
  if (cache[id].length > MAX_CACHE) cache[id] = cache[id].slice(-MAX_CACHE);
33
47
  } catch {}
34
48
  }
@@ -36,13 +50,36 @@ function init(bc, validIds, pluginNotify) {
36
50
 
37
51
  function fpath(id) { return join(DIR, `${id}.jsonl`); }
38
52
  function setPrefix(id, prefix) { prefixes[id] = prefix; }
53
+ function setFinalizeOnIdle(id, presetId) {
54
+ if (!presetId) { delete finalizePreset[id]; return; }
55
+ finalizePreset[id] = presetId;
56
+ entriesById[id] = builder.compactEntries(entriesById[id], presetId);
57
+ }
58
+
59
+ function rewrite(id) {
60
+ const entries = entriesById[id] || [];
61
+ writeFileSync(fpath(id), entries.map(e => JSON.stringify(e)).join('\n') + (entries.length ? '\n' : ''));
62
+ cache[id] = entries.map(e => e.text).join('\n');
63
+ if (cache[id].length > MAX_CACHE) cache[id] = cache[id].slice(-MAX_CACHE);
64
+ tlog(id, `rewrite entries=${entries.length} last=${entries.length ? entries[entries.length - 1].role : 'none'}`);
65
+ clog(id, `rewrite entries=${entries.length} last=${entries.length ? entries[entries.length - 1].role : 'none'}`);
66
+ }
39
67
 
40
68
  function store(id, role, text) {
41
69
  const prefix = prefixes[id] || '';
42
- appendFile(fpath(id), JSON.stringify({ ts: Date.now(), role, text, ...(prefix && { prefix }) }) + '\n', () => {});
43
- if (!cache[id]) cache[id] = '';
44
- cache[id] += '\n' + text;
45
- if (cache[id].length > MAX_CACHE) cache[id] = cache[id].slice(-MAX_CACHE);
70
+ if (finalizePreset[id]) {
71
+ if (!entriesById[id]) entriesById[id] = [];
72
+ const entry = { ts: Date.now(), role, text, ...(prefix && { prefix }) };
73
+ tlog(id, `store role=${role} finalize=${finalizePreset[id]} raw=${JSON.stringify(String(text).slice(0, 160))}`);
74
+ builder.addEntry(entriesById[id], entry, finalizePreset[id]);
75
+ rewrite(id);
76
+ } else {
77
+ tlog(id, `store role=${role} append raw=${JSON.stringify(String(text).slice(0, 160))}`);
78
+ appendFile(fpath(id), JSON.stringify({ ts: Date.now(), role, text, ...(prefix && { prefix }) }) + '\n', () => {});
79
+ if (!cache[id]) cache[id] = '';
80
+ cache[id] += '\n' + text;
81
+ if (cache[id].length > MAX_CACHE) cache[id] = cache[id].slice(-MAX_CACHE);
82
+ }
46
83
  if (broadcast) broadcast({ type: 'transcript.append', id, role, text });
47
84
  if (notifyPlugin) notifyPlugin(id, role, text);
48
85
  }
@@ -72,7 +109,12 @@ function trackInput(id, data) {
72
109
  }
73
110
  if (ch === '\r' || ch === '\n') {
74
111
  const line = buf.text.trim();
75
- if (line) { store(id, 'user', line); if (!userTexts[id]) userTexts[id] = []; userTexts[id].push(line); }
112
+ if (line) {
113
+ delete lastAgentText[id];
114
+ store(id, 'user', line);
115
+ if (!userTexts[id]) userTexts[id] = [];
116
+ userTexts[id].push(line);
117
+ }
76
118
  buf.text = '';
77
119
  } else if (ch === '\x7f' || ch === '\x08') {
78
120
  const chars = Array.from(buf.text);
@@ -84,8 +126,20 @@ function trackInput(id, data) {
84
126
  }
85
127
  }
86
128
 
129
+ function recordInjectedInput(id, text) {
130
+ delete lastAgentText[id];
131
+ for (const raw of String(text).split(/\r?\n/)) {
132
+ const line = raw.trim();
133
+ if (!line) continue;
134
+ store(id, 'user', line);
135
+ if (!userTexts[id]) userTexts[id] = [];
136
+ userTexts[id].push(line);
137
+ }
138
+ }
139
+
87
140
  // Server-side fallback: captures raw PTY output (noisy but always available)
88
141
  function trackOutput(id, data) {
142
+ if (finalizePreset[id]) return;
89
143
  if (!outputBuf[id]) outputBuf[id] = { text: '', timer: null };
90
144
  const buf = outputBuf[id];
91
145
  buf.text += data;
@@ -102,180 +156,46 @@ function flush(id) {
102
156
  if (lines.length) store(id, 'agent', lines.join('\n'));
103
157
  }
104
158
 
105
- // Browser-side clean snapshot: overwrites a per-session file with the full
106
- // xterm buffer as rendered by the browser. No diffing, no JSONL — just the
107
- // clean screen content. Mobile reads this for "last agent message".
108
- function storeBuffer(id, lines) {
109
- const isChrome = t => !t
110
- || /^[─━═\u2500-\u257f]+$/.test(t) // box-drawing horizontal lines
111
- || /^[▀▄█▌▐░▒▓╭╮╰╯│╔╗╚╝║]+$/.test(t) // block elements, box corners, vertical bars
112
- || (/[█▀▄▌▐░▒▓]/.test(t) && /^[█▀▄▌▐░▒▓\s]+$/.test(t)) // ASCII art (blocks + whitespace, e.g. logos)
113
- || /^[❯>$%#]\s*$/.test(t) // bare prompt markers
114
- || /^(esc to interrupt|\? for shortcuts)$/i.test(t); // Claude Code chrome
115
- const filtered = lines.filter(l => !isChrome(l.trim()));
116
- while (filtered.length && !filtered[filtered.length - 1].trim()) filtered.pop();
117
- const screenPath = join(DIR, `${id}.screen`);
118
- if (filtered.length) writeFileSync(screenPath, filtered.join('\n'));
119
- else try { unlinkSync(screenPath); } catch {}
120
- }
121
-
122
- // Read the clean screen snapshot for a session (if available).
123
- function getScreen(id) {
124
- const file = join(DIR, `${id}.screen`);
125
- if (!existsSync(file)) return null;
126
- try { return readFileSync(file, 'utf8'); } catch { return null; }
159
+ function parseTurnsFromLines(id, agent, lines, opts) {
160
+ const turns = parser.parseTurns(agent, lines, getUsers(id));
161
+ if (!opts?.raw && turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
162
+ tlog(id, `parse agent=${agent} lines=${lines?.length || 0} turns=${turns?.map(t => t.role).join(',') || 'none'}`);
163
+ return turns?.length >= 2 ? turns : null;
127
164
  }
128
165
 
129
- // Per-agent screen parsers. Each returns [{role, text}] from .screen content.
130
- const agentParsers = {
131
- 'claude-code': (lines, id) => {
132
- const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
133
- const turns = [];
134
- let current = null;
135
- for (const line of lines) {
136
- const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
137
- if (agent) {
138
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
139
- current = { role: 'agent', text: agent[1] };
140
- continue;
141
- }
142
- const userM = line.match(/^(?:[│ ]\s*)?[❯›]\s(.*)$/);
143
- if (userM && (known ? known.has(userM[1].trim()) : true)) {
144
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
145
- current = { role: 'user', text: userM[1] };
146
- continue;
147
- }
148
- if (!current) continue;
149
- let cont = line;
150
- if (cont.startsWith('│ ')) cont = cont.slice(2);
151
- else if (cont.startsWith(' ')) cont = cont.slice(2);
152
- current.text += '\n' + cont;
153
- }
154
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
155
- return turns.length >= 2 ? turns : null;
156
- },
157
- 'codex': (lines, id) => {
158
- const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
159
- const turns = [];
160
- let current = null;
161
- for (const line of lines) {
162
- const agent = line.match(/^(?:│\s*)?•\s(.*)$/);
163
- if (agent) {
164
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
165
- current = { role: 'agent', text: agent[1] };
166
- continue;
167
- }
168
- const userM = line.match(/^(?:│\s*)?›\s(.*)$/);
169
- if (userM && (known ? known.has(userM[1].trim()) : true)) {
170
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
171
- current = { role: 'user', text: userM[1] };
172
- continue;
173
- }
174
- if (!current) continue;
175
- let cont = line;
176
- if (cont.startsWith('│ ')) cont = cont.slice(2);
177
- else if (cont.startsWith(' ')) cont = cont.slice(2);
178
- current.text += '\n' + cont;
179
- }
180
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
181
- return turns.length >= 2 ? turns : null;
182
- },
183
- 'gemini-cli': (lines, id) => {
184
- const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
185
- const geminiChrome = t => {
186
- const s = t.trim();
187
- return /^shift\+tab to accept/i.test(s)
188
- || /^(Type your message|@path\/to\/)/i.test(s)
189
- || /^(\/\w+ |no sandbox|\/model )/i.test(s)
190
- || /^[~\/\\].*\(main[*]?\)\s*$/i.test(s)
191
- || /^(Logged in with|Plan:|Tips for getting started)/i.test(s)
192
- || /^\d+\.\s+(Ask questions|Be specific|Create GEMINI)/i.test(s)
193
- || /^ℹ\s/.test(s);
194
- };
195
- const turns = [];
196
- let current = null;
197
- for (const line of lines) {
198
- if (geminiChrome(line)) continue;
199
- const isAgent = line.startsWith('✦ ');
200
- const userM = line.startsWith(' > ') ? line.slice(3) : null;
201
- const isUser = userM && (known ? known.has(userM.trim()) : true);
202
- if (isUser || isAgent) {
203
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
204
- current = { role: isUser ? 'user' : 'agent', text: isUser ? userM : line.slice(2) };
205
- continue;
206
- }
207
- if (!current) continue;
208
- current.text += '\n' + line;
209
- }
210
- if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
211
- return turns.length >= 2 ? turns : null;
212
- },
213
- };
214
-
215
- // Fallback anchor parser for agents without a dedicated parser.
216
- // Uses JSONL user inputs to find prompt lines in the screen.
217
- function anchorParse(id, lines) {
218
- const file = fpath(id);
219
- if (!existsSync(file)) return null;
220
- const users = [];
221
- try {
222
- for (const l of readFileSync(file, 'utf8').trim().split('\n')) {
223
- try { const e = JSON.parse(l); if (e.role === 'user') users.push(e.text); } catch {}
224
- }
225
- } catch { return null; }
226
- if (!users.length) return null;
227
-
228
- const isPrompt = (line, text) => { const t = line.trim(); return t.endsWith(text) && t.length - text.length <= 6; };
229
-
230
- const lastUser = users[users.length - 1];
231
- let lastIdx = -1;
232
- for (let i = lines.length - 1; i >= 0; i--) {
233
- if (isPrompt(lines[i], lastUser)) { lastIdx = i; break; }
234
- }
235
- if (lastIdx < 0) return null;
236
-
237
- const anchors = [{ idx: lastIdx, text: lastUser }];
238
- for (let u = users.length - 2; u >= 0 && anchors.length < 3; u--) {
239
- for (let i = anchors[anchors.length - 1].idx - 1; i >= 0; i--) {
240
- if (isPrompt(lines[i], users[u])) { anchors.push({ idx: i, text: users[u] }); break; }
241
- }
242
- }
243
- anchors.reverse();
244
-
245
- const turns = [];
246
- for (let p = 0; p < anchors.length; p++) {
247
- turns.push({ role: 'user', text: anchors[p].text });
248
- const start = anchors[p].idx + 1;
249
- const end = p + 1 < anchors.length ? anchors[p + 1].idx : lines.length;
250
- const agentLines = lines.slice(start, end).filter(l => l.trim());
251
- if (agentLines.length) turns.push({ role: 'agent', text: agentLines.join('\n') });
252
- }
253
- if (turns.length < 2 || turns[turns.length - 1].role !== 'agent') return null;
254
- return turns;
166
+ function captureAgentTurn(id, agent, lines) {
167
+ if (!finalizePreset[id]) return;
168
+ const turns = parseTurnsFromLines(id, agent, lines);
169
+ // Finalized capture should not depend on the viewport still containing the
170
+ // matching user prompt line; if the full turn structure is gone, fall back to
171
+ // the last visible agent block alone.
172
+ const last = turns?.length ? turns[turns.length - 1] : parser.parseLastAgentOnly(agent, lines);
173
+ if (last) tlog(id, `capture lastRole=${last.role} raw=${JSON.stringify(String(last.text).slice(0, 200))}`);
174
+ if (last) clog(id, `capture lastRole=${last.role} raw=${JSON.stringify(String(last.text).slice(0, 200))}`);
175
+ const text = last?.role === 'agent' ? builder.cleanAgentText(finalizePreset[id], last.text) : '';
176
+ tlog(id, `capture cleaned=${JSON.stringify(String(text).slice(0, 200))}`);
177
+ if (!text) { tlog(id, 'capture skip empty-clean'); clog(id, 'capture skip empty-clean'); return; }
178
+ if (text === lastAgentText[id]) { tlog(id, 'capture skip duplicate-clean'); clog(id, 'capture skip duplicate-clean'); return; }
179
+ lastAgentText[id] = text;
180
+ store(id, 'agent', text);
255
181
  }
256
182
 
257
- // For consecutive agent turns, keep only the last one (strips tool output, keeps conversational response).
258
- function collapseAgentTurns(turns) {
259
- if (!turns?.length) return turns;
260
- const result = [];
261
- for (let i = 0; i < turns.length; i++) {
262
- if (turns[i].role === 'agent' && i + 1 < turns.length && turns[i + 1].role === 'agent') continue;
263
- result.push(turns[i]);
264
- }
265
- return result;
183
+ function captureFinalAgentText(id, presetId, text) {
184
+ if (!finalizePreset[id]) return;
185
+ const clean = builder.cleanAgentText(presetId || finalizePreset[id], text);
186
+ tlog(id, `captureFinal raw=${JSON.stringify(String(text).slice(0, 200))}`);
187
+ tlog(id, `captureFinal cleaned=${JSON.stringify(String(clean).slice(0, 200))}`);
188
+ clog(id, `captureFinal raw=${JSON.stringify(String(text).slice(0, 200))}`);
189
+ clog(id, `captureFinal cleaned=${JSON.stringify(String(clean).slice(0, 200))}`);
190
+ if (!clean) { tlog(id, 'captureFinal skip empty-clean'); clog(id, 'captureFinal skip empty-clean'); return; }
191
+ if (clean === lastAgentText[id]) { tlog(id, 'captureFinal skip duplicate-clean'); clog(id, 'captureFinal skip duplicate-clean'); return; }
192
+ lastAgentText[id] = clean;
193
+ store(id, 'agent', clean);
266
194
  }
267
195
 
268
- // Parse .screen into structured turns — use agent-specific parser if available, else anchor fallback.
269
- // opts.raw: if true, preserve trailing user turn (needed by autopilot for freshness checks).
270
- function getScreenTurns(id, agent, opts) {
271
- const screen = getScreen(id);
272
- if (!screen) return null;
273
- const lines = screen.split('\n');
274
- const parser = agentParsers[agent];
275
- const turns = collapseAgentTurns(parser ? parser(lines, id) : anchorParse(id, lines));
276
- // Drop trailing user turn — it's the empty prompt or unanswered input
277
- if (!opts?.raw && turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
278
- return turns?.length >= 2 ? turns : null;
196
+ function getUsers(id) {
197
+ if (userTexts[id]?.length) return userTexts[id];
198
+ return (entriesById[id] || []).filter(e => e.role === 'user').map(e => e.text);
279
199
  }
280
200
 
281
201
  function getLastTurns(id, n) {
@@ -301,6 +221,18 @@ function getLastTurns(id, n) {
301
221
 
302
222
  function getCache() { return { ...cache }; }
303
223
 
224
+ function getReplayText(id, presetId) {
225
+ const entries = builder.compactEntries(entriesById[id], presetId);
226
+ if (!entries?.length) return '';
227
+ const marks = {
228
+ 'claude-code': { user: '❯', agent: '⏺' },
229
+ codex: { user: '›', agent: '•' },
230
+ 'gemini-cli': { user: '>', agent: '✦' },
231
+ opencode: { user: '›', agent: '•' },
232
+ }[presetId] || { user: '›', agent: '•' };
233
+ return entries.map(e => `${e.role === 'user' ? marks.user : marks.agent} ${e.text}`).join('\n\n');
234
+ }
235
+
304
236
  function clear(id) {
305
237
  flush(id);
306
238
  delete inputBuf[id];
@@ -309,9 +241,11 @@ function clear(id) {
309
241
  delete outputBuf[id];
310
242
  }
311
243
  delete cache[id];
244
+ delete entriesById[id];
312
245
  delete prefixes[id];
313
246
  delete userTexts[id];
314
- try { unlinkSync(join(DIR, `${id}.screen`)); } catch {}
247
+ delete lastAgentText[id];
248
+ delete finalizePreset[id];
315
249
  }
316
250
 
317
251
  // Detect interactive menus from raw screen lines. Returns [{value, label, selected}] or null.
@@ -340,4 +274,4 @@ function detectMenu(lines, presetId) {
340
274
  return choices.length ? choices : null;
341
275
  }
342
276
 
343
- module.exports = { init, trackInput, trackOutput, storeBuffer, getScreen, getScreenTurns, getLastTurns, getCache, clear, setPrefix, detectMenu };
277
+ module.exports = { init, trackInput, recordInjectedInput, trackOutput, captureAgentTurn, captureFinalAgentText, parseTurnsFromLines, getLastTurns, getCache, getReplayText, clear, setPrefix, setFinalizeOnIdle, detectMenu };