@yeaft/webchat-agent 0.1.859 → 0.1.863
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/connection/message-router.js +21 -1
- package/conversation.js +79 -6
- package/history.js +22 -59
- package/package.json +1 -1
- package/providers/acp-client.js +135 -0
- package/providers/base.js +41 -0
- package/providers/claude-code.js +62 -1
- package/providers/copilot-models.js +17 -0
- package/providers/copilot.js +681 -176
- package/yeaft/chats/chat-store.js +224 -0
- package/yeaft/conversation/persist.js +98 -0
- package/yeaft/dream-v2/apply.js +8 -0
- package/yeaft/dream-v2/prompts/index.js +3 -0
- package/yeaft/dream-v2/triage.js +16 -1
- package/yeaft/engine.js +24 -8
- package/yeaft/groups/pre-flow.js +13 -4
- package/yeaft/llm/models-dev.js +7 -1
- package/yeaft/memory/segment.js +1 -1
- package/yeaft/memory/store-v2.js +41 -2
- package/yeaft/web-bridge.js +326 -3
package/providers/copilot.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
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';
|
|
3
7
|
import ctx from '../context.js';
|
|
8
|
+
import { AcpClient } from './acp-client.js';
|
|
9
|
+
import { COPILOT_MODELS, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
|
|
4
10
|
|
|
5
11
|
export const name = 'copilot';
|
|
6
12
|
|
|
13
|
+
export const capabilities = Object.freeze({
|
|
14
|
+
compact: false, // TODO: probe ACP for /compact equivalent
|
|
15
|
+
clear: true, // session/new gives us a fresh transcript
|
|
16
|
+
expert: false, // Copilot has /fleet, different model
|
|
17
|
+
mcp: true, // ACP advertises mcpCapabilities at init
|
|
18
|
+
subagents: false,
|
|
19
|
+
attachments: true, // ACP promptCapabilities.image + embeddedContext
|
|
20
|
+
askUser: true, // session/request_permission round-trip
|
|
21
|
+
modelPicker: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
7
24
|
const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
8
|
-
//
|
|
9
|
-
// multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
|
|
10
|
-
// doing) to skip Copilot's tool prompts.
|
|
25
|
+
// YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
|
|
11
26
|
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
27
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
12
28
|
|
|
13
29
|
/**
|
|
14
|
-
* Start (or resume) a Copilot session.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
30
|
+
* Start (or resume) a Copilot ACP session.
|
|
31
|
+
*
|
|
32
|
+
* Spawns one persistent `copilot --acp` child per conversation and runs the
|
|
33
|
+
* ACP handshake: initialize → session/new (or session/load). The child stays
|
|
34
|
+
* alive for the conversation's lifetime; each turn is a `session/prompt`
|
|
35
|
+
* JSONRPC request, not a fresh process.
|
|
18
36
|
*/
|
|
19
37
|
export async function start(opts) {
|
|
20
38
|
const conversationId = opts.conversationId;
|
|
@@ -23,73 +41,82 @@ export async function start(opts) {
|
|
|
23
41
|
if (prior?.copilotChild) {
|
|
24
42
|
try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
25
43
|
}
|
|
44
|
+
if (prior?.acpClient) {
|
|
45
|
+
try { prior.acpClient.close('replaced'); } catch { /* noop */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const providerOptions = opts.providerOptions || prior?.providerOptions || {};
|
|
49
|
+
const model = providerOptions.model || DEFAULT_COPILOT_MODEL;
|
|
50
|
+
const allowAllTools = YOLO || !!providerOptions.allowAllTools;
|
|
26
51
|
|
|
27
|
-
const sessionId = opts.resumeSessionId || randomUUID();
|
|
28
52
|
const state = {
|
|
29
53
|
providerName: name,
|
|
30
|
-
conversationId
|
|
54
|
+
conversationId,
|
|
31
55
|
query: null,
|
|
32
56
|
inputStream: null,
|
|
33
57
|
workDir: opts.workDir,
|
|
34
|
-
claudeSessionId:
|
|
35
|
-
sessionId,
|
|
58
|
+
claudeSessionId: opts.resumeSessionId || null, // set after session/new or session/load
|
|
59
|
+
sessionId: opts.resumeSessionId || null,
|
|
36
60
|
createdAt: prior?.createdAt || Date.now(),
|
|
37
61
|
abortController: null,
|
|
38
62
|
tools: [],
|
|
39
63
|
slashCommands: [],
|
|
40
|
-
model
|
|
64
|
+
model,
|
|
41
65
|
userId: opts.userId,
|
|
42
66
|
username: opts.username,
|
|
43
67
|
disallowedTools: prior?.disallowedTools || null,
|
|
44
68
|
copilotChild: null,
|
|
69
|
+
acpClient: null,
|
|
70
|
+
providerOptions,
|
|
71
|
+
allowAllTools,
|
|
72
|
+
capabilities,
|
|
73
|
+
initialized: false,
|
|
74
|
+
pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
|
|
45
75
|
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
46
76
|
};
|
|
47
77
|
ctx.conversations.set(conversationId, state);
|
|
48
|
-
return state;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function sendInput(state, prompt, opts = {}) {
|
|
52
|
-
const conversationId = opts.conversationId || state.conversationId;
|
|
53
|
-
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
54
|
-
if (!state.sessionId) state.sessionId = randomUUID();
|
|
55
78
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
59
|
-
state.copilotChild = null;
|
|
60
|
-
}
|
|
61
|
-
const abortController = new AbortController();
|
|
62
|
-
state.abortController = abortController;
|
|
63
|
-
state.turnActive = true;
|
|
64
|
-
state.turnResultReceived = false;
|
|
65
|
-
|
|
66
|
-
const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
|
|
67
|
-
if (YOLO) args.push('--allow-all-tools');
|
|
68
|
-
|
|
69
|
-
let child;
|
|
79
|
+
// Best-effort start; failures emit a result envelope and leave state in place
|
|
80
|
+
// so the next sendInput retries.
|
|
70
81
|
try {
|
|
71
|
-
|
|
72
|
-
cwd: state.workDir,
|
|
73
|
-
env: process.env,
|
|
74
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
75
|
-
});
|
|
82
|
+
await _bootAcp(state, opts.resumeSessionId || null, model);
|
|
76
83
|
} catch (err) {
|
|
77
84
|
sendOutput(conversationId, {
|
|
78
85
|
type: 'result',
|
|
79
86
|
subtype: 'error',
|
|
80
87
|
session_id: state.sessionId,
|
|
81
88
|
is_error: true,
|
|
82
|
-
error: `copilot
|
|
89
|
+
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
83
90
|
});
|
|
84
|
-
state.turnActive = false;
|
|
85
|
-
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
86
|
-
return;
|
|
87
91
|
}
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function _bootAcp(state, resumeSessionId, model) {
|
|
96
|
+
const args = ['--acp'];
|
|
97
|
+
// ACP doesn't yet expose a per-session model param in its public schema, so
|
|
98
|
+
// pass --model at spawn for the lifetime of this child.
|
|
99
|
+
if (model) args.push('--model', String(model));
|
|
100
|
+
if (Array.isArray(state.providerOptions?.addDirs)) {
|
|
101
|
+
for (const d of state.providerOptions.addDirs) args.push('--add-dir', String(d));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const child = spawn(COPILOT_BIN, args, {
|
|
105
|
+
cwd: state.workDir,
|
|
106
|
+
env: process.env,
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
});
|
|
88
109
|
state.copilotChild = child;
|
|
89
110
|
|
|
90
|
-
|
|
111
|
+
let stderrBuf = '';
|
|
112
|
+
const STDERR_CAP = 64 * 1024;
|
|
113
|
+
child.stderr.on('data', (chunk) => {
|
|
114
|
+
if (stderrBuf.length < STDERR_CAP) {
|
|
115
|
+
stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
91
118
|
child.on('error', (err) => {
|
|
92
|
-
sendOutput(conversationId, {
|
|
119
|
+
sendOutput(state.conversationId, {
|
|
93
120
|
type: 'result',
|
|
94
121
|
subtype: 'error',
|
|
95
122
|
session_id: state.sessionId,
|
|
@@ -97,69 +124,236 @@ export async function sendInput(state, prompt, opts = {}) {
|
|
|
97
124
|
error: `copilot process error: ${err?.message || err}`,
|
|
98
125
|
});
|
|
99
126
|
});
|
|
127
|
+
child.on('close', (code) => {
|
|
128
|
+
if (state.turnActive) {
|
|
129
|
+
const tail = stderrBuf.trim().slice(-2000);
|
|
130
|
+
sendOutput(state.conversationId, {
|
|
131
|
+
type: 'result',
|
|
132
|
+
subtype: 'error',
|
|
133
|
+
session_id: state.sessionId,
|
|
134
|
+
is_error: true,
|
|
135
|
+
error: tail || `copilot exited mid-turn (code ${code})`,
|
|
136
|
+
});
|
|
137
|
+
ctx.sendToServer({
|
|
138
|
+
type: 'turn_completed',
|
|
139
|
+
conversationId: state.conversationId,
|
|
140
|
+
claudeSessionId: state.sessionId,
|
|
141
|
+
workDir: state.workDir,
|
|
142
|
+
});
|
|
143
|
+
state.turnActive = false;
|
|
144
|
+
}
|
|
145
|
+
// Drain any in-flight permission prompts so the frontend dialog unwedges
|
|
146
|
+
// and the Promise GC roots release.
|
|
147
|
+
_drainPendingPermissions(state, 'child closed');
|
|
148
|
+
state.copilotChild = null;
|
|
149
|
+
state.acpClient = null;
|
|
150
|
+
state.initialized = false;
|
|
151
|
+
});
|
|
100
152
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
},
|
|
153
|
+
const client = new AcpClient({
|
|
154
|
+
stdin: child.stdin,
|
|
155
|
+
stdout: child.stdout,
|
|
156
|
+
onNotification: (method, params) => _handleAcpNotification(state, method, params),
|
|
157
|
+
onRequest: (method, params) => _handleAcpRequest(state, method, params),
|
|
158
|
+
onError: (err) => {
|
|
159
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] acp transport:', err?.message || err);
|
|
160
|
+
},
|
|
109
161
|
});
|
|
162
|
+
state.acpClient = client;
|
|
110
163
|
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
164
|
+
// 1) initialize
|
|
165
|
+
const initResp = await client.request('initialize', {
|
|
166
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
167
|
+
clientCapabilities: {},
|
|
168
|
+
});
|
|
169
|
+
state.acpCapabilities = initResp?.agentCapabilities || {};
|
|
170
|
+
state.initialized = true;
|
|
114
171
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
// 2) session/new or session/load
|
|
173
|
+
if (resumeSessionId && state.acpCapabilities.loadSession) {
|
|
174
|
+
const r = await client.request('session/load', {
|
|
175
|
+
sessionId: resumeSessionId,
|
|
176
|
+
cwd: state.workDir,
|
|
177
|
+
mcpServers: [],
|
|
178
|
+
});
|
|
179
|
+
state.sessionId = resumeSessionId;
|
|
180
|
+
state.claudeSessionId = resumeSessionId;
|
|
181
|
+
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
182
|
+
} else {
|
|
183
|
+
if (resumeSessionId && !state.acpCapabilities.loadSession) {
|
|
184
|
+
// Surface the downgrade — silently handing back a fresh session would
|
|
185
|
+
// confuse a user who asked to resume.
|
|
186
|
+
sendOutput(state.conversationId, {
|
|
187
|
+
type: 'system',
|
|
188
|
+
subtype: 'info',
|
|
189
|
+
message: 'Copilot CLI does not advertise loadSession capability — starting a new session instead of resuming.',
|
|
190
|
+
});
|
|
120
191
|
}
|
|
192
|
+
const r = await client.request('session/new', {
|
|
193
|
+
cwd: state.workDir,
|
|
194
|
+
mcpServers: [],
|
|
195
|
+
});
|
|
196
|
+
state.sessionId = r?.sessionId || randomUUID();
|
|
197
|
+
state.claudeSessionId = state.sessionId;
|
|
198
|
+
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3) Emit a system_init envelope so the UI populates tools / model panels.
|
|
202
|
+
// Copilot's built-in toolset is not enumerated over ACP today; advertise the
|
|
203
|
+
// well-known core set so the panel isn't empty.
|
|
204
|
+
state.tools = _knownCopilotTools();
|
|
205
|
+
sendOutput(state.conversationId, {
|
|
206
|
+
type: 'system',
|
|
207
|
+
subtype: 'init',
|
|
208
|
+
session_id: state.sessionId,
|
|
209
|
+
model: state.model,
|
|
210
|
+
tools: state.tools,
|
|
211
|
+
mcp_servers: [],
|
|
212
|
+
permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
|
|
121
213
|
});
|
|
214
|
+
}
|
|
122
215
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
216
|
+
export async function sendInput(state, prompt, opts = {}) {
|
|
217
|
+
const conversationId = opts.conversationId || state.conversationId;
|
|
218
|
+
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
219
|
+
|
|
220
|
+
// Ensure ACP child + session are up; reboot if a prior crash dropped them.
|
|
221
|
+
if (!state.initialized || !state.acpClient) {
|
|
222
|
+
try {
|
|
223
|
+
await _bootAcp(state, state.sessionId || null, state.model);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
sendOutput(conversationId, {
|
|
226
|
+
type: 'result',
|
|
227
|
+
subtype: 'error',
|
|
228
|
+
session_id: state.sessionId,
|
|
229
|
+
is_error: true,
|
|
230
|
+
error: `copilot ACP reinit failed: ${err?.message || err}`,
|
|
231
|
+
});
|
|
232
|
+
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
233
|
+
return;
|
|
127
234
|
}
|
|
128
|
-
}
|
|
235
|
+
}
|
|
129
236
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
237
|
+
// Per-turn provider option overrides (e.g. updated model). If the model
|
|
238
|
+
// changed we don't restart the child; ACP doesn't expose model switch yet,
|
|
239
|
+
// so we leave a warning rather than silently drop the request.
|
|
240
|
+
const po = { ...(state.providerOptions || {}), ...(opts.providerOptions || {}) };
|
|
241
|
+
if (po.model && po.model !== state.model) {
|
|
242
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] mid-conversation model switch not supported; ignoring');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const abortController = new AbortController();
|
|
246
|
+
state.abortController = abortController;
|
|
247
|
+
state.turnActive = true;
|
|
248
|
+
state.turnResultReceived = false;
|
|
249
|
+
|
|
250
|
+
// Build prompt content blocks. ACP ContentBlock variants: text, image,
|
|
251
|
+
// audio, resource, resource_link. Web attachments arrive on `opts.attachments`
|
|
252
|
+
// (existing wire shape: [{type:'image', data, mimeType} | {type:'text', text}]).
|
|
253
|
+
const promptBlocks = [{ type: 'text', text: String(prompt ?? '') }];
|
|
254
|
+
if (Array.isArray(opts.attachments)) {
|
|
255
|
+
for (const a of opts.attachments) {
|
|
256
|
+
if (!a) continue;
|
|
257
|
+
if (a.type === 'image' && a.data) {
|
|
258
|
+
promptBlocks.push({ type: 'image', data: a.data, mimeType: a.mimeType || 'image/png' });
|
|
259
|
+
} else if (a.type === 'text' && a.text) {
|
|
260
|
+
promptBlocks.push({ type: 'text', text: String(a.text) });
|
|
261
|
+
} else if (typeof a === 'string') {
|
|
262
|
+
promptBlocks.push({ type: 'text', text: a });
|
|
143
263
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
resolve();
|
|
153
|
-
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
abortController.signal.addEventListener('abort', () => {
|
|
268
|
+
if (state.acpClient && state.sessionId) {
|
|
269
|
+
try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
|
|
270
|
+
catch { /* noop */ }
|
|
271
|
+
}
|
|
154
272
|
});
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const resp = await state.acpClient.request('session/prompt', {
|
|
276
|
+
sessionId: state.sessionId,
|
|
277
|
+
prompt: promptBlocks,
|
|
278
|
+
});
|
|
279
|
+
const stopReason = resp?.stopReason || 'end_turn';
|
|
280
|
+
const isErr = stopReason === 'refusal' || stopReason === 'error';
|
|
281
|
+
sendOutput(conversationId, {
|
|
282
|
+
type: 'result',
|
|
283
|
+
subtype: isErr ? 'error' : 'success',
|
|
284
|
+
session_id: state.sessionId,
|
|
285
|
+
stop_reason: stopReason,
|
|
286
|
+
is_error: isErr,
|
|
287
|
+
error: isErr ? `copilot stop_reason=${stopReason}` : undefined,
|
|
288
|
+
});
|
|
289
|
+
} catch (err) {
|
|
290
|
+
sendOutput(conversationId, {
|
|
291
|
+
type: 'result',
|
|
292
|
+
subtype: 'error',
|
|
293
|
+
session_id: state.sessionId,
|
|
294
|
+
is_error: true,
|
|
295
|
+
error: err?.message || String(err),
|
|
296
|
+
});
|
|
297
|
+
} finally {
|
|
298
|
+
state.turnActive = false;
|
|
299
|
+
ctx.sendToServer({
|
|
300
|
+
type: 'turn_completed',
|
|
301
|
+
conversationId,
|
|
302
|
+
claudeSessionId: state.sessionId,
|
|
303
|
+
workDir: state.workDir,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
155
306
|
}
|
|
156
307
|
|
|
157
308
|
export function abort(state) {
|
|
158
309
|
if (state?.abortController) {
|
|
159
310
|
try { state.abortController.abort(); } catch { /* noop */ }
|
|
160
311
|
}
|
|
161
|
-
if (state?.
|
|
162
|
-
try { state.
|
|
312
|
+
if (state?.acpClient && state.sessionId) {
|
|
313
|
+
try { state.acpClient.notify('session/cancel', { sessionId: state.sessionId }); }
|
|
314
|
+
catch { /* noop */ }
|
|
315
|
+
}
|
|
316
|
+
_drainPendingPermissions(state, 'aborted');
|
|
317
|
+
// Fallback: if Copilot ignores session/cancel and the prompt never resolves,
|
|
318
|
+
// SIGTERM the child after a grace period — the close handler will then
|
|
319
|
+
// synthesize the result envelope + turn_completed.
|
|
320
|
+
if (state?.copilotChild && !state._abortKillTimer) {
|
|
321
|
+
state._abortKillTimer = setTimeout(() => {
|
|
322
|
+
state._abortKillTimer = null;
|
|
323
|
+
if (state.turnActive && state.copilotChild) {
|
|
324
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
325
|
+
}
|
|
326
|
+
}, 10000);
|
|
327
|
+
state._abortKillTimer.unref?.();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* /clear support: ask ACP for a brand-new session under the same
|
|
333
|
+
* conversationId. Keeps the child alive — no spawn cost.
|
|
334
|
+
*/
|
|
335
|
+
export async function clear(state) {
|
|
336
|
+
if (!state?.acpClient) return;
|
|
337
|
+
// A fresh session invalidates any in-flight permission prompts.
|
|
338
|
+
_drainPendingPermissions(state, 'session cleared');
|
|
339
|
+
try {
|
|
340
|
+
const r = await state.acpClient.request('session/new', {
|
|
341
|
+
cwd: state.workDir,
|
|
342
|
+
mcpServers: [],
|
|
343
|
+
});
|
|
344
|
+
state.sessionId = r?.sessionId || randomUUID();
|
|
345
|
+
state.claudeSessionId = state.sessionId;
|
|
346
|
+
sendOutput(state.conversationId, {
|
|
347
|
+
type: 'system',
|
|
348
|
+
subtype: 'init',
|
|
349
|
+
session_id: state.sessionId,
|
|
350
|
+
model: state.model,
|
|
351
|
+
tools: state.tools,
|
|
352
|
+
mcp_servers: [],
|
|
353
|
+
permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] clear failed:', err?.message || err);
|
|
163
357
|
}
|
|
164
358
|
}
|
|
165
359
|
|
|
@@ -169,110 +363,421 @@ function sendOutput(conversationId, data) {
|
|
|
169
363
|
ctx.sendToServer({ type: 'claude_output', conversationId, data });
|
|
170
364
|
}
|
|
171
365
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
366
|
+
function _handleAcpNotification(state, method, params) {
|
|
367
|
+
if (method === 'session/update') {
|
|
368
|
+
_handleSessionUpdate(state, params);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] unknown ACP notification:', method);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function _handleAcpRequest(state, method, params) {
|
|
375
|
+
if (method === 'session/request_permission') {
|
|
376
|
+
return _handlePermissionRequest(state, params);
|
|
377
|
+
}
|
|
378
|
+
// fs/read_text_file, fs/write_text_file, terminal/* — Copilot's agent
|
|
379
|
+
// doesn't need them because it runs its own tools, but answer politely
|
|
380
|
+
// to anything we don't implement.
|
|
381
|
+
throw Object.assign(new Error(`unsupported method: ${method}`), { code: -32601 });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _handleSessionUpdate(state, params) {
|
|
385
|
+
if (!params || !params.sessionId || params.sessionId !== state.sessionId) {
|
|
386
|
+
// Stale update from a prior session; ignore.
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const upd = params.update || params;
|
|
390
|
+
const kind = upd.sessionUpdate;
|
|
391
|
+
switch (kind) {
|
|
392
|
+
case 'agent_message_chunk': {
|
|
393
|
+
const text = _extractText(upd.content);
|
|
394
|
+
if (!text) return;
|
|
395
|
+
sendOutput(state.conversationId, {
|
|
396
|
+
type: 'assistant',
|
|
397
|
+
message: { role: 'assistant', content: [{ type: 'text', text }] },
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case 'agent_thought_chunk': {
|
|
402
|
+
const text = _extractText(upd.content);
|
|
403
|
+
if (!text) return;
|
|
404
|
+
sendOutput(state.conversationId, {
|
|
405
|
+
type: 'assistant',
|
|
406
|
+
message: { role: 'assistant', content: [{ type: 'thinking', thinking: text }] },
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
case 'user_message_chunk': {
|
|
411
|
+
// Echo of our own prompt — drop (frontend already shows it).
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
case 'tool_call': {
|
|
415
|
+
const id = upd.toolCallId || upd.id || randomUUID();
|
|
416
|
+
const toolName = upd.title || upd.kind || 'tool';
|
|
417
|
+
const input = upd.rawInput || upd.input || {};
|
|
418
|
+
sendOutput(state.conversationId, {
|
|
419
|
+
type: 'assistant',
|
|
420
|
+
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] },
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
case 'tool_call_update': {
|
|
425
|
+
const id = upd.toolCallId || upd.id;
|
|
426
|
+
if (!id) return;
|
|
427
|
+
const status = upd.status;
|
|
428
|
+
// Only emit a tool_result when the call reaches a terminal state with
|
|
429
|
+
// some content/output; intermediate "in_progress" updates would render
|
|
430
|
+
// as duplicate empty results in the existing renderer.
|
|
431
|
+
const isTerminal = status === 'completed' || status === 'failed';
|
|
432
|
+
if (!isTerminal) return;
|
|
433
|
+
const text = _extractToolContent(upd.content) || (upd.rawOutput ? _stringify(upd.rawOutput) : '');
|
|
434
|
+
sendOutput(state.conversationId, {
|
|
435
|
+
type: 'user',
|
|
436
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, is_error: status === 'failed' }] },
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
case 'plan': {
|
|
441
|
+
// Optional: render as a todo-style assistant message. Keep it minimal.
|
|
442
|
+
const entries = Array.isArray(upd.entries) ? upd.entries : [];
|
|
443
|
+
if (!entries.length) return;
|
|
444
|
+
const text = entries.map(e => `- [${e.status === 'completed' ? 'x' : ' '}] ${e.content}`).join('\n');
|
|
445
|
+
sendOutput(state.conversationId, {
|
|
446
|
+
type: 'assistant',
|
|
447
|
+
message: { role: 'assistant', content: [{ type: 'text', text: `**Plan:**\n${text}` }] },
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
case 'available_commands_update': {
|
|
452
|
+
if (Array.isArray(upd.availableCommands)) {
|
|
453
|
+
state.slashCommands = upd.availableCommands.map(c => c.name || c).filter(Boolean);
|
|
190
454
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] unhandled session update:', kind);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function _handlePermissionRequest(state, params) {
|
|
463
|
+
const opt = Array.isArray(params?.options) ? params.options : [];
|
|
464
|
+
// Defensive: ACP forbids empty options but if a buggy server sends one,
|
|
465
|
+
// reject the request rather than fabricating an optionId Copilot won't
|
|
466
|
+
// recognise.
|
|
467
|
+
if (opt.length === 0) {
|
|
468
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
469
|
+
}
|
|
470
|
+
// Auto-approve if the user enabled allowAllTools (or YOLO env).
|
|
471
|
+
if (state.allowAllTools) {
|
|
472
|
+
const allow = opt.find(o => o.kind === 'allow_always' || o.kind === 'allow_once') || opt[0];
|
|
473
|
+
return { outcome: { outcome: 'selected', optionId: allow.optionId } };
|
|
474
|
+
}
|
|
475
|
+
// Otherwise route through the existing ask-user wire path. We do it inline
|
|
476
|
+
// here using a per-state Promise; the frontend responds via the standard
|
|
477
|
+
// `ask_user_response` message which conversation.js routes back into the
|
|
478
|
+
// driver via `respondToPermissionRequest(state, requestId, optionId)`.
|
|
479
|
+
const requestId = `copilot-perm-${randomUUID()}`;
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
state.pendingPermissions.set(requestId, { resolve, options: opt });
|
|
482
|
+
ctx.sendToServer({
|
|
483
|
+
type: 'ask_user_question',
|
|
484
|
+
conversationId: state.conversationId,
|
|
485
|
+
requestId,
|
|
486
|
+
question: _formatPermissionPrompt(params),
|
|
487
|
+
options: opt.map(o => ({ id: o.optionId, label: o.name || o.optionId, kind: o.kind })),
|
|
488
|
+
});
|
|
489
|
+
});
|
|
202
490
|
}
|
|
203
491
|
|
|
204
492
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
|
|
209
|
-
* we accept several aliases and forward only what we understand):
|
|
210
|
-
* - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
|
|
211
|
-
* - message: { type: 'message', role, content }
|
|
212
|
-
* - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
|
|
213
|
-
* - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
|
|
214
|
-
* - done: { type: 'result'|'done'|'complete', session_id?, error? }
|
|
215
|
-
* - error: { type: 'error', message|error }
|
|
493
|
+
* Drain every in-flight permission prompt with a "cancelled" outcome. Called
|
|
494
|
+
* on child close, abort, and clear so dangling Promises don't pin GC roots
|
|
495
|
+
* and the frontend ask-user dialog unwedges.
|
|
216
496
|
*/
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
|
|
223
|
-
if (!text) return [];
|
|
224
|
-
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } }];
|
|
497
|
+
function _drainPendingPermissions(state, reason) {
|
|
498
|
+
const m = state?.pendingPermissions;
|
|
499
|
+
if (!m || m.size === 0) return;
|
|
500
|
+
for (const { resolve } of m.values()) {
|
|
501
|
+
try { resolve({ outcome: { outcome: 'cancelled' } }); } catch { /* noop */ }
|
|
225
502
|
}
|
|
503
|
+
m.clear();
|
|
504
|
+
if (ctx?.CONFIG?.debug) console.warn(`[copilot] drained pending permissions: ${reason}`);
|
|
505
|
+
}
|
|
226
506
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Called by conversation.js when the frontend posts an ask_user_response for
|
|
509
|
+
* a permission prompt we issued. Exported so the message router can hand it
|
|
510
|
+
* back without exposing the state internals.
|
|
511
|
+
*/
|
|
512
|
+
export function respondToPermissionRequest(state, requestId, optionId) {
|
|
513
|
+
const slot = state?.pendingPermissions?.get(requestId);
|
|
514
|
+
if (!slot) {
|
|
515
|
+
console.warn(`[copilot] respondToPermissionRequest: no pending permission for ${requestId}`);
|
|
516
|
+
return false;
|
|
230
517
|
}
|
|
518
|
+
state.pendingPermissions.delete(requestId);
|
|
519
|
+
const opt = slot.options.find(o => o.optionId === optionId) || slot.options[0];
|
|
520
|
+
slot.resolve({ outcome: { outcome: 'selected', optionId: opt?.optionId || optionId } });
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
231
523
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
524
|
+
function _formatPermissionPrompt(params) {
|
|
525
|
+
const tc = params?.toolCall || {};
|
|
526
|
+
const title = tc.title || tc.kind || 'tool';
|
|
527
|
+
const rawIn = tc.rawInput;
|
|
528
|
+
let suffix = '';
|
|
529
|
+
if (rawIn && typeof rawIn === 'object') {
|
|
530
|
+
try { suffix = '\n```\n' + JSON.stringify(rawIn, null, 2).slice(0, 600) + '\n```'; }
|
|
531
|
+
catch { /* noop */ }
|
|
237
532
|
}
|
|
533
|
+
return `Copilot wants to run \`${title}\`. Allow?${suffix}`;
|
|
534
|
+
}
|
|
238
535
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
536
|
+
function _extractText(content) {
|
|
537
|
+
if (!content) return '';
|
|
538
|
+
if (typeof content === 'string') return content;
|
|
539
|
+
if (content.type === 'text' && typeof content.text === 'string') return content.text;
|
|
540
|
+
if (Array.isArray(content)) {
|
|
541
|
+
return content.map(c => (c?.type === 'text' ? c.text : '')).filter(Boolean).join('');
|
|
245
542
|
}
|
|
543
|
+
return '';
|
|
544
|
+
}
|
|
246
545
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
546
|
+
function _extractToolContent(content) {
|
|
547
|
+
if (!content) return '';
|
|
548
|
+
if (Array.isArray(content)) {
|
|
549
|
+
const parts = [];
|
|
550
|
+
for (const c of content) {
|
|
551
|
+
if (!c) continue;
|
|
552
|
+
// ToolCallContent variants: content (with ContentBlock), diff
|
|
553
|
+
if (c.type === 'content' && c.content) parts.push(_extractText(c.content));
|
|
554
|
+
else if (c.type === 'diff') parts.push(`diff: ${c.path || ''}\n${c.newText || ''}`);
|
|
555
|
+
else if (typeof c === 'string') parts.push(c);
|
|
556
|
+
else parts.push(_stringify(c));
|
|
557
|
+
}
|
|
558
|
+
return parts.filter(Boolean).join('\n');
|
|
256
559
|
}
|
|
560
|
+
return _stringify(content);
|
|
561
|
+
}
|
|
257
562
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
563
|
+
function _stringify(v) {
|
|
564
|
+
if (v == null) return '';
|
|
565
|
+
if (typeof v === 'string') return v;
|
|
566
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function _knownCopilotTools() {
|
|
570
|
+
// Best-effort static list (Copilot doesn't expose its toolset over ACP).
|
|
571
|
+
return [
|
|
572
|
+
{ name: 'bash', description: 'Execute shell commands' },
|
|
573
|
+
{ name: 'read', description: 'Read file contents' },
|
|
574
|
+
{ name: 'write', description: 'Write to a file' },
|
|
575
|
+
{ name: 'edit', description: 'Edit a file in place' },
|
|
576
|
+
{ name: 'grep', description: 'Search file contents' },
|
|
577
|
+
{ name: 'glob', description: 'Find files by glob' },
|
|
578
|
+
{ name: 'list_dir', description: 'List directory contents' },
|
|
579
|
+
{ name: 'web_fetch', description: 'Fetch URL contents' },
|
|
580
|
+
{ name: 'web_search', description: 'Search the web' },
|
|
581
|
+
{ name: 'ask_user', description: 'Ask the user a question' },
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Exported for the model picker UI. */
|
|
586
|
+
export function listModels() {
|
|
587
|
+
return COPILOT_MODELS.slice();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export default { name, capabilities, start, sendInput, abort, clear, listFolders, listSessions, loadHistory, listModels, respondToPermissionRequest };
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
// ---------- history surface (reads ~/.copilot/session-store.db) ----------
|
|
594
|
+
|
|
595
|
+
export function getCopilotDbPath() {
|
|
596
|
+
return process.env.COPILOT_DB_PATH || join(homedir(), '.copilot', 'session-store.db');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let _dbHandle = null;
|
|
600
|
+
let _dbHandlePath = null;
|
|
601
|
+
function openDb() {
|
|
602
|
+
const path = getCopilotDbPath();
|
|
603
|
+
if (!existsSync(path)) return null;
|
|
604
|
+
if (_dbHandle && _dbHandlePath === path) return _dbHandle;
|
|
605
|
+
if (_dbHandle) {
|
|
606
|
+
try { _dbHandle.close(); } catch { /* noop */ }
|
|
607
|
+
_dbHandle = null;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
_dbHandle = new DatabaseSync(path, { readOnly: true });
|
|
611
|
+
_dbHandlePath = path;
|
|
612
|
+
return _dbHandle;
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] cannot open session DB:', err?.message || err);
|
|
615
|
+
return null;
|
|
266
616
|
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Exposed for tests so they can drop the cached handle when swapping
|
|
620
|
+
// COPILOT_DB_PATH between cases.
|
|
621
|
+
export function _resetCopilotDbHandle() {
|
|
622
|
+
if (_dbHandle) {
|
|
623
|
+
try { _dbHandle.close(); } catch { /* noop */ }
|
|
624
|
+
}
|
|
625
|
+
_dbHandle = null;
|
|
626
|
+
_dbHandlePath = null;
|
|
627
|
+
}
|
|
267
628
|
|
|
268
|
-
|
|
269
|
-
return
|
|
629
|
+
function toEpochMs(iso) {
|
|
630
|
+
if (!iso) return 0;
|
|
631
|
+
const t = Date.parse(iso);
|
|
632
|
+
return Number.isFinite(t) ? t : 0;
|
|
270
633
|
}
|
|
271
634
|
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
635
|
+
export async function listFolders() {
|
|
636
|
+
const db = openDb();
|
|
637
|
+
if (!db) return [];
|
|
638
|
+
try {
|
|
639
|
+
const rows = db.prepare(`
|
|
640
|
+
SELECT cwd, COUNT(*) AS sessionCount, MAX(updated_at) AS lastUpdated
|
|
641
|
+
FROM sessions
|
|
642
|
+
WHERE cwd IS NOT NULL AND cwd <> ''
|
|
643
|
+
GROUP BY cwd
|
|
644
|
+
ORDER BY lastUpdated DESC
|
|
645
|
+
`).all();
|
|
646
|
+
return rows.map(r => ({
|
|
647
|
+
name: r.cwd,
|
|
648
|
+
path: r.cwd,
|
|
649
|
+
sessionCount: Number(r.sessionCount) || 0,
|
|
650
|
+
lastModified: toEpochMs(r.lastUpdated),
|
|
651
|
+
}));
|
|
652
|
+
} catch (err) {
|
|
653
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] listFolders failed:', err?.message || err);
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export async function listSessions(workDir) {
|
|
659
|
+
if (!workDir) return [];
|
|
660
|
+
const db = openDb();
|
|
661
|
+
if (!db) return [];
|
|
662
|
+
try {
|
|
663
|
+
const rows = db.prepare(`
|
|
664
|
+
SELECT s.id, s.summary, s.created_at, s.updated_at,
|
|
665
|
+
(SELECT user_message FROM turns WHERE session_id = s.id ORDER BY turn_index ASC LIMIT 1) AS first_user
|
|
666
|
+
FROM sessions s
|
|
667
|
+
WHERE s.cwd = ?
|
|
668
|
+
ORDER BY s.updated_at DESC
|
|
669
|
+
`).all(workDir);
|
|
670
|
+
return rows.map(r => {
|
|
671
|
+
const preview = (r.first_user || '').toString().slice(0, 100);
|
|
672
|
+
const title = (r.summary && r.summary.trim()) || preview || r.id.slice(0, 8);
|
|
673
|
+
return {
|
|
674
|
+
sessionId: r.id,
|
|
675
|
+
workDir,
|
|
676
|
+
title,
|
|
677
|
+
preview,
|
|
678
|
+
lastModified: toEpochMs(r.updated_at) || toEpochMs(r.created_at),
|
|
679
|
+
};
|
|
680
|
+
}).filter(s => s.title);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] listSessions failed:', err?.message || err);
|
|
683
|
+
return [];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export async function loadHistory(workDir, sessionId, limit = 500) {
|
|
688
|
+
if (!sessionId) return [];
|
|
689
|
+
const db = openDb();
|
|
690
|
+
if (!db) return [];
|
|
691
|
+
try {
|
|
692
|
+
const allTurns = db.prepare(`
|
|
693
|
+
SELECT turn_index, user_message, assistant_response, timestamp
|
|
694
|
+
FROM turns WHERE session_id = ? ORDER BY turn_index ASC
|
|
695
|
+
`).all(sessionId);
|
|
696
|
+
// Apply limit at the turn level so we never split a tool_use / tool_result
|
|
697
|
+
// pair when truncating. Each turn typically expands to <=4 messages, so
|
|
698
|
+
// ceil(limit/4) turns is a safe upper bound that preserves recency.
|
|
699
|
+
const turns = limit && allTurns.length > Math.ceil(limit / 4)
|
|
700
|
+
? allTurns.slice(-Math.ceil(limit / 4))
|
|
701
|
+
: allTurns;
|
|
702
|
+
|
|
703
|
+
// Tool-call events per turn, in order. Copilot's schema is not fully
|
|
704
|
+
// documented; in some versions a single tool call writes multiple rows
|
|
705
|
+
// (e.g. a "started" row with the command and a "completed" row with
|
|
706
|
+
// output/exit_code). Dedupe by tool_call_id, preferring rows that
|
|
707
|
+
// carry output so we render one tool_use + one tool_result per call.
|
|
708
|
+
const events = db.prepare(`
|
|
709
|
+
SELECT id, turn_index, tool_call_id, event_type, command, output, exit_code,
|
|
710
|
+
event_key, event_value, created_at
|
|
711
|
+
FROM forge_trajectory_events
|
|
712
|
+
WHERE session_id = ?
|
|
713
|
+
ORDER BY turn_index ASC, id ASC
|
|
714
|
+
`).all(sessionId);
|
|
715
|
+
|
|
716
|
+
const mergedByCallId = new Map();
|
|
717
|
+
const orderedKeys = [];
|
|
718
|
+
for (const e of events) {
|
|
719
|
+
const key = e.tool_call_id || `__row:${e.id}`;
|
|
720
|
+
const prev = mergedByCallId.get(key);
|
|
721
|
+
if (!prev) {
|
|
722
|
+
orderedKeys.push(key);
|
|
723
|
+
mergedByCallId.set(key, { ...e });
|
|
724
|
+
} else {
|
|
725
|
+
// Merge later rows in; non-null values win so the "completed" row
|
|
726
|
+
// adds output/exit_code without erasing the "started" row's command.
|
|
727
|
+
for (const [k, v] of Object.entries(e)) {
|
|
728
|
+
if (v != null && v !== '') prev[k] = v;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const eventsByTurn = new Map();
|
|
733
|
+
for (const key of orderedKeys) {
|
|
734
|
+
const e = mergedByCallId.get(key);
|
|
735
|
+
const arr = eventsByTurn.get(e.turn_index) || [];
|
|
736
|
+
arr.push(e);
|
|
737
|
+
eventsByTurn.set(e.turn_index, arr);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const messages = [];
|
|
741
|
+
for (const t of turns) {
|
|
742
|
+
if (t.user_message) {
|
|
743
|
+
messages.push({
|
|
744
|
+
type: 'user',
|
|
745
|
+
message: { role: 'user', content: [{ type: 'text', text: String(t.user_message) }] },
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
// Render any tool calls captured for this turn as assistant tool_use +
|
|
749
|
+
// user tool_result pairs, then the final assistant text.
|
|
750
|
+
const turnEvents = eventsByTurn.get(t.turn_index) || [];
|
|
751
|
+
for (const e of turnEvents) {
|
|
752
|
+
if (!e.event_type) continue;
|
|
753
|
+
const toolId = e.tool_call_id || `copilot-${t.turn_index}-${messages.length}`;
|
|
754
|
+
const toolName = e.event_type;
|
|
755
|
+
const input = e.command
|
|
756
|
+
? { command: e.command }
|
|
757
|
+
: (e.event_key ? { [e.event_key]: e.event_value } : {});
|
|
758
|
+
messages.push({
|
|
759
|
+
type: 'assistant',
|
|
760
|
+
message: { role: 'assistant', content: [{ type: 'tool_use', id: toolId, name: toolName, input }] },
|
|
761
|
+
});
|
|
762
|
+
const outText = e.output != null
|
|
763
|
+
? String(e.output)
|
|
764
|
+
: (e.exit_code != null ? `exit ${e.exit_code}` : '');
|
|
765
|
+
messages.push({
|
|
766
|
+
type: 'user',
|
|
767
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: outText }] },
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
if (t.assistant_response) {
|
|
771
|
+
messages.push({
|
|
772
|
+
type: 'assistant',
|
|
773
|
+
message: { role: 'assistant', content: [{ type: 'text', text: String(t.assistant_response) }] },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return messages;
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] loadHistory failed:', err?.message || err);
|
|
780
|
+
return [];
|
|
781
|
+
}
|
|
276
782
|
}
|
|
277
783
|
|
|
278
|
-
export default { name, start, sendInput, abort };
|