@asobacloud/nehanda 0.4.0 → 0.6.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
@@ -1,6 +1,6 @@
1
1
  # Nehanda Command-Line Interface (`nehanda-cli`)
2
2
 
3
- An agentic terminal REPL and single-process engine built for governed AI deep research and software development. `nehanda-cli` connects directly to our flagship [Nehanda v3](https://huggingface.co/asoba/nehanda-v3-27b), as well as local or cloud-based Ollama models, LM Studio instances, or any OpenAI-compatible API.
3
+ An agentic terminal REPL and single-process engine built for energy systems research and software development. `nehanda-cli` connects directly to our flagship [Nehanda v3](https://huggingface.co/asoba/nehanda-v3-27b), normalizes multi-OEM energy telemetry through the ODSE protocol, and enforces a governed development workflow across the full research-to-production lifecycle. It also connects to local or cloud-based Ollama models, LM Studio instances, or any OpenAI-compatible API.
4
4
 
5
5
  Every conversation turn, tool call, phase transition, and permission check is stored in a local, queryable SQLite database you own, ensuring complete transcripts exist for auditing and debugging.
6
6
 
@@ -83,12 +83,26 @@ To connect to an Ollama instance running on your network:
83
83
 
84
84
  * **In-Process Engine:** Executes turns directly inside the process via `runUserTurn`, eliminating separate server daemons or background HTTP relays.
85
85
 
86
- * **Jev-Mem Memory Architecture:** Every turn, a System-1 control plane ([Laya](https://huggingface.co/convaiinnovations/laya)) classifies observations into a multi-relational memory graph (semantic / temporal / causal / entity edges), then adaptively retrieves evidence before each model call. System-2 receives a compact canonical header + retrieved evidence + last 2 turns, not a raw transcript — cutting prefill 70–85% on long sessions. See [Jev-Mem Architecture](#jev-mem-architecture) below.
86
+ * **ODSE Energy Normalization:** `lib/energy-middleware.mjs` intercepts MCP tool results from energy data servers and automatically normalizes them to the ODS-E protocol before the model sees the payload. OEM brand is resolved from the MCP server name (e.g. `energy-huawei` → `huawei`) or inferred by field-signature fingerprinting across 20+ OEM schemas (Huawei FusionSolar, Enphase, SolarEdge, Fronius, Sungrow, SMA, Eskom, and others). Multi-channel SCADA arrays and inverter state records render as box-drawing terminal tables with column headers and row-count summaries rather than raw JSON dumps.
87
+
88
+ * **Jev-Mem Memory Architecture:** Every turn, a System-1 control plane ([Laya](https://huggingface.co/convaiinnovations/laya)) classifies observations into a multi-relational memory graph (semantic / temporal / causal / entity edges), then adaptively retrieves evidence before each model call. System-2 receives a compact canonical header + retrieved evidence + last 2 turns, not a raw transcript — cutting prefill 70–85% on long sessions. Domain invariants (ODSE schema mappings, asset capacity limits, simulation bounds) are pinned in the `pinned_context` table and reattached verbatim before every System-2 prompt, preventing model drift and hallucinated parameters across arbitrarily long research sessions. See [Jev-Mem Architecture](#jev-mem-architecture) below.
89
+
90
+ **Compaction entry scoring (Gap B, #4):** When the Jev-Mem compaction falls back to the prose summarizer, `scoreTranscriptEntries` scores old `tool_result` entries for relevance to the current task using Laya `scoreCandidate`. Entries with relevance < 0.25 are excluded from the distillation window. The last 6 entries are always kept. This operates on the fallback path only — memory-graph-level pruning (filtering `retrieveEvidence` output) requires a `session_prune_score` schema extension tracked in a follow-on issue; see `docs/MAP.md §Gap B`.
91
+
92
+ **Intent-driven instruction shaping (Gap C, #4):** Before each System-2 call in `idle`/`explore` phases, a 3-question Laya classification (`task_domain`, `task_scope`, `requires_domain_context`) runs on the user message. The result is injected as a `[TASK CONTEXT — System 1]` preamble into the system prompt, giving lower-parameter models (Nehanda 27B, DeepSeek Coder) precise task framing before generation begins rather than relying on them to infer intent from a generic prompt.
87
93
 
88
94
  * **Dynamic Tool Rescue (`[TOOL_CALL]`):** Native support for endpoints that strip OpenAI tool schemas (such as `nehandaMlProxy`). The engine dynamically injects active tool schemas directly into system prompts as `[TOOL_CALL]` blocks, parsing and executing tools locally without server-side function-calling support. Uses `[TOOL_CALL]` delimiters instead of `<tool_call>` XML to prevent vLLM's `--tool-call-parser qwen3_xml` stop-token interception.
89
95
 
90
96
  * **Deterministic SDLC Workflow:** Enforces a 6-phase state machine (`idle` → `plan` → `implement` → `test` → `verify` → `done`) to prevent unapproved code changes, hallucinated test passes, or unverified implementations.
91
97
 
98
+ * **Interactive Safety Interventions:** When execution checkers flag a command — opening local sockets, inspecting HIL testbed hardware paths, or writing outside the working directory — the engine raises a structured `{ status, reason, risk_score, proposed_remediation }` payload rather than crashing. In the TUI, blocked operations surface a `BlockConfirmMenu` (`[A] Approve for Session | [S] Run in Laya Sandbox | [C] Cancel`) wired directly into the Ink event loop. Session approvals are cached so the same operation is not re-prompted within a session. Headless pipe mode auto-denies without prompting.
99
+
100
+ **Laya second-stage gate (Gap A, #4):** For `medium-risk` blocked commands, a Laya `noul` question (`is_safe_to_auto_approve`) runs before raising `BlockConfirmMenu`. If Laya returns `≥ 0.85` confidence the command is benign, it is auto-approved and cached without operator prompt. `high-risk` commands always bypass Laya and go directly to `BlockConfirmMenu`. Degraded Laya falls through to existing behavior — no regression. Implementation uses a flag variable (`layaAutoApproved`) rather than extracting the bash execution path into a named helper; see `docs/MAP.md §Gap A` for rationale.
101
+
102
+ * **Runtime Visibility:** `RuntimeHealthMonitor` probes active inference endpoints (1s HEAD request) and checks Laya sidecar liveness before each session. The TUI status header shows active model, endpoint health and latency, sidecar PID, and context usage percentage — so researchers have immediate visual confirmation of whether the target backend is an internal local host or an external cloud endpoint before uploading grid telemetry.
103
+
104
+ * **Process Legibility:** Every tool call is visible while it runs. Tool-start lines show argument summaries (`⚙ Read(ui.mjs)`, `⚙ Bash(git status)`), a live spinner with plain-English verbs tracks activity between outputs (`⠋ Analyzing…`, `⠙ Reading ui.mjs…`), and tool results carry honest context suffixes (`✓ Read: … (N lines)`, `✓ Bash: … (240ms)`, `✓ Write: … (N chars)`) — never fabricated data. A dim `esc to cancel` hint stays available while a turn runs, and result previews are capped at 120 characters so raw output never floods the screen. Backed by `summarizeToolArgs`, `verbForTool`, `formatToolResultSuffix`, and `TOOL_VERBS` in `lib/ui.mjs`, with a live spinner channel (`bridge.updateSpinner`) in the Ink TUI.
105
+
92
106
  * **Interactive TUI & Pipe Support:** Rich Ink-based TUI (`bin/nehanda-ui.mjs`) for interactive development sessions, with headless pipe-mode support (`bin/agent.mjs`) for acceptance testing and automation.
93
107
 
94
108
  * **Multi-Provider Switching:** Seamlessly switch between Nehanda 27B, local LM Studio, Ollama instances over LAN, Anthropic Claude, or any OpenAI-compatible API using `/model`.
@@ -103,10 +117,62 @@ To connect to an Ollama instance running on your network:
103
117
 
104
118
  ---
105
119
 
120
+ ## ODSE Energy Normalization
121
+
122
+ `lib/energy-middleware.mjs` sits between MCP tool responses and the model. When an energy data MCP server returns a payload, the middleware resolves the OEM brand and normalizes the data to the [ODS-E](https://ona-protocol.org) schema via `lib/scripts/odse-transform.py` before the model sees it. This means the model always works with a consistent normalized schema regardless of which inverter brand, BESS, or SCADA system the data originated from.
123
+
124
+ ### OEM Detection
125
+
126
+ Brand resolution happens in two passes:
127
+
128
+ 1. **Name-based:** The MCP server name is parsed for a known OEM key. `energy-huawei`, `mcp-solaredge`, `power-sungrow-v2` all resolve correctly. Any prefix before the OEM segment is stripped.
129
+ 2. **Payload fingerprinting:** If the server name doesn't resolve, field signatures are matched against known schemas — e.g. `{ inverter_state, run_state }` → Huawei FusionSolar; `{ end_at, wh_del, devices_reporting }` → Enphase Envoy; `{ data: { telemetries } }` → SolarEdge.
130
+
131
+ If neither pass identifies the OEM, the payload is returned unchanged (pass-through).
132
+
133
+ ### Supported OEMs
134
+
135
+ Huawei FusionSolar, Enphase Envoy, SolarEdge, Fronius, Solarman, SMA, Solis, Sungrow / iSolarCloud, Sungrow BESS / PowerTitan, BYD BESS, Fimer / AuroraVision, SolaxCloud, Eskom (portal, AMR, NRS049), Vestas, Siemens Gamesa, Nordex, Terraco, and generic CSV.
136
+
137
+ ### MCP Server Naming Convention
138
+
139
+ Name energy MCP servers with the OEM as a segment in the server name:
140
+
141
+ ```json
142
+ {
143
+ "mcpServers": {
144
+ "energy-huawei": { "command": "...", "args": ["..."] },
145
+ "energy-solaredge": { "command": "...", "args": ["..."] }
146
+ }
147
+ }
148
+ ```
149
+
150
+ No additional configuration is required — normalization is automatic.
151
+
152
+ ### `odse-transform.py` Output Formats
153
+
154
+ The transform script accepts a `--format` flag for direct use:
155
+
156
+ ```bash
157
+ echo '<payload>' | python3 lib/scripts/odse-transform.py --source huawei --format table
158
+ echo '<payload>' | python3 lib/scripts/odse-transform.py --source solaredge --format ndjson
159
+ echo '<payload>' | python3 lib/scripts/odse-transform.py --source sungrow --format summary
160
+ ```
161
+
162
+ | Format | Output | Default when |
163
+ |---|---|---|
164
+ | `table` | Box-drawing ASCII table with column headers and row count | stdout is a TTY |
165
+ | `ndjson` | One JSON object per line | stdout is piped |
166
+ | `summary` | Metadata header (`rows`, `shape`, `source`) + JSON array | explicit flag only |
167
+
168
+ ---
169
+
106
170
  ## Jev-Mem Architecture
107
171
 
108
172
  `nehanda-cli` implements the memory architecture from **"System-One-Controlled Agentic Memory for Efficient AI Agents"** (Jiang, Li & Li, UT Dallas — [arXiv:2609.23986](https://arxiv.org/abs/2609.23986)). The core insight: the frequent, structured decisions of memory management — typing, relation judgment, retrieval routing, sufficiency assessment — don't need an autoregressive LLM. They need a fast, calibrated System-1 model.
109
173
 
174
+ In the context of energy systems research, this matters directly: long multi-step sessions involving SCADA data ingestion, grid simulation, and iterative code generation accumulate enough context to trigger compaction. Standard sliding-window compaction indiscriminately discards ODSE schema mappings, asset capacity limits, and simulation bounds alongside routine tool outputs — causing the model to hallucinate parameters on subsequent turns. Jev-Mem's pinned context layer prevents this: domain invariants are stored in `pinned_context` and reattached verbatim before every System-2 prompt, surviving arbitrarily many compaction cycles.
175
+
110
176
  The System-1 control plane is [**Laya**](https://huggingface.co/convaiinnovations/laya) — a 421M-parameter, non-autoregressive decision model (open weights, Apache 2.0) that answers typed questions in a single forward pass (~33 ms on GPU, ~200 ms on CPU). It never generates text, so there is nothing to parse and nothing to hallucinate.
111
177
 
112
178
  ```
@@ -135,6 +201,7 @@ Observation (user / tool / assistant turn)
135
201
  │
136
202
  ▼
137
203
  ┌── SYSTEM 2 PROMPT ────────────────────────────────────────────┐
204
+ │ [PINNED DOMAIN INVARIANTS] (ODSE schema, asset specs, ...) │
138
205
  │ [CANONICAL SYSTEM 1 STATE] (task_status, file_target, ...) │
139
206
  │ [RETRIEVED EVIDENCE] (top-K memories, not raw transcript) │
140
207
  │ last 2 turns + last failed tool result │
@@ -164,7 +231,7 @@ On first startup, `lib/scripts/ensure-laya-env.sh` creates a dedicated venv at `
164
231
 
165
232
  ### Memory plane (SQLite)
166
233
 
167
- The memory plane extends the existing `memories` table and adds `memory_edges` and `laya_jev_state`. All data lives in the same local SQLite database as the rest of the session state (`~/.config/nehanda/ona-session.db`).
234
+ The memory plane extends the existing `memories` table and adds `memory_edges`, `laya_jev_state`, and `pinned_context`. All data lives in the same local SQLite database as the rest of the session state (`~/.config/nehanda/ona-session.db`).
168
235
 
169
236
  | Table | Purpose |
170
237
  |---|---|
@@ -172,6 +239,7 @@ The memory plane extends the existing `memories` table and adds `memory_edges` a
172
239
  | `memories_fts` | FTS5 index over memory content for lexical anchor retrieval |
173
240
  | `memory_edges` | Multi-relational edges — same node pair may have independent semantic, temporal, causal, and entity edges |
174
241
  | `laya_jev_state` | Session control block: `task_status`, `file_target`, `escalation_risk`, `confidence_score` |
242
+ | `pinned_context` | Session-scoped domain invariants keyed by string — ODSE schema mappings, asset specs, simulation bounds. Never distilled by compaction; reattached verbatim before every System-2 prompt. |
175
243
 
176
244
  ---
177
245
 
@@ -388,6 +456,7 @@ Session state is persisted locally at `~/.config/nehanda/ona-session.db`. Key ta
388
456
  * `memories`: Jev-Mem canonical node store with overlapping type scores and entity provenance.
389
457
  * `memory_edges`: Multi-relational graph edges (semantic / temporal / causal / entity).
390
458
  * `laya_jev_state`: System-1 session control block (task_status, file_target, escalation_risk).
459
+ * `pinned_context`: Session-scoped domain invariants (ODSE schema mappings, asset specs, simulation bounds) that survive compaction unchanged.
391
460
 
392
461
  ---
393
462
 
@@ -414,10 +483,36 @@ node tests/unit/compact_jev_behavioral.mjs
414
483
  node tests/unit/orchestrate_jev_hotpath_behavioral.mjs
415
484
  ```
416
485
 
486
+ Run the UX improvement behavioral suites:
487
+
488
+ ```bash
489
+ node tests/unit/gap1_runtime_visibility_behavioral.mjs
490
+ node tests/unit/gap2_output_formatting_behavioral.mjs
491
+ node tests/unit/gap3_safety_intervention_behavioral.mjs
492
+ node tests/unit/gap4_pinned_compaction_behavioral.mjs
493
+ node tests/unit/ui_legibility_behavioral.mjs
494
+ ```
495
+
417
496
  ---
418
497
 
419
498
  ## Changelog
420
499
 
500
+ ### 0.6.0
501
+
502
+ - **Provider API Key Management (`lib/auth.mjs`, `lib/orchestrate.mjs`, `bin/nehanda-ui.mjs`):** OpenRouter, ZhipuAI (z.ai), and generic OpenAI-compatible providers can now be configured entirely from within the REPL. `/key openrouter sk-or-v1-...` saves the key to the secure file and auto-sets `base_url`. `/key zhipu sk-zai-...` saves the ZhipuAI key. `/config` now shows compat and Zhipu key status (masked). Fixes a pre-existing gap where `resolveZhipuApiKey()` existed in `lib/auth.mjs` but was never called by the orchestrator — the `zhipu` provider fell through to `OPENAI_API_KEY`. Closes #1.
503
+
504
+ - **Laya System-1 Bashguard Gate (`lib/tools.mjs`):** Medium-risk blocked shell commands now pass through a Laya `noul` question (`is_safe_to_auto_approve`) before raising `BlockConfirmMenu`. Commands with ≥ 0.85 confidence are auto-approved and cached without operator prompt. High-risk commands always bypass Laya. Degraded Laya falls through unchanged. Implementation uses a flag variable (`layaAutoApproved`) — see `docs/MAP.md §Gap A`. Closes #4 (Gap A).
505
+
506
+ - **Compaction Entry Scoring (`lib/compact.mjs`):** When Jev-Mem compaction falls back to the prose summarizer, `scoreTranscriptEntries` scores old `tool_result` entries for relevance to the current task via Laya `scoreCandidate`. Entries with relevance < 0.25 are excluded from the distillation window. Last 6 entries always kept. Operates on the fallback path only — memory-graph-level pruning deferred to a follow-on issue; see `docs/MAP.md §Gap B`. Closes #4 (Gap B).
507
+
508
+ - **Intent-Driven Instruction Shaping (`lib/laya.mjs`, `lib/orchestrate.mjs`):** Before each System-2 call in `idle`/`explore` phases, a 3-question Laya classification (`task_domain`, `task_scope`, `requires_domain_context`) runs on the user message and injects a `[TASK CONTEXT — System 1]` preamble into the system prompt. Gives lower-parameter models (Nehanda 27B, DeepSeek Coder) precise task framing before generation. Null `intentMeta` produces no regression. Closes #4 (Gap C).
509
+
510
+ - **`docs/MAP.md` created** — architectural reference for module contracts, design decisions, and follow-on work.
511
+
512
+ ### 0.5.1
513
+
514
+ - **Process Legibility for Tool Calling (`lib/ui.mjs`, `lib/orchestrate.mjs`, `bin/nehanda-ui.mjs`):** The TUI now shows what the agent is doing while it works. Tool-start lines include argument summaries — `⚙ Read(ui.mjs)`, `⚙ Bash(git status)` — generated by the new `summarizeToolArgs` export (`lib/ui.mjs`), with tool input passed through the orchestration layer's `io.onToolStart(name, toolInput)` contract at both call sites (`lib/orchestrate.mjs`). A live spinner channel (`bridge.updateSpinner` in the Ink REPL, plus verb rotation in the `Spinner` class used by pipe mode) replaces the previous no-op stub, cycling plain-English activity verbs (`Analyzing`, `Thinking`, `Reading`, `Searching`, `Running`, …). Tool results gain honest context suffixes via `formatToolResultSuffix` (`lib/ui.mjs`): line counts for `Read`, character counts for `Write`, elapsed-time `(Nms)` for `Bash` — no fabricated exit codes. The `esc to cancel` hint and the 120-character result preview cap are now regression-protected. Behavioral coverage in `tests/unit/ui_legibility_behavioral.mjs` (15 scenarios, including an end-to-end turn against a fake OpenAI-compatible endpoint). Closes #2.
515
+
421
516
  ### 0.4.0
422
517
  - **Runtime Visibility (`lib/modelDiscovery.mjs`, `lib/ui.mjs`):** New `RuntimeHealthMonitor` class probes active inference endpoints (1s HEAD request, Kubernetes readiness pattern) and checks Laya sidecar liveness via `pgrep`. `renderStatusHeader` and `renderModelSwitchNotice` added to `lib/ui.mjs` — the TUI now shows active model, endpoint health + latency, sidecar PID, and context usage percentage after startup and on every `/model` switch.
423
518
  - **Structured Output Formatting (`lib/ui.mjs`, `lib/scripts/odse-transform.py`):** `formatToolResult` now detects JSON array payloads from tool responses and renders them as `cli-table3` box-drawing tables with column headers and row-count summaries (capped at 10 rows with overflow note), falling back to the plain-text preview for non-array content. `odse-transform.py` gains a `--format table|ndjson|summary` flag; defaults to `table` when stdout is a TTY and `ndjson` when piped.
package/bin/agent.mjs CHANGED
@@ -9,7 +9,7 @@ import { stdin as input, stdout as output } from 'node:process'
9
9
 
10
10
  import { openStore } from '../lib/store.mjs'
11
11
  import { defaultDbPath } from '../lib/paths.mjs'
12
- import { resolveAnthropicCredentials, authStatusSummary, saveSecureCredentials, clearSecureCredentials, interactiveOAuthLogin } from '../lib/auth.mjs'
12
+ import { resolveAnthropicCredentials, authStatusSummary, saveSecureCredentials, clearSecureCredentials, interactiveOAuthLogin, resolveOpenAiCompatKey, resolveZhipuApiKey } from '../lib/auth.mjs'
13
13
  import { bootstrapSettings, getEffectiveSettings, updateEffectiveSettings } from '../lib/settings.mjs'
14
14
  import { resolveWireModel, allModelIds, supportsCustomModels, supportsDiscovery } from '../lib/modelConfig.mjs'
15
15
  import { runHooks } from '../lib/hookplane.mjs'
@@ -889,7 +889,11 @@ async function mainPipe(opts) {
889
889
  settings = updateEffectiveSettings(db, patch)
890
890
  io.println(ui.colors.success(` ✓ Set ${dotPath}`))
891
891
  } else {
892
- io.println(ui.formatConfig(settings))
892
+ const keyStatus = {
893
+ compatKeySet: !!resolveOpenAiCompatKey(),
894
+ zhipuKeySet: !!resolveZhipuApiKey(),
895
+ }
896
+ io.println(ui.formatConfig(settings, keyStatus))
893
897
  }
894
898
  continue
895
899
  }
@@ -21,6 +21,8 @@ import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
21
21
  import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
22
22
  import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
23
23
  import { invalidateMcpToolCache } from '../lib/tools.mjs'
24
+ import { saveOpenAiCompatKey, resolveOpenAiCompatKey, saveSecureCredentials, resolveZhipuApiKey } from '../lib/auth.mjs'
25
+ import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix } from '../lib/ui.mjs'
24
26
  import Database from 'better-sqlite3'
25
27
  import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
26
28
 
@@ -136,7 +138,7 @@ function renderMd(text) {
136
138
  const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
137
139
  const SLASH_COMMANDS = [
138
140
  { name: '/model', desc: 'Change model endpoint' },
139
- { name: '/key', desc: 'Set Nehanda API key' },
141
+ { name: '/key', desc: 'Set API key (/key, /key openrouter, /key zhipu, /key openai)' },
140
142
  { name: '/config', desc: 'Show current config' },
141
143
  { name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
142
144
  { name: '/clear', desc: 'New conversation' },
@@ -292,8 +294,12 @@ function MessageView({ msg }) {
292
294
  }
293
295
  function Spinner({ label }) {
294
296
  const [frame, setFrame] = React.useState(0)
295
- React.useEffect(() => { const t = setInterval(() => setFrame(f => (f + 1) % SPINNER_FRAMES.length), 80); return () => clearInterval(t) }, [])
296
- return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame] + ' '), e(Text, { dimColor: true }, label || 'Thinking…'))
297
+ React.useEffect(() => { const t = setInterval(() => setFrame(f => f + 1), 80); return () => clearInterval(t) }, [])
298
+ // Generic labels rotate plain-English verbs; specific labels (e.g. "Reading ui.mjs") stay static.
299
+ const verb = (!label || label === 'Thinking')
300
+ ? TOOL_VERBS[Math.floor(frame / 5) % TOOL_VERBS.length]
301
+ : label
302
+ return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + ' '), e(Text, { dimColor: true }, verb + '…'))
297
303
  }
298
304
  function SlashMenu({ filter }) {
299
305
  const matches = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter.slice(1)))
@@ -404,6 +410,7 @@ function App({ bridge }) {
404
410
  const [selectState, setSelectState] = React.useState(null)
405
411
  const [blockState, setBlockState] = React.useState(null)
406
412
  const [callCount, setCallCount] = React.useState(0)
413
+ const [spinLabel, setSpinLabel] = React.useState(null)
407
414
  const [modelInfo, setModelInfo] = React.useState(() => {
408
415
  const data = readAgents()
409
416
  const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
@@ -421,6 +428,7 @@ function App({ bridge }) {
421
428
  bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
422
429
  bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
423
430
  bridge.setLoading = v => setIsLoading(v)
431
+ bridge.updateSpinner = text => setSpinLabel(text)
424
432
  bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
425
433
  bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
426
434
  bridge.confirmBlock = payload => new Promise(resolve => setBlockState({ payload, resolve }))
@@ -471,7 +479,7 @@ function App({ bridge }) {
471
479
  ? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
472
480
  : e(MessageView, { key: String(i), msg: item })),
473
481
  streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
474
- isLoading && (!streaming || !streaming.text) ? e(Spinner, {}) : null,
482
+ (isLoading && (!streaming || !streaming.text)) || spinLabel ? e(Spinner, { label: spinLabel || undefined }) : null,
475
483
  askState
476
484
  ? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
477
485
  : blockState
@@ -521,10 +529,14 @@ async function handleCommand(cmd, bridge) {
521
529
  if (name === '/config') {
522
530
  const data = readAgents()
523
531
  const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
532
+ const compatKeySet = !!resolveOpenAiCompatKey()
533
+ const zhipuKeySet = !!resolveZhipuApiKey()
524
534
  bridge.addMessage({ role: 'system', text: [
525
- '', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
526
- ` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
527
- ` API key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`, '',
535
+ '', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
536
+ ` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
537
+ ` Nehanda key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`,
538
+ ` OpenAI-compat key: ${compatKeySet ? chalk.green('set') : chalk.dim('not set')}`,
539
+ ` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`, '',
528
540
  ].join('\n') })
529
541
  return
530
542
  }
@@ -540,10 +552,50 @@ async function handleCommand(cmd, bridge) {
540
552
  }
541
553
  if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
542
554
  if (name === '/key') {
543
- const answer = await bridge.ask('New Nehanda API key:')
555
+ // Parse optional provider subcommand: /key [openrouter|zhipu|openai] [value]
556
+ const parts = cmd.trim().split(/\s+/)
557
+ const sub = parts[1] || ''
558
+ const KNOWN_COMPAT_PROVIDERS = ['openrouter', 'openai', 'zhipu']
559
+
560
+ if (!sub || !KNOWN_COMPAT_PROVIDERS.includes(sub.toLowerCase())) {
561
+ // Default: Nehanda API key (existing behaviour, unchanged)
562
+ const answer = await bridge.ask('New Nehanda API key:')
563
+ 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 }) }
566
+ return
567
+ }
568
+
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:`)
544
573
  if (!answer) return
545
- try { writeApiKey(answer); bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} API key updated.` }) }
546
- catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
574
+
575
+ 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
+ }
598
+ } catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
547
599
  return
548
600
  }
549
601
  if (name === '/model') {
@@ -819,6 +871,8 @@ bridge.onSubmit = async (text) => {
819
871
  let fullText = '', aborted = false
820
872
  controller.signal.addEventListener('abort', () => { aborted = true })
821
873
 
874
+ const toolStarts = new Map()
875
+ const lastToolArgs = new Map()
822
876
  const io = {
823
877
  write(chunk) {
824
878
  if (aborted) return
@@ -827,14 +881,26 @@ bridge.onSubmit = async (text) => {
827
881
  bridge.updateAssistant(lastPartial)
828
882
  },
829
883
  println(msg) { if (!aborted) bridge.addMessage({ role: 'system', text: ' ' + msg }) },
830
- spinner: { start() {}, stop() {} },
884
+ spinner: {
885
+ start: label => bridge.updateSpinner(label),
886
+ stop: () => bridge.updateSpinner(null),
887
+ },
831
888
  ask: bridge.ask,
832
889
  confirmBlock: payload => bridge.confirmBlock(payload),
833
- onToolStart(name) { if (!aborted) bridge.addMessage({ role: 'system', text: ` ⚙ ${name}` }) },
890
+ onToolStart(name, args) {
891
+ if (aborted) return
892
+ lastToolArgs.set(name, args)
893
+ toolStarts.set(name, Date.now())
894
+ const summary = summarizeToolArgs(name, args)
895
+ bridge.addMessage({ role: 'system', text: ` ⚙ ${name}${summary ? `(${summary})` : ''}` })
896
+ bridge.updateSpinner(verbForTool(name) + (summary ? ' ' + summary : ''))
897
+ },
834
898
  onToolResult(name, c, err) {
835
899
  if (aborted) return
836
900
  const preview = String(c || '').slice(0, 120).replace(/\n/g, ' ')
837
- bridge.addMessage({ role: 'system', text: ` ${err ? '✗' : '✓'} ${name}: ${preview}` })
901
+ const suffix = formatToolResultSuffix(name, c, { startedAt: toolStarts.get(name), args: lastToolArgs.get(name) })
902
+ bridge.addMessage({ role: 'system', text: ` ${err ? '✗' : '✓'} ${name}: ${preview}${suffix}` })
903
+ bridge.updateSpinner(null)
838
904
  },
839
905
  }
840
906
 
package/lib/auth.mjs CHANGED
@@ -109,16 +109,36 @@ function computeAlsoConfiguredHints(active, pref) {
109
109
  return { envBearer, envApiKey: envKey, secureFileBearer: fileB, secureFileApiKey: fileK, ignoredHints: ignored }
110
110
  }
111
111
 
112
- export function saveSecureCredentials({ apiKey, bearerToken, zhipuApiKey }) {
112
+ export function saveSecureCredentials({ apiKey, bearerToken, zhipuApiKey, openaiCompatApiKey }) {
113
113
  const p = secureAuthPath()
114
114
  const prev = readSecureAuthFile() || {}
115
115
  const next = { ...prev }
116
116
  if (apiKey !== undefined) { if (apiKey === '') delete next.apiKey; else next.apiKey = apiKey }
117
117
  if (bearerToken !== undefined) { if (bearerToken === '') delete next.bearerToken; else next.bearerToken = bearerToken }
118
118
  if (zhipuApiKey !== undefined) { if (zhipuApiKey === '') delete next.zhipuApiKey; else next.zhipuApiKey = zhipuApiKey }
119
+ if (openaiCompatApiKey !== undefined) { if (openaiCompatApiKey === '') delete next.openaiCompatApiKey; else next.openaiCompatApiKey = openaiCompatApiKey }
119
120
  fs.writeFileSync(p, JSON.stringify(next, null, 0), { mode: 0o600 })
120
121
  }
121
122
 
123
+ /** Save an OpenAI-compatible provider API key (OpenRouter, generic, etc.) to secure storage. */
124
+ export function saveOpenAiCompatKey(key) {
125
+ saveSecureCredentials({ openaiCompatApiKey: key })
126
+ }
127
+
128
+ /**
129
+ * Resolve the API key for openai_compatible provider.
130
+ * Priority: OPENROUTER_API_KEY env → OPENAI_API_KEY env → secure file openaiCompatApiKey
131
+ * (Callers in orchestrate.mjs check model_config.api_key first, before calling this.)
132
+ */
133
+ export function resolveOpenAiCompatKey() {
134
+ const fromOpenRouter = nonempty(process.env.OPENROUTER_API_KEY)
135
+ if (fromOpenRouter) return fromOpenRouter
136
+ const fromOpenAi = nonempty(process.env.OPENAI_API_KEY)
137
+ if (fromOpenAi) return fromOpenAi
138
+ const file = readSecureAuthFile()
139
+ return nonempty(file?.openaiCompatApiKey) || ''
140
+ }
141
+
122
142
  export function resolveZhipuApiKey() {
123
143
  const fromEnv = nonempty(process.env.ZAI_API_KEY)
124
144
  if (fromEnv) return fromEnv
@@ -134,7 +154,12 @@ function readSecureAuthFile() {
134
154
  try {
135
155
  const raw = fs.readFileSync(secureAuthPath(), 'utf8')
136
156
  const j = JSON.parse(raw)
137
- return { apiKey: nonempty(j.apiKey), bearerToken: nonempty(j.bearerToken), zhipuApiKey: nonempty(j.zhipuApiKey) }
157
+ return {
158
+ apiKey: nonempty(j.apiKey),
159
+ bearerToken: nonempty(j.bearerToken),
160
+ zhipuApiKey: nonempty(j.zhipuApiKey),
161
+ openaiCompatApiKey: nonempty(j.openaiCompatApiKey),
162
+ }
138
163
  } catch { return null }
139
164
  }
140
165
 
package/lib/compact.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendEntry } from './transcript.mjs'
2
2
  import { withTransaction, getLayaJevState, upsertLayaJevState, getPinnedContextBlocks } from './store.mjs'
3
3
  import { runHooks } from './hookplane.mjs'
4
- import { extractCanonicalState } from './laya.mjs'
4
+ import { extractCanonicalState, scoreCandidate, noulProb } from './laya.mjs'
5
5
  import {
6
6
  transcriptTailText,
7
7
  extractRecentFilePaths,
@@ -49,7 +49,21 @@ export async function compactConversation(db, rt, summarize, log) {
49
49
  try {
50
50
  summary = await buildJevCollapseSummary(db, rt, pinnedBlocks)
51
51
  } catch {
52
- const text = entries.map(e => {
52
+ // Fallback summarizer path: score tool_result entries for relevance before
53
+ // assembling the distillation text. Low-relevance entries are excluded.
54
+ // Scope note: this gates the fallback path only. Memory-graph-level pruning
55
+ // (filtering what retrieveEvidence returns) requires a session_prune_score
56
+ // column in the memories table — tracked as a follow-on issue.
57
+ const taskContext = entries
58
+ .filter(e => e.entry_type === 'user')
59
+ .slice(-1)
60
+ .map(e => { try { return extractText(JSON.parse(e.payload_json)) } catch { return '' } })
61
+ .join('').slice(0, 200)
62
+
63
+ const scoredEntries = await scoreTranscriptEntries(entries, taskContext, rt.layaOpts || {})
64
+
65
+ const text = scoredEntries.map(e => {
66
+ if (e._prune) return ''
53
67
  try {
54
68
  const p = JSON.parse(e.payload_json)
55
69
  if (e.entry_type === 'user') return `User: ${extractText(p)}`
@@ -338,6 +352,47 @@ export async function buildJevSystem2Messages(db, rt, messages, opts = {}) {
338
352
  }
339
353
  }
340
354
 
355
+ /**
356
+ * Score transcript entries for relevance to the current task using Laya System-1.
357
+ * Only scores tool_result entries older than SCORE_SKIP_RECENT turns.
358
+ * Entries below SCORE_PRUNE_THRESHOLD are marked _prune=true.
359
+ * Degraded Laya: all entries retain _prune=false (keep-all fallback — no data loss).
360
+ *
361
+ * Scope: gates the fallback summarizer path in compactConversation.
362
+ * Memory-graph-level pruning (filtering retrieveEvidence output) requires a
363
+ * session_prune_score column in the memories table — tracked as a follow-on issue.
364
+ *
365
+ * @param {Array} entries - transcript_entries rows with entry_type and payload_json
366
+ * @param {string} taskContext - short string describing the current task
367
+ * @param {object} layaOpts - passed through to queryLaya
368
+ * @returns {Promise<Array>} same entries array, each annotated with _relevance and _prune
369
+ */
370
+ const SCORE_SKIP_RECENT = 6 // last N entries always kept, never scored
371
+ const SCORE_MAX_ENTRIES = 20 // max tool_result entries scored per compaction
372
+ const SCORE_PRUNE_THRESHOLD = 0.25 // relevance noul below this → prune
373
+
374
+ export async function scoreTranscriptEntries(entries, taskContext = '', layaOpts = {}) {
375
+ const toScore = entries
376
+ .slice(0, Math.max(0, entries.length - SCORE_SKIP_RECENT))
377
+ .filter(e => e.entry_type === 'tool_result')
378
+ .slice(0, SCORE_MAX_ENTRIES)
379
+
380
+ for (const entry of toScore) {
381
+ try {
382
+ const p = JSON.parse(entry.payload_json)
383
+ const content = typeof p.content === 'string' ? p.content.slice(0, 400) : ''
384
+ const candidateText = `Task: ${taskContext}\nEntry: ${content}`
385
+ const result = await scoreCandidate(candidateText, { ...layaOpts, timeoutMs: 150 })
386
+ entry._relevance = result.relevance
387
+ entry._prune = !result.degraded && result.relevance < SCORE_PRUNE_THRESHOLD
388
+ } catch {
389
+ entry._relevance = 1 // on error: keep the entry
390
+ entry._prune = false
391
+ }
392
+ }
393
+ return entries
394
+ }
395
+
341
396
  function extractText(payload) {
342
397
  const blocks = payload.content
343
398
  if (!Array.isArray(blocks)) return ''
package/lib/laya.mjs CHANGED
@@ -85,6 +85,34 @@ export const SESSION_STATE_QUESTIONS = {
85
85
  },
86
86
  }
87
87
 
88
+ export const INTENT_QUESTIONS = {
89
+ task_domain: {
90
+ type: 'choice',
91
+ instructions: 'What is the primary domain of this task?',
92
+ criteria: {
93
+ code_edit: 'writing, modifying, or refactoring source code files',
94
+ code_review: 'reading, explaining, or understanding existing code',
95
+ energy_data: 'ODSE telemetry, inverter, BESS, SCADA, or grid data',
96
+ infrastructure: 'deployment, CI/CD, environment config, Docker, AWS',
97
+ research: 'factual lookup, concept explanation, documentation',
98
+ test_debug: 'fixing a failing test, diagnosing an error or stack trace',
99
+ },
100
+ },
101
+ task_scope: {
102
+ type: 'choice',
103
+ instructions: 'How many files or components does this task likely affect?',
104
+ criteria: {
105
+ single_file: 'change confined to one file or function',
106
+ multi_file: 'change spans two to five files',
107
+ architectural: 'change affects interfaces, schemas, or multiple subsystems',
108
+ },
109
+ },
110
+ requires_domain_context: {
111
+ type: 'noul',
112
+ instructions: 'Does this task require energy domain knowledge (ODSE schema, OEM telemetry, asset specs)?',
113
+ },
114
+ }
115
+
88
116
  /** Truncate from the end (most recent) to fit Laya's state budget. */
89
117
  export function truncateForLaya(text, budget = LAYA_STATE_CHAR_BUDGET) {
90
118
  if (!text) return ''
@@ -197,6 +225,19 @@ export function heuristicAnswers(questions, stateText = '') {
197
225
  else if (/\b(edit|write|patch|implement|search\/replace)\b/.test(t)) choice = 'modifying_code'
198
226
  else if (/\b(please confirm|awaiting|should i)\b/.test(t)) choice = 'awaiting_user'
199
227
  out[key] = { choice, confidence: 0.4 }
228
+ } else if (q.type === 'choice' && key === 'task_domain') {
229
+ let choice = 'code_edit'
230
+ if (/\b(odse|telemetry|inverter|huawei|solaredge|enphase|sungrow|bess|scada|kwh|grid|meter|oemid|fusionsolar)\b/.test(t)) choice = 'energy_data'
231
+ else if (/\b(test|fail|error|assert|stack trace|exception|debug)\b/.test(t)) choice = 'test_debug'
232
+ else if (/\b(deploy|docker|ci|cd|aws|lambda|infra|env|config|environment)\b/.test(t)) choice = 'infrastructure'
233
+ else if (/\b(explain|what is|how does|document|readme|concept)\b/.test(t)) choice = 'research'
234
+ else if (/\b(read|review|understand|look at|show me|what does)\b/.test(t)) choice = 'code_review'
235
+ out[key] = { choice, confidence: 0.4 }
236
+ } else if (q.type === 'choice' && key === 'task_scope') {
237
+ let choice = 'single_file'
238
+ if (/\b(schema|interface|protocol|architect|subsystem|refactor.*all|across)\b/.test(t)) choice = 'architectural'
239
+ else if (/\b(files|modules|multiple|several|both)\b/.test(t)) choice = 'multi_file'
240
+ out[key] = { choice, confidence: 0.4 }
200
241
  } else if (q.type === 'noul') {
201
242
  let p = 0.3
202
243
  if (key === 'escalation_risk' || key === 'blocked') p = /\b(error|fail|blocked)\b/.test(t) ? 0.7 : 0.2
@@ -205,6 +246,8 @@ export function heuristicAnswers(questions, stateText = '') {
205
246
  if (key === 'procedural') p = /\b(run|command|step)\b/.test(t) ? 0.6 : 0.3
206
247
  if (key === 'sufficient') p = 0.5
207
248
  if (key === 'further_utility') p = 0.4
249
+ if (key === 'requires_domain_context') p = /\b(odse|telemetry|inverter|kwh|bess|scada|huawei|solaredge|enphase|sungrow|fusionsolar)\b/.test(t) ? 0.85 : 0.1
250
+ if (key === 'is_safe_to_auto_approve') p = 0.3 // conservative: default to requiring confirmation
208
251
  out[key] = { noul: p, confidence: 0.35 }
209
252
  } else if (q.type === 'choice') {
210
253
  const first = Object.keys(q.criteria || {})[0] || 'other'
@@ -214,6 +257,21 @@ export function heuristicAnswers(questions, stateText = '') {
214
257
  return out
215
258
  }
216
259
 
260
+ /**
261
+ * Classify the user's intent from their message text.
262
+ * Returns { task_domain, task_scope, requires_domain_context, degraded }.
263
+ * Never throws — degraded heuristics always available.
264
+ */
265
+ export async function classifyUserIntent(userText, opts = {}) {
266
+ const q = await queryLaya(userText, INTENT_QUESTIONS, opts)
267
+ return {
268
+ task_domain: choiceOf(q.answers, 'task_domain', 'code_edit'),
269
+ task_scope: choiceOf(q.answers, 'task_scope', 'single_file'),
270
+ requires_domain_context: noulProb(q.answers, 'requires_domain_context') >= 0.6,
271
+ degraded: q.degraded,
272
+ }
273
+ }
274
+
217
275
  /**
218
276
  * Extract canonical session control state from transcript tail + tool file paths.
219
277
  * file_target is hybrid: deterministic paths win; Laya only sets categorical fields.
@@ -87,6 +87,12 @@ function ensureChild() {
87
87
  stdio: ['pipe', 'pipe', 'pipe'],
88
88
  env: { ...process.env },
89
89
  })
90
+ child.unref()
91
+ // Unref stdio streams so they don't prevent Node from exiting.
92
+ // The sidecar is a background daemon — its pipes must not hold the event loop.
93
+ child.stdout.unref()
94
+ child.stdin.unref()
95
+ child.stderr.unref()
90
96
  restartCount += 1
91
97
  attachChild(child)
92
98
  return true
@@ -1,7 +1,8 @@
1
1
  import readline from 'node:readline'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import Anthropic from '@anthropic-ai/sdk'
4
- import { resolveAnthropicCredentials } from './auth.mjs'
4
+ import { resolveAnthropicCredentials, resolveZhipuApiKey, resolveOpenAiCompatKey } from './auth.mjs'
5
+ import { classifyUserIntent } from './laya.mjs'
5
6
  import { resolveWireModel, anthropicBaseUrl, omitToolChoice } from './modelConfig.mjs'
6
7
  import { runHooks } from './hookplane.mjs'
7
8
  import { appendEntry, transcriptToAnthropicMessages, transcriptToOpenAIMessages, makeUserPayload, makeAssistantPayload, makeToolResultPayload, applyEpistemicFilter } from './transcript.mjs'
@@ -36,6 +37,20 @@ function getAvailableToolHints() {
36
37
  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
  }
38
39
 
40
+ // ─── Domain context notes for intent-shaped system prompts ───
41
+
42
+ /**
43
+ * Per-domain preamble injected into the system prompt when Laya classifies
44
+ * the user's intent. Used by buildSystemPrompt when intentMeta is present.
45
+ * Only active in idle/explore/default phases — not in implement/test.
46
+ */
47
+ const DOMAIN_CONTEXT_NOTES = {
48
+ energy_data: 'This task involves energy telemetry normalization. Preserve ODS-E field names and units exactly. Do not rename or reformat telemetry fields without verifying the full OEM schema mapping.',
49
+ test_debug: 'A test or build is failing. Diagnose the root cause before proposing a fix. Do not suppress errors, skip assertions, or mark tests as passing without running them.',
50
+ architectural: 'This task crosses subsystem boundaries. Preserve existing interfaces and verify all call sites before modifying function signatures or module contracts.',
51
+ infrastructure: 'This task affects deployment configuration. Validate environment variable names and resource names against the documented naming conventions before writing changes.',
52
+ }
53
+
39
54
  // ─── Phase system prompts ─────────────────────────────────────
40
55
 
41
56
  const PHASE_SYSTEM = {
@@ -225,7 +240,7 @@ Fill in this summary using ONLY the information above — do not invent anything
225
240
  <one sentence: passed or failed, what was verified>`
226
241
  }
227
242
 
228
- export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions, injectedTools = null) {
243
+ export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions, injectedTools = null, intentMeta = null) {
229
244
  const os = process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux'
230
245
  const shell = process.env.SHELL?.split('/').pop() || 'sh'
231
246
  const date = new Date().toISOString().split('T')[0]
@@ -235,6 +250,23 @@ export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions,
235
250
  const builder = PHASE_SYSTEM[phase] || PHASE_SYSTEM.default
236
251
  let prompt = builder(cwd, provider, model, date, os, shell, onaInstructions)
237
252
 
253
+ // Inject intent-shaped task context preamble for idle/explore/default phases.
254
+ // Not injected in implement/test/planning — those phases use generated template
255
+ // messages that carry no real user intent signal.
256
+ if (intentMeta && ['idle', 'explore', 'default'].includes(phase)) {
257
+ const domainNote = DOMAIN_CONTEXT_NOTES[intentMeta.task_domain] || ''
258
+ const contextLines = [
259
+ '[TASK CONTEXT — System 1]',
260
+ `Domain: ${intentMeta.task_domain} | Scope: ${intentMeta.task_scope}`,
261
+ ]
262
+ if (intentMeta.requires_domain_context) {
263
+ contextLines.push('Domain context: required — apply ODSE schema invariants and energy asset constraints precisely.')
264
+ }
265
+ if (domainNote) contextLines.push(domainNote)
266
+ contextLines.push('--------------------------------------------------')
267
+ prompt = contextLines.join('\n') + '\n\n' + prompt
268
+ }
269
+
238
270
  // Pin output language for multilingual models (e.g. Qwen) that otherwise code-switch.
239
271
  if (!prompt.includes(LANGUAGE_LOCK)) {
240
272
  const nl = prompt.indexOf('\n')
@@ -792,7 +824,7 @@ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt
792
824
  if (io.spinner) io.spinner.start('Summarising')
793
825
 
794
826
  const currentPhase = getPhase(db, rt.conversationId)
795
- const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
827
+ const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions, null, rt._intentMeta)
796
828
  const messages = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
797
829
 
798
830
  let bufferedText = ''
@@ -819,7 +851,7 @@ async function runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl,
819
851
  if (io.spinner) io.spinner.start('Summarising')
820
852
 
821
853
  const currentPhase = getPhase(db, rt.conversationId)
822
- const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions)
854
+ const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions, null, rt._intentMeta)
823
855
  const rawMessages = transcriptToOpenAIMessages(db, rt.sessionId)
824
856
  const messages = [{ role: 'system', content: systemPrompt }, ...sanitizeMessages(rawMessages)]
825
857
 
@@ -839,7 +871,7 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
839
871
  for (;;) {
840
872
  if (io.spinner) io.spinner.start('Thinking')
841
873
  const currentPhase = getPhase(db, rt.conversationId)
842
- const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions)
874
+ const system = buildSystemPrompt(rt.cwd, activeProvider(rt.settings), rt._model, currentPhase, rt.onaInstructions, null, rt._intentMeta)
843
875
  const filteredTools = filterToolsByPhase(tools, currentPhase)
844
876
  const rawAnthropic = applyEpistemicFilter(transcriptToAnthropicMessages(db, rt.sessionId), currentPhase)
845
877
  const messages = await buildJevSystem2Messages(db, rt, rawAnthropic)
@@ -888,7 +920,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
888
920
  if (io.spinner) io.spinner.start('Thinking')
889
921
 
890
922
  const currentPhase = getPhase(db, rt.conversationId)
891
- const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions, currentInjectedTools)
923
+ const systemPrompt = buildSystemPrompt(rt.cwd, provider, rt._model, currentPhase, rt.onaInstructions, currentInjectedTools, rt._intentMeta)
892
924
  const filteredTools = filterToolsByPhase(activeTools, currentPhase)
893
925
  const xmlInstructions = (omitToolChoice(provider) || provider === 'nehanda')
894
926
  ? buildXmlToolInstructions(filteredTools, currentPhase)
@@ -923,7 +955,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
923
955
  if (manualCalls.length > 0) {
924
956
  if (io.spinner) io.spinner.stop()
925
957
  for (const call of manualCalls) {
926
- if (io.onToolStart) io.onToolStart(call.name)
958
+ if (io.onToolStart) io.onToolStart(call.name, call.input || {})
927
959
  }
928
960
  assistantBlocks = [...assistantBlocks, ...manualCalls]
929
961
  }
@@ -989,7 +1021,7 @@ async function executeToolUses(db, rt, io, hookRt, toolUses) {
989
1021
  continue
990
1022
  }
991
1023
 
992
- if (io.onToolStart) io.onToolStart(toolName)
1024
+ if (io.onToolStart) io.onToolStart(toolName, toolInput)
993
1025
  const execCtx = { sessionId: rt.sessionId, conversationId: rt.conversationId, cwd: rt.cwd, settings: rt.settings }
994
1026
  const out = await executeBuiltinTool(db, execCtx, toolName, toolInput, io)
995
1027
 
@@ -1022,6 +1054,17 @@ export async function runUserTurn(db, rt, userText, io) {
1022
1054
  const ups = await runHooks(db, hookRt, 'UserPromptSubmit', { prompt: userText })
1023
1055
  if (!ups.ok) { io.println(`UserPromptSubmit blocked: ${ups.userPrompt?.stderr || 'exit 2'}`); return }
1024
1056
 
1057
+ // Classify user intent for idle/explore phases so buildSystemPrompt can shape
1058
+ // instructions for lower-parameter models. Non-fatal — degraded heuristics cover failure.
1059
+ rt._intentMeta = null
1060
+ if (phase === 'idle' || phase === 'explore') {
1061
+ try {
1062
+ rt._intentMeta = await classifyUserIntent(userText, rt.layaOpts || {})
1063
+ } catch {
1064
+ // intentMeta stays null — buildSystemPrompt handles null gracefully
1065
+ }
1066
+ }
1067
+
1025
1068
  if (phase === 'idle' && io.ask) {
1026
1069
  const answer = await io.ask('Use SDLC workflow (plan → implement → test)? [Y/n] ')
1027
1070
  const useSdlc = !answer.trim() || /^y(es)?$/i.test(answer.trim())
@@ -1107,9 +1150,13 @@ async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPha
1107
1150
  } else if (provider === 'nehanda') {
1108
1151
  baseUrl = savedBaseUrl || process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1'
1109
1152
  apiKey = rt.settings?.model_config?.api_key || process.env.NEHANDA_API_KEY || ''
1153
+ } else if (provider === 'zhipu') {
1154
+ baseUrl = savedBaseUrl || 'https://open.bigmodel.cn/api/paas/v4'
1155
+ apiKey = resolveZhipuApiKey()
1110
1156
  } else {
1157
+ // openai_compatible, lm_studio_local, and any future OpenAI-compat providers
1111
1158
  baseUrl = savedBaseUrl || process.env.OPENAI_BASE_URL
1112
- apiKey = process.env.OPENAI_API_KEY
1159
+ apiKey = rt.settings?.model_config?.api_key || resolveOpenAiCompatKey()
1113
1160
  }
1114
1161
 
1115
1162
  const forceManual = rt.settings?.model_config?.force_manual_tools === true
package/lib/tools.mjs CHANGED
@@ -11,6 +11,7 @@ import { createWorktree, removeWorktree, getCurrentWorktree } from './worktree.m
11
11
  import { addTeammate, setTeammateIdle } from './team.mjs'
12
12
  import { validateBashCommand } from './bashguard.mjs'
13
13
  import { cacheSessionApproval, hasSessionApproval } from './permissions.mjs'
14
+ import { queryLaya } from './laya.mjs'
14
15
 
15
16
  const BASH_CAP = 1048576
16
17
  const TRUNC = '[SDLC_TRUNCATED]'
@@ -304,7 +305,38 @@ async function toolBash(cwd, input, db, ctx, io) {
304
305
 
305
306
  // Validate command safety before execution.
306
307
  const guard = validateBashCommand(cmd, cwd)
307
- if (!guard.ok) {
308
+
309
+ // Laya second-stage gate for medium-risk blocked commands.
310
+ // Architectural note (README §Interactive Safety Interventions): rather than
311
+ // extracting bash execution into a helper, we use a flag variable to conditionally
312
+ // bypass the block branch. This keeps the spawn logic co-located and avoids a
313
+ // refactor of the execution path.
314
+ // High-risk commands always go to BlockConfirmMenu — Laya is not consulted.
315
+ // Degraded Laya falls through to existing behavior (no regression).
316
+ let layaAutoApproved = false
317
+ if (!guard.ok && guard.risk_score === 'medium') {
318
+ try {
319
+ const layaState = `Command: ${cmd}\nCwd: ${cwd}`
320
+ const layaResult = await queryLaya(layaState, {
321
+ is_safe_to_auto_approve: {
322
+ type: 'noul',
323
+ instructions: 'Is this a benign, non-destructive operation with zero risk of data loss or irreversible system change?',
324
+ },
325
+ }, { timeoutMs: 150 })
326
+ if (!layaResult.degraded) {
327
+ const a = layaResult.answers?.is_safe_to_auto_approve
328
+ const prob = typeof a === 'number' ? a : (a?.noul ?? 0)
329
+ if (prob >= 0.85) {
330
+ layaAutoApproved = true
331
+ if (sessionId) cacheSessionApproval(sessionId, approvalKey)
332
+ }
333
+ }
334
+ } catch {
335
+ // Laya unavailable — fall through to existing block handling
336
+ }
337
+ }
338
+
339
+ if (!guard.ok && !layaAutoApproved) {
308
340
  // Check session approval cache first (user previously approved this pattern).
309
341
  if (sessionId && hasSessionApproval(sessionId, approvalKey)) {
310
342
  // Approved — fall through to execution.
package/lib/ui.mjs CHANGED
@@ -205,17 +205,106 @@ export function formatToolResult(toolName, content, isError) {
205
205
  return label + icon + ' ' + colors.dim(preview)
206
206
  }
207
207
 
208
+ // ── Process legibility: verbs + tool arg summaries (issue #2) ─
209
+
210
+ // Plain-English activity verbs shown while the agent works. No whimsy.
211
+ export const TOOL_VERBS = [
212
+ 'Analyzing', 'Thinking', 'Retrieving', 'Planning', 'Reading',
213
+ 'Writing', 'Searching', 'Running', 'Coordinating',
214
+ ]
215
+
216
+ const TOOL_VERB_FOR = {
217
+ Read: 'Reading', Write: 'Writing', Edit: 'Editing', NotebookEdit: 'Editing',
218
+ Glob: 'Searching', Grep: 'Searching', WebSearch: 'Searching',
219
+ Bash: 'Running', WebFetch: 'Fetching',
220
+ Agent: 'Delegating', Skill: 'Invoking',
221
+ }
222
+
223
+ export function verbForTool(toolName) {
224
+ return TOOL_VERB_FOR[toolName] || 'Working'
225
+ }
226
+
227
+ function clipSummary(v) {
228
+ const s = String(v).replace(/\s+/g, ' ').trim()
229
+ if (!s) return ''
230
+ return s.length > 60 ? '…' + s.slice(-59) : s
231
+ }
232
+
233
+ /**
234
+ * Compact one-line summary of a tool call's arguments for the tool-start line,
235
+ * e.g. Read → file basename, Bash → command, Grep → pattern. Capped at 60 chars.
236
+ */
237
+ export function summarizeToolArgs(toolName, args) {
238
+ if (!args || typeof args !== 'object') return ''
239
+ const pick = (...keys) => {
240
+ for (const k of keys) {
241
+ const v = args[k]
242
+ if (typeof v === 'string' && v.trim()) return v
243
+ }
244
+ return null
245
+ }
246
+ switch (toolName) {
247
+ case 'Read': case 'Write': case 'Edit': case 'NotebookEdit': {
248
+ const p = pick('file_path', 'path', 'notebook_path')
249
+ return p ? clipSummary(String(p).split('/').pop()) : ''
250
+ }
251
+ case 'Bash': return clipSummary(pick('command'))
252
+ case 'Glob': case 'Grep': return clipSummary(pick('pattern'))
253
+ case 'WebFetch': return clipSummary(pick('url'))
254
+ case 'WebSearch': return clipSummary(pick('query'))
255
+ case 'Agent': return clipSummary(pick('description', 'prompt'))
256
+ case 'Skill': return clipSummary(pick('skill'))
257
+ case 'AskUserQuestion': return clipSummary(pick('question'))
258
+ case 'Brief': return clipSummary(pick('message'))
259
+ case 'TodoWrite': return Array.isArray(args.todos) ? `${args.todos.length} items` : ''
260
+ default: {
261
+ const first = Object.values(args).find(v => typeof v === 'string' && v.trim())
262
+ return first ? clipSummary(first) : ''
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Trailing context for a tool result line: Read → "(N lines)",
269
+ * Write → "(N chars)", anything timed → "(Nms)". Honest data only —
270
+ * never fabricates exit codes.
271
+ */
272
+ export function formatToolResultSuffix(toolName, content, meta = {}) {
273
+ if (toolName === 'Read') {
274
+ if (!content) return ''
275
+ const lines = String(content).split('\n').length
276
+ return ` (${lines} line${lines === 1 ? '' : 's'})`
277
+ }
278
+ if (toolName === 'Write') {
279
+ const n = typeof meta.args?.content === 'string' ? meta.args.content.length : 0
280
+ return n ? ` (${n} chars)` : ''
281
+ }
282
+ if (typeof meta.startedAt === 'number') {
283
+ const ms = Math.max(0, Date.now() - meta.startedAt)
284
+ return ` (${ms}ms)`
285
+ }
286
+ return ''
287
+ }
288
+
208
289
  // ── Spinner ─────────────────────────────────────────────────
209
290
 
210
291
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
211
292
 
212
293
  export class Spinner {
213
- constructor(io) {
294
+ constructor(io, opts = {}) {
214
295
  this.io = io
215
296
  this.frame = 0
216
297
  this.interval = null
217
298
  this.active = false
218
299
  this.message = 'Thinking'
300
+ this.verbs = Array.isArray(opts.verbs) && opts.verbs.length ? opts.verbs : TOOL_VERBS
301
+ this.verbIndex = 0
302
+ }
303
+
304
+ // A generic label ("Thinking") rotates plain-English verbs; explicit labels stay static.
305
+ currentLabel() {
306
+ if (this.message && this.message !== 'Thinking') return this.message
307
+ return this.verbs[this.verbIndex % this.verbs.length]
219
308
  }
220
309
 
221
310
  start(message) {
@@ -223,9 +312,11 @@ export class Spinner {
223
312
  this.active = true
224
313
  this.message = message || 'Thinking'
225
314
  this.frame = 0
315
+ this.verbIndex = 0
226
316
  this.interval = setInterval(() => {
227
317
  const f = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length]
228
- this.io.write(`\r${colors.dim(f + ' ' + this.message + '...')}`)
318
+ if (this.frame > 0 && this.frame % 5 === 0) this.verbIndex++
319
+ this.io.write(`\r${colors.dim(f + ' ' + this.currentLabel() + '...')}`)
229
320
  this.frame++
230
321
  }, 80)
231
322
  }
@@ -270,7 +361,7 @@ export function formatStatus(status) {
270
361
 
271
362
  // ── Config ──────────────────────────────────────────────────
272
363
 
273
- export function formatConfig(settings) {
364
+ export function formatConfig(settings, keyStatus = {}) {
274
365
  const lines = ['', colors.header('Settings')]
275
366
 
276
367
  // Show model config with custom indicator
@@ -289,6 +380,18 @@ export function formatConfig(settings) {
289
380
  }
290
381
  lines.push('')
291
382
  }
383
+
384
+ // Show provider key status when available
385
+ if (keyStatus.compatKeySet !== undefined || keyStatus.zhipuKeySet !== undefined) {
386
+ if (keyStatus.compatKeySet !== undefined) {
387
+ const label = 'OpenAI-compat key:'
388
+ lines.push(` ${colors.key(label)} ${keyStatus.compatKeySet ? colors.success('set') : colors.dim('not set')}`)
389
+ }
390
+ if (keyStatus.zhipuKeySet !== undefined) {
391
+ lines.push(` ${colors.key('Zhipu key:')} ${keyStatus.zhipuKeySet ? colors.success('set') : colors.dim('not set')}`)
392
+ }
393
+ lines.push('')
394
+ }
292
395
 
293
396
  // Show full JSON for other settings
294
397
  const json = JSON.stringify(settings, null, 2)
package/package.json CHANGED
@@ -1,8 +1,27 @@
1
1
  {
2
2
  "name": "@asobacloud/nehanda",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
- "description": "Agentic terminal REPL and single-process engine for governed AI deep research and software development. Connects to Nehanda v3, Ollama, LM Studio, or any OpenAI-compatible API.",
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
+ "keywords": [
7
+ "ai",
8
+ "agent",
9
+ "llm",
10
+ "repl",
11
+ "cli",
12
+ "openrouter",
13
+ "ollama",
14
+ "lm-studio",
15
+ "anthropic",
16
+ "openai",
17
+ "energy",
18
+ "odse",
19
+ "sdlc",
20
+ "memory",
21
+ "jev-mem",
22
+ "system-1",
23
+ "laya"
24
+ ],
6
25
  "engines": {
7
26
  "node": ">=22"
8
27
  },