@miphamai/cli 0.49.0 → 0.51.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 (58) hide show
  1. package/bin/mipham.ts +1 -9
  2. package/package.json +1 -1
  3. package/skills/standard/github-ops.SKILL.md +1 -1
  4. package/skills/standard/web-access/references/cdp-api.md +2 -1
  5. package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
  6. package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
  7. package/skills/standard/web-access/scripts/find-url.mjs +0 -0
  8. package/skills/standard/web-access/scripts/match-site.mjs +0 -0
  9. package/skills/standard/web-access.SKILL.md +10 -3
  10. package/src/agent/cross-session/discovery.ts +26 -1
  11. package/src/commands/project.ts +1 -1
  12. package/src/config/defaults.ts +8 -0
  13. package/src/core/auto-memory.ts +3 -3
  14. package/src/core/behavior-tasks.json +101 -0
  15. package/src/core/behavior-tasks.ts +44 -0
  16. package/src/core/constitution-loader.ts +0 -2
  17. package/src/core/context.ts +0 -1
  18. package/src/core/credential-masker/index.ts +1 -0
  19. package/src/core/credential-masker/matcher.ts +18 -7
  20. package/src/core/credential-masker/search.ts +73 -0
  21. package/src/core/credential-masker.ts +1 -0
  22. package/src/core/crsi-producer.ts +214 -0
  23. package/src/core/crsi-sandbox.ts +5 -1
  24. package/src/core/dream-engine.ts +0 -2
  25. package/src/core/eval-harness.ts +7 -0
  26. package/src/core/instructions.ts +11 -0
  27. package/src/core/memory/memory-loader.ts +3 -1
  28. package/src/core/permission-rules.ts +23 -12
  29. package/src/core/proposal-guard.ts +100 -0
  30. package/src/core/rules-loader.ts +8 -10
  31. package/src/core/task-runner-tasks.json +14 -0
  32. package/src/core/task-runner.ts +163 -0
  33. package/src/daemon/database.ts +0 -1
  34. package/src/daemon/feishu/adapter.ts +2 -1
  35. package/src/daemon/heartbeat.ts +83 -0
  36. package/src/daemon/index.ts +18 -8
  37. package/src/daemon/server.ts +78 -3
  38. package/src/daemon/session-manager.ts +5 -4
  39. package/src/daemon/telegram/adapter.ts +70 -0
  40. package/src/daemon/telegram/api.ts +45 -0
  41. package/src/daemon/telegram/env.ts +14 -0
  42. package/src/daemon/telegram/poller.ts +64 -0
  43. package/src/daemon/telegram/types.ts +10 -0
  44. package/src/i18n-core/locales/en-US.json +123 -0
  45. package/src/i18n-core/locales/zh-CN.json +123 -0
  46. package/src/index.tsx +6 -0
  47. package/src/shared/package-info.ts +4 -1
  48. package/src/shared/types.ts +2 -0
  49. package/src/skills/bundled-skill-assets.ts +5 -5
  50. package/src/skills/bundled-skills.ts +2 -2
  51. package/src/tools/agent/exit-plan.ts +1 -3
  52. package/src/tools/agent/memory.ts +2 -1
  53. package/src/tools/file/glob.ts +38 -23
  54. package/src/tools/file/grep.ts +96 -64
  55. package/src/tools/index.ts +5 -5
  56. package/src/ui/app.tsx +21 -1
  57. package/src/ui/commands.ts +269 -125
  58. package/src/ui/config-wizard.tsx +1 -1
@@ -0,0 +1,163 @@
1
+ // apps/cli/src/core/task-runner.ts
2
+ // CRSI 端到端任务运行器(C-MVP)——行为效果度量基建。
3
+ import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import tasksFile from './task-runner-tasks.json' with { type: 'json' }
6
+ import { QueryEngine } from './engine'
7
+ import { ContextManager } from './context'
8
+ import { PermissionSystem } from './permission'
9
+ import { ProviderRegistry } from '../providers/registry'
10
+ import type { Llm } from '../providers/llm'
11
+ import { createToolRegistry } from '../tools'
12
+ import type { PermissionLevel } from '../shared'
13
+
14
+ export type RunnerGroundTruth = { kind: 'file-contains'; file: string; contains: string[] }
15
+
16
+ export interface RunnerTask {
17
+ id: string
18
+ instruction: string
19
+ groundTruth: RunnerGroundTruth
20
+ }
21
+
22
+ export function loadRunnerTasks(): RunnerTask[] {
23
+ return tasksFile.tasks as unknown as RunnerTask[]
24
+ }
25
+
26
+ export function judgeTask(task: RunnerTask, taskDir: string): { passed: boolean; detail?: string } {
27
+ if (task.groundTruth.kind !== 'file-contains') {
28
+ return { passed: false, detail: `unsupported groundTruth kind: ${task.groundTruth.kind}` }
29
+ }
30
+ const filePath = join(taskDir, task.groundTruth.file)
31
+ if (!existsSync(filePath)) {
32
+ return { passed: false, detail: `file not found: ${task.groundTruth.file}` }
33
+ }
34
+ const content = readFileSync(filePath, 'utf-8')
35
+ for (const needle of task.groundTruth.contains) {
36
+ if (!content.includes(needle)) {
37
+ return { passed: false, detail: `missing substring: ${needle}` }
38
+ }
39
+ }
40
+ return { passed: true }
41
+ }
42
+
43
+ export interface TaskRunResult {
44
+ taskId: string
45
+ passed: boolean
46
+ detail?: string
47
+ }
48
+
49
+ const TASK_DIR_PLACEHOLDER = '<taskDir>'
50
+
51
+ function buildEngine(llm: Llm, permission: PermissionLevel, systemPrompt?: string): QueryEngine {
52
+ const registry = new ProviderRegistry([], 'test', 'test-model')
53
+ // 注册一个永不 chat 的占位 provider——llm 被 setLlm 覆盖,但 process() 内部
54
+ // 多处调用 registry.getActive().config.id 记录 provider id,必须能取到。
55
+ registry.register('test', {
56
+ config: { id: 'test', name: 'Test', protocol: 'openai-compatible', apiKey: 'key', models: [] },
57
+ chat: async function* () {
58
+ yield { type: 'stop' }
59
+ },
60
+ listModels: async () => [],
61
+ healthCheck: async () => true,
62
+ })
63
+ const context = new ContextManager({ maxTokens: 100_000, compactionThreshold: 0.9 })
64
+ if (systemPrompt !== undefined) context.setSystemPrompt(systemPrompt)
65
+ const tools = createToolRegistry()
66
+ const engine = new QueryEngine(registry, context, tools, new PermissionSystem(permission))
67
+ engine.setLlm(llm)
68
+ return engine
69
+ }
70
+
71
+ export async function runTask(
72
+ task: RunnerTask,
73
+ llm: Llm,
74
+ opts: { taskDir?: string; permission?: PermissionLevel; systemPrompt?: string } = {},
75
+ ): Promise<TaskRunResult> {
76
+ const taskDir = opts.taskDir ?? join(process.cwd(), '.mipham', 'task-runner')
77
+ const permission = opts.permission ?? 'bypassPermissions'
78
+
79
+ rmSync(taskDir, { recursive: true, force: true })
80
+ mkdirSync(taskDir, { recursive: true })
81
+
82
+ const instruction = task.instruction.replaceAll(TASK_DIR_PLACEHOLDER, taskDir)
83
+ const engine = buildEngine(llm, permission, opts.systemPrompt)
84
+
85
+ for await (const _ of engine.process(instruction)) {
86
+ /* drain agentic loop */
87
+ }
88
+
89
+ const verdict = judgeTask(task, taskDir)
90
+ return { taskId: task.id, passed: verdict.passed, detail: verdict.detail }
91
+ }
92
+
93
+ export interface TaskRunStats {
94
+ taskId: string
95
+ samples: number
96
+ passed: number
97
+ /** 0-1 */
98
+ passRate: number
99
+ }
100
+
101
+ export async function runTaskN(
102
+ task: RunnerTask,
103
+ llm: Llm,
104
+ n: number,
105
+ opts: { taskDir?: string; permission?: PermissionLevel; systemPrompt?: string } = {},
106
+ ): Promise<TaskRunStats> {
107
+ let passed = 0
108
+ for (let i = 0; i < n; i++) {
109
+ const result = await runTask(task, llm, opts)
110
+ if (result.passed) passed++
111
+ }
112
+ return { taskId: task.id, samples: n, passed, passRate: n > 0 ? passed / n : 0 }
113
+ }
114
+
115
+ export interface RunComparison {
116
+ baseline: TaskRunStats
117
+ candidate: TaskRunStats
118
+ /** 弱判:candidate 不退化(不低于 baseline 且至少 1 次成功) */
119
+ notDegraded: boolean
120
+ /** candidate 严格更好(通过率更高) */
121
+ improved: boolean
122
+ }
123
+
124
+ export function isNotDegraded(baseline: TaskRunStats, candidate: TaskRunStats): boolean {
125
+ return candidate.passRate >= baseline.passRate && candidate.passed >= 1
126
+ }
127
+
128
+ export function isImproved(baseline: TaskRunStats, candidate: TaskRunStats): boolean {
129
+ return candidate.passRate > baseline.passRate
130
+ }
131
+
132
+ export function compareRuns(baseline: TaskRunStats, candidate: TaskRunStats): RunComparison {
133
+ return {
134
+ baseline,
135
+ candidate,
136
+ notDegraded: isNotDegraded(baseline, candidate),
137
+ improved: isImproved(baseline, candidate),
138
+ }
139
+ }
140
+
141
+ export async function runBeforeAfter(
142
+ task: RunnerTask,
143
+ llm: Llm,
144
+ n: number,
145
+ opts: {
146
+ beforePrompt?: string
147
+ afterPrompt?: string
148
+ taskDir?: string
149
+ permission?: PermissionLevel
150
+ } = {},
151
+ ): Promise<RunComparison> {
152
+ const baseline = await runTaskN(task, llm, n, {
153
+ taskDir: opts.taskDir,
154
+ permission: opts.permission,
155
+ systemPrompt: opts.beforePrompt,
156
+ })
157
+ const candidate = await runTaskN(task, llm, n, {
158
+ taskDir: opts.taskDir,
159
+ permission: opts.permission,
160
+ systemPrompt: opts.afterPrompt,
161
+ })
162
+ return compareRuns(baseline, candidate)
163
+ }
@@ -289,7 +289,6 @@ export class DaemonDatabase {
289
289
  }
290
290
 
291
291
  vals.push(id)
292
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
292
  this.db.run(`UPDATE goals SET ${sets.join(', ')} WHERE id = ?`, vals as any)
294
293
  }
295
294
 
@@ -28,7 +28,8 @@ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDep
28
28
  if (!allowed.has(msg.openId)) return
29
29
  if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
30
 
31
- const session = deps.sm.getOrCreateByFeishuOpenId(
31
+ const session = deps.sm.getOrCreateByExternalUser(
32
+ 'feishu',
32
33
  msg.openId,
33
34
  deps.cwd,
34
35
  deps.provider,
@@ -0,0 +1,83 @@
1
+ // apps/cli/src/daemon/heartbeat.ts — 心跳式通知(保守版 KAIROS「订阅与推送」)
2
+ //
3
+ // 借鉴 Claude Code KAIROS 的「主动感知」思路,但严格约束为「只通知、不自主行动」:
4
+ // - 定时扫描 pending 的 goal(active)与 schedule(enabled)
5
+ // - 有 pending 时推送一条摘要(默认走 Feishu),无 pending 时静默
6
+ // - 绝不替用户执行任何动作——「主动感知可以,自主行动必须有闸门」(CRSI 受约束哲学)
7
+ //
8
+ // 纯函数(collectPendingItems / buildHeartbeatMessage / heartbeatTick)便于测试;
9
+ // startHeartbeat 只做 setInterval + unref 的薄接线。
10
+
11
+ import type { DaemonGoal, DaemonSchedule } from './types'
12
+
13
+ /** 默认心跳间隔:30 分钟(提醒型通知,不宜过频)。 */
14
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30 * 60_000
15
+
16
+ export interface PendingItems {
17
+ goalCount: number
18
+ scheduleCount: number
19
+ summaries: string[]
20
+ }
21
+
22
+ /** 收集待办项:active 的 goal + enabled 的 schedule。纯函数。 */
23
+ export function collectPendingItems(
24
+ goals: DaemonGoal[],
25
+ schedules: DaemonSchedule[],
26
+ ): PendingItems {
27
+ const activeGoals = goals.filter((g) => g.status === 'active')
28
+ const enabledSchedules = schedules.filter((s) => s.enabled)
29
+
30
+ const summaries: string[] = []
31
+ for (const g of activeGoals) summaries.push(`🎯 ${g.description}`)
32
+ for (const s of enabledSchedules) summaries.push(`⏰ [${s.cronExpr}] ${s.prompt}`)
33
+
34
+ return {
35
+ goalCount: activeGoals.length,
36
+ scheduleCount: enabledSchedules.length,
37
+ summaries,
38
+ }
39
+ }
40
+
41
+ const MAX_SUMMARY_ITEMS = 10
42
+
43
+ /** 把待办渲染成通知文案;无可待办时返回 null(不打扰)。纯函数。 */
44
+ export function buildHeartbeatMessage(pending: PendingItems): string | null {
45
+ if (pending.goalCount === 0 && pending.scheduleCount === 0) return null
46
+
47
+ const lines = [
48
+ `💓 Mipham 心跳提醒:${pending.goalCount} 个待办 goal,${pending.scheduleCount} 个定时任务`,
49
+ '',
50
+ ...pending.summaries.slice(0, MAX_SUMMARY_ITEMS),
51
+ ]
52
+ if (pending.summaries.length > MAX_SUMMARY_ITEMS) {
53
+ lines.push(`… 另外 ${pending.summaries.length - MAX_SUMMARY_ITEMS} 项`)
54
+ }
55
+ return lines.join('\n')
56
+ }
57
+
58
+ export interface HeartbeatSource {
59
+ listGoals(): DaemonGoal[]
60
+ listSchedules(): DaemonSchedule[]
61
+ }
62
+
63
+ /** 单次心跳:收集待办,有则推送。抽出来便于直接测试。 */
64
+ export function heartbeatTick(source: HeartbeatSource, push: (message: string) => void): void {
65
+ const pending = collectPendingItems(source.listGoals(), source.listSchedules())
66
+ const message = buildHeartbeatMessage(pending)
67
+ if (message) push(message)
68
+ }
69
+
70
+ export interface HeartbeatDeps {
71
+ source: HeartbeatSource
72
+ push: (message: string) => void
73
+ intervalMs?: number
74
+ }
75
+
76
+ /** 启动心跳定时器(unref 不阻止进程退出),返回 stop 函数。 */
77
+ export function startHeartbeat(deps: HeartbeatDeps): () => void {
78
+ const intervalMs = deps.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
79
+ const id = setInterval(() => heartbeatTick(deps.source, deps.push), intervalMs)
80
+ // Bun 的 Timer 有 unref;假定时器/其它运行时不保证——用可选调用容错。
81
+ ;(id as { unref?: () => void }).unref?.()
82
+ return () => clearInterval(id)
83
+ }
@@ -25,6 +25,8 @@ import type { DaemonStatus } from './types'
25
25
  import { logger } from './logger'
26
26
  import { parseFeishuEnv } from './feishu/env.js'
27
27
  import type { FeishuConfig } from './feishu/types.js'
28
+ import { parseTelegramEnv } from './telegram/env.js'
29
+ import type { TelegramConfig } from './telegram/types.js'
28
30
  import { loadConfig } from '../config/loader'
29
31
 
30
32
  const HOME = homedir()
@@ -40,8 +42,6 @@ const DEFAULT_PORT = 45671
40
42
  let activeServer: Server<unknown> | null = null
41
43
  let activeDb: DaemonDatabase | null = null
42
44
  let activePool: WorkerPool | null = null
43
- let activeAgentManager: AgentManager | null = null
44
- let activeMessageBus: MessageBus | null = null
45
45
  let activeScheduleManager: ScheduleManager | null = null
46
46
  let activeRateLimiter: RateLimiter | null = null
47
47
 
@@ -150,9 +150,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
150
150
 
151
151
  // Create agent manager and message bus (Phase 3)
152
152
  const agentManager = new AgentManager(db)
153
- activeAgentManager = agentManager
154
153
  const messageBus = new MessageBus()
155
- activeMessageBus = messageBus
156
154
 
157
155
  // Create goal manager and schedule manager (Phase 4)
158
156
  const goalManager = new GoalManager(db)
@@ -181,6 +179,21 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
181
179
  }
182
180
  }
183
181
 
182
+ // Telegram remote-control adapter(env 未配置时跳过;配置则解析 session 默认 provider/model)
183
+ const telegramConfig = parseTelegramEnv()
184
+ let telegram: { config: TelegramConfig; cwd: string; provider: string; model: string } | undefined
185
+ if (telegramConfig) {
186
+ const cfg = loadConfig()
187
+ // 取第一个非 upcoming(即非「待上线」)的 provider 及其首个 model
188
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
189
+ telegram = {
190
+ config: telegramConfig,
191
+ cwd: process.env.TELEGRAM_CWD || process.cwd(),
192
+ provider: provider?.id ?? 'anthropic',
193
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
194
+ }
195
+ }
196
+
184
197
  // Start HTTP server (Bun.serve starts listening immediately)
185
198
  const server = createServer({
186
199
  db,
@@ -196,6 +209,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
196
209
  scheduleManager,
197
210
  rateLimiter,
198
211
  feishu,
212
+ telegram,
199
213
  })
200
214
  activeServer = server
201
215
 
@@ -250,10 +264,6 @@ export async function stopDaemon(force: boolean = false): Promise<void> {
250
264
  activeRateLimiter = null
251
265
  }
252
266
 
253
- // Clean up agent manager and message bus (Phase 3)
254
- activeAgentManager = null
255
- activeMessageBus = null
256
-
257
267
  // Close database (commits any pending WAL)
258
268
  if (activeDb) {
259
269
  activeDb.close()
@@ -22,9 +22,14 @@ import { bootstrapProviders } from '../providers/bootstrap'
22
22
  import { createToolRegistry } from '../tools'
23
23
  import { PermissionSystem } from '../core/permission'
24
24
  import type { ProviderRegistry } from '../providers/registry'
25
- import type { ToolDefinition, PermissionMode } from '../shared/types'
25
+ import type { ToolDefinition, PermissionMode, PermissionRestrictions } from '../shared/types'
26
26
  import { createFeishuAdapter } from './feishu/adapter.js'
27
+ import { createFeishuApi } from './feishu/api.js'
27
28
  import type { FeishuConfig } from './feishu/types.js'
29
+ import { createTelegramAdapter } from './telegram/adapter.js'
30
+ import { createTelegramApi } from './telegram/api.js'
31
+ import type { TelegramConfig } from './telegram/types.js'
32
+ import { startHeartbeat } from './heartbeat'
28
33
 
29
34
  interface ServerConfig {
30
35
  db: DaemonDatabase
@@ -40,6 +45,7 @@ interface ServerConfig {
40
45
  scheduleManager: ScheduleManager
41
46
  rateLimiter: RateLimiter
42
47
  feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
48
+ telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
43
49
  }
44
50
 
45
51
  interface WsData {
@@ -67,6 +73,25 @@ function resolveDaemonPermission(): PermissionMode {
67
73
  : 'default'
68
74
  }
69
75
 
76
+ /**
77
+ * Build the daemon's PermissionSystem, honoring org-level restrictions
78
+ * (permissionRestrictions) and user-defined allow/deny rules. setRestrictions
79
+ * re-clamps the env-derived mode, so a `MIPHAM_DAEMON_PERMISSION=bypassPermissions`
80
+ * is downgraded when the config forbids it — mirroring the CLI's fail-closed behavior.
81
+ */
82
+ export function buildDaemonPermission(
83
+ restrictions?: PermissionRestrictions,
84
+ rules?: { allow?: string[]; deny?: string[] },
85
+ ): PermissionSystem {
86
+ const permission = new PermissionSystem(resolveDaemonPermission())
87
+ if (restrictions) permission.setRestrictions(restrictions)
88
+ if (rules) {
89
+ for (const rule of rules.allow ?? []) permission.allow(rule)
90
+ for (const rule of rules.deny ?? []) permission.deny(rule)
91
+ }
92
+ return permission
93
+ }
94
+
70
95
  const DAEMON_DEFAULT_CONTEXT_WINDOW = 200_000
71
96
 
72
97
  /**
@@ -98,6 +123,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
98
123
  scheduleManager,
99
124
  rateLimiter,
100
125
  feishu,
126
+ telegram,
101
127
  } = config
102
128
 
103
129
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -185,7 +211,11 @@ export function createServer(config: ServerConfig): Server<WsData> {
185
211
  }
186
212
  }
187
213
 
188
- const permission = new PermissionSystem(resolveDaemonPermission())
214
+ const daemonConfig = loadConfig(cwd)
215
+ const permission = buildDaemonPermission(
216
+ daemonConfig.permissionRestrictions,
217
+ daemonConfig.permissionRules,
218
+ )
189
219
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
190
220
  engine.setSessionId(sessionId)
191
221
  engineCache.set(sessionId, engine)
@@ -194,7 +224,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
194
224
 
195
225
  function getOrCreateWorker(
196
226
  sessionId: string,
197
- ws?: ServerWebSocket<WsData>,
227
+ _ws?: ServerWebSocket<WsData>,
198
228
  ): SessionWorker | null {
199
229
  try {
200
230
  const session = db.getSession(sessionId)
@@ -231,6 +261,51 @@ export function createServer(config: ServerConfig): Server<WsData> {
231
261
  })
232
262
  : undefined
233
263
 
264
+ // ── Telegram remote-control adapter(长轮询,无需 webhook 路由)──
265
+ const telegramAdapter = telegram
266
+ ? createTelegramAdapter(telegram.config, createTelegramApi(telegram.config), {
267
+ sm,
268
+ getOrCreateWorker,
269
+ rateLimiter,
270
+ cwd: telegram.cwd,
271
+ provider: telegram.provider,
272
+ model: telegram.model,
273
+ })
274
+ : undefined
275
+ telegramAdapter?.start()
276
+
277
+ // ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
278
+ const heartbeatSource = {
279
+ listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
280
+ listSchedules: () => sm.listSessions().flatMap((s) => scheduleManager.getSchedules(s.id)),
281
+ }
282
+ if (feishu) {
283
+ const feishuApi = createFeishuApi(feishu.config)
284
+ startHeartbeat({
285
+ source: heartbeatSource,
286
+ push: (message) => {
287
+ for (const openId of feishu.config.allowedOpenIds) {
288
+ void feishuApi.sendText(openId, message).catch(() => {
289
+ /* 推送失败静默,不打断心跳 */
290
+ })
291
+ }
292
+ },
293
+ })
294
+ }
295
+ if (telegram) {
296
+ const telegramApi = createTelegramApi(telegram.config)
297
+ startHeartbeat({
298
+ source: heartbeatSource,
299
+ push: (message) => {
300
+ for (const chatId of telegram.config.allowedChatIds) {
301
+ void telegramApi.sendText(chatId, message).catch(() => {
302
+ /* 推送失败静默,不打断心跳 */
303
+ })
304
+ }
305
+ },
306
+ })
307
+ }
308
+
234
309
  const server = Bun.serve<WsData>({
235
310
  port,
236
311
  hostname,
@@ -1,5 +1,5 @@
1
1
  import type { DaemonDatabase } from './database'
2
- import type { DaemonSession, SessionStatus } from './types'
2
+ import type { DaemonSession } from './types'
3
3
 
4
4
  type SessionCloseCallback = (sessionId: string) => void
5
5
 
@@ -23,13 +23,14 @@ export class SessionManager {
23
23
  return this.db.listSessions(status)
24
24
  }
25
25
 
26
- getOrCreateByFeishuOpenId(
27
- openId: string,
26
+ getOrCreateByExternalUser(
27
+ channel: string,
28
+ userId: string,
28
29
  cwd: string,
29
30
  provider: string,
30
31
  model: string,
31
32
  ): DaemonSession {
32
- const name = `feishu-${openId}`
33
+ const name = `${channel}-${userId}`
33
34
  const existing = this.db.listSessions().find((s) => s.name === name && s.status !== 'closed')
34
35
  if (existing) return existing
35
36
  return this.db.createSession({ name, cwd, provider, model })
@@ -0,0 +1,70 @@
1
+ import type { TelegramApi } from './api.js'
2
+ import type { TelegramConfig, TelegramMessage } from './types.js'
3
+ import type { SessionManager } from '../session-manager'
4
+ import type { SessionWorker } from '../session-worker'
5
+ import type { RateLimiter } from '../rate-limiter'
6
+ import { startTelegramPoller } from './poller.js'
7
+
8
+ export interface TelegramAdapterDeps {
9
+ sm: SessionManager
10
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
11
+ rateLimiter: RateLimiter
12
+ cwd: string
13
+ provider: string
14
+ model: string
15
+ }
16
+
17
+ export interface TelegramAdapter {
18
+ start(): () => void
19
+ handleMessage(msg: TelegramMessage): Promise<void>
20
+ isAllowed(chatId: string): boolean
21
+ }
22
+
23
+ export function createTelegramAdapter(
24
+ config: TelegramConfig,
25
+ api: TelegramApi,
26
+ deps: TelegramAdapterDeps,
27
+ ): TelegramAdapter {
28
+ const allowed = new Set(config.allowedChatIds)
29
+ let stopPoller: (() => void) | null = null
30
+
31
+ async function handleMessage(msg: TelegramMessage): Promise<void> {
32
+ try {
33
+ if (!allowed.has(msg.chatId)) return
34
+ if (!deps.rateLimiter.check(`telegram:${msg.chatId}`).allowed) return
35
+
36
+ const session = deps.sm.getOrCreateByExternalUser(
37
+ 'telegram',
38
+ msg.chatId,
39
+ deps.cwd,
40
+ deps.provider,
41
+ deps.model,
42
+ )
43
+ const worker = deps.getOrCreateWorker(session.id)
44
+ if (!worker) {
45
+ await api.sendText(msg.chatId, '(会话初始化失败,请稍后重试)')
46
+ return
47
+ }
48
+ await worker.processPrompt(msg.text)
49
+ const result = worker.getLastAssistantContent()
50
+ await api.sendText(msg.chatId, result ? result.slice(0, 4096) : '(无回复)')
51
+ } catch (err) {
52
+ console.error('[telegram] message handling failed:', err)
53
+ try {
54
+ await api.sendText(msg.chatId, '(处理失败,请稍后重试)')
55
+ } catch {
56
+ /* 忽略回送失败,不 rethrow */
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ handleMessage,
63
+ isAllowed: (chatId) => allowed.has(chatId),
64
+ start() {
65
+ if (stopPoller) return stopPoller
66
+ stopPoller = startTelegramPoller(api, handleMessage)
67
+ return stopPoller
68
+ },
69
+ }
70
+ }
@@ -0,0 +1,45 @@
1
+ import type { TelegramConfig } from './types.js'
2
+
3
+ export interface TelegramApi {
4
+ getUpdates(
5
+ offset: number,
6
+ timeoutSeconds: number,
7
+ ): Promise<Array<{ update_id: number; message?: unknown }>>
8
+ sendText(chatId: string, text: string): Promise<void>
9
+ }
10
+
11
+ /** 裸 fetch 直连 Telegram Bot API,零依赖。 */
12
+ export function createTelegramApi(config: TelegramConfig): TelegramApi {
13
+ const base = `https://api.telegram.org/bot${config.botToken}`
14
+ return {
15
+ async getUpdates(offset, timeoutSeconds) {
16
+ const url =
17
+ `${base}/getUpdates?offset=${offset}&timeout=${timeoutSeconds}` +
18
+ `&limit=100&allowed_updates=%5B%22message%22%5D`
19
+ const res = await fetch(url)
20
+ if (!res.ok) throw new Error(`getUpdates ${res.status}`)
21
+ const data = (await res.json()) as {
22
+ ok: boolean
23
+ result?: Array<{ update_id: number; message?: unknown }>
24
+ error_code?: number
25
+ description?: string
26
+ }
27
+ if (!data.ok) throw new Error(`getUpdates failed: ${data.error_code ?? res.status}`)
28
+ return data.result ?? []
29
+ },
30
+ async sendText(chatId, text) {
31
+ const res = await fetch(`${base}/sendMessage`, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ chat_id: chatId, text }),
35
+ })
36
+ if (!res.ok) throw new Error(`sendMessage ${res.status}`)
37
+ const data = (await res.json()) as {
38
+ ok: boolean
39
+ error_code?: number
40
+ description?: string
41
+ }
42
+ if (!data.ok) throw new Error(`sendMessage failed: ${data.error_code ?? res.status}`)
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,14 @@
1
+ import type { TelegramConfig } from './types.js'
2
+
3
+ /** fail-closed:缺 botToken → null(daemon 不启用 Telegram)。 */
4
+ export function parseTelegramEnv(): TelegramConfig | null {
5
+ const botToken = process.env.TELEGRAM_BOT_TOKEN
6
+ if (!botToken) return null
7
+ return {
8
+ botToken,
9
+ allowedChatIds: (process.env.TELEGRAM_ALLOWED_CHAT_IDS || '')
10
+ .split(',')
11
+ .map((s) => s.trim())
12
+ .filter(Boolean),
13
+ }
14
+ }
@@ -0,0 +1,64 @@
1
+ import type { TelegramApi } from './api.js'
2
+ import type { TelegramMessage } from './types.js'
3
+
4
+ /** 从 update 提取文本消息;非文本/缺 message → null。chat.id 统一字符串化比对(id < 2^53;超范围 bigint 后续再引入)。 */
5
+ export function extractTextMessage(update: unknown): TelegramMessage | null {
6
+ const msg = (
7
+ update as { message?: { chat?: { id?: number | string }; message_id?: number; text?: string } }
8
+ )?.message
9
+ if (!msg?.text || msg.chat?.id == null) return null
10
+ return { chatId: String(msg.chat.id), messageId: msg.message_id ?? 0, text: msg.text }
11
+ }
12
+
13
+ /** 推进 offset:最大 update_id + 1;空列表沿用 prev。 */
14
+ export function nextOffset(updates: Array<{ update_id?: number }>, prevOffset: number): number {
15
+ if (updates.length === 0) return prevOffset
16
+ return Math.max(...updates.map((u) => u.update_id ?? prevOffset)) + 1
17
+ }
18
+
19
+ /** 指数退避,封顶 30s。 */
20
+ export function nextBackoff(currentMs: number): number {
21
+ return Math.min(currentMs * 2, 30_000)
22
+ }
23
+
24
+ /** 长轮询循环(unref 不阻退出)。返回 stop 函数。循环行为由 Task 7 集成测试覆盖。 */
25
+ export function startTelegramPoller(
26
+ api: TelegramApi,
27
+ onMessage: (msg: TelegramMessage) => Promise<void>,
28
+ opts?: { pollTimeoutSeconds?: number },
29
+ ): () => void {
30
+ let offset = 0
31
+ let backoffMs = 1000
32
+ let stopped = false
33
+ let timer: ReturnType<typeof setTimeout> | null = null
34
+
35
+ async function loop() {
36
+ if (stopped) return
37
+ try {
38
+ const updates = await api.getUpdates(offset, opts?.pollTimeoutSeconds ?? 30)
39
+ for (const u of updates) {
40
+ const msg = extractTextMessage(u)
41
+ if (msg) {
42
+ try {
43
+ await onMessage(msg)
44
+ } catch {
45
+ /* 单条失败不中断轮询 */
46
+ }
47
+ }
48
+ }
49
+ offset = nextOffset(updates, offset)
50
+ backoffMs = 1000
51
+ } catch {
52
+ backoffMs = nextBackoff(backoffMs)
53
+ }
54
+ if (stopped) return
55
+ timer = setTimeout(loop, backoffMs)
56
+ ;(timer as { unref?: () => void }).unref?.()
57
+ }
58
+
59
+ void loop()
60
+ return () => {
61
+ stopped = true
62
+ if (timer) clearTimeout(timer)
63
+ }
64
+ }