@miphamai/cli 0.51.0 → 0.53.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -45,7 +45,6 @@
45
45
  "commander": "^13.1.0",
46
46
  "ink": "^5.2.1",
47
47
  "ink-text-input": "^6.0.0",
48
- "js-tiktoken": "^1.0.21",
49
48
  "react": "^18.3.1",
50
49
  "react-devtools-core": "^4.28.5",
51
50
  "yaml": "^2.9.0"
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: save-to-wiki
3
+ description: Save the current conversation, an insight, or a decision into the Obsidian wiki vault (~/MiphamAI) as a structured note. Analyzes the chat, picks a note type (synthesis/concept/source/decision/session), writes it via the Obsidian MCP, and leaves a memory pointer back. Use when the user types /save, says "save this to the wiki", "file this", "keep this insight", or wants a decision/concept archived.
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # Save to Wiki
8
+
9
+ Good answers and insights shouldn't disappear into chat history. This skill files the most valuable content from the current conversation into the user's Obsidian wiki as a permanent, searchable note.
10
+
11
+ The wiki compounds. Save often.
12
+
13
+ ## Transport
14
+
15
+ Writes go through the Obsidian MCP server (`obsidian` in `~/.mipham/mcp.json`), exposed as tools prefixed `mcp__obsidian__`:
16
+
17
+ - `mcp__obsidian__create_note` — create a new note (target path + markdown body)
18
+ - `mcp__obsidian__append_note` — append to an existing note
19
+ - `mcp__obsidian__get_file` / `mcp__obsidian__list_files` — check whether a note already exists
20
+ - `mcp__obsidian__set_property` — update frontmatter properties
21
+
22
+ If a tool name is unfamiliar, run `/mcp` to list the connected Obsidian tools and use the exact names. Avoid `get_vault_info` — it has a known upstream bug (`Command "vault" not found`) and is not needed for writing.
23
+
24
+ ## Note Type Decision
25
+
26
+ Pick the best type from the conversation content. If the user specifies a type, use it.
27
+
28
+ | Type | Folder (`wiki/`) | Use when |
29
+ | --------- | ---------------- | -------------------------------------------------------- |
30
+ | synthesis | `questions/` | Multi-step analysis, comparison, or answer to a question |
31
+ | concept | `concepts/` | Explaining or defining an idea, pattern, or framework |
32
+ | source | `sources/` | Summary of external material discussed in the session |
33
+ | decision | `meta/` | Architectural, project, or strategic decision made |
34
+ | session | `sessions/` | Full session summary — captures everything discussed |
35
+
36
+ When in doubt, use `synthesis`.
37
+
38
+ ## Frontmatter
39
+
40
+ All note types share this base frontmatter (aligns with the vault's `_templates/`):
41
+
42
+ ```yaml
43
+ ---
44
+ type: <synthesis|concept|source|decision|session>
45
+ title: 'Note Title'
46
+ created: YYYY-MM-DD
47
+ updated: YYYY-MM-DD
48
+ tags:
49
+ - <relevant-tag>
50
+ status: developing
51
+ related:
52
+ - '[[Any Wiki Page Mentioned]]'
53
+ sources: []
54
+ saved_from: Mipham Code
55
+ mipham_memory: <memory-slug>
56
+ ---
57
+ ```
58
+
59
+ - `synthesis` adds: `question: "<original query>"`, `answer_quality: solid`
60
+ - `decision` adds: `decision_date: YYYY-MM-DD`
61
+ - `saved_from` and `mipham_memory` implement the light two-way bridge (see below).
62
+
63
+ ## Workflow
64
+
65
+ 1. **Scan** the conversation and identify the single most valuable content to preserve — an insight, a decision with rationale, or a synthesis. If the conversation is trivial (mechanical Q&A, setup steps already documented, temp debugging), say so and skip.
66
+ 2. **Determine** the note type using the table. Respect an explicit type/title from the user.
67
+ 3. **Name** the note — short and descriptive; ask the user if not already named.
68
+ 4. **Check existence** — use `list_files`/`get_file` to see whether `wiki/<folder>/<title>.md` already exists. If it does, offer to update (`append_note` or rewrite) instead of duplicating.
69
+ 5. **Write** the note via `create_note` (path `wiki/<folder>/<title>.md`) with full frontmatter and a declarative, present-tense body.
70
+ 6. **Leave a memory pointer** — write a `reference` memory via the Memory tool (`action=write`, `name=wiki-<title-slug>`) whose body records the wiki note path and a one-line summary. This lets `/memory` and recall surface the wiki note.
71
+ 7. **Update** `wiki/index.md` (add the note to the relevant section) and `wiki/log.md` (prepend `## [YYYY-MM-DD] save | Note Title`). Refresh `wiki/hot.md` if it tracks recent additions.
72
+
73
+ ## Light Two-Way Bridge
74
+
75
+ - **memory → wiki**: the pointer memory (step 6) stores the wiki path, so memory recall can link back to the note.
76
+ - **wiki → memory**: the note's frontmatter carries `saved_from: Mipham Code` and `mipham_memory: <slug>` — plain strings, not wikilinks, so they don't create broken links in Obsidian.
77
+
78
+ This is a one-way pointer plus a provenance back-reference, not a sync layer. Do not attempt bidirectional synchronization.
79
+
80
+ ## Writing Style
81
+
82
+ - Declarative, present tense. Write the knowledge, not the conversation.
83
+ - Not: "The user asked about X and Claude explained..."
84
+ - Yes: "X works by doing Y. The key insight is Z."
85
+ - Link mentioned concepts/entities/wiki pages with `[[wikilinks]]`.
86
+ - Cite sources where applicable: `(Source: [[Page]])`.
87
+
88
+ ## What to Save vs. Skip
89
+
90
+ **Save**: non-obvious insights, decisions with rationale, analyses that took real effort, comparisons likely to be referenced again, research findings.
91
+
92
+ **Skip**: mechanical Q&A, setup steps already documented, temporary debugging with no lasting insight, anything already in the wiki (update instead of duplicating).
@@ -1,5 +1,6 @@
1
1
  import type { CommandHandler } from '../ui/commands'
2
2
  import { KeyManager } from '../config/keys-manager'
3
+ import { getProviderApiKey } from '../config/loader'
3
4
 
4
5
  export const keysCmd: CommandHandler = async (_ctx, args) => {
5
6
  const manager = new KeyManager()
@@ -58,6 +59,31 @@ export const keysCmd: CommandHandler = async (_ctx, args) => {
58
59
  return { content: lines.join('\n') }
59
60
  }
60
61
 
62
+ // /keys view <provider> — show the plaintext API key (decrypting if stored encrypted)
63
+ if (sub === 'view' || sub === 'show') {
64
+ const provider = args[1]
65
+ if (!provider) {
66
+ return { content: 'Usage: /keys view <provider>\n\nExample: /keys view deepseek' }
67
+ }
68
+ const key = getProviderApiKey(provider)
69
+ if (key === null) {
70
+ return {
71
+ content: `No API key found for provider "${provider}".\n\nIf it was stored encrypted, it may be unreadable (missing/corrupt credential key).`,
72
+ }
73
+ }
74
+ return {
75
+ content: [
76
+ '── Key View ──',
77
+ '',
78
+ `Provider: ${provider}`,
79
+ `API Key: ${key}`,
80
+ '',
81
+ '⚠️ This is your plaintext API key. Do not share it or paste it anywhere.',
82
+ ' Rotate it at any time with /keys rotate <provider>.',
83
+ ].join('\n'),
84
+ }
85
+ }
86
+
61
87
  // /keys (list)
62
88
  const keys = manager.list()
63
89
 
@@ -0,0 +1,114 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ mkdirSync,
6
+ chmodSync,
7
+ copyFileSync,
8
+ } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11
+
12
+ /**
13
+ * Shared AES-256-GCM primitives for encrypting credentials at rest.
14
+ *
15
+ * Extracted from the MCP token store so config.yml API keys get the exact
16
+ * same at-rest protection (parent/subsidiary CLAUDE.md mandate "存储层
17
+ * AES-256-GCM"). The wire format `iv || authTag || ciphertext` (base64) is
18
+ * preserved verbatim from the original token-store implementation so existing
19
+ * `~/.mipham/mcp-tokens/*.enc` files remain decryptable.
20
+ */
21
+
22
+ const ALGORITHM = 'aes-256-gcm'
23
+ const IV_LENGTH = 16
24
+ const AUTH_TAG_LENGTH = 16
25
+ const KEY_LENGTH = 32
26
+
27
+ /** Prefix marking an encrypted API key value in config.yml. */
28
+ export const ENC_PREFIX = 'enc:v1:'
29
+
30
+ /** Shared master-credential key file (used by both MCP tokens and config.yml). */
31
+ const CREDENTIAL_KEY_FILENAME = '.cred-key'
32
+ /** Pre-shared-key filename; migrated to {@link CREDENTIAL_KEY_FILENAME} on first use. */
33
+ const LEGACY_KEY_FILENAME = '.mcp-key'
34
+
35
+ /**
36
+ * Load the shared credential key from `keyDir`, migrating the legacy
37
+ * MCP-only `.mcp-key` to the shared `.cred-key` so existing encrypted MCP
38
+ * tokens stay decryptable. Falls back to creating a fresh key when neither
39
+ * exists.
40
+ */
41
+ export function getCredentialKey(keyDir: string): Buffer {
42
+ const keyPath = join(keyDir, CREDENTIAL_KEY_FILENAME)
43
+ if (!existsSync(keyPath)) {
44
+ const legacyPath = join(keyDir, LEGACY_KEY_FILENAME)
45
+ if (existsSync(legacyPath)) {
46
+ copyFileSync(legacyPath, keyPath)
47
+ chmodSync(keyPath, 0o400)
48
+ }
49
+ }
50
+ return getOrCreateKey(keyPath)
51
+ }
52
+
53
+ /**
54
+ * Load the 32-byte encryption key from `keyPath`, creating a fresh random key
55
+ * (with owner-only read permissions) on first use.
56
+ */
57
+ export function getOrCreateKey(keyPath: string): Buffer {
58
+ if (existsSync(keyPath)) {
59
+ return readFileSync(keyPath)
60
+ }
61
+ const key = randomBytes(KEY_LENGTH)
62
+ mkdirSync(dirname(keyPath), { recursive: true })
63
+ writeFileSync(keyPath, key)
64
+ chmodSync(keyPath, 0o400)
65
+ return key
66
+ }
67
+
68
+ /** Encrypt plaintext with AES-256-GCM. Returns `base64(iv || authTag || ciphertext)`. */
69
+ export function encrypt(plaintext: string, key: Buffer): string {
70
+ const iv = randomBytes(IV_LENGTH)
71
+ const cipher = createCipheriv(ALGORITHM, key, iv)
72
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
73
+ const authTag = cipher.getAuthTag()
74
+ return Buffer.concat([iv, authTag, encrypted]).toString('base64')
75
+ }
76
+
77
+ /** Decrypt a value produced by {@link encrypt}. Throws on wrong key / corrupt data. */
78
+ export function decrypt(ciphertext: string, key: Buffer): string {
79
+ const buf = Buffer.from(ciphertext, 'base64')
80
+ const iv = buf.subarray(0, IV_LENGTH)
81
+ const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
82
+ const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
83
+ const decipher = createDecipheriv(ALGORITHM, key, iv)
84
+ decipher.setAuthTag(authTag)
85
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8')
86
+ }
87
+
88
+ /**
89
+ * True when `value` is an environment-variable template rather than a literal
90
+ * secret — both `${VAR}` and `$VAR` forms (the provider resolvers accept both).
91
+ */
92
+ export function isEnvTemplate(value: string): boolean {
93
+ return /^\$\{.*\}$/.test(value) || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
94
+ }
95
+
96
+ /**
97
+ * Encrypt an API key for storage. Environment-variable templates and empty
98
+ * values are not secrets, so they pass through unchanged (keeping config.yml
99
+ * readable for env-based setups). Literal secrets get the `enc:v1:` prefix.
100
+ */
101
+ export function encryptApiKey(apiKey: string, key: Buffer): string {
102
+ if (!apiKey || isEnvTemplate(apiKey)) return apiKey
103
+ return ENC_PREFIX + encrypt(apiKey, key)
104
+ }
105
+
106
+ /**
107
+ * Decrypt a stored API key. Plaintext (legacy or template) values pass through
108
+ * unchanged; `enc:v1:` values are decrypted. Throws on a missing/corrupt key so
109
+ * the caller can surface a clear error instead of sending a garbage key.
110
+ */
111
+ export function decryptApiKey(stored: string, key: Buffer): string {
112
+ if (!stored.startsWith(ENC_PREFIX)) return stored
113
+ return decrypt(stored.slice(ENC_PREFIX.length), key)
114
+ }
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG: MiphamConfig = {
28
28
  },
29
29
  features: {
30
30
  mcp: { oauthEnabled: true },
31
- context: { useRealTokenizer: true, adaptiveThresholds: true },
31
+ context: { adaptiveThresholds: true },
32
32
  },
33
33
  crsi: {
34
34
  ruleInjection: true,
@@ -27,6 +27,7 @@ import {
27
27
  DEFAULT_BACKGROUND_AGENT_CONFIG,
28
28
  DEFAULT_CROSS_SESSION_CONFIG,
29
29
  } from './defaults'
30
+ import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
30
31
 
31
32
  const MIPHAM_HOME = join(homedir(), '.mipham')
32
33
  const BACKUP_PREFIX = 'config.backup-'
@@ -294,6 +295,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
294
295
  backupConfig(userConfigPath)
295
296
  }
296
297
 
298
+ // ── Decrypt API keys at rest (enc:v1:) back to plaintext ──
299
+ decryptProviderApiKeys(config.providers)
300
+
297
301
  return config
298
302
  }
299
303
 
@@ -438,6 +442,58 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
438
442
  return merged
439
443
  }
440
444
 
445
+ /**
446
+ * Decrypt any encrypted (`enc:v1:`) provider API keys in place, after config
447
+ * merge. Plaintext (legacy / env-template) values pass through untouched. On
448
+ * decrypt failure (missing/corrupt credential key) the key is cleared and a
449
+ * warning is written, so the provider surfaces "apiKey not set" rather than
450
+ * sending a garbage value.
451
+ */
452
+ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
453
+ if (!providers) return
454
+ const needsKey = providers.some((p) => p.apiKey.startsWith(ENC_PREFIX))
455
+ if (!needsKey) return
456
+ const key = getCredentialKey(MIPHAM_HOME)
457
+ for (const p of providers) {
458
+ if (!p.apiKey.startsWith(ENC_PREFIX)) continue
459
+ try {
460
+ p.apiKey = decryptApiKey(p.apiKey, key)
461
+ } catch (err: unknown) {
462
+ const msg = err instanceof Error ? err.message : String(err)
463
+ process.stderr.write(
464
+ `⚠ Mipham Code: failed to decrypt API key for "${p.id}" (credential key missing/corrupt?): ${msg}\n`,
465
+ )
466
+ p.apiKey = ''
467
+ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Read a single provider's API key from config.yml, decrypting it if stored
473
+ * encrypted. Returns null when the provider has no key or the key can't be
474
+ * decrypted. Used by `/keys view` to show the plaintext key on request.
475
+ */
476
+ export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
477
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
478
+ const projectConfigPath = join(cwd, '.mipham', 'config.yml')
479
+ const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
480
+
481
+ try {
482
+ if (!existsSync(configPath)) return null
483
+ const raw = readFileSync(configPath, 'utf-8')
484
+ const doc = (parseYaml(raw) as Record<string, unknown>) || {}
485
+ const providers = (doc.providers as Array<Record<string, unknown>>) || []
486
+ const p = providers.find((x) => x.id === providerId)
487
+ if (!p || typeof p.apiKey !== 'string') return null
488
+ if (!p.apiKey.startsWith(ENC_PREFIX)) return p.apiKey
489
+ return decryptApiKey(p.apiKey, getCredentialKey(MIPHAM_HOME))
490
+ } catch (err: unknown) {
491
+ const msg = err instanceof Error ? err.message : String(err)
492
+ process.stderr.write(`⚠ Mipham Code: failed to read API key for "${providerId}": ${msg}\n`)
493
+ return null
494
+ }
495
+ }
496
+
441
497
  /**
442
498
  * Persist an API key for a single provider to the user-level config.yml.
443
499
  * Reads the existing YAML, updates/replaces the provider's apiKey field,
@@ -445,16 +501,10 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
445
501
  *
446
502
  * Returns true on success, false on failure.
447
503
  */
448
- export function saveProviderApiKey(
449
- providerId: string,
450
- apiKey: string,
451
- cwd: string = process.cwd(),
452
- ): boolean {
453
- const userConfigPath = join(MIPHAM_HOME, 'config.yml')
454
- const projectConfigPath = join(cwd, '.mipham', 'config.yml')
455
-
456
- // Prefer user-level config; fall back to project-level if no user config exists.
457
- const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
504
+ export function saveProviderApiKey(providerId: string, apiKey: string): boolean {
505
+ // API keys are user-level secrets — always persist to the user config, never
506
+ // the project config (which lives in the repo and could be committed).
507
+ const configPath = join(MIPHAM_HOME, 'config.yml')
458
508
 
459
509
  try {
460
510
  mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
@@ -464,19 +514,20 @@ export function saveProviderApiKey(
464
514
  if (existsSync(configPath)) {
465
515
  const raw = readFileSync(configPath, 'utf-8')
466
516
  doc = (parseYaml(raw) as Record<string, unknown>) || {}
467
- } else if (configPath === projectConfigPath) {
468
- mkdirSync(join(cwd, '.mipham'), { recursive: true })
469
517
  }
470
518
 
471
519
  // Find and update the provider in the providers array
472
520
  const providers = (doc.providers as Array<Record<string, unknown>>) || []
473
521
  const idx = providers.findIndex((p) => p.id === providerId)
474
522
 
523
+ // Encrypt literal keys at rest (env-variable templates pass through).
524
+ const storedKey = encryptApiKey(apiKey, getCredentialKey(MIPHAM_HOME))
525
+
475
526
  if (idx >= 0) {
476
- providers[idx] = { ...providers[idx], apiKey }
527
+ providers[idx] = { ...providers[idx], apiKey: storedKey }
477
528
  } else {
478
529
  // Provider not in config — append it
479
- providers.push({ id: providerId, apiKey })
530
+ providers.push({ id: providerId, apiKey: storedKey })
480
531
  }
481
532
 
482
533
  doc.providers = providers
@@ -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.
@@ -0,0 +1,59 @@
1
+ import type { SessionManager } from './session-manager'
2
+ import type { SessionWorker } from './session-worker'
3
+ import type { RateLimiter } from './rate-limiter'
4
+
5
+ export interface ChannelMessageOptions {
6
+ channel: string // 'feishu' | 'telegram' | 'wecom'
7
+ externalId: string // openId / chatId / userId
8
+ text: string
9
+ allowed: Set<string>
10
+ rateLimiter: RateLimiter
11
+ sm: SessionManager
12
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
13
+ cwd: string
14
+ provider: string
15
+ model: string
16
+ sendText: (externalId: string, text: string) => Promise<void>
17
+ maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048
18
+ logPrefix: string // '[feishu]' / '[telegram]' / '[wecom]'
19
+ }
20
+
21
+ /** 三频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
22
+ export async function handleChannelMessage(opts: ChannelMessageOptions): Promise<void> {
23
+ const {
24
+ channel,
25
+ externalId,
26
+ text,
27
+ allowed,
28
+ rateLimiter,
29
+ sm,
30
+ getOrCreateWorker,
31
+ cwd,
32
+ provider,
33
+ model,
34
+ sendText,
35
+ maxLen,
36
+ logPrefix,
37
+ } = opts
38
+ try {
39
+ if (!allowed.has(externalId)) return
40
+ if (!rateLimiter.check(`${channel}:${externalId}`).allowed) return
41
+
42
+ const session = sm.getOrCreateByExternalUser(channel, externalId, cwd, provider, model)
43
+ const worker = getOrCreateWorker(session.id)
44
+ if (!worker) {
45
+ await sendText(externalId, '(会话初始化失败,请稍后重试)')
46
+ return
47
+ }
48
+ await worker.processPrompt(text)
49
+ const result = worker.getLastAssistantContent()
50
+ await sendText(externalId, result ? result.slice(0, maxLen) : '(无回复)')
51
+ } catch (err) {
52
+ console.error(`${logPrefix} message handling failed:`, err)
53
+ try {
54
+ await sendText(externalId, '(处理失败,请稍后重试)')
55
+ } catch {
56
+ /* 忽略回送失败,不 rethrow */
57
+ }
58
+ }
59
+ }
@@ -4,6 +4,7 @@ import type { FeishuConfig, FeishuTextMessage } from './types.js'
4
4
  import type { SessionManager } from '../session-manager'
5
5
  import type { SessionWorker } from '../session-worker'
6
6
  import type { RateLimiter } from '../rate-limiter'
7
+ import { handleChannelMessage } from '../channel-message.js'
7
8
 
8
9
  export interface FeishuAdapterDeps {
9
10
  sm: SessionManager
@@ -24,33 +25,21 @@ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDep
24
25
  const allowed = new Set(config.allowedOpenIds)
25
26
 
26
27
  const onMessage = async (msg: FeishuTextMessage) => {
27
- try {
28
- if (!allowed.has(msg.openId)) return
29
- if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
-
31
- const session = deps.sm.getOrCreateByExternalUser(
32
- 'feishu',
33
- msg.openId,
34
- deps.cwd,
35
- deps.provider,
36
- deps.model,
37
- )
38
- const worker = deps.getOrCreateWorker(session.id)
39
- if (!worker) {
40
- await api.sendText(msg.openId, '(会话初始化失败,请稍后重试)')
41
- return
42
- }
43
- await worker.processPrompt(msg.text)
44
- const result = worker.getLastAssistantContent()
45
- await api.sendText(msg.openId, result ? result.slice(0, 4000) : '(无回复)')
46
- } catch (err) {
47
- console.error('[feishu] message handling failed:', err)
48
- try {
49
- await api.sendText(msg.openId, '(处理失败,请稍后重试)')
50
- } catch {
51
- /* 忽略回送失败,确保不 rethrow → Feishu 不重试 */
52
- }
53
- }
28
+ await handleChannelMessage({
29
+ channel: 'feishu',
30
+ externalId: msg.openId,
31
+ text: msg.text,
32
+ allowed,
33
+ rateLimiter: deps.rateLimiter,
34
+ sm: deps.sm,
35
+ getOrCreateWorker: deps.getOrCreateWorker,
36
+ cwd: deps.cwd,
37
+ provider: deps.provider,
38
+ model: deps.model,
39
+ sendText: (id, t) => api.sendText(id, t),
40
+ maxLen: 4000,
41
+ logPrefix: '[feishu]',
42
+ })
54
43
  }
55
44
 
56
45
  const dispatcher = createFeishuEventDispatcher(config, onMessage)
@@ -27,6 +27,8 @@ import { parseFeishuEnv } from './feishu/env.js'
27
27
  import type { FeishuConfig } from './feishu/types.js'
28
28
  import { parseTelegramEnv } from './telegram/env.js'
29
29
  import type { TelegramConfig } from './telegram/types.js'
30
+ import { parseWecomEnv } from './wecom/env.js'
31
+ import type { WecomConfig } from './wecom/types.js'
30
32
  import { loadConfig } from '../config/loader'
31
33
 
32
34
  const HOME = homedir()
@@ -194,6 +196,20 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
194
196
  }
195
197
  }
196
198
 
199
+ // 企业微信 remote-control adapter(env 未配置时跳过)
200
+ const wecomConfig = parseWecomEnv()
201
+ let wecom: { config: WecomConfig; cwd: string; provider: string; model: string } | undefined
202
+ if (wecomConfig) {
203
+ const cfg = loadConfig()
204
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
205
+ wecom = {
206
+ config: wecomConfig,
207
+ cwd: process.env.WECOM_CWD || process.cwd(),
208
+ provider: provider?.id ?? 'anthropic',
209
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
210
+ }
211
+ }
212
+
197
213
  // Start HTTP server (Bun.serve starts listening immediately)
198
214
  const server = createServer({
199
215
  db,
@@ -210,6 +226,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
210
226
  rateLimiter,
211
227
  feishu,
212
228
  telegram,
229
+ wecom,
213
230
  })
214
231
  activeServer = server
215
232
 
@@ -29,6 +29,9 @@ import type { FeishuConfig } from './feishu/types.js'
29
29
  import { createTelegramAdapter } from './telegram/adapter.js'
30
30
  import { createTelegramApi } from './telegram/api.js'
31
31
  import type { TelegramConfig } from './telegram/types.js'
32
+ import { createWecomAdapter } from './wecom/adapter.js'
33
+ import { createWecomApi } from './wecom/api.js'
34
+ import type { WecomConfig } from './wecom/types.js'
32
35
  import { startHeartbeat } from './heartbeat'
33
36
 
34
37
  interface ServerConfig {
@@ -46,6 +49,7 @@ interface ServerConfig {
46
49
  rateLimiter: RateLimiter
47
50
  feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
48
51
  telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
52
+ wecom?: { config: WecomConfig; cwd: string; provider: string; model: string }
49
53
  }
50
54
 
51
55
  interface WsData {
@@ -124,6 +128,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
124
128
  rateLimiter,
125
129
  feishu,
126
130
  telegram,
131
+ wecom,
127
132
  } = config
128
133
 
129
134
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -274,6 +279,19 @@ export function createServer(config: ServerConfig): Server<WsData> {
274
279
  : undefined
275
280
  telegramAdapter?.start()
276
281
 
282
+ // ── 企业微信 remote-control adapter(长连接,无需 webhook 路由)──
283
+ const wecomAdapter = wecom
284
+ ? createWecomAdapter(wecom.config, createWecomApi(wecom.config), {
285
+ sm,
286
+ getOrCreateWorker,
287
+ rateLimiter,
288
+ cwd: wecom.cwd,
289
+ provider: wecom.provider,
290
+ model: wecom.model,
291
+ })
292
+ : undefined
293
+ wecomAdapter?.start()
294
+
277
295
  // ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
278
296
  const heartbeatSource = {
279
297
  listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),