@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
package/lib/mcp.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { runHooks } from './hookplane.mjs'
3
+
4
+ const MCP_TIMEOUT = 120_000
5
+ const servers = new Map()
6
+ let _elicitationHandler = null
7
+
8
+ /** Set a handler for MCP elicitation requests. Called with (serverName, params) → result. */
9
+ export function setElicitationHandler(handler) { _elicitationHandler = handler }
10
+
11
+ /** Start an MCP server from config. Returns a handle with request(). */
12
+ export function getMcpServer(name, config) {
13
+ if (servers.has(name)) return servers.get(name)
14
+ const handle = startMcpServer(name, config)
15
+ servers.set(name, handle)
16
+ return handle
17
+ }
18
+
19
+ function startMcpServer(name, config) {
20
+ const cmd = config.command
21
+ const args = config.args || []
22
+ const env = { ...process.env, ...(config.env || {}) }
23
+
24
+ let child = null
25
+ let reqId = 1
26
+ let pending = new Map()
27
+ let buffer = ''
28
+ let initialized = false
29
+ let initPromise = null
30
+
31
+ function ensureChild() {
32
+ if (child && !child.killed) return
33
+ child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
34
+ child.stdout.on('data', chunk => {
35
+ buffer += chunk.toString('utf8')
36
+ const lines = buffer.split('\n')
37
+ buffer = lines.pop() ?? ''
38
+ for (const line of lines) {
39
+ if (!line.trim()) continue
40
+ try {
41
+ const msg = JSON.parse(line)
42
+ if (msg.id != null && pending.has(msg.id)) {
43
+ const { resolve } = pending.get(msg.id)
44
+ pending.delete(msg.id)
45
+ resolve(msg)
46
+ } else if (msg.method === 'elicitation/create' && msg.id != null) {
47
+ // Server-initiated elicitation request
48
+ handleElicitation(name, msg, child).catch(() => {})
49
+ }
50
+ } catch { /* skip non-JSON */ }
51
+ }
52
+ })
53
+ child.on('error', () => { child = null })
54
+ child.on('close', () => { child = null; for (const [, p] of pending) p.resolve({ error: { code: -1, message: 'MCP server closed' } }); pending.clear() })
55
+ }
56
+
57
+ function sendRequest(method, params, timeoutMs = MCP_TIMEOUT) {
58
+ ensureChild()
59
+ const id = reqId++
60
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }) + '\n'
61
+ return new Promise((resolve, reject) => {
62
+ const timer = setTimeout(() => {
63
+ pending.delete(id)
64
+ resolve({ error: { code: -32000, message: 'MCP request timeout' } })
65
+ }, timeoutMs)
66
+ pending.set(id, { resolve: (resp) => { clearTimeout(timer); resolve(resp) } })
67
+ try { child.stdin.write(msg, 'utf8') } catch (e) { clearTimeout(timer); pending.delete(id); resolve({ error: { code: -1, message: String(e) } }) }
68
+ })
69
+ }
70
+
71
+ async function initialize() {
72
+ if (initialized) return
73
+ if (initPromise) return initPromise
74
+ initPromise = sendRequest('initialize', {
75
+ protocolVersion: '2024-11-05',
76
+ capabilities: {},
77
+ clientInfo: { name: 'ona-sdlc-repl', version: '0.2.0' },
78
+ }).then(resp => {
79
+ if (!resp.error) {
80
+ sendRequest('notifications/initialized', {}).catch(() => {})
81
+ initialized = true
82
+ }
83
+ })
84
+ return initPromise
85
+ }
86
+
87
+ return {
88
+ name,
89
+ async request(method, params, timeoutMs) {
90
+ await initialize()
91
+ return sendRequest(method, params, timeoutMs)
92
+ },
93
+ close() {
94
+ if (child && !child.killed) { try { child.kill() } catch {} }
95
+ servers.delete(name)
96
+ },
97
+ }
98
+ }
99
+
100
+ export function listMcpServers(settings) {
101
+ const srvs = settings?.mcp_servers || {}
102
+ return Object.keys(srvs)
103
+ }
104
+
105
+ export async function mcpListResources(server) {
106
+ const resp = await server.request('resources/list', {})
107
+ if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
108
+ return { content: JSON.stringify(resp.result?.resources || [], null, 2), is_error: false }
109
+ }
110
+
111
+ export async function mcpReadResource(server, uri) {
112
+ const resp = await server.request('resources/read', { uri })
113
+ if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
114
+ const contents = resp.result?.contents || []
115
+ const text = contents.map(c => c.text || `[blob: ${c.uri}]`).join('\n')
116
+ return { content: text || '(empty)', is_error: false }
117
+ }
118
+
119
+ export async function mcpCallTool(server, toolName, args) {
120
+ const resp = await server.request('tools/call', { name: toolName, arguments: args })
121
+ if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
122
+ const result = resp.result
123
+ const text = Array.isArray(result?.content) ? result.content.map(c => c.text || JSON.stringify(c)).join('\n') : JSON.stringify(result)
124
+ return { content: text, is_error: Boolean(result?.isError) }
125
+ }
126
+
127
+ /** Handle an MCP elicitation request — fires Elicitation + ElicitationResult hooks. */
128
+ async function handleElicitation(serverName, msg, child) {
129
+ const params = msg.params || {}
130
+ const hookRt = {
131
+ sessionId: '', conversationId: '', runtimeDbPath: process.env.AGENT_SDLC_DB || '',
132
+ cwd: process.cwd(), settings: {},
133
+ }
134
+
135
+ // Fire Elicitation hook
136
+ await runHooks(null, hookRt, 'Elicitation', {
137
+ mcp_server_name: serverName,
138
+ message: params.message || '',
139
+ elicitation_id: params.elicitation_id || msg.id,
140
+ mode: params.mode,
141
+ requested_schema: params.schema,
142
+ })
143
+
144
+ // Get user response via handler or default decline
145
+ let action = 'decline', content = null
146
+ if (_elicitationHandler) {
147
+ try {
148
+ const result = await _elicitationHandler(serverName, params)
149
+ action = result?.action || 'decline'
150
+ content = result?.content || null
151
+ } catch { /* decline on error */ }
152
+ }
153
+
154
+ // Fire ElicitationResult hook
155
+ await runHooks(null, hookRt, 'ElicitationResult', {
156
+ mcp_server_name: serverName,
157
+ elicitation_id: params.elicitation_id || msg.id,
158
+ action,
159
+ content,
160
+ })
161
+
162
+ // Send response back to server
163
+ const resp = JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { action, content } }) + '\n'
164
+ try { child.stdin.write(resp, 'utf8') } catch { /* server may have closed */ }
165
+ }
166
+
167
+ export function closeAllMcpServers() {
168
+ for (const [, s] of servers) s.close()
169
+ }
@@ -0,0 +1,152 @@
1
+ /** §2.2 — wire model strings */
2
+ const WIRE = {
3
+ claude_code_subscription: {
4
+ claude_opus_4: 'claude-opus-4-20250514',
5
+ claude_sonnet_4: 'claude-sonnet-4-20250514',
6
+ claude_3_5_haiku: 'claude-3-5-haiku-20241022',
7
+ },
8
+ openai_compatible: {
9
+ gpt_4o: 'gpt-4o',
10
+ gpt_4o_mini: 'gpt-4o-mini',
11
+ o3: 'o3',
12
+ o3_mini: 'o3-mini',
13
+ },
14
+ zhipu: {
15
+ glm_4_7_flash: 'glm-4.7-flash',
16
+ },
17
+ ollama: {
18
+ qwen2_5_14b: 'qwen2.5:14b',
19
+ qwen2_5_7b: 'qwen2.5:7b',
20
+ deepseek_coder_v2: 'deepseek-coder-v2',
21
+ codegemma_7b: 'codegemma:7b',
22
+ },
23
+ lm_studio_local: {
24
+ lm_studio_server_routed: 'lm_studio_server_routed',
25
+ },
26
+ nehanda: {
27
+ nehanda_rag_synthesis_27b: 'nehanda-rag-synthesis-27b',
28
+ },
29
+ }
30
+
31
+ /** Provider capabilities for custom model support and discovery */
32
+ const PROVIDER_CAPABILITIES = {
33
+ claude_code_subscription: {
34
+ supportsCustomModels: false,
35
+ requiresHardcodedMap: true,
36
+ supportsDiscovery: false,
37
+ },
38
+ openai_compatible: {
39
+ supportsCustomModels: true,
40
+ requiresHardcodedMap: false,
41
+ supportsDiscovery: false,
42
+ },
43
+ zhipu: {
44
+ supportsCustomModels: false,
45
+ requiresHardcodedMap: true,
46
+ supportsDiscovery: false,
47
+ },
48
+ ollama: {
49
+ supportsCustomModels: true,
50
+ requiresHardcodedMap: false,
51
+ supportsDiscovery: true,
52
+ discoveryEndpoint: '/api/tags',
53
+ },
54
+ lm_studio_local: {
55
+ supportsCustomModels: true,
56
+ requiresHardcodedMap: false,
57
+ supportsDiscovery: false,
58
+ },
59
+ nehanda: {
60
+ supportsCustomModels: false,
61
+ requiresHardcodedMap: true,
62
+ supportsDiscovery: false,
63
+ omitToolChoice: true,
64
+ },
65
+ }
66
+
67
+ export function supportsCustomModels(provider) {
68
+ const capabilities = PROVIDER_CAPABILITIES[provider]
69
+ return capabilities ? capabilities.supportsCustomModels : false
70
+ }
71
+
72
+ export function supportsDiscovery(provider) {
73
+ const capabilities = PROVIDER_CAPABILITIES[provider]
74
+ return capabilities ? capabilities.supportsDiscovery : false
75
+ }
76
+
77
+ export function getDiscoveryEndpoint(provider) {
78
+ const capabilities = PROVIDER_CAPABILITIES[provider]
79
+ return capabilities?.discoveryEndpoint || null
80
+ }
81
+
82
+ export function omitToolChoice(provider) {
83
+ const capabilities = PROVIDER_CAPABILITIES[provider]
84
+ return capabilities ? !!capabilities.omitToolChoice : false
85
+ }
86
+
87
+ export function resolveWireModel(modelConfig) {
88
+ const { provider, model_id, custom_model_name } = modelConfig || {}
89
+
90
+ if (custom_model_name && custom_model_name.trim()) {
91
+ return custom_model_name.trim()
92
+ }
93
+
94
+ const map = WIRE[provider]
95
+ if (!map) throw new Error(`Unknown provider: ${provider}`)
96
+ const w = map[model_id]
97
+ if (w === undefined) throw new Error(`Invalid model_id ${model_id} for provider ${provider}`)
98
+
99
+ if (provider === 'lm_studio_local') {
100
+ const name = process.env.LM_STUDIO_MODEL
101
+ if (!name || !name.trim()) throw new Error('LM_STUDIO_MODEL not set. Export it or use /model.')
102
+ return name.trim()
103
+ }
104
+
105
+ return w
106
+ }
107
+
108
+ const DEFAULT_NEHANDA_ENDPOINT = 'https://nehanda-ml.asoba.co/v1'
109
+ const DEFAULT_NEHANDA_MODEL = 'nehanda-rag-synthesis-27b'
110
+
111
+ /**
112
+ * Map agents.json agent {model, endpoint} → model_config used by resolveWireModel / orchestrate.
113
+ * Used by nehanda-ui so /model selection actually changes the wire model on the next turn.
114
+ */
115
+ export function modelConfigFromAgent(agent, { apiKey = '', defaultEndpoint = DEFAULT_NEHANDA_ENDPOINT } = {}) {
116
+ const model = agent?.model || DEFAULT_NEHANDA_MODEL
117
+ const endpoint = agent?.endpoint || defaultEndpoint
118
+ const isNehanda = model === DEFAULT_NEHANDA_MODEL || /nehanda-ml\.asoba\.co/.test(endpoint)
119
+
120
+ if (isNehanda) {
121
+ return {
122
+ provider: 'nehanda',
123
+ model_id: 'nehanda_rag_synthesis_27b',
124
+ custom_model_name: null,
125
+ base_url: endpoint,
126
+ api_key: apiKey,
127
+ }
128
+ }
129
+
130
+ const provider = /:11434\b/.test(endpoint) ? 'ollama' : 'openai_compatible'
131
+ return {
132
+ provider,
133
+ model_id: model,
134
+ custom_model_name: model,
135
+ base_url: endpoint,
136
+ api_key: provider === 'ollama' ? 'ollama' : apiKey,
137
+ }
138
+ }
139
+
140
+ export function anthropicBaseUrl() {
141
+ return (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '')
142
+ }
143
+
144
+ export function allModelIds() {
145
+ const out = []
146
+ for (const [prov, map] of Object.entries(WIRE)) {
147
+ for (const mid of Object.keys(map)) {
148
+ out.push({ provider: prov, model_id: mid })
149
+ }
150
+ }
151
+ return out
152
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Model Discovery Service
3
+ * Queries Ollama API to discover available models
4
+ */
5
+
6
+ /**
7
+ * Discover available models from an Ollama server
8
+ * @param {string} baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
9
+ * @param {Object} options - Discovery options
10
+ * @param {number} options.timeout - Request timeout in milliseconds (default: 5000)
11
+ * @returns {Promise<DiscoveryResult>} Discovery result with models or error
12
+ */
13
+ export async function discoverOllamaModels(baseUrl, options = {}) {
14
+ const timeout = options.timeout || 5000
15
+
16
+ try {
17
+ // Construct discovery URL - remove trailing slashes and append endpoint
18
+ const cleanBaseUrl = baseUrl.replace(/\/+$/, '')
19
+ const discoveryUrl = `${cleanBaseUrl}/api/tags`
20
+
21
+ // Make HTTP GET request with timeout
22
+ const controller = new AbortController()
23
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
24
+
25
+ let response
26
+ try {
27
+ response = await fetch(discoveryUrl, {
28
+ method: 'GET',
29
+ signal: controller.signal,
30
+ headers: {
31
+ 'Accept': 'application/json'
32
+ }
33
+ })
34
+ } finally {
35
+ clearTimeout(timeoutId)
36
+ }
37
+
38
+ // Handle HTTP errors
39
+ if (!response.ok) {
40
+ const statusText = response.statusText || 'Unknown error'
41
+ if (response.status === 404) {
42
+ return {
43
+ success: false,
44
+ error: `Endpoint not found (404). The Ollama server at ${baseUrl} may not support model discovery.`
45
+ }
46
+ } else if (response.status >= 500) {
47
+ return {
48
+ success: false,
49
+ error: `Server error (${response.status}): ${statusText}. The Ollama server encountered an internal error.`
50
+ }
51
+ } else {
52
+ return {
53
+ success: false,
54
+ error: `HTTP error (${response.status}): ${statusText}`
55
+ }
56
+ }
57
+ }
58
+
59
+ // Parse JSON response
60
+ let data
61
+ try {
62
+ data = await response.json()
63
+ } catch (parseError) {
64
+ return {
65
+ success: false,
66
+ error: `Invalid JSON response from server: ${parseError.message}`
67
+ }
68
+ }
69
+
70
+ // Validate response structure
71
+ if (!data || typeof data !== 'object') {
72
+ return {
73
+ success: false,
74
+ error: 'Invalid response format: expected JSON object'
75
+ }
76
+ }
77
+
78
+ if (!Array.isArray(data.models)) {
79
+ return {
80
+ success: false,
81
+ error: 'Invalid response format: missing or invalid "models" array'
82
+ }
83
+ }
84
+
85
+ // Extract and validate model information
86
+ const models = []
87
+ for (const model of data.models) {
88
+ // Skip invalid model entries
89
+ if (!model || typeof model !== 'object') {
90
+ continue
91
+ }
92
+
93
+ // Require non-empty name field
94
+ if (!model.name || typeof model.name !== 'string' || !model.name.trim()) {
95
+ continue
96
+ }
97
+
98
+ // Extract model information
99
+ models.push({
100
+ name: model.name,
101
+ modified_at: model.modified_at || null,
102
+ size: typeof model.size === 'number' ? model.size : 0,
103
+ digest: model.digest || null,
104
+ details: model.details || null
105
+ })
106
+ }
107
+
108
+ return {
109
+ success: true,
110
+ models
111
+ }
112
+
113
+ } catch (error) {
114
+ // Handle connection errors and timeouts
115
+ if (error.name === 'AbortError') {
116
+ return {
117
+ success: false,
118
+ error: `Request timed out after ${timeout}ms. The Ollama server may be slow or unreachable.`
119
+ }
120
+ }
121
+
122
+ // Handle network/connection errors
123
+ if (error.code === 'ECONNREFUSED' || error.message.includes('fetch failed')) {
124
+ return {
125
+ success: false,
126
+ error: `Cannot connect to Ollama server at ${baseUrl}. Make sure Ollama is running and accessible.`
127
+ }
128
+ }
129
+
130
+ // Generic error
131
+ return {
132
+ success: false,
133
+ error: `Discovery failed: ${error.message}`
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Format a list of models for display
140
+ * @param {Array} models - Array of model objects from discovery
141
+ * @param {Object} options - Formatting options
142
+ * @param {string} options.sortBy - Sort by 'date', 'name', or 'size' (default: 'date')
143
+ * @param {boolean} options.verbose - Include detailed information (default: false)
144
+ * @returns {string} Formatted model list
145
+ */
146
+ export function formatModelList(models, options = {}) {
147
+ const sortBy = options.sortBy || 'date'
148
+ const verbose = options.verbose || false
149
+
150
+ if (!models || models.length === 0) {
151
+ return 'No models found on the server.\n\nTo pull a model, run: ollama pull <model-name>'
152
+ }
153
+
154
+ // Sort models
155
+ const sorted = [...models]
156
+ if (sortBy === 'name') {
157
+ sorted.sort((a, b) => a.name.localeCompare(b.name))
158
+ } else if (sortBy === 'size') {
159
+ sorted.sort((a, b) => b.size - a.size)
160
+ } else {
161
+ // Sort by modification date (newest first)
162
+ sorted.sort((a, b) => {
163
+ if (!a.modified_at) return 1
164
+ if (!b.modified_at) return -1
165
+ return new Date(b.modified_at) - new Date(a.modified_at)
166
+ })
167
+ }
168
+
169
+ // Format output
170
+ const lines = []
171
+ lines.push('Available models:\n')
172
+
173
+ for (const model of sorted) {
174
+ const size = formatBytes(model.size)
175
+
176
+ if (verbose && model.details) {
177
+ const paramSize = model.details.parameter_size || 'unknown'
178
+ const quantization = model.details.quantization_level || 'unknown'
179
+ lines.push(` ${model.name}`)
180
+ lines.push(` Size: ${size}`)
181
+ lines.push(` Parameters: ${paramSize}`)
182
+ lines.push(` Quantization: ${quantization}`)
183
+ } else {
184
+ lines.push(` ${model.name.padEnd(40)} ${size}`)
185
+ }
186
+ }
187
+
188
+ lines.push('\nUsage: /model ollama/<model-name>')
189
+ lines.push('Example: /model ollama/llama3')
190
+
191
+ return lines.join('\n')
192
+ }
193
+
194
+ /**
195
+ * Format bytes in human-readable format
196
+ * @param {number} bytes - Size in bytes
197
+ * @returns {string} Formatted size string
198
+ */
199
+ function formatBytes(bytes) {
200
+ if (bytes === 0) return '0 B'
201
+ if (bytes < 1024) return `${bytes} B`
202
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
203
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
204
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
205
+ }
@@ -0,0 +1,147 @@
1
+ /** OpenAI Chat Completions–compatible streaming HTTP client (§2.1 lm_studio_local + openai_compatible). */
2
+
3
+ /**
4
+ * Strip thinking traces emitted by reasoning-capable models (Nehanda, DeepSeek-R1, Qwen3).
5
+ * Handles:
6
+ * - Tagged blocks: <think>...</think>
7
+ * - Prose header: "Here's a thinking process: ..."
8
+ * - Numbered planning list that ends before the actual answer (```code or plain prose)
9
+ */
10
+ function stripThinkingTraces(text) {
11
+ // Tagged blocks
12
+ text = text.replace(/<think>[\s\S]*?<\/think>/g, '')
13
+ text = text.replace(/<think>[\s\S]*/g, '')
14
+
15
+ // Prose thinking header
16
+ text = text.replace(/^Here's a thinking process:[\s\S]*?(?=\n\n(?!\n)|\n(?=[A-Z0-9`#]))/m, '')
17
+
18
+ // Numbered planning lists: lines starting with "1." or "1. **" that precede a code block
19
+ // or a clean paragraph. We detect: text starts with a numbered step AND contains a code fence.
20
+ const hasCodeFence = text.includes('```')
21
+ const startsWithStep = /^\s*1\.\s/.test(text)
22
+ if (startsWithStep && hasCodeFence) {
23
+ // Keep only from the first code block onward
24
+ const fenceIdx = text.indexOf('```')
25
+ text = text.slice(fenceIdx)
26
+ }
27
+
28
+ return text.trim()
29
+ }
30
+
31
+ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true }) {
32
+ const url = `${String(baseUrl).replace(/\/$/, '')}/chat/completions`
33
+ const body = { model, messages, stream: useStream, max_tokens: maxTokens }
34
+ // Suppress tool_choice:"auto" for providers that do not support it (e.g. Nehanda
35
+ // vLLM without --enable-auto-tool-choice). When omitToolChoice is true we still
36
+ // send the tools schema so the model can call them — we just omit the directive.
37
+ if (tools?.length) { body.tools = tools; if (!skipToolChoice) body.tool_choice = 'auto' }
38
+ if (numCtx) body.num_ctx = numCtx
39
+
40
+ const res = await fetch(url, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
43
+ body: JSON.stringify(body),
44
+ })
45
+
46
+ if (!res.ok) {
47
+ const t = await res.text()
48
+ throw new Error(`OpenAI-compat ${res.status}: ${t.slice(0, 2000)}`)
49
+ }
50
+ if (!res.body) throw new Error('OpenAI-compat: empty response body')
51
+
52
+ // Non-streaming path: Lambda/proxy returns a single JSON completion object.
53
+ // Used for providers like Nehanda whose Lambda Function URL buffers the full
54
+ // response rather than forwarding SSE chunks.
55
+ if (!useStream) {
56
+ const text = await res.text()
57
+ let json
58
+ try { json = JSON.parse(text) } catch { throw new Error(`OpenAI-compat: non-JSON response: ${text.slice(0, 500)}`) }
59
+ const content = json.choices?.[0]?.message?.content || ''
60
+ const strippedText = stripThinkingTraces(content)
61
+ if (!bufferOutput && strippedText) { if (io.spinner) io.spinner.stop(); io.write(strippedText) }
62
+ const assistantBlocks = []
63
+ if (strippedText) assistantBlocks.push({ type: 'text', text: strippedText })
64
+ return {
65
+ assistantBlocks,
66
+ finishReason: json.choices?.[0]?.finish_reason || 'stop',
67
+ bufferedText: bufferOutput ? strippedText : null,
68
+ usage: json.usage || null,
69
+ }
70
+ }
71
+
72
+ const reader = res.body.getReader()
73
+ const decoder = new TextDecoder()
74
+ let buffer = '', fullText = '', finishReason = null, firstToken = true, usage = null
75
+ const toolAcc = new Map()
76
+
77
+ while (true) {
78
+ const { done, value } = await reader.read()
79
+ if (done) break
80
+ buffer += decoder.decode(value, { stream: true })
81
+ const parts = buffer.split('\n')
82
+ buffer = parts.pop() ?? ''
83
+
84
+ for (const line of parts) {
85
+ const trimmed = line.trim()
86
+ if (!trimmed.startsWith('data:')) continue
87
+ const data = trimmed.slice(5).trim()
88
+ if (data === '[DONE]') continue
89
+ let json
90
+ try { json = JSON.parse(data) } catch { continue }
91
+ const choice = json.choices?.[0]
92
+ const delta = choice?.delta
93
+ if (delta?.content) {
94
+ if (firstToken) {
95
+ firstToken = false
96
+ // Only stop spinner and write newline when streaming directly to output.
97
+ // When buffering, the caller owns the spinner — don't touch it here.
98
+ if (!bufferOutput) { if (io.spinner) io.spinner.stop(); io.write('\n') }
99
+ }
100
+ fullText += delta.content
101
+ if (!bufferOutput) {
102
+ io.write(delta.content)
103
+ }
104
+ }
105
+ if (delta?.tool_calls) {
106
+ if (firstToken) {
107
+ firstToken = false
108
+ // Tool calls are always handled by the caller — don't stop spinner here.
109
+ }
110
+ for (const tc of delta.tool_calls) {
111
+ const idx = tc.index ?? 0
112
+ if (!toolAcc.has(idx)) toolAcc.set(idx, { id: '', name: '', arguments: '' })
113
+ const acc = toolAcc.get(idx)
114
+ if (tc.id) acc.id = tc.id
115
+ if (tc.function?.name) acc.name = tc.function.name
116
+ if (tc.function?.arguments != null) acc.arguments += String(tc.function.arguments)
117
+ }
118
+ }
119
+ if (choice?.finish_reason) finishReason = choice.finish_reason
120
+ if (json.usage) usage = json.usage
121
+ }
122
+ }
123
+
124
+ const toolCalls = [...toolAcc.keys()].sort((a, b) => a - b).map(idx => {
125
+ const acc = toolAcc.get(idx)
126
+ if (!acc.name) return null
127
+ let input = {}
128
+ try { input = acc.arguments.trim() ? JSON.parse(acc.arguments) : {} } catch { input = { _parseError: true, _raw: acc.arguments.slice(0, 500) } }
129
+ return { id: acc.id || `call_${idx}_${Date.now()}`, name: acc.name, input }
130
+ }).filter(Boolean)
131
+
132
+ // Strip thinking traces before returning — applies to buffered and non-buffered paths.
133
+ // Non-buffered (live streaming) will have printed raw tokens already; the stripped
134
+ // version is used for the transcript and any downstream processing.
135
+ const strippedText = stripThinkingTraces(fullText)
136
+
137
+ const assistantBlocks = []
138
+ if (strippedText) assistantBlocks.push({ type: 'text', text: strippedText })
139
+ for (const tc of toolCalls) assistantBlocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input })
140
+
141
+ return {
142
+ assistantBlocks,
143
+ finishReason: finishReason || (toolCalls.length > 0 ? 'tool_calls' : 'stop'),
144
+ bufferedText: bufferOutput ? strippedText : null,
145
+ usage,
146
+ }
147
+ }