f51a6a895e
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the chat loads the newest 50 messages, and the turn's other Write (rbac_routes_test.go, +176) was in the part before the page. - turnCarry (agent-edits.js): every message the route's sliding window drops goes through it; it keeps the file changes since the last prompt it dropped (a dropped call whose result is on the page, and dropped subagents, included). - A Claude page that starts mid-turn answers them as `turn_edits`; the chat keeps the one from the page that holds its oldest message and adds it to that turn's card. Files a turn changed through Bash (python/sed writing a file) are still not listed: nothing in the transcript says which files a command wrote.
208 lines
7.6 KiB
JavaScript
208 lines
7.6 KiB
JavaScript
/**
|
|
* @file agent-edits.js
|
|
* @description The files a Claude subagent wrote or edited, read from its own
|
|
* transcript (`agent-<id>.jsonl`), so the parent's chat can count them in its
|
|
* turn's "Edited N files" card (issue #86: a skill that hands its work to
|
|
* subagents showed only the parent's own edits). Each file's +/- lines come
|
|
* from the same `patchCounts` the chat uses; a failed call counts nothing; a
|
|
* subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is
|
|
* read through `readLines` (a member's transcript only as that member's), and
|
|
* the answer is cached by path + size + mtime, so a finished subagent is read
|
|
* once however often its parent's chat is polled. `turnCarry` does the same
|
|
* for the part of a turn the chat's page of messages cut off.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const { patchCounts, agentInfo } = require("./transcript-message");
|
|
const {
|
|
getSubagentTranscriptPath,
|
|
findSubagentTranscriptPath,
|
|
getSnapshotSubagentTranscriptPath,
|
|
} = require("./claude-home");
|
|
|
|
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
|
const MAX_DEPTH = 3;
|
|
const CACHE_MAX = 500;
|
|
// path → { key: "<size>:<mtime>", edits, agents }
|
|
const cache = new Map();
|
|
|
|
/** One transcript's own edits and the subagents it started. */
|
|
async function scan(file) {
|
|
const { readLines } = require("./run-as");
|
|
const pending = new Map(); // tool_use id → file path
|
|
const edits = new Map(); // path → { added, removed, counted }
|
|
const agents = [];
|
|
for await (const line of readLines(file)) {
|
|
if (!line.trim()) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const content = entry.message?.content;
|
|
if (!Array.isArray(content)) continue;
|
|
if (entry.type === "assistant") {
|
|
for (const b of content) {
|
|
if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue;
|
|
const p = b.input?.file_path ?? b.input?.notebook_path;
|
|
if (typeof p === "string" && p) pending.set(b.id, p);
|
|
}
|
|
} else if (entry.type === "user") {
|
|
for (const b of content) {
|
|
if (b?.type !== "tool_result") continue;
|
|
const agent = agentInfo(entry.toolUseResult);
|
|
if (agent) agents.push(agent.id);
|
|
const p = pending.get(b.tool_use_id);
|
|
if (!p || b.is_error) continue;
|
|
const diff = patchCounts(entry.toolUseResult);
|
|
const f = edits.get(p) ?? { added: 0, removed: 0, counted: false };
|
|
f.added += diff?.added ?? 0;
|
|
f.removed += diff?.removed ?? 0;
|
|
f.counted ||= !!diff;
|
|
edits.set(p, f);
|
|
}
|
|
}
|
|
}
|
|
return { edits, agents };
|
|
}
|
|
|
|
async function scanCached(file) {
|
|
const st = await fs.promises.stat(file);
|
|
const key = `${st.size}:${st.mtimeMs}`;
|
|
const hit = cache.get(file);
|
|
if (hit && hit.key === key) return hit;
|
|
const fresh = { key, ...(await scan(file)) };
|
|
cache.delete(file);
|
|
cache.set(file, fresh);
|
|
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
return fresh;
|
|
}
|
|
|
|
const transcriptOf = (sessionId, cwd, agentId) =>
|
|
getSubagentTranscriptPath(sessionId, cwd, agentId) ||
|
|
findSubagentTranscriptPath(sessionId, agentId, null, cwd) ||
|
|
getSnapshotSubagentTranscriptPath(sessionId, agentId);
|
|
|
|
/**
|
|
* The files subagent `agentId` of session `sessionId` (and its own subagents)
|
|
* changed, as `[{ path, added, removed, counted }]` in first-edit order; an
|
|
* empty list when its transcript cannot be found or read.
|
|
*/
|
|
async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) {
|
|
if (seen.has(agentId)) return [];
|
|
seen.add(agentId);
|
|
let file;
|
|
let got;
|
|
try {
|
|
file = transcriptOf(sessionId, cwd, agentId);
|
|
if (!file) return [];
|
|
got = await scanCached(file);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out = new Map();
|
|
const add = (p, f) => {
|
|
const o = out.get(p) ?? { added: 0, removed: 0, counted: false };
|
|
o.added += f.added;
|
|
o.removed += f.removed;
|
|
o.counted ||= f.counted;
|
|
out.set(p, o);
|
|
};
|
|
got.edits.forEach((f, p) => add(p, f));
|
|
if (depth < MAX_DEPTH) {
|
|
for (const id of got.agents) {
|
|
for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f);
|
|
}
|
|
}
|
|
return [...out].map(([path, f]) => ({ path, ...f }));
|
|
}
|
|
|
|
/** Puts `edits` on every Agent/Task result in `messages` (in place). */
|
|
async function attachAgentEdits(messages, sessionId, cwd) {
|
|
for (const m of messages) {
|
|
for (const c of m.content ?? []) {
|
|
if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue;
|
|
const edits = await agentEdits(sessionId, cwd, c.agent.id);
|
|
if (edits.length) c.edits = edits;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A human prompt: the line that starts a turn (the chat's own rule). */
|
|
const isPrompt = (m) =>
|
|
(m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" &&
|
|
(m.content ?? []).some((c) => c.type === "text");
|
|
|
|
/**
|
|
* The edits of the turn a page of the chat starts in the middle of. The route
|
|
* keeps only the newest `limit` messages; every message it drops goes through
|
|
* `drop()`, which keeps the file changes since the last prompt it dropped.
|
|
* `result(window)` then answers the files that turn changed BEFORE the page's
|
|
* first message — `[{ path, added, removed, counted }]`, or null when the page
|
|
* starts on a prompt or nothing was changed — so the turn's "Edited N files"
|
|
* card is whole although its start was never sent (issue #86: a 120-message
|
|
* turn showed 1 of its files).
|
|
*/
|
|
function turnCarry() {
|
|
let pending = new Map(); // dropped Write/Edit call id → path
|
|
let pendingAgents = new Set(); // dropped Agent/Task call ids
|
|
let files = new Map();
|
|
let agents = [];
|
|
const add = (p, f) => {
|
|
const o = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
|
o.added += f?.added ?? 0;
|
|
o.removed += f?.removed ?? 0;
|
|
o.counted ||= !!f && f.counted !== false;
|
|
files.set(p, o);
|
|
};
|
|
return {
|
|
drop(m) {
|
|
if (!m) return;
|
|
if (isPrompt(m)) {
|
|
pending = new Map();
|
|
pendingAgents = new Set();
|
|
files = new Map();
|
|
agents = [];
|
|
return;
|
|
}
|
|
for (const c of m.content ?? []) {
|
|
if (c.type === "tool_use" && c.id) {
|
|
if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id);
|
|
if (!EDIT_TOOLS.has(c.name)) continue;
|
|
const p = c.input?.file_path ?? c.input?.notebook_path;
|
|
if (typeof p === "string" && p) pending.set(c.id, p);
|
|
} else if (c.type === "tool_result") {
|
|
if (c.agent?.id && !c.is_error) agents.push(c.agent.id);
|
|
pendingAgents.delete(c.id);
|
|
const p = pending.get(c.id);
|
|
if (!p) continue;
|
|
pending.delete(c.id);
|
|
if (!c.is_error) add(p, c.diff ?? null);
|
|
}
|
|
}
|
|
},
|
|
async result(window, sessionId, cwd) {
|
|
if (!window.length || isPrompt(window[0])) return null;
|
|
// A call dropped whose result is on the page: the page shows the result
|
|
// with no call to hang it on, so it counts here.
|
|
for (const m of window) {
|
|
for (const c of m.content ?? []) {
|
|
if (c.type !== "tool_result" || c.is_error) continue;
|
|
const p = pending.get(c.id);
|
|
if (p) add(p, c.diff ?? null);
|
|
if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e);
|
|
}
|
|
}
|
|
for (const id of agents) {
|
|
for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e);
|
|
}
|
|
return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };
|