ghost-dragon 4.2.1
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/.github/workflows/ci.yml +23 -0
- package/CHANGELOG.md +96 -0
- package/README.md +193 -0
- package/bootstrap.ps1 +83 -0
- package/bootstrap.sh +71 -0
- package/dist/agent/loop.d.ts +68 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +135 -0
- package/dist/agent/mcp.d.ts +33 -0
- package/dist/agent/mcp.d.ts.map +1 -0
- package/dist/agent/mcp.js +107 -0
- package/dist/agent/session.d.ts +16 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +55 -0
- package/dist/agent/skills.d.ts +36 -0
- package/dist/agent/skills.d.ts.map +1 -0
- package/dist/agent/skills.js +153 -0
- package/dist/agent/stack.d.ts +21 -0
- package/dist/agent/stack.d.ts.map +1 -0
- package/dist/agent/stack.js +158 -0
- package/dist/agent/task.d.ts +21 -0
- package/dist/agent/task.d.ts.map +1 -0
- package/dist/agent/task.js +45 -0
- package/dist/agent/tools.d.ts +44 -0
- package/dist/agent/tools.d.ts.map +1 -0
- package/dist/agent/tools.js +262 -0
- package/dist/agent/trace.d.ts +34 -0
- package/dist/agent/trace.d.ts.map +1 -0
- package/dist/agent/trace.js +72 -0
- package/dist/agent.d.ts +46 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +103 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +116 -0
- package/dist/brain/anthropic.d.ts +19 -0
- package/dist/brain/anthropic.d.ts.map +1 -0
- package/dist/brain/anthropic.js +74 -0
- package/dist/brain/claude-cli.d.ts +20 -0
- package/dist/brain/claude-cli.d.ts.map +1 -0
- package/dist/brain/claude-cli.js +79 -0
- package/dist/brain/ghost-ember.d.ts +28 -0
- package/dist/brain/ghost-ember.d.ts.map +1 -0
- package/dist/brain/ghost-ember.js +97 -0
- package/dist/brain/index.d.ts +22 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +95 -0
- package/dist/brain/openai-compat.d.ts +21 -0
- package/dist/brain/openai-compat.d.ts.map +1 -0
- package/dist/brain/openai-compat.js +119 -0
- package/dist/brain/router/classify.d.ts +23 -0
- package/dist/brain/router/classify.d.ts.map +1 -0
- package/dist/brain/router/classify.js +160 -0
- package/dist/brain/router/execute.d.ts +23 -0
- package/dist/brain/router/execute.d.ts.map +1 -0
- package/dist/brain/router/execute.js +84 -0
- package/dist/brain/router/index.d.ts +26 -0
- package/dist/brain/router/index.d.ts.map +1 -0
- package/dist/brain/router/index.js +118 -0
- package/dist/brain/router/routing-memory.d.ts +27 -0
- package/dist/brain/router/routing-memory.d.ts.map +1 -0
- package/dist/brain/router/routing-memory.js +77 -0
- package/dist/brain/router/select.d.ts +32 -0
- package/dist/brain/router/select.d.ts.map +1 -0
- package/dist/brain/router/select.js +146 -0
- package/dist/brain/router/two-hop.d.ts +23 -0
- package/dist/brain/router/two-hop.d.ts.map +1 -0
- package/dist/brain/router/two-hop.js +39 -0
- package/dist/brain/router/verify.d.ts +37 -0
- package/dist/brain/router/verify.d.ts.map +1 -0
- package/dist/brain/router/verify.js +111 -0
- package/dist/brain/types.d.ts +55 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +16 -0
- package/dist/brain/worker.d.ts +27 -0
- package/dist/brain/worker.d.ts.map +1 -0
- package/dist/brain/worker.js +71 -0
- package/dist/commands/ai.d.ts +24 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +137 -0
- package/dist/commands/alerts.d.ts +19 -0
- package/dist/commands/alerts.d.ts.map +1 -0
- package/dist/commands/alerts.js +114 -0
- package/dist/commands/billing.d.ts +13 -0
- package/dist/commands/billing.d.ts.map +1 -0
- package/dist/commands/billing.js +55 -0
- package/dist/commands/chat.d.ts +22 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +422 -0
- package/dist/commands/config.d.ts +18 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +136 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/global.d.ts +11 -0
- package/dist/commands/global.d.ts.map +1 -0
- package/dist/commands/global.js +253 -0
- package/dist/commands/keep.d.ts +12 -0
- package/dist/commands/keep.d.ts.map +1 -0
- package/dist/commands/keep.js +58 -0
- package/dist/commands/lifecycle.d.ts +17 -0
- package/dist/commands/lifecycle.d.ts.map +1 -0
- package/dist/commands/lifecycle.js +267 -0
- package/dist/commands/login.d.ts +16 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +234 -0
- package/dist/commands/maintenance.d.ts +12 -0
- package/dist/commands/maintenance.d.ts.map +1 -0
- package/dist/commands/maintenance.js +76 -0
- package/dist/commands/mcp.d.ts +16 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +56 -0
- package/dist/commands/memory.d.ts +13 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +218 -0
- package/dist/commands/osint.d.ts +14 -0
- package/dist/commands/osint.d.ts.map +1 -0
- package/dist/commands/osint.js +161 -0
- package/dist/commands/pentest.d.ts +13 -0
- package/dist/commands/pentest.d.ts.map +1 -0
- package/dist/commands/pentest.js +131 -0
- package/dist/commands/scale.d.ts +14 -0
- package/dist/commands/scale.d.ts.map +1 -0
- package/dist/commands/scale.js +191 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +167 -0
- package/dist/commands/tui.d.ts +17 -0
- package/dist/commands/tui.d.ts.map +1 -0
- package/dist/commands/tui.js +138 -0
- package/dist/commands/wyrm.d.ts +20 -0
- package/dist/commands/wyrm.d.ts.map +1 -0
- package/dist/commands/wyrm.js +274 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +83 -0
- package/dist/ui.d.ts +57 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +174 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +155 -0
- package/dist/wyrm/mcp.d.ts +37 -0
- package/dist/wyrm/mcp.d.ts.map +1 -0
- package/dist/wyrm/mcp.js +137 -0
- package/docs/SYSTEM-PREMORTEM.md +397 -0
- package/dragon-manifest.toml +241 -0
- package/dragon.py +177 -0
- package/install/launchd/lk.ghosts.dragonkeep.plist +57 -0
- package/install/systemd/dragonkeep.service +40 -0
- package/media/dragon-silver-lockup.svg +931 -0
- package/media/dragon-silver-mark.svg +931 -0
- package/media/dragon-silver.png +0 -0
- package/package.json +45 -0
- package/specs/001-godmode/constitution.md +54 -0
- package/specs/001-godmode/plan.md +30 -0
- package/specs/001-godmode/spec.md +64 -0
- package/specs/001-godmode/tasks.md +35 -0
- package/specs/002-premortem-positioning/premortem.md +211 -0
- package/src/agent/loop.ts +165 -0
- package/src/agent/mcp.ts +92 -0
- package/src/agent/session.ts +48 -0
- package/src/agent/skills.ts +138 -0
- package/src/agent/stack.ts +154 -0
- package/src/agent/task.ts +55 -0
- package/src/agent/tools.ts +255 -0
- package/src/agent/trace.ts +76 -0
- package/src/agent.ts +114 -0
- package/src/auth.ts +133 -0
- package/src/brain/anthropic.ts +83 -0
- package/src/brain/claude-cli.ts +78 -0
- package/src/brain/ghost-ember.ts +94 -0
- package/src/brain/index.ts +99 -0
- package/src/brain/openai-compat.ts +115 -0
- package/src/brain/router/classify.ts +167 -0
- package/src/brain/router/execute.ts +80 -0
- package/src/brain/router/index.ts +125 -0
- package/src/brain/router/routing-memory.ts +71 -0
- package/src/brain/router/select.ts +156 -0
- package/src/brain/router/two-hop.ts +62 -0
- package/src/brain/router/verify.ts +123 -0
- package/src/brain/types.ts +61 -0
- package/src/brain/worker.ts +72 -0
- package/src/commands/ai.ts +144 -0
- package/src/commands/alerts.ts +131 -0
- package/src/commands/billing.ts +59 -0
- package/src/commands/chat.ts +318 -0
- package/src/commands/config.ts +137 -0
- package/src/commands/doctor.ts +71 -0
- package/src/commands/global.ts +256 -0
- package/src/commands/keep.ts +67 -0
- package/src/commands/lifecycle.ts +273 -0
- package/src/commands/login.ts +184 -0
- package/src/commands/maintenance.ts +54 -0
- package/src/commands/mcp.ts +57 -0
- package/src/commands/memory.ts +229 -0
- package/src/commands/osint.ts +171 -0
- package/src/commands/pentest.ts +140 -0
- package/src/commands/scale.ts +185 -0
- package/src/commands/serve.ts +171 -0
- package/src/commands/tui.ts +126 -0
- package/src/commands/wyrm.ts +269 -0
- package/src/config.ts +93 -0
- package/src/index.ts +92 -0
- package/src/manifest.ts +104 -0
- package/src/ui.ts +188 -0
- package/src/utils.ts +153 -0
- package/src/wyrm/mcp.ts +130 -0
- package/test/auth.test.ts +70 -0
- package/test/brain.test.ts +39 -0
- package/test/security.test.ts +104 -0
- package/test/skills.test.ts +38 -0
- package/test/ui.test.ts +46 -0
- package/tsconfig.json +19 -0
- package/worker/package-lock.json +1527 -0
- package/worker/package.json +17 -0
- package/worker/src/index.ts +76 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI design system — the "ops console" premium look (stealth silver).
|
|
3
|
+
*
|
|
4
|
+
* One place for the chrome: framed panels (boxen, ANSI-width-aware), a chrome
|
|
5
|
+
* gradient wordmark, status dots, key/value rows, rules + section headers. Every
|
|
6
|
+
* command composes these so the whole CLI reads as one flagship tool.
|
|
7
|
+
*
|
|
8
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from 'chalk'
|
|
12
|
+
import boxen from 'boxen'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { C } from './utils.js'
|
|
15
|
+
|
|
16
|
+
const BORDER = '#454e57' // dim slate — the frame
|
|
17
|
+
const SILVER_STOPS = ['#dfe6ec', '#99a3ac', '#eef3f7', '#99a3ac', '#dfe6ec']
|
|
18
|
+
|
|
19
|
+
function hexLerp(a: string, b: string, t: number): string {
|
|
20
|
+
const A = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16))
|
|
21
|
+
const B = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16))
|
|
22
|
+
const c = A.map((v, i) => Math.round(v + (B[i] - v) * t))
|
|
23
|
+
return '#' + c.map((v) => v.toString(16).padStart(2, '0')).join('')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Brushed-chrome gradient across a string — the premium wordmark treatment. */
|
|
27
|
+
export function chrome(s: string): string {
|
|
28
|
+
const chars = [...s]
|
|
29
|
+
const n = chars.length
|
|
30
|
+
if (n === 0) return s
|
|
31
|
+
return chars
|
|
32
|
+
.map((ch, i) => {
|
|
33
|
+
if (ch === ' ') return ch
|
|
34
|
+
const p = (n === 1 ? 0 : i / (n - 1)) * (SILVER_STOPS.length - 1)
|
|
35
|
+
const lo = Math.floor(p)
|
|
36
|
+
const hi = Math.min(lo + 1, SILVER_STOPS.length - 1)
|
|
37
|
+
return chalk.hex(hexLerp(SILVER_STOPS[lo], SILVER_STOPS[hi], p - lo)).bold(ch)
|
|
38
|
+
})
|
|
39
|
+
.join('')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A framed panel with an optional left-aligned title (rendered in the border). */
|
|
43
|
+
export function panel(lines: string[], opts: { title?: string; borderColor?: string; width?: number } = {}): string {
|
|
44
|
+
return boxen(lines.join('\n'), {
|
|
45
|
+
title: opts.title,
|
|
46
|
+
titleAlignment: 'left',
|
|
47
|
+
padding: { top: 0, bottom: 0, left: 1, right: 2 },
|
|
48
|
+
borderStyle: 'round',
|
|
49
|
+
borderColor: opts.borderColor ?? BORDER,
|
|
50
|
+
...(opts.width ? { width: opts.width } : {}),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ghosts.lk "live/online" accent — a whisper of emerald (oklch 0.55 0.15 155).
|
|
55
|
+
export const emerald = chalk.hex('#2bb673')
|
|
56
|
+
export const emeraldDim = chalk.hex('#1c6e49')
|
|
57
|
+
|
|
58
|
+
/** Zip two rectangular blocks side-by-side. `leftWidth` = the left block's visible
|
|
59
|
+
* width (set a fixed `width` on the left panel so padding lines up). */
|
|
60
|
+
export function joinColumns(left: string, right: string, leftWidth: number, gap = 3): string {
|
|
61
|
+
const L = left.split('\n')
|
|
62
|
+
const R = right.split('\n')
|
|
63
|
+
const n = Math.max(L.length, R.length)
|
|
64
|
+
const blank = ' '.repeat(leftWidth)
|
|
65
|
+
const sep = ' '.repeat(gap)
|
|
66
|
+
const out: string[] = []
|
|
67
|
+
for (let i = 0; i < n; i++) out.push((L[i] ?? blank) + sep + (R[i] ?? ''))
|
|
68
|
+
return out.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A brushed-chrome horizontal rule. */
|
|
72
|
+
export function chromeRule(width: number): string {
|
|
73
|
+
return chrome('─'.repeat(Math.max(4, width)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── sub-cell data viz (the obscure terminal-graphics craft) ──
|
|
77
|
+
const SPARK = '▁▂▃▄▅▆▇█'
|
|
78
|
+
const PARTIAL = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] // eighth-cell fills
|
|
79
|
+
|
|
80
|
+
/** A unicode sparkline (8 vertical levels). Emerald where there's signal, faint for zero. */
|
|
81
|
+
export function sparkline(values: number[]): string {
|
|
82
|
+
if (!values.length) return ''
|
|
83
|
+
const max = Math.max(...values, 1)
|
|
84
|
+
return values.map((v) => (v <= 0 ? C.faint('▁') : emerald(SPARK[Math.min(7, Math.max(0, Math.round((v / max) * 7)))]))).join('')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A fractional gauge bar (eighth-cell precision). Colour ramps green→amber→red. */
|
|
88
|
+
export function gauge(frac: number, width: number): string {
|
|
89
|
+
const f = Math.max(0, Math.min(1, frac))
|
|
90
|
+
const cells = f * width
|
|
91
|
+
const full = Math.floor(cells)
|
|
92
|
+
const rem = Math.round((cells - full) * 8)
|
|
93
|
+
const bar = '█'.repeat(full) + (rem > 0 ? PARTIAL[rem] : '')
|
|
94
|
+
const used = full + (rem > 0 ? 1 : 0)
|
|
95
|
+
const col = f < 0.7 ? emerald : f < 0.9 ? C.high : C.critical
|
|
96
|
+
return col(bar) + C.faint('·'.repeat(Math.max(0, width - used)))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Braille (U+2800 + 8-bit mask) = a 2x4 dot grid per cell → 8 sub-pixels, the
|
|
100
|
+
// densest text-only renderer (see the terminal-subcell-graphics skill).
|
|
101
|
+
const BR_DOTS = [
|
|
102
|
+
[0x01, 0x08],
|
|
103
|
+
[0x02, 0x10],
|
|
104
|
+
[0x04, 0x20],
|
|
105
|
+
[0x40, 0x80],
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A multi-row Braille area/line chart — 4× the vertical resolution of a
|
|
110
|
+
* sparkline and 2× horizontal (values are interpolated across dot columns).
|
|
111
|
+
* Returns `height` colored rows of `width` cells. Emerald signal by default.
|
|
112
|
+
*/
|
|
113
|
+
export function brailleChart(
|
|
114
|
+
values: number[],
|
|
115
|
+
opts: { width?: number; height?: number; max?: number; color?: (s: string) => string; area?: boolean } = {},
|
|
116
|
+
): string[] {
|
|
117
|
+
const width = Math.max(2, opts.width ?? 32)
|
|
118
|
+
const height = Math.max(1, opts.height ?? 3)
|
|
119
|
+
const W = width * 2
|
|
120
|
+
const H = height * 4
|
|
121
|
+
const max = opts.max ?? Math.max(...values, 1)
|
|
122
|
+
const color = opts.color ?? emerald
|
|
123
|
+
const area = opts.area ?? true
|
|
124
|
+
const bits = new Uint8Array(width * height)
|
|
125
|
+
const set = (x: number, y: number) => {
|
|
126
|
+
if (x < 0 || x >= W || y < 0 || y >= H) return
|
|
127
|
+
bits[(y >> 2) * width + (x >> 1)] |= BR_DOTS[y & 3][x & 1]
|
|
128
|
+
}
|
|
129
|
+
const n = values.length
|
|
130
|
+
for (let x = 0; x < W; x++) {
|
|
131
|
+
const idx = n <= 1 ? 0 : (x / (W - 1)) * (n - 1)
|
|
132
|
+
const lo = Math.floor(idx)
|
|
133
|
+
const hi = Math.min(lo + 1, n - 1)
|
|
134
|
+
const v = (values[lo] ?? 0) + ((values[hi] ?? 0) - (values[lo] ?? 0)) * (idx - lo)
|
|
135
|
+
const lvl = Math.max(0, Math.min(H - 1, Math.round((v / max) * (H - 1))))
|
|
136
|
+
const top = H - 1 - lvl
|
|
137
|
+
if (area) for (let y = H - 1; y >= top; y--) set(x, y)
|
|
138
|
+
else set(x, top)
|
|
139
|
+
}
|
|
140
|
+
const rows: string[] = []
|
|
141
|
+
for (let cy = 0; cy < height; cy++) {
|
|
142
|
+
let s = ''
|
|
143
|
+
for (let cx = 0; cx < width; cx++) {
|
|
144
|
+
const b = bits[cy * width + cx]
|
|
145
|
+
s += b ? String.fromCharCode(0x2800 + b) : ' '
|
|
146
|
+
}
|
|
147
|
+
rows.push(color(s))
|
|
148
|
+
}
|
|
149
|
+
return rows
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const dot = {
|
|
153
|
+
on: C.accent('●'),
|
|
154
|
+
off: C.faint('○'),
|
|
155
|
+
live: chalk.hex('#46e0b0')('●'),
|
|
156
|
+
warn: C.high('●'),
|
|
157
|
+
crit: C.critical('●'),
|
|
158
|
+
}
|
|
159
|
+
export const statusDot = (on: boolean) => (on ? dot.on : dot.off)
|
|
160
|
+
|
|
161
|
+
/** "label value" with the label dim + padded to a column. */
|
|
162
|
+
export function kv(label: string, value: string, pad = 7): string {
|
|
163
|
+
return `${C.faint(label.padEnd(pad))} ${value}`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A hairline rule, optionally with a section label on the left. */
|
|
167
|
+
export function rule(width = 50, label?: string): string {
|
|
168
|
+
if (!label) return C.faint('─'.repeat(width))
|
|
169
|
+
const tag = ` ${label.toUpperCase()} `
|
|
170
|
+
return C.faint('──') + C.info(tag) + C.faint('─'.repeat(Math.max(0, width - tag.length - 2)))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Collapse $HOME → ~ for tidy paths. */
|
|
174
|
+
export function tidyPath(p: string): string {
|
|
175
|
+
const h = homedir()
|
|
176
|
+
return p.startsWith(h) ? '~' + p.slice(h.length) : p
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The ops-console brand splash (global header). */
|
|
180
|
+
export function brandHeader(version: string): string {
|
|
181
|
+
return panel(
|
|
182
|
+
[
|
|
183
|
+
`${C.accent('▟█▙')} ${chrome('DRAGON')} ${C.faint('· stack control')}`,
|
|
184
|
+
`${C.accent('▜█▛')} ${C.faint('account.ghosts.lk · v' + version)}`,
|
|
185
|
+
],
|
|
186
|
+
{ title: chrome('GHOST PROTOCOL') },
|
|
187
|
+
)
|
|
188
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for Dragon CLI.
|
|
3
|
+
*
|
|
4
|
+
* Output palette intentionally narrow to match the Dragon Console
|
|
5
|
+
* web UI (one teal/green accent · severity by intensity, not hue).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync, spawn, type SpawnOptions } from 'child_process'
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { createConnection } from 'node:net'
|
|
12
|
+
import chalk from 'chalk'
|
|
13
|
+
|
|
14
|
+
export function exec(cmd: string, cwd?: string): string {
|
|
15
|
+
try {
|
|
16
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
17
|
+
} catch (e: any) {
|
|
18
|
+
throw new Error(e.stderr?.trim() || e.message)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function run(cmd: string, args: string[], cwd: string, opts?: SpawnOptions): Promise<number> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(cmd, args, {
|
|
25
|
+
cwd,
|
|
26
|
+
stdio: 'inherit',
|
|
27
|
+
...opts,
|
|
28
|
+
})
|
|
29
|
+
child.on('close', code => resolve(code ?? 1))
|
|
30
|
+
child.on('error', reject)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchJSON<T = any>(url: string, timeoutMs = 5000): Promise<T> {
|
|
35
|
+
const controller = new AbortController()
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(url, { signal: controller.signal })
|
|
39
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
40
|
+
return res.json() as Promise<T>
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── brand palette: STEALTH SILVER ─────────────────────────────
|
|
47
|
+
// Mirrors account.ghosts.lk's monochrome scheme (near-black canvas,
|
|
48
|
+
// silver/white accent, zinc muted) — the operator-stealth look. The teal
|
|
49
|
+
// Dragon-Console accent was retired here. Severity stays in hue (function
|
|
50
|
+
// over stealth — a CRITICAL must read as red). Truecolor assumed; legacy
|
|
51
|
+
// 16-color terms fall back to the nearest chalk basic colour.
|
|
52
|
+
|
|
53
|
+
export const C = {
|
|
54
|
+
accent: chalk.hex('#c8d0d8'), // stealth silver · primary
|
|
55
|
+
hot: chalk.hex('#eef3f7'), // bright silver/white · live / running
|
|
56
|
+
critical: chalk.hex('#ff5a55'), // red · severity
|
|
57
|
+
high: chalk.hex('#fb923c'), // orange · severity
|
|
58
|
+
medium: chalk.hex('#fbbf24'), // amber · severity
|
|
59
|
+
low: chalk.hex('#8ab4e8'), // soft blue
|
|
60
|
+
info: chalk.hex('#9aa4ad'), // zinc grey
|
|
61
|
+
faint: chalk.hex('#5b6570'), // dim slate
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── output discipline ─────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function label(text: string): string {
|
|
67
|
+
return C.accent.bold(`[ ${text.toUpperCase()} ]`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function eyebrow(num: string | number, text: string): string {
|
|
71
|
+
return C.accent.bold(`[ ${String(num).padStart(2, '0')} // ${text.toUpperCase()} ]`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function dim(text: string): string {
|
|
75
|
+
return chalk.dim(text)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Severity glyphs + hues match the web UI sev pills:
|
|
79
|
+
// CRITICAL ⚠ red · HIGH ! orange · MEDIUM amber · LOW blue · INFO grey
|
|
80
|
+
// CLI helpers use the matching colour so error/warn/info read at a glance.
|
|
81
|
+
export function error(msg: string): void { console.error(C.critical.bold(' ⚠'), msg) }
|
|
82
|
+
export function warn(msg: string): void { console.warn(C.high(' !'), msg) }
|
|
83
|
+
export function success(msg: string): void { console.log(C.accent(' ✓'), msg) }
|
|
84
|
+
export function info(msg: string): void { console.log(C.info(' ·'), msg) }
|
|
85
|
+
|
|
86
|
+
export function table(rows: Record<string, string>[]): void {
|
|
87
|
+
if (rows.length === 0) return
|
|
88
|
+
const keys = Object.keys(rows[0])
|
|
89
|
+
const widths = keys.map(k => Math.max(k.length, ...rows.map(r => (r[k] || '').length)))
|
|
90
|
+
|
|
91
|
+
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ')
|
|
92
|
+
const sep = widths.map(w => '─'.repeat(w)).join('──')
|
|
93
|
+
|
|
94
|
+
console.log(chalk.dim(header))
|
|
95
|
+
console.log(chalk.dim(sep))
|
|
96
|
+
rows.forEach(row => {
|
|
97
|
+
console.log(keys.map((k, i) => (row[k] || '').padEnd(widths[i])).join(' '))
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── module state probes ──────────────────────────────────────
|
|
102
|
+
// Used by `dragon list` + `dragon doctor` so output reflects reality,
|
|
103
|
+
// not "the repo dir exists therefore ✓".
|
|
104
|
+
|
|
105
|
+
export function isInstalled(dir: string): boolean {
|
|
106
|
+
return existsSync(join(dir, '.git'))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isRunning(port?: number, host = '127.0.0.1', timeoutMs = 200): Promise<boolean> {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
if (!port) return resolve(false)
|
|
112
|
+
const sock = createConnection({ host, port })
|
|
113
|
+
const done = (ok: boolean) => { try { sock.destroy() } catch {} resolve(ok) }
|
|
114
|
+
sock.once('connect', () => done(true))
|
|
115
|
+
sock.once('error', () => done(false))
|
|
116
|
+
setTimeout(() => done(false), timeoutMs)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readVersion(dir: string): string | null {
|
|
121
|
+
const parsers: Record<string, (raw: string) => string | null> = {
|
|
122
|
+
'package.json': (raw) => { try { return JSON.parse(raw).version ?? null } catch { return null } },
|
|
123
|
+
'Cargo.toml': (raw) => raw.match(/^version\s*=\s*"([^"]+)"/m)?.[1] ?? null,
|
|
124
|
+
'pyproject.toml': (raw) => raw.match(/^version\s*=\s*"([^"]+)"/m)?.[1] ?? null,
|
|
125
|
+
}
|
|
126
|
+
// Search the repo root + a handful of conventional monorepo layouts so
|
|
127
|
+
// packages like Wyrm (no root manifest, version in packages/mcp-server)
|
|
128
|
+
// still report a version on `dragon list`.
|
|
129
|
+
const candidates = [
|
|
130
|
+
dir,
|
|
131
|
+
join(dir, 'packages', 'mcp-server'),
|
|
132
|
+
join(dir, 'packages', 'core'),
|
|
133
|
+
join(dir, 'packages', 'cli'),
|
|
134
|
+
join(dir, 'apps', 'web'),
|
|
135
|
+
join(dir, 'apps', 'server'),
|
|
136
|
+
]
|
|
137
|
+
for (const c of candidates) {
|
|
138
|
+
for (const [fname, parse] of Object.entries(parsers)) {
|
|
139
|
+
const f = join(c, fname)
|
|
140
|
+
if (!existsSync(f)) continue
|
|
141
|
+
try {
|
|
142
|
+
const v = parse(readFileSync(f, 'utf-8'))
|
|
143
|
+
if (v) return v
|
|
144
|
+
} catch { /* keep trying */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function lastUpdated(dir: string): string | null {
|
|
151
|
+
try { return new Date(statSync(dir).mtime).toISOString().slice(0, 10) }
|
|
152
|
+
catch { return null }
|
|
153
|
+
}
|
package/src/wyrm/mcp.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep Wyrm integration — the agent's memory spine, on by default.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the Wyrm MCP server (`wyrm-mcp`) over stdio and:
|
|
5
|
+
* - exposes a CURATED subset of Wyrm's tools to the brain (recall, remember,
|
|
6
|
+
* capture, search, project/global context, quests, skills, entities, truths)
|
|
7
|
+
* so the model can read + write long-term memory mid-task;
|
|
8
|
+
* - PRIMES each session with the current project's context, folded into the
|
|
9
|
+
* system prompt, so Dragon starts already knowing the project.
|
|
10
|
+
*
|
|
11
|
+
* Everything is best-effort: if Wyrm isn't reachable the agent still runs (just
|
|
12
|
+
* without memory). Tool routing in the loop sends any `wyrm_*` call here.
|
|
13
|
+
*
|
|
14
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
18
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import type { ToolSpec } from '../brain/types.js'
|
|
23
|
+
|
|
24
|
+
/** High-value Wyrm tools we surface to the model (the full surface is ~116). */
|
|
25
|
+
const EXPOSED = new Set([
|
|
26
|
+
'wyrm_recall', 'wyrm_remember', 'wyrm_capture', 'wyrm_search',
|
|
27
|
+
'wyrm_project_context', 'wyrm_global_context', 'wyrm_context_build',
|
|
28
|
+
'wyrm_all_quests', 'wyrm_quest_add', 'wyrm_quest_complete',
|
|
29
|
+
'wyrm_skill_search', 'wyrm_skill_get',
|
|
30
|
+
'wyrm_entity_search', 'wyrm_truth_get', 'wyrm_truth_set', 'wyrm_decided_because',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
function wyrmBin(): string {
|
|
34
|
+
const candidates = [process.env.WYRM_MCP_BIN, join(homedir(), '.npm-global/bin/wyrm-mcp')].filter(Boolean) as string[]
|
|
35
|
+
for (const c of candidates) if (existsSync(c)) return c
|
|
36
|
+
return 'wyrm-mcp' // fall back to PATH
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONNECT_TIMEOUT = 8_000 // a slow/hanging Wyrm must never freeze the agent
|
|
40
|
+
const CALL_TIMEOUT = 25_000
|
|
41
|
+
|
|
42
|
+
/** Resolve to `null` if the promise doesn't settle in `ms` (running `onTimeout`). */
|
|
43
|
+
function withTimeout<T>(p: Promise<T>, ms: number, onTimeout?: () => void): Promise<T | null> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let done = false
|
|
46
|
+
const timer = setTimeout(() => { if (!done) { done = true; onTimeout?.(); resolve(null) } }, ms)
|
|
47
|
+
p.then(
|
|
48
|
+
(v) => { if (!done) { done = true; clearTimeout(timer); resolve(v) } },
|
|
49
|
+
() => { if (!done) { done = true; clearTimeout(timer); resolve(null) } },
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WyrmTool { spec: ToolSpec }
|
|
55
|
+
|
|
56
|
+
export class Wyrm {
|
|
57
|
+
private client: Client | null = null
|
|
58
|
+
private transport: StdioClientTransport | null = null
|
|
59
|
+
private toolNames = new Set<string>()
|
|
60
|
+
connected = false
|
|
61
|
+
|
|
62
|
+
/** Connect + discover tools (bounded by CONNECT_TIMEOUT). Never throws / hangs. */
|
|
63
|
+
async connect(): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
this.transport = new StdioClientTransport({ command: wyrmBin(), args: [], stderr: 'ignore' })
|
|
66
|
+
this.client = new Client({ name: 'dragon-cli', version: '3.7.0' }, { capabilities: {} })
|
|
67
|
+
const ok = await withTimeout(
|
|
68
|
+
(async () => {
|
|
69
|
+
await this.client!.connect(this.transport!)
|
|
70
|
+
const { tools } = await this.client!.listTools()
|
|
71
|
+
this.exposed = tools
|
|
72
|
+
.filter((t) => EXPOSED.has(t.name))
|
|
73
|
+
.map((t) => ({ name: t.name, description: t.description ?? '', parameters: (t.inputSchema as Record<string, unknown>) ?? { type: 'object', properties: {} } }))
|
|
74
|
+
this.exposed.forEach((t) => this.toolNames.add(t.name))
|
|
75
|
+
return true
|
|
76
|
+
})(),
|
|
77
|
+
CONNECT_TIMEOUT,
|
|
78
|
+
() => { try { void this.transport?.close() } catch { /* ignore */ } },
|
|
79
|
+
)
|
|
80
|
+
this.connected = ok === true
|
|
81
|
+
if (!this.connected) { try { await this.client?.close() } catch { /* ignore */ } this.client = null }
|
|
82
|
+
return this.connected
|
|
83
|
+
} catch {
|
|
84
|
+
this.connected = false
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private exposed: ToolSpec[] = []
|
|
90
|
+
|
|
91
|
+
/** Curated Wyrm tool specs to hand the brain (empty if not connected). */
|
|
92
|
+
toolSpecs(): ToolSpec[] {
|
|
93
|
+
return this.exposed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
handles(name: string): boolean {
|
|
97
|
+
return this.toolNames.has(name)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Invoke a Wyrm tool; returns a string result for the model (bounded). */
|
|
101
|
+
async call(name: string, args: Record<string, unknown>): Promise<string> {
|
|
102
|
+
if (!this.client) return 'error: Wyrm not connected'
|
|
103
|
+
const res = await withTimeout(
|
|
104
|
+
this.client.callTool({ name, arguments: args }) as Promise<{ content?: { type: string; text?: string }[]; isError?: boolean }>,
|
|
105
|
+
CALL_TIMEOUT,
|
|
106
|
+
)
|
|
107
|
+
if (res === null) return `error: ${name} timed out`
|
|
108
|
+
const text = (res.content ?? []).map((c) => (c.type === 'text' ? c.text ?? '' : `[${c.type}]`)).join('\n').trim()
|
|
109
|
+
return (res.isError ? 'error: ' : '') + (text || '(no result)')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Best-effort project context for the system prompt. Never throws. */
|
|
113
|
+
async prime(cwd: string): Promise<string | null> {
|
|
114
|
+
if (!this.client) return null
|
|
115
|
+
for (const [name, args] of [
|
|
116
|
+
['wyrm_project_context', { projectPath: cwd }],
|
|
117
|
+
['wyrm_project_context', { project: cwd }],
|
|
118
|
+
] as const) {
|
|
119
|
+
if (!this.toolNames.has(name)) continue
|
|
120
|
+
const out = await this.call(name, args as Record<string, unknown>)
|
|
121
|
+
if (out && !out.startsWith('error')) return out.slice(0, 4000)
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async close(): Promise<void> {
|
|
127
|
+
try { await this.client?.close() } catch { /* ignore */ }
|
|
128
|
+
this.connected = false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth resolution precedence — the "does login actually work on a fresh machine"
|
|
3
|
+
* guarantee, made deterministic. `resolveAuthFrom` is pure (env + config in,
|
|
4
|
+
* headers + mode out), so the whole token > session > none ladder, env > config
|
|
5
|
+
* precedence, origin self-heal, and header-injection rejection are exercised with
|
|
6
|
+
* zero network and zero dependence on THIS box's ~/.dragon/config.json. (Closes
|
|
7
|
+
* the premortem's "untested authed / single-machine path" gap.)
|
|
8
|
+
*
|
|
9
|
+
* Runs against built output: `npm run build && vitest run`.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { resolveAuthFrom, DEFAULT_API } from '../dist/auth.js'
|
|
13
|
+
|
|
14
|
+
// resolveAuthFrom only reads `cfg.auth`; a minimal stub is all it needs.
|
|
15
|
+
const cfg = (auth?: Record<string, unknown>) => ({ auth } as any)
|
|
16
|
+
|
|
17
|
+
describe('resolveAuthFrom — credential precedence', () => {
|
|
18
|
+
it('token beats session beats none', () => {
|
|
19
|
+
const r = resolveAuthFrom({ DRAGON_TOKEN: 'dgn_tok123', DRAGON_SESSION: 'sess456' }, cfg())
|
|
20
|
+
expect(r.mode).toBe('token')
|
|
21
|
+
expect(r.headers.authorization).toBe('Bearer dgn_tok123')
|
|
22
|
+
expect(r.headers.cookie).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
it('session when only a session is present', () => {
|
|
25
|
+
const r = resolveAuthFrom({ DRAGON_SESSION: 'sess456' }, cfg())
|
|
26
|
+
expect(r.mode).toBe('session')
|
|
27
|
+
expect(r.headers.cookie).toBe('gp_session=sess456')
|
|
28
|
+
})
|
|
29
|
+
it('none when nothing is present', () => {
|
|
30
|
+
const r = resolveAuthFrom({}, cfg())
|
|
31
|
+
expect(r.mode).toBe('none')
|
|
32
|
+
expect(r.headers).toEqual({})
|
|
33
|
+
})
|
|
34
|
+
it('env overrides config', () => {
|
|
35
|
+
const r = resolveAuthFrom({ DRAGON_TOKEN: 'env_tok' }, cfg({ token: 'cfg_tok' }))
|
|
36
|
+
expect(r.headers.authorization).toBe('Bearer env_tok')
|
|
37
|
+
})
|
|
38
|
+
it('falls back to config when env is empty, and surfaces the stored email', () => {
|
|
39
|
+
const r = resolveAuthFrom({}, cfg({ token: 'cfg_tok', email: 'ryan@ghosts.lk' }))
|
|
40
|
+
expect(r.mode).toBe('token')
|
|
41
|
+
expect(r.headers.authorization).toBe('Bearer cfg_tok')
|
|
42
|
+
expect(r.email).toBe('ryan@ghosts.lk')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('resolveAuthFrom — apiBase origin guard', () => {
|
|
47
|
+
it('defaults when unset', () => {
|
|
48
|
+
expect(resolveAuthFrom({}, cfg()).apiBase).toBe(DEFAULT_API)
|
|
49
|
+
})
|
|
50
|
+
it('honors a valid https override (env) and strips the trailing slash', () => {
|
|
51
|
+
expect(resolveAuthFrom({ DRAGON_API: 'https://staging.ghosts.lk/' }, cfg()).apiBase).toBe('https://staging.ghosts.lk')
|
|
52
|
+
})
|
|
53
|
+
it('self-heals a restricted-port / junk origin back to default', () => {
|
|
54
|
+
expect(resolveAuthFrom({ DRAGON_API: 'http://127.0.0.1:1' }, cfg()).apiBase).toBe(DEFAULT_API)
|
|
55
|
+
expect(resolveAuthFrom({ DRAGON_API: 'ftp://x' }, cfg()).apiBase).toBe(DEFAULT_API)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('resolveAuthFrom — header-injection rejection', () => {
|
|
60
|
+
it('drops a CRLF-poisoned token (no header smuggling) → session wins', () => {
|
|
61
|
+
const r = resolveAuthFrom({ DRAGON_TOKEN: 'tok\r\nX-Evil: 1', DRAGON_SESSION: 'cleansess' }, cfg())
|
|
62
|
+
expect(r.mode).toBe('session')
|
|
63
|
+
expect(r.headers.authorization).toBeUndefined()
|
|
64
|
+
expect(r.headers.cookie).toBe('gp_session=cleansess')
|
|
65
|
+
})
|
|
66
|
+
it('drops a poisoned token AND a poisoned session → none', () => {
|
|
67
|
+
const r = resolveAuthFrom({ DRAGON_TOKEN: 'a b', DRAGON_SESSION: 'c\nd' }, cfg())
|
|
68
|
+
expect(r.mode).toBe('none')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker brain response normalization — the flaky fp8 tool-caller path the
|
|
3
|
+
* premortem flagged. The Cloudflare brain (Llama 3.3) returns inconsistent
|
|
4
|
+
* shapes; `normalizeWorkerTurn` must NEVER throw into the agent loop. It's pure,
|
|
5
|
+
* so every weird shape is pinned here.
|
|
6
|
+
*
|
|
7
|
+
* Runs against built output: `npm run build && vitest run`.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { normalizeWorkerTurn } from '../dist/brain/worker.js'
|
|
11
|
+
|
|
12
|
+
describe('normalizeWorkerTurn — tolerant of fp8 weirdness', () => {
|
|
13
|
+
it('passes plain text through with no tool calls', () => {
|
|
14
|
+
const t = normalizeWorkerTurn({ response: 'hello' })
|
|
15
|
+
expect(t.text).toBe('hello')
|
|
16
|
+
expect(t.toolCalls).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
it('parses string-encoded arguments into an object', () => {
|
|
19
|
+
const t = normalizeWorkerTurn({ tool_calls: [{ name: 'read_file', arguments: '{"path":"a.ts"}' }] })
|
|
20
|
+
expect(t.toolCalls[0]).toMatchObject({ id: 'wc_0', name: 'read_file', arguments: { path: 'a.ts' } })
|
|
21
|
+
})
|
|
22
|
+
it('keeps object arguments as-is', () => {
|
|
23
|
+
const t = normalizeWorkerTurn({ tool_calls: [{ name: 'grep', arguments: { pattern: 'x' } }] })
|
|
24
|
+
expect(t.toolCalls[0].arguments).toEqual({ pattern: 'x' })
|
|
25
|
+
})
|
|
26
|
+
it('coerces malformed/missing arguments to {} instead of throwing', () => {
|
|
27
|
+
const t = normalizeWorkerTurn({ tool_calls: [{ name: 'ls', arguments: 'not json' }, { name: 'pwd' }] })
|
|
28
|
+
expect(t.toolCalls[0].arguments).toEqual({})
|
|
29
|
+
expect(t.toolCalls[1].arguments).toEqual({})
|
|
30
|
+
})
|
|
31
|
+
it('drops nameless garbage tool_calls and re-indexes the survivors contiguously', () => {
|
|
32
|
+
const t = normalizeWorkerTurn({ tool_calls: [{ arguments: {} }, { name: 'glob', arguments: {} }] })
|
|
33
|
+
expect(t.toolCalls).toHaveLength(1)
|
|
34
|
+
expect(t.toolCalls[0]).toMatchObject({ id: 'wc_0', name: 'glob' })
|
|
35
|
+
})
|
|
36
|
+
it('handles a totally empty response without throwing', () => {
|
|
37
|
+
expect(normalizeWorkerTurn({})).toEqual({ text: '', toolCalls: [] })
|
|
38
|
+
})
|
|
39
|
+
})
|