@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 +1 -2
- package/skills/mipham/save-to-wiki.mipham-skill.md +92 -0
- package/src/commands/keys.ts +26 -0
- package/src/config/credential-crypto.ts +114 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/loader.ts +65 -14
- package/src/core/instructions.ts +36 -12
- package/src/daemon/channel-message.ts +59 -0
- package/src/daemon/feishu/adapter.ts +16 -27
- package/src/daemon/index.ts +17 -0
- package/src/daemon/server.ts +18 -0
- package/src/daemon/telegram/adapter.ts +16 -27
- package/src/daemon/wecom/adapter.ts +61 -0
- package/src/daemon/wecom/api.ts +58 -0
- package/src/daemon/wecom/env.ts +16 -0
- package/src/daemon/wecom/types.ts +12 -0
- package/src/daemon/wecom/ws-client.ts +77 -0
- package/src/i18n-core/locales/en-US.json +3 -0
- package/src/i18n-core/locales/zh-CN.json +3 -0
- package/src/index.tsx +4 -8
- package/src/mcp/token-store.ts +2 -37
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +3 -3
- package/src/skills/bin-check.ts +40 -0
- package/src/skills/bundled-skills.ts +1 -0
- package/src/skills/loader.ts +35 -10
- package/src/skills/sanitizer.ts +1 -2
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/skill.ts +14 -0
- package/src/ui/commands.ts +22 -0
- package/src/ui/config-wizard.tsx +4 -1
- package/src/ui/input.tsx +4 -2
- package/src/ui/vim-motions.ts +17 -0
- package/src/core/tokenizer.ts +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "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).
|
package/src/commands/keys.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/defaults.ts
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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)
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -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)),
|