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.
Files changed (226) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/CHANGELOG.md +96 -0
  3. package/README.md +193 -0
  4. package/bootstrap.ps1 +83 -0
  5. package/bootstrap.sh +71 -0
  6. package/dist/agent/loop.d.ts +68 -0
  7. package/dist/agent/loop.d.ts.map +1 -0
  8. package/dist/agent/loop.js +135 -0
  9. package/dist/agent/mcp.d.ts +33 -0
  10. package/dist/agent/mcp.d.ts.map +1 -0
  11. package/dist/agent/mcp.js +107 -0
  12. package/dist/agent/session.d.ts +16 -0
  13. package/dist/agent/session.d.ts.map +1 -0
  14. package/dist/agent/session.js +55 -0
  15. package/dist/agent/skills.d.ts +36 -0
  16. package/dist/agent/skills.d.ts.map +1 -0
  17. package/dist/agent/skills.js +153 -0
  18. package/dist/agent/stack.d.ts +21 -0
  19. package/dist/agent/stack.d.ts.map +1 -0
  20. package/dist/agent/stack.js +158 -0
  21. package/dist/agent/task.d.ts +21 -0
  22. package/dist/agent/task.d.ts.map +1 -0
  23. package/dist/agent/task.js +45 -0
  24. package/dist/agent/tools.d.ts +44 -0
  25. package/dist/agent/tools.d.ts.map +1 -0
  26. package/dist/agent/tools.js +262 -0
  27. package/dist/agent/trace.d.ts +34 -0
  28. package/dist/agent/trace.d.ts.map +1 -0
  29. package/dist/agent/trace.js +72 -0
  30. package/dist/agent.d.ts +46 -0
  31. package/dist/agent.d.ts.map +1 -0
  32. package/dist/agent.js +103 -0
  33. package/dist/auth.d.ts +74 -0
  34. package/dist/auth.d.ts.map +1 -0
  35. package/dist/auth.js +116 -0
  36. package/dist/brain/anthropic.d.ts +19 -0
  37. package/dist/brain/anthropic.d.ts.map +1 -0
  38. package/dist/brain/anthropic.js +74 -0
  39. package/dist/brain/claude-cli.d.ts +20 -0
  40. package/dist/brain/claude-cli.d.ts.map +1 -0
  41. package/dist/brain/claude-cli.js +79 -0
  42. package/dist/brain/ghost-ember.d.ts +28 -0
  43. package/dist/brain/ghost-ember.d.ts.map +1 -0
  44. package/dist/brain/ghost-ember.js +97 -0
  45. package/dist/brain/index.d.ts +22 -0
  46. package/dist/brain/index.d.ts.map +1 -0
  47. package/dist/brain/index.js +95 -0
  48. package/dist/brain/openai-compat.d.ts +21 -0
  49. package/dist/brain/openai-compat.d.ts.map +1 -0
  50. package/dist/brain/openai-compat.js +119 -0
  51. package/dist/brain/router/classify.d.ts +23 -0
  52. package/dist/brain/router/classify.d.ts.map +1 -0
  53. package/dist/brain/router/classify.js +160 -0
  54. package/dist/brain/router/execute.d.ts +23 -0
  55. package/dist/brain/router/execute.d.ts.map +1 -0
  56. package/dist/brain/router/execute.js +84 -0
  57. package/dist/brain/router/index.d.ts +26 -0
  58. package/dist/brain/router/index.d.ts.map +1 -0
  59. package/dist/brain/router/index.js +118 -0
  60. package/dist/brain/router/routing-memory.d.ts +27 -0
  61. package/dist/brain/router/routing-memory.d.ts.map +1 -0
  62. package/dist/brain/router/routing-memory.js +77 -0
  63. package/dist/brain/router/select.d.ts +32 -0
  64. package/dist/brain/router/select.d.ts.map +1 -0
  65. package/dist/brain/router/select.js +146 -0
  66. package/dist/brain/router/two-hop.d.ts +23 -0
  67. package/dist/brain/router/two-hop.d.ts.map +1 -0
  68. package/dist/brain/router/two-hop.js +39 -0
  69. package/dist/brain/router/verify.d.ts +37 -0
  70. package/dist/brain/router/verify.d.ts.map +1 -0
  71. package/dist/brain/router/verify.js +111 -0
  72. package/dist/brain/types.d.ts +55 -0
  73. package/dist/brain/types.d.ts.map +1 -0
  74. package/dist/brain/types.js +16 -0
  75. package/dist/brain/worker.d.ts +27 -0
  76. package/dist/brain/worker.d.ts.map +1 -0
  77. package/dist/brain/worker.js +71 -0
  78. package/dist/commands/ai.d.ts +24 -0
  79. package/dist/commands/ai.d.ts.map +1 -0
  80. package/dist/commands/ai.js +137 -0
  81. package/dist/commands/alerts.d.ts +19 -0
  82. package/dist/commands/alerts.d.ts.map +1 -0
  83. package/dist/commands/alerts.js +114 -0
  84. package/dist/commands/billing.d.ts +13 -0
  85. package/dist/commands/billing.d.ts.map +1 -0
  86. package/dist/commands/billing.js +55 -0
  87. package/dist/commands/chat.d.ts +22 -0
  88. package/dist/commands/chat.d.ts.map +1 -0
  89. package/dist/commands/chat.js +422 -0
  90. package/dist/commands/config.d.ts +18 -0
  91. package/dist/commands/config.d.ts.map +1 -0
  92. package/dist/commands/config.js +136 -0
  93. package/dist/commands/doctor.d.ts +11 -0
  94. package/dist/commands/doctor.d.ts.map +1 -0
  95. package/dist/commands/doctor.js +73 -0
  96. package/dist/commands/global.d.ts +11 -0
  97. package/dist/commands/global.d.ts.map +1 -0
  98. package/dist/commands/global.js +253 -0
  99. package/dist/commands/keep.d.ts +12 -0
  100. package/dist/commands/keep.d.ts.map +1 -0
  101. package/dist/commands/keep.js +58 -0
  102. package/dist/commands/lifecycle.d.ts +17 -0
  103. package/dist/commands/lifecycle.d.ts.map +1 -0
  104. package/dist/commands/lifecycle.js +267 -0
  105. package/dist/commands/login.d.ts +16 -0
  106. package/dist/commands/login.d.ts.map +1 -0
  107. package/dist/commands/login.js +234 -0
  108. package/dist/commands/maintenance.d.ts +12 -0
  109. package/dist/commands/maintenance.d.ts.map +1 -0
  110. package/dist/commands/maintenance.js +76 -0
  111. package/dist/commands/mcp.d.ts +16 -0
  112. package/dist/commands/mcp.d.ts.map +1 -0
  113. package/dist/commands/mcp.js +56 -0
  114. package/dist/commands/memory.d.ts +13 -0
  115. package/dist/commands/memory.d.ts.map +1 -0
  116. package/dist/commands/memory.js +218 -0
  117. package/dist/commands/osint.d.ts +14 -0
  118. package/dist/commands/osint.d.ts.map +1 -0
  119. package/dist/commands/osint.js +161 -0
  120. package/dist/commands/pentest.d.ts +13 -0
  121. package/dist/commands/pentest.d.ts.map +1 -0
  122. package/dist/commands/pentest.js +131 -0
  123. package/dist/commands/scale.d.ts +14 -0
  124. package/dist/commands/scale.d.ts.map +1 -0
  125. package/dist/commands/scale.js +191 -0
  126. package/dist/commands/serve.d.ts +16 -0
  127. package/dist/commands/serve.d.ts.map +1 -0
  128. package/dist/commands/serve.js +167 -0
  129. package/dist/commands/tui.d.ts +17 -0
  130. package/dist/commands/tui.d.ts.map +1 -0
  131. package/dist/commands/tui.js +138 -0
  132. package/dist/commands/wyrm.d.ts +20 -0
  133. package/dist/commands/wyrm.d.ts.map +1 -0
  134. package/dist/commands/wyrm.js +274 -0
  135. package/dist/config.d.ts +67 -0
  136. package/dist/config.d.ts.map +1 -0
  137. package/dist/config.js +54 -0
  138. package/dist/index.d.ts +16 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +85 -0
  141. package/dist/manifest.d.ts +31 -0
  142. package/dist/manifest.d.ts.map +1 -0
  143. package/dist/manifest.js +83 -0
  144. package/dist/ui.d.ts +57 -0
  145. package/dist/ui.d.ts.map +1 -0
  146. package/dist/ui.js +174 -0
  147. package/dist/utils.d.ts +33 -0
  148. package/dist/utils.d.ts.map +1 -0
  149. package/dist/utils.js +155 -0
  150. package/dist/wyrm/mcp.d.ts +37 -0
  151. package/dist/wyrm/mcp.d.ts.map +1 -0
  152. package/dist/wyrm/mcp.js +137 -0
  153. package/docs/SYSTEM-PREMORTEM.md +397 -0
  154. package/dragon-manifest.toml +241 -0
  155. package/dragon.py +177 -0
  156. package/install/launchd/lk.ghosts.dragonkeep.plist +57 -0
  157. package/install/systemd/dragonkeep.service +40 -0
  158. package/media/dragon-silver-lockup.svg +931 -0
  159. package/media/dragon-silver-mark.svg +931 -0
  160. package/media/dragon-silver.png +0 -0
  161. package/package.json +45 -0
  162. package/specs/001-godmode/constitution.md +54 -0
  163. package/specs/001-godmode/plan.md +30 -0
  164. package/specs/001-godmode/spec.md +64 -0
  165. package/specs/001-godmode/tasks.md +35 -0
  166. package/specs/002-premortem-positioning/premortem.md +211 -0
  167. package/src/agent/loop.ts +165 -0
  168. package/src/agent/mcp.ts +92 -0
  169. package/src/agent/session.ts +48 -0
  170. package/src/agent/skills.ts +138 -0
  171. package/src/agent/stack.ts +154 -0
  172. package/src/agent/task.ts +55 -0
  173. package/src/agent/tools.ts +255 -0
  174. package/src/agent/trace.ts +76 -0
  175. package/src/agent.ts +114 -0
  176. package/src/auth.ts +133 -0
  177. package/src/brain/anthropic.ts +83 -0
  178. package/src/brain/claude-cli.ts +78 -0
  179. package/src/brain/ghost-ember.ts +94 -0
  180. package/src/brain/index.ts +99 -0
  181. package/src/brain/openai-compat.ts +115 -0
  182. package/src/brain/router/classify.ts +167 -0
  183. package/src/brain/router/execute.ts +80 -0
  184. package/src/brain/router/index.ts +125 -0
  185. package/src/brain/router/routing-memory.ts +71 -0
  186. package/src/brain/router/select.ts +156 -0
  187. package/src/brain/router/two-hop.ts +62 -0
  188. package/src/brain/router/verify.ts +123 -0
  189. package/src/brain/types.ts +61 -0
  190. package/src/brain/worker.ts +72 -0
  191. package/src/commands/ai.ts +144 -0
  192. package/src/commands/alerts.ts +131 -0
  193. package/src/commands/billing.ts +59 -0
  194. package/src/commands/chat.ts +318 -0
  195. package/src/commands/config.ts +137 -0
  196. package/src/commands/doctor.ts +71 -0
  197. package/src/commands/global.ts +256 -0
  198. package/src/commands/keep.ts +67 -0
  199. package/src/commands/lifecycle.ts +273 -0
  200. package/src/commands/login.ts +184 -0
  201. package/src/commands/maintenance.ts +54 -0
  202. package/src/commands/mcp.ts +57 -0
  203. package/src/commands/memory.ts +229 -0
  204. package/src/commands/osint.ts +171 -0
  205. package/src/commands/pentest.ts +140 -0
  206. package/src/commands/scale.ts +185 -0
  207. package/src/commands/serve.ts +171 -0
  208. package/src/commands/tui.ts +126 -0
  209. package/src/commands/wyrm.ts +269 -0
  210. package/src/config.ts +93 -0
  211. package/src/index.ts +92 -0
  212. package/src/manifest.ts +104 -0
  213. package/src/ui.ts +188 -0
  214. package/src/utils.ts +153 -0
  215. package/src/wyrm/mcp.ts +130 -0
  216. package/test/auth.test.ts +70 -0
  217. package/test/brain.test.ts +39 -0
  218. package/test/security.test.ts +104 -0
  219. package/test/skills.test.ts +38 -0
  220. package/test/ui.test.ts +46 -0
  221. package/tsconfig.json +19 -0
  222. package/worker/package-lock.json +1527 -0
  223. package/worker/package.json +17 -0
  224. package/worker/src/index.ts +76 -0
  225. package/worker/tsconfig.json +15 -0
  226. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Local coding tools — the agent's hands. These run on THIS machine (that's the
3
+ * whole point of a CLI coding agent vs the hosted Worker assistant): read/write/
4
+ * edit files, list/glob/grep the tree, run shell commands.
5
+ *
6
+ * Mutating tools (write_file, edit_file, bash) pass through an approval gate so
7
+ * nothing touches the disk or runs a command without a yes — unless the session
8
+ * is in auto-approve. Read-only tools (read_file, list_dir, glob, grep) run free.
9
+ *
10
+ * grep/glob shell out to ripgrep (present on this box) for speed + .gitignore
11
+ * awareness; output is capped so a huge result can't blow the context window.
12
+ *
13
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
14
+ */
15
+
16
+ import { spawn } from 'node:child_process'
17
+ import { readFileSync, writeFileSync, mkdirSync, statSync, readdirSync, existsSync, realpathSync } from 'node:fs'
18
+ import { resolve, dirname, isAbsolute, join, relative } from 'node:path'
19
+ import type { ToolSpec } from '../brain/types.js'
20
+
21
+ const MAX_OUTPUT = 30_000 // chars fed back to the model per tool result
22
+ const MAX_READ_BYTES = 200_000
23
+
24
+ export interface ToolContext {
25
+ cwd: string
26
+ /** Returns true to proceed. `dangerous` actions (bash, anything outside the
27
+ * working dir) are NEVER covered by --auto — they always prompt. */
28
+ approve: (summary: string, opts?: { detail?: string; dangerous?: boolean }) => Promise<boolean>
29
+ /** When true, run bash inside a bwrap sandbox (cwd writable, rest read-only,
30
+ * credential dirs masked). Filesystem confinement only — network is shared. */
31
+ sandbox?: boolean
32
+ }
33
+
34
+ export const BWRAP = ['/usr/bin/bwrap', '/usr/local/bin/bwrap'].find((p) => existsSync(p)) ?? null
35
+
36
+ /** Build the argv for a shell command, optionally wrapped in a bwrap jail. */
37
+ function bashArgv(command: string, cwd: string, sandbox: boolean): { cmd: string; args: string[] } {
38
+ if (sandbox && BWRAP) {
39
+ const home = process.env.HOME || ''
40
+ const mask = ['.ssh', '.aws', '.gnupg', '.dragon', '.config/gh', '.npmrc'].flatMap((d) => ['--tmpfs', `${home}/${d}`])
41
+ return {
42
+ cmd: BWRAP,
43
+ args: ['--ro-bind', '/', '/', '--bind', cwd, cwd, '--tmpfs', '/tmp', '--dev', '/dev', '--proc', '/proc', '--unshare-pid', ...mask, '--chdir', cwd, '--', 'bash', '-c', command],
44
+ }
45
+ }
46
+ return { cmd: 'bash', args: ['-c', command] }
47
+ }
48
+
49
+ // Paths the agent must never touch — credentials/keys — even with approval.
50
+ const PROTECTED = [/(^|\/)\.ssh(\/|$)/, /(^|\/)\.aws(\/|$)/, /(^|\/)\.gnupg(\/|$)/, /(^|\/)\.dragon(\/|$)/, /(^|\/)\.git\/config$/, /\.env(\.[\w-]+)?$/, /id_(rsa|ed25519|ecdsa)/, /\.(pem|key|p12|keystore)$/, /(^|\/)\.(npmrc|netrc|pgpass)$/];
51
+
52
+ // Obviously-catastrophic shell — flagged for a louder confirm (never auto-run).
53
+ const CATASTROPHIC = /\brm\s+-[rf]{1,2}\b[^|]*[\s/~*]|\bmkfs\b|\bdd\s+if=|>\s*\/dev\/(sd|nvme|disk)|:\(\)\s*\{\s*:\s*\|\s*:|(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba)?sh\b|\bchmod\s+-R?\s*777\s+\//i;
54
+
55
+ /** Resolve a path and classify it: inside cwd, outside, or protected (symlink-aware). */
56
+ export function guardPath(cwd: string, p: string): { abs: string; outside: boolean; protectedPath: boolean } {
57
+ const absPath = isAbsolute(p) ? resolve(p) : resolve(cwd, p)
58
+ const protectedPath = PROTECTED.some((re) => re.test(absPath))
59
+ let real = absPath
60
+ try { real = realpathSync(absPath) } catch { /* may not exist yet (new file) */ }
61
+ let root = resolve(cwd)
62
+ try { root = realpathSync(cwd) } catch { /* keep resolved */ }
63
+ const within = (x: string) => x === root || x.startsWith(root + '/')
64
+ const outside = !(within(real) || within(absPath))
65
+ return { abs: absPath, outside, protectedPath }
66
+ }
67
+
68
+ export interface LocalTool {
69
+ spec: ToolSpec
70
+ mutating: boolean
71
+ summary(args: Record<string, unknown>): string
72
+ run(args: Record<string, unknown>, ctx: ToolContext): Promise<string>
73
+ }
74
+
75
+ function clamp(s: string): string {
76
+ return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `\n… [truncated ${s.length - MAX_OUTPUT} chars]` : s
77
+ }
78
+
79
+ function capture(cmd: string, args: string[], opts: { cwd: string; input?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }> {
80
+ return new Promise((res) => {
81
+ const child = spawn(cmd, args, { cwd: opts.cwd })
82
+ let stdout = ''
83
+ let stderr = ''
84
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, opts.timeout ?? 120_000)
85
+ child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT * 2) stdout += d })
86
+ child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT) stderr += d })
87
+ child.on('error', (e) => { clearTimeout(timer); res({ stdout, stderr: stderr + String(e), code: 127 }) })
88
+ child.on('close', (code) => { clearTimeout(timer); res({ stdout, stderr, code: code ?? 0 }) })
89
+ if (opts.input !== undefined) { child.stdin.write(opts.input); child.stdin.end() }
90
+ })
91
+ }
92
+
93
+ const read_file: LocalTool = {
94
+ mutating: false,
95
+ summary: (a) => `read ${a.path}`,
96
+ spec: {
97
+ name: 'read_file',
98
+ description: 'Read a UTF-8 text file. Returns the content with 1-based line numbers. Use offset/limit for large files.',
99
+ parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number', description: '1-based start line' }, limit: { type: 'number', description: 'max lines' } }, required: ['path'] },
100
+ },
101
+ async run(a, ctx) {
102
+ const g = guardPath(ctx.cwd, String(a.path))
103
+ if (g.protectedPath) return `error: ${a.path} is a protected path (credentials/keys) — refused`
104
+ if (g.outside && !(await ctx.approve(`read OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: read declined (outside working directory)'
105
+ const p = g.abs
106
+ if (!existsSync(p)) return `error: no such file: ${a.path}`
107
+ const st = statSync(p)
108
+ if (st.isDirectory()) return `error: ${a.path} is a directory (use list_dir)`
109
+ if (st.size > MAX_READ_BYTES) return `error: file too large (${st.size} bytes). Use grep or offset/limit.`
110
+ const lines = readFileSync(p, 'utf-8').split('\n')
111
+ const start = a.offset ? Math.max(1, Number(a.offset)) : 1
112
+ const end = a.limit ? start + Number(a.limit) - 1 : lines.length
113
+ const slice = lines.slice(start - 1, end).map((l, i) => `${String(start + i).padStart(5)}\t${l}`).join('\n')
114
+ return clamp(slice || '(empty file)')
115
+ },
116
+ }
117
+
118
+ const write_file: LocalTool = {
119
+ mutating: true,
120
+ summary: (a) => `write ${a.path} (${String(a.content ?? '').length} bytes)`,
121
+ spec: {
122
+ name: 'write_file',
123
+ description: 'Create or overwrite a file with the given content. Creates parent directories. Prefer edit_file for changes to existing files.',
124
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
125
+ },
126
+ async run(a, ctx) {
127
+ const g = guardPath(ctx.cwd, String(a.path))
128
+ if (g.protectedPath) return `error: ${a.path} is a protected path — refused`
129
+ const p = g.abs
130
+ const content = String(a.content ?? '')
131
+ const existed = existsSync(p)
132
+ if (!(await ctx.approve(`${existed ? 'overwrite' : 'create'} ${relative(ctx.cwd, p) || a.path}${g.outside ? ' (OUTSIDE working dir)' : ''}`, { detail: content.slice(0, 800), dangerous: g.outside }))) return 'error: write declined by user'
133
+ mkdirSync(dirname(p), { recursive: true })
134
+ writeFileSync(p, content)
135
+ return `wrote ${content.length} bytes to ${a.path}`
136
+ },
137
+ }
138
+
139
+ const edit_file: LocalTool = {
140
+ mutating: true,
141
+ summary: (a) => `edit ${a.path}`,
142
+ spec: {
143
+ name: 'edit_file',
144
+ description: 'Replace an exact string in a file. old_string must match verbatim and be unique unless replace_all is true.',
145
+ parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['path', 'old_string', 'new_string'] },
146
+ },
147
+ async run(a, ctx) {
148
+ const g = guardPath(ctx.cwd, String(a.path))
149
+ if (g.protectedPath) return `error: ${a.path} is a protected path — refused`
150
+ const p = g.abs
151
+ if (!existsSync(p)) return `error: no such file: ${a.path}`
152
+ const src = readFileSync(p, 'utf-8')
153
+ const oldS = String(a.old_string)
154
+ const count = src.split(oldS).length - 1
155
+ if (count === 0) return `error: old_string not found in ${a.path}`
156
+ if (count > 1 && !a.replace_all) return `error: old_string appears ${count}× — pass replace_all:true or add context to make it unique`
157
+ if (!(await ctx.approve(`edit ${relative(ctx.cwd, p) || a.path}${g.outside ? ' (OUTSIDE working dir)' : ''}`, { detail: `- ${oldS.slice(0, 300)}\n+ ${String(a.new_string).slice(0, 300)}`, dangerous: g.outside }))) return 'error: edit declined by user'
158
+ const next = a.replace_all ? src.split(oldS).join(String(a.new_string)) : src.replace(oldS, String(a.new_string))
159
+ writeFileSync(p, next)
160
+ return `edited ${a.path} (${count} replacement${count > 1 ? 's' : ''})`
161
+ },
162
+ }
163
+
164
+ const list_dir: LocalTool = {
165
+ mutating: false,
166
+ summary: (a) => `ls ${a.path ?? '.'}`,
167
+ spec: { name: 'list_dir', description: 'List directory entries with type + size.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: [] } },
168
+ async run(a, ctx) {
169
+ const g = guardPath(ctx.cwd, String(a.path ?? '.'))
170
+ if (g.protectedPath) return `error: protected path — refused`
171
+ if (g.outside && !(await ctx.approve(`list OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
172
+ const p = g.abs
173
+ if (!existsSync(p)) return `error: no such directory: ${a.path}`
174
+ const entries = readdirSync(p, { withFileTypes: true })
175
+ .map((e) => {
176
+ const full = join(p, e.name)
177
+ let size = ''
178
+ try { if (e.isFile()) size = ` ${statSync(full).size}b` } catch {}
179
+ return `${e.isDirectory() ? 'd' : '-'} ${e.name}${e.isDirectory() ? '/' : ''}${size}`
180
+ })
181
+ .sort()
182
+ return clamp(entries.join('\n') || '(empty)')
183
+ },
184
+ }
185
+
186
+ const glob: LocalTool = {
187
+ mutating: false,
188
+ summary: (a) => `glob ${a.pattern}`,
189
+ spec: { name: 'glob', description: 'Find files matching a glob (ripgrep, .gitignore-aware). e.g. "src/**/*.ts".', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } },
190
+ async run(a, ctx) {
191
+ const g = guardPath(ctx.cwd, String(a.path ?? '.'))
192
+ if (g.protectedPath) return `error: protected path — refused`
193
+ if (g.outside && !(await ctx.approve(`glob OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
194
+ const dir = g.abs
195
+ const { stdout, stderr, code } = await capture('rg', ['--files', '-g', String(a.pattern), dir], { cwd: ctx.cwd })
196
+ if (code !== 0 && !stdout) return stderr.trim() ? `no matches (${stderr.trim().slice(0, 200)})` : 'no matches'
197
+ return clamp(stdout.split('\n').filter(Boolean).map((f) => relative(ctx.cwd, f) || f).join('\n') || 'no matches')
198
+ },
199
+ }
200
+
201
+ const grep: LocalTool = {
202
+ mutating: false,
203
+ summary: (a) => `grep "${a.pattern}"`,
204
+ spec: {
205
+ name: 'grep',
206
+ description: 'Search file contents with a regex (ripgrep). Returns file:line:match. Use glob to scope by file type.',
207
+ parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, ignore_case: { type: 'boolean' } }, required: ['pattern'] },
208
+ },
209
+ async run(a, ctx) {
210
+ const g = guardPath(ctx.cwd, String(a.path ?? '.'))
211
+ if (g.protectedPath) return `error: protected path — refused`
212
+ if (g.outside && !(await ctx.approve(`grep OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
213
+ const args = ['--line-number', '--no-heading', '--color', 'never']
214
+ if (a.ignore_case) args.push('-i')
215
+ if (a.glob) args.push('-g', String(a.glob))
216
+ args.push('--', String(a.pattern), g.abs)
217
+ const { stdout, stderr, code } = await capture('rg', args, { cwd: ctx.cwd })
218
+ if (code === 1) return 'no matches'
219
+ if (code > 1) return `error: ${stderr.trim().slice(0, 300)}`
220
+ return clamp(stdout || 'no matches')
221
+ },
222
+ }
223
+
224
+ const bash: LocalTool = {
225
+ mutating: true,
226
+ summary: (a) => `bash: ${String(a.command).slice(0, 70)}`,
227
+ spec: {
228
+ name: 'bash',
229
+ description: 'Run a shell command in the working directory and return stdout+stderr+exit code. Use for builds, tests, git, installs. Avoid destructive commands.',
230
+ parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] },
231
+ },
232
+ async run(a, ctx) {
233
+ const command = String(a.command)
234
+ const danger = CATASTROPHIC.test(command)
235
+ const summary = danger ? `⚠ DANGEROUS shell — ${command.slice(0, 80)}` : `run: ${command.slice(0, 80)}`
236
+ // bash is ALWAYS dangerous → never covered by --auto; the full command is shown.
237
+ if (!(await ctx.approve(`${ctx.sandbox && BWRAP ? '[sandboxed] ' : ''}${summary}`, { detail: command, dangerous: true }))) return 'error: command declined by user'
238
+ const { cmd, args } = bashArgv(command, ctx.cwd, !!ctx.sandbox)
239
+ const { stdout, stderr, code } = await capture(cmd, args, { cwd: ctx.cwd, timeout: a.timeout_ms ? Number(a.timeout_ms) : 120_000 })
240
+ const tag = ctx.sandbox ? (BWRAP ? '[sandboxed] ' : '[sandbox unavailable — bwrap not found] ') : ''
241
+ const body = [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`].filter(Boolean).join('\n')
242
+ return clamp(`${tag}exit ${code}\n${body || '(no output)'}`)
243
+ },
244
+ }
245
+
246
+ export const LOCAL_TOOLS: LocalTool[] = [read_file, write_file, edit_file, list_dir, glob, grep, bash]
247
+ const BY_NAME = new Map(LOCAL_TOOLS.map((t) => [t.spec.name, t]))
248
+
249
+ export function localToolSpecs(): ToolSpec[] {
250
+ return LOCAL_TOOLS.map((t) => t.spec)
251
+ }
252
+
253
+ export function getLocalTool(name: string): LocalTool | undefined {
254
+ return BY_NAME.get(name)
255
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Flywheel — emit a tool-use trace per completed agent turn to ~/.dragon/traces/.
3
+ *
4
+ * These traces are the training data for DragonSpark (our own nano brain): the agent's
5
+ * real work becomes the dataset that makes its next model better. DragonSpark's data
6
+ * pipeline does a mandatory secret/customer scrub before any training; we also do a
7
+ * light redaction here at emit time as defense-in-depth.
8
+ *
9
+ * On by default; disable with `--no-trace` or DRAGON_NO_TRACE=1. Local-only — nothing
10
+ * is uploaded; the file just sits on the operator's machine for the DragonSpark repo
11
+ * to ingest (src/dragonspark/traces.py).
12
+ *
13
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
14
+ */
15
+
16
+ import { appendFileSync, mkdirSync, chmodSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import type { BrainMessage } from '../brain/types.js'
20
+
21
+ export const TRACE_DIR = join(homedir(), '.dragon', 'traces')
22
+
23
+ /**
24
+ * Strip secrets before anything touches disk. Covers known key prefixes
25
+ * (sk-, sk-ant-, ghp_, xoxb-, AKIA, dgn_, JWT), cookie + authorization values,
26
+ * and any long opaque blob. Defense-in-depth for traces that may carry tool output.
27
+ */
28
+ export function redact(s: string): string {
29
+ if (!s) return s
30
+ return s
31
+ .replace(/\b(?:sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]+|gho_[A-Za-z0-9]+|xox[baprs]-[A-Za-z0-9-]+|AKIA[A-Z0-9]{16}|dgn_[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, '[redacted]')
32
+ .replace(/(gp_session=)[^;\s"']+/gi, '$1[redacted]')
33
+ .replace(/(authorization["']?\s*[:=]\s*["']?(?:bearer\s+)?)[^\s"',}]+/gi, '$1[redacted]')
34
+ .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[redacted]')
35
+ .replace(/[A-Za-z0-9+/_-]{40,}={0,2}/g, '[redacted]')
36
+ }
37
+
38
+ export function traceEnabled(disabledFlag?: boolean): boolean {
39
+ return !disabledFlag && !process.env.DRAGON_NO_TRACE
40
+ }
41
+
42
+ /**
43
+ * Record one settled turn. `added` is the slice of messages appended during the turn
44
+ * (user → [assistant/tool]* → final assistant). Never throws — a trace failure must
45
+ * not break the session.
46
+ */
47
+ export function recordTurn(added: BrainMessage[], meta: { cwd: string; brain: string; context?: string | null }): void {
48
+ try {
49
+ const user = added.find((m) => m.role === 'user')?.content ?? ''
50
+ const tool_calls = added
51
+ .filter((m) => m.role === 'assistant')
52
+ .flatMap((m) => (m.toolCalls ?? []).map((t) => ({ name: t.name, arguments: redact(JSON.stringify(t.arguments ?? {})).slice(0, 2000) })))
53
+ const tool_results = added
54
+ .filter((m) => m.role === 'tool')
55
+ .map((m) => ({ name: m.toolName ?? '', result: redact(m.content).slice(0, 4000) }))
56
+ const finals = added.filter((m) => m.role === 'assistant' && m.content)
57
+ const final_answer = finals.length ? finals[finals.length - 1].content : ''
58
+
59
+ const rec = {
60
+ ts: new Date().toISOString(),
61
+ cwd: meta.cwd,
62
+ brain: meta.brain,
63
+ prompt: redact(user).slice(0, 8000),
64
+ retrieved_wyrm_context: meta.context ? redact(meta.context).slice(0, 4000) : undefined,
65
+ tool_calls,
66
+ tool_results,
67
+ final_answer: redact(final_answer).slice(0, 8000),
68
+ }
69
+ mkdirSync(TRACE_DIR, { recursive: true, mode: 0o700 })
70
+ const file = join(TRACE_DIR, `${rec.ts.slice(0, 10)}.jsonl`)
71
+ appendFileSync(file, JSON.stringify(rec) + '\n', { mode: 0o600 })
72
+ chmodSync(file, 0o600) // appendFileSync mode only applies on create — enforce on existing too
73
+ } catch {
74
+ /* never break a session over a trace write */
75
+ }
76
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Client for the Dragon assistant — account.ghosts.lk `POST /api/v1/agent`.
3
+ *
4
+ * The endpoint runs a server-side tool-calling loop, then streams the answer as
5
+ * SSE. Two frame kinds arrive on the `data:` channel:
6
+ * - `{"tools":[{"name","ok"}]}` — once, up front, if tools ran (→ onTools)
7
+ * - `{"response":"…"}` — token deltas of the natural-language answer
8
+ * - `[DONE]` — terminator
9
+ *
10
+ * Tools execute server-side and role-gated; the client only DISPLAYS which ran.
11
+ * Request body is `{ messages:[{role,content}], surface }` — the server keeps
12
+ * only user/assistant turns and prepends its own system prompt + memory.
13
+ *
14
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
15
+ */
16
+
17
+ import { resolveAuth } from './auth.js'
18
+
19
+ export interface AgentMsg { role: 'user' | 'assistant'; content: string }
20
+ export interface ToolChip { name: string; ok: boolean }
21
+
22
+ /** Surfaces the server understands — they scope the tool set + system context. */
23
+ export const SURFACES = ['dashboard', 'admin', 'chat', 'activity', 'marketing'] as const
24
+ export type Surface = (typeof SURFACES)[number]
25
+
26
+ export type AgentErrorKind = 'unauthenticated' | 'quota' | 'unavailable' | 'http' | 'network'
27
+
28
+ export class AgentError extends Error {
29
+ constructor(public kind: AgentErrorKind, message: string, public detail?: unknown) {
30
+ super(message)
31
+ this.name = 'AgentError'
32
+ }
33
+ }
34
+
35
+ export interface StreamAgentOpts {
36
+ messages: AgentMsg[]
37
+ surface: string
38
+ onDelta: (s: string) => void
39
+ onTools?: (tools: ToolChip[]) => void
40
+ signal?: AbortSignal
41
+ }
42
+
43
+ /**
44
+ * Stream one assistant turn. Emits tool chips (if any) then token deltas, and
45
+ * returns the assembled answer when `[DONE]` arrives. Throws AgentError on
46
+ * 401 (unauthenticated), 429 (quota), 502 (model down), or transport failure.
47
+ */
48
+ export async function streamAgent(opts: StreamAgentOpts): Promise<string> {
49
+ const { apiBase, headers, mode } = resolveAuth()
50
+
51
+ let res: Response
52
+ try {
53
+ res = await fetch(`${apiBase}/api/v1/agent`, {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', ...headers },
56
+ body: JSON.stringify({ messages: opts.messages, surface: opts.surface }),
57
+ signal: opts.signal,
58
+ })
59
+ } catch (e) {
60
+ if ((e as { name?: string })?.name === 'AbortError') throw e
61
+ throw new AgentError('network', `cannot reach ${apiBase}: ${String(e instanceof Error ? e.message : e)}`)
62
+ }
63
+
64
+ if (res.status === 401) {
65
+ const b = (await res.json().catch(() => ({}))) as { login_url?: string }
66
+ const hint = mode === 'none'
67
+ ? 'no credentials configured — run `dragon login`'
68
+ : 'your session is invalid or expired — run `dragon login` again'
69
+ throw new AgentError('unauthenticated', hint, b)
70
+ }
71
+ if (res.status === 429) {
72
+ const b = (await res.json().catch(() => ({}))) as { used?: number; cap?: number; reset_at?: number }
73
+ const reset = b.reset_at ? new Date(b.reset_at).toLocaleString() : '—'
74
+ throw new AgentError('quota', `daily quota reached (${b.used ?? '?'} / ${b.cap ?? '?'} tokens). Resets ${reset}.`, b)
75
+ }
76
+ if (res.status === 502) {
77
+ throw new AgentError('unavailable', 'the model is momentarily unavailable — try again in a moment.')
78
+ }
79
+ if (!res.ok || !res.body) {
80
+ const t = await res.text().catch(() => res.statusText)
81
+ throw new AgentError('http', `HTTP ${res.status}: ${t}`)
82
+ }
83
+
84
+ const reader = res.body.getReader()
85
+ const decoder = new TextDecoder()
86
+ let buffer = ''
87
+ let assembled = ''
88
+
89
+ for (;;) {
90
+ const { value, done } = await reader.read()
91
+ if (done) break
92
+ buffer += decoder.decode(value, { stream: true })
93
+ const lines = buffer.split('\n')
94
+ buffer = lines.pop() ?? '' // hold the partial trailing line
95
+
96
+ for (const line of lines) {
97
+ if (!line.startsWith('data: ')) continue
98
+ const data = line.slice(6)
99
+ if (data === '[DONE]') return assembled
100
+ try {
101
+ const obj = JSON.parse(data) as { response?: string; tools?: { name: string; ok?: boolean }[] }
102
+ if (Array.isArray(obj.tools)) {
103
+ opts.onTools?.(obj.tools.map((t) => ({ name: String(t.name), ok: t.ok !== false })))
104
+ } else if (typeof obj.response === 'string' && obj.response) {
105
+ assembled += obj.response
106
+ opts.onDelta(obj.response)
107
+ }
108
+ } catch {
109
+ /* skip a malformed frame */
110
+ }
111
+ }
112
+ }
113
+ return assembled
114
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Auth for the Dragon assistant (account.ghosts.lk /api/v1/agent).
3
+ *
4
+ * The assistant is account-scoped: it answers AS the signed-in user and gates
5
+ * its tools by role. The browser authenticates with the `gp_session` HttpOnly
6
+ * cookie minted by Google/GitHub OAuth. A CLI has no browser, so we support two
7
+ * credential carriers, in priority order:
8
+ *
9
+ * 1. Bearer token — `DRAGON_TOKEN` env or `auth.token` in config. The primary
10
+ * path: a 90-day personal access token (`dgn_…`) minted by `dragon login`'s
11
+ * browser device-code flow. Backend is live on account.ghosts.lk.
12
+ * 2. Session cookie — `DRAGON_SESSION` env or `auth.session` in config. The
13
+ * `gp_session` value copied from a signed-in browser. The `--paste` fallback
14
+ * for headless boxes where the device flow can't open a browser.
15
+ *
16
+ * Resolution is env-first so a shell can override the stored credential without
17
+ * touching the config file.
18
+ *
19
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
20
+ */
21
+
22
+ import { loadConfig, saveConfig, type DragonConfig } from './config.js'
23
+
24
+ export const DEFAULT_API = 'https://account.ghosts.lk'
25
+
26
+ // Ports browsers refuse to open (ERR_UNSAFE_PORT / "this address is restricted").
27
+ // We guard against ever handing one of these to a browser or using it as an origin.
28
+ const RESTRICTED_PORTS = new Set([
29
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
30
+ 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161,
31
+ 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563,
32
+ 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061,
33
+ 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080,
34
+ ])
35
+
36
+ /** True if `raw` is a URL a browser will actually open: http(s), a sane port, and
37
+ * https for any non-loopback host (no plaintext auth over the network). */
38
+ export function isBrowsableHttpUrl(raw: string): boolean {
39
+ let u: URL
40
+ try { u = new URL(raw) } catch { return false }
41
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
42
+ const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1'
43
+ if (!isLocal && u.protocol !== 'https:') return false
44
+ const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
45
+ return !RESTRICTED_PORTS.has(port)
46
+ }
47
+
48
+ /** A trustworthy assistant origin, self-healing: a malformed/restricted-port value
49
+ * (e.g. a stale config) silently falls back to the default instead of breaking login. */
50
+ export function sanitizeApiBase(raw?: string): string {
51
+ const trimmed = (raw ?? '').replace(/\/+$/, '')
52
+ return trimmed && isBrowsableHttpUrl(trimmed) ? trimmed : DEFAULT_API
53
+ }
54
+
55
+ export type AuthMode = 'token' | 'session' | 'none'
56
+
57
+ export interface ResolvedAuth {
58
+ apiBase: string
59
+ headers: Record<string, string>
60
+ mode: AuthMode
61
+ email?: string
62
+ }
63
+
64
+ /** Credential values become HTTP header content — accept only printable, space-free
65
+ * ASCII so a CR/LF or control char can't smuggle extra headers. */
66
+ export function validCred(v?: string): string | undefined {
67
+ return v && /^[\x21-\x7e]+$/.test(v) ? v : undefined
68
+ }
69
+
70
+ /** The credential-bearing env vars auth reads — extracted so the precedence
71
+ * logic is unit-testable without a real shell, config file, or second machine. */
72
+ export interface AuthEnv {
73
+ DRAGON_API?: string
74
+ DRAGON_TOKEN?: string
75
+ DRAGON_SESSION?: string
76
+ }
77
+
78
+ /**
79
+ * Pure auth resolution: env-first, then config, behind the cred-injection
80
+ * (`validCred`) and restricted-origin (`sanitizeApiBase`) guards. Token beats
81
+ * session beats none. `resolveAuth()` is just this fed the real env + config —
82
+ * keeping the decision logic deterministic and machine-independent for tests.
83
+ */
84
+ export function resolveAuthFrom(env: AuthEnv, cfg: DragonConfig): ResolvedAuth {
85
+ const apiBase = sanitizeApiBase(env.DRAGON_API || cfg.auth?.apiBase)
86
+ const token = validCred(env.DRAGON_TOKEN || cfg.auth?.token)
87
+ const session = validCred(env.DRAGON_SESSION || cfg.auth?.session)
88
+ const email = cfg.auth?.email
89
+ if (token) return { apiBase, headers: { authorization: `Bearer ${token}` }, mode: 'token', email }
90
+ if (session) return { apiBase, headers: { cookie: `gp_session=${session}` }, mode: 'session', email }
91
+ return { apiBase, headers: {}, mode: 'none', email }
92
+ }
93
+
94
+ /** Resolve the assistant origin + credential headers from env then config. */
95
+ export function resolveAuth(): ResolvedAuth {
96
+ return resolveAuthFrom(process.env, loadConfig())
97
+ }
98
+
99
+ /** Merge a patch into config.auth and persist. */
100
+ export function saveAuth(patch: Partial<NonNullable<DragonConfig['auth']>>): void {
101
+ const cfg = loadConfig()
102
+ cfg.auth = { ...(cfg.auth ?? {}), ...patch }
103
+ saveConfig(cfg)
104
+ }
105
+
106
+ /** Forget all stored credentials (keeps a custom apiBase if set). */
107
+ export function clearAuth(): void {
108
+ const cfg = loadConfig()
109
+ const apiBase = cfg.auth?.apiBase
110
+ cfg.auth = apiBase ? { apiBase } : {}
111
+ saveConfig(cfg)
112
+ }
113
+
114
+ export interface WhoAmI { ok: boolean; status: number; email?: string; error?: string }
115
+
116
+ /**
117
+ * Validate the current (or a supplied) credential against the cheap authed
118
+ * endpoint `GET /api/v1/me/licenses` — no LLM cost. Returns the identity email
119
+ * on success so the caller can confirm + store who we signed in as.
120
+ */
121
+ export async function whoami(override?: { headers?: Record<string, string>; apiBase?: string }): Promise<WhoAmI> {
122
+ const base = override?.apiBase ?? resolveAuth().apiBase
123
+ const headers = override?.headers ?? resolveAuth().headers
124
+ try {
125
+ const res = await fetch(`${base}/api/v1/me/licenses`, { headers })
126
+ if (res.status === 401) return { ok: false, status: 401, error: 'not signed in' }
127
+ if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` }
128
+ const body = (await res.json().catch(() => ({}))) as { account?: { email?: string } }
129
+ return { ok: true, status: 200, email: body.account?.email }
130
+ } catch (e) {
131
+ return { ok: false, status: 0, error: String(e instanceof Error ? e.message : e) }
132
+ }
133
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Claude brain — Anthropic Messages API via the official SDK. The default,
3
+ * "amazing at programming" path.
4
+ *
5
+ * Maps our normalized BrainMessage[] onto Anthropic's content-block format:
6
+ * - assistant tool calls → `tool_use` blocks
7
+ * - tool results → `tool_result` blocks (folded into a user message;
8
+ * consecutive tool messages are batched into one user turn, as the API wants)
9
+ * Streams `text` deltas and collects `tool_use` blocks from the final message.
10
+ *
11
+ * Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
12
+ */
13
+
14
+ import Anthropic from '@anthropic-ai/sdk'
15
+ import type { Brain, BrainMessage, BrainTurn, ToolSpec, TurnOpts } from './types.js'
16
+
17
+ export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
18
+
19
+ type ABlock =
20
+ | { type: 'text'; text: string }
21
+ | { type: 'tool_use'; id: string; name: string; input: unknown }
22
+ | { type: 'tool_result'; tool_use_id: string; content: string }
23
+
24
+ interface AMessage { role: 'user' | 'assistant'; content: ABlock[] }
25
+
26
+ /** Fold our flat BrainMessage[] into Anthropic's role-batched content blocks. */
27
+ function toAnthropic(messages: BrainMessage[]): AMessage[] {
28
+ const out: AMessage[] = []
29
+ for (const m of messages) {
30
+ if (m.role === 'tool') {
31
+ const block: ABlock = { type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: m.content }
32
+ const last = out[out.length - 1]
33
+ if (last && last.role === 'user' && last.content.every((b) => b.type === 'tool_result')) last.content.push(block)
34
+ else out.push({ role: 'user', content: [block] })
35
+ continue
36
+ }
37
+ if (m.role === 'assistant') {
38
+ const blocks: ABlock[] = []
39
+ if (m.content) blocks.push({ type: 'text', text: m.content })
40
+ for (const tc of m.toolCalls ?? []) blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments })
41
+ out.push({ role: 'assistant', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
42
+ continue
43
+ }
44
+ // user
45
+ out.push({ role: 'user', content: [{ type: 'text', text: m.content }] })
46
+ }
47
+ return out
48
+ }
49
+
50
+ function toAnthropicTools(tools: ToolSpec[]) {
51
+ return tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters as Anthropic.Tool.InputSchema }))
52
+ }
53
+
54
+ export function makeClaudeBrain(opts: { apiKey: string; model?: string }): Brain {
55
+ const client = new Anthropic({ apiKey: opts.apiKey })
56
+ const model = opts.model || DEFAULT_CLAUDE_MODEL
57
+ return {
58
+ id: 'claude',
59
+ model,
60
+ async turn(t: TurnOpts): Promise<BrainTurn> {
61
+ const stream = client.messages.stream(
62
+ {
63
+ model,
64
+ max_tokens: t.maxTokens ?? 8192,
65
+ system: t.system,
66
+ messages: toAnthropic(t.messages) as Anthropic.MessageParam[],
67
+ tools: t.tools.length ? toAnthropicTools(t.tools) : undefined,
68
+ },
69
+ { signal: t.signal },
70
+ )
71
+ if (t.onDelta) stream.on('text', (delta) => t.onDelta!(delta))
72
+ const final = await stream.finalMessage()
73
+
74
+ let text = ''
75
+ const toolCalls: BrainTurn['toolCalls'] = []
76
+ for (const block of final.content) {
77
+ if (block.type === 'text') text += block.text
78
+ else if (block.type === 'tool_use') toolCalls.push({ id: block.id, name: block.name, arguments: (block.input ?? {}) as Record<string, unknown> })
79
+ }
80
+ return { text, toolCalls }
81
+ },
82
+ }
83
+ }