@asobacloud/nehanda 0.3.0 → 0.5.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 +81 -3
- package/bin/nehanda-ui.mjs +54 -4
- package/lib/bashguard.mjs +48 -22
- package/lib/compact.mjs +46 -7
- package/lib/modelDiscovery.mjs +97 -0
- package/lib/permissions.mjs +30 -0
- package/lib/scripts/audit-code-integrity.py +1 -3
- package/lib/scripts/odse-transform.py +100 -7
- package/lib/scripts/python-safety-checker.py +11 -0
- package/lib/scripts/shell-safety-checker.sh +4 -0
- package/lib/store.mjs +57 -5
- package/lib/tools.mjs +72 -4
- package/lib/ui.mjs +100 -0
- package/package.json +2 -1
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
|
|
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,18 @@ 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
|
-
* **
|
|
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.
|
|
87
89
|
|
|
88
90
|
* **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
91
|
|
|
90
92
|
* **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
93
|
|
|
94
|
+
* **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.
|
|
95
|
+
|
|
96
|
+
* **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.
|
|
97
|
+
|
|
92
98
|
* **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
99
|
|
|
94
100
|
* **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 +109,62 @@ To connect to an Ollama instance running on your network:
|
|
|
103
109
|
|
|
104
110
|
---
|
|
105
111
|
|
|
112
|
+
## ODSE Energy Normalization
|
|
113
|
+
|
|
114
|
+
`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.
|
|
115
|
+
|
|
116
|
+
### OEM Detection
|
|
117
|
+
|
|
118
|
+
Brand resolution happens in two passes:
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
If neither pass identifies the OEM, the payload is returned unchanged (pass-through).
|
|
124
|
+
|
|
125
|
+
### Supported OEMs
|
|
126
|
+
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
### MCP Server Naming Convention
|
|
130
|
+
|
|
131
|
+
Name energy MCP servers with the OEM as a segment in the server name:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"mcpServers": {
|
|
136
|
+
"energy-huawei": { "command": "...", "args": ["..."] },
|
|
137
|
+
"energy-solaredge": { "command": "...", "args": ["..."] }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
No additional configuration is required — normalization is automatic.
|
|
143
|
+
|
|
144
|
+
### `odse-transform.py` Output Formats
|
|
145
|
+
|
|
146
|
+
The transform script accepts a `--format` flag for direct use:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
echo '<payload>' | python3 lib/scripts/odse-transform.py --source huawei --format table
|
|
150
|
+
echo '<payload>' | python3 lib/scripts/odse-transform.py --source solaredge --format ndjson
|
|
151
|
+
echo '<payload>' | python3 lib/scripts/odse-transform.py --source sungrow --format summary
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Format | Output | Default when |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `table` | Box-drawing ASCII table with column headers and row count | stdout is a TTY |
|
|
157
|
+
| `ndjson` | One JSON object per line | stdout is piped |
|
|
158
|
+
| `summary` | Metadata header (`rows`, `shape`, `source`) + JSON array | explicit flag only |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
106
162
|
## Jev-Mem Architecture
|
|
107
163
|
|
|
108
164
|
`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
165
|
|
|
166
|
+
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.
|
|
167
|
+
|
|
110
168
|
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
169
|
|
|
112
170
|
```
|
|
@@ -135,6 +193,7 @@ Observation (user / tool / assistant turn)
|
|
|
135
193
|
│
|
|
136
194
|
▼
|
|
137
195
|
┌── SYSTEM 2 PROMPT ────────────────────────────────────────────┐
|
|
196
|
+
│ [PINNED DOMAIN INVARIANTS] (ODSE schema, asset specs, ...) │
|
|
138
197
|
│ [CANONICAL SYSTEM 1 STATE] (task_status, file_target, ...) │
|
|
139
198
|
│ [RETRIEVED EVIDENCE] (top-K memories, not raw transcript) │
|
|
140
199
|
│ last 2 turns + last failed tool result │
|
|
@@ -164,7 +223,7 @@ On first startup, `lib/scripts/ensure-laya-env.sh` creates a dedicated venv at `
|
|
|
164
223
|
|
|
165
224
|
### Memory plane (SQLite)
|
|
166
225
|
|
|
167
|
-
The memory plane extends the existing `memories` table and adds `memory_edges` and `
|
|
226
|
+
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
227
|
|
|
169
228
|
| Table | Purpose |
|
|
170
229
|
|---|---|
|
|
@@ -172,6 +231,7 @@ The memory plane extends the existing `memories` table and adds `memory_edges` a
|
|
|
172
231
|
| `memories_fts` | FTS5 index over memory content for lexical anchor retrieval |
|
|
173
232
|
| `memory_edges` | Multi-relational edges — same node pair may have independent semantic, temporal, causal, and entity edges |
|
|
174
233
|
| `laya_jev_state` | Session control block: `task_status`, `file_target`, `escalation_risk`, `confidence_score` |
|
|
234
|
+
| `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
235
|
|
|
176
236
|
---
|
|
177
237
|
|
|
@@ -388,6 +448,7 @@ Session state is persisted locally at `~/.config/nehanda/ona-session.db`. Key ta
|
|
|
388
448
|
* `memories`: Jev-Mem canonical node store with overlapping type scores and entity provenance.
|
|
389
449
|
* `memory_edges`: Multi-relational graph edges (semantic / temporal / causal / entity).
|
|
390
450
|
* `laya_jev_state`: System-1 session control block (task_status, file_target, escalation_risk).
|
|
451
|
+
* `pinned_context`: Session-scoped domain invariants (ODSE schema mappings, asset specs, simulation bounds) that survive compaction unchanged.
|
|
391
452
|
|
|
392
453
|
---
|
|
393
454
|
|
|
@@ -414,10 +475,27 @@ node tests/unit/compact_jev_behavioral.mjs
|
|
|
414
475
|
node tests/unit/orchestrate_jev_hotpath_behavioral.mjs
|
|
415
476
|
```
|
|
416
477
|
|
|
478
|
+
Run the UX improvement behavioral suites:
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
node tests/unit/gap1_runtime_visibility_behavioral.mjs
|
|
482
|
+
node tests/unit/gap2_output_formatting_behavioral.mjs
|
|
483
|
+
node tests/unit/gap3_safety_intervention_behavioral.mjs
|
|
484
|
+
node tests/unit/gap4_pinned_compaction_behavioral.mjs
|
|
485
|
+
```
|
|
486
|
+
|
|
417
487
|
---
|
|
418
488
|
|
|
419
489
|
## Changelog
|
|
420
490
|
|
|
491
|
+
### 0.4.0
|
|
492
|
+
- **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.
|
|
493
|
+
- **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.
|
|
494
|
+
- **Interactive Safety Interventions (`lib/bashguard.mjs`, `lib/tools.mjs`, `bin/nehanda-ui.mjs`):** `validateBashCommand` now returns a structured `{ status, reason, risk_score, proposed_remediation }` payload instead of a flat string. When the TUI is active, blocked commands raise a `BlockConfirmMenu` (`[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 via `cacheSessionApproval` so 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.
|
|
495
|
+
- **Pinned Context Preservation (`lib/store.mjs`, `lib/compact.mjs`):** New `pinned_context` table (schema v3) stores session-scoped invariants — ODSE schema mappings, asset specs, simulation bounds — keyed by string. `compactConversation` and `buildJevCollapseSummary` exclude pinned blocks from the distillation summariser and reattach them verbatim in the `collapse_commit` payload. `compactWithJev` prepends pinned blocks before the canonical System-1 header in every System-2 prompt, ensuring domain rules survive arbitrarily long sessions.
|
|
496
|
+
- Added `cli-table3@0.6.5` as a production dependency.
|
|
497
|
+
- Fixed dead `has_any_assert` variable in `lib/scripts/audit-code-integrity.py` (ruff F841).
|
|
498
|
+
|
|
421
499
|
### 0.3.0
|
|
422
500
|
- Published as `@asobacloud/nehanda` on npm — install with `npm install -g @asobacloud/nehanda`
|
|
423
501
|
- Laya System-1 venv provisioning moved to `npm install` time via `scripts/postinstall.mjs` — CLI starts instantly on every subsequent launch
|
package/bin/nehanda-ui.mjs
CHANGED
|
@@ -345,8 +345,45 @@ function SelectMenu({ title, items, onSelect, onCancel }) {
|
|
|
345
345
|
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
346
346
|
'↑↓ navigate · Enter select' + (items.length <= 9 ? ` · 1–${items.length} jump` : '') + ' · Esc cancel')))
|
|
347
347
|
}
|
|
348
|
+
|
|
349
|
+
// ── Block Confirmation Menu (Gap 3) ──────────────────────────
|
|
350
|
+
// Raised when bashguard or a safety-checker script blocks a command.
|
|
351
|
+
// Resolves the Promise with 'approve' | 'sandbox' | 'cancel'.
|
|
352
|
+
function BlockConfirmMenu({ payload, onDecide }) {
|
|
353
|
+
useInput((input, key) => {
|
|
354
|
+
const ch = input.toLowerCase()
|
|
355
|
+
if (key.escape || ch === 'c') { onDecide('cancel'); return }
|
|
356
|
+
if (ch === 'a') { onDecide('approve'); return }
|
|
357
|
+
if (ch === 's') { onDecide('sandbox'); return }
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const riskColor = payload.risk_score === 'high' ? BRAND.red : BRAND.coral
|
|
361
|
+
const remediation = (payload.proposed_remediation || []).join(', ')
|
|
362
|
+
|
|
363
|
+
return e(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: BRAND.red, marginY: 1 },
|
|
364
|
+
e(Box, { paddingX: 1, paddingY: 0 },
|
|
365
|
+
e(Text, { bold: true, color: BRAND.red }, '⛔ Command Blocked')),
|
|
366
|
+
e(Box, { paddingX: 1 },
|
|
367
|
+
e(Text, { dimColor: true }, 'Reason: '),
|
|
368
|
+
e(Text, { wrap: 'wrap' }, payload.reason || '(unknown)')),
|
|
369
|
+
e(Box, { paddingX: 1 },
|
|
370
|
+
e(Text, { dimColor: true }, 'Risk: '),
|
|
371
|
+
e(Text, { color: riskColor, bold: true }, (payload.risk_score || 'unknown').toUpperCase())),
|
|
372
|
+
remediation
|
|
373
|
+
? e(Box, { paddingX: 1 },
|
|
374
|
+
e(Text, { dimColor: true }, 'Remediation: '),
|
|
375
|
+
e(Text, { dimColor: true }, remediation))
|
|
376
|
+
: null,
|
|
377
|
+
e(Box, { paddingX: 1, marginTop: 1 },
|
|
378
|
+
e(Text, { bold: true, color: BRAND.accent }, '[A]'),
|
|
379
|
+
e(Text, {}, ' Approve for Session '),
|
|
380
|
+
e(Text, { bold: true, color: BRAND.lavender }, '[S]'),
|
|
381
|
+
e(Text, {}, ' Run in Laya Sandbox '),
|
|
382
|
+
e(Text, { bold: true, color: BRAND.dim }, '[C]'),
|
|
383
|
+
e(Text, {}, ' Cancel')))
|
|
384
|
+
}
|
|
385
|
+
|
|
348
386
|
function TokenFooter({ calls, model }) {
|
|
349
|
-
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
350
387
|
return e(Box, { flexDirection: 'column' },
|
|
351
388
|
e(Text, { dimColor: true }, '─'.repeat(cols)),
|
|
352
389
|
e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
|
|
@@ -365,6 +402,7 @@ function App({ bridge }) {
|
|
|
365
402
|
const [isLoading, setIsLoading] = React.useState(false)
|
|
366
403
|
const [askState, setAskState] = React.useState(null)
|
|
367
404
|
const [selectState, setSelectState] = React.useState(null)
|
|
405
|
+
const [blockState, setBlockState] = React.useState(null)
|
|
368
406
|
const [callCount, setCallCount] = React.useState(0)
|
|
369
407
|
const [modelInfo, setModelInfo] = React.useState(() => {
|
|
370
408
|
const data = readAgents()
|
|
@@ -385,6 +423,7 @@ function App({ bridge }) {
|
|
|
385
423
|
bridge.setLoading = v => setIsLoading(v)
|
|
386
424
|
bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
|
|
387
425
|
bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
|
|
426
|
+
bridge.confirmBlock = payload => new Promise(resolve => setBlockState({ payload, resolve }))
|
|
388
427
|
bridge.exit = () => { exit() }
|
|
389
428
|
bridge.refreshModel = () => {
|
|
390
429
|
const data = readAgents()
|
|
@@ -403,6 +442,14 @@ function App({ bridge }) {
|
|
|
403
442
|
setSelectState(null)
|
|
404
443
|
}, [selectState])
|
|
405
444
|
const handleSelectCancel = React.useCallback(() => { selectState?.resolve(null); setSelectState(null) }, [selectState])
|
|
445
|
+
const handleBlock = React.useCallback(decision => {
|
|
446
|
+
if (blockState?.resolve) {
|
|
447
|
+
const label = decision === 'approve' ? '✓ Approved for session' : decision === 'sandbox' ? '⚙ Running in sandbox' : '✗ Cancelled'
|
|
448
|
+
setFinalized(p => [...p, { role: 'system', text: ` ${label}` }])
|
|
449
|
+
blockState.resolve(decision)
|
|
450
|
+
}
|
|
451
|
+
setBlockState(null)
|
|
452
|
+
}, [blockState])
|
|
406
453
|
|
|
407
454
|
useInput((input, key) => {
|
|
408
455
|
if (key.escape) { if (isLoading && bridge.abortCurrent) bridge.abortCurrent(); return }
|
|
@@ -427,9 +474,11 @@ function App({ bridge }) {
|
|
|
427
474
|
isLoading && (!streaming || !streaming.text) ? e(Spinner, {}) : null,
|
|
428
475
|
askState
|
|
429
476
|
? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
430
|
-
:
|
|
431
|
-
? e(
|
|
432
|
-
:
|
|
477
|
+
: blockState
|
|
478
|
+
? e(BlockConfirmMenu, { payload: blockState.payload, onDecide: handleBlock })
|
|
479
|
+
: selectState
|
|
480
|
+
? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
|
|
481
|
+
: e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
|
|
433
482
|
e(TokenFooter, { calls: callCount, model: modelInfo.model }))
|
|
434
483
|
}
|
|
435
484
|
|
|
@@ -780,6 +829,7 @@ bridge.onSubmit = async (text) => {
|
|
|
780
829
|
println(msg) { if (!aborted) bridge.addMessage({ role: 'system', text: ' ' + msg }) },
|
|
781
830
|
spinner: { start() {}, stop() {} },
|
|
782
831
|
ask: bridge.ask,
|
|
832
|
+
confirmBlock: payload => bridge.confirmBlock(payload),
|
|
783
833
|
onToolStart(name) { if (!aborted) bridge.addMessage({ role: 'system', text: ` ⚙ ${name}` }) },
|
|
784
834
|
onToolResult(name, c, err) {
|
|
785
835
|
if (aborted) return
|
package/lib/bashguard.mjs
CHANGED
|
@@ -3,7 +3,15 @@ import path from 'node:path'
|
|
|
3
3
|
/**
|
|
4
4
|
* Validate a bash command before execution.
|
|
5
5
|
* Blocks destructive commands everywhere and filesystem writes outside cwd.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Returns { ok: true } on success, or a structured blocked payload:
|
|
8
|
+
* {
|
|
9
|
+
* ok: false,
|
|
10
|
+
* status: 'blocked',
|
|
11
|
+
* reason: string, — human-readable description
|
|
12
|
+
* risk_score: 'high'|'medium',
|
|
13
|
+
* proposed_remediation: string[]
|
|
14
|
+
* }
|
|
7
15
|
*/
|
|
8
16
|
export function validateBashCommand(cmd, cwd) {
|
|
9
17
|
// Split on pipes, semicolons, &&, || to check each segment
|
|
@@ -21,6 +29,24 @@ export function validateBashCommand(cmd, cwd) {
|
|
|
21
29
|
return { ok: true }
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Build a structured blocked payload.
|
|
34
|
+
* @param {string} reason - Human-readable block reason
|
|
35
|
+
* @param {'high'|'medium'} riskScore
|
|
36
|
+
* @param {string[]} [remediation]
|
|
37
|
+
*/
|
|
38
|
+
function blocked(reason, riskScore = 'high', remediation = []) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: 'blocked',
|
|
42
|
+
reason,
|
|
43
|
+
risk_score: riskScore,
|
|
44
|
+
proposed_remediation: remediation.length
|
|
45
|
+
? remediation
|
|
46
|
+
: ['review_command', 'sandbox'],
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
function checkSegment(seg, cwd) {
|
|
25
51
|
const lower = seg.toLowerCase()
|
|
26
52
|
const tokens = seg.split(/\s+/)
|
|
@@ -33,28 +59,28 @@ function checkSegment(seg, cwd) {
|
|
|
33
59
|
for (const t of rmTargets) {
|
|
34
60
|
const resolved = resolvePath(t, cwd)
|
|
35
61
|
if (isDangerousPath(resolved, cwd)) {
|
|
36
|
-
return
|
|
62
|
+
return blocked(`rm -rf targeting dangerous path: ${t}`, 'high', ['review_command'])
|
|
37
63
|
}
|
|
38
64
|
}
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
// git destructive operations
|
|
42
|
-
if (/\bgit\s+checkout\s+--/.test(seg)) return
|
|
43
|
-
if (/\bgit\s+reset\s+--hard/.test(seg)) return
|
|
44
|
-
if (/\bgit\s+push\s+(-f|--force)/.test(seg)) return
|
|
45
|
-
if (/\bgit\s+clean\s+-[fd]/.test(seg)) return
|
|
46
|
-
if (/\bgit\s+branch\s+-D\b/.test(seg)) return
|
|
47
|
-
if (/\bgit\s+stash\s+drop/.test(seg)) return
|
|
48
|
-
if (/\bgit\s+reflog\s+expire/.test(seg)) return
|
|
68
|
+
if (/\bgit\s+checkout\s+--/.test(seg)) return blocked('git checkout -- discards changes (destructive)', 'high', ['review_command', 'use_git_stash'])
|
|
69
|
+
if (/\bgit\s+reset\s+--hard/.test(seg)) return blocked('git reset --hard (destructive)', 'high', ['review_command', 'use_git_stash'])
|
|
70
|
+
if (/\bgit\s+push\s+(-f|--force)/.test(seg)) return blocked('git push --force (destructive)', 'high', ['review_command'])
|
|
71
|
+
if (/\bgit\s+clean\s+-[fd]/.test(seg)) return blocked('git clean (destructive)', 'high', ['review_command'])
|
|
72
|
+
if (/\bgit\s+branch\s+-D\b/.test(seg)) return blocked('git branch -D (destructive)', 'medium', ['review_command'])
|
|
73
|
+
if (/\bgit\s+stash\s+drop/.test(seg)) return blocked('git stash drop (destructive)', 'medium', ['review_command'])
|
|
74
|
+
if (/\bgit\s+reflog\s+expire/.test(seg)) return blocked('git reflog expire (destructive)', 'high', ['review_command'])
|
|
49
75
|
|
|
50
76
|
// System-level destructive commands
|
|
51
|
-
if (/\bmkfs\b/.test(lower)) return
|
|
52
|
-
if (/\bdd\s+if=/.test(lower)) return
|
|
53
|
-
if (/\bformat\b/.test(lower) && /\/dev\//.test(seg)) return
|
|
54
|
-
if (/\bkillall\b/.test(lower)) return
|
|
55
|
-
if (/\bkill\s+-9\b/.test(seg)) return
|
|
56
|
-
if (/\bsudo\b/.test(lower)) return
|
|
57
|
-
if (/\bsu\s/.test(lower)) return
|
|
77
|
+
if (/\bmkfs\b/.test(lower)) return blocked('mkfs (destructive)', 'high', ['review_command'])
|
|
78
|
+
if (/\bdd\s+if=/.test(lower)) return blocked('dd (destructive)', 'high', ['review_command'])
|
|
79
|
+
if (/\bformat\b/.test(lower) && /\/dev\//.test(seg)) return blocked('format device (destructive)', 'high', ['review_command'])
|
|
80
|
+
if (/\bkillall\b/.test(lower)) return blocked('killall (destructive)', 'high', ['review_command', 'sandbox'])
|
|
81
|
+
if (/\bkill\s+-9\b/.test(seg)) return blocked('kill -9 (destructive)', 'medium', ['review_command', 'sandbox'])
|
|
82
|
+
if (/\bsudo\b/.test(lower)) return blocked('sudo not permitted', 'high', ['review_command', 'sandbox'])
|
|
83
|
+
if (/\bsu\s/.test(lower)) return blocked('su not permitted', 'high', ['review_command'])
|
|
58
84
|
|
|
59
85
|
// ── Category 2: Filesystem writes outside cwd ──
|
|
60
86
|
|
|
@@ -65,7 +91,7 @@ function checkSegment(seg, cwd) {
|
|
|
65
91
|
if (target === '/dev/null') continue
|
|
66
92
|
const resolved = resolvePath(target, cwd)
|
|
67
93
|
if (!isUnderCwd(resolved, cwd)) {
|
|
68
|
-
return
|
|
94
|
+
return blocked(`Write redirect to path outside working directory: ${target}`, 'medium', ['review_command', 'sandbox'])
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
97
|
|
|
@@ -75,7 +101,7 @@ function checkSegment(seg, cwd) {
|
|
|
75
101
|
for (const t of teeArgs) {
|
|
76
102
|
const resolved = resolvePath(t, cwd)
|
|
77
103
|
if (!isUnderCwd(resolved, cwd)) {
|
|
78
|
-
return
|
|
104
|
+
return blocked(`tee to path outside working directory: ${t}`, 'medium', ['review_command', 'sandbox'])
|
|
79
105
|
}
|
|
80
106
|
}
|
|
81
107
|
}
|
|
@@ -88,7 +114,7 @@ function checkSegment(seg, cwd) {
|
|
|
88
114
|
const target = args[args.length - 1]
|
|
89
115
|
const resolved = resolvePath(target, cwd)
|
|
90
116
|
if (!isUnderCwd(resolved, cwd)) {
|
|
91
|
-
return
|
|
117
|
+
return blocked(`${cmd} target outside working directory: ${target}`, 'medium', ['review_command', 'sandbox'])
|
|
92
118
|
}
|
|
93
119
|
}
|
|
94
120
|
}
|
|
@@ -101,7 +127,7 @@ function checkSegment(seg, cwd) {
|
|
|
101
127
|
for (const a of args) {
|
|
102
128
|
const resolved = resolvePath(a, cwd)
|
|
103
129
|
if (!isUnderCwd(resolved, cwd)) {
|
|
104
|
-
return
|
|
130
|
+
return blocked(`${cmd} outside working directory: ${a}`, 'medium', ['review_command', 'sandbox'])
|
|
105
131
|
}
|
|
106
132
|
}
|
|
107
133
|
}
|
|
@@ -113,7 +139,7 @@ function checkSegment(seg, cwd) {
|
|
|
113
139
|
for (const a of sedArgs) {
|
|
114
140
|
const resolved = resolvePath(a, cwd)
|
|
115
141
|
if (!isUnderCwd(resolved, cwd)) {
|
|
116
|
-
return
|
|
142
|
+
return blocked(`sed -i on file outside working directory: ${a}`, 'medium', ['review_command', 'sandbox'])
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
}
|
|
@@ -126,7 +152,7 @@ function checkSegment(seg, cwd) {
|
|
|
126
152
|
if (/^[0-7]+$/.test(a) || /^[a-z]+:[a-z]+$/i.test(a)) continue // skip mode/owner args
|
|
127
153
|
const resolved = resolvePath(a, cwd)
|
|
128
154
|
if (!isUnderCwd(resolved, cwd)) {
|
|
129
|
-
return
|
|
155
|
+
return blocked(`${cmd} -R outside working directory: ${a}`, 'medium', ['review_command', 'sandbox'])
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
158
|
}
|
package/lib/compact.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendEntry } from './transcript.mjs'
|
|
2
|
-
import { withTransaction, getLayaJevState, upsertLayaJevState } from './store.mjs'
|
|
2
|
+
import { withTransaction, getLayaJevState, upsertLayaJevState, getPinnedContextBlocks } from './store.mjs'
|
|
3
3
|
import { runHooks } from './hookplane.mjs'
|
|
4
4
|
import { extractCanonicalState } from './laya.mjs'
|
|
5
5
|
import {
|
|
@@ -41,10 +41,13 @@ export async function compactConversation(db, rt, summarize, log) {
|
|
|
41
41
|
|
|
42
42
|
await runHooks(db, hookRt, 'PreCompact', { trigger: 'manual' })
|
|
43
43
|
|
|
44
|
+
// Fetch pinned blocks — these are never passed to the distillation summariser.
|
|
45
|
+
const pinnedBlocks = getPinnedContextBlocks(db, rt.sessionId)
|
|
46
|
+
|
|
44
47
|
log('Compacting conversation (Jev-Mem)...')
|
|
45
48
|
let summary
|
|
46
49
|
try {
|
|
47
|
-
summary = await buildJevCollapseSummary(db, rt)
|
|
50
|
+
summary = await buildJevCollapseSummary(db, rt, pinnedBlocks)
|
|
48
51
|
} catch {
|
|
49
52
|
const text = entries.map(e => {
|
|
50
53
|
try {
|
|
@@ -57,6 +60,10 @@ export async function compactConversation(db, rt, summarize, log) {
|
|
|
57
60
|
}).filter(Boolean).join('\n')
|
|
58
61
|
log('Jev compact failed; falling back to summarizer.')
|
|
59
62
|
summary = await summarize(text)
|
|
63
|
+
// Reattach pinned blocks verbatim even on fallback path.
|
|
64
|
+
if (pinnedBlocks.length) {
|
|
65
|
+
summary += '\n\n' + formatPinnedBlocksSection(pinnedBlocks)
|
|
66
|
+
}
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
withTransaction(db, () => {
|
|
@@ -66,6 +73,7 @@ export async function compactConversation(db, rt, summarize, log) {
|
|
|
66
73
|
compacted_count: entries.length,
|
|
67
74
|
compacted_from_sequence: startSeq,
|
|
68
75
|
jev: true,
|
|
76
|
+
pinned_keys: pinnedBlocks.map(b => b.key),
|
|
69
77
|
})
|
|
70
78
|
|
|
71
79
|
db.prepare(
|
|
@@ -79,7 +87,7 @@ export async function compactConversation(db, rt, summarize, log) {
|
|
|
79
87
|
return summary
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
async function buildJevCollapseSummary(db, rt) {
|
|
90
|
+
async function buildJevCollapseSummary(db, rt, pinnedBlocks = []) {
|
|
83
91
|
const paths = extractRecentFilePaths(db, rt.sessionId)
|
|
84
92
|
const tail = transcriptTailText(db, rt.sessionId)
|
|
85
93
|
const state = await extractCanonicalState(tail, paths, rt.layaOpts || {})
|
|
@@ -89,7 +97,11 @@ async function buildJevCollapseSummary(db, rt) {
|
|
|
89
97
|
conversationId: rt.conversationId,
|
|
90
98
|
layaOpts: rt.layaOpts || {},
|
|
91
99
|
})
|
|
92
|
-
|
|
100
|
+
let summary = formatCanonicalHeader(state) + '\n' + formatEvidenceBlock(retrieved.evidence)
|
|
101
|
+
if (pinnedBlocks.length) {
|
|
102
|
+
summary += '\n\n' + formatPinnedBlocksSection(pinnedBlocks)
|
|
103
|
+
}
|
|
104
|
+
return summary
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
export function formatCanonicalHeader(layaState) {
|
|
@@ -117,6 +129,21 @@ export function formatEvidenceBlock(evidence = []) {
|
|
|
117
129
|
return ['[RETRIEVED EVIDENCE]', ...lines, '--------------------------------------------------'].join('\n')
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Serialise pinned context blocks into a labelled section that is appended
|
|
134
|
+
* verbatim to the collapse_commit summary — never distilled.
|
|
135
|
+
*/
|
|
136
|
+
export function formatPinnedBlocksSection(blocks = []) {
|
|
137
|
+
if (!blocks.length) return ''
|
|
138
|
+
const lines = ['[PINNED DOMAIN INVARIANTS]']
|
|
139
|
+
for (const b of blocks) {
|
|
140
|
+
lines.push(`-- ${b.key} --`)
|
|
141
|
+
lines.push(String(b.content || ''))
|
|
142
|
+
}
|
|
143
|
+
lines.push('--------------------------------------------------')
|
|
144
|
+
return lines.join('\n')
|
|
145
|
+
}
|
|
146
|
+
|
|
120
147
|
/**
|
|
121
148
|
* Rough token estimate (~4 chars/token) for behavioral budgets.
|
|
122
149
|
*/
|
|
@@ -194,13 +221,22 @@ function isFailedToolContent(content) {
|
|
|
194
221
|
}
|
|
195
222
|
|
|
196
223
|
/**
|
|
197
|
-
* Build System-2 message list: canonical header + evidence + last 2 turns + last failed tool.
|
|
224
|
+
* Build System-2 message list: pinned blocks + canonical header + evidence + last 2 turns + last failed tool.
|
|
198
225
|
* Tool payloads in the trailing window are capped so prefill stays bounded.
|
|
226
|
+
* pinnedBlocks rows come from getPinnedContextBlocks — they are prepended verbatim before the
|
|
227
|
+
* canonical header so they are never truncated by the trailing-message window.
|
|
199
228
|
*/
|
|
200
229
|
export function compactWithJev(messages, layaState, evidence = [], opts = {}) {
|
|
201
230
|
const header = formatCanonicalHeader(layaState)
|
|
202
231
|
const ev = formatEvidenceBlock(evidence)
|
|
203
|
-
const
|
|
232
|
+
const pinnedBlocks = opts.pinnedBlocks || []
|
|
233
|
+
|
|
234
|
+
// Pinned invariants are injected first — outside the truncation budget.
|
|
235
|
+
const pinnedSection = formatPinnedBlocksSection(pinnedBlocks)
|
|
236
|
+
const preamble = pinnedSection
|
|
237
|
+
? `${pinnedSection}\n\n${header}\n${ev}`
|
|
238
|
+
: `${header}\n${ev}`
|
|
239
|
+
|
|
204
240
|
const trailing = selectTrailingMessages(messages, {
|
|
205
241
|
turns: opts.turns ?? 2,
|
|
206
242
|
includeLastFailedTool: opts.includeLastFailedTool !== false,
|
|
@@ -292,7 +328,10 @@ export async function buildJevSystem2Messages(db, rt, messages, opts = {}) {
|
|
|
292
328
|
layaOpts,
|
|
293
329
|
})
|
|
294
330
|
|
|
295
|
-
|
|
331
|
+
// Pinned blocks are always reattached — they survive every compaction cycle.
|
|
332
|
+
const pinnedBlocks = getPinnedContextBlocks(db, rt.sessionId)
|
|
333
|
+
|
|
334
|
+
return compactWithJev(messages, state, retrieved.evidence, { turns, pinnedBlocks })
|
|
296
335
|
} catch {
|
|
297
336
|
// Resilience: rolling window fallback — never crash the CLI
|
|
298
337
|
return selectTrailingMessages(messages, { turns, includeLastFailedTool: true })
|
package/lib/modelDiscovery.mjs
CHANGED
|
@@ -3,6 +3,103 @@
|
|
|
3
3
|
* Queries Ollama API to discover available models
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import http from 'node:http'
|
|
7
|
+
import https from 'node:https'
|
|
8
|
+
import { execSync } from 'node:child_process'
|
|
9
|
+
|
|
10
|
+
// ── Runtime Health Monitor ────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Probes active inference endpoints and the Laya sidecar process to give the
|
|
14
|
+
* TUI a live picture of backend health before each turn.
|
|
15
|
+
*
|
|
16
|
+
* Design follows the Kubernetes readiness-probe pattern: lightweight HEAD
|
|
17
|
+
* requests with a hard 1-second timeout, so a crashed local Ollama instance
|
|
18
|
+
* surfaces immediately rather than stalling the REPL.
|
|
19
|
+
*/
|
|
20
|
+
export class RuntimeHealthMonitor {
|
|
21
|
+
constructor(config = {}) {
|
|
22
|
+
this.activeModel = config.defaultModel || 'nehanda-rag-synthesis-27b'
|
|
23
|
+
this.sidecarScript = config.sidecarScript || 'lib/scripts/laya_worker.py'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Non-blocking HEAD probe of a local inference endpoint.
|
|
28
|
+
* Cloud-managed endpoints (no URL or `cloud:` scheme) are assumed ONLINE
|
|
29
|
+
* with zero latency — they have their own availability guarantees.
|
|
30
|
+
*/
|
|
31
|
+
async probeEndpoint(targetUrl) {
|
|
32
|
+
if (!targetUrl || targetUrl.startsWith('cloud:')) {
|
|
33
|
+
return { status: 'ONLINE', latencyMs: 0, isLocal: false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const start = Date.now()
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(targetUrl)
|
|
39
|
+
const reqModule = url.protocol === 'https:' ? https : http
|
|
40
|
+
|
|
41
|
+
return await new Promise((resolve) => {
|
|
42
|
+
const req = reqModule.request(url, { method: 'HEAD', timeout: 1000 }, (res) => {
|
|
43
|
+
resolve({
|
|
44
|
+
status: res.statusCode < 400 ? 'ONLINE' : 'DEGRADED',
|
|
45
|
+
latencyMs: Date.now() - start,
|
|
46
|
+
isLocal: true,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
req.on('error', () => resolve({ status: 'OFFLINE', latencyMs: -1, isLocal: true }))
|
|
50
|
+
req.on('timeout', () => {
|
|
51
|
+
req.destroy()
|
|
52
|
+
resolve({ status: 'TIMEOUT', latencyMs: -1, isLocal: true })
|
|
53
|
+
})
|
|
54
|
+
req.end()
|
|
55
|
+
})
|
|
56
|
+
} catch {
|
|
57
|
+
return { status: 'INVALID_URL', latencyMs: -1, isLocal: true }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Checks liveness of the local Laya sidecar worker process via pgrep.
|
|
63
|
+
* Returns { active: true, pid: string } or { active: false, pid: null }.
|
|
64
|
+
*/
|
|
65
|
+
checkSidecarHealth() {
|
|
66
|
+
try {
|
|
67
|
+
const pidStr = execSync(`pgrep -f "${this.sidecarScript}"`, { encoding: 'utf8' }).trim()
|
|
68
|
+
if (pidStr) {
|
|
69
|
+
return { active: true, pid: pidStr.split('\n')[0] }
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// pgrep exits 1 when no match — not an error
|
|
73
|
+
}
|
|
74
|
+
return { active: false, pid: null }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Assembles a full runtime status telemetry payload for the UI renderer.
|
|
79
|
+
* Runs endpoint probe and sidecar check concurrently.
|
|
80
|
+
*/
|
|
81
|
+
async getRuntimeStatus(endpointUrl, usedTokens = 0, maxTokens = 128000) {
|
|
82
|
+
const [endpointHealth, sidecarHealth] = await Promise.all([
|
|
83
|
+
this.probeEndpoint(endpointUrl),
|
|
84
|
+
Promise.resolve(this.checkSidecarHealth()),
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const ctxPercentage = maxTokens > 0 ? Math.round((usedTokens / maxTokens) * 100) : 0
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
model: this.activeModel,
|
|
91
|
+
endpoint: endpointUrl || 'Cloud Managed API',
|
|
92
|
+
endpointStatus: endpointHealth.status,
|
|
93
|
+
latency: endpointHealth.latencyMs,
|
|
94
|
+
isLocal: endpointHealth.isLocal,
|
|
95
|
+
sidecarStatus: sidecarHealth.active
|
|
96
|
+
? `ACTIVE (PID ${sidecarHealth.pid})`
|
|
97
|
+
: 'INACTIVE',
|
|
98
|
+
contextUsage: `${usedTokens}/${maxTokens} (${ctxPercentage}%)`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
6
103
|
/**
|
|
7
104
|
* Discover available models from an Ollama server
|
|
8
105
|
* @param {string} baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
|
package/lib/permissions.mjs
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
/** §5.12 — permission evaluation (deny > ask > allow > defaultMode). */
|
|
2
|
+
|
|
3
|
+
// ── Session-scoped approval cache ────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Allows a user who selects [A] in BlockConfirmMenu to suppress subsequent
|
|
6
|
+
// prompts for the same operation within one REPL session. Keys are arbitrary
|
|
7
|
+
// strings — callers use a stable pattern like `Bash:sudo` or `PythonSafetyChecker`.
|
|
8
|
+
//
|
|
9
|
+
const _sessionApprovals = new Map()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Record a single-session approval for key.
|
|
13
|
+
* @param {string} sessionId
|
|
14
|
+
* @param {string} key — e.g. 'Bash:sudo' or 'ShellSafetyChecker'
|
|
15
|
+
*/
|
|
16
|
+
export function cacheSessionApproval(sessionId, key) {
|
|
17
|
+
const bucket = _sessionApprovals.get(sessionId) || new Set()
|
|
18
|
+
bucket.add(key)
|
|
19
|
+
_sessionApprovals.set(sessionId, bucket)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return true if a prior approval for key exists in this session.
|
|
24
|
+
* @param {string} sessionId
|
|
25
|
+
* @param {string} key
|
|
26
|
+
*/
|
|
27
|
+
export function hasSessionApproval(sessionId, key) {
|
|
28
|
+
return _sessionApprovals.get(sessionId)?.has(key) ?? false
|
|
29
|
+
}
|
|
30
|
+
|
|
1
31
|
/** §5.12 — permission evaluation (deny > ask > allow > defaultMode). */
|
|
2
32
|
export function evaluatePermission(permissions, toolName, _toolInput, phase) {
|
|
3
33
|
const p = permissions || { defaultMode: 'default' }
|
|
@@ -189,7 +189,6 @@ class CodeIntegrityAuditor:
|
|
|
189
189
|
|
|
190
190
|
patched_targets = []
|
|
191
191
|
has_substantive_assert = False
|
|
192
|
-
has_any_assert = False
|
|
193
192
|
is_internal_mocked = False
|
|
194
193
|
is_live_aws = False
|
|
195
194
|
|
|
@@ -230,7 +229,6 @@ class CodeIntegrityAuditor:
|
|
|
230
229
|
|
|
231
230
|
# Inspect Assert statements
|
|
232
231
|
if isinstance(subnode, ast.Assert):
|
|
233
|
-
has_any_assert = True
|
|
234
232
|
assert_str = ast.unparse(subnode.test) if hasattr(ast, "unparse") else ""
|
|
235
233
|
# Substantive assertions check equality, subscripting, in-comparisons, or value bounds
|
|
236
234
|
if any(op in assert_str for op in ["==", "!=", " in ", ">", "<", "[", "]"]):
|
|
@@ -240,7 +238,7 @@ class CodeIntegrityAuditor:
|
|
|
240
238
|
if isinstance(subnode, ast.Expr) and isinstance(subnode.value, ast.Call):
|
|
241
239
|
call_str = ast.unparse(subnode.value.func) if hasattr(ast, "unparse") else ""
|
|
242
240
|
if "assert_called" in call_str:
|
|
243
|
-
|
|
241
|
+
pass # mock assertion noted but does not count as substantive outcome check
|
|
244
242
|
|
|
245
243
|
# Flag tests with zero substantive outcome assertions (Assertion Theater)
|
|
246
244
|
if not has_substantive_assert:
|
|
@@ -4,14 +4,21 @@ odse-transform.py — ODSE energy normalization bridge for nehanda-cli.
|
|
|
4
4
|
|
|
5
5
|
Reads a raw OEM energy payload from stdin, transforms it to ODS-E
|
|
6
6
|
normalized JSON using the pip-installed `odse` package, and writes
|
|
7
|
-
the result to stdout
|
|
7
|
+
the result to stdout in the requested format.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
echo '<payload>' | python3 odse-transform.py --source huawei
|
|
11
11
|
echo '<payload>' | python3 odse-transform.py --source solaredge --asset-id SITE-001
|
|
12
|
+
echo '<payload>' | python3 odse-transform.py --source huawei --format ndjson
|
|
13
|
+
echo '<payload>' | python3 odse-transform.py --source huawei --format summary
|
|
14
|
+
|
|
15
|
+
Output formats (--format):
|
|
16
|
+
table — box-drawing ASCII table with column headers (default when TTY)
|
|
17
|
+
ndjson — one JSON object per line (default when piped / non-TTY)
|
|
18
|
+
summary — metadata header (rows, shape, source) then JSON array
|
|
12
19
|
|
|
13
20
|
Exit codes:
|
|
14
|
-
0 — success,
|
|
21
|
+
0 — success, output written to stdout
|
|
15
22
|
1 — unknown source / transform error (caller should pass payload through)
|
|
16
23
|
2 — usage / import error
|
|
17
24
|
"""
|
|
@@ -21,6 +28,66 @@ import json
|
|
|
21
28
|
import sys
|
|
22
29
|
|
|
23
30
|
|
|
31
|
+
def _is_tty() -> bool:
|
|
32
|
+
"""Return True when stdout is an interactive terminal."""
|
|
33
|
+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render_table(records: list, source: str) -> str:
|
|
37
|
+
"""Render records as a plain-text box-drawing table."""
|
|
38
|
+
if not records:
|
|
39
|
+
return f"[odse-transform] 0 rows from source '{source}'"
|
|
40
|
+
|
|
41
|
+
headers = list(records[0].keys()) if isinstance(records[0], dict) else ["value"]
|
|
42
|
+
rows: list[list[str]] = []
|
|
43
|
+
for rec in records:
|
|
44
|
+
if isinstance(rec, dict):
|
|
45
|
+
rows.append([str(rec.get(h, "")) for h in headers])
|
|
46
|
+
else:
|
|
47
|
+
rows.append([str(rec)])
|
|
48
|
+
|
|
49
|
+
# Column widths: max of header length and widest cell value (capped at 40).
|
|
50
|
+
col_widths = [
|
|
51
|
+
min(40, max(len(h), max((len(r[i]) for r in rows), default=0)))
|
|
52
|
+
for i, h in enumerate(headers)
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
def _row(cells: list[str], widths: list[int]) -> str:
|
|
56
|
+
return "│ " + " │ ".join(c[:w].ljust(w) for c, w in zip(cells, widths)) + " │"
|
|
57
|
+
|
|
58
|
+
sep_top = "┌─" + "─┬─".join("─" * w for w in col_widths) + "─┐"
|
|
59
|
+
sep_mid = "├─" + "─┼─".join("─" * w for w in col_widths) + "─┤"
|
|
60
|
+
sep_bot = "└─" + "─┴─".join("─" * w for w in col_widths) + "─┘"
|
|
61
|
+
|
|
62
|
+
lines = [
|
|
63
|
+
sep_top,
|
|
64
|
+
_row(headers, col_widths),
|
|
65
|
+
sep_mid,
|
|
66
|
+
*[_row(r, col_widths) for r in rows],
|
|
67
|
+
sep_bot,
|
|
68
|
+
f" {len(records)} row{'s' if len(records) != 1 else ''} — source: {source}",
|
|
69
|
+
]
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_ndjson(records: list) -> str:
|
|
74
|
+
"""One JSON object per line (newline-delimited JSON)."""
|
|
75
|
+
return "\n".join(json.dumps(rec, default=str) for rec in records)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render_summary(records: list, source: str) -> str:
|
|
79
|
+
"""Metadata header followed by the full JSON array."""
|
|
80
|
+
if not records:
|
|
81
|
+
shape = "0 rows × 0 cols"
|
|
82
|
+
elif isinstance(records[0], dict):
|
|
83
|
+
shape = f"{len(records)} rows × {len(records[0])} cols"
|
|
84
|
+
else:
|
|
85
|
+
shape = f"{len(records)} rows × 1 col"
|
|
86
|
+
|
|
87
|
+
header = json.dumps({"rows": len(records), "shape": shape, "source": source}, default=str)
|
|
88
|
+
return header + "\n" + json.dumps(records, default=str)
|
|
89
|
+
|
|
90
|
+
|
|
24
91
|
def main() -> int:
|
|
25
92
|
parser = argparse.ArgumentParser(description="Transform OEM energy data to ODS-E")
|
|
26
93
|
parser.add_argument(
|
|
@@ -39,9 +106,29 @@ def main() -> int:
|
|
|
39
106
|
default=None,
|
|
40
107
|
help="Optional timezone for timestamp conversion (e.g. Africa/Johannesburg)",
|
|
41
108
|
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--format",
|
|
111
|
+
default=None,
|
|
112
|
+
choices=["table", "ndjson", "summary"],
|
|
113
|
+
dest="fmt",
|
|
114
|
+
help=(
|
|
115
|
+
"Output format. 'table' renders a box-drawing ASCII table; "
|
|
116
|
+
"'ndjson' emits one JSON object per line; "
|
|
117
|
+
"'summary' emits a metadata header then the JSON array. "
|
|
118
|
+
"Defaults to 'table' when stdout is a TTY, otherwise 'ndjson'."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
42
121
|
args = parser.parse_args()
|
|
43
122
|
|
|
44
|
-
#
|
|
123
|
+
# Resolve effective format: explicit flag > TTY detection > ndjson.
|
|
124
|
+
if args.fmt is not None:
|
|
125
|
+
fmt = args.fmt
|
|
126
|
+
elif _is_tty():
|
|
127
|
+
fmt = "table"
|
|
128
|
+
else:
|
|
129
|
+
fmt = "ndjson"
|
|
130
|
+
|
|
131
|
+
# Read full payload from stdin.
|
|
45
132
|
try:
|
|
46
133
|
payload = sys.stdin.read()
|
|
47
134
|
except Exception as exc:
|
|
@@ -52,7 +139,7 @@ def main() -> int:
|
|
|
52
139
|
print("[odse-transform] empty payload", file=sys.stderr)
|
|
53
140
|
return 1
|
|
54
141
|
|
|
55
|
-
# Import odse — fail fast with a clear message if not installed
|
|
142
|
+
# Import odse — fail fast with a clear message if not installed.
|
|
56
143
|
try:
|
|
57
144
|
from odse.transformer import transform # noqa: PLC0415
|
|
58
145
|
except ImportError:
|
|
@@ -62,7 +149,7 @@ def main() -> int:
|
|
|
62
149
|
)
|
|
63
150
|
return 2
|
|
64
151
|
|
|
65
|
-
# Attempt transformation
|
|
152
|
+
# Attempt transformation.
|
|
66
153
|
try:
|
|
67
154
|
kwargs: dict = {}
|
|
68
155
|
if args.timezone:
|
|
@@ -83,9 +170,15 @@ def main() -> int:
|
|
|
83
170
|
print(f"[odse-transform] transform error: {exc}", file=sys.stderr)
|
|
84
171
|
return 1
|
|
85
172
|
|
|
86
|
-
#
|
|
173
|
+
# Render in the requested format.
|
|
87
174
|
try:
|
|
88
|
-
|
|
175
|
+
if fmt == "table":
|
|
176
|
+
print(_render_table(records, args.source))
|
|
177
|
+
elif fmt == "summary":
|
|
178
|
+
print(_render_summary(records, args.source))
|
|
179
|
+
else:
|
|
180
|
+
# ndjson — default for non-TTY / piped callers
|
|
181
|
+
print(_render_ndjson(records))
|
|
89
182
|
except Exception as exc:
|
|
90
183
|
print(f"[odse-transform] serialization error: {exc}", file=sys.stderr)
|
|
91
184
|
return 1
|
|
@@ -189,6 +189,17 @@ class PythonSafetyChecker:
|
|
|
189
189
|
print(f"\n--- {tool} Issues ---")
|
|
190
190
|
print(issue)
|
|
191
191
|
print(f"\n✗ Found {len(self.issues)} issues.")
|
|
192
|
+
# Emit structured blocked payload on stderr so the caller (tools.mjs
|
|
193
|
+
# _parseBlockedPayload) can surface the interactive confirmation menu.
|
|
194
|
+
import json as _json
|
|
195
|
+
reasons = "; ".join(tool for tool, _ in self.issues)
|
|
196
|
+
_payload = {
|
|
197
|
+
"status": "blocked",
|
|
198
|
+
"reason": f"PythonSafetyChecker: {reasons}",
|
|
199
|
+
"risk_score": "high",
|
|
200
|
+
"proposed_remediation": ["review", "sandbox"],
|
|
201
|
+
}
|
|
202
|
+
print(_json.dumps(_payload), file=sys.stderr)
|
|
192
203
|
sys.exit(1)
|
|
193
204
|
|
|
194
205
|
if __name__ == "__main__":
|
|
@@ -301,6 +301,10 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
|
301
301
|
exit 0
|
|
302
302
|
else
|
|
303
303
|
echo "✗ Safety check failed"
|
|
304
|
+
# Emit structured blocked payload on stderr so tools.mjs _parseBlockedPayload
|
|
305
|
+
# can surface the interactive confirmation menu in the TUI.
|
|
306
|
+
printf '{"status":"blocked","reason":"ShellSafetyChecker: %d file(s) with issues","risk_score":"high","proposed_remediation":["review","sandbox"]}\n' \
|
|
307
|
+
"$totalIssues" >&2
|
|
304
308
|
exit 1
|
|
305
309
|
fi
|
|
306
310
|
fi
|
package/lib/store.mjs
CHANGED
|
@@ -6,8 +6,8 @@ import Database from 'better-sqlite3'
|
|
|
6
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
7
|
const SCHEMA_PATH = path.resolve(__dirname, '..', 'schema.sql')
|
|
8
8
|
|
|
9
|
-
/** Current schema version
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
9
|
+
/** Current schema version — v3 adds pinned_context table. */
|
|
10
|
+
export const SCHEMA_VERSION = 3
|
|
11
11
|
|
|
12
12
|
const byPath = new Map()
|
|
13
13
|
|
|
@@ -82,10 +82,31 @@ export function migrateStore(db) {
|
|
|
82
82
|
`)
|
|
83
83
|
|
|
84
84
|
db.prepare(
|
|
85
|
-
`INSERT INTO schema_meta(key,value) VALUES ('schema_version',
|
|
85
|
+
`INSERT INTO schema_meta(key,value) VALUES ('schema_version', '2')
|
|
86
86
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
87
|
-
).run(
|
|
88
|
-
version =
|
|
87
|
+
).run()
|
|
88
|
+
version = 2
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (version < 3) {
|
|
92
|
+
// v3: pinned context blocks — preserved verbatim through compaction.
|
|
93
|
+
db.exec(`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS pinned_context (
|
|
95
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
96
|
+
session_id TEXT NOT NULL,
|
|
97
|
+
key TEXT NOT NULL,
|
|
98
|
+
content TEXT NOT NULL,
|
|
99
|
+
created_at INTEGER NOT NULL,
|
|
100
|
+
UNIQUE(session_id, key)
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_pinned_context_session ON pinned_context(session_id);
|
|
103
|
+
`)
|
|
104
|
+
|
|
105
|
+
db.prepare(
|
|
106
|
+
`INSERT INTO schema_meta(key,value) VALUES ('schema_version', '3')
|
|
107
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
108
|
+
).run()
|
|
109
|
+
version = 3
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
return version
|
|
@@ -276,3 +297,34 @@ export function listMemoryEdges(db, nodeId, relation = null) {
|
|
|
276
297
|
ORDER BY weight DESC
|
|
277
298
|
`).all(nodeId, nodeId)
|
|
278
299
|
}
|
|
300
|
+
|
|
301
|
+
// ── Pinned Context CRUD ───────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Upsert a pinned context block. Content is preserved verbatim through
|
|
305
|
+
* compaction — never distilled. Use for ODSE schema maps, asset specs,
|
|
306
|
+
* simulation bounds, and system invariants that must survive context pruning.
|
|
307
|
+
*/
|
|
308
|
+
export function upsertPinnedContext(db, sessionId, key, content) {
|
|
309
|
+
const now = Date.now()
|
|
310
|
+
db.prepare(`
|
|
311
|
+
INSERT INTO pinned_context(session_id, key, content, created_at)
|
|
312
|
+
VALUES (?, ?, ?, ?)
|
|
313
|
+
ON CONFLICT(session_id, key) DO UPDATE SET
|
|
314
|
+
content = excluded.content,
|
|
315
|
+
created_at = excluded.created_at
|
|
316
|
+
`).run(sessionId, key, content, now)
|
|
317
|
+
return db.prepare(`SELECT * FROM pinned_context WHERE session_id = ? AND key = ?`).get(sessionId, key)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Return all pinned blocks for a session ordered by insertion time. */
|
|
321
|
+
export function getPinnedContextBlocks(db, sessionId) {
|
|
322
|
+
return db.prepare(
|
|
323
|
+
`SELECT * FROM pinned_context WHERE session_id = ? ORDER BY created_at ASC`,
|
|
324
|
+
).all(sessionId)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Remove a single pinned block by key. No-op if it does not exist. */
|
|
328
|
+
export function removePinnedContext(db, sessionId, key) {
|
|
329
|
+
db.prepare(`DELETE FROM pinned_context WHERE session_id = ? AND key = ?`).run(sessionId, key)
|
|
330
|
+
}
|
package/lib/tools.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { runHooks } from './hookplane.mjs'
|
|
|
10
10
|
import { createWorktree, removeWorktree, getCurrentWorktree } from './worktree.mjs'
|
|
11
11
|
import { addTeammate, setTeammateIdle } from './team.mjs'
|
|
12
12
|
import { validateBashCommand } from './bashguard.mjs'
|
|
13
|
+
import { cacheSessionApproval, hasSessionApproval } from './permissions.mjs'
|
|
13
14
|
|
|
14
15
|
const BASH_CAP = 1048576
|
|
15
16
|
const TRUNC = '[SDLC_TRUNCATED]'
|
|
@@ -69,7 +70,7 @@ export async function executeBuiltinTool(db, ctx, toolName, toolInput, io) {
|
|
|
69
70
|
case 'Edit': return toolEdit(cwd, toolInput, db, ctx)
|
|
70
71
|
case 'Glob': return toolGlob(cwd, toolInput)
|
|
71
72
|
case 'Grep': return toolGrep(cwd, toolInput)
|
|
72
|
-
case 'Bash': return await toolBash(cwd, toolInput, db, ctx)
|
|
73
|
+
case 'Bash': return await toolBash(cwd, toolInput, db, ctx, io)
|
|
73
74
|
case 'NotebookEdit': return toolNotebookEdit(cwd, toolInput)
|
|
74
75
|
case 'WebFetch': return await toolWebFetch(toolInput)
|
|
75
76
|
case 'WebSearch': return await toolWebSearch(toolInput)
|
|
@@ -293,12 +294,47 @@ function toolGrep(cwd, input) {
|
|
|
293
294
|
|
|
294
295
|
const CWD_MARKER = '___ONA_CWD___'
|
|
295
296
|
|
|
296
|
-
async function toolBash(cwd, input, db, ctx) {
|
|
297
|
+
async function toolBash(cwd, input, db, ctx, io) {
|
|
297
298
|
const cmd = input?.command
|
|
298
299
|
if (!cmd || typeof cmd !== 'string') return { content: 'Bash: missing command', is_error: true }
|
|
299
|
-
|
|
300
|
+
|
|
301
|
+
// Approval cache key — lets a session-approved pattern skip re-prompting.
|
|
302
|
+
const approvalKey = `Bash:${cmd.slice(0, 80)}`
|
|
303
|
+
const sessionId = ctx?.sessionId
|
|
304
|
+
|
|
305
|
+
// Validate command safety before execution.
|
|
300
306
|
const guard = validateBashCommand(cmd, cwd)
|
|
301
|
-
if (!guard.ok)
|
|
307
|
+
if (!guard.ok) {
|
|
308
|
+
// Check session approval cache first (user previously approved this pattern).
|
|
309
|
+
if (sessionId && hasSessionApproval(sessionId, approvalKey)) {
|
|
310
|
+
// Approved — fall through to execution.
|
|
311
|
+
} else if (io?.confirmBlock) {
|
|
312
|
+
// TUI is live — raise the interactive confirmation menu.
|
|
313
|
+
const decision = await io.confirmBlock(guard)
|
|
314
|
+
if (decision === 'approve') {
|
|
315
|
+
// Cache this approval for the rest of the session.
|
|
316
|
+
if (sessionId) cacheSessionApproval(sessionId, approvalKey)
|
|
317
|
+
// Fall through to execution.
|
|
318
|
+
} else if (decision === 'sandbox') {
|
|
319
|
+
return { content: `[SDLC] Sandboxed: ${guard.reason} — command routed to Laya worker sandbox (not yet implemented)`, is_error: false }
|
|
320
|
+
} else {
|
|
321
|
+
// 'cancel' or any other value
|
|
322
|
+
return {
|
|
323
|
+
content: `[SDLC] Bash command blocked: ${guard.reason} (risk: ${guard.risk_score})`,
|
|
324
|
+
is_error: true,
|
|
325
|
+
_blocked: guard,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
// Headless / pipe mode — auto-deny, no prompt.
|
|
330
|
+
return {
|
|
331
|
+
content: `[SDLC] Bash command blocked: ${guard.reason} (risk: ${guard.risk_score})`,
|
|
332
|
+
is_error: true,
|
|
333
|
+
_blocked: guard,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
302
338
|
const timeoutMs = Math.min(input?.timeout || 120_000, 600_000)
|
|
303
339
|
// Append cwd detection marker
|
|
304
340
|
const wrappedCmd = `${cmd} ; echo "${CWD_MARKER}$(pwd)"`
|
|
@@ -344,6 +380,29 @@ function resolveBash() {
|
|
|
344
380
|
|
|
345
381
|
// ─── Dynamic Script Tool Executor ───────────────────────────────
|
|
346
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Scan stderr lines from a safety-checker script for a structured blocked payload.
|
|
385
|
+
* Safety checkers emit one JSON line of the form:
|
|
386
|
+
* { "status": "blocked", "reason": "...", "risk_score": "high"|"medium", "proposed_remediation": [...] }
|
|
387
|
+
* Returns the parsed object, or null if none found.
|
|
388
|
+
*/
|
|
389
|
+
function _parseBlockedPayload(stderr) {
|
|
390
|
+
if (!stderr) return null
|
|
391
|
+
for (const line of stderr.split('\n').reverse()) {
|
|
392
|
+
const trimmed = line.trim()
|
|
393
|
+
if (!trimmed.startsWith('{')) continue
|
|
394
|
+
try {
|
|
395
|
+
const obj = JSON.parse(trimmed)
|
|
396
|
+
if (obj.status === 'blocked' && typeof obj.reason === 'string') {
|
|
397
|
+
return obj
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
// not JSON — keep scanning
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
347
406
|
async function executeDynamicScriptTool(cwd, toolName, input) {
|
|
348
407
|
const config = dynamicTools.get(toolName)
|
|
349
408
|
if (!config) return { content: `Tool ${toolName} config not found`, is_error: true }
|
|
@@ -412,6 +471,15 @@ async function executeDynamicScriptTool(cwd, toolName, input) {
|
|
|
412
471
|
return
|
|
413
472
|
}
|
|
414
473
|
if (code === 1 && !stdout.trim()) {
|
|
474
|
+
// Attempt to parse a structured blocked payload from the last JSON line on stderr.
|
|
475
|
+
// Safety checkers emit { status, reason, risk_score, proposed_remediation } on stderr
|
|
476
|
+
// alongside human-readable output. If present, forward it as a structured result so
|
|
477
|
+
// the caller (toolBash / confirmBlock) can render the interactive menu.
|
|
478
|
+
const structuredBlock = _parseBlockedPayload(stderr)
|
|
479
|
+
if (structuredBlock) {
|
|
480
|
+
resolve({ content: stderr, is_error: true, _blocked: structuredBlock })
|
|
481
|
+
return
|
|
482
|
+
}
|
|
415
483
|
resolve({ content: stderr || `${toolName} execution error (exited with code 1 and empty output)`, is_error: true })
|
|
416
484
|
return
|
|
417
485
|
}
|
package/lib/ui.mjs
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
import { marked } from 'marked'
|
|
5
5
|
import TerminalRenderer from 'marked-terminal'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
|
|
8
|
+
// cli-table3 is a CommonJS module; load it via createRequire so this ESM file works.
|
|
9
|
+
const _require = createRequire(import.meta.url)
|
|
10
|
+
let Table
|
|
11
|
+
try {
|
|
12
|
+
Table = _require('cli-table3')
|
|
13
|
+
} catch {
|
|
14
|
+
Table = null // graceful degradation when not yet installed
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
// Configure marked for terminal output
|
|
8
18
|
marked.setOptions({
|
|
@@ -156,6 +166,41 @@ export function formatToolStart(toolName) {
|
|
|
156
166
|
export function formatToolResult(toolName, content, isError) {
|
|
157
167
|
const icon = isError ? colors.toolError('✗') : colors.success('✓')
|
|
158
168
|
const label = colors.toolLabel(' └ ')
|
|
169
|
+
|
|
170
|
+
// Attempt JSON-array table rendering when not an error and cli-table3 is available.
|
|
171
|
+
if (!isError && Table && typeof content === 'string' && content.trimStart().startsWith('[')) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(content)
|
|
174
|
+
if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === 'object' && parsed[0] !== null) {
|
|
175
|
+
const TABLE_ROW_CAP = 10
|
|
176
|
+
const rows = parsed.slice(0, TABLE_ROW_CAP)
|
|
177
|
+
const headers = Object.keys(rows[0])
|
|
178
|
+
const termWidth = process.stdout.columns || 80
|
|
179
|
+
// Distribute column widths evenly, leaving room for borders (3 chars per col).
|
|
180
|
+
const colWidth = Math.max(8, Math.floor((termWidth - headers.length * 3 - 1) / headers.length))
|
|
181
|
+
const table = new Table({
|
|
182
|
+
head: headers.map(h => colors.toolName(String(h))),
|
|
183
|
+
colWidths: headers.map(() => colWidth),
|
|
184
|
+
wordWrap: true,
|
|
185
|
+
style: { head: [], border: ['dim'] },
|
|
186
|
+
})
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
table.push(headers.map(h => {
|
|
189
|
+
const v = row[h]
|
|
190
|
+
return v === null || v === undefined ? '' : String(v)
|
|
191
|
+
}))
|
|
192
|
+
}
|
|
193
|
+
const truncNote = parsed.length > TABLE_ROW_CAP
|
|
194
|
+
? colors.dim(`\n … ${parsed.length - TABLE_ROW_CAP} more rows (${parsed.length} total)`)
|
|
195
|
+
: ''
|
|
196
|
+
const rowCountLine = colors.dim(` ${parsed.length} row${parsed.length === 1 ? '' : 's'} — ${toolName}`)
|
|
197
|
+
return label + icon + '\n' + table.toString() + truncNote + '\n' + rowCountLine
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Not valid JSON — fall through to plain preview
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
159
204
|
const preview = (content || '').split('\n')[0].slice(0, 80)
|
|
160
205
|
return label + icon + ' ' + colors.dim(preview)
|
|
161
206
|
}
|
|
@@ -284,3 +329,58 @@ export function formatSessionSummary(sessionId, tokens, model, provider) {
|
|
|
284
329
|
]
|
|
285
330
|
return lines.join('\n')
|
|
286
331
|
}
|
|
332
|
+
|
|
333
|
+
// ── Runtime Status Header (Gap 1) ────────────────────────────
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Renders a persistent status header bar at the top of the REPL terminal.
|
|
337
|
+
* Called after startup and after each /model switch to confirm active backend.
|
|
338
|
+
*
|
|
339
|
+
* @param {object} status - Payload from RuntimeHealthMonitor.getRuntimeStatus()
|
|
340
|
+
* @returns {string} Formatted ANSI string ready for console.log / io.println
|
|
341
|
+
*/
|
|
342
|
+
export function renderStatusHeader(status) {
|
|
343
|
+
const isOnline = status.endpointStatus === 'ONLINE'
|
|
344
|
+
const statusColor = isOnline ? chalk.green : chalk.red
|
|
345
|
+
const sidecarColor = status.sidecarStatus.startsWith('ACTIVE') ? chalk.green : chalk.yellow
|
|
346
|
+
const termWidth = process.stdout.columns || 80
|
|
347
|
+
const divider = colors.dim('─'.repeat(termWidth))
|
|
348
|
+
|
|
349
|
+
const headerLine = [
|
|
350
|
+
chalk.bold.inverse(' NEHANDA REPL '),
|
|
351
|
+
chalk.dim('│'),
|
|
352
|
+
`Model: ${chalk.bold.white(status.model)}`,
|
|
353
|
+
`[${statusColor(status.endpointStatus)}${status.latency >= 0 ? chalk.dim(` ${status.latency}ms`) : ''}]`,
|
|
354
|
+
].join(' ')
|
|
355
|
+
|
|
356
|
+
const subLine = [
|
|
357
|
+
`Sidecar (laya_worker): ${sidecarColor(status.sidecarStatus)}`,
|
|
358
|
+
chalk.dim('│'),
|
|
359
|
+
`Context: ${chalk.white(status.contextUsage)}`,
|
|
360
|
+
chalk.dim('│'),
|
|
361
|
+
`Target: ${chalk.dim(status.endpoint)}`,
|
|
362
|
+
].join(' ')
|
|
363
|
+
|
|
364
|
+
return [divider, headerLine, subLine, divider].join('\n')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Visual feedback printed immediately after a /model switch.
|
|
369
|
+
* Includes an endpoint probe result so silent backend failures surface at once.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} oldModel
|
|
372
|
+
* @param {string} newModel
|
|
373
|
+
* @param {object} status - Payload from RuntimeHealthMonitor.getRuntimeStatus()
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
export function renderModelSwitchNotice(oldModel, newModel, status) {
|
|
377
|
+
const badge = status.endpointStatus === 'ONLINE'
|
|
378
|
+
? chalk.green('[SUCCESS]')
|
|
379
|
+
: chalk.red('[WARNING: ENDPOINT UNREACHABLE]')
|
|
380
|
+
|
|
381
|
+
return [
|
|
382
|
+
'',
|
|
383
|
+
`${badge} Switched active reasoning model: ${chalk.bold(oldModel)} ➔ ${chalk.bold(newModel)}`,
|
|
384
|
+
` Endpoint Probe: ${chalk.dim(status.endpoint)} (${status.endpointStatus})`,
|
|
385
|
+
].join('\n')
|
|
386
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asobacloud/nehanda",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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, Ollama, LM Studio, or any OpenAI-compatible API.",
|
|
6
6
|
"engines": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
40
40
|
"better-sqlite3": "^11.8.1",
|
|
41
41
|
"chalk": "^5.6.2",
|
|
42
|
+
"cli-table3": "^0.6.5",
|
|
42
43
|
"esprima": "^4.0.1",
|
|
43
44
|
"ink": "^6.8.0",
|
|
44
45
|
"ink-text-input": "^6.0.0",
|