@miphamai/cli 0.61.0 → 0.63.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.61.0",
3
+ "version": "0.63.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,7 @@ export class EffectivenessTracker {
44
47
  if (eff) eff.decisionId = decisionId
45
48
  }
46
49
 
47
- recordApplication(ruleId: string, success: boolean): void {
50
+ recordApplication(ruleId: string, success: boolean, opts?: { error?: string }): void {
48
51
  let eff = this.data.get(ruleId)
49
52
  if (!eff) {
50
53
  eff = {
@@ -61,6 +64,12 @@ export class EffectivenessTracker {
61
64
  this.data.set(ruleId, eff)
62
65
  }
63
66
 
67
+ // 可恢复/环境性失败:仍记录,但不进成功率分母(防误降级/误禁用能用的规则)。
68
+ if (!success && opts?.error && isRecoverableToolFailure(opts.error)) {
69
+ eff.recoverableCount = (eff.recoverableCount ?? 0) + 1
70
+ return
71
+ }
72
+
64
73
  eff.appliedCount++
65
74
  if (success) eff.successAfterCount++
66
75
 
@@ -24,6 +24,16 @@ export interface AgentMessage {
24
24
  type: AgentMessageType
25
25
  }
26
26
 
27
+ /**
28
+ * Format an inbound message for injection into the conversation as a
29
+ * user-role notice. Collapsed to one line (`Message from @<sender>: <summary>`)
30
+ * so cross-session peer messages don't bloat the context; the full body stays
31
+ * in the bus for Ctrl+O expansion.
32
+ */
33
+ export function formatInboundMessage(msg: AgentMessage): string {
34
+ return `Message from @${msg.from}: ${msg.summary}`
35
+ }
36
+
27
37
  export class AgentMessageBus {
28
38
  private messages: AgentMessage[] = []
29
39
  private idCounter = 0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 判「可恢复/环境性」失败 vs 真缺陷(borrow openprabs is_recoverable_tool_failure 思想)。
3
+ *
4
+ * 可恢复失败(网络超时、连接拒绝、DNS 解析失败、环境权限)不该进成功率分母,否则会把
5
+ * 成功率拉低、诱导 EffectivenessTracker 禁用能用的规则(openprabs #236 的坑:stale-hash
6
+ * 重试被误判成工具缺陷,把能用的 hashline_edit 禁了)。
7
+ *
8
+ * 刻意排除 ENOENT(no such file or directory):缺文件/路径错更可能是规则本身指错了
9
+ * 路径(幻觉),而非环境瞬态,须计入分母(2026-08-27 review M1)。
10
+ *
11
+ * 不区分工具:这些错误模式(超时/连接/DNS/权限)本身即工具无关的环境信号。旧的
12
+ * `toolName !== 'Bash'` 守卫漏掉 Write/Grep 规则(2026-08-27 review M2),已移除。
13
+ *
14
+ * fail-closed:无 error 一律按真失败算(证不了可恢复就不排除)。
15
+ */
16
+ const RECOVERABLE_PATTERNS: RegExp[] = [
17
+ /\btimed?\s?out\b/i, // timeout / timed out / timedout(网络慢 / 服务慢)
18
+ /(ECONNREFUSED|ECONNRESET|ETIMEDOUT|connection refused|not connected|network unreachable)/i, // 服务未起 / 网络超时
19
+ /(could not resolve host|name or service not known|getaddrinfo|ENOTFOUND)/i, // DNS 解析失败
20
+ /(permission denied|EACCES)/i, // 环境权限
21
+ ]
22
+
23
+ export function isRecoverableToolFailure(error?: string): boolean {
24
+ if (!error) return false
25
+ const e = error.toLowerCase()
26
+ return RECOVERABLE_PATTERNS.some((re) => re.test(e))
27
+ }
@@ -319,6 +319,7 @@ export class SubAgent {
319
319
 
320
320
  const chunks: string[] = []
321
321
  const MAX_TOOL_TURNS = options.maxTurns || 5
322
+ let hitMaxTurns = false
322
323
 
323
324
  let currentMessages = messages
324
325
  let currentSystemPrompt = systemPrompt
@@ -360,7 +361,7 @@ export class SubAgent {
360
361
  })
361
362
  }
362
363
  if (chunk.type === 'error') {
363
- throw new Error(`Sub-agent execution failed: ${chunk.error}`)
364
+ throw new Error(`Sub-agent execution failed (model ${finalModel}): ${chunk.error}`)
364
365
  }
365
366
  if (chunk.type === 'stop') {
366
367
  break
@@ -501,6 +502,9 @@ export class SubAgent {
501
502
 
502
503
  // Don't send system prompt on subsequent turns
503
504
  currentSystemPrompt = ''
505
+
506
+ // On the final permitted turn we still used tools → hit the cap.
507
+ if (turn === MAX_TOOL_TURNS - 1) hitMaxTurns = true
504
508
  }
505
509
  } catch (err) {
506
510
  if (err instanceof DOMException && err.name === 'AbortError') {
@@ -509,10 +513,18 @@ export class SubAgent {
509
513
  if (err instanceof Error && err.message.startsWith('Sub-agent')) {
510
514
  throw err
511
515
  }
512
- throw new Error(`Sub-agent execution failed: ${String(err)}`)
516
+ throw new Error(`Sub-agent execution failed (model ${finalModel}): ${String(err)}`)
513
517
  }
514
518
 
515
- return chunks.join('')
519
+ const result = chunks.join('')
520
+ if (hitMaxTurns) {
521
+ return (
522
+ `[partial result — sub-agent reached its ${MAX_TOOL_TURNS}-turn limit; ` +
523
+ 'task may be incomplete. Use SendMessage to continue this sub-agent.]\n\n' +
524
+ result
525
+ )
526
+ }
527
+ return result
516
528
  }
517
529
 
518
530
  /**
@@ -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
+ }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
- import { join, resolve, sep } from 'node:path'
19
+ import { join, resolve, sep, posix } from 'node:path'
20
20
  import { tmpdir, homedir } from 'node:os'
21
21
  import { randomUUID } from 'node:crypto'
22
22
 
@@ -115,6 +115,12 @@ const PROTECTED_PATHS = [
115
115
  'apps/cli/src/core/crsi-sandbox.ts',
116
116
  'apps/cli/src/core/crsi-producer.ts',
117
117
  'apps/cli/src/core/proposal-guard.ts',
118
+ // 2026-08-27 review 补齐:运行时执行/评估器,改掉会削弱 grader 或安全边界
119
+ 'apps/cli/src/core/crsi-modify.ts',
120
+ 'apps/cli/src/core/rule-engine.ts',
121
+ 'apps/cli/src/core/red-team.ts',
122
+ 'apps/cli/src/core/error-signature-db.ts',
123
+ 'apps/cli/src/core/preflight-checker.ts',
118
124
  ]
119
125
 
120
126
  /** 是否命中只读边界。前缀匹配,目录条目以 `/` 结尾。 */
@@ -130,13 +136,24 @@ export function isProtectedPath(filePath: string): boolean {
130
136
  *
131
137
  * 返回错误字符串(拒绝理由),合法时返回 null。
132
138
  */
133
- export function validateBlastRadius(proposal: { blastRadius?: string[] }): string | null {
139
+ export function validateBlastRadius(proposal: {
140
+ filePath?: string
141
+ blastRadius?: string[]
142
+ }): string | null {
143
+ const filePath = proposal.filePath
134
144
  if (!proposal.blastRadius || proposal.blastRadius.length === 0) {
135
145
  return (
136
146
  'blast radius 未声明:自修改必须枚举触及的全部代码路径。' +
137
147
  '教训:两条渲染路径只接一条 = 局部正确全局遗漏。'
138
148
  )
139
149
  }
150
+ // 2026-08-27 review:blastRadius 不能只自证「非空」,必须覆盖被修改文件本身
151
+ // (前缀匹配,目录条目以 / 结尾),否则声明形同虚设。
152
+ if (filePath && !proposal.blastRadius.some((p) => filePath === p || filePath.startsWith(p))) {
153
+ return (
154
+ `blast radius 未覆盖目标文件 "${filePath}":` + '声明必须包含被修改文件本身(前缀匹配)。'
155
+ )
156
+ }
140
157
  return null
141
158
  }
142
159
 
@@ -225,7 +242,10 @@ export class CrsiSandbox {
225
242
  }
226
243
 
227
244
  const worktreeRoot = resolve(this.worktreePath)
228
- const targetPath = resolve(this.worktreePath, mod.filePath)
245
+ // Normalize "./" and "subdir/../" so the protected-path guard can't be evaded
246
+ // by a path that resolves back inside the worktree (raw prefix match would miss it).
247
+ const normalizedFilePath = posix.normalize(mod.filePath)
248
+ const targetPath = resolve(this.worktreePath, normalizedFilePath)
229
249
 
230
250
  // Path traversal guard: reject any target that resolves outside the sandbox worktree
231
251
  if (targetPath !== worktreeRoot && !targetPath.startsWith(worktreeRoot + sep)) {
@@ -237,7 +257,7 @@ export class CrsiSandbox {
237
257
 
238
258
  // Protected-path guard: the self-improvement loop must not modify the
239
259
  // constitution, eval harness, or improvement machinery itself.
240
- if (isProtectedPath(mod.filePath)) {
260
+ if (isProtectedPath(normalizedFilePath)) {
241
261
  result.error = `Protected path: "${mod.filePath}" is read-only to the self-improvement loop.`
242
262
  result.phase = 'failed'
243
263
  this.sessionReport.modifications.push(result)
@@ -275,7 +295,7 @@ export class CrsiSandbox {
275
295
 
276
296
  // Generate diff
277
297
  try {
278
- result.diff = execSync(`git diff -- "${mod.filePath}"`, {
298
+ result.diff = execSync(`git diff -- "${normalizedFilePath}"`, {
279
299
  cwd: this.worktreePath,
280
300
  timeout: 10_000,
281
301
  encoding: 'utf-8',
@@ -40,7 +40,12 @@ import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './infe
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
41
  import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
42
42
  import { accumulateGraftSavings } from '../shared/graft-savings'
43
- import { getMessageBus, type AgentMessage } from '../agent/message-bus'
43
+ import {
44
+ getMessageBus,
45
+ formatInboundMessage,
46
+ type AgentMessage,
47
+ type AgentMessageBus,
48
+ } from '../agent/message-bus'
44
49
  import { createT } from '../i18n-core/t'
45
50
  import enUS from '../i18n-core/locales/en-US.json'
46
51
  import zhCN from '../i18n-core/locales/zh-CN.json'
@@ -105,6 +110,9 @@ export class QueryEngine {
105
110
  /** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
106
111
  private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
107
112
 
113
+ /** Pending durable cron prompts, enqueued by the cron poller (FIFO — every due job fires). */
114
+ private cronQueue: string[] = []
115
+
108
116
  /** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
109
117
  private onWakeupEnqueued: (() => void) | null = null
110
118
 
@@ -148,6 +156,26 @@ export class QueryEngine {
148
156
  return this.wakeupQueue.length > 0
149
157
  }
150
158
 
159
+ /** Enqueue a durable cron prompt for re-invocation (FIFO — every due job fires). */
160
+ enqueueCronPrompt(prompt: string): void {
161
+ // Dedup by prompt: a recurring job whose previous fire is still queued
162
+ // (slow turn) re-fires the same prompt — skip it rather than pile up.
163
+ if (!this.cronQueue.includes(prompt)) {
164
+ this.cronQueue.push(prompt)
165
+ }
166
+ this.onWakeupEnqueued?.()
167
+ }
168
+
169
+ /** Pop the next pending cron prompt, or null when the queue is empty. */
170
+ dequeueCronPrompt(): string | null {
171
+ return this.cronQueue.shift() ?? null
172
+ }
173
+
174
+ /** Whether a cron prompt is pending re-invocation. */
175
+ hasPendingCron(): boolean {
176
+ return this.cronQueue.length > 0
177
+ }
178
+
151
179
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
152
180
  private sessionId: string = 'session-1'
153
181
 
@@ -232,6 +260,28 @@ export class QueryEngine {
232
260
  }
233
261
  }
234
262
 
263
+ /**
264
+ * Drain unread inbound messages from the in-memory bus (addressed to this
265
+ * session or to "main") and inject them into the conversation as user-role
266
+ * notices. Returns the number of messages injected.
267
+ *
268
+ * The bus is the delivery channel for same-process SendMessage ("main",
269
+ * "bg-*", "sub-agent-*") and the re-post target of pollCrossSessionInbox;
270
+ * this is the missing "drain" step that surfaces those messages to the model.
271
+ */
272
+ drainInboundMessages(bus: AgentMessageBus = getMessageBus()): number {
273
+ const recipients = [...new Set([this.sessionId, 'main'])]
274
+ let injected = 0
275
+ for (const recipient of recipients) {
276
+ for (const msg of bus.poll(recipient)) {
277
+ this.context.addMessage({ role: 'user', content: formatInboundMessage(msg) })
278
+ injected++
279
+ }
280
+ bus.markAllRead(recipient)
281
+ }
282
+ return injected
283
+ }
284
+
235
285
  /** Register a hook engine for pre/post tool-use lifecycle events. */
236
286
  setHookEngine(hooks: HookEngine): void {
237
287
  this.hookEngine = hooks
@@ -451,6 +501,8 @@ export class QueryEngine {
451
501
 
452
502
  // Poll for cross-session messages before each turn
453
503
  await this.pollCrossSessionInbox()
504
+ // Drain in-memory bus messages (same-process SendMessage + cross-session re-posts)
505
+ this.drainInboundMessages()
454
506
 
455
507
  // Fire UserPromptSubmit hooks before processing
456
508
  if (this.hookEngine) {
@@ -1180,7 +1232,9 @@ export class QueryEngine {
1180
1232
  const match = warning.match(/^\[rule:([^\]]+)\]/)
1181
1233
  const ruleId = match?.[1]
1182
1234
  if (ruleId) {
1183
- tracker.recordApplication(ruleId, result.success)
1235
+ tracker.recordApplication(ruleId, result.success, {
1236
+ error: result.error,
1237
+ })
1184
1238
  }
1185
1239
  }
1186
1240
  tracker.persist()
@@ -49,7 +49,8 @@ function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
49
49
  }
50
50
 
51
51
  // Non-zero exit: check for block signal (exit code 2)
52
- const stderr = result.stderr?.toString() || ''
52
+ // 截断 stderr 防 MB 级 hook 输出溢出会话(对齐 HTTP hook 的 slice(0,2000))。
53
+ const stderr = (result.stderr?.toString() || '').slice(0, 2000)
53
54
 
54
55
  if (result.status === 2) {
55
56
  return {
@@ -308,7 +308,7 @@ Never omit it or present the work as purely human-authored.`)
308
308
  const description = modeDescriptions[mode]
309
309
  if (!description) return ''
310
310
 
311
- 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.`
312
312
  }
313
313
 
314
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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Generate the session name (ID) used for cross-session tracking, inbox routing,
3
+ * and session persistence.
4
+ *
5
+ * Includes milliseconds so two sessions launched in the same second do not
6
+ * collide on the shared inbox directory / active-session registry. (2026-08-27
7
+ * review: the old `slice(0, 19)` dropped ms and let same-second launches share
8
+ * one inbox, leaking / misdelivering cross-session messages.)
9
+ */
10
+ export function generateSessionName(resume: string | undefined, now: Date = new Date()): string {
11
+ return resume || `session-${now.toISOString().replace(/[:.]/g, '-')}`
12
+ }
@@ -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
package/src/index.tsx CHANGED
@@ -25,6 +25,7 @@ import { loadSessionMemories, getMemoryManager } from './core/memory/memory-load
25
25
  import { ContextManager } from './core/context'
26
26
  import { PrefixCacheTracker } from './core/context-token'
27
27
  import { QueryEngine } from './core/engine'
28
+ import { generateSessionName } from './core/session-name'
28
29
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
30
  import { SessionLog } from './core/session-log'
30
31
  import { SessionStore } from './core/session-store'
@@ -40,6 +41,7 @@ import { mountConstitution, createConstitution } from './core/constitution-seam'
40
41
  import { ConstitutionLoader } from './core/constitution-loader'
41
42
  import { McpClient } from './mcp/client'
42
43
  import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
44
+ import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
43
45
  import { AgentRegistry } from './agent/agent-registry'
44
46
  import { HookEngine } from './core/hooks'
45
47
  import { ArtifactServer } from './artifacts/server'
@@ -202,8 +204,8 @@ function SetupGate(props: SetupGateProps) {
202
204
  async function connectMcpServers(
203
205
  mcpServers: McpServerConfig[],
204
206
  tools: ReturnType<typeof createToolRegistry>,
205
- ): Promise<void> {
206
- if (mcpServers.length === 0) return
207
+ ): Promise<McpConnectFailure[]> {
208
+ if (mcpServers.length === 0) return []
207
209
  const mcp = McpClient.getInstance()
208
210
  // Wire runtime tool-list changes before connect so mid-connect updates land.
209
211
  syncMcpToolsOnChange(mcp, tools)
@@ -216,14 +218,17 @@ async function connectMcpServers(
216
218
  }
217
219
  }),
218
220
  )
221
+ const failures: McpConnectFailure[] = []
219
222
  for (let i = 0; i < results.length; i++) {
220
223
  const result = results[i]!
221
224
  if (result.status === 'rejected') {
222
- process.stderr.write(
223
- `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
224
- )
225
+ const name = mcpServers[i]!.name
226
+ const reason = String(result.reason)
227
+ failures.push({ name, reason })
228
+ process.stderr.write(`[mcp] Failed to connect "${name}": ${reason}\n`)
225
229
  }
226
230
  }
231
+ return failures
227
232
  }
228
233
 
229
234
  export async function runApp(options: RunOptions): Promise<void> {
@@ -325,8 +330,7 @@ export async function runApp(options: RunOptions): Promise<void> {
325
330
  const pluginManager = new PluginManager()
326
331
 
327
332
  // Generate session name for tracking (used by /cd to persist cwd)
328
- const sessionName =
329
- options.resume || `session-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`
333
+ const sessionName = generateSessionName(options.resume)
330
334
 
331
335
  // Initialize context — restore saved session if available
332
336
  // Read the active model's context window for dynamic max-token sizing.
@@ -498,7 +502,7 @@ export async function runApp(options: RunOptions): Promise<void> {
498
502
  // Connect MCP servers lazily in the background: the UI renders immediately,
499
503
  // and each server's tools register into the shared registry as it connects.
500
504
  // A dead server (e.g. a 15s connect timeout) no longer blocks startup.
501
- void connectMcpServers(config.skills?.mcpServers ?? [], tools)
505
+ const mcpConnectPromise = connectMcpServers(config.skills?.mcpServers ?? [], tools)
502
506
 
503
507
  // Initialize hook engine — register skill-defined hooks
504
508
  const hookEngine = new HookEngine()
@@ -562,6 +566,14 @@ export async function runApp(options: RunOptions): Promise<void> {
562
566
 
563
567
  // ── Cross-session messaging: register session, heartbeat, shutdown ──
564
568
  engine.setSessionId(sessionName)
569
+
570
+ // Surface MCP connect failures to the model once they settle, so it knows
571
+ // those servers' tools are unavailable (rather than silently concluding the
572
+ // tools don't exist). Injected asynchronously — never blocks first paint.
573
+ void mcpConnectPromise.then((failures) => {
574
+ const notice = formatMcpConnectFailures(failures)
575
+ if (notice) engine.getContext().addMessage({ role: 'user', content: notice })
576
+ })
565
577
  // Human-readable, cross-session-addressable name. Defaults to the cwd basename
566
578
  // so `@mipham-code` can address this session; uniqueness is enforced against
567
579
  // other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
@@ -0,0 +1,15 @@
1
+ export interface McpConnectFailure {
2
+ name: string
3
+ reason: string
4
+ }
5
+
6
+ /**
7
+ * Format a startup notice for MCP servers that failed to connect, so the model
8
+ * knows their tools are unavailable (instead of concluding the tools don't
9
+ * exist). Empty string when nothing failed.
10
+ */
11
+ export function formatMcpConnectFailures(failures: McpConnectFailure[]): string {
12
+ if (failures.length === 0) return ''
13
+ const list = failures.map((f) => `- ${f.name}: ${f.reason}`).join('\n')
14
+ return `[MCP] These MCP servers failed to connect — their tools are unavailable:\n${list}`
15
+ }
@@ -26,7 +26,7 @@ export function readClaudeManifest(dir: string): ClaudeManifest | null {
26
26
  const path = join(dir, '.claude-plugin', 'plugin.json')
27
27
  if (!existsSync(path)) return null
28
28
  try {
29
- return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeManifest
29
+ return JSON.parse(readFileSync(path, 'utf-8').replace(/^\uFEFF/, '')) as ClaudeManifest
30
30
  } catch {
31
31
  return null
32
32
  }
@@ -52,7 +52,7 @@ export function validatePlugin(dir: string): PluginValidation {
52
52
 
53
53
  const errors: string[] = []
54
54
  try {
55
- const raw = readFileSync(resolved.path, 'utf-8')
55
+ const raw = readFileSync(resolved.path, 'utf-8').replace(/^\uFEFF/, '')
56
56
  const manifest = JSON.parse(raw) as PluginManifest
57
57
 
58
58
  if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.61.0' as const
12
+ export const PACKAGE_VERSION = '0.63.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