1985 lines
58 KiB
JavaScript
1985 lines
58 KiB
JavaScript
/**
|
|
* Guest-safe Grok Build harness ports: todos, plan mode, memory, glob,
|
|
* AGENTS.md walk-up, JSON hooks, and search_replace uniqueness.
|
|
*/
|
|
|
|
var BARE_AGENT_PLAN_READONLY_TOOLS = Object.freeze({
|
|
read_file: 1,
|
|
list_directory: 1,
|
|
list_dir: 1,
|
|
file_stat: 1,
|
|
search_files: 1,
|
|
grep: 1,
|
|
glob_files: 1,
|
|
glob: 1,
|
|
get_system_info: 1,
|
|
list_bin: 1,
|
|
read_man_page: 1,
|
|
apropos_man: 1,
|
|
read_proc_file: 1,
|
|
get_swarm_peers: 1,
|
|
get_resource_limits: 1,
|
|
web_fetch: 1,
|
|
web_search: 1,
|
|
git_status: 1,
|
|
read_skill: 1,
|
|
list_services: 1,
|
|
service_status: 1,
|
|
list_timers: 1,
|
|
read_cron_log: 1,
|
|
read_audit_log: 1,
|
|
read_boot_policy: 1,
|
|
read_kernel_extension_resolution: 1,
|
|
get_initd_graph: 1,
|
|
read_unit_journal: 1,
|
|
inspect_ipc_backpressure: 1,
|
|
get_network_summary: 1,
|
|
tail_telemetry_streams: 1,
|
|
pkg_index_lookup: 1,
|
|
list_verification_scripts: 1,
|
|
verification_hints: 1,
|
|
runtime_diagnostic_bundle: 1,
|
|
memory_search: 1,
|
|
memory_get: 1,
|
|
memory_append: 1,
|
|
list_skills: 1,
|
|
fuzzy_find: 1,
|
|
wait_for: 1,
|
|
read_many: 1,
|
|
git_diff: 1,
|
|
git_log: 1,
|
|
git_show: 1,
|
|
git_blame: 1,
|
|
history_search: 1,
|
|
list_scheduled: 1,
|
|
find_symbol: 1,
|
|
diff_files: 1,
|
|
export_session: 1,
|
|
remember: 1,
|
|
todo_write: 1,
|
|
enter_plan_mode: 1,
|
|
exit_plan_mode: 1,
|
|
ask_user_question: 1,
|
|
update_goal: 1,
|
|
autonomous_run_status: 1,
|
|
get_hrpc_bridge_health: 1,
|
|
get_hrpc_allowlist_status: 1
|
|
})
|
|
|
|
var BARE_AGENT_PLAN_WRITE_TOOLS = Object.freeze({
|
|
write_file: 1,
|
|
edit_file: 1,
|
|
search_replace: 1,
|
|
apply_patch: 1
|
|
})
|
|
|
|
/**
|
|
* @param {string} pattern
|
|
* @returns {RegExp | null}
|
|
*/
|
|
function bareAgentGlobToRegExp(pattern) {
|
|
const src = String(pattern || '').trim()
|
|
if (!src) return null
|
|
let out = '^'
|
|
for (let i = 0; i < src.length; i++) {
|
|
const c = src.charAt(i)
|
|
if (c === '*' && src.charAt(i + 1) === '*') {
|
|
const next = src.charAt(i + 2)
|
|
if (next === '/' || next === '') {
|
|
out += '.*'
|
|
i += next === '/' ? 2 : 1
|
|
continue
|
|
}
|
|
}
|
|
if (c === '*') {
|
|
out += '[^/]*'
|
|
continue
|
|
}
|
|
if (c === '?') {
|
|
out += '[^/]'
|
|
continue
|
|
}
|
|
if ('\\^$+()[]{}|.'.indexOf(c) !== -1) out += '\\' + c
|
|
else out += c
|
|
}
|
|
out += '$'
|
|
try {
|
|
return new RegExp(out)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} rel
|
|
* @param {string} pattern
|
|
*/
|
|
function bareAgentGlobMatch(rel, pattern) {
|
|
const re = bareAgentGlobToRegExp(pattern)
|
|
if (!re) return false
|
|
const n = String(rel || '').replace(/^\/+/, '')
|
|
if (re.test(n)) return true
|
|
const base = n.split('/').pop() || n
|
|
return re.test(base)
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} updates
|
|
* @param {{ merge?: boolean }} [opts]
|
|
* @param {{ id: string, content: string, status: string }[]} [prev]
|
|
*/
|
|
function bareAgentTodoApply(updates, opts, prev) {
|
|
const merge = Boolean(opts && opts.merge)
|
|
/** @type {{ id: string, content: string, status: string }[]} */
|
|
const list = merge && Array.isArray(prev) ? prev.slice() : []
|
|
const byId = Object.create(null)
|
|
for (let i = 0; i < list.length; i++) byId[list[i].id] = i
|
|
const rows = Array.isArray(updates) ? updates : []
|
|
const seen = Object.create(null)
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i] && typeof rows[i] === 'object' ? rows[i] : {}
|
|
const id = String(row.id || '').trim()
|
|
if (!id) throw new Error('todo_id_required')
|
|
if (seen[id]) throw new Error('duplicate_todo_id:' + id)
|
|
seen[id] = 1
|
|
const statusRaw = String(row.status || 'pending').trim().toLowerCase()
|
|
const status =
|
|
statusRaw === 'in_progress' || statusRaw === 'completed' || statusRaw === 'cancelled'
|
|
? statusRaw
|
|
: 'pending'
|
|
const content = String(row.content == null ? '' : row.content).trim()
|
|
if (byId[id] != null) {
|
|
const cur = list[byId[id]]
|
|
if (content) cur.content = content
|
|
cur.status = status
|
|
} else {
|
|
list.push({ id, content: content || id, status })
|
|
byId[id] = list.length - 1
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
/**
|
|
* @param {{ id: string, content: string, status: string }[]} todos
|
|
*/
|
|
function bareAgentTodoSummarize(todos) {
|
|
const rows = Array.isArray(todos) ? todos : []
|
|
let pending = 0
|
|
let inProgress = 0
|
|
let completed = 0
|
|
const lines = []
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const t = rows[i]
|
|
const st = String(t.status || 'pending')
|
|
if (st === 'completed' || st === 'cancelled') completed++
|
|
else if (st === 'in_progress') inProgress++
|
|
else pending++
|
|
lines.push('- [' + st + '] ' + t.id + ': ' + String(t.content || '').slice(0, 160))
|
|
}
|
|
return {
|
|
total: rows.length,
|
|
pending,
|
|
in_progress: inProgress,
|
|
completed,
|
|
open: pending + inProgress,
|
|
text: lines.join('\n')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ open?: number, turnsSinceTodoWrite?: number, nudgeEnabled?: boolean }} opts
|
|
*/
|
|
function bareAgentTodoNudgeText(opts) {
|
|
const o = opts && typeof opts === 'object' ? opts : {}
|
|
if (o.nudgeEnabled === false) return ''
|
|
const turns = Number(o.turnsSinceTodoWrite) || 0
|
|
const open = Number(o.open) || 0
|
|
if (open > 0 && turns >= 3) {
|
|
return (
|
|
'Open todos remain (' +
|
|
String(open) +
|
|
'). Use todo_write to mark progress or complete items before stopping.'
|
|
)
|
|
}
|
|
if (open === 0 && turns >= 5) {
|
|
return 'Multi-step work: call todo_write to track remaining steps (merge=true to update one id).'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* @param {string} toolName
|
|
* @param {Record<string, unknown>} args
|
|
* @param {{ plan?: string }} paths
|
|
*/
|
|
function bareAgentPlanModeToolAllowed(toolName, args, paths) {
|
|
const name = String(toolName || '')
|
|
if (BARE_AGENT_PLAN_READONLY_TOOLS[name]) return true
|
|
if (!BARE_AGENT_PLAN_WRITE_TOOLS[name]) return false
|
|
const plan = String((paths && paths.plan) || '')
|
|
const p = String(args.path || args.file_path || '')
|
|
return Boolean(plan && p && p === plan)
|
|
}
|
|
|
|
/**
|
|
* @param {string} hay
|
|
* @param {string} needle
|
|
*/
|
|
function bareAgentCountOccurrences(hay, needle) {
|
|
if (!needle) return 0
|
|
let n = 0
|
|
let from = 0
|
|
while (from <= hay.length) {
|
|
const i = hay.indexOf(needle, from)
|
|
if (i === -1) break
|
|
n++
|
|
from = i + Math.max(needle.length, 1)
|
|
}
|
|
return n
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {string} oldStr
|
|
* @param {string} newStr
|
|
* @param {boolean} replaceAll
|
|
*/
|
|
function bareAgentSearchReplaceApply(text, oldStr, newStr, replaceAll) {
|
|
const prev = String(text == null ? '' : text)
|
|
if (!oldStr) {
|
|
if (prev.trim()) {
|
|
return { ok: false, error: 'empty_old_string_cannot_overwrite' }
|
|
}
|
|
return { ok: true, next: String(newStr == null ? '' : newStr), replacements: 1 }
|
|
}
|
|
const count = bareAgentCountOccurrences(prev, oldStr)
|
|
if (count === 0) return { ok: false, error: 'old_string not found', count: 0 }
|
|
if (count > 1 && !replaceAll) {
|
|
return {
|
|
ok: false,
|
|
error: 'old_string not unique',
|
|
count,
|
|
hint: 'add surrounding lines to make the match unique, or set replace_all true'
|
|
}
|
|
}
|
|
const next = replaceAll ? prev.split(oldStr).join(newStr) : prev.replace(oldStr, newStr)
|
|
return { ok: true, next, replacements: replaceAll ? count : 1 }
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {{ offset?: number, limit?: number, numbered?: boolean }} [opts]
|
|
*/
|
|
function bareAgentSliceFileLines(text, opts) {
|
|
const o = opts && typeof opts === 'object' ? opts : {}
|
|
const raw = String(text == null ? '' : text)
|
|
const lines = raw.split('\n')
|
|
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
|
const offset =
|
|
typeof o.offset === 'number' && o.offset >= 1 ? Math.floor(o.offset) : 1
|
|
const limit =
|
|
typeof o.limit === 'number' && o.limit >= 1
|
|
? Math.floor(o.limit)
|
|
: lines.length
|
|
const start = Math.min(lines.length, offset - 1)
|
|
const end = Math.min(lines.length, start + limit)
|
|
const slice = lines.slice(start, end)
|
|
const numbered = o.numbered !== false
|
|
const body = numbered
|
|
? slice
|
|
.map(function (line, i) {
|
|
return String(start + i + 1) + '→' + line
|
|
})
|
|
.join('\n')
|
|
: slice.join('\n')
|
|
return {
|
|
content: body,
|
|
start_line: start + 1,
|
|
end_line: end,
|
|
total_lines: lines.length,
|
|
truncated: start > 0 || end < lines.length
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} query
|
|
*/
|
|
function bareAgentMemoryTokens(query) {
|
|
return String(query || '')
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9_./-]+/)
|
|
.filter(function (t) {
|
|
return t.length > 1
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {string[]} tokens
|
|
*/
|
|
function bareAgentMemoryScore(text, tokens) {
|
|
const low = String(text || '').toLowerCase()
|
|
if (!tokens.length || !low) return 0
|
|
let hits = 0
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
if (low.indexOf(tokens[i]) !== -1) hits++
|
|
}
|
|
return hits / tokens.length
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} startDir
|
|
* @param {number} [maxHops]
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
async function bareAgentDiscoverAgentsMdPaths(ctx, startDir, maxHops) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readFile !== 'function') return []
|
|
/** @type {string[]} */
|
|
const found = []
|
|
const seen = Object.create(null)
|
|
let dir = String(startDir || '').replace(/\/+$/, '') || '/'
|
|
const hops = Math.min(Math.max(Number(maxHops) || 12, 1), 24)
|
|
const names = ['AGENTS.md', 'Claude.md', 'CLAUDE.md']
|
|
for (let n = 0; n < hops; n++) {
|
|
for (let i = 0; i < names.length; i++) {
|
|
const p = (dir === '/' ? '' : dir) + '/' + names[i]
|
|
if (seen[p]) continue
|
|
seen[p] = 1
|
|
try {
|
|
const buf = await vfs.readFile(p)
|
|
if (buf && buf.length) found.push(p)
|
|
} catch {
|
|
/* missing */
|
|
}
|
|
}
|
|
const rulesDirs = [dir + '/.grok/rules', dir + '/.agent/rules']
|
|
for (let r = 0; r < rulesDirs.length; r++) {
|
|
if (typeof vfs.readdir !== 'function') continue
|
|
let entries = []
|
|
try {
|
|
entries = await vfs.readdir(rulesDirs[r])
|
|
} catch {
|
|
continue
|
|
}
|
|
if (!Array.isArray(entries)) continue
|
|
entries = entries.slice().sort()
|
|
for (let e = 0; e < entries.length; e++) {
|
|
const name = String(entries[e] || '')
|
|
if (!/\.md$/i.test(name)) continue
|
|
const p = rulesDirs[r] + '/' + name
|
|
if (seen[p]) continue
|
|
seen[p] = 1
|
|
try {
|
|
const buf = await vfs.readFile(p)
|
|
if (buf && buf.length) found.push(p)
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
if (dir === '/' || dir === '') break
|
|
const slash = dir.lastIndexOf('/')
|
|
dir = slash <= 0 ? '/' : dir.slice(0, slash)
|
|
}
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string[]} files
|
|
* @param {number} [maxChars]
|
|
*/
|
|
async function bareAgentLoadAgentsMdPrompt(ctx, files, maxChars) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readFile !== 'function') return ''
|
|
const cap = Math.min(Math.max(Number(maxChars) || 6000, 500), 16000)
|
|
const parts = []
|
|
let used = 0
|
|
for (let i = 0; i < files.length; i++) {
|
|
if (used >= cap) break
|
|
try {
|
|
const buf = await vfs.readFile(files[i])
|
|
if (!buf || !buf.length) continue
|
|
const t =
|
|
typeof ctx.b4a !== 'undefined' &&
|
|
ctx.b4a &&
|
|
typeof ctx.b4a.toString === 'function'
|
|
? ctx.b4a.toString(buf)
|
|
: String(new TextDecoder().decode(buf))
|
|
const body = String(t || '').trim()
|
|
if (!body) continue
|
|
const block = '### ' + files[i] + '\n' + body
|
|
const room = cap - used
|
|
parts.push(block.slice(0, room))
|
|
used += Math.min(block.length, room)
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
if (!parts.length) return ''
|
|
return (
|
|
'## Discovered project agent files (cwd walk-up, Grok-style)\n' +
|
|
parts.join('\n\n')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} hook
|
|
* @param {string} toolName
|
|
* @param {Record<string, unknown>} args
|
|
*/
|
|
function bareAgentHookDenies(hook, toolName, args) {
|
|
if (!hook || typeof hook !== 'object') return ''
|
|
const event = String(hook.event || hook.type || 'PreToolUse')
|
|
if (event !== 'PreToolUse' && event !== 'pre') return ''
|
|
const tools = Array.isArray(hook.tools) ? hook.tools.map(String) : []
|
|
if (tools.length && tools.indexOf(toolName) === -1) return ''
|
|
const reSrc = String(hook.deny_regex || hook.denyRegex || '').trim()
|
|
if (!reSrc) return ''
|
|
let re
|
|
try {
|
|
re = new RegExp(reSrc, 'i')
|
|
} catch {
|
|
return ''
|
|
}
|
|
const blob =
|
|
toolName +
|
|
' ' +
|
|
String(args.command || args.path || args.file_path || args.pattern || '')
|
|
if (!re.test(blob)) return ''
|
|
return String(hook.reason || hook.message || 'blocked by ~/.agent/hooks').slice(0, 240)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} hooksDir
|
|
* @param {string} toolName
|
|
* @param {Record<string, unknown>} args
|
|
*/
|
|
async function bareAgentRunPreToolHooks(ctx, hooksDir, toolName, args) {
|
|
if (typeof bareAgentRunHooks === 'function') {
|
|
const out = await bareAgentRunHooks(ctx, hooksDir, 'PreToolUse', toolName, args, '')
|
|
return out.deny || ''
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {unknown} buf
|
|
*/
|
|
function bareAgentPortDecode(ctx, buf) {
|
|
if (!buf || !buf.length) return ''
|
|
if (
|
|
typeof ctx.b4a !== 'undefined' &&
|
|
ctx.b4a &&
|
|
typeof ctx.b4a.toString === 'function'
|
|
) {
|
|
return ctx.b4a.toString(buf)
|
|
}
|
|
return String(new TextDecoder().decode(buf))
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} text
|
|
*/
|
|
function bareAgentPortEncode(ctx, text) {
|
|
if (
|
|
typeof ctx.b4a !== 'undefined' &&
|
|
ctx.b4a &&
|
|
typeof ctx.b4a.from === 'function'
|
|
) {
|
|
return ctx.b4a.from(String(text == null ? '' : text))
|
|
}
|
|
return new TextEncoder().encode(String(text == null ? '' : text))
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
*/
|
|
async function bareAgentReadTextFile(ctx, path) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readFile !== 'function') return ''
|
|
try {
|
|
const buf = await vfs.readFile(path)
|
|
return bareAgentPortDecode(ctx, buf)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
* @param {string} text
|
|
*/
|
|
async function bareAgentWriteTextFile(ctx, path, text) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.writeFile !== 'function') {
|
|
throw new Error('vfs.writeFile unavailable')
|
|
}
|
|
if (typeof vfs.mkdir === 'function') {
|
|
const dir = String(path || '').replace(/\/[^/]+$/, '')
|
|
if (dir && dir !== path) {
|
|
try {
|
|
await vfs.mkdir(dir, { recursive: true })
|
|
} catch {
|
|
/* parent may already exist */
|
|
}
|
|
}
|
|
}
|
|
await vfs.writeFile(path, bareAgentPortEncode(ctx, text))
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
* @param {unknown} fallback
|
|
*/
|
|
async function bareAgentReadJsonFile(ctx, path, fallback) {
|
|
const t = await bareAgentReadTextFile(ctx, path)
|
|
if (!t.trim()) return fallback
|
|
try {
|
|
return JSON.parse(t)
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
* @param {unknown} value
|
|
*/
|
|
async function bareAgentWriteJsonFile(ctx, path, value) {
|
|
await bareAgentWriteTextFile(ctx, path, JSON.stringify(value, null, 2) + '\n')
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
*/
|
|
async function bareAgentVfsIsDir(ctx, path) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs) return false
|
|
if (typeof vfs.lstat === 'function' || typeof vfs.stat === 'function') {
|
|
try {
|
|
const st =
|
|
typeof vfs.lstat === 'function' ? await vfs.lstat(path) : await vfs.stat(path)
|
|
if (st && typeof st.isDirectory === 'function') return Boolean(st.isDirectory())
|
|
if (st && typeof st === 'object' && 'isDirectory' in st) {
|
|
return Boolean(/** @type {{ isDirectory?: unknown }} */ (st).isDirectory)
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
if (typeof vfs.readdir === 'function') {
|
|
try {
|
|
await vfs.readdir(path)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
* @param {{ maxFiles?: number, maxDepth?: number }} [opts]
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
async function bareAgentVfsWalkFiles(ctx, root, opts) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readdir !== 'function') return []
|
|
const maxFiles = Math.min(Math.max(Number(opts && opts.maxFiles) || 400, 1), 4000)
|
|
const maxDepth = Math.min(Math.max(Number(opts && opts.maxDepth) || 8, 1), 16)
|
|
const skip = Object.create(null)
|
|
skip['.git'] = 1
|
|
skip['node_modules'] = 1
|
|
skip['.bare-os'] = 1
|
|
const rootN = String(root || '/').replace(/\/+$/, '') || '/'
|
|
const rules =
|
|
opts && Array.isArray(opts.ignore)
|
|
? opts.ignore
|
|
: typeof bareAgentLoadIgnoreRules === 'function'
|
|
? await bareAgentLoadIgnoreRules(ctx, rootN)
|
|
: []
|
|
/** @type {string[]} */
|
|
const out = []
|
|
/** @type {{ dir: string, depth: number }[]} */
|
|
const queue = [{ dir: rootN, depth: 0 }]
|
|
while (queue.length && out.length < maxFiles) {
|
|
const cur = queue.shift()
|
|
if (!cur) break
|
|
let names
|
|
try {
|
|
names = await vfs.readdir(cur.dir)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (!Array.isArray(names)) continue
|
|
for (let i = 0; i < names.length && out.length < maxFiles; i++) {
|
|
const name = String(names[i] || '')
|
|
if (!name || name === '.' || name === '..' || skip[name]) continue
|
|
const full = (cur.dir === '/' ? '' : cur.dir) + '/' + name
|
|
let rel = full
|
|
if (rootN !== '/' && full.indexOf(rootN + '/') === 0) rel = full.slice(rootN.length + 1)
|
|
else if (full.charAt(0) === '/') rel = full.slice(1)
|
|
const isDir = await bareAgentVfsIsDir(ctx, full)
|
|
if (rules.length && bareAgentIgnoreMatch(rel, isDir, rules)) continue
|
|
if (isDir) {
|
|
if (cur.depth + 1 < maxDepth) queue.push({ dir: full, depth: cur.depth + 1 })
|
|
} else {
|
|
out.push(full)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
* @param {string} pattern
|
|
* @param {{ maxFiles?: number, maxDepth?: number }} [opts]
|
|
*/
|
|
async function bareAgentGlobFiles(ctx, root, pattern, opts) {
|
|
const files = await bareAgentVfsWalkFiles(ctx, root, opts)
|
|
const rootN = String(root || '/').replace(/\/+$/, '') || '/'
|
|
/** @type {string[]} */
|
|
const hits = []
|
|
for (let i = 0; i < files.length; i++) {
|
|
const abs = files[i]
|
|
let rel = abs
|
|
if (rootN !== '/' && abs.indexOf(rootN + '/') === 0) rel = abs.slice(rootN.length + 1)
|
|
else if (abs.charAt(0) === '/') rel = abs.slice(1)
|
|
if (bareAgentGlobMatch(rel, pattern) || bareAgentGlobMatch(abs, pattern)) {
|
|
hits.push(abs)
|
|
}
|
|
}
|
|
return hits
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string[]} files
|
|
* @param {string} query
|
|
* @param {{ maxHits?: number, snippet?: number }} [opts]
|
|
*/
|
|
async function bareAgentMemorySearchFiles(ctx, files, query, opts) {
|
|
const tokens = bareAgentMemoryTokens(query)
|
|
const maxHits = Math.min(Math.max(Number(opts && opts.maxHits) || 8, 1), 24)
|
|
const snippet = Math.min(Math.max(Number(opts && opts.snippet) || 280, 80), 1200)
|
|
/** @type {{ path: string, score: number, snippet: string }[]} */
|
|
const scored = []
|
|
for (let i = 0; i < files.length; i++) {
|
|
const path = files[i]
|
|
const text = await bareAgentReadTextFile(ctx, path)
|
|
if (!text) continue
|
|
const score = tokens.length ? bareAgentMemoryScore(text, tokens) : 0.15
|
|
if (score <= 0) continue
|
|
let snip = text.trim().replace(/\s+/g, ' ').slice(0, snippet)
|
|
if (tokens.length) {
|
|
const low = text.toLowerCase()
|
|
let at = -1
|
|
for (let t = 0; t < tokens.length; t++) {
|
|
const j = low.indexOf(tokens[t])
|
|
if (j !== -1 && (at === -1 || j < at)) at = j
|
|
}
|
|
if (at >= 0) {
|
|
const start = Math.max(0, at - 40)
|
|
snip =
|
|
(start > 0 ? '…' : '') +
|
|
text.slice(start, start + snippet).replace(/\s+/g, ' ')
|
|
}
|
|
}
|
|
scored.push({ path, score: Math.round(score * 1000) / 1000, snippet: snip })
|
|
}
|
|
scored.sort(function (a, b) {
|
|
return b.score - a.score
|
|
})
|
|
return scored.slice(0, maxHits)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
*/
|
|
async function bareAgentLoadTodos(ctx, path) {
|
|
const raw = await bareAgentReadJsonFile(ctx, path, [])
|
|
if (!Array.isArray(raw)) return []
|
|
/** @type {{ id: string, content: string, status: string }[]} */
|
|
const out = []
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const row = raw[i] && typeof raw[i] === 'object' ? raw[i] : null
|
|
if (!row) continue
|
|
const id = String(row.id || '').trim()
|
|
if (!id) continue
|
|
const statusRaw = String(row.status || 'pending').trim().toLowerCase()
|
|
const status =
|
|
statusRaw === 'in_progress' || statusRaw === 'completed' || statusRaw === 'cancelled'
|
|
? statusRaw
|
|
: 'pending'
|
|
out.push({
|
|
id,
|
|
content: String(row.content == null ? id : row.content),
|
|
status
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} path
|
|
* @param {{ id: string, content: string, status: string }[]} todos
|
|
*/
|
|
async function bareAgentSaveTodos(ctx, path, todos) {
|
|
await bareAgentWriteJsonFile(ctx, path, Array.isArray(todos) ? todos : [])
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} args
|
|
*/
|
|
function bareAgentToolPathArg(args) {
|
|
if (!args || typeof args !== 'object') return ''
|
|
if (typeof args.path === 'string' && args.path.trim()) return args.path.trim()
|
|
if (typeof args.file_path === 'string' && args.file_path.trim()) {
|
|
return args.file_path.trim()
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
*/
|
|
function bareAgentLooksBinaryText(text) {
|
|
const s = String(text || '')
|
|
if (!s) return false
|
|
if (s.indexOf('\0') !== -1) return true
|
|
let bad = 0
|
|
const n = Math.min(s.length, 800)
|
|
for (let i = 0; i < n; i++) {
|
|
const c = s.charCodeAt(i)
|
|
if (c === 9 || c === 10 || c === 13) continue
|
|
if (c < 32) bad++
|
|
}
|
|
return bad > 8
|
|
}
|
|
|
|
/**
|
|
* Guest-native grep (Grok-style). VFS walk + JS regex — no host rg/node.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{
|
|
* pattern: string,
|
|
* root?: string,
|
|
* glob?: string,
|
|
* ignore_case?: boolean,
|
|
* before?: number,
|
|
* after?: number,
|
|
* context?: number,
|
|
* max_matches?: number,
|
|
* files_with_matches?: boolean
|
|
* }} opts
|
|
*/
|
|
async function bareAgentGrepFiles(ctx, opts) {
|
|
const o = opts && typeof opts === 'object' ? opts : {}
|
|
const pattern = String(o.pattern || '')
|
|
if (!pattern) return { ok: false, error: 'pattern_required' }
|
|
let re
|
|
try {
|
|
re = new RegExp(pattern, o.ignore_case ? 'i' : '')
|
|
} catch (e) {
|
|
const msg =
|
|
e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e)
|
|
return { ok: false, error: 'bad_regex', detail: msg }
|
|
}
|
|
const root = String(o.root || '/').replace(/\/+$/, '') || '/'
|
|
const before = Math.min(
|
|
8,
|
|
Math.max(0, Math.floor(Number(o.before != null ? o.before : o.context) || 0))
|
|
)
|
|
const after = Math.min(
|
|
8,
|
|
Math.max(0, Math.floor(Number(o.after != null ? o.after : o.context) || 0))
|
|
)
|
|
const maxMatches = Math.min(
|
|
200,
|
|
Math.max(1, Math.floor(Number(o.max_matches) || 50))
|
|
)
|
|
const mode = String(o.output_mode || '').toLowerCase()
|
|
const filesOnly = Boolean(o.files_with_matches) || mode === 'files_with_matches'
|
|
const countOnly = Boolean(o.count) || mode === 'count'
|
|
const glob = typeof o.glob === 'string' && o.glob.trim() ? o.glob.trim() : ''
|
|
const files = glob
|
|
? await bareAgentGlobFiles(ctx, root, glob, { maxFiles: 800, maxDepth: 12 })
|
|
: await bareAgentVfsWalkFiles(ctx, root, { maxFiles: 800, maxDepth: 12 })
|
|
/** @type {{ path: string, line?: number, text?: string }[]} */
|
|
const matches = []
|
|
/** @type {string[]} */
|
|
const filesHit = []
|
|
/** @type {{ path: string, count: number }[]} */
|
|
const counts = []
|
|
let truncated = false
|
|
for (let f = 0; f < files.length; f++) {
|
|
const path = files[f]
|
|
const text = await bareAgentReadTextFile(ctx, path)
|
|
if (!text || bareAgentLooksBinaryText(text)) continue
|
|
const lines = text.split('\n')
|
|
if (lines.length && lines[lines.length - 1] === '') lines.pop()
|
|
let fileHit = false
|
|
let fileCount = 0
|
|
for (let i = 0; i < lines.length; i++) {
|
|
re.lastIndex = 0
|
|
if (!re.test(lines[i])) continue
|
|
fileHit = true
|
|
fileCount++
|
|
if (filesOnly || countOnly) continue
|
|
const start = Math.max(0, i - before)
|
|
const end = Math.min(lines.length, i + 1 + after)
|
|
const slice = lines.slice(start, end)
|
|
const body =
|
|
before || after
|
|
? slice
|
|
.map(function (ln, j) {
|
|
const n = start + j + 1
|
|
const mark = n === i + 1 ? ':' : '-'
|
|
return String(n) + mark + ln
|
|
})
|
|
.join('\n')
|
|
: String(i + 1) + ':' + lines[i]
|
|
matches.push({ path, line: i + 1, text: body })
|
|
if (matches.length >= maxMatches) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
if (fileHit && filesOnly) {
|
|
filesHit.push(path)
|
|
if (filesHit.length >= maxMatches) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
if (fileHit && countOnly) {
|
|
counts.push({ path, count: fileCount })
|
|
if (counts.length >= maxMatches) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
if (truncated) break
|
|
}
|
|
if (filesOnly) {
|
|
return {
|
|
ok: true,
|
|
pattern,
|
|
root,
|
|
glob: glob || null,
|
|
output_mode: 'files_with_matches',
|
|
files_with_matches: filesHit,
|
|
count: filesHit.length,
|
|
truncated
|
|
}
|
|
}
|
|
if (countOnly) {
|
|
let total = 0
|
|
for (let i = 0; i < counts.length; i++) total += counts[i].count
|
|
return {
|
|
ok: true,
|
|
pattern,
|
|
root,
|
|
glob: glob || null,
|
|
output_mode: 'count',
|
|
files: counts,
|
|
count: total,
|
|
truncated
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
pattern,
|
|
root,
|
|
glob: glob || null,
|
|
output_mode: 'content',
|
|
matches,
|
|
count: matches.length,
|
|
truncated
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Codex / Grok apply_patch parser (Begin Patch … End Patch).
|
|
* @param {string} text
|
|
* @returns {{ ok: boolean, error?: string, ops?: object[] }}
|
|
*/
|
|
function bareAgentParseApplyPatch(text) {
|
|
const raw = String(text || '').replace(/\r\n/g, '\n')
|
|
if (!raw.trim()) return { ok: false, error: 'empty_patch' }
|
|
const all = raw.split('\n')
|
|
let start = 0
|
|
let end = all.length
|
|
for (let i = 0; i < all.length; i++) {
|
|
if (/^\s*\*\*\*\s*Begin Patch\s*$/i.test(all[i])) {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
for (let i = all.length - 1; i >= 0; i--) {
|
|
if (/^\s*\*\*\*\s*End Patch\s*$/i.test(all[i])) {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
const body = all.slice(start, end)
|
|
/** @type {object[]} */
|
|
const ops = []
|
|
/** @type {Record<string, unknown> | null} */
|
|
let cur = null
|
|
/** @type {{ context: string, old_lines: string[], new_lines: string[], is_end_of_file?: boolean } | null} */
|
|
let chunk = null
|
|
|
|
function flushChunk() {
|
|
if (cur && cur.type === 'update' && chunk) {
|
|
const chunks = Array.isArray(cur.chunks) ? cur.chunks : []
|
|
chunks.push(chunk)
|
|
cur.chunks = chunks
|
|
chunk = null
|
|
}
|
|
}
|
|
function flushOp() {
|
|
flushChunk()
|
|
if (cur) ops.push(cur)
|
|
cur = null
|
|
}
|
|
|
|
for (let i = 0; i < body.length; i++) {
|
|
const line = body[i]
|
|
if (/^\s*\*\*\*\s*Add File:\s*/i.test(line)) {
|
|
flushOp()
|
|
cur = {
|
|
type: 'add',
|
|
path: line.replace(/^\s*\*\*\*\s*Add File:\s*/i, '').trim(),
|
|
content: ''
|
|
}
|
|
continue
|
|
}
|
|
if (/^\s*\*\*\*\s*Delete File:\s*/i.test(line)) {
|
|
flushOp()
|
|
cur = {
|
|
type: 'delete',
|
|
path: line.replace(/^\s*\*\*\*\s*Delete File:\s*/i, '').trim()
|
|
}
|
|
continue
|
|
}
|
|
if (/^\s*\*\*\*\s*Update File:\s*/i.test(line)) {
|
|
flushOp()
|
|
cur = {
|
|
type: 'update',
|
|
path: line.replace(/^\s*\*\*\*\s*Update File:\s*/i, '').trim(),
|
|
move_to: '',
|
|
chunks: []
|
|
}
|
|
continue
|
|
}
|
|
if (/^\s*\*\*\*\s*Move to:\s*/i.test(line) && cur && cur.type === 'update') {
|
|
cur.move_to = line.replace(/^\s*\*\*\*\s*Move to:\s*/i, '').trim()
|
|
continue
|
|
}
|
|
if (/^\s*\*\*\*\s*End of File\s*$/i.test(line)) {
|
|
if (chunk) chunk.is_end_of_file = true
|
|
flushChunk()
|
|
continue
|
|
}
|
|
if (line.indexOf('@@') === 0 && cur && cur.type === 'update') {
|
|
flushChunk()
|
|
chunk = {
|
|
context: line.replace(/^@@\s?/, '').trim(),
|
|
old_lines: [],
|
|
new_lines: []
|
|
}
|
|
continue
|
|
}
|
|
if (cur && cur.type === 'add') {
|
|
const bodyLine = line.charAt(0) === '+' ? line.slice(1) : line
|
|
cur.content = cur.content ? String(cur.content) + '\n' + bodyLine : bodyLine
|
|
continue
|
|
}
|
|
if (cur && cur.type === 'update') {
|
|
if (!chunk) {
|
|
chunk = { context: '', old_lines: [], new_lines: [] }
|
|
}
|
|
const tag = line.charAt(0)
|
|
const rest = line.length ? line.slice(1) : ''
|
|
if (tag === '-') chunk.old_lines.push(rest)
|
|
else if (tag === '+') chunk.new_lines.push(rest)
|
|
else {
|
|
const ctxLine = tag === ' ' ? rest : line
|
|
chunk.old_lines.push(ctxLine)
|
|
chunk.new_lines.push(ctxLine)
|
|
}
|
|
}
|
|
}
|
|
flushOp()
|
|
if (!ops.length) return { ok: false, error: 'no_patch_ops' }
|
|
return { ok: true, ops }
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {{ context: string, old_lines: string[], new_lines: string[], is_end_of_file?: boolean }} chunk
|
|
*/
|
|
function bareAgentApplyPatchChunk(text, chunk) {
|
|
const src = String(text == null ? '' : text)
|
|
const oldBlock = (chunk.old_lines || []).join('\n')
|
|
const newBlock = (chunk.new_lines || []).join('\n')
|
|
if (!oldBlock && !newBlock) {
|
|
return { ok: false, error: 'empty_chunk' }
|
|
}
|
|
if (!oldBlock) {
|
|
const next = src
|
|
? src.replace(/\s*$/, '') + (src.endsWith('\n') ? '' : '\n') + newBlock + '\n'
|
|
: newBlock + (newBlock.endsWith('\n') ? '' : '\n')
|
|
return { ok: true, next }
|
|
}
|
|
let from = 0
|
|
if (chunk.context) {
|
|
const at = src.indexOf(chunk.context)
|
|
if (at === -1) {
|
|
return { ok: false, error: 'chunk_context_not_found', context: chunk.context }
|
|
}
|
|
from = at
|
|
}
|
|
const hay = src.slice(from)
|
|
const count = bareAgentCountOccurrences(hay, oldBlock)
|
|
if (count === 0) return { ok: false, error: 'chunk_old_not_found' }
|
|
if (count > 1 && !chunk.context) {
|
|
return {
|
|
ok: false,
|
|
error: 'chunk_old_not_unique',
|
|
count,
|
|
hint: 'add @@ context or more surrounding lines'
|
|
}
|
|
}
|
|
const next = src.slice(0, from) + hay.replace(oldBlock, newBlock)
|
|
return { ok: true, next }
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {object[]} ops
|
|
* @param {{ home?: string, denyPrefixes?: unknown }} [opts]
|
|
*/
|
|
async function bareAgentApplyPatchOps(ctx, ops, opts) {
|
|
const home = String((opts && opts.home) || '')
|
|
const deny = opts && opts.denyPrefixes
|
|
/** @type {object[]} */
|
|
const results = []
|
|
const rows = Array.isArray(ops) ? ops : []
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const op = rows[i] && typeof rows[i] === 'object' ? rows[i] : {}
|
|
let path = String(op.path || '').trim()
|
|
if (path && path.charAt(0) !== '/' && home) {
|
|
path = home.replace(/\/+$/, '') + '/' + path.replace(/^\.\//, '')
|
|
}
|
|
if (!path) {
|
|
results.push({ ok: false, error: 'path_required' })
|
|
continue
|
|
}
|
|
if (
|
|
typeof bareAgentPathAllowedMutate === 'function' &&
|
|
!bareAgentPathAllowedMutate(path, deny)
|
|
) {
|
|
results.push({ ok: false, path, error: 'path_not_allowed' })
|
|
continue
|
|
}
|
|
if (op.type === 'add') {
|
|
const content = String(op.content == null ? '' : op.content)
|
|
await bareAgentWriteTextFile(
|
|
ctx,
|
|
path,
|
|
content.endsWith('\n') ? content : content + '\n'
|
|
)
|
|
results.push({ ok: true, op: 'add', path })
|
|
continue
|
|
}
|
|
if (op.type === 'delete') {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.unlink !== 'function') {
|
|
results.push({ ok: false, path, error: 'vfs.unlink unavailable' })
|
|
continue
|
|
}
|
|
try {
|
|
await vfs.unlink(path)
|
|
results.push({ ok: true, op: 'delete', path })
|
|
} catch (e) {
|
|
const msg =
|
|
e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e)
|
|
results.push({ ok: false, path, error: msg })
|
|
}
|
|
continue
|
|
}
|
|
if (op.type === 'update') {
|
|
let text = await bareAgentReadTextFile(ctx, path)
|
|
if (!text && text !== '') {
|
|
results.push({ ok: false, path, error: 'missing_file' })
|
|
continue
|
|
}
|
|
const chunks = Array.isArray(op.chunks) ? op.chunks : []
|
|
let failed = null
|
|
for (let c = 0; c < chunks.length; c++) {
|
|
const applied = bareAgentApplyPatchChunk(text, chunks[c])
|
|
if (!applied.ok) {
|
|
failed = applied
|
|
break
|
|
}
|
|
text = applied.next
|
|
}
|
|
if (failed) {
|
|
results.push({ ok: false, path, error: failed.error, hint: failed.hint || null })
|
|
continue
|
|
}
|
|
const dest = String(op.move_to || '').trim() || path
|
|
const destAbs =
|
|
dest.charAt(0) === '/'
|
|
? dest
|
|
: home
|
|
? home.replace(/\/+$/, '') + '/' + dest.replace(/^\.\//, '')
|
|
: dest
|
|
if (
|
|
destAbs !== path &&
|
|
typeof bareAgentPathAllowedMutate === 'function' &&
|
|
!bareAgentPathAllowedMutate(destAbs, deny)
|
|
) {
|
|
results.push({ ok: false, path, error: 'move_path_not_allowed', dest: destAbs })
|
|
continue
|
|
}
|
|
await bareAgentWriteTextFile(ctx, destAbs, text)
|
|
if (destAbs !== path && ctx.vfs && typeof ctx.vfs.unlink === 'function') {
|
|
try {
|
|
await ctx.vfs.unlink(path)
|
|
} catch {
|
|
/* keep original if unlink fails */
|
|
}
|
|
}
|
|
results.push({
|
|
ok: true,
|
|
op: destAbs !== path ? 'move' : 'update',
|
|
path,
|
|
dest: destAbs !== path ? destAbs : undefined,
|
|
chunks: chunks.length
|
|
})
|
|
continue
|
|
}
|
|
results.push({ ok: false, path, error: 'unknown_op' })
|
|
}
|
|
const failed = results.filter(function (r) {
|
|
return !r.ok
|
|
})
|
|
return {
|
|
ok: failed.length === 0,
|
|
applied: results.length - failed.length,
|
|
failed: failed.length,
|
|
results
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse DuckDuckGo instant-answer JSON into a compact result list.
|
|
* @param {unknown} payload
|
|
* @param {number} [max]
|
|
*/
|
|
function bareAgentParseSearchResults(payload, max) {
|
|
const cap = Math.min(Math.max(Number(max) || 8, 1), 16)
|
|
/** @type {{ title: string, url: string, snippet: string }[]} */
|
|
const out = []
|
|
const seen = Object.create(null)
|
|
function add(title, url, snippet) {
|
|
const u = String(url || '').trim()
|
|
if (!u || seen[u] || out.length >= cap) return
|
|
if (!/^https?:\/\//i.test(u)) return
|
|
seen[u] = 1
|
|
out.push({
|
|
title: String(title || u).slice(0, 160),
|
|
url: u,
|
|
snippet: String(snippet || '').replace(/\s+/g, ' ').trim().slice(0, 280)
|
|
})
|
|
}
|
|
const obj = payload && typeof payload === 'object' ? payload : {}
|
|
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
if (rec.AbstractURL || rec.Abstract) {
|
|
add(
|
|
String(rec.Heading || rec.AbstractSource || 'Abstract'),
|
|
String(rec.AbstractURL || ''),
|
|
String(rec.AbstractText || rec.Abstract || '')
|
|
)
|
|
}
|
|
const results = Array.isArray(rec.Results) ? rec.Results : []
|
|
for (let i = 0; i < results.length; i++) {
|
|
const row = results[i] && typeof results[i] === 'object' ? results[i] : {}
|
|
add(row.Text || row.Name, row.FirstURL, row.Text)
|
|
}
|
|
const related = Array.isArray(rec.RelatedTopics) ? rec.RelatedTopics : []
|
|
function walk(list) {
|
|
for (let i = 0; i < list.length && out.length < cap; i++) {
|
|
const row = list[i] && typeof list[i] === 'object' ? list[i] : {}
|
|
if (Array.isArray(row.Topics)) walk(row.Topics)
|
|
else add(row.Text, row.FirstURL, row.Text)
|
|
}
|
|
}
|
|
walk(related)
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Parse Grok-style intervals (5m / 2h / 1d) or a five-field cron line.
|
|
* @param {string} raw
|
|
* @returns {{ kind: 'everyMs', everyMs: number } | { kind: 'calendar', onCalendar: string } | { error: string }}
|
|
*/
|
|
function bareAgentParseScheduleInterval(raw) {
|
|
const s = String(raw || '').trim()
|
|
if (!s) return { error: 'interval_required' }
|
|
const compact = s.replace(/\s+/g, '')
|
|
const m = /^(\d+)(ms|s|m|h|d)$/i.exec(compact)
|
|
if (m) {
|
|
const n = Number(m[1])
|
|
const unit = m[2].toLowerCase()
|
|
let ms = 0
|
|
if (unit === 'ms') ms = n
|
|
else if (unit === 's') ms = n * 1000
|
|
else if (unit === 'm') ms = n * 60 * 1000
|
|
else if (unit === 'h') ms = n * 60 * 60 * 1000
|
|
else ms = n * 24 * 60 * 60 * 1000
|
|
if (ms < 1000) return { error: 'interval_too_short', min_ms: 1000 }
|
|
if (ms > 86400000) return { error: 'interval_too_long', max_ms: 86400000 }
|
|
return { kind: 'everyMs', everyMs: ms }
|
|
}
|
|
const fields = s.split(/\s+/)
|
|
if (fields.length === 5) return { kind: 'calendar', onCalendar: s }
|
|
return { error: 'bad_interval', hint: 'use 5m, 2h, 1d, or five cron fields' }
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
*/
|
|
function bareAgentParseIgnoreRules(text) {
|
|
const lines = String(text || '').split(/\r?\n/)
|
|
/** @type {{ pattern: string, negate: boolean, dirOnly: boolean }[]} */
|
|
const rules = []
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = String(lines[i] || '').trim()
|
|
if (!line || line.charAt(0) === '#') continue
|
|
let negate = false
|
|
if (line.charAt(0) === '!') {
|
|
negate = true
|
|
line = line.slice(1)
|
|
}
|
|
let dirOnly = false
|
|
if (line.charAt(line.length - 1) === '/') {
|
|
dirOnly = true
|
|
line = line.slice(0, -1)
|
|
}
|
|
if (line.charAt(0) === '/') line = line.slice(1)
|
|
if (!line) continue
|
|
rules.push({ pattern: line, negate, dirOnly })
|
|
}
|
|
return rules
|
|
}
|
|
|
|
/**
|
|
* Last matching gitignore-style rule wins.
|
|
* @param {string} rel
|
|
* @param {boolean} isDir
|
|
* @param {{ pattern: string, negate: boolean, dirOnly: boolean }[]} rules
|
|
*/
|
|
function bareAgentIgnoreMatch(rel, isDir, rules) {
|
|
const n = String(rel || '').replace(/^\/+/, '')
|
|
if (!n || !Array.isArray(rules) || !rules.length) return false
|
|
let ignored = false
|
|
for (let i = 0; i < rules.length; i++) {
|
|
const rule = rules[i]
|
|
if (rule.dirOnly && !isDir) continue
|
|
let hit = false
|
|
if (rule.pattern.indexOf('/') === -1) {
|
|
const parts = n.split('/')
|
|
for (let p = 0; p < parts.length; p++) {
|
|
if (bareAgentGlobMatch(parts[p], rule.pattern)) {
|
|
hit = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
hit = bareAgentGlobMatch(n, rule.pattern) || bareAgentGlobMatch(n, '**/' + rule.pattern)
|
|
}
|
|
if (!hit) continue
|
|
ignored = !rule.negate
|
|
}
|
|
return ignored
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
*/
|
|
async function bareAgentLoadIgnoreRules(ctx, root) {
|
|
const names = ['.gitignore', '.agentignore', '.grokignore']
|
|
/** @type {string[]} */
|
|
const chunks = []
|
|
for (let i = 0; i < names.length; i++) {
|
|
const t = await bareAgentReadTextFile(ctx, String(root || '').replace(/\/+$/, '') + '/' + names[i])
|
|
if (t && t.trim()) chunks.push(t)
|
|
}
|
|
return bareAgentParseIgnoreRules(chunks.join('\n'))
|
|
}
|
|
|
|
/**
|
|
* Simple Grok-style fuzzy score (basename + path subsequence). Higher is better.
|
|
* @param {string} query
|
|
* @param {string} path
|
|
*/
|
|
function bareAgentFuzzyScore(query, path) {
|
|
const q = String(query || '').toLowerCase().trim()
|
|
const p = String(path || '')
|
|
if (!q || !p) return 0
|
|
const low = p.toLowerCase()
|
|
const base = (p.split('/').pop() || p).toLowerCase()
|
|
if (base === q) return 1000
|
|
if (base.indexOf(q) === 0) return 800 - Math.min(base.length, 80)
|
|
if (base.indexOf(q) !== -1) return 600 - base.indexOf(q)
|
|
if (low.indexOf(q) !== -1) return 400
|
|
let qi = 0
|
|
let score = 0
|
|
let streak = 0
|
|
for (let i = 0; i < low.length && qi < q.length; i++) {
|
|
if (low.charAt(i) === q.charAt(qi)) {
|
|
qi++
|
|
streak++
|
|
score += 8 + streak * 4
|
|
} else streak = 0
|
|
}
|
|
if (qi < q.length) return 0
|
|
if (base.length && q.length / base.length > 0.5) score += 40
|
|
return score
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
* @param {string} query
|
|
* @param {{ max?: number, maxFiles?: number }} [opts]
|
|
*/
|
|
async function bareAgentFuzzyFind(ctx, root, query, opts) {
|
|
const files = await bareAgentVfsWalkFiles(ctx, root, {
|
|
maxFiles: Math.min(Math.max(Number(opts && opts.maxFiles) || 600, 40), 2000),
|
|
maxDepth: 12
|
|
})
|
|
const cap = Math.min(Math.max(Number(opts && opts.max) || 20, 1), 80)
|
|
/** @type {{ path: string, score: number }[]} */
|
|
const scored = []
|
|
for (let i = 0; i < files.length; i++) {
|
|
const score = bareAgentFuzzyScore(query, files[i])
|
|
if (score <= 0) continue
|
|
scored.push({ path: files[i], score })
|
|
}
|
|
scored.sort(function (a, b) {
|
|
return b.score - a.score
|
|
})
|
|
return scored.slice(0, cap)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} hook
|
|
* @param {string} event
|
|
* @param {string} toolName
|
|
* @param {Record<string, unknown>} args
|
|
* @param {string} [result]
|
|
*/
|
|
function bareAgentHookMatch(hook, event, toolName, args, result) {
|
|
if (!hook || typeof hook !== 'object') return null
|
|
const ev = String(hook.event || hook.type || 'PreToolUse')
|
|
const want = String(event || '')
|
|
if (ev !== want && ev.toLowerCase() !== want.toLowerCase()) return null
|
|
const tools = Array.isArray(hook.tools) ? hook.tools.map(String) : []
|
|
if (tools.length && toolName && tools.indexOf(toolName) === -1) return null
|
|
const reSrc = String(hook.deny_regex || hook.match_regex || hook.denyRegex || '').trim()
|
|
if (reSrc) {
|
|
let re
|
|
try {
|
|
re = new RegExp(reSrc, 'i')
|
|
} catch {
|
|
return null
|
|
}
|
|
const blob =
|
|
toolName +
|
|
' ' +
|
|
String((args && (args.command || args.path || args.file_path || args.pattern)) || '') +
|
|
' ' +
|
|
String(result || '').slice(0, 400)
|
|
if (!re.test(blob)) return null
|
|
}
|
|
return hook
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} hooksDir
|
|
* @param {string} event
|
|
* @param {string} [toolName]
|
|
* @param {Record<string, unknown>} [args]
|
|
* @param {string} [result]
|
|
*/
|
|
async function bareAgentRunHooks(ctx, hooksDir, event, toolName, args, result) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readdir !== 'function' || typeof vfs.readFile !== 'function') {
|
|
return { deny: '', inject: '' }
|
|
}
|
|
let names = []
|
|
try {
|
|
names = await vfs.readdir(hooksDir)
|
|
} catch {
|
|
return { deny: '', inject: '' }
|
|
}
|
|
if (!Array.isArray(names)) return { deny: '', inject: '' }
|
|
names = names.filter(function (n) {
|
|
return /\.json$/i.test(String(n || ''))
|
|
})
|
|
names.sort()
|
|
/** @type {string[]} */
|
|
const injects = []
|
|
for (let i = 0; i < names.length; i++) {
|
|
try {
|
|
const t = await bareAgentReadTextFile(ctx, hooksDir + '/' + names[i])
|
|
if (!t.trim()) continue
|
|
const hook = JSON.parse(t)
|
|
const matched = bareAgentHookMatch(hook, event, toolName || '', args || {}, result || '')
|
|
if (!matched) continue
|
|
if (event === 'PreToolUse' || event === 'pre') {
|
|
const reason = String(matched.reason || matched.message || '').trim()
|
|
if (reason && (matched.deny === true || matched.deny_regex || matched.denyRegex)) {
|
|
return { deny: reason.slice(0, 240), inject: '' }
|
|
}
|
|
if (typeof bareAgentHookDenies === 'function') {
|
|
const d = bareAgentHookDenies(matched, toolName || '', args || {})
|
|
if (d) return { deny: d, inject: '' }
|
|
}
|
|
}
|
|
const inj = String(matched.inject || matched.append || '').trim()
|
|
if (inj) injects.push(inj.slice(0, 800))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return { deny: '', inject: injects.join('\n') }
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} editsPath
|
|
* @param {{ path: string, prev: string, tool?: string }} rec
|
|
*/
|
|
async function bareAgentPushEdit(ctx, editsPath, rec) {
|
|
const prev = await bareAgentReadJsonFile(ctx, editsPath, [])
|
|
const list = Array.isArray(prev) ? prev : []
|
|
list.push({
|
|
path: String(rec.path || ''),
|
|
prev: String(rec.prev == null ? '' : rec.prev),
|
|
tool: String(rec.tool || ''),
|
|
ts: new Date().toISOString()
|
|
})
|
|
while (list.length > 20) list.shift()
|
|
await bareAgentWriteJsonFile(ctx, editsPath, list)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} editsPath
|
|
*/
|
|
async function bareAgentPopEdit(ctx, editsPath) {
|
|
const prev = await bareAgentReadJsonFile(ctx, editsPath, [])
|
|
const list = Array.isArray(prev) ? prev : []
|
|
const last = list.pop()
|
|
await bareAgentWriteJsonFile(ctx, editsPath, list)
|
|
return last && typeof last === 'object' ? last : null
|
|
}
|
|
|
|
/**
|
|
* @param {unknown[]} messages
|
|
* @param {string} query
|
|
* @param {number} [max]
|
|
*/
|
|
function bareAgentHistorySearch(messages, query, max) {
|
|
const tokens = bareAgentMemoryTokens(query)
|
|
const cap = Math.min(Math.max(Number(max) || 8, 1), 24)
|
|
if (!Array.isArray(messages) || !tokens.length) return []
|
|
/** @type {{ role: string, score: number, snippet: string }[]} */
|
|
const hits = []
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const m = messages[i] && typeof messages[i] === 'object' ? messages[i] : null
|
|
if (!m) continue
|
|
const role = String(m.role || '')
|
|
if (role !== 'user' && role !== 'assistant') continue
|
|
const text = String(m.content || '')
|
|
const score = bareAgentMemoryScore(text, tokens)
|
|
if (score <= 0) continue
|
|
hits.push({
|
|
role,
|
|
score: Math.round(score * 1000) / 1000,
|
|
snippet: text.replace(/\s+/g, ' ').trim().slice(0, 280)
|
|
})
|
|
}
|
|
hits.sort(function (a, b) {
|
|
return b.score - a.score
|
|
})
|
|
return hits.slice(0, cap)
|
|
}
|
|
|
|
function bareAgentScheduleId(id) {
|
|
let s = String(id || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
if (!s) s = 'task'
|
|
if (s.indexOf('agent-') !== 0) s = 'agent-' + s
|
|
return s.slice(0, 40)
|
|
}
|
|
|
|
/**
|
|
* User-turn rewind points (Grok /rewind). Index 0 is the first user message.
|
|
* @param {unknown[]} messages
|
|
*/
|
|
function bareAgentRewindPoints(messages) {
|
|
/** @type {{ userIndex: number, messageIndex: number, preview: string }[]} */
|
|
const points = []
|
|
if (!Array.isArray(messages)) return points
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const m = messages[i] && typeof messages[i] === 'object' ? messages[i] : null
|
|
if (!m || String(m.role || '') !== 'user') continue
|
|
points.push({
|
|
userIndex: points.length,
|
|
messageIndex: i,
|
|
preview: String(m.content || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 140)
|
|
})
|
|
}
|
|
return points
|
|
}
|
|
|
|
/**
|
|
* Drop the last N user turns (and everything after that user message).
|
|
* userIndex rewinds to that user turn (keeps messages before it).
|
|
* keep_user keeps the target user message so the prompt can be retried.
|
|
* @param {unknown[]} messages
|
|
* @param {{ steps?: number, userIndex?: number, keep_user?: boolean }} [opts]
|
|
*/
|
|
function bareAgentRewindHistory(messages, opts) {
|
|
const list = Array.isArray(messages) ? messages.slice() : []
|
|
const points = bareAgentRewindPoints(list)
|
|
if (!points.length) return { ok: false, error: 'nothing_to_rewind', messages: list, dropped: 0 }
|
|
const o = opts && typeof opts === 'object' ? opts : {}
|
|
let target
|
|
if (o.userIndex != null && Number.isFinite(Number(o.userIndex))) {
|
|
target = points[Math.floor(Number(o.userIndex))]
|
|
if (!target) return { ok: false, error: 'bad_user_index', messages: list, dropped: 0 }
|
|
} else {
|
|
const steps = Math.min(points.length, Math.max(1, Math.floor(Number(o.steps) || 1)))
|
|
target = points[points.length - steps]
|
|
}
|
|
const keepUser = Boolean(o.keep_user)
|
|
const cut = keepUser ? target.messageIndex : target.messageIndex - 1
|
|
const next = cut < 0 ? [] : list.slice(0, cut + 1)
|
|
return {
|
|
ok: true,
|
|
messages: next,
|
|
dropped: list.length - next.length,
|
|
target: target,
|
|
keep_user: keepUser
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Markdown transcript (Grok /export).
|
|
* @param {unknown[]} messages
|
|
*/
|
|
function bareAgentExportTranscript(messages) {
|
|
const lines = [
|
|
'# Agent session export',
|
|
'',
|
|
'Exported: ' + new Date().toISOString(),
|
|
''
|
|
]
|
|
const list = Array.isArray(messages) ? messages : []
|
|
for (let i = 0; i < list.length; i++) {
|
|
const m = list[i] && typeof list[i] === 'object' ? list[i] : {}
|
|
const role = String(m.role || 'unknown')
|
|
let content = String(m.content || '')
|
|
if (role === 'tool' && content.length > 1200) content = content.slice(0, 1200) + '\n… truncated'
|
|
const calls = Array.isArray(m.tool_calls) ? m.tool_calls : []
|
|
const names = []
|
|
for (let c = 0; c < calls.length; c++) {
|
|
const fn = calls[c] && calls[c].function ? calls[c].function : {}
|
|
if (fn && fn.name) names.push(String(fn.name))
|
|
}
|
|
lines.push('## ' + String(i + 1) + '. ' + role)
|
|
lines.push('')
|
|
if (names.length) lines.push('tools: ' + names.join(', '))
|
|
lines.push(content || '(empty)')
|
|
lines.push('')
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Line-based unified diff (Grok-style file compare, no git required).
|
|
* @param {string} oldText
|
|
* @param {string} newText
|
|
* @param {{ from?: string, to?: string, context?: number }} [opts]
|
|
*/
|
|
function bareAgentUnifiedDiff(oldText, newText, opts) {
|
|
const pathA = String((opts && opts.from) || 'a')
|
|
const pathB = String((opts && opts.to) || 'b')
|
|
const ctxN = Math.min(8, Math.max(0, Math.floor(Number(opts && opts.context) || 3)))
|
|
const a = String(oldText || '')
|
|
.replace(/\r\n/g, '\n')
|
|
.split('\n')
|
|
const b = String(newText || '')
|
|
.replace(/\r\n/g, '\n')
|
|
.split('\n')
|
|
if (a.length && a[a.length - 1] === '') a.pop()
|
|
if (b.length && b[b.length - 1] === '') b.pop()
|
|
if (a.join('\n') === b.join('\n')) {
|
|
return { ok: true, identical: true, text: '', added: 0, removed: 0 }
|
|
}
|
|
const maxLines = 800
|
|
if (a.length > maxLines || b.length > maxLines) {
|
|
return {
|
|
ok: true,
|
|
truncated: true,
|
|
identical: false,
|
|
text:
|
|
'--- ' +
|
|
pathA +
|
|
'\n+++ ' +
|
|
pathB +
|
|
'\n@@ files too large for inline LCS diff (' +
|
|
a.length +
|
|
'/' +
|
|
b.length +
|
|
' lines) @@\n',
|
|
added: 0,
|
|
removed: 0
|
|
}
|
|
}
|
|
const n = a.length
|
|
const m = b.length
|
|
/** @type {number[][]} */
|
|
const dp = new Array(n + 1)
|
|
for (let i = 0; i <= n; i++) {
|
|
dp[i] = new Array(m + 1)
|
|
for (let j = 0; j <= m; j++) dp[i][j] = 0
|
|
}
|
|
for (let i = 1; i <= n; i++) {
|
|
for (let j = 1; j <= m; j++) {
|
|
dp[i][j] =
|
|
a[i - 1] === b[j - 1]
|
|
? dp[i - 1][j - 1] + 1
|
|
: dp[i - 1][j] >= dp[i][j - 1]
|
|
? dp[i - 1][j]
|
|
: dp[i][j - 1]
|
|
}
|
|
}
|
|
/** @type {{ op: string, line: string }[]} */
|
|
const ops = []
|
|
let i = n
|
|
let j = m
|
|
while (i > 0 || j > 0) {
|
|
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
ops.push({ op: ' ', line: a[i - 1] })
|
|
i--
|
|
j--
|
|
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
ops.push({ op: '+', line: b[j - 1] })
|
|
j--
|
|
} else {
|
|
ops.push({ op: '-', line: a[i - 1] })
|
|
i--
|
|
}
|
|
}
|
|
ops.reverse()
|
|
let added = 0
|
|
let removed = 0
|
|
for (let k = 0; k < ops.length; k++) {
|
|
if (ops[k].op === '+') added++
|
|
else if (ops[k].op === '-') removed++
|
|
}
|
|
const lines = ['--- ' + pathA, '+++ ' + pathB]
|
|
let idx = 0
|
|
while (idx < ops.length) {
|
|
while (idx < ops.length && ops[idx].op === ' ') idx++
|
|
if (idx >= ops.length) break
|
|
let start = Math.max(0, idx - ctxN)
|
|
let end = idx
|
|
while (end < ops.length) {
|
|
if (ops[end].op !== ' ') {
|
|
end++
|
|
continue
|
|
}
|
|
let run = 0
|
|
let p = end
|
|
while (p < ops.length && ops[p].op === ' ') {
|
|
run++
|
|
p++
|
|
}
|
|
if (run > ctxN * 2) {
|
|
end += ctxN
|
|
break
|
|
}
|
|
end = p
|
|
}
|
|
end = Math.min(ops.length, end)
|
|
let oldLine = 1
|
|
let newLine = 1
|
|
for (let k = 0; k < start; k++) {
|
|
if (ops[k].op !== '+') oldLine++
|
|
if (ops[k].op !== '-') newLine++
|
|
}
|
|
let oldCount = 0
|
|
let newCount = 0
|
|
for (let k = start; k < end; k++) {
|
|
if (ops[k].op !== '+') oldCount++
|
|
if (ops[k].op !== '-') newCount++
|
|
}
|
|
lines.push(
|
|
'@@ -' +
|
|
String(oldLine) +
|
|
',' +
|
|
String(oldCount) +
|
|
' +' +
|
|
String(newLine) +
|
|
',' +
|
|
String(newCount) +
|
|
' @@'
|
|
)
|
|
for (let k = start; k < end; k++) {
|
|
lines.push(ops[k].op + ops[k].line)
|
|
}
|
|
idx = end
|
|
}
|
|
return {
|
|
ok: true,
|
|
identical: false,
|
|
truncated: false,
|
|
text: lines.join('\n') + '\n',
|
|
added,
|
|
removed
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Definition-oriented regex for a symbol name (JS / Python / Rust / C-like).
|
|
* @param {string} name
|
|
*/
|
|
function bareAgentSymbolRegex(name) {
|
|
const esc = String(name || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
if (!esc) return ''
|
|
return (
|
|
'(?:(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s+' +
|
|
esc +
|
|
'\\b|(?:export\\s+)?(?:default\\s+)?class\\s+' +
|
|
esc +
|
|
'\\b|(?:export\\s+)?(?:const|let|var)\\s+' +
|
|
esc +
|
|
'\\b|def\\s+' +
|
|
esc +
|
|
'\\s*\\(|fn\\s+' +
|
|
esc +
|
|
'\\b|' +
|
|
esc +
|
|
'\\s*=\\s*(?:async\\s+)?(?:function|\\(|class))'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
* @param {string} name
|
|
* @param {{ glob?: string, max?: number }} [opts]
|
|
*/
|
|
async function bareAgentFindSymbol(ctx, root, name, opts) {
|
|
const pattern = bareAgentSymbolRegex(name)
|
|
if (!pattern) return { ok: false, error: 'name_required' }
|
|
const out = await bareAgentGrepFiles(ctx, {
|
|
pattern,
|
|
root,
|
|
glob: opts && opts.glob,
|
|
max_matches: opts && opts.max ? opts.max : 40
|
|
})
|
|
if (!out || out.ok === false) return out
|
|
return {
|
|
ok: true,
|
|
name,
|
|
root,
|
|
matches: out.matches || [],
|
|
count: out.count || 0,
|
|
truncated: Boolean(out.truncated)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grok list_dir-style BFS tree (bounded).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} root
|
|
* @param {{ max?: number, maxDepth?: number }} [opts]
|
|
*/
|
|
async function bareAgentRenderTree(ctx, root, opts) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readdir !== 'function') {
|
|
return { ok: false, error: 'readdir unavailable' }
|
|
}
|
|
const base = String(root || '/').replace(/\/+$/, '') || '/'
|
|
const maxItems = Math.min(Math.max(Number(opts && opts.max) || 200, 20), 800)
|
|
const maxDepth = Math.min(Math.max(Number(opts && opts.maxDepth) || 6, 1), 12)
|
|
const skip = Object.create(null)
|
|
skip['.git'] = 1
|
|
skip['node_modules'] = 1
|
|
skip['.bare-os'] = 1
|
|
/** @type {string[]} */
|
|
const lines = [base + '/']
|
|
/** @type {{ dir: string, prefix: string, depth: number }[]} */
|
|
const queue = [{ dir: base, prefix: '', depth: 0 }]
|
|
let count = 1
|
|
let truncated = false
|
|
while (queue.length && count < maxItems) {
|
|
const cur = queue.shift()
|
|
if (!cur) break
|
|
let names = []
|
|
try {
|
|
names = await vfs.readdir(cur.dir)
|
|
} catch {
|
|
continue
|
|
}
|
|
if (!Array.isArray(names)) continue
|
|
names = names
|
|
.map(function (n) {
|
|
return String(n || '')
|
|
})
|
|
.filter(function (n) {
|
|
return n && n !== '.' && n !== '..' && !skip[n]
|
|
})
|
|
.sort()
|
|
for (let i = 0; i < names.length && count < maxItems; i++) {
|
|
const name = names[i]
|
|
const full = (cur.dir === '/' ? '' : cur.dir) + '/' + name
|
|
const isDir = await bareAgentVfsIsDir(ctx, full)
|
|
const last = i === names.length - 1
|
|
const branch = last ? '`-- ' : '|-- '
|
|
lines.push(cur.prefix + branch + name + (isDir ? '/' : ''))
|
|
count++
|
|
if (isDir && cur.depth + 1 < maxDepth) {
|
|
queue.push({
|
|
dir: full,
|
|
prefix: cur.prefix + (last ? ' ' : '| '),
|
|
depth: cur.depth + 1
|
|
})
|
|
}
|
|
}
|
|
if (count >= maxItems) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
return { ok: true, path: base, tree: lines.join('\n'), count, truncated }
|
|
}
|
|
|
|
/**
|
|
* VFS copy (file or directory). Dest parents are created.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} from
|
|
* @param {string} to
|
|
*/
|
|
async function bareAgentCopyPath(ctx, from, to) {
|
|
const src = String(from || '').replace(/\/+$/, '')
|
|
let dest = String(to || '')
|
|
if (!src || !dest) return { ok: false, error: 'from_and_to_required' }
|
|
const srcIsDir = await bareAgentVfsIsDir(ctx, src)
|
|
const destIsDir = await bareAgentVfsIsDir(ctx, dest.replace(/\/+$/, ''))
|
|
if (destIsDir) {
|
|
const base = src.split('/').pop() || 'copy'
|
|
dest = dest.replace(/\/+$/, '') + '/' + base
|
|
}
|
|
if (!srcIsDir) {
|
|
const text = await bareAgentReadTextFile(ctx, src)
|
|
await bareAgentWriteTextFile(ctx, dest, text)
|
|
return { ok: true, from: src, to: dest, kind: 'file' }
|
|
}
|
|
const files = await bareAgentVfsWalkFiles(ctx, src, { maxFiles: 400, maxDepth: 12 })
|
|
for (let i = 0; i < files.length; i++) {
|
|
const rel = files[i].slice(src.length)
|
|
const text = await bareAgentReadTextFile(ctx, files[i])
|
|
await bareAgentWriteTextFile(ctx, dest + rel, text)
|
|
}
|
|
return { ok: true, from: src, to: dest, kind: 'directory', files: files.length }
|
|
}
|
|
|
|
/**
|
|
* SKILL.md body with YAML frontmatter (Grok skill format).
|
|
* @param {{ name: string, description: string, body?: string }} spec
|
|
*/
|
|
function bareAgentSkillMarkdown(spec) {
|
|
const name = String((spec && spec.name) || '').trim() || 'skill'
|
|
const description = String((spec && spec.description) || '').trim() || name
|
|
const body = String((spec && spec.body) || '').trim() || '# ' + name + '\n'
|
|
return (
|
|
'---\nname: ' +
|
|
name.replace(/\n/g, ' ') +
|
|
'\ndescription: ' +
|
|
description.replace(/\n/g, ' ') +
|
|
'\n---\n\n' +
|
|
body +
|
|
'\n'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Walk-up Grok/Claude/Cursor skill roots (project-local).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} startDir
|
|
*/
|
|
async function bareAgentDiscoverProjectSkillRoots(ctx, startDir) {
|
|
const vfs = ctx && ctx.vfs
|
|
if (!vfs || typeof vfs.readdir !== 'function') return []
|
|
/** @type {{ path: string, source: string }[]} */
|
|
const roots = []
|
|
const seen = Object.create(null)
|
|
let dir = String(startDir || '').replace(/\/+$/, '') || '/'
|
|
const suffixes = ['.grok/skills', '.agents/skills', '.claude/skills', '.cursor/skills']
|
|
for (let hop = 0; hop < 12; hop++) {
|
|
for (let i = 0; i < suffixes.length; i++) {
|
|
const p = (dir === '/' ? '' : dir) + '/' + suffixes[i]
|
|
if (seen[p]) continue
|
|
seen[p] = 1
|
|
try {
|
|
const names = await vfs.readdir(p)
|
|
if (Array.isArray(names) && names.length) roots.push({ path: p, source: 'project' })
|
|
} catch {
|
|
/* missing */
|
|
}
|
|
}
|
|
if (dir === '/') break
|
|
const parent = dir.replace(/\/[^/]+$/, '') || '/'
|
|
if (parent === dir) break
|
|
dir = parent
|
|
}
|
|
return roots
|
|
}
|