@yeaft/webchat-agent 0.1.859 → 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 +22 -4
- package/history.js +22 -59
- package/package.json +1 -1
- package/providers/base.js +24 -0
- package/providers/claude-code.js +51 -1
- package/providers/copilot.js +209 -3
package/conversation.js
CHANGED
|
@@ -390,6 +390,7 @@ export async function createConversation(msg) {
|
|
|
390
390
|
resumeSessionId: null,
|
|
391
391
|
userId,
|
|
392
392
|
username,
|
|
393
|
+
providerOptions: msg.providerOptions || {},
|
|
393
394
|
});
|
|
394
395
|
state.disallowedTools = disallowedTools || null;
|
|
395
396
|
}
|
|
@@ -441,9 +442,11 @@ export async function resumeConversation(msg) {
|
|
|
441
442
|
console.log(`[Resume] workDir: ${effectiveWorkDir} (lazy start)`);
|
|
442
443
|
|
|
443
444
|
// 清理旧条目:同 conversationId 或同 claudeSessionId 的条目(避免重复恢复同一个 session 累积)
|
|
445
|
+
let priorProviderOptions = null;
|
|
444
446
|
for (const [id, conv] of ctx.conversations) {
|
|
445
447
|
if (id === conversationId || (claudeSessionId && conv.claudeSessionId === claudeSessionId)) {
|
|
446
448
|
console.log(`[Resume] Cleaning up old conversation: ${id} (claudeSessionId: ${conv.claudeSessionId})`);
|
|
449
|
+
if (conv.providerOptions && !priorProviderOptions) priorProviderOptions = conv.providerOptions;
|
|
447
450
|
if (conv.abortController) {
|
|
448
451
|
conv.abortController.abort();
|
|
449
452
|
}
|
|
@@ -454,7 +457,15 @@ export async function resumeConversation(msg) {
|
|
|
454
457
|
}
|
|
455
458
|
}
|
|
456
459
|
|
|
457
|
-
|
|
460
|
+
let driverForHistory;
|
|
461
|
+
try { driverForHistory = getProvider(provider); }
|
|
462
|
+
catch (err) {
|
|
463
|
+
console.warn(`[Resume] unknown provider "${provider}", falling back to claude-code history loader:`, err?.message || err);
|
|
464
|
+
driverForHistory = {};
|
|
465
|
+
}
|
|
466
|
+
const historyMessages = typeof driverForHistory.loadHistory === 'function'
|
|
467
|
+
? await driverForHistory.loadHistory(effectiveWorkDir, claudeSessionId)
|
|
468
|
+
: loadSessionHistory(effectiveWorkDir, claudeSessionId);
|
|
458
469
|
if (username) console.log(`[Resume] User: ${username} (${userId})`);
|
|
459
470
|
console.log(`Loaded ${historyMessages.length} history messages`);
|
|
460
471
|
|
|
@@ -492,6 +503,7 @@ export async function resumeConversation(msg) {
|
|
|
492
503
|
resumeSessionId: claudeSessionId || null,
|
|
493
504
|
userId,
|
|
494
505
|
username,
|
|
506
|
+
providerOptions: msg.providerOptions || priorProviderOptions || {},
|
|
495
507
|
});
|
|
496
508
|
state.disallowedTools = disallowedTools || null;
|
|
497
509
|
}
|
|
@@ -656,9 +668,15 @@ export async function handleCancelExecution(msg) {
|
|
|
656
668
|
// 标记为取消状态,防止 processClaudeOutput 的 finally 发送 conversation_closed
|
|
657
669
|
state.cancelled = true;
|
|
658
670
|
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
state.
|
|
671
|
+
// 通过 driver 中止当前查询(Claude 走 abortController;Copilot 走 SIGTERM)
|
|
672
|
+
try {
|
|
673
|
+
const driver = getProvider(state.providerName || DEFAULT_PROVIDER);
|
|
674
|
+
if (typeof driver.abort === 'function') driver.abort(state);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.warn(`[${conversationId}] driver.abort failed:`, err?.message || err);
|
|
677
|
+
if (state.abortController) {
|
|
678
|
+
try { state.abortController.abort(); } catch { /* noop */ }
|
|
679
|
+
}
|
|
662
680
|
}
|
|
663
681
|
|
|
664
682
|
// 关闭输入流
|
package/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'os';
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import ctx from './context.js';
|
|
5
|
+
import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
|
|
5
6
|
|
|
6
7
|
// Claude 项目目录
|
|
7
8
|
export function getClaudeProjectsDir() {
|
|
@@ -184,18 +185,23 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
export async function handleListHistorySessions(msg) {
|
|
187
|
-
const { workDir, requestId, _requestClientId } = msg;
|
|
188
|
+
const { workDir, requestId, _requestClientId, provider } = msg;
|
|
188
189
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
190
|
+
const providerName = provider || DEFAULT_PROVIDER;
|
|
189
191
|
|
|
190
|
-
console.log(`Listing history sessions for: ${effectiveWorkDir}`);
|
|
192
|
+
console.log(`Listing history sessions for: ${effectiveWorkDir} (provider=${providerName})`);
|
|
191
193
|
|
|
192
194
|
try {
|
|
193
|
-
const
|
|
195
|
+
const driver = getProvider(providerName);
|
|
196
|
+
const sessions = typeof driver.listSessions === 'function'
|
|
197
|
+
? await driver.listSessions(effectiveWorkDir)
|
|
198
|
+
: await getHistorySessions(effectiveWorkDir);
|
|
194
199
|
ctx.sendToServer({
|
|
195
200
|
type: 'history_sessions_list',
|
|
196
201
|
requestId,
|
|
197
202
|
_requestClientId,
|
|
198
203
|
workDir: effectiveWorkDir,
|
|
204
|
+
provider: providerName,
|
|
199
205
|
sessions
|
|
200
206
|
});
|
|
201
207
|
} catch (e) {
|
|
@@ -205,85 +211,42 @@ export async function handleListHistorySessions(msg) {
|
|
|
205
211
|
requestId,
|
|
206
212
|
_requestClientId,
|
|
207
213
|
workDir: effectiveWorkDir,
|
|
214
|
+
provider: providerName,
|
|
208
215
|
sessions: [],
|
|
209
216
|
error: e.message
|
|
210
217
|
});
|
|
211
218
|
}
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
//
|
|
221
|
+
// 列出指定 provider 下所有 folder (工作目录)
|
|
215
222
|
export async function handleListFolders(msg) {
|
|
216
|
-
const { requestId, _requestClientId } = msg;
|
|
217
|
-
const
|
|
223
|
+
const { requestId, _requestClientId, provider } = msg;
|
|
224
|
+
const providerName = provider || DEFAULT_PROVIDER;
|
|
218
225
|
|
|
219
|
-
console.log(`Listing folders
|
|
226
|
+
console.log(`Listing folders for provider=${providerName}`);
|
|
220
227
|
|
|
221
228
|
try {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
for (const entry of entries) {
|
|
228
|
-
const entryPath = join(projectsDir, entry);
|
|
229
|
-
const stats = statSync(entryPath);
|
|
230
|
-
|
|
231
|
-
if (stats.isDirectory()) {
|
|
232
|
-
// 过滤掉 crew 角色的 session 文件夹
|
|
233
|
-
// crew 角色的 cwd 在 .crew/roles/{roleName} 下,对应的文件夹名包含 --crew-roles-
|
|
234
|
-
if (entry.includes('--crew-roles-')) {
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 从 session 文件读取真实的工作目录路径
|
|
239
|
-
const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
|
|
240
|
-
|
|
241
|
-
// 快速计数:只数 .jsonl 文件数量,不读取文件内容
|
|
242
|
-
let sessionCount = 0;
|
|
243
|
-
let lastModified = stats.mtime.getTime();
|
|
244
|
-
|
|
245
|
-
try {
|
|
246
|
-
const files = readdirSync(entryPath);
|
|
247
|
-
|
|
248
|
-
for (const file of files) {
|
|
249
|
-
if (file.endsWith('.jsonl')) {
|
|
250
|
-
sessionCount++;
|
|
251
|
-
try {
|
|
252
|
-
const fileStats = statSync(join(entryPath, file));
|
|
253
|
-
if (fileStats.mtime.getTime() > lastModified) {
|
|
254
|
-
lastModified = fileStats.mtime.getTime();
|
|
255
|
-
}
|
|
256
|
-
} catch {}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
|
|
261
|
-
folders.push({
|
|
262
|
-
name: entry,
|
|
263
|
-
path: originalPath,
|
|
264
|
-
sessionCount,
|
|
265
|
-
lastModified
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
229
|
+
const driver = getProvider(providerName);
|
|
230
|
+
let folders = [];
|
|
231
|
+
if (typeof driver.listFolders === 'function') {
|
|
232
|
+
folders = await driver.listFolders();
|
|
269
233
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.log(`Found ${folders.length} folders, sending response...`);
|
|
234
|
+
folders.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
|
235
|
+
console.log(`Found ${folders.length} folders (provider=${providerName}), sending response...`);
|
|
274
236
|
ctx.sendToServer({
|
|
275
237
|
type: 'folders_list',
|
|
276
238
|
requestId,
|
|
277
239
|
_requestClientId,
|
|
240
|
+
provider: providerName,
|
|
278
241
|
folders
|
|
279
242
|
});
|
|
280
|
-
console.log(`folders_list sent with ${folders.length} folders`);
|
|
281
243
|
} catch (e) {
|
|
282
244
|
console.error('Error listing folders:', e);
|
|
283
245
|
ctx.sendToServer({
|
|
284
246
|
type: 'folders_list',
|
|
285
247
|
requestId,
|
|
286
248
|
_requestClientId,
|
|
249
|
+
provider: providerName,
|
|
287
250
|
folders: [],
|
|
288
251
|
error: e.message
|
|
289
252
|
});
|
package/package.json
CHANGED
package/providers/base.js
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* @property {(opts: StartOpts) => Promise<Object>} start
|
|
19
19
|
* @property {(state: Object, prompt: string, opts?: Object) => Promise<void>} sendInput
|
|
20
20
|
* @property {(state: Object) => void} abort
|
|
21
|
+
* @property {() => Promise<FolderInfo[]>} listFolders
|
|
22
|
+
* Return the list of work-directories that this provider has sessions for.
|
|
23
|
+
* @property {(workDir: string) => Promise<SessionInfo[]>} listSessions
|
|
24
|
+
* Return resumable sessions for a given work-directory.
|
|
25
|
+
* @property {(workDir: string, sessionId: string, limit?: number) => Promise<HistoryMessage[]>} loadHistory
|
|
26
|
+
* Return the resumable transcript as an array of `claude_output`-compatible
|
|
27
|
+
* envelopes (the same shape the live stream would have produced).
|
|
21
28
|
*
|
|
22
29
|
* @typedef {Object} StartOpts
|
|
23
30
|
* @property {string} conversationId
|
|
@@ -25,6 +32,23 @@
|
|
|
25
32
|
* @property {string|null} [resumeSessionId]
|
|
26
33
|
* @property {string} [userId]
|
|
27
34
|
* @property {string} [username]
|
|
35
|
+
* @property {Object} [providerOptions] per-provider knobs (model, allowAllTools, ...)
|
|
36
|
+
*
|
|
37
|
+
* @typedef {Object} FolderInfo
|
|
38
|
+
* @property {string} name opaque folder identifier (provider-specific)
|
|
39
|
+
* @property {string} path original cwd path
|
|
40
|
+
* @property {number} sessionCount
|
|
41
|
+
* @property {number} lastModified epoch ms
|
|
42
|
+
*
|
|
43
|
+
* @typedef {Object} SessionInfo
|
|
44
|
+
* @property {string} sessionId
|
|
45
|
+
* @property {string} workDir
|
|
46
|
+
* @property {string} title
|
|
47
|
+
* @property {string} [preview]
|
|
48
|
+
* @property {number} lastModified
|
|
49
|
+
* @property {number} [size]
|
|
50
|
+
*
|
|
51
|
+
* @typedef {Object} HistoryMessage a single claude_output `data` envelope
|
|
28
52
|
*/
|
|
29
53
|
|
|
30
54
|
export const PROVIDER_NAMES = Object.freeze(['claude-code', 'copilot']);
|
package/providers/claude-code.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import { startClaudeQuery } from '../claude.js';
|
|
4
|
+
import {
|
|
5
|
+
getClaudeProjectsDir,
|
|
6
|
+
getHistorySessions,
|
|
7
|
+
loadSessionHistory,
|
|
8
|
+
getWorkDirFromProjectFolder,
|
|
9
|
+
} from '../history.js';
|
|
2
10
|
|
|
3
11
|
export const name = 'claude-code';
|
|
4
12
|
|
|
@@ -31,4 +39,46 @@ export function abort(state) {
|
|
|
31
39
|
}
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
// ---------- history surface ----------
|
|
43
|
+
|
|
44
|
+
export async function listFolders() {
|
|
45
|
+
const projectsDir = getClaudeProjectsDir();
|
|
46
|
+
const folders = [];
|
|
47
|
+
if (!existsSync(projectsDir)) return folders;
|
|
48
|
+
|
|
49
|
+
for (const entry of readdirSync(projectsDir)) {
|
|
50
|
+
const entryPath = join(projectsDir, entry);
|
|
51
|
+
let stats;
|
|
52
|
+
try { stats = statSync(entryPath); } catch { continue; }
|
|
53
|
+
if (!stats.isDirectory()) continue;
|
|
54
|
+
if (entry.includes('--crew-roles-')) continue;
|
|
55
|
+
|
|
56
|
+
const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
|
|
57
|
+
let sessionCount = 0;
|
|
58
|
+
let lastModified = stats.mtime.getTime();
|
|
59
|
+
try {
|
|
60
|
+
for (const file of readdirSync(entryPath)) {
|
|
61
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
62
|
+
sessionCount++;
|
|
63
|
+
try {
|
|
64
|
+
const fs = statSync(join(entryPath, file));
|
|
65
|
+
if (fs.mtime.getTime() > lastModified) lastModified = fs.mtime.getTime();
|
|
66
|
+
} catch { /* noop */ }
|
|
67
|
+
}
|
|
68
|
+
} catch { /* noop */ }
|
|
69
|
+
|
|
70
|
+
folders.push({ name: entry, path: originalPath, sessionCount, lastModified });
|
|
71
|
+
}
|
|
72
|
+
folders.sort((a, b) => b.lastModified - a.lastModified);
|
|
73
|
+
return folders;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function listSessions(workDir) {
|
|
77
|
+
return await getHistorySessions(workDir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadHistory(workDir, sessionId, limit = 500) {
|
|
81
|
+
return loadSessionHistory(workDir, sessionId, limit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };
|
package/providers/copilot.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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';
|
|
4
8
|
|
|
5
9
|
export const name = 'copilot';
|
|
@@ -25,6 +29,7 @@ export async function start(opts) {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const sessionId = opts.resumeSessionId || randomUUID();
|
|
32
|
+
const providerOptions = opts.providerOptions || prior?.providerOptions || {};
|
|
28
33
|
const state = {
|
|
29
34
|
providerName: name,
|
|
30
35
|
conversationId: opts.conversationId,
|
|
@@ -37,11 +42,12 @@ export async function start(opts) {
|
|
|
37
42
|
abortController: null,
|
|
38
43
|
tools: [],
|
|
39
44
|
slashCommands: [],
|
|
40
|
-
model: 'copilot',
|
|
45
|
+
model: providerOptions.model || 'copilot',
|
|
41
46
|
userId: opts.userId,
|
|
42
47
|
username: opts.username,
|
|
43
48
|
disallowedTools: prior?.disallowedTools || null,
|
|
44
49
|
copilotChild: null,
|
|
50
|
+
providerOptions,
|
|
45
51
|
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
46
52
|
};
|
|
47
53
|
ctx.conversations.set(conversationId, state);
|
|
@@ -64,7 +70,15 @@ export async function sendInput(state, prompt, opts = {}) {
|
|
|
64
70
|
state.turnResultReceived = false;
|
|
65
71
|
|
|
66
72
|
const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
|
|
67
|
-
|
|
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');
|
|
68
82
|
|
|
69
83
|
let child;
|
|
70
84
|
try {
|
|
@@ -275,4 +289,196 @@ function normalizeContent(content) {
|
|
|
275
289
|
return [{ type: 'text', text: String(content ?? '') }];
|
|
276
290
|
}
|
|
277
291
|
|
|
278
|
-
export default { name, start, sendInput, abort };
|
|
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
|
+
|