@asobacloud/nehanda 0.6.1 → 0.7.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.
package/README.md CHANGED
@@ -51,31 +51,32 @@ ona-code
51
51
 
52
52
  ### 3. Provider Setup
53
53
 
54
- #### Option A: Nehanda Cloud (Default)
55
-
56
- The CLI defaults to the primary Nehanda endpoint (`https://nehanda-ml.asoba.co/v1`). If an API key is required:
57
-
58
- ```
59
- ❯ /key
60
- New Nehanda API key: <your-key>
61
- ```
62
-
63
- #### Option B: Local LM Studio
64
-
65
- Start LM Studio locally on port `1234` or `8000`, then start the CLI. Select or switch models via:
54
+ Use a single Goose-style wizard — provider, API key, host, and model in one flow. The CLI discovers available models from the provider (`GET /v1/models` with your key, or Ollama `/api/tags`).
66
55
 
67
56
  ```
68
57
  ❯ /model
69
- ```
70
58
 
71
- #### Option C: Remote Ollama over LAN
59
+ ◆ Which provider?
60
+ │ ● OpenRouter
61
+ └
62
+ ◆ API key
63
+ │ sk-or-v1-…
64
+ └
65
+ ◆ API host
66
+ │ https://openrouter.ai/api/v1
67
+ └
68
+ Fetching models from provider…
69
+ ◆ Model
70
+ │ ● openai/gpt-4o
71
+ └
72
+ ✓ Provider / host / model saved — session active
73
+ ```
72
74
 
73
- To connect to an Ollama instance running on your network:
75
+ Shortcuts:
76
+ - `/key openrouter sk-…` — saves the key, then continues into the same host → discover → model steps
77
+ - `/config` — read-only status (provider, model, endpoint, which keys are set)
74
78
 
75
- ```
76
- ❯ /config base_url http://AsobaCorp-1.local:11434/v1
77
- ❯ /model ollama/deepseek-coder-v2:latest
78
- ```
79
+ Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the host, then choose from the discovered catalog (or enter a custom name if discovery fails).
79
80
 
80
81
  ---
81
82
 
@@ -326,9 +327,9 @@ Place the script at `lib/scripts/my-new-tool.py`. It will receive arguments inte
326
327
  | Command | Description |
327
328
  |---|---|
328
329
  | `/help` | Display available commands |
329
- | `/model [name]` | Discover and switch active provider or model endpoint |
330
- | `/key` | Save Nehanda API key |
331
- | `/config` | View or set settings (e.g., `/config base_url <url>`) |
330
+ | `/model` | Configure provider → API key → host → discover models → select (single wizard) |
331
+ | `/key [provider] [value]` | Save API key then continue the configure wizard (`openrouter`, `zhipu`, `openai`, or Nehanda default) |
332
+ | `/config` | Show provider, model, endpoint, and key status (read-only) |
332
333
  | `/mcp` | Manage MCP server connections (see below) |
333
334
  | `/clear` | Clear conversation history and reset transcript state |
334
335
  | `/retry` | Resend the last failed request |
@@ -497,6 +498,10 @@ node tests/unit/ui_legibility_behavioral.mjs
497
498
 
498
499
  ## Changelog
499
500
 
501
+ ### 0.7.0
502
+
503
+ - **Unified `/model` configure wizard with keyed discovery (`lib/configureProvider.mjs`, `lib/modelDiscovery.mjs`, `bin/nehanda-ui.mjs`):** Provider setup is a single Goose-style flow — provider → API key → host → live model catalog → select → persist. OpenRouter / OpenAI-compat / Zhipu / Nehanda / LM Studio discover via authenticated `GET {base}/models`; Ollama keeps `/api/tags`. Discovery failure falls back to custom model name. `/key <provider>` chains into the same wizard tail. `/config` is read-only status. Behavioral coverage in `tests/unit/configure_provider_behavioral.mjs`. Closes #8.
504
+
500
505
  ### 0.6.1
501
506
 
502
507
  - **Fix: Laya provisioning smoke test (`lib/scripts/ensure-laya-env.sh`):** The `Router()` constructibility check was missing `import sys`, causing `npm install` to fail with `NameError: name 'sys' is not defined` even when all packages installed successfully. The marker file was never written, leaving the CLI in a permanently broken state despite a complete venv. Fixed.
package/bin/agent.mjs CHANGED
@@ -20,7 +20,7 @@ import { getPhase, canTransition, setPhase } from '../lib/workflow.mjs'
20
20
  import { executeBuiltinTool, invalidateMcpToolCache } from '../lib/tools.mjs'
21
21
  import { appendEntry, makeUserPayload, makeToolResultPayload } from '../lib/transcript.mjs'
22
22
  import { loadInstructions } from '../lib/instructions.mjs'
23
- import { discoverOllamaModels, formatModelList } from '../lib/modelDiscovery.mjs'
23
+ import { discoverModelsForProvider, formatModelList } from '../lib/modelDiscovery.mjs'
24
24
  import { compactConversation } from '../lib/compact.mjs'
25
25
  import { createTeam, getTeam, listTeams, deleteTeam } from '../lib/team.mjs'
26
26
  import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
@@ -259,25 +259,26 @@ Session:
259
259
  app.addSystemMessage('No provider configured. Use /model to set a provider first.')
260
260
  return
261
261
  }
262
-
262
+
263
263
  if (!supportsDiscovery(provider)) {
264
- app.addSystemMessage(`Model discovery is not supported for provider: ${provider}\n\nProviders with discovery support:\n ollama\n\nFor other providers, use /model <provider>/<model-name> directly.`)
264
+ app.addSystemMessage(`Model discovery is not supported for provider: ${provider}\n\nUse /model to configure a discoverable provider.`)
265
265
  return
266
266
  }
267
-
268
- // Get base URL for the provider
267
+
269
268
  const baseUrl = getBaseUrlForProvider(provider, settings)
270
-
269
+ const apiKey = settings.model_config?.api_key
270
+ || (provider === 'zhipu' ? resolveZhipuApiKey() : resolveOpenAiCompatKey())
271
+
271
272
  app.addSystemMessage(`Discovering models from ${baseUrl}...`)
272
-
273
+
273
274
  try {
274
- const result = await discoverOllamaModels(baseUrl, { timeout: 5000 })
275
-
275
+ const result = await discoverModelsForProvider(provider, { baseUrl, apiKey, timeout: 5000 })
276
+
276
277
  if (!result.success) {
277
- app.addSystemMessage(`\nDiscovery failed: ${result.error}\n\nTroubleshooting:\n • Make sure Ollama is running: ollama serve\n • Check the base URL in settings: /config\n • Verify network connectivity to ${baseUrl}`)
278
+ app.addSystemMessage(`\nDiscovery failed: ${result.error}\n\nTroubleshooting:\n • Check the API key (/key)\n • Check the base URL in settings: /config\n • Verify network connectivity to ${baseUrl}`)
278
279
  return
279
280
  }
280
-
281
+
281
282
  const formatted = formatModelList(result.models)
282
283
  app.addSystemMessage(`\n${formatted}`)
283
284
  } catch (error) {
@@ -842,32 +843,31 @@ async function mainPipe(opts) {
842
843
  io.println(ui.colors.error(' No provider configured. Use /model to set a provider first.'))
843
844
  continue
844
845
  }
845
-
846
+
846
847
  if (!supportsDiscovery(provider)) {
847
848
  io.println(ui.colors.error(` Model discovery is not supported for provider: ${provider}`))
848
- io.println(`\n ${ui.colors.header('Providers with discovery support:')}`)
849
- io.println(' ollama')
850
- io.println(`\n ${ui.colors.dim('For other providers, use /model <provider>/<model-name> directly.')}`)
849
+ io.println(`\n ${ui.colors.dim('Use /model to configure a discoverable provider.')}`)
851
850
  continue
852
851
  }
853
-
854
- // Get base URL for the provider
852
+
855
853
  const baseUrl = getBaseUrlForProvider(provider, settings)
856
-
854
+ const apiKey = settings.model_config?.api_key
855
+ || (provider === 'zhipu' ? resolveZhipuApiKey() : resolveOpenAiCompatKey())
856
+
857
857
  io.println(`\n Discovering models from ${baseUrl}...\n`)
858
-
858
+
859
859
  try {
860
- const result = await discoverOllamaModels(baseUrl, { timeout: 5000 })
861
-
860
+ const result = await discoverModelsForProvider(provider, { baseUrl, apiKey, timeout: 5000 })
861
+
862
862
  if (!result.success) {
863
863
  io.println(ui.colors.error(` Discovery failed: ${result.error}`))
864
864
  io.println(`\n ${ui.colors.header('Troubleshooting:')}`)
865
- io.println(' • Make sure Ollama is running: ollama serve')
865
+ io.println(' • Check the API key (/key)')
866
866
  io.println(' • Check the base URL in settings: /config')
867
867
  io.println(` • Verify network connectivity to ${baseUrl}\n`)
868
868
  continue
869
869
  }
870
-
870
+
871
871
  const formatted = formatModelList(result.models)
872
872
  io.println(formatted)
873
873
  } catch (error) {
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url'
18
18
  import { runUserTurn } from '../lib/orchestrate.mjs'
19
19
  import { loadInstructions } from '../lib/instructions.mjs'
20
20
  import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
21
+ import { runConfigureWizard } from '../lib/configureProvider.mjs'
21
22
  import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
22
23
  import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
23
24
  import { invalidateMcpToolCache } from '../lib/tools.mjs'
@@ -137,8 +138,8 @@ function renderMd(text) {
137
138
  // ── Constants ────────────────────────────────────────────────
138
139
  const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
139
140
  const SLASH_COMMANDS = [
140
- { name: '/model', desc: 'Change model endpoint' },
141
- { name: '/key', desc: 'Set API key (/key, /key openrouter, /key zhipu, /key openai)' },
141
+ { name: '/model', desc: 'Configure provider, key, host, and model (single wizard)' },
142
+ { name: '/key', desc: 'Set API key then continue configure (/key openrouter|zhipu|openai)' },
142
143
  { name: '/config', desc: 'Show current config' },
143
144
  { name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
144
145
  { name: '/clear', desc: 'New conversation' },
@@ -147,41 +148,6 @@ const SLASH_COMMANDS = [
147
148
  { name: '/exit', desc: 'Quit' },
148
149
  ]
149
150
 
150
- // ── Model registry ────────────────────────────────────────────
151
- const DEFAULT_REGISTRY = {
152
- ollama_hosts: ['http://AsobaCorp-1.local:11434'],
153
- models: [
154
- { name: 'nehanda-rag-synthesis-27b', endpoint: 'https://nehanda-ml.asoba.co/v1', label: 'Nehanda 27B (EC2)', desc: 'Primary — fine-tuned Qwen3.6 27B, vLLM, af-south-1' },
155
- { name: 'deepseek-coder-v2:latest', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'DeepSeek Coder V2 (LAN)', desc: 'Windows LAN delegate — coding specialist' },
156
- { name: 'qwen2.5:14b', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'Qwen 2.5 14B (LAN)', desc: 'Windows LAN delegate — general reasoning' },
157
- ],
158
- }
159
- function readRegistry() {
160
- try { return JSON.parse(fs.readFileSync(path.join(AIMEE_DIR, 'model-registry.json'), 'utf8')) }
161
- catch { return DEFAULT_REGISTRY }
162
- }
163
- async function discoverOllamaModels(hosts) {
164
- const out = []
165
- await Promise.all(hosts.map(async host => {
166
- const base = host.replace(/\/$/, '')
167
- try {
168
- const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(2000) })
169
- if (!res.ok) return
170
- const data = await res.json()
171
- for (const m of (data.models || []))
172
- out.push({ name: m.name, endpoint: `${base}/v1`, label: `${m.name} (${new URL(base).hostname})`, desc: `Discovered — ${m.name}`, discovered: true })
173
- } catch {}
174
- }))
175
- return out
176
- }
177
- async function buildModelList() {
178
- const reg = readRegistry()
179
- const base = reg.models || []
180
- const disc = await discoverOllamaModels(reg.ollama_hosts || [])
181
- const keys = new Set(base.map(m => `${m.name}|${m.endpoint}`))
182
- return [...base, ...disc.filter(m => !keys.has(`${m.name}|${m.endpoint}`))]
183
- }
184
-
185
151
  // ── Asoba lock-up (compact) ───────────────────────────────────
186
152
  function AsobaMark() {
187
153
  return e(Box, { flexDirection: 'column', alignItems: 'center' },
@@ -390,6 +356,7 @@ function BlockConfirmMenu({ payload, onDecide }) {
390
356
  }
391
357
 
392
358
  function TokenFooter({ calls, model }) {
359
+ const cols = process.stdout.columns || 80
393
360
  return e(Box, { flexDirection: 'column' },
394
361
  e(Text, { dimColor: true }, '─'.repeat(cols)),
395
362
  e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
@@ -517,6 +484,58 @@ function stripThink(text) {
517
484
  .trim()
518
485
  }
519
486
 
487
+ /**
488
+ * UI adapter for Goose-style configure wizard (#8).
489
+ * Persists agents.json + runtimeSettings.model_config atomically at the end.
490
+ */
491
+ async function runUiConfigureWizard(bridge, opts = {}) {
492
+ try {
493
+ const result = await runConfigureWizard({
494
+ ask: q => bridge.ask(q),
495
+ select: (title, items) => bridge.select(title, items),
496
+ notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
497
+ resolveExistingKey: async (preset) => {
498
+ if (preset.keySlot === 'nehanda') return readApiKey()
499
+ if (preset.keySlot === 'zhipu') return resolveZhipuApiKey()
500
+ if (preset.keySlot === 'openai_compat') return resolveOpenAiCompatKey()
501
+ return ''
502
+ },
503
+ saveKey: async ({ slot, key }) => {
504
+ if (slot === 'nehanda') writeApiKey(key)
505
+ else if (slot === 'zhipu') saveSecureCredentials({ zhipuApiKey: key })
506
+ else if (slot === 'openai_compat') saveOpenAiCompatKey(key)
507
+ },
508
+ persist: async (cfg) => {
509
+ const data = readAgents()
510
+ const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
511
+ if (ag) {
512
+ ag.model = cfg.model
513
+ ag.endpoint = cfg.endpoint
514
+ if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
515
+ }
516
+ fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
517
+ runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
518
+ { model: cfg.model, endpoint: cfg.endpoint },
519
+ { apiKey: cfg.apiKey || readApiKey() },
520
+ )
521
+ },
522
+ }, opts)
523
+
524
+ if (!result) {
525
+ bridge.addMessage({ role: 'system', text: ' Configure cancelled.' })
526
+ return
527
+ }
528
+ bridge.refreshModel()
529
+ bridge.addMessage({ role: 'system', text: [
530
+ ` ${chalk.green('✓')} Provider → ${chalk.bold(result.provider)}`,
531
+ ` ${chalk.green('✓')} Host → ${chalk.dim(result.endpoint)}`,
532
+ ` ${chalk.green('✓')} Model → ${chalk.bold(result.model)}`,
533
+ ].join('\n') })
534
+ } catch (err) {
535
+ bridge.addMessage({ role: 'error', text: 'Configure failed: ' + err.message })
536
+ }
537
+ }
538
+
520
539
  // ── Command handler ───────────────────────────────────────────
521
540
  async function handleCommand(cmd, bridge) {
522
541
  const [name] = cmd.split(/\s+/)
@@ -529,14 +548,17 @@ async function handleCommand(cmd, bridge) {
529
548
  if (name === '/config') {
530
549
  const data = readAgents()
531
550
  const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
551
+ const mc = runtimeSettings?.model_config
532
552
  const compatKeySet = !!resolveOpenAiCompatKey()
533
553
  const zhipuKeySet = !!resolveZhipuApiKey()
534
554
  bridge.addMessage({ role: 'system', text: [
535
- '', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
536
- ` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
555
+ '', ` Provider: ${chalk.hex(BRAND.accent)(mc?.provider || '—')}`,
556
+ ` Model: ${chalk.hex(BRAND.accent)(ag?.model || mc?.custom_model_name || '—')}`,
557
+ ` Endpoint: ${chalk.dim(ag?.endpoint || mc?.base_url || '—')}`,
537
558
  ` Nehanda key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`,
538
559
  ` OpenAI-compat key: ${compatKeySet ? chalk.green('set') : chalk.dim('not set')}`,
539
- ` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`, '',
560
+ ` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`,
561
+ ` ${chalk.dim('Use /model to configure provider, key, host, and model in one flow.')}`, '',
540
562
  ].join('\n') })
541
563
  return
542
564
  }
@@ -552,74 +574,43 @@ async function handleCommand(cmd, bridge) {
552
574
  }
553
575
  if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
554
576
  if (name === '/key') {
555
- // Parse optional provider subcommand: /key [openrouter|zhipu|openai] [value]
577
+ // /key [openrouter|zhipu|openai|nehanda] [value] — save key then continue configure wizard
556
578
  const parts = cmd.trim().split(/\s+/)
557
- const sub = parts[1] || ''
558
- const KNOWN_COMPAT_PROVIDERS = ['openrouter', 'openai', 'zhipu']
579
+ const sub = (parts[1] || '').toLowerCase()
580
+ const KEY_TO_PRESET = {
581
+ openrouter: 'openrouter',
582
+ openai: 'openai_compatible',
583
+ zhipu: 'zhipu',
584
+ nehanda: 'nehanda',
585
+ }
559
586
 
560
- if (!sub || !KNOWN_COMPAT_PROVIDERS.includes(sub.toLowerCase())) {
561
- // Default: Nehanda API key (existing behaviour, unchanged)
587
+ if (!sub || !KEY_TO_PRESET[sub]) {
562
588
  const answer = await bridge.ask('New Nehanda API key:')
563
589
  if (!answer) return
564
- try { writeApiKey(answer); bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Nehanda API key updated.` }) }
565
- catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
590
+ try {
591
+ writeApiKey(answer)
592
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Nehanda API key saved — continuing configure…` })
593
+ await runUiConfigureWizard(bridge, { presetId: 'nehanda', prefilledKey: answer })
594
+ } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
566
595
  return
567
596
  }
568
597
 
569
- const provider = sub.toLowerCase()
570
- // Value may be provided inline (/key openrouter sk-...) or prompted
571
- const inlineValue = parts.slice(2).join('').trim()
572
- const answer = inlineValue || await bridge.ask(`New ${provider} API key:`)
598
+ const presetId = KEY_TO_PRESET[sub]
599
+ const inlineValue = parts.slice(2).join(' ').trim()
600
+ const answer = inlineValue || await bridge.ask(`New ${sub} API key:`)
573
601
  if (!answer) return
574
602
 
575
603
  try {
576
- if (provider === 'zhipu') {
577
- saveSecureCredentials({ zhipuApiKey: answer })
578
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Zhipu API key saved to secure storage.` })
579
- } else {
580
- // openrouter or openai — both use the openaiCompatApiKey slot
581
- saveOpenAiCompatKey(answer)
582
- if (provider === 'openrouter') {
583
- // Auto-configure base_url and provider for OpenRouter
584
- runtimeSettings.model_config = {
585
- ...runtimeSettings.model_config,
586
- base_url: 'https://openrouter.ai/api/v1',
587
- provider: 'openai_compatible',
588
- }
589
- bridge.addMessage({ role: 'system', text: [
590
- ` ${chalk.green('✓')} OpenRouter API key saved to secure storage.`,
591
- ` ${chalk.green('✓')} Base URL set to ${chalk.dim('https://openrouter.ai/api/v1')}`,
592
- ` ${chalk.dim('Use /model to select an OpenRouter model (e.g. openai_compatible/anthropic/claude-3.5-sonnet)')}`,
593
- ].join('\n') })
594
- } else {
595
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} OpenAI-compat API key saved to secure storage.` })
596
- }
597
- }
604
+ if (sub === 'zhipu') saveSecureCredentials({ zhipuApiKey: answer })
605
+ else if (sub === 'nehanda') writeApiKey(answer)
606
+ else saveOpenAiCompatKey(answer)
607
+ bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} ${sub} API key saved — continuing configure…` })
608
+ await runUiConfigureWizard(bridge, { presetId, prefilledKey: answer })
598
609
  } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
599
610
  return
600
611
  }
601
612
  if (name === '/model') {
602
- bridge.addMessage({ role: 'system', text: ' Discovering models…' })
603
- const all = await buildModelList()
604
- const data0 = readAgents()
605
- const curAg = data0.agents?.find(a => a.name === data0.default_agent) || data0.agents?.[0]
606
- const cur = curAg?.model || ''
607
- const items = all.map(m => ({ ...m, label: m.name === cur ? `${m.label} ✓` : m.label }))
608
- const sel = await bridge.select('Select model', items)
609
- if (!sel) return
610
- try {
611
- const data = readAgents()
612
- const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
613
- if (ag) { ag.model = sel.name; ag.endpoint = sel.endpoint }
614
- fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
615
- // Keep in-session model_config in sync so the next turn uses the selected wire name
616
- runtimeSettings.model_config = modelConfigFromAgent(
617
- ag || { model: sel.name, endpoint: sel.endpoint },
618
- { apiKey: readApiKey() },
619
- )
620
- bridge.refreshModel()
621
- bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Model → ${chalk.bold(sel.name)}` })
622
- } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
613
+ await runUiConfigureWizard(bridge)
623
614
  return
624
615
  }
625
616
  if (name === '/mcp') {
@@ -862,6 +853,8 @@ bridge.onSubmit = async (text) => {
862
853
  // field belongs to aimee and was silently reintroducing conflicting tool-call
863
854
  // syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
864
855
  onaInstructions: onaInstructionsContent || null,
856
+ // Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
857
+ signal: controller.signal,
865
858
  settings: {
866
859
  ...runtimeSettings,
867
860
  model_config: modelConfig,
@@ -914,7 +907,7 @@ bridge.onSubmit = async (text) => {
914
907
  bridge.lastFailedText = null
915
908
 
916
909
  } catch (err) {
917
- if (err.name === 'AbortError') {
910
+ if (err.name === 'AbortError' || err.name === 'APIUserAbortError') {
918
911
  bridge.endAssistant({ role: 'assistant', text: lastPartial, cancelled: true })
919
912
  } else {
920
913
  bridge.discardAssistant()
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Goose-style provider configure wizard (#8).
3
+ * Single flow: provider → API key → host → keyed model discovery → persist.
4
+ */
5
+
6
+ import {
7
+ discoverOpenAiCompatModels,
8
+ discoverOllamaModels,
9
+ } from './modelDiscovery.mjs'
10
+
11
+ /** @typedef {{ id: string, label: string, provider: string, defaultHost: string, needsKey: boolean, keySlot: string|null, discovery: 'openai'|'ollama' }} ProviderPreset */
12
+
13
+ /** @type {ProviderPreset[]} */
14
+ export const PROVIDER_PRESETS = [
15
+ {
16
+ id: 'nehanda',
17
+ label: 'Nehanda Cloud',
18
+ provider: 'nehanda',
19
+ defaultHost: 'https://nehanda-ml.asoba.co/v1',
20
+ needsKey: true,
21
+ keySlot: 'nehanda',
22
+ discovery: 'openai',
23
+ },
24
+ {
25
+ id: 'openrouter',
26
+ label: 'OpenRouter',
27
+ provider: 'openai_compatible',
28
+ defaultHost: 'https://openrouter.ai/api/v1',
29
+ needsKey: true,
30
+ keySlot: 'openai_compat',
31
+ discovery: 'openai',
32
+ },
33
+ {
34
+ id: 'openai_compatible',
35
+ label: 'OpenAI-compatible',
36
+ provider: 'openai_compatible',
37
+ defaultHost: 'https://api.openai.com/v1',
38
+ needsKey: true,
39
+ keySlot: 'openai_compat',
40
+ discovery: 'openai',
41
+ },
42
+ {
43
+ id: 'zhipu',
44
+ label: 'Zhipu (z.ai)',
45
+ provider: 'zhipu',
46
+ defaultHost: 'https://open.bigmodel.cn/api/paas/v4',
47
+ needsKey: true,
48
+ keySlot: 'zhipu',
49
+ discovery: 'openai',
50
+ },
51
+ {
52
+ id: 'ollama',
53
+ label: 'Ollama (local / LAN)',
54
+ provider: 'ollama',
55
+ defaultHost: 'http://localhost:11434',
56
+ needsKey: false,
57
+ keySlot: null,
58
+ discovery: 'ollama',
59
+ },
60
+ {
61
+ id: 'lm_studio',
62
+ label: 'LM Studio',
63
+ provider: 'lm_studio_local',
64
+ defaultHost: 'http://localhost:1234/v1',
65
+ needsKey: false,
66
+ keySlot: 'openai_compat',
67
+ discovery: 'openai',
68
+ },
69
+ ]
70
+
71
+ export function normalizeOpenAiBaseUrl(host) {
72
+ const h = String(host || '').trim().replace(/\/+$/, '')
73
+ if (!h) return ''
74
+ return h
75
+ }
76
+
77
+ /** Ollama tags API wants host without trailing /v1. */
78
+ export function normalizeOllamaHost(host) {
79
+ return String(host || '').trim().replace(/\/+$/, '').replace(/\/v1$/i, '')
80
+ }
81
+
82
+ /** Persist endpoint for agents.json — OpenAI-compat style /v1 for Ollama. */
83
+ export function endpointForAgent(preset, host) {
84
+ if (preset.discovery === 'ollama') {
85
+ const base = normalizeOllamaHost(host)
86
+ return base ? `${base}/v1` : ''
87
+ }
88
+ return normalizeOpenAiBaseUrl(host)
89
+ }
90
+
91
+ /**
92
+ * Discover models for a preset using host + optional API key.
93
+ * @param {ProviderPreset} preset
94
+ * @param {{ host: string, apiKey?: string, timeout?: number }} opts
95
+ */
96
+ export async function discoverModelsForPreset(preset, opts = {}) {
97
+ const timeout = opts.timeout || 5000
98
+ if (preset.discovery === 'ollama') {
99
+ const host = normalizeOllamaHost(opts.host)
100
+ return discoverOllamaModels(host, { timeout })
101
+ }
102
+ const host = normalizeOpenAiBaseUrl(opts.host)
103
+ return discoverOpenAiCompatModels(host, { apiKey: opts.apiKey || '', timeout })
104
+ }
105
+
106
+ /**
107
+ * Interactive configure wizard.
108
+ *
109
+ * @param {object} deps
110
+ * @param {(q: string) => Promise<string>} deps.ask
111
+ * @param {(title: string, items: object[]) => Promise<object|null>} deps.select
112
+ * @param {(msg: string) => void} [deps.notify]
113
+ * @param {(ctx: {preset, host, apiKey}) => Promise<{success, models?, error?}>} [deps.discover]
114
+ * @param {(args: {slot: string, key: string}) => Promise<void>} [deps.saveKey]
115
+ * @param {(preset: ProviderPreset) => Promise<string>} [deps.resolveExistingKey]
116
+ * @param {(cfg: object) => Promise<void>} [deps.persist]
117
+ * @param {{ presetId?: string, prefilledKey?: string }} [opts]
118
+ * @returns {Promise<object|null>} configured result or null if cancelled
119
+ */
120
+ export async function runConfigureWizard(deps, opts = {}) {
121
+ const ask = deps.ask
122
+ const select = deps.select
123
+ const notify = deps.notify || (() => {})
124
+ const discover = deps.discover || ((ctx) => discoverModelsForPreset(ctx.preset, ctx))
125
+ const saveKey = deps.saveKey || (async () => {})
126
+ const resolveExistingKey = deps.resolveExistingKey || (async () => '')
127
+ const persist = deps.persist || (async () => {})
128
+
129
+ let preset
130
+ if (opts.presetId) {
131
+ preset = PROVIDER_PRESETS.find(p => p.id === opts.presetId)
132
+ if (!preset) throw new Error(`Unknown provider preset: ${opts.presetId}`)
133
+ } else {
134
+ const items = PROVIDER_PRESETS.map(p => ({
135
+ id: p.id,
136
+ label: p.label,
137
+ desc: p.defaultHost,
138
+ ...p,
139
+ }))
140
+ const picked = await select('Which provider?', items)
141
+ if (!picked) return null
142
+ preset = PROVIDER_PRESETS.find(p => p.id === picked.id) || picked
143
+ }
144
+
145
+ // ── API key ────────────────────────────────────────────────
146
+ let apiKey = typeof opts.prefilledKey === 'string' ? opts.prefilledKey.trim() : ''
147
+ let keyDirty = false
148
+
149
+ if (preset.needsKey && !apiKey) {
150
+ const existing = (await resolveExistingKey(preset)) || ''
151
+ if (existing) {
152
+ const choice = await select('Update API key?', [
153
+ { id: 'keep', label: 'Keep existing key', desc: 'Use the key already in secure storage' },
154
+ { id: 'replace', label: 'Replace key', desc: 'Enter a new API key' },
155
+ ])
156
+ if (!choice) return null
157
+ if (choice.id === 'replace') {
158
+ const entered = await ask(`${preset.label} API key:`)
159
+ if (!entered) return null
160
+ apiKey = entered.trim()
161
+ keyDirty = true
162
+ } else {
163
+ apiKey = existing
164
+ }
165
+ } else {
166
+ const entered = await ask(`${preset.label} API key:`)
167
+ if (!entered) return null
168
+ apiKey = entered.trim()
169
+ keyDirty = true
170
+ }
171
+ } else if (preset.needsKey && apiKey && opts.prefilledKey) {
172
+ // Key already saved by /key — do not re-save
173
+ keyDirty = false
174
+ }
175
+
176
+ // ── Host ───────────────────────────────────────────────────
177
+ const hostPrompt = preset.discovery === 'ollama'
178
+ ? `Ollama host [${preset.defaultHost}]:`
179
+ : `API host [${preset.defaultHost}]:`
180
+ const hostAnswer = await ask(hostPrompt)
181
+ const host = (hostAnswer && hostAnswer.trim()) || preset.defaultHost
182
+ if (!host) return null
183
+
184
+ const endpoint = endpointForAgent(preset, host)
185
+ if (!endpoint) return null
186
+
187
+ // ── Discovery ──────────────────────────────────────────────
188
+ notify('Fetching models from provider…')
189
+ let discovered = []
190
+ let discoveryError = null
191
+ try {
192
+ const result = await discover({
193
+ preset,
194
+ host: preset.discovery === 'ollama' ? normalizeOllamaHost(host) : normalizeOpenAiBaseUrl(host),
195
+ apiKey,
196
+ })
197
+ if (result?.success && Array.isArray(result.models)) {
198
+ discovered = result.models
199
+ } else {
200
+ discoveryError = result?.error || 'Discovery failed'
201
+ notify(`Discovery failed: ${discoveryError}`)
202
+ }
203
+ } catch (err) {
204
+ discoveryError = err.message
205
+ notify(`Discovery failed: ${discoveryError}`)
206
+ }
207
+
208
+ const modelItems = discovered.map(m => ({
209
+ id: m.name,
210
+ name: m.name,
211
+ label: m.name,
212
+ desc: 'Discovered',
213
+ endpoint,
214
+ }))
215
+ modelItems.push({
216
+ id: '__custom__',
217
+ name: '__custom__',
218
+ label: '(enter custom name…)',
219
+ desc: discoveryError ? 'Discovery unavailable — type a model id' : 'Type a model id not listed above',
220
+ endpoint,
221
+ })
222
+
223
+ const modelPick = await select('Select model', modelItems)
224
+ if (!modelPick) return null
225
+
226
+ let modelName = modelPick.name
227
+ if (modelPick.id === '__custom__' || modelName === '__custom__') {
228
+ const custom = await ask('Custom model name:')
229
+ if (!custom || !custom.trim()) return null
230
+ modelName = custom.trim()
231
+ }
232
+
233
+ if (keyDirty && preset.keySlot && apiKey) {
234
+ await saveKey({ slot: preset.keySlot, key: apiKey })
235
+ }
236
+
237
+ const cfg = {
238
+ presetId: preset.id,
239
+ provider: preset.provider,
240
+ model: modelName,
241
+ endpoint,
242
+ apiKey: preset.needsKey ? apiKey : (preset.provider === 'ollama' ? 'ollama' : apiKey),
243
+ model_config: {
244
+ provider: preset.provider,
245
+ model_id: modelName,
246
+ custom_model_name: modelName,
247
+ base_url: endpoint,
248
+ api_key: preset.provider === 'ollama' ? 'ollama' : apiKey,
249
+ },
250
+ }
251
+
252
+ await persist(cfg)
253
+ return cfg
254
+ }
@@ -38,12 +38,14 @@ const PROVIDER_CAPABILITIES = {
38
38
  openai_compatible: {
39
39
  supportsCustomModels: true,
40
40
  requiresHardcodedMap: false,
41
- supportsDiscovery: false,
41
+ supportsDiscovery: true,
42
+ discoveryEndpoint: '/models',
42
43
  },
43
44
  zhipu: {
44
- supportsCustomModels: false,
45
- requiresHardcodedMap: true,
46
- supportsDiscovery: false,
45
+ supportsCustomModels: true,
46
+ requiresHardcodedMap: false,
47
+ supportsDiscovery: true,
48
+ discoveryEndpoint: '/models',
47
49
  },
48
50
  ollama: {
49
51
  supportsCustomModels: true,
@@ -54,12 +56,14 @@ const PROVIDER_CAPABILITIES = {
54
56
  lm_studio_local: {
55
57
  supportsCustomModels: true,
56
58
  requiresHardcodedMap: false,
57
- supportsDiscovery: false,
59
+ supportsDiscovery: true,
60
+ discoveryEndpoint: '/models',
58
61
  },
59
62
  nehanda: {
60
- supportsCustomModels: false,
61
- requiresHardcodedMap: true,
62
- supportsDiscovery: false,
63
+ supportsCustomModels: true,
64
+ requiresHardcodedMap: false,
65
+ supportsDiscovery: true,
66
+ discoveryEndpoint: '/models',
63
67
  omitToolChoice: true,
64
68
  },
65
69
  }
@@ -118,16 +122,22 @@ export function modelConfigFromAgent(agent, { apiKey = '', defaultEndpoint = DEF
118
122
  const isNehanda = model === DEFAULT_NEHANDA_MODEL || /nehanda-ml\.asoba\.co/.test(endpoint)
119
123
 
120
124
  if (isNehanda) {
125
+ const isDefault = model === DEFAULT_NEHANDA_MODEL
121
126
  return {
122
127
  provider: 'nehanda',
123
128
  model_id: 'nehanda_rag_synthesis_27b',
124
- custom_model_name: null,
129
+ // Discovered / non-default names ride as custom_model_name (#8)
130
+ custom_model_name: isDefault ? null : model,
125
131
  base_url: endpoint,
126
132
  api_key: apiKey,
127
133
  }
128
134
  }
129
135
 
130
- const provider = /:11434\b/.test(endpoint) ? 'ollama' : 'openai_compatible'
136
+ let provider = 'openai_compatible'
137
+ if (/:11434\b/.test(endpoint)) provider = 'ollama'
138
+ else if (/bigmodel\.cn|\.z\.ai\b/.test(endpoint)) provider = 'zhipu'
139
+ else if (/:1234\b|:8000\b/.test(endpoint)) provider = 'lm_studio_local'
140
+
131
141
  return {
132
142
  provider,
133
143
  model_id: model,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Model Discovery Service
3
- * Queries Ollama API to discover available models
3
+ * Queries Ollama (/api/tags) and OpenAI-compatible (/v1/models) endpoints.
4
4
  */
5
5
 
6
6
  import http from 'node:http'
@@ -232,6 +232,130 @@ export async function discoverOllamaModels(baseUrl, options = {}) {
232
232
  }
233
233
  }
234
234
 
235
+ /**
236
+ * Normalize an OpenAI-compatible base URL so `{base}/models` resolves correctly.
237
+ * Accepts either `https://host/v1` or `https://host`.
238
+ */
239
+ export function normalizeOpenAiCompatBase(baseUrl) {
240
+ if (!baseUrl || typeof baseUrl !== 'string') return ''
241
+ // Do not force /v1 — Zhipu and similar hosts use paths like /api/paas/v4.
242
+ return baseUrl.trim().replace(/\/+$/, '')
243
+ }
244
+
245
+ /**
246
+ * Strip trailing /v1 so Ollama `/api/tags` is hit on the root host.
247
+ */
248
+ export function normalizeOllamaBase(baseUrl) {
249
+ if (!baseUrl || typeof baseUrl !== 'string') return ''
250
+ return baseUrl.trim().replace(/\/+$/, '').replace(/\/v1$/i, '')
251
+ }
252
+
253
+ function isEmbeddingModelId(id) {
254
+ return /embedding|whisper|tts|dall-e|moderation/i.test(id)
255
+ }
256
+
257
+ /**
258
+ * Discover models from an OpenAI-compatible `/v1/models` endpoint (keyed).
259
+ * @param {string} baseUrl - e.g. https://openrouter.ai/api/v1
260
+ * @param {Object} options
261
+ * @param {string} [options.apiKey] - Bearer token (optional for local servers)
262
+ * @param {number} [options.timeout=5000]
263
+ * @returns {Promise<{success:boolean, models?:Array<{name:string}>, error?:string}>}
264
+ */
265
+ export async function discoverOpenAiCompatModels(baseUrl, options = {}) {
266
+ const timeout = options.timeout || 5000
267
+ const apiKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''
268
+ const cleanBase = normalizeOpenAiCompatBase(baseUrl)
269
+ if (!cleanBase) {
270
+ return { success: false, error: 'Base URL is required for model discovery.' }
271
+ }
272
+ const discoveryUrl = `${cleanBase}/models`
273
+
274
+ try {
275
+ const controller = new AbortController()
276
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
277
+ let response
278
+ try {
279
+ const headers = { Accept: 'application/json' }
280
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
281
+ response = await fetch(discoveryUrl, { method: 'GET', signal: controller.signal, headers })
282
+ } finally {
283
+ clearTimeout(timeoutId)
284
+ }
285
+
286
+ if (!response.ok) {
287
+ const statusText = response.statusText || 'Unknown error'
288
+ if (response.status === 401 || response.status === 403) {
289
+ return {
290
+ success: false,
291
+ error: `Authentication failed (${response.status}): ${statusText}. Check the API key.`,
292
+ }
293
+ }
294
+ return {
295
+ success: false,
296
+ error: `HTTP error (${response.status}): ${statusText}`,
297
+ }
298
+ }
299
+
300
+ let data
301
+ try {
302
+ data = await response.json()
303
+ } catch (parseError) {
304
+ return { success: false, error: `Invalid JSON response from server: ${parseError.message}` }
305
+ }
306
+
307
+ const rows = Array.isArray(data?.data) ? data.data
308
+ : Array.isArray(data?.models) ? data.models
309
+ : null
310
+ if (!rows) {
311
+ return { success: false, error: 'Invalid response format: expected OpenAI-style { data: [...] }' }
312
+ }
313
+
314
+ const models = []
315
+ const seen = new Set()
316
+ for (const row of rows) {
317
+ const id = typeof row?.id === 'string' ? row.id.trim()
318
+ : typeof row?.name === 'string' ? row.name.trim()
319
+ : ''
320
+ if (!id || isEmbeddingModelId(id) || seen.has(id)) continue
321
+ seen.add(id)
322
+ models.push({ name: id })
323
+ }
324
+
325
+ return { success: true, models }
326
+ } catch (error) {
327
+ if (error.name === 'AbortError') {
328
+ return {
329
+ success: false,
330
+ error: `Request timed out after ${timeout}ms. The server may be slow or unreachable.`,
331
+ }
332
+ }
333
+ if (error.code === 'ECONNREFUSED' || (error.message && error.message.includes('fetch failed'))) {
334
+ return {
335
+ success: false,
336
+ error: `Cannot connect to ${cleanBase}. Check the host and network.`,
337
+ }
338
+ }
339
+ return { success: false, error: `Discovery failed: ${error.message}` }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Route discovery by provider id (used by bin/agent.mjs `/models`).
345
+ * @param {string} provider
346
+ * @param {{ baseUrl: string, apiKey?: string, timeout?: number }} opts
347
+ */
348
+ export async function discoverModelsForProvider(provider, opts = {}) {
349
+ const timeout = opts.timeout || 5000
350
+ if (provider === 'ollama') {
351
+ return discoverOllamaModels(normalizeOllamaBase(opts.baseUrl), { timeout })
352
+ }
353
+ return discoverOpenAiCompatModels(opts.baseUrl, {
354
+ apiKey: opts.apiKey || '',
355
+ timeout,
356
+ })
357
+ }
358
+
235
359
  /**
236
360
  * Format a list of models for display
237
361
  * @param {Array} models - Array of model objects from discovery
@@ -28,7 +28,16 @@ function stripThinkingTraces(text) {
28
28
  return text.trim()
29
29
  }
30
30
 
31
- export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true }) {
31
+ /** True for DOM AbortError and Anthropic SDK APIUserAbortError. */
32
+ export function isAbortError(e) {
33
+ if (!e) return false
34
+ if (e.name === 'AbortError' || e.name === 'APIUserAbortError') return true
35
+ if (e.constructor?.name === 'APIUserAbortError') return true
36
+ const msg = String(e.message || '')
37
+ return msg === 'Request was aborted.' || /operation was aborted/i.test(msg)
38
+ }
39
+
40
+ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true, signal } = {}) {
32
41
  const url = `${String(baseUrl).replace(/\/$/, '')}/chat/completions`
33
42
  const body = { model, messages, stream: useStream, max_tokens: maxTokens }
34
43
  // Suppress tool_choice:"auto" for providers that do not support it (e.g. Nehanda
@@ -41,6 +50,7 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
41
50
  method: 'POST',
42
51
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
43
52
  body: JSON.stringify(body),
53
+ signal,
44
54
  })
45
55
 
46
56
  if (!res.ok) {
@@ -8,7 +8,7 @@ import { runHooks } from './hookplane.mjs'
8
8
  import { appendEntry, transcriptToAnthropicMessages, transcriptToOpenAIMessages, makeUserPayload, makeAssistantPayload, makeToolResultPayload, applyEpistemicFilter } from './transcript.mjs'
9
9
  import { evaluatePermission } from './permissions.mjs'
10
10
  import { executeBuiltinTool, anthropicToolDefinitions, openAICompatToolDefinitions, LOCAL_CORE_TOOLS, EXPLORE_ONLY_TOOLS, dynamicTools, getMcpToolDefinitions } from './tools.mjs'
11
- import { streamOpenAIChatCompletion } from './openaiCompat.mjs'
11
+ import { streamOpenAIChatCompletion, isAbortError } from './openaiCompat.mjs'
12
12
  import { withTransaction } from './store.mjs'
13
13
  import { getPhase, setPhase, canTransition } from './workflow.mjs'
14
14
  import { buildJevSystem2Messages } from './compact.mjs'
@@ -425,6 +425,16 @@ function classifyError(e) {
425
425
  return 'unknown'
426
426
  }
427
427
 
428
+ /** Re-throw user aborts so Esc is not swallowed as a soft model failure (issue #7). */
429
+ function rethrowIfAborted(e) {
430
+ if (!isAbortError(e)) return
431
+ if (e.name === 'AbortError') throw e
432
+ const err = new Error('abort')
433
+ err.name = 'AbortError'
434
+ err.cause = e
435
+ throw err
436
+ }
437
+
428
438
  async function askHuman(io, q) {
429
439
  if (!process.stdin.isTTY) return true
430
440
  if (io?.ask) return (await io.ask(q)).trim().toLowerCase().startsWith('y')
@@ -637,13 +647,14 @@ If this file is not relevant to the task, say "Not relevant" and nothing else.`
637
647
  model: rt._model,
638
648
  max_tokens: 512,
639
649
  messages: [{ role: 'user', content: prompt }],
640
- })
650
+ }, { signal: rt.signal })
641
651
  const summary = sanitizeModelText(response.content?.[0]?.text || '')
642
652
  trackTokens(rt.sessionId, response.usage?.input_tokens, response.usage?.output_tokens)
643
653
  if (summary && !/^not relevant/i.test(summary)) {
644
654
  summaries.push({ path, header, summary })
645
655
  }
646
- } catch {
656
+ } catch (e) {
657
+ rethrowIfAborted(e)
647
658
  summaries.push({ path, header, summary: '(summarization failed — see header)' })
648
659
  }
649
660
  if (io.spinner) io.spinner.stop()
@@ -681,13 +692,15 @@ If this file is not relevant to the task, say "Not relevant" and nothing else.`
681
692
  bufferOutput: true,
682
693
  maxTokens: 512,
683
694
  numCtx: rt.settings?.model_config?.num_ctx,
695
+ signal: rt.signal,
684
696
  })
685
697
  const summary = sanitizeModelText(out.bufferedText || '')
686
698
  if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
687
699
  if (summary && !/^not relevant/i.test(summary)) {
688
700
  summaries.push({ path, header, summary })
689
701
  }
690
- } catch {
702
+ } catch (e) {
703
+ rethrowIfAborted(e)
691
704
  summaries.push({ path, header, summary: '(summarization failed — see header)' })
692
705
  }
693
706
  if (io.spinner) io.spinner.stop()
@@ -829,7 +842,10 @@ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt
829
842
 
830
843
  let bufferedText = ''
831
844
  try {
832
- const stream = await client.messages.stream({ model: rt._model, max_tokens: 1024, system, messages, tools: [] })
845
+ const stream = await client.messages.stream(
846
+ { model: rt._model, max_tokens: 1024, system, messages, tools: [] },
847
+ { signal: rt.signal },
848
+ )
833
849
  for await (const ev of stream) {
834
850
  if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
835
851
  bufferedText += ev.delta.text
@@ -839,6 +855,7 @@ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt
839
855
  trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
840
856
  appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(mapAnthropicContent(final.content || [])))
841
857
  } catch (e) {
858
+ rethrowIfAborted(e)
842
859
  if (io.spinner) io.spinner.stop()
843
860
  return null
844
861
  }
@@ -857,11 +874,12 @@ async function runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl,
857
874
 
858
875
  let result = null
859
876
  try {
860
- const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx })
877
+ const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, signal: rt.signal })
861
878
  if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
862
879
  appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(out.assistantBlocks))
863
880
  result = sanitizeModelText(out.bufferedText)
864
- } catch {
881
+ } catch (e) {
882
+ rethrowIfAborted(e)
865
883
  }
866
884
  if (io.spinner) io.spinner.stop()
867
885
  return result
@@ -878,7 +896,10 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
878
896
 
879
897
  let assistantBlocks = [], stopReason = null, bufferedText = ''
880
898
  try {
881
- const stream = await client.messages.stream({ model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools })
899
+ const stream = await client.messages.stream(
900
+ { model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools },
901
+ { signal: rt.signal },
902
+ )
882
903
  for await (const ev of stream) {
883
904
  if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
884
905
  bufferedText += ev.delta.text
@@ -893,6 +914,7 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
893
914
  assistantBlocks = mapAnthropicContent(final.content || [])
894
915
  trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
895
916
  } catch (e) {
917
+ rethrowIfAborted(e)
896
918
  if (io.spinner) io.spinner.stop()
897
919
  await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
898
920
  io.println(`\n[model error] ${e.message}`)
@@ -942,7 +964,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
942
964
  // on top of the prompt-injected instructions, producing malformed hybrid output
943
965
  // like {"Glob", "arguments": {...}} instead of {"name": "Glob", "arguments": {...}}.
944
966
  const toolsForRequest = xmlInstructions ? [] : filteredTools
945
- 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) })
967
+ 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), signal: rt.signal })
946
968
  assistantBlocks = out.assistantBlocks
947
969
  bufferedText = out.bufferedText
948
970
  finishReason = out.finishReason
@@ -962,6 +984,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
962
984
 
963
985
  if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
964
986
  } catch (e) {
987
+ rethrowIfAborted(e)
965
988
  if (e.message?.includes('does not support tools') && activeTools.length > 0) {
966
989
  if (io.spinner) io.spinner.stop()
967
990
  io.println('\n[info] Model switching to manual tool injection...')
package/lib/ui.mjs CHANGED
@@ -120,12 +120,12 @@ export function formatHelp(provider, dbPath) {
120
120
  cmd('/compact', 'Compact conversation'),
121
121
  cmd('/team', 'Manage teams'),
122
122
  '',
123
- cmd('/model [name]', 'Change model'),
123
+ cmd('/model', 'Configure provider, key, host, model'),
124
+ cmd('/key [provider] [value]', 'Save API key then continue configure'),
124
125
  cmd('/login', 'Store credentials'),
125
126
  cmd('/logout', 'Clear credentials'),
126
127
  cmd('/status', 'Auth status'),
127
- cmd('/config', 'Show / set settings'),
128
- cmd('/config set <key> <value>', 'Set any config value'),
128
+ cmd('/config', 'Show provider / model / key status'),
129
129
  cmd('/mcp', 'MCP server management & guide'),
130
130
  cmd('/mcp list', 'List all discovered tools & descriptions'),
131
131
  cmd('/mcp status', 'Show configured MCP servers'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asobacloud/nehanda",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Agentic terminal REPL and single-process engine for governed AI deep research and software development. Connects to Nehanda v3, OpenRouter, Ollama, LM Studio, ZhipuAI, or any OpenAI-compatible API. Includes Laya System-1 control plane for memory management, pre-execution safety gating, and intent-driven instruction shaping.",
6
6
  "keywords": [