opencode-overclock 0.3.0 → 0.4.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 (50) hide show
  1. package/README.md +206 -111
  2. package/package.json +2 -2
  3. package/src/bridge.ts +1 -0
  4. package/src/buddy/companion.ts +104 -5
  5. package/src/buddy/sprites.ts +4 -4
  6. package/src/buddy/tui.ts +175 -65
  7. package/src/core/bridge.ts +34 -0
  8. package/src/core/lifecycle.ts +53 -0
  9. package/src/core/policy.ts +128 -0
  10. package/src/core/summary.ts +33 -0
  11. package/src/core/types.ts +164 -0
  12. package/src/features/buddy.ts +1 -2
  13. package/src/features/guard.ts +168 -30
  14. package/src/features/index.ts +6 -4
  15. package/src/features/recovery.ts +143 -0
  16. package/src/features/sched.ts +147 -89
  17. package/src/features/tasks.ts +52 -18
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +96 -67
  21. package/src/lib/busy.ts +1 -25
  22. package/src/lib/exec.ts +7 -0
  23. package/src/lib/inject.ts +10 -56
  24. package/src/lib/mirror.ts +13 -0
  25. package/src/lib/probe.ts +1 -15
  26. package/src/lib/state.ts +10 -39
  27. package/src/lib/tmux.ts +1 -0
  28. package/src/lib/ui.ts +208 -0
  29. package/src/merge.ts +2 -66
  30. package/src/platform/probe.ts +25 -0
  31. package/src/platform/process/exec.ts +76 -0
  32. package/src/platform/process/tmux.ts +60 -0
  33. package/src/platform/session/busy.ts +33 -0
  34. package/src/platform/session/inject.ts +82 -0
  35. package/src/platform/session/notify.ts +20 -0
  36. package/src/platform/storage/state.ts +77 -0
  37. package/src/platform/storage/store.ts +61 -0
  38. package/src/summary.ts +1 -0
  39. package/src/tools.ts +8 -244
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -73
  42. package/src/v2/context.ts +470 -0
  43. package/src/v2/host.ts +117 -0
  44. package/src/v2/loader.ts +150 -0
  45. package/src/buddy/reactions.ts +0 -41
  46. package/src/buddy/types.ts +0 -30
  47. package/src/config.ts +0 -19
  48. package/src/features/checkpoints.ts +0 -128
  49. package/src/features/sandbox.ts +0 -104
  50. package/src/validate.ts +0 -197
@@ -1,7 +1,9 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import { Cron } from "croner"
3
- import type { FeatureModule } from "../types.ts"
3
+ import type { FeatureModule, SchedOptions } from "../types.ts"
4
+ import type { BusyTracker } from "../core/types.ts"
4
5
  import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
6
+ import { scheduleStore, type ScheduleEntry } from "../lib/mirror.ts"
5
7
  import { inject, toast } from "../lib/inject.ts"
6
8
 
7
9
  const z = tool.schema
@@ -18,94 +20,157 @@ export function parseSpec(spec: string): Spec {
18
20
  return { kind: "cron", expr: spec }
19
21
  }
20
22
 
21
- interface Schedule {
22
- id: string
23
- spec: string
24
- prompt: string
25
- target: "current" | "new-session"
26
- sessionID: string // creator; inject target when target=current
27
- createdAt: string
23
+ export type Schedule = ScheduleEntry
24
+
25
+ export interface ScheduleManager {
26
+ readonly schedules: Map<string, ScheduleEntry>
27
+ create(input: {
28
+ spec: string
29
+ prompt: string
30
+ target: "current" | "new-session"
31
+ sessionID: string
32
+ }): Promise<{ schedule: ScheduleEntry; next: string }>
33
+ list(): Array<{ schedule: ScheduleEntry; next: string }>
34
+ delete(id: string): Promise<boolean>
35
+ arm(s: ScheduleEntry): void
36
+ disarm(id: string): void
37
+ nextRun(s: ScheduleEntry): string
38
+ dispose(): void
39
+ }
40
+
41
+ export interface ScheduleManagerDeps {
42
+ storePath: string
43
+ client: any
44
+ busy?: BusyTracker
45
+ skipIfBusy?: boolean
28
46
  }
29
47
 
30
48
  /**
31
- * Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
32
- * Persisted, rearmed on startup.
49
+ * Encapsulated schedule manager handling timer registration, persistence,
50
+ * and dispatching prompt injections.
33
51
  */
34
- export const sched: FeatureModule = {
35
- name: "sched",
36
- tools: ["schedule_create", "schedule_list", "schedule_delete"],
37
- options: { skipIfBusy: "boolean" },
38
- defaultEnabled: true,
39
- requires: ["session.promptAsync", "session.messages", "session.create"],
40
- async init(ctx, options, shared) {
41
- const dir = await ensureStateDir(ctx.directory)
42
- const storePath = `${dir}/schedules.json`
43
- const schedules = new Map<string, Schedule>()
44
- const timers = new Map<string, Cron | ReturnType<typeof setInterval>>()
45
- const skipIfBusy = options.skipIfBusy !== false
46
-
47
- const persist = () => writeJson(storePath, [...schedules.values()])
48
-
49
- async function fire(s: Schedule) {
50
- try {
51
- if (s.target === "current") {
52
- // target still chewing on previous turn -> skip this fire, no pileup
53
- if (skipIfBusy && shared.busy.isBusy(s.sessionID)) {
54
- await toast(ctx.client, `schedule ${s.id} skipped (session busy)`, "info")
55
- return
56
- }
57
- const ok = await inject(ctx.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
58
- if (!ok) await toast(ctx.client, `schedule ${s.id}: target session gone`, "warning")
59
- } else {
60
- const res = await ctx.client.session.create({ body: { title: `sched:${s.id}` } })
61
- const id = res.data?.id
62
- if (!id) throw new Error("session.create returned no id")
63
- await inject(ctx.client, id, s.prompt)
64
- await toast(ctx.client, `schedule ${s.id} fired -> new session`, "info")
52
+ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<ScheduleManager> {
53
+ const schedules = new Map<string, ScheduleEntry>()
54
+ const timers = new Map<string, { stop(): void }>()
55
+ const skipIfBusy = deps.skipIfBusy !== false
56
+
57
+ const persist = async () => writeJson(deps.storePath, [...schedules.values()])
58
+
59
+ async function fire(s: ScheduleEntry) {
60
+ try {
61
+ if (s.target === "current") {
62
+ if (skipIfBusy && deps.busy?.isBusy(s.sessionID)) {
63
+ await toast(deps.client, `schedule ${s.id} skipped (session busy)`, "info")
64
+ return
65
65
  }
66
- } catch (e) {
67
- console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
68
- await toast(ctx.client, `schedule ${s.id} failed: ${e}`, "error")
66
+ const ok = await inject(deps.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
67
+ if (!ok) await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
68
+ } else {
69
+ const res = await deps.client.session.create({ body: { title: `sched:${s.id}` } })
70
+ const id = res.data?.id
71
+ if (!id) throw new Error("session.create returned no id")
72
+ await inject(deps.client, id, s.prompt)
73
+ await toast(deps.client, `schedule ${s.id} fired -> new session`, "info")
69
74
  }
75
+ } catch (e) {
76
+ console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
77
+ await toast(deps.client, `schedule ${s.id} failed: ${e}`, "error")
70
78
  }
79
+ }
71
80
 
72
- function arm(s: Schedule) {
73
- const spec = parseSpec(s.spec)
74
- timers.set(
75
- s.id,
76
- spec.kind === "interval"
77
- ? setInterval(() => fire(s), spec.ms)
78
- : new Cron(spec.expr, () => fire(s)),
79
- )
80
- }
81
+ function arm(s: ScheduleEntry) {
82
+ const spec = parseSpec(s.spec)
83
+ const timer =
84
+ spec.kind === "interval"
85
+ ? {
86
+ stop: clearInterval.bind(
87
+ null,
88
+ setInterval(() => fire(s), spec.ms),
89
+ ),
90
+ }
91
+ : new Cron(spec.expr, () => fire(s))
92
+ timers.set(s.id, timer)
93
+ }
81
94
 
82
- function disarm(id: string) {
83
- const t = timers.get(id)
84
- if (!t) return
85
- t instanceof Cron ? t.stop() : clearInterval(t)
86
- timers.delete(id)
87
- }
95
+ function disarm(id: string) {
96
+ timers.get(id)?.stop()
97
+ timers.delete(id)
98
+ }
99
+
100
+ function nextRun(s: ScheduleEntry): string {
101
+ const spec = parseSpec(s.spec)
102
+ if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
103
+ return `every ${s.spec}`
104
+ }
88
105
 
89
- const nextRun = (s: Schedule): string => {
90
- const spec = parseSpec(s.spec)
91
- if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
92
- return `every ${s.spec}`
106
+ const stored = await readJson<ScheduleEntry[]>(deps.storePath, [])
107
+ for (const s of stored) {
108
+ schedules.set(s.id, s)
109
+ try {
110
+ arm(s)
111
+ } catch (e) {
112
+ console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
93
113
  }
114
+ }
94
115
 
95
- // rearm persisted schedules (restart-safe)
96
- for (const s of await readJson<Schedule[]>(storePath, [])) {
97
- schedules.set(s.id, s)
98
- try {
99
- arm(s)
100
- } catch (e) {
101
- console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
116
+ return {
117
+ schedules,
118
+ arm,
119
+ disarm,
120
+ nextRun,
121
+ async create(input) {
122
+ parseSpec(input.spec)
123
+ const s: ScheduleEntry = {
124
+ id: `s-${crypto.randomUUID().slice(0, 6)}`,
125
+ spec: input.spec,
126
+ prompt: input.prompt,
127
+ target: input.target,
128
+ sessionID: input.sessionID,
129
+ createdAt: new Date().toISOString(),
102
130
  }
103
- }
131
+ schedules.set(s.id, s)
132
+ arm(s)
133
+ await persist()
134
+ return { schedule: s, next: nextRun(s) }
135
+ },
136
+ list() {
137
+ return [...schedules.values()].map((s) => ({ schedule: s, next: nextRun(s) }))
138
+ },
139
+ async delete(id: string) {
140
+ if (!schedules.delete(id)) return false
141
+ disarm(id)
142
+ await persist()
143
+ return true
144
+ },
145
+ dispose() {
146
+ for (const id of [...timers.keys()]) disarm(id)
147
+ },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
153
+ * Persisted, rearmed on startup.
154
+ */
155
+ export const sched: FeatureModule = {
156
+ name: "sched",
157
+ tools: ["schedule_create", "schedule_list", "schedule_delete"],
158
+ defaultEnabled: true,
159
+ requires: ["session.promptAsync", "session.messages", "session.create"],
160
+ async init(ctx, rawOptions, shared) {
161
+ const options = (rawOptions ?? {}) as SchedOptions
162
+ await ensureStateDir(ctx.directory)
163
+ const storePath = scheduleStore.path(ctx.directory)
164
+
165
+ const manager = await createScheduleManager({
166
+ storePath,
167
+ client: ctx.client,
168
+ busy: shared.busy,
169
+ skipIfBusy: options.skipIfBusy !== false,
170
+ })
104
171
 
105
172
  return {
106
- dispose: async () => {
107
- for (const id of [...timers.keys()]) disarm(id)
108
- },
173
+ dispose: async () => manager.dispose(),
109
174
  tool: {
110
175
  schedule_create: tool({
111
176
  description:
@@ -116,31 +181,25 @@ export const sched: FeatureModule = {
116
181
  target: z.enum(["current", "new-session"]).default("current"),
117
182
  },
118
183
  async execute(args, tctx) {
119
- parseSpec(args.spec) // validate before storing
120
- const s: Schedule = {
121
- id: `s-${crypto.randomUUID().slice(0, 6)}`,
184
+ const { schedule, next } = await manager.create({
122
185
  spec: args.spec,
123
186
  prompt: args.prompt,
124
187
  target: args.target,
125
188
  sessionID: tctx.sessionID,
126
- createdAt: new Date().toISOString(),
127
- }
128
- schedules.set(s.id, s)
129
- arm(s)
130
- await persist()
131
- return `created ${s.id}: ${args.spec} -> ${args.target} (next: ${nextRun(s)})`
189
+ })
190
+ return `created ${schedule.id}: ${args.spec} -> ${args.target} (next: ${next})`
132
191
  },
133
192
  }),
134
193
  schedule_list: tool({
135
194
  description: "List schedules.",
136
195
  args: {},
137
196
  async execute() {
138
- const all = [...schedules.values()]
197
+ const all = manager.list()
139
198
  if (!all.length) return "no schedules"
140
199
  return all
141
200
  .map(
142
- (s) =>
143
- `${s.id} [${s.spec}] -> ${s.target} (next: ${nextRun(s)}) :: ${s.prompt.slice(0, 60)}`,
201
+ ({ schedule: s, next }) =>
202
+ `${s.id} [${s.spec}] -> ${s.target} (next: ${next}) :: ${s.prompt.slice(0, 60)}`,
144
203
  )
145
204
  .join("\n")
146
205
  },
@@ -149,9 +208,8 @@ export const sched: FeatureModule = {
149
208
  description: "Delete a schedule by id.",
150
209
  args: { id: z.string() },
151
210
  async execute(args) {
152
- if (!schedules.delete(args.id)) return `no schedule ${args.id}`
153
- disarm(args.id)
154
- await persist()
211
+ const ok = await manager.delete(args.id)
212
+ if (!ok) return `no schedule ${args.id}`
155
213
  return `deleted ${args.id}`
156
214
  },
157
215
  }),
@@ -2,7 +2,10 @@ import { stat } from "node:fs/promises"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import type { FeatureModule } from "../types.ts"
4
4
  import { ensureStateDir, shellQuote, writeJson } from "../lib/state.ts"
5
+ import { taskStore, type TaskMirrorEntry } from "../lib/mirror.ts"
5
6
  import { inject, toast } from "../lib/inject.ts"
7
+ import { NON_INTERACTIVE_ENV } from "../lib/exec.ts"
8
+ import { spawnTaskPane, type TmuxPane } from "../lib/tmux.ts"
6
9
 
7
10
  const z = tool.schema
8
11
 
@@ -16,6 +19,22 @@ const PROMPT_PATTERNS = [
16
19
  /Overwrite\?/i,
17
20
  ]
18
21
 
22
+ const INTERACTIVE_COMMAND_PATTERNS = [
23
+ /^\s*(?:vi|vim|nvim|nano|pico|emacs)\b/i,
24
+ /^\s*git\s+(?:rebase\s+-i|commit\s+--amend(?!\s+-m))/i,
25
+ /^\s*git\s+add\s+-p\b/i,
26
+ /^\s*(?:python|python3|node|irb|ghci|bash|sh|zsh)\s*$/i,
27
+ ]
28
+
29
+ /** Detects if a command is explicitly interactive (e.g. editor, rebase -i, bare REPL). */
30
+ export function detectInteractiveCommand(command: string): string | null {
31
+ for (const pattern of INTERACTIVE_COMMAND_PATTERNS) {
32
+ const match = command.match(pattern)
33
+ if (match) return match[0].trim()
34
+ }
35
+ return null
36
+ }
37
+
19
38
  /** Last non-empty line of `tail` looks like an interactive y/n or press-key prompt. */
20
39
  export function looksLikePrompt(tail: string): boolean {
21
40
  const lastLine = tail.trimEnd().split("\n").pop() ?? ""
@@ -39,14 +58,7 @@ interface TaskEntry extends TaskRecord {
39
58
  stallTimer?: ReturnType<typeof setInterval>
40
59
  stallNotified: boolean
41
60
  startedAt: number
42
- }
43
-
44
- interface TaskMirrorEntry {
45
- id: string
46
- description: string
47
- status: TaskRecord["status"]
48
- exitCode: number | null
49
- startedAt: number
61
+ tmuxPane?: TmuxPane
50
62
  }
51
63
 
52
64
  export interface TaskManager {
@@ -95,7 +107,8 @@ function startStallWatchdog(
95
107
  lastGrowth = Date.now()
96
108
  return
97
109
  }
98
- if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified) return
110
+ if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified || entry.status !== "running")
111
+ return
99
112
  const file = Bun.file(entry.logPath)
100
113
  const start = Math.max(0, s.size - tailBytes)
101
114
  const tail = await file.slice(start).text()
@@ -125,6 +138,8 @@ export function createTaskManager(opts: {
125
138
  stallCheckIntervalMs?: number
126
139
  stallThresholdMs?: number
127
140
  stallTailBytes?: number
141
+ /** spawn a tmux split pane to tail task logs (only if TMUX is active) */
142
+ tmux?: boolean
128
143
  }): TaskManager {
129
144
  const tasks = new Map<string, TaskEntry>()
130
145
  let counter = 0
@@ -153,6 +168,10 @@ export function createTaskManager(opts: {
153
168
  // shell-level redirection: no piping code, survives plugin restart losing streams
154
169
  const proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
155
170
  cwd: input.cwd,
171
+ env: {
172
+ ...process.env,
173
+ ...NON_INTERACTIVE_ENV,
174
+ },
156
175
  })
157
176
  const entry: TaskEntry = {
158
177
  id,
@@ -169,6 +188,13 @@ export function createTaskManager(opts: {
169
188
  }
170
189
  tasks.set(id, entry)
171
190
  persistMirror()
191
+ let panePromise: Promise<TmuxPane | null> | undefined
192
+ if (opts.tmux) {
193
+ panePromise = spawnTaskPane(entry.logPath, entry.description)
194
+ panePromise.then((pane) => {
195
+ if (pane) entry.tmuxPane = pane
196
+ })
197
+ }
172
198
  if (input.timeoutMs) {
173
199
  entry.timeoutTimer = setTimeout(() => kill(id), input.timeoutMs)
174
200
  }
@@ -181,9 +207,15 @@ export function createTaskManager(opts: {
181
207
  opts.onStall,
182
208
  )
183
209
  }
184
- proc.exited.then((code) => {
210
+ proc.exited.then(async (code) => {
185
211
  if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
186
212
  if (entry.stallTimer) clearInterval(entry.stallTimer)
213
+ if (panePromise) {
214
+ const pane = await panePromise
215
+ pane?.close()
216
+ } else if (entry.tmuxPane) {
217
+ void entry.tmuxPane.close()
218
+ }
187
219
  if (entry.status === "running") entry.status = "exited"
188
220
  entry.exitCode = code
189
221
  persistMirror()
@@ -199,6 +231,7 @@ export function createTaskManager(opts: {
199
231
  persistMirror()
200
232
  if (entry.stallTimer) clearInterval(entry.stallTimer)
201
233
  entry.stallTimer = undefined
234
+ if (entry.tmuxPane) void entry.tmuxPane.close()
202
235
  entry.proc.kill("SIGTERM")
203
236
  const hard = setTimeout(() => entry.proc.kill("SIGKILL"), 3000)
204
237
  entry.proc.exited.then(() => clearTimeout(hard))
@@ -237,17 +270,10 @@ const fmt = (t: TaskRecord) =>
237
270
  export const tasks: FeatureModule = {
238
271
  name: "tasks",
239
272
  tools: ["task_run", "task_status", "task_output", "task_kill"],
240
- options: {
241
- killOnExit: "boolean",
242
- stallDetection: "boolean",
243
- stallThresholdMs: "number",
244
- stallCheckIntervalMs: "number",
245
- },
246
273
  defaultEnabled: true,
247
274
  requires: ["session.promptAsync", "session.messages"],
248
275
  async init(ctx, options, shared) {
249
276
  const logDir = await ensureStateDir(ctx.directory, "tasks")
250
- const stateDir = await ensureStateDir(ctx.directory)
251
277
  const killOnExit = options.killOnExit !== false
252
278
  const stallDetection = options.stallDetection !== false
253
279
  const stallThresholdMs =
@@ -257,7 +283,8 @@ export const tasks: FeatureModule = {
257
283
 
258
284
  const manager = createTaskManager({
259
285
  logDir,
260
- mirrorPath: `${stateDir}/tasks.json`,
286
+ mirrorPath: taskStore.path(ctx.directory),
287
+ tmux: options.tmux === true,
261
288
  onExit: async (task) => {
262
289
  if (task.status === "killed") return
263
290
  const tail = await manager.output(task.id, 20)
@@ -293,6 +320,9 @@ export const tasks: FeatureModule = {
293
320
  })
294
321
 
295
322
  return {
323
+ "shell.env": async (_input, output) => {
324
+ Object.assign(output.env, NON_INTERACTIVE_ENV)
325
+ },
296
326
  dispose: async () => {
297
327
  if (killOnExit) manager.killAll()
298
328
  },
@@ -307,6 +337,10 @@ export const tasks: FeatureModule = {
307
337
  timeout: z.number().optional().describe("seconds until auto-kill"),
308
338
  },
309
339
  async execute(args, tctx) {
340
+ const blocked = detectInteractiveCommand(args.command)
341
+ if (blocked) {
342
+ return `Error: Command '${args.command}' appears to require interactive input (${blocked}). Background tasks run non-interactively and will hang on prompts.`
343
+ }
310
344
  const task = manager.run({
311
345
  command: args.command,
312
346
  description: args.description,
@@ -0,0 +1,99 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+
3
+ export const DEFAULT_TRUNCATABLE_TOOLS = ["task_output", "bash", "grep", "glob", "webfetch"]
4
+
5
+ export const DEFAULT_MAX_CHARS = 40_000
6
+ export const DEFAULT_HEAD_LINES = 10
7
+ export const DEFAULT_TAIL_LINES = 30
8
+
9
+ export interface TruncateResult {
10
+ text: string
11
+ truncated: boolean
12
+ omittedLines: number
13
+ omittedChars: number
14
+ }
15
+
16
+ /**
17
+ * Smartly truncate output preserving top context and bottom tail.
18
+ */
19
+ export function truncateOutput(
20
+ content: string,
21
+ maxChars = DEFAULT_MAX_CHARS,
22
+ headLinesCount = DEFAULT_HEAD_LINES,
23
+ tailLinesCount = DEFAULT_TAIL_LINES,
24
+ ): TruncateResult {
25
+ if (content.length <= maxChars) {
26
+ return { text: content, truncated: false, omittedLines: 0, omittedChars: 0 }
27
+ }
28
+
29
+ const lines = content.split("\n")
30
+ if (lines.length <= headLinesCount + tailLinesCount) {
31
+ // If few lines but very long strings, hard slice
32
+ const head = content.slice(0, Math.floor(maxChars * 0.3))
33
+ const tail = content.slice(-Math.floor(maxChars * 0.7))
34
+ const omittedChars = content.length - head.length - tail.length
35
+ return {
36
+ text: `${head}\n\n[... truncated ${omittedChars} characters to stay within context limits ...]\n\n${tail}`,
37
+ truncated: true,
38
+ omittedLines: 0,
39
+ omittedChars,
40
+ }
41
+ }
42
+
43
+ const head = lines.slice(0, headLinesCount).join("\n")
44
+ const tail = lines.slice(-tailLinesCount).join("\n")
45
+ const omittedLines = lines.length - headLinesCount - tailLinesCount
46
+ const omittedChars = content.length - head.length - tail.length
47
+
48
+ const text = `${head}\n\n[... truncated ${omittedLines} lines (${omittedChars} chars) to stay within context limits ...]\n\n${tail}`
49
+ return {
50
+ text,
51
+ truncated: true,
52
+ omittedLines,
53
+ omittedChars,
54
+ }
55
+ }
56
+
57
+ export interface TruncatorOptions {
58
+ maxChars?: number
59
+ tools?: string[]
60
+ headLines?: number
61
+ tailLines?: number
62
+ }
63
+
64
+ /**
65
+ * Truncator feature module: guards against sudden context window exhaustion
66
+ * by trimming high-volume tool outputs while keeping diagnostic head and tail lines.
67
+ */
68
+ export const truncator: FeatureModule = {
69
+ name: "truncator",
70
+ tools: [],
71
+ defaultEnabled: true,
72
+ async init(_ctx, options, shared) {
73
+ const maxChars = typeof options.maxChars === "number" ? options.maxChars : DEFAULT_MAX_CHARS
74
+ const rawTools = Array.isArray(options.tools)
75
+ ? (options.tools as string[])
76
+ : DEFAULT_TRUNCATABLE_TOOLS
77
+ const targetTools = new Set(
78
+ rawTools.flatMap((t) => {
79
+ const lower = t.toLowerCase()
80
+ const remapped = shared?.toolName ? shared.toolName(t).toLowerCase() : lower
81
+ return [lower, remapped]
82
+ }),
83
+ )
84
+ const headLines = typeof options.headLines === "number" ? options.headLines : DEFAULT_HEAD_LINES
85
+ const tailLines = typeof options.tailLines === "number" ? options.tailLines : DEFAULT_TAIL_LINES
86
+
87
+ return {
88
+ "tool.execute.after": async (input, output) => {
89
+ if (!targetTools.has(input.tool.toLowerCase())) return
90
+ if (typeof output.output !== "string") return
91
+
92
+ const res = truncateOutput(output.output, maxChars, headLines, tailLines)
93
+ if (res.truncated) {
94
+ output.output = res.text
95
+ }
96
+ },
97
+ }
98
+ },
99
+ }