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, 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.
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.
- Node.js: v22.0.0 or higher
- Python: 3.10 or higher (for the Laya System-1 control plane)
- SQLite: Local SQLite runtime support
Global install (recommended):
npm install -g @asobacloud/nehandaDuring install, npm automatically provisions a dedicated Python venv and downloads the Laya checkpoint (~808 MB). This happens once — subsequent starts are instant.
From source:
git clone https://github.com/AsobaCloud/nehanda-cli.git
cd nehanda-cli
npm installThe same one-time provisioning runs automatically as part of npm install.
nehandaOr via the aliases installed with the package:
ona
ona-codeThe CLI defaults to the primary Nehanda endpoint (https://nehanda-ml.asoba.co/v1). If an API key is required:
❯ /key
New Nehanda API key: <your-key>
Start LM Studio locally on port 1234 or 8000, then start the CLI. Select or switch models via:
❯ /model
To connect to an Ollama instance running on your network:
❯ /config base_url http://AsobaCorp-1.local:11434/v1
❯ /model ollama/deepseek-coder-v2:latest
-
In-Process Engine: Executes turns directly inside the process via
runUserTurn, eliminating separate server daemons or background HTTP relays. -
ODSE Energy Normalization:
lib/energy-middleware.mjsintercepts 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. -
Jev-Mem Memory Architecture: Every turn, a System-1 control plane (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_contexttable and reattached verbatim before every System-2 prompt, preventing model drift and hallucinated parameters across arbitrarily long research sessions. See Jev-Mem Architecture below. -
Dynamic Tool Rescue (
[TOOL_CALL]): Native support for endpoints that strip OpenAI tool schemas (such asnehandaMlProxy). 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_xmlstop-token interception. -
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. -
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 aBlockConfirmMenu([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. -
Runtime Visibility:
RuntimeHealthMonitorprobes 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. -
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. -
Multi-Provider Switching: Seamlessly switch between Nehanda 27B, local LM Studio, Ollama instances over LAN, Anthropic Claude, or any OpenAI-compatible API using
/model.
nehanda-cli combines an Ink TUI with a deterministic orchestration engine:
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 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.
Brand resolution happens in two passes:
- Name-based: The MCP server name is parsed for a known OEM key.
energy-huawei,mcp-solaredge,power-sungrow-v2all resolve correctly. Any prefix before the OEM segment is stripped. - 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.
If neither pass identifies the OEM, the payload is returned unchanged (pass-through).
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.
Name energy MCP servers with the OEM as a segment in the server name:
{
"mcpServers": {
"energy-huawei": { "command": "...", "args": ["..."] },
"energy-solaredge": { "command": "...", "args": ["..."] }
}
}No additional configuration is required — normalization is automatic.
The transform script accepts a --format flag for direct use:
echo '<payload>' | python3 lib/scripts/odse-transform.py --source huawei --format table
echo '<payload>' | python3 lib/scripts/odse-transform.py --source solaredge --format ndjson
echo '<payload>' | python3 lib/scripts/odse-transform.py --source sungrow --format summary| Format | Output | Default when |
|---|---|---|
table |
Box-drawing ASCII table with column headers and row count | stdout is a TTY |
ndjson |
One JSON object per line | stdout is piped |
summary |
Metadata header (rows, shape, source) + JSON array |
explicit flag only |
nehanda-cli implements the memory architecture from "System-One-Controlled Agentic Memory for Efficient AI Agents" (Jiang, Li & Li, UT Dallas — arXiv: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.
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.
The System-1 control plane is 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.
Observation (user / tool / assistant turn)
│
▼
┌── WRITE PATH (System 1 — Laya) ───────────────────────────────┐
│ type scores: episodic / semantic / procedural / preference │
│ → deterministic TopK candidates (FTS + entities + recency) │
│ → Laya relation judgments (semantic/causal/episode/entity) │
│ → insert edges iff P(relation) ≥ θ_rel │
│ → update laya_jev_state (task_status, file_target, escalate) │
└───────────────────────────────────────────────────────────────┘
│
▼ SQLite memory plane
nodes + edges (semantic | temporal | causal | entity)
+ FTS/lexical index
│
▼
┌── READ PATH (System 1 — Laya) ────────────────────────────────┐
│ route: which graph views matter for this query │
│ → RRF anchors (FTS + recency) │
│ → assess sufficiency / utility / contradiction │
│ → expand under budget → rescore → stop │
└───────────────────────────────────────────────────────────────┘
│
▼
┌── SYSTEM 2 PROMPT ────────────────────────────────────────────┐
│ [PINNED DOMAIN INVARIANTS] (ODSE schema, asset specs, ...) │
│ [CANONICAL SYSTEM 1 STATE] (task_status, file_target, ...) │
│ [RETRIEVED EVIDENCE] (top-K memories, not raw transcript) │
│ last 2 turns + last failed tool result │
└───────────────────────────────────────────────────────────────┘
- Observations are always persisted on ingest — nothing is dropped on write.
- Candidate discovery is deterministic (FTS + recency) before any Laya relation judgments.
- Temporal and entity edges are derived from structure where possible; Laya is invoked only when needed.
- Retrieval is a closed loop: route → retrieve → assess → expand → reassess → stop on sufficiency.
- System-2 only synthesizes; it does not route, score, or stop the retrieval loop.
| Weights | convaiinnovations/laya — Apache 2.0, open weights |
| Size | 421M parameters (ModernBERT-large backbone + decision head) |
| Latency | ~33 ms / question on GPU; ~200 ms on CPU (MPS on Apple Silicon) |
| Training | RLCD — reinforcement learning against strictly proper scoring rules; honest probabilities are the only way to maximise reward |
| Question types | noul (probability), choice (categorical), score (ordinal) |
| Languages | English checkpoint (root); laya-multilingual covers 100+ languages |
On first startup, lib/scripts/ensure-laya-env.sh creates a dedicated venv at ~/.nehanda/venv and downloads the checkpoint. The sidecar is then warmed before the first turn so all subsequent predictions hit the hot path. If provisioning fails, the CLI exits with a clear error — Laya is not an optional feature.
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).
| Table | Purpose |
|---|---|
memories |
Canonical node store — one row per observation, with overlapping type scores (episodic / semantic / procedural / preference), entity tags, and provenance |
memories_fts |
FTS5 index over memory content for lexical anchor retrieval |
memory_edges |
Multi-relational edges — same node pair may have independent semantic, temporal, causal, and entity edges |
laya_jev_state |
Session control block: task_status, file_target, escalation_risk, confidence_score |
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. |
The Nehanda vLLM deployment runs with --tool-call-parser qwen3_xml and --enable-auto-tool-choice flags. The nehandaMlProxy Lambda function strips tools and tool_choice from requests before forwarding to vLLM to avoid a Qwen3 chat template bug where the presence of a tools array causes system message ordering errors.
To enable tool calling despite this constraint, the engine uses a rescue path:
- System Prompt Injection:
buildXmlToolInstructions()injects tool schemas into the system prompt using[TOOL_CALL]...[/TOOL_CALL]delimiters. - Late Directive Injection: A
[SYSTEM DIRECTIVE]is appended to the final user message to defeat token recency bias on reasoning models. - Model Generation: The model emits tool calls in the
[TOOL_CALL]format within its response text. - Local Parsing & Execution:
parseXmlToolCalls()extracts and executes these calls locally viaexecuteBuiltinTool(), continuing the execution loop even when backends returnfinish_reason: "stop".
The vLLM --tool-call-parser qwen3_xml flag registers <tool_call> XML tags as stop/intercept tokens. When the model emits them in plain text, vLLM terminates generation mid-sentence and hands off to its native parser — which returns nothing because the plain-text path never populates message.tool_calls. This results in truncated responses containing only thinking traces.
Switching to [TOOL_CALL]...[/TOOL_CALL] delimiters bypasses vLLM's stop-token interception entirely, allowing the model to complete generation and return valid, parseable tool calls.
nehanda-cli supports a zero-code tool configuration pattern. Tools are registered by dropping a JSON config into lib/tools/ and a corresponding script into lib/scripts/. No JavaScript changes are required.
- On startup,
lib/tools.mjsscanslib/tools/*.jsonand dynamically registers each tool. - Tool schemas are injected into the API
toolsarray so the model can call them. - Phase visibility (
explore_only,planning_blocked) and mandatory enforcement (mandatory_in) are driven by config metadata. - Prompt injection (
prompt.mandatory_instruction,prompt.available_hint) is read from the config and injected into the appropriate SDLC phase system prompts automatically.
| Tool | Script | What It Checks |
|---|---|---|
AuditCodeIntegrity |
lib/scripts/audit-code-integrity.py |
Lifecycle teardown parity, mock-theater tests, naming invariants, swallowed exceptions |
ShellSafetyChecker |
lib/scripts/shell-safety-checker.sh |
Missing set -euo pipefail, background job silent failure risk, hardcoded credentials |
JsSafetyChecker |
lib/scripts/js-safety-checker.cjs |
Duplicate functions, duplicate HTML element IDs, script block syntax errors |
PythonSafetyChecker |
lib/scripts/python-safety-checker.py |
Bandit security issues, ruff lint, mutable default args, eval/exec/pickle usage |
All four are mandatory in the test phase and available on-demand in explore and idle phases.
Create a JSON config in lib/tools/:
{
"name": "MyNewTool",
"description": "What the tool does.",
"input_schema": {
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target path" }
}
},
"phases": {
"explore_only": true,
"planning_blocked": false,
"mandatory_in": ["test"]
},
"prompt": {
"mandatory_instruction": "Run MyNewTool on the workspace to check for X.",
"available_hint": "Checks for X, Y, and Z"
},
"execution": {
"runtime": "python3",
"script": "lib/scripts/my-new-tool.py",
"args": ["{{target}}"],
"default_timeout": 120000,
"max_timeout": 600000
}
}Place the script at lib/scripts/my-new-tool.py. It will receive arguments interpolated from args and run with cwd set to the target workspace. No JS code changes needed.
| Command | Description |
|---|---|
/help |
Display available commands |
/model [name] |
Discover and switch active provider or model endpoint |
/key |
Save Nehanda API key |
/config |
View or set settings (e.g., /config base_url <url>) |
/mcp |
Manage MCP server connections (see below) |
/clear |
Clear conversation history and reset transcript state |
/retry |
Resend the last failed request |
/exit |
Exit the REPL |
| Sub-command | Description |
|---|---|
/mcp status |
List all configured MCP servers and their commands |
/mcp list |
Connect to each server and enumerate available tools |
/mcp reload [server] |
Re-read mcp.json and bust the tool cache (optionally for one server) |
/mcp add <name> <command> [args…] |
Register a new server and save it to ~/.config/nehanda/mcp.json |
/mcp env |
Show environment variables for all configured servers |
/mcp env <server> |
Show environment variables for a specific server |
/mcp env <server> <KEY> <value> |
Set an environment variable (e.g. an API key) |
/mcp env <server> <KEY> |
Clear an environment variable |
nehanda-cli can consume tools from any external MCP server. Servers are configured in a standard mcp.json file using the same schema as Claude Desktop and other MCP clients.
Config file locations (both are read and merged at startup; project-local takes priority):
./mcp.json— project-local, committed with the repo~/.config/nehanda/mcp.json— user global
Example mcp.json:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
},
"powermcp": {
"command": "npx",
"args": ["-y", "harvard-powermcp"],
"env": { "API_KEY": "your-key" }
}
}
}At startup, nehanda-cli loads mcp.json, spawns the configured servers, and calls tools/list on each. Discovered tools are injected into the model's tool list under the namespace mcp__<server>__<tool> and are available for the model to call during any conversation turn — no additional configuration required.
Use /mcp list to verify what tools are visible, and /mcp reload to pick up changes without restarting.
Many MCP servers require an API key or other secrets. Rather than editing mcp.json by hand, use the /mcp env command directly from the REPL:
❯ /mcp env # show env vars for all servers
❯ /mcp env asoba # show env vars for 'asoba' server
❯ /mcp env asoba ASOBA_API_KEY sk-abc123... # set the key
✓ Set ASOBA_API_KEY = ******** on asoba
Config file: ~/.config/nehanda/mcp.json
This writes the value to the mcp.json file where the server is defined (project-local ./mcp.json takes priority over global ~/.config/nehanda/mcp.json), then automatically reloads the server so the change takes effect immediately.
To clear a key:
❯ /mcp env asoba ASOBA_API_KEY
✓ Cleared ASOBA_API_KEY on asoba
Tip: Keys are masked in /mcp env output. Only the first 6 and last 4 characters are shown.
mcp.json ships with a jupyter server pre-configured pointing at localhost:8888. To connect it to your JupyterLab instance:
❯ /mcp env jupyter JUPYTER_URL http://my-server:8888
❯ /mcp env jupyter JUPYTER_TOKEN <your-token>
Both values are written to ~/.config/nehanda/mcp.json and never committed. The server reloads automatically.
Use /config set <dot.path> <value> to set any configuration value without editing files:
❯ /config set model_config.base_url https://api.anthropic.com
❯ /config set model_config.num_ctx 8192
Numeric values are auto-converted. Changes persist in the local settings database.
The engine enforces state transitions across six distinct phases:
-
idle: Discovery and triage. Mutating file tools are physically masked out. Safety and analysis tools are available on-demand. -
plan: Model formulates success criteria and implementation steps (EnterPlanMode). No tools available. -
implement: Code changes applied using file editing and shell execution (ExitPlanMode). -
test: Automated test generation and execution (SubmitImplementation). After tests pass, all tools markedmandatory_in: ["test"]must be run. The system prompt enforces this — the model cannot declare success until all mandatory safety checkers pass. -
verify: Inspection of test outputs and coverage verification (SubmitTest). -
done: Final sign-off and git commit creation.
Session state is persisted locally at ~/.config/nehanda/ona-session.db. Key tables include:
conversations: Active workflow phases and project roots.transcript_entries: Sequence of user messages, assistant turns, tool calls, and results.plans: Content, hashes, and approval status for technical plans.events: SDLC milestones and test execution output.memories: Jev-Mem canonical node store with overlapping type scores and entity provenance.memory_edges: Multi-relational graph edges (semantic / temporal / causal / entity).laya_jev_state: System-1 session control block (task_status, file_target, escalation_risk).pinned_context: Session-scoped domain invariants (ODSE schema mappings, asset specs, simulation bounds) that survive compaction unchanged.
Run the acceptance suite:
npm run acceptanceVerify SDLC hook ordering:
npm run verifyRun the Jev-Mem behavioral suite directly:
node tests/unit/jev_write_behavioral.mjs
node tests/unit/jev_read_behavioral.mjs
node tests/unit/compact_jev_behavioral.mjs
node tests/unit/orchestrate_jev_hotpath_behavioral.mjsRun the UX improvement behavioral suites:
node tests/unit/gap1_runtime_visibility_behavioral.mjs
node tests/unit/gap2_output_formatting_behavioral.mjs
node tests/unit/gap3_safety_intervention_behavioral.mjs
node tests/unit/gap4_pinned_compaction_behavioral.mjs- Runtime Visibility (
lib/modelDiscovery.mjs,lib/ui.mjs): NewRuntimeHealthMonitorclass probes active inference endpoints (1s HEAD request, Kubernetes readiness pattern) and checks Laya sidecar liveness viapgrep.renderStatusHeaderandrenderModelSwitchNoticeadded tolib/ui.mjs— the TUI now shows active model, endpoint health + latency, sidecar PID, and context usage percentage after startup and on every/modelswitch. - Structured Output Formatting (
lib/ui.mjs,lib/scripts/odse-transform.py):formatToolResultnow detects JSON array payloads from tool responses and renders them ascli-table3box-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.pygains a--format table|ndjson|summaryflag; defaults totablewhen stdout is a TTY andndjsonwhen piped. - Interactive Safety Interventions (
lib/bashguard.mjs,lib/tools.mjs,bin/nehanda-ui.mjs):validateBashCommandnow returns a structured{ status, reason, risk_score, proposed_remediation }payload instead of a flat string. When the TUI is active, blocked commands raise aBlockConfirmMenu([A] Approve for Session | [S] Run in Laya Sandbox | [C] Cancel) wired directly into the Ink event loop — no blocking readline, no crash. Session approvals are cached in-memory viacacheSessionApprovalso the same operation is not re-prompted within a session. Safety checker scripts (python-safety-checker.py,shell-safety-checker.sh) emit the structured JSON payload to stderr on exit 1 so the TUI can surface them identically. Headless pipe mode auto-denies without prompting. - Pinned Context Preservation (
lib/store.mjs,lib/compact.mjs): Newpinned_contexttable (schema v3) stores session-scoped invariants — ODSE schema mappings, asset specs, simulation bounds — keyed by string.compactConversationandbuildJevCollapseSummaryexclude pinned blocks from the distillation summariser and reattach them verbatim in thecollapse_commitpayload.compactWithJevprepends pinned blocks before the canonical System-1 header in every System-2 prompt, ensuring domain rules survive arbitrarily long sessions. - Added
cli-table3@0.6.5as a production dependency. - Fixed dead
has_any_assertvariable inlib/scripts/audit-code-integrity.py(ruff F841).
- Published as
@asobacloud/nehandaon npm — install withnpm install -g @asobacloud/nehanda - Laya System-1 venv provisioning moved to
npm installtime viascripts/postinstall.mjs— CLI starts instantly on every subsequent launch - Laya provisioning now streams live pip progress to the terminal instead of running silently
- Added project-local
mcp.jsonwithdatalayer/jupyter-mcp-serverpreconfigured for JupyterLab — set your server URL and token with/mcp env jupyter JUPYTER_URL <url>and/mcp env jupyter JUPYTER_TOKEN <token>
- Jev-Mem memory architecture (System-1 Laya control plane)
- Dynamic Tool Rescue (
[TOOL_CALL]delimiter path) - Deterministic 6-phase SDLC workflow
- Multi-provider switching
Jiang, Z., Li, Y., & Li, J. (2026). System-One-Controlled Agentic Memory for Efficient AI Agents. arXiv:2609.23986. https://arxiv.org/abs/2609.23986
Convai Innovations. Laya: Multilingual, non-autoregressive System-1 decision model. Hugging Face. https://huggingface.co/convaiinnovations/laya
See LICENSE for details.