@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.
- package/bin/mipham.ts +1 -9
- package/package.json +1 -2
- package/skills/standard/web-access/references/cdp-api.md +2 -1
- package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
- package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
- package/skills/standard/web-access/scripts/find-url.mjs +0 -0
- package/skills/standard/web-access/scripts/match-site.mjs +0 -0
- package/skills/standard/web-access.SKILL.md +10 -3
- package/src/commands/keys.ts +26 -0
- package/src/commands/project.ts +1 -1
- package/src/config/credential-crypto.ts +114 -0
- package/src/config/defaults.ts +6 -1
- package/src/config/loader.ts +65 -14
- package/src/core/auto-memory.ts +3 -3
- package/src/core/constitution-loader.ts +0 -2
- package/src/core/context.ts +0 -1
- package/src/core/credential-masker/index.ts +1 -0
- package/src/core/credential-masker/matcher.ts +18 -7
- package/src/core/credential-masker/search.ts +73 -0
- package/src/core/credential-masker.ts +1 -0
- package/src/core/crsi-sandbox.ts +0 -1
- package/src/core/dream-engine.ts +0 -2
- package/src/core/instructions.ts +36 -12
- package/src/core/memory/memory-loader.ts +3 -1
- package/src/core/permission-rules.ts +23 -12
- package/src/core/rules-loader.ts +8 -10
- package/src/daemon/database.ts +0 -1
- package/src/daemon/feishu/adapter.ts +2 -1
- package/src/daemon/index.ts +18 -8
- package/src/daemon/server.ts +53 -10
- package/src/daemon/session-manager.ts +5 -4
- package/src/daemon/telegram/adapter.ts +70 -0
- package/src/daemon/telegram/api.ts +45 -0
- package/src/daemon/telegram/env.ts +14 -0
- package/src/daemon/telegram/poller.ts +64 -0
- package/src/daemon/telegram/types.ts +10 -0
- package/src/i18n-core/locales/en-US.json +123 -0
- package/src/i18n-core/locales/zh-CN.json +123 -0
- package/src/index.tsx +10 -8
- package/src/mcp/token-store.ts +2 -37
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +5 -3
- package/src/skills/bin-check.ts +40 -0
- package/src/skills/bundled-skill-assets.ts +5 -5
- package/src/skills/bundled-skills.ts +1 -1
- 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/exit-plan.ts +1 -3
- package/src/tools/agent/memory.ts +2 -1
- package/src/tools/agent/skill.ts +14 -0
- package/src/tools/file/glob.ts +38 -23
- package/src/tools/file/grep.ts +86 -64
- package/src/tools/index.ts +5 -5
- package/src/ui/commands.ts +191 -119
- package/src/ui/config-wizard.tsx +5 -2
- 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
|
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -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
|
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -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
|
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.
|
|
@@ -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 =
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/core/rules-loader.ts
CHANGED
|
@@ -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
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
}
|
package/src/daemon/database.ts
CHANGED
|
@@ -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.
|
|
31
|
+
const session = deps.sm.getOrCreateByExternalUser(
|
|
32
|
+
'feishu',
|
|
32
33
|
msg.openId,
|
|
33
34
|
deps.cwd,
|
|
34
35
|
deps.provider,
|
package/src/daemon/index.ts
CHANGED
|
@@ -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()
|
package/src/daemon/server.ts
CHANGED
|
@@ -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)
|
|
75
|
-
* so a `MIPHAM_DAEMON_PERMISSION=bypassPermissions`
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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 =
|
|
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
|
+
}
|