@asobacloud/nehanda 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +404 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nehanda-ui — Ink terminal UI for ona-code
|
|
3
|
+
// Connects directly to nehanda-ml.asoba.co via the in-process ona-code engine.
|
|
4
|
+
// No aimee-server, no HTTP daemon, no cross-repo resolution.
|
|
5
|
+
|
|
6
|
+
if (process.stdout.isTTY) {
|
|
7
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { execFile } from 'node:child_process'
|
|
14
|
+
import { promisify } from 'node:util'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
// ── In-process engine imports (direct, single-repo) ──────────
|
|
18
|
+
import { runUserTurn } from '../lib/orchestrate.mjs'
|
|
19
|
+
import { loadInstructions } from '../lib/instructions.mjs'
|
|
20
|
+
import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
|
|
21
|
+
import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
|
|
22
|
+
import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
|
|
23
|
+
import { invalidateMcpToolCache } from '../lib/tools.mjs'
|
|
24
|
+
import Database from 'better-sqlite3'
|
|
25
|
+
import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const REPO_ROOT = path.resolve(__dirname, '..')
|
|
29
|
+
|
|
30
|
+
// All remaining UI deps live in ona-code's own node_modules
|
|
31
|
+
const { default: React } = await import(`${REPO_ROOT}/node_modules/react/index.js`)
|
|
32
|
+
const { render, Box, Text, useApp, useInput, Static }
|
|
33
|
+
= await import(`${REPO_ROOT}/node_modules/ink/build/index.js`)
|
|
34
|
+
const { default: TextInput } = await import(`${REPO_ROOT}/node_modules/ink-text-input/build/index.js`)
|
|
35
|
+
const { default: chalk } = await import(`${REPO_ROOT}/node_modules/chalk/source/index.js`)
|
|
36
|
+
const { marked } = await import(`${REPO_ROOT}/node_modules/marked/lib/marked.esm.js`)
|
|
37
|
+
const { default: TerminalRenderer }= await import(`${REPO_ROOT}/node_modules/marked-terminal/index.js`)
|
|
38
|
+
|
|
39
|
+
const e = React.createElement
|
|
40
|
+
|
|
41
|
+
// ── Asoba 3 Visual Identity ───────────────────────────────────
|
|
42
|
+
const BRAND = {
|
|
43
|
+
accent: '#455BF1',
|
|
44
|
+
lavender: '#9D93D6',
|
|
45
|
+
coral: '#F2AEAC',
|
|
46
|
+
red: '#E20419',
|
|
47
|
+
dim: 'gray',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Config paths ─────────────────────────────────────────────
|
|
51
|
+
const NEHANDA_DIR = path.join(os.homedir(), '.config', 'nehanda')
|
|
52
|
+
const NEHANDA_DB = path.join(NEHANDA_DIR, 'ona-session.db')
|
|
53
|
+
const AIMEE_DIR = path.join(os.homedir(), '.config', 'aimee')
|
|
54
|
+
const AGENTS_JSON = path.join(AIMEE_DIR, 'agents.json')
|
|
55
|
+
const SPLASH_SEEN = path.join(NEHANDA_DIR, '.splash-seen')
|
|
56
|
+
|
|
57
|
+
function hasSeenSplash() { try { return fs.existsSync(SPLASH_SEEN) } catch { return false } }
|
|
58
|
+
function markSplashSeen() {
|
|
59
|
+
try { fs.mkdirSync(NEHANDA_DIR, { recursive: true }); fs.writeFileSync(SPLASH_SEEN, new Date().toISOString()) }
|
|
60
|
+
catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Config helpers ────────────────────────────────────────────
|
|
64
|
+
function readAgents() {
|
|
65
|
+
try { return JSON.parse(fs.readFileSync(AGENTS_JSON, 'utf8')) }
|
|
66
|
+
catch { return { agents: [], default_agent: '' } }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Nehanda API key: stored in agents.json api_key or NEHANDA_API_KEY env var. */
|
|
70
|
+
function readApiKey() {
|
|
71
|
+
const data = readAgents()
|
|
72
|
+
const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
73
|
+
return agent?.api_key || process.env.NEHANDA_API_KEY || ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeApiKey(key) {
|
|
77
|
+
const data = readAgents()
|
|
78
|
+
const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
79
|
+
if (agent) { agent.api_key = key; fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8') }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Session DB bootstrap ─────────────────────────────────────
|
|
83
|
+
fs.mkdirSync(NEHANDA_DIR, { recursive: true })
|
|
84
|
+
const sessionDb = new Database(NEHANDA_DB)
|
|
85
|
+
const { content: onaInstructionsContent } = loadInstructions(process.cwd())
|
|
86
|
+
sessionDb.exec(`
|
|
87
|
+
CREATE TABLE IF NOT EXISTS conversations(
|
|
88
|
+
id TEXT PRIMARY KEY, project_dir TEXT,
|
|
89
|
+
phase TEXT DEFAULT 'idle',
|
|
90
|
+
last_active TEXT DEFAULT (datetime('now'))
|
|
91
|
+
);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS transcript_entries(
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
session_id TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
95
|
+
entry_type TEXT NOT NULL, payload_json TEXT NOT NULL,
|
|
96
|
+
tool_use_id TEXT, created_at TEXT DEFAULT (datetime('now'))
|
|
97
|
+
);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS plans(
|
|
99
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
100
|
+
conversation_id TEXT NOT NULL, content TEXT,
|
|
101
|
+
hash TEXT, status TEXT DEFAULT 'draft', approved_at TEXT
|
|
102
|
+
);
|
|
103
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
104
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
105
|
+
conversation_id TEXT NOT NULL,
|
|
106
|
+
event_type TEXT NOT NULL, detail TEXT,
|
|
107
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
108
|
+
);
|
|
109
|
+
CREATE TABLE IF NOT EXISTS summaries(
|
|
110
|
+
conversation_id TEXT PRIMARY KEY,
|
|
111
|
+
content TEXT, word_count INTEGER,
|
|
112
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
113
|
+
);
|
|
114
|
+
`)
|
|
115
|
+
|
|
116
|
+
// ── Markdown ─────────────────────────────────────────────────
|
|
117
|
+
marked.setOptions({
|
|
118
|
+
renderer: new TerminalRenderer({
|
|
119
|
+
code: chalk.hex(BRAND.lavender),
|
|
120
|
+
codespan: chalk.hex(BRAND.lavender),
|
|
121
|
+
strong: chalk.bold,
|
|
122
|
+
em: chalk.italic,
|
|
123
|
+
heading: chalk.bold.hex(BRAND.accent),
|
|
124
|
+
hr: () => chalk.dim('─'.repeat(50)),
|
|
125
|
+
paragraph: text => text + '\n',
|
|
126
|
+
link: (href, _t, text) => `${text} ${chalk.dim.underline(href)}`,
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
function renderMd(text) {
|
|
130
|
+
if (!text) return ''
|
|
131
|
+
try { return marked(text).replace(/\n{3,}/g, '\n\n').trimEnd() }
|
|
132
|
+
catch { return text }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Constants ────────────────────────────────────────────────
|
|
136
|
+
const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
137
|
+
const SLASH_COMMANDS = [
|
|
138
|
+
{ name: '/model', desc: 'Change model endpoint' },
|
|
139
|
+
{ name: '/key', desc: 'Set Nehanda API key' },
|
|
140
|
+
{ name: '/config', desc: 'Show current config' },
|
|
141
|
+
{ name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
|
|
142
|
+
{ name: '/clear', desc: 'New conversation' },
|
|
143
|
+
{ name: '/retry', desc: 'Resend the last failed message' },
|
|
144
|
+
{ name: '/help', desc: 'Show commands' },
|
|
145
|
+
{ name: '/exit', desc: 'Quit' },
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
// ── Model registry ────────────────────────────────────────────
|
|
149
|
+
const DEFAULT_REGISTRY = {
|
|
150
|
+
ollama_hosts: ['http://AsobaCorp-1.local:11434'],
|
|
151
|
+
models: [
|
|
152
|
+
{ name: 'nehanda-rag-synthesis-27b', endpoint: 'https://nehanda-ml.asoba.co/v1', label: 'Nehanda 27B (EC2)', desc: 'Primary — fine-tuned Qwen3.6 27B, vLLM, af-south-1' },
|
|
153
|
+
{ name: 'deepseek-coder-v2:latest', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'DeepSeek Coder V2 (LAN)', desc: 'Windows LAN delegate — coding specialist' },
|
|
154
|
+
{ name: 'qwen2.5:14b', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'Qwen 2.5 14B (LAN)', desc: 'Windows LAN delegate — general reasoning' },
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
function readRegistry() {
|
|
158
|
+
try { return JSON.parse(fs.readFileSync(path.join(AIMEE_DIR, 'model-registry.json'), 'utf8')) }
|
|
159
|
+
catch { return DEFAULT_REGISTRY }
|
|
160
|
+
}
|
|
161
|
+
async function discoverOllamaModels(hosts) {
|
|
162
|
+
const out = []
|
|
163
|
+
await Promise.all(hosts.map(async host => {
|
|
164
|
+
const base = host.replace(/\/$/, '')
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(2000) })
|
|
167
|
+
if (!res.ok) return
|
|
168
|
+
const data = await res.json()
|
|
169
|
+
for (const m of (data.models || []))
|
|
170
|
+
out.push({ name: m.name, endpoint: `${base}/v1`, label: `${m.name} (${new URL(base).hostname})`, desc: `Discovered — ${m.name}`, discovered: true })
|
|
171
|
+
} catch {}
|
|
172
|
+
}))
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
async function buildModelList() {
|
|
176
|
+
const reg = readRegistry()
|
|
177
|
+
const base = reg.models || []
|
|
178
|
+
const disc = await discoverOllamaModels(reg.ollama_hosts || [])
|
|
179
|
+
const keys = new Set(base.map(m => `${m.name}|${m.endpoint}`))
|
|
180
|
+
return [...base, ...disc.filter(m => !keys.has(`${m.name}|${m.endpoint}`))]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Asoba lock-up (compact) ───────────────────────────────────
|
|
184
|
+
function AsobaMark() {
|
|
185
|
+
return e(Box, { flexDirection: 'column', alignItems: 'center' },
|
|
186
|
+
e(Text, {}, e(Text, { color: BRAND.accent }, '▐▐▐ ')),
|
|
187
|
+
e(Text, { bold: true }, 'N E H A N D A'),
|
|
188
|
+
e(Text, { dimColor: true }, 'by Asoba'),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ASOBA_BADGE = `
|
|
193
|
+
............'.
|
|
194
|
+
'....................'
|
|
195
|
+
............................
|
|
196
|
+
................................
|
|
197
|
+
....................................
|
|
198
|
+
......................................
|
|
199
|
+
........................................
|
|
200
|
+
\`.................;(LwwU{,.................
|
|
201
|
+
...............I&$$$$$$$$$$a^...............
|
|
202
|
+
.............."W$$$$@$@@$@$$$$Y...............
|
|
203
|
+
.............,@$$$$$$$$$$$@$@$$O..............
|
|
204
|
+
'.............W$$$$$$t...^M$$$$$@;.............
|
|
205
|
+
'.............$$$@$$/.....'@$$$$@{.............
|
|
206
|
+
..............@$$$$@u......@$$@$$).............
|
|
207
|
+
..............k$$$$@@q:..:.B$$$$$).............
|
|
208
|
+
.............\`8$$$@$$$$$W'B$$$$$).............
|
|
209
|
+
..............'q$@$$$$$$W'B$@$$$).............
|
|
210
|
+
...............\`d$@$$@$W'B@$$$$(............
|
|
211
|
+
..................'I]|[l....................
|
|
212
|
+
.........................................'
|
|
213
|
+
.......................................'
|
|
214
|
+
.....................................
|
|
215
|
+
..................................
|
|
216
|
+
..............................
|
|
217
|
+
........................
|
|
218
|
+
..................
|
|
219
|
+
\`\``
|
|
220
|
+
|
|
221
|
+
const BADGE_DOT = new Set([' ','.', "'", '`',',',':',';','^'])
|
|
222
|
+
function BadgeLine({ line }) {
|
|
223
|
+
const runs = []
|
|
224
|
+
for (const ch of line) {
|
|
225
|
+
const cls = BADGE_DOT.has(ch) ? 'dot' : 'glyph'
|
|
226
|
+
const last = runs[runs.length - 1]
|
|
227
|
+
if (last && last.cls === cls) last.text += ch
|
|
228
|
+
else runs.push({ cls, text: ch })
|
|
229
|
+
}
|
|
230
|
+
return e(Text, {}, ...runs.map((r, i) => e(Text, {
|
|
231
|
+
key: String(i), color: r.cls === 'glyph' ? BRAND.accent : undefined,
|
|
232
|
+
bold: r.cls === 'glyph', dimColor: r.cls === 'dot',
|
|
233
|
+
}, r.text)))
|
|
234
|
+
}
|
|
235
|
+
function AsobaBadge() {
|
|
236
|
+
const lines = ASOBA_BADGE.split('\n')
|
|
237
|
+
return e(Box, { flexDirection: 'column', alignItems: 'center' },
|
|
238
|
+
...lines.map((l, i) => e(BadgeLine, { key: String(i), line: l })))
|
|
239
|
+
}
|
|
240
|
+
function SplashScreen({ onDone }) {
|
|
241
|
+
useInput(() => onDone())
|
|
242
|
+
React.useEffect(() => { const t = setTimeout(onDone, 1400); return () => clearTimeout(t) }, [onDone])
|
|
243
|
+
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
244
|
+
const bw = Math.max(...ASOBA_BADGE.split('\n').map(l => l.length))
|
|
245
|
+
return e(Box, { flexDirection: 'column', alignItems: 'center', marginY: 1 },
|
|
246
|
+
cols >= bw + 4 ? e(AsobaBadge, {}) : e(AsobaMark, {}),
|
|
247
|
+
e(Box, { marginTop: 1 }, e(Text, { bold: true, color: BRAND.accent }, 'NEHANDA')),
|
|
248
|
+
e(Text, { dimColor: true }, 'by Asoba'),
|
|
249
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'press any key to continue…')),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
function WelcomeBanner({ model, endpoint }) {
|
|
253
|
+
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
254
|
+
const bw = Math.min(cols - 2, 80)
|
|
255
|
+
const lw = Math.floor(bw * 0.42), rw = bw - lw - 3
|
|
256
|
+
return e(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: BRAND.accent, width: bw, marginBottom: 1 },
|
|
257
|
+
e(Box, { flexDirection: 'row', paddingX: 1 },
|
|
258
|
+
e(Box, { flexDirection: 'column', width: lw, alignItems: 'center', paddingY: 1 },
|
|
259
|
+
e(AsobaMark, {}), e(Text, { dimColor: true }, model || '—')),
|
|
260
|
+
e(Box, { width: 1, borderStyle: 'single', borderColor: BRAND.accent, borderTop: false, borderBottom: false, borderRight: false, borderLeft: true }),
|
|
261
|
+
e(Box, { flexDirection: 'column', width: rw, paddingLeft: 1, paddingY: 1 },
|
|
262
|
+
e(Text, { bold: true, color: BRAND.accent }, 'Configuration'),
|
|
263
|
+
e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
264
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
|
|
265
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
|
|
266
|
+
),
|
|
267
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'Type /help for commands')),
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Message view ─────────────────────────────────────────────
|
|
274
|
+
function MessageView({ msg }) {
|
|
275
|
+
if (msg.role === 'user')
|
|
276
|
+
return e(Box, { flexDirection: 'row' }, e(Text, { bold: true, color: BRAND.accent }, '❯ '),
|
|
277
|
+
e(Box, { flexShrink: 1 }, e(Text, { wrap: 'wrap' }, msg.text)))
|
|
278
|
+
if (msg.role === 'assistant') {
|
|
279
|
+
if (!msg.text) return msg.cancelled
|
|
280
|
+
? e(Box, { flexDirection: 'row' }, e(Text, { dimColor: true }, '● '), e(Text, { dimColor: true, italic: true }, '(cancelled)'))
|
|
281
|
+
: null
|
|
282
|
+
return e(Box, { flexDirection: 'row' }, e(Text, { dimColor: true }, '● '),
|
|
283
|
+
e(Box, { flexDirection: 'column', flexShrink: 1 },
|
|
284
|
+
e(Text, {}, renderMd(msg.text)),
|
|
285
|
+
msg.cancelled ? e(Text, { dimColor: true, italic: true }, '⏹ cancelled') : null))
|
|
286
|
+
}
|
|
287
|
+
if (msg.role === 'system')
|
|
288
|
+
return e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, msg.text))
|
|
289
|
+
if (msg.role === 'error')
|
|
290
|
+
return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.red }, '✗ ' + msg.text))
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
function Spinner({ label }) {
|
|
294
|
+
const [frame, setFrame] = React.useState(0)
|
|
295
|
+
React.useEffect(() => { const t = setInterval(() => setFrame(f => (f + 1) % SPINNER_FRAMES.length), 80); return () => clearInterval(t) }, [])
|
|
296
|
+
return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame] + ' '), e(Text, { dimColor: true }, label || 'Thinking…'))
|
|
297
|
+
}
|
|
298
|
+
function SlashMenu({ filter }) {
|
|
299
|
+
const matches = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter.slice(1)))
|
|
300
|
+
if (!matches.length) return null
|
|
301
|
+
return e(Box, { flexDirection: 'column', marginLeft: 2 },
|
|
302
|
+
...matches.map((c, i) => e(Box, { key: String(i) },
|
|
303
|
+
e(Text, { color: BRAND.accent }, c.name.padEnd(12)), e(Text, { dimColor: true }, c.desc))))
|
|
304
|
+
}
|
|
305
|
+
function InputArea({ onSubmit, isLoading }) {
|
|
306
|
+
const [value, setValue] = React.useState('')
|
|
307
|
+
const showMenu = value.startsWith('/') && !value.includes(' ')
|
|
308
|
+
const handleSubmit = React.useCallback(v => { if (!v.trim()) return; setValue(''); onSubmit(v.trim()) }, [onSubmit])
|
|
309
|
+
if (isLoading) return e(Box, { paddingX: 1 }, e(Text, { dimColor: true }, 'esc to cancel'))
|
|
310
|
+
return e(Box, { flexDirection: 'column' },
|
|
311
|
+
showMenu ? e(SlashMenu, { filter: value }) : null,
|
|
312
|
+
e(Box, { paddingX: 1 }, e(Text, { bold: true, color: BRAND.accent }, '❯ '),
|
|
313
|
+
e(TextInput, { value, onChange: setValue, onSubmit: handleSubmit })))
|
|
314
|
+
}
|
|
315
|
+
function AskPrompt({ question, onAnswer, onCancel }) {
|
|
316
|
+
const [value, setValue] = React.useState('')
|
|
317
|
+
useInput((_input, key) => { if (key.escape) onCancel() })
|
|
318
|
+
return e(Box, { flexDirection: 'column' },
|
|
319
|
+
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
|
|
320
|
+
e(Box, { paddingX: 1 }, e(Text, { color: BRAND.accent }, '❯ '),
|
|
321
|
+
e(TextInput, { value, onChange: setValue, onSubmit: React.useCallback(v => onAnswer(v.trim()), [onAnswer]) })),
|
|
322
|
+
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, 'Esc cancel')))
|
|
323
|
+
}
|
|
324
|
+
function SelectMenu({ title, items, onSelect, onCancel }) {
|
|
325
|
+
const [idx, setIdx] = React.useState(0)
|
|
326
|
+
useInput((input, key) => {
|
|
327
|
+
if (key.escape) { onCancel(); return }
|
|
328
|
+
if (key.upArrow) { setIdx(i => (i - 1 + items.length) % items.length); return }
|
|
329
|
+
if (key.downArrow) { setIdx(i => (i + 1) % items.length); return }
|
|
330
|
+
if (key.return) { onSelect(items[idx]); return }
|
|
331
|
+
const n = parseInt(input, 10)
|
|
332
|
+
if (!isNaN(n) && n >= 1 && n <= items.length) onSelect(items[n - 1])
|
|
333
|
+
})
|
|
334
|
+
return e(Box, { flexDirection: 'column', marginLeft: 1 },
|
|
335
|
+
e(Text, { bold: true, color: BRAND.accent }, title),
|
|
336
|
+
e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
337
|
+
...items.map((item, i) => {
|
|
338
|
+
const active = i === idx
|
|
339
|
+
return e(Box, { key: String(i), flexDirection: 'column' },
|
|
340
|
+
e(Box, {}, e(Text, { color: active ? BRAND.accent : undefined }, active ? '❯ ' : ' '),
|
|
341
|
+
e(Text, { color: BRAND.lavender }, `${i + 1}. `),
|
|
342
|
+
e(Text, { bold: true, color: active ? BRAND.accent : undefined }, item.label)),
|
|
343
|
+
e(Box, { marginLeft: 5 }, e(Text, { dimColor: true }, item.desc)))
|
|
344
|
+
})),
|
|
345
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
346
|
+
'↑↓ navigate · Enter select' + (items.length <= 9 ? ` · 1–${items.length} jump` : '') + ' · Esc cancel')))
|
|
347
|
+
}
|
|
348
|
+
function TokenFooter({ calls, model }) {
|
|
349
|
+
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
350
|
+
return e(Box, { flexDirection: 'column' },
|
|
351
|
+
e(Text, { dimColor: true }, '─'.repeat(cols)),
|
|
352
|
+
e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
|
|
353
|
+
e(Text, { dimColor: true }, `calls ${calls}`),
|
|
354
|
+
e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
|
|
355
|
+
e(Text, { dimColor: true }, '? /help ^C exit')))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Main App ──────────────────────────────────────────────────
|
|
359
|
+
function App({ bridge }) {
|
|
360
|
+
const { exit } = useApp()
|
|
361
|
+
const [showSplash, setShowSplash] = React.useState(() => !hasSeenSplash())
|
|
362
|
+
const [finalized, setFinalized] = React.useState([])
|
|
363
|
+
const [streaming, setStreaming] = React.useState(null)
|
|
364
|
+
const [clearGen, setClearGen] = React.useState(0)
|
|
365
|
+
const [isLoading, setIsLoading] = React.useState(false)
|
|
366
|
+
const [askState, setAskState] = React.useState(null)
|
|
367
|
+
const [selectState, setSelectState] = React.useState(null)
|
|
368
|
+
const [callCount, setCallCount] = React.useState(0)
|
|
369
|
+
const [modelInfo, setModelInfo] = React.useState(() => {
|
|
370
|
+
const data = readAgents()
|
|
371
|
+
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
372
|
+
return { model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' }
|
|
373
|
+
})
|
|
374
|
+
const lastCtrlCRef = React.useRef(0)
|
|
375
|
+
const bannerRef = React.useRef({ kind: 'banner' })
|
|
376
|
+
const historyItems = React.useMemo(() => [bannerRef.current, ...finalized], [finalized])
|
|
377
|
+
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
bridge.addMessage = msg => setFinalized(p => [...p, msg])
|
|
380
|
+
bridge.beginAssistant = () => setStreaming({ text: '' })
|
|
381
|
+
bridge.updateAssistant= text => setStreaming({ text })
|
|
382
|
+
bridge.discardAssistant=() => setStreaming(null)
|
|
383
|
+
bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
|
|
384
|
+
bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
|
|
385
|
+
bridge.setLoading = v => setIsLoading(v)
|
|
386
|
+
bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
|
|
387
|
+
bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
|
|
388
|
+
bridge.exit = () => { exit() }
|
|
389
|
+
bridge.refreshModel = () => {
|
|
390
|
+
const data = readAgents()
|
|
391
|
+
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
392
|
+
setModelInfo({ model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' })
|
|
393
|
+
}
|
|
394
|
+
}, [bridge, exit])
|
|
395
|
+
|
|
396
|
+
const handleAnswer = React.useCallback(answer => {
|
|
397
|
+
if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: askState.question + ' ' + answer }]); askState.resolve(answer) }
|
|
398
|
+
setAskState(null)
|
|
399
|
+
}, [askState])
|
|
400
|
+
const handleAskCancel = React.useCallback(() => { askState?.resolve(null); setAskState(null) }, [askState])
|
|
401
|
+
const handleSelect = React.useCallback(item => {
|
|
402
|
+
if (selectState?.resolve) { setFinalized(p => [...p, { role: 'system', text: ` Selected: ${item.label}` }]); selectState.resolve(item) }
|
|
403
|
+
setSelectState(null)
|
|
404
|
+
}, [selectState])
|
|
405
|
+
const handleSelectCancel = React.useCallback(() => { selectState?.resolve(null); setSelectState(null) }, [selectState])
|
|
406
|
+
|
|
407
|
+
useInput((input, key) => {
|
|
408
|
+
if (key.escape) { if (isLoading && bridge.abortCurrent) bridge.abortCurrent(); return }
|
|
409
|
+
if (key.ctrl && input === 'c') {
|
|
410
|
+
if (isLoading && bridge.abortCurrent) { bridge.abortCurrent(); return }
|
|
411
|
+
const now = Date.now()
|
|
412
|
+
if (now - lastCtrlCRef.current < 1200) { exit(); process.exit(0) }
|
|
413
|
+
lastCtrlCRef.current = now
|
|
414
|
+
bridge.addMessage({ role: 'system', text: ' (press Ctrl+C again to exit)' })
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
if (showSplash)
|
|
419
|
+
return e(SplashScreen, { onDone: () => { markSplashSeen(); setShowSplash(false) } })
|
|
420
|
+
|
|
421
|
+
return e(Box, { flexDirection: 'column' },
|
|
422
|
+
e(Static, { items: historyItems, key: clearGen },
|
|
423
|
+
(item, i) => item.kind === 'banner'
|
|
424
|
+
? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
|
|
425
|
+
: e(MessageView, { key: String(i), msg: item })),
|
|
426
|
+
streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
|
|
427
|
+
isLoading && (!streaming || !streaming.text) ? e(Spinner, {}) : null,
|
|
428
|
+
askState
|
|
429
|
+
? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
430
|
+
: selectState
|
|
431
|
+
? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
|
|
432
|
+
: e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
|
|
433
|
+
e(TokenFooter, { calls: callCount, model: modelInfo.model }))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Diff executor ─────────────────────────────────────────────
|
|
437
|
+
function applyDiffBlocks(text, bridge) {
|
|
438
|
+
const re = /^(.+?\.(?:\w+))\s*\n<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/gm
|
|
439
|
+
let match
|
|
440
|
+
while ((match = re.exec(text)) !== null) {
|
|
441
|
+
const [, filepath, search, replace] = match
|
|
442
|
+
try {
|
|
443
|
+
const abs = path.isAbsolute(filepath) ? filepath : path.resolve(process.cwd(), filepath)
|
|
444
|
+
if (!fs.existsSync(abs)) { bridge.addMessage({ role: 'error', text: `✗ File not found: ${filepath}` }); continue }
|
|
445
|
+
const content = fs.readFileSync(abs, 'utf-8')
|
|
446
|
+
if (!content.includes(search)) { bridge.addMessage({ role: 'error', text: `✗ Search block not found: ${filepath}` }); continue }
|
|
447
|
+
fs.writeFileSync(abs, content.replace(search, replace), 'utf-8')
|
|
448
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Applied diff to ${chalk.bold(filepath)}` })
|
|
449
|
+
} catch (err) {
|
|
450
|
+
bridge.addMessage({ role: 'error', text: `✗ ${filepath}: ${err.message}` })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Thinking trace stripper ───────────────────────────────────
|
|
456
|
+
function stripThink(text) {
|
|
457
|
+
return (text || '')
|
|
458
|
+
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
459
|
+
.replace(/<think>[\s\S]*/g, '')
|
|
460
|
+
.trim()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Command handler ───────────────────────────────────────────
|
|
464
|
+
async function handleCommand(cmd, bridge) {
|
|
465
|
+
const [name] = cmd.split(/\s+/)
|
|
466
|
+
|
|
467
|
+
if (name === '/help') {
|
|
468
|
+
bridge.addMessage({ role: 'system', text: '\nCommands:\n' +
|
|
469
|
+
SLASH_COMMANDS.map(c => ` ${chalk.hex(BRAND.accent)(c.name.padEnd(12))} ${chalk.dim(c.desc)}`).join('\n') + '\n' })
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (name === '/config') {
|
|
473
|
+
const data = readAgents()
|
|
474
|
+
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
475
|
+
bridge.addMessage({ role: 'system', text: [
|
|
476
|
+
'', ` Model: ${chalk.hex(BRAND.accent)(ag?.model || '—')}`,
|
|
477
|
+
` Endpoint: ${chalk.dim(ag?.endpoint || '—')}`,
|
|
478
|
+
` API key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`, '',
|
|
479
|
+
].join('\n') })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (name === '/clear') {
|
|
483
|
+
bridge.clearMessages()
|
|
484
|
+
bridge.addMessage({ role: 'system', text: ' Conversation cleared.' })
|
|
485
|
+
bridge.convId = `nehanda-${Date.now()}` // new conversation ID resets transcript
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (name === '/retry') {
|
|
489
|
+
if (!bridge.lastFailedText) { bridge.addMessage({ role: 'system', text: ' Nothing to retry.' }); return }
|
|
490
|
+
const t = bridge.lastFailedText; bridge.lastFailedText = null; await bridge.onSubmit(t); return
|
|
491
|
+
}
|
|
492
|
+
if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
|
|
493
|
+
if (name === '/key') {
|
|
494
|
+
const answer = await bridge.ask('New Nehanda API key:')
|
|
495
|
+
if (!answer) return
|
|
496
|
+
try { writeApiKey(answer); bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} API key updated.` }) }
|
|
497
|
+
catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
if (name === '/model') {
|
|
501
|
+
bridge.addMessage({ role: 'system', text: ' Discovering models…' })
|
|
502
|
+
const all = await buildModelList()
|
|
503
|
+
const data0 = readAgents()
|
|
504
|
+
const curAg = data0.agents?.find(a => a.name === data0.default_agent) || data0.agents?.[0]
|
|
505
|
+
const cur = curAg?.model || ''
|
|
506
|
+
const items = all.map(m => ({ ...m, label: m.name === cur ? `${m.label} ✓` : m.label }))
|
|
507
|
+
const sel = await bridge.select('Select model', items)
|
|
508
|
+
if (!sel) return
|
|
509
|
+
try {
|
|
510
|
+
const data = readAgents()
|
|
511
|
+
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
512
|
+
if (ag) { ag.model = sel.name; ag.endpoint = sel.endpoint }
|
|
513
|
+
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
514
|
+
// Keep in-session model_config in sync so the next turn uses the selected wire name
|
|
515
|
+
runtimeSettings.model_config = modelConfigFromAgent(
|
|
516
|
+
ag || { model: sel.name, endpoint: sel.endpoint },
|
|
517
|
+
{ apiKey: readApiKey() },
|
|
518
|
+
)
|
|
519
|
+
bridge.refreshModel()
|
|
520
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Model → ${chalk.bold(sel.name)}` })
|
|
521
|
+
} catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
if (name === '/mcp') {
|
|
525
|
+
const parts = cmd.trim().split(/\s+/)
|
|
526
|
+
const sub = parts[1] || ''
|
|
527
|
+
const servers = runtimeSettings.mcp_servers || {}
|
|
528
|
+
const serverNames = Object.keys(servers)
|
|
529
|
+
|
|
530
|
+
if (!sub || sub === 'help') {
|
|
531
|
+
const lines = [
|
|
532
|
+
'',
|
|
533
|
+
` ${chalk.bold('MCP Server & Tool Commands:')}`,
|
|
534
|
+
` ${chalk.hex(BRAND.accent)('/mcp list'.padEnd(28))} ${chalk.dim('Discover and list all available MCP tools')}`,
|
|
535
|
+
` ${chalk.hex(BRAND.accent)('/mcp status'.padEnd(28))} ${chalk.dim('Show configured servers and command paths')}`,
|
|
536
|
+
` ${chalk.hex(BRAND.accent)('/mcp reload [server]'.padEnd(28))} ${chalk.dim('Reload mcp.json configs into session')}`,
|
|
537
|
+
` ${chalk.hex(BRAND.accent)('/mcp add <name> <cmd> […]'.padEnd(28))} ${chalk.dim('Register a new MCP server')}`,
|
|
538
|
+
` ${chalk.hex(BRAND.accent)('/mcp env [server] [K] [V]'.padEnd(28))} ${chalk.dim('View or set server environment variables')}`,
|
|
539
|
+
'',
|
|
540
|
+
` ${chalk.dim(`Configured servers: ${serverNames.length ? serverNames.join(', ') : 'none'}`)}`,
|
|
541
|
+
` ${chalk.dim('Tip: Run /mcp list to inspect tools injected into the assistant.')}`,
|
|
542
|
+
'',
|
|
543
|
+
]
|
|
544
|
+
bridge.addMessage({ role: 'system', text: lines.join('\n') })
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (sub === 'status') {
|
|
549
|
+
if (!serverNames.length) {
|
|
550
|
+
bridge.addMessage({ role: 'system', text: ' No MCP servers configured.\n Add servers to mcp.json in your project root or ~/.config/nehanda/mcp.json' })
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const lines = ['\n MCP Servers:']
|
|
554
|
+
for (const name of serverNames) {
|
|
555
|
+
const cfg = servers[name]
|
|
556
|
+
lines.push(` ${chalk.hex(BRAND.accent)(name.padEnd(20))} ${chalk.dim(cfg.command + ' ' + (cfg.args || []).join(' '))}`)
|
|
557
|
+
}
|
|
558
|
+
lines.push('')
|
|
559
|
+
lines.push(` ${chalk.dim('Tip: Run /mcp list to view tools provided by these servers.')}\n`)
|
|
560
|
+
bridge.addMessage({ role: 'system', text: lines.join('\n') })
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (sub === 'list') {
|
|
565
|
+
if (!serverNames.length) {
|
|
566
|
+
bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
bridge.addMessage({ role: 'system', text: ' Discovering MCP tools…' })
|
|
570
|
+
const lines = []
|
|
571
|
+
for (const srvName of serverNames) {
|
|
572
|
+
const cfg = servers[srvName]
|
|
573
|
+
try {
|
|
574
|
+
const srv = getMcpServer(srvName, cfg)
|
|
575
|
+
const resp = await srv.request('tools/list', {})
|
|
576
|
+
const tools = resp.result?.tools || []
|
|
577
|
+
if (resp.error) {
|
|
578
|
+
lines.push(` ${chalk.hex(BRAND.red)('✗')} ${srvName}: ${resp.error.message}`)
|
|
579
|
+
} else if (!tools.length) {
|
|
580
|
+
lines.push(` ${chalk.dim(srvName)}: (no tools)`)
|
|
581
|
+
} else {
|
|
582
|
+
lines.push(` ${chalk.hex(BRAND.accent)(srvName)} (${tools.length} tool${tools.length === 1 ? '' : 's'}):`)
|
|
583
|
+
for (const t of tools) {
|
|
584
|
+
lines.push(` ${chalk.hex(BRAND.lavender)(t.name.padEnd(30))} ${chalk.dim(t.description || '')}`)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
lines.push(` ${chalk.hex(BRAND.red)('✗')} ${srvName}: ${err.message}`)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
bridge.addMessage({ role: 'system', text: '\n' + lines.join('\n') + '\n' })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (sub === 'reload') {
|
|
596
|
+
const targetServer = parts[2] || null
|
|
597
|
+
// Re-read mcp.json into runtimeSettings
|
|
598
|
+
runtimeSettings.mcp_servers = {}
|
|
599
|
+
applyMcpConfig(runtimeSettings, process.cwd())
|
|
600
|
+
// Bust the tool discovery cache so next turn re-fetches
|
|
601
|
+
invalidateMcpToolCache(targetServer || undefined)
|
|
602
|
+
const count = Object.keys(runtimeSettings.mcp_servers).length
|
|
603
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} MCP config reloaded. ${count} server${count === 1 ? '' : 's'} configured.` })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (sub === 'add') {
|
|
608
|
+
// /mcp add <name> <command> [args...]
|
|
609
|
+
const srvName = parts[2]
|
|
610
|
+
const command = parts[3]
|
|
611
|
+
const args = parts.slice(4)
|
|
612
|
+
if (!srvName || !command) {
|
|
613
|
+
bridge.addMessage({ role: 'system', text: ' Usage: /mcp add <name> <command> [args…]\n Example: /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem /path' })
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const existing = readGlobalMcpConfig()
|
|
617
|
+
existing[srvName] = { command, args, env: {} }
|
|
618
|
+
writeGlobalMcpConfig(existing)
|
|
619
|
+
// Reload immediately
|
|
620
|
+
runtimeSettings.mcp_servers = {}
|
|
621
|
+
applyMcpConfig(runtimeSettings, process.cwd())
|
|
622
|
+
invalidateMcpToolCache()
|
|
623
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Added ${chalk.bold(srvName)} → ${command} ${args.join(' ')}\n Saved to ${globalMcpConfigPath()}` })
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (sub === 'env') {
|
|
628
|
+
const targetServer = parts[2]
|
|
629
|
+
const envKey = parts[3]
|
|
630
|
+
const envVal = parts[4]
|
|
631
|
+
|
|
632
|
+
if (!targetServer) {
|
|
633
|
+
// Show env for all servers
|
|
634
|
+
if (!serverNames.length) {
|
|
635
|
+
bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
const lines = ['\n MCP Server Environment Variables:']
|
|
639
|
+
for (const name of serverNames) {
|
|
640
|
+
const cfg = servers[name]
|
|
641
|
+
const envKeys = Object.keys(cfg.env || {})
|
|
642
|
+
if (!envKeys.length) {
|
|
643
|
+
lines.push(` ${chalk.hex(BRAND.accent)(name)}: (none)`)
|
|
644
|
+
} else {
|
|
645
|
+
lines.push(` ${chalk.hex(BRAND.accent)(name)}:`)
|
|
646
|
+
for (const k of envKeys) {
|
|
647
|
+
const v = cfg.env[k]
|
|
648
|
+
const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
|
|
649
|
+
lines.push(` ${k} = ${chalk.dim(masked)}`)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
bridge.addMessage({ role: 'system', text: lines.join('\n') + '\n' })
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (!envKey) {
|
|
658
|
+
// Show env for a specific server
|
|
659
|
+
const cfg = servers[targetServer]
|
|
660
|
+
if (!cfg) {
|
|
661
|
+
bridge.addMessage({ role: 'system', text: ` Server "${targetServer}" not found.` })
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
const envKeys = Object.keys(cfg.env || {})
|
|
665
|
+
if (!envKeys.length) {
|
|
666
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(targetServer)}: (no env vars set)` })
|
|
667
|
+
} else {
|
|
668
|
+
const lines = [` ${chalk.hex(BRAND.accent)(targetServer)}:`]
|
|
669
|
+
for (const k of envKeys) {
|
|
670
|
+
const v = cfg.env[k]
|
|
671
|
+
const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
|
|
672
|
+
lines.push(` ${k} = ${chalk.dim(masked)}`)
|
|
673
|
+
}
|
|
674
|
+
bridge.addMessage({ role: 'system', text: lines.join('\n') + '\n' })
|
|
675
|
+
}
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Set or clear env var
|
|
680
|
+
const result = updateMcpServerEnv(targetServer, envKey, envVal || '', process.cwd())
|
|
681
|
+
if (!result.ok) {
|
|
682
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
// Reload runtime settings and bust cache
|
|
686
|
+
runtimeSettings.mcp_servers = {}
|
|
687
|
+
applyMcpConfig(runtimeSettings, process.cwd())
|
|
688
|
+
invalidateMcpToolCache()
|
|
689
|
+
if (result.set) {
|
|
690
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(envKey)} = ${'********'} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
|
|
691
|
+
} else {
|
|
692
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Cleared ${chalk.bold(envKey)} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
|
|
693
|
+
}
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload [server] · add <name> <command> [args…] · env [server] [KEY [VALUE]]' })
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
bridge.addMessage({ role: 'system', text: ` Unknown command: ${name}. Type /help.` })
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Entry point ───────────────────────────────────────────────
|
|
705
|
+
const bridge = {
|
|
706
|
+
convId: `nehanda-${Date.now()}`,
|
|
707
|
+
lastFailedText: null,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── Runtime settings — shared across turns ───────────────────
|
|
711
|
+
// Built once here; applyMcpConfig merges mcp.json servers in.
|
|
712
|
+
// Each turn rebuilds model_config from agents.json via modelConfigFromAgent
|
|
713
|
+
// so /model selection changes the wire model (not just the endpoint).
|
|
714
|
+
const _bootAgent = (() => {
|
|
715
|
+
const data = readAgents()
|
|
716
|
+
return data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
717
|
+
})()
|
|
718
|
+
const runtimeSettings = {
|
|
719
|
+
model_config: modelConfigFromAgent(_bootAgent, {
|
|
720
|
+
apiKey: readApiKey(),
|
|
721
|
+
defaultEndpoint: process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1',
|
|
722
|
+
}),
|
|
723
|
+
permissions: { defaultMode: 'default' },
|
|
724
|
+
mcp_servers: {},
|
|
725
|
+
}
|
|
726
|
+
applyMcpConfig(runtimeSettings, process.cwd())
|
|
727
|
+
|
|
728
|
+
bridge.onSubmit = async (text) => {
|
|
729
|
+
bridge.addMessage({ role: 'user', text })
|
|
730
|
+
if (text.startsWith('/')) { await handleCommand(text, bridge); return }
|
|
731
|
+
|
|
732
|
+
bridge.setLoading(true)
|
|
733
|
+
const controller = new AbortController()
|
|
734
|
+
bridge.abortCurrent = () => controller.abort()
|
|
735
|
+
let lastPartial = ''
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
bridge.beginAssistant()
|
|
739
|
+
|
|
740
|
+
const agentsData = readAgents()
|
|
741
|
+
const agent = agentsData.agents?.find(a => a.name === agentsData.default_agent) || agentsData.agents?.[0]
|
|
742
|
+
const modelConfig = modelConfigFromAgent(agent, {
|
|
743
|
+
apiKey: readApiKey(),
|
|
744
|
+
defaultEndpoint: process.env.NEHANDA_BASE_URL || 'https://nehanda-ml.asoba.co/v1',
|
|
745
|
+
})
|
|
746
|
+
runtimeSettings.model_config = modelConfig
|
|
747
|
+
|
|
748
|
+
// Ensure conversation row exists for this convId
|
|
749
|
+
sessionDb.prepare(
|
|
750
|
+
`INSERT OR IGNORE INTO conversations(id, project_dir, phase) VALUES (?, ?, 'idle')`
|
|
751
|
+
).run(bridge.convId, process.cwd())
|
|
752
|
+
|
|
753
|
+
const rt = {
|
|
754
|
+
sessionId: bridge.convId,
|
|
755
|
+
conversationId: bridge.convId,
|
|
756
|
+
cwd: process.cwd(),
|
|
757
|
+
runtimeDbPath: null,
|
|
758
|
+
bareMode: false,
|
|
759
|
+
// Project instructions sourced from nehanda's own loadInstructions() (Ona.md),
|
|
760
|
+
// matching bin/agent.mjs. Does NOT read agents.json exec_system_prompt — that
|
|
761
|
+
// field belongs to aimee and was silently reintroducing conflicting tool-call
|
|
762
|
+
// syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
|
|
763
|
+
onaInstructions: onaInstructionsContent || null,
|
|
764
|
+
settings: {
|
|
765
|
+
...runtimeSettings,
|
|
766
|
+
model_config: modelConfig,
|
|
767
|
+
},
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let fullText = '', aborted = false
|
|
771
|
+
controller.signal.addEventListener('abort', () => { aborted = true })
|
|
772
|
+
|
|
773
|
+
const io = {
|
|
774
|
+
write(chunk) {
|
|
775
|
+
if (aborted) return
|
|
776
|
+
fullText += chunk
|
|
777
|
+
lastPartial = stripThink(fullText).trimStart()
|
|
778
|
+
bridge.updateAssistant(lastPartial)
|
|
779
|
+
},
|
|
780
|
+
println(msg) { if (!aborted) bridge.addMessage({ role: 'system', text: ' ' + msg }) },
|
|
781
|
+
spinner: { start() {}, stop() {} },
|
|
782
|
+
ask: bridge.ask,
|
|
783
|
+
onToolStart(name) { if (!aborted) bridge.addMessage({ role: 'system', text: ` ⚙ ${name}` }) },
|
|
784
|
+
onToolResult(name, c, err) {
|
|
785
|
+
if (aborted) return
|
|
786
|
+
const preview = String(c || '').slice(0, 120).replace(/\n/g, ' ')
|
|
787
|
+
bridge.addMessage({ role: 'system', text: ` ${err ? '✗' : '✓'} ${name}: ${preview}` })
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
await runUserTurn(sessionDb, rt, text, io)
|
|
792
|
+
|
|
793
|
+
if (aborted) { const e = new Error('abort'); e.name = 'AbortError'; throw e }
|
|
794
|
+
|
|
795
|
+
const reply = stripThink(fullText)
|
|
796
|
+
bridge.endAssistant({ role: 'assistant', text: reply })
|
|
797
|
+
applyDiffBlocks(reply, bridge)
|
|
798
|
+
bridge.lastFailedText = null
|
|
799
|
+
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err.name === 'AbortError') {
|
|
802
|
+
bridge.endAssistant({ role: 'assistant', text: lastPartial, cancelled: true })
|
|
803
|
+
} else {
|
|
804
|
+
bridge.discardAssistant()
|
|
805
|
+
bridge.addMessage({ role: 'error', text: err.message + '\n Type /retry to resend.' })
|
|
806
|
+
bridge.lastFailedText = text
|
|
807
|
+
}
|
|
808
|
+
} finally {
|
|
809
|
+
bridge.setLoading(false)
|
|
810
|
+
bridge.abortCurrent = null
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Provision Laya venv and warm the sidecar before the first turn.
|
|
815
|
+
// This is mandatory — if it fails, abort with a clear error.
|
|
816
|
+
try {
|
|
817
|
+
await ensureLayaReady()
|
|
818
|
+
} catch (e) {
|
|
819
|
+
process.stderr.write(`[startup] Laya provisioning failed: ${e.message}\n`)
|
|
820
|
+
process.stderr.write('[startup] Cannot start: Laya (System-1) is required. Fix the error above and retry.\n')
|
|
821
|
+
process.exit(1)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
render(e(App, { bridge }), { exitOnCtrlC: false })
|