opencode-overclock 0.3.0 → 0.5.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/README.md +252 -111
- package/package.json +6 -4
- package/skills/codebase-design/DEEPENING.md +35 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
- package/skills/codebase-design/SKILL.md +93 -0
- package/skills/diagnosing-bugs/SKILL.md +123 -0
- package/skills/domain-modeling/ADR-FORMAT.md +55 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
- package/skills/domain-modeling/SKILL.md +102 -0
- package/skills/doubt/SKILL.md +80 -0
- package/skills/grilling/SKILL.md +96 -0
- package/skills/source-discipline/SKILL.md +78 -0
- package/skills/tdd/SKILL.md +87 -0
- package/skills/to-spec/SKILL.md +69 -0
- package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
- package/skills/to-tickets/SKILL.md +74 -0
- package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +67 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +193 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +421 -37
- package/src/features/index.ts +18 -4
- package/src/features/recovery.ts +153 -0
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +183 -89
- package/src/features/tasks.ts +134 -33
- package/src/features/truncator.ts +116 -0
- package/src/features/usage.ts +46 -65
- package/src/features/workflow.ts +256 -0
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +13 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -66
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +317 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +89 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +99 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -244
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -73
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +120 -0
- package/src/v2/loader.ts +150 -0
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doubt-reviewer.ts +26 -0
- package/src/workflow/agents/engineering-coach.ts +23 -0
- package/src/workflow/agents/performance-auditor.ts +29 -0
- package/src/workflow/agents/security-auditor.ts +23 -0
- package/src/workflow/agents/spec-reviewer.ts +15 -0
- package/src/workflow/agents/standards-reviewer.ts +24 -0
- package/src/workflow/agents/test-engineer.ts +28 -0
- package/src/workflow/catalog.ts +210 -0
- package/src/workflow/templates/build.ts +47 -0
- package/src/workflow/templates/define.ts +45 -0
- package/src/workflow/templates/diagnose.ts +58 -0
- package/src/workflow/templates/plan.ts +52 -0
- package/src/workflow/templates/ship.ts +64 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -197
package/src/features/tasks.ts
CHANGED
|
@@ -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 { killProcessTree, NON_INTERACTIVE_ENV, redactSensitiveOutput, sanitizeEnv } 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)
|
|
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()
|
|
@@ -114,20 +127,55 @@ function startStallWatchdog(
|
|
|
114
127
|
}, checkIntervalMs)
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
export async function readLogTail(logPath: string, tailLines = 50): Promise<string> {
|
|
131
|
+
const file = Bun.file(logPath)
|
|
132
|
+
if (!(await file.exists())) return "(no output)"
|
|
133
|
+
const s = await stat(logPath).catch(() => null)
|
|
134
|
+
if (!s || s.size === 0) return "(no output)"
|
|
135
|
+
|
|
136
|
+
const effectiveTail = Math.max(1, tailLines)
|
|
137
|
+
// Cap read window to last 512KB to prevent memory exhaustion on giant log files
|
|
138
|
+
const maxBytes = 512 * 1024
|
|
139
|
+
const start = Math.max(0, s.size - maxBytes)
|
|
140
|
+
const text = await (start > 0 ? file.slice(start).text() : file.text())
|
|
141
|
+
const lines = text.trimEnd().split("\n")
|
|
142
|
+
return lines.slice(-effectiveTail).join("\n")
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
/** Exported for tests. onExit fires after status/exitCode settled. */
|
|
118
146
|
export function createTaskManager(opts: {
|
|
119
147
|
logDir: string
|
|
120
148
|
/** mirror JSON path, written on every state change (spawn/exit/kill); omit to disable */
|
|
121
149
|
mirrorPath?: string
|
|
122
|
-
onExit?: (task: TaskRecord) => void
|
|
150
|
+
onExit?: (task: TaskRecord) => void | Promise<void>
|
|
123
151
|
/** enables the stall watchdog; absent -> no polling at all */
|
|
124
152
|
onStall?: (task: TaskRecord, tail: string) => void
|
|
125
153
|
stallCheckIntervalMs?: number
|
|
126
154
|
stallThresholdMs?: number
|
|
127
155
|
stallTailBytes?: number
|
|
156
|
+
/** spawn a tmux split pane to tail task logs (only if TMUX is active) */
|
|
157
|
+
tmux?: boolean
|
|
158
|
+
sanitizeEnv?: boolean
|
|
159
|
+
envAllowlist?: string[]
|
|
160
|
+
maxTasks?: number
|
|
128
161
|
}): TaskManager {
|
|
129
162
|
const tasks = new Map<string, TaskEntry>()
|
|
130
163
|
let counter = 0
|
|
164
|
+
const maxRetainedTasks = opts.maxTasks ?? 100
|
|
165
|
+
|
|
166
|
+
function pruneFinishedTasks(): void {
|
|
167
|
+
if (tasks.size <= maxRetainedTasks) return
|
|
168
|
+
const finished: string[] = []
|
|
169
|
+
for (const [id, entry] of tasks) {
|
|
170
|
+
if (entry.status !== "running") {
|
|
171
|
+
finished.push(id)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const toRemove = tasks.size - maxRetainedTasks
|
|
175
|
+
for (let i = 0; i < Math.min(toRemove, finished.length); i++) {
|
|
176
|
+
tasks.delete(finished[i]!)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
131
179
|
|
|
132
180
|
function persistMirror(): void {
|
|
133
181
|
if (!opts.mirrorPath) return
|
|
@@ -150,10 +198,38 @@ export function createTaskManager(opts: {
|
|
|
150
198
|
}): TaskRecord {
|
|
151
199
|
const id = `t${(++counter).toString(36)}-${crypto.randomUUID().slice(0, 6)}`
|
|
152
200
|
const logPath = `${opts.logDir}/${id}.log`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
201
|
+
|
|
202
|
+
let proc: Bun.Subprocess
|
|
203
|
+
try {
|
|
204
|
+
proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
|
|
205
|
+
cwd: input.cwd,
|
|
206
|
+
env: {
|
|
207
|
+
...(opts.sanitizeEnv === false ? process.env : sanitizeEnv(process.env, opts.envAllowlist)),
|
|
208
|
+
...NON_INTERACTIVE_ENV,
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
} catch (e) {
|
|
212
|
+
console.warn(`[overclock] failed to spawn task ${id}: ${e}`)
|
|
213
|
+
const failedEntry: TaskEntry = {
|
|
214
|
+
id,
|
|
215
|
+
description: input.description,
|
|
216
|
+
command: input.command,
|
|
217
|
+
cwd: input.cwd,
|
|
218
|
+
sessionID: input.sessionID,
|
|
219
|
+
status: "exited",
|
|
220
|
+
exitCode: 1,
|
|
221
|
+
logPath,
|
|
222
|
+
proc: null as any,
|
|
223
|
+
stallNotified: false,
|
|
224
|
+
startedAt: Date.now(),
|
|
225
|
+
}
|
|
226
|
+
tasks.set(id, failedEntry)
|
|
227
|
+
pruneFinishedTasks()
|
|
228
|
+
persistMirror()
|
|
229
|
+
opts.onExit?.(strip(failedEntry))
|
|
230
|
+
return strip(failedEntry)
|
|
231
|
+
}
|
|
232
|
+
|
|
157
233
|
const entry: TaskEntry = {
|
|
158
234
|
id,
|
|
159
235
|
description: input.description,
|
|
@@ -169,6 +245,13 @@ export function createTaskManager(opts: {
|
|
|
169
245
|
}
|
|
170
246
|
tasks.set(id, entry)
|
|
171
247
|
persistMirror()
|
|
248
|
+
let panePromise: Promise<TmuxPane | null> | undefined
|
|
249
|
+
if (opts.tmux) {
|
|
250
|
+
panePromise = spawnTaskPane(entry.logPath, entry.description)
|
|
251
|
+
panePromise.then((pane) => {
|
|
252
|
+
if (pane) entry.tmuxPane = pane
|
|
253
|
+
})
|
|
254
|
+
}
|
|
172
255
|
if (input.timeoutMs) {
|
|
173
256
|
entry.timeoutTimer = setTimeout(() => kill(id), input.timeoutMs)
|
|
174
257
|
}
|
|
@@ -181,13 +264,24 @@ export function createTaskManager(opts: {
|
|
|
181
264
|
opts.onStall,
|
|
182
265
|
)
|
|
183
266
|
}
|
|
184
|
-
proc.exited.then((code) => {
|
|
267
|
+
proc.exited.then(async (code) => {
|
|
185
268
|
if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
|
|
186
269
|
if (entry.stallTimer) clearInterval(entry.stallTimer)
|
|
270
|
+
if (panePromise) {
|
|
271
|
+
const pane = await panePromise
|
|
272
|
+
pane?.close()
|
|
273
|
+
} else if (entry.tmuxPane) {
|
|
274
|
+
void entry.tmuxPane.close()
|
|
275
|
+
}
|
|
187
276
|
if (entry.status === "running") entry.status = "exited"
|
|
188
277
|
entry.exitCode = code
|
|
189
278
|
persistMirror()
|
|
190
|
-
|
|
279
|
+
try {
|
|
280
|
+
await opts.onExit?.(strip(entry))
|
|
281
|
+
} finally {
|
|
282
|
+
pruneFinishedTasks()
|
|
283
|
+
persistMirror()
|
|
284
|
+
}
|
|
191
285
|
})
|
|
192
286
|
return strip(entry)
|
|
193
287
|
}
|
|
@@ -196,12 +290,16 @@ export function createTaskManager(opts: {
|
|
|
196
290
|
const entry = tasks.get(id)
|
|
197
291
|
if (!entry || entry.status !== "running") return false
|
|
198
292
|
entry.status = "killed"
|
|
293
|
+
pruneFinishedTasks()
|
|
199
294
|
persistMirror()
|
|
200
295
|
if (entry.stallTimer) clearInterval(entry.stallTimer)
|
|
201
296
|
entry.stallTimer = undefined
|
|
202
|
-
entry.
|
|
203
|
-
|
|
204
|
-
|
|
297
|
+
if (entry.tmuxPane) void entry.tmuxPane.close()
|
|
298
|
+
if (entry.proc) {
|
|
299
|
+
void killProcessTree(entry.proc, "SIGTERM")
|
|
300
|
+
const hard = setTimeout(() => void killProcessTree(entry.proc, "SIGKILL"), 3000)
|
|
301
|
+
entry.proc.exited.then(() => clearTimeout(hard))
|
|
302
|
+
}
|
|
205
303
|
return true
|
|
206
304
|
}
|
|
207
305
|
|
|
@@ -216,10 +314,7 @@ export function createTaskManager(opts: {
|
|
|
216
314
|
output: async (id, tailLines = 50) => {
|
|
217
315
|
const e = tasks.get(id)
|
|
218
316
|
if (!e) return `no task ${id}`
|
|
219
|
-
|
|
220
|
-
if (!(await file.exists())) return "(no output)"
|
|
221
|
-
const lines = (await file.text()).split("\n")
|
|
222
|
-
return lines.slice(-tailLines - 1).join("\n")
|
|
317
|
+
return readLogTail(e.logPath, tailLines)
|
|
223
318
|
},
|
|
224
319
|
killAll: () => {
|
|
225
320
|
for (const id of tasks.keys()) kill(id)
|
|
@@ -237,17 +332,10 @@ const fmt = (t: TaskRecord) =>
|
|
|
237
332
|
export const tasks: FeatureModule = {
|
|
238
333
|
name: "tasks",
|
|
239
334
|
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
335
|
defaultEnabled: true,
|
|
247
336
|
requires: ["session.promptAsync", "session.messages"],
|
|
248
337
|
async init(ctx, options, shared) {
|
|
249
338
|
const logDir = await ensureStateDir(ctx.directory, "tasks")
|
|
250
|
-
const stateDir = await ensureStateDir(ctx.directory)
|
|
251
339
|
const killOnExit = options.killOnExit !== false
|
|
252
340
|
const stallDetection = options.stallDetection !== false
|
|
253
341
|
const stallThresholdMs =
|
|
@@ -257,10 +345,15 @@ export const tasks: FeatureModule = {
|
|
|
257
345
|
|
|
258
346
|
const manager = createTaskManager({
|
|
259
347
|
logDir,
|
|
260
|
-
mirrorPath:
|
|
348
|
+
mirrorPath: taskStore.path(ctx.directory),
|
|
349
|
+
tmux: options.tmux === true,
|
|
350
|
+
sanitizeEnv: options.sanitizeEnv !== false,
|
|
351
|
+
envAllowlist: Array.isArray(options.envAllowlist) ? (options.envAllowlist as string[]) : undefined,
|
|
352
|
+
maxTasks: typeof options.maxTasks === "number" ? options.maxTasks : 100,
|
|
261
353
|
onExit: async (task) => {
|
|
262
354
|
if (task.status === "killed") return
|
|
263
|
-
const
|
|
355
|
+
const rawTail = await readLogTail(task.logPath, 20)
|
|
356
|
+
const tail = redactSensitiveOutput(rawTail)
|
|
264
357
|
const ok = task.exitCode === 0
|
|
265
358
|
await toast(
|
|
266
359
|
ctx.client,
|
|
@@ -278,12 +371,13 @@ export const tasks: FeatureModule = {
|
|
|
278
371
|
stallThresholdMs,
|
|
279
372
|
stallCheckIntervalMs,
|
|
280
373
|
onStall: async (task: TaskRecord, tail: string) => {
|
|
374
|
+
const safeTail = redactSensitiveOutput(tail.trimEnd())
|
|
281
375
|
await toast(ctx.client, `task ${task.id} looks stalled (waiting for input?)`, "warning")
|
|
282
376
|
await inject(
|
|
283
377
|
ctx.client,
|
|
284
378
|
task.sessionID,
|
|
285
379
|
`[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
|
|
286
|
-
`last output:\n${
|
|
380
|
+
`last output:\n${safeTail}\n\n` +
|
|
287
381
|
`The command is likely blocked on a prompt. Kill it with ${shared.toolName("task_kill")} and re-run non-interactively ` +
|
|
288
382
|
`(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
|
|
289
383
|
)
|
|
@@ -293,6 +387,9 @@ export const tasks: FeatureModule = {
|
|
|
293
387
|
})
|
|
294
388
|
|
|
295
389
|
return {
|
|
390
|
+
"shell.env": async (_input, output) => {
|
|
391
|
+
Object.assign(output.env, NON_INTERACTIVE_ENV)
|
|
392
|
+
},
|
|
296
393
|
dispose: async () => {
|
|
297
394
|
if (killOnExit) manager.killAll()
|
|
298
395
|
},
|
|
@@ -307,6 +404,10 @@ export const tasks: FeatureModule = {
|
|
|
307
404
|
timeout: z.number().optional().describe("seconds until auto-kill"),
|
|
308
405
|
},
|
|
309
406
|
async execute(args, tctx) {
|
|
407
|
+
const blocked = detectInteractiveCommand(args.command)
|
|
408
|
+
if (blocked) {
|
|
409
|
+
return `Error: Command '${args.command}' appears to require interactive input (${blocked}). Background tasks run non-interactively and will hang on prompts.`
|
|
410
|
+
}
|
|
310
411
|
const task = manager.run({
|
|
311
412
|
command: args.command,
|
|
312
413
|
description: args.description,
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
* Enforces strict character and line bounds so oversized lines never overflow context.
|
|
19
|
+
*/
|
|
20
|
+
export function truncateOutput(
|
|
21
|
+
content: string,
|
|
22
|
+
maxChars = DEFAULT_MAX_CHARS,
|
|
23
|
+
headLinesCount = DEFAULT_HEAD_LINES,
|
|
24
|
+
tailLinesCount = DEFAULT_TAIL_LINES,
|
|
25
|
+
): TruncateResult {
|
|
26
|
+
const effectiveMax = Math.max(100, maxChars)
|
|
27
|
+
if (content.length <= effectiveMax) {
|
|
28
|
+
return { text: content, truncated: false, omittedLines: 0, omittedChars: 0 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const headCount = Math.max(0, headLinesCount)
|
|
32
|
+
const tailCount = Math.max(0, tailLinesCount)
|
|
33
|
+
const lines = content.split("\n")
|
|
34
|
+
|
|
35
|
+
const maxHeadChars = Math.floor(effectiveMax * 0.3)
|
|
36
|
+
const maxTailChars = Math.floor(effectiveMax * 0.7)
|
|
37
|
+
|
|
38
|
+
if (lines.length <= headCount + tailCount) {
|
|
39
|
+
// If few lines but very long strings, hard slice
|
|
40
|
+
const head = content.slice(0, maxHeadChars)
|
|
41
|
+
const tail = maxTailChars > 0 ? content.slice(-maxTailChars) : ""
|
|
42
|
+
const omittedChars = Math.max(0, content.length - head.length - tail.length)
|
|
43
|
+
return {
|
|
44
|
+
text: `${head}\n\n[... truncated ${omittedChars} characters to stay within context limits ...]\n\n${tail}`,
|
|
45
|
+
truncated: true,
|
|
46
|
+
omittedLines: 0,
|
|
47
|
+
omittedChars,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let head = headCount > 0 ? lines.slice(0, headCount).join("\n") : ""
|
|
52
|
+
let tail = tailCount > 0 ? lines.slice(-tailCount).join("\n") : ""
|
|
53
|
+
|
|
54
|
+
// Guard against giant single lines in head or tail violating maxChars limit
|
|
55
|
+
if (head.length > maxHeadChars) {
|
|
56
|
+
head = head.slice(0, maxHeadChars) + "\n... [line truncated]"
|
|
57
|
+
}
|
|
58
|
+
if (tail.length > maxTailChars) {
|
|
59
|
+
tail = "[line truncated] ...\n" + (maxTailChars > 0 ? tail.slice(-maxTailChars) : "")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const omittedLines = Math.max(0, lines.length - headCount - tailCount)
|
|
63
|
+
const omittedChars = Math.max(0, content.length - head.length - tail.length)
|
|
64
|
+
|
|
65
|
+
const text = `${head}\n\n[... truncated ${omittedLines} lines (${omittedChars} chars) to stay within context limits ...]\n\n${tail}`
|
|
66
|
+
return {
|
|
67
|
+
text,
|
|
68
|
+
truncated: true,
|
|
69
|
+
omittedLines,
|
|
70
|
+
omittedChars,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TruncatorOptions {
|
|
75
|
+
maxChars?: number
|
|
76
|
+
tools?: string[]
|
|
77
|
+
headLines?: number
|
|
78
|
+
tailLines?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Truncator feature module: guards against sudden context window exhaustion
|
|
83
|
+
* by trimming high-volume tool outputs while keeping diagnostic head and tail lines.
|
|
84
|
+
*/
|
|
85
|
+
export const truncator: FeatureModule = {
|
|
86
|
+
name: "truncator",
|
|
87
|
+
tools: [],
|
|
88
|
+
defaultEnabled: true,
|
|
89
|
+
async init(_ctx, options, shared) {
|
|
90
|
+
const maxChars = typeof options.maxChars === "number" ? options.maxChars : DEFAULT_MAX_CHARS
|
|
91
|
+
const rawTools = Array.isArray(options.tools)
|
|
92
|
+
? (options.tools as string[])
|
|
93
|
+
: DEFAULT_TRUNCATABLE_TOOLS
|
|
94
|
+
const targetTools = new Set(
|
|
95
|
+
rawTools.flatMap((t) => {
|
|
96
|
+
const lower = t.toLowerCase()
|
|
97
|
+
const remapped = shared?.toolName ? shared.toolName(t).toLowerCase() : lower
|
|
98
|
+
return [lower, remapped]
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
const headLines = typeof options.headLines === "number" ? options.headLines : DEFAULT_HEAD_LINES
|
|
102
|
+
const tailLines = typeof options.tailLines === "number" ? options.tailLines : DEFAULT_TAIL_LINES
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"tool.execute.after": async (input, output) => {
|
|
106
|
+
if (!targetTools.has(input.tool.toLowerCase())) return
|
|
107
|
+
if (typeof output.output !== "string") return
|
|
108
|
+
|
|
109
|
+
const res = truncateOutput(output.output, maxChars, headLines, tailLines)
|
|
110
|
+
if (res.truncated) {
|
|
111
|
+
output.output = res.text
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|
package/src/features/usage.ts
CHANGED
|
@@ -1,27 +1,9 @@
|
|
|
1
|
-
import { tool } from "@opencode-ai/plugin"
|
|
2
1
|
import type { FeatureModule } from "../types.ts"
|
|
3
2
|
import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
|
|
3
|
+
import { usageStore, type DayBucket, type UsageState, type UsageTokens } from "../lib/mirror.ts"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export interface UsageTokens {
|
|
8
|
-
input: number
|
|
9
|
-
output: number
|
|
10
|
-
reasoning: number
|
|
11
|
-
cacheRead: number
|
|
12
|
-
cacheWrite: number
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface DayBucket {
|
|
16
|
-
cost: number
|
|
17
|
-
tokens: UsageTokens
|
|
18
|
-
messages: number
|
|
19
|
-
seen: string[]
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface UsageState {
|
|
23
|
-
days: Record<string, DayBucket>
|
|
24
|
-
}
|
|
5
|
+
// Declared in platform/storage/store.ts so the TUI can inspect usage data without importing this feature module.
|
|
6
|
+
export type { UsageTokens, DayBucket, UsageState } from "../lib/mirror.ts"
|
|
25
7
|
|
|
26
8
|
export interface SessionUsage {
|
|
27
9
|
sessionID: string
|
|
@@ -30,6 +12,8 @@ export interface SessionUsage {
|
|
|
30
12
|
}
|
|
31
13
|
|
|
32
14
|
const RETENTION_DAYS = 60
|
|
15
|
+
const MAX_SEEN_PER_DAY = 1000
|
|
16
|
+
const MAX_TRACKED_SESSIONS = 200
|
|
33
17
|
|
|
34
18
|
function zeroTokens(): UsageTokens {
|
|
35
19
|
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
|
|
@@ -91,6 +75,7 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
|
|
|
91
75
|
}
|
|
92
76
|
|
|
93
77
|
async function flush(): Promise<void> {
|
|
78
|
+
pruneOldDays(state, Date.now())
|
|
94
79
|
await writeJson(opts.statePath, state)
|
|
95
80
|
}
|
|
96
81
|
|
|
@@ -99,49 +84,51 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
|
|
|
99
84
|
pruneOldDays(state, Date.now())
|
|
100
85
|
}
|
|
101
86
|
|
|
87
|
+
function extractTokens(t: any): UsageTokens {
|
|
88
|
+
return {
|
|
89
|
+
input: t?.input ?? 0,
|
|
90
|
+
output: t?.output ?? 0,
|
|
91
|
+
reasoning: t?.reasoning ?? 0,
|
|
92
|
+
cacheRead: t?.cache?.read ?? 0,
|
|
93
|
+
cacheWrite: t?.cache?.write ?? 0,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
102
97
|
function onEvent(event: { type: string; properties?: unknown }): void {
|
|
103
98
|
try {
|
|
99
|
+
if (event.type === "session.deleted") {
|
|
100
|
+
const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
|
|
101
|
+
const id = props?.sessionID ?? props?.info?.id
|
|
102
|
+
if (id) sessions.delete(id)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
104
106
|
if (event.type !== "message.updated") return
|
|
105
|
-
const info = (event.properties as
|
|
106
|
-
|
|
107
|
-
id?: string
|
|
108
|
-
role?: string
|
|
109
|
-
sessionID?: string
|
|
110
|
-
time?: { created?: number; completed?: number }
|
|
111
|
-
cost?: number
|
|
112
|
-
tokens?: {
|
|
113
|
-
input?: number
|
|
114
|
-
output?: number
|
|
115
|
-
reasoning?: number
|
|
116
|
-
cache?: { read?: number; write?: number }
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
| undefined
|
|
120
|
-
if (!info || info.role !== "assistant") return
|
|
121
|
-
if (!info.time?.completed) return
|
|
107
|
+
const info = (event.properties as any)?.info
|
|
108
|
+
if (!info || info.role !== "assistant" || !info.time?.completed) return
|
|
122
109
|
if (typeof info.id !== "string" || typeof info.sessionID !== "string") return
|
|
110
|
+
|
|
123
111
|
const created = info.time.created ?? info.time.completed
|
|
124
112
|
const day = dayKey(created)
|
|
125
113
|
const bucket = state.days[day] ?? emptyBucket()
|
|
126
114
|
state.days[day] = bucket
|
|
127
115
|
if (bucket.seen.includes(info.id)) return
|
|
116
|
+
|
|
117
|
+
if (bucket.seen.length >= MAX_SEEN_PER_DAY) {
|
|
118
|
+
bucket.seen = bucket.seen.slice(-MAX_SEEN_PER_DAY + 1)
|
|
119
|
+
}
|
|
128
120
|
bucket.seen.push(info.id)
|
|
129
121
|
|
|
130
122
|
const cost = info.cost ?? 0
|
|
131
|
-
const
|
|
132
|
-
const input = t.input ?? 0
|
|
133
|
-
const output = t.output ?? 0
|
|
134
|
-
const reasoning = t.reasoning ?? 0
|
|
135
|
-
const cacheRead = t.cache?.read ?? 0
|
|
136
|
-
const cacheWrite = t.cache?.write ?? 0
|
|
123
|
+
const tok = extractTokens(info.tokens)
|
|
137
124
|
|
|
138
125
|
bucket.cost += cost
|
|
139
126
|
bucket.messages += 1
|
|
140
|
-
bucket.tokens.input += input
|
|
141
|
-
bucket.tokens.output += output
|
|
142
|
-
bucket.tokens.reasoning += reasoning
|
|
143
|
-
bucket.tokens.cacheRead += cacheRead
|
|
144
|
-
bucket.tokens.cacheWrite += cacheWrite
|
|
127
|
+
bucket.tokens.input += tok.input
|
|
128
|
+
bucket.tokens.output += tok.output
|
|
129
|
+
bucket.tokens.reasoning += tok.reasoning
|
|
130
|
+
bucket.tokens.cacheRead += tok.cacheRead
|
|
131
|
+
bucket.tokens.cacheWrite += tok.cacheWrite
|
|
145
132
|
|
|
146
133
|
const sess = sessions.get(info.sessionID) ?? {
|
|
147
134
|
sessionID: info.sessionID,
|
|
@@ -149,10 +136,16 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
|
|
|
149
136
|
tokens: { input: 0, output: 0 },
|
|
150
137
|
}
|
|
151
138
|
sess.cost += cost
|
|
152
|
-
sess.tokens.input += input
|
|
153
|
-
sess.tokens.output += output
|
|
139
|
+
sess.tokens.input += tok.input
|
|
140
|
+
sess.tokens.output += tok.output
|
|
154
141
|
sessions.set(info.sessionID, sess)
|
|
155
142
|
|
|
143
|
+
if (sessions.size > MAX_TRACKED_SESSIONS) {
|
|
144
|
+
// Evict oldest session to bound memory
|
|
145
|
+
const oldest = sessions.keys().next().value
|
|
146
|
+
if (oldest) sessions.delete(oldest)
|
|
147
|
+
}
|
|
148
|
+
|
|
156
149
|
scheduleFlush()
|
|
157
150
|
} catch (e) {
|
|
158
151
|
console.warn(`[overclock] usage: event handling failed: ${e}`)
|
|
@@ -236,11 +229,11 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
|
|
|
236
229
|
*/
|
|
237
230
|
export const usage: FeatureModule = {
|
|
238
231
|
name: "usage",
|
|
239
|
-
tools: [
|
|
232
|
+
tools: [],
|
|
240
233
|
defaultEnabled: true,
|
|
241
234
|
async init(ctx) {
|
|
242
235
|
const dir = await ensureStateDir(ctx.directory)
|
|
243
|
-
const tracker = createUsageTracker({ statePath:
|
|
236
|
+
const tracker = createUsageTracker({ statePath: usageStore.path(ctx.directory) })
|
|
244
237
|
await tracker.load()
|
|
245
238
|
|
|
246
239
|
return {
|
|
@@ -254,18 +247,6 @@ export const usage: FeatureModule = {
|
|
|
254
247
|
console.warn(`[overclock] usage: event handling failed: ${e}`)
|
|
255
248
|
}
|
|
256
249
|
},
|
|
257
|
-
tool: {
|
|
258
|
-
usage_report: tool({
|
|
259
|
-
description:
|
|
260
|
-
"Report cost/token usage for the last N days (default 7) plus today's per-session breakdown.",
|
|
261
|
-
args: {
|
|
262
|
-
days: z.number().optional().describe("number of days to report, default 7"),
|
|
263
|
-
},
|
|
264
|
-
async execute(args) {
|
|
265
|
-
return tracker.report(args.days ?? 7)
|
|
266
|
-
},
|
|
267
|
-
}),
|
|
268
|
-
},
|
|
269
250
|
}
|
|
270
251
|
},
|
|
271
252
|
}
|