@asobacloud/nehanda 0.8.0 → 0.9.1
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 +42 -11
- package/bin/agent.mjs +4 -1
- package/bin/nehanda-ui.mjs +195 -29
- package/lib/configureProvider.mjs +4 -3
- package/lib/explore.mjs +109 -0
- package/lib/issues.mjs +144 -0
- package/lib/laya_sidecar.mjs +11 -5
- package/lib/modelPicker.mjs +124 -0
- package/lib/openaiCompat.mjs +17 -1
- package/lib/orchestrate.mjs +160 -23
- package/lib/scripts/laya_worker.py +7 -2
- package/lib/store.mjs +8 -0
- package/lib/ui.mjs +12 -0
- package/lib/workflow.mjs +18 -6
- package/package.json +5 -1
- package/scripts/build-artifacts.sh +81 -0
- package/scripts/install.sh +51 -0
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 energy systems research and software development. `nehanda-cli`
|
|
3
|
+
An agentic terminal REPL and single-process engine built for energy systems research and software development. `nehanda-cli` works with Ollama, LM Studio, OpenRouter, Anthropic Claude, ZhipuAI, or any OpenAI-compatible API — local or cloud. It normalizes multi-OEM energy telemetry through the ODSE protocol and enforces a governed development workflow across the full research-to-production lifecycle.
|
|
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
|
|
|
@@ -94,7 +94,7 @@ Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the hos
|
|
|
94
94
|
|
|
95
95
|
* **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.
|
|
96
96
|
|
|
97
|
-
* **Deterministic SDLC Workflow:** Enforces a
|
|
97
|
+
* **Deterministic SDLC Workflow:** Enforces a 7-phase state machine (`idle` → `explore` → `planning` → `test` → `implement` → `test` → `verify` → `done`) following the AGENTS.md TDD mandate. Transitions are gated: `planning → implement` is blocked until an approved plan exists and at least one RED behavioral test is recorded; `test → verify` requires all tests passing and safety checkers clean.
|
|
98
98
|
|
|
99
99
|
* **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.
|
|
100
100
|
|
|
@@ -102,7 +102,7 @@ Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the hos
|
|
|
102
102
|
|
|
103
103
|
* **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.
|
|
104
104
|
|
|
105
|
-
* **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
|
+
* **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. When a reasoning model (Qwen3, Gemma) is thinking, the spinner shows live token progress (`⠋ Thinking (42 tokens)…`) rather than a frozen screen. Pressing Esc immediately updates the spinner to `Cancelling…` before the in-flight request is torn down. 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.
|
|
106
106
|
|
|
107
107
|
* **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.
|
|
108
108
|
|
|
@@ -430,19 +430,23 @@ Numeric values are auto-converted. Changes persist in the local settings databas
|
|
|
430
430
|
|
|
431
431
|
## SDLC Workflow
|
|
432
432
|
|
|
433
|
-
The engine enforces state transitions across
|
|
433
|
+
The engine enforces state transitions across seven distinct phases, aligned with the AGENTS.md TDD mandate:
|
|
434
434
|
|
|
435
|
-
1. **`idle`**: Discovery and triage. Mutating file tools are
|
|
435
|
+
1. **`idle`**: Discovery and triage. Mutating file tools are available. Safety and analysis tools are available on-demand.
|
|
436
436
|
|
|
437
|
-
2. **`
|
|
437
|
+
2. **`explore`**: Codebase exploration only. `lib/explore.mjs` drives this phase — the model uses Glob, Grep, and Read to aggregate file summaries before entering planning. Ends when the model outputs `Exploration complete.`
|
|
438
438
|
|
|
439
|
-
3. **`
|
|
439
|
+
3. **`planning`**: Model formulates a plan using exploration summaries (`EnterPlanMode`). Mutating tools are blocked until a plan is approved. No code changes permitted.
|
|
440
440
|
|
|
441
|
-
4. **`test`**:
|
|
441
|
+
4. **`test`**: Behavioral test creation and RED confirmation. The model writes tests and runs them to confirm they fail for the diagnosed reason. **Gated:** `planning → implement` is blocked until at least one RED test result is recorded here.
|
|
442
442
|
|
|
443
|
-
5. **`
|
|
443
|
+
5. **`implement`**: Code changes applied using file editing and shell execution (`ExitPlanMode`). Only reachable after a confirmed RED test exists and a plan is approved.
|
|
444
444
|
|
|
445
|
-
6. **`
|
|
445
|
+
6. **`test`**: Automated test execution to verify GREEN (`SubmitImplementation`). **All tools marked `mandatory_in: ["test"]` must run and pass** before this phase can advance — the system prompt enforces this.
|
|
446
|
+
|
|
447
|
+
7. **`verify`**: Inspection of test outputs and coverage verification (`SubmitTest`). **Gated:** requires all test results passing and at least one safety checker event recorded.
|
|
448
|
+
|
|
449
|
+
8. **`done`**: Final sign-off and git commit creation.
|
|
446
450
|
|
|
447
451
|
---
|
|
448
452
|
|
|
@@ -494,10 +498,37 @@ node tests/unit/gap4_pinned_compaction_behavioral.mjs
|
|
|
494
498
|
node tests/unit/ui_legibility_behavioral.mjs
|
|
495
499
|
```
|
|
496
500
|
|
|
501
|
+
Run the Ollama, spinner, and Laya behavioral suites:
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
node tests/unit/test-ollama-llm-call-behavioral.mjs
|
|
505
|
+
node tests/unit/test-ollama-permission-prompt-behavioral.mjs
|
|
506
|
+
node tests/unit/laya_warm_load_behavioral.mjs
|
|
507
|
+
node tests/unit/issue22_query_context_behavioral.mjs
|
|
508
|
+
```
|
|
509
|
+
|
|
497
510
|
---
|
|
498
511
|
|
|
499
512
|
## Changelog
|
|
500
513
|
|
|
514
|
+
### 0.9.0
|
|
515
|
+
|
|
516
|
+
- **AGENTS.md SDLC workflow + local issue tracking (`lib/workflow.mjs`, `lib/issues.mjs`, `lib/explore.mjs`):** The orchestrator now enforces the full E-P-C-V-D workflow defined in AGENTS.md — Explore, Plan, Confirm, Code, Validate. A local `.issues/` directory tracks open and closed issues with structured metadata (observed behavior, diagnosis, acceptance criteria). `lib/explore.mjs` drives the EXPLORE phase, aggregating file summaries before entering PLANNING. Closes #15.
|
|
517
|
+
|
|
518
|
+
- **Generic agent config restored to `~/.config/nehanda` (`lib/settings.mjs`, `scripts/postinstall.mjs`):** Removed the `.aimee` dependency and all references to the legacy agent registry. Agent configuration now lives exclusively at `~/.config/nehanda/agents.json`, consistent with the documented config file locations. Closes #16.
|
|
519
|
+
|
|
520
|
+
- **Working directory in welcome banner and footer (`bin/nehanda-ui.mjs`):** The TUI banner displayed on startup and the status footer both now show the current working directory, giving researchers immediate confirmation of which project root is active before issuing any commands. Closes #17.
|
|
521
|
+
|
|
522
|
+
- **Spinner stabilization: AskPrompt, spitfire suppression, null-safe `askHuman` (`bin/nehanda-ui.mjs`):** Fixed three related spinner regressions: (1) the spinner was visible and cycling behind interactive `io.ask` permission prompts, obscuring the question; (2) rapid `bridge.updateSpinner` calls between tool results caused visible label flicker (spitfire); (3) `askHuman` threw on a null `io` reference in headless contexts. Closes #18.
|
|
523
|
+
|
|
524
|
+
- **Ollama LLM call path: behavioral coverage + documentation (`lib/orchestrate.mjs`, `tests/unit/test-ollama-llm-call-behavioral.mjs`, `docs/MAP.md`):** Full behavioral RED/GREEN test suite for the Ollama call path — `detectOllamaToolSupport` probe logic (capabilities, template, modelfile fields, HTTP error fail-open), native vs. manual tool injection dispatch, `Authorization: Bearer ollama` header, `OLLAMA_BASE_URL` env var resolution, and `buildSystemPrompt` injection format. `docs/MAP.md` documents the full call path contract. Closes #19.
|
|
525
|
+
|
|
526
|
+
- **Turn-start and probe spinner stabilization (`bin/nehanda-ui.mjs`, `lib/orchestrate.mjs`):** Prior to this fix, the spinner had no label during the gap between turn submission and the first model event — cycling generic `TOOL_VERBS` while Laya intent classification and the Ollama `detectOllamaToolSupport` probe (up to 3 s) ran silently. Fixed by pre-seeding `bridge.updateSpinner('Analyzing')` immediately on submit and `io.spinner.start('Analyzing')` at `runSdlcOpenAICompat` entry. Behavioral coverage in `tests/unit/test-ollama-permission-prompt-behavioral.mjs`. Closes #20.
|
|
527
|
+
|
|
528
|
+
- **Laya sidecar startup warming (`lib/laya_sidecar.mjs`):** `ensureLayaReady()` now calls `loadLaya()` (`method: 'load'`) instead of `pingLaya()` (`method: 'ping'`). The `ping` path responded immediately without loading model weights, leaving the first user-turn `classifyUserIntent` call to cold-load the 322M-parameter checkpoint mid-turn (200–400 ms stall). `load` invokes `_ensure_loaded()` in `laya_worker.py`, priming both `noul` and `choice` criteria into MPS/CPU RAM before the first turn. Behavioral coverage in `tests/unit/laya_warm_load_behavioral.mjs`. Closes #21.
|
|
529
|
+
|
|
530
|
+
- **Grounded query context, Ollama `think:false`, reasoning token streaming, instant Esc cancel (`lib/orchestrate.mjs`, `lib/openaiCompat.mjs`, `bin/nehanda-ui.mjs`):** Four improvements to reduce reasoning model overhead and improve cancellation UX: (1) `buildSystemPrompt` appends an explicit `[GROUNDED FACTS]` block (date, cwd, platform, shell) to every phase prompt so models never spend tokens debating their own awareness; (2) `streamOpenAIChatCompletion` now parses `delta.reasoning` and `delta.reasoning_content` SSE fields and updates the spinner live — `⠋ Thinking (42 tokens)…` — rather than sitting frozen during the thinking phase; (3) a `think: false` option is forwarded to the Ollama API request body when `model_config.thinking === false`, disabling chain-of-thought for supported models; (4) `detectOllamaToolSupport` now accepts `rt.signal` composed via `AbortSignal.any([signal, AbortSignal.timeout(3000)])` so Esc cancels the capability probe immediately rather than waiting the full 3 s timeout, and `bridge.abortCurrent` updates the spinner to `Cancelling…` before invoking `controller.abort()`. Behavioral coverage in `tests/unit/issue22_query_context_behavioral.mjs`. Closes #22.
|
|
531
|
+
|
|
501
532
|
### 0.8.0
|
|
502
533
|
|
|
503
534
|
- **Full `/help` + explicit MCP env unset (`lib/slashCommands.mjs`, `bin/nehanda-ui.mjs`):** `/help` lists every command and subcommand (`/key` variants, full `/mcp` surface). Omitting a VALUE no longer silently clears env vars — use `/mcp env unset <server> <NAME>` (or `clear`). Autocomplete stays top-level unless you type `/mcp` or `/key`. Behavioral coverage in `tests/unit/help_commands_behavioral.mjs`. Closes #9.
|
|
@@ -541,7 +572,7 @@ node tests/unit/ui_legibility_behavioral.mjs
|
|
|
541
572
|
### 0.2.0
|
|
542
573
|
- Jev-Mem memory architecture (System-1 Laya control plane)
|
|
543
574
|
- Dynamic Tool Rescue (`[TOOL_CALL]` delimiter path)
|
|
544
|
-
- Deterministic
|
|
575
|
+
- Deterministic SDLC workflow (expanded to 7 phases in 0.9.0)
|
|
545
576
|
- Multi-provider switching
|
|
546
577
|
|
|
547
578
|
---
|
package/bin/agent.mjs
CHANGED
|
@@ -1081,7 +1081,10 @@ function resolveModelArg(arg) {
|
|
|
1081
1081
|
|
|
1082
1082
|
// First, try exact match in hardcoded WIRE map (backward compatibility)
|
|
1083
1083
|
if (arg.includes('/')) {
|
|
1084
|
-
|
|
1084
|
+
// Split on the FIRST "/" only — the model name may itself contain slashes (#12)
|
|
1085
|
+
const slashIdx = arg.indexOf('/')
|
|
1086
|
+
const p = arg.slice(0, slashIdx)
|
|
1087
|
+
const m = arg.slice(slashIdx + 1)
|
|
1085
1088
|
|
|
1086
1089
|
// Validate provider and model_id are non-empty
|
|
1087
1090
|
if (!p || !p.trim()) {
|
package/bin/nehanda-ui.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// nehanda-ui — Ink terminal UI for ona-code
|
|
3
3
|
// Connects directly to nehanda-ml.asoba.co via the in-process ona-code engine.
|
|
4
|
-
//
|
|
4
|
+
// Standalone terminal UI, no external HTTP daemon, no cross-repo resolution.
|
|
5
5
|
|
|
6
6
|
if (process.stdout.isTTY) {
|
|
7
7
|
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
@@ -28,7 +28,14 @@ import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfi
|
|
|
28
28
|
import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
|
|
29
29
|
import { invalidateMcpToolCache } from '../lib/tools.mjs'
|
|
30
30
|
import { saveOpenAiCompatKey, resolveOpenAiCompatKey, saveSecureCredentials, resolveZhipuApiKey } from '../lib/auth.mjs'
|
|
31
|
-
import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix } from '../lib/ui.mjs'
|
|
31
|
+
import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix, maskSecretAnswer } from '../lib/ui.mjs'
|
|
32
|
+
import {
|
|
33
|
+
PICKER_THRESHOLD,
|
|
34
|
+
createAskIdSequencer,
|
|
35
|
+
groupModelsByVendor,
|
|
36
|
+
filterModelItems,
|
|
37
|
+
visibleWindow,
|
|
38
|
+
} from '../lib/modelPicker.mjs'
|
|
32
39
|
import Database from 'better-sqlite3'
|
|
33
40
|
import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
|
|
34
41
|
|
|
@@ -46,6 +53,10 @@ const { default: TerminalRenderer }= await import(`${REPO_ROOT}/node_modules/mar
|
|
|
46
53
|
|
|
47
54
|
const e = React.createElement
|
|
48
55
|
|
|
56
|
+
// Per-question id source for AskPrompt React keys (HP-1a, #10). Module scope:
|
|
57
|
+
// ids only need to be unique for the process lifetime.
|
|
58
|
+
const askIdSeq = createAskIdSequencer()
|
|
59
|
+
|
|
49
60
|
// ── Asoba 3 Visual Identity ───────────────────────────────────
|
|
50
61
|
const BRAND = {
|
|
51
62
|
accent: '#455BF1',
|
|
@@ -58,9 +69,9 @@ const BRAND = {
|
|
|
58
69
|
// ── Config paths ─────────────────────────────────────────────
|
|
59
70
|
const NEHANDA_DIR = path.join(os.homedir(), '.config', 'nehanda')
|
|
60
71
|
const NEHANDA_DB = path.join(NEHANDA_DIR, 'ona-session.db')
|
|
61
|
-
const
|
|
62
|
-
const AGENTS_JSON = path.join(AIMEE_DIR, 'agents.json')
|
|
72
|
+
const AGENTS_JSON = path.join(NEHANDA_DIR, 'agents.json')
|
|
63
73
|
const SPLASH_SEEN = path.join(NEHANDA_DIR, '.splash-seen')
|
|
74
|
+
const LAUNCH_CWD = process.cwd()
|
|
64
75
|
|
|
65
76
|
function hasSeenSplash() { try { return fs.existsSync(SPLASH_SEEN) } catch { return false } }
|
|
66
77
|
function markSplashSeen() {
|
|
@@ -83,8 +94,15 @@ function readApiKey() {
|
|
|
83
94
|
|
|
84
95
|
function writeApiKey(key) {
|
|
85
96
|
const data = readAgents()
|
|
86
|
-
|
|
87
|
-
if (agent) {
|
|
97
|
+
let agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
98
|
+
if (!agent) {
|
|
99
|
+
agent = { name: data.default_agent || 'default' }
|
|
100
|
+
if (!Array.isArray(data.agents)) data.agents = []
|
|
101
|
+
data.agents.push(agent)
|
|
102
|
+
data.default_agent = agent.name
|
|
103
|
+
}
|
|
104
|
+
agent.api_key = key
|
|
105
|
+
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
// ── Session DB bootstrap ─────────────────────────────────────
|
|
@@ -213,7 +231,7 @@ function SplashScreen({ onDone }) {
|
|
|
213
231
|
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'press any key to continue…')),
|
|
214
232
|
)
|
|
215
233
|
}
|
|
216
|
-
function WelcomeBanner({ model, endpoint }) {
|
|
234
|
+
function WelcomeBanner({ model, endpoint, cwd = LAUNCH_CWD }) {
|
|
217
235
|
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
218
236
|
const bw = Math.min(cols - 2, 80)
|
|
219
237
|
const lw = Math.floor(bw * 0.42), rw = bw - lw - 3
|
|
@@ -225,8 +243,9 @@ function WelcomeBanner({ model, endpoint }) {
|
|
|
225
243
|
e(Box, { flexDirection: 'column', width: rw, paddingLeft: 1, paddingY: 1 },
|
|
226
244
|
e(Text, { bold: true, color: BRAND.accent }, 'Configuration'),
|
|
227
245
|
e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
228
|
-
e(Text, {}, e(Text, { dimColor: true }, 'Model:
|
|
229
|
-
e(Text, {}, e(Text, { dimColor: true }, 'Endpoint:
|
|
246
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
|
|
247
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
|
|
248
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Directory: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
|
|
230
249
|
),
|
|
231
250
|
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'Type /help for commands')),
|
|
232
251
|
),
|
|
@@ -287,6 +306,9 @@ function InputArea({ onSubmit, isLoading }) {
|
|
|
287
306
|
}
|
|
288
307
|
function AskPrompt({ question, onAnswer, onCancel }) {
|
|
289
308
|
const [value, setValue] = React.useState('')
|
|
309
|
+
// Defense-in-depth for HP-1a (#10): if this instance ever re-renders with a
|
|
310
|
+
// new question (fiber reuse), the typed value must not carry over.
|
|
311
|
+
React.useEffect(() => { setValue('') }, [question])
|
|
290
312
|
useInput((_input, key) => { if (key.escape) onCancel() })
|
|
291
313
|
return e(Box, { flexDirection: 'column' },
|
|
292
314
|
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
|
|
@@ -294,7 +316,124 @@ function AskPrompt({ question, onAnswer, onCancel }) {
|
|
|
294
316
|
e(TextInput, { value, onChange: setValue, onSubmit: React.useCallback(v => onAnswer(v.trim()), [onAnswer]) })),
|
|
295
317
|
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, 'Esc cancel')))
|
|
296
318
|
}
|
|
319
|
+
// ── Model Picker (large lists, #10 / HP-2) ───────────────────
|
|
320
|
+
// Three views: vendor GROUPS → windowed BROWSE within a vendor →
|
|
321
|
+
// type-to-filter SEARCH across every discovered model.
|
|
322
|
+
// Digits jump rows, '/' opens search, Esc steps back.
|
|
323
|
+
function ModelPicker({ title, items, onSelect, onCancel }) {
|
|
324
|
+
const groups = React.useMemo(() => groupModelsByVendor(items), [items])
|
|
325
|
+
const [view, setView] = React.useState('groups') // 'groups' | 'browse' | 'search'
|
|
326
|
+
const [groupIdx, setGroupIdx] = React.useState(0)
|
|
327
|
+
const [browseIdx, setBrowseIdx] = React.useState(0)
|
|
328
|
+
const [filter, setFilter] = React.useState('')
|
|
329
|
+
const [searchIdx, setSearchIdx] = React.useState(0)
|
|
330
|
+
|
|
331
|
+
const browseModels = view === 'browse' && groups[groupIdx] ? groups[groupIdx].models : []
|
|
332
|
+
const searchResults = React.useMemo(() => filterModelItems(items, filter), [items, filter])
|
|
333
|
+
const prevView = React.useRef('groups')
|
|
334
|
+
|
|
335
|
+
const jump = (setter, list, n) => {
|
|
336
|
+
if (!isNaN(n) && n >= 1 && n <= list.length) setter(n - 1)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
useInput((input, key) => {
|
|
340
|
+
if (view === 'search') {
|
|
341
|
+
// TextInput owns printable chars + Enter (its onSubmit); arrows pass through.
|
|
342
|
+
if (key.escape) {
|
|
343
|
+
if (filter) { setFilter(''); setSearchIdx(0) }
|
|
344
|
+
else { setView(prevView.current); setSearchIdx(0) }
|
|
345
|
+
} else if (key.upArrow) { setSearchIdx(i => (i - 1 + searchResults.length) % searchResults.length) }
|
|
346
|
+
else if (key.downArrow) { setSearchIdx(i => (i + 1) % searchResults.length) }
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (key.escape) {
|
|
350
|
+
if (view === 'browse') setView('groups')
|
|
351
|
+
else onCancel()
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (input === '/') { prevView.current = view; setView('search'); setFilter(''); setSearchIdx(0); return }
|
|
355
|
+
if (view === 'groups') {
|
|
356
|
+
if (key.upArrow) { setGroupIdx(i => (i - 1 + groups.length) % groups.length); return }
|
|
357
|
+
if (key.downArrow) { setGroupIdx(i => (i + 1) % groups.length); return }
|
|
358
|
+
if (key.return) {
|
|
359
|
+
const g = groups[groupIdx]
|
|
360
|
+
if (!g) return
|
|
361
|
+
if (g.isCustom) { onSelect(g.models[0]); return }
|
|
362
|
+
setBrowseIdx(0); setView('browse')
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
jump(setGroupIdx, groups, parseInt(input, 10))
|
|
366
|
+
} else if (view === 'browse') {
|
|
367
|
+
if (key.upArrow) { setBrowseIdx(i => (i - 1 + browseModels.length) % browseModels.length); return }
|
|
368
|
+
if (key.downArrow) { setBrowseIdx(i => (i + 1) % browseModels.length); return }
|
|
369
|
+
if (key.return) { const m = browseModels[browseIdx]; if (m) onSelect(m); return }
|
|
370
|
+
const win = visibleWindow(browseModels, browseIdx)
|
|
371
|
+
const shown = win.visible
|
|
372
|
+
const n = parseInt(input, 10)
|
|
373
|
+
if (!isNaN(n) && n >= 1 && n <= shown.length) {
|
|
374
|
+
const item = shown[n - 1]
|
|
375
|
+
const abs = browseModels.indexOf(item)
|
|
376
|
+
if (abs !== -1) setBrowseIdx(abs)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const row = (item, i, cursor) => e(Box, { key: String(item.id ?? i), flexDirection: 'column' },
|
|
382
|
+
e(Box, {}, e(Text, { color: cursor ? BRAND.accent : undefined }, cursor ? '❯ ' : ' '),
|
|
383
|
+
e(Text, { color: BRAND.lavender }, `${i + 1}. `),
|
|
384
|
+
e(Text, { bold: true, color: cursor ? BRAND.accent : undefined }, item.label)),
|
|
385
|
+
e(Box, { marginLeft: 5 }, e(Text, { dimColor: true }, item.desc)))
|
|
386
|
+
const hiddenHints = win => e(Box, { key: 'hints', flexDirection: 'column' },
|
|
387
|
+
win.hiddenAbove ? e(Text, { dimColor: true }, ` ↑ ${win.hiddenAbove} more above`) : null,
|
|
388
|
+
win.hiddenBelow ? e(Text, { dimColor: true }, ` ↓ ${win.hiddenBelow} more below`) : null)
|
|
389
|
+
|
|
390
|
+
let body
|
|
391
|
+
if (view === 'groups') {
|
|
392
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
393
|
+
...groups.map((g, i) => row(g, i, i === groupIdx)),
|
|
394
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
395
|
+
`↑↓/1–${Math.min(9, groups.length)} vendor · Enter open · / search all · Esc cancel`)))
|
|
396
|
+
} else if (view === 'browse') {
|
|
397
|
+
const win = visibleWindow(browseModels, browseIdx)
|
|
398
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
399
|
+
e(Text, { dimColor: true }, `${groups[groupIdx].label} — ${browseModels.length} models`),
|
|
400
|
+
...win.visible.map((m, i) => row(m, win.start + i, win.start + i === browseIdx)),
|
|
401
|
+
hiddenHints(win),
|
|
402
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
403
|
+
'↑↓ navigate · Enter select · / search all · Esc back')))
|
|
404
|
+
} else {
|
|
405
|
+
const win = visibleWindow(searchResults, searchIdx)
|
|
406
|
+
const handleSearchSubmit = v => {
|
|
407
|
+
const item = searchResults[searchIdx]
|
|
408
|
+
if (item) onSelect(item)
|
|
409
|
+
}
|
|
410
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
411
|
+
e(Box, { paddingX: 1 }, e(Text, { color: BRAND.accent }, '/'),
|
|
412
|
+
e(TextInput, { value: filter, onChange: v => { setFilter(v); setSearchIdx(0) }, onSubmit: handleSearchSubmit })),
|
|
413
|
+
searchResults.length
|
|
414
|
+
? e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
415
|
+
...win.visible.map((m, i) => row(m, win.start + i, win.start + i === searchIdx)),
|
|
416
|
+
hiddenHints(win))
|
|
417
|
+
: e(Text, { dimColor: true }, ' (no matches)'),
|
|
418
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
419
|
+
'type to filter · ↑↓ select · Enter confirm · Esc clear/back')))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return e(Box, { flexDirection: 'column', marginLeft: 1 },
|
|
423
|
+
e(Text, { bold: true, color: BRAND.accent }, title),
|
|
424
|
+
body)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Wrapper (no hooks): large catalogs (e.g. OpenRouter's hundreds of models)
|
|
428
|
+
// switch to the grouped/filterable picker instead of a flat scroll list (HP-2, #10).
|
|
297
429
|
function SelectMenu({ title, items, onSelect, onCancel }) {
|
|
430
|
+
if (items.length > PICKER_THRESHOLD) {
|
|
431
|
+
return e(ModelPicker, { title, items, onSelect, onCancel })
|
|
432
|
+
}
|
|
433
|
+
return e(SelectMenuFlat, { title, items, onSelect, onCancel })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function SelectMenuFlat({ title, items, onSelect, onCancel }) {
|
|
298
437
|
const [idx, setIdx] = React.useState(0)
|
|
299
438
|
useInput((input, key) => {
|
|
300
439
|
if (key.escape) { onCancel(); return }
|
|
@@ -356,13 +495,17 @@ function BlockConfirmMenu({ payload, onDecide }) {
|
|
|
356
495
|
e(Text, {}, ' Cancel')))
|
|
357
496
|
}
|
|
358
497
|
|
|
359
|
-
function TokenFooter({ calls, model }) {
|
|
498
|
+
function TokenFooter({ calls, model, cwd = LAUNCH_CWD }) {
|
|
360
499
|
const cols = process.stdout.columns || 80
|
|
361
500
|
return e(Box, { flexDirection: 'column' },
|
|
362
501
|
e(Text, { dimColor: true }, '─'.repeat(cols)),
|
|
363
502
|
e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
|
|
364
503
|
e(Text, { dimColor: true }, `calls ${calls}`),
|
|
365
|
-
e(
|
|
504
|
+
e(Box, { flexDirection: 'row' },
|
|
505
|
+
e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
|
|
506
|
+
e(Text, { dimColor: true }, ' '),
|
|
507
|
+
e(Text, {}, e(Text, { dimColor: true }, 'dir: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
|
|
508
|
+
),
|
|
366
509
|
e(Text, { dimColor: true }, '? /help ^C exit')))
|
|
367
510
|
}
|
|
368
511
|
|
|
@@ -384,7 +527,8 @@ function App({ bridge }) {
|
|
|
384
527
|
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
385
528
|
return { model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' }
|
|
386
529
|
})
|
|
387
|
-
const lastCtrlCRef
|
|
530
|
+
const lastCtrlCRef = React.useRef(0)
|
|
531
|
+
const lastSpinLabelRef = React.useRef(null) // #18: retain last explicit label to prevent spitfire between tool calls
|
|
388
532
|
const bannerRef = React.useRef({ kind: 'banner' })
|
|
389
533
|
const historyItems = React.useMemo(() => [bannerRef.current, ...finalized], [finalized])
|
|
390
534
|
|
|
@@ -396,8 +540,12 @@ function App({ bridge }) {
|
|
|
396
540
|
bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
|
|
397
541
|
bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
|
|
398
542
|
bridge.setLoading = v => setIsLoading(v)
|
|
399
|
-
bridge.updateSpinner = text => setSpinLabel(text)
|
|
400
|
-
|
|
543
|
+
bridge.updateSpinner = text => { if (text !== null) lastSpinLabelRef.current = text; setSpinLabel(text) }
|
|
544
|
+
// HP-1a (#10): unique id per question — AskPrompt is keyed by it, so every
|
|
545
|
+
// new wizard question remounts with an EMPTY input (no key carry-over).
|
|
546
|
+
// opts.secret → the persisted transcript line is masked (HP-1b).
|
|
547
|
+
bridge.ask = (q, opts) => new Promise(resolve =>
|
|
548
|
+
setAskState({ id: askIdSeq.next(), question: q, secret: !!(opts && opts.secret), resolve }))
|
|
401
549
|
bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
|
|
402
550
|
bridge.confirmBlock = payload => new Promise(resolve => setBlockState({ payload, resolve }))
|
|
403
551
|
bridge.exit = () => { exit() }
|
|
@@ -409,7 +557,10 @@ function App({ bridge }) {
|
|
|
409
557
|
}, [bridge, exit])
|
|
410
558
|
|
|
411
559
|
const handleAnswer = React.useCallback(answer => {
|
|
412
|
-
|
|
560
|
+
// HP-1b (#10): secret answers persist masked to transcript history; the
|
|
561
|
+
// wizard still receives the raw answer via resolve().
|
|
562
|
+
const shown = askState?.secret ? maskSecretAnswer(answer) : answer
|
|
563
|
+
if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: `${askState.question} ${shown}`.trim() }]); askState.resolve(answer) }
|
|
413
564
|
setAskState(null)
|
|
414
565
|
}, [askState])
|
|
415
566
|
const handleAskCancel = React.useCallback(() => { askState?.resolve(null); setAskState(null) }, [askState])
|
|
@@ -444,18 +595,25 @@ function App({ bridge }) {
|
|
|
444
595
|
return e(Box, { flexDirection: 'column' },
|
|
445
596
|
e(Static, { items: historyItems, key: clearGen },
|
|
446
597
|
(item, i) => item.kind === 'banner'
|
|
447
|
-
? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
|
|
598
|
+
? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint, cwd: LAUNCH_CWD })
|
|
448
599
|
: e(MessageView, { key: String(i), msg: item })),
|
|
449
600
|
streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
|
|
450
|
-
|
|
601
|
+
// #18 fix 1: !askState hides Spinner while AskPrompt is shown (permission prompt visible).
|
|
602
|
+
// #18 fix 2: effectiveSpinLabel uses lastSpinLabelRef fallback when spinLabel=null during
|
|
603
|
+
// active loading, preventing snap-back to a random rotating verb between tool calls.
|
|
604
|
+
(() => {
|
|
605
|
+
const effectiveSpinLabel = spinLabel ?? (isLoading ? lastSpinLabelRef.current : null)
|
|
606
|
+
return (!askState && ((isLoading && (!streaming || !streaming.text)) || effectiveSpinLabel))
|
|
607
|
+
? e(Spinner, { label: effectiveSpinLabel || undefined }) : null
|
|
608
|
+
})(),
|
|
451
609
|
askState
|
|
452
|
-
? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
610
|
+
? e(AskPrompt, { key: askState.id, question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
453
611
|
: blockState
|
|
454
612
|
? e(BlockConfirmMenu, { payload: blockState.payload, onDecide: handleBlock })
|
|
455
613
|
: selectState
|
|
456
614
|
? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
|
|
457
615
|
: e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
|
|
458
|
-
e(TokenFooter, { calls: callCount, model: modelInfo.model }))
|
|
616
|
+
e(TokenFooter, { calls: callCount, model: modelInfo.model, cwd: LAUNCH_CWD }))
|
|
459
617
|
}
|
|
460
618
|
|
|
461
619
|
// ── Diff executor ─────────────────────────────────────────────
|
|
@@ -492,7 +650,7 @@ function stripThink(text) {
|
|
|
492
650
|
async function runUiConfigureWizard(bridge, opts = {}) {
|
|
493
651
|
try {
|
|
494
652
|
const result = await runConfigureWizard({
|
|
495
|
-
ask: q => bridge.ask(q),
|
|
653
|
+
ask: (q, opts) => bridge.ask(q, opts),
|
|
496
654
|
select: (title, items) => bridge.select(title, items),
|
|
497
655
|
notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
|
|
498
656
|
resolveExistingKey: async (preset) => {
|
|
@@ -508,12 +666,16 @@ async function runUiConfigureWizard(bridge, opts = {}) {
|
|
|
508
666
|
},
|
|
509
667
|
persist: async (cfg) => {
|
|
510
668
|
const data = readAgents()
|
|
511
|
-
|
|
512
|
-
if (ag) {
|
|
513
|
-
ag
|
|
514
|
-
|
|
515
|
-
|
|
669
|
+
let ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
670
|
+
if (!ag) {
|
|
671
|
+
ag = { name: data.default_agent || 'default' }
|
|
672
|
+
if (!Array.isArray(data.agents)) data.agents = []
|
|
673
|
+
data.agents.push(ag)
|
|
674
|
+
data.default_agent = ag.name
|
|
516
675
|
}
|
|
676
|
+
ag.model = cfg.model
|
|
677
|
+
ag.endpoint = cfg.endpoint
|
|
678
|
+
if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
|
|
517
679
|
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
518
680
|
runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
|
|
519
681
|
{ model: cfg.model, endpoint: cfg.endpoint },
|
|
@@ -589,7 +751,7 @@ async function handleCommand(cmd, bridge) {
|
|
|
589
751
|
}
|
|
590
752
|
|
|
591
753
|
if (!sub || !KEY_TO_PRESET[sub]) {
|
|
592
|
-
const answer = await bridge.ask('New Nehanda API key:')
|
|
754
|
+
const answer = await bridge.ask('New Nehanda API key:', { secret: true })
|
|
593
755
|
if (!answer) return
|
|
594
756
|
try {
|
|
595
757
|
writeApiKey(answer)
|
|
@@ -601,7 +763,7 @@ async function handleCommand(cmd, bridge) {
|
|
|
601
763
|
|
|
602
764
|
const presetId = KEY_TO_PRESET[sub]
|
|
603
765
|
const inlineValue = parts.slice(2).join(' ').trim()
|
|
604
|
-
const answer = inlineValue || await bridge.ask(`New ${sub} API key
|
|
766
|
+
const answer = inlineValue || await bridge.ask(`New ${sub} API key:`, { secret: true })
|
|
605
767
|
if (!answer) return
|
|
606
768
|
|
|
607
769
|
try {
|
|
@@ -833,8 +995,12 @@ bridge.onSubmit = async (text) => {
|
|
|
833
995
|
if (text.startsWith('/')) { await handleCommand(text, bridge); return }
|
|
834
996
|
|
|
835
997
|
bridge.setLoading(true)
|
|
998
|
+
bridge.updateSpinner('Analyzing')
|
|
836
999
|
const controller = new AbortController()
|
|
837
|
-
bridge.abortCurrent = () =>
|
|
1000
|
+
bridge.abortCurrent = () => {
|
|
1001
|
+
bridge.updateSpinner('Cancelling…') // #22: immediate UI feedback before abort propagates
|
|
1002
|
+
controller.abort()
|
|
1003
|
+
}
|
|
838
1004
|
let lastPartial = ''
|
|
839
1005
|
|
|
840
1006
|
try {
|
|
@@ -861,7 +1027,7 @@ bridge.onSubmit = async (text) => {
|
|
|
861
1027
|
bareMode: false,
|
|
862
1028
|
// Project instructions sourced from nehanda's own loadInstructions() (Ona.md),
|
|
863
1029
|
// matching bin/agent.mjs. Does NOT read agents.json exec_system_prompt — that
|
|
864
|
-
// field
|
|
1030
|
+
// legacy field was silently reintroducing conflicting tool-call
|
|
865
1031
|
// syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
|
|
866
1032
|
onaInstructions: onaInstructionsContent || null,
|
|
867
1033
|
// Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
|
|
@@ -107,7 +107,7 @@ export async function discoverModelsForPreset(preset, opts = {}) {
|
|
|
107
107
|
* Interactive configure wizard.
|
|
108
108
|
*
|
|
109
109
|
* @param {object} deps
|
|
110
|
-
* @param {(q: string) => Promise<string>} deps.ask
|
|
110
|
+
* @param {(q: string, opts?: { secret?: boolean }) => Promise<string>} deps.ask
|
|
111
111
|
* @param {(title: string, items: object[]) => Promise<object|null>} deps.select
|
|
112
112
|
* @param {(msg: string) => void} [deps.notify]
|
|
113
113
|
* @param {(ctx: {preset, host, apiKey}) => Promise<{success, models?, error?}>} [deps.discover]
|
|
@@ -155,7 +155,8 @@ export async function runConfigureWizard(deps, opts = {}) {
|
|
|
155
155
|
])
|
|
156
156
|
if (!choice) return null
|
|
157
157
|
if (choice.id === 'replace') {
|
|
158
|
-
|
|
158
|
+
// {secret:true} → the TUI masks this answer in transcript history (HP-1b, #10)
|
|
159
|
+
const entered = await ask(`${preset.label} API key:`, { secret: true })
|
|
159
160
|
if (!entered) return null
|
|
160
161
|
apiKey = entered.trim()
|
|
161
162
|
keyDirty = true
|
|
@@ -163,7 +164,7 @@ export async function runConfigureWizard(deps, opts = {}) {
|
|
|
163
164
|
apiKey = existing
|
|
164
165
|
}
|
|
165
166
|
} else {
|
|
166
|
-
const entered = await ask(`${preset.label} API key
|
|
167
|
+
const entered = await ask(`${preset.label} API key:`, { secret: true })
|
|
167
168
|
if (!entered) return null
|
|
168
169
|
apiKey = entered.trim()
|
|
169
170
|
keyDirty = true
|
package/lib/explore.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Directories to skip entirely during exploration
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
'node_modules', '.git', '.issues', 'dist', 'build', 'tmp', '.tmp',
|
|
7
|
+
'coverage', '.nyc_output', '__pycache__', '.venv', 'venv',
|
|
8
|
+
'.next', '.nuxt', 'out',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
// Documentation files receive highest priority (read first)
|
|
12
|
+
const DOC_FILENAMES = new Set([
|
|
13
|
+
'README.md', 'readme.md', 'README', 'readme',
|
|
14
|
+
'CONTRIBUTING.md', 'CONTRIBUTING', 'ARCHITECTURE.md', 'ARCHITECTURE',
|
|
15
|
+
'AGENTS.md', 'GEMINI.md', 'CHANGELOG.md', 'CHANGES.md',
|
|
16
|
+
'DESIGN.md', 'OVERVIEW.md', 'ONBOARDING.md', 'SETUP.md',
|
|
17
|
+
'MAP.md', 'RUNBOOK.md', 'OPERATIONS.md', 'SPECIFICATION.md', 'SPEC.md',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
// Secondary documentation: anything under docs/ or .docs/
|
|
21
|
+
const DOC_DIRS = new Set(['docs', '.docs', 'documentation', 'doc'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scans a repository directory and returns files in documentation-first order.
|
|
25
|
+
*
|
|
26
|
+
* Priority order:
|
|
27
|
+
* 1. Root-level named documentation files (README.md, CONTRIBUTING.md, etc.)
|
|
28
|
+
* 2. All Markdown files inside docs/ subdirectories
|
|
29
|
+
* 3. Root-level *.md files not already listed
|
|
30
|
+
* 4. Configuration files (package.json, pyproject.toml, etc.)
|
|
31
|
+
* 5. All other source files (alphabetical, depth-first)
|
|
32
|
+
*
|
|
33
|
+
* @param {string} cwd - The repository root to scan
|
|
34
|
+
* @returns {string[]} - Absolute paths in exploration priority order
|
|
35
|
+
*/
|
|
36
|
+
export function getDocPrioritizedFiles(cwd) {
|
|
37
|
+
const abs = path.resolve(cwd)
|
|
38
|
+
|
|
39
|
+
// Tier 1: High-priority named root documentation files
|
|
40
|
+
const tier1 = []
|
|
41
|
+
for (const name of DOC_FILENAMES) {
|
|
42
|
+
const f = path.join(abs, name)
|
|
43
|
+
if (fs.existsSync(f) && fs.statSync(f).isFile()) {
|
|
44
|
+
tier1.push(f)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Collect all files in the repo (excluding SKIP_DIRS)
|
|
49
|
+
const allFiles = []
|
|
50
|
+
function walk(dir) {
|
|
51
|
+
let entries
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name))
|
|
60
|
+
} else if (entry.isFile()) {
|
|
61
|
+
allFiles.push(path.join(dir, entry.name))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
walk(abs)
|
|
66
|
+
|
|
67
|
+
const tier1Set = new Set(tier1)
|
|
68
|
+
|
|
69
|
+
// Tier 2: Markdown files inside docs/ directories
|
|
70
|
+
const tier2 = allFiles.filter(f => {
|
|
71
|
+
if (tier1Set.has(f)) return false
|
|
72
|
+
const rel = path.relative(abs, f)
|
|
73
|
+
const parts = rel.split(path.sep)
|
|
74
|
+
return parts.length > 1 && DOC_DIRS.has(parts[0]) && f.endsWith('.md')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const tier2Set = new Set(tier2)
|
|
78
|
+
|
|
79
|
+
// Tier 3: Remaining root-level *.md files
|
|
80
|
+
const tier3 = allFiles.filter(f => {
|
|
81
|
+
if (tier1Set.has(f) || tier2Set.has(f)) return false
|
|
82
|
+
const rel = path.relative(abs, f)
|
|
83
|
+
return !rel.includes(path.sep) && f.endsWith('.md')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const tier3Set = new Set(tier3)
|
|
87
|
+
|
|
88
|
+
// Tier 4: Config files
|
|
89
|
+
const CONFIG_NAMES = new Set([
|
|
90
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
91
|
+
'pyproject.toml', 'setup.py', 'setup.cfg', 'Cargo.toml',
|
|
92
|
+
'.env.example', '.env.sample', 'tsconfig.json', 'jest.config.js',
|
|
93
|
+
'jest.config.mjs', 'eslint.config.js', '.eslintrc.js', '.eslintrc.json',
|
|
94
|
+
])
|
|
95
|
+
const tier4 = allFiles.filter(f => {
|
|
96
|
+
if (tier1Set.has(f) || tier2Set.has(f) || tier3Set.has(f)) return false
|
|
97
|
+
const base = path.basename(f)
|
|
98
|
+
return CONFIG_NAMES.has(base)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const tier4Set = new Set(tier4)
|
|
102
|
+
|
|
103
|
+
// Tier 5: Everything else
|
|
104
|
+
const tier5 = allFiles.filter(f =>
|
|
105
|
+
!tier1Set.has(f) && !tier2Set.has(f) && !tier3Set.has(f) && !tier4Set.has(f)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return [...tier1, ...tier2, ...tier3, ...tier4, ...tier5]
|
|
109
|
+
}
|