@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 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
- const historyMessages = loadSessionHistory(effectiveWorkDir, claudeSessionId);
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
- if (state.abortController) {
661
- state.abortController.abort();
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 sessions = await getHistorySessions(effectiveWorkDir);
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
- // 列出 Claude projects 目录下的所有 folder (工作目录)
221
+ // 列出指定 provider 下所有 folder (工作目录)
215
222
  export async function handleListFolders(msg) {
216
- const { requestId, _requestClientId } = msg;
217
- const projectsDir = getClaudeProjectsDir();
223
+ const { requestId, _requestClientId, provider } = msg;
224
+ const providerName = provider || DEFAULT_PROVIDER;
218
225
 
219
- console.log(`Listing folders from: ${projectsDir}`);
226
+ console.log(`Listing folders for provider=${providerName}`);
220
227
 
221
228
  try {
222
- const folders = [];
223
-
224
- if (existsSync(projectsDir)) {
225
- const entries = readdirSync(projectsDir);
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
- folders.sort((a, b) => b.lastModified - a.lastModified);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.859",
3
+ "version": "0.1.860",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
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']);
@@ -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
- export default { name, start, sendInput, abort };
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 };
@@ -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
- if (YOLO) args.push('--allow-all-tools');
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
+