@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +404 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
package/lib/laya.mjs ADDED
@@ -0,0 +1,296 @@
1
+ import { layaRequest, LAYA_DEFAULT_TIMEOUT_MS } from './laya_sidecar.mjs'
2
+
3
+ /** Max characters of state passed to Laya (~300 tokens). */
4
+ export const LAYA_STATE_CHAR_BUDGET = 1200
5
+
6
+ export const TASK_STATUS_CRITERIA = {
7
+ investigating: 'reading code, searching, diagnosing, exploring',
8
+ modifying_code: 'editing, writing, applying patches, implementing',
9
+ awaiting_user: 'needs user input, clarification, or approval',
10
+ verifying: 'running tests, checking builds, validating behavior',
11
+ blocked: 'stuck on errors, failures, missing deps, permission denied',
12
+ idle: 'greeting, done, no active engineering task',
13
+ }
14
+
15
+ export const TYPE_QUESTIONS = {
16
+ episodic: {
17
+ type: 'noul',
18
+ instructions: 'Is this a concrete interaction event or turn-specific observation?',
19
+ },
20
+ semantic: {
21
+ type: 'noul',
22
+ instructions: 'Does this encode lasting factual knowledge about the codebase or domain?',
23
+ },
24
+ procedural: {
25
+ type: 'noul',
26
+ instructions: 'Does this describe how to do something (steps, commands, workflows)?',
27
+ },
28
+ preference: {
29
+ type: 'noul',
30
+ instructions: 'Does this capture a user preference or standing instruction?',
31
+ },
32
+ }
33
+
34
+ export const RELATION_QUESTIONS = {
35
+ semantic_related: {
36
+ type: 'noul',
37
+ instructions: 'Are these two memories semantically related?',
38
+ },
39
+ causal_influence: {
40
+ type: 'noul',
41
+ instructions: 'Does the first memory causally influence or explain the second?',
42
+ },
43
+ same_episode: {
44
+ type: 'noul',
45
+ instructions: 'Do these memories belong to the same task episode?',
46
+ },
47
+ entity_equivalent: {
48
+ type: 'noul',
49
+ instructions: 'Do these memories refer to the same entity (file, symbol, service)?',
50
+ },
51
+ }
52
+
53
+ export const ROUTE_QUESTIONS = {
54
+ need_semantic: { type: 'noul', instructions: 'Should semantic relations be searched for this query?' },
55
+ need_temporal: { type: 'noul', instructions: 'Should temporal relations be searched for this query?' },
56
+ need_causal: { type: 'noul', instructions: 'Should causal relations be searched for this query?' },
57
+ need_entity: { type: 'noul', instructions: 'Should entity relations be searched for this query?' },
58
+ multi_hop: { type: 'noul', instructions: 'Does answering require multi-hop graph traversal?' },
59
+ recency_important: { type: 'noul', instructions: 'Is recent information especially important?' },
60
+ }
61
+
62
+ export const EVIDENCE_QUESTIONS = {
63
+ sufficient: { type: 'noul', instructions: 'Is the current evidence set sufficient to answer?' },
64
+ further_utility: { type: 'noul', instructions: 'Would further retrieval likely improve the answer?' },
65
+ missing_required: { type: 'noul', instructions: 'Is required evidence still missing?' },
66
+ contradiction: { type: 'noul', instructions: 'Is there an unresolved contradiction in the evidence?' },
67
+ }
68
+
69
+ export const CANDIDATE_QUESTIONS = {
70
+ relevance: { type: 'noul', instructions: 'Is this candidate relevant to the query?' },
71
+ relation_useful: { type: 'noul', instructions: 'Is the relation that reached this candidate useful?' },
72
+ novelty: { type: 'noul', instructions: 'Does this candidate add new information beyond current evidence?' },
73
+ supports_evidence: { type: 'noul', instructions: 'Does this candidate support or clarify current evidence?' },
74
+ }
75
+
76
+ export const SESSION_STATE_QUESTIONS = {
77
+ task_status: {
78
+ type: 'choice',
79
+ instructions: 'What is the agent currently doing?',
80
+ criteria: TASK_STATUS_CRITERIA,
81
+ },
82
+ escalation_risk: {
83
+ type: 'noul',
84
+ instructions: 'Is the agent blocked, failing repeatedly, or needing escalation?',
85
+ },
86
+ }
87
+
88
+ /** Truncate from the end (most recent) to fit Laya's state budget. */
89
+ export function truncateForLaya(text, budget = LAYA_STATE_CHAR_BUDGET) {
90
+ if (!text) return ''
91
+ const s = String(text)
92
+ if (s.length <= budget) return s
93
+ return s.slice(s.length - budget)
94
+ }
95
+
96
+ /**
97
+ * @typedef {{ predict?: Function, timeoutMs?: number }} LayaOpts
98
+ */
99
+
100
+ /** Optional injectible predictor for tests; defaults to sidecar. */
101
+ let _predictOverride = null
102
+
103
+ export function setLayaPredictOverride(fn) {
104
+ _predictOverride = fn
105
+ }
106
+
107
+ export function clearLayaPredictOverride() {
108
+ _predictOverride = null
109
+ }
110
+
111
+ /**
112
+ * Core System-1 call. Returns { ok, answers, error, degraded }.
113
+ * On timeout/failure, returns degraded heuristic answers when questions are known.
114
+ */
115
+ export async function queryLaya(state, questions, opts = {}) {
116
+ const timeoutMs = opts.timeoutMs ?? LAYA_DEFAULT_TIMEOUT_MS
117
+ const clipped = truncateForLaya(state)
118
+
119
+ if (typeof opts.predict === 'function') {
120
+ try {
121
+ const result = await opts.predict(clipped, questions)
122
+ return { ok: true, answers: normalizeAnswers(result), degraded: false }
123
+ } catch (e) {
124
+ return { ok: false, answers: heuristicAnswers(questions, clipped), error: String(e), degraded: true }
125
+ }
126
+ }
127
+
128
+ if (_predictOverride) {
129
+ try {
130
+ const result = await _predictOverride(clipped, questions)
131
+ return { ok: true, answers: normalizeAnswers(result), degraded: false }
132
+ } catch (e) {
133
+ process.stderr.write(
134
+ `[laya] WARNING: System-1 degraded (predictOverride threw) — ${e}. Falling back to heuristics.\n`
135
+ )
136
+ return { ok: false, answers: heuristicAnswers(questions, clipped), error: String(e), degraded: true }
137
+ }
138
+ }
139
+
140
+ const resp = await layaRequest('predict', { state: clipped, questions }, timeoutMs)
141
+ if (!resp.ok) {
142
+ const errMsg = resp.error || 'laya failed'
143
+ // Laya is mandatory — a failure here means System-1 is not working.
144
+ // Emit a visible warning so operators know this is not running correctly.
145
+ process.stderr.write(
146
+ `[laya] WARNING: System-1 degraded — ${errMsg}. ` +
147
+ `Run lib/scripts/ensure-laya-env.sh to fix. Falling back to heuristics.\n`
148
+ )
149
+ return {
150
+ ok: false,
151
+ answers: heuristicAnswers(questions, clipped),
152
+ error: errMsg,
153
+ degraded: true,
154
+ }
155
+ }
156
+ return { ok: true, answers: normalizeAnswers(resp.result), degraded: false }
157
+ }
158
+
159
+ function normalizeAnswers(result) {
160
+ if (!result) return {}
161
+ if (result.answers && typeof result.answers === 'object') return result.answers
162
+ return result
163
+ }
164
+
165
+ function noulProb(answers, key) {
166
+ const a = answers?.[key]
167
+ if (a == null) return 0
168
+ if (typeof a === 'number') return a
169
+ if (typeof a.noul === 'number') return a.noul
170
+ if (typeof a.probability === 'number') return a.probability
171
+ return 0
172
+ }
173
+
174
+ function choiceOf(answers, key, fallback = 'investigating') {
175
+ const a = answers?.[key]
176
+ if (!a) return fallback
177
+ if (typeof a === 'string') return a
178
+ if (typeof a.choice === 'string') return a.choice
179
+ return fallback
180
+ }
181
+
182
+ function confidenceOf(answers, key) {
183
+ const a = answers?.[key]
184
+ if (a && typeof a.confidence === 'number') return a.confidence
185
+ return null
186
+ }
187
+
188
+ /** Keyword heuristic fallback when Laya is unavailable — never crash the CLI. */
189
+ export function heuristicAnswers(questions, stateText = '') {
190
+ const t = (stateText || '').toLowerCase()
191
+ const out = {}
192
+ for (const [key, q] of Object.entries(questions || {})) {
193
+ if (q.type === 'choice' && key === 'task_status') {
194
+ let choice = 'investigating'
195
+ if (/\b(error|fail|blocked|denied)\b/.test(t)) choice = 'blocked'
196
+ else if (/\b(test|pytest|npm test|verify|build)\b/.test(t)) choice = 'verifying'
197
+ else if (/\b(edit|write|patch|implement|search\/replace)\b/.test(t)) choice = 'modifying_code'
198
+ else if (/\b(please confirm|awaiting|should i)\b/.test(t)) choice = 'awaiting_user'
199
+ out[key] = { choice, confidence: 0.4 }
200
+ } else if (q.type === 'noul') {
201
+ let p = 0.3
202
+ if (key === 'escalation_risk' || key === 'blocked') p = /\b(error|fail|blocked)\b/.test(t) ? 0.7 : 0.2
203
+ if (key === 'episodic') p = 0.6
204
+ if (key === 'semantic') p = 0.5
205
+ if (key === 'procedural') p = /\b(run|command|step)\b/.test(t) ? 0.6 : 0.3
206
+ if (key === 'sufficient') p = 0.5
207
+ if (key === 'further_utility') p = 0.4
208
+ out[key] = { noul: p, confidence: 0.35 }
209
+ } else if (q.type === 'choice') {
210
+ const first = Object.keys(q.criteria || {})[0] || 'other'
211
+ out[key] = { choice: first, confidence: 0.3 }
212
+ }
213
+ }
214
+ return out
215
+ }
216
+
217
+ /**
218
+ * Extract canonical session control state from transcript tail + tool file paths.
219
+ * file_target is hybrid: deterministic paths win; Laya only sets categorical fields.
220
+ */
221
+ export async function extractCanonicalState(transcriptText, filePaths = [], opts = {}) {
222
+ const paths = Array.isArray(filePaths) ? filePaths.filter(Boolean) : []
223
+ const file_target = paths.length ? paths[paths.length - 1] : null
224
+ const q = await queryLaya(transcriptText, SESSION_STATE_QUESTIONS, opts)
225
+ const answers = q.answers
226
+ const task_status = choiceOf(answers, 'task_status')
227
+ const escalation_risk = noulProb(answers, 'escalation_risk') >= 0.5
228
+ const conf = confidenceOf(answers, 'task_status') ?? (q.degraded ? 0.35 : 0.7)
229
+ return {
230
+ task_status,
231
+ file_target,
232
+ escalation_risk,
233
+ confidence_score: conf,
234
+ degraded: q.degraded,
235
+ error: q.error || null,
236
+ }
237
+ }
238
+
239
+ export async function typeMemoryObservation(text, opts = {}) {
240
+ const q = await queryLaya(text, TYPE_QUESTIONS, opts)
241
+ return {
242
+ t_episodic: noulProb(q.answers, 'episodic'),
243
+ t_semantic: noulProb(q.answers, 'semantic'),
244
+ t_procedural: noulProb(q.answers, 'procedural'),
245
+ t_preference: noulProb(q.answers, 'preference'),
246
+ degraded: q.degraded,
247
+ }
248
+ }
249
+
250
+ export async function judgeRelations(pairText, opts = {}) {
251
+ const q = await queryLaya(pairText, RELATION_QUESTIONS, opts)
252
+ return {
253
+ semantic: noulProb(q.answers, 'semantic_related'),
254
+ causal: noulProb(q.answers, 'causal_influence'),
255
+ same_episode: noulProb(q.answers, 'same_episode'),
256
+ entity: noulProb(q.answers, 'entity_equivalent'),
257
+ degraded: q.degraded,
258
+ }
259
+ }
260
+
261
+ export async function routeQuery(queryText, opts = {}) {
262
+ const q = await queryLaya(queryText, ROUTE_QUESTIONS, opts)
263
+ return {
264
+ semantic: noulProb(q.answers, 'need_semantic'),
265
+ temporal: noulProb(q.answers, 'need_temporal'),
266
+ causal: noulProb(q.answers, 'need_causal'),
267
+ entity: noulProb(q.answers, 'need_entity'),
268
+ multi_hop: noulProb(q.answers, 'multi_hop'),
269
+ recency: noulProb(q.answers, 'recency_important'),
270
+ degraded: q.degraded,
271
+ }
272
+ }
273
+
274
+ export async function assessEvidence(evidenceText, opts = {}) {
275
+ const q = await queryLaya(evidenceText, EVIDENCE_QUESTIONS, opts)
276
+ return {
277
+ sufficient: noulProb(q.answers, 'sufficient'),
278
+ further_utility: noulProb(q.answers, 'further_utility'),
279
+ missing_required: noulProb(q.answers, 'missing_required'),
280
+ contradiction: noulProb(q.answers, 'contradiction'),
281
+ degraded: q.degraded,
282
+ }
283
+ }
284
+
285
+ export async function scoreCandidate(candidateText, opts = {}) {
286
+ const q = await queryLaya(candidateText, CANDIDATE_QUESTIONS, opts)
287
+ return {
288
+ relevance: noulProb(q.answers, 'relevance'),
289
+ relation_useful: noulProb(q.answers, 'relation_useful'),
290
+ novelty: noulProb(q.answers, 'novelty'),
291
+ supports_evidence: noulProb(q.answers, 'supports_evidence'),
292
+ degraded: q.degraded,
293
+ }
294
+ }
295
+
296
+ export { noulProb, choiceOf }
@@ -0,0 +1,198 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+ const WORKER_PATH = path.resolve(__dirname, 'scripts', 'laya_worker.py')
8
+ const ENSURE_SCRIPT = path.resolve(__dirname, 'scripts', 'ensure-laya-env.sh')
9
+ const DEFAULT_TIMEOUT_MS = 100
10
+ const NEHANDA_HOME = process.env.NEHANDA_HOME || path.join(process.env.HOME || '/tmp', '.nehanda')
11
+ const VENV_PYTHON = path.join(NEHANDA_HOME, 'venv', 'bin', 'python')
12
+ const LAYA_READY_MARKER = path.join(NEHANDA_HOME, '.laya-ready')
13
+
14
+ let child = null
15
+ let reqId = 1
16
+ let pending = new Map()
17
+ let buffer = ''
18
+ let restartCount = 0
19
+ const MAX_RESTARTS = 3
20
+
21
+ // Track provision state so ensureLayaReady() is idempotent across the process lifetime.
22
+ let _provisionDone = false
23
+ let _provisionError = null
24
+
25
+ /**
26
+ * Resolve the Python binary that has the laya package installed.
27
+ * Returns the venv python path if available, otherwise throws — no silent
28
+ * fallback to system python3 which will never have laya installed.
29
+ */
30
+ function resolvePython() {
31
+ if (process.env.LAYA_PYTHON) return process.env.LAYA_PYTHON
32
+ if (fs.existsSync(VENV_PYTHON)) return VENV_PYTHON
33
+ throw new Error(
34
+ `Laya venv not found at ${VENV_PYTHON}. ` +
35
+ `Run lib/scripts/ensure-laya-env.sh or call ensureLayaReady() at startup.`
36
+ )
37
+ }
38
+
39
+ function attachChild(c) {
40
+ buffer = ''
41
+ c.stdout.on('data', chunk => {
42
+ buffer += chunk.toString('utf8')
43
+ const lines = buffer.split('\n')
44
+ buffer = lines.pop() ?? ''
45
+ for (const line of lines) {
46
+ if (!line.trim()) continue
47
+ let msg
48
+ try { msg = JSON.parse(line) } catch { continue }
49
+ if (msg.id != null && pending.has(msg.id)) {
50
+ const { resolve, timer } = pending.get(msg.id)
51
+ clearTimeout(timer)
52
+ pending.delete(msg.id)
53
+ resolve(msg)
54
+ }
55
+ }
56
+ })
57
+ c.on('error', err => {
58
+ process.stderr.write(`[laya-sidecar] worker error: ${err.message}\n`)
59
+ child = null
60
+ })
61
+ c.on('close', code => {
62
+ if (code != null && code !== 0) {
63
+ process.stderr.write(`[laya-sidecar] worker exited with code ${code}\n`)
64
+ }
65
+ child = null
66
+ for (const [, p] of pending) {
67
+ clearTimeout(p.timer)
68
+ p.resolve({ ok: false, error: 'laya worker closed' })
69
+ }
70
+ pending.clear()
71
+ })
72
+ }
73
+
74
+ function ensureChild() {
75
+ if (child && !child.killed) return true
76
+ if (restartCount >= MAX_RESTARTS) return false
77
+ if (!fs.existsSync(WORKER_PATH)) return false
78
+ let py
79
+ try {
80
+ py = resolvePython()
81
+ } catch (e) {
82
+ process.stderr.write(`[laya-sidecar] ${e.message}\n`)
83
+ return false
84
+ }
85
+ try {
86
+ child = spawn(py, [WORKER_PATH], {
87
+ stdio: ['pipe', 'pipe', 'pipe'],
88
+ env: { ...process.env },
89
+ })
90
+ restartCount += 1
91
+ attachChild(child)
92
+ return true
93
+ } catch (e) {
94
+ process.stderr.write(`[laya-sidecar] failed to spawn worker: ${e.message}\n`)
95
+ child = null
96
+ return false
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Verify Laya is provisioned and warm the sidecar with a real ping so the
102
+ * checkpoint is in memory before the first user turn.
103
+ *
104
+ * Provisioning happens at npm install time via scripts/postinstall.mjs.
105
+ * If the marker is missing, the user is directed to re-run provisioning
106
+ * rather than attempting a silent download mid-session.
107
+ *
108
+ * Throws on failure — Laya is mandatory, not optional.
109
+ */
110
+ export async function ensureLayaReady() {
111
+ if (_provisionDone) return
112
+ if (_provisionError) throw _provisionError
113
+
114
+ const markerExists = fs.existsSync(LAYA_READY_MARKER) && fs.existsSync(VENV_PYTHON)
115
+
116
+ if (!markerExists) {
117
+ const msg =
118
+ `[laya-sidecar] Laya is not provisioned.\n` +
119
+ ` The postinstall step did not complete successfully.\n` +
120
+ ` Re-run provisioning manually:\n` +
121
+ ` bash ${ENSURE_SCRIPT}\n` +
122
+ ` Or reinstall the package:\n` +
123
+ ` npm install @asobacloud/nehanda`
124
+ process.stderr.write(msg + '\n')
125
+ _provisionError = new Error(msg)
126
+ throw _provisionError
127
+ }
128
+
129
+ // Warm the sidecar: spawn worker + send a real predict so the checkpoint
130
+ // loads into memory before the first user turn.
131
+ process.stderr.write('[laya-sidecar] warming Laya sidecar...\n')
132
+ const warm = await pingLaya(60_000)
133
+ if (!warm.ok) {
134
+ const msg = `[laya-sidecar] Laya warm-up failed: ${warm.error}`
135
+ process.stderr.write(msg + '\n')
136
+ _provisionError = new Error(msg)
137
+ throw _provisionError
138
+ }
139
+
140
+ process.stderr.write('[laya-sidecar] Laya ready.\n')
141
+ _provisionDone = true
142
+ }
143
+
144
+ /**
145
+ * Send a request to the Laya stdio worker.
146
+ * @returns {Promise<{ok:boolean, result?:any, error?:string}>}
147
+ */
148
+ export function layaRequest(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
149
+ return new Promise(resolve => {
150
+ if (!ensureChild()) {
151
+ resolve({ ok: false, error: 'laya worker unavailable' })
152
+ return
153
+ }
154
+ const id = reqId++
155
+ const payload = JSON.stringify({ id, method, params }) + '\n'
156
+ const timer = setTimeout(() => {
157
+ pending.delete(id)
158
+ resolve({ ok: false, error: `laya timeout after ${timeoutMs}ms` })
159
+ }, timeoutMs)
160
+ pending.set(id, { resolve, timer })
161
+ try {
162
+ child.stdin.write(payload, 'utf8')
163
+ } catch (e) {
164
+ clearTimeout(timer)
165
+ pending.delete(id)
166
+ resolve({ ok: false, error: String(e) })
167
+ }
168
+ })
169
+ }
170
+
171
+ /** Warm ping (longer timeout for cold start). */
172
+ export async function pingLaya(timeoutMs = 30_000) {
173
+ restartCount = 0
174
+ return layaRequest('ping', {}, timeoutMs)
175
+ }
176
+
177
+ export async function shutdownLaya() {
178
+ if (!child) return
179
+ await layaRequest('shutdown', {}, 2000)
180
+ try { child.kill() } catch { /* */ }
181
+ child = null
182
+ restartCount = 0
183
+ }
184
+
185
+ /** Test helper: reset restart budget. */
186
+ export function resetLayaSidecar() {
187
+ if (child) {
188
+ try { child.kill() } catch { /* */ }
189
+ }
190
+ child = null
191
+ restartCount = 0
192
+ pending.clear()
193
+ buffer = ''
194
+ _provisionDone = false
195
+ _provisionError = null
196
+ }
197
+
198
+ export const LAYA_DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS
@@ -0,0 +1,189 @@
1
+ /**
2
+ * mcp-config.mjs
3
+ *
4
+ * Reads mcp.json and merges configured servers into settings.mcp_servers.
5
+ *
6
+ * Search order (first found wins, then merged in priority order):
7
+ * 1. <project-root>/mcp.json — project-local, highest priority
8
+ * 2. ~/.config/nehanda/mcp.json — user global
9
+ *
10
+ * Expected mcp.json schema (standard Anthropic/Claude Desktop format):
11
+ * {
12
+ * "mcpServers": {
13
+ * "server-name": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@some/mcp-server"],
16
+ * "env": { "API_KEY": "..." }
17
+ * }
18
+ * }
19
+ * }
20
+ *
21
+ * The resulting entries are mapped into settings.mcp_servers using the
22
+ * same shape that lib/tools.mjs and lib/mcp.mjs already consume:
23
+ * { [name]: { command, args, env } }
24
+ */
25
+
26
+ import fs from 'node:fs'
27
+ import path from 'node:path'
28
+ import os from 'node:os'
29
+
30
+ const GLOBAL_MCP_PATH = path.join(os.homedir(), '.config', 'nehanda', 'mcp.json')
31
+
32
+ /**
33
+ * Resolve candidate mcp.json paths for a given project root.
34
+ * Returns them in priority order (highest first).
35
+ */
36
+ function candidatePaths(projectRoot) {
37
+ const candidates = []
38
+ if (projectRoot) candidates.push(path.join(projectRoot, 'mcp.json'))
39
+ candidates.push(GLOBAL_MCP_PATH)
40
+ return candidates
41
+ }
42
+
43
+ /**
44
+ * Parse a single mcp.json file. Returns { servers: {name: config}, path } or null.
45
+ * Tolerates missing files and bad JSON gracefully.
46
+ */
47
+ function parseMcpFile(filePath) {
48
+ try {
49
+ const raw = fs.readFileSync(filePath, 'utf8')
50
+ const parsed = JSON.parse(raw)
51
+ const servers = parsed?.mcpServers
52
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null
53
+ // Normalise each server entry
54
+ const normalised = {}
55
+ for (const [name, cfg] of Object.entries(servers)) {
56
+ if (!cfg?.command || typeof cfg.command !== 'string') continue // skip invalid
57
+ normalised[name] = {
58
+ command: cfg.command,
59
+ args: Array.isArray(cfg.args) ? cfg.args : [],
60
+ env: (cfg.env && typeof cfg.env === 'object' && !Array.isArray(cfg.env)) ? cfg.env : {},
61
+ }
62
+ }
63
+ return { servers: normalised, path: filePath }
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Load all mcp.json files for the given project root and return a merged
71
+ * server map. Project-local entries override global ones for the same name.
72
+ *
73
+ * @param {string} projectRoot - Absolute path to the project working directory
74
+ * @returns {{ servers: Record<string, {command:string, args:string[], env:object}>, sources: string[] }}
75
+ */
76
+ export function loadMcpConfig(projectRoot) {
77
+ const candidates = candidatePaths(projectRoot)
78
+ const layers = []
79
+
80
+ for (const p of candidates) {
81
+ const result = parseMcpFile(p)
82
+ if (result) layers.push(result)
83
+ }
84
+
85
+ // Merge layers: first found (project-local) wins
86
+ const merged = {}
87
+ // Iterate in reverse so the highest-priority (project-local, index 0) overwrites
88
+ for (let i = layers.length - 1; i >= 0; i--) {
89
+ Object.assign(merged, layers[i].servers)
90
+ }
91
+
92
+ return {
93
+ servers: merged,
94
+ sources: layers.map(l => l.path),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Merge mcp.json servers into an existing settings object in-place.
100
+ * Settings object must have (or will receive) a `mcp_servers` key.
101
+ * Existing programmatic entries (e.g. set via /config) are preserved;
102
+ * mcp.json entries for the same name are overwritten by the file.
103
+ *
104
+ * @param {object} settings - Effective settings object (mutated in place)
105
+ * @param {string} projectRoot - Absolute path to the project working directory
106
+ * @returns {string[]} List of mcp.json files that were loaded
107
+ */
108
+ export function applyMcpConfig(settings, projectRoot) {
109
+ const { servers, sources } = loadMcpConfig(projectRoot)
110
+ if (!settings.mcp_servers || typeof settings.mcp_servers !== 'object') {
111
+ settings.mcp_servers = {}
112
+ }
113
+ Object.assign(settings.mcp_servers, servers)
114
+ return sources
115
+ }
116
+
117
+ /**
118
+ * Return the path where a new global mcp.json should be written.
119
+ */
120
+ export function globalMcpConfigPath() {
121
+ return GLOBAL_MCP_PATH
122
+ }
123
+
124
+ /**
125
+ * Write a new or updated global mcp.json. Creates parent dirs as needed.
126
+ *
127
+ * @param {Record<string, {command:string, args:string[], env:object}>} servers
128
+ */
129
+ export function writeGlobalMcpConfig(servers) {
130
+ const dir = path.dirname(GLOBAL_MCP_PATH)
131
+ fs.mkdirSync(dir, { recursive: true })
132
+ const content = JSON.stringify({ mcpServers: servers }, null, 2)
133
+ fs.writeFileSync(GLOBAL_MCP_PATH, content, 'utf8')
134
+ }
135
+
136
+ /**
137
+ * Find which mcp.json file a server is defined in (project-local first).
138
+ * Returns { server, path } or null.
139
+ */
140
+ export function getMcpServerSource(serverName, projectRoot) {
141
+ const candidates = candidatePaths(projectRoot)
142
+ for (const filePath of candidates) {
143
+ const result = parseMcpFile(filePath)
144
+ if (result && result.servers[serverName]) {
145
+ return { server: result.servers[serverName], path: filePath }
146
+ }
147
+ }
148
+ return null
149
+ }
150
+
151
+ /**
152
+ * Set (or clear) an environment variable for an MCP server.
153
+ * Updates the mcp.json file where the server is defined and reloads.
154
+ *
155
+ * @param {string} serverName - Server name
156
+ * @param {string} key - Env var name (e.g. ASOBA_API_KEY)
157
+ * @param {string} value - Value to set (empty string clears the key)
158
+ * @param {string} projectRoot - Project working directory
159
+ * @returns {{ ok: boolean, path?: string, key?: string, set?: boolean, error?: string }}
160
+ */
161
+ export function updateMcpServerEnv(serverName, key, value, projectRoot) {
162
+ const candidates = candidatePaths(projectRoot)
163
+
164
+ for (const filePath of candidates) {
165
+ const result = parseMcpFile(filePath)
166
+ if (result && result.servers[serverName]) {
167
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
168
+ const srv = raw.mcpServers[serverName]
169
+ if (!srv.env) srv.env = {}
170
+ if (value === '') {
171
+ delete srv.env[key]
172
+ } else {
173
+ srv.env[key] = value
174
+ }
175
+ fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + '\n', 'utf8')
176
+ return { ok: true, path: filePath, key, set: value !== '' }
177
+ }
178
+ }
179
+
180
+ return { ok: false, error: `Server "${serverName}" not found in any mcp.json` }
181
+ }
182
+
183
+ /**
184
+ * Read the current global mcp.json server map (empty object if not present).
185
+ */
186
+ export function readGlobalMcpConfig() {
187
+ const result = parseMcpFile(GLOBAL_MCP_PATH)
188
+ return result?.servers ?? {}
189
+ }