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/README.md +7 -0
- package/agent-presets.json +7 -9
- package/bin/claude-hook.js +31 -0
- package/bin/gemini-hook.js +31 -0
- package/bin/notify-helper.js +9 -2
- package/codex-config.js +77 -0
- package/config.js +2 -0
- package/handlers.js +164 -56
- package/package.json +1 -2
- package/plugin-loader.js +176 -50
- package/plugins/autopilot/clideck-plugin.json +5 -3
- package/plugins/autopilot/index.js +24 -30
- package/plugins/autopilot/package.json +7 -0
- package/plugins/trim-clip/clideck-plugin.json +1 -0
- package/plugins/voice-input/clideck-plugin.json +1 -0
- package/public/index.html +2 -4
- package/public/js/app.js +62 -5
- package/public/js/creator.js +14 -2
- package/public/js/settings.js +8 -6
- package/public/js/terminals.js +126 -26
- package/public/js/toast.js +2 -17
- package/public/js/utils.js +9 -0
- package/public/tailwind.css +1 -1
- package/server.js +87 -21
- package/sessions.js +41 -8
- package/telemetry-receiver.js +83 -80
- package/tools/merge-jsonl-roles.mjs +182 -0
- package/transcript-builder.js +53 -0
- package/transcript-parser.js +135 -0
- package/transcript.js +115 -181
package/telemetry-receiver.js
CHANGED
|
@@ -6,10 +6,11 @@ const ioActivity = require('./activity');
|
|
|
6
6
|
const activity = new Map(); // sessionId → has received events
|
|
7
7
|
const lastEvent = new Map(); // sessionId → last OTEL event name (+ kind)
|
|
8
8
|
const pendingSetup = new Map(); // sessionId → timer (waiting for first event)
|
|
9
|
-
const pendingIdle = new Map(); // sessionId → timer (PTY silence → idle)
|
|
10
9
|
const codexMenuPoll = new Map(); // sessionId → interval (polling for menu after response.completed)
|
|
11
|
-
const
|
|
12
|
-
const
|
|
10
|
+
const codexPendingStop = new Map(); // sessionId → ts (notify hook arrived; wait for next response.completed)
|
|
11
|
+
const codexOutputDone = new Map(); // sessionId → ts (fallback if notify never fires)
|
|
12
|
+
const codexPendingIdle = new Map(); // sessionId → timer (tiny settle before committing idle)
|
|
13
|
+
const escPendingIdle = new Map(); // sessionId → timer (Esc interrupt → short grace before forcing idle)
|
|
13
14
|
let broadcastFn = null;
|
|
14
15
|
let sessionsFn = null;
|
|
15
16
|
|
|
@@ -76,33 +77,70 @@ function handleLogs(req, res) {
|
|
|
76
77
|
|
|
77
78
|
// Debug telemetry logs — uncomment as needed, do not delete
|
|
78
79
|
// if (serviceName === 'claude-code' && eventName) console.log(`[telemetry:claude] ${eventName}`);
|
|
79
|
-
// if (serviceName === 'codex_cli_rs' && eventName)
|
|
80
|
+
// if (serviceName === 'codex_cli_rs' && eventName) {
|
|
81
|
+
// const details = Object.entries(attrs)
|
|
82
|
+
// .filter(([k]) => k !== 'event.name')
|
|
83
|
+
// .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
84
|
+
// .join(' ');
|
|
85
|
+
// console.log(`[telemetry:codex] ${eventName} session=${resolvedId.slice(0,8)}${details ? ' ' + details : ''}`);
|
|
86
|
+
// }
|
|
80
87
|
// if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
|
|
81
88
|
|
|
82
89
|
// Track last event per session (used by menu detection validation)
|
|
83
90
|
if (eventName) lastEvent.set(resolvedId, eventName + (attrs['event.kind'] ? ':' + attrs['event.kind'] : ''));
|
|
84
91
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
92
|
+
// Codex can emit a brief completion between tool phases. Keep idle
|
|
93
|
+
// pending for a tiny settle window and cancel it on any fresh Codex
|
|
94
|
+
// activity before the idle is committed to UI/notifications.
|
|
95
|
+
if (serviceName === 'codex_cli_rs' && eventName) {
|
|
96
|
+
const isTrustedCompletion = eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed';
|
|
97
|
+
if (!isTrustedCompletion) cancelCodexPendingIdle(resolvedId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Status: Codex user_prompt → working. Claude and Gemini use hooks.
|
|
101
|
+
if (eventName === 'codex.user_prompt') {
|
|
102
|
+
codexPendingStop.delete(resolvedId);
|
|
103
|
+
codexOutputDone.delete(resolvedId);
|
|
90
104
|
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
91
|
-
// Gemini uses PTY silence heuristic for idle; Codex idle comes from notify hook
|
|
92
|
-
if (serviceName !== 'codex_cli_rs') startPendingIdle(resolvedId, serviceName);
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
//
|
|
107
|
+
// Fallback: when notify does not fire, require an output item to finish
|
|
108
|
+
// before treating the next response.completed as a real end-of-turn.
|
|
109
|
+
if (eventName === 'codex.websocket_event' && attrs['event.kind'] === 'response.output_item.done') {
|
|
110
|
+
codexOutputDone.set(resolvedId, Date.now());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Codex: after notify hook arms a pending stop, the next response.completed commits idle.
|
|
114
|
+
// Also poll briefly for a visible choice menu.
|
|
96
115
|
if (eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed') {
|
|
116
|
+
const pendingStopAt = codexPendingStop.get(resolvedId);
|
|
117
|
+
if (pendingStopAt && Date.now() - pendingStopAt <= 5000) {
|
|
118
|
+
// console.log(`[codex] complete matched pending-stop session=${resolvedId.slice(0,8)} age=${Date.now() - pendingStopAt}ms`);
|
|
119
|
+
codexPendingStop.delete(resolvedId);
|
|
120
|
+
codexOutputDone.delete(resolvedId);
|
|
121
|
+
scheduleCodexIdle(resolvedId, 'telemetry-stop');
|
|
122
|
+
} else {
|
|
123
|
+
const outputDoneAt = codexOutputDone.get(resolvedId);
|
|
124
|
+
if (outputDoneAt && Date.now() - outputDoneAt <= 5000) {
|
|
125
|
+
// console.log(`[codex] complete matched output-item.done fallback session=${resolvedId.slice(0,8)} age=${Date.now() - outputDoneAt}ms`);
|
|
126
|
+
codexOutputDone.delete(resolvedId);
|
|
127
|
+
scheduleCodexIdle(resolvedId, 'telemetry-fallback');
|
|
128
|
+
} else {
|
|
129
|
+
// console.log(`[codex] response.completed with no notify-stop and no output-item.done fallback session=${resolvedId.slice(0,8)} outputDone=${outputDoneAt ? Date.now() - outputDoneAt + 'ms' : 'none'}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
97
132
|
startCodexMenuPoll(resolvedId);
|
|
98
133
|
}
|
|
99
134
|
// Codex: tool_decision → user approved, cancel menu poll, back to working
|
|
100
135
|
if (eventName === 'codex.tool_decision') {
|
|
136
|
+
codexPendingStop.delete(resolvedId);
|
|
137
|
+
codexOutputDone.delete(resolvedId);
|
|
101
138
|
cancelCodexMenuPoll(resolvedId);
|
|
102
139
|
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
103
140
|
}
|
|
104
141
|
// Codex: user_prompt or next sse_event cancels menu poll
|
|
105
142
|
if ((eventName === 'codex.user_prompt' || (eventName === 'codex.sse_event' && attrs['event.kind'] !== 'response.completed'))) {
|
|
143
|
+
codexOutputDone.delete(resolvedId);
|
|
106
144
|
cancelCodexMenuPoll(resolvedId);
|
|
107
145
|
}
|
|
108
146
|
|
|
@@ -147,55 +185,14 @@ function cancelPendingSetup(sessionId) {
|
|
|
147
185
|
}
|
|
148
186
|
}
|
|
149
187
|
|
|
150
|
-
//
|
|
151
|
-
// Used by Gemini only — Claude uses hooks, Codex uses sse_event completion.
|
|
152
|
-
// Agent working indicators in PTY output.
|
|
153
|
-
const GEMINI_WORKING_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
|
154
|
-
|
|
155
|
-
function startPendingIdle(id, agent) {
|
|
156
|
-
if (pendingIdle.has(id)) return; // already monitoring
|
|
157
|
-
let isIdle = false;
|
|
158
|
-
let activeStart = 0;
|
|
159
|
-
const check = setInterval(() => {
|
|
160
|
-
const lastOut = ioActivity.lastOutputAt(id);
|
|
161
|
-
const lastIn = ioActivity.lastInputAt(id);
|
|
162
|
-
// Ignore echo: if last output is within 100ms of last input, treat as silent
|
|
163
|
-
const agentOut = (lastIn && lastOut - lastIn >= 0 && lastOut - lastIn < 100) ? 0 : lastOut;
|
|
164
|
-
let silent = (Date.now() - agentOut) >= 2000;
|
|
165
|
-
// Agent override: if recent output has spinner/working chars, not silent
|
|
166
|
-
if (silent && (Date.now() - lastOut) < 2000) {
|
|
167
|
-
const chunk = ioActivity.lastChunk(id);
|
|
168
|
-
if (GEMINI_WORKING_RE.test(chunk)) silent = false;
|
|
169
|
-
}
|
|
170
|
-
if (silent && !isIdle) {
|
|
171
|
-
isIdle = true;
|
|
172
|
-
activeStart = 0;
|
|
173
|
-
broadcastFn?.({ type: 'session.status', id, working: false, source: 'telemetry' });
|
|
174
|
-
} else if (!silent && isIdle) {
|
|
175
|
-
if (!activeStart) activeStart = Date.now();
|
|
176
|
-
if (Date.now() - activeStart >= 2000) {
|
|
177
|
-
isIdle = false;
|
|
178
|
-
broadcastFn?.({ type: 'session.status', id, working: true, source: 'telemetry' });
|
|
179
|
-
}
|
|
180
|
-
} else if (!silent) {
|
|
181
|
-
activeStart = 0;
|
|
182
|
-
}
|
|
183
|
-
}, 250);
|
|
184
|
-
pendingIdle.set(id, check);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function cancelPendingIdle(id) {
|
|
188
|
-
const timer = pendingIdle.get(id);
|
|
189
|
-
if (timer) { clearInterval(timer); pendingIdle.delete(id); }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Codex: after response.completed, poll screen capture every 500ms for up to 3s
|
|
188
|
+
// Codex: after response.completed, poll terminal capture every 500ms for up to 3s
|
|
193
189
|
function startCodexMenuPoll(id) {
|
|
194
190
|
cancelCodexMenuPoll(id);
|
|
195
191
|
const started = Date.now();
|
|
196
192
|
const poll = setInterval(() => {
|
|
197
193
|
if (Date.now() - started > 3000) { cancelCodexMenuPoll(id); return; }
|
|
198
|
-
|
|
194
|
+
// console.log(`[terminal.capture] session=${id.slice(0,8)} source=codex-menu-poll`);
|
|
195
|
+
broadcastFn?.({ type: 'terminal.capture', id });
|
|
199
196
|
}, 500);
|
|
200
197
|
codexMenuPoll.set(id, poll);
|
|
201
198
|
}
|
|
@@ -205,42 +202,48 @@ function cancelCodexMenuPoll(id) {
|
|
|
205
202
|
if (timer) { clearInterval(timer); codexMenuPoll.delete(id); }
|
|
206
203
|
}
|
|
207
204
|
|
|
205
|
+
function armCodexStop(id) {
|
|
206
|
+
codexPendingStop.set(id, Date.now());
|
|
207
|
+
codexOutputDone.delete(id);
|
|
208
|
+
// console.log(`[codex] pending-stop armed session=${id.slice(0,8)}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function scheduleCodexIdle(id, source) {
|
|
212
|
+
cancelCodexPendingIdle(id);
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
codexPendingIdle.delete(id);
|
|
215
|
+
broadcastFn?.({ type: 'session.status', id, working: false, source });
|
|
216
|
+
}, 300);
|
|
217
|
+
codexPendingIdle.set(id, timer);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cancelCodexPendingIdle(id) {
|
|
221
|
+
const timer = codexPendingIdle.get(id);
|
|
222
|
+
if (timer) { clearTimeout(timer); codexPendingIdle.delete(id); }
|
|
223
|
+
}
|
|
224
|
+
|
|
208
225
|
function startEscIdle(id) {
|
|
209
226
|
cancelEscIdle(id);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const silence = Date.now() - Math.max(ignoreUntil, lastOut);
|
|
216
|
-
const elapsed = Date.now() - started;
|
|
217
|
-
if (elapsed > 10000) {
|
|
218
|
-
// console.log(`[escIdle] timeout session=${id.slice(0,8)} silence=${silence}ms`);
|
|
219
|
-
cancelEscIdle(id);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
if (silence >= 2000) {
|
|
223
|
-
// console.log(`[escIdle] idle session=${id.slice(0,8)} silence=${silence}ms`);
|
|
224
|
-
escSuppressUntil.set(id, Date.now() + 2000);
|
|
225
|
-
cancelEscIdle(id);
|
|
226
|
-
broadcastFn?.({ type: 'session.status', id, working: false, source: 'esc' });
|
|
227
|
-
}
|
|
228
|
-
}, 250);
|
|
229
|
-
escPendingIdle.set(id, check);
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
escPendingIdle.delete(id);
|
|
229
|
+
broadcastFn?.({ type: 'session.status', id, working: false, source: 'esc' });
|
|
230
|
+
}, 350);
|
|
231
|
+
escPendingIdle.set(id, timer);
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
function cancelEscIdle(id) {
|
|
233
235
|
const timer = escPendingIdle.get(id);
|
|
234
|
-
if (timer) {
|
|
236
|
+
if (timer) { clearTimeout(timer); escPendingIdle.delete(id); }
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
function clear(id) {
|
|
238
240
|
activity.delete(id);
|
|
239
241
|
lastEvent.delete(id);
|
|
240
|
-
cancelPendingIdle(id);
|
|
241
242
|
cancelCodexMenuPoll(id);
|
|
243
|
+
cancelCodexPendingIdle(id);
|
|
244
|
+
codexPendingStop.delete(id);
|
|
245
|
+
codexOutputDone.delete(id);
|
|
242
246
|
cancelEscIdle(id);
|
|
243
|
-
escSuppressUntil.delete(id);
|
|
244
247
|
const pending = pendingSetup.get(id);
|
|
245
248
|
if (pending) { clearTimeout(pending.timer); pendingSetup.delete(id); }
|
|
246
249
|
}
|
|
@@ -252,4 +255,4 @@ function hasEvents(id) {
|
|
|
252
255
|
return activity.has(id);
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
module.exports = { init, handleLogs, clear, hasEvents, getLastEvent, cancelCodexMenuPoll, watchSession,
|
|
258
|
+
module.exports = { init, handleLogs, clear, hasEvents, getLastEvent, cancelCodexMenuPoll, watchSession, startEscIdle, armCodexStop };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
const isCodex = process.argv.includes('--codex');
|
|
6
|
+
const file = process.argv.filter(a => !a.startsWith('--'))[2];
|
|
7
|
+
if (!file) {
|
|
8
|
+
console.error('Usage: node merge-jsonl-roles.mjs <file.jsonl> [--codex]');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const lines = readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
13
|
+
const merged = [];
|
|
14
|
+
const SPINNER_WORD = 'Working';
|
|
15
|
+
const SPINNER_TAIL = 's • esc to interrupt)';
|
|
16
|
+
const SPINNER_PREFIXES = ['Working', 'Workin', 'Worki', 'Work', 'Wor', 'Wo', 'W'];
|
|
17
|
+
const SPINNER_SUFFIXES = ['Working', 'orking', 'rking', 'king', 'ing', 'ng', 'g'];
|
|
18
|
+
const SPINNER_OUTRO = /^\d+s • esc to interr?upt\)/;
|
|
19
|
+
|
|
20
|
+
for (const entry of lines) {
|
|
21
|
+
const prev = merged[merged.length - 1];
|
|
22
|
+
if (prev && prev.role === entry.role && entry.role === 'user') {
|
|
23
|
+
prev.text += '\n' + entry.text;
|
|
24
|
+
} else {
|
|
25
|
+
merged.push({ ...entry });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanText(text) {
|
|
30
|
+
const extra = isCodex ? '\u2022\u203a' : '';
|
|
31
|
+
const re = new RegExp(`[^a-zA-Z0-9.,;:!?'"()\\-/\\s@#$%&*+=<>\\[\\]{}\\\\|_~\`^${extra}]`, 'g');
|
|
32
|
+
|
|
33
|
+
let out = text;
|
|
34
|
+
if (isCodex) {
|
|
35
|
+
out = collapseCodexSpinner(out);
|
|
36
|
+
// collapse consecutive • (with optional whitespace between) into one
|
|
37
|
+
out = out.replace(/\u2022(?:\s*\u2022)+/g, '\u2022');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return out
|
|
41
|
+
.replace(re, ' ')
|
|
42
|
+
.replace(/[ \t]+/g, ' ')
|
|
43
|
+
.replace(/\n(?:[ \t]*\n)+/g, '\n')
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collapseCodexSpinner(text) {
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < text.length;) {
|
|
50
|
+
const buildLen = consumeSpinnerBuildUp(text, i);
|
|
51
|
+
if (buildLen > 0) {
|
|
52
|
+
out += '\u2022';
|
|
53
|
+
i += buildLen;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fragmentLen = consumeSpinnerFragments(text, i);
|
|
58
|
+
if (fragmentLen > 0) {
|
|
59
|
+
out += '\u2022';
|
|
60
|
+
i += fragmentLen;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
out += text[i];
|
|
65
|
+
i += 1;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function consumeSpinnerBuildUp(text, start) {
|
|
71
|
+
if (text[start] !== '\u2022') return 0;
|
|
72
|
+
|
|
73
|
+
let i = start + 1;
|
|
74
|
+
let matched = 0;
|
|
75
|
+
while (matched < SPINNER_WORD.length && text[i] === SPINNER_WORD[matched]) {
|
|
76
|
+
i += 1;
|
|
77
|
+
matched += 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (matched === 0) return 0;
|
|
81
|
+
if (matched < SPINNER_WORD.length) return i - start;
|
|
82
|
+
if (text[i] !== '(') return i - start;
|
|
83
|
+
|
|
84
|
+
i += 1;
|
|
85
|
+
while (/[0-9]/.test(text[i] || '')) i += 1;
|
|
86
|
+
|
|
87
|
+
let tail = 0;
|
|
88
|
+
while (tail < SPINNER_TAIL.length && text[i] === SPINNER_TAIL[tail]) {
|
|
89
|
+
i += 1;
|
|
90
|
+
tail += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return i - start;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function consumeSpinnerFragments(text, start) {
|
|
97
|
+
let i = start;
|
|
98
|
+
let tokens = 0;
|
|
99
|
+
let sawBullet = false;
|
|
100
|
+
let startsWithBulletPrefix = false;
|
|
101
|
+
|
|
102
|
+
while (true) {
|
|
103
|
+
const bulletPrefix = text[i] === '\u2022' ? matchSpinnerPart(text, i + 1, SPINNER_PREFIXES) : '';
|
|
104
|
+
if (bulletPrefix) {
|
|
105
|
+
startsWithBulletPrefix ||= tokens === 0;
|
|
106
|
+
sawBullet = true;
|
|
107
|
+
i += 1 + bulletPrefix.length;
|
|
108
|
+
tokens += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const suffix = matchSpinnerPart(text, i, SPINNER_SUFFIXES);
|
|
113
|
+
if (suffix && text[i + suffix.length] === '\u2022') {
|
|
114
|
+
sawBullet = true;
|
|
115
|
+
i += suffix.length + 1;
|
|
116
|
+
tokens += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const prefix = matchSpinnerPart(text, i, SPINNER_PREFIXES);
|
|
121
|
+
if (prefix) {
|
|
122
|
+
i += prefix.length;
|
|
123
|
+
tokens += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (suffix) {
|
|
128
|
+
i += suffix.length;
|
|
129
|
+
tokens += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const outro = text.slice(i).match(SPINNER_OUTRO)?.[0];
|
|
134
|
+
if (outro) {
|
|
135
|
+
sawBullet = true;
|
|
136
|
+
i += outro.length;
|
|
137
|
+
tokens += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const digits = text.slice(i).match(/^\d+/)?.[0];
|
|
142
|
+
if (digits) {
|
|
143
|
+
i += digits.length;
|
|
144
|
+
tokens += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (startsWithBulletPrefix) return i - start;
|
|
152
|
+
return sawBullet && tokens >= 2 ? i - start : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function matchSpinnerPart(text, start, parts) {
|
|
156
|
+
return parts.find(part => text.startsWith(part, start)) || '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function splitCodexAgentEntry(entry) {
|
|
160
|
+
if (!isCodex || entry.role !== 'agent') return [entry];
|
|
161
|
+
|
|
162
|
+
const work = { ...entry, role: 'agent_work' };
|
|
163
|
+
const cut = entry.text.lastIndexOf('\u2022');
|
|
164
|
+
if (cut === -1) return [work];
|
|
165
|
+
|
|
166
|
+
const output = cleanAgentOutput(entry.text.slice(cut + 1));
|
|
167
|
+
if (!output) return [work];
|
|
168
|
+
|
|
169
|
+
return [work, { ts: entry.ts, role: 'agent_output', text: output }];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cleanAgentOutput(text) {
|
|
173
|
+
const out = text.trim();
|
|
174
|
+
const footer = out.lastIndexOf('\u203a');
|
|
175
|
+
return (footer === -1 ? out : out.slice(0, footer)).trim();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cleaned = merged.map(e => ({ ...e, text: cleanText(e.text) }));
|
|
179
|
+
const transformed = cleaned.flatMap(splitCodexAgentEntry);
|
|
180
|
+
const out = transformed.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
181
|
+
writeFileSync(file, out);
|
|
182
|
+
console.log(`${lines.length} lines → ${merged.length} lines`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function cleanAgentText(presetId, text) {
|
|
2
|
+
let out = String(text || '').split('\n').map(l => l.replace(/[ \t]+$/g, '')).join('\n').trim();
|
|
3
|
+
out = out.replace(/\n\n─{5,}\s*$/u, '').trim();
|
|
4
|
+
if (presetId === 'codex' || presetId === 'claude-code') {
|
|
5
|
+
const cut = Math.max(out.lastIndexOf('›'), out.lastIndexOf('❯'));
|
|
6
|
+
if (cut !== -1) out = out.slice(0, cut).trim();
|
|
7
|
+
}
|
|
8
|
+
if (presetId === 'gemini-cli') {
|
|
9
|
+
const cut = out.lastIndexOf('\n >');
|
|
10
|
+
if (cut !== -1) out = out.slice(0, cut).trim();
|
|
11
|
+
out = out.replace(/\n\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]?\s*Executing Hook:[\s\S]*$/, '').trim();
|
|
12
|
+
}
|
|
13
|
+
if (presetId === 'claude-code') {
|
|
14
|
+
out = out.replace(/\n\s*.*\(running stop hook\)[\s\S]*$/, '').trim();
|
|
15
|
+
out = out.replace(/\n\s*\?\s*for shortcuts[\s\S]*$/, '').trim();
|
|
16
|
+
out = out.replace(/\n\s*esc to interrupt[\s\S]*$/, '').trim();
|
|
17
|
+
out = out.replace(/\n\s*[✻✢✣✤✥✦✧]\s+[^\n]*$/, '').trim();
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeEntry(entry, presetId) {
|
|
23
|
+
const text = entry.role === 'agent'
|
|
24
|
+
? cleanAgentText(presetId, entry.text)
|
|
25
|
+
: String(entry.text || '').trim();
|
|
26
|
+
return text ? { ...entry, text } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addEntry(entries, entry, presetId) {
|
|
30
|
+
const next = normalizeEntry(entry, presetId);
|
|
31
|
+
if (!next) return entries;
|
|
32
|
+
const prev = entries[entries.length - 1];
|
|
33
|
+
if (prev && prev.role === 'user' && next.role === 'user') {
|
|
34
|
+
prev.text += '\n' + next.text;
|
|
35
|
+
prev.ts = next.ts;
|
|
36
|
+
return entries;
|
|
37
|
+
}
|
|
38
|
+
if (prev && prev.role === 'agent' && next.role === 'agent') {
|
|
39
|
+
prev.text = next.text;
|
|
40
|
+
prev.ts = next.ts;
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
entries.push(next);
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compactEntries(entries, presetId) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const entry of entries || []) addEntry(out, entry, presetId);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { addEntry, compactEntries, cleanAgentText };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
function parseTurns(presetId, lines, users) {
|
|
2
|
+
const parser = parsers[presetId];
|
|
3
|
+
return collapseAgentTurns(parser ? parser(lines, users) : anchorParse(lines, users));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function parseLastAgentOnly(presetId, lines) {
|
|
7
|
+
const turns = collapseAgentTurns((parsers[presetId] || (() => null))(lines, null));
|
|
8
|
+
if (!turns?.length) return null;
|
|
9
|
+
const last = [...turns].reverse().find(t => t.role === 'agent');
|
|
10
|
+
return last || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const parsers = {
|
|
14
|
+
'claude-code': (lines, users) => {
|
|
15
|
+
const known = users?.length ? new Set(users) : null;
|
|
16
|
+
const turns = [];
|
|
17
|
+
let current = null;
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
|
|
20
|
+
if (agent) {
|
|
21
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
22
|
+
current = { role: 'agent', text: agent[1] };
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const userM = line.match(/^(?:[│ ]\s*)?[❯›]\s(.*)$/);
|
|
26
|
+
if (userM && (known ? known.has(userM[1].trim()) : true)) {
|
|
27
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
28
|
+
current = { role: 'user', text: userM[1] };
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!current) continue;
|
|
32
|
+
let cont = line;
|
|
33
|
+
if (cont.startsWith('│ ')) cont = cont.slice(2);
|
|
34
|
+
else if (cont.startsWith(' ')) cont = cont.slice(2);
|
|
35
|
+
current.text += '\n' + cont;
|
|
36
|
+
}
|
|
37
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
38
|
+
return turns.length >= 2 ? turns : null;
|
|
39
|
+
},
|
|
40
|
+
codex: (lines, users) => {
|
|
41
|
+
const known = users?.length ? new Set(users) : null;
|
|
42
|
+
const turns = [];
|
|
43
|
+
let current = null;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const agent = line.match(/^(?:│\s*)?•\s(.*)$/);
|
|
46
|
+
if (agent) {
|
|
47
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
48
|
+
current = { role: 'agent', text: agent[1] };
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const userM = line.match(/^(?:│\s*)?›\s(.*)$/);
|
|
52
|
+
if (userM && (known ? known.has(userM[1].trim()) : true)) {
|
|
53
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
54
|
+
current = { role: 'user', text: userM[1] };
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!current) continue;
|
|
58
|
+
let cont = line;
|
|
59
|
+
if (cont.startsWith('│ ')) cont = cont.slice(2);
|
|
60
|
+
else if (cont.startsWith(' ')) cont = cont.slice(2);
|
|
61
|
+
current.text += '\n' + cont;
|
|
62
|
+
}
|
|
63
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
64
|
+
return turns.length >= 2 ? turns : null;
|
|
65
|
+
},
|
|
66
|
+
'gemini-cli': (lines, users) => {
|
|
67
|
+
const known = users?.length ? new Set(users) : null;
|
|
68
|
+
const isChrome = t => {
|
|
69
|
+
const s = t.trim();
|
|
70
|
+
return /^shift\+tab to accept/i.test(s)
|
|
71
|
+
|| /^(Type your message|@path\/to\/)/i.test(s)
|
|
72
|
+
|| /^(\/\w+ |no sandbox|\/model )/i.test(s)
|
|
73
|
+
|| /^[~\/\\].*\(main[*]?\)\s*$/i.test(s)
|
|
74
|
+
|| /^(Logged in with|Plan:|Tips for getting started)/i.test(s)
|
|
75
|
+
|| /^\d+\.\s+(Ask questions|Be specific|Create GEMINI)/i.test(s)
|
|
76
|
+
|| /^ℹ\s/.test(s);
|
|
77
|
+
};
|
|
78
|
+
const turns = [];
|
|
79
|
+
let current = null;
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (isChrome(line)) continue;
|
|
82
|
+
const isAgent = line.startsWith('✦ ');
|
|
83
|
+
const userM = line.startsWith(' > ') ? line.slice(3) : null;
|
|
84
|
+
const isUser = userM && (known ? known.has(userM.trim()) : true);
|
|
85
|
+
if (isUser || isAgent) {
|
|
86
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
87
|
+
current = { role: isUser ? 'user' : 'agent', text: isUser ? userM : line.slice(2) };
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!current) continue;
|
|
91
|
+
current.text += '\n' + line;
|
|
92
|
+
}
|
|
93
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
94
|
+
return turns.length >= 2 ? turns : null;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function anchorParse(lines, users) {
|
|
99
|
+
if (!users?.length) return null;
|
|
100
|
+
const isPrompt = (line, text) => { const t = line.trim(); return t.endsWith(text) && t.length - text.length <= 6; };
|
|
101
|
+
const lastUser = users[users.length - 1];
|
|
102
|
+
let lastIdx = -1;
|
|
103
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
104
|
+
if (isPrompt(lines[i], lastUser)) { lastIdx = i; break; }
|
|
105
|
+
}
|
|
106
|
+
if (lastIdx < 0) return null;
|
|
107
|
+
const anchors = [{ idx: lastIdx, text: lastUser }];
|
|
108
|
+
for (let u = users.length - 2; u >= 0 && anchors.length < 3; u--) {
|
|
109
|
+
for (let i = anchors[anchors.length - 1].idx - 1; i >= 0; i--) {
|
|
110
|
+
if (isPrompt(lines[i], users[u])) { anchors.push({ idx: i, text: users[u] }); break; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
anchors.reverse();
|
|
114
|
+
const turns = [];
|
|
115
|
+
for (let p = 0; p < anchors.length; p++) {
|
|
116
|
+
turns.push({ role: 'user', text: anchors[p].text });
|
|
117
|
+
const start = anchors[p].idx + 1;
|
|
118
|
+
const end = p + 1 < anchors.length ? anchors[p + 1].idx : lines.length;
|
|
119
|
+
const agentLines = lines.slice(start, end).filter(l => l.trim());
|
|
120
|
+
if (agentLines.length) turns.push({ role: 'agent', text: agentLines.join('\n') });
|
|
121
|
+
}
|
|
122
|
+
return turns.length >= 2 && turns[turns.length - 1].role === 'agent' ? turns : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collapseAgentTurns(turns) {
|
|
126
|
+
if (!turns?.length) return turns;
|
|
127
|
+
const out = [];
|
|
128
|
+
for (let i = 0; i < turns.length; i++) {
|
|
129
|
+
if (turns[i].role === 'agent' && i + 1 < turns.length && turns[i + 1].role === 'agent') continue;
|
|
130
|
+
out.push(turns[i]);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { parseTurns, parseLastAgentOnly };
|