vibeostheog 0.19.1 → 0.19.3
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/plugins/vibeOS-tui.tsx +340 -0
- package/CHANGELOG.md +7 -0
- package/README.md +2 -0
- package/package.json +7 -3
- package/scripts/deploy.mjs +24 -0
- package/src/index.js +11 -6
- package/src/lib/vibeos-mcp-server.js +320 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { createSignal } from "solid-js"
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PORT = 9578
|
|
8
|
+
const TIERS_FILE = join(homedir(), ".claude/model-tiers.json")
|
|
9
|
+
|
|
10
|
+
function getBaseUrl() {
|
|
11
|
+
try {
|
|
12
|
+
if (existsSync(TIERS_FILE)) {
|
|
13
|
+
const tiers = JSON.parse(readFileSync(TIERS_FILE, "utf-8"))
|
|
14
|
+
const port = Number(tiers?.selection?.mcp_port)
|
|
15
|
+
if (Number.isFinite(port) && port > 0) return `http://localhost:${port}`
|
|
16
|
+
}
|
|
17
|
+
} catch {}
|
|
18
|
+
return `http://localhost:${DEFAULT_PORT}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type StatusResponse = {
|
|
22
|
+
todos: {
|
|
23
|
+
total: number
|
|
24
|
+
pending: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
enabled: boolean
|
|
28
|
+
active_slot: string
|
|
29
|
+
enforce: boolean
|
|
30
|
+
flow_enforcer: boolean
|
|
31
|
+
flow_extract_todos: boolean
|
|
32
|
+
tdd_enforcer: boolean
|
|
33
|
+
tdd_strict: boolean
|
|
34
|
+
thinking: string
|
|
35
|
+
current_model: string
|
|
36
|
+
credit_percent: number
|
|
37
|
+
version: string
|
|
38
|
+
backend_connected?: boolean
|
|
39
|
+
backend_health_url?: string | null
|
|
40
|
+
model_locked?: boolean
|
|
41
|
+
locked_slot?: string | null
|
|
42
|
+
locked_model?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type SavingsResponse = {
|
|
46
|
+
lifetime: {
|
|
47
|
+
delegation_usd: number
|
|
48
|
+
cache_usd: number
|
|
49
|
+
missed_context7_usd: number
|
|
50
|
+
total_warns: number
|
|
51
|
+
}
|
|
52
|
+
current_session: {
|
|
53
|
+
delegation_usd: number
|
|
54
|
+
cache_usd: number
|
|
55
|
+
warns_count: number
|
|
56
|
+
tool_breakdown: Record<string, number>
|
|
57
|
+
}
|
|
58
|
+
cache_hits_this_session: number
|
|
59
|
+
trend: string
|
|
60
|
+
savings_rate_per_hour: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Named export for TUI auto-discovery
|
|
64
|
+
export const vibeOSTui = async (api, _options, _meta) => {
|
|
65
|
+
try {
|
|
66
|
+
if (api?.ui?.toast) {
|
|
67
|
+
api.ui.toast({ variant: "info", message: "vibeOS TUI plugin executing" })
|
|
68
|
+
}
|
|
69
|
+
if (typeof process !== "undefined") {
|
|
70
|
+
process.stderr?.write?.("[vibeOS-tui] plugin function called\n")
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (typeof process !== "undefined") {
|
|
74
|
+
process.stderr?.write?.("[vibeOS-tui] ERROR: " + String(e) + "\n")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const plugin: TuiPlugin = async (api, _options, _meta) => {
|
|
80
|
+
const [status, setStatus] = createSignal<StatusResponse | null>(null)
|
|
81
|
+
const [savings, setSavings] = createSignal<SavingsResponse | null>(null)
|
|
82
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
83
|
+
|
|
84
|
+
const poll = async () => {
|
|
85
|
+
try {
|
|
86
|
+
const baseUrl = getBaseUrl()
|
|
87
|
+
const [s, sa] = await Promise.all([
|
|
88
|
+
fetch(`${baseUrl}/status`).then((r) => r.json()),
|
|
89
|
+
fetch(`${baseUrl}/savings`).then((r) => r.json()),
|
|
90
|
+
])
|
|
91
|
+
setStatus(s)
|
|
92
|
+
setSavings(sa)
|
|
93
|
+
setError(null)
|
|
94
|
+
} catch {
|
|
95
|
+
setError("vibeOS MCP offline")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await poll()
|
|
100
|
+
const timer = setInterval(poll, 3000)
|
|
101
|
+
|
|
102
|
+
api.lifecycle.onDispose(() => {
|
|
103
|
+
clearInterval(timer)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const doAction = async (body: Record<string, unknown>) => {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`${getBaseUrl()}/trinity`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
})
|
|
113
|
+
const data = await res.json()
|
|
114
|
+
if (data.ok) {
|
|
115
|
+
api.ui.toast({ variant: "success", message: data.result })
|
|
116
|
+
await poll()
|
|
117
|
+
} else {
|
|
118
|
+
api.ui.toast({ variant: "error", message: "Action failed" })
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
api.ui.toast({ variant: "error", message: "vibeOS offline" })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const Slot = api.ui.Slot
|
|
126
|
+
|
|
127
|
+
api.slots.register((props: { session_id: string }) => {
|
|
128
|
+
const s = status()
|
|
129
|
+
const sv = savings()
|
|
130
|
+
|
|
131
|
+
if (error()) {
|
|
132
|
+
return (
|
|
133
|
+
<box flexDirection="column">
|
|
134
|
+
<Slot name="sidebar_title" session_id={props.session_id} title="vibeOS">
|
|
135
|
+
<text dim>vibeOS offline</text>
|
|
136
|
+
</Slot>
|
|
137
|
+
<Slot name="sidebar_content" session_id={props.session_id}>
|
|
138
|
+
<box flexDirection="column" padding={1}>
|
|
139
|
+
<text dim>vibeOS MCP not running</text>
|
|
140
|
+
<newline />
|
|
141
|
+
<text dim>ensure the server plugin is active</text>
|
|
142
|
+
</box>
|
|
143
|
+
</Slot>
|
|
144
|
+
<Slot name="sidebar_footer" session_id={props.session_id}>
|
|
145
|
+
<text dim>vibeOS MCP offline</text>
|
|
146
|
+
</Slot>
|
|
147
|
+
</box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const activeSlot = s?.active_slot ?? "?"
|
|
152
|
+
const enabled = s?.enabled ?? false
|
|
153
|
+
const trendArrow = sv?.trend === "up" ? "^" : sv?.trend === "down" ? "v" : "-"
|
|
154
|
+
const delegation = (sv?.current_session?.delegation_usd ?? 0) + (sv?.lifetime?.delegation_usd ?? 0)
|
|
155
|
+
const cache = (sv?.current_session?.cache_usd ?? 0) + (sv?.lifetime?.cache_usd ?? 0)
|
|
156
|
+
const lifetime = (sv?.lifetime?.delegation_usd ?? 0) + (sv?.lifetime?.cache_usd ?? 0)
|
|
157
|
+
const missedC7 = sv?.lifetime?.missed_context7_usd ?? 0
|
|
158
|
+
const toolBreakdown = sv?.current_session?.tool_breakdown ?? {}
|
|
159
|
+
const topTools = Object.entries(toolBreakdown)
|
|
160
|
+
.sort(([, a], [, b]) => (b as number) - (a as number))
|
|
161
|
+
.slice(0, 5)
|
|
162
|
+
const trendColor = sv?.trend === "up" ? "green" : sv?.trend === "down" ? "red" : "yellow"
|
|
163
|
+
const shortModel = s?.current_model?.split("/")[1] ?? s?.current_model ?? "?"
|
|
164
|
+
const flowOn = s?.flow_enforcer ?? false
|
|
165
|
+
const tddOn = s?.tdd_enforcer ?? false
|
|
166
|
+
const backendConnected = s?.backend_connected ?? false
|
|
167
|
+
const lockLabel = s?.model_locked
|
|
168
|
+
? `${s?.locked_slot ? `${s.locked_slot} ` : ""}${s?.locked_model ?? ""}`.trim()
|
|
169
|
+
: "off"
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<box flexDirection="column">
|
|
173
|
+
<Slot name="sidebar_title" session_id={props.session_id} title="vibeOS">
|
|
174
|
+
<box>
|
|
175
|
+
<text bold>vibeOS</text>
|
|
176
|
+
<text dim> | </text>
|
|
177
|
+
<text color={enabled ? "green" : "red"} bold>{activeSlot}</text>
|
|
178
|
+
<text> </text>
|
|
179
|
+
<text color={enabled ? "green" : "red"}>{enabled ? "." : "o"}</text>
|
|
180
|
+
</box>
|
|
181
|
+
</Slot>
|
|
182
|
+
<Slot name="sidebar_content" session_id={props.session_id}>
|
|
183
|
+
<box flexDirection="column" padding={1}>
|
|
184
|
+
<text dim bold>MODEL STATUS</text>
|
|
185
|
+
<newline />
|
|
186
|
+
<box>
|
|
187
|
+
<text bold={activeSlot === "brain"} color={activeSlot === "brain" ? "green" : undefined}>
|
|
188
|
+
{shortModel}
|
|
189
|
+
</text>
|
|
190
|
+
{activeSlot === "brain" && <text color="green"> active</text>}
|
|
191
|
+
</box>
|
|
192
|
+
<newline />
|
|
193
|
+
<box>
|
|
194
|
+
<text>Backend </text>
|
|
195
|
+
<text color={backendConnected ? "green" : "red"} bold>{backendConnected ? "ON" : "OFF"}</text>
|
|
196
|
+
</box>
|
|
197
|
+
<newline />
|
|
198
|
+
<box>
|
|
199
|
+
<text>Lock </text>
|
|
200
|
+
<text color={s?.model_locked ? "green" : "red"} bold>{s?.model_locked ? "ON" : "OFF"}</text>
|
|
201
|
+
{s?.model_locked && lockLabel && <text dim> {lockLabel}</text>}
|
|
202
|
+
</box>
|
|
203
|
+
<newline />
|
|
204
|
+
<text dim>---</text>
|
|
205
|
+
<newline />
|
|
206
|
+
<box>
|
|
207
|
+
<text>Flow </text>
|
|
208
|
+
<text color={flowOn ? "green" : "red"} bold>{flowOn ? "ON" : "OFF"}</text>
|
|
209
|
+
</box>
|
|
210
|
+
<newline />
|
|
211
|
+
<box>
|
|
212
|
+
<text>TDD </text>
|
|
213
|
+
<text color={tddOn ? "green" : "red"} bold>{tddOn ? "ON" : "OFF"}</text>
|
|
214
|
+
{tddOn && s?.tdd_strict && <text dim> strict</text>}
|
|
215
|
+
</box>
|
|
216
|
+
<newline />
|
|
217
|
+
<box>
|
|
218
|
+
<text>Enforce </text>
|
|
219
|
+
<text color={s?.enforce ? "green" : "red"} bold>{s?.enforce ? "ON" : "OFF"}</text>
|
|
220
|
+
</box>
|
|
221
|
+
<newline />
|
|
222
|
+
<text dim>---</text>
|
|
223
|
+
<newline />
|
|
224
|
+
<box>
|
|
225
|
+
<text>Thinking: </text>
|
|
226
|
+
<text>{s?.thinking ?? "?"}</text>
|
|
227
|
+
</box>
|
|
228
|
+
<newline />
|
|
229
|
+
<newline />
|
|
230
|
+
<text dim bold>SAVINGS</text>
|
|
231
|
+
<newline />
|
|
232
|
+
<box>
|
|
233
|
+
<text bold>Saved: </text>
|
|
234
|
+
<text bold color={trendColor}>${lifetime.toFixed(2)} {trendArrow}</text>
|
|
235
|
+
</box>
|
|
236
|
+
<newline />
|
|
237
|
+
<box><text> Delegation: </text><text>${delegation.toFixed(2)}</text></box>
|
|
238
|
+
<newline />
|
|
239
|
+
<box><text> Cache: </text><text>${cache.toFixed(2)}</text></box>
|
|
240
|
+
<newline />
|
|
241
|
+
<box><text> C7 missed: </text><text>${missedC7.toFixed(2)}</text></box>
|
|
242
|
+
<newline />
|
|
243
|
+
<text dim>---</text>
|
|
244
|
+
<newline />
|
|
245
|
+
<box><text>Rate: </text><text>${sv?.savings_rate_per_hour?.toFixed(2) ?? "0.00"}/hr</text></box>
|
|
246
|
+
<newline />
|
|
247
|
+
<box><text>Warns: </text><text>{sv?.current_session?.warns_count ?? 0}</text></box>
|
|
248
|
+
<newline />
|
|
249
|
+
<text dim>---</text>
|
|
250
|
+
<newline />
|
|
251
|
+
<text dim>Tool split:</text>
|
|
252
|
+
<newline />
|
|
253
|
+
{topTools.map(([tool, val]) => (
|
|
254
|
+
<newline />
|
|
255
|
+
<text dim bold>TODOS</text>
|
|
256
|
+
<newline />
|
|
257
|
+
<box>
|
|
258
|
+
<text>Pending: </text>
|
|
259
|
+
<text color={s?.todos?.pending > 0 ? "yellow" : "green"} bold>
|
|
260
|
+
{s?.todos?.pending ?? 0}
|
|
261
|
+
</text>
|
|
262
|
+
<text> / {s?.todos?.total ?? 0}</text>
|
|
263
|
+
</box>
|
|
264
|
+
<newline />
|
|
265
|
+
|
|
266
|
+
<>
|
|
267
|
+
<box>
|
|
268
|
+
<text> {tool.padEnd(8)}</text>
|
|
269
|
+
<text>${(val as number).toFixed(2)}</text>
|
|
270
|
+
</box>
|
|
271
|
+
<newline />
|
|
272
|
+
</>
|
|
273
|
+
))}
|
|
274
|
+
<newline />
|
|
275
|
+
<text dim bold>CONTROLS</text>
|
|
276
|
+
<newline />
|
|
277
|
+
<box>
|
|
278
|
+
<text
|
|
279
|
+
onClick={() => doAction({ action: "set", slot: "brain", level: null })}
|
|
280
|
+
color={activeSlot === "brain" ? "green" : "dim"}
|
|
281
|
+
bold={activeSlot === "brain"}
|
|
282
|
+
>[brain]</text>
|
|
283
|
+
<text> </text>
|
|
284
|
+
<text
|
|
285
|
+
onClick={() => doAction({ action: "set", slot: "medium", level: null })}
|
|
286
|
+
color={activeSlot === "medium" ? "green" : "dim"}
|
|
287
|
+
bold={activeSlot === "medium"}
|
|
288
|
+
>[medium]</text>
|
|
289
|
+
<text> </text>
|
|
290
|
+
<text
|
|
291
|
+
onClick={() => doAction({ action: "set", slot: "cheap", level: null })}
|
|
292
|
+
color={activeSlot === "cheap" ? "green" : "dim"}
|
|
293
|
+
bold={activeSlot === "cheap"}
|
|
294
|
+
>[cheap]</text>
|
|
295
|
+
</box>
|
|
296
|
+
<newline />
|
|
297
|
+
<box>
|
|
298
|
+
<text
|
|
299
|
+
onClick={() => doAction({ action: "flow", slot: flowOn ? "off" : "on", level: null })}
|
|
300
|
+
color={flowOn ? "green" : "red"} bold
|
|
301
|
+
>[Flow {flowOn ? "ON" : "OFF"}]</text>
|
|
302
|
+
</box>
|
|
303
|
+
<newline />
|
|
304
|
+
<box>
|
|
305
|
+
<text
|
|
306
|
+
onClick={() => doAction({ action: "tdd", slot: tddOn ? "off" : "on", level: null })}
|
|
307
|
+
color={tddOn ? "green" : "red"} bold
|
|
308
|
+
>[TDD {tddOn ? "ON" : "OFF"}]</text>
|
|
309
|
+
</box>
|
|
310
|
+
<newline />
|
|
311
|
+
<box>
|
|
312
|
+
<text
|
|
313
|
+
onClick={() => doAction({ action: "enforce", slot: s?.enforce ? "off" : "on", level: null })}
|
|
314
|
+
color={s?.enforce ? "green" : "red"} bold
|
|
315
|
+
>[Enforce {s?.enforce ? "ON" : "OFF"}]</text>
|
|
316
|
+
</box>
|
|
317
|
+
<newline />
|
|
318
|
+
<box>
|
|
319
|
+
<text
|
|
320
|
+
onClick={() => doAction({ action: "disable", slot: null, level: null })}
|
|
321
|
+
color="red" bold
|
|
322
|
+
>[Disable]</text>
|
|
323
|
+
</box>
|
|
324
|
+
</box>
|
|
325
|
+
</Slot>
|
|
326
|
+
<Slot name="sidebar_footer" session_id={props.session_id}>
|
|
327
|
+
<box>
|
|
328
|
+
<text dim>Saved </text>
|
|
329
|
+
<text color={trendColor}>{lifetime.toFixed(2)}</text>
|
|
330
|
+
<text dim> {trendArrow} </text>
|
|
331
|
+
<text>{sv?.savings_rate_per_hour?.toFixed(2) ?? "0.00"}/hr</text>
|
|
332
|
+
<text dim> | {sv?.current_session?.warns_count ?? 0} warns</text>
|
|
333
|
+
</box>
|
|
334
|
+
</Slot>
|
|
335
|
+
</box>
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default { tui: plugin }
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 0.19.3
|
|
2
|
+
- fix: auto-register vibeOS plugin in opencode.json during deploy
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
## 0.19.2
|
|
6
|
+
- fix: export server and tui entrypoints
|
|
7
|
+
|
|
1
8
|
## 0.19.1
|
|
2
9
|
- fix: make README and runtime self-contained
|
|
3
10
|
- test: add 59 integration + e2e tests for cross-module behavior and user workflows
|
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ If you keep a local checkout of the plugin, point OpenCode at the built file ins
|
|
|
48
48
|
|
|
49
49
|
Restart OpenCode Desktop after changing the config.
|
|
50
50
|
|
|
51
|
+
The package also exposes `vibeostheog/server` and `vibeostheog/tui` for integrations that need the MCP server or sidebar plugin entrypoints directly.
|
|
52
|
+
|
|
51
53
|
## Common Npm Commands
|
|
52
54
|
|
|
53
55
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeostheog",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "Cost-aware delegation enforcer for OpenCode. Tracks model usage, routes Task subagents to cheaper tiers, surfaces cumulative savings in chat. Includes research audit, reporting framework, project memory, progressive scratchpad decadence, and trinity CLI for brain/medium/cheap slot switching.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"release": "node scripts/release.mjs",
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
"precommit": "node scripts/pre-commit.mjs",
|
|
23
23
|
"audit-state": "node scripts/audit-state.mjs",
|
|
24
24
|
"migrate-ledger": "node scripts/migrate-ledger.mjs",
|
|
25
|
-
"postinstall": "node scripts/deploy.mjs
|
|
25
|
+
"postinstall": "node scripts/deploy.mjs"
|
|
26
26
|
},
|
|
27
27
|
"type": "module",
|
|
28
28
|
"exports": {
|
|
29
|
-
".": "./src/index.js"
|
|
29
|
+
".": "./src/index.js",
|
|
30
|
+
"./server": "./src/lib/vibeos-mcp-server.js",
|
|
31
|
+
"./tui": "./.opencode/plugins/vibeOS-tui.tsx"
|
|
30
32
|
},
|
|
31
33
|
"keywords": [
|
|
32
34
|
"opencode",
|
|
@@ -46,6 +48,8 @@
|
|
|
46
48
|
},
|
|
47
49
|
"files": [
|
|
48
50
|
"src/index.js",
|
|
51
|
+
"src/lib/vibeos-mcp-server.js",
|
|
52
|
+
".opencode/plugins/vibeOS-tui.tsx",
|
|
49
53
|
"scripts/deploy.mjs",
|
|
50
54
|
"model-tiers.sample.json",
|
|
51
55
|
"README.md",
|
package/scripts/deploy.mjs
CHANGED
|
@@ -110,6 +110,30 @@ try {
|
|
|
110
110
|
process.stderr.write("[vibeOS deploy] Or install via WSL and use crontab there.\n")
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Auto-register in opencode.json so OpenCode loads the plugin
|
|
114
|
+
try {
|
|
115
|
+
const ocConfigPath = join(homedir(), ".config", "opencode", "opencode.json")
|
|
116
|
+
if (existsSync(ocConfigPath)) {
|
|
117
|
+
const raw = readFileSync(ocConfigPath, "utf-8")
|
|
118
|
+
let config = {}
|
|
119
|
+
try {
|
|
120
|
+
const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
|
|
121
|
+
config = JSON.parse(cleaned)
|
|
122
|
+
} catch {
|
|
123
|
+
config = {}
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(config.plugin)) config.plugin = []
|
|
126
|
+
const hasVibeOs = config.plugin.some(p => typeof p === "string" && p.includes("vibeOS"))
|
|
127
|
+
if (!hasVibeOs) {
|
|
128
|
+
config.plugin.push("./plugins/vibeOS.js")
|
|
129
|
+
writeFileSync(ocConfigPath, JSON.stringify(config, null, 2) + "\n")
|
|
130
|
+
process.stderr.write("[vibeOS deploy] Registered vibeOS in opencode.json\n")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
process.stderr.write("[vibeOS deploy] Could not auto-register in opencode.json (plugin may need manual config)\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
process.stderr.write("[vibeOS deploy] Done\n")
|
|
114
138
|
} catch (e) {
|
|
115
139
|
process.stderr.write(`[vibeOS deploy] ERROR: ${e.message}\n`)
|
package/src/index.js
CHANGED
|
@@ -545,7 +545,7 @@ function computeSessionMetrics(state, sessionId) {
|
|
|
545
545
|
};
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
// src/lib/vibeos-mcp-server.
|
|
548
|
+
// src/lib/vibeos-mcp-server.js
|
|
549
549
|
import http from "node:http";
|
|
550
550
|
import { parse as parseUrl } from "node:url";
|
|
551
551
|
import { createReadStream, existsSync as existsSync2, statSync as statSync2 } from "node:fs";
|
|
@@ -594,7 +594,8 @@ function resolveDashboardDir() {
|
|
|
594
594
|
join2(_MCP_DIR, "dashboard", "dist")
|
|
595
595
|
];
|
|
596
596
|
for (const p of c) {
|
|
597
|
-
if (existsSync2(join2(p, "index.html")))
|
|
597
|
+
if (existsSync2(join2(p, "index.html")))
|
|
598
|
+
return p;
|
|
598
599
|
}
|
|
599
600
|
return c[0];
|
|
600
601
|
}
|
|
@@ -820,8 +821,10 @@ function createMcpServer(deps) {
|
|
|
820
821
|
};
|
|
821
822
|
return {
|
|
822
823
|
async start(port) {
|
|
823
|
-
if (server2)
|
|
824
|
-
|
|
824
|
+
if (server2)
|
|
825
|
+
return server2;
|
|
826
|
+
if (startPromise)
|
|
827
|
+
return startPromise;
|
|
825
828
|
startPromise = new Promise((resolve, reject) => {
|
|
826
829
|
const srv = http.createServer((req, res) => {
|
|
827
830
|
void handler(req, res);
|
|
@@ -839,8 +842,10 @@ function createMcpServer(deps) {
|
|
|
839
842
|
}
|
|
840
843
|
},
|
|
841
844
|
async close() {
|
|
842
|
-
if (!server2)
|
|
843
|
-
|
|
845
|
+
if (!server2)
|
|
846
|
+
return;
|
|
847
|
+
if (closePromise)
|
|
848
|
+
return closePromise;
|
|
844
849
|
closePromise = new Promise((resolve, reject) => {
|
|
845
850
|
server2?.close((err) => err ? reject(err) : resolve());
|
|
846
851
|
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 vibeOS <https://github.com/DrunkkToys/vibeOS>
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { parse as parseUrl } from "node:url";
|
|
5
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
6
|
+
import { extname, join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const MIME_MAP = {
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".js": "application/javascript; charset=utf-8",
|
|
11
|
+
".css": "text/css; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".ico": "image/x-icon",
|
|
15
|
+
};
|
|
16
|
+
function json(res, statusCode, data) {
|
|
17
|
+
res.statusCode = statusCode;
|
|
18
|
+
res.setHeader("Content-Type", "application/json");
|
|
19
|
+
res.end(JSON.stringify(data));
|
|
20
|
+
}
|
|
21
|
+
function parseBody(req) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let raw = "";
|
|
24
|
+
req.on("data", (chunk) => {
|
|
25
|
+
raw += String(chunk || "");
|
|
26
|
+
if (raw.length > 1024 * 1024) {
|
|
27
|
+
reject(new Error("payload too large"));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
req.on("end", () => {
|
|
31
|
+
if (!raw.trim()) {
|
|
32
|
+
resolve({});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
resolve(JSON.parse(raw));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
reject(new Error("invalid request"));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
req.on("error", reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const _MCP_FILENAME = fileURLToPath(import.meta.url);
|
|
46
|
+
const _MCP_DIR = dirname(_MCP_FILENAME);
|
|
47
|
+
function resolveDashboardDir() {
|
|
48
|
+
const c = [
|
|
49
|
+
join(_MCP_DIR, "dashboard", "dist"),
|
|
50
|
+
];
|
|
51
|
+
for (const p of c) {
|
|
52
|
+
if (existsSync(join(p, "index.html")))
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
return c[0];
|
|
56
|
+
}
|
|
57
|
+
const DASHBOARD_DIR = resolveDashboardDir();
|
|
58
|
+
const BACKEND_HEALTH_URL = process.env.VIBEOS_BACKEND_HEALTH_URL || "http://127.0.0.1:3000/health";
|
|
59
|
+
const BACKEND_HEALTH_TTL_MS = 5_000;
|
|
60
|
+
let backendHealth = { ok: null, checkedAt: 0 };
|
|
61
|
+
async function probeBackendHealth(force = false) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (!force && backendHealth.ok !== null && (now - backendHealth.checkedAt) < BACKEND_HEALTH_TTL_MS) {
|
|
64
|
+
return backendHealth.ok;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const ctl = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => ctl.abort(), 1500);
|
|
69
|
+
const res = await fetch(BACKEND_HEALTH_URL, { signal: ctl.signal });
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
backendHealth = { ok: res.ok, checkedAt: now };
|
|
72
|
+
return res.ok;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
backendHealth = { ok: false, checkedAt: now };
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function sendFile(res, fp) {
|
|
80
|
+
if (!existsSync(fp)) {
|
|
81
|
+
res.statusCode = 404;
|
|
82
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
83
|
+
res.end("not found");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const ext = extname(fp).toLowerCase();
|
|
87
|
+
const mime = MIME_MAP[ext] || "application/octet-stream";
|
|
88
|
+
const st = statSync(fp);
|
|
89
|
+
res.statusCode = 200;
|
|
90
|
+
res.setHeader("Content-Type", mime);
|
|
91
|
+
res.setHeader("Content-Length", st.size);
|
|
92
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
93
|
+
const s = createReadStream(fp);
|
|
94
|
+
s.pipe(res);
|
|
95
|
+
s.on("error", () => { res.statusCode = 500; res.end(); });
|
|
96
|
+
}
|
|
97
|
+
function serveDashboard(res, p) {
|
|
98
|
+
const idx = join(DASHBOARD_DIR, "index.html");
|
|
99
|
+
let fp = join(DASHBOARD_DIR, p === "/" ? "index.html" : p);
|
|
100
|
+
if (existsSync(fp) && statSync(fp).isFile()) {
|
|
101
|
+
sendFile(res, fp);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (existsSync(idx)) {
|
|
105
|
+
sendFile(res, idx);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
res.statusCode = 404;
|
|
109
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
110
|
+
res.end("not found");
|
|
111
|
+
}
|
|
112
|
+
export function createMcpServer(deps) {
|
|
113
|
+
let server = null;
|
|
114
|
+
let startPromise = null;
|
|
115
|
+
let closePromise = null;
|
|
116
|
+
const handler = async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const method = (req.method || "GET").toUpperCase();
|
|
119
|
+
const parsed = parseUrl(req.url || "/", true);
|
|
120
|
+
const path = parsed.pathname || "/";
|
|
121
|
+
if (method === "GET" && path === "/status") {
|
|
122
|
+
const state = deps.getState();
|
|
123
|
+
const ok = await probeBackendHealth();
|
|
124
|
+
json(res, 200, { ...state, backend_connected: ok === true, backend_health_url: BACKEND_HEALTH_URL });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (method === "GET" && path === "/savings") {
|
|
128
|
+
json(res, 200, deps.getSavings());
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (method === "GET" && path === "/todos") {
|
|
132
|
+
json(res, 200, deps.getTodos());
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (method === "GET" && path === "/sessions") {
|
|
136
|
+
const state = deps.getState();
|
|
137
|
+
const sessionsMap = state?.sessions_raw || {};
|
|
138
|
+
const sessions = Object.entries(sessionsMap).map(([id, ses]) => ({
|
|
139
|
+
id,
|
|
140
|
+
started: ses?.started || null,
|
|
141
|
+
cost_usd: Number(ses?.cost_usd ?? 0) || 0,
|
|
142
|
+
delegation_savings_usd: Array.isArray(ses?.warns)
|
|
143
|
+
? ses.warns.reduce((sum, w) => sum + (Number(w?.est_savings_usd ?? 0) || 0), 0)
|
|
144
|
+
: ses?.total_savings_usd || 0,
|
|
145
|
+
cache_savings_usd: Number(ses?.cache_savings_usd ?? 0) || 0,
|
|
146
|
+
warns_count: Array.isArray(ses?.warns) ? ses.warns.length : 0,
|
|
147
|
+
}));
|
|
148
|
+
json(res, 200, { sessions, total_sessions: sessions.length });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (method === "GET" && path === "/sessions/current") {
|
|
152
|
+
json(res, 200, deps.getSessionMetrics(deps.getCurrentSessionId()));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (method === "GET" && path === "/reports") {
|
|
156
|
+
try {
|
|
157
|
+
const query = parsed.query;
|
|
158
|
+
const type = typeof query.type === "string" ? query.type : undefined;
|
|
159
|
+
const project = typeof query.project === "string" ? query.project : undefined;
|
|
160
|
+
const hoursRaw = query.hours;
|
|
161
|
+
const hours = hoursRaw != null ? Number(hoursRaw) : undefined;
|
|
162
|
+
const fingerprint = typeof query.fingerprint === "string" ? query.fingerprint : undefined;
|
|
163
|
+
const reports = deps.listReports({ type, project, hours: Number.isFinite(hours) ? hours : undefined, fingerprint });
|
|
164
|
+
json(res, 200, reports);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
const error = err;
|
|
168
|
+
if (error?.status === 404) {
|
|
169
|
+
json(res, 404, { error: "not found", status: 404 });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (method === "GET" && path.startsWith("/reports/")) {
|
|
177
|
+
const id = decodeURIComponent(path.replace(/^\/reports\//, "")).trim();
|
|
178
|
+
const report = deps.readReport(id);
|
|
179
|
+
if (!report) {
|
|
180
|
+
json(res, 404, { error: "not found", status: 404 });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
json(res, 200, report);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (method === "GET" && path === "/diagnose") {
|
|
187
|
+
json(res, 200, deps.runDiagnose());
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (method === "GET" && path === "/project") {
|
|
191
|
+
json(res, 200, deps.runProject());
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (method === "POST" && path === "/trinity") {
|
|
195
|
+
let body;
|
|
196
|
+
try {
|
|
197
|
+
body = await parseBody(req);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
json(res, 400, { error: "invalid request", status: 400 });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const action = body?.action;
|
|
204
|
+
const slot = body?.slot;
|
|
205
|
+
const level = body?.level;
|
|
206
|
+
if (!action || typeof action !== "string") {
|
|
207
|
+
json(res, 400, { error: "invalid request", status: 400 });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const result = await deps.runTrinity(action, { slot, level });
|
|
211
|
+
const txt = typeof result === "string" ? result : JSON.stringify(result);
|
|
212
|
+
const ok = !(txt.startsWith("❌") || txt.toLowerCase().includes("unknown action"));
|
|
213
|
+
json(res, ok ? 200 : 400, ok ? { ok: true, result } : { ok: false, error: txt });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (method === "POST" && path === "/research-audit") {
|
|
217
|
+
let body;
|
|
218
|
+
try {
|
|
219
|
+
body = await parseBody(req);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
json(res, 400, { error: "invalid request", status: 400 });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const hours = Number(body?.hours ?? 24);
|
|
226
|
+
const report = deps.runResearchAudit(Number.isFinite(hours) ? hours : 24);
|
|
227
|
+
json(res, 200, report);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (method === "POST" && path === "/reports") {
|
|
231
|
+
let body;
|
|
232
|
+
try {
|
|
233
|
+
body = await parseBody(req);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
json(res, 400, { error: "invalid request", status: 400 });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!body || typeof body !== "object") {
|
|
240
|
+
json(res, 400, { error: "invalid request", status: 400 });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const id = deps.saveReport({
|
|
244
|
+
type: "manual",
|
|
245
|
+
summary: body.summary || "",
|
|
246
|
+
findings: body.findings || [],
|
|
247
|
+
metrics: body.metrics || {},
|
|
248
|
+
narrative: body.narrative || "",
|
|
249
|
+
tags: Array.isArray(body.tags) ? body.tags : [],
|
|
250
|
+
});
|
|
251
|
+
if (!id) {
|
|
252
|
+
json(res, 500, { error: "failed to save report", status: 500 });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
json(res, 200, { ok: true, id });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (method === "POST" && path === "/sessions/checkout") {
|
|
259
|
+
const result = deps.generateSessionCheckout();
|
|
260
|
+
json(res, 200, result);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (method === "GET" && path === "/") {
|
|
264
|
+
serveDashboard(res, "/");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (method === "GET" && (path.startsWith("/assets/") || path.startsWith("/favicon") || path.endsWith(".js") || path.endsWith(".css") || path.endsWith(".html"))) {
|
|
268
|
+
serveDashboard(res, path);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (method === "GET" && path === "/health") {
|
|
272
|
+
json(res, 200, { ok: true });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
json(res, 404, { error: "not found", status: 404 });
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
const message = err instanceof Error ? err.message : "server error";
|
|
279
|
+
json(res, 500, { error: message, status: 500 });
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
async start(port) {
|
|
284
|
+
if (server)
|
|
285
|
+
return server;
|
|
286
|
+
if (startPromise)
|
|
287
|
+
return startPromise;
|
|
288
|
+
startPromise = new Promise((resolve, reject) => {
|
|
289
|
+
const srv = http.createServer((req, res) => { void handler(req, res); });
|
|
290
|
+
srv.once("error", reject);
|
|
291
|
+
srv.listen(port, () => {
|
|
292
|
+
server = srv;
|
|
293
|
+
resolve(srv);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
return await startPromise;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
startPromise = null;
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
async close() {
|
|
304
|
+
if (!server)
|
|
305
|
+
return;
|
|
306
|
+
if (closePromise)
|
|
307
|
+
return closePromise;
|
|
308
|
+
closePromise = new Promise((resolve, reject) => {
|
|
309
|
+
server?.close(err => err ? reject(err) : resolve());
|
|
310
|
+
});
|
|
311
|
+
try {
|
|
312
|
+
await closePromise;
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
server = null;
|
|
316
|
+
closePromise = null;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|