@asobacloud/nehanda 0.0.0-stage → 0.3.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 +444 -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
package/lib/tools.mjs
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { setPhase, getPhase } from './workflow.mjs'
|
|
6
|
+
import { withTransaction } from './store.mjs'
|
|
7
|
+
import { getMcpServer, listMcpServers, mcpListResources, mcpReadResource } from './mcp.mjs'
|
|
8
|
+
import { applyEnergyNormalization } from './energy-middleware.mjs'
|
|
9
|
+
import { runHooks } from './hookplane.mjs'
|
|
10
|
+
import { createWorktree, removeWorktree, getCurrentWorktree } from './worktree.mjs'
|
|
11
|
+
import { addTeammate, setTeammateIdle } from './team.mjs'
|
|
12
|
+
import { validateBashCommand } from './bashguard.mjs'
|
|
13
|
+
|
|
14
|
+
const BASH_CAP = 1048576
|
|
15
|
+
const TRUNC = '[SDLC_TRUNCATED]'
|
|
16
|
+
const READ_CAP = 1048576
|
|
17
|
+
|
|
18
|
+
// Dynamically load declarative tools from the 'tools' directory
|
|
19
|
+
const TOOLS_CONFIG_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'tools')
|
|
20
|
+
export const dynamicTools = new Map()
|
|
21
|
+
|
|
22
|
+
export const EXPLORE_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
|
23
|
+
const PLANNING_BLOCKED_TOOLS = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit'])
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(TOOLS_CONFIG_DIR)) {
|
|
27
|
+
const files = fs.readdirSync(TOOLS_CONFIG_DIR)
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
if (file.endsWith('.json')) {
|
|
30
|
+
const filePath = path.join(TOOLS_CONFIG_DIR, file)
|
|
31
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
32
|
+
dynamicTools.set(config.name, config)
|
|
33
|
+
|
|
34
|
+
if (config.phases?.explore_only) {
|
|
35
|
+
EXPLORE_ONLY_TOOLS.add(config.name)
|
|
36
|
+
}
|
|
37
|
+
if (config.phases?.planning_blocked) {
|
|
38
|
+
PLANNING_BLOCKED_TOOLS.add(config.name)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Failed to load declarative tools:', err)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Tool dispatch ────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export async function executeBuiltinTool(db, ctx, toolName, toolInput, io) {
|
|
50
|
+
const { conversationId, cwd } = ctx
|
|
51
|
+
|
|
52
|
+
// The harness owns phase-based tool filtering. If a model calls a blocked tool
|
|
53
|
+
// anyway (e.g. hallucination), enforce it here as a hard stop.
|
|
54
|
+
const phase = getPhase(db, conversationId)
|
|
55
|
+
if (phase === 'planning' && PLANNING_BLOCKED_TOOLS.has(toolName)) {
|
|
56
|
+
return { content: `Tool ${toolName} is not available in planning phase. Use Read, Glob, or Grep to explore the codebase, then write your plan as text.`, is_error: true }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// §1 Bug fix: surface JSON parse failures from streaming tool call arguments
|
|
60
|
+
if (toolInput?._parseError) {
|
|
61
|
+
const raw = String(toolInput._raw || '').slice(0, 200)
|
|
62
|
+
return { content: `Tool call arguments could not be parsed (truncated JSON): ${raw}`, is_error: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
switch (toolName) {
|
|
67
|
+
case 'Read': return await toolRead(cwd, toolInput)
|
|
68
|
+
case 'Write': return toolWrite(cwd, toolInput, db, ctx)
|
|
69
|
+
case 'Edit': return toolEdit(cwd, toolInput, db, ctx)
|
|
70
|
+
case 'Glob': return toolGlob(cwd, toolInput)
|
|
71
|
+
case 'Grep': return toolGrep(cwd, toolInput)
|
|
72
|
+
case 'Bash': return await toolBash(cwd, toolInput, db, ctx)
|
|
73
|
+
case 'NotebookEdit': return toolNotebookEdit(cwd, toolInput)
|
|
74
|
+
case 'WebFetch': return await toolWebFetch(toolInput)
|
|
75
|
+
case 'WebSearch': return await toolWebSearch(toolInput)
|
|
76
|
+
case 'AskUserQuestion': return await toolAskUser(io, toolInput)
|
|
77
|
+
case 'Brief': return toolBrief(io, toolInput)
|
|
78
|
+
case 'TodoWrite': return toolTodoWrite(db, ctx, toolInput)
|
|
79
|
+
case 'TaskOutput': return toolTaskOutput(db, ctx, toolInput)
|
|
80
|
+
case 'TaskStop': return toolTaskStop(toolInput)
|
|
81
|
+
// Phase transitions are owned by the orchestrator harness, not the model.
|
|
82
|
+
// If the model somehow calls these, return a clear error.
|
|
83
|
+
case 'EnterPlanMode':
|
|
84
|
+
case 'ExitPlanMode':
|
|
85
|
+
case 'SubmitImplementation':
|
|
86
|
+
case 'SubmitTest':
|
|
87
|
+
return { content: `[SDLC] ${toolName} is not a model tool. Phase transitions are managed by the harness.`, is_error: true }
|
|
88
|
+
case 'Agent': return await toolAgent(db, ctx, toolInput, io)
|
|
89
|
+
case 'Skill': return toolSkill(cwd, toolInput)
|
|
90
|
+
case 'ToolSearch': return toolSearchFn(toolInput)
|
|
91
|
+
case 'EnterWorktree': return await toolEnterWorktree(db, ctx, toolInput)
|
|
92
|
+
case 'ExitWorktree': return await toolExitWorktree(db, ctx, toolInput)
|
|
93
|
+
case 'ListMcpResources': return await toolListMcpResources(ctx, toolInput)
|
|
94
|
+
case 'ReadMcpResource': return await toolReadMcpResource(ctx, toolInput)
|
|
95
|
+
default:
|
|
96
|
+
if (dynamicTools.has(toolName)) {
|
|
97
|
+
return await executeDynamicScriptTool(cwd, toolName, toolInput)
|
|
98
|
+
}
|
|
99
|
+
if (toolName.startsWith('mcp__')) return await toolMcpInvoke(ctx, toolName, toolInput)
|
|
100
|
+
return { content: `Unknown tool: ${toolName}`, is_error: true }
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { content: `[SDLC_INTERNAL] ${String(e?.message || e)}`, is_error: true }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Read ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function toolRead(cwd, input) {
|
|
110
|
+
const rel = input?.file_path
|
|
111
|
+
if (!rel || typeof rel !== 'string') return { content: 'Read: missing file_path', is_error: true }
|
|
112
|
+
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
|
|
113
|
+
if (!fs.existsSync(abs)) return { content: `Read: not found: ${rel}`, is_error: true }
|
|
114
|
+
const stat = fs.statSync(abs)
|
|
115
|
+
if (stat.isDirectory()) return { content: `Read: ${rel} is a directory`, is_error: true }
|
|
116
|
+
const buf = fs.readFileSync(abs)
|
|
117
|
+
if (buf.length > READ_CAP) {
|
|
118
|
+
return { content: `Read ${rel}: ${buf.length} bytes (truncated to ${READ_CAP})\n---\n${buf.subarray(0, READ_CAP).toString('utf8')}\n${TRUNC}`, is_error: false }
|
|
119
|
+
}
|
|
120
|
+
const text = buf.toString('utf8')
|
|
121
|
+
const lines = text.split('\n')
|
|
122
|
+
const offset = input?.offset || 0
|
|
123
|
+
const limit = input?.limit || lines.length
|
|
124
|
+
const slice = lines.slice(offset, offset + limit)
|
|
125
|
+
const numbered = slice.map((l, i) => `${offset + i + 1}\t${l}`).join('\n')
|
|
126
|
+
return { content: `Read ${rel}: ${lines.length} lines.\n---\n${numbered}`, is_error: false }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Write ────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function toolWrite(cwd, input, db, ctx) {
|
|
132
|
+
const rel = input?.file_path
|
|
133
|
+
const content = input?.content
|
|
134
|
+
if (!rel || typeof rel !== 'string') return { content: 'Write: missing file_path', is_error: true }
|
|
135
|
+
if (typeof content !== 'string') return { content: 'Write: missing content', is_error: true }
|
|
136
|
+
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
|
|
137
|
+
if (abs !== path.resolve(cwd) && !abs.startsWith(path.resolve(cwd) + path.sep)) {
|
|
138
|
+
return { content: 'Write: path outside working directory', is_error: true }
|
|
139
|
+
}
|
|
140
|
+
const existed = fs.existsSync(abs)
|
|
141
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
142
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
143
|
+
const lines = content.split('\n').length
|
|
144
|
+
// §3 FileChanged hook
|
|
145
|
+
if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1' && db && ctx) {
|
|
146
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
|
|
147
|
+
runHooks(db, hookRt, 'FileChanged', { file_path: abs, event: existed ? 'change' : 'add' }).catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
return { content: `${existed ? 'Updated' : 'Created'} ${rel} (${lines} lines)`, is_error: false }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Edit ─────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function toolEdit(cwd, input, db, ctx) {
|
|
155
|
+
const rel = input?.file_path
|
|
156
|
+
const oldStr = input?.old_string
|
|
157
|
+
const newStr = input?.new_string
|
|
158
|
+
if (!rel) return { content: 'Edit: missing file_path', is_error: true }
|
|
159
|
+
if (typeof oldStr !== 'string') return { content: 'Edit: missing old_string', is_error: true }
|
|
160
|
+
if (typeof newStr !== 'string') return { content: 'Edit: missing new_string', is_error: true }
|
|
161
|
+
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
|
|
162
|
+
if (abs !== path.resolve(cwd) && !abs.startsWith(path.resolve(cwd) + path.sep)) {
|
|
163
|
+
return { content: 'Edit: path outside working directory', is_error: true }
|
|
164
|
+
}
|
|
165
|
+
if (!fs.existsSync(abs)) return { content: `Edit: not found: ${rel}`, is_error: true }
|
|
166
|
+
let text = fs.readFileSync(abs, 'utf8')
|
|
167
|
+
if (input?.replace_all) {
|
|
168
|
+
if (!text.includes(oldStr)) return { content: `Edit: old_string not found in ${rel}`, is_error: true }
|
|
169
|
+
text = text.replaceAll(oldStr, newStr)
|
|
170
|
+
} else {
|
|
171
|
+
const idx = text.indexOf(oldStr)
|
|
172
|
+
if (idx === -1) return { content: `Edit: old_string not found in ${rel}`, is_error: true }
|
|
173
|
+
const count = text.split(oldStr).length - 1
|
|
174
|
+
if (count > 1) return { content: `Edit: old_string matches ${count} locations in ${rel} — provide more context to make it unique, or use replace_all`, is_error: true }
|
|
175
|
+
text = text.slice(0, idx) + newStr + text.slice(idx + oldStr.length)
|
|
176
|
+
}
|
|
177
|
+
fs.writeFileSync(abs, text, 'utf8')
|
|
178
|
+
if (process.env.SDLC_DISABLE_ALL_HOOKS !== '1' && db && ctx) {
|
|
179
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
|
|
180
|
+
runHooks(db, hookRt, 'FileChanged', { file_path: abs, event: 'change' }).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
return { content: `Edited ${rel}`, is_error: false }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Glob ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
function toolGlob(cwd, input) {
|
|
188
|
+
const pattern = input?.pattern
|
|
189
|
+
if (!pattern) return { content: 'Glob: missing pattern', is_error: true }
|
|
190
|
+
const searchDir = input?.path ? path.resolve(cwd, input.path) : cwd
|
|
191
|
+
if (!fs.existsSync(searchDir)) return { content: `Glob: directory not found: ${searchDir}`, is_error: true }
|
|
192
|
+
const results = []
|
|
193
|
+
const maxResults = 100
|
|
194
|
+
globWalk(searchDir, pattern, results, maxResults, searchDir)
|
|
195
|
+
return { content: results.length ? results.join('\n') : '(no matches)', is_error: false }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function globWalk(dir, pattern, results, max, root) {
|
|
199
|
+
if (results.length >= max) return
|
|
200
|
+
let entries
|
|
201
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
if (results.length >= max) return
|
|
204
|
+
if (e.name.startsWith('.') && !pattern.startsWith('.')) continue
|
|
205
|
+
const full = path.join(dir, e.name)
|
|
206
|
+
const rel = path.relative(root, full)
|
|
207
|
+
if (e.isDirectory()) {
|
|
208
|
+
if (e.name === 'node_modules' || e.name === '.git') continue
|
|
209
|
+
globWalk(full, pattern, results, max, root)
|
|
210
|
+
} else {
|
|
211
|
+
if (globMatch(pattern, rel) || globMatch(pattern, e.name)) results.push(rel)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function globMatch(pattern, str) {
|
|
217
|
+
const regex = pattern
|
|
218
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
219
|
+
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
|
|
220
|
+
.replace(/\*/g, '[^/]*')
|
|
221
|
+
.replace(/<<<GLOBSTAR>>>/g, '.*')
|
|
222
|
+
.replace(/\?/g, '[^/]')
|
|
223
|
+
return new RegExp(`^${regex}$`).test(str)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Grep ─────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function toolGrep(cwd, input) {
|
|
229
|
+
const pattern = input?.pattern
|
|
230
|
+
if (!pattern) return { content: 'Grep: missing pattern', is_error: true }
|
|
231
|
+
const searchPath = input?.path ? path.resolve(cwd, input.path) : cwd
|
|
232
|
+
const mode = input?.output_mode || 'files_with_matches'
|
|
233
|
+
const caseI = input?.['-i'] || false
|
|
234
|
+
const headLimit = input?.head_limit ?? 250
|
|
235
|
+
const contextLines = input?.['-C'] || input?.context || 0
|
|
236
|
+
let re
|
|
237
|
+
try { re = new RegExp(pattern, caseI ? 'i' : '') } catch (e) { return { content: `Grep: invalid regex: ${e.message}`, is_error: true } }
|
|
238
|
+
|
|
239
|
+
const stat = fs.existsSync(searchPath) ? fs.statSync(searchPath) : null
|
|
240
|
+
if (!stat) return { content: `Grep: path not found: ${searchPath}`, is_error: true }
|
|
241
|
+
|
|
242
|
+
const matchingFiles = []
|
|
243
|
+
const contentLines = []
|
|
244
|
+
let matchCount = 0
|
|
245
|
+
|
|
246
|
+
function searchFile(filePath) {
|
|
247
|
+
if (headLimit > 0 && matchingFiles.length >= headLimit && mode === 'files_with_matches') return
|
|
248
|
+
let text
|
|
249
|
+
try { text = fs.readFileSync(filePath, 'utf8') } catch { return }
|
|
250
|
+
const lines = text.split('\n')
|
|
251
|
+
let fileMatched = false
|
|
252
|
+
for (let i = 0; i < lines.length; i++) {
|
|
253
|
+
if (re.test(lines[i])) {
|
|
254
|
+
matchCount++
|
|
255
|
+
if (!fileMatched) { matchingFiles.push(path.relative(cwd, filePath)); fileMatched = true }
|
|
256
|
+
if (mode === 'content') {
|
|
257
|
+
if (headLimit > 0 && contentLines.length >= headLimit) return
|
|
258
|
+
const start = Math.max(0, i - contextLines)
|
|
259
|
+
const end = Math.min(lines.length - 1, i + contextLines)
|
|
260
|
+
for (let j = start; j <= end; j++) {
|
|
261
|
+
const prefix = j === i ? `${j + 1}:` : `${j + 1}-`
|
|
262
|
+
contentLines.push(`${path.relative(cwd, filePath)}:${prefix}${lines[j]}`)
|
|
263
|
+
}
|
|
264
|
+
if (contextLines > 0) contentLines.push('--')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function walk(dir) {
|
|
271
|
+
let entries
|
|
272
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
273
|
+
for (const e of entries) {
|
|
274
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue
|
|
275
|
+
const full = path.join(dir, e.name)
|
|
276
|
+
if (e.isDirectory()) walk(full)
|
|
277
|
+
else {
|
|
278
|
+
if (input?.glob && !globMatch(input.glob, e.name)) continue
|
|
279
|
+
searchFile(full)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (stat.isFile()) searchFile(searchPath)
|
|
285
|
+
else walk(searchPath)
|
|
286
|
+
|
|
287
|
+
if (mode === 'files_with_matches') return { content: matchingFiles.join('\n') || '(no matches)', is_error: false }
|
|
288
|
+
if (mode === 'count') return { content: `${matchCount} matches in ${matchingFiles.length} files`, is_error: false }
|
|
289
|
+
return { content: contentLines.join('\n') || '(no matches)', is_error: false }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Bash ─────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
const CWD_MARKER = '___ONA_CWD___'
|
|
295
|
+
|
|
296
|
+
async function toolBash(cwd, input, db, ctx) {
|
|
297
|
+
const cmd = input?.command
|
|
298
|
+
if (!cmd || typeof cmd !== 'string') return { content: 'Bash: missing command', is_error: true }
|
|
299
|
+
// Validate command safety before execution
|
|
300
|
+
const guard = validateBashCommand(cmd, cwd)
|
|
301
|
+
if (!guard.ok) return { content: `[SDLC] Bash command blocked: ${guard.reason}`, is_error: true }
|
|
302
|
+
const timeoutMs = Math.min(input?.timeout || 120_000, 600_000)
|
|
303
|
+
// Append cwd detection marker
|
|
304
|
+
const wrappedCmd = `${cmd} ; echo "${CWD_MARKER}$(pwd)"`
|
|
305
|
+
return new Promise(resolve => {
|
|
306
|
+
const child = spawn(resolveBash(), ['-c', wrappedCmd], { cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
307
|
+
let out = Buffer.alloc(0), err = Buffer.alloc(0)
|
|
308
|
+
const push = (b, chunk) => {
|
|
309
|
+
const n = Buffer.concat([b, chunk])
|
|
310
|
+
return n.length <= BASH_CAP ? n : Buffer.concat([n.subarray(0, BASH_CAP), Buffer.from(TRUNC)])
|
|
311
|
+
}
|
|
312
|
+
child.stdout.on('data', d => { out = push(out, d) })
|
|
313
|
+
child.stderr.on('data', d => { err = push(err, d) })
|
|
314
|
+
const t = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
315
|
+
child.on('close', code => {
|
|
316
|
+
clearTimeout(t)
|
|
317
|
+
let content = out.toString('utf8')
|
|
318
|
+
// Extract and strip cwd marker
|
|
319
|
+
const markerIdx = content.lastIndexOf(CWD_MARKER)
|
|
320
|
+
let newCwd = null
|
|
321
|
+
if (markerIdx !== -1) {
|
|
322
|
+
newCwd = content.slice(markerIdx + CWD_MARKER.length).trim()
|
|
323
|
+
content = content.slice(0, markerIdx).trimEnd()
|
|
324
|
+
}
|
|
325
|
+
const se = err.toString('utf8')
|
|
326
|
+
if (se) content += `\n--- stderr ---\n${se}`
|
|
327
|
+
// Fire CwdChanged hook if directory changed
|
|
328
|
+
if (newCwd && newCwd !== cwd && db && ctx && process.env.SDLC_DISABLE_ALL_HOOKS !== '1') {
|
|
329
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd, settings: ctx.settings }
|
|
330
|
+
process.chdir(newCwd)
|
|
331
|
+
runHooks(db, hookRt, 'CwdChanged', { old_cwd: cwd, new_cwd: newCwd }).catch(() => {})
|
|
332
|
+
}
|
|
333
|
+
resolve({ content, is_error: code !== 0, newCwd: (newCwd && newCwd !== cwd) ? newCwd : null })
|
|
334
|
+
})
|
|
335
|
+
child.on('error', e => { clearTimeout(t); resolve({ content: String(e?.message || e), is_error: true }) })
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function resolveBash() {
|
|
340
|
+
if (fs.existsSync('/bin/bash')) return '/bin/bash'
|
|
341
|
+
if (fs.existsSync('/usr/bin/bash')) return '/usr/bin/bash'
|
|
342
|
+
return 'bash'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Dynamic Script Tool Executor ───────────────────────────────
|
|
346
|
+
|
|
347
|
+
async function executeDynamicScriptTool(cwd, toolName, input) {
|
|
348
|
+
const config = dynamicTools.get(toolName)
|
|
349
|
+
if (!config) return { content: `Tool ${toolName} config not found`, is_error: true }
|
|
350
|
+
|
|
351
|
+
let scriptPath = config.execution.script
|
|
352
|
+
if (!path.isAbsolute(scriptPath)) {
|
|
353
|
+
scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', scriptPath)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Override dynamically using environment variable (e.g. AUDIT_CODE_INTEGRITY_SCRIPT for AuditCodeIntegrity)
|
|
357
|
+
const envKey = `${toolName.replace(/([A-Z])/g, '_$1').toUpperCase()}_SCRIPT`.replace(/^_/, '')
|
|
358
|
+
const resolvedScriptPath = process.env[envKey] || scriptPath
|
|
359
|
+
|
|
360
|
+
if (!fs.existsSync(resolvedScriptPath)) {
|
|
361
|
+
return { content: `${toolName}: script not found at ${resolvedScriptPath}.`, is_error: true }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Parse arguments template
|
|
365
|
+
const spawnArgs = []
|
|
366
|
+
const templateArgs = config.execution.args || []
|
|
367
|
+
for (let i = 0; i < templateArgs.length; i++) {
|
|
368
|
+
const arg = templateArgs[i]
|
|
369
|
+
if (arg.startsWith('{{') && arg.endsWith('}}')) {
|
|
370
|
+
const key = arg.slice(2, -2)
|
|
371
|
+
const val = input[key]
|
|
372
|
+
if (val !== undefined) {
|
|
373
|
+
spawnArgs.push(String(val))
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
// Check if next arg is a placeholder, and if its value is undefined, skip both the flag and the placeholder
|
|
377
|
+
const nextArg = templateArgs[i + 1]
|
|
378
|
+
if (nextArg && nextArg.startsWith('{{') && nextArg.endsWith('}}')) {
|
|
379
|
+
const key = nextArg.slice(2, -2)
|
|
380
|
+
if (input[key] === undefined) {
|
|
381
|
+
i++ // skip next arg
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
spawnArgs.push(arg)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const cmd = config.execution.runtime || 'python3'
|
|
390
|
+
const args = [resolvedScriptPath, ...spawnArgs]
|
|
391
|
+
const timeoutMs = Math.min(
|
|
392
|
+
input?.timeout || config.execution.default_timeout || 120_000,
|
|
393
|
+
config.execution.max_timeout || 600_000
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return new Promise(resolve => {
|
|
397
|
+
const child = spawn(cmd, args, { cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
398
|
+
let out = Buffer.alloc(0), err = Buffer.alloc(0)
|
|
399
|
+
const push = (b, chunk) => {
|
|
400
|
+
const n = Buffer.concat([b, chunk])
|
|
401
|
+
return n.length <= BASH_CAP ? n : Buffer.concat([n.subarray(0, BASH_CAP), Buffer.from(TRUNC)])
|
|
402
|
+
}
|
|
403
|
+
child.stdout.on('data', d => { out = push(out, d) })
|
|
404
|
+
child.stderr.on('data', d => { err = push(err, d) })
|
|
405
|
+
const t = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
406
|
+
child.on('close', code => {
|
|
407
|
+
clearTimeout(t)
|
|
408
|
+
const stdout = out.toString('utf8')
|
|
409
|
+
const stderr = err.toString('utf8')
|
|
410
|
+
if (code !== 0 && code !== 1) {
|
|
411
|
+
resolve({ content: stderr || `${toolName} exited with code ${code}`, is_error: true })
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (code === 1 && !stdout.trim()) {
|
|
415
|
+
resolve({ content: stderr || `${toolName} execution error (exited with code 1 and empty output)`, is_error: true })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
resolve({ content: stdout, is_error: code === 1 })
|
|
419
|
+
})
|
|
420
|
+
child.on('error', e => { clearTimeout(t); resolve({ content: String(e?.message || e), is_error: true }) })
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── NotebookEdit ─────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
function toolNotebookEdit(cwd, input) {
|
|
427
|
+
const rel = input?.notebook_path
|
|
428
|
+
if (!rel) return { content: 'NotebookEdit: missing notebook_path', is_error: true }
|
|
429
|
+
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(cwd, rel)
|
|
430
|
+
if (!fs.existsSync(abs)) return { content: `NotebookEdit: not found: ${rel}`, is_error: true }
|
|
431
|
+
let nb
|
|
432
|
+
try { nb = JSON.parse(fs.readFileSync(abs, 'utf8')) } catch (e) { return { content: `NotebookEdit: parse error: ${e.message}`, is_error: true } }
|
|
433
|
+
const cells = nb.cells || []
|
|
434
|
+
const cellId = input?.cell_id
|
|
435
|
+
const newSource = input?.new_source
|
|
436
|
+
const cellType = input?.cell_type || 'code'
|
|
437
|
+
if (typeof newSource !== 'string') return { content: 'NotebookEdit: missing new_source', is_error: true }
|
|
438
|
+
|
|
439
|
+
const sourceLines = newSource.split('\n').map((l, i, a) => i < a.length - 1 ? l + '\n' : l)
|
|
440
|
+
|
|
441
|
+
if (cellId) {
|
|
442
|
+
const idx = cells.findIndex(c => c.id === cellId || c.metadata?.id === cellId)
|
|
443
|
+
if (idx >= 0) {
|
|
444
|
+
cells[idx].source = sourceLines
|
|
445
|
+
cells[idx].cell_type = cellType
|
|
446
|
+
} else {
|
|
447
|
+
cells.push({ id: cellId, cell_type: cellType, source: sourceLines, metadata: { id: cellId }, outputs: [] })
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
const id = randomUUID().slice(0, 8)
|
|
451
|
+
cells.unshift({ id, cell_type: cellType, source: sourceLines, metadata: { id }, outputs: [] })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
nb.cells = cells
|
|
455
|
+
fs.writeFileSync(abs, JSON.stringify(nb, null, 1), 'utf8')
|
|
456
|
+
return { content: `NotebookEdit: updated ${rel} (${cells.length} cells)`, is_error: false }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ─── WebFetch ─────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
async function toolWebFetch(input) {
|
|
462
|
+
const url = input?.url
|
|
463
|
+
if (!url) return { content: 'WebFetch: missing url', is_error: true }
|
|
464
|
+
try { new URL(url) } catch { return { content: `WebFetch: invalid url: ${url}`, is_error: true } }
|
|
465
|
+
const start = Date.now()
|
|
466
|
+
try {
|
|
467
|
+
const resp = await fetch(url, { headers: { 'User-Agent': 'ona-sdlc-repl/0.2' }, signal: AbortSignal.timeout(30_000) })
|
|
468
|
+
const text = await resp.text()
|
|
469
|
+
const capped = text.length > BASH_CAP ? text.slice(0, BASH_CAP) + `\n${TRUNC}` : text
|
|
470
|
+
const duration = Date.now() - start
|
|
471
|
+
if (!resp.ok) return { content: `WebFetch ${resp.status} ${resp.statusText}: ${capped.slice(0, 2000)}`, is_error: true }
|
|
472
|
+
return { content: `WebFetch ${url} (${resp.status}, ${text.length} bytes, ${duration}ms):\n${capped}`, is_error: false }
|
|
473
|
+
} catch (e) {
|
|
474
|
+
return { content: `WebFetch error: ${String(e?.message || e)}`, is_error: true }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ─── WebSearch ────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
async function toolWebSearch(input) {
|
|
481
|
+
const query = input?.query
|
|
482
|
+
if (!query || query.length < 2) return { content: 'WebSearch: query must be ≥2 chars', is_error: true }
|
|
483
|
+
try {
|
|
484
|
+
const url = `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`
|
|
485
|
+
const resp = await fetch(url, { headers: { 'User-Agent': 'ona-sdlc-repl/0.2' }, signal: AbortSignal.timeout(15_000) })
|
|
486
|
+
const html = await resp.text()
|
|
487
|
+
const results = parseDDGLite(html).slice(0, 8)
|
|
488
|
+
if (!results.length) return { content: `WebSearch: no results for "${query}"`, is_error: false }
|
|
489
|
+
const text = results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`).join('\n\n')
|
|
490
|
+
return { content: `WebSearch results for "${query}":\n\n${text}`, is_error: false }
|
|
491
|
+
} catch (e) {
|
|
492
|
+
return { content: `WebSearch error: ${String(e?.message || e)}`, is_error: true }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseDDGLite(html) {
|
|
497
|
+
const results = []
|
|
498
|
+
const linkRe = /<a[^>]+class="result-link"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
|
|
499
|
+
const snippetRe = /<td[^>]+class="result-snippet"[^>]*>([\s\S]*?)<\/td>/gi
|
|
500
|
+
const links = [...html.matchAll(linkRe)]
|
|
501
|
+
const snippets = [...html.matchAll(snippetRe)]
|
|
502
|
+
for (let i = 0; i < links.length; i++) {
|
|
503
|
+
const url = links[i][1].replace(/&/g, '&')
|
|
504
|
+
const title = links[i][2].replace(/<[^>]+>/g, '').trim()
|
|
505
|
+
const snippet = (snippets[i]?.[1] || '').replace(/<[^>]+>/g, '').trim()
|
|
506
|
+
if (url && title) results.push({ url, title, snippet })
|
|
507
|
+
}
|
|
508
|
+
if (!results.length) {
|
|
509
|
+
const simpleLinks = [...html.matchAll(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi)]
|
|
510
|
+
for (const m of simpleLinks.slice(0, 8)) {
|
|
511
|
+
const u = m[1]; const t = m[2].replace(/<[^>]+>/g, '').trim()
|
|
512
|
+
if (t.length > 5 && !u.includes('duckduckgo.com')) results.push({ url: u, title: t, snippet: '' })
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return results
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── AskUserQuestion ──────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
async function toolAskUser(io, input) {
|
|
521
|
+
const question = input?.question || input?.prompt || 'Please provide input:'
|
|
522
|
+
if (!io?.ask) return { content: 'AskUserQuestion: no interactive input available', is_error: true }
|
|
523
|
+
const answer = await io.ask(`${question}\n> `)
|
|
524
|
+
return { content: String(answer || '').trim() || '(no response)', is_error: false }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── Brief ────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
// Strip BOS/EOS tokens that some models (DeepSeek, Qwen) leak into tool call arguments
|
|
530
|
+
function sanitizeBriefMessage(msg) {
|
|
531
|
+
return msg
|
|
532
|
+
.replace(/<[||]begin[▁_]of[▁_]sentence[||]>/g, '')
|
|
533
|
+
.replace(/<[||]end[▁_]of[▁_]sentence[||]>/g, '')
|
|
534
|
+
.replace(/<\|im_start\|>/g, '')
|
|
535
|
+
.replace(/<\|im_end\|>/g, '')
|
|
536
|
+
.trim()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function toolBrief(io, input) {
|
|
540
|
+
const raw = input?.message || input?.content || ''
|
|
541
|
+
const msg = sanitizeBriefMessage(raw)
|
|
542
|
+
if (io?.println) io.println(msg)
|
|
543
|
+
return { content: 'Message displayed.', is_error: false }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── TodoWrite ────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
async function toolTodoWrite(db, ctx, input) {
|
|
549
|
+
const todos = input?.todos
|
|
550
|
+
if (!Array.isArray(todos)) return { content: 'TodoWrite: missing todos array', is_error: true }
|
|
551
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd || process.cwd(), settings: ctx.settings }
|
|
552
|
+
withTransaction(db, () => {
|
|
553
|
+
for (const t of todos) {
|
|
554
|
+
const id = t.id || randomUUID().slice(0, 8)
|
|
555
|
+
db.prepare(`INSERT OR REPLACE INTO state(conversation_id, key, value) VALUES (?, ?, ?)`).run(ctx.conversationId, `todo:${id}`, JSON.stringify(t))
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
for (const t of todos) {
|
|
559
|
+
const id = t.id || ''
|
|
560
|
+
if (t.status === 'completed') {
|
|
561
|
+
await runHooks(db, hookRt, 'TaskCompleted', { task_id: id, task_subject: t.content || t.title || '' })
|
|
562
|
+
} else {
|
|
563
|
+
await runHooks(db, hookRt, 'TaskCreated', { task_id: id, task_subject: t.content || t.title || '' })
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return { content: `TodoWrite: ${todos.length} item(s) saved.`, is_error: false }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─── TaskOutput ───────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
function toolTaskOutput(db, ctx, input) {
|
|
572
|
+
const output = input?.output || input?.content || ''
|
|
573
|
+
withTransaction(db, () => {
|
|
574
|
+
db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'task_output', ?)`).run(ctx.conversationId, ctx.sessionId, typeof output === 'string' ? output : JSON.stringify(output))
|
|
575
|
+
})
|
|
576
|
+
return { content: 'Task output recorded.', is_error: false }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ─── TaskStop ─────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
function toolTaskStop(input) {
|
|
582
|
+
const taskId = input?.task_id || input?.shell_id
|
|
583
|
+
if (!taskId) return { content: 'TaskStop: missing task_id', is_error: true }
|
|
584
|
+
// Background tasks tracked by the orchestrator; signal via process group
|
|
585
|
+
try { process.kill(Number(taskId), 'SIGTERM') } catch {}
|
|
586
|
+
return { content: `TaskStop: sent SIGTERM to ${taskId}`, is_error: false }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── EnterWorktree ───────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
async function toolEnterWorktree(db, ctx, input) {
|
|
592
|
+
const name = input?.name
|
|
593
|
+
if (!name) return { content: 'EnterWorktree: missing name', is_error: true }
|
|
594
|
+
const result = createWorktree(ctx.cwd, name)
|
|
595
|
+
if (!result.ok) return { content: `EnterWorktree: ${result.error}`, is_error: true }
|
|
596
|
+
process.chdir(result.worktreePath)
|
|
597
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
|
|
598
|
+
await runHooks(db, hookRt, 'WorktreeCreate', { name })
|
|
599
|
+
await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: result.worktreePath })
|
|
600
|
+
return { content: `Entered worktree: ${result.worktreePath} (branch: ${result.worktreeBranch})`, is_error: false }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ─── ExitWorktree ────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
async function toolExitWorktree(db, ctx, input) {
|
|
606
|
+
const wt = getCurrentWorktree()
|
|
607
|
+
if (!wt) return { content: 'ExitWorktree: not in a worktree', is_error: true }
|
|
608
|
+
const action = input?.action || 'remove'
|
|
609
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
|
|
610
|
+
if (action === 'remove') {
|
|
611
|
+
const result = removeWorktree()
|
|
612
|
+
if (!result.ok) return { content: `ExitWorktree: ${result.error}`, is_error: true }
|
|
613
|
+
process.chdir(result.originalCwd)
|
|
614
|
+
await runHooks(db, hookRt, 'WorktreeRemove', { worktree_path: result.removedPath })
|
|
615
|
+
await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: result.originalCwd })
|
|
616
|
+
return { content: `Exited and removed worktree. Restored to ${result.originalCwd}`, is_error: false }
|
|
617
|
+
}
|
|
618
|
+
// keep — just chdir back without removing
|
|
619
|
+
process.chdir(wt.originalCwd)
|
|
620
|
+
await runHooks(db, hookRt, 'CwdChanged', { old_cwd: ctx.cwd, new_cwd: wt.originalCwd })
|
|
621
|
+
return { content: `Exited worktree (kept at ${wt.worktreePath}). Restored to ${wt.originalCwd}`, is_error: false }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ─── Agent ────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
async function toolAgent(db, ctx, input, io) {
|
|
627
|
+
const prompt = input?.prompt
|
|
628
|
+
if (!prompt) return { content: 'Agent: missing prompt', is_error: true }
|
|
629
|
+
const desc = input?.description || 'subagent'
|
|
630
|
+
const subSessionId = randomUUID()
|
|
631
|
+
const subConvId = ctx.conversationId // same conversation
|
|
632
|
+
|
|
633
|
+
db.prepare(`INSERT INTO sessions(session_id, conversation_id) VALUES (?,?)`).run(subSessionId, subConvId)
|
|
634
|
+
db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'subagent_start', ?)`).run(subConvId, subSessionId, JSON.stringify({ description: desc }))
|
|
635
|
+
const hookRt = { sessionId: ctx.sessionId, conversationId: ctx.conversationId, runtimeDbPath: process.env.AGENT_SDLC_DB || '', cwd: ctx.cwd, settings: ctx.settings }
|
|
636
|
+
await runHooks(db, hookRt, 'SubagentStart', { agent_id: subSessionId, agent_type: desc })
|
|
637
|
+
|
|
638
|
+
// Run one turn in the sub-session using the same orchestration
|
|
639
|
+
const { runUserTurn } = await import('./orchestrate.mjs')
|
|
640
|
+
const subRt = { ...ctx, sessionId: subSessionId }
|
|
641
|
+
const collected = []
|
|
642
|
+
const subIo = {
|
|
643
|
+
write: s => collected.push(s),
|
|
644
|
+
println: s => collected.push(s + '\n'),
|
|
645
|
+
ask: io?.ask,
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
await runUserTurn(db, subRt, prompt, subIo)
|
|
649
|
+
|
|
650
|
+
const output = collected.join('')
|
|
651
|
+
db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?, ?, 'subagent_stop', ?)`).run(subConvId, subSessionId, JSON.stringify({ description: desc }))
|
|
652
|
+
await runHooks(db, hookRt, 'SubagentStop', { stop_hook_active: false, agent_id: subSessionId, agent_transcript_path: '', agent_type: desc, last_assistant_message: output.slice(0, 500) })
|
|
653
|
+
|
|
654
|
+
// If this is a team member, fire TeammateIdle
|
|
655
|
+
const teamName = input?.team_name || ctx.teamName
|
|
656
|
+
if (teamName) {
|
|
657
|
+
const agentId = `${desc}@${teamName}`
|
|
658
|
+
setTeammateIdle(teamName, agentId)
|
|
659
|
+
await runHooks(db, hookRt, 'TeammateIdle', { teammate_name: desc, team_name: teamName })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return { content: output || '(agent produced no output)', is_error: false }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ─── Skill ────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
function toolSkill(cwd, input) {
|
|
668
|
+
const name = input?.skill
|
|
669
|
+
if (!name) return { content: 'Skill: missing skill name', is_error: true }
|
|
670
|
+
|
|
671
|
+
// Search for skill markdown file in standard locations:
|
|
672
|
+
// .ona/skills/<name>/SKILL.md, .claude/skills/<name>/SKILL.md,
|
|
673
|
+
// .ona/skills/<name>.md, .claude/skills/<name>.md
|
|
674
|
+
const searchDirs = [
|
|
675
|
+
path.join(cwd, '.ona', 'skills'),
|
|
676
|
+
path.join(cwd, '.claude', 'skills'),
|
|
677
|
+
path.join(process.env.HOME || '', '.ona', 'skills'),
|
|
678
|
+
path.join(process.env.HOME || '', '.claude', 'skills'),
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
for (const dir of searchDirs) {
|
|
682
|
+
if (!fs.existsSync(dir)) continue
|
|
683
|
+
// Directory format: <name>/SKILL.md
|
|
684
|
+
const dirSkill = path.join(dir, name, 'SKILL.md')
|
|
685
|
+
if (fs.existsSync(dirSkill)) {
|
|
686
|
+
try {
|
|
687
|
+
const content = fs.readFileSync(dirSkill, 'utf8')
|
|
688
|
+
return { content: `Skill '${name}':\n\n${content}`, is_error: false }
|
|
689
|
+
} catch (e) {
|
|
690
|
+
return { content: `Skill: failed to read ${dirSkill}: ${e.message}`, is_error: true }
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Flat format: <name>.md
|
|
694
|
+
const flatSkill = path.join(dir, `${name}.md`)
|
|
695
|
+
if (fs.existsSync(flatSkill)) {
|
|
696
|
+
try {
|
|
697
|
+
const content = fs.readFileSync(flatSkill, 'utf8')
|
|
698
|
+
return { content: `Skill '${name}':\n\n${content}`, is_error: false }
|
|
699
|
+
} catch (e) {
|
|
700
|
+
return { content: `Skill: failed to read ${flatSkill}: ${e.message}`, is_error: true }
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// No skill file found — return available skills list
|
|
706
|
+
const available = []
|
|
707
|
+
for (const dir of searchDirs) {
|
|
708
|
+
if (!fs.existsSync(dir)) continue
|
|
709
|
+
try {
|
|
710
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
711
|
+
for (const e of entries) {
|
|
712
|
+
if (e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))) {
|
|
713
|
+
available.push(e.name)
|
|
714
|
+
} else if (e.isFile() && e.name.endsWith('.md')) {
|
|
715
|
+
available.push(e.name.replace(/\.md$/, ''))
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} catch { /* skip unreadable dirs */ }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (available.length) {
|
|
722
|
+
return { content: `Skill '${name}' not found. Available skills: ${[...new Set(available)].join(', ')}`, is_error: true }
|
|
723
|
+
}
|
|
724
|
+
return { content: `Skill '${name}' not found. No skills directory configured. Create .ona/skills/${name}/SKILL.md to define a skill.`, is_error: true }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ─── ToolSearch ───────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
function toolSearchFn(input) {
|
|
730
|
+
const query = (input?.query || '').toLowerCase()
|
|
731
|
+
const max = input?.max_results || 5
|
|
732
|
+
const defs = anthropicToolDefinitions()
|
|
733
|
+
const matches = defs.filter(t => t.name.toLowerCase().includes(query) || t.description.toLowerCase().includes(query)).slice(0, max)
|
|
734
|
+
if (!matches.length) return { content: `ToolSearch: no matches for "${query}"`, is_error: false }
|
|
735
|
+
const text = matches.map(t => `${t.name}: ${t.description}`).join('\n')
|
|
736
|
+
return { content: text, is_error: false }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ─── MCP tools ────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
async function toolListMcpResources(ctx, input) {
|
|
742
|
+
const serverName = input?.server
|
|
743
|
+
const settings = ctx.settings || {}
|
|
744
|
+
const serverConfigs = settings.mcp_servers || {}
|
|
745
|
+
if (serverName) {
|
|
746
|
+
if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
|
|
747
|
+
const srv = getMcpServer(serverName, serverConfigs[serverName])
|
|
748
|
+
return mcpListResources(srv)
|
|
749
|
+
}
|
|
750
|
+
const names = Object.keys(serverConfigs)
|
|
751
|
+
if (!names.length) return { content: 'No MCP servers configured.', is_error: false }
|
|
752
|
+
const all = []
|
|
753
|
+
for (const n of names) {
|
|
754
|
+
const srv = getMcpServer(n, serverConfigs[n])
|
|
755
|
+
const r = await mcpListResources(srv)
|
|
756
|
+
all.push(`[${n}]\n${r.content}`)
|
|
757
|
+
}
|
|
758
|
+
return { content: all.join('\n\n'), is_error: false }
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function toolReadMcpResource(ctx, input) {
|
|
762
|
+
const serverName = input?.server
|
|
763
|
+
const uri = input?.uri
|
|
764
|
+
if (!serverName || !uri) return { content: 'ReadMcpResource: missing server or uri', is_error: true }
|
|
765
|
+
const settings = ctx.settings || {}
|
|
766
|
+
const serverConfigs = settings.mcp_servers || {}
|
|
767
|
+
if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
|
|
768
|
+
const srv = getMcpServer(serverName, serverConfigs[serverName])
|
|
769
|
+
return mcpReadResource(srv, uri)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function toolMcpInvoke(ctx, toolName, toolInput) {
|
|
773
|
+
// mcp__<server>__<tool>
|
|
774
|
+
const parts = toolName.split('__')
|
|
775
|
+
if (parts.length < 3) return { content: `Invalid MCP tool name: ${toolName}`, is_error: true }
|
|
776
|
+
const serverName = parts[1]
|
|
777
|
+
const mcpToolName = parts.slice(2).join('__')
|
|
778
|
+
const settings = ctx.settings || {}
|
|
779
|
+
const serverConfigs = settings.mcp_servers || {}
|
|
780
|
+
if (!serverConfigs[serverName]) return { content: `MCP server '${serverName}' not configured`, is_error: true }
|
|
781
|
+
const srv = getMcpServer(serverName, serverConfigs[serverName])
|
|
782
|
+
const { mcpCallTool: callTool } = await import('./mcp.mjs')
|
|
783
|
+
const result = await callTool(srv, mcpToolName, toolInput || {})
|
|
784
|
+
|
|
785
|
+
// Pass energy payloads through ODSE normalization if OEM is recognized.
|
|
786
|
+
// On any failure applyEnergyNormalization returns the original content unchanged.
|
|
787
|
+
if (!result.is_error && result.content) {
|
|
788
|
+
const { content } = await applyEnergyNormalization(serverName, result.content)
|
|
789
|
+
return { ...result, content }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return result
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ─── Tool definitions ─────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
export function anthropicToolDefinitions() {
|
|
798
|
+
const tools = [
|
|
799
|
+
{ name: 'Read', description: 'Read a file from the filesystem.', input_schema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file (absolute or relative to cwd)' }, offset: { type: 'integer', description: 'Start line (0-based)' }, limit: { type: 'integer', description: 'Max lines to read' } }, required: ['file_path'] } },
|
|
800
|
+
{ name: 'Write', description: 'Create or overwrite a file.', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } },
|
|
801
|
+
{ name: 'Edit', description: 'Find and replace text in a file.', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['file_path', 'old_string', 'new_string'] } },
|
|
802
|
+
{ name: 'Glob', description: 'Find files matching a glob pattern.', input_schema: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string', description: 'Directory to search (default: cwd)' } }, required: ['pattern'] } },
|
|
803
|
+
{ name: 'Grep', description: 'Search file contents with regex.', input_schema: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'] }, glob: { type: 'string' }, '-i': { type: 'boolean' }, '-C': { type: 'integer' }, head_limit: { type: 'integer' } }, required: ['pattern'] } },
|
|
804
|
+
{ name: 'Bash', description: 'Run a shell command.', input_schema: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'integer', description: 'Timeout in ms (max 600000)' } }, required: ['command'] } },
|
|
805
|
+
{ name: 'NotebookEdit', description: 'Edit a Jupyter notebook cell.', input_schema: { type: 'object', properties: { notebook_path: { type: 'string' }, cell_id: { type: 'string' }, new_source: { type: 'string' }, cell_type: { type: 'string', enum: ['code', 'markdown'] } }, required: ['notebook_path', 'new_source', 'cell_type'] } },
|
|
806
|
+
{ name: 'WebFetch', description: 'Fetch content from a URL.', input_schema: { type: 'object', properties: { url: { type: 'string' }, prompt: { type: 'string' } }, required: ['url'] } },
|
|
807
|
+
{ name: 'WebSearch', description: 'Search the web.', input_schema: { type: 'object', properties: { query: { type: 'string' }, allowed_domains: { type: 'array', items: { type: 'string' } }, blocked_domains: { type: 'array', items: { type: 'string' } } }, required: ['query'] } },
|
|
808
|
+
{ name: 'AskUserQuestion', description: 'Ask the user a question and wait for response.', input_schema: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] } },
|
|
809
|
+
{ name: 'Brief', description: 'Display a message to the user.', input_schema: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] } },
|
|
810
|
+
{ name: 'TodoWrite', description: 'Create or update todo items.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } }, required: ['todos'] } },
|
|
811
|
+
{ name: 'TaskOutput', description: 'Record output from a task.', input_schema: { type: 'object', properties: { output: { type: 'string' } } } },
|
|
812
|
+
{ name: 'TaskStop', description: 'Stop a background task.', input_schema: { type: 'object', properties: { task_id: { type: 'string' } } } },
|
|
813
|
+
{ name: 'Agent', description: 'Run a subagent with an isolated session.', input_schema: { type: 'object', properties: { description: { type: 'string' }, prompt: { type: 'string' } }, required: ['prompt'] } },
|
|
814
|
+
{ name: 'Skill', description: 'Invoke a skill or slash command.', input_schema: { type: 'object', properties: { skill: { type: 'string' }, args: { type: 'string' } }, required: ['skill'] } },
|
|
815
|
+
{ name: 'ToolSearch', description: 'Search for available tools.', input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'integer' } }, required: ['query'] } },
|
|
816
|
+
{ name: 'EnterWorktree', description: 'Create a git worktree for isolated work.', input_schema: { type: 'object', properties: { name: { type: 'string', description: 'Worktree name (alphanumeric, max 64 chars)' } }, required: ['name'] } },
|
|
817
|
+
{ name: 'ExitWorktree', description: 'Exit the current worktree.', input_schema: { type: 'object', properties: { action: { type: 'string', enum: ['keep', 'remove'], description: 'Keep or remove the worktree (default: remove)' } } } },
|
|
818
|
+
{ name: 'ListMcpResources', description: 'List MCP server resources.', input_schema: { type: 'object', properties: { server: { type: 'string' } } } },
|
|
819
|
+
{ name: 'ReadMcpResource', description: 'Read a resource from an MCP server.', input_schema: { type: 'object', properties: { server: { type: 'string' }, uri: { type: 'string' } }, required: ['server', 'uri'] } },
|
|
820
|
+
]
|
|
821
|
+
for (const [name, config] of dynamicTools) {
|
|
822
|
+
tools.push({
|
|
823
|
+
name: config.name,
|
|
824
|
+
description: config.description,
|
|
825
|
+
input_schema: config.input_schema
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
return tools
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function openAICompatToolDefinitions() {
|
|
832
|
+
return anthropicToolDefinitions().map(t => ({
|
|
833
|
+
type: 'function',
|
|
834
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
835
|
+
}))
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Core tools for local models with limited context (§2.1 lm_studio_local). */
|
|
839
|
+
export const LOCAL_CORE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'])
|
|
840
|
+
|
|
841
|
+
// ─── MCP tool discovery ───────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
/** In-memory cache: serverName → { tools, ts } */
|
|
844
|
+
const _mcpToolCache = new Map()
|
|
845
|
+
const MCP_CACHE_TTL_MS = 60_000 // re-discover after 1 minute
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Connect to all configured MCP servers, call tools/list on each, and return
|
|
849
|
+
* tool definitions in Anthropic format. Results are cached per-server for
|
|
850
|
+
* MCP_CACHE_TTL_MS to avoid spawning child processes on every turn.
|
|
851
|
+
*
|
|
852
|
+
* Tool names are namespaced as mcp__<server>__<tool> so they route through
|
|
853
|
+
* toolMcpInvoke() in executeBuiltinTool() without any extra plumbing.
|
|
854
|
+
*
|
|
855
|
+
* @param {object} settings - Effective settings object (settings.mcp_servers used)
|
|
856
|
+
* @returns {Promise<Array>} Anthropic-format tool definitions
|
|
857
|
+
*/
|
|
858
|
+
export async function getMcpToolDefinitions(settings) {
|
|
859
|
+
const serverConfigs = settings?.mcp_servers
|
|
860
|
+
if (!serverConfigs || typeof serverConfigs !== 'object') return []
|
|
861
|
+
|
|
862
|
+
const names = Object.keys(serverConfigs)
|
|
863
|
+
if (!names.length) return []
|
|
864
|
+
|
|
865
|
+
const allTools = []
|
|
866
|
+
const now = Date.now()
|
|
867
|
+
|
|
868
|
+
for (const name of names) {
|
|
869
|
+
const config = serverConfigs[name]
|
|
870
|
+
if (!config?.command) continue
|
|
871
|
+
|
|
872
|
+
// Return cached entry if still fresh
|
|
873
|
+
const cached = _mcpToolCache.get(name)
|
|
874
|
+
if (cached && now - cached.ts < MCP_CACHE_TTL_MS) {
|
|
875
|
+
allTools.push(...cached.tools)
|
|
876
|
+
continue
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const srv = getMcpServer(name, config)
|
|
881
|
+
const resp = await srv.request('tools/list', {})
|
|
882
|
+
if (resp.error) continue
|
|
883
|
+
|
|
884
|
+
const serverTools = (resp.result?.tools || []).map(t => ({
|
|
885
|
+
name: `mcp__${name}__${t.name}`,
|
|
886
|
+
description: `[MCP:${name}] ${t.description || t.name}`,
|
|
887
|
+
input_schema: t.inputSchema || { type: 'object', properties: {} },
|
|
888
|
+
}))
|
|
889
|
+
|
|
890
|
+
_mcpToolCache.set(name, { tools: serverTools, ts: now })
|
|
891
|
+
allTools.push(...serverTools)
|
|
892
|
+
} catch {
|
|
893
|
+
// Server unreachable — skip silently, don't break the turn
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return allTools
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Invalidate the MCP tool cache for one server (or all if no name given).
|
|
902
|
+
* Called by /mcp reload.
|
|
903
|
+
*/
|
|
904
|
+
export function invalidateMcpToolCache(serverName) {
|
|
905
|
+
if (serverName) _mcpToolCache.delete(serverName)
|
|
906
|
+
else _mcpToolCache.clear()
|
|
907
|
+
}
|