@steerable/agent-shell 0.6.20 → 0.6.22

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.
Files changed (64) hide show
  1. package/README.md +34 -0
  2. package/dist/auth/index.d.ts +38 -0
  3. package/dist/auth/index.js +20 -0
  4. package/dist/host/pack-assembly.d.ts +7 -7
  5. package/dist/host/runtime.d.ts +6 -2
  6. package/dist/host/runtime.js +54 -15
  7. package/dist/host/visible-terminal-exec.d.ts +0 -2
  8. package/dist/host/visible-terminal-exec.js +4 -8
  9. package/dist/insights/flush.d.ts +5 -4
  10. package/dist/insights/flush.js +14 -15
  11. package/dist/insights/record.d.ts +6 -5
  12. package/dist/insights/record.js +9 -10
  13. package/dist/llm/index.d.ts +6 -2
  14. package/dist/llm/index.js +19 -9
  15. package/dist/local-backend/ai-suggestions.d.ts +32 -0
  16. package/dist/local-backend/ai-suggestions.js +160 -0
  17. package/dist/local-backend/coreloop-stream.js +1 -1
  18. package/dist/local-backend/mentioned-paths.d.ts +28 -0
  19. package/dist/local-backend/mentioned-paths.js +67 -0
  20. package/dist/local-backend/prompt-builder.d.ts +10 -4
  21. package/dist/local-backend/prompt-builder.js +13 -12
  22. package/dist/local-backend/router.d.ts +26 -4
  23. package/dist/local-backend/router.js +243 -96
  24. package/dist/local-backend/skills/00-identity/SKILL.md +1 -1
  25. package/dist/local-backend/task-service.d.ts +14 -13
  26. package/dist/local-backend/task-service.js +32 -29
  27. package/dist/local-backend/turn-files.d.ts +57 -0
  28. package/dist/local-backend/turn-files.js +285 -0
  29. package/dist/local-backend/worktree-service.d.ts +1 -1
  30. package/dist/local-backend/worktree-service.js +6 -6
  31. package/dist/main.js +45 -26
  32. package/dist/preload.cjs +30 -10
  33. package/dist/preload.js +33 -10
  34. package/dist/server/http-server.d.ts +13 -1
  35. package/dist/server/http-server.js +45 -8
  36. package/dist/server/index.js +4 -90
  37. package/dist/server/sse-bus.d.ts +1 -1
  38. package/dist/server/start.d.ts +26 -0
  39. package/dist/server/start.js +111 -0
  40. package/dist/sidecar/boot.d.ts +3 -1
  41. package/dist/sidecar/boot.js +6 -7
  42. package/dist/sidecar/reverse-approval.d.ts +2 -0
  43. package/dist/sidecar/reverse-approval.js +2 -1
  44. package/dist/sidecar/reverse-ask-user.d.ts +2 -0
  45. package/dist/sidecar/reverse-ask-user.js +2 -1
  46. package/dist/sidecar/reverse-spawn.js +12 -4
  47. package/dist/sidecar/reverse-tools.d.ts +1 -1
  48. package/dist/sidecar/reverse-tools.js +1 -1
  49. package/dist/sidecar/storage-path.js +2 -3
  50. package/dist/storage/driver.d.ts +57 -0
  51. package/dist/storage/driver.js +104 -0
  52. package/dist/storage/index.d.ts +80 -67
  53. package/dist/storage/index.js +528 -296
  54. package/dist/storage/pack-migrations.d.ts +2 -4
  55. package/dist/storage/pack-migrations.js +2 -4
  56. package/dist/storage/scoped-store.d.ts +125 -0
  57. package/dist/storage/scoped-store.js +1 -0
  58. package/dist/storage/sqlite-driver.d.ts +16 -0
  59. package/dist/storage/sqlite-driver.js +135 -0
  60. package/dist/storage/write-lease-error.d.ts +11 -0
  61. package/dist/storage/write-lease-error.js +18 -0
  62. package/package.json +14 -5
  63. package/dist/storage/local-store-singleton.d.ts +0 -28
  64. package/dist/storage/local-store-singleton.js +0 -38
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  import os from 'node:os';
5
- import { getAppRootDir } from '../runtime.js';
6
+ import { getAppRootDir, shellOpenPath } from '../runtime.js';
6
7
  import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
@@ -10,7 +11,7 @@ import { driveWithAutoContinue, resolveAutoContinueMax, } from './auto-continue-
10
11
  import { builtinSubagentParam } from './subagent-profiles.js';
11
12
  import { appendTimelineDelta, syncTimelineTools, } from './turn-timeline.js';
12
13
  import { turnDurationMs } from './turn-duration.js';
13
- import { localStore, DEFAULT_SYSTEM_PROMPT, telemetryEnabled } from '../storage/index.js';
14
+ import { DEFAULT_SYSTEM_PROMPT, telemetryEnabled } from '../storage/index.js';
14
15
  import { isSkillPinned, isToolAllowed, mergeAgentCapabilities, normalizeToolPolicy, resolveSkillExcludes, } from './agent-capability.js';
15
16
  import { getBrand } from '../brand.js';
16
17
  import { recordInsightEvent, recordInsightTurn, recordInsightProfile } from '../insights/record.js';
@@ -21,11 +22,12 @@ import { allowEgressForBaseUrl, getActiveEgressBroker, getEgressPosture } from '
21
22
  import { buildExecSandbox, parseExecPolicy } from '../sidecar/exec-sandbox.js';
22
23
  import { SidecarSupervisor } from '../sidecar/index.js';
23
24
  import { diagnoseLlmConnection } from './llm-diagnose.js';
24
- import { buildSystemPrompt, buildForcedSkillMessage, conditionsFromTools, } from './prompt-builder.js';
25
+ import { brandSkillVars, buildSystemPrompt, buildForcedSkillMessage, conditionsFromTools, } from './prompt-builder.js';
25
26
  import { parseUserMessageTriggers } from './message-triggers.js';
26
27
  import { findSkill, loadSkills, classifySkillOrigin, listSkillRoots } from './skill-loader.js';
27
28
  import { installSkillFromDirectory } from './skill-install.js';
28
29
  import { generateChatTitle } from './ai-title.js';
30
+ import { fallbackSuggestedReplies, generateSuggestedReplies, } from './ai-suggestions.js';
29
31
  import { detectDeferredExecution } from './deferred-detector.js';
30
32
  import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js';
31
33
  import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
@@ -37,6 +39,9 @@ import { loadProjectRuleFiles } from '../project-rules.js';
37
39
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
38
40
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
39
41
  import { matchPackBackendRoute } from './pack-backend-routes.js';
42
+ import { collectTurnFiles } from './turn-files.js';
43
+ import { resolveMentionedPaths } from './mentioned-paths.js';
44
+ import { getAuthProvider } from '../auth/index.js';
40
45
  /**
41
46
  * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
42
47
  * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
@@ -59,7 +64,7 @@ export function userFacingCoreLoopFailure(reason) {
59
64
  /**
60
65
  * Parses a pagination-style query param (`?page=`, `?limit=`) into a finite
61
66
  * positive integer, falling back to `fallback` for missing/non-numeric input
62
- * (e.g. `?page=abc`) instead of letting `NaN` leak into `localStore` queries
67
+ * (e.g. `?page=abc`) instead of letting `NaN` leak into storage queries
63
68
  * and the echoed-back `pagination` block of the response.
64
69
  */
65
70
  function parsePositiveIntParam(raw, fallback) {
@@ -145,19 +150,44 @@ function buildLocalAgent() {
145
150
  }
146
151
  export class LocalBackendRouter {
147
152
  toolRouter;
153
+ defaultStore;
154
+ storeContext = new AsyncLocalStorage();
155
+ resolveStore;
148
156
  broadcast;
149
157
  /** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
150
158
  taskService;
151
- constructor(toolRouter, options = {}) {
159
+ constructor(toolRouter, options) {
152
160
  this.toolRouter = toolRouter;
161
+ this.defaultStore = options.store;
162
+ this.resolveStore = options.resolveStore ?? (() => options.store);
153
163
  this.broadcast = options.broadcast ?? null;
154
164
  this.taskService = options.taskService ?? null;
155
165
  }
156
- async handle(request) {
166
+ get store() {
167
+ return this.storeContext.getStore() ?? this.defaultStore;
168
+ }
169
+ /** Store selected for the active request, or the host default outside one. */
170
+ get activeStore() {
171
+ return this.store;
172
+ }
173
+ handle(request) {
174
+ return this.storeContext.run(this.resolveStore(request.principal), () => this.handleScoped(request));
175
+ }
176
+ async handleScoped(request) {
157
177
  const { method } = request;
158
178
  const url = new URL(request.path, 'http://local.backend');
159
179
  const pathname = url.pathname;
160
180
  if (method === 'GET' && pathname === '/api/v2/auth/me') {
181
+ const authProvider = getAuthProvider();
182
+ if (authProvider) {
183
+ if (!request.principal) {
184
+ return { status: 401, data: { detail: 'unauthorized' } };
185
+ }
186
+ return {
187
+ status: 200,
188
+ data: await authProvider.describeSelf(request.principal),
189
+ };
190
+ }
161
191
  return { status: 200, data: buildLocalApiUser() };
162
192
  }
163
193
  // Desktop Agent —— 在本地模式下虚拟一个永远在线的 agent,所有 exec 直接走 ToolRouter
@@ -215,7 +245,7 @@ export class LocalBackendRouter {
215
245
  if (method === 'GET' && pathname === '/api/v2/chats') {
216
246
  const page = parsePositiveIntParam(url.searchParams.get('page'), 1);
217
247
  const limit = parsePositiveIntParam(url.searchParams.get('limit'), 50);
218
- const { chats, total } = localStore.listChats(page, limit);
248
+ const { chats, total } = await this.store.listChats(page, limit);
219
249
  return {
220
250
  status: 200,
221
251
  data: {
@@ -247,10 +277,10 @@ export class LocalBackendRouter {
247
277
  const branchesMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches$/);
248
278
  if (method === 'GET' && branchesMatch) {
249
279
  const chatId = branchesMatch[1];
250
- const chat = localStore.getChat(chatId);
280
+ const chat = await this.store.getChat(chatId);
251
281
  if (!chat)
252
282
  return { status: 404, data: { detail: 'chat not found' } };
253
- const activeRecordId = localStore.getChatRecordId(chatId) ?? chatId;
283
+ const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
254
284
  const supervisor = getSidecarSupervisor();
255
285
  const branches = supervisor ? await supervisor.sessionBranches(activeRecordId) : null;
256
286
  return {
@@ -268,10 +298,10 @@ export class LocalBackendRouter {
268
298
  const branchTreeMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches\/tree$/);
269
299
  if (method === 'GET' && branchTreeMatch) {
270
300
  const chatId = branchTreeMatch[1];
271
- const chat = localStore.getChat(chatId);
301
+ const chat = await this.store.getChat(chatId);
272
302
  if (!chat)
273
303
  return { status: 404, data: { detail: 'chat not found' } };
274
- const activeRecordId = localStore.getChatRecordId(chatId) ?? chatId;
304
+ const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
275
305
  const supervisor = getSidecarSupervisor();
276
306
  const family = supervisor ? await supervisor.sessionTree(activeRecordId) : null;
277
307
  return {
@@ -292,7 +322,7 @@ export class LocalBackendRouter {
292
322
  const activateMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches\/activate$/);
293
323
  if (method === 'POST' && activateMatch) {
294
324
  const chatId = activateMatch[1];
295
- const chat = localStore.getChat(chatId);
325
+ const chat = await this.store.getChat(chatId);
296
326
  if (!chat)
297
327
  return { status: 404, data: { detail: 'chat not found' } };
298
328
  const payload = this.toRecord(request.body);
@@ -304,7 +334,7 @@ export class LocalBackendRouter {
304
334
  if (!supervisor) {
305
335
  return { status: 503, data: { detail: 'sidecar unavailable' } };
306
336
  }
307
- const activeRecordId = localStore.getChatRecordId(chatId) ?? chatId;
337
+ const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
308
338
  const family = await supervisor.sessionTree(activeRecordId);
309
339
  const activation = resolveBranchActivation(activeRecordId, family?.tree ?? null, targetRecordId);
310
340
  if (!activation.ok) {
@@ -314,8 +344,8 @@ export class LocalBackendRouter {
314
344
  if (!projection) {
315
345
  return { status: 404, data: { detail: `record not found: ${targetRecordId}` } };
316
346
  }
317
- localStore.setChatRecordId(chatId, targetRecordId);
318
- localStore.replaceChatMessages(chatId, projection.messages
347
+ await this.store.setChatRecordId(chatId, targetRecordId);
348
+ await this.store.replaceChatMessages(chatId, projection.messages
319
349
  .filter((m) => m.role === 'user' || m.role === 'assistant')
320
350
  .map((m) => ({ role: m.role, content: m.content })));
321
351
  return {
@@ -339,7 +369,7 @@ export class LocalBackendRouter {
339
369
  };
340
370
  }
341
371
  }
342
- const created = localStore.createChat('新对话', typeof payload.agentId === 'string' ? payload.agentId : getBrand().defaultAgentId, projectId);
372
+ const created = await this.store.createChat('新对话', typeof payload.agentId === 'string' ? payload.agentId : getBrand().defaultAgentId, projectId);
343
373
  return {
344
374
  status: 200,
345
375
  data: {
@@ -359,7 +389,7 @@ export class LocalBackendRouter {
359
389
  const exceptChatId = typeof payload.exceptChatId === 'string' && payload.exceptChatId.trim()
360
390
  ? payload.exceptChatId.trim()
361
391
  : null;
362
- const deletedChatIds = localStore.deleteEmptyChats(exceptChatId);
392
+ const deletedChatIds = await this.store.deleteEmptyChats(exceptChatId);
363
393
  return { status: 200, data: { deletedChatIds } };
364
394
  }
365
395
  // ───── 项目模式:projects CRUD ─────
@@ -419,7 +449,7 @@ export class LocalBackendRouter {
419
449
  if (!ok)
420
450
  return this.notFound('项目不存在');
421
451
  // 会话不删,只解绑——它们变成"无项目对话"。
422
- const detached = localStore.clearProjectAssignment(projectId);
452
+ const detached = await this.store.clearProjectAssignment(projectId);
423
453
  return { status: 200, data: { success: true, detachedChats: detached } };
424
454
  }
425
455
  }
@@ -455,17 +485,17 @@ export class LocalBackendRouter {
455
485
  return { status: 503, data: { detail: 'task service unavailable' } };
456
486
  }
457
487
  const chatId = chatTasksMatch[1];
458
- const chat = localStore.getChat(chatId);
488
+ const chat = await this.store.getChat(chatId);
459
489
  if (!chat)
460
490
  return { status: 404, data: { detail: 'chat not found' } };
461
- return { status: 200, data: { tasks: localStore.listTasks(chatId) } };
491
+ return { status: 200, data: { tasks: await this.store.listTasks(chatId) } };
462
492
  }
463
493
  const taskProcessMatch = pathname.match(/^\/api\/v2\/tasks\/([^/]+)\/process$/);
464
494
  if (method === 'GET' && taskProcessMatch) {
465
495
  if (!this.taskService) {
466
496
  return { status: 503, data: { detail: 'task service unavailable' } };
467
497
  }
468
- const snapshot = this.taskService.getProcess(taskProcessMatch[1]);
498
+ const snapshot = await this.taskService.getProcess(taskProcessMatch[1]);
469
499
  if (!snapshot)
470
500
  return { status: 404, data: { detail: '任务不存在' } };
471
501
  return {
@@ -503,7 +533,7 @@ export class LocalBackendRouter {
503
533
  const projectContextMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/project-context$/);
504
534
  if (projectContextMatch && method === 'GET') {
505
535
  const chatId = projectContextMatch[1];
506
- const chat = localStore.getChat(chatId);
536
+ const chat = await this.store.getChat(chatId);
507
537
  if (!chat)
508
538
  return this.notFound('Chat not found');
509
539
  const registry = this.toolRouter.projectRegistry;
@@ -528,7 +558,7 @@ export class LocalBackendRouter {
528
558
  if (chatMatch) {
529
559
  const chatId = chatMatch[1];
530
560
  if (method === 'GET') {
531
- const chat = localStore.getChat(chatId);
561
+ const chat = await this.store.getChat(chatId);
532
562
  if (!chat)
533
563
  return this.notFound('Chat not found');
534
564
  return {
@@ -549,13 +579,13 @@ export class LocalBackendRouter {
549
579
  }
550
580
  if (method === 'DELETE') {
551
581
  if (url.searchParams.get('onlyIfEmpty') === '1') {
552
- const deleted = localStore.deleteChatIfEmpty(chatId);
582
+ const deleted = await this.store.deleteChatIfEmpty(chatId);
553
583
  return {
554
584
  status: 200,
555
585
  data: { success: true, deleted, chatId },
556
586
  };
557
587
  }
558
- const ok = localStore.deleteChat(chatId);
588
+ const ok = await this.store.deleteChat(chatId);
559
589
  if (!ok)
560
590
  return this.notFound('Chat not found');
561
591
  return {
@@ -572,7 +602,7 @@ export class LocalBackendRouter {
572
602
  if (chatPinMatch && method === 'PUT') {
573
603
  const chatId = chatPinMatch[1];
574
604
  const payload = this.toRecord(request.body);
575
- const updated = localStore.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
605
+ const updated = await this.store.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
576
606
  if (!updated)
577
607
  return this.notFound('Chat not found');
578
608
  return {
@@ -607,7 +637,7 @@ export class LocalBackendRouter {
607
637
  return this.badRequest('projectId 必须是项目 id 字符串或 null');
608
638
  }
609
639
  }
610
- const updated = localStore.updateChat(chatId, {
640
+ const updated = await this.store.updateChat(chatId, {
611
641
  title: typeof payload.title === 'string' ? payload.title : undefined,
612
642
  systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
613
643
  pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
@@ -635,7 +665,7 @@ export class LocalBackendRouter {
635
665
  if (messageListMatch && method === 'GET') {
636
666
  const chatId = messageListMatch[1];
637
667
  const limit = parsePositiveIntParam(url.searchParams.get('limit'), 200);
638
- const records = localStore.listMessages(chatId, limit);
668
+ const records = await this.store.listMessages(chatId, limit);
639
669
  const messages = records.map(item => ({
640
670
  id: item.id,
641
671
  chatId: item.chatId,
@@ -652,7 +682,7 @@ export class LocalBackendRouter {
652
682
  // 同一 record 上开出第二个写者)。listMessages 是 DESC,records[0]
653
683
  // 即最后一条。
654
684
  const interrupted = detectInterruptedTurn({
655
- turnActive: localStore.getTurnActive(chatId) !== null,
685
+ turnActive: await this.store.getTurnActive(chatId) !== null,
656
686
  streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
657
687
  lastMessageRole: records[0]?.role ?? null,
658
688
  });
@@ -708,7 +738,7 @@ export class LocalBackendRouter {
708
738
  }
709
739
  if (method === 'GET' && pathname === '/api/v2/chat-agents') {
710
740
  const includeArchived = url.searchParams.get('include_archived') === 'true';
711
- const agents = localStore.listChatAgents(includeArchived);
741
+ const agents = await this.store.listChatAgents(includeArchived);
712
742
  return {
713
743
  status: 200,
714
744
  data: {
@@ -719,7 +749,7 @@ export class LocalBackendRouter {
719
749
  }
720
750
  if (method === 'POST' && pathname === '/api/v2/chat-agents') {
721
751
  const payload = this.toRecord(request.body);
722
- const agent = localStore.createChatAgent({
752
+ const agent = await this.store.createChatAgent({
723
753
  name: String(payload.name || '新助手'),
724
754
  icon: typeof payload.icon === 'string' ? payload.icon : null,
725
755
  color: typeof payload.color === 'string' ? payload.color : null,
@@ -1144,14 +1174,14 @@ export class LocalBackendRouter {
1144
1174
  if (chatAgentMatch) {
1145
1175
  const agentId = chatAgentMatch[1];
1146
1176
  if (method === 'GET') {
1147
- const agent = localStore.getChatAgent(agentId);
1177
+ const agent = await this.store.getChatAgent(agentId);
1148
1178
  if (!agent)
1149
1179
  return this.notFound('Agent not found');
1150
1180
  return { status: 200, data: agent };
1151
1181
  }
1152
1182
  if (method === 'PATCH') {
1153
1183
  const payload = this.toRecord(request.body);
1154
- const agent = localStore.updateChatAgent(agentId, {
1184
+ const agent = await this.store.updateChatAgent(agentId, {
1155
1185
  name: typeof payload.name === 'string' ? payload.name : undefined,
1156
1186
  icon: typeof payload.icon === 'string' ? payload.icon : undefined,
1157
1187
  color: typeof payload.color === 'string' ? payload.color : undefined,
@@ -1170,7 +1200,7 @@ export class LocalBackendRouter {
1170
1200
  return { status: 200, data: { agent } };
1171
1201
  }
1172
1202
  if (method === 'DELETE') {
1173
- const ok = localStore.archiveChatAgent(agentId);
1203
+ const ok = await this.store.archiveChatAgent(agentId);
1174
1204
  if (!ok)
1175
1205
  return this.notFound('Agent not found');
1176
1206
  return {
@@ -1373,12 +1403,12 @@ export class LocalBackendRouter {
1373
1403
  if (method === 'GET') {
1374
1404
  return {
1375
1405
  status: 200,
1376
- data: localStore.getLlmSettings(),
1406
+ data: await this.store.getLlmSettings(),
1377
1407
  };
1378
1408
  }
1379
1409
  if (method === 'POST') {
1380
1410
  const payload = this.toRecord(request.body);
1381
- const saved = llmService.setSettings({
1411
+ const saved = await llmService.setSettings({
1382
1412
  provider: sanitizeLlmProvider(payload.provider),
1383
1413
  vendorId: sanitizeVendorId(payload.vendorId),
1384
1414
  model: String(payload.model || 'llama3.1:8b'),
@@ -1398,7 +1428,7 @@ export class LocalBackendRouter {
1398
1428
  : undefined,
1399
1429
  compat: sanitizeCompatOverrides(payload.compat),
1400
1430
  presets: sanitizePresetsChoice(payload.presets),
1401
- });
1431
+ }, this.store);
1402
1432
  // 立即让新默认超时对后续 local_exec_shell 生效(重启后由 main.ts 启动时恢复)。
1403
1433
  setDefaultExecTimeoutMs(saved.execTimeoutSeconds ? saved.execTimeoutSeconds * 1000 : null);
1404
1434
  // 代理白名单是 boot 时按当时的 baseUrl 派生的,换了网关后本会话(含
@@ -1418,12 +1448,12 @@ export class LocalBackendRouter {
1418
1448
  if (method === 'GET') {
1419
1449
  return {
1420
1450
  status: 200,
1421
- data: localStore.getTelemetrySettings(),
1451
+ data: await this.store.getTelemetrySettings(),
1422
1452
  };
1423
1453
  }
1424
1454
  if (method === 'POST') {
1425
1455
  const payload = this.toRecord(request.body);
1426
- const saved = localStore.setTelemetrySettings({
1456
+ const saved = await this.store.setTelemetrySettings({
1427
1457
  endpoint: typeof payload.endpoint === 'string' ? payload.endpoint : undefined,
1428
1458
  privacyMode: payload.privacyMode === 'full' ? 'full' : 'metadata',
1429
1459
  serviceName: typeof payload.serviceName === 'string' ? payload.serviceName : undefined,
@@ -1438,12 +1468,12 @@ export class LocalBackendRouter {
1438
1468
  if (method === 'GET') {
1439
1469
  return {
1440
1470
  status: 200,
1441
- data: localStore.getWebSearchSettings(),
1471
+ data: await this.store.getWebSearchSettings(),
1442
1472
  };
1443
1473
  }
1444
1474
  if (method === 'POST') {
1445
1475
  const payload = this.toRecord(request.body);
1446
- const saved = localStore.setWebSearchSettings({
1476
+ const saved = await this.store.setWebSearchSettings({
1447
1477
  provider: payload.provider === 'ddg' ? 'ddg' : 'tavily',
1448
1478
  apiKey: typeof payload.apiKey === 'string' ? payload.apiKey : undefined,
1449
1479
  });
@@ -1455,10 +1485,10 @@ export class LocalBackendRouter {
1455
1485
  }
1456
1486
  if (pathname === '/api/v2/local-settings/insights') {
1457
1487
  if (method === 'GET') {
1458
- const settings = localStore.ensureInsightsSettings();
1488
+ const settings = await this.store.ensureInsightsSettings();
1459
1489
  return {
1460
1490
  status: 200,
1461
- data: { ...settings, stats: localStore.insightStats() },
1491
+ data: { ...settings, stats: await this.store.insightStats() },
1462
1492
  };
1463
1493
  }
1464
1494
  if (method === 'POST') {
@@ -1487,20 +1517,20 @@ export class LocalBackendRouter {
1487
1517
  patch.apiBase = payload.apiBase;
1488
1518
  if (Object.keys(profilePatch).length)
1489
1519
  patch.profile = profilePatch;
1490
- const saved = localStore.setInsightsSettings(patch);
1520
+ const saved = await this.store.setInsightsSettings(patch);
1491
1521
  const hasProfileFields = Boolean(saved.profile.displayName ||
1492
1522
  saved.profile.email ||
1493
1523
  saved.profile.company ||
1494
1524
  saved.profile.note);
1495
1525
  if (hasProfileFields) {
1496
- recordInsightProfile(saved.profile);
1526
+ await recordInsightProfile(this.store, saved.profile);
1497
1527
  }
1498
- void flushInsightsOutbox().catch((err) => {
1528
+ void flushInsightsOutbox(this.store).catch((err) => {
1499
1529
  console.warn('[insights] flush after settings save failed', err);
1500
1530
  });
1501
1531
  return {
1502
1532
  status: 200,
1503
- data: { ...saved, stats: localStore.insightStats() },
1533
+ data: { ...saved, stats: await this.store.insightStats() },
1504
1534
  };
1505
1535
  }
1506
1536
  }
@@ -1513,19 +1543,19 @@ export class LocalBackendRouter {
1513
1543
  const properties = payload.properties && typeof payload.properties === 'object'
1514
1544
  ? payload.properties
1515
1545
  : {};
1516
- recordInsightEvent(eventName, properties);
1546
+ await recordInsightEvent(this.store, eventName, properties);
1517
1547
  return { status: 200, data: { status: 'ok' } };
1518
1548
  }
1519
1549
  if (method === 'GET' && pathname === '/api/v2/insights/export') {
1520
- return { status: 200, data: buildInsightsExportPayload() };
1550
+ return { status: 200, data: await buildInsightsExportPayload(this.store) };
1521
1551
  }
1522
1552
  if (method === 'POST' && pathname === '/api/v2/insights/upload-local') {
1523
- const ok = await uploadInsightsBundle();
1553
+ const ok = await uploadInsightsBundle(this.store);
1524
1554
  return {
1525
1555
  status: ok ? 200 : 502,
1526
1556
  data: {
1527
1557
  ok,
1528
- stats: localStore.insightStats(),
1558
+ stats: await this.store.insightStats(),
1529
1559
  detail: ok ? 'uploaded' : 'upload_failed_kept_local',
1530
1560
  },
1531
1561
  };
@@ -1534,7 +1564,7 @@ export class LocalBackendRouter {
1534
1564
  const days = parsePositiveIntParam(url.searchParams.get('days'), 30);
1535
1565
  return {
1536
1566
  status: 200,
1537
- data: localStore.getUsageSummary(days),
1567
+ data: await this.store.getUsageSummary(days),
1538
1568
  };
1539
1569
  }
1540
1570
  if (method === 'POST' && pathname.match(/^\/api\/v2\/chats\/[^/]+\/suggest-replies$/)) {
@@ -1620,7 +1650,7 @@ export class LocalBackendRouter {
1620
1650
  if (!chatId) {
1621
1651
  return { status: 400, data: { detail: 'chatId is required' } };
1622
1652
  }
1623
- const traces = localStore.listTracesByChat(chatId, limit).map(item => ({
1653
+ const traces = (await this.store.listTracesByChat(chatId, limit)).map(item => ({
1624
1654
  ...item,
1625
1655
  payload: this.safeJson(item.payload, {}),
1626
1656
  }));
@@ -1631,7 +1661,7 @@ export class LocalBackendRouter {
1631
1661
  }
1632
1662
  const traceMatch = pathname.match(/^\/api\/v2\/local\/traces\/([^/]+)$/);
1633
1663
  if (traceMatch && method === 'GET') {
1634
- const trace = localStore.getTrace(traceMatch[1]);
1664
+ const trace = await this.store.getTrace(traceMatch[1]);
1635
1665
  if (!trace)
1636
1666
  return this.notFound('Trace not found');
1637
1667
  return {
@@ -1642,6 +1672,37 @@ export class LocalBackendRouter {
1642
1672
  },
1643
1673
  };
1644
1674
  }
1675
+ // 回合产物列表的「点击打开」:用系统默认应用打开本地路径(与 agent 的
1676
+ // local_open_path 工具同一宿主能力,这里给渲染层一个 HTTP 入口,CS/BS
1677
+ // 两种模式都经 localBackend.request 到达)。
1678
+ if (method === 'POST' && pathname === '/api/v2/local/open-path') {
1679
+ const payload = this.toRecord(request.body);
1680
+ const target = typeof payload.path === 'string' ? payload.path.trim() : '';
1681
+ if (!target || !path.isAbsolute(target)) {
1682
+ return { status: 400, data: { detail: 'absolute path is required' } };
1683
+ }
1684
+ const openError = await shellOpenPath(target);
1685
+ if (openError) {
1686
+ return { status: 200, data: { success: false, error: openError } };
1687
+ }
1688
+ return { status: 200, data: { success: true } };
1689
+ }
1690
+ // 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
1691
+ // 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
1692
+ // 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
1693
+ if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
1694
+ const payload = this.toRecord(request.body);
1695
+ const candidates = Array.isArray(payload.candidates)
1696
+ ? payload.candidates.filter((item) => typeof item === 'string')
1697
+ : [];
1698
+ if (candidates.length === 0) {
1699
+ return { status: 200, data: { resolved: [] } };
1700
+ }
1701
+ const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
1702
+ const baseDir = (chatId ? (await this.resolveChatProject(chatId))?.folderPath : null) ?? os.homedir();
1703
+ const resolved = await resolveMentionedPaths({ candidates, baseDir });
1704
+ return { status: 200, data: { resolved } };
1705
+ }
1645
1706
  // ─── 场景包路由(1.2:/api/v2/<包前缀>/* 由包经 pack-backend-routes
1646
1707
  // 注册表贡献;在宿主自带路由之后、fallback 之前匹配,包不能遮蔽宿主
1647
1708
  // 路由)。 ───
@@ -1696,10 +1757,14 @@ export class LocalBackendRouter {
1696
1757
  * `accumulatedContent += parsedData.content` 一行追加渲染。
1697
1758
  * - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
1698
1759
  * - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
1760
+ * - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
1699
1761
  * - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
1700
1762
  * - 结束:标准 SSE `[DONE]` 串。
1701
1763
  */
1702
- async handleStream(request, emit, options = {}) {
1764
+ handleStream(request, emit, options = {}) {
1765
+ return this.storeContext.run(this.resolveStore(request.principal), () => this.handleStreamScoped(request, emit, options));
1766
+ }
1767
+ async handleStreamScoped(request, emit, options = {}) {
1703
1768
  const signal = options.signal;
1704
1769
  const url = new URL(request.path, 'http://local.backend');
1705
1770
  const pathname = url.pathname;
@@ -1715,7 +1780,7 @@ export class LocalBackendRouter {
1715
1780
  }
1716
1781
  const chatId = sendMatch ? sendMatch[1] : regenerateMatch[1];
1717
1782
  const payload = this.toRecord(request.body);
1718
- let chat = localStore.getChat(chatId);
1783
+ let chat = await this.store.getChat(chatId);
1719
1784
  if (!chat) {
1720
1785
  // 会话 URL 即会话身份:外部平台(自动化/eval)把一个 chatId 当成一次
1721
1786
  // 会话任务反复打开运行,本地会话却可能已经被用户清理、被空会话 prune、
@@ -1731,7 +1796,7 @@ export class LocalBackendRouter {
1731
1796
  const agentId = typeof payload.agentId === 'string' && payload.agentId.trim()
1732
1797
  ? payload.agentId.trim()
1733
1798
  : 'local-assistant';
1734
- chat = localStore.createChatWithId(chatId, '新对话', agentId);
1799
+ chat = await this.store.createChatWithId(chatId, '新对话', agentId);
1735
1800
  console.warn('[local-backend] stream: chat missing, auto-provisioned', {
1736
1801
  chatId,
1737
1802
  agentId,
@@ -1769,7 +1834,7 @@ export class LocalBackendRouter {
1769
1834
  // the *real* request instead of a generic placeholder (matters for
1770
1835
  // deferred-exec detection, mode preamble, etc., which all key off the
1771
1836
  // actual ask).
1772
- const priorMessages = localStore.listMessages(chatId, 200).reverse();
1837
+ const priorMessages = (await this.store.listMessages(chatId, 200)).reverse();
1773
1838
  const resolution = resolveRegenerateContext(priorMessages, targetMessageId);
1774
1839
  if (!resolution.ok) {
1775
1840
  emit(this.sse('error', { message: 'regenerate target message not found or not an assistant message' }));
@@ -1788,13 +1853,13 @@ export class LocalBackendRouter {
1788
1853
  const supervisor = getSidecarSupervisor();
1789
1854
  const outcome = supervisor
1790
1855
  ? await supervisor.forkSession({
1791
- recordId: localStore.getChatRecordId(chatId) ?? chatId,
1856
+ recordId: await this.store.getChatRecordId(chatId) ?? chatId,
1792
1857
  beforeUserIndex: resolveRegenerateForkOrdinal(priorMessages, targetMessageId),
1793
1858
  newRecordId: `${chatId}:r${Date.now().toString(36)}`,
1794
1859
  })
1795
1860
  : null;
1796
1861
  if (outcome?.ok) {
1797
- localStore.setChatRecordId(chatId, outcome.fork.recordId);
1862
+ await this.store.setChatRecordId(chatId, outcome.fork.recordId);
1798
1863
  console.log('[local-backend] regenerate: record forked (old tail preserved)', {
1799
1864
  chatId,
1800
1865
  branchRecordId: outcome.fork.recordId,
@@ -1813,7 +1878,7 @@ export class LocalBackendRouter {
1813
1878
  emit(this.sse('error', { message: plan.message }));
1814
1879
  return { status: 409 };
1815
1880
  }
1816
- const deleted = localStore.deleteMessagesFrom(chatId, targetMessageId);
1881
+ const deleted = await this.store.deleteMessagesFrom(chatId, targetMessageId);
1817
1882
  console.log('[local-backend] regenerate: truncated history', {
1818
1883
  chatId,
1819
1884
  targetMessageId,
@@ -1825,9 +1890,9 @@ export class LocalBackendRouter {
1825
1890
  // sidecar 回放 durable record 的投影作为循环种子。只在检测确为
1826
1891
  // interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
1827
1892
  // 凭空多接一段回复。
1828
- const prior = localStore.listMessages(chatId, 200);
1893
+ const prior = await this.store.listMessages(chatId, 200);
1829
1894
  const interrupted = detectInterruptedTurn({
1830
- turnActive: localStore.getTurnActive(chatId) !== null,
1895
+ turnActive: await this.store.getTurnActive(chatId) !== null,
1831
1896
  streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
1832
1897
  lastMessageRole: prior[0]?.role ?? null,
1833
1898
  });
@@ -1858,8 +1923,8 @@ export class LocalBackendRouter {
1858
1923
  // 3. 历史里目前没有任何 assistant 消息——也就是说,本轮即将产生的是第一条助手回复
1859
1924
  // 这避免在已经聊到一半的对话里突然把标题换掉。listMessages 是 DESC,limit 拉
1860
1925
  // 大一点确保看到完整历史(200 已经覆盖绝大多数本地会话)。
1861
- const existingAssistant = localStore
1862
- .listMessages(chatId, 200)
1926
+ const existingAssistant = (await this.store
1927
+ .listMessages(chatId, 200))
1863
1928
  .some((m) => m.role === 'assistant');
1864
1929
  const shouldGenerateTitle = !regenerateMatch &&
1865
1930
  !isResume &&
@@ -1880,7 +1945,7 @@ export class LocalBackendRouter {
1880
1945
  // persisted (and already in the durable record).
1881
1946
  let currentUserMessageId;
1882
1947
  if (!regenerateMatch && !isResume) {
1883
- const userMessage = localStore.addMessage(chatId, 'user', userMessageText);
1948
+ const userMessage = await this.store.addMessage(chatId, 'user', userMessageText);
1884
1949
  currentUserMessageId = userMessage.id;
1885
1950
  emit(this.sseData({ type: 'user_message', message: userMessage }));
1886
1951
  }
@@ -1889,7 +1954,7 @@ export class LocalBackendRouter {
1889
1954
  const chatMode = payload.mode === 'plan' ? 'plan' : 'agent';
1890
1955
  // 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
1891
1956
  // 策略决定工具列表,工具列表又决定技能的触发条件。
1892
- const turnAgents = this.resolveTurnAgents(chatId, payload);
1957
+ const turnAgents = await this.resolveTurnAgents(chatId, payload);
1893
1958
  // Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
1894
1959
  // 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
1895
1960
  // 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
@@ -2001,7 +2066,7 @@ export class LocalBackendRouter {
2001
2066
  });
2002
2067
  return;
2003
2068
  }
2004
- const fresh = localStore.getChat(chatId);
2069
+ const fresh = await this.store.getChat(chatId);
2005
2070
  if (!fresh) {
2006
2071
  console.log('[local-backend] title-gen skip', {
2007
2072
  chatId,
@@ -2017,7 +2082,7 @@ export class LocalBackendRouter {
2017
2082
  });
2018
2083
  return;
2019
2084
  }
2020
- const updated = localStore.updateChat(chatId, { title: result.title });
2085
+ const updated = await this.store.updateChat(chatId, { title: result.title });
2021
2086
  if (!updated) {
2022
2087
  console.warn('[local-backend] title-gen write failed', { chatId });
2023
2088
  return;
@@ -2046,8 +2111,8 @@ export class LocalBackendRouter {
2046
2111
  * Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
2047
2112
  * 工具调用补上 projectRoot 围栏。
2048
2113
  */
2049
- resolveChatProject(chatId) {
2050
- const projectId = localStore.getChat(chatId)?.projectId;
2114
+ async resolveChatProject(chatId) {
2115
+ const projectId = (await this.store.getChat(chatId))?.projectId;
2051
2116
  if (!projectId)
2052
2117
  return null;
2053
2118
  const project = this.toolRouter.projectRegistry?.get(projectId);
@@ -2068,9 +2133,9 @@ export class LocalBackendRouter {
2068
2133
  *
2069
2134
  * @param chatId 当前对话 id。
2070
2135
  * @param payload 前端提交的流请求体。
2071
- * @returns 本轮的智能体顺序、人设智能体,以及合并后的能力面。
2136
+ * @returns 本轮的智能体顺序、人设智能体、自称,以及合并后的能力面。
2072
2137
  */
2073
- resolveTurnAgents(chatId, payload) {
2138
+ async resolveTurnAgents(chatId, payload) {
2074
2139
  const mentionedAgentIds = Array.isArray(payload.mentionedAgentIds)
2075
2140
  ? payload.mentionedAgentIds.filter((s) => typeof s === 'string')
2076
2141
  : [];
@@ -2082,19 +2147,20 @@ export class LocalBackendRouter {
2082
2147
  ].filter((id, idx, arr) => arr.indexOf(id) === idx);
2083
2148
  if (orderedAgentIds.length === 0) {
2084
2149
  const fallbackId = (typeof payload.agentId === 'string' && payload.agentId) ||
2085
- localStore.getChat(chatId)?.agentId ||
2150
+ (await this.store.getChat(chatId))?.agentId ||
2086
2151
  null;
2087
2152
  if (fallbackId)
2088
2153
  orderedAgentIds.push(fallbackId);
2089
2154
  }
2090
- const agents = orderedAgentIds
2091
- .map((id) => localStore.getChatAgent(id))
2155
+ const agents = (await Promise.all(orderedAgentIds.map((id) => this.store.getChatAgent(id))))
2092
2156
  .filter((agent) => Boolean(agent));
2093
2157
  return {
2094
2158
  orderedAgentIds,
2095
2159
  // 能力面来自全部解析到的智能体,人设前言只用配了 rolePrompt 的那些——
2096
- // 自建智能体可以只勾技能、不写人设。
2160
+ // 自建智能体可以只勾技能、不写人设。自称始终跟第一个解析到的智能体,
2161
+ // 不要求写了人设——否则没 rolePrompt 的角色会回落成产品品牌。
2097
2162
  personaAgents: agents.filter((agent) => Boolean(agent.rolePrompt)),
2163
+ identityName: agents[0]?.name.trim() || null,
2098
2164
  capability: mergeAgentCapabilities(agents),
2099
2165
  };
2100
2166
  }
@@ -2105,7 +2171,7 @@ export class LocalBackendRouter {
2105
2171
  // 摘要,也不再按 40 条窗口截断——让框架 record 累积完整对话并按需压缩。
2106
2172
  // 上限即存储层 listMessages 的 1000 条硬上限;超出时最旧历史随
2107
2173
  // host_revision 优雅退化。
2108
- let rawHistory = localStore.listMessages(chatId, HISTORY_SEED_LIMIT).reverse();
2174
+ let rawHistory = (await this.store.listMessages(chatId, HISTORY_SEED_LIMIT)).reverse();
2109
2175
  // 剔除刚刚写入数据库的当前轮用户消息,避免在上下文历史中与我们显式添加的
2110
2176
  // latestUserMessage 发生重复。send 路径按 id 精确剔除——上一条 assistant
2111
2177
  // 回复在流结束后异步落库(createdAt 取落库时刻),快速连续发送时它会排到
@@ -2150,7 +2216,7 @@ export class LocalBackendRouter {
2150
2216
  })))
2151
2217
  .filter(Boolean);
2152
2218
  const settings = llmService.getSettings();
2153
- const { personaAgents, capability } = turnAgents;
2219
+ const { personaAgents, capability, identityName } = turnAgents;
2154
2220
  let personaPreamble = '';
2155
2221
  if (personaAgents.length === 1) {
2156
2222
  personaPreamble = `【当前角色】${personaAgents[0].name}\n${personaAgents[0].rolePrompt}`;
@@ -2218,6 +2284,7 @@ export class LocalBackendRouter {
2218
2284
  excludeSkillNames,
2219
2285
  pinnedSkillNames: capability.pinnedSkills,
2220
2286
  personaPreamble: effectivePersonaPreamble,
2287
+ identityName,
2221
2288
  realityCheckSuffix: realityCheck,
2222
2289
  forcedMcpTool,
2223
2290
  ignoreConditions: capability.loadAllSkills,
@@ -2246,7 +2313,7 @@ export class LocalBackendRouter {
2246
2313
  // 项目模式:绑定项目的对话在系统提示末尾追加沙箱约定(两条拼装路径
2247
2314
  // 都追加)。硬围栏在 ToolRouter/LocalExecutor,这里让模型事先知道边界,
2248
2315
  // 减少越界尝试。
2249
- const chatProject = this.resolveChatProject(chatId);
2316
+ const chatProject = await this.resolveChatProject(chatId);
2250
2317
  if (chatProject) {
2251
2318
  systemPrompt +=
2252
2319
  `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,根目录:${chatProject.folderPath}\n` +
@@ -2258,7 +2325,7 @@ export class LocalBackendRouter {
2258
2325
  // 仅在用户显式信任该项目后才注入模型上下文——未信任一律不读取、不注入
2259
2326
  // (打开恶意仓库时,一段构造的规则文件不能劫持 agent)。信任状态持久化、
2260
2327
  // 可在设置里撤销;每回合重新读盘,规则保存即生效。
2261
- const projectId = localStore.getChat(chatId)?.projectId;
2328
+ const projectId = (await this.store.getChat(chatId))?.projectId;
2262
2329
  const registry = this.toolRouter.projectRegistry;
2263
2330
  if (projectId && registry?.isTrusted(projectId)) {
2264
2331
  const rules = loadProjectRuleFiles(chatProject.folderPath);
@@ -2284,7 +2351,10 @@ export class LocalBackendRouter {
2284
2351
  const forcedSkill = await findSkill(forcedSkillName, { exclude: forcedSkillExcludes });
2285
2352
  if (forcedSkill) {
2286
2353
  // 场景包的正文占位符变量(如包技能正文里的工作区路径占位符)。
2287
- finalUserContent = `${buildForcedSkillMessage(forcedSkill, collectPackForcedSkillVars(chatId))}\n\n---\n\n${latestUserMessage}`;
2354
+ finalUserContent = `${buildForcedSkillMessage(forcedSkill, {
2355
+ ...brandSkillVars({ identityName }),
2356
+ ...collectPackForcedSkillVars(chatId),
2357
+ })}\n\n---\n\n${latestUserMessage}`;
2288
2358
  }
2289
2359
  }
2290
2360
  // W6-3 多模态:渲染进程把图片附件以 metadata.images({path,name})传上来,
@@ -2336,14 +2406,14 @@ export class LocalBackendRouter {
2336
2406
  .slice(0, MAX_REFERENCED_CHATS);
2337
2407
  const out = [];
2338
2408
  for (const refChatId of ids) {
2339
- const refChat = localStore.getChat(refChatId);
2409
+ const refChat = await this.store.getChat(refChatId);
2340
2410
  if (!refChat) {
2341
2411
  console.warn('[local-backend] referenced chat not found, skipping', { refChatId });
2342
2412
  continue;
2343
2413
  }
2344
2414
  const sections = [];
2345
- const recent = (await Promise.all(localStore
2346
- .listMessages(refChatId, RECENT_MESSAGES_PER_CHAT)
2415
+ const recent = (await Promise.all((await this.store
2416
+ .listMessages(refChatId, RECENT_MESSAGES_PER_CHAT))
2347
2417
  .reverse()
2348
2418
  .map(async (item) => {
2349
2419
  if (item.role !== 'user' && item.role !== 'assistant')
@@ -2718,7 +2788,7 @@ export class LocalBackendRouter {
2718
2788
  // empty list, so writes confine to system scratch dirs only.
2719
2789
  // `execPolicy: 'full'`(输入框「完整权限」)关闭这一层,让 mkdir
2720
2790
  // Downloads 这类项目外写入不再被 Seatbelt 拦成 Operation not permitted。
2721
- const chatProject = this.resolveChatProject(chatId);
2791
+ const chatProject = await this.resolveChatProject(chatId);
2722
2792
  const execSandbox = buildExecSandbox([
2723
2793
  // 场景包声明的每会话可写根(如文档包在项目根之外落盘产物的
2724
2794
  // 工作区)。
@@ -2754,19 +2824,27 @@ export class LocalBackendRouter {
2754
2824
  // 下方的清除点,残留的标记就是「上次回复被中断」的签名(与活进程写下的
2755
2825
  // cancelled/failed 终态区分)。标记只覆盖「已交给 sidecar」的阶段:503
2756
2826
  // (sidecar 未运行)在上方已 return,record 里没有这一轮,无可续跑。
2757
- localStore.setTurnActive(chatId);
2827
+ await this.store.setTurnActive(chatId);
2758
2828
  // 实时快照:executedActions / timeline / children 传引用(原地 mutate),
2759
2829
  // content 由 onText 逐段更新。切走再切回的 renderer 靠 GET /live-stream 读它。
2760
2830
  const live = registerLiveStream(chatId, { executedActions, timeline, children });
2761
2831
  // 场景包的回合观察器(如识别包技能调用并广播产物更新)。
2762
2832
  const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
2833
+ // 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
2834
+ // (项目根 + 包工作区),与沙箱是否启用无关——「完整权限」下根列表只是
2835
+ // 不再被强制,产物仍大概率落在这里;根之外的显式写由工具参数并集补。
2836
+ const turnStartedAtMs = Date.now();
2837
+ const turnFileRoots = [
2838
+ ...collectPackExecWritableRoots(chatId),
2839
+ ...(chatProject ? [chatProject.folderPath] : []),
2840
+ ];
2763
2841
  try {
2764
2842
  const coreLoopOptions = {
2765
2843
  supervisor,
2766
2844
  chatId,
2767
2845
  // W5-2: after a regenerate-fork the chat's live record is the
2768
2846
  // branch; undefined falls back to chatId on the sidecar.
2769
- recordId: localStore.getChatRecordId(chatId) ?? undefined,
2847
+ recordId: await this.store.getChatRecordId(chatId) ?? undefined,
2770
2848
  systemPrompt,
2771
2849
  messages,
2772
2850
  resume,
@@ -2950,14 +3028,14 @@ export class LocalBackendRouter {
2950
3028
  : // resume 要求 messages 为空:记录是唯一权威,再喂一份历史会
2951
3029
  // 静默分叉(sidecar 对此 fail loud)。
2952
3030
  { ...coreLoopOptions, resume: true, messages: [] }),
2953
- afterPass: (outcome) => {
3031
+ afterPass: async (outcome) => {
2954
3032
  if (outcome.traceId)
2955
3033
  passTraceIds.push(outcome.traceId);
2956
3034
  // W6-9 用量归因:落一条 chat 用量事件(含成本估算,无单价模型 costUsd=null)。
2957
3035
  // 每趟各落一条——续跑的花费是真实发生的,不该被合并掉。
2958
3036
  if (outcome.usage) {
2959
3037
  try {
2960
- localStore.recordUsageEvent({
3038
+ await this.store.recordUsageEvent({
2961
3039
  chatId,
2962
3040
  kind: 'chat',
2963
3041
  provider: settings.provider,
@@ -3011,8 +3089,22 @@ export class LocalBackendRouter {
3011
3089
  passTraceIds.push(failureTraceId);
3012
3090
  }
3013
3091
  }
3014
- const durationMs = turnDurationMs(localStore.getTurnActive(chatId)?.startedAt, Date.now());
3015
- const assistant = localStore.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
3092
+ const durationMs = turnDurationMs((await this.store.getTurnActive(chatId))?.startedAt, Date.now());
3093
+ // 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
3094
+ // 产物)。收集失败只记日志,永远不该拖垮回合收尾。
3095
+ let turnFiles = [];
3096
+ try {
3097
+ turnFiles = await collectTurnFiles({
3098
+ roots: turnFileRoots,
3099
+ sinceMs: turnStartedAtMs,
3100
+ actions: executedActions,
3101
+ projectRoot: chatProject?.folderPath ?? null,
3102
+ });
3103
+ }
3104
+ catch (turnFilesErr) {
3105
+ console.warn('[local-backend] collect turn files failed', turnFilesErr);
3106
+ }
3107
+ const assistant = await this.store.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
3016
3108
  completionStatus,
3017
3109
  completionReason,
3018
3110
  executedActions,
@@ -3022,9 +3114,10 @@ export class LocalBackendRouter {
3022
3114
  traceId: outcomeTraceId,
3023
3115
  ...(durationMs != null ? { durationMs } : {}),
3024
3116
  ...(autoContinuations > 0 ? { autoContinuations } : {}),
3117
+ ...(turnFiles.length > 0 ? { turnFiles } : {}),
3025
3118
  }));
3026
3119
  try {
3027
- recordInsightTurn({
3120
+ await recordInsightTurn(this.store, {
3028
3121
  chatId,
3029
3122
  mode: chatMode,
3030
3123
  modelId: overrideModel || settings.model,
@@ -3041,7 +3134,7 @@ export class LocalBackendRouter {
3041
3134
  // W7-1: 回复落库后才清标记(顺序不能反——先清后写的话,两者之间崩溃
3042
3135
  // 会丢回复且无任何续跑入口;这个顺序的最坏情况是残留标记被检测器的
3043
3136
  // 「末尾已是 assistant」规则判定为已完结)。
3044
- localStore.clearTurnActive(chatId);
3137
+ await this.store.clearTurnActive(chatId);
3045
3138
  // 回合落库即结束——移除实时快照,后续 GET /live-stream 返回 active=false。
3046
3139
  removeLiveStream(chatId);
3047
3140
  // Persist the sidecar-recorded traces into harness_traces so the CoreLoop
@@ -3050,7 +3143,7 @@ export class LocalBackendRouter {
3050
3143
  // 一趟一条:自动续跑的每一趟都是一次独立的 sidecar 运行,全部挂到本轮
3051
3144
  // 那条 assistant 消息上(message_id 无唯一约束),否则中间趟的 trace 就
3052
3145
  // 从消息侧不可达了。
3053
- const telemetry = localStore.getTelemetrySettings();
3146
+ const telemetry = await this.store.getTelemetrySettings();
3054
3147
  for (const [index, traceId] of passTraceIds.entries()) {
3055
3148
  // 只有最后一趟携带本轮终态;之前每一趟都是撞墙截停才有下一趟。
3056
3149
  const passStatus = index === passTraceIds.length - 1 ? completionStatus : 'budget_exhausted';
@@ -3060,7 +3153,7 @@ export class LocalBackendRouter {
3060
3153
  const durationMs = typeof sidecarTrace.durationMs === 'number'
3061
3154
  ? sidecarTrace.durationMs
3062
3155
  : null;
3063
- localStore.saveTrace({
3156
+ await this.store.saveTrace({
3064
3157
  id: traceId,
3065
3158
  chatId,
3066
3159
  messageId: assistant.id,
@@ -3094,11 +3187,65 @@ export class LocalBackendRouter {
3094
3187
  });
3095
3188
  }
3096
3189
  }
3190
+ // 产物文件列表赶在 message_id 之前发:前端按 message_id 把本轮队列
3191
+ // 归档到落库消息上,顺序保证产物列表随同一批次归档。
3192
+ if (turnFiles.length > 0) {
3193
+ emit(this.sseData({ type: 'turn_files', files: turnFiles }));
3194
+ }
3097
3195
  emit(this.sseData({ type: 'message_id', messageId: assistant.id }));
3098
3196
  emit('data: [DONE]\n\n');
3099
3197
  this.runTitleGenInBackground(chatId, shouldGenerateTitle, firstUserMessageForTitle);
3198
+ this.runSuggestedRepliesInBackground({
3199
+ chatId,
3200
+ messageId: assistant.id,
3201
+ userText: firstUserMessageForTitle,
3202
+ assistantText,
3203
+ completionStatus,
3204
+ });
3100
3205
  return { status: 200 };
3101
3206
  }
3207
+ /**
3208
+ * 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
3209
+ * 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
3210
+ * 之后渲染端已经不再监听这条流。
3211
+ */
3212
+ runSuggestedRepliesInBackground(args) {
3213
+ const { chatId, messageId, userText, assistantText, completionStatus } = args;
3214
+ if (completionStatus === 'cancelled' || completionStatus === 'failed')
3215
+ return;
3216
+ if (!assistantText.trim())
3217
+ return;
3218
+ const fallback = fallbackSuggestedReplies(userText, assistantText);
3219
+ this.publishSuggestedReplies(chatId, messageId, fallback);
3220
+ void (async () => {
3221
+ try {
3222
+ const result = await generateSuggestedReplies(userText, assistantText, {
3223
+ perAttemptTimeoutMs: 60_000,
3224
+ });
3225
+ if (result.usedFallback)
3226
+ return;
3227
+ const same = result.suggestions.length === fallback.length &&
3228
+ result.suggestions.every((item, i) => item === fallback[i]);
3229
+ if (same)
3230
+ return;
3231
+ this.publishSuggestedReplies(chatId, messageId, result.suggestions);
3232
+ }
3233
+ catch (err) {
3234
+ console.warn('[local-backend] suggested-replies failed', { chatId, err });
3235
+ }
3236
+ })();
3237
+ }
3238
+ async publishSuggestedReplies(chatId, messageId, suggestions) {
3239
+ if (suggestions.length === 0)
3240
+ return;
3241
+ try {
3242
+ await this.store.patchMessageMetadata(chatId, messageId, { suggestedReplies: suggestions });
3243
+ }
3244
+ catch (err) {
3245
+ console.warn('[local-backend] suggested-replies persist failed', { chatId, messageId, err });
3246
+ }
3247
+ this.broadcast?.('suggested-replies', { chatId, messageId, suggestions });
3248
+ }
3102
3249
  /**
3103
3250
  * 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
3104
3251
  * 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。