team-anya-cli 0.1.4 → 0.1.6

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 (46) 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 +66 -2
  8. package/apps/server/dist/gateway/feishu-ws.js +5 -4
  9. package/apps/server/dist/gateway/message-intake.js +21 -4
  10. package/apps/server/dist/loid/brain.js +1 -0
  11. package/apps/server/dist/loid/mcp-server.js +1 -0
  12. package/apps/server/dist/loid/session-manager.js +95 -11
  13. package/apps/server/dist/main.js +58 -3
  14. package/apps/web/dist/assets/index-BiiEB0qZ.css +1 -0
  15. package/apps/web/dist/assets/{index-CJzAjoVH.js → index-D1AK5ZEE.js} +189 -189
  16. package/apps/web/dist/index.html +2 -2
  17. package/package.json +1 -1
  18. package/packages/core/dist/office-init.js +4 -0
  19. package/packages/core/dist/scope/defaults.js +15 -0
  20. package/packages/core/dist/scope/index.js +1 -1
  21. package/packages/db/dist/index.js +95 -7
  22. package/packages/db/dist/schema/cc-sessions.js +1 -1
  23. package/packages/db/dist/schema/index.js +1 -0
  24. package/packages/db/dist/schema/tasks.js +2 -0
  25. package/packages/db/dist/schema/topics.js +20 -0
  26. package/packages/db/src/migrations/0005_lethal_golden_guardian.sql +5 -0
  27. package/packages/db/src/migrations/0006_add_topics.sql +21 -0
  28. package/packages/db/src/migrations/0007_add_topic_root_message_id.sql +1 -0
  29. package/packages/db/src/migrations/meta/0005_snapshot.json +1513 -0
  30. package/packages/db/src/migrations/meta/0006_snapshot.json +1513 -0
  31. package/packages/db/src/migrations/meta/_journal.json +21 -0
  32. package/packages/mcp-tools/dist/layer2/franky/topic-checkpoint.js +43 -0
  33. package/packages/mcp-tools/dist/layer2/franky/topic-escalate.js +19 -0
  34. package/packages/mcp-tools/dist/layer2/loid/topic-close.js +22 -0
  35. package/packages/mcp-tools/dist/layer2/loid/topic-create.js +56 -0
  36. package/packages/mcp-tools/dist/layer3/adapters/feishu-adapter.js +1 -0
  37. package/packages/mcp-tools/dist/layer3/channel-send.js +1 -0
  38. package/packages/mcp-tools/dist/registry.js +105 -17
  39. package/workspace/CHARTER.md +7 -4
  40. package/workspace/CLAUDE.md +18 -9
  41. package/workspace/PROTOCOL.md +35 -1
  42. package/workspace/TOOLS.md +6 -0
  43. package/workspace/franky/CLAUDE.md +37 -0
  44. package/workspace/franky/PLAYBOOK.md +215 -0
  45. package/workspace/franky/PROFILE.md +80 -0
  46. package/apps/web/dist/assets/index-CHIT0Dya.css +0 -1
@@ -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)
@@ -265,13 +285,39 @@ export class FeishuSender {
265
285
  return response.data?.message_id ?? 'unknown';
266
286
  }
267
287
  // ── 表情回复(Reaction)──
288
+ /** "处理中"指示 emoji,收到消息时添加,回复后移除 */
289
+ static TYPING_EMOJI = 'OneSecond';
290
+ /** 待清除的 typing reaction,key 为 chatId */
291
+ pendingTypingReactions = new Map();
292
+ /**
293
+ * 给消息添加"处理中"表情,并记录到 pending 映射
294
+ *
295
+ * 收到消息后立即调用,大模型回复或出错时通过 clearTypingReaction 移除。
296
+ */
297
+ async addTypingReaction(chatId, messageId) {
298
+ const reactionId = await this.addReaction(messageId, FeishuSender.TYPING_EMOJI);
299
+ if (reactionId) {
300
+ this.pendingTypingReactions.set(chatId, { messageId, reactionId });
301
+ }
302
+ }
303
+ /**
304
+ * 清除指定群聊的"处理中"表情
305
+ *
306
+ * 在大模型回复、冷启动回复、或出错回复时调用。
307
+ */
308
+ async clearTypingReaction(chatId) {
309
+ const pending = this.pendingTypingReactions.get(chatId);
310
+ if (!pending)
311
+ return;
312
+ this.pendingTypingReactions.delete(chatId);
313
+ await this.deleteReaction(pending.messageId, pending.reactionId);
314
+ }
268
315
  /**
269
316
  * 给消息添加表情回复
270
317
  *
271
- * 用于收到用户消息后立即反馈"已收到",无需等待 Agent 处理完成。
272
318
  * 调用飞书 POST /im/v1/messages/{message_id}/reactions
273
319
  */
274
- async addReaction(messageId, emojiType = 'OnIt') {
320
+ async addReaction(messageId, emojiType) {
275
321
  try {
276
322
  const response = await this.client.im.messageReaction.create({
277
323
  path: { message_id: messageId },
@@ -290,6 +336,24 @@ export class FeishuSender {
290
336
  return null;
291
337
  }
292
338
  }
339
+ /**
340
+ * 删除消息上的表情回复
341
+ *
342
+ * 调用飞书 DELETE /im/v1/messages/{message_id}/reactions/{reaction_id}
343
+ */
344
+ async deleteReaction(messageId, reactionId) {
345
+ try {
346
+ const response = await this.client.im.messageReaction.delete({
347
+ path: { message_id: messageId, reaction_id: reactionId },
348
+ });
349
+ if (response.code !== 0) {
350
+ console.warn(`[feishu-sender] 删除表情回复失败: ${response.msg || `code ${response.code}`}`);
351
+ }
352
+ }
353
+ catch (err) {
354
+ console.warn('[feishu-sender] 删除表情回复异常:', err);
355
+ }
356
+ }
293
357
  // ── 出站场景 ──
294
358
  async sendBlockedAlert(params) {
295
359
  // 用自然语言,不用制式模板
@@ -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
  }
@@ -181,10 +182,10 @@ export class FeishuWSClient {
181
182
  return;
182
183
  const eventId = event.header?.event_id;
183
184
  const message = await parseFeishuMessage(event, eventId, this.mediaDownloader);
184
- // 立即添加表情回复(fire-and-forget,不阻断消息处理)
185
- if (this.feishuSender && event.message.message_id) {
186
- this.feishuSender.addReaction(event.message.message_id, 'OnIt').catch(err => {
187
- this.log.warn({ err }, '表情回复失败');
185
+ // 立即添加"处理中"表情(fire-and-forget,大模型回复后会自动移除)
186
+ if (this.feishuSender && event.message.message_id && event.message.chat_id) {
187
+ this.feishuSender.addTypingReaction(event.message.chat_id, event.message.message_id).catch(err => {
188
+ this.log.warn({ err }, 'typing 表情添加失败');
188
189
  });
189
190
  }
190
191
  // 解析发送者名称(异步,不阻断消息处理)
@@ -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
  }
@@ -45,6 +45,7 @@ export class LoidBrain {
45
45
  logger: this.config.logger,
46
46
  }, {
47
47
  db: this.deps.mcpDeps.db,
48
+ feishuSender: this.deps.feishuSender,
48
49
  });
49
50
  this.ready = true;
50
51
  this.config.logger?.info('LoidBrain 已初始化 (Session 模式)');
@@ -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,