opencode-overclock 0.2.2 → 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.
- package/README.md +285 -81
- package/package.json +3 -3
- 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 +53 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +164 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +168 -30
- package/src/features/index.ts +6 -4
- package/src/features/recovery.ts +143 -0
- package/src/features/sched.ts +147 -89
- package/src/features/tasks.ts +54 -20
- package/src/features/truncator.ts +99 -0
- package/src/features/usage.ts +26 -65
- package/src/index.ts +98 -55
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +7 -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 -35
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +76 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +82 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +77 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -39
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +117 -0
- package/src/v2/loader.ts +150 -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 -143
package/src/buddy/tui.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { Ui } from "../lib/ui.ts"
|
|
3
|
+
import {
|
|
4
|
+
rollCompanion,
|
|
5
|
+
describeCompanion,
|
|
6
|
+
migrateSpecies,
|
|
7
|
+
pickReaction,
|
|
8
|
+
createReactionGate,
|
|
9
|
+
cycleSpecies,
|
|
10
|
+
switchSpecies,
|
|
11
|
+
speciesDescription,
|
|
12
|
+
SPECIES,
|
|
13
|
+
type Companion,
|
|
14
|
+
type Rarity,
|
|
15
|
+
type ReactionKind,
|
|
16
|
+
type Species,
|
|
17
|
+
} from "./companion.ts"
|
|
4
18
|
import { spriteFrame, withBubble, spriteHeight, SPRITE_WIDTH, type SpriteState } from "./sprites.ts"
|
|
5
|
-
import { pickReaction, createReactionGate, type ReactionKind } from "./reactions.ts"
|
|
6
19
|
|
|
7
20
|
const KV_KEY = "buddy.companion"
|
|
8
21
|
/** Below this terminal width the buddy hides rather than crowd the prompt. */
|
|
@@ -20,6 +33,8 @@ const SLEEP_AFTER_MS = 120_000
|
|
|
20
33
|
interface SpriteNode {
|
|
21
34
|
content: string
|
|
22
35
|
visible: boolean
|
|
36
|
+
height?: number | string
|
|
37
|
+
fg?: unknown
|
|
23
38
|
destroyed?: boolean
|
|
24
39
|
}
|
|
25
40
|
|
|
@@ -38,20 +53,17 @@ function rarityColor(rarity: Rarity, theme: TuiThemeCurrent): unknown {
|
|
|
38
53
|
|
|
39
54
|
/**
|
|
40
55
|
* Hatch (or load) the companion and mount it in the prompt-right slots.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* never take the rest of the TUI plugin down.
|
|
56
|
+
* `enableJsx` throws when the host does not map @opentui/solid to its own instance;
|
|
57
|
+
* the caller degrades to "no buddy" and the rest of the TUI plugin is unaffected.
|
|
44
58
|
*/
|
|
45
|
-
export async function registerBuddy(
|
|
46
|
-
|
|
47
|
-
jsx: (type: string, props?: Record<string, unknown> | null) => unknown
|
|
48
|
-
}
|
|
59
|
+
export async function registerBuddy(ui: Ui): Promise<void> {
|
|
60
|
+
await ui.enableJsx()
|
|
49
61
|
|
|
50
|
-
let companion = api.kv
|
|
62
|
+
let companion = ui.api.kv?.get<Companion | undefined>(KV_KEY, undefined)
|
|
51
63
|
if (!companion) {
|
|
52
64
|
companion = rollCompanion()
|
|
53
|
-
api.kv
|
|
54
|
-
|
|
65
|
+
ui.api.kv?.set(KV_KEY, companion)
|
|
66
|
+
ui.toast(`a buddy hatched: ${describeCompanion(companion)}`)
|
|
55
67
|
} else {
|
|
56
68
|
// A companion persisted under a species we have since retired has no art:
|
|
57
69
|
// every lookup into ART would throw from inside the slot render, where the
|
|
@@ -60,11 +72,11 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
|
60
72
|
const migrated = migrateSpecies(companion)
|
|
61
73
|
if (migrated) {
|
|
62
74
|
companion = migrated
|
|
63
|
-
api.kv
|
|
64
|
-
|
|
75
|
+
ui.api.kv?.set(KV_KEY, migrated)
|
|
76
|
+
ui.toast(`${migrated.name} is a ${migrated.species} now`)
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
|
-
|
|
79
|
+
let active: Companion = companion
|
|
68
80
|
|
|
69
81
|
// One ticker drives every mounted node (home + session slots).
|
|
70
82
|
const nodes = new Set<SpriteNode>()
|
|
@@ -82,9 +94,9 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
|
82
94
|
|
|
83
95
|
function paint(): void {
|
|
84
96
|
const now = Date.now()
|
|
85
|
-
const frame = spriteFrame(
|
|
97
|
+
const frame = spriteFrame(active.species, currentState(now), tick)
|
|
86
98
|
const text = withBubble(frame, now < bubbleUntil ? bubble : undefined)
|
|
87
|
-
const wide = api.renderer
|
|
99
|
+
const wide = (ui.api.renderer?.width ?? 120) >= MIN_COLS
|
|
88
100
|
for (const node of [...nodes]) {
|
|
89
101
|
try {
|
|
90
102
|
if (node.destroyed) {
|
|
@@ -108,29 +120,116 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
|
108
120
|
paint()
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
|
|
123
|
+
function setCompanion(next: Companion, toastMessage?: string): void {
|
|
124
|
+
active = next
|
|
125
|
+
ui.api.kv?.set(KV_KEY, next)
|
|
126
|
+
const currentTheme = ui.api.theme?.current
|
|
127
|
+
for (const node of [...nodes]) {
|
|
128
|
+
try {
|
|
129
|
+
if (node.destroyed) {
|
|
130
|
+
nodes.delete(node)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (currentTheme) {
|
|
134
|
+
node.fg = rarityColor(active.rarity, currentTheme)
|
|
135
|
+
}
|
|
136
|
+
node.height = spriteHeight(active.species)
|
|
137
|
+
} catch {
|
|
138
|
+
nodes.delete(node)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
react("pet")
|
|
142
|
+
ui.toast(toastMessage ?? describeCompanion(active))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function openSwitchDialog(dialog?: unknown): void {
|
|
146
|
+
const stack = (dialog ?? ui.api.ui?.dialog) as
|
|
147
|
+
{ replace(render: () => unknown, onClose?: () => void): void; clear(): void } | undefined
|
|
148
|
+
const DialogSelect = ui.api.ui?.DialogSelect
|
|
149
|
+
|
|
150
|
+
if (!stack || typeof stack.replace !== "function" || !DialogSelect) {
|
|
151
|
+
const next = cycleSpecies(active.species)
|
|
152
|
+
setCompanion(
|
|
153
|
+
switchSpecies(active, next),
|
|
154
|
+
`switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let closed = false
|
|
160
|
+
const selectBuddy = (speciesOrRandom: string) => {
|
|
161
|
+
if (closed) return
|
|
162
|
+
closed = true
|
|
163
|
+
try {
|
|
164
|
+
stack.clear()
|
|
165
|
+
} catch {
|
|
166
|
+
// ignore
|
|
167
|
+
}
|
|
168
|
+
if (speciesOrRandom === "random") {
|
|
169
|
+
const fresh = rollCompanion()
|
|
170
|
+
setCompanion(fresh, `a new buddy hatched: ${describeCompanion(fresh)}`)
|
|
171
|
+
} else if ((SPECIES as readonly string[]).includes(speciesOrRandom)) {
|
|
172
|
+
const nextSpecies = speciesOrRandom as Species
|
|
173
|
+
setCompanion(
|
|
174
|
+
switchSpecies(active, nextSpecies),
|
|
175
|
+
`switched to ${nextSpecies}: ${describeCompanion(switchSpecies(active, nextSpecies))}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const options = [
|
|
181
|
+
...SPECIES.map((species) => ({
|
|
182
|
+
title: species,
|
|
183
|
+
value: species,
|
|
184
|
+
description: speciesDescription(species, species === active.species),
|
|
185
|
+
onSelect: () => selectBuddy(species),
|
|
186
|
+
})),
|
|
187
|
+
{
|
|
188
|
+
title: "random roll",
|
|
189
|
+
value: "random",
|
|
190
|
+
description: "hatch a brand new companion with new stats & rarity",
|
|
191
|
+
onSelect: () => selectBuddy("random"),
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
stack.replace(() =>
|
|
197
|
+
ui.node(DialogSelect as any, {
|
|
198
|
+
title: "Switch Buddy",
|
|
199
|
+
placeholder: "Select a species...",
|
|
200
|
+
current: active.species,
|
|
201
|
+
options,
|
|
202
|
+
onSelect: (opt: { value: string }) => selectBuddy(opt.value),
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
} catch {
|
|
206
|
+
const next = cycleSpecies(active.species)
|
|
207
|
+
setCompanion(
|
|
208
|
+
switchSpecies(active, next),
|
|
209
|
+
`switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ui.every(TICK_MS, () => {
|
|
112
215
|
tick++
|
|
113
216
|
paint()
|
|
114
|
-
}
|
|
115
|
-
api.lifecycle.onDispose(async () => clearInterval(timer))
|
|
217
|
+
})
|
|
116
218
|
|
|
117
219
|
const gate = createReactionGate()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}),
|
|
132
|
-
]
|
|
133
|
-
for (const unsub of unsubs) api.lifecycle.onDispose(async () => unsub())
|
|
220
|
+
ui.on("session.status", (event) => {
|
|
221
|
+
lastActivity = Date.now()
|
|
222
|
+
if (event.properties?.status?.type === "idle" && gate.tryFire()) react("done")
|
|
223
|
+
})
|
|
224
|
+
ui.on("session.error", () => {
|
|
225
|
+
if (gate.tryFire()) react("error")
|
|
226
|
+
})
|
|
227
|
+
ui.on("permission.asked", () => {
|
|
228
|
+
if (gate.tryFire()) react("permission")
|
|
229
|
+
})
|
|
230
|
+
ui.on("question.asked", () => {
|
|
231
|
+
if (gate.tryFire()) react("question")
|
|
232
|
+
})
|
|
134
233
|
|
|
135
234
|
// The host renders the slot inside a one-line flex row next to the agent/model
|
|
136
235
|
// text (prompt/index.tsx: justifyContent="space-between"). An in-flow sprite
|
|
@@ -139,44 +238,55 @@ export async function registerBuddy(api: TuiPluginApi): Promise<void> {
|
|
|
139
238
|
// parent's edges, and bottom/right pin the creature's last row onto the
|
|
140
239
|
// agent/model line while it grows upward over the (usually empty) input area.
|
|
141
240
|
const renderSprite = (theme: TuiThemeCurrent) =>
|
|
142
|
-
|
|
241
|
+
ui.node("text", {
|
|
143
242
|
position: "absolute",
|
|
144
243
|
bottom: 0,
|
|
145
244
|
right: 0,
|
|
146
245
|
width: SPRITE_WIDTH,
|
|
147
|
-
height: spriteHeight(
|
|
148
|
-
content: spriteFrame(
|
|
149
|
-
fg: rarityColor(
|
|
246
|
+
height: spriteHeight(active.species),
|
|
247
|
+
content: spriteFrame(active.species, "idle", tick),
|
|
248
|
+
fg: rarityColor(active.rarity, theme),
|
|
150
249
|
selectable: false,
|
|
151
|
-
visible: api.renderer
|
|
250
|
+
visible: (ui.api.renderer?.width ?? 120) >= MIN_COLS,
|
|
152
251
|
ref: (node: SpriteNode | undefined) => {
|
|
153
252
|
if (node) nodes.add(node)
|
|
154
253
|
},
|
|
155
254
|
})
|
|
156
255
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
256
|
+
ui.slots({
|
|
257
|
+
home_prompt_right: (ctx) => renderSprite(ctx.theme.current),
|
|
258
|
+
session_prompt_right: (ctx) => renderSprite(ctx.theme.current),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
ui.command({
|
|
262
|
+
title: "Overclock: Pet buddy",
|
|
263
|
+
slash: "oc-buddy",
|
|
264
|
+
aliases: ["buddy"],
|
|
265
|
+
run: () => {
|
|
266
|
+
react("pet")
|
|
267
|
+
ui.toast(describeCompanion(active))
|
|
163
268
|
},
|
|
164
269
|
})
|
|
165
270
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
271
|
+
ui.command({
|
|
272
|
+
title: "Overclock: Switch buddy",
|
|
273
|
+
slash: "oc-buddy-switch",
|
|
274
|
+
aliases: ["buddy-switch"],
|
|
275
|
+
run: (dialog) => {
|
|
276
|
+
openSwitchDialog(dialog)
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
ui.command({
|
|
281
|
+
title: "Overclock: Cycle buddy",
|
|
282
|
+
slash: "oc-buddy-cycle",
|
|
283
|
+
aliases: ["buddy-cycle"],
|
|
284
|
+
run: () => {
|
|
285
|
+
const next = cycleSpecies(active.species)
|
|
286
|
+
setCompanion(
|
|
287
|
+
switchSpecies(active, next),
|
|
288
|
+
`switched to ${next}: ${describeCompanion(switchSpecies(active, next))}`,
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
})
|
|
182
292
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Hooks, Plugin as V1Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import type { PluginContext as V2Context } from "@opencode-ai/plugin/v2/promise"
|
|
3
|
+
import type { HybridPlugin, HybridPluginDefinition } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
export type { HybridPlugin, HybridPluginDefinition } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a plugin export that satisfies both OpenCode V1 and V2 host lifecycles.
|
|
9
|
+
*
|
|
10
|
+
* - On a V1 host, it can be called directly as a function `(input, options)` or read via `{ id, server }`.
|
|
11
|
+
* - On a V2 host, it presents `{ id, setup(context) }` conforming to the V2 plugin specification.
|
|
12
|
+
*/
|
|
13
|
+
export function createHybridPlugin<TOptions = Record<string, unknown>>(
|
|
14
|
+
definition: HybridPluginDefinition<TOptions>,
|
|
15
|
+
): HybridPlugin<TOptions> {
|
|
16
|
+
const v1Runner: V1Plugin = async (input, options) => {
|
|
17
|
+
if (!definition.server) return {}
|
|
18
|
+
return (await definition.server(input, options as TOptions)) as Hooks
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const v2Runner = async (context: V2Context): Promise<void> => {
|
|
22
|
+
if (!definition.setup) return
|
|
23
|
+
const options = (context.options ?? {}) as TOptions
|
|
24
|
+
await definition.setup(context, options)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hybrid = Object.assign(v1Runner, {
|
|
28
|
+
id: definition.id,
|
|
29
|
+
server: v1Runner,
|
|
30
|
+
setup: v2Runner,
|
|
31
|
+
}) as HybridPlugin<TOptions>
|
|
32
|
+
|
|
33
|
+
return hybrid
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin"
|
|
2
|
+
import { applyToolPolicy, EMPTY_POLICY, type ToolPolicy } from "./policy.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose many Partial<Hooks> into one unified Hooks object.
|
|
6
|
+
* - fn hooks: sequential composition in array order.
|
|
7
|
+
* - tool definitions: merged and filtered/remapped via policy.
|
|
8
|
+
* - dispose: fault-tolerant sequential cleanup guaranteeing every disposer executes.
|
|
9
|
+
*/
|
|
10
|
+
export function mergeHooks(parts: Partial<Hooks>[], policy: ToolPolicy = EMPTY_POLICY): Hooks {
|
|
11
|
+
const merged: Record<string, unknown> = {}
|
|
12
|
+
const rawTools: Record<string, unknown> = {}
|
|
13
|
+
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
for (const [key, value] of Object.entries(part)) {
|
|
16
|
+
if (value === undefined) continue
|
|
17
|
+
if (key === "tool") {
|
|
18
|
+
const toolsObj = value as Record<string, unknown>
|
|
19
|
+
for (const [toolName, toolDef] of Object.entries(toolsObj)) {
|
|
20
|
+
if (rawTools[toolName] !== undefined) {
|
|
21
|
+
console.warn(`[overclock] tool collision: ${toolName} (later module wins)`)
|
|
22
|
+
}
|
|
23
|
+
rawTools[toolName] = toolDef
|
|
24
|
+
}
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
const prev = merged[key] as ((...a: unknown[]) => Promise<void>) | undefined
|
|
28
|
+
const next = value as (...a: unknown[]) => Promise<void>
|
|
29
|
+
merged[key] = prev
|
|
30
|
+
? async (...args: unknown[]) => {
|
|
31
|
+
if (key === "dispose") {
|
|
32
|
+
await Promise.resolve(prev(...args)).catch((e) =>
|
|
33
|
+
console.warn(`[overclock] dispose error: ${e}`),
|
|
34
|
+
)
|
|
35
|
+
await Promise.resolve(next(...args)).catch((e) =>
|
|
36
|
+
console.warn(`[overclock] dispose error: ${e}`),
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
await prev(...args)
|
|
41
|
+
await next(...args)
|
|
42
|
+
}
|
|
43
|
+
: next
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const processedTools = applyToolPolicy(rawTools, policy)
|
|
48
|
+
if (Object.keys(processedTools).length > 0) {
|
|
49
|
+
merged.tool = processedTools
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return merged as Hooks
|
|
53
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ConfigIssue, FeatureModule, OverclockOptions, ToolPolicy } from "./types.ts"
|
|
2
|
+
export { EMPTY_POLICY, type ToolPolicy } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tool ids opencode registers itself (observed on 1.18.4 via `/experimental/tool/ids`).
|
|
6
|
+
*
|
|
7
|
+
* Only used to warn: a tool registered under one of these *replaces* the built-in in the final
|
|
8
|
+
* tool map, and a name that differs only by case (`Task` vs `task`) is worse still -- the host
|
|
9
|
+
* offers both, and a consumer that matches case-insensitively sees a duplicate.
|
|
10
|
+
*/
|
|
11
|
+
export const HOST_TOOL_IDS: readonly string[] = [
|
|
12
|
+
"apply_patch",
|
|
13
|
+
"bash",
|
|
14
|
+
"edit",
|
|
15
|
+
"glob",
|
|
16
|
+
"grep",
|
|
17
|
+
"invalid",
|
|
18
|
+
"question",
|
|
19
|
+
"read",
|
|
20
|
+
"skill",
|
|
21
|
+
"task",
|
|
22
|
+
"todowrite",
|
|
23
|
+
"webfetch",
|
|
24
|
+
"websearch",
|
|
25
|
+
"write",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve tool rename and allowlist policies.
|
|
30
|
+
*
|
|
31
|
+
* - Remaps tool names according to `config.toolNames`.
|
|
32
|
+
* - Withholds any tool not in `config.toolAllowlist` (if configured).
|
|
33
|
+
* - Emits warnings on collisions with host built-in tool names.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveToolPolicy(
|
|
36
|
+
config: Pick<OverclockOptions, "toolNames" | "toolAllowlist">,
|
|
37
|
+
features: readonly FeatureModule[],
|
|
38
|
+
): { policy: ToolPolicy; issues: ConfigIssue[] } {
|
|
39
|
+
const issues: ConfigIssue[] = []
|
|
40
|
+
const rename: Record<string, string> = { ...(config.toolNames ?? {}) }
|
|
41
|
+
const withheld = new Set<string>()
|
|
42
|
+
|
|
43
|
+
let allowedSet: Set<string> | undefined
|
|
44
|
+
if (config.toolAllowlist !== undefined) {
|
|
45
|
+
if (typeof config.toolAllowlist === "string") {
|
|
46
|
+
allowedSet = new Set([config.toolAllowlist])
|
|
47
|
+
} else if (
|
|
48
|
+
Array.isArray(config.toolAllowlist) &&
|
|
49
|
+
config.toolAllowlist.every((s) => typeof s === "string")
|
|
50
|
+
) {
|
|
51
|
+
allowedSet = new Set(config.toolAllowlist)
|
|
52
|
+
} else {
|
|
53
|
+
issues.push({
|
|
54
|
+
path: "toolAllowlist",
|
|
55
|
+
message: `must be a string or array of strings, got ${Array.isArray(config.toolAllowlist) ? "array with non-strings" : typeof config.toolAllowlist}`,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const allTools = features.flatMap((f) => f.tools ?? [])
|
|
61
|
+
for (const declared of allTools) {
|
|
62
|
+
const visible = rename[declared] ?? declared
|
|
63
|
+
|
|
64
|
+
// Check collisions with host built-ins
|
|
65
|
+
const twin = HOST_TOOL_IDS.find((id) => id.toLowerCase() === visible.toLowerCase())
|
|
66
|
+
if (twin) {
|
|
67
|
+
issues.push({
|
|
68
|
+
path: `tool "${declared}"`,
|
|
69
|
+
message:
|
|
70
|
+
twin === visible
|
|
71
|
+
? `"${visible}" is an opencode built-in -- registering it replaces that built-in`
|
|
72
|
+
: `"${visible}" differs from opencode's built-in "${twin}" only by case; anything matching case-insensitively sees one name twice`,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check allowlist
|
|
77
|
+
if (allowedSet && !allowedSet.has(visible)) {
|
|
78
|
+
withheld.add(declared)
|
|
79
|
+
issues.push({
|
|
80
|
+
path: `tool "${declared}"`,
|
|
81
|
+
message: `"${visible}" is not in toolAllowlist -- withheld from the model. Add it to toolAllowlist or remap via toolNames`,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { policy: { rename, withheld }, issues }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rewrite declared tool names appearing inside a description in a single regex pass.
|
|
91
|
+
* Word-anchored so a name that is a substring of a longer identifier is not clobbered,
|
|
92
|
+
* and single-pass so chained mappings (A -> B, B -> C) do not cascade.
|
|
93
|
+
*/
|
|
94
|
+
export function renameInText(text: string, rename: Record<string, string>): string {
|
|
95
|
+
const activeEntries = Object.entries(rename).filter(([from, to]) => from !== to)
|
|
96
|
+
if (activeEntries.length === 0) return text
|
|
97
|
+
|
|
98
|
+
const pattern = new RegExp(
|
|
99
|
+
`\\b(${activeEntries.map(([k]) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
|
|
100
|
+
"g",
|
|
101
|
+
)
|
|
102
|
+
return text.replace(pattern, (match) => rename[match] ?? match)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filter withheld tools and apply renames and description updates to tool definitions.
|
|
107
|
+
*/
|
|
108
|
+
export function applyToolPolicy(
|
|
109
|
+
tools: Record<string, unknown>,
|
|
110
|
+
policy: ToolPolicy = { rename: {}, withheld: new Set() },
|
|
111
|
+
): Record<string, unknown> {
|
|
112
|
+
const result: Record<string, unknown> = {}
|
|
113
|
+
const { rename, withheld } = policy
|
|
114
|
+
const hasRenames = Object.keys(rename).length > 0
|
|
115
|
+
|
|
116
|
+
for (const [declared, def] of Object.entries(tools)) {
|
|
117
|
+
if (withheld.has(declared)) continue
|
|
118
|
+
const name = rename[declared] ?? declared
|
|
119
|
+
if (result[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
|
|
120
|
+
|
|
121
|
+
const d = def as { description?: unknown }
|
|
122
|
+
result[name] =
|
|
123
|
+
hasRenames && typeof d?.description === "string"
|
|
124
|
+
? { ...d, description: renameInText(d.description, rename) }
|
|
125
|
+
: def
|
|
126
|
+
}
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FeatureModule, ToolPolicy } from "./types.ts"
|
|
2
|
+
import { EMPTY_POLICY } from "./policy.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One-line inventory of what this plugin just added to the session.
|
|
6
|
+
*
|
|
7
|
+
* Reports capability: installing overclock grants the agent background shell execution and
|
|
8
|
+
* recurring scheduling, and that should not be something a user discovers by accident.
|
|
9
|
+
*/
|
|
10
|
+
export function summarise(
|
|
11
|
+
enabled: readonly FeatureModule[],
|
|
12
|
+
skipped: readonly string[],
|
|
13
|
+
policy: ToolPolicy = EMPTY_POLICY,
|
|
14
|
+
): string {
|
|
15
|
+
const { rename, withheld } = policy
|
|
16
|
+
const offered = enabled.flatMap((f) => (f.tools ?? []).filter((t) => !withheld.has(t)))
|
|
17
|
+
// Report the name the model is actually offered, not the declared one -- under a remap the
|
|
18
|
+
// declared name appears nowhere on the wire, so listing it would misdescribe the session.
|
|
19
|
+
const parts = enabled.map((f) => {
|
|
20
|
+
const names = (f.tools ?? []).filter((t) => !withheld.has(t)).map((t) => rename[t] ?? t)
|
|
21
|
+
return `${f.name}${names.length ? ` (${names.join(", ")})` : ""}`
|
|
22
|
+
})
|
|
23
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
24
|
+
let line = `${plural(enabled.length, "module")}, ${plural(offered.length, "tool")}: ${parts.join(" · ")}`
|
|
25
|
+
const applied = offered.filter((t) => rename[t] && rename[t] !== t).map((t) => `${t}->${rename[t]}`)
|
|
26
|
+
if (applied.length) line += ` | renamed: ${applied.join(", ")}`
|
|
27
|
+
// Withheld tools are the one case where the session is quietly less capable than the config
|
|
28
|
+
// implies, so they are named here rather than left to the issue log alone.
|
|
29
|
+
const held = enabled.flatMap((f) => (f.tools ?? []).filter((t) => withheld.has(t)))
|
|
30
|
+
if (held.length) line += ` | withheld: ${held.join(", ")}`
|
|
31
|
+
if (skipped.length) line += ` | skipped: ${skipped.join(", ")}`
|
|
32
|
+
return line
|
|
33
|
+
}
|