@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 +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/crsi-modify.ts +27 -1
- package/src/core/crsi-producer.ts +16 -2
- package/src/core/crsi-sandbox.ts +18 -0
- package/src/core/engine.ts +27 -1
- package/src/core/eval-harness.ts +11 -1
- package/src/core/instructions.ts +64 -28
- 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/crsi-modify.ts
CHANGED
|
@@ -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
|
-
): {
|
|
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
|
-
): {
|
|
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
|
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -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 {
|
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/eval-harness.ts
CHANGED
|
@@ -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({
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
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 (
|
|
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
|