opencode-mcp-triage 0.7.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/.opencode/commands/mcp-triage.md +4 -0
- package/.opencode/commands/triage.md +5 -0
- package/.opencode/plugins/opencode-mcp-triage.ts +1 -0
- package/README.md +242 -0
- package/bin/opencode-mcp-triage.cjs +764 -0
- package/package.json +60 -0
- package/postinstall.cjs +27 -0
- package/src/config.ts +245 -0
- package/src/index.ts +425 -0
- package/src/lock.ts +42 -0
- package/src/triage.ts +121 -0
- package/src/types.ts +63 -0
- package/src/writer.ts +468 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* opencode-mcp-triage — Subagent Router Plugin
|
|
3
|
+
* ==============================================
|
|
4
|
+
* Version: 0.6.1
|
|
5
|
+
* License: MIT
|
|
6
|
+
*
|
|
7
|
+
* Routes MCP work to scoped subagents. On first run, automatically
|
|
8
|
+
* disables all MCP tools globally in opencode.jsonc so they don't
|
|
9
|
+
* consume tokens in the main agent. Subagents re-enable specific
|
|
10
|
+
* servers via tool scoping.
|
|
11
|
+
*
|
|
12
|
+
* How it works:
|
|
13
|
+
* 1. Plugin init: reads MCP servers + subagents from config
|
|
14
|
+
* 2. Writes "servername_*": false to project config (disables MCP tools in main session)
|
|
15
|
+
* 3. Exposes triage_mcp tool: scores user query against subagent names/descriptions/MCP names
|
|
16
|
+
* 4. Returns best-matching subagent — user invokes it via @name or Task tool
|
|
17
|
+
*
|
|
18
|
+
* Token savings: MCP tools have large descriptions. Disabling them in the
|
|
19
|
+
* main session saves ~80% of MCP-related tokens. Subagents only carry
|
|
20
|
+
* their scoped servers' tools.
|
|
21
|
+
*
|
|
22
|
+
* Install: { "plugin": ["opencode-mcp-triage"] } in opencode.jsonc
|
|
23
|
+
* Docs: https://github.com/cascharly/opencode-mcp-triage
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
27
|
+
import { tool } from "@opencode-ai/plugin"
|
|
28
|
+
import type { McpServer, Subagent } from "./types.js"
|
|
29
|
+
import { readMcpConfig, readSubagentConfig } from "./config.js"
|
|
30
|
+
import { scoreSubagents, THRESHOLD } from "./triage.js"
|
|
31
|
+
import { ensureToolsDisabled, ensureSubagentsCreated } from "./writer.js"
|
|
32
|
+
|
|
33
|
+
/** Cache TTL: 5 seconds — balances freshness with performance */
|
|
34
|
+
const CACHE_TTL_MS = 5000
|
|
35
|
+
|
|
36
|
+
/** Reload debounce: 1 second cooldown to prevent spam */
|
|
37
|
+
const RELOAD_COOLDOWN_MS = 1000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mutable plugin state — updated on init and reload.
|
|
41
|
+
*
|
|
42
|
+
* mcpServers: all MCP servers from config (name + description)
|
|
43
|
+
* subagents: agents with MCP tool scoping
|
|
44
|
+
* mcpNames: flat list of MCP server names (for display)
|
|
45
|
+
* assignedMcps: set of MCP names that have at least one subagent
|
|
46
|
+
*/
|
|
47
|
+
interface State {
|
|
48
|
+
mcpServers: McpServer[]
|
|
49
|
+
subagents: Subagent[]
|
|
50
|
+
mcpNames: string[]
|
|
51
|
+
assignedMcps: Set<string>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generic cache with TTL.
|
|
56
|
+
* Stores a value with an expiry timestamp. Returns null if expired.
|
|
57
|
+
*/
|
|
58
|
+
interface CacheEntry<T> {
|
|
59
|
+
value: T
|
|
60
|
+
expiresAt: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Cache<T> {
|
|
64
|
+
get(): T | null
|
|
65
|
+
set(value: T): void
|
|
66
|
+
invalidate(): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createCache<T>(ttlMs: number): Cache<T> {
|
|
70
|
+
let entry: CacheEntry<T> | null = null
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
get(): T | null {
|
|
74
|
+
if (entry && Date.now() < entry.expiresAt) {
|
|
75
|
+
return entry.value
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
},
|
|
79
|
+
set(value: T) {
|
|
80
|
+
entry = { value, expiresAt: Date.now() + ttlMs }
|
|
81
|
+
},
|
|
82
|
+
invalidate() {
|
|
83
|
+
entry = null
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds state from raw config data.
|
|
90
|
+
*
|
|
91
|
+
* assignedMcps tracks which MCP servers are covered by subagents.
|
|
92
|
+
* Used to report unassigned servers (no subagent handles them).
|
|
93
|
+
*/
|
|
94
|
+
function buildState(mcpServers: McpServer[], subagents: Subagent[]): State {
|
|
95
|
+
const assignedMcps = new Set<string>()
|
|
96
|
+
for (const sa of subagents) {
|
|
97
|
+
for (const m of sa.mcpServers) {
|
|
98
|
+
assignedMcps.add(m)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
mcpServers,
|
|
103
|
+
subagents,
|
|
104
|
+
mcpNames: mcpServers.map((s) => s.name),
|
|
105
|
+
assignedMcps,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sends a TUI toast notification via OpenCode client.
|
|
111
|
+
* Gracefully handles missing client.tui (older OpenCode versions).
|
|
112
|
+
*/
|
|
113
|
+
function showToast(
|
|
114
|
+
client: unknown,
|
|
115
|
+
message: string,
|
|
116
|
+
variant: "success" | "info" | "error" = "info"
|
|
117
|
+
) {
|
|
118
|
+
try {
|
|
119
|
+
const c = client as { tui?: { showToast?: (...args: unknown[]) => unknown } }
|
|
120
|
+
c.tui?.showToast?.({ message, variant })
|
|
121
|
+
} catch {
|
|
122
|
+
// TUI not available — silently skip
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* OpenCode plugin entry point.
|
|
128
|
+
*
|
|
129
|
+
* Runs once when OpenCode starts. Must complete before returning —
|
|
130
|
+
* ensureToolsDisabled writes to config file and must finish before
|
|
131
|
+
* the main agent starts using tools.
|
|
132
|
+
*
|
|
133
|
+
* Returns two tools:
|
|
134
|
+
* - triage_mcp: routes queries to the best subagent
|
|
135
|
+
* - mcp_stats: shows routing status and token savings
|
|
136
|
+
*/
|
|
137
|
+
export const server: Plugin = async ({ directory, client }) => {
|
|
138
|
+
// Config caches with 5s TTL — picks up CLI toggles without restart
|
|
139
|
+
const mcpCache = createCache<McpServer[]>(CACHE_TTL_MS)
|
|
140
|
+
const subagentCache = createCache<Subagent[]>(CACHE_TTL_MS)
|
|
141
|
+
|
|
142
|
+
// Reload debounce: track last reload time to prevent spam
|
|
143
|
+
let lastReloadAt = 0
|
|
144
|
+
|
|
145
|
+
async function getCachedMcpServers(): Promise<McpServer[]> {
|
|
146
|
+
const cached = mcpCache.get()
|
|
147
|
+
if (cached) return cached
|
|
148
|
+
const result = await readMcpConfig(directory)
|
|
149
|
+
mcpCache.set(result)
|
|
150
|
+
return result
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function getCachedSubagents(): Promise<Subagent[]> {
|
|
154
|
+
const cached = subagentCache.get()
|
|
155
|
+
if (cached) return cached
|
|
156
|
+
const result = await readSubagentConfig(directory)
|
|
157
|
+
subagentCache.set(result)
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const mcpServers = await getCachedMcpServers()
|
|
162
|
+
const mcpNames = mcpServers.map((s) => s.name)
|
|
163
|
+
|
|
164
|
+
// Phase 1: disable all MCP tools in main agent
|
|
165
|
+
// This MUST complete before returning — otherwise main session
|
|
166
|
+
// could use MCP tools before they're disabled
|
|
167
|
+
await ensureToolsDisabled(directory, mcpNames)
|
|
168
|
+
|
|
169
|
+
// Phase 2: read current subagents, then auto-create for unassigned MCPs
|
|
170
|
+
let subagents = await getCachedSubagents()
|
|
171
|
+
const created = await ensureSubagentsCreated(directory, mcpServers, subagents)
|
|
172
|
+
if (created > 0) {
|
|
173
|
+
// Re-read after auto-create so state is accurate
|
|
174
|
+
subagentCache.invalidate()
|
|
175
|
+
subagents = await getCachedSubagents()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const state = buildState(mcpServers, subagents)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
tool: {
|
|
182
|
+
/**
|
|
183
|
+
* Triage Tool: matches a user query to the best subagent.
|
|
184
|
+
*
|
|
185
|
+
* Scoring uses keyword matching against subagent name, description,
|
|
186
|
+
* and assigned MCP server names. Returns the top match if the score
|
|
187
|
+
* gap exceeds THRESHOLD, otherwise shows multiple options.
|
|
188
|
+
*
|
|
189
|
+
* Special queries:
|
|
190
|
+
* - "reload": re-reads config without restarting OpenCode
|
|
191
|
+
* - "": lists all available subagents
|
|
192
|
+
*/
|
|
193
|
+
triage_mcp: tool({
|
|
194
|
+
description:
|
|
195
|
+
"Discover and route to the right MCP subagent. " +
|
|
196
|
+
"Call this before any non-trivial MCP task. " +
|
|
197
|
+
"Pass a short description of what you need. " +
|
|
198
|
+
"Returns the best matching subagent and its available MCP tools. " +
|
|
199
|
+
"Use query 'reload' to re-read MCP config without restarting.",
|
|
200
|
+
args: {
|
|
201
|
+
query: tool.schema
|
|
202
|
+
.string()
|
|
203
|
+
.describe(
|
|
204
|
+
"What you want to do — e.g. 'search library docs', " +
|
|
205
|
+
"'manage GitHub issues', 'database operations', " +
|
|
206
|
+
"or 'reload' to refresh MCP config"
|
|
207
|
+
),
|
|
208
|
+
},
|
|
209
|
+
async execute(args, context) {
|
|
210
|
+
// Abort signal handling — check if request was cancelled
|
|
211
|
+
if (context?.abort?.aborted) {
|
|
212
|
+
return "Triage cancelled."
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const query = args.query.trim()
|
|
216
|
+
|
|
217
|
+
// "reload" — re-read config files without restarting
|
|
218
|
+
if (query.toLowerCase() === "reload") {
|
|
219
|
+
// Debounce: prevent spam reloads
|
|
220
|
+
const now = Date.now()
|
|
221
|
+
if (now - lastReloadAt < RELOAD_COOLDOWN_MS) {
|
|
222
|
+
return "Reload cooldown active. Try again in a moment."
|
|
223
|
+
}
|
|
224
|
+
lastReloadAt = now
|
|
225
|
+
|
|
226
|
+
mcpCache.invalidate()
|
|
227
|
+
subagentCache.invalidate()
|
|
228
|
+
state.mcpServers = await getCachedMcpServers()
|
|
229
|
+
let sa = await getCachedSubagents()
|
|
230
|
+
const created = await ensureSubagentsCreated(
|
|
231
|
+
directory,
|
|
232
|
+
state.mcpServers,
|
|
233
|
+
sa
|
|
234
|
+
)
|
|
235
|
+
if (created > 0) {
|
|
236
|
+
subagentCache.invalidate()
|
|
237
|
+
sa = await getCachedSubagents()
|
|
238
|
+
}
|
|
239
|
+
state.subagents = sa
|
|
240
|
+
const fresh = buildState(state.mcpServers, state.subagents)
|
|
241
|
+
Object.assign(state, fresh)
|
|
242
|
+
const lines = ["MCP config reloaded."]
|
|
243
|
+
if (created > 0) {
|
|
244
|
+
lines.push(`Auto-created ${created} subagent(s) for new MCP(s).`)
|
|
245
|
+
}
|
|
246
|
+
lines.push(
|
|
247
|
+
`Subagents: ${state.subagents.map((s) => s.name).join(", ") || "none"}`
|
|
248
|
+
)
|
|
249
|
+
lines.push(
|
|
250
|
+
`MCP servers: ${state.mcpServers.map((s) => s.name).join(", ") || "none"}`
|
|
251
|
+
)
|
|
252
|
+
showToast(client, "MCP config reloaded", "success")
|
|
253
|
+
return lines.join("\n")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Empty query — list all subagents
|
|
257
|
+
if (!query) {
|
|
258
|
+
if (state.subagents.length === 0) {
|
|
259
|
+
return [
|
|
260
|
+
"No MCP subagents configured.",
|
|
261
|
+
"",
|
|
262
|
+
"Add subagents in opencode.jsonc under 'agent' section with tool scoping.",
|
|
263
|
+
].join("\n")
|
|
264
|
+
}
|
|
265
|
+
const lines = ["Available subagents:"]
|
|
266
|
+
for (const sa of state.subagents) {
|
|
267
|
+
const mcps = sa.mcpServers.join(", ")
|
|
268
|
+
lines.push(
|
|
269
|
+
` @${sa.name} — ${sa.description || "no description"}${mcps ? ` [${mcps}]` : ""}`
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
lines.push("")
|
|
273
|
+
lines.push("Use @agent-name in your message to invoke a subagent.")
|
|
274
|
+
return lines.join("\n")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// No subagents configured — show setup instructions
|
|
278
|
+
if (state.subagents.length === 0) {
|
|
279
|
+
return [
|
|
280
|
+
"No MCP subagents configured.",
|
|
281
|
+
"",
|
|
282
|
+
"Add subagents in opencode.jsonc:",
|
|
283
|
+
` "agent": { "myserver": { "mode": "subagent", "description": "...", "tools": { "myserver_*": true } } }`,
|
|
284
|
+
].join("\n")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Score and rank subagents
|
|
288
|
+
const scored = scoreSubagents(query, state.subagents)
|
|
289
|
+
.filter((s) => s.score > 0)
|
|
290
|
+
.sort((a, b) => b.score - a.score)
|
|
291
|
+
|
|
292
|
+
// No matches — show available options
|
|
293
|
+
if (scored.length === 0) {
|
|
294
|
+
const names = state.subagents.map((s) => s.name).join(", ")
|
|
295
|
+
showToast(client, `No match for "${query}"`, "error")
|
|
296
|
+
return [
|
|
297
|
+
`No subagent matches "${query}".`,
|
|
298
|
+
`Available: ${names}`,
|
|
299
|
+
"",
|
|
300
|
+
"Try broader keywords or call triage_mcp with empty query to list all.",
|
|
301
|
+
].join("\n")
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Check if top match is clearly better than runner-up
|
|
305
|
+
const gap = scored[0].score - (scored[1]?.score ?? 0)
|
|
306
|
+
|
|
307
|
+
if (gap >= THRESHOLD || scored.length === 1) {
|
|
308
|
+
// Clear winner — route to it
|
|
309
|
+
const match = scored[0]
|
|
310
|
+
const mcps = match.subagent.mcpServers.join(", ")
|
|
311
|
+
const lines = [
|
|
312
|
+
`ROUTED: @${match.subagent.name}`,
|
|
313
|
+
match.subagent.description
|
|
314
|
+
? ` ${match.subagent.description}`
|
|
315
|
+
: "",
|
|
316
|
+
mcps ? ` MCP: ${mcps}` : "",
|
|
317
|
+
` Matched by: ${match.matchedBy}`,
|
|
318
|
+
"",
|
|
319
|
+
`Invoke with @${match.subagent.name} in your message, or use the Task tool:`,
|
|
320
|
+
` task({ subagent_type: "${match.subagent.name}", prompt: "..." })`,
|
|
321
|
+
]
|
|
322
|
+
showToast(client, `Routed to @${match.subagent.name}`, "success")
|
|
323
|
+
return lines.join("\n")
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Too close to call — show top 5 options
|
|
327
|
+
const top = scored.slice(0, 5)
|
|
328
|
+
const lines = [`Multiple subagents match "${query}":`, ""]
|
|
329
|
+
top.forEach((s, i) => {
|
|
330
|
+
const mcps = s.subagent.mcpServers.join(", ")
|
|
331
|
+
lines.push(
|
|
332
|
+
` ${i + 1}. @${s.subagent.name}${s.subagent.description ? ` — ${s.subagent.description}` : ""}${mcps ? ` [${mcps}]` : ""}`
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
lines.push("")
|
|
336
|
+
lines.push(
|
|
337
|
+
`Be more specific, or name the subagent directly: @${top[0].subagent.name}`
|
|
338
|
+
)
|
|
339
|
+
showToast(client, `${scored.length} subagents match`, "info")
|
|
340
|
+
return lines.join("\n")
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Stats tool: displays routing status and token savings.
|
|
346
|
+
*
|
|
347
|
+
* Shows:
|
|
348
|
+
* - Subagent routing map (which subagent handles which MCP servers)
|
|
349
|
+
* - Unassigned servers (no subagent covers them)
|
|
350
|
+
* - Token savings confirmation (0 MCP tokens in main session)
|
|
351
|
+
* - Coverage percentage
|
|
352
|
+
*/
|
|
353
|
+
mcp_stats: tool({
|
|
354
|
+
description:
|
|
355
|
+
"Show MCP subagent routing status and token savings. " +
|
|
356
|
+
"Displays which MCP servers are routed to which subagents. " +
|
|
357
|
+
"MCP tools in main session are disabled — only subagents carry them.",
|
|
358
|
+
args: {},
|
|
359
|
+
async execute() {
|
|
360
|
+
const lines: string[] = []
|
|
361
|
+
const { subagents: sa, mcpNames, assignedMcps } = state
|
|
362
|
+
|
|
363
|
+
lines.push("MCP Subagent Routing Status")
|
|
364
|
+
lines.push("")
|
|
365
|
+
|
|
366
|
+
if (sa.length === 0) {
|
|
367
|
+
lines.push(" No subagents configured.")
|
|
368
|
+
lines.push("")
|
|
369
|
+
lines.push(
|
|
370
|
+
" Configure subagents with MCP tool scoping for token savings."
|
|
371
|
+
)
|
|
372
|
+
return lines.join("\n")
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
lines.push(
|
|
376
|
+
` Strategy: Global disable → Subagent enable via tool scoping`
|
|
377
|
+
)
|
|
378
|
+
lines.push(
|
|
379
|
+
` Subagents: ${sa.length} | MCP servers: ${mcpNames.length}`
|
|
380
|
+
)
|
|
381
|
+
lines.push("")
|
|
382
|
+
lines.push(" Subagent routing map:")
|
|
383
|
+
lines.push(" ─".repeat(30))
|
|
384
|
+
|
|
385
|
+
for (const s of sa) {
|
|
386
|
+
const mcps = s.mcpServers.join(", ")
|
|
387
|
+
lines.push(
|
|
388
|
+
` @${s.name.padEnd(18)} → ${mcps || "no MCP servers"}`
|
|
389
|
+
)
|
|
390
|
+
if (s.description) {
|
|
391
|
+
lines.push(` ${" ".repeat(19)}${s.description}`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Find MCP servers not assigned to any subagent
|
|
396
|
+
const unassigned = mcpNames.filter(
|
|
397
|
+
(n) => !sa.some((s) => s.mcpServers.includes(n))
|
|
398
|
+
)
|
|
399
|
+
if (unassigned.length > 0) {
|
|
400
|
+
lines.push("")
|
|
401
|
+
lines.push(
|
|
402
|
+
` Unassigned: ${unassigned.join(", ")} (no subagent)`
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const assigned = assignedMcps.size
|
|
407
|
+
const pct = mcpNames.length > 0
|
|
408
|
+
? Math.round((assigned / mcpNames.length) * 100)
|
|
409
|
+
: 0
|
|
410
|
+
|
|
411
|
+
lines.push("")
|
|
412
|
+
lines.push(" ─".repeat(30))
|
|
413
|
+
lines.push(
|
|
414
|
+
` MCP tools in main session: 0 tokens (globally disabled)`
|
|
415
|
+
)
|
|
416
|
+
lines.push(
|
|
417
|
+
` MCP coverage: ${assigned}/${mcpNames.length} servers routed (${pct}%)`
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return lines.join("\n")
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, stat, rename } from "node:fs/promises"
|
|
2
|
+
import { join, dirname } from "node:path"
|
|
3
|
+
|
|
4
|
+
const LOCK_FILENAME = "mcp-triage.json"
|
|
5
|
+
|
|
6
|
+
/** Max lock file size: 64KB — prevents memory exhaustion */
|
|
7
|
+
const MAX_LOCK_SIZE = 64 * 1024
|
|
8
|
+
|
|
9
|
+
export interface LockFile {
|
|
10
|
+
version: 1
|
|
11
|
+
autoCreated: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readLock(directory: string): Promise<LockFile | null> {
|
|
15
|
+
const path = join(directory, ".opencode", LOCK_FILENAME)
|
|
16
|
+
try {
|
|
17
|
+
const stats = await stat(path)
|
|
18
|
+
if (stats.size > MAX_LOCK_SIZE) return null
|
|
19
|
+
const raw = await readFile(path, "utf-8")
|
|
20
|
+
return JSON.parse(raw) as LockFile
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeLock(
|
|
27
|
+
directory: string,
|
|
28
|
+
lock: LockFile
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const path = join(directory, ".opencode", LOCK_FILENAME)
|
|
31
|
+
await mkdir(dirname(path), { recursive: true })
|
|
32
|
+
|
|
33
|
+
// Atomic write: write to temp file then rename
|
|
34
|
+
const tmpPath = path + ".tmp"
|
|
35
|
+
await writeFile(tmpPath, JSON.stringify(lock, null, 2) + "\n", "utf-8")
|
|
36
|
+
try {
|
|
37
|
+
await rename(tmpPath, path)
|
|
38
|
+
} catch {
|
|
39
|
+
// Rename failed (cross-device) — fallback to direct write
|
|
40
|
+
await writeFile(path, JSON.stringify(lock, null, 2) + "\n", "utf-8")
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/triage.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword-based subagent scoring engine.
|
|
3
|
+
*
|
|
4
|
+
* Scoring strategy (no LLM, pure text matching):
|
|
5
|
+
* 1. Split query into words (min 3 chars, strip punctuation)
|
|
6
|
+
* 2. For each subagent, score against name, description, and MCP server names
|
|
7
|
+
* 3. Word boundary match > substring match (15 vs 10 base points)
|
|
8
|
+
* 4. Name and MCP matches weighted 3x, description weighted 1x
|
|
9
|
+
*
|
|
10
|
+
* Threshold (30): minimum score gap between 1st and 2nd place for auto-routing.
|
|
11
|
+
* If gap < threshold, we show multiple options instead of picking one.
|
|
12
|
+
*
|
|
13
|
+
* Why these weights:
|
|
14
|
+
* - NAME_WEIGHT=3: subagent name is the strongest signal ("github" in query → github agent)
|
|
15
|
+
* - DESC_WEIGHT=1: description is broader, more false positives
|
|
16
|
+
* - MCP names use NAME_WEIGHT: server name matches are as strong as agent name matches
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Subagent, ScoredSubagent } from "./types.js"
|
|
20
|
+
|
|
21
|
+
/** Minimum score gap between top two candidates for confident routing */
|
|
22
|
+
export const THRESHOLD = 30
|
|
23
|
+
/** Words shorter than this are ignored (too generic) */
|
|
24
|
+
const MIN_WORD_LENGTH = 3
|
|
25
|
+
/** Multiplier for name and MCP server matches */
|
|
26
|
+
const NAME_WEIGHT = 3
|
|
27
|
+
/** Multiplier for description matches (lower — more noise) */
|
|
28
|
+
const DESC_WEIGHT = 1
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Escapes regex special characters in a string.
|
|
32
|
+
* Used when building dynamic regex patterns from user input or config values.
|
|
33
|
+
*/
|
|
34
|
+
function escapeRegex(s: string): string {
|
|
35
|
+
return s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scores a single word against a target string.
|
|
40
|
+
*
|
|
41
|
+
* Returns:
|
|
42
|
+
* - 15 if word matches as a whole word (boundary match)
|
|
43
|
+
* - 10 if word is a substring of target
|
|
44
|
+
* - 0 if no match
|
|
45
|
+
*
|
|
46
|
+
* Case-insensitive. Boundary match uses \b word boundaries.
|
|
47
|
+
*/
|
|
48
|
+
function getWordBonus(word: string, target: string): number {
|
|
49
|
+
const re = new RegExp(`\\b${escapeRegex(word)}\\b`, "i")
|
|
50
|
+
if (re.test(target)) return 15
|
|
51
|
+
if (target.includes(word)) return 10
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scores all subagents against a triage query.
|
|
57
|
+
*
|
|
58
|
+
* Scoring happens in three passes:
|
|
59
|
+
* 1. Query words vs subagent name (highest weight)
|
|
60
|
+
* 2. Query words vs subagent description (lower weight)
|
|
61
|
+
* 3. Query words vs MCP server names assigned to the subagent (highest weight)
|
|
62
|
+
*
|
|
63
|
+
* Uses a Set for matchedBy to avoid duplicate entries when multiple
|
|
64
|
+
* query words match the same MCP server.
|
|
65
|
+
*
|
|
66
|
+
* Returns all subagents with their scores — caller filters by score > 0.
|
|
67
|
+
*/
|
|
68
|
+
export function scoreSubagents(
|
|
69
|
+
query: string,
|
|
70
|
+
subagents: Subagent[]
|
|
71
|
+
): ScoredSubagent[] {
|
|
72
|
+
// Normalize query: lowercase, split on whitespace, strip punctuation, filter short words
|
|
73
|
+
const words = query
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.split(/\s+/)
|
|
76
|
+
.map((w) => w.replace(/[^\p{L}\p{N}]/gu, ""))
|
|
77
|
+
.filter((w) => w.length >= MIN_WORD_LENGTH)
|
|
78
|
+
|
|
79
|
+
if (words.length === 0) return []
|
|
80
|
+
|
|
81
|
+
return subagents.map((subagent) => {
|
|
82
|
+
const nameLower = subagent.name.toLowerCase()
|
|
83
|
+
const descLower = subagent.description.toLowerCase()
|
|
84
|
+
const mcpNames = subagent.mcpServers.map((s) => s.toLowerCase())
|
|
85
|
+
let score = 0
|
|
86
|
+
// Use Set to deduplicate — multiple query words can match the same MCP server
|
|
87
|
+
const matched = new Set<string>()
|
|
88
|
+
|
|
89
|
+
// Pass 1: score against subagent name (strongest signal)
|
|
90
|
+
for (const word of words) {
|
|
91
|
+
const bonus = getWordBonus(word, nameLower)
|
|
92
|
+
if (bonus > 0) {
|
|
93
|
+
score += NAME_WEIGHT * bonus
|
|
94
|
+
matched.add(`name:${word}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pass 2: score against description (broader, more noise)
|
|
99
|
+
for (const word of words) {
|
|
100
|
+
const bonus = getWordBonus(word, descLower)
|
|
101
|
+
if (bonus > 0) {
|
|
102
|
+
score += DESC_WEIGHT * bonus
|
|
103
|
+
matched.add(`desc:${word}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pass 3: score against MCP server names assigned to this subagent
|
|
108
|
+
// Treats MCP name matches as strong as name matches
|
|
109
|
+
for (const mcpName of mcpNames) {
|
|
110
|
+
for (const word of words) {
|
|
111
|
+
const bonus = getWordBonus(word, mcpName)
|
|
112
|
+
if (bonus > 0) {
|
|
113
|
+
score += NAME_WEIGHT * bonus
|
|
114
|
+
matched.add(`mcp:${mcpName}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { subagent, score, matchedBy: Array.from(matched).join(", ") }
|
|
120
|
+
})
|
|
121
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions for opencode-mcp-triage.
|
|
3
|
+
*
|
|
4
|
+
* Key distinction:
|
|
5
|
+
* - McpConfigEntry: raw config from opencode.jsonc (has type, command, url, etc.)
|
|
6
|
+
* - McpServer: simplified name + description for display
|
|
7
|
+
* - Subagent: agent with MCP tool scoping, extracted from agent config
|
|
8
|
+
* - ScoredSubagent: subagent + relevance score from keyword matching
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MCP server config entry from opencode.jsonc.
|
|
13
|
+
*
|
|
14
|
+
* OpenCode supports both "env" and "environment" keys for env vars.
|
|
15
|
+
* "enabled" defaults to true when omitted — we only filter out explicit false.
|
|
16
|
+
*/
|
|
17
|
+
export interface McpConfigEntry {
|
|
18
|
+
type: "local" | "remote"
|
|
19
|
+
command?: string[]
|
|
20
|
+
args?: string[]
|
|
21
|
+
url?: string
|
|
22
|
+
env?: Record<string, string>
|
|
23
|
+
environment?: Record<string, string>
|
|
24
|
+
headers?: Record<string, string>
|
|
25
|
+
enabled?: boolean
|
|
26
|
+
description?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Simplified MCP server representation.
|
|
31
|
+
* Used for routing state and display — stripped of connection details.
|
|
32
|
+
*/
|
|
33
|
+
export interface McpServer {
|
|
34
|
+
name: string
|
|
35
|
+
description: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Subagent extracted from opencode.jsonc agent config.
|
|
40
|
+
*
|
|
41
|
+
* A subagent is any agent entry with:
|
|
42
|
+
* - mode !== "primary"
|
|
43
|
+
* - tools object containing at least one "servername_*": true entry
|
|
44
|
+
*
|
|
45
|
+
* The mcpServers array holds the server name prefixes (without _* suffix).
|
|
46
|
+
*/
|
|
47
|
+
export interface Subagent {
|
|
48
|
+
name: string
|
|
49
|
+
description: string
|
|
50
|
+
mcpServers: string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subagent enriched with a relevance score from triage matching.
|
|
55
|
+
*
|
|
56
|
+
* score: cumulative points from keyword matches across name, description, and MCP names
|
|
57
|
+
* matchedBy: human-readable explanation of which fields matched which keywords
|
|
58
|
+
*/
|
|
59
|
+
export interface ScoredSubagent {
|
|
60
|
+
subagent: Subagent
|
|
61
|
+
score: number
|
|
62
|
+
matchedBy: string
|
|
63
|
+
}
|