feique 1.3.2 → 1.4.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 (55) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/backend/claude.js +28 -54
  4. package/dist/backend/claude.js.map +1 -1
  5. package/dist/backend/factory.d.ts +28 -0
  6. package/dist/backend/factory.js +61 -0
  7. package/dist/backend/factory.js.map +1 -1
  8. package/dist/backend/probe.d.ts +29 -0
  9. package/dist/backend/probe.js +99 -0
  10. package/dist/backend/probe.js.map +1 -0
  11. package/dist/bridge/admin-config.d.ts +47 -0
  12. package/dist/bridge/admin-config.js +141 -0
  13. package/dist/bridge/admin-config.js.map +1 -0
  14. package/dist/bridge/collab-commands.d.ts +42 -0
  15. package/dist/bridge/collab-commands.js +254 -0
  16. package/dist/bridge/collab-commands.js.map +1 -0
  17. package/dist/bridge/commands.d.ts +1 -1
  18. package/dist/bridge/commands.js +3 -0
  19. package/dist/bridge/commands.js.map +1 -1
  20. package/dist/bridge/feishu-commands.d.ts +27 -0
  21. package/dist/bridge/feishu-commands.js +462 -0
  22. package/dist/bridge/feishu-commands.js.map +1 -0
  23. package/dist/bridge/lifecycle.d.ts +46 -0
  24. package/dist/bridge/lifecycle.js +228 -0
  25. package/dist/bridge/lifecycle.js.map +1 -0
  26. package/dist/bridge/memory-commands.d.ts +26 -0
  27. package/dist/bridge/memory-commands.js +330 -0
  28. package/dist/bridge/memory-commands.js.map +1 -0
  29. package/dist/bridge/reply-builders.d.ts +30 -0
  30. package/dist/bridge/reply-builders.js +72 -0
  31. package/dist/bridge/reply-builders.js.map +1 -0
  32. package/dist/bridge/run-pipeline.d.ts +86 -0
  33. package/dist/bridge/run-pipeline.js +442 -0
  34. package/dist/bridge/run-pipeline.js.map +1 -0
  35. package/dist/bridge/run-scheduler.d.ts +47 -0
  36. package/dist/bridge/run-scheduler.js +121 -0
  37. package/dist/bridge/run-scheduler.js.map +1 -0
  38. package/dist/bridge/service-utils.d.ts +47 -0
  39. package/dist/bridge/service-utils.js +309 -0
  40. package/dist/bridge/service-utils.js.map +1 -0
  41. package/dist/bridge/service.d.ts +114 -66
  42. package/dist/bridge/service.js +225 -2196
  43. package/dist/bridge/service.js.map +1 -1
  44. package/dist/config/load.js +1 -1
  45. package/dist/config/load.js.map +1 -1
  46. package/dist/config/paths.js +1 -20
  47. package/dist/config/paths.js.map +1 -1
  48. package/dist/config/schema.d.ts +3 -0
  49. package/dist/config/schema.js +3 -1
  50. package/dist/config/schema.js.map +1 -1
  51. package/dist/feishu/long-connection.js +1 -0
  52. package/dist/feishu/long-connection.js.map +1 -1
  53. package/dist/feishu/webhook.js +1 -0
  54. package/dist/feishu/webhook.js.map +1 -1
  55. package/package.json +1 -1
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { watch as watchFile } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { randomUUID } from 'node:crypto';
5
4
  import { buildHelpText, buildFullHelpText, isReadOnlyCommand, normalizeIncomingText, parseBridgeCommand, } from './commands.js';
6
5
  import { buildConversationKey } from '../state/session-store.js';
7
6
  import { buildMessageCard, buildStatusCard } from '../feishu/cards.js';
@@ -11,38 +10,34 @@ import { IdempotencyStore } from '../state/idempotency-store.js';
11
10
  import { RunStateStore } from '../state/run-state-store.js';
12
11
  import { isProcessAlive, terminateProcess } from '../runtime/process.js';
13
12
  import { resolveKnowledgeRoots, searchKnowledgeBase } from '../knowledge/search.js';
14
- import { FeishuWikiClient } from '../feishu/wiki.js';
15
- import { FeishuDocClient } from '../feishu/doc.js';
16
- import { FeishuBaseClient } from '../feishu/base.js';
17
- import { FeishuTaskClient } from '../feishu/task.js';
18
13
  import { resolveMessageResources } from '../feishu/message-resource.js';
19
14
  import { MemoryStore } from '../state/memory-store.js';
20
- import { retrieveMemoryContext } from '../memory/retrieve.js';
21
- import { summarizeThreadTurn } from '../memory/summarize.js';
22
15
  import { CodexSessionIndex } from '../codex/session-index.js';
23
16
  import { resolveProjectBackendWithOverride, resolveProjectBackendName } from '../backend/factory.js';
17
+ import { buildQueueKey, isExecutionRunStatus, isVisibleRunStatus, mapRunStatusToPhase, buildMessageDedupeKey, buildCardDedupeKey, truncateExcerpt, friendlyErrorMessage, resolveAdminListTarget, buildConversationKeyForConversation, renderMemorySection, formatAgeFromNow, replaceObject, replaceProjects, } from './service-utils.js';
18
+ import { handleDocCommand as handleDocCommandImpl, handleTaskCommand as handleTaskCommandImpl, handleBaseCommand as handleBaseCommandImpl, handleWikiCommand as handleWikiCommandImpl, } from './feishu-commands.js';
19
+ import { handleMemoryCommand as handleMemoryCommandImpl } from './memory-commands.js';
20
+ import { handleAdminConfigCommand as handleAdminConfigCommandImpl, parseProjectPatch as parseProjectPatchImpl, } from './admin-config.js';
21
+ import { scheduleProjectExecution as scheduleProjectExecutionImpl, buildAcknowledgedRunReply, buildRunStatusSummary, } from './run-scheduler.js';
22
+ import { formatQuotedReply, buildReplyTitle, sanitizeUserVisibleReply, supportsInteractiveCardActions, resolveRunLifecycleReplyMode, buildRunLifecycleCard, } from './reply-builders.js';
23
+ import { executePrompt as executePromptImpl } from './run-pipeline.js';
24
+ import { recoverRuntimeState as recoverRuntimeStateImpl, reloadConfig as reloadConfigImpl, runDigestCycle as runDigestCycleImpl, runMemoryMaintenance as runMemoryMaintenanceImpl, runAuditMaintenance as runAuditMaintenanceImpl, runMaintenanceCycle as runMaintenanceCycleImpl, } from './lifecycle.js';
25
+ import { handleLearnCommand as handleLearnCommandImpl, handleRecallCommand as handleRecallCommandImpl, handleHandoffCommand as handleHandoffCommandImpl, handlePickupCommand as handlePickupCommandImpl, handleReviewCommand as handleReviewCommandImpl, handleApproveCommand as handleApproveCommandImpl, handleRejectCommand as handleRejectCommandImpl, handleInsightsCommand as handleInsightsCommandImpl, handleTrustCommand as handleTrustCommandImpl, handleDigestCommand as handleDigestCommandImpl, handleGapsCommand as handleGapsCommandImpl, handleTimelineCommand as handleTimelineCommandImpl, } from './collab-commands.js';
24
26
  import { bindProjectAlias, createProjectAlias, removeProjectAlias, updateProjectConfig, updateStringList } from '../config/mutate.js';
25
- import { buildFeishuPost, truncateForFeishuCard } from '../feishu/text.js';
27
+ import { buildFeishuPost } from '../feishu/text.js';
26
28
  import { ConfigHistoryStore } from '../state/config-history-store.js';
27
29
  import { loadBridgeConfigFile } from '../config/load.js';
28
- import { writeUtf8Atomic } from '../utils/fs.js';
29
30
  import { expandHomePath } from '../utils/path.js';
30
31
  import { canAccessGlobalCapability, canAccessProject, canAccessProjectCapability, describeMinimumRole, filterAccessibleProjects, resolveProjectAccessRole } from '../security/access.js';
31
32
  import { adoptProjectSession as adoptSharedProjectSession, listBridgeSessions as listSharedBridgeSessions, switchProjectBinding as switchSharedProjectBinding } from '../control-plane/project-session.js';
32
- import { getProjectArchiveDir, getProjectAuditDir, getProjectAuditFile, getProjectCacheDir, getProjectDownloadsDir, getProjectTempDir } from '../projects/paths.js';
33
+ import { getProjectAuditDir, getProjectCacheDir, getProjectDownloadsDir, getProjectTempDir } from '../projects/paths.js';
33
34
  import { buildTeamActivityView, detectOverlaps, formatTeamView, formatOverlapAlerts } from '../collaboration/awareness.js';
34
- import { extractInsights, buildLearnInput, formatRecallResults } from '../collaboration/knowledge.js';
35
- import { createHandoff, acceptHandoff, createReview, resolveReview, formatHandoff, formatReview, formatReviewResult } from '../collaboration/handoff.js';
36
- import { analyzeTeamHealth, formatInsightsReport } from '../collaboration/insights.js';
37
- import { classifyOperation, enforceTrustBoundary, recordRunOutcome, formatTrustState, DEFAULT_TRUST_POLICY } from '../collaboration/trust.js';
38
- import { buildProjectTimeline, buildOnboardingContext, formatTimeline, isNewActor } from '../collaboration/timeline.js';
35
+ import { createReview } from '../collaboration/handoff.js';
36
+ import { classifyOperation, enforceTrustBoundary } from '../collaboration/trust.js';
39
37
  import { HandoffStore } from '../state/handoff-store.js';
40
38
  import { TrustStore } from '../state/trust-store.js';
41
39
  import { IntentClassifier } from './intent-classifier.js';
42
- import { buildTeamDigest, formatTeamDigest, createDigestPeriod } from '../collaboration/digest.js';
43
- import { checkRunAlerts, checkLongRunningAlerts, formatAlert, DEFAULT_ALERT_RULES } from '../collaboration/proactive-alerts.js';
44
- import { detectKnowledgeGaps, formatKnowledgeGaps } from '../collaboration/knowledge-gaps.js';
45
- import { estimateCost } from '../observability/cost.js';
40
+ import { checkRunAlerts, formatAlert, DEFAULT_ALERT_RULES } from '../collaboration/proactive-alerts.js';
46
41
  export class FeiqueService {
47
42
  config;
48
43
  feishuClient;
@@ -64,6 +59,10 @@ export class FeiqueService {
64
59
  activeRuns = new Map();
65
60
  runReplyTargets = new Map();
66
61
  chatRateWindows = new Map();
62
+ /** Dedupe admin notifications for backend failover: one alert per (from→to) direction per process lifetime. */
63
+ failoverNotified = new Set();
64
+ /** Dedupe rejected-chat notifications: one reply + one admin alert per chat_id per process lifetime. */
65
+ rejectedChatNotified = new Set();
67
66
  maintenanceTimer;
68
67
  digestTimer;
69
68
  configWatcher;
@@ -102,79 +101,14 @@ export class FeiqueService {
102
101
  }
103
102
  }
104
103
  async recoverRuntimeState() {
105
- const recovered = await this.runStateStore.recoverOrphanedRuns();
106
- for (const run of recovered) {
107
- await this.auditLog.append({
108
- type: 'codex.run.recovered',
109
- run_id: run.run_id,
110
- project_alias: run.project_alias,
111
- conversation_key: run.conversation_key,
112
- status: run.status,
113
- pid: run.pid,
114
- });
115
- }
116
- return recovered;
104
+ return recoverRuntimeStateImpl(this);
117
105
  }
118
- /**
119
- * Reload config from disk with validation, diff, and admin notification.
120
- *
121
- * Flow:
122
- * 1. Parse new config — if invalid, reject and notify admin with error
123
- * 2. Diff against current config — identify what changed
124
- * 3. Apply new config to memory
125
- * 4. Notify admin chat(s) with change summary
126
- */
127
106
  async reloadConfig(configPath) {
128
- let newConfig;
129
- try {
130
- const { config } = await loadBridgeConfigFile(configPath);
131
- newConfig = config;
132
- }
133
- catch (error) {
134
- const msg = error instanceof Error ? error.message : String(error);
135
- this.logger.error({ configPath, error: msg }, 'Config reload rejected — invalid config');
136
- // Notify admin about the broken config
137
- const alertText = `🔴 配置变更被拒绝\n\n文件: ${configPath}\n原因: ${msg}\n\n当前服务继续使用旧配置运行。请修正后重新保存。`;
138
- for (const chatId of this.config.security.admin_chat_ids) {
139
- try {
140
- await this.feishuClient.sendText(chatId, alertText);
141
- }
142
- catch { /* best-effort */ }
143
- }
144
- await this.auditLog.append({
145
- type: 'config.reload.rejected',
146
- config_path: configPath,
147
- error: msg,
148
- });
149
- return { ok: false, error: msg };
107
+ const result = await reloadConfigImpl(this, configPath);
108
+ if (result.newConfig) {
109
+ this.config = result.newConfig;
150
110
  }
151
- // Diff: what changed?
152
- const changes = diffConfigs(this.config, newConfig);
153
- if (changes.length === 0) {
154
- this.logger.debug({ configPath }, 'Config file changed but no effective differences');
155
- return { ok: true, changes: [] };
156
- }
157
- // Apply
158
- const oldConfig = this.config;
159
- this.config = newConfig;
160
- this.logger.info({ configPath, changeCount: changes.length }, 'Config reloaded');
161
- // Notify admin
162
- const changeList = changes.slice(0, 15).map((c) => ` • ${c}`).join('\n');
163
- const truncated = changes.length > 15 ? `\n …及其他 ${changes.length - 15} 项变更` : '';
164
- const notifyText = `✅ 配置已热加载\n\n${changes.length} 项变更:\n${changeList}${truncated}`;
165
- for (const chatId of (oldConfig.security.admin_chat_ids.length > 0 ? oldConfig.security.admin_chat_ids : newConfig.security.admin_chat_ids)) {
166
- try {
167
- await this.feishuClient.sendText(chatId, notifyText);
168
- }
169
- catch { /* best-effort */ }
170
- }
171
- await this.auditLog.append({
172
- type: 'config.reload.applied',
173
- config_path: configPath,
174
- change_count: changes.length,
175
- changes: changes.slice(0, 20),
176
- });
177
- return { ok: true, changes };
111
+ return { ok: result.ok, ...(result.error ? { error: result.error } : {}), ...(result.changes ? { changes: result.changes } : {}) };
178
112
  }
179
113
  /**
180
114
  * Watch config file for changes and auto-reload with validation.
@@ -247,120 +181,16 @@ export class FeiqueService {
247
181
  this.digestTimer.unref?.();
248
182
  }
249
183
  async runDigestCycle() {
250
- const chatIds = this.config.service.team_digest_chat_ids;
251
- if (chatIds.length === 0)
252
- return;
253
- try {
254
- const period = createDigestPeriod(this.config.service.team_digest_interval_hours);
255
- const runs = await this.runStateStore.listRuns();
256
- const memories = this.config.service.memory_enabled
257
- ? await this.memoryStore.listRecentMemories({ scope: 'project', project_alias: '' }, 100)
258
- : [];
259
- const auditEvents = await this.auditLog.tail(500);
260
- const digest = buildTeamDigest(runs, memories, auditEvents, period);
261
- if (digest.summary.total_runs === 0) {
262
- return; // Nothing to report
263
- }
264
- const text = formatTeamDigest(digest);
265
- for (const chatId of chatIds) {
266
- try {
267
- await this.feishuClient.sendText(chatId, text);
268
- }
269
- catch (error) {
270
- this.logger.warn({ chatId, error }, 'Failed to send team digest');
271
- }
272
- }
273
- await this.auditLog.append({
274
- type: 'collaboration.digest.sent',
275
- period_label: period.label,
276
- total_runs: digest.summary.total_runs,
277
- chat_ids: chatIds,
278
- });
279
- // Send per-project mini-digests to project notification chats
280
- for (const projectDigest of digest.topProjects) {
281
- const projectChatIds = this.config.projects[projectDigest.alias]?.notification_chat_ids ?? [];
282
- if (projectChatIds.length === 0)
283
- continue;
284
- const successPct = Math.round(projectDigest.success_rate * 100);
285
- const miniDigestText = [
286
- `📊 项目摘要 [${projectDigest.alias}] — ${period.label}`,
287
- `运行: ${projectDigest.runs} | 成功率: ${successPct}%`,
288
- `参与者: ${projectDigest.actors.join(', ') || '无'}`,
289
- ].join('\n');
290
- for (const chatId of projectChatIds) {
291
- try {
292
- await this.feishuClient.sendText(chatId, miniDigestText);
293
- }
294
- catch { /* best-effort */ }
295
- }
296
- }
297
- }
298
- catch (error) {
299
- this.logger.error({ error }, 'Failed to generate team digest');
300
- }
184
+ return runDigestCycleImpl(this);
301
185
  }
302
186
  async runMemoryMaintenance() {
303
- if (!this.config.service.memory_enabled) {
304
- return 0;
305
- }
306
- const cleaned = await this.memoryStore.cleanupExpiredMemories();
307
- if (cleaned > 0) {
308
- await this.auditLog.append({
309
- type: 'memory.archive.expired.maintenance',
310
- count: cleaned,
311
- });
312
- this.logger.info({ cleaned }, 'Expired memories cleaned by background maintenance');
313
- }
314
- return cleaned;
187
+ return runMemoryMaintenanceImpl(this);
315
188
  }
316
189
  async runAuditMaintenance() {
317
- const auditTargets = this.listManagedAuditTargets();
318
- let scanned = 0;
319
- let archived = 0;
320
- let removed = 0;
321
- for (const target of auditTargets) {
322
- const auditLog = new AuditLog(target.stateDir, target.fileName);
323
- const result = await auditLog.cleanup({
324
- retentionDays: this.config.service.audit_retention_days,
325
- archiveAfterDays: this.config.service.audit_archive_after_days,
326
- archiveDir: target.archiveDir,
327
- });
328
- scanned += 1;
329
- archived += result.archived;
330
- removed += result.removed;
331
- }
332
- if (archived > 0 || removed > 0) {
333
- await this.auditLog.append({
334
- type: 'audit.cleanup.completed',
335
- scanned,
336
- archived,
337
- removed,
338
- });
339
- this.logger.info({ scanned, archived, removed }, 'Audit retention cleanup completed');
340
- }
341
- return { scanned, archived, removed };
190
+ return runAuditMaintenanceImpl(this);
342
191
  }
343
192
  async runMaintenanceCycle() {
344
- if (this.config.service.memory_enabled) {
345
- await this.runMemoryMaintenance();
346
- }
347
- await this.runAuditMaintenance();
348
- // Proactive: check for long-running tasks
349
- try {
350
- const activeRuns = await this.runStateStore.listRuns();
351
- const longAlerts = checkLongRunningAlerts(activeRuns);
352
- for (const alert of longAlerts) {
353
- const text = formatAlert(alert);
354
- for (const chatId of this.config.security.admin_chat_ids) {
355
- try {
356
- await this.feishuClient.sendText(chatId, text);
357
- }
358
- catch { /* best-effort */ }
359
- }
360
- await this.notifyProjectChats(alert.project_alias, text);
361
- }
362
- }
363
- catch { /* best-effort */ }
193
+ return runMaintenanceCycleImpl(this);
364
194
  }
365
195
  async handleIncomingMessage(context) {
366
196
  this.currentMessageContext = context;
@@ -510,7 +340,7 @@ export class FeiqueService {
510
340
  await this.handleGapsCommand(context);
511
341
  return;
512
342
  case 'prompt':
513
- await this.handlePromptMessage(context, selectionKey, command.prompt, context.text);
343
+ await this.handlePromptMessage(context, selectionKey, command.prompt);
514
344
  return;
515
345
  }
516
346
  }
@@ -655,430 +485,7 @@ export class FeiqueService {
655
485
  return this.runStateStore.listRuns();
656
486
  }
657
487
  async executePrompt(input) {
658
- const conversation = (await this.sessionStore.getConversation(input.sessionKey)) ??
659
- (await this.sessionStore.ensureConversation(input.sessionKey, {
660
- chat_id: input.chatId,
661
- actor_id: input.actorId,
662
- tenant_key: input.tenantKey,
663
- scope: input.project.session_scope,
664
- }));
665
- let currentSession = conversation.projects[input.projectAlias];
666
- // Auto-adopt latest local session when no active session exists
667
- if (!currentSession?.thread_id && this.config.service.project_switch_auto_adopt_latest) {
668
- try {
669
- const sessionBackendOverrideForAdopt = await this.sessionStore.getProjectBackend(input.sessionKey, input.projectAlias);
670
- const backendForAdopt = this.resolveBackendByName(input.projectAlias, sessionBackendOverrideForAdopt);
671
- const latestLocal = await backendForAdopt.findLatestSession(input.project.root);
672
- if (latestLocal) {
673
- await this.sessionStore.upsertProjectSession(input.sessionKey, input.projectAlias, {
674
- thread_id: latestLocal.sessionId,
675
- });
676
- const refreshed = await this.sessionStore.getConversation(input.sessionKey);
677
- currentSession = refreshed?.projects[input.projectAlias];
678
- this.logger.info({ projectAlias: input.projectAlias, sessionId: latestLocal.sessionId, backend: latestLocal.backend }, 'Auto-adopted latest local session for prompt execution');
679
- }
680
- }
681
- catch { /* auto-adopt is best-effort */ }
682
- }
683
- if (this.config.service.memory_enabled) {
684
- await this.memoryStore.cleanupExpiredMemories();
685
- }
686
- const memoryContext = this.config.service.memory_enabled
687
- ? await retrieveMemoryContext(this.memoryStore, {
688
- conversationKey: input.sessionKey,
689
- projectAlias: input.projectAlias,
690
- threadId: currentSession?.thread_id,
691
- query: input.prompt,
692
- searchLimit: this.config.service.memory_search_limit,
693
- groupChatId: input.incomingMessage.chat_type === 'group' ? input.incomingMessage.chat_id : undefined,
694
- includeGroupMemories: this.config.service.memory_group_enabled && input.incomingMessage.chat_type === 'group',
695
- })
696
- : { pinnedMemories: [], relevantMemories: [], pinnedGroupMemories: [], relevantGroupMemories: [] };
697
- // Direction 6: Inject onboarding context for new actors
698
- let onboardingPrefix = '';
699
- if (input.actorId && this.config.service.memory_enabled) {
700
- try {
701
- const allRuns = await this.runStateStore.listRuns();
702
- if (isNewActor(input.actorId, allRuns, input.projectAlias)) {
703
- const memories = await this.memoryStore.listRecentMemories({ scope: 'project', project_alias: input.projectAlias }, 10);
704
- const timeline = buildProjectTimeline(allRuns, memories, [], input.projectAlias, 10);
705
- onboardingPrefix = buildOnboardingContext(timeline, memories, input.projectAlias);
706
- }
707
- }
708
- catch { /* onboarding injection is best-effort */ }
709
- }
710
- const effectivePrompt = onboardingPrefix
711
- ? `${onboardingPrefix}\n\n${input.prompt}`
712
- : input.prompt;
713
- const bridgePrompt = await this.buildBridgePrompt(input.projectAlias, input.project, input.incomingMessage, effectivePrompt, memoryContext);
714
- const startedAt = Date.now();
715
- const projectRoot = this.resolveProjectRoot(input.project);
716
- const runId = input.runId ?? randomUUID();
717
- let lastProgressUpdate = 0;
718
- const activeRun = {
719
- runId,
720
- controller: new AbortController(),
721
- };
722
- this.activeRuns.set(input.queueKey, activeRun);
723
- const sessionBackendOverride = await this.sessionStore.getProjectBackend(input.sessionKey, input.projectAlias);
724
- const backend = this.resolveBackendByName(input.projectAlias, sessionBackendOverride);
725
- const backendLabel = backend.name === 'claude' ? 'Claude' : 'Codex';
726
- await this.updateRunStartedReply(input.chatId, input.projectAlias, runId, backendLabel);
727
- await this.runStateStore.upsertRun(runId, {
728
- queue_key: input.queueKey,
729
- conversation_key: input.sessionKey,
730
- project_alias: input.projectAlias,
731
- chat_id: input.chatId,
732
- actor_id: input.actorId,
733
- actor_name: input.incomingMessage.actor_name,
734
- session_id: currentSession?.thread_id,
735
- project_root: projectRoot,
736
- prompt_excerpt: truncateExcerpt(input.prompt),
737
- status: 'running',
738
- status_detail: undefined,
739
- });
740
- await this.auditLog.append({
741
- type: 'codex.run.started',
742
- run_id: runId,
743
- chat_id: input.chatId,
744
- actor_id: input.actorId,
745
- project_alias: input.projectAlias,
746
- conversation_key: input.sessionKey,
747
- session_id: currentSession?.thread_id,
748
- prompt: input.prompt,
749
- });
750
- await this.appendProjectAuditEvent(input.projectAlias, input.project, {
751
- type: 'codex.run.started',
752
- run_id: runId,
753
- chat_id: input.chatId,
754
- actor_id: input.actorId,
755
- session_id: currentSession?.thread_id,
756
- project_root: projectRoot,
757
- });
758
- this.logger.info({
759
- runId,
760
- queueKey: input.queueKey,
761
- sessionKey: input.sessionKey,
762
- projectAlias: input.projectAlias,
763
- projectRoot,
764
- sessionId: currentSession?.thread_id,
765
- }, 'Codex run started');
766
- this.metrics?.recordCodexTurnStarted(input.projectAlias, runId);
767
- try {
768
- const outputTokenLimit = backend.name === 'claude'
769
- ? (this.config.claude?.output_token_limit ?? this.config.codex.output_token_limit)
770
- : this.config.codex.output_token_limit;
771
- const result = await backend.run({
772
- workdir: input.project.root,
773
- prompt: bridgePrompt,
774
- sessionId: currentSession?.thread_id,
775
- timeoutMs: backend.name === 'claude'
776
- ? (this.config.claude?.run_timeout_ms ?? this.config.codex.run_timeout_ms)
777
- : this.config.codex.run_timeout_ms,
778
- signal: activeRun.controller.signal,
779
- logger: this.logger,
780
- projectConfig: backend.name === 'codex'
781
- ? {
782
- profile: input.project.profile ?? this.config.codex.default_profile,
783
- model: input.project.codex_model,
784
- sandbox: input.project.codex_sandbox ?? input.project.sandbox ?? this.config.codex.default_sandbox,
785
- tempDir: this.resolveProjectTempDir(input.projectAlias, input.project),
786
- cacheDir: this.resolveProjectCacheDir(input.projectAlias, input.project),
787
- }
788
- : {
789
- permissionMode: input.project.claude_permission_mode ?? this.config.claude?.default_permission_mode,
790
- model: input.project.claude_model ?? this.config.claude?.default_model,
791
- maxBudgetUsd: input.project.claude_max_budget_usd ?? this.config.claude?.max_budget_usd,
792
- allowedTools: input.project.claude_allowed_tools ?? this.config.claude?.allowed_tools,
793
- systemPromptAppend: input.project.claude_system_prompt_append ?? this.config.claude?.system_prompt_append,
794
- },
795
- onSpawn: async (pid) => {
796
- activeRun.pid = pid;
797
- await this.runStateStore.upsertRun(runId, {
798
- queue_key: input.queueKey,
799
- conversation_key: input.sessionKey,
800
- project_alias: input.projectAlias,
801
- chat_id: input.chatId,
802
- actor_id: input.actorId,
803
- session_id: currentSession?.thread_id,
804
- project_root: projectRoot,
805
- prompt_excerpt: truncateExcerpt(input.prompt),
806
- status: 'running',
807
- status_detail: undefined,
808
- pid,
809
- });
810
- },
811
- onEvent: async (event) => {
812
- if (!this.config.service.emit_progress_updates) {
813
- return;
814
- }
815
- const message = backend.summarizeEvent(event);
816
- if (!message) {
817
- return;
818
- }
819
- const now = Date.now();
820
- if (now - lastProgressUpdate < this.config.service.progress_update_interval_ms) {
821
- return;
822
- }
823
- lastProgressUpdate = now;
824
- await this.updateRunProgressReply(input, runId, message, backendLabel);
825
- },
826
- });
827
- const excerpt = result.finalMessage.slice(0, outputTokenLimit);
828
- if (!excerpt.trim()) {
829
- this.logger.warn({
830
- runId,
831
- queueKey: input.queueKey,
832
- sessionKey: input.sessionKey,
833
- projectAlias: input.projectAlias,
834
- sessionId: result.sessionId,
835
- durationMs: Date.now() - startedAt,
836
- }, 'Codex run completed without a displayable final message');
837
- }
838
- // Extract and send any [SEND_FILE:path] markers before text reply
839
- const { cleanText: excerptWithoutFiles, filePaths } = extractFileMarkers(excerpt);
840
- if (filePaths.length > 0) {
841
- for (const filePath of filePaths) {
842
- try {
843
- await this.feishuClient.sendFile(input.chatId, filePath);
844
- this.logger.info({ chatId: input.chatId, filePath }, 'Sent file to Feishu');
845
- }
846
- catch (err) {
847
- const msg = err instanceof Error ? err.message : String(err);
848
- this.logger.warn({ chatId: input.chatId, filePath, error: msg }, 'Failed to send file to Feishu');
849
- // Notify user about the failure inline
850
- excerptWithoutFiles === excerpt || await this.feishuClient.sendText(input.chatId, `⚠️ 文件发送失败: ${filePath}\n${msg}`);
851
- }
852
- }
853
- }
854
- const finalExcerpt = excerptWithoutFiles.trim() || excerpt;
855
- const cardSummary = truncateForFeishuCard(finalExcerpt || `${backendLabel} 已完成,但没有返回可显示文本。`);
856
- await this.auditLog.append({
857
- type: 'codex.run.completed',
858
- run_id: runId,
859
- chat_id: input.chatId,
860
- actor_id: input.actorId,
861
- project_alias: input.projectAlias,
862
- conversation_key: input.sessionKey,
863
- session_id: result.sessionId,
864
- exit_code: result.exitCode,
865
- duration_ms: Date.now() - startedAt,
866
- backend: backend.name,
867
- });
868
- await this.appendProjectAuditEvent(input.projectAlias, input.project, {
869
- type: 'codex.run.completed',
870
- run_id: runId,
871
- chat_id: input.chatId,
872
- actor_id: input.actorId,
873
- session_id: result.sessionId,
874
- duration_ms: Date.now() - startedAt,
875
- backend: backend.name,
876
- });
877
- this.logger.info({
878
- runId,
879
- queueKey: input.queueKey,
880
- sessionKey: input.sessionKey,
881
- projectAlias: input.projectAlias,
882
- sessionId: result.sessionId,
883
- exitCode: result.exitCode,
884
- finalMessageChars: excerpt.length,
885
- durationMs: Date.now() - startedAt,
886
- }, 'Codex run completed');
887
- await this.sessionStore.upsertProjectSession(input.sessionKey, input.projectAlias, {
888
- thread_id: result.sessionId,
889
- last_prompt: input.prompt,
890
- last_response_excerpt: excerpt,
891
- });
892
- if (this.config.service.memory_enabled && result.sessionId) {
893
- const summaryDraft = summarizeThreadTurn({
894
- previousSummary: memoryContext.threadSummary?.summary,
895
- prompt: input.prompt,
896
- responseExcerpt: excerpt,
897
- maxChars: this.config.service.thread_summary_max_chars,
898
- });
899
- const threadSummary = await this.memoryStore.upsertThreadSummary({
900
- conversation_key: input.sessionKey,
901
- project_alias: input.projectAlias,
902
- thread_id: result.sessionId,
903
- summary: summaryDraft.summary,
904
- recent_prompt: input.prompt,
905
- recent_response_excerpt: excerpt,
906
- files_touched: summaryDraft.filesTouched,
907
- open_tasks: summaryDraft.openTasks,
908
- decisions: summaryDraft.decisions,
909
- });
910
- await this.auditLog.append({
911
- type: 'memory.thread_summary.updated',
912
- run_id: runId,
913
- project_alias: input.projectAlias,
914
- conversation_key: input.sessionKey,
915
- thread_id: result.sessionId,
916
- files_touched: threadSummary.files_touched,
917
- });
918
- }
919
- await this.enforceSessionHistoryLimit(input.sessionKey, input.projectAlias);
920
- await this.runStateStore.upsertRun(runId, {
921
- queue_key: input.queueKey,
922
- conversation_key: input.sessionKey,
923
- project_alias: input.projectAlias,
924
- chat_id: input.chatId,
925
- actor_id: input.actorId,
926
- session_id: result.sessionId,
927
- project_root: projectRoot,
928
- pid: activeRun.pid,
929
- prompt_excerpt: truncateExcerpt(input.prompt),
930
- status: 'success',
931
- status_detail: undefined,
932
- input_tokens: result.inputTokens,
933
- output_tokens: result.outputTokens,
934
- estimated_cost_usd: estimateCost(result.inputTokens, result.outputTokens, backend.name),
935
- });
936
- this.metrics?.recordCodexTurn('success', input.projectAlias, (Date.now() - startedAt) / 1000, runId);
937
- // Record cost and token metrics
938
- if (result.inputTokens || result.outputTokens) {
939
- const costUsd = estimateCost(result.inputTokens, result.outputTokens, backend.name) ?? 0;
940
- this.metrics?.recordCost(input.projectAlias, backend.name, costUsd);
941
- this.metrics?.recordTokens(input.projectAlias, backend.name, result.inputTokens ?? 0, result.outputTokens ?? 0);
942
- }
943
- // Direction 5: Record trust outcome
944
- try {
945
- const trustState = await this.trustStore.getOrCreate(input.projectAlias);
946
- const updated = recordRunOutcome(trustState, true, DEFAULT_TRUST_POLICY);
947
- await this.trustStore.update(input.projectAlias, updated);
948
- this.metrics?.recordTrustLevel(input.projectAlias, updated.current_level);
949
- }
950
- catch { /* trust tracking is best-effort */ }
951
- // Proactive alerts: check if this run triggers any team alerts
952
- try {
953
- const completedRunState = await this.runStateStore.getRun(runId);
954
- if (completedRunState) {
955
- await this.checkAndSendAlerts(completedRunState);
956
- }
957
- }
958
- catch { /* alerts are best-effort */ }
959
- // Direction 2: Auto-extract knowledge
960
- if (this.config.service.memory_enabled && excerpt.length >= 100) {
961
- try {
962
- const insight = extractInsights(input.prompt, excerpt, input.projectAlias);
963
- if (insight) {
964
- await this.memoryStore.saveProjectMemory({
965
- project_alias: insight.project_alias,
966
- title: insight.title,
967
- content: insight.content,
968
- tags: insight.tags,
969
- source: 'auto',
970
- created_by: input.actorId,
971
- });
972
- }
973
- }
974
- catch { /* auto-extraction is best-effort */ }
975
- }
976
- await this.sendOrUpdateRunOutcome({
977
- input,
978
- runId,
979
- title: `${backendLabel} 已完成`,
980
- body: finalExcerpt || `${backendLabel} 已完成,但没有返回可显示文本。`,
981
- runStatus: 'success',
982
- runPhase: '已完成',
983
- cardSummary,
984
- sessionId: result.sessionId,
985
- });
986
- }
987
- catch (error) {
988
- const message = error instanceof Error ? error.message : String(error);
989
- const cancelled = error instanceof Error && error.name === 'AbortError' && activeRun.cancelReason === 'user';
990
- const status = cancelled ? 'cancelled' : 'failure';
991
- if (!cancelled && error instanceof Error && error.name === 'AbortError') {
992
- activeRun.cancelReason = 'timeout';
993
- }
994
- if (!cancelled && activeRun.cancelReason === 'timeout') {
995
- this.metrics?.recordCodexTurn('failure', input.projectAlias, (Date.now() - startedAt) / 1000, runId);
996
- }
997
- else {
998
- this.metrics?.recordCodexTurn(cancelled ? 'cancelled' : 'failure', input.projectAlias, (Date.now() - startedAt) / 1000, runId);
999
- }
1000
- await this.runStateStore.upsertRun(runId, {
1001
- queue_key: input.queueKey,
1002
- conversation_key: input.sessionKey,
1003
- project_alias: input.projectAlias,
1004
- chat_id: input.chatId,
1005
- actor_id: input.actorId,
1006
- session_id: currentSession?.thread_id,
1007
- project_root: projectRoot,
1008
- pid: activeRun.pid,
1009
- prompt_excerpt: truncateExcerpt(input.prompt),
1010
- status,
1011
- status_detail: undefined,
1012
- error: message,
1013
- });
1014
- await this.auditLog.append({
1015
- type: cancelled ? 'codex.run.cancelled' : 'codex.run.failed',
1016
- run_id: runId,
1017
- chat_id: input.chatId,
1018
- actor_id: input.actorId,
1019
- project_alias: input.projectAlias,
1020
- conversation_key: input.sessionKey,
1021
- error: message,
1022
- });
1023
- await this.appendProjectAuditEvent(input.projectAlias, input.project, {
1024
- type: cancelled ? 'codex.run.cancelled' : 'codex.run.failed',
1025
- run_id: runId,
1026
- chat_id: input.chatId,
1027
- actor_id: input.actorId,
1028
- error: message,
1029
- });
1030
- // Direction 5: Record trust failure (only for actual failures, not cancellations)
1031
- if (!cancelled) {
1032
- try {
1033
- const trustState = await this.trustStore.getOrCreate(input.projectAlias);
1034
- const updated = recordRunOutcome(trustState, false, DEFAULT_TRUST_POLICY);
1035
- await this.trustStore.update(input.projectAlias, updated);
1036
- }
1037
- catch { /* trust tracking is best-effort */ }
1038
- // Notify project chats about the failure
1039
- await this.notifyProjectChats(input.projectAlias, `❌ 运行失败 [${input.projectAlias}]\n${message.slice(0, 200)}`);
1040
- // Proactive alerts on failure
1041
- try {
1042
- const failedRunState = await this.runStateStore.getRun(runId);
1043
- if (failedRunState) {
1044
- await this.checkAndSendAlerts(failedRunState);
1045
- }
1046
- }
1047
- catch { /* alerts are best-effort */ }
1048
- }
1049
- if (cancelled) {
1050
- this.logger.warn({
1051
- runId,
1052
- queueKey: input.queueKey,
1053
- sessionKey: input.sessionKey,
1054
- projectAlias: input.projectAlias,
1055
- durationMs: Date.now() - startedAt,
1056
- }, 'Codex run cancelled');
1057
- }
1058
- else {
1059
- this.logger.error({
1060
- error,
1061
- runId,
1062
- queueKey: input.queueKey,
1063
- sessionKey: input.sessionKey,
1064
- projectAlias: input.projectAlias,
1065
- durationMs: Date.now() - startedAt,
1066
- }, 'Codex run failed');
1067
- }
1068
- await this.sendOrUpdateRunOutcome({
1069
- input,
1070
- runId,
1071
- title: cancelled ? '运行已取消' : '执行失败',
1072
- body: cancelled ? '当前运行已取消。' : ['执行失败。', '', friendlyErrorMessage(message)].join('\n'),
1073
- runStatus: cancelled ? 'cancelled' : 'failure',
1074
- runPhase: cancelled ? '已取消' : '失败',
1075
- cardSummary: truncateForFeishuCard(cancelled ? '当前运行已取消。' : friendlyErrorMessage(message)),
1076
- });
1077
- }
1078
- finally {
1079
- this.activeRuns.delete(input.queueKey);
1080
- this.runReplyTargets.delete(runId);
1081
- }
488
+ return executePromptImpl(this, input);
1082
489
  }
1083
490
  async handleProjectCommand(context, selectionKey, alias, followupPrompt) {
1084
491
  if (!alias) {
@@ -1095,7 +502,7 @@ export class FeiqueService {
1095
502
  await this.sendTextReply(context.chat_id, `当前 chat_id 无权切换到项目 ${alias}。至少需要 ${describeMinimumRole('viewer')} 权限。`, context.message_id, context.text);
1096
503
  return;
1097
504
  }
1098
- const project = this.requireProject(alias);
505
+ this.requireProject(alias); // throws if missing — validates the alias before switching
1099
506
  const switched = await switchSharedProjectBinding(this.config, this.sessionStore, this.codexSessionIndex, {
1100
507
  chatId: context.chat_id,
1101
508
  actorId: context.actor_id,
@@ -1126,7 +533,7 @@ export class FeiqueService {
1126
533
  await this.handleReadOnlyFollowupCommand(context, selectionKey, followupCommand, followupPrompt);
1127
534
  return;
1128
535
  }
1129
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
536
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1130
537
  return;
1131
538
  }
1132
539
  await this.sendTextReply(context.chat_id, switched.text, context.message_id, context.text);
@@ -1175,13 +582,13 @@ export class FeiqueService {
1175
582
  await this.handleAdminCommand(context, selectionKey, command);
1176
583
  return;
1177
584
  case 'prompt':
1178
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
585
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1179
586
  return;
1180
587
  default:
1181
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
588
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1182
589
  }
1183
590
  }
1184
- async handlePromptMessage(context, selectionKey, rawPrompt, originalText) {
591
+ async handlePromptMessage(context, selectionKey, rawPrompt) {
1185
592
  const prompt = normalizeIncomingText(rawPrompt) || (context.attachments.length > 0 ? '请结合这条飞书消息附带的多媒体信息继续处理。' : '');
1186
593
  if (!prompt) {
1187
594
  return;
@@ -1372,7 +779,6 @@ export class FeiqueService {
1372
779
  }
1373
780
  async handleSessionCommand(context, selectionKey, action, threadId) {
1374
781
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1375
- const sessions = await this.sessionStore.listProjectSessions(projectContext.sessionKey, projectContext.projectAlias);
1376
782
  const activeSessionId = (await this.sessionStore.getConversation(projectContext.sessionKey))?.projects[projectContext.projectAlias]?.thread_id;
1377
783
  switch (action) {
1378
784
  case 'list': {
@@ -1441,215 +847,39 @@ export class FeiqueService {
1441
847
  // ── Direction 2: Knowledge Loop ──
1442
848
  async handleLearnCommand(context, selectionKey, value) {
1443
849
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1444
- const input = buildLearnInput(value, projectContext.projectAlias, context.actor_id, context.chat_id);
1445
- await this.memoryStore.saveProjectMemory({
1446
- project_alias: input.project_alias,
1447
- title: input.title,
1448
- content: input.content,
1449
- tags: input.tags,
1450
- source: input.source,
1451
- created_by: context.actor_id,
1452
- });
1453
- await this.auditLog.append({
1454
- type: 'collaboration.knowledge.learned',
1455
- project_alias: input.project_alias,
1456
- actor_id: context.actor_id,
1457
- title: input.title,
1458
- });
1459
- await this.sendTextReply(context.chat_id, `💡 团队知识已记录: "${input.title}"\n项目: ${input.project_alias}`, context.message_id, context.text);
850
+ return handleLearnCommandImpl(this, context, projectContext, value);
1460
851
  }
1461
852
  async handleRecallCommand(context, selectionKey, query) {
1462
853
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1463
- const memories = await this.memoryStore.searchMemories({ scope: 'project', project_alias: projectContext.projectAlias }, query, 10);
1464
- const text = formatRecallResults(memories, query);
1465
- await this.sendTextReply(context.chat_id, text, context.message_id, context.text);
854
+ return handleRecallCommandImpl(this, context, projectContext, query);
1466
855
  }
1467
- // ── Direction 3: Handoff & Review ──
1468
856
  async handleHandoffCommand(context, selectionKey, summary) {
1469
857
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1470
- const conversation = await this.sessionStore.getConversation(projectContext.sessionKey);
1471
- const projectState = conversation?.projects[projectContext.projectAlias];
1472
- const record = createHandoff({
1473
- from_actor_id: context.actor_id ?? 'unknown',
1474
- from_actor_name: context.actor_name,
1475
- project_alias: projectContext.projectAlias,
1476
- conversation_key: projectContext.sessionKey,
1477
- thread_id: projectState?.active_thread_id ?? projectState?.thread_id,
1478
- summary: summary ?? '会话交接',
1479
- last_prompt: projectState?.last_prompt,
1480
- last_response_excerpt: projectState?.last_response_excerpt,
1481
- });
1482
- await this.handoffStore.addHandoff(record);
1483
- await this.auditLog.append({
1484
- type: 'collaboration.handoff.created',
1485
- handoff_id: record.id,
1486
- from_actor_id: record.from_actor_id,
1487
- project_alias: record.project_alias,
1488
- });
1489
- await this.sendTextReply(context.chat_id, formatHandoff(record), context.message_id, context.text);
858
+ return handleHandoffCommandImpl(this, context, projectContext, summary);
1490
859
  }
1491
860
  async handlePickupCommand(context, selectionKey, id) {
1492
- let handoff = id
1493
- ? await this.handoffStore.updateHandoff(id, {}) // just to find it
1494
- : await this.handoffStore.getPendingHandoffForActor(context.actor_id ?? '', undefined);
1495
- if (id) {
1496
- handoff = await this.handoffStore.getPendingHandoff();
1497
- if (handoff && !handoff.id.startsWith(id)) {
1498
- handoff = null;
1499
- }
1500
- }
1501
- if (!handoff || handoff.status !== 'pending') {
1502
- await this.sendTextReply(context.chat_id, '没有找到待接手的交接任务。', context.message_id, context.text);
1503
- return;
1504
- }
1505
- const accepted = acceptHandoff(handoff, context.actor_id ?? 'unknown');
1506
- await this.handoffStore.updateHandoff(handoff.id, {
1507
- status: 'accepted',
1508
- accepted_at: accepted.accepted_at,
1509
- accepted_by: accepted.accepted_by,
1510
- });
1511
- // Adopt the session if there's a thread_id
1512
- if (handoff.thread_id) {
1513
- const projectContext = await this.resolveProjectContext(context, selectionKey);
1514
- await this.sessionStore.setActiveProjectSession(projectContext.sessionKey, handoff.project_alias, handoff.thread_id);
1515
- }
1516
- await this.auditLog.append({
1517
- type: 'collaboration.handoff.accepted',
1518
- handoff_id: handoff.id,
1519
- accepted_by: context.actor_id,
1520
- project_alias: handoff.project_alias,
1521
- });
1522
- await this.sendTextReply(context.chat_id, `✅ 已接手 ${handoff.from_actor_name ?? handoff.from_actor_id} 的交接任务 [${handoff.project_alias}]\n摘要: ${handoff.summary}`, context.message_id, context.text);
861
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
862
+ return handlePickupCommandImpl(this, context, projectContext, id);
1523
863
  }
1524
864
  async handleReviewCommand(context, selectionKey) {
1525
865
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1526
- const runs = await this.runStateStore.listRuns();
1527
- const latestRun = runs.find((r) => r.project_alias === projectContext.projectAlias && (r.status === 'success' || r.status === 'failure'));
1528
- if (!latestRun) {
1529
- await this.sendTextReply(context.chat_id, '没有找到最近的运行结果可供评审。', context.message_id, context.text);
1530
- return;
1531
- }
1532
- const review = createReview({
1533
- run_id: latestRun.run_id,
1534
- project_alias: projectContext.projectAlias,
1535
- chat_id: context.chat_id,
1536
- actor_id: context.actor_id ?? 'unknown',
1537
- content_excerpt: latestRun.prompt_excerpt,
1538
- });
1539
- await this.handoffStore.addReview(review);
1540
- await this.auditLog.append({
1541
- type: 'collaboration.review.created',
1542
- review_id: review.id,
1543
- run_id: review.run_id,
1544
- project_alias: review.project_alias,
1545
- });
1546
- await this.sendTextReply(context.chat_id, formatReview(review), context.message_id, context.text);
866
+ return handleReviewCommandImpl(this, context, projectContext);
1547
867
  }
1548
868
  async handleApproveCommand(context, comment) {
1549
- const pending = await this.handoffStore.getPendingReview(context.chat_id);
1550
- if (!pending) {
1551
- await this.sendTextReply(context.chat_id, '当前没有待评审的内容。', context.message_id, context.text);
1552
- return;
1553
- }
1554
- const resolved = resolveReview(pending, 'approved', context.actor_id ?? 'unknown', comment);
1555
- await this.handoffStore.updateReview(pending.id, {
1556
- status: 'approved',
1557
- reviewer_id: resolved.reviewer_id,
1558
- review_comment: resolved.review_comment,
1559
- resolved_at: resolved.resolved_at,
1560
- });
1561
- await this.auditLog.append({
1562
- type: 'collaboration.review.approved',
1563
- review_id: pending.id,
1564
- reviewer_id: context.actor_id,
1565
- });
1566
- await this.sendTextReply(context.chat_id, formatReviewResult(resolved), context.message_id, context.text);
869
+ return handleApproveCommandImpl(this, context, comment);
1567
870
  }
1568
871
  async handleRejectCommand(context, reason) {
1569
- const pending = await this.handoffStore.getPendingReview(context.chat_id);
1570
- if (!pending) {
1571
- await this.sendTextReply(context.chat_id, '当前没有待评审的内容。', context.message_id, context.text);
1572
- return;
1573
- }
1574
- const resolved = resolveReview(pending, 'rejected', context.actor_id ?? 'unknown', reason);
1575
- await this.handoffStore.updateReview(pending.id, {
1576
- status: 'rejected',
1577
- reviewer_id: resolved.reviewer_id,
1578
- review_comment: resolved.review_comment,
1579
- resolved_at: resolved.resolved_at,
1580
- });
1581
- await this.auditLog.append({
1582
- type: 'collaboration.review.rejected',
1583
- review_id: pending.id,
1584
- reviewer_id: context.actor_id,
1585
- reason,
1586
- });
1587
- await this.sendTextReply(context.chat_id, formatReviewResult(resolved), context.message_id, context.text);
872
+ return handleRejectCommandImpl(this, context, reason);
1588
873
  }
1589
- // ── Direction 4: Insights ──
1590
874
  async handleInsightsCommand(context) {
1591
- const runs = await this.runStateStore.listRuns();
1592
- const auditEvents = await this.auditLog.tail(500);
1593
- const insights = analyzeTeamHealth(runs, auditEvents);
1594
- const text = formatInsightsReport(insights);
1595
- await this.sendTextReply(context.chat_id, text, context.message_id, context.text);
875
+ return handleInsightsCommandImpl(this, context);
1596
876
  }
1597
- // ── Direction 5: Trust ──
1598
877
  async handleTrustCommand(context, selectionKey, action, level) {
1599
878
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1600
- if (action === 'set' && level) {
1601
- const TRUST_ORDER = ['observe', 'suggest', 'execute', 'autonomous'];
1602
- const validLevels = [...TRUST_ORDER];
1603
- const state = await this.trustStore.getOrCreate(projectContext.projectAlias);
1604
- // Handle relative promote/demote from natural language
1605
- let resolvedLevel = level;
1606
- if (level === '_promote') {
1607
- const idx = TRUST_ORDER.indexOf(state.current_level);
1608
- if (idx >= TRUST_ORDER.length - 1) {
1609
- await this.sendTextReply(context.chat_id, `已经是最高信任等级 (${state.current_level}),无法继续提升。`, context.message_id, context.text);
1610
- return;
1611
- }
1612
- resolvedLevel = TRUST_ORDER[idx + 1];
1613
- }
1614
- else if (level === '_demote') {
1615
- const idx = TRUST_ORDER.indexOf(state.current_level);
1616
- if (idx <= 0) {
1617
- await this.sendTextReply(context.chat_id, `已经是最低信任等级 (${state.current_level}),无法继续降低。`, context.message_id, context.text);
1618
- return;
1619
- }
1620
- resolvedLevel = TRUST_ORDER[idx - 1];
1621
- }
1622
- if (!validLevels.includes(resolvedLevel)) {
1623
- await this.sendTextReply(context.chat_id, `无效的信任等级。有效值: ${validLevels.join(', ')}`, context.message_id, context.text);
1624
- return;
1625
- }
1626
- state.current_level = resolvedLevel;
1627
- state.last_evaluated_at = new Date().toISOString();
1628
- await this.trustStore.update(projectContext.projectAlias, state);
1629
- this.metrics?.recordTrustLevel(projectContext.projectAlias, resolvedLevel);
1630
- await this.auditLog.append({
1631
- type: 'collaboration.trust.set',
1632
- project_alias: projectContext.projectAlias,
1633
- actor_id: context.actor_id,
1634
- level,
1635
- });
1636
- await this.sendTextReply(context.chat_id, `🛡️ 项目 ${projectContext.projectAlias} 的信任等级已设置为: ${level}`, context.message_id, context.text);
1637
- return;
1638
- }
1639
- const state = await this.trustStore.getOrCreate(projectContext.projectAlias);
1640
- await this.sendTextReply(context.chat_id, formatTrustState(state), context.message_id, context.text);
879
+ return handleTrustCommandImpl(this, context, projectContext, action, level);
1641
880
  }
1642
- // ── Team Digest ──
1643
881
  async handleDigestCommand(context) {
1644
- const period = createDigestPeriod(this.config.service.team_digest_interval_hours);
1645
- const runs = await this.runStateStore.listRuns();
1646
- const memories = this.config.service.memory_enabled
1647
- ? await this.memoryStore.listRecentMemories({ scope: 'project', project_alias: '' }, 100)
1648
- : [];
1649
- const auditEvents = await this.auditLog.tail(500);
1650
- const digest = buildTeamDigest(runs, memories, auditEvents, period);
1651
- const text = formatTeamDigest(digest);
1652
- await this.sendTextReply(context.chat_id, text, context.message_id, context.text);
882
+ return handleDigestCommandImpl(this, context);
1653
883
  }
1654
884
  // ── Proactive Alerts ──
1655
885
  async checkAndSendAlerts(completedRun) {
@@ -1681,26 +911,12 @@ export class FeiqueService {
1681
911
  }
1682
912
  // ── Knowledge Gap Detection ──
1683
913
  async handleGapsCommand(context) {
1684
- const runs = await this.runStateStore.listRuns();
1685
- const memories = this.config.service.memory_enabled
1686
- ? await this.memoryStore.listRecentMemories({ scope: 'project', project_alias: '' }, 200)
1687
- : [];
1688
- const gaps = detectKnowledgeGaps(runs, memories);
1689
- const text = formatKnowledgeGaps(gaps);
1690
- await this.sendTextReply(context.chat_id, text, context.message_id, context.text);
914
+ return handleGapsCommandImpl(this, context);
1691
915
  }
1692
916
  // ── Direction 6: Timeline ──
1693
917
  async handleTimelineCommand(context, selectionKey, projectArg) {
1694
918
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1695
- const projectAlias = projectArg ?? projectContext.projectAlias;
1696
- const runs = await this.runStateStore.listRuns();
1697
- const auditEvents = await this.auditLog.tail(200);
1698
- const memories = this.config.service.memory_enabled
1699
- ? await this.memoryStore.listRecentMemories({ scope: 'project', project_alias: projectAlias }, 20)
1700
- : [];
1701
- const timeline = buildProjectTimeline(runs, memories, auditEvents, projectAlias, 20);
1702
- const text = formatTimeline(timeline);
1703
- await this.sendTextReply(context.chat_id, text, context.message_id, context.text);
919
+ return handleTimelineCommandImpl(this, context, projectContext, projectArg);
1704
920
  }
1705
921
  async handleAdminCommand(context, selectionKey, command) {
1706
922
  const runtimeConfigPath = this.runtimeControl?.configPath;
@@ -1758,37 +974,42 @@ export class FeiqueService {
1758
974
  await this.sendTextReply(context.chat_id, this.buildProjectsAdminText(globalAdmin || serviceObserver ? undefined : new Set(projectOperatorAliases)), context.message_id, context.text);
1759
975
  return;
1760
976
  }
1761
- if (command.action === 'add' || command.action === 'create') {
977
+ if (command.action === 'add' || command.action === 'create' || command.action === 'setup') {
1762
978
  if (!(globalAdmin || globalConfigAdmin)) {
1763
979
  await this.sendTextReply(context.chat_id, '当前 chat_id 无权动态接入项目。', context.message_id, context.text);
1764
980
  return;
1765
981
  }
1766
982
  if (!command.alias || !command.value) {
1767
- await this.sendTextReply(context.chat_id, command.action === 'create' ? '用法: /admin project create <alias> <root>' : '用法: /admin project add <alias> <root>', context.message_id, context.text);
983
+ await this.sendTextReply(context.chat_id, command.action === 'setup'
984
+ ? '用法: /admin project setup <alias> <root>\n一键创建项目并将当前群设为 operator。'
985
+ : command.action === 'create' ? '用法: /admin project create <alias> <root>' : '用法: /admin project add <alias> <root>', context.message_id, context.text);
1768
986
  return;
1769
987
  }
1770
- if (command.action === 'create' && this.config.projects[command.alias]) {
988
+ const isCreate = command.action === 'create' || command.action === 'setup';
989
+ if (isCreate && this.config.projects[command.alias]) {
1771
990
  await this.sendTextReply(context.chat_id, `项目已存在: ${command.alias}`, context.message_id, context.text);
1772
991
  return;
1773
992
  }
1774
993
  const resolvedRoot = path.resolve(expandHomePath(command.value));
1775
994
  const snapshot = await this.snapshotConfigForAdminMutation(context, `project.${command.action}`, `${command.alias} -> ${resolvedRoot}`);
1776
- if (command.action === 'create') {
995
+ if (isCreate) {
1777
996
  await createProjectAlias({ configPath: runtimeConfigPath, alias: command.alias, root: command.value });
1778
997
  }
1779
998
  else {
1780
999
  await bindProjectAlias({ configPath: runtimeConfigPath, alias: command.alias, root: command.value });
1781
1000
  }
1001
+ // For setup: auto-add current chat as operator + viewer
1002
+ const autoOperator = command.action === 'setup' ? [context.chat_id] : [];
1782
1003
  this.config.projects[command.alias] = {
1783
1004
  root: resolvedRoot,
1784
1005
  session_scope: 'chat',
1785
1006
  mention_required: true,
1786
1007
  knowledge_paths: [],
1787
1008
  wiki_space_ids: [],
1788
- viewer_chat_ids: [],
1789
- operator_chat_ids: [],
1009
+ viewer_chat_ids: [...autoOperator],
1010
+ operator_chat_ids: [...autoOperator],
1790
1011
  admin_chat_ids: [],
1791
- notification_chat_ids: [],
1012
+ notification_chat_ids: [...autoOperator],
1792
1013
  session_operator_chat_ids: [],
1793
1014
  run_operator_chat_ids: [],
1794
1015
  config_admin_chat_ids: [],
@@ -1798,7 +1019,27 @@ export class FeiqueService {
1798
1019
  chat_rate_limit_window_seconds: 60,
1799
1020
  chat_rate_limit_max_runs: 20,
1800
1021
  };
1801
- await this.sendTextReply(context.chat_id, `${command.action === 'create' ? '已创建并接入项目' : '已接入项目'}: ${command.alias}\n根目录: ${resolvedRoot}`, context.message_id, context.text);
1022
+ // Persist the auto-added chat_ids to config file for setup
1023
+ if (command.action === 'setup') {
1024
+ await updateProjectConfig(runtimeConfigPath, command.alias, {
1025
+ viewer_chat_ids: autoOperator,
1026
+ operator_chat_ids: autoOperator,
1027
+ notification_chat_ids: autoOperator,
1028
+ });
1029
+ }
1030
+ const replyLines = [
1031
+ `${isCreate ? '已创建并接入项目' : '已接入项目'}: ${command.alias}`,
1032
+ `根目录: ${resolvedRoot}`,
1033
+ ];
1034
+ if (command.action === 'setup') {
1035
+ replyLines.push(`已自动将当前群设为 operator + viewer + notification`);
1036
+ replyLines.push('');
1037
+ replyLines.push('可在其他群执行以下命令添加权限:');
1038
+ replyLines.push(`/admin project set ${command.alias} operator_chat_ids +<chat_id>`);
1039
+ replyLines.push('');
1040
+ replyLines.push(`切换到此项目: /project ${command.alias}`);
1041
+ }
1042
+ await this.sendTextReply(context.chat_id, replyLines.join('\n'), context.message_id, context.text);
1802
1043
  await this.appendAdminAudit({
1803
1044
  type: `admin.project.${command.action}`,
1804
1045
  chat_id: context.chat_id,
@@ -1806,8 +1047,9 @@ export class FeiqueService {
1806
1047
  project_alias: command.alias,
1807
1048
  root: resolvedRoot,
1808
1049
  snapshot_id: snapshot.id,
1050
+ auto_operator: command.action === 'setup' ? context.chat_id : undefined,
1809
1051
  });
1810
- this.logger.info({ alias: command.alias, root: resolvedRoot, actorId: context.actor_id, created: command.action === 'create' }, command.action === 'create' ? 'Project created by Feishu admin' : 'Project added by Feishu admin');
1052
+ this.logger.info({ alias: command.alias, root: resolvedRoot, actorId: context.actor_id, created: isCreate, setup: command.action === 'setup' }, command.action === 'setup' ? 'Project setup by Feishu admin' : isCreate ? 'Project created by Feishu admin' : 'Project added by Feishu admin');
1811
1053
  return;
1812
1054
  }
1813
1055
  if (command.action === 'remove') {
@@ -1845,9 +1087,9 @@ export class FeiqueService {
1845
1087
  await this.sendTextReply(context.chat_id, `当前 chat_id 无权修改项目 ${command.alias}。`, context.message_id, context.text);
1846
1088
  return;
1847
1089
  }
1848
- const patch = this.parseProjectPatch(command.field, command.value);
1090
+ const patch = parseProjectPatchImpl(this.config, command.field, command.value, command.alias);
1849
1091
  if (!patch) {
1850
- await this.sendTextReply(context.chat_id, '支持字段: root, profile, sandbox, session_scope, mention_required, description, viewer_chat_ids, operator_chat_ids, admin_chat_ids, session_operator_chat_ids, run_operator_chat_ids, config_admin_chat_ids, download_dir, temp_dir, cache_dir, log_dir, run_priority, chat_rate_limit_window_seconds, chat_rate_limit_max_runs', context.message_id, context.text);
1092
+ await this.sendTextReply(context.chat_id, '支持字段: root, profile, sandbox, session_scope, mention_required, description, viewer_chat_ids, operator_chat_ids, admin_chat_ids, notification_chat_ids, session_operator_chat_ids, run_operator_chat_ids, config_admin_chat_ids, download_dir, temp_dir, cache_dir, log_dir, run_priority, chat_rate_limit_window_seconds, chat_rate_limit_max_runs\n\n列表字段支持增量操作: +value 添加, -value 移除', context.message_id, context.text);
1851
1093
  return;
1852
1094
  }
1853
1095
  const snapshot = await this.snapshotConfigForAdminMutation(context, 'project.set', `${command.alias}.${command.field}=${command.value}`);
@@ -1946,309 +1188,17 @@ export class FeiqueService {
1946
1188
  await this.sendTextReply(context.chat_id, `项目 ${projectContext.projectAlias} 已切换到 ${label} 后端。\n下一条消息将使用 ${label} 执行。`, context.message_id, context.text);
1947
1189
  }
1948
1190
  async handleMemoryCommand(context, selectionKey, action, scope, value, filters) {
1949
- if (!this.config.service.memory_enabled) {
1950
- await this.sendTextReply(context.chat_id, '当前未启用记忆功能。请在配置里设置 `service.memory_enabled = true`。', context.message_id, context.text);
1951
- return;
1952
- }
1953
- try {
1954
- const explicitExpiredCleanup = action === 'forget' && value?.trim() === 'all-expired';
1955
- if (!explicitExpiredCleanup) {
1956
- await this.memoryStore.cleanupExpiredMemories();
1957
- }
1958
- const projectContext = await this.resolveProjectContext(context, selectionKey);
1959
- const conversation = await this.sessionStore.getConversation(projectContext.sessionKey);
1960
- const activeThreadId = conversation?.projects[projectContext.projectAlias]?.thread_id;
1961
- const groupMemoryAvailable = this.config.service.memory_group_enabled && context.chat_type === 'group';
1962
- if (action === 'status') {
1963
- if (scope === 'group') {
1964
- const target = this.resolveMemoryTarget(context, 'group');
1965
- const [count, pinnedCount] = await Promise.all([
1966
- this.memoryStore.countGroupMemories(projectContext.projectAlias, target.chatId),
1967
- this.memoryStore.countPinnedGroupMemories(projectContext.projectAlias, target.chatId),
1968
- ]);
1969
- await this.sendTextReply(context.chat_id, [
1970
- `项目: ${projectContext.projectAlias}`,
1971
- `群共享记忆数: ${count}`,
1972
- `Pinned 群共享记忆数: ${pinnedCount}`,
1973
- `群 chat_id: ${target.chatId}`,
1974
- ].join('\n'), context.message_id, context.text);
1975
- return;
1976
- }
1977
- const [count, pinnedCount, threadSummary, groupCount, groupPinnedCount] = await Promise.all([
1978
- this.memoryStore.countProjectMemories(projectContext.projectAlias),
1979
- this.memoryStore.countPinnedProjectMemories(projectContext.projectAlias),
1980
- activeThreadId ? this.memoryStore.getThreadSummary(projectContext.sessionKey, projectContext.projectAlias, activeThreadId) : Promise.resolve(null),
1981
- groupMemoryAvailable ? this.memoryStore.countGroupMemories(projectContext.projectAlias, context.chat_id) : Promise.resolve(0),
1982
- groupMemoryAvailable ? this.memoryStore.countPinnedGroupMemories(projectContext.projectAlias, context.chat_id) : Promise.resolve(0),
1983
- ]);
1984
- await this.sendTextReply(context.chat_id, [
1985
- `项目: ${projectContext.projectAlias}`,
1986
- `项目记忆数: ${count}`,
1987
- `Pinned 项目记忆数: ${pinnedCount}`,
1988
- ...(groupMemoryAvailable ? [`群共享记忆数: ${groupCount}`, `Pinned 群共享记忆数: ${groupPinnedCount}`] : []),
1989
- `当前会话: ${activeThreadId ?? '未开始'}`,
1990
- '',
1991
- threadSummary?.summary ?? '当前没有 thread summary。',
1992
- ].join('\n'), context.message_id, context.text);
1993
- return;
1994
- }
1995
- if (action === 'stats') {
1996
- const target = this.resolveMemoryTarget(context, scope);
1997
- const stats = await this.memoryStore.getMemoryStats({
1998
- scope: target.scope,
1999
- project_alias: projectContext.projectAlias,
2000
- chat_id: target.chatId,
2001
- });
2002
- await this.sendTextReply(context.chat_id, [
2003
- `项目: ${projectContext.projectAlias}`,
2004
- `${target.label}统计:`,
2005
- `active_count: ${stats.active_count}`,
2006
- `expired_count: ${stats.expired_count}`,
2007
- `pinned_count: ${stats.pinned_count}`,
2008
- `archived_count: ${stats.archived_count}`,
2009
- `latest_accessed_at: ${stats.latest_accessed_at ?? '-'}`,
2010
- `latest_updated_at: ${stats.latest_updated_at ?? '-'}`,
2011
- `latest_archived_at: ${stats.latest_archived_at ?? '-'}`,
2012
- ].join('\n'), context.message_id, context.text);
2013
- return;
2014
- }
2015
- if (action === 'recent') {
2016
- const target = this.resolveMemoryTarget(context, scope);
2017
- const recent = await this.memoryStore.listRecentMemories({ scope: target.scope, project_alias: projectContext.projectAlias, chat_id: target.chatId }, this.config.service.memory_recent_limit, filters);
2018
- if (recent.length === 0) {
2019
- await this.sendTextReply(context.chat_id, [
2020
- `项目: ${projectContext.projectAlias}`,
2021
- `当前没有可展示的${target.label}。`,
2022
- ...this.renderMemoryFilterLines(filters),
2023
- ].join('\n'), context.message_id, context.text);
2024
- return;
2025
- }
2026
- await this.sendTextReply(context.chat_id, [
2027
- `项目: ${projectContext.projectAlias}`,
2028
- `最近${target.label}:`,
2029
- ...this.renderMemoryFilterLines(filters),
2030
- '',
2031
- ...recent.map((item, index) => [
2032
- `${index + 1}. ${item.title}${item.pinned ? ' [pinned]' : ''}`,
2033
- ` id: ${item.id}`,
2034
- ` source: ${item.source}`,
2035
- ...(item.created_by ? [` created_by: ${item.created_by}`] : []),
2036
- ...(item.tags.length > 0 ? [` tags: ${item.tags.join(', ')}`] : []),
2037
- ` updated_at: ${item.updated_at}`,
2038
- ...(item.last_accessed_at ? [` last_accessed_at: ${item.last_accessed_at}`] : []),
2039
- ...(item.expires_at ? [` expires_at: ${item.expires_at}`] : []),
2040
- ` ${truncateExcerpt(item.content, 180)}`,
2041
- ].join('\n')),
2042
- ].join('\n'), context.message_id, context.text);
2043
- return;
2044
- }
2045
- if (action === 'search') {
2046
- if (!value?.trim()) {
2047
- await this.sendTextReply(context.chat_id, '用法: /memory search [--tag <tag>] [--source <source>] [--created-by <actor_id>] <query>', context.message_id, context.text);
2048
- return;
2049
- }
2050
- const target = this.resolveMemoryTarget(context, scope);
2051
- const hits = await this.memoryStore.searchMemories({ scope: target.scope, project_alias: projectContext.projectAlias, chat_id: target.chatId }, value, this.config.service.memory_search_limit, filters);
2052
- await this.auditLog.append({
2053
- type: 'memory.search',
2054
- chat_id: context.chat_id,
2055
- actor_id: context.actor_id,
2056
- project_alias: projectContext.projectAlias,
2057
- scope: target.scope,
2058
- query: value,
2059
- result_count: hits.length,
2060
- });
2061
- if (hits.length === 0) {
2062
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `${target.label}搜索: ${value}`, ...this.renderMemoryFilterLines(filters), '未找到匹配记忆。'].join('\n'), context.message_id, context.text);
2063
- return;
2064
- }
2065
- await this.sendTextReply(context.chat_id, [
2066
- `项目: ${projectContext.projectAlias}`,
2067
- `${target.label}搜索: ${value}`,
2068
- ...this.renderMemoryFilterLines(filters),
2069
- '',
2070
- ...hits.map((hit, index) => [
2071
- `${index + 1}. ${hit.title}${hit.pinned ? ' [pinned]' : ''}`,
2072
- ` id: ${hit.id}`,
2073
- ` source: ${hit.source}`,
2074
- ...(hit.created_by ? [` created_by: ${hit.created_by}`] : []),
2075
- ...(hit.tags.length > 0 ? [` tags: ${hit.tags.join(', ')}`] : []),
2076
- ...(hit.last_accessed_at ? [` last_accessed_at: ${hit.last_accessed_at}`] : []),
2077
- ` ${truncateExcerpt(hit.content, 180)}`,
2078
- ].join('\n')),
2079
- ].join('\n'), context.message_id, context.text);
2080
- return;
2081
- }
2082
- if (action === 'pin' || action === 'unpin' || action === 'forget' || action === 'restore') {
2083
- if (!value?.trim()) {
2084
- const usage = action === 'forget'
2085
- ? '用法: /memory forget <id> 或 /memory forget group <id>'
2086
- : action === 'restore'
2087
- ? '用法: /memory restore <id> 或 /memory restore group <id>'
2088
- : `用法: /memory ${action} <id> 或 /memory ${action} group <id>`;
2089
- await this.sendTextReply(context.chat_id, usage, context.message_id, context.text);
2090
- return;
2091
- }
2092
- const target = this.resolveMemoryTarget(context, scope);
2093
- const selector = { scope: target.scope, project_alias: projectContext.projectAlias, chat_id: target.chatId };
2094
- if (action === 'forget' && value === 'all-expired') {
2095
- const cleaned = await this.memoryStore.cleanupExpiredMemories(selector);
2096
- await this.auditLog.append({
2097
- type: 'memory.archive.expired',
2098
- chat_id: context.chat_id,
2099
- actor_id: context.actor_id,
2100
- project_alias: projectContext.projectAlias,
2101
- scope: target.scope,
2102
- count: cleaned,
2103
- });
2104
- await this.sendTextReply(context.chat_id, `${target.label}已归档过期项: ${cleaned}`, context.message_id, context.text);
2105
- return;
2106
- }
2107
- const existing = await this.memoryStore.getMemoryById(selector, value, { includeArchived: action === 'restore', includeExpired: action === 'restore' });
2108
- if (!existing) {
2109
- await this.sendTextReply(context.chat_id, `未找到可更新的${target.label} ID: ${value}`, context.message_id, context.text);
2110
- return;
2111
- }
2112
- if (action === 'forget') {
2113
- const archived = await this.memoryStore.archiveMemory(selector, value, { archived_by: context.actor_id, reason: 'manual' });
2114
- if (archived) {
2115
- await this.auditLog.append({
2116
- type: 'memory.archive',
2117
- chat_id: context.chat_id,
2118
- actor_id: context.actor_id,
2119
- project_alias: projectContext.projectAlias,
2120
- scope: target.scope,
2121
- memory_id: value,
2122
- });
2123
- }
2124
- await this.sendTextReply(context.chat_id, archived
2125
- ? [`${target.label}已归档: ${archived.title}`, `memory_id: ${archived.id}`, `可用 /memory restore${target.scope === 'group' ? ' group' : ''} ${archived.id} 恢复`].join('\n')
2126
- : `未找到可归档的${target.label} ID: ${value}`, context.message_id, context.text);
2127
- return;
2128
- }
2129
- if (action === 'restore') {
2130
- const restored = await this.memoryStore.restoreMemory(selector, value, context.actor_id);
2131
- if (restored) {
2132
- await this.auditLog.append({
2133
- type: 'memory.restore',
2134
- chat_id: context.chat_id,
2135
- actor_id: context.actor_id,
2136
- project_alias: projectContext.projectAlias,
2137
- scope: target.scope,
2138
- memory_id: value,
2139
- });
2140
- }
2141
- await this.sendTextReply(context.chat_id, restored ? `${target.label}已恢复: ${restored.title}\nmemory_id: ${restored.id}` : `未找到可恢复的${target.label} ID: ${value}`, context.message_id, context.text);
2142
- return;
2143
- }
2144
- const pinned = action === 'pin';
2145
- let agedOutMemoryTitle;
2146
- let agedOutMemoryId;
2147
- if (pinned && !existing.pinned) {
2148
- const pinnedCount = await this.memoryStore.countPinnedMemories(selector);
2149
- if (pinnedCount >= this.config.service.memory_max_pinned_per_scope) {
2150
- if (this.config.service.memory_pin_overflow_strategy === 'age-out') {
2151
- const oldest = await this.memoryStore.getOldestPinnedMemory(selector, this.config.service.memory_pin_age_basis);
2152
- if (oldest && oldest.id !== existing.id) {
2153
- await this.memoryStore.setMemoryPinned(selector, oldest.id, false);
2154
- agedOutMemoryTitle = oldest.title;
2155
- agedOutMemoryId = oldest.id;
2156
- await this.auditLog.append({
2157
- type: 'memory.pin.aged_out',
2158
- chat_id: context.chat_id,
2159
- actor_id: context.actor_id,
2160
- project_alias: projectContext.projectAlias,
2161
- scope: target.scope,
2162
- memory_id: oldest.id,
2163
- replaced_by: existing.id,
2164
- });
2165
- }
2166
- else {
2167
- await this.sendTextReply(context.chat_id, `${target.label}置顶数量已达上限 (${this.config.service.memory_max_pinned_per_scope})。请先取消置顶旧记录。`, context.message_id, context.text);
2168
- return;
2169
- }
2170
- }
2171
- else {
2172
- await this.sendTextReply(context.chat_id, `${target.label}置顶数量已达上限 (${this.config.service.memory_max_pinned_per_scope})。请先取消置顶旧记录。`, context.message_id, context.text);
2173
- return;
2174
- }
2175
- }
2176
- }
2177
- const updated = await this.memoryStore.setMemoryPinned(selector, value, pinned);
2178
- if (!updated) {
2179
- await this.sendTextReply(context.chat_id, `未找到可更新的${target.label} ID: ${value}`, context.message_id, context.text);
2180
- return;
2181
- }
2182
- await this.auditLog.append({
2183
- type: pinned ? 'memory.pin' : 'memory.unpin',
2184
- chat_id: context.chat_id,
2185
- actor_id: context.actor_id,
2186
- project_alias: projectContext.projectAlias,
2187
- scope: target.scope,
2188
- memory_id: value,
2189
- });
2190
- await this.sendTextReply(context.chat_id, [
2191
- `${target.label}${pinned ? '已置顶' : '已取消置顶'}: ${updated.title}`,
2192
- `memory_id: ${updated.id}`,
2193
- ...(agedOutMemoryId ? [`已自动老化旧置顶: ${agedOutMemoryTitle} (${agedOutMemoryId})`] : []),
2194
- ].join('\n'), context.message_id, context.text);
2195
- return;
2196
- }
2197
- const content = value?.trim();
2198
- if (!content) {
2199
- await this.sendTextReply(context.chat_id, '用法: /memory save <text> 或 /memory save group <text>', context.message_id, context.text);
2200
- return;
2201
- }
2202
- const target = this.resolveMemoryTarget(context, scope);
2203
- const title = truncateExcerpt(content.replace(/\s+/g, ' ').trim(), 60);
2204
- const expiresAt = this.buildMemoryExpiresAt();
2205
- const saved = await this.memoryStore.saveMemory({
2206
- scope: target.scope,
2207
- project_alias: projectContext.projectAlias,
2208
- chat_id: target.chatId,
2209
- title,
2210
- content,
2211
- tags: filters?.tag ? [filters.tag] : undefined,
2212
- source: filters?.source ?? 'manual',
2213
- created_by: context.actor_id,
2214
- expires_at: expiresAt,
2215
- });
2216
- await this.auditLog.append({
2217
- type: 'memory.save',
2218
- chat_id: context.chat_id,
2219
- actor_id: context.actor_id,
2220
- project_alias: projectContext.projectAlias,
2221
- scope: target.scope,
2222
- memory_id: saved.id,
2223
- title: saved.title,
2224
- });
2225
- await this.sendTextReply(context.chat_id, [
2226
- `项目: ${projectContext.projectAlias}`,
2227
- `已保存${target.label}: ${saved.title}`,
2228
- `memory_id: ${saved.id}`,
2229
- ...(saved.expires_at ? [`expires_at: ${saved.expires_at}`] : []),
2230
- ].join('\n'), context.message_id, context.text);
2231
- }
2232
- catch (error) {
2233
- const message = error instanceof Error ? error.message : String(error);
2234
- await this.sendTextReply(context.chat_id, message, context.message_id, context.text);
2235
- }
2236
- }
2237
- renderMemoryFilterLines(filters) {
2238
- return [
2239
- ...(filters?.tag ? [`tag: ${filters.tag}`] : []),
2240
- ...(filters?.source ? [`source: ${filters.source}`] : []),
2241
- ...(filters?.created_by ? [`created_by: ${filters.created_by}`] : []),
2242
- ];
2243
- }
2244
- async handleKnowledgeCommand(context, selectionKey, action, query) {
2245
- const projectContext = await this.resolveProjectContext(context, selectionKey);
2246
- const roots = await resolveKnowledgeRoots(projectContext.project);
2247
- if (action === 'status') {
2248
- const message = roots.length
2249
- ? [`项目: ${projectContext.projectAlias}`, '知识库目录:', ...roots.map((root) => `- ${root}`)].join('\n')
2250
- : [`项目: ${projectContext.projectAlias}`, '当前没有可用知识库目录。', '可在项目配置中设置 knowledge_paths,或在项目根下提供 docs/README。'].join('\n');
2251
- await this.sendTextReply(context.chat_id, message, context.message_id, context.text);
1191
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
1192
+ return handleMemoryCommandImpl(this, context, projectContext, action, scope, value, filters);
1193
+ }
1194
+ async handleKnowledgeCommand(context, selectionKey, action, query) {
1195
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
1196
+ const roots = await resolveKnowledgeRoots(projectContext.project);
1197
+ if (action === 'status') {
1198
+ const message = roots.length
1199
+ ? [`项目: ${projectContext.projectAlias}`, '知识库目录:', ...roots.map((root) => `- ${root}`)].join('\n')
1200
+ : [`项目: ${projectContext.projectAlias}`, '当前没有可用知识库目录。', '可在项目配置中设置 knowledge_paths,或在项目根下提供 docs/README。'].join('\n');
1201
+ await this.sendTextReply(context.chat_id, message, context.message_id, context.text);
2252
1202
  return;
2253
1203
  }
2254
1204
  if (!query) {
@@ -2282,449 +1232,19 @@ export class FeiqueService {
2282
1232
  }
2283
1233
  async handleDocCommand(context, selectionKey, action, value, extra) {
2284
1234
  const projectContext = await this.resolveProjectContext(context, selectionKey);
2285
- if (!canAccessProject(this.config, projectContext.projectAlias, context.chat_id, action === 'create' ? 'operator' : 'viewer')) {
2286
- await this.sendTextReply(context.chat_id, `当前 chat_id 无权${action === 'create' ? '写入' : '读取'}项目 ${projectContext.projectAlias} 关联的飞书文档。`, context.message_id, context.text);
2287
- return;
2288
- }
2289
- const docClient = new FeishuDocClient(this.feishuClient.createSdkClient());
2290
- if (action === 'create') {
2291
- const title = value?.trim();
2292
- if (!title) {
2293
- await this.sendTextReply(context.chat_id, '用法: /doc create <title>', context.message_id, context.text);
2294
- return;
2295
- }
2296
- const created = await docClient.create(title, extra?.trim());
2297
- await this.auditLog.append({
2298
- type: 'doc.create',
2299
- chat_id: context.chat_id,
2300
- actor_id: context.actor_id,
2301
- project_alias: projectContext.projectAlias,
2302
- document_id: created.documentId,
2303
- title: created.title,
2304
- });
2305
- await this.sendTextReply(context.chat_id, ['已创建飞书文档', `标题: ${created.title ?? title}`, `文档: ${created.documentId}`, ...(created.url ? [`链接: ${created.url}`] : [])].join('\n'), context.message_id, context.text);
2306
- return;
2307
- }
2308
- if (!value) {
2309
- await this.sendTextReply(context.chat_id, '用法: /doc read <url|token>', context.message_id, context.text);
2310
- return;
2311
- }
2312
- const document = await docClient.read(value);
2313
- await this.auditLog.append({
2314
- type: 'doc.read',
2315
- chat_id: context.chat_id,
2316
- actor_id: context.actor_id,
2317
- project_alias: projectContext.projectAlias,
2318
- document_id: document.documentId,
2319
- title: document.title,
2320
- });
2321
- await this.sendTextReply(context.chat_id, [
2322
- `标题: ${document.title ?? '未知'}`,
2323
- `文档: ${document.documentId}`,
2324
- ...(document.url ? [`链接: ${document.url}`] : []),
2325
- '',
2326
- truncateExcerpt(document.content?.replace(/\s+/g, ' ').trim() ?? '文档暂无可读取的纯文本内容。', 1200),
2327
- ].join('\n'), context.message_id, context.text);
1235
+ return handleDocCommandImpl(this, context, projectContext, action, value, extra);
2328
1236
  }
2329
1237
  async handleTaskCommand(context, selectionKey, action, value) {
2330
1238
  const projectContext = await this.resolveProjectContext(context, selectionKey);
2331
- if (!canAccessProject(this.config, projectContext.projectAlias, context.chat_id, action === 'create' || action === 'complete' ? 'operator' : 'viewer')) {
2332
- await this.sendTextReply(context.chat_id, `当前 chat_id 无权${action === 'create' || action === 'complete' ? '写入' : '查看'}项目 ${projectContext.projectAlias} 关联的飞书任务。`, context.message_id, context.text);
2333
- return;
2334
- }
2335
- const taskClient = new FeishuTaskClient(this.feishuClient.createSdkClient());
2336
- if (action === 'list') {
2337
- const limit = clampListLimit(value, 10, 20);
2338
- const tasks = await taskClient.list(limit);
2339
- const lines = tasks.length > 0
2340
- ? tasks.map((task, index) => `${index + 1}. ${task.summary ?? '(无标题)'}\n guid: ${task.guid}\n status: ${task.status ?? 'unknown'}${task.url ? `\n url: ${task.url}` : ''}`)
2341
- : ['当前没有可见任务。'];
2342
- await this.sendTextReply(context.chat_id, ['最近任务', '', ...lines].join('\n'), context.message_id, context.text);
2343
- return;
2344
- }
2345
- if (action === 'get') {
2346
- if (!value) {
2347
- await this.sendTextReply(context.chat_id, '用法: /task get <task_guid>', context.message_id, context.text);
2348
- return;
2349
- }
2350
- const task = await taskClient.get(value);
2351
- await this.auditLog.append({
2352
- type: 'task.read',
2353
- chat_id: context.chat_id,
2354
- actor_id: context.actor_id,
2355
- project_alias: projectContext.projectAlias,
2356
- task_guid: task.guid,
2357
- });
2358
- await this.sendTextReply(context.chat_id, [
2359
- `任务: ${task.summary ?? '(无标题)'}`,
2360
- `guid: ${task.guid}`,
2361
- `status: ${task.status ?? 'unknown'}`,
2362
- ...(task.url ? [`链接: ${task.url}`] : []),
2363
- '',
2364
- task.description ?? '无描述',
2365
- ].join('\n'), context.message_id, context.text);
2366
- return;
2367
- }
2368
- if (action === 'create') {
2369
- const summary = value?.trim();
2370
- if (!summary) {
2371
- await this.sendTextReply(context.chat_id, '用法: /task create <summary>', context.message_id, context.text);
2372
- return;
2373
- }
2374
- const task = await taskClient.create(summary);
2375
- await this.auditLog.append({
2376
- type: 'task.create',
2377
- chat_id: context.chat_id,
2378
- actor_id: context.actor_id,
2379
- project_alias: projectContext.projectAlias,
2380
- task_guid: task.guid,
2381
- summary: task.summary,
2382
- });
2383
- await this.sendTextReply(context.chat_id, [`已创建任务`, `标题: ${task.summary ?? summary}`, `guid: ${task.guid}`, ...(task.url ? [`链接: ${task.url}`] : [])].join('\n'), context.message_id, context.text);
2384
- return;
2385
- }
2386
- if (!value) {
2387
- await this.sendTextReply(context.chat_id, '用法: /task complete <task_guid>', context.message_id, context.text);
2388
- return;
2389
- }
2390
- const task = await taskClient.complete(value);
2391
- await this.auditLog.append({
2392
- type: 'task.complete',
2393
- chat_id: context.chat_id,
2394
- actor_id: context.actor_id,
2395
- project_alias: projectContext.projectAlias,
2396
- task_guid: task.guid,
2397
- summary: task.summary,
2398
- });
2399
- await this.sendTextReply(context.chat_id, [`已完成任务`, `标题: ${task.summary ?? '(无标题)'}`, `guid: ${task.guid}`, `status: ${task.status ?? 'unknown'}`].join('\n'), context.message_id, context.text);
1239
+ return handleTaskCommandImpl(this, context, projectContext, action, value);
2400
1240
  }
2401
1241
  async handleBaseCommand(context, selectionKey, action, appToken, tableId, recordId, value) {
2402
1242
  const projectContext = await this.resolveProjectContext(context, selectionKey);
2403
- if (!canAccessProject(this.config, projectContext.projectAlias, context.chat_id, action === 'create' || action === 'update' ? 'operator' : 'viewer')) {
2404
- await this.sendTextReply(context.chat_id, `当前 chat_id 无权${action === 'create' || action === 'update' ? '写入' : '查看'}项目 ${projectContext.projectAlias} 关联的多维表格。`, context.message_id, context.text);
2405
- return;
2406
- }
2407
- const baseClient = new FeishuBaseClient(this.feishuClient.createSdkClient());
2408
- if (action === 'tables') {
2409
- if (!appToken) {
2410
- await this.sendTextReply(context.chat_id, '用法: /base tables <app_token>', context.message_id, context.text);
2411
- return;
2412
- }
2413
- const tables = await baseClient.listTables(appToken, 20);
2414
- const lines = tables.length > 0
2415
- ? tables.map((table, index) => `${index + 1}. ${table.name ?? '(未命名表)'}\n table_id: ${table.tableId}${table.revision !== undefined ? `\n revision: ${table.revision}` : ''}`)
2416
- : ['当前 Base 中没有可见数据表。'];
2417
- await this.sendTextReply(context.chat_id, [`Base: ${appToken}`, '', ...lines].join('\n'), context.message_id, context.text);
2418
- return;
2419
- }
2420
- if (action === 'records') {
2421
- if (!appToken || !tableId) {
2422
- await this.sendTextReply(context.chat_id, '用法: /base records <app_token> <table_id> [limit]', context.message_id, context.text);
2423
- return;
2424
- }
2425
- const limit = clampListLimit(value, 10, 20);
2426
- const records = await baseClient.listRecords(appToken, tableId, limit);
2427
- const lines = records.length > 0
2428
- ? records.map((record, index) => `${index + 1}. ${record.recordId}\n fields: ${truncateExcerpt(JSON.stringify(record.fields), 240)}${record.recordUrl ? `\n url: ${record.recordUrl}` : ''}`)
2429
- : ['当前数据表没有可见记录。'];
2430
- await this.sendTextReply(context.chat_id, [`Base: ${appToken}`, `Table: ${tableId}`, '', ...lines].join('\n'), context.message_id, context.text);
2431
- return;
2432
- }
2433
- if (action === 'create') {
2434
- if (!appToken || !tableId || !value) {
2435
- await this.sendTextReply(context.chat_id, '用法: /base create <app_token> <table_id> <json>', context.message_id, context.text);
2436
- return;
2437
- }
2438
- const fields = parseJsonObject(value);
2439
- const record = await baseClient.createRecord(appToken, tableId, fields);
2440
- await this.auditLog.append({
2441
- type: 'base.record.create',
2442
- chat_id: context.chat_id,
2443
- actor_id: context.actor_id,
2444
- project_alias: projectContext.projectAlias,
2445
- app_token: appToken,
2446
- table_id: tableId,
2447
- record_id: record.recordId,
2448
- });
2449
- await this.sendTextReply(context.chat_id, [`已创建 Base 记录`, `app: ${appToken}`, `table: ${tableId}`, `record: ${record.recordId}`, `fields: ${truncateExcerpt(JSON.stringify(record.fields), 240)}`].join('\n'), context.message_id, context.text);
2450
- return;
2451
- }
2452
- if (!appToken || !tableId || !recordId || !value) {
2453
- await this.sendTextReply(context.chat_id, '用法: /base update <app_token> <table_id> <record_id> <json>', context.message_id, context.text);
2454
- return;
2455
- }
2456
- const fields = parseJsonObject(value);
2457
- const record = await baseClient.updateRecord(appToken, tableId, recordId, fields);
2458
- await this.auditLog.append({
2459
- type: 'base.record.update',
2460
- chat_id: context.chat_id,
2461
- actor_id: context.actor_id,
2462
- project_alias: projectContext.projectAlias,
2463
- app_token: appToken,
2464
- table_id: tableId,
2465
- record_id: record.recordId,
2466
- });
2467
- await this.sendTextReply(context.chat_id, [`已更新 Base 记录`, `app: ${appToken}`, `table: ${tableId}`, `record: ${record.recordId}`, `fields: ${truncateExcerpt(JSON.stringify(record.fields), 240)}`].join('\n'), context.message_id, context.text);
1243
+ return handleBaseCommandImpl(this, context, projectContext, action, appToken, tableId, recordId, value);
2468
1244
  }
2469
1245
  async handleWikiCommand(context, selectionKey, action, value, extra, target, role) {
2470
1246
  const projectContext = await this.resolveProjectContext(context, selectionKey);
2471
- const wikiClient = new FeishuWikiClient(this.feishuClient.createSdkClient());
2472
- if (action === 'spaces') {
2473
- const spaces = await wikiClient.listSpaces(10);
2474
- const lines = spaces.length > 0
2475
- ? spaces.map((space) => `- ${space.name} (${space.id})${space.description ? ` | ${space.description}` : ''}`)
2476
- : ['当前应用可访问的知识空间为空。请确认机器人已被加入目标空间。'];
2477
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `配置过滤空间数: ${projectContext.project.wiki_space_ids.length}`, '', ...lines].join('\n'), context.message_id, context.text);
2478
- return;
2479
- }
2480
- if (action === 'search') {
2481
- if (!value) {
2482
- await this.sendTextReply(context.chat_id, '用法: /wiki search <query>', context.message_id, context.text);
2483
- return;
2484
- }
2485
- const hits = await wikiClient.search(value, projectContext.project.wiki_space_ids, 5);
2486
- await this.auditLog.append({
2487
- type: 'wiki.search',
2488
- chat_id: context.chat_id,
2489
- actor_id: context.actor_id,
2490
- project_alias: projectContext.projectAlias,
2491
- query: value,
2492
- result_count: hits.length,
2493
- });
2494
- if (hits.length === 0) {
2495
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `飞书知识库搜索: ${value}`, '未找到匹配结果。', '', '提示: 确认机器人有目标空间访问权限,或在项目配置里设置 wiki_space_ids。'].join('\n'), context.message_id, context.text);
2496
- return;
2497
- }
2498
- const lines = hits.map((hit, index) => [
2499
- `${index + 1}. ${hit.title}`,
2500
- ` space: ${hit.spaceId}`,
2501
- ` token: ${hit.objToken}`,
2502
- ...(hit.url ? [` url: ${hit.url}`] : []),
2503
- ].join('\n'));
2504
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `飞书知识库搜索: ${value}`, '', ...lines].join('\n'), context.message_id, context.text);
2505
- return;
2506
- }
2507
- if (action === 'members') {
2508
- const spaceId = value?.trim() || projectContext.project.wiki_space_ids[0];
2509
- if (!spaceId) {
2510
- await this.sendTextReply(context.chat_id, '用法: /wiki members [space_id],或先在项目配置里设置默认 wiki_space_ids。', context.message_id, context.text);
2511
- return;
2512
- }
2513
- const members = await wikiClient.listMembers(spaceId, 20);
2514
- await this.auditLog.append({
2515
- type: 'wiki.members',
2516
- chat_id: context.chat_id,
2517
- actor_id: context.actor_id,
2518
- project_alias: projectContext.projectAlias,
2519
- space_id: spaceId,
2520
- result_count: members.length,
2521
- });
2522
- const lines = members.length > 0
2523
- ? members.map((member, index) => `${index + 1}. ${member.memberId}\n member_type: ${member.memberType}\n role: ${member.memberRole}${member.type ? `\n type: ${member.type}` : ''}`)
2524
- : ['当前知识空间没有可见成员,或机器人没有成员读取权限。'];
2525
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `知识空间成员: ${spaceId}`, '', ...lines].join('\n'), context.message_id, context.text);
2526
- return;
2527
- }
2528
- if (action === 'create') {
2529
- const defaultSpaceId = projectContext.project.wiki_space_ids[0];
2530
- const spaceId = extra ?? defaultSpaceId;
2531
- const title = value?.trim();
2532
- if (!title) {
2533
- await this.sendTextReply(context.chat_id, '用法: /wiki create <title> 或 /wiki create <space_id> <title>', context.message_id, context.text);
2534
- return;
2535
- }
2536
- if (!spaceId) {
2537
- await this.sendTextReply(context.chat_id, '当前项目未配置默认 wiki_space_ids,请使用 `/wiki create <space_id> <title>`。', context.message_id, context.text);
2538
- return;
2539
- }
2540
- const created = await wikiClient.createDoc(spaceId, title);
2541
- await this.auditLog.append({
2542
- type: 'wiki.create',
2543
- chat_id: context.chat_id,
2544
- actor_id: context.actor_id,
2545
- project_alias: projectContext.projectAlias,
2546
- title,
2547
- space_id: created.spaceId,
2548
- obj_token: created.objToken,
2549
- node_token: created.nodeToken,
2550
- });
2551
- await this.sendTextReply(context.chat_id, [
2552
- `项目: ${projectContext.projectAlias}`,
2553
- `已创建飞书文档: ${created.title ?? title}`,
2554
- `空间: ${created.spaceId ?? spaceId}`,
2555
- ...(created.nodeToken ? [`节点: ${created.nodeToken}`] : []),
2556
- ...(created.objToken ? [`文档: ${created.objToken}`] : []),
2557
- ].join('\n'), context.message_id, context.text);
2558
- return;
2559
- }
2560
- if (action === 'grant') {
2561
- const spaceId = extra?.trim();
2562
- const memberType = target?.trim();
2563
- const memberId = value?.trim();
2564
- const memberRole = role?.trim() || 'member';
2565
- if (!spaceId || !memberType || !memberId) {
2566
- await this.sendTextReply(context.chat_id, '用法: /wiki grant <space_id> <member_type> <member_id> [member|admin]', context.message_id, context.text);
2567
- return;
2568
- }
2569
- const granted = await wikiClient.addMember(spaceId, memberType, memberId, memberRole);
2570
- await this.auditLog.append({
2571
- type: 'wiki.member.grant',
2572
- chat_id: context.chat_id,
2573
- actor_id: context.actor_id,
2574
- project_alias: projectContext.projectAlias,
2575
- space_id: spaceId,
2576
- member_id: granted.memberId,
2577
- member_type: granted.memberType,
2578
- member_role: granted.memberRole,
2579
- });
2580
- await this.sendTextReply(context.chat_id, [
2581
- `项目: ${projectContext.projectAlias}`,
2582
- '已添加知识空间成员',
2583
- `空间: ${spaceId}`,
2584
- `member_type: ${granted.memberType}`,
2585
- `member_id: ${granted.memberId}`,
2586
- `role: ${granted.memberRole}`,
2587
- ].join('\n'), context.message_id, context.text);
2588
- return;
2589
- }
2590
- if (action === 'rename') {
2591
- const nodeToken = extra?.trim();
2592
- const title = value?.trim();
2593
- if (!nodeToken || !title) {
2594
- await this.sendTextReply(context.chat_id, '用法: /wiki rename <node_token> <title>', context.message_id, context.text);
2595
- return;
2596
- }
2597
- await wikiClient.renameNode(nodeToken, title, projectContext.project.wiki_space_ids[0]);
2598
- await this.auditLog.append({
2599
- type: 'wiki.rename',
2600
- chat_id: context.chat_id,
2601
- actor_id: context.actor_id,
2602
- project_alias: projectContext.projectAlias,
2603
- node_token: nodeToken,
2604
- title,
2605
- });
2606
- await this.sendTextReply(context.chat_id, [`项目: ${projectContext.projectAlias}`, `已更新知识库节点标题`, `节点: ${nodeToken}`, `标题: ${title}`].join('\n'), context.message_id, context.text);
2607
- return;
2608
- }
2609
- if (action === 'copy') {
2610
- const nodeToken = value?.trim();
2611
- const targetSpaceId = extra?.trim() || projectContext.project.wiki_space_ids[0];
2612
- if (!nodeToken) {
2613
- await this.sendTextReply(context.chat_id, '用法: /wiki copy <node_token> [target_space_id]', context.message_id, context.text);
2614
- return;
2615
- }
2616
- if (!targetSpaceId) {
2617
- await this.sendTextReply(context.chat_id, '当前项目未配置默认 wiki_space_ids,请显式传入 target_space_id。', context.message_id, context.text);
2618
- return;
2619
- }
2620
- const copied = await wikiClient.copyNode(nodeToken, targetSpaceId);
2621
- await this.auditLog.append({
2622
- type: 'wiki.copy',
2623
- chat_id: context.chat_id,
2624
- actor_id: context.actor_id,
2625
- project_alias: projectContext.projectAlias,
2626
- node_token: nodeToken,
2627
- target_space_id: copied.spaceId,
2628
- obj_token: copied.objToken,
2629
- });
2630
- await this.sendTextReply(context.chat_id, [
2631
- `项目: ${projectContext.projectAlias}`,
2632
- `已复制知识库节点`,
2633
- `源节点: ${nodeToken}`,
2634
- `目标空间: ${copied.spaceId ?? targetSpaceId}`,
2635
- ...(copied.nodeToken ? [`新节点: ${copied.nodeToken}`] : []),
2636
- ...(copied.objToken ? [`对象: ${copied.objToken}`] : []),
2637
- ].join('\n'), context.message_id, context.text);
2638
- return;
2639
- }
2640
- if (action === 'move') {
2641
- const sourceSpaceId = extra?.trim();
2642
- const nodeToken = value?.trim();
2643
- const targetSpaceId = target?.trim() || projectContext.project.wiki_space_ids[0];
2644
- if (!sourceSpaceId || !nodeToken) {
2645
- await this.sendTextReply(context.chat_id, '用法: /wiki move <source_space_id> <node_token> [target_space_id]', context.message_id, context.text);
2646
- return;
2647
- }
2648
- if (!targetSpaceId) {
2649
- await this.sendTextReply(context.chat_id, '当前项目未配置默认 wiki_space_ids,请显式传入 target_space_id。', context.message_id, context.text);
2650
- return;
2651
- }
2652
- const moved = await wikiClient.moveNode(sourceSpaceId, nodeToken, targetSpaceId);
2653
- await this.auditLog.append({
2654
- type: 'wiki.move',
2655
- chat_id: context.chat_id,
2656
- actor_id: context.actor_id,
2657
- project_alias: projectContext.projectAlias,
2658
- node_token: nodeToken,
2659
- source_space_id: sourceSpaceId,
2660
- target_space_id: moved.spaceId,
2661
- obj_token: moved.objToken,
2662
- });
2663
- await this.sendTextReply(context.chat_id, [
2664
- `项目: ${projectContext.projectAlias}`,
2665
- `已移动知识库节点`,
2666
- `源空间: ${sourceSpaceId}`,
2667
- `源节点: ${nodeToken}`,
2668
- `目标空间: ${moved.spaceId ?? targetSpaceId}`,
2669
- ...(moved.nodeToken ? [`当前节点: ${moved.nodeToken}`] : []),
2670
- ].join('\n'), context.message_id, context.text);
2671
- return;
2672
- }
2673
- if (action === 'revoke') {
2674
- const spaceId = extra?.trim();
2675
- const memberType = target?.trim();
2676
- const memberId = value?.trim();
2677
- const memberRole = role?.trim() || 'member';
2678
- if (!spaceId || !memberType || !memberId) {
2679
- await this.sendTextReply(context.chat_id, '用法: /wiki revoke <space_id> <member_type> <member_id> [member|admin]', context.message_id, context.text);
2680
- return;
2681
- }
2682
- const revoked = await wikiClient.removeMember(spaceId, memberType, memberId, memberRole);
2683
- await this.auditLog.append({
2684
- type: 'wiki.member.revoke',
2685
- chat_id: context.chat_id,
2686
- actor_id: context.actor_id,
2687
- project_alias: projectContext.projectAlias,
2688
- space_id: spaceId,
2689
- member_id: revoked.memberId,
2690
- member_type: revoked.memberType,
2691
- member_role: revoked.memberRole,
2692
- });
2693
- await this.sendTextReply(context.chat_id, [
2694
- `项目: ${projectContext.projectAlias}`,
2695
- '已移除知识空间成员',
2696
- `空间: ${spaceId}`,
2697
- `member_type: ${revoked.memberType}`,
2698
- `member_id: ${revoked.memberId}`,
2699
- `role: ${revoked.memberRole}`,
2700
- ].join('\n'), context.message_id, context.text);
2701
- return;
2702
- }
2703
- if (!value) {
2704
- await this.sendTextReply(context.chat_id, '用法: /wiki read <url|token>', context.message_id, context.text);
2705
- return;
2706
- }
2707
- const result = await wikiClient.read(value);
2708
- await this.auditLog.append({
2709
- type: 'wiki.read',
2710
- chat_id: context.chat_id,
2711
- actor_id: context.actor_id,
2712
- project_alias: projectContext.projectAlias,
2713
- target: value,
2714
- obj_type: result.objType,
2715
- obj_token: result.objToken,
2716
- });
2717
- const summary = result.content ? truncateExcerpt(result.content.replace(/\s+/g, ' ').trim(), 1200) : '当前对象不是 docx 文档,暂不支持直接拉取纯文本内容。';
2718
- await this.sendTextReply(context.chat_id, [
2719
- `项目: ${projectContext.projectAlias}`,
2720
- `标题: ${result.title ?? '未知'}`,
2721
- `类型: ${result.objType ?? '未知'}`,
2722
- ...(result.spaceId ? [`空间: ${result.spaceId}`] : []),
2723
- ...(result.objToken ? [`对象: ${result.objToken}`] : []),
2724
- ...(result.url ? [`链接: ${result.url}`] : []),
2725
- '',
2726
- summary,
2727
- ].join('\n'), context.message_id, context.text);
1247
+ return handleWikiCommandImpl(this, context, projectContext, action, value, extra, target, role);
2728
1248
  }
2729
1249
  async buildProjectsText(selectionKey, chatId) {
2730
1250
  const selected = await this.resolveProjectAlias(selectionKey);
@@ -2787,11 +1307,11 @@ export class FeiqueService {
2787
1307
  const session = conversation?.projects[projectAlias];
2788
1308
  const sessionCount = Object.keys(session?.sessions ?? {}).length;
2789
1309
  const isExecutableRun = activeRun ? isExecutionRunStatus(activeRun.status) : false;
2790
- const includeActions = this.supportsInteractiveCardActions();
1310
+ const includeActions = supportsInteractiveCardActions(this.config);
2791
1311
  const actionChatId = conversation?.chat_id ?? activeRun?.chat_id ?? fallbackChatId;
2792
1312
  return buildStatusCard({
2793
1313
  title: '当前会话状态',
2794
- summary: this.buildRunStatusSummary(session?.last_response_excerpt, activeRun),
1314
+ summary: buildRunStatusSummary(session?.last_response_excerpt, activeRun),
2795
1315
  projectAlias,
2796
1316
  sessionId: session?.thread_id,
2797
1317
  runStatus: activeRun?.status,
@@ -2962,32 +1482,6 @@ export class FeiqueService {
2962
1482
  shouldRequireMention(project) {
2963
1483
  return project.mention_required || this.config.security.require_group_mentions;
2964
1484
  }
2965
- resolveMemoryTarget(context, requestedScope) {
2966
- if (requestedScope === 'group') {
2967
- if (!this.config.service.memory_group_enabled) {
2968
- throw new Error('群共享记忆未启用。请在配置中设置 `service.memory_group_enabled = true`。');
2969
- }
2970
- if (context.chat_type !== 'group') {
2971
- throw new Error('群共享记忆只能在群聊中使用。');
2972
- }
2973
- return {
2974
- scope: 'group',
2975
- chatId: context.chat_id,
2976
- label: '群共享记忆',
2977
- };
2978
- }
2979
- return {
2980
- scope: 'project',
2981
- label: '项目记忆',
2982
- };
2983
- }
2984
- buildMemoryExpiresAt() {
2985
- const ttlDays = this.config.service.memory_default_ttl_days;
2986
- if (!ttlDays) {
2987
- return undefined;
2988
- }
2989
- return new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
2990
- }
2991
1485
  async cancelActiveRun(queueKey, reason) {
2992
1486
  const live = this.activeRuns.get(queueKey);
2993
1487
  if (live) {
@@ -3017,112 +1511,7 @@ export class FeiqueService {
3017
1511
  return terminateProcess(persisted.pid, 'SIGTERM');
3018
1512
  }
3019
1513
  async scheduleProjectExecution(projectContext, metadata, task) {
3020
- const runId = randomUUID();
3021
- const queued = await this.prepareQueuedExecution(projectContext, metadata, runId);
3022
- const rootKey = buildProjectRootQueueKey(projectContext.project.root);
3023
- const startGate = createDeferred();
3024
- // Record queue depth when enqueuing
3025
- this.metrics?.recordQueueDepth(projectContext.projectAlias, this.queue.getPendingCount(projectContext.queueKey) + 1);
3026
- return {
3027
- runId,
3028
- queued,
3029
- release: () => startGate.resolve(),
3030
- completion: this.queue.run(projectContext.queueKey, async () => {
3031
- await this.projectRootQueue.run(rootKey, async () => {
3032
- await startGate.promise;
3033
- await task(runId);
3034
- }, { priority: projectContext.project.run_priority });
3035
- // Record queue depth after dequeue
3036
- this.metrics?.recordQueueDepth(projectContext.projectAlias, this.queue.getPendingCount(projectContext.queueKey));
3037
- }),
3038
- };
3039
- }
3040
- async prepareQueuedExecution(projectContext, metadata, runId) {
3041
- const queuePending = this.queue.getPendingCount(projectContext.queueKey);
3042
- const rootKey = buildProjectRootQueueKey(projectContext.project.root);
3043
- const rootPending = this.projectRootQueue.getPendingCount(rootKey);
3044
- if (queuePending <= 0 && rootPending <= 0) {
3045
- return null;
3046
- }
3047
- const projectRoot = this.resolveProjectRoot(projectContext.project);
3048
- const reason = queuePending > 0 ? 'project' : 'project-root';
3049
- const frontCount = reason === 'project' ? queuePending : rootPending;
3050
- const blockingRun = reason === 'project'
3051
- ? await this.runStateStore.getActiveRun(projectContext.queueKey)
3052
- : await this.runStateStore.getExecutionRunByProjectRoot(projectRoot);
3053
- const detail = this.buildQueuedStatusDetail(projectContext.projectAlias, reason, frontCount, blockingRun);
3054
- await this.runStateStore.upsertRun(runId, {
3055
- queue_key: projectContext.queueKey,
3056
- conversation_key: projectContext.sessionKey,
3057
- project_alias: projectContext.projectAlias,
3058
- chat_id: metadata.chatId,
3059
- actor_id: metadata.actorId,
3060
- actor_name: metadata.actorName,
3061
- project_root: projectRoot,
3062
- prompt_excerpt: truncateExcerpt(metadata.prompt),
3063
- status: 'queued',
3064
- status_detail: detail,
3065
- });
3066
- await this.auditLog.append({
3067
- type: 'codex.run.queued',
3068
- run_id: runId,
3069
- chat_id: metadata.chatId,
3070
- actor_id: metadata.actorId,
3071
- project_alias: projectContext.projectAlias,
3072
- conversation_key: projectContext.sessionKey,
3073
- project_root: projectRoot,
3074
- queue_reason: reason,
3075
- blocking_run_id: blockingRun?.run_id,
3076
- front_count: frontCount,
3077
- });
3078
- this.logger.warn({
3079
- runId,
3080
- queueKey: projectContext.queueKey,
3081
- sessionKey: projectContext.sessionKey,
3082
- projectAlias: projectContext.projectAlias,
3083
- projectRoot,
3084
- reason,
3085
- frontCount,
3086
- blockingStatus: blockingRun?.status,
3087
- blockingProjectAlias: blockingRun?.project_alias,
3088
- }, 'Codex run queued');
3089
- return {
3090
- runId,
3091
- detail,
3092
- reason,
3093
- };
3094
- }
3095
- buildAcknowledgedRunReply(projectAlias, phase, detail, mode) {
3096
- if (mode === 'text') {
3097
- return [`项目: ${projectAlias}`, `状态: ${phase}`, '', detail].join('\n');
3098
- }
3099
- return detail;
3100
- }
3101
- buildQueuedStatusDetail(projectAlias, reason, frontCount, blockingRun) {
3102
- const lines = [
3103
- reason === 'project' ? `当前项目 ${projectAlias} 已有任务在处理,已进入排队。` : '当前仓库正在被其他会话操作,已进入排队。',
3104
- frontCount > 0 ? `前方还有 ${frontCount} 个任务。` : null,
3105
- ];
3106
- if (blockingRun) {
3107
- const actorName = blockingRun.actor_name ?? blockingRun.actor_id ?? '其他成员';
3108
- lines.push(`当前执行: ${actorName}`);
3109
- const elapsedMs = Date.now() - new Date(blockingRun.started_at).getTime();
3110
- const elapsedMin = Math.round(elapsedMs / 60_000);
3111
- if (elapsedMin > 0) {
3112
- lines.push(`已运行: ${elapsedMin} 分钟`);
3113
- }
3114
- if (reason === 'project-root' && blockingRun.project_alias && blockingRun.project_alias !== projectAlias) {
3115
- lines.push(`占用项目: ${blockingRun.project_alias}`);
3116
- }
3117
- }
3118
- lines.push(`排队时间: ${new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`);
3119
- return lines.filter(Boolean).join('\n');
3120
- }
3121
- buildRunStatusSummary(lastResponseExcerpt, activeRun) {
3122
- if (activeRun?.status === 'queued' && activeRun.status_detail) {
3123
- return [activeRun.status_detail, lastResponseExcerpt ? `\n上一轮摘要:\n${lastResponseExcerpt}` : null].filter(Boolean).join('\n');
3124
- }
3125
- return lastResponseExcerpt ?? '暂无会话摘要。';
1514
+ return scheduleProjectExecutionImpl(this, projectContext, metadata, task);
3126
1515
  }
3127
1516
  isAdminChat(chatId) {
3128
1517
  return this.config.security.admin_chat_ids.includes(chatId);
@@ -3236,9 +1625,6 @@ export class FeiqueService {
3236
1625
  canMutateRuntimeConfig(chatId) {
3237
1626
  return canAccessGlobalCapability(this.config, chatId, 'config:mutate');
3238
1627
  }
3239
- canReadConfigHistory(chatId) {
3240
- return canAccessGlobalCapability(this.config, chatId, 'config:history') || this.canMutateRuntimeConfig(chatId);
3241
- }
3242
1628
  canAccessAdminCommand(command, currentProjectAlias, authorizedProjectAliases, operatorProjectAliases, globalCapabilities) {
3243
1629
  if (command.resource === 'project') {
3244
1630
  if (command.action === 'list') {
@@ -3267,56 +1653,7 @@ export class FeiqueService {
3267
1653
  return true;
3268
1654
  }
3269
1655
  async handleAdminConfigCommand(context, command) {
3270
- if (command.action === 'history' && !this.canReadConfigHistory(context.chat_id)) {
3271
- await this.sendTextReply(context.chat_id, '当前 chat_id 无权查看配置历史。', context.message_id, context.text);
3272
- return;
3273
- }
3274
- if (command.action === 'rollback' && !this.canMutateRuntimeConfig(context.chat_id)) {
3275
- await this.sendTextReply(context.chat_id, '当前 chat_id 无权回滚配置。', context.message_id, context.text);
3276
- return;
3277
- }
3278
- if (!this.runtimeControl?.configPath) {
3279
- await this.sendTextReply(context.chat_id, '当前运行实例没有可写配置路径,无法执行配置历史操作。', context.message_id, context.text);
3280
- return;
3281
- }
3282
- if (command.action === 'history') {
3283
- const snapshots = await this.configHistoryStore.listSnapshots();
3284
- if (snapshots.length === 0) {
3285
- await this.sendTextReply(context.chat_id, '当前没有可回滚的配置快照。', context.message_id, context.text);
3286
- return;
3287
- }
3288
- const lines = ['最近配置快照:'];
3289
- for (const snapshot of snapshots) {
3290
- lines.push(`- ${snapshot.id} | ${snapshot.at} | ${snapshot.action}${snapshot.summary ? ` | ${snapshot.summary}` : ''}`);
3291
- }
3292
- await this.sendTextReply(context.chat_id, lines.join('\n'), context.message_id, context.text);
3293
- return;
3294
- }
3295
- const target = await this.configHistoryStore.getSnapshot(command.value);
3296
- if (!target) {
3297
- await this.sendTextReply(context.chat_id, '未找到指定配置快照。可先执行 `/admin config history`。', context.message_id, context.text);
3298
- return;
3299
- }
3300
- const rollbackSnapshot = await this.snapshotConfigForAdminMutation(context, 'config.rollback', `rollback -> ${target.id}`);
3301
- const previousContent = rollbackSnapshot.content;
3302
- try {
3303
- await writeUtf8Atomic(this.runtimeControl.configPath, target.content);
3304
- await this.reloadRuntimeConfigFromDisk(this.runtimeControl.configPath);
3305
- }
3306
- catch (error) {
3307
- await writeUtf8Atomic(this.runtimeControl.configPath, previousContent);
3308
- await this.reloadRuntimeConfigFromDisk(this.runtimeControl.configPath);
3309
- throw error;
3310
- }
3311
- await this.appendAdminAudit({
3312
- type: 'admin.config.rollback',
3313
- chat_id: context.chat_id,
3314
- actor_id: context.actor_id,
3315
- target_snapshot_id: target.id,
3316
- snapshot_id: rollbackSnapshot.id,
3317
- config_path: this.runtimeControl.configPath,
3318
- });
3319
- await this.sendTextReply(context.chat_id, `已回滚配置。\n目标快照: ${target.id}\n回滚前快照: ${rollbackSnapshot.id}\n如需生效到某些运行时状态,请再执行 /admin service restart。`, context.message_id, context.text);
1656
+ return handleAdminConfigCommandImpl(this, context, command);
3320
1657
  }
3321
1658
  async snapshotConfigForAdminMutation(context, action, summary) {
3322
1659
  if (!this.runtimeControl?.configPath) {
@@ -3343,65 +1680,6 @@ export class FeiqueService {
3343
1680
  replaceObject(this.config.feishu, nextConfig.feishu);
3344
1681
  replaceProjects(this.config.projects, nextConfig.projects);
3345
1682
  }
3346
- parseProjectPatch(field, value) {
3347
- switch (field) {
3348
- case 'root':
3349
- return { root: value };
3350
- case 'profile':
3351
- return { profile: value };
3352
- case 'sandbox':
3353
- if (value === 'read-only' || value === 'workspace-write' || value === 'danger-full-access') {
3354
- return { sandbox: value };
3355
- }
3356
- return null;
3357
- case 'session_scope':
3358
- if (value === 'chat' || value === 'chat-user') {
3359
- return { session_scope: value };
3360
- }
3361
- return null;
3362
- case 'mention_required':
3363
- if (value === 'true' || value === 'false') {
3364
- return { mention_required: value === 'true' };
3365
- }
3366
- return null;
3367
- case 'description':
3368
- return { description: value };
3369
- case 'viewer_chat_ids':
3370
- return { viewer_chat_ids: splitCommaSeparatedValues(value) };
3371
- case 'operator_chat_ids':
3372
- return { operator_chat_ids: splitCommaSeparatedValues(value) };
3373
- case 'admin_chat_ids':
3374
- return { admin_chat_ids: splitCommaSeparatedValues(value) };
3375
- case 'session_operator_chat_ids':
3376
- return { session_operator_chat_ids: splitCommaSeparatedValues(value) };
3377
- case 'run_operator_chat_ids':
3378
- return { run_operator_chat_ids: splitCommaSeparatedValues(value) };
3379
- case 'config_admin_chat_ids':
3380
- return { config_admin_chat_ids: splitCommaSeparatedValues(value) };
3381
- case 'download_dir':
3382
- return { download_dir: value };
3383
- case 'temp_dir':
3384
- return { temp_dir: value };
3385
- case 'cache_dir':
3386
- return { cache_dir: value };
3387
- case 'log_dir':
3388
- return { log_dir: value };
3389
- case 'run_priority': {
3390
- const parsed = Number(value);
3391
- return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000 ? { run_priority: parsed } : null;
3392
- }
3393
- case 'chat_rate_limit_window_seconds': {
3394
- const parsed = Number(value);
3395
- return Number.isInteger(parsed) && parsed > 0 ? { chat_rate_limit_window_seconds: parsed } : null;
3396
- }
3397
- case 'chat_rate_limit_max_runs': {
3398
- const parsed = Number(value);
3399
- return Number.isInteger(parsed) && parsed > 0 ? { chat_rate_limit_max_runs: parsed } : null;
3400
- }
3401
- default:
3402
- return null;
3403
- }
3404
- }
3405
1683
  resolveProjectDownloadDir(projectAlias, project) {
3406
1684
  return getProjectDownloadsDir(this.config.storage.dir, projectAlias, project);
3407
1685
  }
@@ -3425,28 +1703,6 @@ export class FeiqueService {
3425
1703
  catch { /* best-effort */ }
3426
1704
  }
3427
1705
  }
3428
- listManagedAuditTargets() {
3429
- const targets = [
3430
- {
3431
- stateDir: this.config.storage.dir,
3432
- fileName: 'audit.jsonl',
3433
- archiveDir: path.join(this.config.storage.dir, 'archive'),
3434
- },
3435
- {
3436
- stateDir: this.config.storage.dir,
3437
- fileName: 'admin-audit.jsonl',
3438
- archiveDir: path.join(this.config.storage.dir, 'archive'),
3439
- },
3440
- ];
3441
- for (const [alias, project] of Object.entries(this.config.projects)) {
3442
- targets.push({
3443
- stateDir: getProjectAuditDir(this.config.storage.dir, alias, project),
3444
- fileName: path.basename(getProjectAuditFile(this.config.storage.dir, alias, project)),
3445
- archiveDir: getProjectArchiveDir(this.config.storage.dir, alias),
3446
- });
3447
- }
3448
- return targets;
3449
- }
3450
1706
  checkAndConsumeChatRateLimit(projectAlias, project, chatId) {
3451
1707
  const windowMs = project.chat_rate_limit_window_seconds * 1000;
3452
1708
  const maxRuns = project.chat_rate_limit_max_runs;
@@ -3500,12 +1756,108 @@ export class FeiqueService {
3500
1756
  }
3501
1757
  this.config.feishu.allowed_chat_ids = values;
3502
1758
  }
3503
- resolveProjectRoot(project) {
3504
- return path.resolve(project.root);
3505
- }
3506
1759
  resolveBackendByName(projectAlias, sessionOverride) {
3507
1760
  return resolveProjectBackendWithOverride(this.config, projectAlias, sessionOverride, this.codexSessionIndex);
3508
1761
  }
1762
+ /**
1763
+ * Called when resolveProjectBackendWithFailover returns a non-null failover.
1764
+ * Responsibilities:
1765
+ * - Log a warning with structured context
1766
+ * - Emit an audit event
1767
+ * - Notify admin chats (deduped by from→to direction for the process lifetime)
1768
+ * - Send a user-visible notice into the current chat so the user knows
1769
+ * why their run is on a different backend than expected
1770
+ */
1771
+ async handleBackendFailover(chatId, projectAlias, runId, info) {
1772
+ this.logger.warn({ projectAlias, runId, from: info.from, to: info.to, reason: info.reason }, 'Backend failover: primary probe failed, switched to alternate');
1773
+ try {
1774
+ await this.auditLog.append({
1775
+ type: 'backend.failover',
1776
+ project_alias: projectAlias,
1777
+ run_id: runId,
1778
+ from: info.from,
1779
+ to: info.to,
1780
+ reason: info.reason,
1781
+ });
1782
+ }
1783
+ catch { /* best-effort */ }
1784
+ const userNotice = `⚠️ ${info.from} 不可用,已临时切换到 ${info.to} 运行本次请求。\n原因: ${info.reason}`;
1785
+ try {
1786
+ await this.feishuClient.sendText(chatId, userNotice);
1787
+ }
1788
+ catch { /* best-effort */ }
1789
+ const dedupeKey = `${info.from}->${info.to}`;
1790
+ if (this.failoverNotified.has(dedupeKey))
1791
+ return;
1792
+ this.failoverNotified.add(dedupeKey);
1793
+ const adminChatIds = this.config.security.admin_chat_ids ?? [];
1794
+ if (adminChatIds.length === 0)
1795
+ return;
1796
+ const adminText = `🔁 Backend failover 已触发\n\n` +
1797
+ `方向: ${info.from} → ${info.to}\n` +
1798
+ `项目: ${projectAlias}\n` +
1799
+ `原因: ${info.reason}\n\n` +
1800
+ `后续同方向的切换不会重复通知,直到服务重启。`;
1801
+ for (const adminChat of adminChatIds) {
1802
+ try {
1803
+ await this.feishuClient.sendText(adminChat, adminText);
1804
+ }
1805
+ catch { /* best-effort */ }
1806
+ }
1807
+ }
1808
+ /**
1809
+ * Called by the transport layer when an incoming chat is rejected by the
1810
+ * allowlist (`feishu.allowed_chat_ids` / `allowed_group_ids`).
1811
+ *
1812
+ * Replaces the previous "silent drop" behavior with a pairing-style
1813
+ * experience: tell the user what their chat_id is so an admin can add it,
1814
+ * notify admins that a new chat is knocking, and record the rejection in
1815
+ * the audit log. Deduped per chat_id for the process lifetime so repeated
1816
+ * attempts from the same unauthorized chat do not spam either side.
1817
+ *
1818
+ * This is best-effort: any send failure is swallowed. We never want a
1819
+ * rejection flow to throw out of the transport dispatcher.
1820
+ */
1821
+ async handleRejectedChat(chatId, chatType) {
1822
+ if (this.rejectedChatNotified.has(chatId))
1823
+ return;
1824
+ this.rejectedChatNotified.add(chatId);
1825
+ try {
1826
+ await this.auditLog.append({
1827
+ type: 'chat.rejected',
1828
+ chat_id: chatId,
1829
+ chat_type: chatType,
1830
+ });
1831
+ }
1832
+ catch { /* best-effort */ }
1833
+ const listHint = chatType === 'group'
1834
+ ? '`feishu.allowed_group_ids`(群聊)'
1835
+ : '`feishu.allowed_chat_ids`(私聊)';
1836
+ const userText = `抱歉,该 chat 未被授权访问本 bot。\n` +
1837
+ `你的 chat_id: ${chatId}\n\n` +
1838
+ `请联系管理员并附上这个 id,请求加入 ${listHint}。`;
1839
+ try {
1840
+ await this.feishuClient.sendText(chatId, userText);
1841
+ }
1842
+ catch { /* best-effort */ }
1843
+ const adminChatIds = this.config.security.admin_chat_ids ?? [];
1844
+ if (adminChatIds.length === 0)
1845
+ return;
1846
+ const adminCommand = chatType === 'group'
1847
+ ? `/admin group add ${chatId}`
1848
+ : `/admin chat add ${chatId}`;
1849
+ const adminText = `🔔 新 chat 请求接入\n\n` +
1850
+ `chat_id: ${chatId}\n` +
1851
+ `类型: ${chatType}\n\n` +
1852
+ `如需授权,运行:\n${adminCommand}\n\n` +
1853
+ `后续来自同一 chat 的请求不会重复通知,直到服务重启。`;
1854
+ for (const adminChat of adminChatIds) {
1855
+ try {
1856
+ await this.feishuClient.sendText(adminChat, adminText);
1857
+ }
1858
+ catch { /* best-effort */ }
1859
+ }
1860
+ }
3509
1861
  async enforceSessionHistoryLimit(conversationKey, projectAlias) {
3510
1862
  const sessions = await this.sessionStore.listProjectSessions(conversationKey, projectAlias);
3511
1863
  const overflow = sessions.slice(this.config.service.session_history_limit);
@@ -3522,11 +1874,11 @@ export class FeiqueService {
3522
1874
  mentionPrefix = `<at user_id="${actor.actor_id}">${displayName}</at>\n`;
3523
1875
  }
3524
1876
  const bodyWithMention = mentionPrefix ? mentionPrefix + body : body;
3525
- const title = this.buildReplyTitle(this.sanitizeUserVisibleReply(body));
1877
+ const title = buildReplyTitle(sanitizeUserVisibleReply(body));
3526
1878
  // Card mode uses replyToMessageId for threading — @mention tags render as
3527
1879
  // literal text inside card JSON, so use the clean body for cards.
3528
- const formattedBodyClean = this.sanitizeUserVisibleReply(this.formatQuotedReply(body, originalText));
3529
- const formattedBodyWithMention = this.sanitizeUserVisibleReply(this.formatQuotedReply(bodyWithMention, originalText));
1880
+ const formattedBodyClean = sanitizeUserVisibleReply(formatQuotedReply(body, originalText));
1881
+ const formattedBodyWithMention = sanitizeUserVisibleReply(formatQuotedReply(bodyWithMention, originalText));
3530
1882
  if (this.config.service.reply_mode === 'card') {
3531
1883
  const card = buildMessageCard({
3532
1884
  title,
@@ -3568,7 +1920,7 @@ export class FeiqueService {
3568
1920
  return response;
3569
1921
  }
3570
1922
  if (this.config.service.reply_quote_user_message && replyToMessageId) {
3571
- const response = await this.feishuClient.sendText(chatId, this.sanitizeUserVisibleReply(bodyWithMention), { replyToMessageId });
1923
+ const response = await this.feishuClient.sendText(chatId, sanitizeUserVisibleReply(bodyWithMention), { replyToMessageId });
3572
1924
  await this.auditLog.append({
3573
1925
  type: 'message.replied',
3574
1926
  chat_id: chatId,
@@ -3594,10 +1946,10 @@ export class FeiqueService {
3594
1946
  return this.feishuClient.sendCard(chatId, card);
3595
1947
  }
3596
1948
  async sendRunLifecycleReply(input) {
3597
- const lifecycleMode = this.resolveRunLifecycleReplyMode();
1949
+ const lifecycleMode = resolveRunLifecycleReplyMode(this.config);
3598
1950
  const lifecycleReplyOptions = input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : undefined;
3599
1951
  if (lifecycleMode === 'card') {
3600
- const card = this.buildRunLifecycleCard({
1952
+ const card = buildRunLifecycleCard({
3601
1953
  title: input.title,
3602
1954
  body: input.body,
3603
1955
  projectAlias: input.projectAlias,
@@ -3618,8 +1970,8 @@ export class FeiqueService {
3618
1970
  return response;
3619
1971
  }
3620
1972
  if (lifecycleMode === 'post') {
3621
- const postBody = this.sanitizeUserVisibleReply(this.formatQuotedReply(input.body, input.originalText));
3622
- const title = this.buildReplyTitle(postBody);
1973
+ const postBody = sanitizeUserVisibleReply(formatQuotedReply(input.body, input.originalText));
1974
+ const title = buildReplyTitle(postBody);
3623
1975
  const post = buildFeishuPost(title, postBody);
3624
1976
  const response = lifecycleReplyOptions
3625
1977
  ? await this.feishuClient.sendPost(input.chatId, post, lifecycleReplyOptions)
@@ -3635,7 +1987,7 @@ export class FeiqueService {
3635
1987
  return response;
3636
1988
  }
3637
1989
  const response = lifecycleReplyOptions
3638
- ? await this.feishuClient.sendText(input.chatId, this.sanitizeUserVisibleReply(this.formatQuotedReply(input.body, input.originalText)), lifecycleReplyOptions)
1990
+ ? await this.feishuClient.sendText(input.chatId, sanitizeUserVisibleReply(formatQuotedReply(input.body, input.originalText)), lifecycleReplyOptions)
3639
1991
  : await this.sendTextReply(input.chatId, input.body, input.replyToMessageId, input.originalText);
3640
1992
  await this.auditLog.append({
3641
1993
  type: 'codex.run.replied',
@@ -3651,20 +2003,20 @@ export class FeiqueService {
3651
2003
  if (queued) {
3652
2004
  return {
3653
2005
  title: '已加入排队',
3654
- body: this.buildAcknowledgedRunReply(projectAlias, '排队中', queued.detail, mode),
2006
+ body: buildAcknowledgedRunReply(projectAlias, '排队中', queued.detail, mode),
3655
2007
  runStatus: 'queued',
3656
2008
  runPhase: '排队中',
3657
2009
  };
3658
2010
  }
3659
2011
  return {
3660
2012
  title: '已接收请求',
3661
- body: this.buildAcknowledgedRunReply(projectAlias, '已接收', '已收到你的消息,正在准备处理。', mode),
2013
+ body: buildAcknowledgedRunReply(projectAlias, '已接收', '已收到你的消息,正在准备处理。', mode),
3662
2014
  runStatus: 'running',
3663
2015
  runPhase: '已接收',
3664
2016
  };
3665
2017
  }
3666
2018
  async sendInitialRunLifecycleReply(input) {
3667
- const lifecycleMode = this.resolveRunLifecycleReplyMode();
2019
+ const lifecycleMode = resolveRunLifecycleReplyMode(this.config);
3668
2020
  const draft = this.buildInitialRunLifecycleReply(input.projectAlias, input.queued, lifecycleMode);
3669
2021
  try {
3670
2022
  const response = await this.sendRunLifecycleReply({
@@ -3684,7 +2036,7 @@ export class FeiqueService {
3684
2036
  this.logger.warn({ error, runId: input.runId, projectAlias: input.projectAlias }, 'Failed to send initial lifecycle reply');
3685
2037
  }
3686
2038
  }
3687
- async rememberRunReplyTarget(runId, response, mode = this.resolveRunLifecycleReplyMode()) {
2039
+ async rememberRunReplyTarget(runId, response, mode = resolveRunLifecycleReplyMode(this.config)) {
3688
2040
  this.runReplyTargets.set(runId, {
3689
2041
  messageId: response.message_id,
3690
2042
  mode,
@@ -3696,7 +2048,7 @@ export class FeiqueService {
3696
2048
  return;
3697
2049
  }
3698
2050
  const label = backendLabel ?? 'AI';
3699
- const body = this.buildAcknowledgedRunReply(projectAlias, '处理中', '桥接器已开始处理你的请求。', target.mode);
2051
+ const body = buildAcknowledgedRunReply(projectAlias, '处理中', '桥接器已开始处理你的请求。', target.mode);
3700
2052
  await this.updateRunLifecycleReply({
3701
2053
  chatId,
3702
2054
  projectAlias,
@@ -3714,7 +2066,7 @@ export class FeiqueService {
3714
2066
  }
3715
2067
  const label = backendLabel ?? 'AI';
3716
2068
  const body = [
3717
- this.buildAcknowledgedRunReply(input.projectAlias, '处理中', '桥接器正在持续处理你的请求。', target.mode),
2069
+ buildAcknowledgedRunReply(input.projectAlias, '处理中', '桥接器正在持续处理你的请求。', target.mode),
3718
2070
  '最新进展:',
3719
2071
  progress,
3720
2072
  ]
@@ -3766,10 +2118,10 @@ export class FeiqueService {
3766
2118
  if (!target?.messageId) {
3767
2119
  return false;
3768
2120
  }
3769
- const sanitizedBody = this.sanitizeUserVisibleReply(input.body);
2121
+ const sanitizedBody = sanitizeUserVisibleReply(input.body);
3770
2122
  if (target.mode === 'card') {
3771
- const includeActions = input.runStatus === 'success' && this.supportsInteractiveCardActions() && input.sessionKey !== undefined;
3772
- await this.feishuClient.updateCard(target.messageId, this.buildRunLifecycleCard({
2123
+ const includeActions = input.runStatus === 'success' && supportsInteractiveCardActions(this.config) && input.sessionKey !== undefined;
2124
+ await this.feishuClient.updateCard(target.messageId, buildRunLifecycleCard({
3773
2125
  title: input.title,
3774
2126
  body: input.body,
3775
2127
  projectAlias: input.projectAlias,
@@ -3804,7 +2156,7 @@ export class FeiqueService {
3804
2156
  }));
3805
2157
  }
3806
2158
  else if (target.mode === 'post') {
3807
- const title = this.buildReplyTitle(sanitizedBody);
2159
+ const title = buildReplyTitle(sanitizedBody);
3808
2160
  await this.feishuClient.updatePost(target.messageId, buildFeishuPost(title, sanitizedBody));
3809
2161
  }
3810
2162
  else {
@@ -3830,332 +2182,9 @@ export class FeiqueService {
3830
2182
  });
3831
2183
  return true;
3832
2184
  }
3833
- formatQuotedReply(body, originalText) {
3834
- return body;
3835
- }
3836
- buildReplyTitle(body) {
3837
- const firstLine = body
3838
- .split(/\r?\n/)
3839
- .map((line) => line.trim())
3840
- .find(Boolean);
3841
- return truncateExcerpt(firstLine ?? '飞鹊 (Feique)', 40);
3842
- }
3843
- sanitizeUserVisibleReply(body) {
3844
- return body
3845
- .split(/\r?\n/)
3846
- .filter((line) => !/^(运行|当前运行|阻塞运行|run[_ -]?id|session[_ -]?id|conversation[_ -]?key|chat[_ -]?id|tenant[_ -]?key|project[_ -]?root|pid):/i.test(line.trim()))
3847
- .join('\n')
3848
- .replace(/\n{3,}/g, '\n\n')
3849
- .trim();
3850
- }
3851
- supportsInteractiveCardActions() {
3852
- return this.config.feishu.transport === 'webhook';
3853
- }
3854
- resolveRunLifecycleReplyMode() {
3855
- if (this.config.service.reply_mode === 'post') {
3856
- return 'card';
3857
- }
3858
- return this.config.service.reply_mode;
3859
- }
3860
- buildRunLifecycleCard(input) {
3861
- const sanitizedBody = this.sanitizeUserVisibleReply(input.body);
3862
- if (input.includeActions) {
3863
- return buildStatusCard({
3864
- title: input.title,
3865
- summary: input.cardSummary ?? truncateForFeishuCard(this.stripLifecycleMetadata(sanitizedBody)),
3866
- projectAlias: input.projectAlias,
3867
- runStatus: input.runStatus,
3868
- runPhase: input.runPhase,
3869
- includeActions: true,
3870
- rerunPayload: input.rerunPayload,
3871
- newSessionPayload: input.newSessionPayload,
3872
- statusPayload: input.statusPayload,
3873
- cancelPayload: input.cancelPayload,
3874
- });
3875
- }
3876
- return buildMessageCard({
3877
- title: input.title,
3878
- body: this.stripLifecycleMetadata(sanitizedBody),
3879
- status: input.runStatus,
3880
- phase: input.runPhase,
3881
- projectAlias: input.projectAlias,
3882
- });
3883
- }
3884
- stripLifecycleMetadata(body) {
3885
- return body
3886
- .split(/\r?\n/)
3887
- .filter((line) => !/^(项目|处理状态|会话|当前会话|已保存会话数):/.test(line.trim()))
3888
- .join('\n')
3889
- .replace(/\n{3,}/g, '\n\n')
3890
- .trim();
3891
- }
3892
- }
3893
- export function buildQueueKey(conversationKey, projectAlias) {
3894
- return `${conversationKey}::project::${projectAlias}`;
3895
- }
3896
- export function buildProjectRootQueueKey(projectRoot) {
3897
- return `root::${path.resolve(projectRoot)}`;
3898
- }
3899
- function isExecutionRunStatus(status) {
3900
- return status === 'running' || status === 'orphaned';
3901
- }
3902
- function isVisibleRunStatus(status) {
3903
- return status === 'queued' || isExecutionRunStatus(status);
3904
- }
3905
- function buildMessageDedupeKey(context) {
3906
- return ['message', context.tenant_key ?? 'tenant', context.chat_id, context.message_id].join('::');
3907
- }
3908
- function buildCardDedupeKey(context, action) {
3909
- if (!context.open_message_id) {
3910
- return null;
3911
- }
3912
- return ['card', context.tenant_key ?? 'tenant', context.chat_id ?? 'chat', context.actor_id ?? 'actor', context.open_message_id, action].join('::');
3913
- }
3914
- /**
3915
- * Extract [SEND_FILE:/path/to/file] markers from AI response text.
3916
- * Returns cleaned text (markers removed) and list of file paths.
3917
- */
3918
- function extractFileMarkers(text) {
3919
- const FILE_MARKER_RE = /\[SEND_FILE:([^\]]+)\]/g;
3920
- const filePaths = [];
3921
- let match;
3922
- while ((match = FILE_MARKER_RE.exec(text)) !== null) {
3923
- const filePath = match[1]?.trim();
3924
- if (filePath) {
3925
- filePaths.push(filePath);
3926
- }
3927
- }
3928
- const cleanText = text.replace(FILE_MARKER_RE, '').replace(/\n{3,}/g, '\n\n').trim();
3929
- return { cleanText, filePaths };
3930
- }
3931
- /**
3932
- * Shallow diff two BridgeConfig objects, returning human-readable change descriptions.
3933
- */
3934
- function diffConfigs(oldConfig, newConfig) {
3935
- const changes = [];
3936
- // Projects added/removed
3937
- const oldProjects = new Set(Object.keys(oldConfig.projects));
3938
- const newProjects = new Set(Object.keys(newConfig.projects));
3939
- for (const p of newProjects) {
3940
- if (!oldProjects.has(p))
3941
- changes.push(`项目新增: ${p}`);
3942
- }
3943
- for (const p of oldProjects) {
3944
- if (!newProjects.has(p))
3945
- changes.push(`项目移除: ${p}`);
3946
- }
3947
- // Project-level changes
3948
- for (const alias of newProjects) {
3949
- if (!oldProjects.has(alias))
3950
- continue;
3951
- const oldP = oldConfig.projects[alias];
3952
- const newP = newConfig.projects[alias];
3953
- if (!oldP || !newP)
3954
- continue;
3955
- const fields = ['root', 'backend', 'persona', 'codex_model', 'claude_model', 'mention_required', 'description', 'session_scope'];
3956
- for (const f of fields) {
3957
- const ov = String(oldP[f] ?? '');
3958
- const nv = String(newP[f] ?? '');
3959
- if (ov !== nv)
3960
- changes.push(`${alias}.${f}: ${ov || '(空)'} → ${nv || '(空)'}`);
3961
- }
3962
- // Array fields
3963
- const arrayFields = ['skills', 'admin_chat_ids', 'operator_chat_ids', 'notification_chat_ids'];
3964
- for (const f of arrayFields) {
3965
- const ov = JSON.stringify(oldP[f] ?? []);
3966
- const nv = JSON.stringify(newP[f] ?? []);
3967
- if (ov !== nv)
3968
- changes.push(`${alias}.${f} 变更`);
3969
- }
3970
- }
3971
- // Service-level changes
3972
- const serviceFields = ['default_project', 'reply_mode', 'persona', 'team_digest_enabled', 'intent_classifier_enabled'];
3973
- for (const f of serviceFields) {
3974
- const ov = String(oldConfig.service[f] ?? '');
3975
- const nv = String(newConfig.service[f] ?? '');
3976
- if (ov !== nv)
3977
- changes.push(`service.${f}: ${ov || '(空)'} → ${nv || '(空)'}`);
3978
- }
3979
- // Backend default
3980
- if (oldConfig.backend?.default !== newConfig.backend?.default) {
3981
- changes.push(`backend.default: ${oldConfig.backend?.default ?? 'codex'} → ${newConfig.backend?.default ?? 'codex'}`);
3982
- }
3983
- // Security admin changes
3984
- if (JSON.stringify(oldConfig.security.admin_chat_ids) !== JSON.stringify(newConfig.security.admin_chat_ids)) {
3985
- changes.push('security.admin_chat_ids 变更');
3986
- }
3987
- // Embedding provider
3988
- if (oldConfig.embedding.provider !== newConfig.embedding.provider) {
3989
- changes.push(`embedding.provider: ${oldConfig.embedding.provider} → ${newConfig.embedding.provider}`);
3990
- }
3991
- if (oldConfig.embedding.ollama_model !== newConfig.embedding.ollama_model) {
3992
- changes.push(`embedding.ollama_model: ${oldConfig.embedding.ollama_model} → ${newConfig.embedding.ollama_model}`);
3993
- }
3994
- return changes;
3995
- }
3996
- function truncateExcerpt(text, limit = 160) {
3997
- return text.length > limit ? `${text.slice(0, limit)}...` : text;
3998
- }
3999
- /** Map raw error strings to user-friendly Chinese messages. */
4000
- function friendlyErrorMessage(error) {
4001
- const lower = error.toLowerCase();
4002
- if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('econnreset')) {
4003
- return '网络连接失败,请检查网络或稍后重试';
4004
- }
4005
- if (lower.includes('enoent') || lower.includes('enotdir')) {
4006
- return '文件路径不存在,请检查项目配置';
4007
- }
4008
- if (lower.includes('permission denied') || lower.includes('eacces')) {
4009
- return '权限不足,请检查文件权限';
4010
- }
4011
- if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('etimedout')) {
4012
- return '执行超时,请尝试拆分为更小的任务';
4013
- }
4014
- if (lower.includes('rate limit') || lower.includes('429') || lower.includes('too many requests')) {
4015
- return 'API 频率限制,请稍后重试';
4016
- }
4017
- if (lower.includes('enomem') || lower.includes('out of memory')) {
4018
- return '内存不足,请关闭其他程序或减少并发任务';
4019
- }
4020
- // Default: show a truncated version of the raw error
4021
- const truncated = error.length > 100 ? error.slice(0, 100) + '...' : error;
4022
- return `执行异常: ${truncated}。如需帮助请联系管理员。`;
4023
- }
4024
- function splitCommaSeparatedValues(value) {
4025
- return value
4026
- .split(',')
4027
- .map((item) => item.trim())
4028
- .filter(Boolean);
4029
- }
4030
- function resolveAdminListTarget(resource) {
4031
- switch (resource) {
4032
- case 'viewer':
4033
- return { section: 'security', key: 'viewer_chat_ids' };
4034
- case 'operator':
4035
- return { section: 'security', key: 'operator_chat_ids' };
4036
- case 'admin':
4037
- return { section: 'security', key: 'admin_chat_ids' };
4038
- case 'service-observer':
4039
- return { section: 'security', key: 'service_observer_chat_ids' };
4040
- case 'service-restart':
4041
- return { section: 'security', key: 'service_restart_chat_ids' };
4042
- case 'config-admin':
4043
- return { section: 'security', key: 'config_admin_chat_ids' };
4044
- case 'group':
4045
- return { section: 'feishu', key: 'allowed_group_ids' };
4046
- case 'chat':
4047
- return { section: 'feishu', key: 'allowed_chat_ids' };
4048
- }
4049
- }
4050
- function buildConversationKeyForConversation(conversation) {
4051
- return buildConversationKey({
4052
- tenantKey: conversation.tenant_key,
4053
- chatId: conversation.chat_id,
4054
- actorId: conversation.actor_id,
4055
- scope: conversation.scope,
4056
- });
4057
- }
4058
- function renderMemorySection(title, items, budget) {
4059
- if (items.length === 0) {
4060
- return [];
4061
- }
4062
- const lines = ['', title];
4063
- let used = 0;
4064
- for (const item of items) {
4065
- const line = `- ${item.title}${item.pinned ? ' [pinned]' : ''}: ${item.content}`;
4066
- if (used + line.length > budget) {
4067
- break;
4068
- }
4069
- lines.push(truncateExcerpt(line, 280));
4070
- used += line.length;
4071
- }
4072
- return lines.length > 2 ? lines : [];
4073
- }
4074
- function formatAgeFromNow(isoTimestamp) {
4075
- const deltaMs = Date.now() - Date.parse(isoTimestamp);
4076
- if (!Number.isFinite(deltaMs) || deltaMs < 0) {
4077
- return '0s';
4078
- }
4079
- const totalSeconds = Math.floor(deltaMs / 1000);
4080
- if (totalSeconds < 60) {
4081
- return `${totalSeconds}s`;
4082
- }
4083
- const totalMinutes = Math.floor(totalSeconds / 60);
4084
- if (totalMinutes < 60) {
4085
- return `${totalMinutes}m`;
4086
- }
4087
- const totalHours = Math.floor(totalMinutes / 60);
4088
- if (totalHours < 24) {
4089
- return `${totalHours}h`;
4090
- }
4091
- return `${Math.floor(totalHours / 24)}d`;
4092
- }
4093
- function parseJsonObject(input) {
4094
- try {
4095
- const parsed = JSON.parse(input);
4096
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
4097
- throw new Error('JSON payload must be an object.');
4098
- }
4099
- return parsed;
4100
- }
4101
- catch (error) {
4102
- throw new Error(`JSON 解析失败: ${error instanceof Error ? error.message : String(error)}`);
4103
- }
4104
- }
4105
- function clampListLimit(input, fallback, max) {
4106
- const parsed = Number(input ?? fallback);
4107
- if (!Number.isFinite(parsed) || parsed <= 0) {
4108
- return fallback;
4109
- }
4110
- return Math.min(Math.trunc(parsed), max);
4111
- }
4112
- function mapRunStatusToPhase(status) {
4113
- switch (status) {
4114
- case 'queued':
4115
- return '排队中';
4116
- case 'running':
4117
- return '执行中';
4118
- case 'success':
4119
- return '已完成';
4120
- case 'failure':
4121
- return '失败';
4122
- case 'cancelled':
4123
- return '已取消';
4124
- case 'stale':
4125
- return '中断';
4126
- case 'orphaned':
4127
- return '恢复中';
4128
- default:
4129
- return status;
4130
- }
4131
- }
4132
- function replaceObject(target, next) {
4133
- for (const key of Object.keys(target)) {
4134
- if (!(key in next)) {
4135
- delete target[key];
4136
- }
4137
- }
4138
- for (const [key, value] of Object.entries(next)) {
4139
- target[key] = value;
4140
- }
4141
- }
4142
- function replaceProjects(target, next) {
4143
- for (const key of Object.keys(target)) {
4144
- if (!(key in next)) {
4145
- delete target[key];
4146
- }
4147
- }
4148
- for (const [alias, project] of Object.entries(next)) {
4149
- target[alias] = project;
4150
- }
4151
- }
4152
- function createDeferred() {
4153
- let resolve;
4154
- let reject;
4155
- const promise = new Promise((innerResolve, innerReject) => {
4156
- resolve = innerResolve;
4157
- reject = innerReject;
4158
- });
4159
- return { promise, resolve, reject };
4160
2185
  }
2186
+ // Module-level helpers extracted to service-utils.ts (β step 1).
2187
+ // Re-exported here for backward compatibility (src/mcp/server.ts and
2188
+ // src/index.ts depend on these names being importable from this module).
2189
+ export { buildQueueKey, buildProjectRootQueueKey } from './service-utils.js';
4161
2190
  //# sourceMappingURL=service.js.map