Align tool names with Copilot coding agent conventions: - sf_exec → run_command - sf_exec_search → read_output - sf_resume → resume_agent - capture_thought → log_reasoning - sf_log_judgment → log_decision - sf_self_report → report_issue - sf_self_feedback_resolve → resolve_issue - sf_save_gate_result → record_gate - sf_autonomous_checkpoint → checkpoint - sf_milestone_generate_id → new_milestone_id - sf_graph → memory_graph - memory_query → memory_search - sf_retrieval_evidence → search_evidence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
135 lines
4 KiB
JavaScript
135 lines
4 KiB
JavaScript
// SF Exec History — read-side helpers for the exec sandbox.
|
|
//
|
|
// Pure I/O: scans `.sf/exec/*.meta.json` under a base directory and
|
|
// returns lightweight records. Used by the read_output tool and
|
|
// any future compaction-snapshot enrichment.
|
|
import {
|
|
closeSync,
|
|
openSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
function listMetaFiles(baseDir) {
|
|
const dir = resolve(baseDir, ".sf", "exec");
|
|
try {
|
|
return readdirSync(dir)
|
|
.filter((name) => name.endsWith(".meta.json"))
|
|
.map((name) => join(dir, name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
function safeReadMeta(path) {
|
|
try {
|
|
const raw = readFileSync(path, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed.id !== "string" || typeof parsed.runtime !== "string")
|
|
return null;
|
|
return {
|
|
id: parsed.id,
|
|
runtime: parsed.runtime,
|
|
purpose: typeof parsed.purpose === "string" ? parsed.purpose : null,
|
|
started_at:
|
|
typeof parsed.started_at === "string" ? parsed.started_at : "",
|
|
finished_at:
|
|
typeof parsed.finished_at === "string" ? parsed.finished_at : "",
|
|
duration_ms:
|
|
typeof parsed.duration_ms === "number" ? parsed.duration_ms : 0,
|
|
exit_code: typeof parsed.exit_code === "number" ? parsed.exit_code : null,
|
|
signal: typeof parsed.signal === "string" ? parsed.signal : null,
|
|
timed_out: parsed.timed_out === true,
|
|
stdout_bytes:
|
|
typeof parsed.stdout_bytes === "number" ? parsed.stdout_bytes : 0,
|
|
stderr_bytes:
|
|
typeof parsed.stderr_bytes === "number" ? parsed.stderr_bytes : 0,
|
|
stdout_truncated: parsed.stdout_truncated === true,
|
|
stderr_truncated: parsed.stderr_truncated === true,
|
|
stdout_path: path.replace(/\.meta\.json$/, ".stdout"),
|
|
stderr_path: path.replace(/\.meta\.json$/, ".stderr"),
|
|
meta_path: path,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
/**
|
|
* List all execution history entries, sorted by most recent first.
|
|
*/
|
|
export function listExecHistory(baseDir) {
|
|
const metas = listMetaFiles(baseDir)
|
|
.map((path) => {
|
|
let mtime = 0;
|
|
try {
|
|
mtime = statSync(path).mtimeMs;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
const entry = safeReadMeta(path);
|
|
return entry ? { entry, mtime } : null;
|
|
})
|
|
.filter((value) => value !== null);
|
|
metas.sort((a, b) => b.mtime - a.mtime);
|
|
return metas.map((m) => m.entry);
|
|
}
|
|
function matchesFilters(entry, opts) {
|
|
if (opts.runtime && entry.runtime !== opts.runtime) return false;
|
|
if (opts.failing_only) {
|
|
const failed =
|
|
entry.timed_out || (entry.exit_code !== 0 && entry.exit_code !== null);
|
|
if (!failed) return false;
|
|
}
|
|
const query = (opts.query ?? "").trim().toLowerCase();
|
|
if (!query) return true;
|
|
const haystack = `${entry.id} ${entry.purpose ?? ""}`.toLowerCase();
|
|
return haystack.includes(query);
|
|
}
|
|
function readDigestPreview(entry, maxChars) {
|
|
if (!entry.stdout_path || maxChars <= 0) return undefined;
|
|
try {
|
|
const size = statSync(entry.stdout_path).size;
|
|
if (size === 0) return undefined;
|
|
const readBytes = Math.min(size, maxChars * 4);
|
|
const buf = Buffer.allocUnsafe(readBytes);
|
|
const fd = openSync(entry.stdout_path, "r");
|
|
try {
|
|
const bytesRead = readSync(
|
|
fd,
|
|
buf,
|
|
0,
|
|
readBytes,
|
|
Math.max(0, size - readBytes),
|
|
);
|
|
const text = buf.subarray(0, bytesRead).toString("utf-8");
|
|
const trimmed = text.trimEnd();
|
|
return trimmed.length <= maxChars
|
|
? trimmed
|
|
: trimmed.slice(trimmed.length - maxChars);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
/**
|
|
* Search execution history with filtering and return hits with digest previews.
|
|
*/
|
|
export function searchExecHistory(baseDir, opts = {}) {
|
|
const limit = clampLimit(opts.limit, 20, 200);
|
|
const entries = listExecHistory(baseDir);
|
|
const filtered = entries.filter((entry) => matchesFilters(entry, opts));
|
|
return filtered.slice(0, limit).map((entry) => ({
|
|
entry,
|
|
digest_preview: readDigestPreview(entry, 300),
|
|
}));
|
|
}
|
|
function clampLimit(value, fallback, max) {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
if (value < 1) return 1;
|
|
if (value > max) return max;
|
|
return Math.floor(value);
|
|
}
|