mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
chore: add agent transcript skill
This commit is contained in:
83
.agents/skills/agent-transcript/SKILL.md
Normal file
83
.agents/skills/agent-transcript/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: agent-transcript
|
||||
description: "Add a redacted agent transcript section to GitHub PR or issue bodies during OpenClaw agent-created PR/issue workflows."
|
||||
---
|
||||
|
||||
# Agent Transcript
|
||||
|
||||
Best-effort local-only provenance for OpenClaw PR/issue bodies. Use during agent-created GitHub PR or issue workflows before creating/updating the body.
|
||||
|
||||
## Contract
|
||||
|
||||
- Never use network. Session discovery reads local agent logs only.
|
||||
- Never upload raw logs. Render sanitized Markdown first.
|
||||
- Always ask the user before adding transcript logs to a GitHub PR/issue body.
|
||||
- Tell the user sanitized session logs help reviewers and can make PRs easier to prioritize.
|
||||
- Offer a local HTML preview before insertion. If the user wants preview, open it and wait for confirmation before adding the section.
|
||||
- Fail closed on unresolved secrets, private keys, browser/session/cookie details, or auth URLs.
|
||||
- Drop system/developer prompts, raw tool outputs, reasoning, env, cookies, tokens, and broad local paths.
|
||||
- Keep user prompts, assistant visible decisions, terse tool summaries, and test/proof outcomes.
|
||||
- Best effort only: PR/issue creation must continue if no safe transcript is found.
|
||||
- Use a collapsed `<details>` section and update existing markers instead of duplicating sections.
|
||||
|
||||
## Helper
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript --help
|
||||
```
|
||||
|
||||
Find a likely local session:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript find \
|
||||
--query "$PR_TITLE $BRANCH_OR_PR_URL" \
|
||||
--cwd "$PWD" \
|
||||
--since-days 14
|
||||
```
|
||||
|
||||
Render a PR/issue body section:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript render \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/agent-transcript.md
|
||||
```
|
||||
|
||||
Preview one candidate session locally:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript preview \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/agent-transcript-preview.html
|
||||
open /tmp/agent-transcript-preview.html
|
||||
```
|
||||
|
||||
Append/update a body file before `gh pr create --body-file` or connector PR creation:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript append-body \
|
||||
--body /tmp/pr-body.md \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/pr-body.with-transcript.md
|
||||
```
|
||||
|
||||
## PR/Issue Workflow
|
||||
|
||||
1. Draft the normal PR/issue body first.
|
||||
2. Run `find` with title, branch, PR URL/number if known, and cwd.
|
||||
3. If a high-confidence session is found, ask:
|
||||
`Include a redacted agent transcript? It helps reviewers and can make the PR easier to prioritize. I can open a local preview first.`
|
||||
4. If the user wants preview, run `preview`, open the HTML with `open`, and wait for confirmation.
|
||||
5. If the user approves, run `append-body`.
|
||||
6. Use the enriched body file for creation/update.
|
||||
7. If no safe session is found, say nothing and continue without transcript. If the user declines, continue without transcript.
|
||||
|
||||
## Review Artifacts
|
||||
|
||||
For manual audits across many PR/session candidates, create a local HTML preview from a local JSON file. This is for maintainers only and is not part of the PR/issue workflow:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript html \
|
||||
--prs /tmp/recent-prs.json \
|
||||
--out /tmp/agent-transcript-preview.html
|
||||
```
|
||||
636
.agents/skills/agent-transcript/scripts/agent-transcript
Executable file
636
.agents/skills/agent-transcript/scripts/agent-transcript
Executable file
@@ -0,0 +1,636 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const MARKER_START = "<!-- agent-transcript:start -->";
|
||||
const MARKER_END = "<!-- agent-transcript:end -->";
|
||||
const DEFAULT_MAX_CHARS = 50000;
|
||||
const DEFAULT_ENTRY_MAX_CHARS = 6000;
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
agent-transcript find --query TEXT [--cwd PATH] [--since-days N] [--root PATH...]
|
||||
agent-transcript render --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL]
|
||||
agent-transcript preview --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL]
|
||||
agent-transcript append-body --body FILE --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N]
|
||||
agent-transcript html --prs FILE [--out FILE] [--since-days N] [--min-score N] [--root PATH...] [--exclude-session FILE...]
|
||||
|
||||
Local-only. No network calls.`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) {
|
||||
args._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next == null || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (args[key] == null) args[key] = next;
|
||||
else if (Array.isArray(args[key])) args[key].push(next);
|
||||
else args[key] = [args[key], next];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (value == null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function homePath(...parts) {
|
||||
return path.join(os.homedir(), ...parts);
|
||||
}
|
||||
|
||||
function defaultRoots() {
|
||||
return [
|
||||
homePath(".codex", "sessions"),
|
||||
homePath(".claude", "projects"),
|
||||
homePath(".pi", "agent", "sessions"),
|
||||
];
|
||||
}
|
||||
|
||||
function walkJsonl(root, sinceMs, out = []) {
|
||||
if (!root || !fs.existsSync(root)) return out;
|
||||
const stat = fs.statSync(root);
|
||||
if (stat.isFile()) {
|
||||
if (root.endsWith(".jsonl") && stat.mtimeMs >= sinceMs) out.push(root);
|
||||
return out;
|
||||
}
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
const file = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) walkJsonl(file, sinceMs, out);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
const entryStat = fs.statSync(file);
|
||||
if (entryStat.mtimeMs >= sinceMs) out.push(file);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJsonl(file, maxLines = 12000) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const lines = text.split(/\n+/).filter(Boolean).slice(0, maxLines);
|
||||
const rows = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
rows.push({ type: "unparsed", text: line });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function stringContent(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(stringContent).filter(Boolean).join("\n");
|
||||
if (typeof value === "object") {
|
||||
if (typeof value.text === "string") return value.text;
|
||||
if (typeof value.content === "string") return value.content;
|
||||
if (typeof value.message === "string") return value.message;
|
||||
if (Array.isArray(value.content)) return stringContent(value.content);
|
||||
if (value.type === "text" && value.text) return String(value.text);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function detectAgent(file, rows) {
|
||||
if (file.includes(`${path.sep}.codex${path.sep}`)) return "codex";
|
||||
if (file.includes(`${path.sep}.claude${path.sep}`)) return "claude";
|
||||
if (file.includes(`${path.sep}.pi${path.sep}`)) return "pi";
|
||||
if (rows.some((row) => row?.type === "session_meta" || row?.type === "response_item")) return "codex";
|
||||
if (rows.some((row) => row?.sessionId && row?.userType)) return "claude";
|
||||
return "agent";
|
||||
}
|
||||
|
||||
function eventText(row) {
|
||||
if (row?.type === "event_msg") {
|
||||
const payload = row.payload || {};
|
||||
return stringContent(payload.message || payload.text_elements || payload.content);
|
||||
}
|
||||
if (row?.type === "response_item") {
|
||||
const payload = row.payload || {};
|
||||
return stringContent(payload.content || payload.summary || payload.arguments || payload.output);
|
||||
}
|
||||
if (row?.message) return stringContent(row.message);
|
||||
if (row?.content) return stringContent(row.content);
|
||||
if (row?.text) return stringContent(row.text);
|
||||
return "";
|
||||
}
|
||||
|
||||
function eventRole(row) {
|
||||
if (row?.type === "event_msg") {
|
||||
const type = row.payload?.type;
|
||||
if (type === "user_message") return "user";
|
||||
if (type === "agent_message") return "assistant";
|
||||
if (type === "token_count" || type === "task_started" || type === "task_complete") return null;
|
||||
if (type === "web_search_end") return "web";
|
||||
}
|
||||
if (row?.type === "response_item") {
|
||||
const payload = row.payload || {};
|
||||
if (payload.type === "function_call") return "tool";
|
||||
if (payload.type === "function_call_output") return "tool_output";
|
||||
if (payload.type === "reasoning") return null;
|
||||
if (payload.type === "web_search_call") return "web";
|
||||
if (payload.role === "user") return "user";
|
||||
if (payload.role === "assistant") return "assistant";
|
||||
}
|
||||
if (row?.type === "user") return "user";
|
||||
if (row?.type === "assistant") return "assistant";
|
||||
if (row?.message?.role === "user") return "user";
|
||||
if (row?.message?.role === "assistant") return "assistant";
|
||||
if (row?.type === "tool_result" || row?.type === "tool_use") return "tool";
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSetupBlob(text) {
|
||||
return (
|
||||
text.includes("<INSTRUCTIONS>") ||
|
||||
text.includes("# AGENTS.MD") ||
|
||||
text.includes("Knowledge cutoff:") ||
|
||||
text.includes("You are Codex") ||
|
||||
/\byour instructions\b/i.test(text) ||
|
||||
/\binstructions absorbed\b/i.test(text) ||
|
||||
/\bAGENTS\.md\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function redact(input, stats) {
|
||||
let s = String(input ?? "");
|
||||
const rules = [
|
||||
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"],
|
||||
[/sk-[A-Za-z0-9_-]{20,}/g, "[REDACTED_OPENAI_KEY]"],
|
||||
[/(gh[pousr]_[A-Za-z0-9_]{20,})/g, "[REDACTED_GITHUB_TOKEN]"],
|
||||
[/(AKIA[0-9A-Z]{16})/g, "[REDACTED_AWS_KEY]"],
|
||||
[/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED_JWT]"],
|
||||
[/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "[REDACTED_AUTH_HEADER]"],
|
||||
[/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]"],
|
||||
[/\b(?:\+?\d[\d .()-]{7,}\d)\b/g, "[REDACTED_PHONE]"],
|
||||
[/\/Users\/[^\s`"'>)]+/g, "[LOCAL_PATH]"],
|
||||
[/~\/[^\s`"'>)]+/g, "[HOME_PATH]"],
|
||||
[/([?&](?:token|key|secret|signature|sig|access_token|auth)=)[^\s`"'>&]+/gi, "$1[REDACTED]"],
|
||||
];
|
||||
for (const [re, repl] of rules) {
|
||||
const before = s;
|
||||
s = s.replace(re, repl);
|
||||
if (s !== before) stats.redactions++;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function unsafe(text) {
|
||||
const patterns = [
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
||||
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/i,
|
||||
/\b(?:user_session|_gh_sess|__Host-user_session_same_site|GH_SESSION_TOKEN)\b/i,
|
||||
/\b(?:GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY)\b/,
|
||||
/\/upload\/policies\/assets|uploadToken|authenticity_token/i,
|
||||
];
|
||||
return patterns.filter((pattern) => pattern.test(text)).map((pattern) => String(pattern));
|
||||
}
|
||||
|
||||
function normalizeEntry(role, text, stats, options = {}) {
|
||||
let t = redact(text, stats).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (!t) return null;
|
||||
if (hasSetupBlob(t)) t = "[instructions recap omitted; policy/config text, not task dialogue]";
|
||||
if (unsafe(t).length) t = "[omitted: browser/session/auth internals; not useful for public PR transcript]";
|
||||
const entryMaxChars = Number(options.entryMaxChars || options["entry-max-chars"] || DEFAULT_ENTRY_MAX_CHARS);
|
||||
if (t.length > entryMaxChars) {
|
||||
t = `${t.slice(0, entryMaxChars).trimEnd()}\n...[truncated ${t.length - entryMaxChars} chars]`;
|
||||
}
|
||||
return `[${role}]\n${t}`;
|
||||
}
|
||||
|
||||
function entryRole(entry) {
|
||||
const match = entry.match(/^\[([^\]]+)\]\n/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function entryBody(entry) {
|
||||
return entry.replace(/^\[[^\]]+\]\n/, "");
|
||||
}
|
||||
|
||||
function coalesceEntries(entries) {
|
||||
const coalesced = [];
|
||||
for (const entry of entries) {
|
||||
const role = entryRole(entry);
|
||||
const body = entryBody(entry);
|
||||
const last = coalesced[coalesced.length - 1];
|
||||
if (!last || !role || entryRole(last) !== role || role === "tool summary") {
|
||||
coalesced.push(entry);
|
||||
continue;
|
||||
}
|
||||
const lastBody = entryBody(last);
|
||||
if (lastBody === body || lastBody.includes(body)) continue;
|
||||
if (body.includes(lastBody)) {
|
||||
coalesced[coalesced.length - 1] = `[${role}]\n${body}`;
|
||||
continue;
|
||||
}
|
||||
coalesced[coalesced.length - 1] = `[${role}]\n${lastBody}\n\n${body}`;
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
function toolFamily(name) {
|
||||
const normalized = String(name).toLowerCase();
|
||||
if (
|
||||
/(read|fetch|open|list|find|search|grep|rg|sed|cat|head|tail|jq|wc|status|diff|show|view|snapshot|screenshot)/.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return "read";
|
||||
}
|
||||
if (/(write|edit|patch|apply|create|update|append|save|comment|fill|click|type|navigate|upload)/.test(normalized)) {
|
||||
return "write";
|
||||
}
|
||||
if (/(exec|command|shell|run|test|build|lint|format|install|pnpm|npm|node|git|gh|ssh)/.test(normalized)) {
|
||||
return "execute";
|
||||
}
|
||||
if (/(web|http|fetch|browser|chrome|github|dropbox|notion|gmail|calendar)/.test(normalized)) {
|
||||
return "network";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function shellFamily(command) {
|
||||
const cmd = String(command || "").trim();
|
||||
if (!cmd) return "execute";
|
||||
if (
|
||||
/^(rg|grep|sed|cat|head|tail|jq|wc|ls|find|pwd|git (status|diff|show|log|blame)|gh (pr|issue|api|run|repo|auth) (view|list|status)|test |stat |ps |which |command -v )\b/.test(
|
||||
cmd,
|
||||
)
|
||||
) {
|
||||
return "read";
|
||||
}
|
||||
if (/^(open |chmod |mkdir |touch |cp |mv |kill |git add|git commit|git push|gh pr create|gh issue create)\b/.test(cmd)) {
|
||||
return "write";
|
||||
}
|
||||
if (/^(node|npm|pnpm|bun|python|python3|ruby|tsx|tsgo|make|cargo|go test|swift|xcodebuild)\b/.test(cmd)) {
|
||||
return "execute";
|
||||
}
|
||||
if (/^(ssh|curl|wget|tailscale|nc )\b/.test(cmd)) return "network";
|
||||
return "execute";
|
||||
}
|
||||
|
||||
function toolCallFamily(row) {
|
||||
const name = row.payload?.name || row.name || row.message?.name || row.type || "tool";
|
||||
if (name === "exec_command") {
|
||||
try {
|
||||
const args = JSON.parse(row.payload?.arguments || "{}");
|
||||
return shellFamily(args.cmd);
|
||||
} catch {
|
||||
return "execute";
|
||||
}
|
||||
}
|
||||
if (name === "apply_patch") return "write";
|
||||
if (name === "write_stdin") return "execute";
|
||||
return toolFamily(name);
|
||||
}
|
||||
|
||||
function compactToolSummary(familyCounts, dropped) {
|
||||
const families = new Map();
|
||||
for (const [family, count] of familyCounts.entries()) {
|
||||
families.set(family, (families.get(family) || 0) + count);
|
||||
}
|
||||
const ordered = ["read", "write", "execute", "network", "other"]
|
||||
.map((family) => [family, families.get(family) || 0])
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([family, count]) => `${count} ${family}`);
|
||||
const calls = ordered.length ? ordered.join(", ") : "0 tool";
|
||||
return `${calls}; raw tool outputs dropped: ${dropped}`;
|
||||
}
|
||||
|
||||
function recountEntries(stats, entries) {
|
||||
stats.rawEntries = stats.entries;
|
||||
stats.entries = entries.length;
|
||||
stats.user = entries.filter((entry) => entry.startsWith("[user]\n")).length;
|
||||
stats.assistant = entries.filter((entry) => entry.startsWith("[assistant]\n")).length;
|
||||
}
|
||||
|
||||
function renderSession(file, options = {}) {
|
||||
const rows = readJsonl(file);
|
||||
const agent = detectAgent(file, rows);
|
||||
const stats = {
|
||||
agent,
|
||||
entries: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolOutputsDropped: 0,
|
||||
web: 0,
|
||||
redactions: 0,
|
||||
omittedUnsafe: 0,
|
||||
};
|
||||
const toolCounts = new Map();
|
||||
const items = [];
|
||||
const seenEntries = new Set();
|
||||
const hasEventDialogue = rows.some((row) => {
|
||||
const type = row?.type === "event_msg" ? row.payload?.type : null;
|
||||
return type === "user_message" || type === "agent_message";
|
||||
});
|
||||
for (const row of rows) {
|
||||
const role = eventRole(row);
|
||||
if (!role) continue;
|
||||
if (hasEventDialogue && row.type === "response_item" && (role === "user" || role === "assistant")) {
|
||||
continue;
|
||||
}
|
||||
if (role === "tool_output") {
|
||||
stats.toolOutputsDropped++;
|
||||
continue;
|
||||
}
|
||||
if (role === "tool") {
|
||||
const family = toolCallFamily(row);
|
||||
toolCounts.set(family, (toolCounts.get(family) || 0) + 1);
|
||||
stats.toolCalls++;
|
||||
continue;
|
||||
}
|
||||
if (role === "web") {
|
||||
stats.web++;
|
||||
continue;
|
||||
}
|
||||
const before = eventText(row);
|
||||
const entry = normalizeEntry(role, before, stats, options);
|
||||
if (!entry) continue;
|
||||
const dedupeKey = entry.replace(/\s+/g, " ").trim();
|
||||
if (seenEntries.has(dedupeKey)) continue;
|
||||
seenEntries.add(dedupeKey);
|
||||
if (entry.includes("[omitted: browser/session/auth internals")) stats.omittedUnsafe++;
|
||||
items.push(entry);
|
||||
stats.entries++;
|
||||
if (role === "user") stats.user++;
|
||||
if (role === "assistant") stats.assistant++;
|
||||
}
|
||||
if (toolCounts.size) {
|
||||
items.push(`[tool summary]\n${compactToolSummary(toolCounts, stats.toolOutputsDropped)}`);
|
||||
stats.entries++;
|
||||
}
|
||||
const renderedItems = coalesceEntries(items);
|
||||
recountEntries(stats, renderedItems);
|
||||
const maxChars = Number(options.maxChars || DEFAULT_MAX_CHARS);
|
||||
let joined = renderedItems.join("\n\n");
|
||||
if (joined.length > maxChars) joined = `${joined.slice(0, maxChars).trimEnd()}\n\n...[transcript truncated to ${maxChars} chars]`;
|
||||
const headerBits = [options.title, options.url].filter(Boolean).join(" | ");
|
||||
const unsafeAfter = unsafe(joined);
|
||||
const safe = unsafeAfter.length === 0;
|
||||
const markdown = `${MARKER_START}
|
||||
## Agent Transcript
|
||||
|
||||
<details>
|
||||
<summary>Redacted ${agent} session transcript${headerBits ? `: ${redact(headerBits, stats)}` : ""}</summary>
|
||||
|
||||
\`\`\`\`text
|
||||
source: [LOCAL_SESSION]
|
||||
redaction: local paths, emails, phone-shaped strings, token-shaped strings, auth headers, auth query params
|
||||
omitted: raw tool outputs, system/developer prompts, local paths, secrets, browser/session/auth details
|
||||
stats: ${JSON.stringify(stats)}
|
||||
|
||||
${joined}
|
||||
\`\`\`\`
|
||||
|
||||
</details>
|
||||
${MARKER_END}
|
||||
`;
|
||||
return { file, agent, safe, unsafeAfter, stats, markdown };
|
||||
}
|
||||
|
||||
function readBoundedText(file, maxBytes = 220000) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.size <= maxBytes) {
|
||||
const buffer = Buffer.alloc(stat.size);
|
||||
fs.readSync(fd, buffer, 0, stat.size, 0);
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
const half = Math.floor(maxBytes / 2);
|
||||
const head = Buffer.alloc(half);
|
||||
const tail = Buffer.alloc(half);
|
||||
fs.readSync(fd, head, 0, half, 0);
|
||||
fs.readSync(fd, tail, 0, half, Math.max(0, stat.size - half));
|
||||
return `${head.toString("utf8")}\n[...middle omitted for scan...]\n${tail.toString("utf8")}`;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionScanRecord(file) {
|
||||
const stat = fs.statSync(file);
|
||||
let agent = "agent";
|
||||
try {
|
||||
agent = detectAgent(file, readJsonl(file, 50));
|
||||
} catch {}
|
||||
return {
|
||||
file,
|
||||
agent,
|
||||
mtime: new Date(stat.mtimeMs).toISOString(),
|
||||
haystack: `${file}\n${readBoundedText(file)}`.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function scoreScanRecord(record, terms, cwd) {
|
||||
const haystack = record.haystack;
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
for (const term of terms) {
|
||||
const normalized = term.toLowerCase().trim();
|
||||
if (normalized.length < 3) continue;
|
||||
if (haystack.includes(normalized)) {
|
||||
score += Math.min(20, Math.max(3, Math.floor(normalized.length / 3)));
|
||||
reasons.push(normalized.slice(0, 80));
|
||||
}
|
||||
}
|
||||
if (cwd) {
|
||||
const cwdLower = cwd.toLowerCase();
|
||||
if (haystack.includes(cwdLower) || record.file.toLowerCase().includes(cwdLower.replaceAll("/", "-"))) {
|
||||
score += 8;
|
||||
reasons.push("cwd");
|
||||
}
|
||||
}
|
||||
return { file: record.file, score, reasons, mtime: record.mtime, agent: record.agent };
|
||||
}
|
||||
|
||||
function findSessions(options) {
|
||||
const sinceDays = Number(options["since-days"] || 14);
|
||||
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
||||
const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots();
|
||||
const query = String(options.query || "");
|
||||
const terms = query
|
||||
.split(/\s+/)
|
||||
.concat(query.match(/https?:\/\/\S+/g) || [])
|
||||
.filter(Boolean);
|
||||
const files = roots.flatMap((root) => walkJsonl(root, sinceMs));
|
||||
const results = files
|
||||
.map((file) => scoreScanRecord(sessionScanRecord(file), terms, options.cwd))
|
||||
.filter((result) => result.score > 0)
|
||||
.sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime))
|
||||
.slice(0, Number(options.limit || 10));
|
||||
return results;
|
||||
}
|
||||
|
||||
function sessionScanRecords(options) {
|
||||
const sinceDays = Number(options["since-days"] || 14);
|
||||
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
||||
const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots();
|
||||
const excluded = new Set(asArray(options["exclude-session"]).map((file) => path.resolve(file)));
|
||||
return roots
|
||||
.flatMap((root) => walkJsonl(root, sinceMs))
|
||||
.filter((file) => !excluded.has(path.resolve(file)))
|
||||
.map(sessionScanRecord);
|
||||
}
|
||||
|
||||
function replaceSection(body, section) {
|
||||
const start = body.indexOf(MARKER_START);
|
||||
const end = body.indexOf(MARKER_END);
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return `${body.slice(0, start).trimEnd()}\n\n${section.trim()}\n\n${body.slice(end + MARKER_END.length).trimStart()}`;
|
||||
}
|
||||
return `${body.trimEnd()}\n\n${section.trim()}\n`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function htmlDocument(records) {
|
||||
const rows = records
|
||||
.map((record) => `<section>
|
||||
<h2><a href="${escapeHtml(record.url || "")}">${escapeHtml(record.title || record.url || "PR")}</a></h2>
|
||||
<p><code>${escapeHtml(record.session ? "[LOCAL_SESSION]" : "no session")}</code> score: ${escapeHtml(record.score ?? "")} safe: ${escapeHtml(record.safe ?? "")}</p>
|
||||
<pre>${escapeHtml(record.markdown || record.error || "")}</pre>
|
||||
</section>`)
|
||||
.join("\n");
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Agent Transcript Preview</title>
|
||||
<style>
|
||||
body{font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:32px;color:#1f2328;background:#fff}
|
||||
section{border-top:1px solid #d0d7de;padding:24px 0}
|
||||
h1,h2{line-height:1.2}
|
||||
pre{white-space:pre-wrap;background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:16px;overflow:auto}
|
||||
code{background:#f6f8fa;padding:2px 4px;border-radius:4px}
|
||||
a{color:#0969da}
|
||||
</style>
|
||||
<h1>Agent Transcript Preview</h1>
|
||||
${rows}
|
||||
`;
|
||||
}
|
||||
|
||||
function singlePreviewDocument(record) {
|
||||
return htmlDocument([record]);
|
||||
}
|
||||
|
||||
function readPrs(file) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : parsed.items || parsed.prs || [];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const args = parseArgs(rest);
|
||||
if (!command || command === "--help" || command === "-h" || args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
if (command === "find") {
|
||||
console.log(JSON.stringify(findSessions(args), null, 2));
|
||||
return;
|
||||
}
|
||||
if (command === "render") {
|
||||
if (!args.session) throw new Error("--session is required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
if (args.out) fs.writeFileSync(args.out, rendered.markdown);
|
||||
else process.stdout.write(rendered.markdown);
|
||||
return;
|
||||
}
|
||||
if (command === "preview") {
|
||||
if (!args.session) throw new Error("--session is required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
const output = singlePreviewDocument({
|
||||
title: args.title || "Agent Transcript Preview",
|
||||
url: args.url || "",
|
||||
session: args.session,
|
||||
safe: rendered.safe,
|
||||
markdown: rendered.markdown,
|
||||
});
|
||||
if (args.out) fs.writeFileSync(args.out, output);
|
||||
else process.stdout.write(output);
|
||||
return;
|
||||
}
|
||||
if (command === "append-body") {
|
||||
if (!args.body || !args.session) throw new Error("--body and --session are required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
const body = fs.readFileSync(args.body, "utf8");
|
||||
const next = replaceSection(body, rendered.markdown);
|
||||
if (args.out) fs.writeFileSync(args.out, next);
|
||||
else process.stdout.write(next);
|
||||
return;
|
||||
}
|
||||
if (command === "html") {
|
||||
if (!args.prs) throw new Error("--prs is required");
|
||||
const records = [];
|
||||
const scanRecords = sessionScanRecords(args);
|
||||
const minScore = Number(args["min-score"] || 50);
|
||||
for (const pr of readPrs(args.prs)) {
|
||||
const query = [pr.url, pr.number ? `#${pr.number}` : "", pr.number, pr.title, pr.headRefName, pr.headRefName || pr.branch]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const terms = query
|
||||
.split(/\s+/)
|
||||
.concat(query.match(/https?:\/\/\S+/g) || [])
|
||||
.filter(Boolean);
|
||||
const [candidate] = scanRecords
|
||||
.map((record) => scoreScanRecord(record, terms, args.cwd))
|
||||
.filter((result) => result.score >= minScore)
|
||||
.sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime));
|
||||
if (!candidate) {
|
||||
records.push({ ...pr, error: "No local session match found." });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const rendered = renderSession(candidate.file, { ...args, title: pr.title, url: pr.url });
|
||||
records.push({
|
||||
...pr,
|
||||
session: candidate.file,
|
||||
score: candidate.score,
|
||||
safe: rendered.safe,
|
||||
markdown: rendered.markdown,
|
||||
});
|
||||
} catch (error) {
|
||||
records.push({ ...pr, session: candidate.file, score: candidate.score, error: String(error) });
|
||||
}
|
||||
}
|
||||
const output = htmlDocument(records);
|
||||
if (args.out) fs.writeFileSync(args.out, output);
|
||||
else process.stdout.write(output);
|
||||
return;
|
||||
}
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user