@miphamai/cli 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mipham.ts +1 -9
- package/package.json +1 -1
- package/skills/standard/github-ops.SKILL.md +1 -1
- 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/agent/cross-session/discovery.ts +26 -1
- package/src/commands/project.ts +1 -1
- package/src/config/defaults.ts +8 -0
- package/src/core/auto-memory.ts +3 -3
- package/src/core/behavior-tasks.json +101 -0
- package/src/core/behavior-tasks.ts +44 -0
- 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-producer.ts +214 -0
- package/src/core/crsi-sandbox.ts +5 -1
- package/src/core/dream-engine.ts +0 -2
- package/src/core/eval-harness.ts +7 -0
- package/src/core/instructions.ts +11 -0
- package/src/core/memory/memory-loader.ts +3 -1
- package/src/core/permission-rules.ts +23 -12
- package/src/core/proposal-guard.ts +100 -0
- package/src/core/rules-loader.ts +8 -10
- package/src/core/task-runner-tasks.json +14 -0
- package/src/core/task-runner.ts +163 -0
- package/src/daemon/database.ts +0 -1
- package/src/daemon/feishu/adapter.ts +2 -1
- package/src/daemon/heartbeat.ts +83 -0
- package/src/daemon/index.ts +18 -8
- package/src/daemon/server.ts +78 -3
- 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 +6 -0
- package/src/shared/package-info.ts +4 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/bundled-skill-assets.ts +5 -5
- package/src/skills/bundled-skills.ts +2 -2
- package/src/tools/agent/exit-plan.ts +1 -3
- package/src/tools/agent/memory.ts +2 -1
- package/src/tools/file/glob.ts +38 -23
- package/src/tools/file/grep.ts +96 -64
- package/src/tools/index.ts +5 -5
- package/src/ui/app.tsx +21 -1
- package/src/ui/commands.ts +269 -125
- package/src/ui/config-wizard.tsx +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// apps/cli/src/core/task-runner.ts
|
|
2
|
+
// CRSI 端到端任务运行器(C-MVP)——行为效果度量基建。
|
|
3
|
+
import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import tasksFile from './task-runner-tasks.json' with { type: 'json' }
|
|
6
|
+
import { QueryEngine } from './engine'
|
|
7
|
+
import { ContextManager } from './context'
|
|
8
|
+
import { PermissionSystem } from './permission'
|
|
9
|
+
import { ProviderRegistry } from '../providers/registry'
|
|
10
|
+
import type { Llm } from '../providers/llm'
|
|
11
|
+
import { createToolRegistry } from '../tools'
|
|
12
|
+
import type { PermissionLevel } from '../shared'
|
|
13
|
+
|
|
14
|
+
export type RunnerGroundTruth = { kind: 'file-contains'; file: string; contains: string[] }
|
|
15
|
+
|
|
16
|
+
export interface RunnerTask {
|
|
17
|
+
id: string
|
|
18
|
+
instruction: string
|
|
19
|
+
groundTruth: RunnerGroundTruth
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadRunnerTasks(): RunnerTask[] {
|
|
23
|
+
return tasksFile.tasks as unknown as RunnerTask[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function judgeTask(task: RunnerTask, taskDir: string): { passed: boolean; detail?: string } {
|
|
27
|
+
if (task.groundTruth.kind !== 'file-contains') {
|
|
28
|
+
return { passed: false, detail: `unsupported groundTruth kind: ${task.groundTruth.kind}` }
|
|
29
|
+
}
|
|
30
|
+
const filePath = join(taskDir, task.groundTruth.file)
|
|
31
|
+
if (!existsSync(filePath)) {
|
|
32
|
+
return { passed: false, detail: `file not found: ${task.groundTruth.file}` }
|
|
33
|
+
}
|
|
34
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
35
|
+
for (const needle of task.groundTruth.contains) {
|
|
36
|
+
if (!content.includes(needle)) {
|
|
37
|
+
return { passed: false, detail: `missing substring: ${needle}` }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { passed: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TaskRunResult {
|
|
44
|
+
taskId: string
|
|
45
|
+
passed: boolean
|
|
46
|
+
detail?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TASK_DIR_PLACEHOLDER = '<taskDir>'
|
|
50
|
+
|
|
51
|
+
function buildEngine(llm: Llm, permission: PermissionLevel, systemPrompt?: string): QueryEngine {
|
|
52
|
+
const registry = new ProviderRegistry([], 'test', 'test-model')
|
|
53
|
+
// 注册一个永不 chat 的占位 provider——llm 被 setLlm 覆盖,但 process() 内部
|
|
54
|
+
// 多处调用 registry.getActive().config.id 记录 provider id,必须能取到。
|
|
55
|
+
registry.register('test', {
|
|
56
|
+
config: { id: 'test', name: 'Test', protocol: 'openai-compatible', apiKey: 'key', models: [] },
|
|
57
|
+
chat: async function* () {
|
|
58
|
+
yield { type: 'stop' }
|
|
59
|
+
},
|
|
60
|
+
listModels: async () => [],
|
|
61
|
+
healthCheck: async () => true,
|
|
62
|
+
})
|
|
63
|
+
const context = new ContextManager({ maxTokens: 100_000, compactionThreshold: 0.9 })
|
|
64
|
+
if (systemPrompt !== undefined) context.setSystemPrompt(systemPrompt)
|
|
65
|
+
const tools = createToolRegistry()
|
|
66
|
+
const engine = new QueryEngine(registry, context, tools, new PermissionSystem(permission))
|
|
67
|
+
engine.setLlm(llm)
|
|
68
|
+
return engine
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runTask(
|
|
72
|
+
task: RunnerTask,
|
|
73
|
+
llm: Llm,
|
|
74
|
+
opts: { taskDir?: string; permission?: PermissionLevel; systemPrompt?: string } = {},
|
|
75
|
+
): Promise<TaskRunResult> {
|
|
76
|
+
const taskDir = opts.taskDir ?? join(process.cwd(), '.mipham', 'task-runner')
|
|
77
|
+
const permission = opts.permission ?? 'bypassPermissions'
|
|
78
|
+
|
|
79
|
+
rmSync(taskDir, { recursive: true, force: true })
|
|
80
|
+
mkdirSync(taskDir, { recursive: true })
|
|
81
|
+
|
|
82
|
+
const instruction = task.instruction.replaceAll(TASK_DIR_PLACEHOLDER, taskDir)
|
|
83
|
+
const engine = buildEngine(llm, permission, opts.systemPrompt)
|
|
84
|
+
|
|
85
|
+
for await (const _ of engine.process(instruction)) {
|
|
86
|
+
/* drain agentic loop */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const verdict = judgeTask(task, taskDir)
|
|
90
|
+
return { taskId: task.id, passed: verdict.passed, detail: verdict.detail }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TaskRunStats {
|
|
94
|
+
taskId: string
|
|
95
|
+
samples: number
|
|
96
|
+
passed: number
|
|
97
|
+
/** 0-1 */
|
|
98
|
+
passRate: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runTaskN(
|
|
102
|
+
task: RunnerTask,
|
|
103
|
+
llm: Llm,
|
|
104
|
+
n: number,
|
|
105
|
+
opts: { taskDir?: string; permission?: PermissionLevel; systemPrompt?: string } = {},
|
|
106
|
+
): Promise<TaskRunStats> {
|
|
107
|
+
let passed = 0
|
|
108
|
+
for (let i = 0; i < n; i++) {
|
|
109
|
+
const result = await runTask(task, llm, opts)
|
|
110
|
+
if (result.passed) passed++
|
|
111
|
+
}
|
|
112
|
+
return { taskId: task.id, samples: n, passed, passRate: n > 0 ? passed / n : 0 }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RunComparison {
|
|
116
|
+
baseline: TaskRunStats
|
|
117
|
+
candidate: TaskRunStats
|
|
118
|
+
/** 弱判:candidate 不退化(不低于 baseline 且至少 1 次成功) */
|
|
119
|
+
notDegraded: boolean
|
|
120
|
+
/** candidate 严格更好(通过率更高) */
|
|
121
|
+
improved: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isNotDegraded(baseline: TaskRunStats, candidate: TaskRunStats): boolean {
|
|
125
|
+
return candidate.passRate >= baseline.passRate && candidate.passed >= 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isImproved(baseline: TaskRunStats, candidate: TaskRunStats): boolean {
|
|
129
|
+
return candidate.passRate > baseline.passRate
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function compareRuns(baseline: TaskRunStats, candidate: TaskRunStats): RunComparison {
|
|
133
|
+
return {
|
|
134
|
+
baseline,
|
|
135
|
+
candidate,
|
|
136
|
+
notDegraded: isNotDegraded(baseline, candidate),
|
|
137
|
+
improved: isImproved(baseline, candidate),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function runBeforeAfter(
|
|
142
|
+
task: RunnerTask,
|
|
143
|
+
llm: Llm,
|
|
144
|
+
n: number,
|
|
145
|
+
opts: {
|
|
146
|
+
beforePrompt?: string
|
|
147
|
+
afterPrompt?: string
|
|
148
|
+
taskDir?: string
|
|
149
|
+
permission?: PermissionLevel
|
|
150
|
+
} = {},
|
|
151
|
+
): Promise<RunComparison> {
|
|
152
|
+
const baseline = await runTaskN(task, llm, n, {
|
|
153
|
+
taskDir: opts.taskDir,
|
|
154
|
+
permission: opts.permission,
|
|
155
|
+
systemPrompt: opts.beforePrompt,
|
|
156
|
+
})
|
|
157
|
+
const candidate = await runTaskN(task, llm, n, {
|
|
158
|
+
taskDir: opts.taskDir,
|
|
159
|
+
permission: opts.permission,
|
|
160
|
+
systemPrompt: opts.afterPrompt,
|
|
161
|
+
})
|
|
162
|
+
return compareRuns(baseline, candidate)
|
|
163
|
+
}
|
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,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// apps/cli/src/daemon/heartbeat.ts — 心跳式通知(保守版 KAIROS「订阅与推送」)
|
|
2
|
+
//
|
|
3
|
+
// 借鉴 Claude Code KAIROS 的「主动感知」思路,但严格约束为「只通知、不自主行动」:
|
|
4
|
+
// - 定时扫描 pending 的 goal(active)与 schedule(enabled)
|
|
5
|
+
// - 有 pending 时推送一条摘要(默认走 Feishu),无 pending 时静默
|
|
6
|
+
// - 绝不替用户执行任何动作——「主动感知可以,自主行动必须有闸门」(CRSI 受约束哲学)
|
|
7
|
+
//
|
|
8
|
+
// 纯函数(collectPendingItems / buildHeartbeatMessage / heartbeatTick)便于测试;
|
|
9
|
+
// startHeartbeat 只做 setInterval + unref 的薄接线。
|
|
10
|
+
|
|
11
|
+
import type { DaemonGoal, DaemonSchedule } from './types'
|
|
12
|
+
|
|
13
|
+
/** 默认心跳间隔:30 分钟(提醒型通知,不宜过频)。 */
|
|
14
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30 * 60_000
|
|
15
|
+
|
|
16
|
+
export interface PendingItems {
|
|
17
|
+
goalCount: number
|
|
18
|
+
scheduleCount: number
|
|
19
|
+
summaries: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 收集待办项:active 的 goal + enabled 的 schedule。纯函数。 */
|
|
23
|
+
export function collectPendingItems(
|
|
24
|
+
goals: DaemonGoal[],
|
|
25
|
+
schedules: DaemonSchedule[],
|
|
26
|
+
): PendingItems {
|
|
27
|
+
const activeGoals = goals.filter((g) => g.status === 'active')
|
|
28
|
+
const enabledSchedules = schedules.filter((s) => s.enabled)
|
|
29
|
+
|
|
30
|
+
const summaries: string[] = []
|
|
31
|
+
for (const g of activeGoals) summaries.push(`🎯 ${g.description}`)
|
|
32
|
+
for (const s of enabledSchedules) summaries.push(`⏰ [${s.cronExpr}] ${s.prompt}`)
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
goalCount: activeGoals.length,
|
|
36
|
+
scheduleCount: enabledSchedules.length,
|
|
37
|
+
summaries,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_SUMMARY_ITEMS = 10
|
|
42
|
+
|
|
43
|
+
/** 把待办渲染成通知文案;无可待办时返回 null(不打扰)。纯函数。 */
|
|
44
|
+
export function buildHeartbeatMessage(pending: PendingItems): string | null {
|
|
45
|
+
if (pending.goalCount === 0 && pending.scheduleCount === 0) return null
|
|
46
|
+
|
|
47
|
+
const lines = [
|
|
48
|
+
`💓 Mipham 心跳提醒:${pending.goalCount} 个待办 goal,${pending.scheduleCount} 个定时任务`,
|
|
49
|
+
'',
|
|
50
|
+
...pending.summaries.slice(0, MAX_SUMMARY_ITEMS),
|
|
51
|
+
]
|
|
52
|
+
if (pending.summaries.length > MAX_SUMMARY_ITEMS) {
|
|
53
|
+
lines.push(`… 另外 ${pending.summaries.length - MAX_SUMMARY_ITEMS} 项`)
|
|
54
|
+
}
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HeartbeatSource {
|
|
59
|
+
listGoals(): DaemonGoal[]
|
|
60
|
+
listSchedules(): DaemonSchedule[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 单次心跳:收集待办,有则推送。抽出来便于直接测试。 */
|
|
64
|
+
export function heartbeatTick(source: HeartbeatSource, push: (message: string) => void): void {
|
|
65
|
+
const pending = collectPendingItems(source.listGoals(), source.listSchedules())
|
|
66
|
+
const message = buildHeartbeatMessage(pending)
|
|
67
|
+
if (message) push(message)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HeartbeatDeps {
|
|
71
|
+
source: HeartbeatSource
|
|
72
|
+
push: (message: string) => void
|
|
73
|
+
intervalMs?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 启动心跳定时器(unref 不阻止进程退出),返回 stop 函数。 */
|
|
77
|
+
export function startHeartbeat(deps: HeartbeatDeps): () => void {
|
|
78
|
+
const intervalMs = deps.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
|
|
79
|
+
const id = setInterval(() => heartbeatTick(deps.source, deps.push), intervalMs)
|
|
80
|
+
// Bun 的 Timer 有 unref;假定时器/其它运行时不保证——用可选调用容错。
|
|
81
|
+
;(id as { unref?: () => void }).unref?.()
|
|
82
|
+
return () => clearInterval(id)
|
|
83
|
+
}
|
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
|
@@ -22,9 +22,14 @@ import { bootstrapProviders } from '../providers/bootstrap'
|
|
|
22
22
|
import { createToolRegistry } from '../tools'
|
|
23
23
|
import { PermissionSystem } from '../core/permission'
|
|
24
24
|
import type { ProviderRegistry } from '../providers/registry'
|
|
25
|
-
import type { ToolDefinition, PermissionMode } from '../shared/types'
|
|
25
|
+
import type { ToolDefinition, PermissionMode, PermissionRestrictions } from '../shared/types'
|
|
26
26
|
import { createFeishuAdapter } from './feishu/adapter.js'
|
|
27
|
+
import { createFeishuApi } from './feishu/api.js'
|
|
27
28
|
import type { FeishuConfig } from './feishu/types.js'
|
|
29
|
+
import { createTelegramAdapter } from './telegram/adapter.js'
|
|
30
|
+
import { createTelegramApi } from './telegram/api.js'
|
|
31
|
+
import type { TelegramConfig } from './telegram/types.js'
|
|
32
|
+
import { startHeartbeat } from './heartbeat'
|
|
28
33
|
|
|
29
34
|
interface ServerConfig {
|
|
30
35
|
db: DaemonDatabase
|
|
@@ -40,6 +45,7 @@ interface ServerConfig {
|
|
|
40
45
|
scheduleManager: ScheduleManager
|
|
41
46
|
rateLimiter: RateLimiter
|
|
42
47
|
feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
|
|
48
|
+
telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
interface WsData {
|
|
@@ -67,6 +73,25 @@ function resolveDaemonPermission(): PermissionMode {
|
|
|
67
73
|
: 'default'
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Build the daemon's PermissionSystem, honoring org-level restrictions
|
|
78
|
+
* (permissionRestrictions) and user-defined allow/deny rules. setRestrictions
|
|
79
|
+
* re-clamps the env-derived mode, so a `MIPHAM_DAEMON_PERMISSION=bypassPermissions`
|
|
80
|
+
* is downgraded when the config forbids it — mirroring the CLI's fail-closed behavior.
|
|
81
|
+
*/
|
|
82
|
+
export function buildDaemonPermission(
|
|
83
|
+
restrictions?: PermissionRestrictions,
|
|
84
|
+
rules?: { allow?: string[]; deny?: string[] },
|
|
85
|
+
): PermissionSystem {
|
|
86
|
+
const permission = new PermissionSystem(resolveDaemonPermission())
|
|
87
|
+
if (restrictions) permission.setRestrictions(restrictions)
|
|
88
|
+
if (rules) {
|
|
89
|
+
for (const rule of rules.allow ?? []) permission.allow(rule)
|
|
90
|
+
for (const rule of rules.deny ?? []) permission.deny(rule)
|
|
91
|
+
}
|
|
92
|
+
return permission
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
const DAEMON_DEFAULT_CONTEXT_WINDOW = 200_000
|
|
71
96
|
|
|
72
97
|
/**
|
|
@@ -98,6 +123,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
98
123
|
scheduleManager,
|
|
99
124
|
rateLimiter,
|
|
100
125
|
feishu,
|
|
126
|
+
telegram,
|
|
101
127
|
} = config
|
|
102
128
|
|
|
103
129
|
const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
|
|
@@ -185,7 +211,11 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
|
|
188
|
-
const
|
|
214
|
+
const daemonConfig = loadConfig(cwd)
|
|
215
|
+
const permission = buildDaemonPermission(
|
|
216
|
+
daemonConfig.permissionRestrictions,
|
|
217
|
+
daemonConfig.permissionRules,
|
|
218
|
+
)
|
|
189
219
|
const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
|
|
190
220
|
engine.setSessionId(sessionId)
|
|
191
221
|
engineCache.set(sessionId, engine)
|
|
@@ -194,7 +224,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
194
224
|
|
|
195
225
|
function getOrCreateWorker(
|
|
196
226
|
sessionId: string,
|
|
197
|
-
|
|
227
|
+
_ws?: ServerWebSocket<WsData>,
|
|
198
228
|
): SessionWorker | null {
|
|
199
229
|
try {
|
|
200
230
|
const session = db.getSession(sessionId)
|
|
@@ -231,6 +261,51 @@ export function createServer(config: ServerConfig): Server<WsData> {
|
|
|
231
261
|
})
|
|
232
262
|
: undefined
|
|
233
263
|
|
|
264
|
+
// ── Telegram remote-control adapter(长轮询,无需 webhook 路由)──
|
|
265
|
+
const telegramAdapter = telegram
|
|
266
|
+
? createTelegramAdapter(telegram.config, createTelegramApi(telegram.config), {
|
|
267
|
+
sm,
|
|
268
|
+
getOrCreateWorker,
|
|
269
|
+
rateLimiter,
|
|
270
|
+
cwd: telegram.cwd,
|
|
271
|
+
provider: telegram.provider,
|
|
272
|
+
model: telegram.model,
|
|
273
|
+
})
|
|
274
|
+
: undefined
|
|
275
|
+
telegramAdapter?.start()
|
|
276
|
+
|
|
277
|
+
// ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
|
|
278
|
+
const heartbeatSource = {
|
|
279
|
+
listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
|
|
280
|
+
listSchedules: () => sm.listSessions().flatMap((s) => scheduleManager.getSchedules(s.id)),
|
|
281
|
+
}
|
|
282
|
+
if (feishu) {
|
|
283
|
+
const feishuApi = createFeishuApi(feishu.config)
|
|
284
|
+
startHeartbeat({
|
|
285
|
+
source: heartbeatSource,
|
|
286
|
+
push: (message) => {
|
|
287
|
+
for (const openId of feishu.config.allowedOpenIds) {
|
|
288
|
+
void feishuApi.sendText(openId, message).catch(() => {
|
|
289
|
+
/* 推送失败静默,不打断心跳 */
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
if (telegram) {
|
|
296
|
+
const telegramApi = createTelegramApi(telegram.config)
|
|
297
|
+
startHeartbeat({
|
|
298
|
+
source: heartbeatSource,
|
|
299
|
+
push: (message) => {
|
|
300
|
+
for (const chatId of telegram.config.allowedChatIds) {
|
|
301
|
+
void telegramApi.sendText(chatId, message).catch(() => {
|
|
302
|
+
/* 推送失败静默,不打断心跳 */
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
234
309
|
const server = Bun.serve<WsData>({
|
|
235
310
|
port,
|
|
236
311
|
hostname,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DaemonDatabase } from './database'
|
|
2
|
-
import type { DaemonSession
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { TelegramApi } from './api.js'
|
|
2
|
+
import type { TelegramMessage } from './types.js'
|
|
3
|
+
|
|
4
|
+
/** 从 update 提取文本消息;非文本/缺 message → null。chat.id 统一字符串化比对(id < 2^53;超范围 bigint 后续再引入)。 */
|
|
5
|
+
export function extractTextMessage(update: unknown): TelegramMessage | null {
|
|
6
|
+
const msg = (
|
|
7
|
+
update as { message?: { chat?: { id?: number | string }; message_id?: number; text?: string } }
|
|
8
|
+
)?.message
|
|
9
|
+
if (!msg?.text || msg.chat?.id == null) return null
|
|
10
|
+
return { chatId: String(msg.chat.id), messageId: msg.message_id ?? 0, text: msg.text }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 推进 offset:最大 update_id + 1;空列表沿用 prev。 */
|
|
14
|
+
export function nextOffset(updates: Array<{ update_id?: number }>, prevOffset: number): number {
|
|
15
|
+
if (updates.length === 0) return prevOffset
|
|
16
|
+
return Math.max(...updates.map((u) => u.update_id ?? prevOffset)) + 1
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 指数退避,封顶 30s。 */
|
|
20
|
+
export function nextBackoff(currentMs: number): number {
|
|
21
|
+
return Math.min(currentMs * 2, 30_000)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 长轮询循环(unref 不阻退出)。返回 stop 函数。循环行为由 Task 7 集成测试覆盖。 */
|
|
25
|
+
export function startTelegramPoller(
|
|
26
|
+
api: TelegramApi,
|
|
27
|
+
onMessage: (msg: TelegramMessage) => Promise<void>,
|
|
28
|
+
opts?: { pollTimeoutSeconds?: number },
|
|
29
|
+
): () => void {
|
|
30
|
+
let offset = 0
|
|
31
|
+
let backoffMs = 1000
|
|
32
|
+
let stopped = false
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
34
|
+
|
|
35
|
+
async function loop() {
|
|
36
|
+
if (stopped) return
|
|
37
|
+
try {
|
|
38
|
+
const updates = await api.getUpdates(offset, opts?.pollTimeoutSeconds ?? 30)
|
|
39
|
+
for (const u of updates) {
|
|
40
|
+
const msg = extractTextMessage(u)
|
|
41
|
+
if (msg) {
|
|
42
|
+
try {
|
|
43
|
+
await onMessage(msg)
|
|
44
|
+
} catch {
|
|
45
|
+
/* 单条失败不中断轮询 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
offset = nextOffset(updates, offset)
|
|
50
|
+
backoffMs = 1000
|
|
51
|
+
} catch {
|
|
52
|
+
backoffMs = nextBackoff(backoffMs)
|
|
53
|
+
}
|
|
54
|
+
if (stopped) return
|
|
55
|
+
timer = setTimeout(loop, backoffMs)
|
|
56
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
void loop()
|
|
60
|
+
return () => {
|
|
61
|
+
stopped = true
|
|
62
|
+
if (timer) clearTimeout(timer)
|
|
63
|
+
}
|
|
64
|
+
}
|