@asobacloud/nehanda 0.5.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 +25 -0
- package/bin/agent.mjs +6 -2
- package/bin/nehanda-ui.mjs +79 -13
- package/lib/auth.mjs +27 -2
- package/lib/compact.mjs +57 -2
- package/lib/laya.mjs +58 -0
- package/lib/laya_sidecar.mjs +6 -0
- package/lib/orchestrate.mjs +56 -9
- package/lib/tools.mjs +33 -1
- package/lib/ui.mjs +106 -3
- package/package.json +21 -2
package/README.md
CHANGED
|
@@ -87,14 +87,22 @@ To connect to an Ollama instance running on your network:
|
|
|
87
87
|
|
|
88
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
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.
|
|
93
|
+
|
|
90
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.
|
|
91
95
|
|
|
92
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.
|
|
93
97
|
|
|
94
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.
|
|
95
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
|
+
|
|
96
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.
|
|
97
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
|
+
|
|
98
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.
|
|
99
107
|
|
|
100
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`.
|
|
@@ -482,12 +490,29 @@ node tests/unit/gap1_runtime_visibility_behavioral.mjs
|
|
|
482
490
|
node tests/unit/gap2_output_formatting_behavioral.mjs
|
|
483
491
|
node tests/unit/gap3_safety_intervention_behavioral.mjs
|
|
484
492
|
node tests/unit/gap4_pinned_compaction_behavioral.mjs
|
|
493
|
+
node tests/unit/ui_legibility_behavioral.mjs
|
|
485
494
|
```
|
|
486
495
|
|
|
487
496
|
---
|
|
488
497
|
|
|
489
498
|
## Changelog
|
|
490
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
|
+
|
|
491
516
|
### 0.4.0
|
|
492
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.
|
|
493
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
|
-
|
|
892
|
+
const keyStatus = {
|
|
893
|
+
compatKeySet: !!resolveOpenAiCompatKey(),
|
|
894
|
+
zhipuKeySet: !!resolveZhipuApiKey(),
|
|
895
|
+
}
|
|
896
|
+
io.println(ui.formatConfig(settings, keyStatus))
|
|
893
897
|
}
|
|
894
898
|
continue
|
|
895
899
|
}
|
package/bin/nehanda-ui.mjs
CHANGED
|
@@ -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
|
|
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 =>
|
|
296
|
-
|
|
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:
|
|
526
|
-
` Endpoint:
|
|
527
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
package/lib/laya_sidecar.mjs
CHANGED
|
@@ -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
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
},
|