team-anya 0.2.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 (145) hide show
  1. package/README.md +38 -0
  2. package/apps/server/dist/broker/cc-broker.js +267 -0
  3. package/apps/server/dist/cli.js +296 -0
  4. package/apps/server/dist/config.js +78 -0
  5. package/apps/server/dist/daemon.js +51 -0
  6. package/apps/server/dist/franky/context-builder.js +161 -0
  7. package/apps/server/dist/franky/franky-mcp-server.js +110 -0
  8. package/apps/server/dist/franky/franky-orchestrator.js +629 -0
  9. package/apps/server/dist/franky/index.js +5 -0
  10. package/apps/server/dist/franky/topic-router.js +16 -0
  11. package/apps/server/dist/gateway/chat-sync.js +135 -0
  12. package/apps/server/dist/gateway/command-router.js +116 -0
  13. package/apps/server/dist/gateway/commands/cancel.js +32 -0
  14. package/apps/server/dist/gateway/commands/help.js +16 -0
  15. package/apps/server/dist/gateway/commands/index.js +26 -0
  16. package/apps/server/dist/gateway/commands/restart.js +43 -0
  17. package/apps/server/dist/gateway/commands/status.js +34 -0
  18. package/apps/server/dist/gateway/commands/tasks.js +33 -0
  19. package/apps/server/dist/gateway/feishu-sender.js +508 -0
  20. package/apps/server/dist/gateway/feishu-ws.js +353 -0
  21. package/apps/server/dist/gateway/health-monitor.js +154 -0
  22. package/apps/server/dist/gateway/http.js +1064 -0
  23. package/apps/server/dist/gateway/media-downloader.js +182 -0
  24. package/apps/server/dist/gateway/message-events.js +10 -0
  25. package/apps/server/dist/gateway/message-intake.js +72 -0
  26. package/apps/server/dist/gateway/message-queue.js +118 -0
  27. package/apps/server/dist/gateway/session-reader.js +142 -0
  28. package/apps/server/dist/gateway/ws-push.js +115 -0
  29. package/apps/server/dist/loid/brain.js +121 -0
  30. package/apps/server/dist/loid/clarifier.js +162 -0
  31. package/apps/server/dist/loid/context-builder.js +462 -0
  32. package/apps/server/dist/loid/mcp-server.js +119 -0
  33. package/apps/server/dist/loid/memory-settler.js +189 -0
  34. package/apps/server/dist/loid/opportunity-manager.js +148 -0
  35. package/apps/server/dist/loid/profile-updater.js +179 -0
  36. package/apps/server/dist/loid/project-registry.js +192 -0
  37. package/apps/server/dist/loid/reporter.js +148 -0
  38. package/apps/server/dist/loid/schemas.js +117 -0
  39. package/apps/server/dist/loid/self-calibrator.js +314 -0
  40. package/apps/server/dist/loid/session-manager.js +472 -0
  41. package/apps/server/dist/loid/session.js +276 -0
  42. package/apps/server/dist/main.js +528 -0
  43. package/apps/server/dist/tracing/index.js +2 -0
  44. package/apps/server/dist/tracing/trace-context.js +92 -0
  45. package/apps/server/dist/types/message.js +2 -0
  46. package/apps/server/dist/yor/yor-mcp-server.js +107 -0
  47. package/apps/server/dist/yor/yor-orchestrator.js +248 -0
  48. package/apps/web/dist/assets/index-BiiEB0qZ.css +1 -0
  49. package/apps/web/dist/assets/index-Dnb9LGZd.js +798 -0
  50. package/apps/web/dist/index.html +13 -0
  51. package/package.json +42 -0
  52. package/packages/cc-client/dist/claude-code-backend.js +792 -0
  53. package/packages/cc-client/dist/index.js +2 -0
  54. package/packages/cc-client/package.json +11 -0
  55. package/packages/core/dist/constants.js +60 -0
  56. package/packages/core/dist/errors.js +35 -0
  57. package/packages/core/dist/index.js +9 -0
  58. package/packages/core/dist/office-init.js +190 -0
  59. package/packages/core/dist/repo-cache.js +70 -0
  60. package/packages/core/dist/scope/checker.js +114 -0
  61. package/packages/core/dist/scope/defaults.js +55 -0
  62. package/packages/core/dist/scope/index.js +3 -0
  63. package/packages/core/dist/state-machine.js +86 -0
  64. package/packages/core/dist/types/audit.js +12 -0
  65. package/packages/core/dist/types/backend.js +2 -0
  66. package/packages/core/dist/types/commitment.js +17 -0
  67. package/packages/core/dist/types/communication.js +18 -0
  68. package/packages/core/dist/types/index.js +9 -0
  69. package/packages/core/dist/types/opportunity.js +27 -0
  70. package/packages/core/dist/types/org.js +26 -0
  71. package/packages/core/dist/types/task.js +46 -0
  72. package/packages/core/dist/types/workspace.js +39 -0
  73. package/packages/core/dist/workspace-manager.js +314 -0
  74. package/packages/core/package.json +10 -0
  75. package/packages/db/dist/client.js +69 -0
  76. package/packages/db/dist/index.js +756 -0
  77. package/packages/db/dist/schema/audit-events.js +13 -0
  78. package/packages/db/dist/schema/cc-sessions.js +14 -0
  79. package/packages/db/dist/schema/chats.js +35 -0
  80. package/packages/db/dist/schema/commitments.js +18 -0
  81. package/packages/db/dist/schema/communication-events.js +14 -0
  82. package/packages/db/dist/schema/index.js +14 -0
  83. package/packages/db/dist/schema/message-log.js +20 -0
  84. package/packages/db/dist/schema/opportunities.js +23 -0
  85. package/packages/db/dist/schema/org.js +36 -0
  86. package/packages/db/dist/schema/projects.js +23 -0
  87. package/packages/db/dist/schema/tasks.js +51 -0
  88. package/packages/db/dist/schema/topics.js +22 -0
  89. package/packages/db/dist/schema/trace-spans.js +19 -0
  90. package/packages/db/dist/schema/workspaces.js +15 -0
  91. package/packages/db/package.json +12 -0
  92. package/packages/db/src/migrations/0000_baseline.sql +251 -0
  93. package/packages/db/src/migrations/0001_workspaces.sql +19 -0
  94. package/packages/db/src/migrations/0002_workspace_parent.sql +1 -0
  95. package/packages/db/src/migrations/0003_chat_context.sql +3 -0
  96. package/packages/db/src/migrations/meta/_journal.json +34 -0
  97. package/packages/mcp-tools/dist/index.js +41 -0
  98. package/packages/mcp-tools/dist/layer1/audit-append.js +38 -0
  99. package/packages/mcp-tools/dist/layer1/audit-query.js +51 -0
  100. package/packages/mcp-tools/dist/layer1/memory-brief.js +168 -0
  101. package/packages/mcp-tools/dist/layer1/memory-context.js +124 -0
  102. package/packages/mcp-tools/dist/layer1/memory-digest.js +126 -0
  103. package/packages/mcp-tools/dist/layer1/memory-forget.js +108 -0
  104. package/packages/mcp-tools/dist/layer1/memory-learn.js +63 -0
  105. package/packages/mcp-tools/dist/layer1/memory-recall.js +287 -0
  106. package/packages/mcp-tools/dist/layer1/memory-reflect.js +80 -0
  107. package/packages/mcp-tools/dist/layer1/memory-remember.js +119 -0
  108. package/packages/mcp-tools/dist/layer1/memory-search.js +263 -0
  109. package/packages/mcp-tools/dist/layer1/memory-write.js +21 -0
  110. package/packages/mcp-tools/dist/layer1/org-lookup.js +47 -0
  111. package/packages/mcp-tools/dist/layer1/project-get.js +28 -0
  112. package/packages/mcp-tools/dist/layer1/project-list.js +20 -0
  113. package/packages/mcp-tools/dist/layer1/report-daily.js +68 -0
  114. package/packages/mcp-tools/dist/layer1/task-get.js +29 -0
  115. package/packages/mcp-tools/dist/layer1/task-update.js +34 -0
  116. package/packages/mcp-tools/dist/layer2/franky/topic-checkpoint.js +43 -0
  117. package/packages/mcp-tools/dist/layer2/franky/topic-escalate.js +19 -0
  118. package/packages/mcp-tools/dist/layer2/loid/decision-log.js +15 -0
  119. package/packages/mcp-tools/dist/layer2/loid/decision-no-action.js +15 -0
  120. package/packages/mcp-tools/dist/layer2/loid/delivery-create-pr.js +30 -0
  121. package/packages/mcp-tools/dist/layer2/loid/delivery-share.js +12 -0
  122. package/packages/mcp-tools/dist/layer2/loid/delivery-submit.js +77 -0
  123. package/packages/mcp-tools/dist/layer2/loid/delivery-upload.js +18 -0
  124. package/packages/mcp-tools/dist/layer2/loid/project-remove.js +16 -0
  125. package/packages/mcp-tools/dist/layer2/loid/project-upsert.js +33 -0
  126. package/packages/mcp-tools/dist/layer2/loid/task-dispatch.js +206 -0
  127. package/packages/mcp-tools/dist/layer2/loid/task-escalate-to-topic.js +170 -0
  128. package/packages/mcp-tools/dist/layer2/loid/task-lookup.js +45 -0
  129. package/packages/mcp-tools/dist/layer2/loid/topic-close.js +22 -0
  130. package/packages/mcp-tools/dist/layer2/loid/topic-create.js +60 -0
  131. package/packages/mcp-tools/dist/layer2/loid/yor-approve.js +8 -0
  132. package/packages/mcp-tools/dist/layer2/loid/yor-kill.js +7 -0
  133. package/packages/mcp-tools/dist/layer2/loid/yor-rework.js +7 -0
  134. package/packages/mcp-tools/dist/layer2/loid/yor-spawn.js +28 -0
  135. package/packages/mcp-tools/dist/layer2/loid/yor-status.js +8 -0
  136. package/packages/mcp-tools/dist/layer2/yor/task-block.js +11 -0
  137. package/packages/mcp-tools/dist/layer2/yor/task-deliver.js +35 -0
  138. package/packages/mcp-tools/dist/layer2/yor/task-progress.js +21 -0
  139. package/packages/mcp-tools/dist/layer3/adapters/feishu-adapter.js +203 -0
  140. package/packages/mcp-tools/dist/layer3/adapters/types.js +28 -0
  141. package/packages/mcp-tools/dist/layer3/channel-receive.js +11 -0
  142. package/packages/mcp-tools/dist/layer3/channel-send.js +75 -0
  143. package/packages/mcp-tools/dist/layer3/file-upload.js +44 -0
  144. package/packages/mcp-tools/dist/registry.js +911 -0
  145. package/packages/mcp-tools/package.json +13 -0
@@ -0,0 +1,629 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { execFile as execFileCb } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { getTopic, getWorkspaceRecord, insertAuditEvent, insertCCSession, updateCCSessionEnded, getCCSessionsByTask, upsertTopic } from '@team-anya/db';
7
+ import { FRANKY_DEFAULT_SCOPE } from '@team-anya/core';
8
+ import { startFrankyMcpServer } from './franky-mcp-server.js';
9
+ const execFile = promisify(execFileCb);
10
+ // ── FrankyOrchestrator ──
11
+ /**
12
+ * FrankyOrchestrator:话题协作者编排层
13
+ *
14
+ * 职责:
15
+ * - 管理每个 Topic 对应的 Franky CC 实例(最多一个)
16
+ * - 消息驱动:用户发消息时,实例存在则注入消息,不存在则自动创建
17
+ * - 监听 CC 实例的 result / exited / crash 事件
18
+ * - Idle 超时回收(预留接口,Phase 3 细化)
19
+ *
20
+ * 与 YorOrchestrator 的核心区别:
21
+ * - Yor:一次性任务执行(brief -> 执行 -> deliver -> 销毁)
22
+ * - Franky:持续协作(话题开启 -> 消息驱动对话 -> 话题关闭)
23
+ */
24
+ export class FrankyOrchestrator {
25
+ broker;
26
+ config;
27
+ instances = new Map(); // key = topicId
28
+ spawning = new Map(); // 防止并发 spawn
29
+ logger;
30
+ idleCheckInterval = null;
31
+ idleTimeoutMs;
32
+ constructor(broker, config) {
33
+ this.broker = broker;
34
+ this.config = config;
35
+ this.logger = config.logger ?? { info: console.log, error: console.error };
36
+ this.idleTimeoutMs = config.idleTimeoutMs ?? 30 * 60 * 1000; // 默认 30 分钟
37
+ // 启动 idle 检查定时器
38
+ this.idleCheckInterval = setInterval(() => this.checkIdleInstances(), 5 * 60 * 1000);
39
+ // 监听 CC session ID 事件(Franky 实例)
40
+ this.broker.on('instance.sessionId', (instanceId, cliSessionId) => {
41
+ const brokerInstance = this.broker.getInstance(instanceId);
42
+ if (brokerInstance?.role !== 'franky')
43
+ return;
44
+ const topicId = this.findTopicByInstanceId(instanceId);
45
+ if (topicId) {
46
+ insertCCSession(this.config.db, {
47
+ session_id: cliSessionId,
48
+ role: 'franky',
49
+ instance_id: instanceId,
50
+ task_id: topicId,
51
+ project_path: brokerInstance.config.workingDir,
52
+ });
53
+ this.logger.info(`[FrankyOrchestrator] 记录 CC session: ${cliSessionId} (topic: ${topicId})`);
54
+ }
55
+ });
56
+ // 监听 CC 实例事件
57
+ this.broker.on('instance.result', (instanceId, role, _result) => {
58
+ if (role !== 'franky')
59
+ return;
60
+ const topicId = this.findTopicByInstanceId(instanceId);
61
+ if (topicId) {
62
+ const instance = this.instances.get(topicId);
63
+ if (instance)
64
+ instance.lastActivityAt = new Date();
65
+ this.logger.info(`[FrankyOrchestrator] 实例 ${instanceId} 执行完成 (topic: ${topicId})`);
66
+ }
67
+ });
68
+ this.broker.on('instance.exited', (instanceId, role) => {
69
+ if (role !== 'franky')
70
+ return;
71
+ const topicId = this.findTopicByInstanceId(instanceId);
72
+ if (topicId) {
73
+ // 更新 cc_sessions.ended_at
74
+ const brokerInstance = this.broker.getInstance(instanceId);
75
+ if (brokerInstance?.cliSessionId) {
76
+ updateCCSessionEnded(this.config.db, brokerInstance.cliSessionId);
77
+ }
78
+ this.logger.info(`[FrankyOrchestrator] 实例 ${instanceId} 已退出 (topic: ${topicId})`);
79
+ this.cleanupInstance(topicId);
80
+ }
81
+ });
82
+ this.broker.on('instance.crash', (instanceId, role, error) => {
83
+ if (role !== 'franky')
84
+ return;
85
+ const topicId = this.findTopicByInstanceId(instanceId);
86
+ if (topicId) {
87
+ // 更新 cc_sessions.ended_at
88
+ const brokerInstance = this.broker.getInstance(instanceId);
89
+ if (brokerInstance?.cliSessionId) {
90
+ updateCCSessionEnded(this.config.db, brokerInstance.cliSessionId);
91
+ }
92
+ this.logger.error(`[FrankyOrchestrator] 实例 ${instanceId} 崩溃 (topic: ${topicId}):`, error);
93
+ this.cleanupInstance(topicId);
94
+ // 通知外部(用于向用户发送崩溃提醒)
95
+ const topic = getTopic(this.config.db, topicId);
96
+ this.config.onCrash?.(topicId, topic?.title ?? topicId);
97
+ }
98
+ });
99
+ this.broker.on('instance.rate_limited', (instanceId, role, result) => {
100
+ if (role !== 'franky')
101
+ return;
102
+ const topicId = this.findTopicByInstanceId(instanceId);
103
+ if (topicId && result.rateLimitInfo) {
104
+ const topic = getTopic(this.config.db, topicId);
105
+ const topicTitle = topic?.title ?? topicId;
106
+ this.logger.info(`[FrankyOrchestrator] Franky 限流 (topic: ${topicId}): resetsAt=${result.rateLimitInfo.resetsAt}`);
107
+ this.config.onRateLimited?.(topicId, topicTitle, result.rateLimitInfo.resetsAt);
108
+ }
109
+ });
110
+ }
111
+ /**
112
+ * 向话题发送消息。
113
+ * - suspended 实例:尝试 resume,失败则 fallback 到全新 spawn
114
+ * - 不存在实例:全新 spawn
115
+ */
116
+ async sendMessage(topicId, message) {
117
+ let instance = this.instances.get(topicId);
118
+ if (instance?.suspended) {
119
+ // 挂起状态:尝试 resume
120
+ instance = await this.ensureInstance(topicId, () => this.resumeForTopic(topicId));
121
+ }
122
+ else if (!instance) {
123
+ // 完全不存在:全新 spawn
124
+ instance = await this.ensureInstance(topicId, () => this.spawnForTopic(topicId));
125
+ }
126
+ // 格式化用户消息为 prompt
127
+ const prompt = this.formatUserMessage(message);
128
+ // 注入消息到 CC 实例
129
+ try {
130
+ this.broker.sendPrompt(instance.instanceId, prompt);
131
+ instance.lastActivityAt = new Date();
132
+ }
133
+ catch (err) {
134
+ this.logger.error(`[FrankyOrchestrator] 发送消息失败 (topic: ${topicId}):`, err);
135
+ // 实例可能已死,清理后重试一次
136
+ await this.cleanupInstance(topicId);
137
+ const newInstance = await this.spawnForTopic(topicId);
138
+ this.broker.sendPrompt(newInstance.instanceId, prompt);
139
+ }
140
+ }
141
+ /**
142
+ * 防并发 spawn/resume 包装
143
+ */
144
+ async ensureInstance(topicId, factory) {
145
+ let pending = this.spawning.get(topicId);
146
+ if (!pending) {
147
+ pending = factory();
148
+ this.spawning.set(topicId, pending);
149
+ try {
150
+ return await pending;
151
+ }
152
+ finally {
153
+ this.spawning.delete(topicId);
154
+ }
155
+ }
156
+ return pending;
157
+ }
158
+ /**
159
+ * 关闭指定 Topic 的 Franky 实例
160
+ */
161
+ async closeTopic(topicId) {
162
+ await this.cleanupInstance(topicId);
163
+ this.logger.info(`[FrankyOrchestrator] 关闭话题 ${topicId} 的 Franky 实例`);
164
+ }
165
+ /**
166
+ * 主动重启话题(/restart 命令):完全清理,下次消息走全新 session + context 重建
167
+ */
168
+ async restartTopic(topicId) {
169
+ await this.cleanupInstance(topicId);
170
+ this.logger.info(`[FrankyOrchestrator] 话题 ${topicId} 已重启(全新 session)`);
171
+ }
172
+ /**
173
+ * 获取所有活跃的 Franky 实例状态
174
+ */
175
+ status() {
176
+ return [...this.instances.values()].map(i => ({
177
+ topicId: i.topicId,
178
+ instanceId: i.instanceId,
179
+ lastActivityAt: i.lastActivityAt.toISOString(),
180
+ ...(i.suspended ? { suspended: true } : {}),
181
+ }));
182
+ }
183
+ /**
184
+ * 优雅关闭所有实例
185
+ */
186
+ async shutdown() {
187
+ if (this.idleCheckInterval) {
188
+ clearInterval(this.idleCheckInterval);
189
+ this.idleCheckInterval = null;
190
+ }
191
+ const topicIds = [...this.instances.keys()];
192
+ for (const topicId of topicIds) {
193
+ await this.cleanupInstance(topicId);
194
+ }
195
+ }
196
+ // ── 私有方法 ──
197
+ /**
198
+ * Resume 挂起的 Franky 实例(复用之前的 CC session)
199
+ * 失败时自动 fallback 到全新 spawn
200
+ */
201
+ async resumeForTopic(topicId) {
202
+ const suspended = this.instances.get(topicId);
203
+ const savedSessionId = suspended?.savedCliSessionId;
204
+ if (!savedSessionId) {
205
+ this.logger.info(`[FrankyOrchestrator] 无可用 sessionId,fallback 到全新 spawn (topic: ${topicId})`);
206
+ this.instances.delete(topicId);
207
+ return this.spawnForTopic(topicId);
208
+ }
209
+ const topic = getTopic(this.config.db, topicId);
210
+ if (!topic)
211
+ throw new Error(`话题 ${topicId} 不存在`);
212
+ const instanceId = `franky:${topicId}`;
213
+ const topicWorkDir = topic.workspace_path;
214
+ if (!topicWorkDir || !existsSync(topicWorkDir)) {
215
+ this.logger.info(`[FrankyOrchestrator] 工作区不存在,fallback 到全新 spawn (topic: ${topicId})`);
216
+ this.instances.delete(topicId);
217
+ return this.spawnForTopic(topicId);
218
+ }
219
+ // 重新启动 MCP Server
220
+ const mcpServer = await this.startMcpServer(topicWorkDir, topicId, topic);
221
+ try {
222
+ const logFile = join(topicWorkDir, `cc-backend.log`);
223
+ await this.broker.spawn({
224
+ id: instanceId,
225
+ role: 'franky',
226
+ backendConfig: {
227
+ type: 'claude-code',
228
+ binary: this.config.binary,
229
+ workingDir: topicWorkDir,
230
+ permissionMode: 'bypassPermissions',
231
+ resumeSessionId: savedSessionId,
232
+ mcpServers: [{
233
+ name: 'franky-tools',
234
+ url: mcpServer.url,
235
+ headers: { 'x-anya-internal': 'true' },
236
+ }],
237
+ },
238
+ scope: FRANKY_DEFAULT_SCOPE,
239
+ mcpServer,
240
+ logFile,
241
+ });
242
+ // resume 成功:更新 instance
243
+ const instance = {
244
+ topicId,
245
+ instanceId,
246
+ mcpServer,
247
+ lastActivityAt: new Date(),
248
+ };
249
+ this.instances.set(topicId, instance);
250
+ this.logger.info(`[FrankyOrchestrator] Resume 成功 ${instanceId} (session: ${savedSessionId})`);
251
+ return instance;
252
+ }
253
+ catch (err) {
254
+ this.logger.error(`[FrankyOrchestrator] Resume 失败,fallback 到全新 spawn (topic: ${topicId}):`, err);
255
+ await mcpServer.close().catch(() => { });
256
+ this.instances.delete(topicId);
257
+ return this.spawnForTopic(topicId);
258
+ }
259
+ }
260
+ /**
261
+ * 为指定 Topic 创建 Franky CC 实例
262
+ */
263
+ async spawnForTopic(topicId) {
264
+ const topic = getTopic(this.config.db, topicId);
265
+ if (!topic)
266
+ throw new Error(`话题 ${topicId} 不存在`);
267
+ const instanceId = `franky:${topicId}`;
268
+ // ── 准备工作区 ──
269
+ const hasExistingWorkspace = !!topic.workspace_id;
270
+ let topicWorkDir;
271
+ let repoInfo;
272
+ if (hasExistingWorkspace) {
273
+ // 从 escalate 而来:forkWorkspace 已创建独立 franky 工作区
274
+ const wsRecord = getWorkspaceRecord(this.config.db, topic.workspace_id);
275
+ if (!wsRecord)
276
+ throw new Error(`工作区 ${topic.workspace_id} 不存在`);
277
+ topicWorkDir = wsRecord.path;
278
+ this.logger.info(`[FrankyOrchestrator] 复用已有工作区 ${topicWorkDir} (topic: ${topicId})`);
279
+ }
280
+ else {
281
+ // 通过 WorkspaceManager 创建工作区(clone --local)
282
+ const wsResult = await this.prepareWorkspace(topic);
283
+ topicWorkDir = wsResult.workDir;
284
+ repoInfo = wsResult.repoInfo;
285
+ // 回写 workspace_path 和 branch_name 到 DB
286
+ if (wsResult.branchName) {
287
+ upsertTopic(this.config.db, {
288
+ id: topic.id,
289
+ title: topic.title,
290
+ status: topic.status,
291
+ description: topic.description ?? undefined,
292
+ project_id: topic.project_id ?? undefined,
293
+ thread_id: topic.thread_id ?? undefined,
294
+ root_message_id: topic.root_message_id ?? undefined,
295
+ chat_id: topic.chat_id ?? undefined,
296
+ created_by: topic.created_by ?? undefined,
297
+ workspace_path: topicWorkDir,
298
+ branch_name: wsResult.branchName,
299
+ });
300
+ }
301
+ }
302
+ // 生成/重建 context.md
303
+ const contextPath = join(topicWorkDir, 'context.md');
304
+ const isRebuild = existsSync(contextPath);
305
+ // ── rebuild 模式:优先尝试从 DB 恢复上次 session(系统重启后仍可 resume)──
306
+ if (isRebuild) {
307
+ const lastSession = getCCSessionsByTask(this.config.db, topicId).find(s => !s.ended_at);
308
+ if (lastSession) {
309
+ this.logger.info(`[FrankyOrchestrator] 发现可恢复 session ${lastSession.session_id} (topic: ${topicId})`);
310
+ try {
311
+ const mcpServer = await this.startMcpServer(topicWorkDir, topicId, topic);
312
+ const logFile = join(topicWorkDir, `cc-backend.log`);
313
+ await this.broker.spawn({
314
+ id: instanceId,
315
+ role: 'franky',
316
+ backendConfig: {
317
+ type: 'claude-code',
318
+ binary: this.config.binary,
319
+ workingDir: topicWorkDir,
320
+ permissionMode: 'bypassPermissions',
321
+ resumeSessionId: lastSession.session_id,
322
+ mcpServers: [{
323
+ name: 'franky-tools',
324
+ url: mcpServer.url,
325
+ headers: { 'x-anya-internal': 'true' },
326
+ }],
327
+ },
328
+ scope: FRANKY_DEFAULT_SCOPE,
329
+ mcpServer,
330
+ logFile,
331
+ });
332
+ const instance = { topicId, instanceId, mcpServer, lastActivityAt: new Date() };
333
+ this.instances.set(topicId, instance);
334
+ this.logger.info(`[FrankyOrchestrator] Resume 成功 ${instanceId} (session: ${lastSession.session_id})`);
335
+ return instance;
336
+ }
337
+ catch (err) {
338
+ this.logger.error(`[FrankyOrchestrator] Resume 失败,fallback 到全新 session:`, err);
339
+ // 标记该 session 已结束,避免下次再尝试
340
+ updateCCSessionEnded(this.config.db, lastSession.session_id);
341
+ }
342
+ }
343
+ // resume 失败或无可恢复 session:重建 context.md
344
+ try {
345
+ const { writeTopicContext } = await import('./context-builder.js');
346
+ await writeTopicContext({ db: this.config.db, workspacePath: topicWorkDir, logger: this.logger }, topicId);
347
+ this.logger.info(`[FrankyOrchestrator] 重建 context.md (topic: ${topicId})`);
348
+ }
349
+ catch (err) {
350
+ this.logger.error(`[FrankyOrchestrator] context.md 重建失败,使用现有文件:`, err);
351
+ }
352
+ }
353
+ else if (!hasExistingWorkspace) {
354
+ // 新建模式(非 escalate):生成初始 context(含仓库信息)
355
+ await this.generateInitialContext(topic, contextPath, repoInfo);
356
+ }
357
+ // ── 全新 session 启动 ──
358
+ const mcpServer = await this.startMcpServer(topicWorkDir, topicId, topic);
359
+ // 准备启动 prompt(读取 context.md)
360
+ let startupPrompt;
361
+ try {
362
+ startupPrompt = await readFile(contextPath, 'utf-8');
363
+ }
364
+ catch {
365
+ startupPrompt = `# 话题: ${topic.title}\n\n目标: ${topic.description ?? '(未设定)'}`;
366
+ }
367
+ // 通过 CCBroker 启动实例
368
+ const logFile = join(topicWorkDir, `cc-backend.log`);
369
+ await this.broker.spawn({
370
+ id: instanceId,
371
+ role: 'franky',
372
+ backendConfig: {
373
+ type: 'claude-code',
374
+ binary: this.config.binary,
375
+ workingDir: topicWorkDir,
376
+ permissionMode: 'bypassPermissions',
377
+ mcpServers: [{
378
+ name: 'franky-tools',
379
+ url: mcpServer.url,
380
+ headers: { 'x-anya-internal': 'true' },
381
+ }],
382
+ },
383
+ scope: FRANKY_DEFAULT_SCOPE,
384
+ mcpServer,
385
+ logFile,
386
+ });
387
+ // 发送启动 prompt
388
+ this.broker.sendPrompt(instanceId, startupPrompt);
389
+ const instance = {
390
+ topicId,
391
+ instanceId,
392
+ mcpServer,
393
+ lastActivityAt: new Date(),
394
+ };
395
+ this.instances.set(topicId, instance);
396
+ insertAuditEvent(this.config.db, {
397
+ event_type: 'franky_spawned',
398
+ actor: 'system',
399
+ summary: `启动 Franky 实例: ${topicId}`,
400
+ detail: JSON.stringify({ instanceId, topicId }),
401
+ });
402
+ this.logger.info(`[FrankyOrchestrator] 启动 Franky 实例 ${instanceId} (topic: ${topicId})`);
403
+ return instance;
404
+ }
405
+ /**
406
+ * 通过 WorkspaceManager 创建工作区(clone --local 模式)
407
+ *
408
+ * - 调用 createWorkspace() 创建目录 + 角色文件 + DB 记录
409
+ * - 为每个 repo 调用 setupRepo() 执行 clone --local + 修正 remote + 创建分支
410
+ * - 初始化根目录 git(CC 需要 .git 作为项目边界)
411
+ */
412
+ async prepareWorkspace(topic) {
413
+ const wm = this.config.workspaceManager;
414
+ // 1. 创建工作区(含角色文件 + 共享文档)
415
+ const ws = await wm.createWorkspace({
416
+ role: 'franky',
417
+ ownerType: 'topic',
418
+ ownerId: topic.id,
419
+ projectId: topic.project_id,
420
+ });
421
+ const workDir = ws.path;
422
+ // 2. 如果关联了项目,为每个 repo 做 clone --local
423
+ if (!topic.project_id || !this.config.getProjectConfig) {
424
+ return { workDir };
425
+ }
426
+ let projectConfig;
427
+ try {
428
+ projectConfig = await this.config.getProjectConfig(topic.project_id);
429
+ }
430
+ catch (err) {
431
+ this.logger.error(`[FrankyOrchestrator] 获取项目配置失败 (${topic.project_id}):`, err);
432
+ return { workDir };
433
+ }
434
+ if (projectConfig.mode !== 'project' || !projectConfig.repos?.length) {
435
+ return { workDir };
436
+ }
437
+ const branchName = topic.branch_name ?? `feat/topic-${topic.id}`;
438
+ const lines = ['## 代码工作区', ''];
439
+ for (const repo of projectConfig.repos) {
440
+ const gitUrl = repo.git_url;
441
+ if (!gitUrl) {
442
+ this.logger.error(`[FrankyOrchestrator] 仓库 "${repo.name}" 未配置 git_url,跳过`);
443
+ continue;
444
+ }
445
+ try {
446
+ await wm.setupRepo(ws.id, {
447
+ repoName: repo.name,
448
+ gitUrl,
449
+ baseBranch: repo.default_branch ?? 'main',
450
+ taskBranch: branchName,
451
+ remoteUrl: gitUrl,
452
+ });
453
+ lines.push(`- \`${repo.name}/\` → 分支 \`${branchName}\`,路径 \`${join(workDir, repo.name)}\``);
454
+ this.logger.info(`[FrankyOrchestrator] 仓库已设置: ${repo.name} @ ${branchName}`);
455
+ }
456
+ catch (err) {
457
+ const msg = err instanceof Error ? err.message : String(err);
458
+ this.logger.error(`[FrankyOrchestrator] 仓库 clone 失败 (${repo.name}): ${msg}`);
459
+ return { workDir };
460
+ }
461
+ }
462
+ // 3. git init 根目录(CC 需要 .git 作为项目边界)
463
+ try {
464
+ await execFile('git', ['init', workDir]);
465
+ const ignoreContent = projectConfig.repos.map(r => `/${r.name}/`).join('\n') + '\n';
466
+ await writeFile(join(workDir, '.gitignore'), ignoreContent, 'utf-8');
467
+ }
468
+ catch (err) {
469
+ this.logger.error(`[FrankyOrchestrator] git init 失败:`, err);
470
+ }
471
+ lines.push('');
472
+ lines.push('> 代码在上述子目录中(独立 clone),git 操作请进入对应子目录执行。');
473
+ return { workDir, branchName, repoInfo: lines.join('\n') };
474
+ }
475
+ /**
476
+ * 启动 Franky MCP Server
477
+ */
478
+ async startMcpServer(topicWorkDir, topicId, topic) {
479
+ const mcpDeps = {
480
+ db: this.config.db,
481
+ workspacePath: topicWorkDir,
482
+ topicId,
483
+ replyToThreadId: topic.thread_id ?? undefined,
484
+ channelRegistry: this.config.channelRegistry,
485
+ onMessageSent: this.config.onMessageSent,
486
+ onTopicClosed: (tid) => this.closeTopic(tid),
487
+ onTopicEscalated: this.config.onTopicEscalated,
488
+ logger: this.logger,
489
+ };
490
+ return startFrankyMcpServer(mcpDeps);
491
+ }
492
+ /**
493
+ * 生成初始 context.md
494
+ */
495
+ async generateInitialContext(topic, contextPath, repoInfo) {
496
+ const lines = [
497
+ `# 话题上下文:${topic.title} (${topic.id})`,
498
+ '',
499
+ '## 目标',
500
+ topic.description ?? '(用户未设定详细描述)',
501
+ '',
502
+ '## 通讯信息',
503
+ ...(topic.chat_id ? [`- 群聊 ID:\`${topic.chat_id}\``] : []),
504
+ ...(topic.root_message_id ? [`- 话题根消息 ID:\`${topic.root_message_id}\``] : []),
505
+ ...(topic.thread_id ? [`- 话题 ID:\`${topic.thread_id}\`(仅供参考,channel.send 的 reply_to 请用根消息 ID)`] : []),
506
+ '',
507
+ '## 项目信息',
508
+ `- 话题 ID:${topic.id}`,
509
+ ...(topic.project_id ? [`- 关联项目:${topic.project_id}`] : []),
510
+ ...(topic.branch_name ? [`- 分支:${topic.branch_name}`] : []),
511
+ '',
512
+ ];
513
+ if (repoInfo) {
514
+ lines.push(repoInfo);
515
+ lines.push('');
516
+ }
517
+ await writeFile(contextPath, lines.join('\n'), 'utf-8');
518
+ }
519
+ /**
520
+ * 格式化用户消息为 CC prompt(包含结构化元信息)
521
+ */
522
+ formatUserMessage(message) {
523
+ const lines = [];
524
+ // 消息事件
525
+ const event = {
526
+ type: 'user_message',
527
+ time: message.metadata?.event_create_time ?? new Date().toISOString(),
528
+ sender_id: message.sender ?? '未知',
529
+ sender_name: message.senderName ?? null,
530
+ content: message.content,
531
+ };
532
+ if (message.metadata?.message_id) {
533
+ event.message_id = message.metadata.message_id;
534
+ }
535
+ if (message.metadata?.parent_message_id) {
536
+ event.reply_to = message.metadata.parent_message_id;
537
+ }
538
+ if (message.media.length > 0) {
539
+ event.media = message.media.map(m => ({
540
+ type: m.mediaType,
541
+ name: m.originalName,
542
+ path: m.localPath,
543
+ }));
544
+ }
545
+ lines.push(JSON.stringify(event, null, 2));
546
+ if (message.media.length > 0) {
547
+ lines.push('');
548
+ lines.push('提示: 本条消息包含媒体附件,你可以通过 media.path 中的本地路径读取文件内容(图片可直接查看,文件可读取文本)。');
549
+ }
550
+ return lines.join('\n');
551
+ }
552
+ /**
553
+ * 检查并回收空闲实例(挂起而非销毁,保留 resume 能力)
554
+ */
555
+ async checkIdleInstances() {
556
+ const now = Date.now();
557
+ for (const [topicId, instance] of this.instances) {
558
+ if (instance.suspended)
559
+ continue;
560
+ const idle = now - instance.lastActivityAt.getTime();
561
+ if (idle > this.idleTimeoutMs) {
562
+ this.logger.info(`[FrankyOrchestrator] 挂起空闲实例 ${instance.instanceId} (idle: ${Math.round(idle / 60000)}min)`);
563
+ await this.suspendInstance(topicId);
564
+ }
565
+ }
566
+ }
567
+ /**
568
+ * 挂起实例:释放进程资源但保留 sessionId 以便 resume
569
+ */
570
+ async suspendInstance(topicId) {
571
+ const instance = this.instances.get(topicId);
572
+ if (!instance || instance.suspended)
573
+ return;
574
+ // 从 broker 获取 cliSessionId 并保存
575
+ const brokerInstance = this.broker.getInstance(instance.instanceId);
576
+ if (brokerInstance?.cliSessionId) {
577
+ instance.savedCliSessionId = brokerInstance.cliSessionId;
578
+ updateCCSessionEnded(this.config.db, brokerInstance.cliSessionId);
579
+ this.logger.info(`[FrankyOrchestrator] 保存 sessionId ${brokerInstance.cliSessionId} (topic: ${topicId})`);
580
+ }
581
+ try {
582
+ await instance.mcpServer.close();
583
+ }
584
+ catch (err) {
585
+ this.logger.error(`[FrankyOrchestrator] MCP server 关闭失败:`, err);
586
+ }
587
+ try {
588
+ await this.broker.dispose(instance.instanceId);
589
+ }
590
+ catch {
591
+ // 可能已经退出
592
+ }
593
+ instance.suspended = true;
594
+ }
595
+ /**
596
+ * 完全清理实例(不保留 resume 信息)
597
+ */
598
+ async cleanupInstance(topicId) {
599
+ const instance = this.instances.get(topicId);
600
+ if (!instance)
601
+ return;
602
+ if (!instance.suspended) {
603
+ try {
604
+ await instance.mcpServer.close();
605
+ }
606
+ catch (err) {
607
+ this.logger.error(`[FrankyOrchestrator] MCP server 关闭失败:`, err);
608
+ }
609
+ try {
610
+ await this.broker.dispose(instance.instanceId);
611
+ }
612
+ catch {
613
+ // 可能已经退出
614
+ }
615
+ }
616
+ this.instances.delete(topicId);
617
+ }
618
+ /**
619
+ * 通过 instanceId 反查 topicId
620
+ */
621
+ findTopicByInstanceId(instanceId) {
622
+ for (const [topicId, instance] of this.instances) {
623
+ if (instance.instanceId === instanceId)
624
+ return topicId;
625
+ }
626
+ return undefined;
627
+ }
628
+ }
629
+ //# sourceMappingURL=franky-orchestrator.js.map
@@ -0,0 +1,5 @@
1
+ export { FrankyOrchestrator } from './franky-orchestrator.js';
2
+ export { startFrankyMcpServer } from './franky-mcp-server.js';
3
+ export { FrankyTopicRouter } from './topic-router.js';
4
+ export { buildTopicContext, writeTopicContext } from './context-builder.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * TopicRouter 实现:将消息路由给对应 Topic 的 Franky 实例
3
+ */
4
+ export class FrankyTopicRouter {
5
+ orchestrator;
6
+ logger;
7
+ constructor(orchestrator, logger) {
8
+ this.orchestrator = orchestrator;
9
+ this.logger = logger ?? { info: console.log, error: console.error };
10
+ }
11
+ async routeToTopic(topicId, msg) {
12
+ this.logger.info(`[TopicRouter] 路由消息到话题 ${topicId}: ${msg.sender}`);
13
+ await this.orchestrator.sendMessage(topicId, msg);
14
+ }
15
+ }
16
+ //# sourceMappingURL=topic-router.js.map