@yeaft/webchat-agent 0.1.857 → 0.1.860
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/conversation.js +114 -32
- package/history.js +22 -59
- package/package.json +1 -1
- package/providers/base.js +58 -0
- package/providers/claude-code.js +84 -0
- package/providers/copilot.js +484 -0
- package/providers/index.js +19 -0
- package/yeaft/conversation/persist.js +94 -53
- package/yeaft/conversation/search.js +24 -6
- package/yeaft/init.js +6 -11
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import ctx from '../context.js';
|
|
8
|
+
|
|
9
|
+
export const name = 'copilot';
|
|
10
|
+
|
|
11
|
+
const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
12
|
+
// Opt-in only: --allow-all-tools is a destructive footgun by default in a
|
|
13
|
+
// multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
|
|
14
|
+
// doing) to skip Copilot's tool prompts.
|
|
15
|
+
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start (or resume) a Copilot session.
|
|
19
|
+
* Copilot's `-p` mode is one-shot per turn, so "start" just prepares state.
|
|
20
|
+
* Each sendInput() spawns one `copilot -p ...` child with the same
|
|
21
|
+
* --session-id for continuity.
|
|
22
|
+
*/
|
|
23
|
+
export async function start(opts) {
|
|
24
|
+
const conversationId = opts.conversationId;
|
|
25
|
+
// Tear down any prior entry so we don't leak children.
|
|
26
|
+
const prior = ctx.conversations.get(conversationId);
|
|
27
|
+
if (prior?.copilotChild) {
|
|
28
|
+
try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sessionId = opts.resumeSessionId || randomUUID();
|
|
32
|
+
const providerOptions = opts.providerOptions || prior?.providerOptions || {};
|
|
33
|
+
const state = {
|
|
34
|
+
providerName: name,
|
|
35
|
+
conversationId: opts.conversationId,
|
|
36
|
+
query: null,
|
|
37
|
+
inputStream: null,
|
|
38
|
+
workDir: opts.workDir,
|
|
39
|
+
claudeSessionId: sessionId,
|
|
40
|
+
sessionId,
|
|
41
|
+
createdAt: prior?.createdAt || Date.now(),
|
|
42
|
+
abortController: null,
|
|
43
|
+
tools: [],
|
|
44
|
+
slashCommands: [],
|
|
45
|
+
model: providerOptions.model || 'copilot',
|
|
46
|
+
userId: opts.userId,
|
|
47
|
+
username: opts.username,
|
|
48
|
+
disallowedTools: prior?.disallowedTools || null,
|
|
49
|
+
copilotChild: null,
|
|
50
|
+
providerOptions,
|
|
51
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
52
|
+
};
|
|
53
|
+
ctx.conversations.set(conversationId, state);
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function sendInput(state, prompt, opts = {}) {
|
|
58
|
+
const conversationId = opts.conversationId || state.conversationId;
|
|
59
|
+
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
60
|
+
if (!state.sessionId) state.sessionId = randomUUID();
|
|
61
|
+
|
|
62
|
+
// Abort any in-flight turn.
|
|
63
|
+
if (state.copilotChild) {
|
|
64
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
65
|
+
state.copilotChild = null;
|
|
66
|
+
}
|
|
67
|
+
const abortController = new AbortController();
|
|
68
|
+
state.abortController = abortController;
|
|
69
|
+
state.turnActive = true;
|
|
70
|
+
state.turnResultReceived = false;
|
|
71
|
+
|
|
72
|
+
const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
|
|
73
|
+
const po = { ...(state.providerOptions || {}), ...(opts.providerOptions || {}) };
|
|
74
|
+
if (po.model) args.push('--model', String(po.model));
|
|
75
|
+
if (po.effort) args.push('--effort', String(po.effort));
|
|
76
|
+
if (Array.isArray(po.addDirs)) {
|
|
77
|
+
for (const d of po.addDirs) args.push('--add-dir', String(d));
|
|
78
|
+
}
|
|
79
|
+
// YOLO env var still wins as a global override; per-conv allowAllTools
|
|
80
|
+
// lets the user opt in from the UI without setting an env var.
|
|
81
|
+
if (YOLO || po.allowAllTools) args.push('--allow-all-tools');
|
|
82
|
+
|
|
83
|
+
let child;
|
|
84
|
+
try {
|
|
85
|
+
child = spawn(COPILOT_BIN, args, {
|
|
86
|
+
cwd: state.workDir,
|
|
87
|
+
env: process.env,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
sendOutput(conversationId, {
|
|
92
|
+
type: 'result',
|
|
93
|
+
subtype: 'error',
|
|
94
|
+
session_id: state.sessionId,
|
|
95
|
+
is_error: true,
|
|
96
|
+
error: `copilot spawn failed: ${err?.message || err}`,
|
|
97
|
+
});
|
|
98
|
+
state.turnActive = false;
|
|
99
|
+
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
state.copilotChild = child;
|
|
103
|
+
|
|
104
|
+
// Pre-register error handler so async ENOENT from spawn is never unhandled.
|
|
105
|
+
child.on('error', (err) => {
|
|
106
|
+
sendOutput(conversationId, {
|
|
107
|
+
type: 'result',
|
|
108
|
+
subtype: 'error',
|
|
109
|
+
session_id: state.sessionId,
|
|
110
|
+
is_error: true,
|
|
111
|
+
error: `copilot process error: ${err?.message || err}`,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let killTimer = null;
|
|
116
|
+
abortController.signal.addEventListener('abort', () => {
|
|
117
|
+
try { child.kill('SIGTERM'); } catch { /* noop */ }
|
|
118
|
+
// Escalate to SIGKILL if the child ignores SIGTERM, so the awaited
|
|
119
|
+
// close promise resolves and the next turn isn't blocked forever.
|
|
120
|
+
killTimer = setTimeout(() => {
|
|
121
|
+
try { child.kill('SIGKILL'); } catch { /* noop */ }
|
|
122
|
+
}, 5000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let stderrBuf = '';
|
|
126
|
+
const STDERR_CAP = 64 * 1024;
|
|
127
|
+
let sawResult = false;
|
|
128
|
+
|
|
129
|
+
const parser = createNdjsonParser((evt) => {
|
|
130
|
+
const envelopes = translateCopilotEvent(evt, state);
|
|
131
|
+
for (const e of envelopes) {
|
|
132
|
+
sendOutput(conversationId, e);
|
|
133
|
+
if (e?.type === 'result') sawResult = true;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
child.stdout.on('data', (chunk) => parser.push(chunk));
|
|
138
|
+
child.stderr.on('data', (chunk) => {
|
|
139
|
+
if (stderrBuf.length < STDERR_CAP) {
|
|
140
|
+
stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await new Promise((resolve) => {
|
|
145
|
+
child.on('close', (code) => {
|
|
146
|
+
if (killTimer) clearTimeout(killTimer);
|
|
147
|
+
parser.flush();
|
|
148
|
+
if (!sawResult) {
|
|
149
|
+
const ok = code === 0;
|
|
150
|
+
sendOutput(conversationId, {
|
|
151
|
+
type: 'result',
|
|
152
|
+
subtype: ok ? 'success' : 'error',
|
|
153
|
+
session_id: state.sessionId,
|
|
154
|
+
is_error: !ok,
|
|
155
|
+
error: ok ? undefined : (stderrBuf.trim().slice(0, 2000) || `copilot exited with code ${code}`),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
state.copilotChild = null;
|
|
159
|
+
state.turnActive = false;
|
|
160
|
+
ctx.sendToServer({
|
|
161
|
+
type: 'turn_completed',
|
|
162
|
+
conversationId,
|
|
163
|
+
claudeSessionId: state.sessionId,
|
|
164
|
+
workDir: state.workDir,
|
|
165
|
+
});
|
|
166
|
+
resolve();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function abort(state) {
|
|
172
|
+
if (state?.abortController) {
|
|
173
|
+
try { state.abortController.abort(); } catch { /* noop */ }
|
|
174
|
+
}
|
|
175
|
+
if (state?.copilotChild) {
|
|
176
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------- internals ----------
|
|
181
|
+
|
|
182
|
+
function sendOutput(conversationId, data) {
|
|
183
|
+
ctx.sendToServer({ type: 'claude_output', conversationId, data });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function createNdjsonParser(onEvent) {
|
|
187
|
+
let buf = '';
|
|
188
|
+
return {
|
|
189
|
+
push(chunk) {
|
|
190
|
+
buf += chunk.toString('utf8');
|
|
191
|
+
let idx;
|
|
192
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
193
|
+
const line = buf.slice(0, idx).trim();
|
|
194
|
+
buf = buf.slice(idx + 1);
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
let evt;
|
|
197
|
+
try { evt = JSON.parse(line); }
|
|
198
|
+
catch (err) {
|
|
199
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unparsable line:', line.slice(0, 200));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
try { onEvent(evt); }
|
|
203
|
+
catch (err) { console.warn('[copilot] event handler error:', err?.message || err); }
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
flush() {
|
|
207
|
+
const line = buf.trim();
|
|
208
|
+
buf = '';
|
|
209
|
+
if (!line) return;
|
|
210
|
+
try {
|
|
211
|
+
const evt = JSON.parse(line);
|
|
212
|
+
onEvent(evt);
|
|
213
|
+
} catch { /* discard trailing junk */ }
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Map a Copilot NDJSON event to zero-or-more claude_output envelopes.
|
|
220
|
+
* Defensive: unknown shapes are logged and dropped.
|
|
221
|
+
*
|
|
222
|
+
* Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
|
|
223
|
+
* we accept several aliases and forward only what we understand):
|
|
224
|
+
* - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
|
|
225
|
+
* - message: { type: 'message', role, content }
|
|
226
|
+
* - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
|
|
227
|
+
* - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
|
|
228
|
+
* - done: { type: 'result'|'done'|'complete', session_id?, error? }
|
|
229
|
+
* - error: { type: 'error', message|error }
|
|
230
|
+
*/
|
|
231
|
+
export function translateCopilotEvent(evt, state) {
|
|
232
|
+
if (!evt || typeof evt !== 'object') return [];
|
|
233
|
+
const t = evt.type;
|
|
234
|
+
|
|
235
|
+
if (t === 'text' || t === 'text_delta' || t === 'assistant_text') {
|
|
236
|
+
const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
|
|
237
|
+
if (!text) return [];
|
|
238
|
+
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } }];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (t === 'message' && evt.role === 'assistant') {
|
|
242
|
+
const content = normalizeContent(evt.content);
|
|
243
|
+
return [{ type: 'assistant', message: { role: 'assistant', content } }];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (t === 'tool_call' || t === 'tool_use') {
|
|
247
|
+
const id = evt.id || evt.call_id || randomUUID();
|
|
248
|
+
const toolName = evt.name || evt.tool || 'unknown';
|
|
249
|
+
const input = evt.input ?? evt.arguments ?? {};
|
|
250
|
+
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] } }];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (t === 'tool_result') {
|
|
254
|
+
const tool_use_id = evt.tool_use_id || evt.id || 'unknown';
|
|
255
|
+
const content = typeof evt.content === 'string'
|
|
256
|
+
? evt.content
|
|
257
|
+
: (typeof evt.output === 'string' ? evt.output : JSON.stringify(evt.content ?? evt.output ?? ''));
|
|
258
|
+
return [{ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id, content }] } }];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (t === 'result' || t === 'done' || t === 'complete') {
|
|
262
|
+
const isErr = !!evt.error || evt.is_error === true;
|
|
263
|
+
return [{
|
|
264
|
+
type: 'result',
|
|
265
|
+
subtype: isErr ? 'error' : 'success',
|
|
266
|
+
session_id: evt.session_id || state?.sessionId || null,
|
|
267
|
+
is_error: isErr,
|
|
268
|
+
error: isErr ? (evt.error || evt.message || 'copilot error') : undefined,
|
|
269
|
+
}];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (t === 'error') {
|
|
273
|
+
return [{
|
|
274
|
+
type: 'result',
|
|
275
|
+
subtype: 'error',
|
|
276
|
+
session_id: state?.sessionId || null,
|
|
277
|
+
is_error: true,
|
|
278
|
+
error: evt.message || evt.error || 'copilot error',
|
|
279
|
+
}];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unknown event type:', t);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeContent(content) {
|
|
287
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
288
|
+
if (Array.isArray(content)) return content;
|
|
289
|
+
return [{ type: 'text', text: String(content ?? '') }];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };
|
|
293
|
+
|
|
294
|
+
// ---------- history surface (reads ~/.copilot/session-store.db) ----------
|
|
295
|
+
|
|
296
|
+
export function getCopilotDbPath() {
|
|
297
|
+
return process.env.COPILOT_DB_PATH || join(homedir(), '.copilot', 'session-store.db');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let _dbHandle = null;
|
|
301
|
+
let _dbHandlePath = null;
|
|
302
|
+
function openDb() {
|
|
303
|
+
const path = getCopilotDbPath();
|
|
304
|
+
if (!existsSync(path)) return null;
|
|
305
|
+
if (_dbHandle && _dbHandlePath === path) return _dbHandle;
|
|
306
|
+
if (_dbHandle) {
|
|
307
|
+
try { _dbHandle.close(); } catch { /* noop */ }
|
|
308
|
+
_dbHandle = null;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
_dbHandle = new DatabaseSync(path, { readOnly: true });
|
|
312
|
+
_dbHandlePath = path;
|
|
313
|
+
return _dbHandle;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] cannot open session DB:', err?.message || err);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Exposed for tests so they can drop the cached handle when swapping
|
|
321
|
+
// COPILOT_DB_PATH between cases.
|
|
322
|
+
export function _resetCopilotDbHandle() {
|
|
323
|
+
if (_dbHandle) {
|
|
324
|
+
try { _dbHandle.close(); } catch { /* noop */ }
|
|
325
|
+
}
|
|
326
|
+
_dbHandle = null;
|
|
327
|
+
_dbHandlePath = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function toEpochMs(iso) {
|
|
331
|
+
if (!iso) return 0;
|
|
332
|
+
const t = Date.parse(iso);
|
|
333
|
+
return Number.isFinite(t) ? t : 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function listFolders() {
|
|
337
|
+
const db = openDb();
|
|
338
|
+
if (!db) return [];
|
|
339
|
+
try {
|
|
340
|
+
const rows = db.prepare(`
|
|
341
|
+
SELECT cwd, COUNT(*) AS sessionCount, MAX(updated_at) AS lastUpdated
|
|
342
|
+
FROM sessions
|
|
343
|
+
WHERE cwd IS NOT NULL AND cwd <> ''
|
|
344
|
+
GROUP BY cwd
|
|
345
|
+
ORDER BY lastUpdated DESC
|
|
346
|
+
`).all();
|
|
347
|
+
return rows.map(r => ({
|
|
348
|
+
name: r.cwd,
|
|
349
|
+
path: r.cwd,
|
|
350
|
+
sessionCount: Number(r.sessionCount) || 0,
|
|
351
|
+
lastModified: toEpochMs(r.lastUpdated),
|
|
352
|
+
}));
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] listFolders failed:', err?.message || err);
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function listSessions(workDir) {
|
|
360
|
+
if (!workDir) return [];
|
|
361
|
+
const db = openDb();
|
|
362
|
+
if (!db) return [];
|
|
363
|
+
try {
|
|
364
|
+
const rows = db.prepare(`
|
|
365
|
+
SELECT s.id, s.summary, s.created_at, s.updated_at,
|
|
366
|
+
(SELECT user_message FROM turns WHERE session_id = s.id ORDER BY turn_index ASC LIMIT 1) AS first_user
|
|
367
|
+
FROM sessions s
|
|
368
|
+
WHERE s.cwd = ?
|
|
369
|
+
ORDER BY s.updated_at DESC
|
|
370
|
+
`).all(workDir);
|
|
371
|
+
return rows.map(r => {
|
|
372
|
+
const preview = (r.first_user || '').toString().slice(0, 100);
|
|
373
|
+
const title = (r.summary && r.summary.trim()) || preview || r.id.slice(0, 8);
|
|
374
|
+
return {
|
|
375
|
+
sessionId: r.id,
|
|
376
|
+
workDir,
|
|
377
|
+
title,
|
|
378
|
+
preview,
|
|
379
|
+
lastModified: toEpochMs(r.updated_at) || toEpochMs(r.created_at),
|
|
380
|
+
};
|
|
381
|
+
}).filter(s => s.title);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] listSessions failed:', err?.message || err);
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function loadHistory(workDir, sessionId, limit = 500) {
|
|
389
|
+
if (!sessionId) return [];
|
|
390
|
+
const db = openDb();
|
|
391
|
+
if (!db) return [];
|
|
392
|
+
try {
|
|
393
|
+
const allTurns = db.prepare(`
|
|
394
|
+
SELECT turn_index, user_message, assistant_response, timestamp
|
|
395
|
+
FROM turns WHERE session_id = ? ORDER BY turn_index ASC
|
|
396
|
+
`).all(sessionId);
|
|
397
|
+
// Apply limit at the turn level so we never split a tool_use / tool_result
|
|
398
|
+
// pair when truncating. Each turn typically expands to <=4 messages, so
|
|
399
|
+
// ceil(limit/4) turns is a safe upper bound that preserves recency.
|
|
400
|
+
const turns = limit && allTurns.length > Math.ceil(limit / 4)
|
|
401
|
+
? allTurns.slice(-Math.ceil(limit / 4))
|
|
402
|
+
: allTurns;
|
|
403
|
+
|
|
404
|
+
// Tool-call events per turn, in order. Copilot's schema is not fully
|
|
405
|
+
// documented; in some versions a single tool call writes multiple rows
|
|
406
|
+
// (e.g. a "started" row with the command and a "completed" row with
|
|
407
|
+
// output/exit_code). Dedupe by tool_call_id, preferring rows that
|
|
408
|
+
// carry output so we render one tool_use + one tool_result per call.
|
|
409
|
+
const events = db.prepare(`
|
|
410
|
+
SELECT id, turn_index, tool_call_id, event_type, command, output, exit_code,
|
|
411
|
+
event_key, event_value, created_at
|
|
412
|
+
FROM forge_trajectory_events
|
|
413
|
+
WHERE session_id = ?
|
|
414
|
+
ORDER BY turn_index ASC, id ASC
|
|
415
|
+
`).all(sessionId);
|
|
416
|
+
|
|
417
|
+
const mergedByCallId = new Map();
|
|
418
|
+
const orderedKeys = [];
|
|
419
|
+
for (const e of events) {
|
|
420
|
+
const key = e.tool_call_id || `__row:${e.id}`;
|
|
421
|
+
const prev = mergedByCallId.get(key);
|
|
422
|
+
if (!prev) {
|
|
423
|
+
orderedKeys.push(key);
|
|
424
|
+
mergedByCallId.set(key, { ...e });
|
|
425
|
+
} else {
|
|
426
|
+
// Merge later rows in; non-null values win so the "completed" row
|
|
427
|
+
// adds output/exit_code without erasing the "started" row's command.
|
|
428
|
+
for (const [k, v] of Object.entries(e)) {
|
|
429
|
+
if (v != null && v !== '') prev[k] = v;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const eventsByTurn = new Map();
|
|
434
|
+
for (const key of orderedKeys) {
|
|
435
|
+
const e = mergedByCallId.get(key);
|
|
436
|
+
const arr = eventsByTurn.get(e.turn_index) || [];
|
|
437
|
+
arr.push(e);
|
|
438
|
+
eventsByTurn.set(e.turn_index, arr);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const messages = [];
|
|
442
|
+
for (const t of turns) {
|
|
443
|
+
if (t.user_message) {
|
|
444
|
+
messages.push({
|
|
445
|
+
type: 'user',
|
|
446
|
+
message: { role: 'user', content: [{ type: 'text', text: String(t.user_message) }] },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
// Render any tool calls captured for this turn as assistant tool_use +
|
|
450
|
+
// user tool_result pairs, then the final assistant text.
|
|
451
|
+
const turnEvents = eventsByTurn.get(t.turn_index) || [];
|
|
452
|
+
for (const e of turnEvents) {
|
|
453
|
+
if (!e.event_type) continue;
|
|
454
|
+
const toolId = e.tool_call_id || `copilot-${t.turn_index}-${messages.length}`;
|
|
455
|
+
const toolName = e.event_type;
|
|
456
|
+
const input = e.command
|
|
457
|
+
? { command: e.command }
|
|
458
|
+
: (e.event_key ? { [e.event_key]: e.event_value } : {});
|
|
459
|
+
messages.push({
|
|
460
|
+
type: 'assistant',
|
|
461
|
+
message: { role: 'assistant', content: [{ type: 'tool_use', id: toolId, name: toolName, input }] },
|
|
462
|
+
});
|
|
463
|
+
const outText = e.output != null
|
|
464
|
+
? String(e.output)
|
|
465
|
+
: (e.exit_code != null ? `exit ${e.exit_code}` : '');
|
|
466
|
+
messages.push({
|
|
467
|
+
type: 'user',
|
|
468
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: outText }] },
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (t.assistant_response) {
|
|
472
|
+
messages.push({
|
|
473
|
+
type: 'assistant',
|
|
474
|
+
message: { role: 'assistant', content: [{ type: 'text', text: String(t.assistant_response) }] },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return messages;
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] loadHistory failed:', err?.message || err);
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider } from './base.js';
|
|
2
|
+
import * as claudeCode from './claude-code.js';
|
|
3
|
+
import * as copilot from './copilot.js';
|
|
4
|
+
|
|
5
|
+
const REGISTRY = Object.freeze({
|
|
6
|
+
'claude-code': claudeCode,
|
|
7
|
+
'copilot': copilot,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function getProvider(nameOrUndef) {
|
|
11
|
+
const key = nameOrUndef || DEFAULT_PROVIDER;
|
|
12
|
+
const driver = REGISTRY[key];
|
|
13
|
+
if (!driver) {
|
|
14
|
+
throw new Error(`Unknown chat provider: ${nameOrUndef} (known: ${PROVIDER_NAMES.join(', ')})`);
|
|
15
|
+
}
|
|
16
|
+
return driver;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider };
|