clideck 1.22.7 → 1.23.2

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,4 +1,4 @@
1
- const { appendFile, mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync } = require('fs');
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
4
 
@@ -9,11 +9,19 @@ const MAX_CACHE = 50 * 1024;
9
9
  const inputBuf = {};
10
10
  const outputBuf = {};
11
11
  const cache = {};
12
+ const prefixes = {};
12
13
  let broadcast = null;
14
+ let notifyPlugin = null;
13
15
 
14
- function init(bc, validIds) {
16
+ function init(bc, validIds, pluginNotify) {
15
17
  broadcast = bc;
18
+ notifyPlugin = pluginNotify || null;
16
19
  if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
20
+ // Clean up orphaned .screen files (no matching .jsonl or session)
21
+ for (const file of readdirSync(DIR).filter(f => f.endsWith('.screen'))) {
22
+ const id = basename(file, '.screen');
23
+ if (!existsSync(fpath(id)) && (!validIds || !validIds.has(id))) { try { unlinkSync(join(DIR, file)); } catch {} }
24
+ }
17
25
  for (const file of readdirSync(DIR).filter(f => f.endsWith('.jsonl'))) {
18
26
  const id = basename(file, '.jsonl');
19
27
  if (validIds && !validIds.has(id)) { try { unlinkSync(join(DIR, file)); } catch {} continue; }
@@ -26,13 +34,16 @@ function init(bc, validIds) {
26
34
  }
27
35
 
28
36
  function fpath(id) { return join(DIR, `${id}.jsonl`); }
37
+ function setPrefix(id, prefix) { prefixes[id] = prefix; }
29
38
 
30
39
  function store(id, role, text) {
31
- appendFile(fpath(id), JSON.stringify({ ts: Date.now(), role, text }) + '\n', () => {});
40
+ const prefix = prefixes[id] || '';
41
+ appendFile(fpath(id), JSON.stringify({ ts: Date.now(), role, text, ...(prefix && { prefix }) }) + '\n', () => {});
32
42
  if (!cache[id]) cache[id] = '';
33
43
  cache[id] += '\n' + text;
34
44
  if (cache[id].length > MAX_CACHE) cache[id] = cache[id].slice(-MAX_CACHE);
35
- if (broadcast) broadcast({ type: 'transcript.append', id, text });
45
+ if (broadcast) broadcast({ type: 'transcript.append', id, role, text });
46
+ if (notifyPlugin) notifyPlugin(id, role, text);
36
47
  }
37
48
 
38
49
  function trackInput(id, data) {
@@ -58,6 +69,7 @@ function trackInput(id, data) {
58
69
  }
59
70
  }
60
71
 
72
+ // Server-side fallback: captures raw PTY output (noisy but always available)
61
73
  function trackOutput(id, data) {
62
74
  if (!outputBuf[id]) outputBuf[id] = { text: '', timer: null };
63
75
  const buf = outputBuf[id];
@@ -75,6 +87,155 @@ function flush(id) {
75
87
  if (lines.length) store(id, 'agent', lines.join('\n'));
76
88
  }
77
89
 
90
+ // Browser-side clean snapshot: overwrites a per-session file with the full
91
+ // xterm buffer as rendered by the browser. No diffing, no JSONL — just the
92
+ // clean screen content. Mobile reads this for "last agent message".
93
+ function storeBuffer(id, lines) {
94
+ const isChrome = t => !t || /^[─━═\u2500-\u257f]+$/.test(t) || /^[❯>$%#]\s*$/.test(t) || t === 'esc to interrupt' || t === '? for shortcuts';
95
+ const filtered = lines.filter(l => !isChrome(l.trim()));
96
+ while (filtered.length && !filtered[filtered.length - 1].trim()) filtered.pop();
97
+ const screenPath = join(DIR, `${id}.screen`);
98
+ if (filtered.length) writeFileSync(screenPath, filtered.join('\n'));
99
+ }
100
+
101
+ // Read the clean screen snapshot for a session (if available).
102
+ function getScreen(id) {
103
+ const file = join(DIR, `${id}.screen`);
104
+ if (!existsSync(file)) return null;
105
+ try { return readFileSync(file, 'utf8'); } catch { return null; }
106
+ }
107
+
108
+ // Per-agent screen parsers. Each returns [{role, text}] from .screen content.
109
+ const agentParsers = {
110
+ 'claude-code': (lines) => {
111
+ const turns = [];
112
+ let current = null;
113
+ for (const line of lines) {
114
+ const m = line.match(/^(?:[│ ]\s*)?([❯›]|[⏺•])\s(.*)$/);
115
+ if (m) {
116
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
117
+ current = { role: m[1] === '❯' || m[1] === '›' ? 'user' : 'agent', text: m[2] };
118
+ continue;
119
+ }
120
+ if (!current) continue;
121
+ let cont = line;
122
+ if (cont.startsWith('│ ')) cont = cont.slice(2);
123
+ else if (cont.startsWith(' ')) cont = cont.slice(2);
124
+ current.text += '\n' + cont;
125
+ }
126
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
127
+ return turns.length >= 2 ? turns : null;
128
+ },
129
+ 'codex': (lines) => {
130
+ const turns = [];
131
+ let current = null;
132
+ for (const line of lines) {
133
+ const m = line.match(/^(?:│\s*)?([›•])\s(.*)$/);
134
+ if (m) {
135
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
136
+ current = { role: m[1] === '›' ? 'user' : 'agent', text: m[2] };
137
+ continue;
138
+ }
139
+ if (!current) continue;
140
+ let cont = line;
141
+ if (cont.startsWith('│ ')) cont = cont.slice(2);
142
+ else if (cont.startsWith(' ')) cont = cont.slice(2);
143
+ current.text += '\n' + cont;
144
+ }
145
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
146
+ return turns.length >= 2 ? turns : null;
147
+ },
148
+ 'gemini-cli': (lines) => {
149
+ const turns = [];
150
+ let current = null;
151
+ for (const line of lines) {
152
+ const isUser = line.startsWith(' > ');
153
+ const isAgent = line.startsWith('✦ ');
154
+ if (isUser || isAgent) {
155
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
156
+ current = { role: isUser ? 'user' : 'agent', text: isUser ? line.slice(3) : line.slice(2) };
157
+ continue;
158
+ }
159
+ if (!current) continue;
160
+ current.text += '\n' + line;
161
+ }
162
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
163
+ return turns.length >= 2 ? turns : null;
164
+ },
165
+ };
166
+
167
+ // Fallback anchor parser for agents without a dedicated parser.
168
+ // Uses JSONL user inputs to find prompt lines in the screen.
169
+ function anchorParse(id, lines) {
170
+ const file = fpath(id);
171
+ if (!existsSync(file)) return null;
172
+ const users = [];
173
+ try {
174
+ for (const l of readFileSync(file, 'utf8').trim().split('\n')) {
175
+ try { const e = JSON.parse(l); if (e.role === 'user') users.push(e.text); } catch {}
176
+ }
177
+ } catch { return null; }
178
+ if (!users.length) return null;
179
+
180
+ const isPrompt = (line, text) => { const t = line.trim(); return t.endsWith(text) && t.length - text.length <= 6; };
181
+
182
+ const lastUser = users[users.length - 1];
183
+ let lastIdx = -1;
184
+ for (let i = lines.length - 1; i >= 0; i--) {
185
+ if (isPrompt(lines[i], lastUser)) { lastIdx = i; break; }
186
+ }
187
+ if (lastIdx < 0) return null;
188
+
189
+ const anchors = [{ idx: lastIdx, text: lastUser }];
190
+ for (let u = users.length - 2; u >= 0 && anchors.length < 3; u--) {
191
+ for (let i = anchors[anchors.length - 1].idx - 1; i >= 0; i--) {
192
+ if (isPrompt(lines[i], users[u])) { anchors.push({ idx: i, text: users[u] }); break; }
193
+ }
194
+ }
195
+ anchors.reverse();
196
+
197
+ const turns = [];
198
+ for (let p = 0; p < anchors.length; p++) {
199
+ turns.push({ role: 'user', text: anchors[p].text });
200
+ const start = anchors[p].idx + 1;
201
+ const end = p + 1 < anchors.length ? anchors[p + 1].idx : lines.length;
202
+ const agentLines = lines.slice(start, end).filter(l => l.trim());
203
+ if (agentLines.length) turns.push({ role: 'agent', text: agentLines.join('\n') });
204
+ }
205
+ if (turns.length < 2 || turns[turns.length - 1].role !== 'agent') return null;
206
+ return turns;
207
+ }
208
+
209
+ // Parse .screen into structured turns — use agent-specific parser if available, else anchor fallback.
210
+ function getScreenTurns(id, agent) {
211
+ const screen = getScreen(id);
212
+ if (!screen) return null;
213
+ const lines = screen.split('\n');
214
+ const parser = agentParsers[agent];
215
+ return parser ? parser(lines) : anchorParse(id, lines);
216
+ }
217
+
218
+ function getLastTurns(id, n) {
219
+ n = n || 4;
220
+ const file = fpath(id);
221
+ if (!existsSync(file)) return [];
222
+ try {
223
+ const lines = readFileSync(file, 'utf8').trim().split('\n');
224
+ const turns = [];
225
+ for (let i = lines.length - 1; i >= 0; i--) {
226
+ let entry;
227
+ try { entry = JSON.parse(lines[i]); } catch { continue; }
228
+ if (turns.length && turns[turns.length - 1].role === entry.role) {
229
+ turns[turns.length - 1].text = entry.text + '\n' + turns[turns.length - 1].text;
230
+ } else {
231
+ turns.push({ role: entry.role, text: entry.text });
232
+ if (turns.length >= n) break;
233
+ }
234
+ }
235
+ return turns.reverse();
236
+ } catch { return []; }
237
+ }
238
+
78
239
  function getCache() { return { ...cache }; }
79
240
 
80
241
  function clear(id) {
@@ -85,6 +246,8 @@ function clear(id) {
85
246
  delete outputBuf[id];
86
247
  }
87
248
  delete cache[id];
249
+ delete prefixes[id];
250
+ try { unlinkSync(join(DIR, `${id}.screen`)); } catch {}
88
251
  }
89
252
 
90
- module.exports = { init, trackInput, trackOutput, getCache, clear };
253
+ module.exports = { init, trackInput, trackOutput, storeBuffer, getScreen, getScreenTurns, getLastTurns, getCache, clear, setPrefix };