@miphamai/cli 0.50.0 → 0.52.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 (57) hide show
  1. package/bin/mipham.ts +1 -9
  2. package/package.json +1 -2
  3. package/skills/standard/web-access/references/cdp-api.md +2 -1
  4. package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
  5. package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
  6. package/skills/standard/web-access/scripts/find-url.mjs +0 -0
  7. package/skills/standard/web-access/scripts/match-site.mjs +0 -0
  8. package/skills/standard/web-access.SKILL.md +10 -3
  9. package/src/commands/keys.ts +26 -0
  10. package/src/commands/project.ts +1 -1
  11. package/src/config/credential-crypto.ts +114 -0
  12. package/src/config/defaults.ts +6 -1
  13. package/src/config/loader.ts +65 -14
  14. package/src/core/auto-memory.ts +3 -3
  15. package/src/core/constitution-loader.ts +0 -2
  16. package/src/core/context.ts +0 -1
  17. package/src/core/credential-masker/index.ts +1 -0
  18. package/src/core/credential-masker/matcher.ts +18 -7
  19. package/src/core/credential-masker/search.ts +73 -0
  20. package/src/core/credential-masker.ts +1 -0
  21. package/src/core/crsi-sandbox.ts +0 -1
  22. package/src/core/dream-engine.ts +0 -2
  23. package/src/core/instructions.ts +36 -12
  24. package/src/core/memory/memory-loader.ts +3 -1
  25. package/src/core/permission-rules.ts +23 -12
  26. package/src/core/rules-loader.ts +8 -10
  27. package/src/daemon/database.ts +0 -1
  28. package/src/daemon/feishu/adapter.ts +2 -1
  29. package/src/daemon/index.ts +18 -8
  30. package/src/daemon/server.ts +53 -10
  31. package/src/daemon/session-manager.ts +5 -4
  32. package/src/daemon/telegram/adapter.ts +70 -0
  33. package/src/daemon/telegram/api.ts +45 -0
  34. package/src/daemon/telegram/env.ts +14 -0
  35. package/src/daemon/telegram/poller.ts +64 -0
  36. package/src/daemon/telegram/types.ts +10 -0
  37. package/src/i18n-core/locales/en-US.json +123 -0
  38. package/src/i18n-core/locales/zh-CN.json +123 -0
  39. package/src/index.tsx +10 -8
  40. package/src/mcp/token-store.ts +2 -37
  41. package/src/shared/package-info.ts +1 -1
  42. package/src/shared/types.ts +5 -3
  43. package/src/skills/bin-check.ts +40 -0
  44. package/src/skills/bundled-skill-assets.ts +5 -5
  45. package/src/skills/bundled-skills.ts +1 -1
  46. package/src/skills/loader.ts +35 -10
  47. package/src/skills/sanitizer.ts +1 -2
  48. package/src/skills/seam.ts +1 -1
  49. package/src/tools/agent/exit-plan.ts +1 -3
  50. package/src/tools/agent/memory.ts +2 -1
  51. package/src/tools/agent/skill.ts +14 -0
  52. package/src/tools/file/glob.ts +38 -23
  53. package/src/tools/file/grep.ts +86 -64
  54. package/src/tools/index.ts +5 -5
  55. package/src/ui/commands.ts +191 -119
  56. package/src/ui/config-wizard.tsx +5 -2
  57. package/src/core/tokenizer.ts +0 -80
@@ -0,0 +1,73 @@
1
+ import type { CredentialMaskingConfig } from '../../shared/index.ts'
2
+ import { matchCredentialFile } from './matcher'
3
+ import { CREDENTIAL_SENTINEL } from './types'
4
+
5
+ /**
6
+ * Mask grep/search output: any match line that belongs to a credential file
7
+ * (e.g. `.env`, `.aws/credentials`) has its content replaced with the
8
+ * credential sentinel, so Grep can't leak secrets that Read would mask.
9
+ *
10
+ * `format` disambiguates the two shapes the Grep tool emits — each invocation
11
+ * produces exactly one, so there is no cross-format ambiguity to guess at:
12
+ * - 'heading' (ripgrep `--heading`): file path on its own line, then `N:content` lines
13
+ * - 'filename' (plain grep `-rn`): `path:N:content` on a single line
14
+ *
15
+ * NOTE: the sensitive file's *path* stays visible — only its match content is
16
+ * masked. That's intentional for grep (a targeted search where the model already
17
+ * named the path); the secret is the content, not the location. Contrast
18
+ * `maskGlobOutput`, which hides the path entirely (enumeration's leak is existence).
19
+ */
20
+ export function maskSearchOutput(
21
+ stdout: string,
22
+ config?: CredentialMaskingConfig,
23
+ format: 'heading' | 'filename' = 'heading',
24
+ ): string {
25
+ if (!config?.enabled) return stdout
26
+ const out: string[] = []
27
+ let currentMasked = false
28
+
29
+ for (const line of stdout.split('\n')) {
30
+ if (format === 'filename') {
31
+ const m = line.match(/^(.+?):(\d+):(.*)$/)
32
+ if (m) {
33
+ out.push(
34
+ matchCredentialFile(m[1]!, config) ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}` : line,
35
+ )
36
+ } else {
37
+ out.push(line)
38
+ }
39
+ continue
40
+ }
41
+
42
+ // 'heading' format: match lines are `N:content`; everything non-empty else is a file heading.
43
+ const m = line.match(/^(\d+):(.*)$/)
44
+ if (m) {
45
+ out.push(currentMasked ? `${m[1]}:${CREDENTIAL_SENTINEL}` : line)
46
+ continue
47
+ }
48
+ if (line.trim() === '') {
49
+ out.push(line) // blank separator — pass through, keep current file
50
+ continue
51
+ }
52
+ currentMasked = !!matchCredentialFile(line, config)
53
+ out.push(line)
54
+ }
55
+
56
+ return out.join('\n')
57
+ }
58
+
59
+ /**
60
+ * Mask a glob file listing: sensitive file paths are replaced with the
61
+ * credential sentinel so file enumeration can't reveal their existence.
62
+ *
63
+ * NOTE: unlike `maskSearchOutput` (which keeps the searched path visible and
64
+ * masks only content), glob hides the whole path — here the leak is the file's
65
+ * *existence*, not its content.
66
+ */
67
+ export function maskGlobOutput(paths: string, config?: CredentialMaskingConfig): string {
68
+ if (!config?.enabled) return paths
69
+ return paths
70
+ .split('\n')
71
+ .map((p) => (matchCredentialFile(p, config) ? CREDENTIAL_SENTINEL : p))
72
+ .join('\n')
73
+ }
@@ -13,6 +13,7 @@ export { matchCredentialFile } from './credential-masker/matcher'
13
13
  export { maskContent } from './credential-masker/pipeline'
14
14
  export { maskOutput } from './credential-masker/output-scrub'
15
15
  export { filterEnv } from './credential-masker/env-filter'
16
+ export { maskSearchOutput, maskGlobOutput } from './credential-masker/search'
16
17
 
17
18
  import { CREDENTIAL_SENTINEL } from './credential-masker/types'
18
19
 
@@ -84,7 +84,6 @@ export interface CrsiSessionReport {
84
84
  // ── Constants ──
85
85
 
86
86
  const WORKTREE_PREFIX = 'crsi-sandbox-'
87
- const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
88
87
  const TEST_TIMEOUT_MS = 120_000 // 2 minutes
89
88
  const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
90
89
 
@@ -469,7 +469,6 @@ export class DreamEngine {
469
469
  const lines = match[1]!.split('\n')
470
470
  const fm: Record<string, unknown> = {}
471
471
  let currentNested: Record<string, unknown> | null = null
472
- let nestedKey = ''
473
472
  for (const line of lines) {
474
473
  // Handle nested YAML (e.g. "metadata:\n type: feedback")
475
474
  const nested = line.match(/^ (\w[\w\s]*?):\s*(.+)$/)
@@ -485,7 +484,6 @@ export class DreamEngine {
485
484
  if (val === '') {
486
485
  // Empty value → start nested block
487
486
  currentNested = {}
488
- nestedKey = key
489
487
  fm[key] = currentNested
490
488
  } else {
491
489
  currentNested = null
@@ -20,9 +20,37 @@ function parseFrontmatter(raw: string): FrontmatterResult {
20
20
  }
21
21
  }
22
22
 
23
+ /** Strip the named sections (by heading title) from a markdown document. */
24
+ export function stripSections(content: string, excluded: string[]): string {
25
+ if (excluded.length === 0) return content
26
+ const lines = content.split('\n')
27
+ const out: string[] = []
28
+ let skipLevel = 0
29
+ for (const line of lines) {
30
+ const m = line.match(/^(#{1,3})\s+(.+?)\s*$/)
31
+ if (m) {
32
+ const level = m[1]!.length
33
+ const title = m[2]!.trim()
34
+ if (excluded.includes(title)) {
35
+ skipLevel = level
36
+ } else if (skipLevel > 0 && level <= skipLevel) {
37
+ skipLevel = 0
38
+ }
39
+ }
40
+ if (skipLevel === 0) out.push(line)
41
+ }
42
+ return out.join('\n')
43
+ }
44
+
45
+ /** Normalize a `prompt-exclude` frontmatter value (YAML list or single string). */
46
+ export function parsePromptExclude(value: unknown): string[] {
47
+ if (Array.isArray(value)) return value.map((v) => String(v))
48
+ if (typeof value === 'string') return [value]
49
+ return []
50
+ }
51
+
23
52
  export class InstructionsLoader {
24
53
  private instructions: InstructionFile[] = []
25
- private skillsReminder = ''
26
54
 
27
55
  loadAll(cwd: string): void {
28
56
  this.instructions = []
@@ -59,7 +87,13 @@ export class InstructionsLoader {
59
87
  directory: 'Directory Rules',
60
88
  user: 'User Preferences',
61
89
  }
62
- parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
90
+ // Strip doc-only sections declared via `prompt-exclude` frontmatter
91
+ // (changelog/roadmap/catalog are human-facing, not machine rules).
92
+ const content = stripSections(
93
+ inst.content,
94
+ parsePromptExclude(inst.frontmatter['prompt-exclude']),
95
+ )
96
+ parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
63
97
  }
64
98
 
65
99
  // P2-2: Inject current permission mode so the model knows its constraints
@@ -67,11 +101,6 @@ export class InstructionsLoader {
67
101
  parts.push(this.buildPermissionContext(permissionMode))
68
102
  }
69
103
 
70
- // Append skills reminder after all instructions
71
- if (this.skillsReminder) {
72
- parts.push(this.skillsReminder)
73
- }
74
-
75
104
  // Inject critical thinking self-check layer (for analysis/comparison tasks)
76
105
  parts.push(`## Critical Thinking Self-Check
77
106
 
@@ -177,11 +206,6 @@ Never omit it or present the work as purely human-authored.`)
177
206
  return parts.join('\n\n---\n\n')
178
207
  }
179
208
 
180
- /** Set the skills system-reminder block to inject into the system prompt. */
181
- setSkillsReminder(reminder: string): void {
182
- this.skillsReminder = reminder
183
- }
184
-
185
209
  /**
186
210
  * P2-2: Build a permission-mode context block for the system prompt.
187
211
  * Tells the model its current permission level and what to expect.
@@ -1,7 +1,9 @@
1
+ import { join } from 'node:path'
2
+ import { homedir } from 'node:os'
1
3
  import { MemoryManager } from './memory-manager'
2
4
  import type { MemoryManager as MemoryManagerType } from './memory-manager'
3
5
 
4
- const MEMORY_DIR = `${process.env.HOME || '~'}/.mipham/memory`
6
+ const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
5
7
 
6
8
  let instance: MemoryManagerType | null = null
7
9
 
@@ -1,14 +1,16 @@
1
1
  import type { PermissionRuleEntry } from '../shared/index.ts'
2
+ import { matchPath } from './credential-masker/matcher'
2
3
 
3
- /**
4
- * Match a Bash(command_pattern) rule against an actual tool call.
5
- *
6
- * Pattern formats:
7
- * "Bash" → matches any Bash call
8
- * "Bash(git:*)" → matches "git status", "git diff --cached", etc.
9
- * "Bash(npm test:*)" → matches "npm test -- --coverage"
10
- * "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
11
- */
4
+ // Match a tool(parameter) rule against an actual tool call.
5
+ //
6
+ // Pattern formats:
7
+ // "Bash" → matches any Bash call
8
+ // "Bash(git:*)" → matches "git status", "git diff --cached", etc.
9
+ // "Bash(npm test:*)" → matches "npm test -- --coverage"
10
+ // "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
11
+ // "Read(**/.ssh/*)" → matches Read of any path under .ssh
12
+ // "Grep(**/vendor)" → matches Grep rooted under a vendor directory
13
+ // "Glob(**/.ssh)" → matches Glob rooted under a .ssh directory
12
14
  export function matchBashRule(
13
15
  pattern: string,
14
16
  toolName: string,
@@ -31,10 +33,19 @@ export function matchBashRule(
31
33
  return wildcardMatch(subPattern!, cmd.trim())
32
34
  }
33
35
 
34
- // For Write/Edit: match against the file_path
35
- if (baseTool === 'Write' || baseTool === 'Edit') {
36
+ // For Write/Edit/Read: match against the file_path with path-glob semantics.
37
+ // Use matchPath (NOT wildcardMatch): wildcardMatch is tuned for Bash commands
38
+ // (`:` → colon-or-whitespace, `*` → `.*`), which is wrong for filesystem
39
+ // paths — `*` would cross `/` and Windows drive letters like `C:\` get mangled.
40
+ if (baseTool === 'Write' || baseTool === 'Edit' || baseTool === 'Read') {
36
41
  const path = String(toolInput.file_path || '')
37
- return wildcardMatch(subPattern!, path)
42
+ return matchPath(path, subPattern!)
43
+ }
44
+
45
+ // For Grep/Glob: match against the base search path (a directory)
46
+ if (baseTool === 'Grep' || baseTool === 'Glob') {
47
+ const path = String(toolInput.path || '')
48
+ return matchPath(path, subPattern!)
38
49
  }
39
50
 
40
51
  return false
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
21
21
  import { join } from 'node:path'
22
+ import { globToRegexSource } from './credential-masker/matcher'
22
23
 
23
24
  interface RuleFile {
24
25
  name: string
@@ -117,18 +118,15 @@ export class RulesLoader {
117
118
  }
118
119
 
119
120
  /**
120
- * Simple glob matching. Supports **, *, and exact file matching.
121
- * Returns true if file matches pattern.
121
+ * Glob matching for path-scoped rules. Reuses the shared path-glob core
122
+ * (globToRegexSource) so `*`/`**`/`?` semantics match credential-file
123
+ * matching. Anchoring differs: rules match by *suffix* (a rule `*.ts`
124
+ * applies to any file ending in `.ts`), not full path.
122
125
  */
123
126
  private matchPath(file: string, pattern: string): boolean {
124
- // Convert glob pattern to regex
125
- let regexStr = pattern
126
- .replace(/\./g, '\\.')
127
- .replace(/\*\*/g, '<<GLOBSTAR>>')
128
- .replace(/\*/g, '[^/]*')
129
- .replace(/<<GLOBSTAR>>/g, '.*')
130
-
131
- // If pattern doesn't start with ** or *, anchor to be a suffix match
127
+ let regexStr = globToRegexSource(pattern)
128
+
129
+ // If pattern doesn't start with ** or *, anchor to a suffix match
132
130
  if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
133
131
  regexStr = regexStr + '$'
134
132
  }
@@ -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,
@@ -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()
@@ -26,6 +26,9 @@ import type { ToolDefinition, PermissionMode, PermissionRestrictions } from '../
26
26
  import { createFeishuAdapter } from './feishu/adapter.js'
27
27
  import { createFeishuApi } from './feishu/api.js'
28
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'
29
32
  import { startHeartbeat } from './heartbeat'
30
33
 
31
34
  interface ServerConfig {
@@ -42,6 +45,7 @@ interface ServerConfig {
42
45
  scheduleManager: ScheduleManager
43
46
  rateLimiter: RateLimiter
44
47
  feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
48
+ telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
45
49
  }
46
50
 
47
51
  interface WsData {
@@ -71,13 +75,20 @@ function resolveDaemonPermission(): PermissionMode {
71
75
 
72
76
  /**
73
77
  * Build the daemon's PermissionSystem, honoring org-level restrictions
74
- * (permissionRestrictions). setRestrictions re-clamps the env-derived mode,
75
- * so a `MIPHAM_DAEMON_PERMISSION=bypassPermissions` is downgraded when the
76
- * config forbids it — mirroring the CLI's fail-closed behavior.
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.
77
81
  */
78
- export function buildDaemonPermission(restrictions?: PermissionRestrictions): PermissionSystem {
82
+ export function buildDaemonPermission(
83
+ restrictions?: PermissionRestrictions,
84
+ rules?: { allow?: string[]; deny?: string[] },
85
+ ): PermissionSystem {
79
86
  const permission = new PermissionSystem(resolveDaemonPermission())
80
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
+ }
81
92
  return permission
82
93
  }
83
94
 
@@ -112,6 +123,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
112
123
  scheduleManager,
113
124
  rateLimiter,
114
125
  feishu,
126
+ telegram,
115
127
  } = config
116
128
 
117
129
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -199,7 +211,11 @@ export function createServer(config: ServerConfig): Server<WsData> {
199
211
  }
200
212
  }
201
213
 
202
- const permission = buildDaemonPermission(loadConfig(cwd).permissionRestrictions)
214
+ const daemonConfig = loadConfig(cwd)
215
+ const permission = buildDaemonPermission(
216
+ daemonConfig.permissionRestrictions,
217
+ daemonConfig.permissionRules,
218
+ )
203
219
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
204
220
  engine.setSessionId(sessionId)
205
221
  engineCache.set(sessionId, engine)
@@ -208,7 +224,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
208
224
 
209
225
  function getOrCreateWorker(
210
226
  sessionId: string,
211
- ws?: ServerWebSocket<WsData>,
227
+ _ws?: ServerWebSocket<WsData>,
212
228
  ): SessionWorker | null {
213
229
  try {
214
230
  const session = db.getSession(sessionId)
@@ -245,14 +261,28 @@ export function createServer(config: ServerConfig): Server<WsData> {
245
261
  })
246
262
  : undefined
247
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
+
248
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
+ }
249
282
  if (feishu) {
250
283
  const feishuApi = createFeishuApi(feishu.config)
251
284
  startHeartbeat({
252
- source: {
253
- listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
254
- listSchedules: () => sm.listSessions().flatMap((s) => scheduleManager.getSchedules(s.id)),
255
- },
285
+ source: heartbeatSource,
256
286
  push: (message) => {
257
287
  for (const openId of feishu.config.allowedOpenIds) {
258
288
  void feishuApi.sendText(openId, message).catch(() => {
@@ -262,6 +292,19 @@ export function createServer(config: ServerConfig): Server<WsData> {
262
292
  },
263
293
  })
264
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
+ }
265
308
 
266
309
  const server = Bun.serve<WsData>({
267
310
  port,
@@ -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
+ }