team-anya-cli 0.1.5 → 0.1.7

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 (48) hide show
  1. package/apps/server/dist/broker/cc-broker.js +5 -1
  2. package/apps/server/dist/franky/context-builder.js +160 -0
  3. package/apps/server/dist/franky/franky-mcp-server.js +107 -0
  4. package/apps/server/dist/franky/franky-orchestrator.js +450 -0
  5. package/apps/server/dist/franky/index.js +5 -0
  6. package/apps/server/dist/franky/topic-router.js +16 -0
  7. package/apps/server/dist/gateway/feishu-sender.js +20 -0
  8. package/apps/server/dist/gateway/feishu-ws.js +1 -0
  9. package/apps/server/dist/gateway/message-intake.js +21 -4
  10. package/apps/server/dist/loid/mcp-server.js +1 -0
  11. package/apps/server/dist/loid/session-manager.js +0 -31
  12. package/apps/server/dist/main.js +39 -3
  13. package/apps/web/dist/assets/{index-DT5NuALG.js → index-D1AK5ZEE.js} +1 -1
  14. package/apps/web/dist/index.html +1 -1
  15. package/package.json +1 -1
  16. package/packages/core/dist/office-init.js +4 -0
  17. package/packages/core/dist/scope/defaults.js +15 -0
  18. package/packages/core/dist/scope/index.js +1 -1
  19. package/packages/db/dist/index.js +68 -1
  20. package/packages/db/dist/schema/index.js +1 -0
  21. package/packages/db/dist/schema/tasks.js +2 -0
  22. package/packages/db/dist/schema/topics.js +20 -0
  23. package/packages/db/src/migrations/{0000_simple_magneto.sql → 0000_baseline.sql} +158 -55
  24. package/packages/db/src/migrations/meta/_journal.json +4 -39
  25. package/packages/mcp-tools/dist/layer2/franky/topic-checkpoint.js +43 -0
  26. package/packages/mcp-tools/dist/layer2/franky/topic-escalate.js +19 -0
  27. package/packages/mcp-tools/dist/layer2/loid/topic-close.js +22 -0
  28. package/packages/mcp-tools/dist/layer2/loid/topic-create.js +56 -0
  29. package/packages/mcp-tools/dist/layer3/adapters/feishu-adapter.js +1 -0
  30. package/packages/mcp-tools/dist/layer3/channel-send.js +1 -0
  31. package/packages/mcp-tools/dist/registry.js +105 -17
  32. package/workspace/CHARTER.md +7 -4
  33. package/workspace/CLAUDE.md +18 -9
  34. package/workspace/PROTOCOL.md +35 -1
  35. package/workspace/TOOLS.md +6 -0
  36. package/workspace/franky/CLAUDE.md +37 -0
  37. package/workspace/franky/PLAYBOOK.md +215 -0
  38. package/workspace/franky/PROFILE.md +80 -0
  39. package/packages/db/src/migrations/0001_nifty_morph.sql +0 -42
  40. package/packages/db/src/migrations/0002_common_joshua_kane.sql +0 -20
  41. package/packages/db/src/migrations/0003_add_cc_sessions.sql +0 -13
  42. package/packages/db/src/migrations/0004_jittery_triathlon.sql +0 -1
  43. package/packages/db/src/migrations/0005_lethal_golden_guardian.sql +0 -5
  44. package/packages/db/src/migrations/meta/0000_snapshot.json +0 -987
  45. package/packages/db/src/migrations/meta/0001_snapshot.json +0 -1280
  46. package/packages/db/src/migrations/meta/0002_snapshot.json +0 -1417
  47. package/packages/db/src/migrations/meta/0004_snapshot.json +0 -1505
  48. package/packages/db/src/migrations/meta/0005_snapshot.json +0 -1513
@@ -0,0 +1,450 @@
1
+ import { readFile, mkdir, writeFile, copyFile } 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, insertAuditEvent, insertCCSession, updateCCSessionEnded, 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
+ });
96
+ }
97
+ /**
98
+ * 向专项发送消息。如果 Franky 实例不存在,自动创建。
99
+ */
100
+ async sendMessage(topicId, message) {
101
+ let instance = this.instances.get(topicId);
102
+ if (!instance) {
103
+ // 防止并发 spawn:多条消息同时到达时共享同一个 spawn promise
104
+ let pending = this.spawning.get(topicId);
105
+ if (!pending) {
106
+ pending = this.spawnForTopic(topicId);
107
+ this.spawning.set(topicId, pending);
108
+ try {
109
+ instance = await pending;
110
+ }
111
+ finally {
112
+ this.spawning.delete(topicId);
113
+ }
114
+ }
115
+ else {
116
+ instance = await pending;
117
+ }
118
+ }
119
+ // 格式化用户消息为 prompt
120
+ const prompt = this.formatUserMessage(message);
121
+ // 注入消息到 CC 实例
122
+ try {
123
+ this.broker.sendPrompt(instance.instanceId, prompt);
124
+ instance.lastActivityAt = new Date();
125
+ }
126
+ catch (err) {
127
+ this.logger.error(`[FrankyOrchestrator] 发送消息失败 (topic: ${topicId}):`, err);
128
+ // 实例可能已死,清理后重试一次
129
+ this.cleanupInstance(topicId);
130
+ const newInstance = await this.spawnForTopic(topicId);
131
+ this.broker.sendPrompt(newInstance.instanceId, prompt);
132
+ }
133
+ }
134
+ /**
135
+ * 关闭指定 Topic 的 Franky 实例
136
+ */
137
+ async closeTopic(topicId) {
138
+ await this.cleanupInstance(topicId);
139
+ this.logger.info(`[FrankyOrchestrator] 关闭专项 ${topicId} 的 Franky 实例`);
140
+ }
141
+ /**
142
+ * 获取所有活跃的 Franky 实例状态
143
+ */
144
+ status() {
145
+ return [...this.instances.values()].map(i => ({
146
+ topicId: i.topicId,
147
+ instanceId: i.instanceId,
148
+ lastActivityAt: i.lastActivityAt.toISOString(),
149
+ }));
150
+ }
151
+ /**
152
+ * 优雅关闭所有实例
153
+ */
154
+ async shutdown() {
155
+ if (this.idleCheckInterval) {
156
+ clearInterval(this.idleCheckInterval);
157
+ this.idleCheckInterval = null;
158
+ }
159
+ const topicIds = [...this.instances.keys()];
160
+ for (const topicId of topicIds) {
161
+ await this.cleanupInstance(topicId);
162
+ }
163
+ }
164
+ // ── 私有方法 ──
165
+ /**
166
+ * 为指定 Topic 创建 Franky CC 实例
167
+ */
168
+ async spawnForTopic(topicId) {
169
+ const topic = getTopic(this.config.db, topicId);
170
+ if (!topic)
171
+ throw new Error(`专项 ${topicId} 不存在`);
172
+ const instanceId = `franky:${topicId}`;
173
+ const frankyRoot = join(this.config.workspacePath, 'franky');
174
+ const topicWorkDir = join(frankyRoot, 'topics', topicId);
175
+ // 确保 topic 目录存在
176
+ await mkdir(topicWorkDir, { recursive: true });
177
+ // 准备 git worktree(如果关联了项目)
178
+ const worktreeResult = await this.prepareWorktrees(topic, topicWorkDir);
179
+ // 复制启动文件到 topic 目录(CLAUDE.md 的相对路径基于 topicWorkDir)
180
+ await this.prepareTopicWorkspace(frankyRoot, topicWorkDir);
181
+ // 生成/重建 context.md
182
+ const contextPath = join(topicWorkDir, 'context.md');
183
+ const isRebuild = existsSync(contextPath);
184
+ if (isRebuild) {
185
+ // 重建模式:从 DB + checkpoints + message_log 重建上下文
186
+ try {
187
+ const { writeTopicContext } = await import('./context-builder.js');
188
+ await writeTopicContext({ db: this.config.db, workspacePath: this.config.workspacePath, logger: this.logger }, topicId);
189
+ this.logger.info(`[FrankyOrchestrator] 重建 context.md (topic: ${topicId})`);
190
+ }
191
+ catch (err) {
192
+ this.logger.error(`[FrankyOrchestrator] context.md 重建失败,使用现有文件:`, err);
193
+ }
194
+ }
195
+ else {
196
+ // 新建模式:生成初始 context(含 worktree 信息)
197
+ await this.generateInitialContext(topic, contextPath, worktreeResult?.worktreeInfo);
198
+ }
199
+ // 如果创建了 worktree,回写 workspace_path 和 branch_name 到 DB
200
+ if (worktreeResult) {
201
+ upsertTopic(this.config.db, {
202
+ id: topic.id,
203
+ title: topic.title,
204
+ status: topic.status,
205
+ description: topic.description ?? undefined,
206
+ project_id: topic.project_id ?? undefined,
207
+ thread_id: topic.thread_id ?? undefined,
208
+ root_message_id: topic.root_message_id ?? undefined,
209
+ chat_id: topic.chat_id ?? undefined,
210
+ created_by: topic.created_by ?? undefined,
211
+ workspace_path: topicWorkDir,
212
+ branch_name: worktreeResult.branchName,
213
+ });
214
+ }
215
+ // 启动 Franky MCP Server
216
+ const mcpDeps = {
217
+ db: this.config.db,
218
+ workspacePath: topicWorkDir,
219
+ topicId,
220
+ replyToThreadId: topic.thread_id ?? undefined,
221
+ channelRegistry: this.config.channelRegistry,
222
+ onMessageSent: this.config.onMessageSent,
223
+ onTopicClosed: (tid) => this.closeTopic(tid),
224
+ onTopicEscalated: this.config.onTopicEscalated,
225
+ logger: this.logger,
226
+ };
227
+ const mcpServer = await startFrankyMcpServer(mcpDeps);
228
+ // 准备启动 prompt(读取 context.md)
229
+ let startupPrompt;
230
+ try {
231
+ startupPrompt = await readFile(contextPath, 'utf-8');
232
+ }
233
+ catch {
234
+ startupPrompt = `# 专项: ${topic.title}\n\n目标: ${topic.description ?? '(未设定)'}`;
235
+ }
236
+ // 通过 CCBroker 启动实例
237
+ const logFile = join(topicWorkDir, `cc-backend.log`);
238
+ await this.broker.spawn({
239
+ id: instanceId,
240
+ role: 'franky',
241
+ backendConfig: {
242
+ type: 'claude-code',
243
+ binary: this.config.binary,
244
+ workingDir: topicWorkDir,
245
+ permissionMode: 'bypassPermissions',
246
+ mcpServers: [{ name: 'franky-tools', url: mcpServer.url }],
247
+ },
248
+ scope: FRANKY_DEFAULT_SCOPE,
249
+ mcpServer,
250
+ logFile,
251
+ });
252
+ // 发送启动 prompt
253
+ this.broker.sendPrompt(instanceId, startupPrompt);
254
+ const instance = {
255
+ topicId,
256
+ instanceId,
257
+ mcpServer,
258
+ lastActivityAt: new Date(),
259
+ };
260
+ this.instances.set(topicId, instance);
261
+ insertAuditEvent(this.config.db, {
262
+ event_type: 'franky_spawned',
263
+ actor: 'system',
264
+ summary: `启动 Franky 实例: ${topicId}`,
265
+ detail: JSON.stringify({ instanceId, topicId }),
266
+ });
267
+ this.logger.info(`[FrankyOrchestrator] 启动 Franky 实例 ${instanceId} (topic: ${topicId})`);
268
+ return instance;
269
+ }
270
+ /**
271
+ * 准备 git worktree(如果 topic 关联了项目)
272
+ *
273
+ * 与 Yor 的 task-dispatch 类似,但额外处理 respawn 场景:
274
+ * - worktree 目录已存在时跳过创建(复用)
275
+ * - 分支已存在时不带 -b 参数附加
276
+ * - 创建失败时 graceful degradation(不阻塞 spawn)
277
+ */
278
+ async prepareWorktrees(topic, topicWorkDir) {
279
+ if (!topic.project_id || !this.config.getProjectConfig)
280
+ return null;
281
+ let projectConfig;
282
+ try {
283
+ projectConfig = await this.config.getProjectConfig(topic.project_id);
284
+ }
285
+ catch (err) {
286
+ this.logger.error(`[FrankyOrchestrator] 获取项目配置失败 (${topic.project_id}):`, err);
287
+ return null;
288
+ }
289
+ if (projectConfig.mode !== 'project' || !projectConfig.repos?.length)
290
+ return null;
291
+ const branchName = topic.branch_name ?? `feat/topic-${topic.id}`;
292
+ const lines = ['## 代码工作区', ''];
293
+ const createdWorktrees = [];
294
+ for (const repo of projectConfig.repos) {
295
+ const wtPath = join(topicWorkDir, repo.name);
296
+ const defaultBranch = repo.default_branch ?? 'main';
297
+ if (existsSync(wtPath)) {
298
+ this.logger.info(`[FrankyOrchestrator] worktree 已存在,复用: ${wtPath}`);
299
+ }
300
+ else {
301
+ try {
302
+ await execFile('git', ['-C', repo.repo_path, 'fetch', 'origin']);
303
+ // 检查分支是否已存在(之前创建后被手动清理 worktree 的场景)
304
+ let branchExists = false;
305
+ try {
306
+ const { stdout } = await execFile('git', ['-C', repo.repo_path, 'branch', '--list', branchName]);
307
+ branchExists = stdout.trim().length > 0;
308
+ }
309
+ catch { /* ignore */ }
310
+ if (branchExists) {
311
+ await execFile('git', ['-C', repo.repo_path, 'worktree', 'add', wtPath, branchName]);
312
+ }
313
+ else {
314
+ await execFile('git', ['-C', repo.repo_path, 'worktree', 'add', wtPath, '-b', branchName, `origin/${defaultBranch}`]);
315
+ }
316
+ createdWorktrees.push({ repoPath: repo.repo_path, wtPath });
317
+ this.logger.info(`[FrankyOrchestrator] worktree 已创建: ${wtPath} (branch: ${branchName})`);
318
+ }
319
+ catch (err) {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ this.logger.error(`[FrankyOrchestrator] worktree 创建失败 (${repo.name}): ${msg}`);
322
+ // 回滚已创建的 worktree
323
+ for (const wt of createdWorktrees) {
324
+ try {
325
+ await execFile('git', ['-C', wt.repoPath, 'worktree', 'remove', wt.wtPath, '--force']);
326
+ }
327
+ catch { /* ignore rollback failure */ }
328
+ }
329
+ return null;
330
+ }
331
+ }
332
+ lines.push(`- \`${repo.name}/\` → 分支 \`${branchName}\`,路径 \`${wtPath}\``);
333
+ }
334
+ lines.push('');
335
+ lines.push('> 代码在上述 worktree 子目录中,git 操作请进入对应子目录执行。');
336
+ return { branchName, worktreeInfo: lines.join('\n') };
337
+ }
338
+ /**
339
+ * 准备 topic 工作目录:复制启动文件,使 CLAUDE.md 的相对路径正确解析
340
+ *
341
+ * 目录结构:
342
+ * topics/TOPIC-xxx/ ← workingDir(CC 实例在此启动)
343
+ * ├── CLAUDE.md ← 复制自 franky/CLAUDE.md
344
+ * ├── PROFILE.md ← 复制自 franky/PROFILE.md
345
+ * ├── PLAYBOOK.md ← 复制自 franky/PLAYBOOK.md
346
+ * ├── ../CHARTER.md ← topics/CHARTER.md(复制自 office/CHARTER.md)
347
+ * ├── ../PROTOCOL.md ← topics/PROTOCOL.md
348
+ * ├── ../TOOLS.md ← topics/TOOLS.md
349
+ * └── context.md ← 系统生成
350
+ */
351
+ async prepareTopicWorkspace(frankyRoot, topicWorkDir) {
352
+ const officeRoot = this.config.workspacePath;
353
+ const topicsDir = join(frankyRoot, 'topics');
354
+ // CLAUDE.md 中 `../CHARTER.md` 从 topic dir 解析到 topics/ 目录
355
+ const parentFiles = ['CHARTER.md', 'PROTOCOL.md', 'TOOLS.md'];
356
+ for (const file of parentFiles) {
357
+ const src = join(officeRoot, file);
358
+ const dest = join(topicsDir, file);
359
+ if (existsSync(src))
360
+ await copyFile(src, dest);
361
+ }
362
+ // CLAUDE.md 中 `PROFILE.md` / `PLAYBOOK.md` 在 topic dir 同级
363
+ const localFiles = ['CLAUDE.md', 'PROFILE.md', 'PLAYBOOK.md'];
364
+ for (const file of localFiles) {
365
+ const src = join(frankyRoot, file);
366
+ const dest = join(topicWorkDir, file);
367
+ if (existsSync(src))
368
+ await copyFile(src, dest);
369
+ }
370
+ }
371
+ /**
372
+ * 生成初始 context.md
373
+ */
374
+ async generateInitialContext(topic, contextPath, worktreeInfo) {
375
+ const lines = [
376
+ `# 专项上下文:${topic.title} (${topic.id})`,
377
+ '',
378
+ '## 目标',
379
+ topic.description ?? '(用户未设定详细描述)',
380
+ '',
381
+ '## 通讯信息',
382
+ ...(topic.chat_id ? [`- 群聊 ID:\`${topic.chat_id}\``] : []),
383
+ ...(topic.root_message_id ? [`- 话题根消息 ID:\`${topic.root_message_id}\``] : []),
384
+ ...(topic.thread_id ? [`- 话题 ID:\`${topic.thread_id}\`(仅供参考,channel.send 的 reply_to 请用根消息 ID)`] : []),
385
+ '',
386
+ '## 项目信息',
387
+ `- 专项 ID:${topic.id}`,
388
+ ...(topic.project_id ? [`- 关联项目:${topic.project_id}`] : []),
389
+ ...(topic.branch_name ? [`- 分支:${topic.branch_name}`] : []),
390
+ '',
391
+ ];
392
+ if (worktreeInfo) {
393
+ lines.push(worktreeInfo);
394
+ lines.push('');
395
+ }
396
+ await writeFile(contextPath, lines.join('\n'), 'utf-8');
397
+ }
398
+ /**
399
+ * 格式化用户消息为 CC prompt
400
+ */
401
+ formatUserMessage(message) {
402
+ const sender = message.senderName ?? message.sender ?? '用户';
403
+ return `[${sender}]: ${message.content}`;
404
+ }
405
+ /**
406
+ * 检查并回收空闲实例
407
+ */
408
+ async checkIdleInstances() {
409
+ const now = Date.now();
410
+ for (const [topicId, instance] of this.instances) {
411
+ const idle = now - instance.lastActivityAt.getTime();
412
+ if (idle > this.idleTimeoutMs) {
413
+ this.logger.info(`[FrankyOrchestrator] 回收空闲实例 ${instance.instanceId} (idle: ${Math.round(idle / 60000)}min)`);
414
+ await this.cleanupInstance(topicId);
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * 清理指定 Topic 的 Franky 实例
420
+ */
421
+ async cleanupInstance(topicId) {
422
+ const instance = this.instances.get(topicId);
423
+ if (!instance)
424
+ return;
425
+ try {
426
+ await instance.mcpServer.close();
427
+ }
428
+ catch (err) {
429
+ this.logger.error(`[FrankyOrchestrator] MCP server 关闭失败:`, err);
430
+ }
431
+ try {
432
+ await this.broker.dispose(instance.instanceId);
433
+ }
434
+ catch {
435
+ // 可能已经退出
436
+ }
437
+ this.instances.delete(topicId);
438
+ }
439
+ /**
440
+ * 通过 instanceId 反查 topicId
441
+ */
442
+ findTopicByInstanceId(instanceId) {
443
+ for (const [topicId, instance] of this.instances) {
444
+ if (instance.instanceId === instanceId)
445
+ return topicId;
446
+ }
447
+ return undefined;
448
+ }
449
+ }
450
+ //# 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
@@ -135,6 +135,7 @@ export class FeishuSender {
135
135
  data: {
136
136
  content: JSON.stringify({ text: params.text }),
137
137
  msg_type: 'text',
138
+ ...(params.replyInThread ? { reply_in_thread: true } : {}),
138
139
  },
139
140
  });
140
141
  if (response.code !== 0) {
@@ -142,6 +143,25 @@ export class FeishuSender {
142
143
  }
143
144
  return response.data?.message_id ?? 'unknown';
144
145
  }
146
+ /**
147
+ * 获取回复消息的 thread_id(创建话题后从响应中提取)
148
+ */
149
+ async sendReplyInThread(params) {
150
+ const response = await this.client.im.message.reply({
151
+ path: { message_id: params.replyToMessageId },
152
+ data: {
153
+ content: JSON.stringify({ text: params.text }),
154
+ msg_type: 'text',
155
+ reply_in_thread: true,
156
+ },
157
+ });
158
+ if (response.code !== 0) {
159
+ throw new Error(`飞书创建话题失败: ${response.msg || `code ${response.code}`}`);
160
+ }
161
+ const messageId = response.data?.message_id ?? 'unknown';
162
+ const threadId = response.data?.thread_id ?? null;
163
+ return { messageId, threadId };
164
+ }
145
165
  // ── 图片上传与发送 ──
146
166
  /**
147
167
  * 上传图片到飞书(获取 image_key)
@@ -28,6 +28,7 @@ export async function parseFeishuMessage(event, eventId, downloader) {
28
28
  ...(event.header?.create_time ? { event_create_time: event.header.create_time } : {}),
29
29
  ...(msg.parent_id ? { parent_message_id: msg.parent_id } : {}),
30
30
  ...(msg.root_id ? { root_message_id: msg.root_id } : {}),
31
+ ...(msg.thread_id ? { thread_id: msg.thread_id } : {}),
31
32
  },
32
33
  };
33
34
  }
@@ -1,11 +1,11 @@
1
- import { insertMessageLog } from '@team-anya/db';
1
+ import { insertMessageLog, getTopicByThreadId } from '@team-anya/db';
2
2
  export class MessageIntake {
3
3
  deps;
4
4
  constructor(deps) {
5
5
  this.deps = deps;
6
6
  }
7
7
  /**
8
- * 处理新消息:最小检测 + 透传给 Loid
8
+ * 处理新消息:审计记录 命令拦截 → Topic 路由 → Loid
9
9
  */
10
10
  async handle(message) {
11
11
  const logger = this.deps.logger;
@@ -40,9 +40,26 @@ export class MessageIntake {
40
40
  return;
41
41
  }
42
42
  }
43
- // 2. 构建上下文(含产品/项目注册表)
43
+ // 2. Topic 路由分叉(严格 thread_id 匹配,omt_ 前缀)
44
+ if (this.deps.topicRouter) {
45
+ const threadId = message.metadata?.thread_id;
46
+ if (threadId) {
47
+ try {
48
+ const topic = getTopicByThreadId(this.deps.db, threadId);
49
+ if (topic) {
50
+ logger?.info(`[MessageIntake] 路由到专项 ${topic.id}: ${message.sender}@${threadId}`);
51
+ await this.deps.topicRouter.routeToTopic(topic.id, message);
52
+ return;
53
+ }
54
+ }
55
+ catch (err) {
56
+ logger?.error('[MessageIntake] Topic 路由查询失败,降级走 Loid:', err);
57
+ }
58
+ }
59
+ }
60
+ // 3. 构建上下文(含产品/项目注册表)
44
61
  const context = await this.deps.contextBuilder.buildMessageContext(message);
45
- // 3. 透传给 Loid(完全自主判断)
62
+ // 4. 透传给 Loid(完全自主判断)
46
63
  logger?.info(`[MessageIntake] 转发消息给 Loid: ${message.sender}@${message.chatId ?? 'DM'}`);
47
64
  await this.deps.loidBrain.handleNewMessage(context);
48
65
  }
@@ -13,6 +13,7 @@ function createMcpServer(deps) {
13
13
  channelRegistry: deps.channelRegistry,
14
14
  logger: deps.logger,
15
15
  onMessageSent: deps.onMessageSent,
16
+ threadCreator: deps.threadCreator,
16
17
  yorOrchestrator: deps.yorOrchestrator,
17
18
  getProjectConfig: deps.getProjectConfig,
18
19
  onProjectChanged: deps.onProjectChanged,
@@ -9,16 +9,6 @@ export class LoidSessionManager {
9
9
  config;
10
10
  deps;
11
11
  logger;
12
- /** 冷启动时随机回复的话术池 */
13
- static COLD_START_REPLIES = [
14
- '稍等,我先理一下思路',
15
- '好,我想想',
16
- '让我看看',
17
- '收到,稍等一下',
18
- '我先看看上下文',
19
- '好的,等我一下',
20
- ];
21
- lastColdStartIndex = -1;
22
12
  /** 崩溃恢复中的话术池(像同事重启电脑一样自然) */
23
13
  static CRASH_RECOVERY_REPLIES = [
24
14
  '抱歉,刚才脑子卡了一下,我重新捋一下',
@@ -48,11 +38,6 @@ export class LoidSessionManager {
48
38
  } while (idx === lastIndex && pool.length > 1);
49
39
  return { text: pool[idx], index: idx };
50
40
  }
51
- pickColdStartReply() {
52
- const { text, index } = this.pickRandom(LoidSessionManager.COLD_START_REPLIES, this.lastColdStartIndex);
53
- this.lastColdStartIndex = index;
54
- return text;
55
- }
56
41
  pickCrashRecoveryReply() {
57
42
  const { text, index } = this.pickRandom(LoidSessionManager.CRASH_RECOVERY_REPLIES, this.lastCrashRecoveryIndex);
58
43
  this.lastCrashRecoveryIndex = index;
@@ -80,22 +65,6 @@ export class LoidSessionManager {
80
65
  // session 已销毁,删除并新建
81
66
  this.sessions.delete(key);
82
67
  }
83
- // 冷启动:先回复一句话,让用户知道系统在处理
84
- const messageId = ctx.message.metadata?.message_id;
85
- if (this.deps.feishuSender && messageId) {
86
- const reply = this.pickColdStartReply();
87
- this.deps.feishuSender.sendReply({
88
- text: reply,
89
- replyToMessageId: messageId,
90
- }).then(() => {
91
- // 冷启动回复成功后,移除"处理中"表情
92
- this.deps.feishuSender.clearTypingReaction(key).catch(() => { });
93
- }).catch(err => {
94
- this.logger.error('[SessionManager] 冷启动回复失败:', err);
95
- // 即使回复失败也清除 typing reaction
96
- this.deps.feishuSender.clearTypingReaction(key).catch(() => { });
97
- });
98
- }
99
68
  // 新建 session
100
69
  try {
101
70
  const session = await this.createSession(key);