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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-CLI brain — drive the agent with the LOCAL `claude` CLI (Claude Code) in
|
|
3
|
+
* headless mode, reusing the operator's EXISTING Claude Code auth. No separate
|
|
4
|
+
* ANTHROPIC_API_KEY needed: if `--brain claude` has no API key but the `claude` binary
|
|
5
|
+
* is on PATH, the factory falls back here ("use this Claude").
|
|
6
|
+
*
|
|
7
|
+
* It runs `claude -p --output-format json` per turn, feeding the rendered conversation
|
|
8
|
+
* on stdin and asking for ONLY the next tool call(s) as a JSON array (EMBER's exact
|
|
9
|
+
* format) — so the same parser handles it AND the captured ~/.dragon/traces are
|
|
10
|
+
* Claude-quality decisions IN dragon-cli's contract: the gold distillation data for the
|
|
11
|
+
* next EMBER. Caveats: heavier than the API (spawns Claude Code per turn) and it bills
|
|
12
|
+
* the operator's Claude Code usage — fine for flywheel-filling, not low-latency chat.
|
|
13
|
+
*
|
|
14
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import type { Brain, BrainMessage, BrainTurn, TurnOpts } from './types.js'
|
|
19
|
+
import { parseEmberToolCalls } from './ghost-ember.js'
|
|
20
|
+
|
|
21
|
+
function render(system: string, messages: BrainMessage[], toolNames: string[]): string {
|
|
22
|
+
const convo = messages.map((m) => {
|
|
23
|
+
if (m.role === 'tool') return `[tool result: ${m.toolName ?? 'tool'}]\n${m.content}`.slice(0, 4000)
|
|
24
|
+
if (m.role === 'assistant') return `[assistant] ${m.toolCalls?.length ? JSON.stringify(m.toolCalls.map((t) => ({ tool: t.name, arguments: t.arguments }))) : m.content}`
|
|
25
|
+
return `[user] ${m.content}`
|
|
26
|
+
}).join('\n\n')
|
|
27
|
+
return `${system}\n\n── CONVERSATION SO FAR ──\n${convo}\n\n── YOUR TASK ──\n` +
|
|
28
|
+
'You are the REASONING brain of an external agent — do NOT use your own tools or take any action yourself. ' +
|
|
29
|
+
'When you need the agent to act, respond with ONLY a JSON array of the next tool call(s), no prose:\n' +
|
|
30
|
+
'[{"tool":"<name>","arguments":{…}}]\n' +
|
|
31
|
+
'When you have enough to finish, respond with your final answer as PLAIN TEXT (no JSON array). ' +
|
|
32
|
+
`The agent's available tools are: ${toolNames.join(', ')}.`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runClaude(args: string[], prompt: string, signal?: AbortSignal): Promise<string> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const cp = spawn('claude', args, { stdio: ['pipe', 'pipe', 'ignore'], signal })
|
|
38
|
+
let out = ''
|
|
39
|
+
cp.stdout.on('data', (d) => { out += d })
|
|
40
|
+
cp.on('error', reject)
|
|
41
|
+
cp.on('close', () => {
|
|
42
|
+
// --output-format json → either a single result object or a stream array;
|
|
43
|
+
// the assistant text lives in the `result` field of the type:'result' event.
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(out)
|
|
46
|
+
if (Array.isArray(parsed)) {
|
|
47
|
+
const res = parsed.find((e) => e && e.type === 'result')
|
|
48
|
+
return resolve(typeof res?.result === 'string' ? res.result : '')
|
|
49
|
+
}
|
|
50
|
+
return resolve(typeof parsed?.result === 'string' ? parsed.result : out)
|
|
51
|
+
} catch { resolve(out) }
|
|
52
|
+
})
|
|
53
|
+
cp.stdin.write(prompt)
|
|
54
|
+
cp.stdin.end()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function makeClaudeCliBrain(opts?: { model?: string }): Brain {
|
|
59
|
+
return {
|
|
60
|
+
id: 'claude',
|
|
61
|
+
model: opts?.model ? `${opts.model} (cli)` : 'claude-code (cli)',
|
|
62
|
+
async turn(t: TurnOpts): Promise<BrainTurn> {
|
|
63
|
+
// --model FIRST (so the variadic --disallowedTools below doesn't swallow it).
|
|
64
|
+
const args = ['-p', '--output-format', 'json']
|
|
65
|
+
if (opts?.model) args.push('--model', opts.model)
|
|
66
|
+
// Disable Claude Code's OWN acting tools so it can't do the work itself — it must
|
|
67
|
+
// route through dragon-cli (its decisions become EMBER's training traces) and only
|
|
68
|
+
// produce prose once dragon-cli feeds the results back. Variadic → keep LAST.
|
|
69
|
+
args.push('--disallowedTools', 'Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob',
|
|
70
|
+
'MultiEdit', 'NotebookEdit', 'Task', 'WebFetch', 'WebSearch')
|
|
71
|
+
const prompt = render(t.system, t.messages, t.tools.map((x) => x.name))
|
|
72
|
+
const text = await runClaude(args, prompt, t.signal)
|
|
73
|
+
const toolCalls = parseEmberToolCalls(text)
|
|
74
|
+
t.onDelta?.(toolCalls.length ? toolCalls.map((c) => `→ ${c.name}`).join(' ') : text)
|
|
75
|
+
return { text: toolCalls.length ? '' : text, toolCalls }
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost / EMBER brain — Ghost Protocol's own local model (DragonSpark's EMBER,
|
|
3
|
+
* served by Ollama) as the agent's reasoning layer.
|
|
4
|
+
*
|
|
5
|
+
* EMBER is a small specialist FINE-TUNED to emit tool calls as a JSON array in the
|
|
6
|
+
* response TEXT — `[{"tool":"Bash","arguments":{…}}]` — NOT via the OpenAI native
|
|
7
|
+
* function-calling protocol. So this adapter, unlike the generic openai-compat brain:
|
|
8
|
+
* 1. does NOT send `tools`/`tool_choice` (EMBER ignores them); instead it appends a
|
|
9
|
+
* terse output contract + the available tool names to the system prompt, matching
|
|
10
|
+
* what EMBER was trained on (DragonSpark configs/fixtures/system_prompt.txt);
|
|
11
|
+
* 2. parses EMBER's JSON-array output back into the loop's normalized ToolCall shape.
|
|
12
|
+
*
|
|
13
|
+
* This format bridge is doubly important: it makes EMBER's calls EXECUTE in the loop
|
|
14
|
+
* AND makes the emitted ~/.dragon/traces training-grade (the flywheel that improves the
|
|
15
|
+
* next EMBER). Wyrm-RAG comes for free — dragon-cli already folds recalled project
|
|
16
|
+
* context into the system prompt (loop.ts buildSystemPrompt `primed`), so EMBER decides
|
|
17
|
+
* WITH memory, which is the whole "Wyrm is the brain" thesis.
|
|
18
|
+
*
|
|
19
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Brain, BrainMessage, BrainTurn, ToolCall, TurnOpts } from './types.js'
|
|
23
|
+
|
|
24
|
+
const EMBER_CONTRACT =
|
|
25
|
+
'\n\n── OUTPUT CONTRACT (EMBER) ──\n' +
|
|
26
|
+
'Respond with ONLY a JSON array of the tool call(s) to make, no prose:\n' +
|
|
27
|
+
'[{"tool":"<name>","arguments":{…}}]\n' +
|
|
28
|
+
'If the task is complete and no tool is needed, respond with [].'
|
|
29
|
+
|
|
30
|
+
function oaiMessages(system: string, messages: BrainMessage[], toolNames: string[]) {
|
|
31
|
+
const sys = system + EMBER_CONTRACT + '\nAvailable tools: ' + toolNames.join(', ')
|
|
32
|
+
const out: { role: string; content: string }[] = [{ role: 'system', content: sys }]
|
|
33
|
+
for (const m of messages) {
|
|
34
|
+
if (m.role === 'tool') {
|
|
35
|
+
out.push({ role: 'user', content: `[observed] ${m.toolName ?? 'tool'} → ${m.content}`.slice(0, 1200) })
|
|
36
|
+
} else if (m.role === 'assistant') {
|
|
37
|
+
out.push({ role: 'assistant', content: m.toolCalls?.length ? JSON.stringify(m.toolCalls.map((t) => ({ tool: t.name, arguments: t.arguments }))) : m.content })
|
|
38
|
+
} else {
|
|
39
|
+
out.push({ role: 'user', content: m.content })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pull EMBER's JSON tool array out of the response text (tolerant of stray prose). */
|
|
46
|
+
export function parseEmberToolCalls(text: string): ToolCall[] {
|
|
47
|
+
const m = text.match(/\[[\s\S]*\]/)
|
|
48
|
+
if (!m) return []
|
|
49
|
+
let arr: unknown
|
|
50
|
+
try { arr = JSON.parse(m[0]) } catch { return [] }
|
|
51
|
+
if (!Array.isArray(arr)) return []
|
|
52
|
+
const calls: ToolCall[] = []
|
|
53
|
+
arr.forEach((c, i) => {
|
|
54
|
+
if (c && typeof c === 'object' && typeof (c as Record<string, unknown>).tool === 'string') {
|
|
55
|
+
const o = c as Record<string, unknown>
|
|
56
|
+
calls.push({ id: `ember_${Date.now()}_${i}`, name: o.tool as string, arguments: (o.arguments as Record<string, unknown>) ?? {} })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
return calls
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function makeGhostEmberBrain(opts: { baseURL: string; model: string }): Brain {
|
|
63
|
+
const baseURL = opts.baseURL.replace(/\/+$/, '')
|
|
64
|
+
return {
|
|
65
|
+
id: 'ghost',
|
|
66
|
+
model: opts.model,
|
|
67
|
+
async turn(t: TurnOpts): Promise<BrainTurn> {
|
|
68
|
+
const toolNames = t.tools.map((tl) => tl.name)
|
|
69
|
+
const res = await fetch(`${baseURL}/chat/completions`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ghost' },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model: opts.model,
|
|
74
|
+
messages: oaiMessages(t.system, t.messages, toolNames),
|
|
75
|
+
temperature: 0.1,
|
|
76
|
+
stream: false,
|
|
77
|
+
max_tokens: t.maxTokens ?? 512,
|
|
78
|
+
}),
|
|
79
|
+
signal: t.signal,
|
|
80
|
+
})
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const body = await res.text().catch(() => res.statusText)
|
|
83
|
+
throw new Error(`ghost(EMBER) brain HTTP ${res.status}: ${body.slice(0, 300)} — is Ollama serving '${opts.model}'? (dragon-spark serve)`)
|
|
84
|
+
}
|
|
85
|
+
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] }
|
|
86
|
+
const text = data.choices?.[0]?.message?.content ?? ''
|
|
87
|
+
const toolCalls = parseEmberToolCalls(text)
|
|
88
|
+
// EMBER emits JSON, not prose — surface a readable line for the operator/trace.
|
|
89
|
+
const display = toolCalls.length ? toolCalls.map((c) => `→ ${c.name}`).join(' ') : text
|
|
90
|
+
t.onDelta?.(display)
|
|
91
|
+
return { text: toolCalls.length ? '' : text, toolCalls }
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain factory — resolves which model drives the agent, from (in priority)
|
|
3
|
+
* an explicit override → env → ~/.dragon/config.json → built-in default
|
|
4
|
+
* ('claude'). Keys come from env first, then config (`dragon config key …`).
|
|
5
|
+
*
|
|
6
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'node:child_process'
|
|
10
|
+
import { loadConfig } from '../config.js'
|
|
11
|
+
import type { Brain } from './types.js'
|
|
12
|
+
import { makeClaudeBrain, DEFAULT_CLAUDE_MODEL } from './anthropic.js'
|
|
13
|
+
import { makeClaudeCliBrain } from './claude-cli.js'
|
|
14
|
+
import { makeOpenAICompatBrain } from './openai-compat.js'
|
|
15
|
+
import { makeGhostEmberBrain } from './ghost-ember.js'
|
|
16
|
+
import { makeWorkerBrain } from './worker.js'
|
|
17
|
+
import { makeRouterBrain } from './router/index.js'
|
|
18
|
+
|
|
19
|
+
/** Is the local `claude` CLI (Claude Code) on PATH? Lets `--brain claude` reuse the
|
|
20
|
+
* operator's existing Claude Code auth when no API key is configured. */
|
|
21
|
+
function hasClaudeCli(): boolean {
|
|
22
|
+
try { execSync('command -v claude', { stdio: 'ignore' }); return true } catch { return false }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { Brain, BrainMessage, ToolSpec, ToolCall, BrainTurn } from './types.js'
|
|
26
|
+
|
|
27
|
+
export type Provider = 'claude' | 'openai' | 'local' | 'ghost' | 'worker' | 'custom' | 'router'
|
|
28
|
+
export const PROVIDERS: Provider[] = ['claude', 'worker', 'local', 'ghost', 'openai', 'custom', 'router']
|
|
29
|
+
|
|
30
|
+
/** Missing/invalid key → thrown so commands can print a friendly fix. */
|
|
31
|
+
export class BrainConfigError extends Error {
|
|
32
|
+
constructor(public provider: Provider, message: string) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = 'BrainConfigError'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveProvider(override?: string): Provider {
|
|
39
|
+
const cfg = loadConfig()
|
|
40
|
+
const want = (override || process.env.DRAGON_BRAIN || cfg.brain?.provider || 'claude').toLowerCase()
|
|
41
|
+
return (PROVIDERS as string[]).includes(want) ? (want as Provider) : 'claude'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getBrain(opts?: { provider?: string; model?: string }): Brain {
|
|
45
|
+
const cfg = loadConfig()
|
|
46
|
+
const provider = resolveProvider(opts?.provider)
|
|
47
|
+
|
|
48
|
+
if (provider === 'claude') {
|
|
49
|
+
const apiKey = process.env.ANTHROPIC_API_KEY || cfg.brain?.keys?.anthropic
|
|
50
|
+
const override = opts?.model || process.env.DRAGON_MODEL || cfg.brain?.model
|
|
51
|
+
if (apiKey) return makeClaudeBrain({ apiKey, model: override || DEFAULT_CLAUDE_MODEL })
|
|
52
|
+
// No API key → reuse the local Claude Code CLI's own auth ("use this Claude").
|
|
53
|
+
if (hasClaudeCli()) return makeClaudeCliBrain({ model: override })
|
|
54
|
+
throw new BrainConfigError('claude', 'no Anthropic key and no `claude` CLI found — set ANTHROPIC_API_KEY, run `dragon config key anthropic <key>`, install Claude Code, or use `--brain local`.')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (provider === 'openai') {
|
|
58
|
+
const apiKey = process.env.OPENAI_API_KEY || cfg.brain?.keys?.openai
|
|
59
|
+
if (!apiKey) throw new BrainConfigError('openai', 'no OpenAI key — set OPENAI_API_KEY or run `dragon config key openai <key>`.')
|
|
60
|
+
const model = opts?.model || process.env.DRAGON_MODEL || cfg.brain?.model || 'gpt-4o'
|
|
61
|
+
return makeOpenAICompatBrain({ id: 'openai', baseURL: 'https://api.openai.com/v1', apiKey, model })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (provider === 'worker') {
|
|
65
|
+
// Our Cloudflare Workers AI — free, no key, just `dragon login`.
|
|
66
|
+
return makeWorkerBrain()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (provider === 'custom') {
|
|
70
|
+
// ANY OpenAI-compatible endpoint — OpenRouter, vLLM, LM Studio, llama.cpp, etc.
|
|
71
|
+
const baseURL = process.env.DRAGON_OPENAI_BASE || cfg.brain?.customBaseURL
|
|
72
|
+
if (!baseURL) throw new BrainConfigError('custom', 'no custom endpoint — set DRAGON_OPENAI_BASE (any OpenAI-compatible base URL) or `dragon config custom-url <url>`.')
|
|
73
|
+
const apiKey = process.env.DRAGON_OPENAI_KEY || cfg.brain?.keys?.openai || 'none'
|
|
74
|
+
const model = opts?.model || process.env.DRAGON_CUSTOM_MODEL || cfg.brain?.customModel || 'default'
|
|
75
|
+
return makeOpenAICompatBrain({ id: 'custom', baseURL, apiKey, model })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (provider === 'router') {
|
|
79
|
+
// The Ghost Router — classifies each turn and delegates to the best local
|
|
80
|
+
// model (or escalates to Claude). `resolve` is injected so the router can
|
|
81
|
+
// build sibling brains without a circular import.
|
|
82
|
+
const localBaseURL = process.env.DRAGON_LOCAL_URL || cfg.brain?.localBaseURL || 'http://localhost:11434/v1'
|
|
83
|
+
return makeRouterBrain({ resolve: (p, m) => getBrain({ provider: p, model: m }), localBaseURL })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (provider === 'ghost') {
|
|
87
|
+
// DragonSpark's EMBER — Ghost Protocol's own fine-tuned local model, served by
|
|
88
|
+
// Ollama. Uses the EMBER-specific adapter (JSON-array tool format + Wyrm-RAG via
|
|
89
|
+
// the primed system prompt), NOT generic OpenAI function-calling.
|
|
90
|
+
const baseURL = process.env.DRAGON_GHOST_URL || cfg.brain?.ghostBaseURL || 'http://localhost:11434/v1'
|
|
91
|
+
const model = opts?.model || process.env.DRAGON_GHOST_MODEL || cfg.brain?.ghostModel || 'ember'
|
|
92
|
+
return makeGhostEmberBrain({ baseURL, model })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// local (Ollama / any OpenAI-compatible local server) — sovereign, $0
|
|
96
|
+
const baseURL = process.env.DRAGON_LOCAL_URL || cfg.brain?.localBaseURL || 'http://localhost:11434/v1'
|
|
97
|
+
const model = opts?.model || process.env.DRAGON_LOCAL_MODEL || cfg.brain?.localModel || 'qwen2.5-coder:7b'
|
|
98
|
+
return makeOpenAICompatBrain({ id: 'local', baseURL, apiKey: 'ollama', model })
|
|
99
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible brain — one implementation, two providers:
|
|
3
|
+
* - 'openai' → https://api.openai.com/v1 (GPT)
|
|
4
|
+
* - 'local' → http://localhost:11434/v1 (Ollama; sovereign, $0)
|
|
5
|
+
* Any other OpenAI-compatible server works too via a custom baseURL.
|
|
6
|
+
*
|
|
7
|
+
* Speaks the Chat Completions streaming protocol with function tools, decoding
|
|
8
|
+
* SSE deltas: `delta.content` → text, `delta.tool_calls[]` → accumulated by
|
|
9
|
+
* index (id + name once, arguments streamed as string chunks → JSON-parsed at
|
|
10
|
+
* the end). No SDK — plain fetch keeps the dep surface small.
|
|
11
|
+
*
|
|
12
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Brain, BrainMessage, BrainTurn, TurnOpts } from './types.js'
|
|
16
|
+
|
|
17
|
+
interface OAIMessage {
|
|
18
|
+
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
19
|
+
content: string | null
|
|
20
|
+
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]
|
|
21
|
+
tool_call_id?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toOpenAI(system: string, messages: BrainMessage[]): OAIMessage[] {
|
|
25
|
+
const out: OAIMessage[] = [{ role: 'system', content: system }]
|
|
26
|
+
for (const m of messages) {
|
|
27
|
+
if (m.role === 'tool') {
|
|
28
|
+
out.push({ role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content })
|
|
29
|
+
} else if (m.role === 'assistant') {
|
|
30
|
+
out.push({
|
|
31
|
+
role: 'assistant',
|
|
32
|
+
content: m.content || null,
|
|
33
|
+
tool_calls: m.toolCalls?.length
|
|
34
|
+
? m.toolCalls.map((tc) => ({ id: tc.id, type: 'function' as const, function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) } }))
|
|
35
|
+
: undefined,
|
|
36
|
+
})
|
|
37
|
+
} else {
|
|
38
|
+
out.push({ role: 'user', content: m.content })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AccTool { id: string; name: string; args: string }
|
|
45
|
+
|
|
46
|
+
export function makeOpenAICompatBrain(opts: { id: string; baseURL: string; apiKey: string; model: string }): Brain {
|
|
47
|
+
const baseURL = opts.baseURL.replace(/\/+$/, '')
|
|
48
|
+
return {
|
|
49
|
+
id: opts.id,
|
|
50
|
+
model: opts.model,
|
|
51
|
+
async turn(t: TurnOpts): Promise<BrainTurn> {
|
|
52
|
+
const res = await fetch(`${baseURL}/chat/completions`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${opts.apiKey}` },
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: opts.model,
|
|
57
|
+
messages: toOpenAI(t.system, t.messages),
|
|
58
|
+
tools: t.tools.length ? t.tools.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })) : undefined,
|
|
59
|
+
tool_choice: t.tools.length ? 'auto' : undefined,
|
|
60
|
+
stream: true,
|
|
61
|
+
max_tokens: t.maxTokens ?? 8192,
|
|
62
|
+
}),
|
|
63
|
+
signal: t.signal,
|
|
64
|
+
})
|
|
65
|
+
if (!res.ok || !res.body) {
|
|
66
|
+
const body = await res.text().catch(() => res.statusText)
|
|
67
|
+
throw new Error(`${opts.id} brain HTTP ${res.status}: ${body.slice(0, 400)}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const reader = res.body.getReader()
|
|
71
|
+
const dec = new TextDecoder()
|
|
72
|
+
let buf = ''
|
|
73
|
+
let text = ''
|
|
74
|
+
const acc = new Map<number, AccTool>()
|
|
75
|
+
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { value, done } = await reader.read()
|
|
78
|
+
if (done) break
|
|
79
|
+
buf += dec.decode(value, { stream: true })
|
|
80
|
+
const lines = buf.split('\n')
|
|
81
|
+
buf = lines.pop() ?? ''
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (!line.startsWith('data: ')) continue
|
|
84
|
+
const data = line.slice(6).trim()
|
|
85
|
+
if (data === '[DONE]') break
|
|
86
|
+
let obj: any
|
|
87
|
+
try { obj = JSON.parse(data) } catch { continue }
|
|
88
|
+
const delta = obj.choices?.[0]?.delta
|
|
89
|
+
if (!delta) continue
|
|
90
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
91
|
+
text += delta.content
|
|
92
|
+
t.onDelta?.(delta.content)
|
|
93
|
+
}
|
|
94
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
95
|
+
const idx = tc.index ?? 0
|
|
96
|
+
const cur = acc.get(idx) ?? { id: '', name: '', args: '' }
|
|
97
|
+
if (tc.id) cur.id = tc.id
|
|
98
|
+
if (tc.function?.name) cur.name = tc.function.name
|
|
99
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments
|
|
100
|
+
acc.set(idx, cur)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const toolCalls: BrainTurn['toolCalls'] = [...acc.values()]
|
|
106
|
+
.filter((a) => a.name)
|
|
107
|
+
.map((a, i) => {
|
|
108
|
+
let args: Record<string, unknown> = {}
|
|
109
|
+
try { args = a.args ? JSON.parse(a.args) : {} } catch { args = {} }
|
|
110
|
+
return { id: a.id || `call_${i}`, name: a.name, arguments: args }
|
|
111
|
+
})
|
|
112
|
+
return { text, toolCalls }
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request classification for the Ghost Router — intent × difficulty × stakes.
|
|
3
|
+
*
|
|
4
|
+
* INTENT is decided semantically: we embed the current user message with the
|
|
5
|
+
* already-resident nomic-embed-text (via Ollama, ~0 extra VRAM) and take the
|
|
6
|
+
* nearest route centroid. A deterministic keyword classifier is the fallback when
|
|
7
|
+
* embeddings are unavailable, so routing degrades gracefully (never throws).
|
|
8
|
+
*
|
|
9
|
+
* DIFFICULTY and STAKES are cheap deterministic heuristics — no model needed.
|
|
10
|
+
*
|
|
11
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BrainMessage } from '../types.js'
|
|
15
|
+
|
|
16
|
+
export type Intent = 'reasoning' | 'code' | 'tools' | 'chat' | 'ops_security'
|
|
17
|
+
export type Stakes = 'security' | 'financial' | 'critical' | 'standard' | 'dev'
|
|
18
|
+
|
|
19
|
+
export interface Classification {
|
|
20
|
+
intent: Intent
|
|
21
|
+
difficulty: number // 0..1
|
|
22
|
+
stakes: Stakes
|
|
23
|
+
via: 'embed' | 'keyword' // how intent was decided (telemetry)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMBED_URL = (base: string) => base.replace(/\/v1\/?$/, '').replace(/\/+$/, '') + '/api/embeddings'
|
|
27
|
+
|
|
28
|
+
/** Seed phrases per route — centroids are the mean of their embeddings. */
|
|
29
|
+
const ROUTE_SEEDS: Record<Intent, string[]> = {
|
|
30
|
+
reasoning: [
|
|
31
|
+
'solve this math problem step by step', 'prove that the statement holds',
|
|
32
|
+
'what is the time complexity', 'derive the closed-form formula',
|
|
33
|
+
'solve this logic puzzle', 'crack this cipher / modular arithmetic',
|
|
34
|
+
],
|
|
35
|
+
code: [
|
|
36
|
+
'write a function that', 'implement this feature', 'refactor this code',
|
|
37
|
+
'add a unit test for', 'fix the bug in this code',
|
|
38
|
+
],
|
|
39
|
+
tools: [
|
|
40
|
+
'run the command', 'edit the file', 'deploy the service', 'list the files',
|
|
41
|
+
'grep the codebase for', 'execute and show the output',
|
|
42
|
+
],
|
|
43
|
+
chat: [
|
|
44
|
+
'hello', 'what do you think about', 'explain this briefly',
|
|
45
|
+
'summarize this', 'give me a quick answer',
|
|
46
|
+
],
|
|
47
|
+
ops_security: [
|
|
48
|
+
'run a penetration test', 'find the vulnerability', 'audit this for security',
|
|
49
|
+
'harden this configuration', 'scan the target host',
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const KEYWORDS: Record<Intent, RegExp> = {
|
|
54
|
+
ops_security: /\b(pentest|exploit|vuln|cve|nmap|payload|recon|harden|audit|red.?team|owasp)\b/i,
|
|
55
|
+
reasoning: /\b(prove|theorem|complexity|derive|calculate|compute|equation|algorithm|puzzle|cipher|optimal)\b/i,
|
|
56
|
+
code: /\b(function|refactor|implement|class|compile|unit test|bug|stack trace|typescript|python)\b/i,
|
|
57
|
+
tools: /\b(run|execute|deploy|grep|edit|read the file|list files|install|git )\b/i,
|
|
58
|
+
chat: /.*/, // fallback
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const STAKES_RULES: [Stakes, RegExp][] = [
|
|
62
|
+
// NOTE: pentest/exploit are our DOMAIN, not "stakes" — they must stay local
|
|
63
|
+
// (privacy-first). Only genuinely sensitive/irreversible ops escalate.
|
|
64
|
+
['security', /\b(production|prod\b|credential|secret|api.?key|ssh|rm -rf|drop table|sudo)\b/i],
|
|
65
|
+
['financial', /\b(payment|invoice|billing|refund|bank|wire|salary|price|charge|stripe|paddle)\b/i],
|
|
66
|
+
['critical', /\b(migration|database|deploy to prod|delete|irreversible|legal|contract)\b/i],
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
function lastUser(messages: BrainMessage[]): string {
|
|
70
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user') return messages[i].content
|
|
71
|
+
return messages.length ? messages[messages.length - 1].content : ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function embed(base: string, text: string, signal?: AbortSignal): Promise<number[] | null> {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(EMBED_URL(base), {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ model: process.env.DRAGON_ROUTER_EMBED || 'nomic-embed-text', prompt: text }),
|
|
80
|
+
signal,
|
|
81
|
+
})
|
|
82
|
+
if (!res.ok) return null
|
|
83
|
+
const data = (await res.json()) as { embedding?: number[] }
|
|
84
|
+
return Array.isArray(data.embedding) && data.embedding.length ? data.embedding : null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cosine(a: number[], b: number[]): number {
|
|
91
|
+
let dot = 0, na = 0, nb = 0
|
|
92
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i] }
|
|
93
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Route centroids are computed once and cached. If embeddings are unavailable
|
|
97
|
+
// (e.g. Ollama still starting), we keep CENTROIDS null and RETRY after a cooldown
|
|
98
|
+
// rather than disabling semantic routing for the whole process lifetime.
|
|
99
|
+
let CENTROIDS: Record<Intent, number[]> | null = null
|
|
100
|
+
let centroidsNextRetry = 0
|
|
101
|
+
|
|
102
|
+
async function ensureCentroids(base: string, signal?: AbortSignal): Promise<Record<Intent, number[]> | null> {
|
|
103
|
+
if (CENTROIDS) return CENTROIDS
|
|
104
|
+
if (Date.now() < centroidsNextRetry) return null
|
|
105
|
+
centroidsNextRetry = Date.now() + 30_000 // don't hammer a down Ollama
|
|
106
|
+
const out = {} as Record<Intent, number[]>
|
|
107
|
+
for (const intent of Object.keys(ROUTE_SEEDS) as Intent[]) {
|
|
108
|
+
const vecs: number[][] = []
|
|
109
|
+
for (const phrase of ROUTE_SEEDS[intent]) {
|
|
110
|
+
const v = await embed(base, phrase, signal)
|
|
111
|
+
if (v) vecs.push(v)
|
|
112
|
+
}
|
|
113
|
+
if (!vecs.length) return null // embeddings unavailable → keyword fallback, retry later
|
|
114
|
+
const dim = vecs[0].length
|
|
115
|
+
const mean = new Array(dim).fill(0)
|
|
116
|
+
for (const v of vecs) for (let i = 0; i < dim; i++) mean[i] += v[i] / vecs.length
|
|
117
|
+
out[intent] = mean
|
|
118
|
+
}
|
|
119
|
+
return (CENTROIDS = out)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function keywordIntent(text: string): Intent {
|
|
123
|
+
for (const intent of ['ops_security', 'reasoning', 'code', 'tools'] as Intent[]) {
|
|
124
|
+
if (KEYWORDS[intent].test(text)) return intent
|
|
125
|
+
}
|
|
126
|
+
return 'chat'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scoreDifficulty(text: string, toolCount: number): number {
|
|
130
|
+
const len = text.length
|
|
131
|
+
let d = Math.min(len / 1600, 0.5) // length signal, capped at 0.5
|
|
132
|
+
if (/\b(step by step|prove|derive|complex|multi-step|optimi[sz]e|edge case|why)\b/i.test(text)) d += 0.2
|
|
133
|
+
if ((text.match(/\?/g) || []).length >= 2) d += 0.1 // multiple questions
|
|
134
|
+
if (/```|\bfunction\b|\bclass\b/.test(text)) d += 0.1 // code present
|
|
135
|
+
d += Math.min(toolCount / 40, 0.15) // many tools = harder orchestration
|
|
136
|
+
return Math.max(0, Math.min(1, d))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function classifyStakes(text: string): Stakes {
|
|
140
|
+
for (const [tier, re] of STAKES_RULES) if (re.test(text)) return tier
|
|
141
|
+
return 'standard'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function classify(
|
|
145
|
+
base: string,
|
|
146
|
+
messages: BrainMessage[],
|
|
147
|
+
toolCount: number,
|
|
148
|
+
signal?: AbortSignal,
|
|
149
|
+
): Promise<Classification> {
|
|
150
|
+
const text = lastUser(messages)
|
|
151
|
+
const stakes = classifyStakes(text)
|
|
152
|
+
const difficulty = scoreDifficulty(text, toolCount)
|
|
153
|
+
|
|
154
|
+
const centroids = await ensureCentroids(base, signal)
|
|
155
|
+
if (centroids) {
|
|
156
|
+
const v = await embed(base, text, signal)
|
|
157
|
+
if (v) {
|
|
158
|
+
let best: Intent = 'chat', bestScore = -Infinity
|
|
159
|
+
for (const intent of Object.keys(centroids) as Intent[]) {
|
|
160
|
+
const s = cosine(v, centroids[intent])
|
|
161
|
+
if (s > bestScore) { bestScore = s; best = intent }
|
|
162
|
+
}
|
|
163
|
+
return { intent: best, difficulty, stakes, via: 'embed' }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { intent: keywordIntent(text), difficulty, stakes, via: 'keyword' }
|
|
167
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution-based verification (ROUTER-BLUEPRINT.md §2) — the highest-value reward
|
|
3
|
+
* for code/security work: actually RUN the candidate and use pass/fail as the signal.
|
|
4
|
+
*
|
|
5
|
+
* SECURITY-FIRST (this is a security company): auto-running model-generated code is
|
|
6
|
+
* dangerous, so it is OFF by default and only ever runs:
|
|
7
|
+
* - when DRAGON_VERIFY_EXEC=1 is explicitly set,
|
|
8
|
+
* - inside a bwrap sandbox with NO network, a tmpfs, and a read-only /usr,
|
|
9
|
+
* - for python3 / node ONLY (never bash/sh),
|
|
10
|
+
* - under a hard timeout.
|
|
11
|
+
* If bwrap is missing we REFUSE to execute (fail closed).
|
|
12
|
+
*
|
|
13
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync, execSync } from 'node:child_process'
|
|
17
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
export interface ExecResult {
|
|
22
|
+
ran: boolean
|
|
23
|
+
ok?: boolean
|
|
24
|
+
lang?: string
|
|
25
|
+
output?: string
|
|
26
|
+
reason?: string // why it didn't run
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LANGS: Record<string, { file: string; cmd: string }> = {
|
|
30
|
+
python: { file: 'snippet.py', cmd: 'python3' },
|
|
31
|
+
py: { file: 'snippet.py', cmd: 'python3' },
|
|
32
|
+
javascript: { file: 'snippet.js', cmd: 'node' },
|
|
33
|
+
js: { file: 'snippet.js', cmd: 'node' },
|
|
34
|
+
node: { file: 'snippet.js', cmd: 'node' },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasBwrap(): boolean {
|
|
38
|
+
try { execSync('command -v bwrap', { stdio: 'ignore' }); return true } catch { return false }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pull the last fenced code block + its language. */
|
|
42
|
+
function lastCodeBlock(text: string): { lang: string; code: string } | null {
|
|
43
|
+
const blocks = [...text.matchAll(/```([a-zA-Z0-9]*)\n([\s\S]*?)```/g)]
|
|
44
|
+
if (!blocks.length) return null
|
|
45
|
+
const b = blocks[blocks.length - 1]
|
|
46
|
+
return { lang: (b[1] || '').toLowerCase(), code: b[2] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function executeVerify(text: string, _signal?: AbortSignal): Promise<ExecResult | null> {
|
|
50
|
+
if (process.env.DRAGON_VERIFY_EXEC !== '1') return null // off by default
|
|
51
|
+
const block = lastCodeBlock(text)
|
|
52
|
+
if (!block) return null
|
|
53
|
+
const spec = LANGS[block.lang]
|
|
54
|
+
if (!spec) return { ran: false, reason: `unsupported lang '${block.lang}' (only python/node auto-run)` }
|
|
55
|
+
if (!hasBwrap()) return { ran: false, reason: 'no bwrap sandbox — refusing to execute (fail-closed)' }
|
|
56
|
+
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'dragon-verify-'))
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(join(dir, spec.file), block.code, { mode: 0o600 })
|
|
60
|
+
const args = [
|
|
61
|
+
'--unshare-all', '--die-with-parent',
|
|
62
|
+
'--cap-drop', 'ALL', '--new-session', // drop all caps + no controlling tty (defense-in-depth)
|
|
63
|
+
'--ro-bind', '/usr', '/usr',
|
|
64
|
+
'--ro-bind', '/lib', '/lib', '--ro-bind', '/lib64', '/lib64',
|
|
65
|
+
'--proc', '/proc', '--dev', '/dev',
|
|
66
|
+
'--tmpfs', '/tmp',
|
|
67
|
+
'--bind', dir, '/work', '--chdir', '/work',
|
|
68
|
+
'--setenv', 'HOME', '/work', '--setenv', 'PATH', '/usr/bin:/bin',
|
|
69
|
+
spec.cmd, join('/work', spec.file),
|
|
70
|
+
]
|
|
71
|
+
const r = spawnSync('bwrap', args, { timeout: 8000, encoding: 'utf-8', maxBuffer: 1 << 20 })
|
|
72
|
+
if (r.error) return { ran: false, lang: block.lang, reason: String(r.error.message || r.error) }
|
|
73
|
+
const output = ((r.stdout || '') + (r.stderr || '')).slice(0, 2000)
|
|
74
|
+
return { ran: true, ok: r.status === 0, lang: block.lang, output }
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { ran: false, lang: block.lang, reason: (e as Error).message }
|
|
77
|
+
} finally {
|
|
78
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* best-effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|