Further Agent updates

This commit is contained in:
2026-08-18 15:51:13 -04:00
parent a94de5be1d
commit 2ad8ae1666
22 changed files with 3043 additions and 60 deletions
@@ -47,8 +47,15 @@ var BARE_AGENT_PLAN_READONLY_TOOLS = Object.freeze({
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,
@@ -808,7 +815,9 @@ async function bareAgentGrepFiles(ctx, opts) {
200,
Math.max(1, Math.floor(Number(o.max_matches) || 50))
)
const filesOnly = Boolean(o.files_with_matches)
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 })
@@ -817,6 +826,8 @@ async function bareAgentGrepFiles(ctx, opts) {
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]
@@ -825,11 +836,13 @@ async function bareAgentGrepFiles(ctx, opts) {
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
if (filesOnly) break
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)
@@ -856,6 +869,13 @@ async function bareAgentGrepFiles(ctx, opts) {
break
}
}
if (fileHit && countOnly) {
counts.push({ path, count: fileCount })
if (counts.length >= maxMatches) {
truncated = true
break
}
}
if (truncated) break
}
if (filesOnly) {
@@ -864,16 +884,32 @@ async function bareAgentGrepFiles(ctx, opts) {
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
@@ -1515,3 +1551,434 @@ function bareAgentScheduleId(id) {
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
}
+17 -1
View File
@@ -102,6 +102,22 @@ async function bareAgentDiscoverSkills(ctx, paths) {
}
await scanRoot(paths.workspaceSkills, 'workspace')
await scanRoot(paths.skillsGlobal, 'global')
let extras = Array.isArray(paths.extraSkillRoots) ? paths.extraSkillRoots : []
if (!extras.length && typeof bareAgentDiscoverProjectSkillRoots === 'function') {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
const start = String(env.PWD || env.CWD || env.HOME || '').trim()
if (start) extras = await bareAgentDiscoverProjectSkillRoots(ctx, start)
}
for (let i = 0; i < extras.length; i++) {
const item = extras[i]
const root = typeof item === 'string' ? item : String((item && item.path) || '')
const source =
typeof item === 'object' && item && item.source ? String(item.source) : 'project'
if (root) await scanRoot(root, source)
}
return out
}
@@ -117,7 +133,7 @@ async function bareAgentSkillsCompactPrompt(ctx, paths, maxChars) {
let block =
'## Available skills (compact index)\n' +
'Each skill is a directory with **SKILL.md** (optional YAML frontmatter: `name`, `description`, …).\n' +
'**Workspace skills** (`~/.agent/workspace/skills/`) override **global** (`~/.agent/skills/`) when names match.\n' +
'**Workspace skills** (`~/.agent/workspace/skills/`) override **global** (`~/.agent/skills/`) and walk-up `.grok/skills` / `.agents/skills` when names match.\n' +
'To run one: call the **read_skill** tool with the skill id or frontmatter `name` before following its instructions.\n\n'
if (!skills.length) {
block += '(No skills discovered yet — add folders under `workspace/skills/<id>/SKILL.md`.)\n'
+353 -2
View File
@@ -281,7 +281,12 @@ function bareAgentToolDefinitions() {
include_stat: {
type: 'boolean',
description: 'If true, call stat on each entry (slower; default false)'
}
},
tree: {
type: 'boolean',
description: 'Grok-style bounded BFS tree instead of a flat listing'
},
max_depth: { type: 'integer', description: 'Tree depth (default 6)' }
},
required: ['path']
}
@@ -1117,6 +1122,10 @@ function bareAgentToolDefinitions() {
files_with_matches: {
type: 'boolean',
description: 'Return matching paths only'
},
output_mode: {
type: 'string',
description: 'content (default) | files_with_matches | count'
}
},
required: ['pattern']
@@ -1362,6 +1371,168 @@ function bareAgentToolDefinitions() {
}
}
},
{
type: 'function',
function: {
name: 'git_log',
description:
'git log --oneline (optional -N / path) via /bin/git. Prefer over raw run_command.',
parameters: {
type: 'object',
properties: {
cwd: { type: 'string' },
max: { type: 'integer', description: 'Default 20, cap 80' },
path: { type: 'string' }
}
}
}
},
{
type: 'function',
function: {
name: 'git_show',
description: 'git show <rev> (optional path / --stat) via /bin/git.',
parameters: {
type: 'object',
properties: {
cwd: { type: 'string' },
rev: { type: 'string', description: 'Commit-ish (default HEAD)' },
path: { type: 'string' },
stat: { type: 'boolean' }
}
}
}
},
{
type: 'function',
function: {
name: 'git_blame',
description: 'git blame a file via /bin/git.',
parameters: {
type: 'object',
properties: {
cwd: { type: 'string' },
path: { type: 'string' }
},
required: ['path']
}
}
},
{
type: 'function',
function: {
name: 'copy_path',
description:
'Copy a file or directory on the VFS (parents created). Prefer this over run_command cp.',
parameters: {
type: 'object',
properties: {
from_path: { type: 'string' },
to_path: { type: 'string' }
},
required: ['from_path', 'to_path']
}
}
},
{
type: 'function',
function: {
name: 'diff_files',
description:
'Unified diff of two UTF-8 files (no git). Prefer this when comparing two paths that are not a git hunk.',
parameters: {
type: 'object',
properties: {
from_path: { type: 'string' },
to_path: { type: 'string' },
context: { type: 'integer', description: 'Context lines (default 3)' }
},
required: ['from_path', 'to_path']
}
}
},
{
type: 'function',
function: {
name: 'find_symbol',
description:
'Find likely definitions of a symbol (function/class/const/def/fn) under a root. Prefer over ad-hoc grep for go-to-definition.',
parameters: {
type: 'object',
properties: {
name: { type: 'string' },
root: { type: 'string' },
glob: { type: 'string' },
max: { type: 'integer' }
},
required: ['name']
}
}
},
{
type: 'function',
function: {
name: 'create_skill',
description:
'Write a Grok-style SKILL.md (YAML frontmatter + body) under ~/.agent/workspace/skills/<id>/.',
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Folder name (kebab-case)' },
name: { type: 'string' },
description: { type: 'string' },
body: { type: 'string', description: 'Markdown instructions after frontmatter' }
},
required: ['id', 'description']
}
}
},
{
type: 'function',
function: {
name: 'remember',
description:
'Save a durable FACT to MEMORY.md now (Grok /remember). Same store as memory_append.',
parameters: {
type: 'object',
properties: {
text: { type: 'string' },
daily: { type: 'boolean' }
},
required: ['text']
}
}
},
{
type: 'function',
function: {
name: 'rewind_session',
description:
'Grok /rewind: drop the last N user turns from ~/.agent/history.json (and everything after). Default 1 turn.',
parameters: {
type: 'object',
properties: {
steps: { type: 'integer', description: 'How many user turns to drop (default 1)' },
user_index: { type: 'integer', description: 'Rewind to this 0-based user turn instead' },
keep_user: { type: 'boolean', description: 'Keep the target user prompt' }
}
}
}
},
{
type: 'function',
function: {
name: 'export_session',
description:
'Grok /export: write the current chat history as Markdown. Default ~/.agent/export.md.',
parameters: {
type: 'object',
properties: {
path: { type: 'string' }
}
}
}
},
{
type: 'function',
function: {
@@ -1484,6 +1655,13 @@ async function bareAgentDispatchTool(o) {
if (toolName === 'ripgrep') {
return bareAgentDispatchTool({ ...o, toolName: 'grep' })
}
if (toolName === 'remember') {
return bareAgentDispatchTool({
...o,
toolName: 'memory_append',
argsJson: JSON.stringify({ ...args, kind: 'FACT' })
})
}
const vfs = ctx.vfs
const mutateDenyPrefixes = (function () {
@@ -2487,6 +2665,14 @@ async function bareAgentDispatchTool(o) {
if (!bareAgentPathAllowed(dir)) {
return bareAgentJsonResult({ ok: false, error: 'path_not_allowed' })
}
if (Boolean(args.tree) && typeof bareAgentRenderTree === 'function') {
appendProgress('list_directory tree ' + dir)
const tree = await bareAgentRenderTree(ctx, dir, {
max: maxEnt,
maxDepth: args.max_depth
})
return bareAgentJsonResult(tree)
}
if (!vfs || typeof vfs.readdir !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'readdir unavailable' })
}
@@ -3509,7 +3695,8 @@ async function bareAgentDispatchTool(o) {
after: args.after,
context: args.context,
max_matches: args.max_matches,
files_with_matches: Boolean(args.files_with_matches)
files_with_matches: Boolean(args.files_with_matches),
output_mode: typeof args.output_mode === 'string' ? args.output_mode : ''
})
return bareAgentJsonResult(out)
}
@@ -3976,6 +4163,170 @@ async function bareAgentDispatchTool(o) {
return bareAgentJsonResult({ ok: true, query, hits })
}
if (toolName === 'git_log' || toolName === 'git_show' || toolName === 'git_blame') {
const cwd =
(typeof args.cwd === 'string' && args.cwd.trim()) ||
(ctx.env && typeof ctx.env === 'object'
? String(ctx.env.PWD || ctx.env.CWD || home || '').trim()
: '') ||
home ||
'/home'
if (!execLine) return bareAgentJsonResult({ ok: false, error: 'execLine unavailable' })
const quoted = bareAgentShellQuote(cwd)
let cmd = 'git -C ' + quoted + ' '
if (toolName === 'git_log') {
const max = Math.min(80, Math.max(1, Math.floor(Number(args.max) || 20)))
const p = typeof args.path === 'string' ? args.path.trim() : ''
cmd += 'log --oneline -' + String(max) + (p ? ' -- ' + bareAgentShellQuote(p) : '')
} else if (toolName === 'git_show') {
const rev = typeof args.rev === 'string' && args.rev.trim() ? args.rev.trim() : 'HEAD'
const p = typeof args.path === 'string' ? args.path.trim() : ''
cmd +=
'show ' +
(args.stat ? '--stat ' : '') +
bareAgentShellQuote(rev) +
(p ? ' -- ' + bareAgentShellQuote(p) : '')
} else {
const p = typeof args.path === 'string' ? args.path.trim() : ''
if (!p) return bareAgentJsonResult({ ok: false, error: 'path_required' })
cmd += 'blame -- ' + bareAgentShellQuote(p)
}
appendProgress(toolName + ' ' + cwd)
const r = await captureExec(cmd, 30000)
return bareAgentJsonResult(
r.ok === false ? r : { ok: true, cwd, stdout_stderr: r.stdout_stderr }
)
}
if (toolName === 'copy_path') {
const from = typeof args.from_path === 'string' ? args.from_path.trim() : ''
const to = typeof args.to_path === 'string' ? args.to_path.trim() : ''
if (
!from ||
!to ||
from.includes('..') ||
to.includes('..') ||
!bareAgentPathAllowed(from) ||
!bareAgentPathAllowedMutate(to, mutateDenyPrefixes)
) {
return bareAgentJsonResult({ ok: false, error: 'path_not_allowed' })
}
if (typeof bareAgentCopyPath !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'copy_unavailable' })
}
appendProgress('copy_path ' + from)
const out = await bareAgentCopyPath(ctx, from, to)
return bareAgentJsonResult(out)
}
if (toolName === 'diff_files') {
const from = typeof args.from_path === 'string' ? args.from_path.trim() : ''
const to = typeof args.to_path === 'string' ? args.to_path.trim() : ''
if (!from || !to || !bareAgentPathAllowed(from) || !bareAgentPathAllowed(to)) {
return bareAgentJsonResult({ ok: false, error: 'path_not_allowed' })
}
if (typeof bareAgentUnifiedDiff !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'diff_unavailable' })
}
const a = await bareAgentReadTextFile(ctx, from)
const b = await bareAgentReadTextFile(ctx, to)
appendProgress('diff_files ' + from)
const out = bareAgentUnifiedDiff(a, b, {
from,
to,
context: args.context
})
return bareAgentJsonResult(out)
}
if (toolName === 'find_symbol') {
const name = typeof args.name === 'string' ? args.name.trim() : ''
if (!name) return bareAgentJsonResult({ ok: false, error: 'name_required' })
const root =
(typeof args.root === 'string' && args.root.trim()) || home || '/home'
if (!bareAgentPathAllowed(root) || typeof bareAgentFindSymbol !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'find_symbol_unavailable' })
}
appendProgress('find_symbol ' + name)
const out = await bareAgentFindSymbol(ctx, root, name, {
glob: typeof args.glob === 'string' ? args.glob : '',
max: args.max
})
return bareAgentJsonResult(out)
}
if (toolName === 'create_skill') {
const id = String(args.id || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!id) return bareAgentJsonResult({ ok: false, error: 'id_required' })
const destRoot =
(typeof paths.workspaceSkills === 'string' && paths.workspaceSkills) ||
(typeof paths.workspace === 'string' ? paths.workspace + '/skills' : paths.dir + '/workspace/skills')
const dest = destRoot + '/' + id + '/SKILL.md'
if (!bareAgentPathAllowedMutate(dest, mutateDenyPrefixes)) {
return bareAgentJsonResult({ ok: false, error: 'path_not_allowed' })
}
const md =
typeof bareAgentSkillMarkdown === 'function'
? bareAgentSkillMarkdown({
name: typeof args.name === 'string' ? args.name : id,
description: typeof args.description === 'string' ? args.description : id,
body: typeof args.body === 'string' ? args.body : ''
})
: String(args.body || '')
await bareAgentWriteTextFile(ctx, dest, md)
appendProgress('create_skill ' + id)
return bareAgentJsonResult({ ok: true, id, path: dest })
}
if (toolName === 'rewind_session') {
const histPath = paths.history || paths.dir + '/history.json'
const raw = await bareAgentReadJsonFile(ctx, histPath, [])
if (typeof bareAgentRewindHistory !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'rewind_unavailable' })
}
const out = bareAgentRewindHistory(Array.isArray(raw) ? raw : [], {
steps: args.steps,
userIndex: args.user_index,
keep_user: args.keep_user
})
if (!out.ok) return bareAgentJsonResult(out)
await bareAgentSaveHistory(ctx, histPath, out.messages)
appendProgress('rewind_session dropped=' + String(out.dropped))
return bareAgentJsonResult({
ok: true,
dropped: out.dropped,
remaining: out.messages.length,
target: out.target || null,
keep_user: Boolean(out.keep_user)
})
}
if (toolName === 'export_session') {
const histPath = paths.history || paths.dir + '/history.json'
const dest =
(typeof args.path === 'string' && args.path.trim()) ||
(paths.dir ? paths.dir + '/export.md' : '/tmp/agent-export.md')
if (!bareAgentPathAllowedMutate(dest, mutateDenyPrefixes)) {
return bareAgentJsonResult({ ok: false, error: 'path_not_allowed' })
}
const raw = await bareAgentReadJsonFile(ctx, histPath, [])
const md =
typeof bareAgentExportTranscript === 'function'
? bareAgentExportTranscript(Array.isArray(raw) ? raw : [])
: ''
await bareAgentWriteTextFile(ctx, dest, md)
appendProgress('export_session ' + dest)
return bareAgentJsonResult({
ok: true,
path: dest,
messages: Array.isArray(raw) ? raw.length : 0
})
}
return bareAgentJsonResult({ ok: false, error: 'unknown_tool ' + toolName })
} catch (e) {
const msg =
+3 -3
View File
@@ -267,7 +267,7 @@ ACCESS (denylist, not allowlist). You already have full guest admin. NEVER ASK w
- ask_user_question is only for a real product choice the user must make. Never use it (or chat) to request permission.
CODING LOOP. Multi-step work: todo_write (merge=true). Large unknown surface: enter_plan_mode, write ~/.agent/plan.md, exit_plan_mode, then implement. Plan mode is read-only except that plan file. Do not stop after a plan-only reply — keep calling tools until verified.
1. Discover: glob_files, grep (preferred over search_files / run_command grep), list_directory, read_file, memory_search / memory_get, web_search then web_fetch, git_status, read_skill if a skill matches. Read before you edit. Walk-up AGENTS.md from cwd is already injected when present.
1. Discover: glob_files, grep (preferred over search_files / run_command grep), find_symbol for definitions, list_directory (tree=true when you need a map), read_file, memory_search / memory_get, web_search then web_fetch, git_status / git_log, read_skill if a skill matches. Read before you edit. Walk-up AGENTS.md and .grok/skills from cwd are already injected when present.
2. Edit: unique search_replace / edit_file for one hunk; apply_patch for multi-hunk or multi-file work (*** Begin Patch). Create with write_file / create_directory. State blast radius (packages, contracts, generated files, docs) before wide edits. Match surrounding style. No placeholders. Comments only for non-obvious constraints.
3. Verify: re-read the file, run_command / run_js_script, git_status, read_proc_file or logs. Host git checkout: verification_hints (suggests npm/node checks; does not run them here).
4. Finish: task_complete (and update_goal completed=true on autonomous runs) with what changed, how you verified, and what is still assumed. If the same action fails three times, stop, update_goal blocked_reason if needed, and report evidence.
@@ -284,8 +284,8 @@ TOOL CALLING. Prefer specialized tools over bash: grep not run_command grep; rea
COMMUNICATION. Write for a reader who has not seen tool calls. Lead with the answer. Define project terms on first use. State facts literally. The final message must stand alone. Do not invent acronyms.
TOOL MAP (schemas are already attached — use them):
- Files: read_file, read_many, write_file, edit_file, search_replace, apply_patch, undo_last_edit, create_directory, list_directory, file_stat, glob_files, fuzzy_find, grep, search_files, move_path, delete_path, list_bin
- Code / harness: run_command (optional cwd), run_js_script, run_js_script_at_path, todo_write, enter_plan_mode, exit_plan_mode, memory_search, memory_get, memory_append, list_skills, read_skill, edit_agent_config, git_status, git_diff, history_search, schedule_task, unschedule_task, list_scheduled, wait_for
- Files: read_file, read_many, write_file, edit_file, search_replace, apply_patch, undo_last_edit, create_directory, list_directory (tree=true for BFS), file_stat, glob_files, fuzzy_find, grep (output_mode content|files_with_matches|count), search_files, move_path, copy_path, diff_files, delete_path, find_symbol, list_bin
- Code / harness: run_command (optional cwd), run_js_script, run_js_script_at_path, todo_write, enter_plan_mode, exit_plan_mode, memory_search, memory_get, memory_append, remember, list_skills, read_skill, create_skill, edit_agent_config, git_status, git_diff, git_log, git_show, git_blame, history_search, rewind_session, export_session, schedule_task, unschedule_task, list_scheduled, wait_for
- Kernel / ops: read_proc_file, runtime_diagnostic_bundle, get_system_info, get_resource_limits, get_swarm_peers, list_services, service_status, list_timers, read_cron_log, read_audit_log, read_boot_policy, read_kernel_extension_resolution, get_initd_graph, read_unit_journal, inspect_ipc_backpressure, get_network_summary, tail_telemetry_streams, pkg_index_lookup
- Checks: list_verification_scripts, run_maintenance_gate, run_contract_checks, summarize_build_drift, verification_hints
- Docs: read_man_page, apropos_man (documentation search only)
@@ -8,9 +8,9 @@
"agent --status",
"agent --plan REQUEST...",
"agent --auto GOAL...",
"agent skills | todos | plan | compact | reset | undo | hooks | history"
"agent skills | todos | plan | compact | reset | undo | hooks | history | rewind | export | remember | recap"
],
"description": "Runs the in-guest ReAct coding agent (QVAC local or OpenAI-compatible REST). Full guest admin by default (denylist). Tools include grep, apply_patch, web_search, git_status, todos, plan mode, and skills. History lives in ~/.agent/history.json.",
"description": "Runs the in-guest ReAct coding agent (QVAC local or OpenAI-compatible REST). Full guest admin by default (denylist). Tools include grep, apply_patch, web_search, git helpers, find_symbol, session rewind/export, todos, plan mode, and skills. History lives in ~/.agent/history.json.",
"options": [
{
"flag": "--setup, --config",
@@ -12,7 +12,7 @@ Workspace files are already in the system prompt. Skills index is already attach
## Coding loop
1. Discover — glob_files, grep, list_directory, read_file (offset/limit), git_status, web_search. Read before you edit.
1. Discover — glob_files, grep, find_symbol, list_directory (tree=true), read_file (offset/limit), git_status / git_log, web_search. Read before you edit.
2. Track — todo_write (merge=true) for multi-step work. Large unknown surface: enter_plan_mode, write ~/.agent/plan.md, exit_plan_mode, then implement. Plan mode is read-only except that file.
3. Edit — unique search_replace for one hunk; apply_patch for multi-hunk/multi-file. write_file to create or overwrite. Match surrounding style. No placeholders.
4. Verify — re-read, run_command / run_js_script, read_proc_file or logs. Host checkout: verification_hints (does not run npm here).
@@ -8,12 +8,14 @@ You already have these tools. Schemas are attached. NEVER ASK to use them.
- `write_file` — create or overwrite (parents created).
- `edit_file` / `search_replace` — unique `old_string` unless `replace_all`.
- `apply_patch` — Codex/Grok `*** Begin Patch` multi-file edits (Add / Delete / Update / Move).
- `create_directory`, `list_directory`, `file_stat`, `glob_files`.
- `grep` — VFS-native regex search (glob, ignore_case, context). Prefer over `search_files` / `run_command grep`.
- `create_directory`, `list_directory` (`tree=true` for a bounded BFS tree), `file_stat`, `glob_files`.
- `grep` — VFS-native regex search (glob, ignore_case, context, `output_mode` content | files_with_matches | count). Prefer over `search_files` / `run_command grep`.
- `find_symbol` — definition-oriented search (function/class/const/def/fn).
- `search_files``grep -Rnl` via the guest shell (legacy).
- `move_path`, `delete_path` — enabled. Base system is read-only.
- `move_path`, `copy_path`, `diff_files`, `delete_path` — enabled. Base system is read-only.
- `list_bin` — guest `/bin` utilities (POSIX-in-JS, not GNU).
- `git_status``git status --short`, `diff --stat`, recent log.
- `git_log` / `git_show` / `git_blame` — prefer these over raw `run_command git`.
Prefer `list_directory` / `glob_files` / `file_stat` / `grep` over `ls` / `find` / shell grep.
@@ -24,8 +26,9 @@ Prefer `list_directory` / `glob_files` / `file_stat` / `grep` over `ls` / `find`
- `run_js_script_at_path` — existing absolute `.mjs`.
- `todo_write` — session todos (`merge=true` to update by id).
- `enter_plan_mode` / `exit_plan_mode` — plan mode is read-only except `~/.agent/plan.md`.
- `memory_search` / `memory_get` / `memory_append` — MEMORY.md and daily logs.
- `list_skills` / `read_skill` — compact catalog then full SKILL.md.
- `memory_search` / `memory_get` / `memory_append` / `remember` — MEMORY.md and daily logs.
- `list_skills` / `read_skill` / `create_skill` catalog, full SKILL.md, or author a new workspace skill.
- Project skills are also walked from `.grok/skills`, `.agents/skills`, `.claude/skills`, `.cursor/skills`.
- `schedule_task` / `unschedule_task` / `list_scheduled` — guest timers (`agent-*.timer`).
- `fuzzy_find` — filename search when you remember part of a name.
- `read_many` — several files in one call.
@@ -33,6 +36,8 @@ Prefer `list_directory` / `glob_files` / `file_stat` / `grep` over `ls` / `find`
- `undo_last_edit` — restore the last snapshot from `~/.agent/edits.json`.
- `git_diff``git diff` / `--stat`.
- `history_search` — keyword search of this session's history.
- `rewind_session` — Grok `/rewind`: drop the last N user turns from `~/.agent/history.json`.
- `export_session` — Grok `/export`: write a Markdown transcript (default `~/.agent/export.md`).
- glob/grep honor `.gitignore`, `.agentignore`, and `.grokignore` at the walk root.
- `edit_agent_config` — shallow merge of known `~/.agent/config.json` keys.
+95 -3
View File
@@ -61,8 +61,15 @@ async function run(ctx, argv) {
sub === 'reset' ||
sub === 'undo' ||
sub === 'hooks' ||
sub === 'history')) ||
(sub === 'history' && rest[0] === 'history'))
sub === 'history' ||
sub === 'rewind' ||
sub === 'export' ||
sub === 'remember' ||
sub === 'recap')) ||
(sub === 'history' && rest[0] === 'history') ||
(sub === 'rewind' && rest[0] === 'rewind') ||
(sub === 'export' && rest[0] === 'export') ||
(sub === 'remember' && rest[0] === 'remember'))
) {
if (sub === 'status') statusFlag = true
else if (sub === 'reset') resetFlag = true
@@ -182,7 +189,7 @@ function bareAgentCliUsage(argv0) {
' --compact\n' +
' ' +
argv0 +
' skills | todos | plan | undo | hooks | history\n' +
' skills | todos | plan | undo | hooks | history | rewind | export | remember | recap\n' +
'\n' +
'Production coding/OS agent. Default backend is QVAC (local); or any OpenAI-compatible REST API.\n' +
'Config: ~/.agent/config.json. Full guest admin (denylist). NEVER ASK — it just works.\n' +
@@ -206,6 +213,10 @@ function bareAgentCliUsage(argv0) {
' undo Restore the last file edit snapshot\n' +
' hooks List ~/.agent/hooks/*.json\n' +
' history [query] Search or tail chat history\n' +
' rewind [N] Drop the last N user turns from history (default 1)\n' +
' export [path] Write a Markdown transcript (default ~/.agent/export.md)\n' +
' remember TEXT Append a FACT to MEMORY.md\n' +
' recap Print the last user + assistant pair\n' +
'\n' +
'Examples:\n' +
' ' +
@@ -375,6 +386,87 @@ async function bareAgentRunInspectSubcommand(ctx, argv0, sub, extra) {
ctx.exitCode = 0
return
}
if (sub === 'rewind') {
const steps = Math.max(1, Math.floor(Number(extra) || 1))
const messages = await bareAgentLoadHistory(ctx, paths.history)
if (typeof bareAgentRewindHistory !== 'function') {
ctx.console.error(argv0 + ': rewind unavailable')
ctx.exitCode = 1
return
}
const out = bareAgentRewindHistory(messages, { steps })
if (!out.ok) {
ctx.console.log(argv0 + ': ' + (out.error || 'nothing to rewind'))
ctx.exitCode = 0
return
}
await bareAgentSaveHistory(ctx, paths.history, out.messages)
ctx.console.log(
argv0 +
': rewound ' +
String(out.dropped) +
' message(s), ' +
String(out.messages.length) +
' remain'
)
ctx.exitCode = 0
return
}
if (sub === 'export') {
const dest = String(extra || '').trim() || paths.dir + '/export.md'
const messages = await bareAgentLoadHistory(ctx, paths.history)
const md =
typeof bareAgentExportTranscript === 'function'
? bareAgentExportTranscript(messages)
: ''
await bareAgentWriteTextFile(ctx, dest, md)
ctx.console.log(argv0 + ': exported ' + String(messages.length) + ' messages to ' + dest)
ctx.exitCode = 0
return
}
if (sub === 'remember') {
const text = String(extra || '').trim()
if (!text) {
ctx.console.error(argv0 + ': remember requires a note')
ctx.exitCode = 1
return
}
const dest = paths.workspace + '/MEMORY.md'
const line = '- FACT — ' + text.replace(/\s+/g, ' ')
let prev = ''
try {
prev = await bareAgentReadTextFile(ctx, dest)
} catch {
prev = ''
}
await bareAgentWriteTextFile(ctx, dest, (prev ? prev.replace(/\s*$/, '') + '\n' : '') + line + '\n')
ctx.console.log(argv0 + ': remembered in ' + dest)
ctx.exitCode = 0
return
}
if (sub === 'recap') {
const messages = await bareAgentLoadHistory(ctx, paths.history)
let lastUser = ''
let lastAsst = ''
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] && typeof messages[i] === 'object' ? messages[i] : null
if (!m) continue
const role = String(m.role || '')
if (!lastAsst && role === 'assistant') lastAsst = String(m.content || '')
if (!lastUser && role === 'user') lastUser = String(m.content || '')
if (lastUser && lastAsst) break
}
ctx.console.log(
lastUser || lastAsst
? 'USER\n' +
lastUser.slice(0, 1200) +
'\n\nASSISTANT\n' +
lastAsst.slice(0, 2000)
: argv0 + ': no recap (empty history)'
)
ctx.exitCode = 0
return
}
} catch (e) {
const msg =
e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e)
@@ -156,7 +156,17 @@ test('agent-tools exposes agent ops tools', async (t) => {
'wait_for',
'undo_last_edit',
'git_diff',
'history_search'
'history_search',
'git_log',
'git_show',
'git_blame',
'copy_path',
'diff_files',
'find_symbol',
'create_skill',
'remember',
'rewind_session',
'export_session'
]) {
t.ok(
TOOLS.includes("name: '" + toolName + "'"),
@@ -220,6 +230,10 @@ test('agent CLI exposes production flags and inspect subcommands', async (t) =>
t.ok(CLI.includes("sub === 'undo'"))
t.ok(CLI.includes("sub === 'hooks'"))
t.ok(CLI.includes("sub === 'history'"))
t.ok(CLI.includes("sub === 'rewind'"))
t.ok(CLI.includes("sub === 'export'"))
t.ok(CLI.includes("sub === 'remember'"))
t.ok(CLI.includes("sub === 'recap'"))
t.ok(TUI.includes('planMode'))
t.ok(TUI.includes('modelOverride'))
t.ok(TUI.includes('extraSystemFile'))
@@ -611,6 +611,182 @@ test('gitignore, fuzzy, undo, history search', async (t) => {
t.ok(waited.matched)
})
test('rewind, export, unified diff, symbol regex', async (t) => {
const s = loadPort()
const hist = [
{ role: 'user', content: 'first' },
{ role: 'assistant', content: 'ok1' },
{ role: 'user', content: 'second' },
{ role: 'assistant', content: 'ok2' }
]
const points = s.bareAgentRewindPoints(hist)
t.is(points.length, 2)
const rewound = s.bareAgentRewindHistory(hist, { steps: 1 })
t.ok(rewound.ok)
t.is(rewound.dropped, 2)
t.is(rewound.messages.length, 2)
t.is(rewound.messages[0].content, 'first')
const md = s.bareAgentExportTranscript(hist)
t.ok(md.includes('# Agent session export'))
t.ok(md.includes('first'))
const diff = s.bareAgentUnifiedDiff('a\nb\nc\n', 'a\nB\nc\n', { from: '/a', to: '/b' })
t.ok(diff.ok)
t.absent(diff.identical)
t.ok(String(diff.text).includes('-b'))
t.ok(String(diff.text).includes('+B'))
const re = new RegExp(s.bareAgentSymbolRegex('bareAgentCopyPath'))
t.ok(re.test('async function bareAgentCopyPath(ctx, from, to) {'))
t.absent(re.test('const x = bareAgentCopyPath'))
})
test('copy, tree, find_symbol, create_skill, rewind dispatch', async (t) => {
const d = loadDispatch()
const { vfs, files, b4a } = makeVfs({
'/home/guest/src/lib.js': 'async function helloWorld() {\n return 1\n}\n',
'/home/guest/src/a.txt': 'alpha\n',
'/home/guest/src/b.txt': 'beta\n',
'/home/guest/.agent/history.json': JSON.stringify(
[
{ role: 'user', content: 'one' },
{ role: 'assistant', content: 'ok' },
{ role: 'user', content: 'two' },
{ role: 'assistant', content: 'ok2' }
],
null,
2
)
})
await vfs.mkdir('/home/guest/src')
await vfs.mkdir('/home/guest/.agent')
const ctx = { vfs, b4a }
const paths = {
dir: '/home/guest/.agent',
history: '/home/guest/.agent/history.json',
workspace: '/home/guest/.agent/workspace',
workspaceSkills: '/home/guest/.agent/workspace/skills',
workspaceMemory: '/home/guest/.agent/workspace/memory'
}
const copied = await dispatch(d, {
ctx,
paths,
toolName: 'copy_path',
args: { from_path: '/home/guest/src/a.txt', to_path: '/home/guest/src/a.copy.txt' },
home: '/home/guest'
})
t.ok(copied.ok)
t.is(files.get('/home/guest/src/a.copy.txt'), 'alpha\n')
const tree = await dispatch(d, {
ctx,
paths,
toolName: 'list_directory',
args: { path: '/home/guest/src', tree: true },
home: '/home/guest'
})
t.ok(tree.ok)
t.ok(String(tree.tree || '').includes('lib.js'))
const found = await dispatch(d, {
ctx,
paths,
toolName: 'find_symbol',
args: { name: 'helloWorld', root: '/home/guest/src' },
home: '/home/guest'
})
t.ok(found.ok)
t.ok(found.count >= 1)
const counted = await dispatch(d, {
ctx,
paths,
toolName: 'grep',
args: { pattern: 'hello', root: '/home/guest/src', output_mode: 'count' },
home: '/home/guest'
})
t.ok(counted.ok)
t.is(counted.output_mode, 'count')
t.ok(counted.count >= 1)
const skill = await dispatch(d, {
ctx,
paths,
toolName: 'create_skill',
args: { id: 'demo-skill', description: 'Demo', body: '# Demo\n\nDo the thing.\n' },
home: '/home/guest'
})
t.ok(skill.ok)
t.ok(String(files.get('/home/guest/.agent/workspace/skills/demo-skill/SKILL.md') || '').includes('name: demo-skill'))
const rewound = await dispatch(d, {
ctx,
paths,
toolName: 'rewind_session',
args: { steps: 1 },
home: '/home/guest'
})
t.ok(rewound.ok)
t.is(rewound.dropped, 2)
const hist = JSON.parse(files.get('/home/guest/.agent/history.json') || '[]')
t.is(hist.length, 2)
const remembered = await dispatch(d, {
ctx,
paths,
toolName: 'remember',
args: { text: 'holesail keys live in ~/.holesail' },
home: '/home/guest'
})
t.ok(remembered.ok)
t.is(remembered.kind, 'FACT')
t.ok(String(files.get('/home/guest/.agent/workspace/MEMORY.md') || '').includes('holesail keys'))
const exported = await dispatch(d, {
ctx,
paths,
toolName: 'export_session',
args: { path: '/home/guest/.agent/export.md' },
home: '/home/guest'
})
t.ok(exported.ok)
t.ok(String(files.get('/home/guest/.agent/export.md') || '').includes('# Agent session export'))
})
test('copy from read-only system path and project skill roots', async (t) => {
const d = loadDispatch()
const { vfs, files, b4a } = makeVfs({
'/bin/sh': 'readonly-shell\n',
'/home/guest/proj/.grok/skills/demo/SKILL.md':
'---\nname: demo\ndescription: Project skill\n---\n\n# Demo\n'
})
await vfs.mkdir('/bin')
await vfs.mkdir('/home/guest')
const ctx = { vfs, b4a, env: { PWD: '/home/guest/proj', HOME: '/home/guest' } }
const copied = await dispatch(d, {
ctx,
paths: { dir: '/home/guest/.agent', workspace: '/home/guest/.agent/workspace' },
toolName: 'copy_path',
args: { from_path: '/bin/sh', to_path: '/home/guest/sh.copy' },
home: '/home/guest'
})
t.ok(copied.ok)
t.is(files.get('/home/guest/sh.copy'), 'readonly-shell\n')
const blocked = await dispatch(d, {
ctx,
paths: { dir: '/home/guest/.agent' },
toolName: 'copy_path',
args: { from_path: '/home/guest/sh.copy', to_path: '/bin/sh.hijack' },
home: '/home/guest'
})
t.absent(blocked.ok)
t.is(blocked.error, 'path_not_allowed')
const s = loadPort()
const roots = await s.bareAgentDiscoverProjectSkillRoots(ctx, '/home/guest/proj/src')
t.ok(roots.some((r) => String(r.path || '').endsWith('/.grok/skills')))
})
test('search result parser extracts DDG-style topics', async (t) => {
const s = loadPort()
const rows = s.bareAgentParseSearchResults(