@asobacloud/nehanda 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +444 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,1163 @@
1
+ import readline from 'node:readline'
2
+ import { randomUUID } from 'node:crypto'
3
+ import Anthropic from '@anthropic-ai/sdk'
4
+ import { resolveAnthropicCredentials } from './auth.mjs'
5
+ import { resolveWireModel, anthropicBaseUrl, omitToolChoice } from './modelConfig.mjs'
6
+ import { runHooks } from './hookplane.mjs'
7
+ import { appendEntry, transcriptToAnthropicMessages, transcriptToOpenAIMessages, makeUserPayload, makeAssistantPayload, makeToolResultPayload, applyEpistemicFilter } from './transcript.mjs'
8
+ import { evaluatePermission } from './permissions.mjs'
9
+ import { executeBuiltinTool, anthropicToolDefinitions, openAICompatToolDefinitions, LOCAL_CORE_TOOLS, EXPLORE_ONLY_TOOLS, dynamicTools, getMcpToolDefinitions } from './tools.mjs'
10
+ import { streamOpenAIChatCompletion } from './openaiCompat.mjs'
11
+ import { withTransaction } from './store.mjs'
12
+ import { getPhase, setPhase, canTransition } from './workflow.mjs'
13
+ import { buildJevSystem2Messages } from './compact.mjs'
14
+
15
+ // ─── Dynamic tool prompt injection ───────────────────────────
16
+
17
+ function getMandatoryToolInstructions(phase) {
18
+ const instructions = []
19
+ for (const [, config] of dynamicTools) {
20
+ if (config.phases?.mandatory_in?.includes(phase) && config.prompt?.mandatory_instruction) {
21
+ instructions.push(`- MANDATORY: ${config.prompt.mandatory_instruction}`)
22
+ }
23
+ }
24
+ if (instructions.length === 0) return ''
25
+ return `- After tests pass, you MUST run these mandatory safety checks. Do NOT skip them. If any fail, fix the issues and re-run.\n${instructions.join('\n')}\n`
26
+ }
27
+
28
+ function getAvailableToolHints() {
29
+ const hints = []
30
+ for (const [, config] of dynamicTools) {
31
+ if (config.prompt?.available_hint) {
32
+ hints.push(`- ${config.name}: ${config.prompt.available_hint}`)
33
+ }
34
+ }
35
+ if (hints.length === 0) return ''
36
+ return `\n# Safety & Analysis Tools\nYou have access to these tools for code review and safety analysis:\n${hints.join('\n')}\n\n`
37
+ }
38
+
39
+ // ─── Phase system prompts ─────────────────────────────────────
40
+
41
+ const PHASE_SYSTEM = {
42
+ explore: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
43
+
44
+ You are in EXPLORE phase. Your ONLY job is to find and read files relevant to the task.
45
+ - Use Glob, Grep, and Read to explore the codebase
46
+ - Read every file that could be relevant to the task
47
+ - Do NOT write plans, suggest steps, ask questions, or produce any output other than tool calls
48
+ - When you have read everything relevant, output EXACTLY this single line and nothing else:
49
+ Exploration complete.
50
+
51
+ # Environment
52
+ - Working directory: ${cwd}
53
+ - Platform: ${os} | Shell: ${shell} | Date: ${date}
54
+ - Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
55
+
56
+ planning: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
57
+
58
+ You are in PLANNING phase. File summaries from exploration are provided below.
59
+ Write your plan based ONLY on those summaries. Do NOT use any tools.
60
+
61
+ Write your plan using EXACTLY this template and nothing else:
62
+
63
+ ## Objective
64
+ <one sentence describing what you will produce>
65
+
66
+ ## Plan
67
+ 1. <exact command, script invocation, or file write — not vague intent>
68
+ 2. <exact command, script invocation, or file write>
69
+ ...
70
+
71
+ Rules: every step must name exact paths and commands extracted from the summaries. Never use "review", "reference", or "based on documentation" as a step.
72
+
73
+ ## Proof of Success
74
+ <specific file or output that will exist when done>
75
+
76
+ # Environment
77
+ - Working directory: ${cwd}
78
+ - Platform: ${os} | Shell: ${shell} | Date: ${date}
79
+ - Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
80
+
81
+ implement: (cwd, provider, model, date, os, shell, onaInstructions) => `You are ona, an autonomous CLI agent.
82
+
83
+ You are in IMPLEMENT phase. Execute the approved plan exactly.
84
+ - Use Write or Edit to create/modify files
85
+ - Use Bash to run commands if needed
86
+ - When all implementation work is done, write a one-line summary as your final response: "Done: <what you did>"
87
+ - Do not ask questions. Do not explain. Just implement.
88
+
89
+ # Environment
90
+ - Working directory: ${cwd}
91
+ - Platform: ${os} | Shell: ${shell} | Date: ${date}
92
+ - Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`,
93
+
94
+ test: (cwd, provider, model, date, os, shell, onaInstructions) => {
95
+ const mandatoryChecks = getMandatoryToolInstructions('test')
96
+ return `You are ona, an autonomous CLI agent.
97
+
98
+ You are in TEST phase.
99
+ - Run the project tests using Bash
100
+ - If tests fail, fix the code and re-run
101
+ ${mandatoryChecks}
102
+ - When all tests pass AND all mandatory checks pass, write a one-line summary as your final response: "Tests passed: <what was verified>"
103
+ - Do not ask questions. Do not explain. Just run the tests and checks.
104
+
105
+ # Environment
106
+ - Working directory: ${cwd}
107
+ - Platform: ${os} | Shell: ${shell} | Date: ${date}
108
+ - Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`
109
+ },
110
+
111
+ default: (cwd, provider, model, date, os, shell, onaInstructions) => {
112
+ const availableHints = getAvailableToolHints()
113
+ return `You are ona, an interactive CLI agent that helps users with software engineering tasks.
114
+
115
+ - Proactively use tools. Do not ask for permission.
116
+ - Be concise. Lead with actions, not reasoning.
117
+ - Use Read to understand files before editing.
118
+ - For discovery — listing files, finding paths, searching file contents — use Glob and Grep, not Bash. Reserve Bash for actual execution: running commands, tests, installs, git operations.
119
+ ${availableHints}
120
+ # Environment
121
+ - Working directory: ${cwd}
122
+ - Platform: ${os} | Shell: ${shell} | Date: ${date}
123
+ - Provider: ${provider} | Model: ${model}${onaInstructions ? `\n\n# Project Instructions\n${onaInstructions}` : ''}`
124
+ },
125
+ }
126
+
127
+ // ─── Phase user message templates ────────────────────────────
128
+
129
+ function buildExploreUserMessage(userRequest) {
130
+ return `Task: ${userRequest}
131
+
132
+ Explore systematically to find files relevant to this specific task:
133
+
134
+ 1. Glob pattern "**/*.md" to find documentation files — read any whose names relate to the task
135
+ 2. Glob pattern "**/*.py" to find Python scripts — read any that look relevant to the task
136
+ 3. Glob pattern "**/*.sh" to find shell scripts — read any that look relevant to the task
137
+ 4. Use Grep to search for keywords from the task if globs don't surface the right files
138
+ 5. Read only files that appeared in glob/grep results and are plausibly relevant to this specific task
139
+
140
+ IMPORTANT: Your task is "${userRequest}". Only read files related to this task.
141
+
142
+ Use the working directory as the base for all globs.
143
+ Do NOT guess paths. Only read files that appear in glob results.
144
+ When done reading, output EXACTLY:
145
+ Exploration complete.`
146
+ }
147
+
148
+ function buildPlanningUserMessage(summaries, userRequest) {
149
+ let fileSection
150
+ if (!summaries || summaries.length === 0) {
151
+ fileSection = '(no files were successfully read during exploration)'
152
+ } else {
153
+ fileSection = summaries.map(({ path, header, summary }) => {
154
+ const parts = [`### ${path}`]
155
+ if (summary) parts.push(`**Relevance:** ${summary}`)
156
+ if (header) parts.push('**File header (first 50 lines):**\n```\n' + header + '\n```')
157
+ return parts.join('\n')
158
+ }).join('\n\n')
159
+ }
160
+
161
+ return `Exploration and summarization are complete. Here is what each file contains that is relevant to the task:
162
+
163
+ ${fileSection}
164
+
165
+ Original task: ${userRequest}
166
+
167
+ Write your plan using ONLY the content above. Do NOT reference files not listed here. Do NOT use any tools.
168
+
169
+ ## Objective
170
+ <one sentence: what you will produce>
171
+
172
+ ## Plan
173
+ 1. <concrete step — must specify exact file path, command, or code change>
174
+ 2. <concrete step>
175
+
176
+ RULES FOR PLAN STEPS:
177
+ - Every step must be executable: name the exact script, command, or file to write
178
+ - If running a script, include the exact invocation with arguments drawn from the summaries above
179
+ - If writing a file, name the target path and describe its contents
180
+ - NEVER use vague verbs: "review", "understand", "reference", "use as reference", "based on the documentation"
181
+
182
+ ## Proof of Success
183
+ <specific file or output that will exist when done>
184
+
185
+ Do NOT use any tools. Write the plan only.`
186
+ }
187
+
188
+ function buildImplementUserMessage(planContent) {
189
+ return `Approved plan:
190
+
191
+ ${planContent}
192
+
193
+ Implement the plan now using Write, Edit, and Bash as needed.`
194
+ }
195
+
196
+ function buildTestUserMessage() {
197
+ return `Implementation accepted. You MUST run the tests now using Bash before doing anything else. Execute the appropriate test command for this project (e.g. python hello_world.py, pytest, npm test, etc.).`
198
+ }
199
+
200
+ function buildImplementSummaryPrompt(toolResults) {
201
+ return `You just completed the implementation. Here is what actually happened:
202
+
203
+ ${toolResults}
204
+
205
+ Fill in this summary using ONLY the information above — do not invent anything:
206
+
207
+ ## Files Changed
208
+ - <file path>: <what was done to it>
209
+
210
+ ## Result
211
+ <one sentence: what was produced>`
212
+ }
213
+
214
+ function buildTestSummaryPrompt(toolResults) {
215
+ return `You just ran the tests. Here is what actually happened:
216
+
217
+ ${toolResults}
218
+
219
+ Fill in this summary using ONLY the information above — do not invent anything:
220
+
221
+ ## Tests Run
222
+ <command that was executed>
223
+
224
+ ## Result
225
+ <one sentence: passed or failed, what was verified>`
226
+ }
227
+
228
+ export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions, injectedTools = null) {
229
+ const os = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux'
230
+ const shell = process.env.SHELL?.split('/').pop() || 'sh'
231
+ const date = new Date().toISOString().split('T')[0]
232
+ const ticks = String.fromCharCode(96, 96, 96)
233
+ const LANGUAGE_LOCK = 'Always respond in English.'
234
+
235
+ const builder = PHASE_SYSTEM[phase] || PHASE_SYSTEM.default
236
+ let prompt = builder(cwd, provider, model, date, os, shell, onaInstructions)
237
+
238
+ // Pin output language for multilingual models (e.g. Qwen) that otherwise code-switch.
239
+ if (!prompt.includes(LANGUAGE_LOCK)) {
240
+ const nl = prompt.indexOf('\n')
241
+ prompt = nl === -1
242
+ ? `${prompt}\n\n${LANGUAGE_LOCK}`
243
+ : `${prompt.slice(0, nl)}\n\n${LANGUAGE_LOCK}${prompt.slice(nl)}`
244
+ }
245
+
246
+ if (injectedTools && injectedTools.length > 0) {
247
+ prompt += `\n\n# MANDATORY: Tool Calling Format
248
+ Your environment requires JSON tool calls in markdown blocks:
249
+ ${ticks}json
250
+ {"tool": "ToolName", "input": {"param": "value"}}
251
+ ${ticks}
252
+
253
+ Available Tools:
254
+ ${injectedTools.map(t => `- ${t.function.name}: ${t.function.description} (Schema: ${JSON.stringify(t.function.parameters.properties)})`).join('\n')}`
255
+ }
256
+
257
+ return prompt
258
+ }
259
+
260
+ function buildXmlToolInstructions(tools, phase) {
261
+ if (phase === 'planning' || !tools || tools.length === 0) return ''
262
+
263
+ const KEY_TOOLS = ['Bash', 'Read', 'Glob']
264
+ const examples = tools
265
+ .filter(t => KEY_TOOLS.includes(t.function?.name))
266
+ .map(t => {
267
+ const fn = t.function
268
+ const req = fn.parameters?.required || []
269
+ const args = Object.entries(fn.parameters?.properties || {})
270
+ .filter(([k]) => req.includes(k))
271
+ .map(([k, v]) => ` "${k}": "${v.type === 'integer' ? '0' : '...'}"`)
272
+ .join(',\n')
273
+ return `[TOOL_CALL]\n{"name": "${fn.name}", "arguments": {\n${args}\n}}\n[/TOOL_CALL]`
274
+ })
275
+ .join('\n\n')
276
+
277
+ const allNames = tools.filter(t => t.function?.name).map(t => t.function.name).join(', ')
278
+
279
+ return `\n\n# Tool Calling Format\n\nTo call a tool, emit a [TOOL_CALL] block containing a valid JSON object. Do NOT use XML tags inside the block.\n\nExamples:\n\n${examples}\n\nAll available tools: ${allNames}\n\nRules:\n- Emit strictly [TOOL_CALL] {"name": "...", "arguments": {...}} [/TOOL_CALL]. Do NOT use <arguments> or XML tags.\n- Escape all double quotes inside command strings (e.g., "echo \\"---LOG---\\"").\n- One tool call per response. After tool use is complete, write your final answer as plain text.`
280
+ }
281
+
282
+
283
+ function filterToolsByPhase(tools, phase) {
284
+ if (phase === 'explore') {
285
+ return tools.filter(t => {
286
+ const name = t.name || t.function?.name
287
+ return EXPLORE_ONLY_TOOLS.has(name)
288
+ })
289
+ }
290
+ if (phase === 'planning') {
291
+ return []
292
+ }
293
+ return tools
294
+ }
295
+
296
+ const sessionTokens = new Map()
297
+
298
+ export function getSessionTokens(sessionId) {
299
+ return sessionTokens.get(sessionId) || { input: 0, output: 0, calls: 0 }
300
+ }
301
+
302
+ function trackTokens(sessionId, inputTokens, outputTokens) {
303
+ const prev = sessionTokens.get(sessionId) || { input: 0, output: 0, calls: 0 }
304
+ sessionTokens.set(sessionId, {
305
+ input: prev.input + (inputTokens || 0),
306
+ output: prev.output + (outputTokens || 0),
307
+ calls: prev.calls + 1,
308
+ })
309
+ }
310
+
311
+ function makeAnthropicClient(cred) {
312
+ const baseURL = anthropicBaseUrl()
313
+ if (cred.mode === 'bearer') return new Anthropic({ authToken: cred.secret, apiKey: null, baseURL, defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' } })
314
+ if (cred.mode === 'api_key') return new Anthropic({ apiKey: cred.secret, baseURL })
315
+ throw new Error('No credentials for the active model provider')
316
+ }
317
+
318
+ function activeProvider(settings) {
319
+ return settings?.model_config?.provider || 'claude_code_subscription'
320
+ }
321
+
322
+ async function toolsForProvider(provider, settings, nativeToolSupport = true) {
323
+ const coreOnly = provider === 'lm_studio_local' || (provider === 'ollama' && !nativeToolSupport)
324
+ const mcpTools = await getMcpToolDefinitions(settings)
325
+
326
+ const anthropicBase = coreOnly
327
+ ? anthropicToolDefinitions().filter(t => LOCAL_CORE_TOOLS.has(t.name))
328
+ : anthropicToolDefinitions()
329
+
330
+ const openaiBase = coreOnly
331
+ ? openAICompatToolDefinitions().filter(t => LOCAL_CORE_TOOLS.has(t.function.name))
332
+ : openAICompatToolDefinitions()
333
+
334
+ // Append MCP tools as Anthropic-format and OpenAI-compat-format entries
335
+ const anthropicMcp = mcpTools // already Anthropic format from getMcpToolDefinitions
336
+ const openaiMcp = mcpTools.map(t => ({
337
+ type: 'function',
338
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
339
+ }))
340
+
341
+ return {
342
+ anthropic: [...anthropicBase, ...anthropicMcp],
343
+ openai: [...openaiBase, ...openaiMcp],
344
+ }
345
+ }
346
+
347
+ const ollamaCapabilityCache = new Map()
348
+
349
+ async function detectOllamaToolSupport(baseUrl, modelName) {
350
+ const key = `${baseUrl}::${modelName}`
351
+ if (ollamaCapabilityCache.has(key)) return ollamaCapabilityCache.get(key)
352
+
353
+ let result = true
354
+ try {
355
+ const apiBase = baseUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
356
+ const resp = await fetch(`${apiBase}/api/show`, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ name: modelName }),
360
+ signal: AbortSignal.timeout(3000)
361
+ })
362
+ if (!resp.ok) { result = true }
363
+ else {
364
+ const data = await resp.json()
365
+ result = !!(
366
+ data.template?.includes('.Tools') ||
367
+ data.modelfile?.includes('.Tools') ||
368
+ data.capabilities?.includes('tools')
369
+ )
370
+ }
371
+ } catch {
372
+ result = true
373
+ }
374
+
375
+ ollamaCapabilityCache.set(key, result)
376
+ return result
377
+ }
378
+
379
+ function mapAnthropicContent(content) {
380
+ return content.map(b => {
381
+ if (b.type === 'text') return { type: 'text', text: b.text }
382
+ if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} }
383
+ return null
384
+ }).filter(Boolean)
385
+ }
386
+
387
+ function classifyError(e) {
388
+ const msg = String(e?.message || e).toLowerCase()
389
+ if (msg.includes('401')) return 'authentication_failed'
390
+ if (msg.includes('429')) return 'rate_limit'
391
+ if (msg.includes('404')) return 'model_not_found'
392
+ if (msg.includes('500') || msg.includes('503')) return 'server_error'
393
+ return 'unknown'
394
+ }
395
+
396
+ async function askHuman(io, q) {
397
+ if (!process.stdin.isTTY) return true
398
+ if (io?.ask) return (await io.ask(q)).trim().toLowerCase().startsWith('y')
399
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
400
+ return new Promise(resolve => {
401
+ rl.question(q, ans => { rl.close(); resolve(/^y(es)?$/i.test(String(ans || '').trim())) })
402
+ })
403
+ }
404
+
405
+ function parseManualToolCalls(text) {
406
+ const calls = []
407
+ const ticks = String.fromCharCode(96, 96, 96)
408
+ const pattern = ticks + '(?:json)?\\s*(\\{[\\s\\S]*?\\})\\s*' + ticks
409
+ const blockRegex = new RegExp(pattern, 'g')
410
+ let match
411
+ while ((match = blockRegex.exec(text)) !== null) {
412
+ try {
413
+ const parsed = JSON.parse(match[1].trim())
414
+ if (parsed.tool) {
415
+ calls.push({ type: 'tool_use', id: `manual-${Math.random().toString(36).slice(2, 9)}`, name: parsed.tool, input: parsed.input || {} })
416
+ }
417
+ } catch { }
418
+ }
419
+ return calls
420
+ }
421
+
422
+ function parseXmlToolCalls(text, knownToolNames = null) {
423
+ if (!text || !text.includes('[TOOL_CALL]')) return []
424
+ const calls = []
425
+
426
+ let cursor = 0
427
+ while (true) {
428
+ const startIdx = text.indexOf('[TOOL_CALL]', cursor)
429
+ if (startIdx === -1) break
430
+
431
+ const contentStart = startIdx + '[TOOL_CALL]'.length
432
+ let endIdx = text.indexOf('[/TOOL_CALL]', contentStart)
433
+ if (endIdx === -1) {
434
+ endIdx = text.length
435
+ }
436
+
437
+ cursor = endIdx + (endIdx === text.length ? 0 : '[/TOOL_CALL]'.length)
438
+
439
+ let raw = text.slice(contentStart, endIdx).trim()
440
+ if (!raw) continue
441
+
442
+ raw = raw.replace(/<\/?arguments>/g, '').replace(/<\/?tool_call>/g, '').trim()
443
+
444
+ const firstBrace = raw.indexOf('{')
445
+ const lastBrace = raw.lastIndexOf('}')
446
+
447
+ if (firstBrace === -1) continue
448
+
449
+ if (lastBrace > firstBrace) {
450
+ raw = raw.slice(firstBrace, lastBrace + 1)
451
+ } else {
452
+ raw = raw.slice(firstBrace)
453
+ }
454
+
455
+ // Repair the `{"ToolName", "arguments": {...}}` shorthand — model drops
456
+ // the "name": key and leaves the tool name as a bare leading string.
457
+ raw = raw.replace(/^\{\s*"([A-Za-z_][\w-]*)"\s*,/, '{"name":"$1",')
458
+
459
+ let parsed = null
460
+ try {
461
+ parsed = JSON.parse(raw)
462
+ } catch {
463
+ let openBraces = 0, openBrackets = 0
464
+ for (const ch of raw) {
465
+ if (ch === '{') openBraces++
466
+ else if (ch === '}') openBraces--
467
+ else if (ch === '[') openBrackets++
468
+ else if (ch === ']') openBrackets--
469
+ }
470
+
471
+ let repaired = raw
472
+ while (openBrackets > 0) { repaired += ']'; openBrackets-- }
473
+ while (openBraces > 0) { repaired += '}'; openBraces-- }
474
+
475
+ try {
476
+ parsed = JSON.parse(repaired)
477
+ } catch {
478
+ continue
479
+ }
480
+ }
481
+
482
+ if (!parsed || typeof parsed !== 'object') continue
483
+
484
+ const name = parsed.name || parsed.tool
485
+ if (!name || typeof name !== 'string') continue
486
+ if (knownToolNames && knownToolNames.size > 0 && !knownToolNames.has(name)) continue
487
+
488
+ const args = parsed.arguments || parsed.input
489
+ const input = (args && typeof args === 'object' && !Array.isArray(args)) ? args : {}
490
+
491
+ calls.push({
492
+ type: 'tool_use',
493
+ id: `tc-${Math.random().toString(36).slice(2, 9)}`,
494
+ name,
495
+ input,
496
+ })
497
+ }
498
+
499
+ return calls
500
+ }
501
+
502
+ function sanitizeModelText(text) {
503
+ if (!text) return text
504
+ return text
505
+ .replace(/<[||]begin[▁_]of[▁_]sentence[||]>/g, '')
506
+ .replace(/<[||]end[▁_]of[▁_]sentence[||]>/g, '')
507
+ .replace(/<\|im_start\|>\w*/g, '')
508
+ .replace(/<\|im_end\|>/g, '')
509
+ .trim()
510
+ }
511
+
512
+ function sanitizeMessages(messages) {
513
+ return messages.map(m => {
514
+ if (typeof m.content === 'string') {
515
+ return { ...m, content: sanitizeModelText(m.content) }
516
+ }
517
+ if (Array.isArray(m.content)) {
518
+ return {
519
+ ...m,
520
+ content: m.content.map(block => {
521
+ if (typeof block === 'string') return sanitizeModelText(block)
522
+ if (block?.text) return { ...block, text: sanitizeModelText(block.text) }
523
+ if (block?.content && typeof block.content === 'string') return { ...block, content: sanitizeModelText(block.content) }
524
+ return block
525
+ }),
526
+ }
527
+ }
528
+ return m
529
+ })
530
+ }
531
+
532
+ const CONTENT_CAP_CHARS = 6000
533
+ const HEADER_LINES = 50
534
+
535
+ function extractFilesRead(db, sessionId) {
536
+ const rows = db.prepare(`
537
+ SELECT entry_type, payload_json FROM transcript_entries
538
+ WHERE session_id = ? ORDER BY sequence ASC
539
+ `).all(sessionId)
540
+
541
+ const toolCalls = new Map()
542
+ for (const row of rows) {
543
+ try {
544
+ const payload = JSON.parse(row.payload_json)
545
+ if (row.entry_type === 'assistant') {
546
+ for (const b of payload.content || []) {
547
+ if (b.type === 'tool_use' && b.name === 'Read') {
548
+ toolCalls.set(b.id, b.input?.path || b.input?.file_path || '(unknown)')
549
+ }
550
+ }
551
+ }
552
+ } catch { }
553
+ }
554
+
555
+ const seen = new Set()
556
+ const files = []
557
+
558
+ for (const row of rows) {
559
+ try {
560
+ const payload = JSON.parse(row.payload_json)
561
+ if (row.entry_type === 'tool_result') {
562
+ const filePath = toolCalls.get(payload.tool_use_id)
563
+ if (!filePath || payload.is_error || seen.has(filePath)) continue
564
+ seen.add(filePath)
565
+
566
+ const raw = typeof payload.content === 'string'
567
+ ? payload.content
568
+ : (payload.content?.[0]?.text ?? '')
569
+
570
+ const content = raw.length > CONTENT_CAP_CHARS
571
+ ? raw.slice(0, CONTENT_CAP_CHARS) + `\n... [truncated — ${raw.length} chars total]`
572
+ : raw
573
+
574
+ const header = raw.split('\n').slice(0, HEADER_LINES).join('\n')
575
+
576
+ files.push({ path: filePath, content, header })
577
+ }
578
+ } catch { }
579
+ }
580
+
581
+ return files
582
+ }
583
+
584
+ async function summarizeFilesAnthropicForTask(client, rt, filesRead, userRequest, io) {
585
+ if (!filesRead.length) return []
586
+ const summaries = []
587
+
588
+ for (const { path, content, header } of filesRead) {
589
+ if (io.spinner) io.spinner.start(`Summarising ${path.split('/').pop()}`)
590
+ const prompt = `File path: ${path}
591
+
592
+ File content:
593
+ \`\`\`
594
+ ${content}
595
+ \`\`\`
596
+
597
+ Task: ${userRequest}
598
+
599
+ In 4-6 sentences, what does this file tell you that is directly relevant to accomplishing the task?
600
+ Focus on: exact script names, function signatures, argument names, expected file paths, output locations, prerequisites, and any constraints.
601
+ If this file is not relevant to the task, say "Not relevant" and nothing else.`
602
+
603
+ try {
604
+ const response = await client.messages.create({
605
+ model: rt._model,
606
+ max_tokens: 512,
607
+ messages: [{ role: 'user', content: prompt }],
608
+ })
609
+ const summary = sanitizeModelText(response.content?.[0]?.text || '')
610
+ trackTokens(rt.sessionId, response.usage?.input_tokens, response.usage?.output_tokens)
611
+ if (summary && !/^not relevant/i.test(summary)) {
612
+ summaries.push({ path, header, summary })
613
+ }
614
+ } catch {
615
+ summaries.push({ path, header, summary: '(summarization failed — see header)' })
616
+ }
617
+ if (io.spinner) io.spinner.stop()
618
+ }
619
+
620
+ return summaries
621
+ }
622
+
623
+ async function summarizeFilesOpenAICompatForTask(baseUrl, apiKey, rt, filesRead, userRequest, io) {
624
+ if (!filesRead.length) return []
625
+ const summaries = []
626
+
627
+ for (const { path, content, header } of filesRead) {
628
+ if (io.spinner) io.spinner.start(`Summarising ${path.split('/').pop()}`)
629
+ const prompt = `File path: ${path}
630
+
631
+ File content:
632
+ \`\`\`
633
+ ${content}
634
+ \`\`\`
635
+
636
+ Task: ${userRequest}
637
+
638
+ In 4-6 sentences, what does this file tell you that is directly relevant to accomplishing the task?
639
+ Focus on: exact script names, function signatures, argument names, expected file paths, output locations, prerequisites, and any constraints.
640
+ If this file is not relevant to the task, say "Not relevant" and nothing else.`
641
+
642
+ try {
643
+ const out = await streamOpenAIChatCompletion({
644
+ baseUrl, apiKey,
645
+ model: rt._model,
646
+ messages: [{ role: 'user', content: prompt }],
647
+ tools: [],
648
+ io,
649
+ bufferOutput: true,
650
+ maxTokens: 512,
651
+ numCtx: rt.settings?.model_config?.num_ctx,
652
+ })
653
+ const summary = sanitizeModelText(out.bufferedText || '')
654
+ if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
655
+ if (summary && !/^not relevant/i.test(summary)) {
656
+ summaries.push({ path, header, summary })
657
+ }
658
+ } catch {
659
+ summaries.push({ path, header, summary: '(summarization failed — see header)' })
660
+ }
661
+ if (io.spinner) io.spinner.stop()
662
+ }
663
+
664
+ return summaries
665
+ }
666
+
667
+ async function exploreGateAnthropic(db, rt, io, client, userRequest) {
668
+ const filesRead = extractFilesRead(db, rt.sessionId)
669
+ io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
670
+
671
+ const summaries = await summarizeFilesAnthropicForTask(client, rt, filesRead, userRequest, io)
672
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
673
+ setPhase(db, rt.conversationId, 'planning', 'explore_complete')
674
+ io.println('✓ Summarisation complete. Entering planning phase.')
675
+ return true
676
+ }
677
+
678
+ async function exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, userRequest) {
679
+ const filesRead = extractFilesRead(db, rt.sessionId)
680
+ io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
681
+
682
+ const summaries = await summarizeFilesOpenAICompatForTask(baseUrl, apiKey, rt, filesRead, userRequest, io)
683
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
684
+ setPhase(db, rt.conversationId, 'planning', 'explore_complete')
685
+ io.println('✓ Summarisation complete. Entering planning phase.')
686
+ return true
687
+ }
688
+
689
+ async function planGate(db, rt, io, planText) {
690
+ const clean = sanitizeModelText(planText)
691
+ if (!clean) {
692
+ io.println('\n[planning] Model produced no plan text. Try again.')
693
+ return false
694
+ }
695
+ io.println(`\n${'─'.repeat(60)}\n${clean}\n${'─'.repeat(60)}`)
696
+ const answer = io.ask ? await io.ask('Approve this plan? [y/N] ') : 'y'
697
+ if (!/^y(es)?$/i.test(String(answer || '').trim())) {
698
+ io.println('Plan rejected. Revise your request or try again.')
699
+ setPhase(db, rt.conversationId, 'idle', 'plan_rejected')
700
+ return false
701
+ }
702
+ const hash = randomUUID().slice(0, 16)
703
+ withTransaction(db, () => {
704
+ db.prepare(`INSERT INTO plans(conversation_id, content, hash, status, approved_at) VALUES (?,?,?,'approved',datetime('now'))`).run(rt.conversationId, clean, hash)
705
+ })
706
+ setPhase(db, rt.conversationId, 'implement', 'plan_approved')
707
+ io.println('✓ Plan approved. Entering implementation phase.')
708
+ return true
709
+ }
710
+
711
+ async function implementGate(db, rt, io, summaryText) {
712
+ if (summaryText) io.println(`\n${'─'.repeat(60)}\n${summaryText}\n${'─'.repeat(60)}`)
713
+ const answer = io.ask ? await io.ask('Accept implementation and run tests? [y/N] ') : 'y'
714
+ if (!/^y(es)?$/i.test(String(answer || '').trim())) {
715
+ io.println('Implementation rejected. Continuing in implement phase.')
716
+ return false
717
+ }
718
+ setPhase(db, rt.conversationId, 'test', 'implementation_accepted')
719
+ io.println('✓ Implementation accepted. Entering test phase.')
720
+ return true
721
+ }
722
+
723
+ async function testGate(db, rt, io, summaryText) {
724
+ if (summaryText) io.println(`\n${'─'.repeat(60)}\n${summaryText}\n${'─'.repeat(60)}`)
725
+ const answer = io.ask ? await io.ask('Accept test results and complete task? [y/N] ') : 'y'
726
+ if (!/^y(es)?$/i.test(String(answer || '').trim())) {
727
+ io.println('Test results rejected. Continuing in test phase.')
728
+ return false
729
+ }
730
+ setPhase(db, rt.conversationId, 'idle', 'tests_accepted')
731
+ io.println('✓ Task complete. Returning to idle.')
732
+ return true
733
+ }
734
+
735
+ function extractRecentToolResults(db, sessionId) {
736
+ const rows = db.prepare(`
737
+ SELECT entry_type, payload_json FROM transcript_entries
738
+ WHERE session_id = ? ORDER BY sequence ASC
739
+ `).all(sessionId)
740
+
741
+ let lastUserIdx = -1
742
+ for (let i = rows.length - 1; i >= 0; i--) {
743
+ if (rows[i].entry_type === 'user') { lastUserIdx = i; break }
744
+ }
745
+
746
+ const startIdx = lastUserIdx >= 0 ? lastUserIdx + 1 : 0
747
+ const toolCalls = new Map()
748
+ const results = []
749
+
750
+ for (let i = startIdx; i < rows.length; i++) {
751
+ const row = rows[i]
752
+ try {
753
+ const payload = JSON.parse(row.payload_json)
754
+ if (row.entry_type === 'assistant') {
755
+ for (const b of payload.content || []) {
756
+ if (b.type === 'tool_use') toolCalls.set(b.id, b.name)
757
+ }
758
+ } else if (row.entry_type === 'tool_result') {
759
+ const name = toolCalls.get(payload.tool_use_id) || 'tool'
760
+ const content = typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
761
+ const status = payload.is_error ? '✗' : '✓'
762
+ results.push(`${status} ${name}: ${content.slice(0, 300)}`)
763
+ }
764
+ } catch { }
765
+ }
766
+
767
+ if (!results.length) {
768
+ for (const row of rows) {
769
+ try {
770
+ const payload = JSON.parse(row.payload_json)
771
+ if (row.entry_type === 'assistant') {
772
+ for (const b of payload.content || []) {
773
+ if (b.type === 'tool_use') toolCalls.set(b.id, b.name)
774
+ }
775
+ } else if (row.entry_type === 'tool_result') {
776
+ const name = toolCalls.get(payload.tool_use_id) || 'tool'
777
+ const content = typeof payload.content === 'string' ? payload.content : JSON.stringify(payload.content)
778
+ const status = payload.is_error ? '✗' : '✓'
779
+ if (!results.some(r => r.includes(content.slice(0, 50)))) {
780
+ results.push(`${status} ${name}: ${content.slice(0, 300)}`)
781
+ }
782
+ }
783
+ } catch { }
784
+ }
785
+ }
786
+
787
+ return results.length ? results.join('\n') : '(no tool results recorded)'
788
+ }
789
+
790
+ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt) {
791
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(summaryPrompt))
792
+ if (io.spinner) io.spinner.start('Summarising')
793
+
794
+ const currentPhase = getPhase(db, rt.conversationId)
795
+ const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
796
+ const messages = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
797
+
798
+ let bufferedText = ''
799
+ try {
800
+ const stream = await client.messages.stream({ model: rt._model, max_tokens: 1024, system, messages, tools: [] })
801
+ for await (const ev of stream) {
802
+ if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
803
+ bufferedText += ev.delta.text
804
+ }
805
+ }
806
+ const final = await stream.finalMessage()
807
+ trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
808
+ appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(mapAnthropicContent(final.content || [])))
809
+ } catch (e) {
810
+ if (io.spinner) io.spinner.stop()
811
+ return null
812
+ }
813
+ if (io.spinner) io.spinner.stop()
814
+ return sanitizeModelText(bufferedText)
815
+ }
816
+
817
+ async function runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, summaryPrompt) {
818
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(summaryPrompt))
819
+ if (io.spinner) io.spinner.start('Summarising')
820
+
821
+ const currentPhase = getPhase(db, rt.conversationId)
822
+ const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions)
823
+ const rawMessages = transcriptToOpenAIMessages(db, rt.sessionId)
824
+ const messages = [{ role: 'system', content: systemPrompt }, ...sanitizeMessages(rawMessages)]
825
+
826
+ let result = null
827
+ try {
828
+ const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx })
829
+ if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
830
+ appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(out.assistantBlocks))
831
+ result = sanitizeModelText(out.bufferedText)
832
+ } catch {
833
+ }
834
+ if (io.spinner) io.spinner.stop()
835
+ return result
836
+ }
837
+
838
+ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
839
+ for (;;) {
840
+ if (io.spinner) io.spinner.start('Thinking')
841
+ const currentPhase = getPhase(db, rt.conversationId)
842
+ const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
843
+ const filteredTools = filterToolsByPhase(tools, currentPhase)
844
+ const rawAnthropic = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
845
+ const messages = await buildJevSystem2Messages(db, rt, rawAnthropic)
846
+
847
+ let assistantBlocks = [], stopReason = null, bufferedText = ''
848
+ try {
849
+ const stream = await client.messages.stream({ model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools })
850
+ for await (const ev of stream) {
851
+ if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
852
+ bufferedText += ev.delta.text
853
+ }
854
+ if (ev.type === 'content_block_start' && ev.content_block?.type === 'tool_use') {
855
+ if (io.spinner) io.spinner.stop()
856
+ if (io.onToolStart) io.onToolStart(ev.content_block.name)
857
+ }
858
+ }
859
+ const final = await stream.finalMessage()
860
+ stopReason = final.stop_reason
861
+ assistantBlocks = mapAnthropicContent(final.content || [])
862
+ trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
863
+ } catch (e) {
864
+ if (io.spinner) io.spinner.stop()
865
+ await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
866
+ io.println(`\n[model error] ${e.message}`)
867
+ return null
868
+ }
869
+
870
+ if (io.spinner) io.spinner.stop()
871
+ appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(assistantBlocks))
872
+
873
+ const toolUses = assistantBlocks.filter(b => b.type === 'tool_use')
874
+
875
+ if (!toolUses.length || stopReason === 'end_turn') {
876
+ return bufferedText
877
+ }
878
+
879
+ await executeToolUses(db, rt, io, hookRt, toolUses)
880
+ }
881
+ }
882
+
883
+ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase) {
884
+ let activeTools = injectedTools ? [] : allTools
885
+ let currentInjectedTools = injectedTools
886
+
887
+ for (;;) {
888
+ if (io.spinner) io.spinner.start('Thinking')
889
+
890
+ const currentPhase = getPhase(db, rt.conversationId)
891
+ const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions, currentInjectedTools)
892
+ const filteredTools = filterToolsByPhase(activeTools, currentPhase)
893
+ const xmlInstructions = (omitToolChoice(provider) || provider === 'nehanda')
894
+ ? buildXmlToolInstructions(filteredTools, currentPhase)
895
+ : ''
896
+ const fullSystemPrompt = systemPrompt + xmlInstructions
897
+ const rawMessages = transcriptToOpenAIMessages(db, rt.sessionId)
898
+ const filteredMessages = applyEpistemicFilter(sanitizeMessages(rawMessages), currentPhase)
899
+ const jevMessages = await buildJevSystem2Messages(db, rt, filteredMessages)
900
+
901
+ const messages = [{ role: 'system', content: fullSystemPrompt }, ...jevMessages]
902
+
903
+ let assistantBlocks, bufferedText = null, finishReason = null
904
+ try {
905
+ // When xmlInstructions is non-empty, tool calling is being driven entirely by
906
+ // prompt-injected [TOOL_CALL] text parsing (parseXmlToolCalls), specifically to
907
+ // route around vLLM's --tool-call-parser qwen3_xml stop-token bug (see README).
908
+ // Sending the native `tools` schema in the same request re-engages vLLM's own
909
+ // --enable-auto-tool-choice machinery (see deploy_sagemaker.sh SM_VLLM_ENABLE_AUTO_TOOL_CHOICE)
910
+ // on top of the prompt-injected instructions, producing malformed hybrid output
911
+ // like {"Glob", "arguments": {...}} instead of {"name": "Glob", "arguments": {...}}.
912
+ const toolsForRequest = xmlInstructions ? [] : filteredTools
913
+ const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider) })
914
+ assistantBlocks = out.assistantBlocks
915
+ bufferedText = out.bufferedText
916
+ finishReason = out.finishReason
917
+
918
+ const text = assistantBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
919
+ const knownToolNames = new Set(filteredTools.map(t => t.function?.name).filter(Boolean))
920
+ const xmlCalls = parseXmlToolCalls(text, knownToolNames)
921
+ if (process.env.DEBUG_NEHANDA) console.log('\n[DEBUG] xmlCalls count:', xmlCalls.length, 'knownToolNames:', Array.from(knownToolNames))
922
+ const manualCalls = xmlCalls.length > 0 ? xmlCalls : parseManualToolCalls(text)
923
+ if (manualCalls.length > 0) {
924
+ if (io.spinner) io.spinner.stop()
925
+ for (const call of manualCalls) {
926
+ if (io.onToolStart) io.onToolStart(call.name)
927
+ }
928
+ assistantBlocks = [...assistantBlocks, ...manualCalls]
929
+ }
930
+
931
+ if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
932
+ } catch (e) {
933
+ if (e.message?.includes('does not support tools') && activeTools.length > 0) {
934
+ if (io.spinner) io.spinner.stop()
935
+ io.println('\n[info] Model switching to manual tool injection...')
936
+ activeTools = []
937
+ currentInjectedTools = allTools
938
+ const cacheKey = `${baseUrl}::${rt._model}`
939
+ ollamaCapabilityCache.set(cacheKey, false)
940
+ continue
941
+ }
942
+ if (io.spinner) io.spinner.stop()
943
+ await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
944
+ io.println(`\n[model error] ${e.message}`)
945
+ return null
946
+ }
947
+
948
+ if (io.spinner) io.spinner.stop()
949
+
950
+ if (!assistantBlocks.length) return bufferedText
951
+
952
+ appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(assistantBlocks))
953
+ const toolUses = assistantBlocks.filter(b => b.type === 'tool_use')
954
+
955
+ let displayText = bufferedText || ''
956
+ if (displayText) {
957
+ const ticks = String.fromCharCode(96, 96, 96)
958
+ const pattern = ticks + '(?:json)?\\s*\\{[\\s\\S]*?\\}\\s*' + ticks
959
+ displayText = displayText.replace(new RegExp(pattern, 'g'), '').trim()
960
+ }
961
+
962
+ if (!toolUses.length) {
963
+ return displayText || bufferedText
964
+ }
965
+ if (finishReason === 'length') {
966
+ return displayText || bufferedText
967
+ }
968
+
969
+ await executeToolUses(db, rt, io, hookRt, toolUses)
970
+ }
971
+ }
972
+
973
+ async function executeToolUses(db, rt, io, hookRt, toolUses) {
974
+ for (const tu of toolUses) {
975
+ const toolName = tu.name, toolUseId = tu.id, toolInput = tu.input || {}
976
+
977
+ const pre = await runHooks(db, hookRt, 'PreToolUse', { tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId })
978
+ if (!pre.ok) continue
979
+
980
+ const decision = evaluatePermission(rt.settings?.permissions, toolName, toolInput, getPhase(db, rt.conversationId))
981
+ if (decision === 'ask') {
982
+ const ok = await askHuman(io, `Allow ${toolName}? [y/N] `)
983
+ if (!ok) {
984
+ appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, 'User denied permission.', true), toolUseId)
985
+ continue
986
+ }
987
+ } else if (decision === 'deny') {
988
+ appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, 'Permission denied by policy.', true), toolUseId)
989
+ continue
990
+ }
991
+
992
+ if (io.onToolStart) io.onToolStart(toolName)
993
+ const execCtx = { sessionId: rt.sessionId, conversationId: rt.conversationId, cwd: rt.cwd, settings: rt.settings }
994
+ const out = await executeBuiltinTool(db, execCtx, toolName, toolInput, io)
995
+
996
+ if (toolName === 'Bash' && out.newCwd) rt.cwd = out.newCwd
997
+
998
+ withTransaction(db, () => {
999
+ appendEntry(db, rt.sessionId, 'tool_result', makeToolResultPayload(toolUseId, out.content, out.is_error), toolUseId)
1000
+ })
1001
+
1002
+ if (io.onToolResult) io.onToolResult(toolName, out.content, out.is_error)
1003
+
1004
+ await runHooks(db, hookRt, out.is_error ? 'PostToolUseFailure' : 'PostToolUse', {
1005
+ tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId,
1006
+ ...(out.is_error ? { error: out.content } : { tool_response: { content: out.content, is_error: out.is_error } }),
1007
+ })
1008
+ }
1009
+ }
1010
+
1011
+ export async function runUserTurn(db, rt, userText, io) {
1012
+ rt._userText = userText
1013
+ const provider = activeProvider(rt.settings)
1014
+ const phase = getPhase(db, rt.conversationId)
1015
+ const hookRt = {
1016
+ sessionId: rt.sessionId, conversationId: rt.conversationId,
1017
+ runtimeDbPath: rt.runtimeDbPath, cwd: rt.cwd,
1018
+ permissionMode: rt.settings?.permissions?.defaultMode ?? 'default',
1019
+ settings: rt.settings,
1020
+ }
1021
+
1022
+ const ups = await runHooks(db, hookRt, 'UserPromptSubmit', { prompt: userText })
1023
+ if (!ups.ok) { io.println(`UserPromptSubmit blocked: ${ups.userPrompt?.stderr || 'exit 2'}`); return }
1024
+
1025
+ if (phase === 'idle' && io.ask) {
1026
+ const answer = await io.ask('Use SDLC workflow (plan → implement → test)? [Y/n] ')
1027
+ const useSdlc = !answer.trim() || /^y(es)?$/i.test(answer.trim())
1028
+ if (useSdlc) {
1029
+ setPhase(db, rt.conversationId, 'explore', 'user_confirmed')
1030
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildExploreUserMessage(userText)))
1031
+ } else {
1032
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(userText))
1033
+ }
1034
+ } else {
1035
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(userText))
1036
+ }
1037
+
1038
+ let model
1039
+ try { model = resolveWireModel(rt.settings.model_config) } catch (e) { io.println(`[config] ${e.message}`); return }
1040
+ rt._model = model
1041
+
1042
+ const currentPhase = getPhase(db, rt.conversationId)
1043
+
1044
+ if (['lm_studio_local', 'openai_compatible', 'zhipu', 'ollama', 'nehanda'].includes(provider)) {
1045
+ await runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, currentPhase)
1046
+ } else {
1047
+ await runSdlcAnthropic(db, rt, io, hookRt, model, currentPhase)
1048
+ }
1049
+
1050
+ await runHooks(db, hookRt, 'Stop', { stop_hook_active: false })
1051
+ }
1052
+
1053
+ async function runSdlcAnthropic(db, rt, io, hookRt, model, startPhase) {
1054
+ const cred = resolveAnthropicCredentials({ bareMode: rt.bareMode, apiKeyHelper: rt.settings?.apiKeyHelper ?? null })
1055
+ if (cred.mode === 'none') { io.println('No credentials found.'); return }
1056
+ const client = makeAnthropicClient(cred)
1057
+ const { anthropic: tools } = await toolsForProvider(activeProvider(rt.settings), rt.settings)
1058
+
1059
+ let phase = startPhase
1060
+
1061
+ if (phase === 'idle') {
1062
+ const text = await runAnthropicPhase(db, rt, io, hookRt, client, tools, phase)
1063
+ if (text) io.write(text)
1064
+ return
1065
+ }
1066
+
1067
+ while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
1068
+ const text = await runAnthropicPhase(db, rt, io, hookRt, client, tools, phase)
1069
+ if (text === null) return
1070
+
1071
+ let advanced = false
1072
+
1073
+ if (phase === 'explore') {
1074
+ advanced = await exploreGateAnthropic(db, rt, io, client, rt._userText)
1075
+ } else if (phase === 'planning') {
1076
+ advanced = await planGate(db, rt, io, text)
1077
+ } else if (phase === 'implement') {
1078
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1079
+ const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildImplementSummaryPrompt(toolResults))
1080
+ advanced = await implementGate(db, rt, io, summary)
1081
+ } else if (phase === 'test') {
1082
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1083
+ const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildTestSummaryPrompt(toolResults))
1084
+ advanced = await testGate(db, rt, io, summary)
1085
+ }
1086
+
1087
+ if (!advanced) break
1088
+
1089
+ phase = getPhase(db, rt.conversationId)
1090
+ if (phase === 'idle') break
1091
+
1092
+ if (phase === 'implement') {
1093
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1094
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
1095
+ } else if (phase === 'test') {
1096
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPhase) {
1102
+ let baseUrl, apiKey
1103
+ const savedBaseUrl = rt.settings?.model_config?.base_url?.replace(/\/+$/, '')
1104
+ if (provider === 'ollama') {
1105
+ baseUrl = savedBaseUrl || process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1'
1106
+ apiKey = 'ollama'
1107
+ } else if (provider === 'nehanda') {
1108
+ baseUrl = savedBaseUrl || process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1'
1109
+ apiKey = rt.settings?.model_config?.api_key || process.env.NEHANDA_API_KEY || ''
1110
+ } else {
1111
+ baseUrl = savedBaseUrl || process.env.OPENAI_BASE_URL
1112
+ apiKey = process.env.OPENAI_API_KEY
1113
+ }
1114
+
1115
+ const forceManual = rt.settings?.model_config?.force_manual_tools === true
1116
+
1117
+ let nativeSupport = true
1118
+ if (provider === 'ollama') nativeSupport = await detectOllamaToolSupport(baseUrl, model)
1119
+
1120
+ const { openai: allTools } = await toolsForProvider(provider, rt.settings, nativeSupport)
1121
+ const injectedTools = (forceManual || !nativeSupport) ? allTools : null
1122
+
1123
+ let phase = startPhase
1124
+
1125
+ if (phase === 'idle') {
1126
+ const text = await runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase)
1127
+ if (text) io.write(text)
1128
+ return
1129
+ }
1130
+
1131
+ while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
1132
+ const text = await runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase)
1133
+ if (text === null) return
1134
+
1135
+ let advanced = false
1136
+
1137
+ if (phase === 'explore') {
1138
+ advanced = await exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, rt._userText)
1139
+ } else if (phase === 'planning') {
1140
+ advanced = await planGate(db, rt, io, text)
1141
+ } else if (phase === 'implement') {
1142
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1143
+ const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildImplementSummaryPrompt(toolResults))
1144
+ advanced = await implementGate(db, rt, io, summary)
1145
+ } else if (phase === 'test') {
1146
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1147
+ const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildTestSummaryPrompt(toolResults))
1148
+ advanced = await testGate(db, rt, io, summary)
1149
+ }
1150
+
1151
+ if (!advanced) break
1152
+
1153
+ phase = getPhase(db, rt.conversationId)
1154
+ if (phase === 'idle') break
1155
+
1156
+ if (phase === 'implement') {
1157
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1158
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
1159
+ } else if (phase === 'test') {
1160
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1161
+ }
1162
+ }
1163
+ }