feique 1.3.3 → 1.5.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 (69) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/backend/claude.d.ts +2 -0
  4. package/dist/backend/claude.js +57 -54
  5. package/dist/backend/claude.js.map +1 -1
  6. package/dist/backend/codex.d.ts +2 -0
  7. package/dist/backend/codex.js +27 -0
  8. package/dist/backend/codex.js.map +1 -1
  9. package/dist/backend/factory.d.ts +43 -0
  10. package/dist/backend/factory.js +109 -29
  11. package/dist/backend/factory.js.map +1 -1
  12. package/dist/backend/probe.d.ts +27 -0
  13. package/dist/backend/probe.js +85 -0
  14. package/dist/backend/probe.js.map +1 -0
  15. package/dist/backend/qwen.d.ts +59 -0
  16. package/dist/backend/qwen.js +372 -0
  17. package/dist/backend/qwen.js.map +1 -0
  18. package/dist/backend/registry.d.ts +58 -0
  19. package/dist/backend/registry.js +23 -0
  20. package/dist/backend/registry.js.map +1 -0
  21. package/dist/backend/types.d.ts +6 -1
  22. package/dist/bridge/admin-config.d.ts +47 -0
  23. package/dist/bridge/admin-config.js +141 -0
  24. package/dist/bridge/admin-config.js.map +1 -0
  25. package/dist/bridge/collab-commands.d.ts +42 -0
  26. package/dist/bridge/collab-commands.js +254 -0
  27. package/dist/bridge/collab-commands.js.map +1 -0
  28. package/dist/bridge/commands.d.ts +1 -1
  29. package/dist/bridge/commands.js +10 -6
  30. package/dist/bridge/commands.js.map +1 -1
  31. package/dist/bridge/feishu-commands.d.ts +27 -0
  32. package/dist/bridge/feishu-commands.js +462 -0
  33. package/dist/bridge/feishu-commands.js.map +1 -0
  34. package/dist/bridge/intent-classifier.d.ts +7 -2
  35. package/dist/bridge/intent-classifier.js +1 -1
  36. package/dist/bridge/intent-classifier.js.map +1 -1
  37. package/dist/bridge/lifecycle.d.ts +46 -0
  38. package/dist/bridge/lifecycle.js +228 -0
  39. package/dist/bridge/lifecycle.js.map +1 -0
  40. package/dist/bridge/memory-commands.d.ts +26 -0
  41. package/dist/bridge/memory-commands.js +330 -0
  42. package/dist/bridge/memory-commands.js.map +1 -0
  43. package/dist/bridge/reply-builders.d.ts +30 -0
  44. package/dist/bridge/reply-builders.js +72 -0
  45. package/dist/bridge/reply-builders.js.map +1 -0
  46. package/dist/bridge/run-pipeline.d.ts +86 -0
  47. package/dist/bridge/run-pipeline.js +453 -0
  48. package/dist/bridge/run-pipeline.js.map +1 -0
  49. package/dist/bridge/run-scheduler.d.ts +47 -0
  50. package/dist/bridge/run-scheduler.js +121 -0
  51. package/dist/bridge/run-scheduler.js.map +1 -0
  52. package/dist/bridge/service-utils.d.ts +47 -0
  53. package/dist/bridge/service-utils.js +309 -0
  54. package/dist/bridge/service-utils.js.map +1 -0
  55. package/dist/bridge/service.d.ts +114 -66
  56. package/dist/bridge/service.js +230 -2199
  57. package/dist/bridge/service.js.map +1 -1
  58. package/dist/config/load.js +1 -1
  59. package/dist/config/load.js.map +1 -1
  60. package/dist/config/paths.js +1 -20
  61. package/dist/config/paths.js.map +1 -1
  62. package/dist/config/schema.d.ts +50 -16
  63. package/dist/config/schema.js +41 -2
  64. package/dist/config/schema.js.map +1 -1
  65. package/dist/feishu/long-connection.js +1 -0
  66. package/dist/feishu/long-connection.js.map +1 -1
  67. package/dist/feishu/webhook.js +1 -0
  68. package/dist/feishu/webhook.js.map +1 -1
  69. 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,35 @@ 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 { getBackendDefinition, listBackendNames } from '../backend/registry.js';
18
+ import { buildQueueKey, isExecutionRunStatus, isVisibleRunStatus, mapRunStatusToPhase, buildMessageDedupeKey, buildCardDedupeKey, truncateExcerpt, friendlyErrorMessage, resolveAdminListTarget, buildConversationKeyForConversation, renderMemorySection, formatAgeFromNow, replaceObject, replaceProjects, } from './service-utils.js';
19
+ import { handleDocCommand as handleDocCommandImpl, handleTaskCommand as handleTaskCommandImpl, handleBaseCommand as handleBaseCommandImpl, handleWikiCommand as handleWikiCommandImpl, } from './feishu-commands.js';
20
+ import { handleMemoryCommand as handleMemoryCommandImpl } from './memory-commands.js';
21
+ import { handleAdminConfigCommand as handleAdminConfigCommandImpl, parseProjectPatch as parseProjectPatchImpl, } from './admin-config.js';
22
+ import { scheduleProjectExecution as scheduleProjectExecutionImpl, buildAcknowledgedRunReply, buildRunStatusSummary, } from './run-scheduler.js';
23
+ import { formatQuotedReply, buildReplyTitle, sanitizeUserVisibleReply, supportsInteractiveCardActions, resolveRunLifecycleReplyMode, buildRunLifecycleCard, } from './reply-builders.js';
24
+ import { executePrompt as executePromptImpl } from './run-pipeline.js';
25
+ import { recoverRuntimeState as recoverRuntimeStateImpl, reloadConfig as reloadConfigImpl, runDigestCycle as runDigestCycleImpl, runMemoryMaintenance as runMemoryMaintenanceImpl, runAuditMaintenance as runAuditMaintenanceImpl, runMaintenanceCycle as runMaintenanceCycleImpl, } from './lifecycle.js';
26
+ 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
27
  import { bindProjectAlias, createProjectAlias, removeProjectAlias, updateProjectConfig, updateStringList } from '../config/mutate.js';
25
- import { buildFeishuPost, truncateForFeishuCard } from '../feishu/text.js';
28
+ import { buildFeishuPost } from '../feishu/text.js';
26
29
  import { ConfigHistoryStore } from '../state/config-history-store.js';
27
30
  import { loadBridgeConfigFile } from '../config/load.js';
28
- import { writeUtf8Atomic } from '../utils/fs.js';
29
31
  import { expandHomePath } from '../utils/path.js';
30
32
  import { canAccessGlobalCapability, canAccessProject, canAccessProjectCapability, describeMinimumRole, filterAccessibleProjects, resolveProjectAccessRole } from '../security/access.js';
31
33
  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';
34
+ import { getProjectAuditDir, getProjectCacheDir, getProjectDownloadsDir, getProjectTempDir } from '../projects/paths.js';
33
35
  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';
36
+ import { createReview } from '../collaboration/handoff.js';
37
+ import { classifyOperation, enforceTrustBoundary } from '../collaboration/trust.js';
39
38
  import { HandoffStore } from '../state/handoff-store.js';
40
39
  import { TrustStore } from '../state/trust-store.js';
41
40
  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';
41
+ import { checkRunAlerts, formatAlert, DEFAULT_ALERT_RULES } from '../collaboration/proactive-alerts.js';
46
42
  export class FeiqueService {
47
43
  config;
48
44
  feishuClient;
@@ -64,6 +60,10 @@ export class FeiqueService {
64
60
  activeRuns = new Map();
65
61
  runReplyTargets = new Map();
66
62
  chatRateWindows = new Map();
63
+ /** Dedupe admin notifications for backend failover: one alert per (from→to) direction per process lifetime. */
64
+ failoverNotified = new Set();
65
+ /** Dedupe rejected-chat notifications: one reply + one admin alert per chat_id per process lifetime. */
66
+ rejectedChatNotified = new Set();
67
67
  maintenanceTimer;
68
68
  digestTimer;
69
69
  configWatcher;
@@ -102,79 +102,14 @@ export class FeiqueService {
102
102
  }
103
103
  }
104
104
  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;
105
+ return recoverRuntimeStateImpl(this);
117
106
  }
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
107
  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 };
108
+ const result = await reloadConfigImpl(this, configPath);
109
+ if (result.newConfig) {
110
+ this.config = result.newConfig;
150
111
  }
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 };
112
+ return { ok: result.ok, ...(result.error ? { error: result.error } : {}), ...(result.changes ? { changes: result.changes } : {}) };
178
113
  }
179
114
  /**
180
115
  * Watch config file for changes and auto-reload with validation.
@@ -247,120 +182,16 @@ export class FeiqueService {
247
182
  this.digestTimer.unref?.();
248
183
  }
249
184
  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
- }
185
+ return runDigestCycleImpl(this);
301
186
  }
302
187
  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;
188
+ return runMemoryMaintenanceImpl(this);
315
189
  }
316
190
  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 };
191
+ return runAuditMaintenanceImpl(this);
342
192
  }
343
193
  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 */ }
194
+ return runMaintenanceCycleImpl(this);
364
195
  }
365
196
  async handleIncomingMessage(context) {
366
197
  this.currentMessageContext = context;
@@ -510,7 +341,7 @@ export class FeiqueService {
510
341
  await this.handleGapsCommand(context);
511
342
  return;
512
343
  case 'prompt':
513
- await this.handlePromptMessage(context, selectionKey, command.prompt, context.text);
344
+ await this.handlePromptMessage(context, selectionKey, command.prompt);
514
345
  return;
515
346
  }
516
347
  }
@@ -655,430 +486,7 @@ export class FeiqueService {
655
486
  return this.runStateStore.listRuns();
656
487
  }
657
488
  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
- }
489
+ return executePromptImpl(this, input);
1082
490
  }
1083
491
  async handleProjectCommand(context, selectionKey, alias, followupPrompt) {
1084
492
  if (!alias) {
@@ -1095,7 +503,7 @@ export class FeiqueService {
1095
503
  await this.sendTextReply(context.chat_id, `当前 chat_id 无权切换到项目 ${alias}。至少需要 ${describeMinimumRole('viewer')} 权限。`, context.message_id, context.text);
1096
504
  return;
1097
505
  }
1098
- const project = this.requireProject(alias);
506
+ this.requireProject(alias); // throws if missing — validates the alias before switching
1099
507
  const switched = await switchSharedProjectBinding(this.config, this.sessionStore, this.codexSessionIndex, {
1100
508
  chatId: context.chat_id,
1101
509
  actorId: context.actor_id,
@@ -1126,7 +534,7 @@ export class FeiqueService {
1126
534
  await this.handleReadOnlyFollowupCommand(context, selectionKey, followupCommand, followupPrompt);
1127
535
  return;
1128
536
  }
1129
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
537
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1130
538
  return;
1131
539
  }
1132
540
  await this.sendTextReply(context.chat_id, switched.text, context.message_id, context.text);
@@ -1175,13 +583,13 @@ export class FeiqueService {
1175
583
  await this.handleAdminCommand(context, selectionKey, command);
1176
584
  return;
1177
585
  case 'prompt':
1178
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
586
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1179
587
  return;
1180
588
  default:
1181
- await this.handlePromptMessage(context, selectionKey, followupPrompt, context.text);
589
+ await this.handlePromptMessage(context, selectionKey, followupPrompt);
1182
590
  }
1183
591
  }
1184
- async handlePromptMessage(context, selectionKey, rawPrompt, originalText) {
592
+ async handlePromptMessage(context, selectionKey, rawPrompt) {
1185
593
  const prompt = normalizeIncomingText(rawPrompt) || (context.attachments.length > 0 ? '请结合这条飞书消息附带的多媒体信息继续处理。' : '');
1186
594
  if (!prompt) {
1187
595
  return;
@@ -1372,7 +780,6 @@ export class FeiqueService {
1372
780
  }
1373
781
  async handleSessionCommand(context, selectionKey, action, threadId) {
1374
782
  const projectContext = await this.resolveProjectContext(context, selectionKey);
1375
- const sessions = await this.sessionStore.listProjectSessions(projectContext.sessionKey, projectContext.projectAlias);
1376
783
  const activeSessionId = (await this.sessionStore.getConversation(projectContext.sessionKey))?.projects[projectContext.projectAlias]?.thread_id;
1377
784
  switch (action) {
1378
785
  case 'list': {
@@ -1441,215 +848,39 @@ export class FeiqueService {
1441
848
  // ── Direction 2: Knowledge Loop ──
1442
849
  async handleLearnCommand(context, selectionKey, value) {
1443
850
  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);
851
+ return handleLearnCommandImpl(this, context, projectContext, value);
1460
852
  }
1461
853
  async handleRecallCommand(context, selectionKey, query) {
1462
854
  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);
855
+ return handleRecallCommandImpl(this, context, projectContext, query);
1466
856
  }
1467
- // ── Direction 3: Handoff & Review ──
1468
857
  async handleHandoffCommand(context, selectionKey, summary) {
1469
858
  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);
859
+ return handleHandoffCommandImpl(this, context, projectContext, summary);
1490
860
  }
1491
861
  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);
862
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
863
+ return handlePickupCommandImpl(this, context, projectContext, id);
1523
864
  }
1524
865
  async handleReviewCommand(context, selectionKey) {
1525
866
  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);
867
+ return handleReviewCommandImpl(this, context, projectContext);
1547
868
  }
1548
869
  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);
870
+ return handleApproveCommandImpl(this, context, comment);
1567
871
  }
1568
872
  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);
873
+ return handleRejectCommandImpl(this, context, reason);
1588
874
  }
1589
- // ── Direction 4: Insights ──
1590
875
  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);
876
+ return handleInsightsCommandImpl(this, context);
1596
877
  }
1597
- // ── Direction 5: Trust ──
1598
878
  async handleTrustCommand(context, selectionKey, action, level) {
1599
879
  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);
880
+ return handleTrustCommandImpl(this, context, projectContext, action, level);
1641
881
  }
1642
- // ── Team Digest ──
1643
882
  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);
883
+ return handleDigestCommandImpl(this, context);
1653
884
  }
1654
885
  // ── Proactive Alerts ──
1655
886
  async checkAndSendAlerts(completedRun) {
@@ -1681,26 +912,12 @@ export class FeiqueService {
1681
912
  }
1682
913
  // ── Knowledge Gap Detection ──
1683
914
  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);
915
+ return handleGapsCommandImpl(this, context);
1691
916
  }
1692
917
  // ── Direction 6: Timeline ──
1693
918
  async handleTimelineCommand(context, selectionKey, projectArg) {
1694
919
  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);
920
+ return handleTimelineCommandImpl(this, context, projectContext, projectArg);
1704
921
  }
1705
922
  async handleAdminCommand(context, selectionKey, command) {
1706
923
  const runtimeConfigPath = this.runtimeControl?.configPath;
@@ -1758,37 +975,42 @@ export class FeiqueService {
1758
975
  await this.sendTextReply(context.chat_id, this.buildProjectsAdminText(globalAdmin || serviceObserver ? undefined : new Set(projectOperatorAliases)), context.message_id, context.text);
1759
976
  return;
1760
977
  }
1761
- if (command.action === 'add' || command.action === 'create') {
978
+ if (command.action === 'add' || command.action === 'create' || command.action === 'setup') {
1762
979
  if (!(globalAdmin || globalConfigAdmin)) {
1763
980
  await this.sendTextReply(context.chat_id, '当前 chat_id 无权动态接入项目。', context.message_id, context.text);
1764
981
  return;
1765
982
  }
1766
983
  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);
984
+ await this.sendTextReply(context.chat_id, command.action === 'setup'
985
+ ? '用法: /admin project setup <alias> <root>\n一键创建项目并将当前群设为 operator。'
986
+ : command.action === 'create' ? '用法: /admin project create <alias> <root>' : '用法: /admin project add <alias> <root>', context.message_id, context.text);
1768
987
  return;
1769
988
  }
1770
- if (command.action === 'create' && this.config.projects[command.alias]) {
989
+ const isCreate = command.action === 'create' || command.action === 'setup';
990
+ if (isCreate && this.config.projects[command.alias]) {
1771
991
  await this.sendTextReply(context.chat_id, `项目已存在: ${command.alias}`, context.message_id, context.text);
1772
992
  return;
1773
993
  }
1774
994
  const resolvedRoot = path.resolve(expandHomePath(command.value));
1775
995
  const snapshot = await this.snapshotConfigForAdminMutation(context, `project.${command.action}`, `${command.alias} -> ${resolvedRoot}`);
1776
- if (command.action === 'create') {
996
+ if (isCreate) {
1777
997
  await createProjectAlias({ configPath: runtimeConfigPath, alias: command.alias, root: command.value });
1778
998
  }
1779
999
  else {
1780
1000
  await bindProjectAlias({ configPath: runtimeConfigPath, alias: command.alias, root: command.value });
1781
1001
  }
1002
+ // For setup: auto-add current chat as operator + viewer
1003
+ const autoOperator = command.action === 'setup' ? [context.chat_id] : [];
1782
1004
  this.config.projects[command.alias] = {
1783
1005
  root: resolvedRoot,
1784
1006
  session_scope: 'chat',
1785
1007
  mention_required: true,
1786
1008
  knowledge_paths: [],
1787
1009
  wiki_space_ids: [],
1788
- viewer_chat_ids: [],
1789
- operator_chat_ids: [],
1010
+ viewer_chat_ids: [...autoOperator],
1011
+ operator_chat_ids: [...autoOperator],
1790
1012
  admin_chat_ids: [],
1791
- notification_chat_ids: [],
1013
+ notification_chat_ids: [...autoOperator],
1792
1014
  session_operator_chat_ids: [],
1793
1015
  run_operator_chat_ids: [],
1794
1016
  config_admin_chat_ids: [],
@@ -1798,7 +1020,27 @@ export class FeiqueService {
1798
1020
  chat_rate_limit_window_seconds: 60,
1799
1021
  chat_rate_limit_max_runs: 20,
1800
1022
  };
1801
- await this.sendTextReply(context.chat_id, `${command.action === 'create' ? '已创建并接入项目' : '已接入项目'}: ${command.alias}\n根目录: ${resolvedRoot}`, context.message_id, context.text);
1023
+ // Persist the auto-added chat_ids to config file for setup
1024
+ if (command.action === 'setup') {
1025
+ await updateProjectConfig(runtimeConfigPath, command.alias, {
1026
+ viewer_chat_ids: autoOperator,
1027
+ operator_chat_ids: autoOperator,
1028
+ notification_chat_ids: autoOperator,
1029
+ });
1030
+ }
1031
+ const replyLines = [
1032
+ `${isCreate ? '已创建并接入项目' : '已接入项目'}: ${command.alias}`,
1033
+ `根目录: ${resolvedRoot}`,
1034
+ ];
1035
+ if (command.action === 'setup') {
1036
+ replyLines.push(`已自动将当前群设为 operator + viewer + notification`);
1037
+ replyLines.push('');
1038
+ replyLines.push('可在其他群执行以下命令添加权限:');
1039
+ replyLines.push(`/admin project set ${command.alias} operator_chat_ids +<chat_id>`);
1040
+ replyLines.push('');
1041
+ replyLines.push(`切换到此项目: /project ${command.alias}`);
1042
+ }
1043
+ await this.sendTextReply(context.chat_id, replyLines.join('\n'), context.message_id, context.text);
1802
1044
  await this.appendAdminAudit({
1803
1045
  type: `admin.project.${command.action}`,
1804
1046
  chat_id: context.chat_id,
@@ -1806,8 +1048,9 @@ export class FeiqueService {
1806
1048
  project_alias: command.alias,
1807
1049
  root: resolvedRoot,
1808
1050
  snapshot_id: snapshot.id,
1051
+ auto_operator: command.action === 'setup' ? context.chat_id : undefined,
1809
1052
  });
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');
1053
+ 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
1054
  return;
1812
1055
  }
1813
1056
  if (command.action === 'remove') {
@@ -1845,9 +1088,9 @@ export class FeiqueService {
1845
1088
  await this.sendTextReply(context.chat_id, `当前 chat_id 无权修改项目 ${command.alias}。`, context.message_id, context.text);
1846
1089
  return;
1847
1090
  }
1848
- const patch = this.parseProjectPatch(command.field, command.value);
1091
+ const patch = parseProjectPatchImpl(this.config, command.field, command.value, command.alias);
1849
1092
  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);
1093
+ 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
1094
  return;
1852
1095
  }
1853
1096
  const snapshot = await this.snapshotConfigForAdminMutation(context, 'project.set', `${command.alias}.${command.field}=${command.value}`);
@@ -1928,8 +1171,9 @@ export class FeiqueService {
1928
1171
  return;
1929
1172
  }
1930
1173
  const normalized = name.toLowerCase();
1931
- if (normalized !== 'codex' && normalized !== 'claude') {
1932
- await this.sendTextReply(context.chat_id, `未知后端: ${name}\n可选值: codex | claude`, context.message_id, context.text);
1174
+ if (!getBackendDefinition(normalized)) {
1175
+ const known = listBackendNames().join(' | ');
1176
+ await this.sendTextReply(context.chat_id, `未知后端: ${name}\n可选值: ${known}`, context.message_id, context.text);
1933
1177
  return;
1934
1178
  }
1935
1179
  const backendName = normalized;
@@ -1942,313 +1186,21 @@ export class FeiqueService {
1942
1186
  conversation_key: projectContext.sessionKey,
1943
1187
  backend: backendName,
1944
1188
  });
1945
- const label = backendName === 'claude' ? 'Claude Code' : 'Codex';
1189
+ const label = backendName === 'claude' ? 'Claude Code' : backendName === 'qwen' ? 'Qwen Code' : 'Codex';
1946
1190
  await this.sendTextReply(context.chat_id, `项目 ${projectContext.projectAlias} 已切换到 ${label} 后端。\n下一条消息将使用 ${label} 执行。`, context.message_id, context.text);
1947
1191
  }
1948
1192
  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);
1193
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
1194
+ return handleMemoryCommandImpl(this, context, projectContext, action, scope, value, filters);
1195
+ }
1196
+ async handleKnowledgeCommand(context, selectionKey, action, query) {
1197
+ const projectContext = await this.resolveProjectContext(context, selectionKey);
1198
+ const roots = await resolveKnowledgeRoots(projectContext.project);
1199
+ if (action === 'status') {
1200
+ const message = roots.length
1201
+ ? [`项目: ${projectContext.projectAlias}`, '知识库目录:', ...roots.map((root) => `- ${root}`)].join('\n')
1202
+ : [`项目: ${projectContext.projectAlias}`, '当前没有可用知识库目录。', '可在项目配置中设置 knowledge_paths,或在项目根下提供 docs/README。'].join('\n');
1203
+ await this.sendTextReply(context.chat_id, message, context.message_id, context.text);
2252
1204
  return;
2253
1205
  }
2254
1206
  if (!query) {
@@ -2282,449 +1234,19 @@ export class FeiqueService {
2282
1234
  }
2283
1235
  async handleDocCommand(context, selectionKey, action, value, extra) {
2284
1236
  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);
1237
+ return handleDocCommandImpl(this, context, projectContext, action, value, extra);
2328
1238
  }
2329
1239
  async handleTaskCommand(context, selectionKey, action, value) {
2330
1240
  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);
1241
+ return handleTaskCommandImpl(this, context, projectContext, action, value);
2400
1242
  }
2401
1243
  async handleBaseCommand(context, selectionKey, action, appToken, tableId, recordId, value) {
2402
1244
  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);
1245
+ return handleBaseCommandImpl(this, context, projectContext, action, appToken, tableId, recordId, value);
2468
1246
  }
2469
1247
  async handleWikiCommand(context, selectionKey, action, value, extra, target, role) {
2470
1248
  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);
1249
+ return handleWikiCommandImpl(this, context, projectContext, action, value, extra, target, role);
2728
1250
  }
2729
1251
  async buildProjectsText(selectionKey, chatId) {
2730
1252
  const selected = await this.resolveProjectAlias(selectionKey);
@@ -2787,11 +1309,11 @@ export class FeiqueService {
2787
1309
  const session = conversation?.projects[projectAlias];
2788
1310
  const sessionCount = Object.keys(session?.sessions ?? {}).length;
2789
1311
  const isExecutableRun = activeRun ? isExecutionRunStatus(activeRun.status) : false;
2790
- const includeActions = this.supportsInteractiveCardActions();
1312
+ const includeActions = supportsInteractiveCardActions(this.config);
2791
1313
  const actionChatId = conversation?.chat_id ?? activeRun?.chat_id ?? fallbackChatId;
2792
1314
  return buildStatusCard({
2793
1315
  title: '当前会话状态',
2794
- summary: this.buildRunStatusSummary(session?.last_response_excerpt, activeRun),
1316
+ summary: buildRunStatusSummary(session?.last_response_excerpt, activeRun),
2795
1317
  projectAlias,
2796
1318
  sessionId: session?.thread_id,
2797
1319
  runStatus: activeRun?.status,
@@ -2962,32 +1484,6 @@ export class FeiqueService {
2962
1484
  shouldRequireMention(project) {
2963
1485
  return project.mention_required || this.config.security.require_group_mentions;
2964
1486
  }
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
1487
  async cancelActiveRun(queueKey, reason) {
2992
1488
  const live = this.activeRuns.get(queueKey);
2993
1489
  if (live) {
@@ -3017,112 +1513,7 @@ export class FeiqueService {
3017
1513
  return terminateProcess(persisted.pid, 'SIGTERM');
3018
1514
  }
3019
1515
  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 ?? '暂无会话摘要。';
1516
+ return scheduleProjectExecutionImpl(this, projectContext, metadata, task);
3126
1517
  }
3127
1518
  isAdminChat(chatId) {
3128
1519
  return this.config.security.admin_chat_ids.includes(chatId);
@@ -3236,9 +1627,6 @@ export class FeiqueService {
3236
1627
  canMutateRuntimeConfig(chatId) {
3237
1628
  return canAccessGlobalCapability(this.config, chatId, 'config:mutate');
3238
1629
  }
3239
- canReadConfigHistory(chatId) {
3240
- return canAccessGlobalCapability(this.config, chatId, 'config:history') || this.canMutateRuntimeConfig(chatId);
3241
- }
3242
1630
  canAccessAdminCommand(command, currentProjectAlias, authorizedProjectAliases, operatorProjectAliases, globalCapabilities) {
3243
1631
  if (command.resource === 'project') {
3244
1632
  if (command.action === 'list') {
@@ -3267,56 +1655,7 @@ export class FeiqueService {
3267
1655
  return true;
3268
1656
  }
3269
1657
  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);
1658
+ return handleAdminConfigCommandImpl(this, context, command);
3320
1659
  }
3321
1660
  async snapshotConfigForAdminMutation(context, action, summary) {
3322
1661
  if (!this.runtimeControl?.configPath) {
@@ -3343,65 +1682,6 @@ export class FeiqueService {
3343
1682
  replaceObject(this.config.feishu, nextConfig.feishu);
3344
1683
  replaceProjects(this.config.projects, nextConfig.projects);
3345
1684
  }
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
1685
  resolveProjectDownloadDir(projectAlias, project) {
3406
1686
  return getProjectDownloadsDir(this.config.storage.dir, projectAlias, project);
3407
1687
  }
@@ -3425,28 +1705,6 @@ export class FeiqueService {
3425
1705
  catch { /* best-effort */ }
3426
1706
  }
3427
1707
  }
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
1708
  checkAndConsumeChatRateLimit(projectAlias, project, chatId) {
3451
1709
  const windowMs = project.chat_rate_limit_window_seconds * 1000;
3452
1710
  const maxRuns = project.chat_rate_limit_max_runs;
@@ -3500,12 +1758,108 @@ export class FeiqueService {
3500
1758
  }
3501
1759
  this.config.feishu.allowed_chat_ids = values;
3502
1760
  }
3503
- resolveProjectRoot(project) {
3504
- return path.resolve(project.root);
3505
- }
3506
1761
  resolveBackendByName(projectAlias, sessionOverride) {
3507
1762
  return resolveProjectBackendWithOverride(this.config, projectAlias, sessionOverride, this.codexSessionIndex);
3508
1763
  }
1764
+ /**
1765
+ * Called when resolveProjectBackendWithFailover returns a non-null failover.
1766
+ * Responsibilities:
1767
+ * - Log a warning with structured context
1768
+ * - Emit an audit event
1769
+ * - Notify admin chats (deduped by from→to direction for the process lifetime)
1770
+ * - Send a user-visible notice into the current chat so the user knows
1771
+ * why their run is on a different backend than expected
1772
+ */
1773
+ async handleBackendFailover(chatId, projectAlias, runId, info) {
1774
+ this.logger.warn({ projectAlias, runId, from: info.from, to: info.to, reason: info.reason }, 'Backend failover: primary probe failed, switched to alternate');
1775
+ try {
1776
+ await this.auditLog.append({
1777
+ type: 'backend.failover',
1778
+ project_alias: projectAlias,
1779
+ run_id: runId,
1780
+ from: info.from,
1781
+ to: info.to,
1782
+ reason: info.reason,
1783
+ });
1784
+ }
1785
+ catch { /* best-effort */ }
1786
+ const userNotice = `⚠️ ${info.from} 不可用,已临时切换到 ${info.to} 运行本次请求。\n原因: ${info.reason}`;
1787
+ try {
1788
+ await this.feishuClient.sendText(chatId, userNotice);
1789
+ }
1790
+ catch { /* best-effort */ }
1791
+ const dedupeKey = `${info.from}->${info.to}`;
1792
+ if (this.failoverNotified.has(dedupeKey))
1793
+ return;
1794
+ this.failoverNotified.add(dedupeKey);
1795
+ const adminChatIds = this.config.security.admin_chat_ids ?? [];
1796
+ if (adminChatIds.length === 0)
1797
+ return;
1798
+ const adminText = `🔁 Backend failover 已触发\n\n` +
1799
+ `方向: ${info.from} → ${info.to}\n` +
1800
+ `项目: ${projectAlias}\n` +
1801
+ `原因: ${info.reason}\n\n` +
1802
+ `后续同方向的切换不会重复通知,直到服务重启。`;
1803
+ for (const adminChat of adminChatIds) {
1804
+ try {
1805
+ await this.feishuClient.sendText(adminChat, adminText);
1806
+ }
1807
+ catch { /* best-effort */ }
1808
+ }
1809
+ }
1810
+ /**
1811
+ * Called by the transport layer when an incoming chat is rejected by the
1812
+ * allowlist (`feishu.allowed_chat_ids` / `allowed_group_ids`).
1813
+ *
1814
+ * Replaces the previous "silent drop" behavior with a pairing-style
1815
+ * experience: tell the user what their chat_id is so an admin can add it,
1816
+ * notify admins that a new chat is knocking, and record the rejection in
1817
+ * the audit log. Deduped per chat_id for the process lifetime so repeated
1818
+ * attempts from the same unauthorized chat do not spam either side.
1819
+ *
1820
+ * This is best-effort: any send failure is swallowed. We never want a
1821
+ * rejection flow to throw out of the transport dispatcher.
1822
+ */
1823
+ async handleRejectedChat(chatId, chatType) {
1824
+ if (this.rejectedChatNotified.has(chatId))
1825
+ return;
1826
+ this.rejectedChatNotified.add(chatId);
1827
+ try {
1828
+ await this.auditLog.append({
1829
+ type: 'chat.rejected',
1830
+ chat_id: chatId,
1831
+ chat_type: chatType,
1832
+ });
1833
+ }
1834
+ catch { /* best-effort */ }
1835
+ const listHint = chatType === 'group'
1836
+ ? '`feishu.allowed_group_ids`(群聊)'
1837
+ : '`feishu.allowed_chat_ids`(私聊)';
1838
+ const userText = `抱歉,该 chat 未被授权访问本 bot。\n` +
1839
+ `你的 chat_id: ${chatId}\n\n` +
1840
+ `请联系管理员并附上这个 id,请求加入 ${listHint}。`;
1841
+ try {
1842
+ await this.feishuClient.sendText(chatId, userText);
1843
+ }
1844
+ catch { /* best-effort */ }
1845
+ const adminChatIds = this.config.security.admin_chat_ids ?? [];
1846
+ if (adminChatIds.length === 0)
1847
+ return;
1848
+ const adminCommand = chatType === 'group'
1849
+ ? `/admin group add ${chatId}`
1850
+ : `/admin chat add ${chatId}`;
1851
+ const adminText = `🔔 新 chat 请求接入\n\n` +
1852
+ `chat_id: ${chatId}\n` +
1853
+ `类型: ${chatType}\n\n` +
1854
+ `如需授权,运行:\n${adminCommand}\n\n` +
1855
+ `后续来自同一 chat 的请求不会重复通知,直到服务重启。`;
1856
+ for (const adminChat of adminChatIds) {
1857
+ try {
1858
+ await this.feishuClient.sendText(adminChat, adminText);
1859
+ }
1860
+ catch { /* best-effort */ }
1861
+ }
1862
+ }
3509
1863
  async enforceSessionHistoryLimit(conversationKey, projectAlias) {
3510
1864
  const sessions = await this.sessionStore.listProjectSessions(conversationKey, projectAlias);
3511
1865
  const overflow = sessions.slice(this.config.service.session_history_limit);
@@ -3522,11 +1876,11 @@ export class FeiqueService {
3522
1876
  mentionPrefix = `<at user_id="${actor.actor_id}">${displayName}</at>\n`;
3523
1877
  }
3524
1878
  const bodyWithMention = mentionPrefix ? mentionPrefix + body : body;
3525
- const title = this.buildReplyTitle(this.sanitizeUserVisibleReply(body));
1879
+ const title = buildReplyTitle(sanitizeUserVisibleReply(body));
3526
1880
  // Card mode uses replyToMessageId for threading — @mention tags render as
3527
1881
  // 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));
1882
+ const formattedBodyClean = sanitizeUserVisibleReply(formatQuotedReply(body, originalText));
1883
+ const formattedBodyWithMention = sanitizeUserVisibleReply(formatQuotedReply(bodyWithMention, originalText));
3530
1884
  if (this.config.service.reply_mode === 'card') {
3531
1885
  const card = buildMessageCard({
3532
1886
  title,
@@ -3568,7 +1922,7 @@ export class FeiqueService {
3568
1922
  return response;
3569
1923
  }
3570
1924
  if (this.config.service.reply_quote_user_message && replyToMessageId) {
3571
- const response = await this.feishuClient.sendText(chatId, this.sanitizeUserVisibleReply(bodyWithMention), { replyToMessageId });
1925
+ const response = await this.feishuClient.sendText(chatId, sanitizeUserVisibleReply(bodyWithMention), { replyToMessageId });
3572
1926
  await this.auditLog.append({
3573
1927
  type: 'message.replied',
3574
1928
  chat_id: chatId,
@@ -3594,10 +1948,10 @@ export class FeiqueService {
3594
1948
  return this.feishuClient.sendCard(chatId, card);
3595
1949
  }
3596
1950
  async sendRunLifecycleReply(input) {
3597
- const lifecycleMode = this.resolveRunLifecycleReplyMode();
1951
+ const lifecycleMode = resolveRunLifecycleReplyMode(this.config);
3598
1952
  const lifecycleReplyOptions = input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : undefined;
3599
1953
  if (lifecycleMode === 'card') {
3600
- const card = this.buildRunLifecycleCard({
1954
+ const card = buildRunLifecycleCard({
3601
1955
  title: input.title,
3602
1956
  body: input.body,
3603
1957
  projectAlias: input.projectAlias,
@@ -3618,8 +1972,8 @@ export class FeiqueService {
3618
1972
  return response;
3619
1973
  }
3620
1974
  if (lifecycleMode === 'post') {
3621
- const postBody = this.sanitizeUserVisibleReply(this.formatQuotedReply(input.body, input.originalText));
3622
- const title = this.buildReplyTitle(postBody);
1975
+ const postBody = sanitizeUserVisibleReply(formatQuotedReply(input.body, input.originalText));
1976
+ const title = buildReplyTitle(postBody);
3623
1977
  const post = buildFeishuPost(title, postBody);
3624
1978
  const response = lifecycleReplyOptions
3625
1979
  ? await this.feishuClient.sendPost(input.chatId, post, lifecycleReplyOptions)
@@ -3635,7 +1989,7 @@ export class FeiqueService {
3635
1989
  return response;
3636
1990
  }
3637
1991
  const response = lifecycleReplyOptions
3638
- ? await this.feishuClient.sendText(input.chatId, this.sanitizeUserVisibleReply(this.formatQuotedReply(input.body, input.originalText)), lifecycleReplyOptions)
1992
+ ? await this.feishuClient.sendText(input.chatId, sanitizeUserVisibleReply(formatQuotedReply(input.body, input.originalText)), lifecycleReplyOptions)
3639
1993
  : await this.sendTextReply(input.chatId, input.body, input.replyToMessageId, input.originalText);
3640
1994
  await this.auditLog.append({
3641
1995
  type: 'codex.run.replied',
@@ -3651,20 +2005,20 @@ export class FeiqueService {
3651
2005
  if (queued) {
3652
2006
  return {
3653
2007
  title: '已加入排队',
3654
- body: this.buildAcknowledgedRunReply(projectAlias, '排队中', queued.detail, mode),
2008
+ body: buildAcknowledgedRunReply(projectAlias, '排队中', queued.detail, mode),
3655
2009
  runStatus: 'queued',
3656
2010
  runPhase: '排队中',
3657
2011
  };
3658
2012
  }
3659
2013
  return {
3660
2014
  title: '已接收请求',
3661
- body: this.buildAcknowledgedRunReply(projectAlias, '已接收', '已收到你的消息,正在准备处理。', mode),
2015
+ body: buildAcknowledgedRunReply(projectAlias, '已接收', '已收到你的消息,正在准备处理。', mode),
3662
2016
  runStatus: 'running',
3663
2017
  runPhase: '已接收',
3664
2018
  };
3665
2019
  }
3666
2020
  async sendInitialRunLifecycleReply(input) {
3667
- const lifecycleMode = this.resolveRunLifecycleReplyMode();
2021
+ const lifecycleMode = resolveRunLifecycleReplyMode(this.config);
3668
2022
  const draft = this.buildInitialRunLifecycleReply(input.projectAlias, input.queued, lifecycleMode);
3669
2023
  try {
3670
2024
  const response = await this.sendRunLifecycleReply({
@@ -3684,7 +2038,7 @@ export class FeiqueService {
3684
2038
  this.logger.warn({ error, runId: input.runId, projectAlias: input.projectAlias }, 'Failed to send initial lifecycle reply');
3685
2039
  }
3686
2040
  }
3687
- async rememberRunReplyTarget(runId, response, mode = this.resolveRunLifecycleReplyMode()) {
2041
+ async rememberRunReplyTarget(runId, response, mode = resolveRunLifecycleReplyMode(this.config)) {
3688
2042
  this.runReplyTargets.set(runId, {
3689
2043
  messageId: response.message_id,
3690
2044
  mode,
@@ -3696,7 +2050,7 @@ export class FeiqueService {
3696
2050
  return;
3697
2051
  }
3698
2052
  const label = backendLabel ?? 'AI';
3699
- const body = this.buildAcknowledgedRunReply(projectAlias, '处理中', '桥接器已开始处理你的请求。', target.mode);
2053
+ const body = buildAcknowledgedRunReply(projectAlias, '处理中', '桥接器已开始处理你的请求。', target.mode);
3700
2054
  await this.updateRunLifecycleReply({
3701
2055
  chatId,
3702
2056
  projectAlias,
@@ -3714,7 +2068,7 @@ export class FeiqueService {
3714
2068
  }
3715
2069
  const label = backendLabel ?? 'AI';
3716
2070
  const body = [
3717
- this.buildAcknowledgedRunReply(input.projectAlias, '处理中', '桥接器正在持续处理你的请求。', target.mode),
2071
+ buildAcknowledgedRunReply(input.projectAlias, '处理中', '桥接器正在持续处理你的请求。', target.mode),
3718
2072
  '最新进展:',
3719
2073
  progress,
3720
2074
  ]
@@ -3766,10 +2120,10 @@ export class FeiqueService {
3766
2120
  if (!target?.messageId) {
3767
2121
  return false;
3768
2122
  }
3769
- const sanitizedBody = this.sanitizeUserVisibleReply(input.body);
2123
+ const sanitizedBody = sanitizeUserVisibleReply(input.body);
3770
2124
  if (target.mode === 'card') {
3771
- const includeActions = input.runStatus === 'success' && this.supportsInteractiveCardActions() && input.sessionKey !== undefined;
3772
- await this.feishuClient.updateCard(target.messageId, this.buildRunLifecycleCard({
2125
+ const includeActions = input.runStatus === 'success' && supportsInteractiveCardActions(this.config) && input.sessionKey !== undefined;
2126
+ await this.feishuClient.updateCard(target.messageId, buildRunLifecycleCard({
3773
2127
  title: input.title,
3774
2128
  body: input.body,
3775
2129
  projectAlias: input.projectAlias,
@@ -3804,7 +2158,7 @@ export class FeiqueService {
3804
2158
  }));
3805
2159
  }
3806
2160
  else if (target.mode === 'post') {
3807
- const title = this.buildReplyTitle(sanitizedBody);
2161
+ const title = buildReplyTitle(sanitizedBody);
3808
2162
  await this.feishuClient.updatePost(target.messageId, buildFeishuPost(title, sanitizedBody));
3809
2163
  }
3810
2164
  else {
@@ -3830,332 +2184,9 @@ export class FeiqueService {
3830
2184
  });
3831
2185
  return true;
3832
2186
  }
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
2187
  }
2188
+ // Module-level helpers extracted to service-utils.ts (β step 1).
2189
+ // Re-exported here for backward compatibility (src/mcp/server.ts and
2190
+ // src/index.ts depend on these names being importable from this module).
2191
+ export { buildQueueKey, buildProjectRootQueueKey } from './service-utils.js';
4161
2192
  //# sourceMappingURL=service.js.map