@keyou007/agent-bot 0.1.22 → 0.1.24-alpha.0

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 (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +26 -2
  3. package/README.zh.md +26 -2
  4. package/config.example.yaml +5 -0
  5. package/dist/cli/controlProtocol.js.map +1 -1
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/help.js.map +1 -1
  8. package/dist/cli/taskGroupOptions.js +27 -0
  9. package/dist/cli/taskGroupOptions.js.map +1 -1
  10. package/dist/cli.js +21 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/codex/CodexProcessManager.js +19 -3
  13. package/dist/codex/CodexProcessManager.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +92 -9
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/commands/CommandRouter.js +27 -0
  17. package/dist/commands/CommandRouter.js.map +1 -1
  18. package/dist/config/loadConfig.js +4 -0
  19. package/dist/config/loadConfig.js.map +1 -1
  20. package/dist/config/schema.js +1 -0
  21. package/dist/config/schema.js.map +1 -1
  22. package/dist/feishu/CardRenderer.js +31 -2
  23. package/dist/feishu/CardRenderer.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +6 -3
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/MergedForwardMessage.js +17 -7
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  28. package/dist/index.js +50 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/local-files/TurnPreviewPage.js +106 -87
  31. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  32. package/dist/proxy/ConversationExport.js +41 -0
  33. package/dist/proxy/ConversationExport.js.map +1 -0
  34. package/dist/proxy/ProxySessionController.js +118 -7
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +65 -0
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/state/migrations.js +12 -0
  39. package/dist/state/migrations.js.map +1 -1
  40. package/dist/updates/AutomaticUpdatePreparer.js +66 -0
  41. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -0
  42. package/dist/updates/DailyUpdateMonitor.js +261 -0
  43. package/dist/updates/DailyUpdateMonitor.js.map +1 -0
  44. package/dist/updates/PublishedRelease.js +43 -0
  45. package/dist/updates/PublishedRelease.js.map +1 -0
  46. package/dist/updates/types.js +2 -0
  47. package/dist/updates/types.js.map +1 -0
  48. package/docs/technical-reference.md +4 -0
  49. package/docs/technical-reference.zh.md +4 -0
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/skills/agent-bot/SKILL.md +11 -0
  53. package/src/cli/controlProtocol.ts +9 -0
  54. package/src/cli/help.ts +4 -0
  55. package/src/cli/taskGroupOptions.ts +33 -3
  56. package/src/cli.ts +21 -0
  57. package/src/codex/CodexProcessManager.ts +21 -4
  58. package/src/codex/CodexRuntime.ts +104 -12
  59. package/src/commands/CommandRouter.ts +24 -0
  60. package/src/commands/commandTypes.ts +1 -0
  61. package/src/config/loadConfig.ts +4 -0
  62. package/src/config/schema.ts +4 -1
  63. package/src/feishu/CardRenderer.ts +34 -2
  64. package/src/feishu/FeishuMessageClient.ts +6 -2
  65. package/src/feishu/MergedForwardMessage.ts +29 -7
  66. package/src/feishu/types.ts +1 -1
  67. package/src/index.ts +49 -0
  68. package/src/local-files/TurnPreviewPage.ts +105 -88
  69. package/src/proxy/ConversationExport.ts +43 -0
  70. package/src/proxy/ProxySessionController.ts +166 -8
  71. package/src/runtime/types.ts +6 -0
  72. package/src/state/StateStore.ts +69 -0
  73. package/src/state/migrations.ts +12 -0
  74. package/src/updates/AutomaticUpdatePreparer.ts +76 -0
  75. package/src/updates/DailyUpdateMonitor.ts +252 -0
  76. package/src/updates/PublishedRelease.ts +44 -0
  77. package/src/updates/types.ts +19 -0
@@ -59,6 +59,7 @@ import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
59
59
  import type {
60
60
  AgentRuntime,
61
61
  ApprovalDecision,
62
+ ConversationTurn,
62
63
  ModelOption,
63
64
  PermissionMode,
64
65
  RemoteCompletedTurnSummary,
@@ -81,6 +82,7 @@ import {
81
82
  import {
82
83
  StateStore,
83
84
  type CardActionBinding,
85
+ type ForkHistorySourceRecord,
84
86
  type MessageReactionRecord,
85
87
  type MessageReactionStatus,
86
88
  type QueuedPromptRecord,
@@ -92,6 +94,7 @@ import { createId } from "../utils/id.js";
92
94
  import { formatStorageSize } from "../utils/formatStorageSize.js";
93
95
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
94
96
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
97
+ import { conversationImportPrompt, exportConversation } from "./ConversationExport.js";
95
98
  import {
96
99
  executeShellCommand,
97
100
  type ShellCommandOptions,
@@ -260,6 +263,8 @@ const HELP_COMMAND_SECTIONS: Array<{
260
263
  usage: "[序号或任务 ID]",
261
264
  description: "从当前或指定任务最近完成轮次创建分支",
262
265
  },
266
+ { command: "/clone", usage: "[title] [--agent &#60;name&#62;]", description: "导出用户 Prompt 和最终回答,交给新 Agent 任务读取" },
267
+ { command: "/clonegroup", usage: "[title] [--agent &#60;name&#62;]", description: "将对话上下文交给新群中的 Agent 任务" },
263
268
  { command: "/turns", usage: "[Turn ID 或序号]", description: "浏览历史轮次,并可 Reset 对话上下文;带参数查看运行详情" },
264
269
  {
265
270
  command: "/title",
@@ -477,6 +482,14 @@ export interface ControlTaskGroupResult {
477
482
  task: SessionRecord;
478
483
  }
479
484
 
485
+ export interface ControlTaskCloneResult {
486
+ sourceLocalSessionId: string;
487
+ contextFile: string;
488
+ turnCount: number;
489
+ task: SessionRecord;
490
+ group?: CreatedFeishuGroupContext;
491
+ }
492
+
480
493
  export interface ControlTaskReleaseResult {
481
494
  agentName: string;
482
495
  status: "waiting" | "released";
@@ -487,6 +500,7 @@ export interface ProxyLifecycle {
487
500
  supervised?: boolean;
488
501
  restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
489
502
  cancelSafeRestart?(scheduleId: number): Promise<boolean>;
503
+ cancelAutomaticUpdate?(action: CardAction): Promise<void>;
490
504
  rememberFeishuUserOpenId?(userOpenId: string): Promise<void> | void;
491
505
  persistAgentExecutionDefaults?(
492
506
  agentName: string,
@@ -975,7 +989,10 @@ export class ProxySessionController {
975
989
  await this.outbound.withReplyTarget(contextKey, replyTarget, async () => {
976
990
  try {
977
991
  const kind = String(scopedAction.value.action ?? "");
978
- if (kind === "help_command") {
992
+ if (kind === "agentbot_update_cancel") {
993
+ if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
994
+ await this.lifecycle.cancelAutomaticUpdate(scopedAction);
995
+ } else if (kind === "help_command") {
979
996
  await this.executeHelpCommandAction(scopedAction, contextKey, replyTarget);
980
997
  } else if (kind === "turn_details") {
981
998
  await this.outbound.showDetails(contextKey, String(scopedAction.value.turnId ?? ""));
@@ -1508,6 +1525,22 @@ export class ProxySessionController {
1508
1525
  };
1509
1526
  }
1510
1527
 
1528
+ async controlCloneTask(
1529
+ localSessionId: string,
1530
+ requestedTitle?: string,
1531
+ requestedAgentName?: string,
1532
+ newGroup = false,
1533
+ userOpenId?: string,
1534
+ ): Promise<ControlTaskCloneResult> {
1535
+ const source = this.requireControlSession(localSessionId);
1536
+ const contextKey = this.controlSessionContextKey(source);
1537
+ const replyTarget = this.controlSessionReplyTarget(source);
1538
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.cloneSession(
1539
+ contextKey, { ...source, contextKey }, requestedTitle, requestedAgentName,
1540
+ newGroup, userOpenId, replyTarget,
1541
+ ));
1542
+ }
1543
+
1511
1544
  async controlForkTaskGroup(
1512
1545
  localSessionId: string,
1513
1546
  requestedTitle: string | undefined,
@@ -1708,6 +1741,7 @@ export class ProxySessionController {
1708
1741
  await loaded.runtime.setReasoningEffort(record.localSessionId, nextEffort);
1709
1742
  }
1710
1743
  this.store.updateRuntimeSession(record.localSessionId, { model: nextValue, reasoningEffort: nextEffort });
1744
+ this.outbound.updateSessionModel(record.localSessionId, nextValue);
1711
1745
  } else if (setting === "thinking" && nextValue) {
1712
1746
  const models = await loaded.runtime.listModels(loaded.session.modelProvider, loaded.session.model);
1713
1747
  const currentModel = models.find((candidate) => candidate.id === loaded.session.model)
@@ -2010,6 +2044,22 @@ export class ProxySessionController {
2010
2044
  command.projectless === true,
2011
2045
  );
2012
2046
  return;
2047
+ case "clone":
2048
+ case "clonegroup": {
2049
+ let source = this.currentSession(contextKey);
2050
+ let throughTurnId: string | undefined;
2051
+ if (incomingMessage?.threadContext && (!source
2052
+ || !this.store.findLatestCompletedTurnId(source.localSessionId, contextKey))) {
2053
+ const resolved = this.resolveThreadForkAnchor(incomingMessage, false);
2054
+ source = resolved.source;
2055
+ throughTurnId = resolved.anchor.turnId;
2056
+ }
2057
+ await this.cloneSession(
2058
+ contextKey, source ?? this.requireCurrentSession(contextKey), command.title,
2059
+ command.agentName, command.type === "clonegroup", userId, replyTarget, throughTurnId,
2060
+ );
2061
+ return;
2062
+ }
2013
2063
  case "forkgroup":
2014
2064
  await this.forkCurrentSessionToFeishuGroup(contextKey, command.title, userId, incomingMessage);
2015
2065
  return;
@@ -3013,7 +3063,7 @@ export class ProxySessionController {
3013
3063
  }
3014
3064
  }
3015
3065
 
3016
- private resolveThreadForkAnchor(message: IncomingMessage): ResolvedThreadForkAnchor {
3066
+ private resolveThreadForkAnchor(message: IncomingMessage, includeSnapshot = true): ResolvedThreadForkAnchor {
3017
3067
  if (!message.threadContext || !message.threadId || !message.chatId) {
3018
3068
  throw new Error("当前消息不属于可识别的飞书话题。");
3019
3069
  }
@@ -3034,8 +3084,9 @@ export class ProxySessionController {
3034
3084
  throw new Error("话题来源任务不属于当前会话,已拒绝创建分支。");
3035
3085
  }
3036
3086
 
3037
- const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId));
3038
- if (isTurnStillRunning(snapshot?.status)
3087
+ const snapshot = includeSnapshot ? turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId)) : undefined;
3088
+ const status = includeSnapshot ? snapshot?.status : this.store.getTurnPromptSummary(anchor.turnId)?.status;
3089
+ if (isTurnStillRunning(status)
3039
3090
  || (source.lastTurnId === anchor.turnId && source.lastTurnStatus === "running")) {
3040
3091
  throw new Error("话题对应的轮次仍在执行,App Server 暂时不能从这一轮 fork。请等待该轮完成后再在话题中发送消息。");
3041
3092
  }
@@ -3734,6 +3785,10 @@ export class ProxySessionController {
3734
3785
  while (true) {
3735
3786
  let pendingTurnId: string | undefined;
3736
3787
  try {
3788
+ this.outbound.updateSessionModel(
3789
+ loaded.record.localSessionId,
3790
+ loaded.runtime.getSession(loaded.record.localSessionId)?.model ?? loaded.session.model,
3791
+ );
3737
3792
  pendingTurnId = await this.outbound.startPendingTurn(
3738
3793
  loaded.record.localSessionId,
3739
3794
  loaded.record.contextKey,
@@ -3828,6 +3883,7 @@ export class ProxySessionController {
3828
3883
  replyTarget?: MessageReplyTarget,
3829
3884
  requestedTitle?: string,
3830
3885
  executionSettings: SessionExecutionSettings = {},
3886
+ activate = true,
3831
3887
  ): Promise<SessionRecord> {
3832
3888
  const agent = this.ensureAgent(agentName);
3833
3889
  const resolvedExecutionSettings = mergeExecutionSettings(agent.defaults ?? {}, executionSettings);
@@ -3847,7 +3903,7 @@ export class ProxySessionController {
3847
3903
  permissionMode: resolvedExecutionSettings.permissionMode,
3848
3904
  });
3849
3905
  }
3850
- this.store.setCurrentSession(contextKey, localSessionId);
3906
+ if (activate) this.store.setCurrentSession(contextKey, localSessionId);
3851
3907
  this.outbound.registerSession(
3852
3908
  localSessionId,
3853
3909
  contextKey,
@@ -4000,6 +4056,105 @@ export class ProxySessionController {
4000
4056
  return { group, task };
4001
4057
  }
4002
4058
 
4059
+ private async cloneSession(
4060
+ sourceContextKey: string,
4061
+ source: SessionRecord,
4062
+ requestedTitle: string | undefined,
4063
+ requestedAgentName: string | undefined,
4064
+ newGroup: boolean,
4065
+ userId?: string,
4066
+ replyTarget?: MessageReplyTarget,
4067
+ throughTurnId?: string,
4068
+ ): Promise<ControlTaskCloneResult> {
4069
+ const context = this.store.getOrCreateUserContext(sourceContextKey, this.config.defaults.agent!);
4070
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
4071
+ this.ensureAgent(agentName);
4072
+ if (newGroup && !userId?.startsWith("ou_")) {
4073
+ throw new Error("/clonegroup 只能由具有 open_id 的飞书用户触发。");
4074
+ }
4075
+ await this.outbound.sendText(sourceContextKey, `正在导出对话并创建 ${this.agentLabel(agentName)} 克隆任务,请稍后。`);
4076
+ const exported = await exportConversation(
4077
+ path.join(path.dirname(this.config.storage.sqlitePath), "context-transfers"),
4078
+ this.readCloneConversation(source, throughTurnId),
4079
+ );
4080
+ const title = normalizeTaskTitle(requestedTitle) ?? `${source.title ?? "任务"}(克隆)`;
4081
+ const cwd = detectProjectlessWorkspace(source.cwd) ? undefined : source.cwd;
4082
+ const group = newGroup ? await this.createFeishuGroupContext(
4083
+ sourceContextKey, agentName, title, userId, cwd, "/clonegroup",
4084
+ ) : undefined;
4085
+ const contextKey = group?.contextKey ?? sourceContextKey;
4086
+ let task: SessionRecord;
4087
+ try {
4088
+ task = await this.createSession(
4089
+ contextKey, agentName, cwd, false, false, undefined, undefined, title,
4090
+ { permissionMode: source.permissionMode ?? "auto" }, false,
4091
+ );
4092
+ } catch (error) {
4093
+ await this.outbound.sendText(contextKey,
4094
+ `${group ? "群已创建,但" : ""}克隆任务创建失败;上下文文件已保留:${exported.filePath}`);
4095
+ throw error;
4096
+ }
4097
+ this.store.audit(contextKey, "session_cloned", {
4098
+ sourceLocalSessionId: source.localSessionId,
4099
+ clonedLocalSessionId: task.localSessionId,
4100
+ contextFile: exported.filePath,
4101
+ turnCount: exported.turnCount,
4102
+ });
4103
+ try {
4104
+ await this.startTurn(
4105
+ await this.loadSession(task), conversationImportPrompt(exported.filePath),
4106
+ group ? undefined : replyTarget, undefined,
4107
+ { displayPrompt: `读取历史上下文(${exported.turnCount} 轮对话)` },
4108
+ );
4109
+ } catch (error) {
4110
+ await this.outbound.sendText(contextKey,
4111
+ `克隆任务已创建,但上下文读取启动失败。任务 ID:${task.localSessionId};上下文文件:${exported.filePath}`);
4112
+ throw error;
4113
+ }
4114
+ this.store.setCurrentSession(contextKey, task.localSessionId);
4115
+ await this.outbound.sendText(sourceContextKey, group
4116
+ ? `已创建克隆群:${group.name},正在读取 ${exported.turnCount} 轮历史对话。`
4117
+ : `已切换到 ${this.agentLabel(agentName)} 克隆任务:${title},正在读取 ${exported.turnCount} 轮历史对话。`);
4118
+ return {
4119
+ sourceLocalSessionId: source.localSessionId,
4120
+ contextFile: exported.filePath,
4121
+ turnCount: exported.turnCount,
4122
+ task: this.store.getSession(task.localSessionId) ?? task,
4123
+ ...(group ? { group } : {}),
4124
+ };
4125
+ }
4126
+
4127
+ private async *readCloneConversation(source: SessionRecord, throughTurnId?: string): AsyncIterable<ConversationTurn> {
4128
+ const runtime = this.runtimes.forAgent(source.agentName);
4129
+ if (!source.remoteSessionId || !runtime.readConversation) {
4130
+ yield* this.store.readConversation(source.localSessionId, throughTurnId);
4131
+ return;
4132
+ }
4133
+ let localSessionId: string | undefined = source.localSessionId;
4134
+ let remoteSessionId = source.remoteSessionId;
4135
+ const visited = new Set<string>();
4136
+ while (!visited.has(remoteSessionId)) {
4137
+ visited.add(remoteSessionId);
4138
+ const fork: ForkHistorySourceRecord | undefined = localSessionId
4139
+ ? this.store.getForkHistorySource(localSessionId) : undefined;
4140
+ if (fork && runtime.readRemoteForkSource) {
4141
+ const remote = await runtime.readRemoteForkSource(remoteSessionId);
4142
+ const completed = remote.lastCompletedTurnId
4143
+ ?? (remote.lastTurnStatus === "completed" ? remote.lastTurnId : undefined);
4144
+ if (!completed) {
4145
+ if (!fork.sourceRemoteSessionId) throw new Error("找不到分支的原始任务,无法导出继承的上下文。");
4146
+ throughTurnId ??= fork.sourceTurnId;
4147
+ localSessionId = fork.sourceLocalSessionId;
4148
+ remoteSessionId = fork.sourceRemoteSessionId;
4149
+ continue;
4150
+ }
4151
+ }
4152
+ yield* runtime.readConversation(remoteSessionId, throughTurnId);
4153
+ return;
4154
+ }
4155
+ throw new Error("分支来源包含循环,无法导出上下文。");
4156
+ }
4157
+
4003
4158
  private async forkCurrentSessionToFeishuGroup(
4004
4159
  sourceContextKey: string,
4005
4160
  requestedTitle: string | undefined,
@@ -4098,7 +4253,7 @@ export class ProxySessionController {
4098
4253
  taskTitle: string,
4099
4254
  userId: string | undefined,
4100
4255
  boundProjectCwd: string | undefined,
4101
- commandName: "/newgroup" | "/forkgroup",
4256
+ commandName: "/newgroup" | "/forkgroup" | "/clonegroup",
4102
4257
  ): Promise<CreatedFeishuGroupContext> {
4103
4258
  if (!userId?.startsWith("ou_")) {
4104
4259
  throw new Error(`${commandName} 只能由具有 open_id 的飞书用户消息触发。`);
@@ -5187,7 +5342,9 @@ export class ProxySessionController {
5187
5342
  throw error;
5188
5343
  }
5189
5344
  };
5190
- return loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5345
+ const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5346
+ this.outbound.updateSessionModel(loaded.record.localSessionId, session.model);
5347
+ return session;
5191
5348
  }
5192
5349
 
5193
5350
  private async assertProviderModelSettings(
@@ -5249,6 +5406,7 @@ export class ProxySessionController {
5249
5406
  await loaded.runtime.setReasoningEffort(loaded.record.localSessionId, nextEffort);
5250
5407
  }
5251
5408
  this.store.updateRuntimeSession(loaded.record.localSessionId, { model, reasoningEffort: nextEffort });
5409
+ this.outbound.updateSessionModel(loaded.record.localSessionId, model);
5252
5410
  await this.persistAgentExecutionDefaults(loaded.record.localSessionId);
5253
5411
  const effortMessage = nextEffort && nextEffort !== currentEffort
5254
5412
  ? `,思考强度已自动调整为 ${nextEffort}`
@@ -8318,7 +8476,7 @@ function latestRemoteTimestamp(...values: Array<number | undefined>): number | u
8318
8476
  return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
8319
8477
  }
8320
8478
 
8321
- function isTurnStillRunning(status?: TurnViewState["status"]): boolean {
8479
+ function isTurnStillRunning(status?: string): boolean {
8322
8480
  return status === "starting"
8323
8481
  || status === "running"
8324
8482
  || status === "tool_running"
@@ -124,6 +124,11 @@ export interface RemoteCompletedTurnSummary {
124
124
  completedAt?: number;
125
125
  }
126
126
 
127
+ export interface ConversationTurn {
128
+ turnId: string;
129
+ messages: Array<{ role: "user" | "assistant"; text: string }>;
130
+ }
131
+
127
132
  export interface RemoteTurnPage {
128
133
  turns: RemoteCompletedTurnSummary[];
129
134
  nextCursor?: string;
@@ -256,6 +261,7 @@ export interface AgentRuntime {
256
261
  readRemoteSessionMetrics?(remoteSessionId: string): Promise<RemoteSessionMetrics>;
257
262
  readRemoteForkSource?(remoteSessionId: string): Promise<RemoteSessionSummary>;
258
263
  listRemoteTurnSummaries?(remoteSessionId: string, input: { cursor?: string; limit: number }): Promise<RemoteTurnPage>;
264
+ readConversation?(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn>;
259
265
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
260
266
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
261
267
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { migrations } from "./migrations.js";
5
+ import type { UpdateCheckSchedule, UpdateNotice } from "../updates/types.js";
6
+ import type { ConversationTurn } from "../runtime/types.js";
5
7
 
6
8
  export type SessionStatus = "starting" | "ready" | "running" | "closed" | "failed";
7
9
 
@@ -287,6 +289,36 @@ export class StateStore {
287
289
  `);
288
290
  }
289
291
 
292
+ getUpdateCheckSchedule(): UpdateCheckSchedule | undefined {
293
+ const row = this.db.prepare("SELECT value_json FROM update_check_schedule WHERE id = 1")
294
+ .get() as { value_json: string } | undefined;
295
+ return row ? JSON.parse(row.value_json) as UpdateCheckSchedule : undefined;
296
+ }
297
+
298
+ saveUpdateCheckSchedule(schedule: UpdateCheckSchedule): void {
299
+ this.db.prepare(`INSERT INTO update_check_schedule (id, value_json) VALUES (1, ?)
300
+ ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json`).run(JSON.stringify(schedule));
301
+ }
302
+
303
+ getUpdateNotice(version: string): UpdateNotice | undefined {
304
+ const row = this.db.prepare("SELECT value_json FROM update_notices WHERE version = ?")
305
+ .get(version) as { value_json: string } | undefined;
306
+ return row ? JSON.parse(row.value_json) as UpdateNotice : undefined;
307
+ }
308
+
309
+ saveUpdateNotice(notice: UpdateNotice): void {
310
+ this.db.prepare(`INSERT INTO update_notices (version, status, value_json) VALUES (?, ?, ?)
311
+ ON CONFLICT(version) DO UPDATE SET status = excluded.status, value_json = excluded.value_json`)
312
+ .run(notice.version, notice.status, JSON.stringify(notice));
313
+ }
314
+
315
+ listPendingUpdateNotices(): UpdateNotice[] {
316
+ const rows = this.db.prepare(`SELECT value_json FROM update_notices
317
+ WHERE status IN ('announcing', 'countdown', 'preparing', 'scheduled')`)
318
+ .all() as Array<{ value_json: string }>;
319
+ return rows.map((row) => JSON.parse(row.value_json) as UpdateNotice);
320
+ }
321
+
290
322
  saveGoalCardDelivery(localSessionId: string, contextKey: string, messageId: string): void {
291
323
  const updatedAt = new Date().toISOString();
292
324
  this.db.prepare(`
@@ -1047,6 +1079,43 @@ export class StateStore {
1047
1079
  return row ? JSON.parse(row.snapshot_json) : undefined;
1048
1080
  }
1049
1081
 
1082
+ *readConversation(localSessionId: string, throughTurnId?: string): Iterable<ConversationTurn> {
1083
+ const index = this.listTaskTurnGraphIndex(localSessionId);
1084
+ const byId = new Map(index.map((turn) => [turn.turnId, turn]));
1085
+ const current = this.getSession(localSessionId)?.lastTurnId;
1086
+ const parent = current ? this.getTurnParent(current, localSessionId) : undefined;
1087
+ const head = throughTurnId ?? (current && byId.has(current) ? current : undefined)
1088
+ ?? (parent && byId.has(parent) ? parent : undefined)
1089
+ ?? this.findLatestCompletedTurnId(localSessionId) ?? index[0]?.turnId;
1090
+ const branch: string[] = [];
1091
+ const seen = new Set<string>();
1092
+ let turnId: string | undefined = head;
1093
+ while (turnId && !seen.has(turnId)) {
1094
+ seen.add(turnId);
1095
+ const turn = byId.get(turnId);
1096
+ if (!turn) throw new Error("上下文历史不完整,无法导出当前分支。");
1097
+ branch.push(turnId);
1098
+ turnId = turn.parentTurnId;
1099
+ }
1100
+ if (turnId) throw new Error("上下文历史包含循环,无法导出当前分支。");
1101
+ const read = this.db.prepare(`SELECT
1102
+ json_extract(snapshot_json, '$.prompt') AS prompt,
1103
+ json_extract(snapshot_json, '$.finalResponse') AS answer,
1104
+ (SELECT json_group_array(json_extract(value, '$.text'))
1105
+ FROM json_each(snapshot_json, '$.activities')
1106
+ WHERE json_extract(value, '$.kind') = 'user') AS followups
1107
+ FROM turn_snapshots WHERE turn_id = ?`);
1108
+ for (const id of branch.reverse()) {
1109
+ const row = read.get(id) as { prompt: string | null; answer: string | null; followups: string };
1110
+ const messages: ConversationTurn["messages"] = [];
1111
+ for (const text of [row.prompt, ...(JSON.parse(row.followups) as string[])]) {
1112
+ if (text?.trim()) messages.push({ role: "user", text });
1113
+ }
1114
+ if (row.answer?.trim()) messages.push({ role: "assistant", text: row.answer });
1115
+ yield { turnId: id, messages };
1116
+ }
1117
+ }
1118
+
1050
1119
  getTurnPromptSummary(turnId: string): TurnPromptSummary | undefined {
1051
1120
  const row = this.db.prepare(`SELECT
1052
1121
  json_extract(snapshot_json, '$.status') AS status,
@@ -195,4 +195,16 @@ export const migrations = [
195
195
  CREATE INDEX IF NOT EXISTS idx_card_action_bindings_created
196
196
  ON card_action_bindings(created_at);
197
197
  `,
198
+ `
199
+ CREATE TABLE IF NOT EXISTS update_check_schedule (
200
+ id INTEGER PRIMARY KEY CHECK (id = 1),
201
+ value_json TEXT NOT NULL
202
+ );
203
+ CREATE TABLE IF NOT EXISTS update_notices (
204
+ version TEXT PRIMARY KEY,
205
+ status TEXT NOT NULL,
206
+ value_json TEXT NOT NULL
207
+ );
208
+ CREATE INDEX IF NOT EXISTS idx_update_notices_status ON update_notices(status);
209
+ `,
198
210
  ] as const;
@@ -0,0 +1,76 @@
1
+ import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { promisify } from "node:util";
4
+ import { z } from "zod";
5
+ import { loadConfig } from "../config/loadConfig.js";
6
+ import { controlEndpoint } from "../cli/controlProtocol.js";
7
+ import { finalizeSelfUpdatePlan, prepareSelfUpdate, releaseSelfUpdatePlan, requireNpmSelfUpdateInstallation } from "../cli/SelfUpdater.js";
8
+ import { stableVersionSchema } from "./PublishedRelease.js";
9
+
10
+ const resultSchema = z.discriminatedUnion("status", [
11
+ z.object({ status: z.literal("current") }),
12
+ z.object({ status: z.literal("prepared"), planPath: z.string().min(1), targetVersion: stableVersionSchema }),
13
+ ]);
14
+ const runFile = promisify(execFile);
15
+ type RunPreparation = (file: string, args: string[], options: ExecFileOptionsWithStringEncoding) => Promise<{ stdout: string }>;
16
+ interface UpdateProfile { home: string; configPath: string }
17
+
18
+ export async function checkAutomaticUpdateSupport(profile: UpdateProfile, run: RunPreparation = runFile): Promise<void> {
19
+ z.object({ status: z.literal("supported") }).parse(await runPreparer("--check", profile, run));
20
+ }
21
+
22
+ export async function prepareAutomaticUpdate(
23
+ version: string,
24
+ profile: UpdateProfile,
25
+ run: RunPreparation = runFile,
26
+ ): Promise<z.infer<typeof resultSchema>> {
27
+ stableVersionSchema.parse(version);
28
+ const result = resultSchema.parse(await runPreparer(version, profile, run));
29
+ if (result.status === "prepared" && result.targetVersion !== version) {
30
+ throw new Error("The prepared update does not match the announced version.");
31
+ }
32
+ return result;
33
+ }
34
+
35
+ async function runPreparer(argument: string, profile: UpdateProfile, run: RunPreparation): Promise<unknown> {
36
+ const environment: NodeJS.ProcessEnv = { ...process.env, AGENT_BOT_HOME: profile.home, AGENT_BOT_CONFIG: profile.configPath };
37
+ delete environment.AGENT_BOT;
38
+ // npm preparation is synchronous and can take minutes; keep it off the live worker's event loop.
39
+ const { stdout } = await run(process.execPath, [fileURLToPath(import.meta.url), argument], {
40
+ env: environment, windowsHide: true, encoding: "utf8", maxBuffer: 1_000_000,
41
+ });
42
+ return JSON.parse(String(stdout)) as unknown;
43
+ }
44
+
45
+ async function main(): Promise<void> {
46
+ if (process.argv[2] === "--check") {
47
+ requireNpmSelfUpdateInstallation();
48
+ process.stdout.write(JSON.stringify({ status: "supported" }));
49
+ return;
50
+ }
51
+ const version = stableVersionSchema.parse(process.argv[2]);
52
+ const config = loadConfig();
53
+ const prepared = await prepareSelfUpdate({ version, channel: "latest" });
54
+ if (prepared.status === "prepared") {
55
+ try {
56
+ finalizeSelfUpdatePlan(prepared.planPath, {
57
+ controlEndpoint: controlEndpoint(config.storage.sqlitePath),
58
+ databasePath: config.storage.sqlitePath,
59
+ restartService: true,
60
+ workingDirectory: process.cwd(),
61
+ reason: `Agent Bot 自动更新到 ${version}`,
62
+ });
63
+ } catch (error) {
64
+ releaseSelfUpdatePlan(prepared.planPath);
65
+ throw error;
66
+ }
67
+ }
68
+ process.stdout.write(JSON.stringify(prepared));
69
+ }
70
+
71
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
72
+ void main().catch((error: unknown) => {
73
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
74
+ process.exitCode = 1;
75
+ });
76
+ }