@miphamai/cli 0.61.0 → 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 +1 -1
- package/src/agent/effectiveness-tracker.ts +14 -1
- package/src/agent/recoverable-failure.ts +21 -0
- package/src/core/cron-poller.ts +52 -0
- package/src/core/cron.ts +73 -0
- package/src/core/engine.ts +27 -1
- package/src/core/instructions.ts +1 -1
- package/src/core/memory/memory-manager.ts +11 -9
- package/src/core/memory/tfidf.ts +108 -0
- package/src/daemon/schedule-manager.ts +1 -73
- package/src/shared/package-info.ts +1 -1
- package/src/tools/scheduling/cron.ts +34 -10
- package/src/ui/app.tsx +21 -2
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/core/cron.ts
ADDED
|
@@ -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
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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()
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
116
|
+
createdAt: now.toISOString(),
|
|
117
|
+
nextFire: computeNextFire(cron, now),
|
|
118
|
+
lastFired: null,
|
|
93
119
|
}
|
|
94
120
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
690
|
+
// RemoteEngine (remote attach mode) has no wakeup queue — /loop + cron are CLI-local.
|
|
690
691
|
if (!('hasPendingWakeup' in engine)) return
|
|
691
|
-
|
|
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
|