@miphamai/cli 0.60.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.60.1",
3
+ "version": "0.62.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,11 +1,14 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import type { CrsiProvenanceBridge, CrsiVerdict } from './crsi-provenance-bridge.js'
4
+ import { isRecoverableToolFailure } from './recoverable-failure.js'
4
5
 
5
6
  export interface RuleEffectiveness {
6
7
  ruleId: string
7
8
  appliedCount: number
8
9
  successAfterCount: number
10
+ /** 可恢复/环境性失败数——记录但排除在成功率分母外。 */
11
+ recoverableCount?: number
9
12
  preRuleFailureRate: number
10
13
  postRuleFailureRate: number
11
14
  status: 'active' | 'degrading' | 'disabled'
@@ -44,7 +47,11 @@ export class EffectivenessTracker {
44
47
  if (eff) eff.decisionId = decisionId
45
48
  }
46
49
 
47
- recordApplication(ruleId: string, success: boolean): void {
50
+ recordApplication(
51
+ ruleId: string,
52
+ success: boolean,
53
+ opts?: { toolName?: string; error?: string },
54
+ ): void {
48
55
  let eff = this.data.get(ruleId)
49
56
  if (!eff) {
50
57
  eff = {
@@ -61,6 +68,12 @@ export class EffectivenessTracker {
61
68
  this.data.set(ruleId, eff)
62
69
  }
63
70
 
71
+ // 可恢复/环境性失败:仍记录,但不进成功率分母(防误降级/误禁用能用的规则)。
72
+ if (!success && opts?.error && isRecoverableToolFailure(opts.toolName ?? '', opts.error)) {
73
+ eff.recoverableCount = (eff.recoverableCount ?? 0) + 1
74
+ return
75
+ }
76
+
64
77
  eff.appliedCount++
65
78
  if (success) eff.successAfterCount++
66
79
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 判「可恢复/环境性」失败 vs 真缺陷(borrow opencrabs `is_recoverable_tool_failure` 思想)。
3
+ *
4
+ * 可恢复失败(网络超时、连接拒绝、缺文件、环境权限)不该进成功率分母,否则会把成功率
5
+ * 拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash 重试
6
+ * 被误判成工具缺陷,把能用的 hashline_edit 禁了)。
7
+ *
8
+ * fail-closed:无 error、或非 Bash 工具,一律按真失败算(证不了可恢复就不排除)。
9
+ */
10
+ const RECOVERABLE_PATTERNS: RegExp[] = [
11
+ /\btimed?\s?out\b/i, // timeout / timed out(网络慢 / 服务死)
12
+ /(ECONNREFUSED|connection refused|not connected|network unreachable)/i, // 服务未起
13
+ /(no such file or directory|ENOENT)/i, // 缺资源,非缺陷
14
+ /(permission denied|EACCES)/i, // 环境权限
15
+ ]
16
+
17
+ export function isRecoverableToolFailure(toolName: string, error?: string): boolean {
18
+ if (!error || toolName !== 'Bash') return false
19
+ const e = error.toLowerCase()
20
+ return RECOVERABLE_PATTERNS.some((re) => re.test(e))
21
+ }
@@ -0,0 +1,52 @@
1
+ // Cron poller — makes the CLI's durable CronCreate jobs actually fire.
2
+ //
3
+ // Every minute it reads ~/.mipham/cron/*.json and, for each job whose
4
+ // nextFire has passed, enqueues the prompt into the engine's cron queue
5
+ // (which re-invokes it into the current session) and then advances the job
6
+ // (recurring → next fire; one-shot → deleted).
7
+
8
+ import { computeNextFire } from './cron'
9
+ import type { CronJob } from '../tools/scheduling/cron'
10
+ import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
11
+
12
+ /** Jobs whose nextFire is at or before `now`. Pure — separated for tests. */
13
+ export function findDueJobs(jobs: CronJob[], now: Date): CronJob[] {
14
+ return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime())
15
+ }
16
+
17
+ /** Next state after firing a due job: recurring advances; one-shot → null (delete). */
18
+ export function advanceJob(job: CronJob, now: Date): CronJob | null {
19
+ if (!job.recurring) return null
20
+ return {
21
+ ...job,
22
+ lastFired: now.toISOString(),
23
+ nextFire: computeNextFire(job.cron, now),
24
+ }
25
+ }
26
+
27
+ /** Read due jobs, enqueue their prompts, and advance/delete. Returns fired count. */
28
+ export function checkCronJobs(enqueue: (prompt: string) => void, now = new Date()): number {
29
+ const due = findDueJobs(readAllJobs(), now)
30
+ for (const job of due) {
31
+ enqueue(job.prompt)
32
+ const next = advanceJob(job, now)
33
+ if (next) writeJob(next)
34
+ else deleteJobFile(job.id)
35
+ }
36
+ return due.length
37
+ }
38
+
39
+ /**
40
+ * Start the cron poller. Returns a stop function. The interval is unref'd so
41
+ * it never keeps the process alive on its own.
42
+ */
43
+ export function startCronPoller(
44
+ enqueue: (prompt: string) => void,
45
+ intervalMs = 60_000,
46
+ ): () => void {
47
+ const timer = setInterval(() => {
48
+ checkCronJobs(enqueue)
49
+ }, intervalMs)
50
+ timer.unref()
51
+ return () => clearInterval(timer)
52
+ }
@@ -0,0 +1,73 @@
1
+ // Simple minute-granularity cron expression parser.
2
+ //
3
+ // Supports the standard 5-field cron format:
4
+ // minute hour day-of-month month day-of-week
5
+ //
6
+ // Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
7
+ // Falls back to +1 minute if the expression is invalid or no match is found
8
+ // within 366 days.
9
+
10
+ export function computeNextFire(cronExpr: string, from: Date): string {
11
+ const fields = cronExpr.trim().split(/\s+/)
12
+ if (fields.length !== 5) {
13
+ return new Date(from.getTime() + 60_000).toISOString()
14
+ }
15
+
16
+ const minField = fields[0]!
17
+ const hourField = fields[1]!
18
+ const domField = fields[2]!
19
+ const monthField = fields[3]!
20
+ const dowField = fields[4]!
21
+
22
+ function matches(value: number, field: string): boolean {
23
+ if (field === '*') return true
24
+ // */N step
25
+ const stepMatch = field.match(/^\*\/(\d+)$/)
26
+ if (stepMatch) {
27
+ return value % parseInt(stepMatch[1]!, 10) === 0
28
+ }
29
+ // Comma-separated list
30
+ if (field.includes(',')) {
31
+ return field.split(',').some((f) => matches(value, f))
32
+ }
33
+ // Range
34
+ const rangeMatch = field.match(/^(\d+)-(\d+)$/)
35
+ if (rangeMatch) {
36
+ const low = parseInt(rangeMatch[1]!, 10)
37
+ const high = parseInt(rangeMatch[2]!, 10)
38
+ return value >= low && value <= high
39
+ }
40
+ // Exact value
41
+ return value === parseInt(field, 10)
42
+ }
43
+
44
+ const date = new Date(from)
45
+ date.setSeconds(0, 0)
46
+ // Start from the next minute to avoid matching the current minute
47
+ date.setMinutes(date.getMinutes() + 1)
48
+
49
+ // Try up to 366 days forward (safety limit — ~527k iterations)
50
+ const maxIterations = 366 * 24 * 60
51
+ for (let i = 0; i < maxIterations; i++) {
52
+ const minute = date.getMinutes()
53
+ const hour = date.getHours()
54
+ const dom = date.getDate()
55
+ const month = date.getMonth() + 1 // JS months are 0-indexed
56
+ const dow = date.getDay() // 0 = Sunday
57
+
58
+ if (
59
+ matches(minute, minField) &&
60
+ matches(hour, hourField) &&
61
+ matches(dom, domField) &&
62
+ matches(month, monthField) &&
63
+ matches(dow, dowField)
64
+ ) {
65
+ return date.toISOString()
66
+ }
67
+
68
+ date.setMinutes(date.getMinutes() + 1)
69
+ }
70
+
71
+ // Fallback: 1 minute from now
72
+ return new Date(from.getTime() + 60_000).toISOString()
73
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { randomUUID } from 'node:crypto'
16
- import { CrsiSandbox } from './crsi-sandbox'
16
+ import { CrsiSandbox, validateBlastRadius } from './crsi-sandbox'
17
17
  import type { CrsiModificationResult } from './crsi-sandbox'
18
18
  import { runEval, appendEvalScore, getLastEvalScore } from './eval-harness'
19
19
 
@@ -28,6 +28,13 @@ export interface CrsiProposal {
28
28
  originalContent?: string
29
29
  /** 触发此改动的 CRSI insight id */
30
30
  crsiInsightId?: string
31
+ /**
32
+ * 完整覆盖(blast radius):此改动触及的**所有**代码路径(文件列表)。
33
+ *
34
+ * 源于 2026-08-26 教训:修「思考转储」只接实时指示器、漏历史行冲刷路径——
35
+ * 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,否则 fail-closed 拒绝。
36
+ */
37
+ blastRadius?: string[]
31
38
  }
32
39
 
33
40
  // ── Pending proposal registry (两阶段闸门) ──
@@ -42,6 +49,25 @@ export function runCrsiModification(
42
49
  proposal: CrsiProposal,
43
50
  sandbox: CrsiSandbox = new CrsiSandbox(),
44
51
  ): CrsiModificationResult {
52
+ // 完整覆盖闸:自修改必须声明 blastRadius,否则 fail-closed(在 worktree 之前,零副作用)。
53
+ const blastRadiusError = validateBlastRadius(proposal)
54
+ if (blastRadiusError) {
55
+ return {
56
+ modification: {
57
+ id: 'crsi-mod-rejected-blast-radius',
58
+ description: proposal.description,
59
+ filePath: proposal.filePath,
60
+ newContent: proposal.newContent,
61
+ originalContent: proposal.originalContent ?? '',
62
+ crsiInsightId: proposal.crsiInsightId,
63
+ timestamp: new Date().toISOString(),
64
+ },
65
+ applied: false,
66
+ phase: 'failed',
67
+ error: blastRadiusError,
68
+ }
69
+ }
70
+
45
71
  sandbox.createWorktree()
46
72
 
47
73
  const applied = sandbox.applyModification({
@@ -127,7 +127,13 @@ export function produceCrsiProposal(
127
127
  metaRules: MetaRule[],
128
128
  currentLessons: string,
129
129
  timestamp: string,
130
- ): { description: string; filePath: string; newContent: string; originalContent: string } | null {
130
+ ): {
131
+ description: string
132
+ filePath: string
133
+ newContent: string
134
+ originalContent: string
135
+ blastRadius: string[]
136
+ } | null {
131
137
  const signal = selectCrsiSignal(insights, metaRules)
132
138
  if (!signal) return null
133
139
 
@@ -142,6 +148,7 @@ export function produceCrsiProposal(
142
148
  filePath: LESSONS_FILE,
143
149
  newContent,
144
150
  originalContent: currentLessons,
151
+ blastRadius: [LESSONS_FILE],
145
152
  }
146
153
  }
147
154
 
@@ -215,7 +222,13 @@ export function renderManagedRuleSource(signal: CrsiSignal): string | null {
215
222
  export function produceRuleProposal(
216
223
  signal: CrsiSignal,
217
224
  currentManagedRules: string,
218
- ): { description: string; filePath: string; newContent: string; originalContent: string } | null {
225
+ ): {
226
+ description: string
227
+ filePath: string
228
+ newContent: string
229
+ originalContent: string
230
+ blastRadius: string[]
231
+ } | null {
219
232
  const ruleSource = renderManagedRuleSource(signal)
220
233
  if (!ruleSource) return null
221
234
 
@@ -232,6 +245,7 @@ export function produceRuleProposal(
232
245
  filePath: MANAGED_RULES_FILE,
233
246
  newContent,
234
247
  originalContent: currentManagedRules,
248
+ blastRadius: [MANAGED_RULES_FILE],
235
249
  }
236
250
  }
237
251
 
@@ -122,6 +122,24 @@ export function isProtectedPath(filePath: string): boolean {
122
122
  return PROTECTED_PATHS.some((p) => filePath === p || filePath.startsWith(p))
123
123
  }
124
124
 
125
+ /**
126
+ * 完整覆盖闸(blast radius):自修改 proposal 必须声明非空 blastRadius,否则拒绝。
127
+ *
128
+ * 源于 2026-08-26 教训:修「思考转储」只接实时指示器、漏历史行冲刷路径——
129
+ * 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,fail-closed。
130
+ *
131
+ * 返回错误字符串(拒绝理由),合法时返回 null。
132
+ */
133
+ export function validateBlastRadius(proposal: { blastRadius?: string[] }): string | null {
134
+ if (!proposal.blastRadius || proposal.blastRadius.length === 0) {
135
+ return (
136
+ 'blast radius 未声明:自修改必须枚举触及的全部代码路径。' +
137
+ '教训:两条渲染路径只接一条 = 局部正确全局遗漏。'
138
+ )
139
+ }
140
+ return null
141
+ }
142
+
125
143
  // ── Sandbox ──
126
144
 
127
145
  export class CrsiSandbox {
@@ -105,6 +105,9 @@ export class QueryEngine {
105
105
  /** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
106
106
  private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
107
107
 
108
+ /** Pending durable cron prompts, enqueued by the cron poller (FIFO — every due job fires). */
109
+ private cronQueue: string[] = []
110
+
108
111
  /** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
109
112
  private onWakeupEnqueued: (() => void) | null = null
110
113
 
@@ -148,6 +151,26 @@ export class QueryEngine {
148
151
  return this.wakeupQueue.length > 0
149
152
  }
150
153
 
154
+ /** Enqueue a durable cron prompt for re-invocation (FIFO — every due job fires). */
155
+ enqueueCronPrompt(prompt: string): void {
156
+ // Dedup by prompt: a recurring job whose previous fire is still queued
157
+ // (slow turn) re-fires the same prompt — skip it rather than pile up.
158
+ if (!this.cronQueue.includes(prompt)) {
159
+ this.cronQueue.push(prompt)
160
+ }
161
+ this.onWakeupEnqueued?.()
162
+ }
163
+
164
+ /** Pop the next pending cron prompt, or null when the queue is empty. */
165
+ dequeueCronPrompt(): string | null {
166
+ return this.cronQueue.shift() ?? null
167
+ }
168
+
169
+ /** Whether a cron prompt is pending re-invocation. */
170
+ hasPendingCron(): boolean {
171
+ return this.cronQueue.length > 0
172
+ }
173
+
151
174
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
152
175
  private sessionId: string = 'session-1'
153
176
 
@@ -1180,7 +1203,10 @@ export class QueryEngine {
1180
1203
  const match = warning.match(/^\[rule:([^\]]+)\]/)
1181
1204
  const ruleId = match?.[1]
1182
1205
  if (ruleId) {
1183
- tracker.recordApplication(ruleId, result.success)
1206
+ tracker.recordApplication(ruleId, result.success, {
1207
+ toolName: name,
1208
+ error: result.error,
1209
+ })
1184
1210
  }
1185
1211
  }
1186
1212
  tracker.persist()
@@ -19,7 +19,7 @@ import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
19
19
  import { ErrorSignatureDB } from './error-signature-db'
20
20
  import { PreFlightChecker } from './preflight-checker'
21
21
  import { RedTeam } from './red-team'
22
- import { isProtectedPath } from './crsi-sandbox'
22
+ import { isProtectedPath, validateBlastRadius } from './crsi-sandbox'
23
23
  import { produceRuleProposal, MANAGED_RULES_FILE } from './crsi-producer'
24
24
  import type { CrsiSignal } from './crsi-producer'
25
25
  import { loadBehaviorTasks, judgeBehaviorTask } from './behavior-tasks'
@@ -167,6 +167,16 @@ export function runEval(): EvalReport {
167
167
  results.push({ id, description: `受保护路径被拒: ${path}`, passed: isProtectedPath(path) })
168
168
  }
169
169
 
170
+ // ── 完整覆盖闸(ground truth:未声明 blast radius 的 proposal 被 fail-closed 拒绝) ──
171
+ results.push({
172
+ id: 'blast-radius-gate',
173
+ description: '自修改 proposal 未声明 blast radius 被拒,声明后放行',
174
+ passed:
175
+ validateBlastRadius({ blastRadius: undefined }) !== null &&
176
+ validateBlastRadius({ blastRadius: [] }) !== null &&
177
+ validateBlastRadius({ blastRadius: ['apps/cli/src/foo.ts'] }) === null,
178
+ })
179
+
170
180
  // ── 安全(ground truth:16 攻击零漏过) ──
171
181
  const redTeam = new RedTeam().run(constitution, preflight, errorDB)
172
182
  results.push({
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, resolve, relative, sep, isAbsolute } from 'node:path'
3
3
  import { execSync } from 'node:child_process'
4
4
  import { parse as parseYaml } from 'yaml'
5
5
  import type { InstructionFile } from '../shared/index.ts'
@@ -59,32 +59,78 @@ export function parsePromptExclude(value: unknown): string[] {
59
59
  return []
60
60
  }
61
61
 
62
+ /** 定位仓库根(git rev-parse --show-toplevel),非 git 目录回退 cwd。 */
63
+ export function gitRoot(cwd: string): string {
64
+ try {
65
+ return execSync('git rev-parse --show-toplevel', {
66
+ cwd,
67
+ timeout: 5000,
68
+ encoding: 'utf-8',
69
+ }).trim()
70
+ } catch {
71
+ return cwd
72
+ }
73
+ }
74
+
75
+ /** 从仓库根到 cwd 的目录链(含两端),就近(cwd)在最后。cwd 不在 root 下时退化为 [cwd]。 */
76
+ export function discoverDirectories(root: string, cwd: string): string[] {
77
+ const absRoot = resolve(root)
78
+ const absCwd = resolve(cwd)
79
+ if (absCwd === absRoot) return [absRoot]
80
+
81
+ const rel = relative(absRoot, absCwd)
82
+ if (rel.startsWith('..') || isAbsolute(rel)) return [absCwd]
83
+
84
+ const dirs = [absRoot]
85
+ let cur = absRoot
86
+ for (const seg of rel.split(sep)) {
87
+ cur = join(cur, seg)
88
+ dirs.push(cur)
89
+ }
90
+ return dirs
91
+ }
92
+
93
+ /**
94
+ * 每目录内指令文件的读取顺序(后加载 = 更高优先级)。
95
+ * AGENTS.md(行业标准基线)→ AGENTS.override.md(Codex 覆盖层)→
96
+ * MIPHAM.md(Mipham 品牌)→ CLAUDE.md(Claude Code 兼容)。
97
+ * MIPHAM→CLAUDE 相对顺序保持现状不变,仅前置 AGENTS 两条,避免行为回归。
98
+ */
99
+ export const INSTRUCTION_FILENAMES = [
100
+ 'AGENTS.md',
101
+ 'AGENTS.override.md',
102
+ 'MIPHAM.md',
103
+ 'CLAUDE.md',
104
+ ] as const
105
+
62
106
  export class InstructionsLoader {
63
107
  private instructions: InstructionFile[] = []
64
108
  private crsiLessonSummaries: CrsiLessonSummary[] = []
65
109
 
66
110
  loadAll(cwd: string): void {
67
111
  this.instructions = []
112
+ const root = gitRoot(cwd)
113
+
114
+ // Tier 1: 集团/公司策略(锚定仓库根,从任意子目录启动都正确;不读 AGENTS.md)
115
+ this.tryLoad(join(root, '..', '..', 'CLAUDE.md'), 'group') // Rismed_Ronxin_Capital
116
+ this.tryLoad(join(root, '..', 'CLAUDE.md'), 'company') // One_Mipham_Corporation
117
+ this.tryLoad(join(root, '..', 'MIPHAM.md'), 'group')
118
+
119
+ // Tier 2: 递归项目层 — git 根 → cwd,逐目录读,就近(cwd)最后 = 优先级最高
120
+ const dirs = discoverDirectories(root, cwd)
121
+ dirs.forEach((dir, i) => {
122
+ const level: InstructionFile['level'] = i === dirs.length - 1 ? 'project' : 'directory'
123
+ for (const name of INSTRUCTION_FILENAMES) {
124
+ this.tryLoad(join(dir, name), level)
125
+ }
126
+ })
68
127
 
69
- // Tier 1: Ancestor-level CLAUDE.md (for Claude Code compatibility)
70
- // ../../ = Rismed_Ronxin_Capital
71
- this.tryLoad(join(cwd, '..', '..', 'CLAUDE.md'), 'group')
72
- // ../ = One_Mipham_Corporation
73
- this.tryLoad(join(cwd, '..', 'CLAUDE.md'), 'company')
74
-
75
- // Tier 1b: Group-level MIPHAM.md — One_Mipham_Corporation is the root
76
- this.tryLoad(join(cwd, '..', 'MIPHAM.md'), 'group')
77
-
78
- // Tier 2: Project-level (CLAUDE.md + MIPHAM.md at cwd)
79
- this.tryLoad(join(cwd, 'MIPHAM.md'), 'project')
80
- this.tryLoad(join(cwd, 'CLAUDE.md'), 'project')
81
-
82
- // Tier 3: User-level ~/.mipham/USER.md
128
+ // Tier 3: 用户层 ~/.mipham/USER.md
83
129
  const home = process.env.HOME || '~'
84
130
  this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
85
131
 
86
132
  // CRSI 教训召回:读 crsi-lessons.md 提取精华,注入系统提示(只写不读 → 写后召回)
87
- this.crsiLessonSummaries = this.loadCrsiLessons(cwd)
133
+ this.crsiLessonSummaries = this.loadCrsiLessons(root)
88
134
  }
89
135
 
90
136
  buildSystemPrompt(permissionMode?: string): string {
@@ -235,17 +281,7 @@ Never omit it or present the work as purely human-authored.`)
235
281
  }
236
282
 
237
283
  /** 读 crsi-lessons.md(按仓库根定位)提取教训精华。读不到则返回空。 */
238
- private loadCrsiLessons(cwd: string): CrsiLessonSummary[] {
239
- let root = cwd
240
- try {
241
- root = execSync('git rev-parse --show-toplevel', {
242
- cwd,
243
- timeout: 5000,
244
- encoding: 'utf-8',
245
- }).trim()
246
- } catch {
247
- // 非 git 目录 → 回退 cwd
248
- }
284
+ private loadCrsiLessons(root: string): CrsiLessonSummary[] {
249
285
  try {
250
286
  const content = readFileSync(join(root, LESSONS_FILE), 'utf-8')
251
287
  return extractCrsiLessonSummaries(content)
@@ -272,7 +308,7 @@ Never omit it or present the work as purely human-authored.`)
272
308
  const description = modeDescriptions[mode]
273
309
  if (!description) return ''
274
310
 
275
- return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry with the same tool and similar parameters. Move on to a different approach or ask the user for guidance.`
311
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode. If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch to bypassPermissions (Shift+Tab) or add an allow rule (/permissions), then wait for the user's answer.`
276
312
  }
277
313
 
278
314
  list(): InstructionFile[] {
@@ -8,6 +8,7 @@ import {
8
8
  statSync,
9
9
  } from 'node:fs'
10
10
  import { join, extname } from 'node:path'
11
+ import { similarities } from './tfidf'
11
12
 
12
13
  export interface MemoryMetadata {
13
14
  type: 'user' | 'feedback' | 'project' | 'reference'
@@ -113,21 +114,22 @@ export class MemoryManager {
113
114
 
114
115
  recall(context: string, limit: number = 10): MemoryEntry[] {
115
116
  const contextLower = context.toLowerCase()
117
+ const entries = [...this.memories.values()]
118
+ // TF-IDF cosine similarity (CJK-bigram aware) replaces the old word-overlap.
119
+ const sims = similarities(
120
+ context,
121
+ entries.map((e) => e.content),
122
+ )
116
123
  const scored: Array<{ entry: MemoryEntry; score: number }> = []
117
124
  const seen = new Set<string>()
118
125
 
119
- for (const entry of this.memories.values()) {
120
- let score = 0
121
- // Match against relevance tags
126
+ for (let i = 0; i < entries.length; i++) {
127
+ const entry = entries[i]!
128
+ let score = sims[i]! * 10
129
+ // Match against relevance tags (strong curated signal)
122
130
  for (const tag of entry.metadata.relevance) {
123
131
  if (contextLower.includes(tag.toLowerCase())) score += 3
124
132
  }
125
- // Match against content keywords
126
- const contentWords = entry.content.toLowerCase().split(/\s+/)
127
- const contextWords = new Set(contextLower.split(/\s+/))
128
- for (const word of contentWords) {
129
- if (contextWords.has(word) && word.length > 3) score += 1
130
- }
131
133
  if (score > 0) {
132
134
  scored.push({ entry, score })
133
135
  seen.add(entry.name)
@@ -0,0 +1,108 @@
1
+ // Local TF-IDF similarity — zero-dependency "semantic-ish" recall.
2
+ //
3
+ // The built-in memory is local files and must stay offline. This module upgrades
4
+ // the old exact-keyword overlap to TF-IDF-weighted cosine similarity, with a CJK
5
+ // character-bigram tokenizer so Chinese content (which has no word separators)
6
+ // is matched on adjacent-character overlap rather than whole-word equality.
7
+
8
+ const CJK_RE = /[㐀-䶿一-鿿]/
9
+ const WORD_RE = /[a-z0-9]/
10
+
11
+ function isCjk(ch: string): boolean {
12
+ return CJK_RE.test(ch)
13
+ }
14
+
15
+ function isWordChar(ch: string): boolean {
16
+ return WORD_RE.test(ch)
17
+ }
18
+
19
+ /**
20
+ * Tokenize text into CJK character bigrams + lowercase ASCII words (length ≥ 2).
21
+ * Punctuation and single-char ASCII tokens are dropped as noise.
22
+ */
23
+ export function tokenize(text: string): string[] {
24
+ const tokens: string[] = []
25
+ const lower = text.toLowerCase()
26
+ let i = 0
27
+
28
+ while (i < lower.length) {
29
+ const ch = lower[i]!
30
+ if (isCjk(ch)) {
31
+ let run = ''
32
+ while (i < lower.length && isCjk(lower[i]!)) {
33
+ run += lower[i]!
34
+ i++
35
+ }
36
+ if (run.length === 1) {
37
+ tokens.push(run)
38
+ } else {
39
+ for (let j = 0; j < run.length - 1; j++) {
40
+ tokens.push(run.slice(j, j + 2))
41
+ }
42
+ }
43
+ } else if (isWordChar(ch)) {
44
+ let word = ''
45
+ while (i < lower.length && isWordChar(lower[i]!)) {
46
+ word += lower[i]!
47
+ i++
48
+ }
49
+ if (word.length >= 2) tokens.push(word)
50
+ } else {
51
+ i++
52
+ }
53
+ }
54
+
55
+ return tokens
56
+ }
57
+
58
+ /** Cosine similarity between two sparse term-weight vectors. */
59
+ export function cosine(a: Map<string, number>, b: Map<string, number>): number {
60
+ let dot = 0
61
+ let normA = 0
62
+ for (const [term, w] of a) {
63
+ normA += w * w
64
+ const wb = b.get(term)
65
+ if (wb !== undefined) dot += w * wb
66
+ }
67
+ let normB = 0
68
+ for (const w of b.values()) normB += w * w
69
+ if (normA === 0 || normB === 0) return 0
70
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB))
71
+ }
72
+
73
+ /** TF-IDF weight vector for one token list, given corpus document frequency. */
74
+ function tfidfVector(
75
+ tokens: string[],
76
+ df: Map<string, number>,
77
+ numDocs: number,
78
+ ): Map<string, number> {
79
+ const tf = new Map<string, number>()
80
+ for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1)
81
+
82
+ const vec = new Map<string, number>()
83
+ for (const [term, freq] of tf) {
84
+ const idf = Math.log((numDocs + 1) / ((df.get(term) ?? 0) + 1)) + 1
85
+ vec.set(term, freq * idf)
86
+ }
87
+ return vec
88
+ }
89
+
90
+ /**
91
+ * Cosine similarity of a query against each document, index-aligned.
92
+ * `docs` are raw texts; tokenization + TF-IDF weighting is applied internally.
93
+ */
94
+ export function similarities(query: string, docs: string[]): number[] {
95
+ const docTokens = docs.map(tokenize)
96
+ const queryTokens = tokenize(query)
97
+ const numDocs = docs.length
98
+
99
+ const df = new Map<string, number>()
100
+ for (const tokens of docTokens) {
101
+ for (const term of new Set(tokens)) {
102
+ df.set(term, (df.get(term) ?? 0) + 1)
103
+ }
104
+ }
105
+
106
+ const queryVec = tfidfVector(queryTokens, df, numDocs)
107
+ return docTokens.map((tokens) => cosine(queryVec, tfidfVector(tokens, df, numDocs)))
108
+ }
@@ -6,79 +6,7 @@ import type { DaemonDatabase } from './database'
6
6
  import type { WorkerPool } from './worker-pool'
7
7
  import type { DaemonSchedule } from './types'
8
8
  import { logger } from './logger'
9
-
10
- // Simple minute-granularity cron expression parser.
11
- //
12
- // Supports the standard 5-field cron format:
13
- // minute hour day-of-month month day-of-week
14
- //
15
- // Handles: * (wildcard), star/N (step), N (exact), N-M (range), N,M,O (list).
16
- // Falls back to +1 minute if the expression is invalid or no match is found
17
- // within 366 days.
18
- function computeNextFire(cronExpr: string, from: Date): string {
19
- const fields = cronExpr.trim().split(/\s+/)
20
- if (fields.length !== 5) {
21
- return new Date(from.getTime() + 60_000).toISOString()
22
- }
23
-
24
- const minField = fields[0]!
25
- const hourField = fields[1]!
26
- const domField = fields[2]!
27
- const monthField = fields[3]!
28
- const dowField = fields[4]!
29
-
30
- function matches(value: number, field: string): boolean {
31
- if (field === '*') return true
32
- // */N step
33
- const stepMatch = field.match(/^\*\/(\d+)$/)
34
- if (stepMatch) {
35
- return value % parseInt(stepMatch[1]!, 10) === 0
36
- }
37
- // Comma-separated list
38
- if (field.includes(',')) {
39
- return field.split(',').some((f) => matches(value, f))
40
- }
41
- // Range
42
- const rangeMatch = field.match(/^(\d+)-(\d+)$/)
43
- if (rangeMatch) {
44
- const low = parseInt(rangeMatch[1]!, 10)
45
- const high = parseInt(rangeMatch[2]!, 10)
46
- return value >= low && value <= high
47
- }
48
- // Exact value
49
- return value === parseInt(field, 10)
50
- }
51
-
52
- const date = new Date(from)
53
- date.setSeconds(0, 0)
54
- // Start from the next minute to avoid matching the current minute
55
- date.setMinutes(date.getMinutes() + 1)
56
-
57
- // Try up to 366 days forward (safety limit — ~527k iterations)
58
- const maxIterations = 366 * 24 * 60
59
- for (let i = 0; i < maxIterations; i++) {
60
- const minute = date.getMinutes()
61
- const hour = date.getHours()
62
- const dom = date.getDate()
63
- const month = date.getMonth() + 1 // JS months are 0-indexed
64
- const dow = date.getDay() // 0 = Sunday
65
-
66
- if (
67
- matches(minute, minField) &&
68
- matches(hour, hourField) &&
69
- matches(dom, domField) &&
70
- matches(month, monthField) &&
71
- matches(dow, dowField)
72
- ) {
73
- return date.toISOString()
74
- }
75
-
76
- date.setMinutes(date.getMinutes() + 1)
77
- }
78
-
79
- // Fallback: 1 minute from now
80
- return new Date(from.getTime() + 60_000).toISOString()
81
- }
9
+ import { computeNextFire } from '../core/cron'
82
10
 
83
11
  export class ScheduleManager {
84
12
  private db: DaemonDatabase
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.60.1' as const
12
+ export const PACKAGE_VERSION = '0.62.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -10,6 +10,7 @@ import { join } from 'node:path'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { homedir } from 'node:os'
12
12
  import type { ToolDefinition } from '../../shared/index.ts'
13
+ import { computeNextFire } from '../../core/cron'
13
14
 
14
15
  const CRON_DIR = join(homedir(), '.mipham', 'cron')
15
16
 
@@ -17,12 +18,14 @@ function ensureCronDir(): void {
17
18
  if (!existsSync(CRON_DIR)) mkdirSync(CRON_DIR, { recursive: true })
18
19
  }
19
20
 
20
- interface CronJob {
21
+ export interface CronJob {
21
22
  id: string
22
23
  cron: string
23
24
  prompt: string
24
25
  recurring: boolean
25
26
  createdAt: string
27
+ nextFire: string
28
+ lastFired: string | null
26
29
  }
27
30
 
28
31
  function jobPath(id: string): string {
@@ -33,13 +36,21 @@ function generateId(cron: string, prompt: string): string {
33
36
  return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
34
37
  }
35
38
 
36
- function readAllJobs(): CronJob[] {
39
+ /**
40
+ * Read all durable cron jobs. Backfills `nextFire`/`lastFired` for files
41
+ * written before the executor landed (they only had id/cron/prompt/recurring).
42
+ */
43
+ export function readAllJobs(): CronJob[] {
37
44
  ensureCronDir()
38
45
  const jobs: CronJob[] = []
39
46
  for (const file of readdirSync(CRON_DIR)) {
40
47
  if (!file.endsWith('.json')) continue
41
48
  try {
42
- jobs.push(JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')))
49
+ const parsed = JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')) as Partial<CronJob>
50
+ if (!parsed.nextFire)
51
+ parsed.nextFire = computeNextFire(parsed.cron ?? '* * * * *', new Date())
52
+ if (parsed.lastFired === undefined) parsed.lastFired = null
53
+ jobs.push(parsed as CronJob)
43
54
  } catch {
44
55
  /* skip corrupt files */
45
56
  }
@@ -47,6 +58,20 @@ function readAllJobs(): CronJob[] {
47
58
  return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
48
59
  }
49
60
 
61
+ /** Persist a job (create or update) to its JSON file. */
62
+ export function writeJob(job: CronJob): void {
63
+ ensureCronDir()
64
+ writeFileSync(jobPath(job.id), JSON.stringify(job, null, 2), 'utf-8')
65
+ }
66
+
67
+ /** Delete a job's file. Returns false when the job did not exist. */
68
+ export function deleteJobFile(id: string): boolean {
69
+ const path = jobPath(id)
70
+ if (!existsSync(path)) return false
71
+ unlinkSync(path)
72
+ return true
73
+ }
74
+
50
75
  export const cronCreateTool: ToolDefinition = {
51
76
  name: 'CronCreate',
52
77
  description:
@@ -82,17 +107,18 @@ export const cronCreateTool: ToolDefinition = {
82
107
  const recurring = params.recurring !== false
83
108
  const id = generateId(cron, prompt)
84
109
 
85
- ensureCronDir()
86
-
110
+ const now = new Date()
87
111
  const job: CronJob = {
88
112
  id,
89
113
  cron,
90
114
  prompt: prompt.slice(0, 1000),
91
115
  recurring,
92
- createdAt: new Date().toISOString(),
116
+ createdAt: now.toISOString(),
117
+ nextFire: computeNextFire(cron, now),
118
+ lastFired: null,
93
119
  }
94
120
 
95
- writeFileSync(jobPath(id), JSON.stringify(job, null, 2), 'utf-8')
121
+ writeJob(job)
96
122
 
97
123
  const type = recurring ? 'recurring' : 'one-shot'
98
124
  return {
@@ -124,11 +150,9 @@ export const cronDeleteTool: ToolDefinition = {
124
150
  },
125
151
  async execute(params, _ctx) {
126
152
  const id = params.id as string
127
- const path = jobPath(id)
128
- if (!existsSync(path)) {
153
+ if (!deleteJobFile(id)) {
129
154
  return { success: false, content: '', error: `Cron job "${id}" not found.` }
130
155
  }
131
- unlinkSync(path)
132
156
  return { success: true, content: `Cron job "${id}" deleted.` }
133
157
  },
134
158
  }
package/src/ui/app.tsx CHANGED
@@ -52,6 +52,7 @@ import type { PermissionMode } from '../shared/index.ts'
52
52
  import { sanitizeForDisplay } from '../shared/sanitize.ts'
53
53
  import { recordLoopTurn, readAutoloopJournal } from '../commands/autoloop-journal.js'
54
54
  import { cancelAllSessionTimers } from '../tools/scheduling/schedule-wakeup.js'
55
+ import { startCronPoller } from '../core/cron-poller'
55
56
 
56
57
  interface AppProps {
57
58
  engine: QueryEngine | RemoteEngine
@@ -686,9 +687,16 @@ export function App({
686
687
  // User turn wins: if a user submitted meanwhile, turnId advanced and this loop wakeup
687
688
  // yields (the next timer fire re-enqueues). Spec §六 — do not force it to run anyway.
688
689
  if (turnIdRef.current !== turnId) return
689
- // RemoteEngine (remote attach mode) has no wakeup queue — /loop is CLI-local.
690
+ // RemoteEngine (remote attach mode) has no wakeup queue — /loop + cron are CLI-local.
690
691
  if (!('hasPendingWakeup' in engine)) return
691
- const next = engine.hasPendingWakeup() ? engine.dequeueWakeup() : null
692
+ // Cron first (absolute-time scheduled, FIFO — every due job fires), then /loop.
693
+ const cronPrompt = engine.hasPendingCron() ? engine.dequeueCronPrompt() : null
694
+ const next =
695
+ cronPrompt !== null
696
+ ? { prompt: cronPrompt, noop: false }
697
+ : engine.hasPendingWakeup()
698
+ ? engine.dequeueWakeup()
699
+ : null
692
700
  if (next) await runTurn(next.prompt, 'loop', undefined, next.noop)
693
701
  }
694
702
 
@@ -713,6 +721,17 @@ export function App({
713
721
  drainLoopQueueRef.current?.(turnIdRef.current)
714
722
  }, [wakeupTick, isLoading])
715
723
 
724
+ // ── cron poller ──
725
+ // Start the durable-cron poller so CronCreate jobs actually fire into this session.
726
+ // It enqueues due prompts into the engine's cron queue; the drain above re-invokes
727
+ // them. Cron is CLI-local (RemoteEngine has no cron queue), like /loop.
728
+ useEffect(() => {
729
+ if (!('hasPendingWakeup' in engine)) return
730
+ const qe = engine // narrow to QueryEngine for the cleanup closure
731
+ const stop = startCronPoller((prompt) => qe.enqueueCronPrompt(prompt))
732
+ return stop
733
+ }, [engine])
734
+
716
735
  const handleSubmit = useCallback(
717
736
  async (input: string) => {
718
737
  if (!input.trim()) return