myxce/.wolf/hooks/post-read.js
2026-08-27 08:27:43 +00:00

102 lines
No EOL
4.3 KiB
JavaScript

import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, estimateTokens, readStdin, normalizePath, getProjectDir, hookMain, getSessionFilePath } from "./shared.js";
import { lookupEntry } from "./anatomy-store.js";
/**
* The PostToolUse payload carries the tool result in `tool_response`, whose
* shape depends on the tool and harness version: a plain string, an array of
* content blocks, or a structured object ({content} or {file:{content}}).
* Older builds of this hook read a `tool_output` field that never existed in
* Claude Code's payload, so read-token tracking was always zero (issue: the
* ledger under-reported every session).
*/
function extractToolResponseText(resp) {
if (typeof resp === "string")
return resp;
if (Array.isArray(resp)) {
return resp
.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : ""))
.join("");
}
if (resp && typeof resp === "object") {
const obj = resp;
if (typeof obj.content === "string")
return obj.content;
if (obj.file && typeof obj.file.content === "string")
return obj.file.content;
}
return "";
}
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
return;
}
const sessionFile = getSessionFilePath(input);
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const content = extractToolResponseText(input.tool_response) || input.tool_output?.content || "";
if (!filePath) {
return;
}
// Ranged reads: pre-read already recorded the contact with ranged:true.
// Registering them as full reads here is what used to make a later
// legitimate full read look like a duplicate (~20x warning inflation).
if (input.tool_input?.offset !== undefined || input.tool_input?.limit !== undefined) {
return;
}
const normalizedFile = normalizePath(filePath);
// Skip tracking for .wolf/ internal files — consistent with pre-read
const projectDir = normalizePath(getProjectDir());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
// 2.4: measure OpenWolf's own context cost instead of hiding it. Tagged
// separately from project reads so anatomy hit-rates stay meaningful.
try {
if (content) {
const session = readJSON(sessionFile, { files_read: {} });
const tok = estimateTokens(content, "prose");
session.wolf_internal_tokens = (session.wolf_internal_tokens ?? 0) + tok;
const perFile = (session.wolf_internal_reads ?? {});
perFile[relToProject] = (perFile[relToProject] ?? 0) + tok;
session.wolf_internal_reads = perFile;
writeJSON(sessionFile, session);
}
}
catch { }
return;
}
const ext = path.extname(filePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".css", ".json", ".yaml", ".yml"]);
const proseExts = new Set([".md", ".txt", ".rst"]);
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
let tokens = content ? estimateTokens(content, type) : 0;
// Fallback: if tool_output had no content, use the anatomy token estimate
if (tokens === 0) {
const entry = lookupEntry(wolfDir, projectDir, normalizedFile);
if (entry)
tokens = entry.tokens;
}
const session = readJSON(sessionFile, { files_read: {} });
const existing = session.files_read[normalizedFile];
if (existing && existing.ranged !== true) {
existing.tokens = tokens;
}
else {
// Fresh full read (or an upgrade of a ranged-only contact to a full read).
session.files_read[normalizedFile] = {
count: 1,
tokens,
first_read: existing?.first_read ?? new Date().toISOString(),
};
}
writeJSON(sessionFile, session);
}
hookMain("post-read", main);
//# sourceMappingURL=post-read.js.map