@miphamai/cli 0.38.0 → 0.39.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.
Files changed (60) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/sub-agent.ts +4 -3
  4. package/src/config/defaults.ts +19 -1
  5. package/src/config/keys-manager.ts +11 -1
  6. package/src/config/loader.ts +20 -10
  7. package/src/core/context.ts +86 -56
  8. package/src/core/credential-masker/output-scrub.ts +5 -0
  9. package/src/core/crsi-sandbox.ts +19 -7
  10. package/src/core/engine.ts +50 -33
  11. package/src/core/instructions.ts +4 -42
  12. package/src/core/permission.ts +7 -1
  13. package/src/core/red-team.ts +4 -4
  14. package/src/core/self-critique.ts +3 -1
  15. package/src/core/session-log.ts +225 -0
  16. package/src/core/session-store.ts +119 -34
  17. package/src/daemon/auth.ts +9 -6
  18. package/src/daemon/cors.ts +24 -16
  19. package/src/daemon/rate-limiter.ts +4 -0
  20. package/src/daemon/server.ts +24 -3
  21. package/src/index.tsx +54 -28
  22. package/src/mcp/http-transport.ts +2 -0
  23. package/src/mcp/oauth.ts +1 -1
  24. package/src/providers/llm-replay.ts +36 -0
  25. package/src/providers/llm.ts +16 -0
  26. package/src/providers/registry.ts +2 -1
  27. package/src/security/gate.ts +10 -0
  28. package/src/security/url.ts +37 -14
  29. package/src/shared/constants.ts +4 -486
  30. package/src/shared/types.ts +6 -437
  31. package/src/skills/fork-executor.ts +3 -1
  32. package/src/skills/loader.ts +2 -1
  33. package/src/skills/seam.ts +18 -0
  34. package/src/tools/agent/agent.ts +1 -0
  35. package/src/tools/agent/skill.ts +1 -0
  36. package/src/tools/exec/bash.ts +140 -128
  37. package/src/tools/exec/git.ts +17 -0
  38. package/src/tools/file/read.ts +73 -66
  39. package/src/tools/file/write.ts +1 -1
  40. package/src/tools/index.ts +54 -132
  41. package/src/tools/network/web-fetch.ts +41 -26
  42. package/src/tools/seam.ts +25 -0
  43. package/src/tools/validation.ts +91 -0
  44. package/src/ui/commands.ts +17 -5
  45. package/src/vajra/compose/assemble.ts +19 -0
  46. package/src/vajra/compose/bundle.ts +39 -0
  47. package/src/vajra/compose/dump.ts +5 -0
  48. package/src/vajra/compose/index.ts +4 -0
  49. package/src/vajra/compose/mount.ts +30 -0
  50. package/src/vajra/context.ts +183 -0
  51. package/src/vajra/events.ts +13 -0
  52. package/src/vajra/index.ts +4 -0
  53. package/src/vajra/leaf/plan-runner.ts +94 -0
  54. package/src/vajra/service.ts +15 -0
  55. package/src/workflow/primitives/agent.ts +3 -1
  56. package/src/workflow/runtime.ts +23 -15
  57. package/src/workflow/sandbox.ts +51 -9
  58. package/src/core/compaction-progress.ts +0 -205
  59. package/src/core/context-compact.ts +0 -124
  60. package/src/core/context-drain.ts +0 -113
@@ -0,0 +1,183 @@
1
+ import type { EventsOfMode } from './events'
2
+ import type { Service, Mounted, ServiceStatus } from './service'
3
+
4
+ export type Disposer = () => void
5
+
6
+ type Listener = (...args: any[]) => any
7
+
8
+ export class Context {
9
+ private services = new Map<string, unknown>()
10
+ private effects: Disposer[] = []
11
+ private listeners = new Map<string, Listener[]>()
12
+ private waiters: Array<() => boolean> = []
13
+ private scopes = new Map<unknown, Context>()
14
+ readonly parent?: Context
15
+
16
+ constructor(parent?: Context) {
17
+ this.parent = parent
18
+ }
19
+
20
+ provide<T>(key: string, value: T): Disposer {
21
+ this.services.set(key, value)
22
+ this.flushWaiters()
23
+ return () => {
24
+ this.services.delete(key)
25
+ }
26
+ }
27
+
28
+ private flushWaiters(): void {
29
+ this.waiters = this.waiters.filter((w) => !w())
30
+ }
31
+
32
+ mount(service: Service): Mounted {
33
+ const keys = service.inject ?? []
34
+ let status: ServiceStatus = 'inactive'
35
+ let error: Error | undefined
36
+ let disposer: Disposer = () => {}
37
+ let applied = false
38
+ let disposed = false
39
+ let effectSnapshot = -1
40
+
41
+ const tryApply = (): boolean => {
42
+ if (applied) return true
43
+ if (!keys.every((k) => this.has(k))) return false
44
+ applied = true
45
+ status = 'loading'
46
+ effectSnapshot = this.effects.length
47
+ try {
48
+ disposer = service.apply(this) ?? (() => {})
49
+ status = 'active'
50
+ } catch (e) {
51
+ error = e as Error
52
+ status = 'failed'
53
+ }
54
+ return true
55
+ }
56
+
57
+ const waiter = (): boolean => {
58
+ if (disposed) {
59
+ this.waiters = this.waiters.filter((w) => w !== waiter)
60
+ return true
61
+ }
62
+ if (!tryApply()) return false
63
+ this.waiters = this.waiters.filter((w) => w !== waiter)
64
+ return true
65
+ }
66
+
67
+ if (!tryApply()) {
68
+ this.waiters.push(waiter)
69
+ }
70
+
71
+ const mounted: Mounted = {
72
+ dispose: () => {
73
+ disposed = true
74
+ this.waiters = this.waiters.filter((w) => w !== waiter)
75
+ if (status === 'active') {
76
+ status = 'unloading'
77
+ disposer()
78
+ }
79
+ if (effectSnapshot >= 0) {
80
+ while (this.effects.length > effectSnapshot) {
81
+ this.effects.pop()!()
82
+ }
83
+ }
84
+ },
85
+ status: () => status,
86
+ }
87
+ Object.defineProperty(mounted, 'error', { get: () => error })
88
+ return mounted
89
+ }
90
+
91
+ get<T>(key: string): T | undefined {
92
+ return (this.services.get(key) as T | undefined) ?? this.parent?.get<T>(key)
93
+ }
94
+
95
+ has(key: string): boolean {
96
+ return this.services.has(key) || (this.parent?.has(key) ?? false)
97
+ }
98
+
99
+ keys(): string[] {
100
+ return [...this.services.keys()]
101
+ }
102
+
103
+ /** 枚举本层 + 父层所有键(local 遮蔽 parent,去重)。 */
104
+ keysRecursive(): string[] {
105
+ const seen = new Set<string>(this.keys())
106
+ for (const k of this.parent?.keysRecursive() ?? []) seen.add(k)
107
+ return [...seen]
108
+ }
109
+
110
+ scope(key: unknown): Context {
111
+ let child = this.scopes.get(key)
112
+ if (!child) {
113
+ child = new Context(this)
114
+ this.scopes.set(key, child)
115
+ }
116
+ return child
117
+ }
118
+
119
+ effect(fn: () => Disposer | void): Disposer {
120
+ const d = fn() ?? (() => {})
121
+ let disposed = false
122
+ const dispose = () => {
123
+ if (disposed) return
124
+ disposed = true
125
+ d()
126
+ }
127
+ this.effects.push(dispose)
128
+ return dispose
129
+ }
130
+
131
+ on(event: string, fn: Listener): Disposer {
132
+ const ls = this.listeners.get(event) ?? []
133
+ ls.push(fn)
134
+ this.listeners.set(event, ls)
135
+ return () => {
136
+ const cur = this.listeners.get(event)
137
+ if (cur)
138
+ this.listeners.set(
139
+ event,
140
+ cur.filter((l) => l !== fn),
141
+ )
142
+ }
143
+ }
144
+
145
+ emit(event: EventsOfMode<'emit'>, ...args: unknown[]): void {
146
+ for (const fn of this.listeners.get(event) ?? []) fn(...args)
147
+ }
148
+
149
+ async waterfall<T>(event: EventsOfMode<'waterfall'>, value: T, ...args: unknown[]): Promise<T> {
150
+ const ls = this.listeners.get(event) ?? []
151
+ const run = async (i: number, v: T): Promise<T> => {
152
+ if (i >= ls.length) return v
153
+ const next = async (nextVal?: T) => run(i + 1, nextVal === undefined ? v : nextVal)
154
+ return (await ls[i]!(v, ...args, next)) as T
155
+ }
156
+ return run(0, value)
157
+ }
158
+
159
+ async parallel(event: EventsOfMode<'parallel'>, ...args: unknown[]): Promise<unknown[]> {
160
+ return Promise.all((this.listeners.get(event) ?? []).map((fn) => fn(...args)))
161
+ }
162
+
163
+ async serial(event: EventsOfMode<'serial'>, ...args: unknown[]): Promise<unknown[]> {
164
+ const out: unknown[] = []
165
+ for (const fn of this.listeners.get(event) ?? []) out.push(await fn(...args))
166
+ return out
167
+ }
168
+
169
+ dispose(): void {
170
+ const errors: unknown[] = []
171
+ while (this.effects.length) {
172
+ try {
173
+ this.effects.pop()!()
174
+ } catch (e) {
175
+ errors.push(e)
176
+ }
177
+ }
178
+ this.services.clear()
179
+ this.scopes.clear()
180
+ this.listeners.clear()
181
+ if (errors.length) throw new AggregateError(errors)
182
+ }
183
+ }
@@ -0,0 +1,13 @@
1
+ export type DispatchMode = 'emit' | 'waterfall' | 'parallel' | 'serial'
2
+
3
+ /**
4
+ * 事件契约映射。每个事件名声明其派发模式;里程碑/测试通过
5
+ * declaration merging 扩展本 interface(无需改内核)。
6
+ */
7
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
8
+ export interface EventMap {}
9
+
10
+ /** 提取声明为指定 mode 的事件名联合。 */
11
+ export type EventsOfMode<M extends DispatchMode> = {
12
+ [K in keyof EventMap]: EventMap[K] extends { mode: M } ? K : never
13
+ }[keyof EventMap]
@@ -0,0 +1,4 @@
1
+ export { Context } from './context'
2
+ export type { Disposer } from './context'
3
+ export type { DispatchMode, EventMap, EventsOfMode } from './events'
4
+ export type { Service, ServiceStatus, Mounted } from './service'
@@ -0,0 +1,94 @@
1
+ import type { Service } from '../index'
2
+ import type { Llm } from '../../providers/llm'
3
+ import type { BundleLine } from '../compose/bundle'
4
+
5
+ export type PlanTask = { id: string; description: string }
6
+ export type Plan = { name: string; tasks: PlanTask[] }
7
+ export type TaskOutcome = {
8
+ taskId: string
9
+ status: 'done' | 'needs-changes' | 'error'
10
+ result: string
11
+ review: string
12
+ }
13
+
14
+ export const PLAN_RUNNER_KEY = 'plan-runner'
15
+
16
+ export interface PlanRunner {
17
+ run(plan: Plan): Promise<TaskOutcome[]>
18
+ }
19
+
20
+ // 事件契约:declaration merging 扩展 EventMap(内核首例,不改内核)
21
+ declare module '../events' {
22
+ interface EventMap {
23
+ 'plan/task-start': { mode: 'emit'; in: { taskId: string } }
24
+ 'plan/task-done': {
25
+ mode: 'emit'
26
+ in: { taskId: string; status: 'done' | 'needs-changes' | 'error' }
27
+ }
28
+ }
29
+ }
30
+
31
+ async function chatText(llm: Llm, prompt: string, model: string): Promise<string> {
32
+ let text = ''
33
+ for await (const chunk of llm.chat({
34
+ model,
35
+ messages: [{ role: 'user', content: prompt }],
36
+ })) {
37
+ if (chunk.type === 'text' && chunk.content) text += chunk.content
38
+ }
39
+ return text
40
+ }
41
+
42
+ /** 创建 plan-runner Service。model 缺省为空串(「用 active model」,由 ProviderRegistry 的 req.model || activeModelId 回退);传值则锁定该模型。 */
43
+ export function createPlanRunnerService(options: { model?: string } = {}): Service {
44
+ const model = options.model ?? ''
45
+ return {
46
+ inject: ['llm'],
47
+ apply(ctx) {
48
+ const runner: PlanRunner = {
49
+ async run(plan) {
50
+ const outcomes: TaskOutcome[] = []
51
+ for (const task of plan.tasks) {
52
+ ctx.emit('plan/task-start', { taskId: task.id })
53
+ const taskCtx = ctx.scope(task.id) // 每任务独立作用域(继承父层 llm 缝)
54
+ let result = ''
55
+ let review = ''
56
+ try {
57
+ result = await chatText(
58
+ taskCtx.get<Llm>('llm')!,
59
+ `Implement: ${task.description}`,
60
+ model,
61
+ )
62
+ review = await chatText(
63
+ taskCtx.get<Llm>('llm')!,
64
+ `Review: does the result satisfy "${task.description}"? Result: ${result}`,
65
+ model,
66
+ )
67
+ } catch (e) {
68
+ outcomes.push({ taskId: task.id, status: 'error', result, review: String(e) })
69
+ ctx.emit('plan/task-done', { taskId: task.id, status: 'error' })
70
+ continue
71
+ }
72
+ const status: TaskOutcome['status'] = review.startsWith('APPROVE')
73
+ ? 'done'
74
+ : 'needs-changes'
75
+ outcomes.push({ taskId: task.id, status, result, review })
76
+ ctx.emit('plan/task-done', { taskId: task.id, status })
77
+ }
78
+ return outcomes
79
+ },
80
+ }
81
+ ctx.provide(PLAN_RUNNER_KEY, runner)
82
+ },
83
+ }
84
+ }
85
+
86
+ /** 默认 plan-runner Service(model 空串 = 用 active model)。 */
87
+ export const planRunnerService: Service = createPlanRunnerService()
88
+
89
+ /** 从 bundle 行解析 plan-runner Service(读 config.model 传工厂;id 非 plan-runner 或纯数据行返回 undefined)。 */
90
+ export function planRunnerFromLine(line: BundleLine): Service | undefined {
91
+ if (line.id !== 'plan-runner') return undefined
92
+ const model = typeof line.config.model === 'string' ? line.config.model : undefined
93
+ return createPlanRunnerService({ model })
94
+ }
@@ -0,0 +1,15 @@
1
+ import type { Context } from './context'
2
+ import type { Disposer } from './context'
3
+
4
+ export interface Service {
5
+ inject?: string[]
6
+ apply(ctx: Context): void | Disposer
7
+ }
8
+
9
+ export type ServiceStatus = 'inactive' | 'loading' | 'active' | 'unloading' | 'failed'
10
+
11
+ export interface Mounted {
12
+ dispose(): void
13
+ status(): ServiceStatus
14
+ readonly error?: Error
15
+ }
@@ -1,5 +1,6 @@
1
1
  import { SubAgent } from '../../agent/sub-agent'
2
2
  import type { ProviderRegistry } from '../../providers/registry'
3
+ import type { Llm } from '../../providers/llm'
3
4
  import type { ToolDefinition } from '../../shared/index.ts'
4
5
  import type { PermissionSystem } from '../../core/permission'
5
6
  import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
@@ -41,6 +42,7 @@ export async function workflowAgent(
41
42
  registry: ProviderRegistry,
42
43
  toolRegistry: Map<string, ToolDefinition>,
43
44
  opts: WorkflowAgentOpts = {},
45
+ llm?: Llm,
44
46
  ): Promise<unknown> {
45
47
  // If provider override, switch temporarily
46
48
  if (opts.provider) {
@@ -82,7 +84,7 @@ export async function workflowAgent(
82
84
  `Return ONLY the JSON object, no other text. Do not wrap in markdown code fences.`
83
85
  }
84
86
 
85
- const sub = new SubAgent(registry, toolRegistry, opts.permissionSystem)
87
+ const sub = new SubAgent(registry, toolRegistry, opts.permissionSystem, undefined, undefined, llm)
86
88
 
87
89
  // ── Execute with optional schema validation + retry ──
88
90
  let result: unknown = ''
@@ -1,5 +1,5 @@
1
1
  import vm from 'node:vm'
2
- import { createSandbox } from './sandbox'
2
+ import { createSandbox, sealValue } from './sandbox'
3
3
  import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
4
4
  import { createBudget } from './budget'
5
5
  import { workflowAgent } from './primitives/agent'
@@ -103,6 +103,7 @@ export async function runWorkflow(
103
103
  const registry: ProviderRegistry = engine.getRegistry()
104
104
  const toolRegistry = engine.getTools()
105
105
  const permission = engine.getPermission()
106
+ const llm = engine.getLlm()
106
107
 
107
108
  const budget = createBudget(budgetTotal)
108
109
 
@@ -124,10 +125,16 @@ export async function runWorkflow(
124
125
 
125
126
  bus.emitEvent({ type: 'agent:start', agentId, label: agentId, phase })
126
127
 
127
- const result = await workflowAgent(prompt, registry, toolRegistry, {
128
- ...(opts || {}),
129
- permissionSystem: permission,
130
- } as Record<string, unknown>)
128
+ const result = await workflowAgent(
129
+ prompt,
130
+ registry,
131
+ toolRegistry,
132
+ {
133
+ ...(opts || {}),
134
+ permissionSystem: permission,
135
+ } as Record<string, unknown>,
136
+ llm,
137
+ )
131
138
 
132
139
  const durationMs = Date.now() - startTime
133
140
  bus.emitEvent({ type: 'agent:end', agentId, label: agentId, success: true, durationMs })
@@ -159,19 +166,20 @@ export async function runWorkflow(
159
166
  })()
160
167
  `
161
168
 
162
- // Execute in node:vm sandbox — no access to host globals
169
+ // Execute in a node:vm context (deterministic replay; NOT a security boundary).
163
170
  const vmScript = new vm.Script(wrappedScript, { filename: 'workflow.js' })
164
171
  const sandboxCtx = createSandbox(args, budget)
165
172
 
166
- // Inject primitives into the sandbox context (whitelist approach)
167
- sandboxCtx.agent = agent
168
- sandboxCtx.parallel = parallel
169
- sandboxCtx.pipeline = pipeline
170
- sandboxCtx.verify = verify
171
- sandboxCtx.judge = judge
172
- sandboxCtx.loopUntilConvergence = loopUntilConvergence
173
- sandboxCtx.phase = wrappedPhase
174
- sandboxCtx.log = log
173
+ // Inject primitives into the context (sealed so their constructor chain
174
+ // can't reach host globals via `fn.constructor('return process')()`).
175
+ sandboxCtx.agent = sealValue(agent)
176
+ sandboxCtx.parallel = sealValue(parallel)
177
+ sandboxCtx.pipeline = sealValue(pipeline)
178
+ sandboxCtx.verify = sealValue(verify)
179
+ sandboxCtx.judge = sealValue(judge)
180
+ sandboxCtx.loopUntilConvergence = sealValue(loopUntilConvergence)
181
+ sandboxCtx.phase = sealValue(wrappedPhase)
182
+ sandboxCtx.log = sealValue(log)
175
183
 
176
184
  let result: unknown
177
185
  try {
@@ -4,22 +4,64 @@ import vm from 'node:vm'
4
4
  const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
5
5
 
6
6
  /**
7
- * Create a sandboxed VM context for workflow script execution.
8
- * Blocks Date.now(), Math.random(), argless new Date(), crypto.randomUUID(),
9
- * plus explicit sandbox escape vectors: eval, Function constructor, import(),
10
- * require(), process, Bun, fetch, setTimeout/setInterval, etc.
7
+ * Wrap a host function so `.constructor` / `.prototype` / `__proto__` access
8
+ * cannot reach the host `Function` constructor (blocks
9
+ * `fn.constructor('return process')()` and friends).
10
+ */
11
+ function sealFunction<T extends (...args: never[]) => unknown>(fn: T): T {
12
+ return new Proxy(fn, {
13
+ get(target, prop) {
14
+ if (prop === 'constructor' || prop === 'prototype' || prop === '__proto__') {
15
+ return undefined
16
+ }
17
+ return Reflect.get(target, prop)
18
+ },
19
+ apply(target, thisArg, argArray) {
20
+ return Reflect.apply(target, thisArg, argArray)
21
+ },
22
+ }) as T
23
+ }
24
+
25
+ /**
26
+ * Best-effort seal a value: freeze objects and wrap function properties so
27
+ * their constructor chain can't reach host globals.
28
+ */
29
+ export function sealValue(value: unknown, depth = 0): unknown {
30
+ if (depth > 4) return value
31
+ if (typeof value === 'function') {
32
+ return sealFunction(value as (...args: never[]) => unknown)
33
+ }
34
+ if (value && typeof value === 'object') {
35
+ for (const key of Object.getOwnPropertyNames(value)) {
36
+ const child = (value as Record<string, unknown>)[key]
37
+ if (typeof child === 'function') {
38
+ ;(value as Record<string, unknown>)[key] = sealFunction(
39
+ child as (...args: never[]) => unknown,
40
+ )
41
+ }
42
+ }
43
+ return Object.freeze(value)
44
+ }
45
+ return value
46
+ }
47
+
48
+ /**
49
+ * Create a VM context for workflow script execution.
11
50
  *
12
- * P0-2 (v2.1.223 alignment): Added explicit defense-in-depth denial of all
13
- * host globals (process, require, Bun, fetch, setTimeout, etc.) and
14
- * constructor.constructor escape vector blocking.
51
+ * SECURITY: node:vm is NOT a security boundary. Workflow scripts are
52
+ * model-authored code running in the same process — the model already has
53
+ * Bash access, so this context does NOT isolate the script from the host.
54
+ * Its purpose is determinism (blocking Date.now/Math.random/randomUUID for
55
+ * reproducible replay) plus best-effort blocking of common escape vectors.
56
+ * Do not treat this as a sandbox that can safely run untrusted code.
15
57
  */
16
58
  export function createSandbox(
17
59
  args: unknown,
18
60
  budget: { total: number | null; spent(): number; remaining(): number },
19
61
  ): vm.Context {
20
62
  const sandboxObj: Record<string, unknown> = {
21
- args,
22
- budget,
63
+ args: sealValue(args),
64
+ budget: sealValue(budget),
23
65
  console: {
24
66
  log: (..._a: unknown[]) => {}, // no-op in sandbox
25
67
  error: (..._a: unknown[]) => {},
@@ -1,205 +0,0 @@
1
- /**
2
- * P1.1: Compaction Progress Tracker
3
- *
4
- * Provides user-visible progress feedback during context compaction:
5
- * - Phase indicator (which compaction layer is active)
6
- * - Message count (how many messages being processed)
7
- * - Retry countdown for API-based summarization
8
- * - Stuck detection hints
9
- *
10
- * Designed as an event emitter that the UI layer subscribes to.
11
- * Falls back to stderr output when no UI subscriber is registered.
12
- */
13
-
14
- // ── Types ──
15
-
16
- export type CompactionPhase =
17
- 'microcompact' | 'snip' | 'reactive-compact' | 'emergency-drain' | 'complete'
18
-
19
- export interface CompactionStatus {
20
- phase: CompactionPhase
21
- /** Human-readable description of current action */
22
- message: string
23
- /** 0-100 percentage, or -1 if indeterminate */
24
- percent: number
25
- /** Total messages before compaction */
26
- messagesBefore: number
27
- /** Messages remaining after compaction */
28
- messagesAfter: number
29
- /** Tokens before compaction */
30
- tokensBefore: number
31
- /** Tokens after compaction */
32
- tokensAfter: number
33
- /** Elapsed ms since compaction started */
34
- elapsedMs: number
35
- /** Retry count for summarization API calls */
36
- retryCount: number
37
- /** Whether a retry is in progress */
38
- retrying: boolean
39
- /** Whether the compaction appears stuck (no progress for >30s) */
40
- stuck: boolean
41
- }
42
-
43
- export type ProgressCallback = (status: CompactionStatus) => void
44
-
45
- // ── Constants ──
46
-
47
- /** Timeout in ms after which we consider compaction "stuck" */
48
- const STUCK_TIMEOUT_MS = 30_000
49
-
50
- /** Max retries for summarizer API calls */
51
- const MAX_SUMMARIZER_RETRIES = 3
52
-
53
- /** Retry delay in ms between summarizer attempts */
54
- const RETRY_DELAY_MS = 2_000
55
-
56
- // ── Tracker ──
57
-
58
- export class CompactionProgressTracker {
59
- private callback: ProgressCallback | null = null
60
- private status: CompactionStatus
61
- private startTime = 0
62
- private lastProgressTime = 0
63
-
64
- constructor(messagesBefore: number, tokensBefore: number) {
65
- this.status = {
66
- phase: 'microcompact',
67
- message: 'Starting compaction...',
68
- percent: 0,
69
- messagesBefore,
70
- messagesAfter: messagesBefore,
71
- tokensBefore,
72
- tokensAfter: tokensBefore,
73
- elapsedMs: 0,
74
- retryCount: 0,
75
- retrying: false,
76
- stuck: false,
77
- }
78
- this.startTime = Date.now()
79
- this.lastProgressTime = Date.now()
80
- }
81
-
82
- /** Register a UI callback for progress updates. */
83
- onProgress(cb: ProgressCallback): void {
84
- this.callback = cb
85
- }
86
-
87
- /** Update phase with new values. */
88
- update(partial: {
89
- phase?: CompactionPhase
90
- message?: string
91
- percent?: number
92
- messagesAfter?: number
93
- tokensAfter?: number
94
- retrying?: boolean
95
- }): void {
96
- if (partial.phase !== undefined) this.status.phase = partial.phase
97
- if (partial.message !== undefined) this.status.message = partial.message
98
- if (partial.percent !== undefined) this.status.percent = partial.percent
99
- if (partial.messagesAfter !== undefined) this.status.messagesAfter = partial.messagesAfter
100
- if (partial.tokensAfter !== undefined) this.status.tokensAfter = partial.tokensAfter
101
- if (partial.retrying !== undefined) this.status.retrying = partial.retrying
102
-
103
- this.status.elapsedMs = Date.now() - this.startTime
104
-
105
- // Check for stuck detection
106
- if (this.status.phase !== 'complete') {
107
- const timeSinceProgress = Date.now() - this.lastProgressTime
108
- this.status.stuck = timeSinceProgress > STUCK_TIMEOUT_MS
109
- }
110
-
111
- this.emit()
112
- }
113
-
114
- /** Mark current retry attempt. */
115
- onRetry(attempt: number): void {
116
- this.status.retryCount = attempt
117
- this.status.retrying = true
118
- this.emit()
119
- }
120
-
121
- /** Mark retry as complete (success or giving up). */
122
- onRetryComplete(): void {
123
- this.status.retrying = false
124
- this.lastProgressTime = Date.now()
125
- this.status.stuck = false
126
- this.emit()
127
- }
128
-
129
- /** Mark compaction as complete. */
130
- complete(messagesAfter: number, tokensAfter: number): void {
131
- this.status.phase = 'complete'
132
- this.status.percent = 100
133
- this.status.messagesAfter = messagesAfter
134
- this.status.tokensAfter = tokensAfter
135
- this.status.message = `Compaction complete: ${this.status.messagesBefore} → ${messagesAfter} messages`
136
- this.status.elapsedMs = Date.now() - this.startTime
137
- this.emit()
138
- }
139
-
140
- /** Get current status. */
141
- getStatus(): CompactionStatus {
142
- return { ...this.status }
143
- }
144
-
145
- /** Generate a compact summary string for CLI display. */
146
- getSummary(): string {
147
- const s = this.status
148
- const elapsed = (s.elapsedMs / 1000).toFixed(1)
149
- const stuckHint = s.stuck ? ' (stalled — press Esc to cancel)' : ''
150
- const retryHint = s.retrying ? ` [retry ${s.retryCount}/${MAX_SUMMARIZER_RETRIES}...]` : ''
151
- const pct = s.percent >= 0 ? ` ${s.percent}%` : ''
152
- return `🔄 [${s.phase}]${pct} ${s.message}${retryHint}${stuckHint} (${elapsed}s)`
153
- }
154
-
155
- private emit(): void {
156
- if (this.callback) {
157
- try {
158
- this.callback(this.getStatus())
159
- } catch {
160
- // UI callback failure shouldn't block compaction
161
- }
162
- }
163
- }
164
-
165
- // ── Static helpers ──
166
-
167
- /** Retry a summarizer call with backoff and progress tracking. */
168
- static async retrySummarizer(
169
- summarizer: () => Promise<string>,
170
- tracker: CompactionProgressTracker,
171
- messagesToSummarize: number,
172
- ): Promise<string> {
173
- let lastError: Error | undefined
174
-
175
- for (let attempt = 1; attempt <= MAX_SUMMARIZER_RETRIES; attempt++) {
176
- try {
177
- tracker.update({
178
- message: `Summarizing ${messagesToSummarize} messages${attempt > 1 ? ` (attempt ${attempt}/${MAX_SUMMARIZER_RETRIES})` : ''}...`,
179
- percent: 30 + attempt * 20,
180
- retrying: attempt > 1,
181
- })
182
-
183
- const result = await summarizer()
184
- tracker.onRetryComplete()
185
- return result
186
- } catch (err) {
187
- lastError = err instanceof Error ? err : new Error(String(err))
188
-
189
- if (attempt < MAX_SUMMARIZER_RETRIES) {
190
- tracker.onRetry(attempt)
191
- // Wait before retry
192
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
193
- tracker.onRetryComplete()
194
- }
195
- }
196
- }
197
-
198
- tracker.update({
199
- message: `Summarization failed after ${MAX_SUMMARIZER_RETRIES} attempts — falling back to truncation`,
200
- percent: 60,
201
- })
202
-
203
- throw lastError ?? new Error('Summarization failed')
204
- }
205
- }