Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf

- sabia-core: descoberta de interfaces, controle de modo monitor/canal
  via iw (ponte até ter nl80211 nativo), captura crua 802.11 via
  AF_PACKET (sem libpcap), parsing de radiotap e quadros de
  gerenciamento (beacon/probe/EAPOL), agregação em tabela de APs/
  clientes, escrita de .pcap, trilha de auditoria encadeada por hash,
  auto-atualização via GitHub releases (curl, sem dependências de
  rede extras)
- sabia-cli: `sabia iface`, `sabia scan`, `sabia update`,
  `sabia audit-verify`, `sabia ui` (servidor local + navegador,
  protegido por token, mesma filosofia do Guará)
- OpenWolf inicializado para memória de projeto entre agentes
This commit is contained in:
Lucas committed 2026-10-23 08:22:04 -03:00
commit e989413018
76 files changed
+8386

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
// OpenWolf plugin entry — implementation lives in ./openwolf/.
import {OpenWolf as installed} from "./openwolf/index.js"
import {pinRuntime, scheduleUpdate} from "./openwolf/runtime-updates.js"
import {pathToFileURL} from "node:url"
import * as path from "node:path"
import type {Plugin} from "@opencode-ai/plugin"
export const OpenWolf: Plugin = async (ctx) => {
scheduleUpdate(ctx.directory)
// The server's plugin instance remains fixed; no reload during a session.
let pkg: string | undefined
try { pkg = pinRuntime(ctx.directory, "opencode-server:" + process.pid + ":" + Date.now()) } catch {}
if (pkg) {
try {
const next = await import(pathToFileURL(path.join(pkg,"src/templates/opencode-plugin/index.ts")).href)
return await next.OpenWolf(ctx)
} catch {} // retain the installed plugin if the staged module cannot load
}
return installed(ctx)
}
+196
View File
@@ -0,0 +1,196 @@
import { normalizeSession } from "./session-state.js";
export { normalizeSession } from "./session-state.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as crypto from "node:crypto";
// Cross-process mutual exclusion for anatomy writers (OPENWOLF-2.0 §F2b).
//
// Mechanism: lockfile created with the "wx" flag (atomic O_EXCL on macOS,
// Linux and Windows, no native deps). The file body records the owner for
// staleness detection. A stale lock (older than STALE_MS, or a dead pid on
// the same host) is stolen via rename-then-unlink: rename is atomic, so of N
// competing stealers exactly one wins and the rest keep waiting.
//
// Callers NEVER block the agent: on budget exhaustion withAnatomyLock returns
// null and the caller skips its update (the next writer converges the state).
// Self-contained on purpose: compiled standalone into the hooks bundle and
// imported directly by tests.
const LOCK_FILE = "anatomy-index.lock";
const STALE_MS = 10_000; // > hook timeout, so a killed hook's lock is reclaimable
export const HOOK_LOCK_BUDGET_MS = 2_000;
export const CLI_LOCK_BUDGET_MS = 5_000;
interface LockBody {
pid: number;
hostname: string;
acquiredAt: number;
nonce?: string;
}
/** Dependency-free synchronous sleep. */
function sleep(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function tryAcquire(lockPath: string): string | null {
const nonce = crypto.randomBytes(16).toString("hex");
try {
const body: LockBody = { pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now(), nonce };
fs.writeFileSync(lockPath, JSON.stringify(body), { flag: "wx" });
return nonce;
} catch { return null; }
}
function isStale(lockPath: string): boolean {
try {
const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as LockBody;
// Age never grants permission to evict a live writer.
if (body.hostname !== os.hostname() || !Number.isSafeInteger(body.pid) || body.pid <= 0) return false;
try { process.kill(body.pid, 0); return false; }
catch (err) { return (err as NodeJS.ErrnoException).code === "ESRCH"; }
} catch {
try { return Date.now() - fs.statSync(lockPath).mtimeMs > STALE_MS; }
catch { return false; }
}
}
function trySteal(lockPath: string): void {
// Serialize reapers, then inspect AGAIN. A stale observation made before
// another reaper ran must never rename a new owner's lock (#83).
const reaper = lockPath + ".reap";
let fd: number;
try { fd = fs.openSync(reaper, "wx"); } catch { return; }
try {
if (isStale(lockPath)) {
try { fs.unlinkSync(lockPath); } catch {}
}
} finally {
fs.closeSync(fd);
try { fs.unlinkSync(reaper); } catch {}
}
}
/**
* Run `fn` while holding the named lockfile. Returns fn's result, or null if
* the lock could not be acquired within `budgetMs` (caller must degrade
* gracefully — skip the update, never block). Same mechanism as the anatomy
* lock; used for the other multi-writer JSON files (cron-state, token-ledger).
*/
export function acquireLock(lockPath: string, budgetMs: number): (() => void) | null {
const deadline = Date.now() + budgetMs;
let attempt = 0;
let nonce: string | null = null;
while (true) {
nonce = tryAcquire(lockPath);
if (nonce !== null) break;
if (isStale(lockPath)) trySteal(lockPath);
if (Date.now() >= deadline) return null;
// Start tight, then back off. A contended session-state critical section
// is 1-3 ms, so the old flat 25-50 ms poll meant a 60-way herd needed
// roughly 2 s just to drain — precisely the hook budget, which made the
// slowest few writers give up and skip their update under load. The
// capped exponential keeps long holders (anatomy writes) from spinning.
const base = Math.min(2 * 2 ** Math.min(attempt, 4), 32);
sleep(base + Math.floor(Math.random() * base));
attempt++;
}
return () => {
try {
const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")) as LockBody;
if (owner.nonce === nonce) fs.unlinkSync(lockPath);
} catch {}
}
}
export function withFileLock<T>(lockPath: string, budgetMs: number, fn: () => T): T | null {
const release = acquireLock(lockPath, budgetMs);
if (!release) return null;
try { return fn(); } finally { release(); }
}
/**
* Serialized read-modify-write against a shared JSON file.
*
* Issues #83, #84, #86 and #88, each reported with a fix by @davdittrich
* (PRs #98, #109, #108, #105). This is the one helper those four sites share.
*
* Atomic writes (tmp + rename) prevent TORN files. They do not prevent LOST
* UPDATES: two processes that each read, modify, and write independently both
* produce a valid file, and the second one silently erases the first one's
* change. Measured on real hooks: 60 concurrent post-read processes kept 26 of
* 60 reads (#83); 60 concurrent registrations kept 43 of 60 projects (#88);
* 60 concurrent SessionStart hooks counted 35 of 60 sessions (#84).
*
* The fix is not a better write, it is a serialized transaction. `mutate`
* receives the CURRENT on-disk value, read inside the lock, and its result is
* written before the lock is released. Never apply a delta to a snapshot read
* before calling this.
*
* Returns the written value, or null if the lock could not be acquired within
* `budgetMs` — callers must degrade, never block the agent. There is no
* unlocked fallback on purpose: writing anyway on timeout defeats the lock for
* every well-behaved writer (#86).
*/
export function mutateJSON<T>(
filePath: string,
fallback: T,
budgetMs: number,
mutate: (current: T) => T | void,
): T | null {
// The lockfile lives next to the target, so its directory has to exist
// BEFORE the first acquire attempt. Without this, a session file whose
// directory has not been created yet can never take its own lock: every
// tryAcquire fails with ENOENT, the whole budget is burned waiting, and the
// update is skipped entirely.
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
} catch {}
return withFileLock(filePath + ".lock", budgetMs, () => {
let current: T;
try {
current = JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
current = fallback;
}
if (/[/\\]hooks[/\\]sessions[/\\][^/\\]+\.json$/.test(filePath)) {
current = normalizeSession(current, path.basename(filePath, ".json")) as T;
}
const returned = mutate(current);
const next = returned === undefined ? current : (returned as T);
writeJSONAtomic(filePath, next);
return next;
});
}
/** tmp + rename, with the Windows fallback used elsewhere in the codebase. */
function writeJSONAtomic(filePath: string, data: unknown): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const body = JSON.stringify(data, null, 2);
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, body, "utf-8");
fs.renameSync(tmp, filePath);
} catch {
try { fs.unlinkSync(tmp); } catch {}
throw new Error(`Atomic write failed: ${filePath}`);
}
}
/**
* Run `fn` while holding the anatomy lock. Returns fn's result, or null if
* the lock could not be acquired within `budgetMs`.
*/
export function withAnatomyLock<T>(wolfDir: string, budgetMs: number, fn: () => T): T | null {
return withFileLock(path.join(wolfDir, LOCK_FILE), budgetMs, fn);
}
/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */
+424
View File
@@ -0,0 +1,424 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { AnatomyEntry } from "./types.js"
// ── Markdown format (canonical — mirrors src/hooks/anatomy-store.ts) ────────
// The auto-generated header block: never captured as preamble, or every
// import→render cycle would duplicate it.
const AUTO_HEADER = /^(?:# anatomy\.md\s*$|> Auto-maintained by OpenWolf\.|> Files:\s*\d+\s*tracked)/
// Legacy symbol sub-bullets (2.0.0–2.0.3 renders): mechanical output, dropped
// rather than preserved. Any OTHER indented bullet is hand-written and kept.
const SYMBOL_SUBBULLET = /^ {2}- (?:fn|class|method|section) `[^`]+` L\d+-\d+ \(~\d+ tok\)$/
/** Strip leading/trailing blank lines so preserved blocks don't grow by two lines per write. */
function trimBlankEdges(lines: string[]): string[] {
let start = 0
let end = lines.length
while (start < end && lines[start].trim() === "") start++
while (end > start && lines[end - 1].trim() === "") end--
return lines.slice(start, end)
}
export function parseAnatomyWithRaw(content: string): {
sections: Map<string, AnatomyEntry[]>
rawLines: Map<string, string[]>
preamble: string[]
} {
const sections = new Map<string, AnatomyEntry[]>()
const rawLines = new Map<string, string[]>()
const preamble: string[] = []
let currentSection = ""
for (const raw of content.split("\n")) {
const line = raw.replace(/\r$/, "")
const sm = line.match(/^## (.+)/)
if (sm) {
currentSection = sm[1].trim()
if (!sections.has(currentSection)) sections.set(currentSection, [])
continue
}
if (!currentSection) {
// Above the first section heading: preserve everything hand-written
// (issue #61), skipping only our own generated header block.
if (!AUTO_HEADER.test(line)) preamble.push(line)
continue
}
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/)
if (em) {
sections.get(currentSection)!.push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
})
continue
}
if (line.trim() === "") continue
if (SYMBOL_SUBBULLET.test(line)) continue
if (!rawLines.has(currentSection)) rawLines.set(currentSection, [])
rawLines.get(currentSection)!.push(line)
}
return { sections, rawLines, preamble: trimBlankEdges(preamble) }
}
export function parseAnatomy(content: string): Map<string, AnatomyEntry[]> {
return parseAnatomyWithRaw(content).sections
}
// serializeAnatomy was removed: it could not carry rawLines or preamble, and
// every writer must go through renderStore so preserved content survives
// (issue #61).
export function extractDescription(filePath: string): string {
const MAX_DESC = 150
const basename = path.basename(filePath)
const ext = path.extname(basename).toLowerCase()
const known: Record<string, string> = {
"package.json": "Node.js package manifest",
"tsconfig.json": "TypeScript configuration",
".gitignore": "Git ignore rules",
"README.md": "Project documentation",
}
if (known[basename]) return known[basename]
let content: string
try {
const fd = fs.openSync(filePath, "r")
const buf = Buffer.alloc(12288)
const n = fs.readSync(fd, buf, 0, 12288, 0)
fs.closeSync(fd)
content = buf.subarray(0, n).toString("utf-8")
} catch {
return ""
}
if (!content.trim()) return ""
const cap = (s: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..."
if (ext === ".md" || ext === ".mdx") {
const m = content.match(/^#{1,2}\s+(.+)$/m)
if (m) return cap(m[1].trim())
}
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") {
if (basename === "page.tsx" || basename === "page.js") return "Next.js page component"
if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout"
const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || [])
.map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean) as string[]
if (exports.length > 0 && exports.length <= 5) return `Exports ${exports.join(", ")}`
if (exports.length > 5) return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`)
}
const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/)
if (declM) return `Declares ${declM[1]}`
return ""
}
// ── Durable store + lock (mirrors src/hooks/anatomy-store.ts, F2b) ──────────
import * as crypto from "node:crypto"
import * as os from "node:os"
export const STORE_FILE = "anatomy-index.json"
const LOCK_STALE_MS = 10_000
export const LOCK_BUDGET_MS = 2_000
export function sha256(text: string): string {
return crypto.createHash("sha256").update(text).digest("hex")
}
export interface SymbolEntry {
name: string
kind: "fn" | "class" | "method" | "section"
startLine: number
endLine: number
tokens: number
}
export interface StoreFileEntry {
description: string
tokens: number
/** sha256 (first 16 hex chars) of file content when last indexed. */
hash?: string
size?: number
mtimeMs?: number
updatedAt: string
source: "hook" | "scan" | "md-import"
symbols?: SymbolEntry[]
}
export interface AnatomyStoreData {
version: 1
meta: {
lastScanned: string
fileCount: number
hits: number
misses: number
/** sha256 of the markdown this store last rendered — skew detection key. */
renderedHash: string
storeUpdatedAt: string
}
/** Keyed by full normalized relative path, e.g. "src/hooks/shared.ts". */
files: Record<string, StoreFileEntry>
/** Non-conforming anatomy.md lines preserved verbatim, keyed by section. */
rawLines?: Record<string, string[]>
/**
* Hand-written content above the first `## ` heading (issue #61). A typed
* field rather than a reserved rawLines key: old compiled hooks still
* installed in projects ignore an unknown field and round-trip it safely,
* whereas a magic "" section key would render as a stray `## ` heading.
*/
preamble?: string[]
}
export function newStore(): AnatomyStoreData {
const now = new Date().toISOString()
return { version: 1, meta: { lastScanned: now, fileCount: 0, hits: 0, misses: 0, renderedHash: "", storeUpdatedAt: now }, files: {} }
}
export function loadStore(wolfDir: string): AnatomyStoreData | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(wolfDir, STORE_FILE), "utf-8"))
if (parsed && parsed.version === 1 && parsed.files && parsed.meta) return parsed as AnatomyStoreData
return null
} catch {
return null
}
}
export function saveStore(wolfDir: string, store: AnatomyStoreData): void {
store.meta.fileCount = Object.keys(store.files).length
store.meta.storeUpdatedAt = new Date().toISOString()
const filePath = path.join(wolfDir, STORE_FILE)
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
const body = JSON.stringify(store, null, 2)
try {
fs.writeFileSync(tmp, body, "utf-8")
fs.renameSync(tmp, filePath)
} catch {
try { fs.writeFileSync(filePath, body, "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/** Section key for a relpath: "src/hooks/" or "./" for root files. */
export function sectionKeyOf(relPath: string): string {
const dir = path.dirname(relPath).split(path.sep).join("/")
return dir === "." ? "./" : dir + "/"
}
/**
* Render the store to markdown, byte-identical to the legacy format. Symbols
* deliberately do NOT render: they live in anatomy-index.json and reach the
* model through the per-file pre-read hint. Rendering them roughly 2.4x'd
* anatomy.md, and agents instructed to consult anatomy.md paid that bill
* wholesale every session.
*/
export function renderStore(store: AnatomyStoreData): string {
const bySection = new Map<string, Array<{ file: string; entry: StoreFileEntry }>>()
for (const [relPath, entry] of Object.entries(store.files)) {
const key = sectionKeyOf(relPath)
if (!bySection.has(key)) bySection.set(key, [])
bySection.get(key)!.push({ file: relPath.slice(relPath.lastIndexOf("/") + 1), entry })
}
const lines: string[] = [
"# anatomy.md",
"",
`> Auto-maintained by OpenWolf. Last scanned: ${store.meta.lastScanned}`,
`> Files: ${Object.keys(store.files).length} tracked | Anatomy hits: ${store.meta.hits} | Misses: ${store.meta.misses}`,
"",
]
const preamble = trimBlankEdges(store.preamble ?? [])
if (preamble.length > 0) {
lines.push(...preamble, "")
}
const rawBySection = store.rawLines ?? {}
const keys = [...new Set([...bySection.keys(), ...Object.keys(rawBySection)])].sort()
for (const key of keys) {
lines.push(`## ${key}`)
lines.push("")
const raws = rawBySection[key] ?? []
for (const raw of raws) {
lines.push(raw)
}
const entries = (bySection.get(key) ?? []).sort((a, b) => a.file.localeCompare(b.file))
if (raws.length > 0 && entries.length > 0) lines.push("")
for (const { file, entry } of entries) {
const desc = entry.description ? ` — ${entry.description}` : ""
lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`)
}
lines.push("")
}
return lines.join("\n")
}
/** Write the rendered markdown atomically and pin its hash in the store. */
export function renderToFile(wolfDir: string, store: AnatomyStoreData): void {
const content = renderStore(store)
store.meta.renderedHash = sha256(content)
const anatomyPath = path.join(wolfDir, "anatomy.md")
const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
try {
fs.writeFileSync(tmp, content, "utf-8")
fs.renameSync(tmp, anatomyPath)
} catch {
try { fs.writeFileSync(anatomyPath, content, "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/**
* Absorb out-of-band edits to anatomy.md (old compiled hooks, agent/human
* hand-edits) into the store. ADDITIVE-ONLY:
* - md entry differs → md wins description/tokens (newer intent)
* - md entry absent from store → added (source "md-import")
* - store entry absent from md → KEPT unless the file is gone from disk
* (deletions are exclusively the full scanner's job)
* Symbols always survive (they only flow store → render).
*/
export function importFromMarkdown(store: AnatomyStoreData, mdContent: string, projectRoot: string): void {
// Corruption guard: an empty/unreadable anatomy.md must never wipe the
// preserved hand-written content in the store.
if (!mdContent.trim()) return
const { sections, rawLines, preamble } = parseAnatomyWithRaw(mdContent)
if (rawLines.size > 0) {
store.rawLines = Object.fromEntries(rawLines)
} else {
delete store.rawLines
}
if (preamble.length > 0) {
store.preamble = preamble
} else {
delete store.preamble
}
const seen = new Set<string>()
for (const [sectionKey, entries] of sections) {
const dir = sectionKey === "./" ? "" : sectionKey
for (const e of entries) {
const relPath = (dir + e.file).split("\\").join("/")
seen.add(relPath)
const existing = store.files[relPath]
if (!existing) {
store.files[relPath] = { description: e.description, tokens: e.tokens, updatedAt: new Date().toISOString(), source: "md-import" }
} else if (existing.description !== e.description || existing.tokens !== e.tokens) {
existing.description = e.description
existing.tokens = e.tokens
existing.updatedAt = new Date().toISOString()
existing.source = "md-import"
}
}
}
// Entries the md no longer lists: keep unless the file is really gone.
for (const relPath of Object.keys(store.files)) {
if (seen.has(relPath)) continue
if (!fs.existsSync(path.join(projectRoot, relPath))) delete store.files[relPath]
}
}
/**
* Read-side lookup: resolve a file to its anatomy entry. Store-first with an
* O(1) relpath key; falls back to a suffix scan (paths outside the root) and
* finally to parsing anatomy.md for projects that predate the store.
* `normalizedFile` and `projectDir` use forward slashes.
*/
export function lookupEntry(
wolfDir: string,
projectDir: string,
normalizedFile: string
): { file: string; description: string; tokens: number; symbols?: SymbolEntry[]; size?: number; mtimeMs?: number } | null {
const rel = normalizedFile.startsWith(projectDir + "/")
? normalizedFile.slice(projectDir.length + 1)
: normalizedFile.startsWith("/") ? null : normalizedFile
const store = loadStore(wolfDir)
if (store) {
const toResult = (rp: string, e: StoreFileEntry) => ({
file: rp.slice(rp.lastIndexOf("/") + 1),
description: e.description,
tokens: e.tokens,
symbols: e.symbols,
size: e.size,
mtimeMs: e.mtimeMs,
})
const hit = rel ? store.files[rel] : undefined
if (hit) return toResult(rel!, hit)
for (const [rp, e] of Object.entries(store.files)) {
if (normalizedFile === rp || normalizedFile.endsWith("/" + rp)) {
return toResult(rp, e)
}
}
return null
}
// Pre-store project: legacy markdown scan.
let md: string
try {
md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8")
} catch {
return null
}
for (const [sectionKey, entries] of parseAnatomy(md)) {
const dir = sectionKey === "./" ? "" : sectionKey
for (const entry of entries) {
const entryRelPath = (dir + entry.file).split("\\").join("/")
if (normalizedFile === entryRelPath || normalizedFile.endsWith("/" + entryRelPath)) {
return entry
}
}
}
return null
}
/**
* Standard writer entry point: load the store (bootstrapping from anatomy.md
* on first contact), and absorb any md-side divergence before the caller
* mutates. Call ONLY while holding the anatomy lock.
*/
export function loadStoreReconciled(wolfDir: string, projectRoot: string): AnatomyStoreData {
let store = loadStore(wolfDir)
let md: string | null = null
try { md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") } catch {}
if (!store) {
store = newStore()
if (md) importFromMarkdown(store, md, projectRoot)
return store
}
if (md !== null && sha256(md) !== store.meta.renderedHash) importFromMarkdown(store, md, projectRoot)
return store
}
function lockSleep(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}
export function withAnatomyLock<T>(wolfDir: string, budgetMs: number, fn: () => T): T | null {
const lockPath = path.join(wolfDir, "anatomy-index.lock")
const deadline = Date.now() + budgetMs
while (true) {
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }), { flag: "wx" })
break
} catch {}
let stale = false
try {
const body = JSON.parse(fs.readFileSync(lockPath, "utf-8"))
stale = typeof body.acquiredAt !== "number" || Date.now() - body.acquiredAt > LOCK_STALE_MS
if (!stale && body.hostname === os.hostname() && typeof body.pid === "number") {
try { process.kill(body.pid, 0) } catch (err) { stale = (err as NodeJS.ErrnoException).code === "ESRCH" }
}
} catch {
try { stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {}
}
if (stale) {
const graveyard = lockPath + "." + crypto.randomBytes(4).toString("hex") + ".stale"
try { fs.renameSync(lockPath, graveyard); try { fs.unlinkSync(graveyard) } catch {} } catch {}
}
if (Date.now() >= deadline) return null
lockSleep(25 + Math.floor(Math.random() * 25))
}
try {
return fn()
} finally {
try { fs.unlinkSync(lockPath) } catch {}
}
}
+13
View File
@@ -0,0 +1,13 @@
/** PR #69 @liveoakwag, extended with a persisted high-water mark.
* Caller MUST hold buglog.json.lock while allocating and persisting.
*/
export function nextBugId(log: { bugs: Array<{ id?: unknown }>; next_id?: number }): string {
let next = Number.isSafeInteger(log.next_id) && log.next_id! > 0 ? log.next_id! : 1;
for (const bug of log.bugs) {
const match = /^bug-(\d+)$/.exec(String(bug?.id ?? ""));
if (match && Number.isSafeInteger(Number(match[1]))) next = Math.max(next, Number(match[1]) + 1);
}
if (!Number.isSafeInteger(next + 1)) throw new Error("Bug ID space exhausted");
log.next_id = next + 1;
return `bug-${String(next).padStart(3, "0")}`;
}
+43
View File
@@ -0,0 +1,43 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {withFileLock,HOOK_LOCK_BUDGET_MS} from './anatomy-lock.js';
import {nextBugId} from './bug-id.js';
import {sharedWolfDir} from './knowledge-root.js';
export interface BugObservation {error_message:string;file:string;root_cause:string;fix:string;tags:string[];line?:number;status?:string;observed_worktree?:string;}
export function recordBug(wolfDir:string,bug:BugObservation):void {
const dir=path.join(wolfDir,'bug-pending');fs.mkdirSync(dir,{recursive:true});
const id=crypto.randomUUID();const file=path.join(dir,id+'.json');
const fd=fs.openSync(file,'wx',0o600);
try {fs.writeFileSync(fd,JSON.stringify({id,at:new Date().toISOString(),bug}));fs.fsyncSync(fd);} finally {fs.closeSync(fd);}
reconcileBugs(wolfDir);
}
export function reconcileBugs(wolfDir:string):boolean {
const pending=path.join(wolfDir,'bug-pending');let names:string[];
try {names=fs.readdirSync(pending).filter(n=>n.endsWith('.json'));} catch {return true;}
if (!names.length) return true;
const file=path.join(sharedWolfDir(wolfDir),'buglog.json');
const ok=withFileLock(file+'.lock',HOOK_LOCK_BUDGET_MS,()=>{
let log:any={version:1,bugs:[]};
try {log=JSON.parse(fs.readFileSync(file,'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code!=='ENOENT') throw e;}
if (Array.isArray(log)) log={version:1,bugs:log};
if (!log || !Array.isArray(log.bugs)) throw new Error('Invalid buglog; observations retained without overwriting it');
log.applied_observations ??= {};
for (const name of names) {
let event:any;
try {event=JSON.parse(fs.readFileSync(path.join(pending,name),'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code==='ENOENT') continue;throw e;}
if (!event.id || !event.bug || typeof event.bug.file!=='string') throw new Error('Invalid bug observation retained');
if (log.applied_observations[event.id]) continue;
const b=event.bug as BugObservation;
const existing=log.bugs.find((old:any)=>old?.file===b.file && old.error_message===b.error_message && old.fix===b.fix);
if (existing) {existing.occurrences=(Number.isSafeInteger(existing.occurrences)?existing.occurrences:0)+1;existing.last_seen=event.at;}
else log.bugs.push({...b,id:nextBugId(log),timestamp:event.at,last_seen:event.at,occurrences:1,related_bugs:[]});
log.applied_observations[event.id]=true;
}
const tmp=file+'.'+crypto.randomUUID()+'.tmp';
try {const fd=fs.openSync(tmp,'wx',0o600);try {fs.writeFileSync(fd,JSON.stringify(log,null,2));fs.fsyncSync(fd);}finally{fs.closeSync(fd);}fs.renameSync(tmp,file);}finally{try{fs.unlinkSync(tmp);}catch{}}
for(const name of names){try{fs.unlinkSync(path.join(pending,name));}catch{}}
return true;
});
return ok===true;
}
@@ -0,0 +1,76 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
export interface ReadObservation {
id: string;
file: string;
at: string;
tokens: number;
fingerprint?: string;
mtime?: number;
internal?: boolean;
kind?: "read" | "write";
action?: string;
editKey?: string;
}
/** One immutable event per file: no contended append, and no shared offset. */
export function persistRead(sessionFile: string, event: ReadObservation): void {
const dir = sessionFile + ".events";
fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, crypto.createHash("sha256").update(event.id).digest("hex") + ".json");
const tmp = dest + "." + crypto.randomUUID() + ".tmp";
try {
const fd = fs.openSync(tmp, "wx", 0o600);
try { fs.writeFileSync(fd, JSON.stringify(event)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
// A retry of the same upstream event cannot replace an already persisted event.
try { fs.linkSync(tmp, dest); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; }
} finally { try { fs.unlinkSync(tmp); } catch {} }
}
export function reconcileReads(sessionFile: string, budget = HOOK_LOCK_BUDGET_MS): boolean {
const dir = sessionFile + ".events";
let names: string[];
try { names = fs.readdirSync(dir).filter(n => n.endsWith(".json")); } catch { return true; }
if (!names.length) return true;
const done: string[] = [];
const result = mutateJSON<Record<string, any>>(sessionFile, { files_read: {} }, budget, s => {
s.applied_read_events ??= {};
s.files_read ??= {};
const events = names.flatMap(name => {
try { return [{ name, event: JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")) as ReadObservation }]; }
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return []; throw e; }
}).sort((a,b) => a.event.at.localeCompare(b.event.at));
for (const { name, event: e } of events) {
if (!s.applied_read_events[e.id]) {
if (e.kind === "write") {
s.files_written ??= [];
s.edit_counts ??= {};
s.files_written.push({file:e.file,action:e.action ?? "edit",tokens:e.tokens,at:e.at});
const key=e.editKey ?? e.file;
s.edit_counts[key]=(s.edit_counts[key] ?? 0)+1;
delete s.files_read[e.file];
} else if (e.internal) {
s.wolf_internal_tokens = (s.wolf_internal_tokens ?? 0) + e.tokens;
s.wolf_internal_reads ??= {};
s.wolf_internal_reads[e.file] = (s.wolf_internal_reads[e.file] ?? 0) + e.tokens;
} else {
const previous = s.files_read[e.file];
if (!previous?.observed_at || previous.observed_at <= e.at) {
s.files_read[e.file] = { ...previous, count: previous?.count ?? 1, tokens: e.tokens, first_read: previous?.first_read ?? e.at, observed_at: e.at, ranged: false, read_hash: e.fingerprint, read_mtime: e.mtime };
}
}
s.applied_read_events[e.id] = true;
}
done.push(name);
}
});
if (result === null) return false;
// Receipts remain in the state, so a crash between commit and unlink is safe.
for (const name of done) { try { fs.unlinkSync(path.join(dir, name)); } catch {} }
return true;
}
export const persistObservation = persistRead;
+165
View File
@@ -0,0 +1,165 @@
import { normalizeSession } from "./session-state.js"
import { gcSessionFiles as canonicalGC } from "./shared.js"
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import * as fs from "node:fs"
import * as path from "node:path"
import * as crypto from "node:crypto"
export function getWolfDir(directory: string): string {
return path.join(directory, ".wolf")
}
export function wolfDirExists(directory: string): boolean {
return fs.existsSync(getWolfDir(directory))
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return (
typeof v === "object" &&
v !== null &&
!Array.isArray(v) &&
Object.getPrototypeOf(v) === Object.prototype
)
}
/**
* Recursively fills missing keys in `loaded` from `defaults`.
* Loaded values always win; defaults only fill gaps. Arrays and scalars
* are replaced wholesale (not merged).
*/
function deepMergeDefaults<T>(defaults: T, loaded: T): T {
if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded
const result: Record<string, unknown> = { ...(defaults as Record<string, unknown>) }
for (const key of Object.keys(loaded as Record<string, unknown>)) {
const lv = (loaded as Record<string, unknown>)[key]
const dv = (defaults as Record<string, unknown>)[key]
if (isPlainObject(lv) && isPlainObject(dv)) {
result[key] = deepMergeDefaults(dv, lv)
} else {
result[key] = lv
}
}
return result as T
}
/**
* Reads JSON from `filePath`. If the file exists and parses, its values are
* deep-merged over `fallback` so that missing nested keys fall back to the
* provided defaults (loaded values always win). If the file is missing or
* unparseable, `fallback` is returned as-is.
*
* This prevents `TypeError: Cannot read properties of undefined` when a
* user's file predates a section a newer release reads (e.g. a pre-2.0
* token-ledger.json without `lifetime`).
*/
export function readJSON<T>(filePath: string, fallback: T): T {
try {
const raw = fs.readFileSync(filePath, "utf-8")
const parsed = JSON.parse(raw) as T
const value=deepMergeDefaults(fallback, parsed)
return /[\\/]hooks[\\/]sessions[\\/][^\\/]+\.json$/.test(filePath) ? normalizeSession(value,path.basename(filePath,".json")) as T : value
} catch {
return fallback
}
}
export function writeJSON(filePath: string, data: unknown): void {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8")
fs.renameSync(tmp, filePath)
} catch {
try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/**
* Per-session state path, mirroring getSessionFilePath() in src/hooks/shared.ts
* (same id validation, same layout).
*
* Issue #89, reported with PR #113 by @davdittrich, whose session-file GC is
* adopted below.
*
* Every OpenCode handler receives a sessionId but they all used to persist to
* one shared hooks/_session.json, so starting a second session in the same
* project overwrote the first one's state and each later read or write from
* either session mutated the survivor (#89). The legacy shared file is used
* only when there is no usable id, so existing single-session installs keep
* working.
*/
export function sessionFilePath(hooksDir: string, sessionId: string | undefined): string {
if (typeof sessionId === "string" && /^[\w.-]{4,128}$/.test(sessionId)) {
return path.join(hooksDir, "sessions", `${sessionId}.json`)
}
return path.join(hooksDir, "_session.json")
}
/**
* Delete per-session state files older than maxAgeDays.
*
* Mirrors gcSessionFiles() in src/hooks/shared.ts. Splitting state per session
* fixes cross-session contamination but creates one file per session forever,
* so the directory needs the same bound the main hooks already apply.
*/
export function gcSessionFiles(hooksDir: string, maxAgeDays = 7): void {
canonicalGC(maxAgeDays,path.dirname(hooksDir))
}
export function readMarkdown(filePath: string): string {
try {
return fs.readFileSync(filePath, "utf-8")
} catch {
return ""
}
}
export function appendMarkdown(filePath: string, line: string): void {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const ok = withFileLock(filePath + ".lock", HOOK_LOCK_BUDGET_MS, () => { fs.appendFileSync(filePath, line, "utf-8"); return true })
if (ok === null) {
const pending = filePath + ".pending"
fs.mkdirSync(pending, {recursive:true})
fs.writeFileSync(path.join(pending, `${Date.now()}-${crypto.randomUUID()}.json`), JSON.stringify({line}), {flag:"wx",mode:0o600})
}
}
export function timeShort(): string {
const d = new Date()
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
}
export function timestamp(): string {
return new Date().toISOString()
}
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/")
}
export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number {
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75
return Math.ceil(text.length / ratio)
}
// Files whose contents (or content-derived descriptions) must never reach
// anatomy.md / memory.md because they hold secrets (issue #54). Mirrors
// isSensitiveFile in src/hooks/shared.ts.
const SENSITIVE_EXTENSIONS = new Set([
".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate",
])
const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"])
export function isSensitiveFile(basename: string): boolean {
const lower = basename.toLowerCase()
if (lower === ".env" || lower.startsWith(".env.")) return true
if (SENSITIVE_BASENAMES.has(lower)) return true
const dot = lower.lastIndexOf(".")
if (dot >= 0 && SENSITIVE_EXTENSIONS.has(lower.slice(dot))) return true
if (/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower)) return true
if (lower.includes("credential") || /^secrets\.(json|ya?ml|toml)$/.test(lower)) return true
return false
}
@@ -0,0 +1,98 @@
import {recordReceipt} from './visibility.js';
/** Small local checkpoints: no transcript scanning, Git calls or model requests. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {mutateJSON} from './anatomy-lock.js';
export const hashText=(s:string)=>crypto.createHash('sha256').update(s).digest('hex');
export function sensitivePath(s:string):boolean{return /(?:^|[/\\])(?:\.env(?:\.[^/\\]+)?|credentials(?:\.json)?|secrets?(?:\.[^/\\]+)?|id_(?:rsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx))(?:$|[/\\])/i.test(s)}
export function redact(s:string):string {
return s.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,'[REDACTED PRIVATE KEY]')
.replace(/\b(?:sk-[\w-]{12,}|gh[pousr]_[\w]{20,}|github_pat_[\w]{20,}|AKIA[A-Z0-9]{16})\b/g,'[REDACTED CREDENTIAL]')
.replace(/((?:authorization\s*[:=]\s*(?:bearer\s+)?|(?:api[_-]?key|access[_-]?token|password|secret)\s*["']?\s*[:=]\s*["']?))[^\s"',;}]+/gi,'$1[REDACTED]')
.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/g,'$1[REDACTED]@');
}
export interface ActiveState {
schema:1;session:string;agent:string;updated_at:string;objective:string;constraints:string[];
next_action:string;unresolved:string[];completed:string[];recent:Array<{id:string;kind:string;text:string;at:string}>;
applied:Record<string,boolean>;compactions:number;injections:Array<{hash:string;at:string;bytes:number}>;
imported_packet?:string;coverage:string[];last_context?:Record<string,unknown>;
}
export const activeFile=(root:string,agent:string,session:string)=>path.join(root,'.wolf','handoff','active',hashText(agent+':'+session)+'.json');
export function emptyActive(agent:string,session:string):ActiveState{return {schema:1,agent,session,updated_at:'',objective:'',constraints:[],next_action:'',unresolved:[],completed:[],recent:[],applied:{},compactions:0,injections:[],coverage:['Semantic state requires an explicit checkpoint; missed hooks may need transcript recovery.']}}
export interface CheckpointEvent {id:string;kind:string;at:string;text?:string;patch?:Partial<Pick<ActiveState,'objective'|'constraints'|'next_action'|'unresolved'|'completed'|'imported_packet'>>}
export function queueCheckpoint(root:string,agent:string,session:string,event:CheckpointEvent):void {
if(!session||!fs.existsSync(path.join(root,'.wolf')))return;
const dir=activeFile(root,agent,session)+'.events';fs.mkdirSync(dir,{recursive:true});
const file=path.join(dir,hashText(event.id)+'.json'),tmp=file+'.'+crypto.randomUUID()+'.tmp';
const body=JSON.stringify({agent,session,event});
try {const fd=fs.openSync(tmp,'wx',0o600);try{fs.writeFileSync(fd,body);fs.fsyncSync(fd)}finally{fs.closeSync(fd)}try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}}
}
export function reconcileActive(root:string,agent:string,session:string):ActiveState {
const file=activeFile(root,agent,session),dir=file+'.events';let names:string[]=[];
try{names=fs.readdirSync(dir).filter(n=>n.endsWith('.json')).sort()}catch{}
const done:string[]=[];
const result=mutateJSON<ActiveState>(file,emptyActive(agent,session),50,state=>{
const events=names.flatMap(n=>{try{return [{n,...JSON.parse(fs.readFileSync(path.join(dir,n),'utf8'))}]}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return [];throw e}}).sort((a,b)=>a.event.at.localeCompare(b.event.at)||a.event.id.localeCompare(b.event.id));
for(const {n,event:e} of events as Array<{n:string;event:CheckpointEvent}>) {
if(!state.applied[e.id]) {
if(e.patch){
for(const key of ['objective','next_action'] as const)if(typeof e.patch[key]==='string')state[key]=redact(e.patch[key]!).slice(0,4000);
for(const key of ['constraints','unresolved','completed'] as const)if(Array.isArray(e.patch[key]))state[key]=e.patch[key]!.filter(v=>typeof v==='string').slice(0,100).map(v=>redact(v).slice(0,1000));
if(typeof e.patch.imported_packet==='string'&&/^[a-f0-9]{64}$/.test(e.patch.imported_packet))state.imported_packet=e.patch.imported_packet;
}
if(e.kind==='request'&&!state.objective&&e.text)state.objective=redact(e.text).slice(0,4000);
if(e.kind==='compaction')state.compactions++;
state.recent.push({id:e.id,kind:e.kind,text:redact(e.text??'').slice(0,1200),at:e.at});state.recent=state.recent.sort((a,b)=>a.at.localeCompare(b.at)||a.id.localeCompare(b.id)).slice(-24);
state.applied[e.id]=true;if(!state.updated_at||Date.parse(e.at)>Date.parse(state.updated_at))state.updated_at=e.at;
}
done.push(n);
}
});
if(result)for(const n of done)try{fs.unlinkSync(path.join(dir,n))}catch{}
if(result){
for(const e of result.recent)if(done.length&&['semantic-checkpoint','handover-import','compaction'].includes(e.kind))recordReceipt(root,{agent,session,operation:'checkpoint-saved',evidence:agent+':'+session+':'+e.id,at:Date.parse(e.at)});
return result;
}
try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return emptyActive(agent,session)}
}
export function observeCheckpoint(root:string,agent:string,hook:string,input:any):void {
const session=typeof input.session_id==='string'?input.session_id:'';if(!session)return;
let kind='',text='';
if(hook==='precompact')kind='compaction';
if(hook==='session-end')kind='session-end';
if(hook==='stop')kind='turn-end';
if(hook==='user-prompt-submit'){kind='request';text=typeof input.prompt==='string'?input.prompt:''}
if(hook==='post-write'){kind='edit';const f=input.tool_input?.file_path??input.tool_input?.path??'';text=sensitivePath(f)?'[sensitive path omitted]':String(f)}
if(hook==='post-bash'){
const command=input.tool_input?.command;
if(typeof command==='string'&&/\b(test|pytest|vitest|jest|cargo test|go test|build|check)\b/i.test(command)){kind='validation-observation';text=`Command: ${command.slice(0,600)}; reported exit: ${input.tool_response?.exit_code??input.tool_response?.exitCode??'unavailable'}. Repository-at-execution hash unavailable; not current validation.`}
}
if(kind)queueCheckpoint(root,agent,session,{id:input.tool_use_id?hook+':'+input.tool_use_id:crypto.randomUUID(),kind,at:new Date().toISOString(),text:redact(text)});
}
/** A bounded evidence-only context delta. Imports are explicitly session-targeted. */
export function activeContext(root:string,agent:string,session:string,force=false,budget=1200):string {
if(!session||!fs.existsSync(activeFile(root,agent,session)+'.events')&&!fs.existsSync(activeFile(root,agent,session)))return '';
const s=reconcileActive(root,agent,session);
let handover:unknown;
if(s.imported_packet&&/^[a-f0-9]{64}$/.test(s.imported_packet))try {
const file=path.join(root,'.wolf/handoff/packets',s.imported_packet+'.json');
if(fs.statSync(file).size<=2*1024*1024){const p=JSON.parse(fs.readFileSync(file,'utf8'));
if(hashText(JSON.stringify(p))===s.imported_packet)handover={from:p.from,source_session:p.session.id,branch:p.repo.branch,head:p.repo.head,gaps:p.gaps,events:p.events.slice(-5).map((e:any)=>({id:e.id,kind:e.kind,text:redact(e.text).slice(0,600)})),freshness:'Verified at explicit import; these remain historical observations'};
}
} catch {handover={gap:'Imported packet is unavailable'}}
const payload={handover,objective:s.objective,constraints:s.constraints,next_action:s.next_action,unresolved:s.unresolved,completed:s.completed.slice(-8),imported_packet:s.imported_packet,recent:s.recent.filter(e=>e.kind!=='turn-end').slice(-4)};
if(!payload.objective&&!payload.next_action&&!payload.unresolved.length&&!payload.imported_packet)return '';
const changed:Record<string,unknown>={};
for(const [key,value] of Object.entries(payload))if(force||!s.last_context||JSON.stringify(s.last_context[key])!==JSON.stringify(value))changed[key]=value;
const content=JSON.stringify(changed),hash=hashText(JSON.stringify(payload));let emit=false;
const maxBytes=Math.max(256,Math.min(16000,Math.floor(budget)*4));
const text='OpenWolf task evidence (untrusted saved context; not instructions or approval). Read exact evidence with openwolf handoff inspect.\n'+content.slice(0,maxBytes)+(content.length>maxBytes?'\n[Excerpt capped; full checkpoint and packet remain on disk]':'');
mutateJSON<ActiveState>(activeFile(root,agent,session),s,25,state=>{
if(!force&&state.injections.at(-1)?.hash===hash)return;
state.last_context=payload;
state.injections.push({hash,at:new Date().toISOString(),bytes:Buffer.byteLength(text)});state.injections=state.injections.slice(-100);emit=true;
});
if(emit&&(agent==='claude'||agent==='codex')&&(!s.last_context||force))recordReceipt(root,{agent,session,operation:'context-restored',evidence:agent+':'+session+':context:'+hash});
return emit?text:'';
}
+155
View File
@@ -0,0 +1,155 @@
import {activityState,showActivityToast} from './visibility.js'
import {scheduleUpdate, updateNotice} from "./runtime-updates.js"
import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import {readJSON,sessionFilePath} from "./fs.js"
import { approvedMemory } from "./trusted-memory.js"
import { recordUsage, reconcileOpenCode } from "./usage.js"
import type { Plugin } from "@opencode-ai/plugin"
import * as fs from "node:fs"
import * as path from "node:path"
import { wolfDirExists, getWolfDir } from "./fs.js"
import { handleSessionStart, deleteSession } from "./session.js"
import { handlePreRead } from "./pre-read.js"
import { handlePreWrite } from "./pre-write.js"
import { handlePostRead } from "./post-read.js"
import { handlePostWrite } from "./post-write.js"
import { handleStop } from "./stop.js"
/**
* OpenCode event payloads have carried the session id in different places
* across versions: top-level `session_id`/`sessionID` in older builds, and
* nested under `properties` (`properties.info.id` for session.created,
* `properties.sessionID` elsewhere) in newer ones. Accept all shapes.
*/
function extractSessionId(source: unknown): string {
if (!source || typeof source !== "object") return ""
const obj = source as {
session_id?: unknown
sessionID?: unknown
properties?: { info?: { id?: unknown }; sessionID?: unknown }
}
const candidates = [
obj.session_id,
obj.sessionID,
obj.properties?.info?.id,
obj.properties?.sessionID,
]
for (const c of candidates) {
if (typeof c === "string" && c) return c
}
return ""
}
export const OpenWolf: Plugin = async ({ directory, client }) => {
let reconciliation: Promise<void> = Promise.resolve()
const refreshUsage = (id?: string) => {
reconciliation = reconciliation.then(() => reconcileOpenCode(directory,client,id)).catch(error => console.warn(`OpenWolf usage reconciliation: ${error}`))
return reconciliation
}
if (wolfDirExists(directory)) void refreshUsage()
return {
event: async ({ event }: { event: { type: string; [key: string]: unknown } }) => {
if (event.type === "session.created" && !wolfDirExists(directory)) return
if (!wolfDirExists(directory)) return
if (event.type === "message.updated") {
try {recordUsage(directory, (event as any).properties?.info)} catch(error) {console.warn(String(error))}
}
const sessionId = extractSessionId(event)
if (!sessionId) return
if (event.type === "session.created") {
handleSessionStart(directory, sessionId)
scheduleUpdate(directory)
const notice=updateNotice(directory,sessionId)
if(notice)showActivityToast(client,notice)
}
if (event.type === "session.idle" || event.type === "session.error" || event.type === "session.deleted") {
scheduleUpdate(directory)
const update=updateNotice(directory,sessionId)
const activity=update??activityState(directory,{agent:"opencode",session:sessionId,turn:String(readJSON<Record<string,unknown>>(sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId),{}).stop_count??0),surface:"opencode-toast"}).message
if(activity)showActivityToast(client,activity)
await refreshUsage(sessionId)
handleStop(directory, sessionId)
}
if (event.type === "session.deleted") {
const file=sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId)
mutateJSON<Record<string,unknown>>(file,{},HOOK_LOCK_BUDGET_MS,state=>{state.ended=new Date().toISOString()})
deleteSession(sessionId)
}
},
"tool.execute.before": async (input: { tool: string; sessionID: string }, output: { args: Record<string, unknown> }) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
const args: Record<string, unknown> = output.args || {}
const tool = input.tool.toLowerCase()
if (tool === "read") {
const filePath = String(args.filePath || args.file_path || "")
const isRangedRead = args.offset !== undefined || args.limit !== undefined
if (filePath) handlePreRead(directory, sessionId, filePath, isRangedRead)
}
if (tool === "write" || tool === "edit") {
const filePath = String(args.filePath || args.file_path || "")
const content = String(args.content || "")
const oldStr = String(args.old_string || args.oldString || "")
const newStr = String(args.new_string || args.newString || "")
if (filePath) handlePreWrite(directory, sessionId, filePath, content, oldStr, newStr)
}
},
"tool.execute.after": async (input: { tool: string; sessionID: string; args: Record<string, unknown>; callID?: string }, output: Record<string, unknown>) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
const tool = input.tool.toLowerCase()
const args = input.args || {}
if (tool === "read") {
const filePath = String(args.filePath || args.file_path || "")
const content = String((output as any).output || "")
if (filePath) handlePostRead(directory, sessionId, filePath, content, args.offset !== undefined || args.limit !== undefined, input.callID)
}
if (tool === "write" || tool === "edit") {
const filePath = String(args.filePath || args.file_path || "")
const content = String(args.content || "")
const oldStr = String(args.old_string || args.oldString || "")
const newStr = String(args.new_string || args.newString || "")
if (filePath) handlePostWrite(directory, sessionId, input.tool, filePath, content, oldStr, newStr)
}
},
stop: async (input: Record<string, unknown>) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
handleStop(directory, sessionId)
},
"experimental.chat.system.transform": async (_input: Record<string, unknown>, output: { system: string[] }) => {
if (!wolfDirExists(directory)) return
const wolfDir = getWolfDir(directory)
const openwolfPath = path.join(wolfDir, "OPENWOLF.md")
if (fs.existsSync(openwolfPath)) {
try {
const openwolfContent = approvedMemory(wolfDir, "OPENWOLF.md")
output.system.push(`\n<openwolf-protocol>\n${openwolfContent}\n</openwolf-protocol>`)
} catch {}
}
},
}
}
@@ -0,0 +1,16 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {execFileSync} from 'node:child_process';
/** Only durable bug knowledge is shared. Anatomy, usage, sessions and STATUS
* always remain in the worktree's own .wolf directory.
*/
export function sharedWolfDir(wolfDir: string): string {
const root=path.dirname(wolfDir);
try {
if (!fs.statSync(path.join(root,'.git')).isFile()) return wolfDir;
const common=execFileSync('git',['rev-parse','--path-format=absolute','--git-common-dir'],{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:1000}).trim();
if (path.basename(common) !== '.git') return wolfDir;
const shared=path.join(path.dirname(common),'.wolf');
return fs.statSync(shared).isDirectory() ? shared : wolfDir;
} catch {return wolfDir;}
}
+369
View File
@@ -0,0 +1,369 @@
import type { RealUsage } from "./shared.js";
// ─────────────────────────────────────────────────────────────────────────────
// Pure token-ledger math: types, folds, lifetime derivation, migrations.
// Deliberately free of value imports so tests (and any consumer) can load it
// straight from source under Node's type stripping. IO and session assembly
// live in ledger.ts.
// ─────────────────────────────────────────────────────────────────────────────
export const MAX_LEDGER_SESSIONS = 200;
export interface SessionFileRead {
count: number;
tokens: number;
first_read: string;
read_mtime?: number;
anatomy_hit?: boolean;
}
export interface SessionData {
agent?: string;
session_id: string;
started: string;
files_read: Record<string, SessionFileRead>;
files_written: Array<{ file: string; action: string; tokens: number; at: string }>;
edit_counts: Record<string, number>;
anatomy_hits: number;
anatomy_misses: number;
repeated_reads_warned: number;
reads_denied?: number;
denied_tokens_saved?: number;
stop_count: number;
reminders_sent: Record<string, number>;
pending_reminders?: string[];
injected_tokens_estimated?: number;
injected_by_source?: Record<string, number>;
bash_governed?: Array<{ family: string; action: string; original_tokens: number; entered_tokens: number; at: string }>;
[key: string]: unknown;
}
export interface FamilyTotals {
calls: number;
original_tokens: number;
entered_tokens: number;
}
/** Sum of two per-key maps, used for both family and per-model rollups. */
export function foldMap<T extends object>(
target: Record<string, T>,
source: Record<string, T> | undefined
): void {
for (const [key, value] of Object.entries(source ?? {})) {
if (!value || typeof value !== "object") continue;
const bucket = (target[key] ??= {} as T);
for (const [field, n] of Object.entries(value)) {
if (typeof n !== "number" || !isFinite(n)) continue;
(bucket as Record<string, number>)[field] = ((bucket as Record<string, number>)[field] ?? 0) + n;
}
}
}
export interface SessionEntry {
id: string;
agent: string;
started: string;
ended: string;
reads: Array<{
file: string;
tokens_estimated: number;
was_repeated: boolean;
anatomy_had_description: boolean;
}>;
writes: Array<{ file: string; tokens_estimated: number; action: string }>;
totals: {
input_tokens_estimated: number;
output_tokens_estimated: number;
reads_count: number;
writes_count: number;
repeated_reads_blocked: number;
repeated_reads_warned?: number;
anatomy_lookups: number;
anatomy_misses?: number;
savings_estimated?: number;
/** Tokens OpenWolf itself injected into context (digests, hints, warnings). */
injection_tokens_estimated?: number;
/** Bash governor (2.3): original size of governed outputs vs what entered. */
bash_governed_original_tokens?: number;
bash_governed_entered_tokens?: number;
bash_governed_calls?: number;
/** Per-command-family governor breakdown (2.5). The scalars above cannot
* answer "which families are actually paying off" — the classifier
* already knows the family at the rewrite point, so keep it. */
bash_governed_by_family?: Record<string, FamilyTotals>;
/** Tokens the agent spent reading .wolf/ state itself (2.4). */
wolf_internal_tokens?: number;
};
injected_by_source?: Record<string, number>;
real_usage?: RealUsage;
/** Transcript-verified hook activity (2.2). Absent = verification
* unavailable (old transcript, format drift, or non-Claude agent); the
* self-reported totals are then estimates, not facts. */
verified?: {
hooks_fired: number;
hooks_failed: number;
injections_delivered: number;
injection_tokens_delivered: number;
per_hook: Record<string, { fired: number; failed: number; last_exit: number }>;
last_failure?: { hook: string; stderr_head: string };
};
}
export interface LifetimeTotals {
total_tokens_estimated: number;
total_reads: number;
total_writes: number;
total_sessions: number;
anatomy_hits: number;
anatomy_misses: number;
repeated_reads_blocked: number;
repeated_reads_warned: number;
estimated_savings_vs_bare_cli: number;
injection_tokens_estimated: number;
[key: string]: number;
}
export interface LifetimeMaps {
/** Governor deltas per command family, lifetime. */
bash_governed_by_family: Record<string, FamilyTotals>;
/** Measured usage per model id, lifetime. Drives the cost figure. */
real_by_model: Record<string, ModelUsage>;
}
export interface ModelUsage {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
api_calls: number;
}
export interface LedgerData {
version: number;
created_at: string;
lifetime: LifetimeTotals;
/** Totals folded out of sessions that were rolled off the retained window. */
lifetime_baseline?: Partial<LifetimeTotals>;
/** Lifetime per-key rollups. Kept out of LifetimeTotals, whose index
* signature is number, and out of lifetime_baseline, which numericFields()
* strips down to scalars on every trim. */
lifetime_maps?: LifetimeMaps;
lifetime_baseline_maps?: LifetimeMaps;
sessions: SessionEntry[];
[key: string]: unknown;
}
export function emptyLedger(): LedgerData {
return {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
};
}
/** The totals block of a session entry, derived from live session state. */
export function buildSessionTotals(
session: SessionData,
reads: SessionEntry["reads"],
writes: SessionEntry["writes"]
): SessionEntry["totals"] {
return {
input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0),
output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0),
reads_count: reads.length,
writes_count: writes.length,
// Honest accounting: only reads the hook actually denied count as
// blocked (warnings do not prevent the read from happening).
repeated_reads_blocked: session.reads_denied ?? 0,
repeated_reads_warned: session.repeated_reads_warned ?? 0,
anatomy_lookups: session.anatomy_hits,
anatomy_misses: session.anatomy_misses,
// Honest savings: tokens of reads that were denied, nothing else.
savings_estimated: session.denied_tokens_saved ?? 0,
// The other side of the scale: what OpenWolf's own context injection cost.
injection_tokens_estimated: session.injected_tokens_estimated ?? 0,
// Bash governor deltas: original-vs-entered is measured at the rewrite
// point, which nothing else in the ecosystem can observe.
bash_governed_calls: (session.bash_governed ?? []).length || undefined,
bash_governed_original_tokens: sumBy(session.bash_governed, (g) => g.original_tokens),
bash_governed_entered_tokens: sumBy(session.bash_governed, (g) => g.entered_tokens),
bash_governed_by_family: byFamily(session.bash_governed),
wolf_internal_tokens: typeof session.wolf_internal_tokens === "number" ? session.wolf_internal_tokens : undefined,
};
}
/**
* Group the session's governed calls by command family. The session file keeps
* only the last 200 records, so this is computed at flush time while they are
* still there; the ledger then carries the rollup forever.
*/
function byFamily(
governed: SessionData["bash_governed"]
): Record<string, FamilyTotals> | undefined {
if (!governed || governed.length === 0) return undefined;
const out: Record<string, FamilyTotals> = {};
for (const g of governed) {
const key = typeof g.family === "string" && g.family ? g.family : "other";
const bucket = (out[key] ??= { calls: 0, original_tokens: 0, entered_tokens: 0 });
bucket.calls++;
if (isFinite(g.original_tokens)) bucket.original_tokens += g.original_tokens;
if (isFinite(g.entered_tokens)) bucket.entered_tokens += g.entered_tokens;
}
return out;
}
function sumBy<T>(arr: T[] | undefined, fn: (item: T) => number): number | undefined {
if (!arr || arr.length === 0) return undefined;
return arr.reduce((sum, item) => sum + fn(item), 0);
}
/**
* Position-weighted context cost (2.2). Fresh input is ~0.004% of a session's
* input-side tokens; the real cost of a byte is that it sits in the cached
* prefix and is re-read at the cache-read rate on EVERY subsequent API call:
* cost = tokens x (total_calls - call_index) x rate_per_token
* A 10k-token read at call 500 of 1,458 costs ~$2.87 (Sonnet-class rates);
* the same read at call 1,450 costs ~$0.02. Waste rankings must use this,
* not raw token counts.
*/
export function positionWeightedCostUsd(
tokens: number,
callIndex: number,
totalCalls: number,
cacheReadUsdPerMTok: number
): number {
const remaining = Math.max(0, totalCalls - callIndex);
return (tokens * remaining * cacheReadUsdPerMTok) / 1_000_000;
}
export function addInto(target: Record<string, number>, key: string, value: number | undefined): void {
if (typeof value !== "number" || !isFinite(value)) return;
target[key] = (target[key] ?? 0) + value;
}
/** Copy only real numeric fields out of a possibly-partial totals object. */
export function numericFields(source: Record<string, number | undefined> | undefined): Record<string, number> {
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(source ?? {})) {
if (typeof v === "number" && isFinite(v)) out[k] = v;
}
return out;
}
/**
* Fold one session's per-key rollups into a LifetimeMaps accumulator. Kept
* separate from foldEntry because those maps cannot live in the scalar
* accumulator that numericFields() produces.
*/
export function emptyMaps(): LifetimeMaps {
return { bash_governed_by_family: {}, real_by_model: {} };
}
export function foldEntryMaps(acc: LifetimeMaps, e: SessionEntry): void {
foldMap(acc.bash_governed_by_family, e.totals?.bash_governed_by_family);
foldMap(acc.real_by_model, e.real_usage?.per_model as Record<string, ModelUsage> | undefined);
}
export function foldEntry(acc: Record<string, number>, e: SessionEntry): void {
addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated);
addInto(acc, "total_reads", e.totals.reads_count);
addInto(acc, "total_writes", e.totals.writes_count);
addInto(acc, "anatomy_hits", e.totals.anatomy_lookups);
addInto(acc, "anatomy_misses", e.totals.anatomy_misses);
addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked);
addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned);
addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated);
addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated);
addInto(acc, "bash_governed_calls", e.totals.bash_governed_calls);
addInto(acc, "bash_governed_original_tokens", e.totals.bash_governed_original_tokens);
addInto(acc, "bash_governed_entered_tokens", e.totals.bash_governed_entered_tokens);
addInto(acc, "wolf_internal_tokens", e.totals.wolf_internal_tokens);
if (e.real_usage) {
addInto(acc, "real_input_tokens", e.real_usage.input_tokens);
addInto(acc, "real_output_tokens", e.real_usage.output_tokens);
addInto(acc, "real_cache_read_tokens", e.real_usage.cache_read_input_tokens);
addInto(acc, "real_cache_creation_tokens", e.real_usage.cache_creation_input_tokens);
addInto(acc, "real_api_calls", e.real_usage.api_calls);
}
}
/**
* Derive lifetime = baseline + fold(sessions). total_sessions is intentionally
* NOT derived here — session-start counts it once per new session.
*/
export function recomputeLifetime(ledger: LedgerData): void {
const acc = numericFields(ledger.lifetime_baseline);
delete acc.total_sessions;
const maps = emptyMaps();
foldMap(maps.bash_governed_by_family, ledger.lifetime_baseline_maps?.bash_governed_by_family);
foldMap(maps.real_by_model, ledger.lifetime_baseline_maps?.real_by_model);
for (const e of ledger.sessions) {
foldEntry(acc, e);
foldEntryMaps(maps, e);
}
ledger.lifetime_maps = maps;
const totalSessions = ledger.lifetime?.total_sessions ?? 0;
ledger.lifetime = {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
...acc,
total_sessions: totalSessions,
} as LifetimeTotals;
}
/**
* One-time legacy migration: sessions written before 2.0.5 stored the number
* of duplicate-read WARNINGS in totals.repeated_reads_blocked (the field
* predates deny mode). Under the current semantics a blocked read always
* credits savings_estimated > 0 (a denial saves the previous read's tokens,
* and denyEligible requires tokens > 0), so blocked > 0 with zero savings can
* only be legacy data. Move those counts to repeated_reads_warned and zero
* the blocked field. Also migrates the lifetime_baseline, which may hold
* folded-off legacy sessions. Returns the number of records rewritten.
* Caller is responsible for recomputeLifetime() and persisting.
*/
export function migrateLegacyBlockedCounts(ledger: LedgerData): number {
let migrated = 0;
for (const s of ledger.sessions ?? []) {
const t = s?.totals;
if (!t) continue;
if ((t.repeated_reads_blocked ?? 0) > 0 && !((t.savings_estimated ?? 0) > 0)) {
t.repeated_reads_warned = (t.repeated_reads_warned ?? 0) + t.repeated_reads_blocked;
t.repeated_reads_blocked = 0;
migrated++;
}
}
const base = ledger.lifetime_baseline;
if (
base &&
(base.repeated_reads_blocked ?? 0) > 0 &&
!((base.estimated_savings_vs_bare_cli ?? 0) > 0)
) {
base.repeated_reads_warned = (base.repeated_reads_warned ?? 0) + (base.repeated_reads_blocked ?? 0);
base.repeated_reads_blocked = 0;
migrated++;
}
return migrated;
}
+133
View File
@@ -0,0 +1,133 @@
import * as fs from "node:fs";
import * as crypto from "node:crypto";
import * as path from "node:path";
import { readJSON, writeJSON, readTranscriptUsage, detectAgent } from "./shared.js";
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
import {
MAX_LEDGER_SESSIONS,
emptyLedger,
buildSessionTotals,
numericFields,
foldEntry,
foldEntryMaps,
emptyMaps,
recomputeLifetime,
type SessionData,
type SessionEntry,
type LedgerData,
} from "./ledger-math.js";
// ─────────────────────────────────────────────────────────────────────────────
// Token-ledger writer shared by the Stop and SessionEnd hooks.
//
// Stop fires at the end of EVERY turn, so the ledger write must be idempotent:
// the session entry is UPSERTED by session id (replaced, never appended), and
// lifetime totals are derived — archived baseline + fold over the retained
// sessions — rather than incremented. The old increment-on-every-Stop scheme
// re-added turns 1..N on turn N, quadratically inflating every lifetime metric
// and duplicating session entries.
//
// The pure math (folds, lifetime derivation, migrations) lives in
// ledger-math.ts; this module owns IO and session-entry assembly.
// ─────────────────────────────────────────────────────────────────────────────
export * from "./ledger-math.js";
/** Build the ledger entry for the current session state (idempotent). */
export function buildSessionEntry(session: SessionData, transcriptPath?: string): SessionEntry {
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: data.anatomy_hit === true,
}));
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}));
const entry: SessionEntry = {
id: session.session_id,
agent: typeof session.agent === "string" ? session.agent : detectAgent(),
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: buildSessionTotals(session, reads, writes),
};
if (session.injected_by_source && Object.keys(session.injected_by_source).length > 0) {
entry.injected_by_source = { ...session.injected_by_source };
}
if (transcriptPath) {
// readTranscriptUsage dedupes by message id, so this is the cumulative
// total for the whole session — correct to REPLACE, never to add.
const real = readTranscriptUsage(transcriptPath);
if (real) entry.real_usage = real;
}
return entry;
}
/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */
export function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void {
if (!entry.id) return;
const pending = path.join(wolfDir,"ledger-pending");
fs.mkdirSync(pending,{recursive:true});
const file = path.join(pending,crypto.randomUUID()+".json");
const fd = fs.openSync(file,"wx",0o600);
try { fs.writeFileSync(fd,JSON.stringify(entry)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
reconcileLedger(wolfDir);
}
/** All per-session snapshots survive the 200-row UI window. A late resumed
* session replaces its old snapshot instead of being added to baseline twice.
*/
export function reconcileLedger(wolfDir: string): boolean {
const ledgerPath = path.join(wolfDir,"token-ledger.json");
const pending = path.join(wolfDir,"ledger-pending");
const snapshots = path.join(wolfDir,"ledger-sessions");
fs.mkdirSync(snapshots,{recursive:true});
const result = withFileLock(ledgerPath+".lock",HOOK_LOCK_BUDGET_MS,()=>{
const read = <T>(file: string, fallback: T): T => {
try {return JSON.parse(fs.readFileSync(file,"utf8"));} catch(e) {if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e;}
};
const ledger = read<LedgerData>(ledgerPath,emptyLedger());
const baselineFile = path.join(wolfDir,"_legacy-ledger-baseline.json");
const legacy = read(baselineFile,{totals:ledger.lifetime_baseline ?? {},maps:ledger.lifetime_baseline_maps ?? emptyMaps()});
if (!fs.existsSync(baselineFile)) writeJSON(baselineFile,legacy);
const key = (e: SessionEntry) => crypto.createHash("sha256").update(`${e.agent ?? 'claude'}:${e.id}`).digest("hex")+".json";
for (const e of ledger.sessions ?? []) {
if (!e?.id) continue;
const dest = path.join(snapshots,key(e));
if (!fs.existsSync(dest)) writeJSON(dest,e);
}
let names: string[] = [];
try {names=fs.readdirSync(pending).filter(n=>n.endsWith(".json"));} catch {}
for (const name of names) {
const e = read<SessionEntry | null>(path.join(pending,name),null);
if (!e?.id || !e.totals) throw new Error("Invalid pending ledger entry retained for review");
const dest = path.join(snapshots,key(e));
const previous = read<SessionEntry | null>(dest,null);
if (!previous || e.ended >= previous.ended) writeJSON(dest,e);
}
const all = fs.readdirSync(snapshots).filter(n=>n.endsWith(".json")).map(n=>read<SessionEntry>(path.join(snapshots,n),null as any))
.sort((a,b)=>a.started.localeCompare(b.started) || a.id.localeCompare(b.id));
ledger.sessions = all.slice(-MAX_LEDGER_SESSIONS);
const baseline = numericFields(legacy.totals);
ledger.lifetime_baseline = baseline;
ledger.lifetime_baseline_maps = structuredClone(legacy.maps);
for (const old of all.slice(0,Math.max(0,all.length-MAX_LEDGER_SESSIONS))) {
foldEntry(baseline,old);
foldEntryMaps(ledger.lifetime_baseline_maps,old);
}
recomputeLifetime(ledger);
writeJSON(ledgerPath,ledger);
for (const name of names) {try {fs.unlinkSync(path.join(pending,name));} catch {}}
return true;
});
return result === true;
}
+41
View File
@@ -0,0 +1,41 @@
import { persistRead, reconcileReads } from "./event-journal.js"
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, normalizePath, estimateTokens, sessionFilePath } from "./fs.js"
import { lookupEntry } from "./anatomy.js"
import type { PartialSessionState } from "./types.js"
export function handlePostRead(directory: string, sessionId: string, filePath: string, content: string, ranged = false, observationId?: string): void {
if (ranged) return
filePath=path.resolve(directory,filePath)
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const normalizedFile = normalizePath(filePath)
const projectDir = normalizePath(directory)
const rel=path.relative(directory,filePath)
if (!rel || rel==='..' || rel.startsWith('..'+path.sep) || path.isAbsolute(rel) || rel.split(path.sep)[0]==='.wolf') 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 as "code" | "prose" | "mixed") : 0
// Fallback: if the tool output had no content, use the anatomy token estimate
if (tokens === 0) {
const entry = lookupEntry(wolfDir, projectDir, normalizedFile)
if (entry) tokens = entry.tokens
}
let fingerprint: string | undefined
let mtime=0
try {const current=fs.readFileSync(filePath,"utf8");if(current===content){fingerprint=crypto.createHash("sha256").update(current).digest("hex");mtime=fs.statSync(filePath).mtimeMs}}catch{}
persistRead(sessionFile,{id:observationId ?? crypto.randomUUID(),file:normalizedFile,at:new Date().toISOString(),tokens,fingerprint,mtime})
reconcileReads(sessionFile)
}
+261
View File
@@ -0,0 +1,261 @@
import { persistObservation, reconcileReads } from "./event-journal.js"
import * as crypto from "node:crypto"
import { recordBug } from "./bug-journal.js"
import { sharedWolfDir } from "./knowledge-root.js";
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import { nextBugId } from "./bug-id.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, normalizePath, estimateTokens, isSensitiveFile, sessionFilePath } from "./fs.js"
import { extractDescription, withAnatomyLock, loadStoreReconciled, saveStore, renderToFile, sha256, LOCK_BUDGET_MS } from "./anatomy.js"
import type { PartialSessionState, FixDetection } from "./types.js"
// File types where a value/string change is normal content editing, not a bug
// fix — auto bug detection never runs on these (see autoDetectBugFix). Without
// this, a version bump in a README or a key change in a JSON/YAML config is
// logged as a "wrong-value" bug, since the detector matches quoted spans
// (including markdown backticks) regardless of file type.
const NON_CODE_EXTS = new Set([
".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc",
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".env",
".lock", ".csv", ".tsv",
])
export function handlePostWrite(
directory: string,
sessionId: string,
toolName: string,
filePath: string,
content: string,
oldStr: string,
newStr: string
): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const projectRoot = directory
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath)
const relPath = normalizePath(path.relative(projectRoot, absolutePath))
if (relPath.startsWith(".wolf/")) return
// Never track files outside the project root (e.g. a scratchpad under
// /private/tmp). path.relative() yields ../.. section keys that pollute
// anatomy.md and are wiped again by every full scan, so the index churns
// instead of converging.
if (relPath.startsWith("..") || path.isAbsolute(relPath)) return
// Never track secret-bearing files in anatomy/memory (issue #54): .env is
// not the only file whose description would leak sensitive content.
const baseName = path.basename(absolutePath)
if (isSensitiveFile(baseName)) return
updateAnatomy(wolfDir, absolutePath, projectRoot, content)
appendToMemory(wolfDir, toolName, absolutePath, projectRoot, content, newStr)
trackSession(sessionFile, filePath, toolName, content, newStr, baseName, projectRoot, absolutePath)
if (oldStr && newStr) {
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr)
}
}
function updateAnatomy(wolfDir: string, absolutePath: string, projectRoot: string, content: string): void {
try {
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath))
let fileContent = ""
try {
fileContent = fs.readFileSync(absolutePath, "utf-8")
} catch {
fileContent = content ?? ""
}
const desc = extractDescription(absolutePath).slice(0, 100)
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const proseExts = new Set([".md", ".txt", ".rst"])
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed"
const tokens = estimateTokens(fileContent, type as "code" | "prose" | "mixed")
let size: number | undefined
let mtimeMs: number | undefined
try {
const st = fs.statSync(absolutePath)
size = st.size
mtimeMs = st.mtimeMs
} catch {}
withAnatomyLock(wolfDir, LOCK_BUDGET_MS, () => {
const store = loadStoreReconciled(wolfDir, projectRoot)
store.files[relPathLocal] = {
description: desc,
tokens,
hash: sha256(fileContent).slice(0, 16),
size,
mtimeMs,
updatedAt: new Date().toISOString(),
source: "hook",
// The plugin cannot recompute symbols; drop them so stale line
// ranges never misdirect a slice read (the next scan restores them).
symbols: undefined,
}
store.meta.lastScanned = new Date().toISOString()
renderToFile(wolfDir, store)
saveStore(wolfDir, store)
})
} catch {}
}
function appendToMemory(
wolfDir: string,
toolName: string,
absolutePath: string,
projectRoot: string,
content: string,
newStr: string
): void {
try {
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited"
const relFile = normalizePath(path.relative(projectRoot, absolutePath))
const fileContent = content ?? ""
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const type = codeExts.has(ext) ? "code" : "mixed"
const writeTokens = estimateTokens(fileContent || newStr, type as "code" | "prose" | "mixed")
let changeDesc = ""
if (content && newStr) {
changeDesc = summarizeEdit(content, newStr, path.basename(absolutePath))
}
const memoryPath = path.join(wolfDir, "memory.md")
const outcome = changeDesc || "—"
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`)
} catch {}
}
function trackSession(
sessionFile: string,
filePath: string,
toolName: string,
content: string,
newStr: string,
baseName: string,
projectRoot: string,
absolutePath: string
): void {
try {
const normalizedFile=normalizePath(absolutePath)
const action=toolName.toLowerCase()==="write"?"create":"edit"
const tokens=estimateTokens(content || newStr,"code")
const editKey=normalizePath(path.relative(projectRoot,absolutePath))
persistObservation(sessionFile,{id:crypto.randomUUID(),kind:"write",file:normalizedFile,action,tokens,editKey,at:new Date().toISOString()})
reconcileReads(sessionFile)
} catch {}
}
export function summarizeEdit(oldStr: string, newStr: string, filename: string): string {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
const oldCount = oldLines.length
const newCount = newLines.length
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) return "added error handling"
if (newStr.includes("?.") && !oldStr.includes("?.")) return "added optional chaining"
if (newStr.includes("?? ") && !oldStr.includes("?? ")) return "added nullish coalescing"
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) return `removed ${oldCount} lines`
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) return `added ${newImports - oldImports} import(s)`
if (oldCount === 1 && newCount === 1) {
const o = oldStr.trim()
const n = newStr.trim()
const oStr = o.match(/['"`]([^'"`]+)['"`]/)
const nStr = n.match(/['"`]([^'"`]+)['"`]/)
if (oStr && nStr && oStr[1] !== nStr[1]) return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`
return "inline fix"
}
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/)
if (fnMatch) return `modified ${fnMatch[1]}()`
if (newCount > oldCount + 5) return `expanded (+${newCount - oldCount} lines)`
if (oldCount > newCount + 5) return `reduced (-${oldCount - newCount} lines)`
return `${oldCount}→${newCount} lines`
}
function bugAutoDetectEnabled(wolfDir: string): boolean {
try {
const cfg = readJSON<{ openwolf?: { buglog?: { auto_detect?: boolean } } }>(
path.join(wolfDir, "config.json"),
{}
)
// Default on; only an explicit `false` disables auto bug detection.
return cfg.openwolf?.buglog?.auto_detect !== false
} catch {
return true
}
}
export function autoDetectBugFix(wolfDir: string, absolutePath: string, projectRoot: string, oldStr: string, newStr: string): void {
const basename = path.basename(absolutePath)
const ext = path.extname(basename).toLowerCase()
// Bug-fix detection is a code concept — never fire on prose/docs/data files.
if (NON_CODE_EXTS.has(ext)) return
// Respect an explicit opt-out in .wolf/config.json (default: enabled).
if (!bugAutoDetectEnabled(wolfDir)) return
const detection=detectFixPattern(oldStr,newStr,ext,basename)
if (!detection) return
recordBug(wolfDir,{error_message:detection.summary,file:normalizePath(path.relative(projectRoot,absolutePath)),root_cause:detection.rootCause,fix:detection.fix,tags:["auto-detected",detection.category,ext.slice(1)||"unknown"],status:"candidate",observed_worktree:projectRoot})
}
export function detectFixPattern(oldStr: string, newStr: string, ext: string, basename: string): FixDetection | null {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
if (!/(?:Test|IT|Spec)\.\w+$|(?:[._](?:test|spec))\.\w+$|^test_/.test(basename) && /\bcatch\s*\(/.test(newStr) && !/\bcatch\s*\(/.test(oldStr)) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || basename
return { category: "error-handling", summary: `Missing error handling in ${fn}`, rootCause: "Code path had no error handling", fix: "Added try/catch block", context: extractChangedLines(oldStr, newStr) }
}
if ((newStr.includes("?.") && !oldStr.includes("?.")) || (newStr.includes("?? ") && !oldStr.includes("?? "))) {
return { category: "null-safety", summary: `Null/undefined access in ${basename}`, rootCause: "Property access on potentially null/undefined value", fix: "Added null safety", context: extractChangedLines(oldStr, newStr) }
}
if (/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(newStr) && !/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(oldStr)) {
const condition = newStr.match(/if\s*\(([^)]+)\)/)?.[1]?.trim().slice(0, 60) || "condition"
return { category: "guard-clause", summary: "Missing guard clause", rootCause: `No early return for: ${condition}`, fix: `Added guard clause: if (${condition.slice(0, 40)})` }
}
if (oldLines.length <= 3 && newLines.length <= 3) {
const oStrs = oldStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
const nStrs = newStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
if (oStrs.length > 0 && nStrs.length > 0) {
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
if (oStrs[i] !== nStrs[i]) {
return { category: "wrong-value", summary: "Incorrect value in code", rootCause: `Had ${oStrs[i].slice(0, 50)}`, fix: `Changed to ${nStrs[i].slice(0, 50)}` }
}
}
}
}
if (newStr.includes("await ") && !oldStr.includes("await ")) {
return { category: "async-fix", summary: "Missing await", rootCause: "Async call without await", fix: "Added await to async call", context: extractChangedLines(oldStr, newStr) }
}
return null
}
function extractChangedLines(oldStr: string, newStr: string): string {
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean))
const added = newStr.split("\n").map(l => l.trim()).filter(l => l && !oldLines.has(l))
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ")
}
+100
View File
@@ -0,0 +1,100 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, normalizePath, sessionFilePath } from "./fs.js"
import { lookupEntry } from "./anatomy.js"
import type { PartialSessionState } from "./types.js"
export function handlePreRead(directory: string, sessionId: string, filePath: string, isRangedRead = false): void {
filePath=path.resolve(directory,filePath)
const relative=path.relative(directory,filePath)
if (!relative || relative === ".." || relative.startsWith(".."+path.sep) || path.isAbsolute(relative)) return
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const normalizedFile = normalizePath(filePath)
const projectDir = normalizePath(directory)
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: ""
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) return
// Ranged reads (offset/limit) are exactly what the symbol hints steer the
// model toward — never warn about them or record them as full reads (a
// ranged first contact must not make a later legitimate full read look like
// a duplicate).
if (isRangedRead) return
const session = readJSON<PartialSessionState>(sessionFile, {
session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0,
})
if (!session.files_read) session.files_read = {}
// Repeat detection is mtime-gated (issue #41): only warn while the file is
// unchanged since the recorded read. A modified file is a legitimate
// re-read — drop the stale record and track it fresh below.
if (session.files_read[normalizedFile]) {
const prev = session.files_read[normalizedFile]
let modifiedSinceRead = true
try {
const mtime = fs.statSync(filePath).mtimeMs
modifiedSinceRead = prev.read_mtime === undefined || mtime !== prev.read_mtime
} catch {}
if (!modifiedSinceRead) {
prev.count++
session.repeated_reads_warned = (session.repeated_reads_warned || 0) + 1
console.warn(`OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok), unchanged since. If you only need the gist, your earlier read may suffice; for exact text (edit anchors, line numbers), the re-read is fine.`)
writeJSON(sessionFile, session)
return
}
delete session.files_read[normalizedFile]
}
// Anatomy lookup: O(1) against the durable store, legacy md scan fallback.
const entry = lookupEntry(wolfDir, projectDir, normalizedFile)
const found = entry !== null
if (entry) {
if (entry.description) {
console.warn(`OpenWolf anatomy: ${entry.file}: ${entry.description} (~${entry.tokens} tok)`)
}
// Symbol hint: point at slices of big files. Suppressed if the on-disk
// file no longer matches what was indexed — a stale line range that
// misdirects an offset read is worse than no hint at all.
if (entry.symbols && entry.symbols.length > 0) {
let fresh = false
try {
const st = fs.statSync(filePath)
fresh = (entry.size === undefined || st.size === entry.size) &&
(entry.mtimeMs === undefined || Math.abs(st.mtimeMs - entry.mtimeMs) < 1)
} catch {}
if (fresh) {
const top = [...entry.symbols].sort((a, b) => b.tokens - a.tokens).slice(0, 5)
const list = top.map((s) => `${s.kind} ${s.name} L${s.startLine}-${s.endLine} ~${s.tokens} tok`).join("; ")
console.warn(`Largest sections: ${list}. Read with offset/limit to fetch just the part you need.`)
}
}
}
session.anatomy_hits = (session.anatomy_hits || 0) + (found ? 1 : 0)
session.anatomy_misses = (session.anatomy_misses || 0) + (found ? 0 : 1)
// Record initial read entry (tokens will be updated in post-read)
let readMtime: number | undefined
try {
readMtime = fs.statSync(filePath).mtimeMs
} catch {}
session.files_read[normalizedFile] = {
count: 1,
tokens: 0,
first_read: new Date().toISOString(),
read_mtime: readMtime,
anatomy_hit: found,
}
writeJSON(sessionFile, session)
}
+110
View File
@@ -0,0 +1,110 @@
import {recordReceipt} from './visibility.js';
import { sharedWolfDir } from "./knowledge-root.js";
import { approvedMemory } from "./trusted-memory.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, readMarkdown, normalizePath, readJSON } from "./fs.js"
const STOP_WORDS = new Set([
"error", "function", "return", "const", "this", "that", "with", "from",
"import", "export", "class", "interface", "type", "undefined", "null",
"true", "false", "string", "number", "object", "array", "value",
"file", "path", "name", "data", "response", "request", "result",
"should", "must", "does", "have", "been", "will", "would", "could",
"when", "then", "else", "each", "some", "every", "only",
])
function tokenize(text: string): Set<string> {
return new Set(
text.replace(/[^\w\s]/g, " ").split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()))
.map(w => w.toLowerCase())
)
}
export function handlePreWrite(directory: string, sessionId: string, filePath: string, content: string, oldStr: string, newStr: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const allContent = [content, oldStr, newStr].join("\n")
if (!allContent.trim()) return
checkCerebrum(wolfDir, allContent)
if (filePath && (oldStr || content)) {
checkBugLog(wolfDir, filePath, oldStr, newStr, content, sessionId)
}
}
function checkCerebrum(wolfDir: string, content: string): void {
const cerebrumContent = approvedMemory(wolfDir, "cerebrum.md")
const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1]
if (!doNotRepeatSection) return
const entries = doNotRepeatSection.split("## ")[0]
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"))
for (const line of lines) {
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "")
if (!trimmed) continue
const patterns: string[] = []
const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g)
if (quotedMatches) {
for (const qm of quotedMatches) {
patterns.push(qm.replace(/["'`]/g, ""))
}
}
const neverMatch = trimmed.match(/(?:never use|avoid|don't use|do not use)\s+(\w+)/i)
if (neverMatch) patterns.push(neverMatch[1])
for (const pattern of patterns) {
try {
const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
if (regex.test(content)) {
console.warn(`⚠️ OpenWolf cerebrum warning: "${trimmed}" — check your code before proceeding.`)
}
} catch {}
}
}
}
interface BugEntry {
id: string
error_message: string
root_cause: string
fix: string
file: string
tags: string[]
}
function checkBugLog(wolfDir: string, filePath: string, oldStr: string, newStr: string, content: string, sessionId: string): void {
const bugLogPath = path.join(sharedWolfDir(wolfDir), "buglog.json")
if (!fs.existsSync(bugLogPath)) return
const bugLog = readJSON<{ version: number; bugs: BugEntry[] }>(bugLogPath, { version: 1, bugs: [] })
if (bugLog.bugs.length === 0) return
const basename = path.basename(filePath)
const fileMatches = bugLog.bugs.filter((b: BugEntry) => path.basename(b.file) === basename)
if (fileMatches.length === 0) return
const editText = (oldStr + " " + newStr + " " + content).toLowerCase()
const editTokens = tokenize(editText)
const relevant = fileMatches.filter((bug: BugEntry) => {
const tagHit = bug.tags.some((t: string) => editText.includes(t.toLowerCase()))
if (tagHit) return true
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause)
const overlap = [...editTokens].filter(t => bugTokens.has(t))
return overlap.length >= 3
})
if (relevant.length === 0) return
const fixes=relevant.slice(0,2).filter(b=>typeof b.fix==="string"&&b.fix.trim())
if(fixes.length)recordReceipt(path.dirname(wolfDir),{agent:"opencode",session:sessionId,operation:"fix-retrieved",evidence:JSON.stringify(fixes.map(b=>[b.id,b.fix])),count:fixes.length})
console.warn(`📋 OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} — review for context, do NOT apply blindly:`)
for (const bug of relevant.slice(0, 2)) {
console.warn(` [${bug.id}] "${bug.error_message.slice(0, 70)}"`)
console.warn(` Cause: ${bug.root_cause.slice(0, 80)}`)
console.warn(` Fix: ${bug.fix.slice(0, 80)}`)
}
}
@@ -0,0 +1,119 @@
import {visibilityMode} from './visibility.js';
/** Session-pinned npm runtimes. This module performs local I/O only. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {spawn} from 'node:child_process';
import {pathToFileURL, fileURLToPath} from 'node:url';
export const RUNTIME_PROTOCOL = 1;
export const CHECK_INTERVAL = 24 * 3600_000;
export interface UpdateState {
checkedAt?: number; latest?: string; selected?: string; previous?: string;
status?: 'current'|'ready'|'available'|'error'|'unsupported'; detail?: string;
}
export const stableVersion = (v: unknown): v is string => typeof v === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(v) && v.split('.').every(n=>Number.isSafeInteger(Number(n)));
export function newer(a: string,b: string): boolean {
if (!stableVersion(a)||!stableVersion(b)) return false;
const x=a.split('.').map(Number),y=b.split('.').map(Number);
for(let i=0;i<3;i++)if(x[i]!==y[i])return x[i]>y[i];
return false;
}
export function compatible(a:string,b:string):boolean {
if(!stableVersion(a)||!stableVersion(b))return false;
const x=a.split('.'),y=b.split('.');
return x[0]===y[0] && (x[0]!=='0'||x[1]===y[1]);
}
export function readObject(file:string):any {try{const v=JSON.parse(fs.readFileSync(file,'utf8'));return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch{return {}}}
export function updatesDir(root:string):string{return path.join(root,'.wolf','updates')}
export function updateState(root:string):UpdateState {return readObject(path.join(updatesDir(root),'state.json'))}
export function atomicObject(file:string,value:unknown):void {
fs.mkdirSync(path.dirname(file),{recursive:true});const temp=file+'.'+crypto.randomUUID()+'.tmp';
try{const fd=fs.openSync(temp,'wx',0o600);try{fs.writeFileSync(fd,JSON.stringify(value));fs.fsyncSync(fd)}finally{fs.closeSync(fd)}fs.renameSync(temp,file)}finally{try{fs.unlinkSync(temp)}catch{}}
}
export function updatePolicy(root:string):'compatible'|'all'|'notify'|'off' {
const file=path.join(root,'.wolf','config.json');
let cfg:any;
try {
const parsed=JSON.parse(fs.readFileSync(file,'utf8'));
if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))return 'off';
cfg=parsed.openwolf;
} catch(error) { if((error as NodeJS.ErrnoException).code!=='ENOENT')return 'off'; }
if(cfg?.enabled===false || process.env.OPENWOLF_NO_UPDATE==='1')return 'off';
const mode=cfg?.updates?.mode ?? 'compatible';
return ['compatible','all','notify','off'].includes(mode)?mode:'off';
}
export function installedVersion(root:string):string {
const v=readObject(path.join(root,'.wolf','hooks','runtime.json')).version;
return stableVersion(v)?v:'unknown';
}
export function releasePackage(root:string,version:string):string {
if(!stableVersion(version))throw new Error('Invalid release version');
return path.join(updatesDir(root),'releases',version,'node_modules','openwolf');
}
export function readyRelease(root:string,version:unknown):string|undefined {
if(!stableVersion(version))return;
const pkg=releasePackage(root,version),meta=readObject(path.join(pkg,'package.json'));
const ready=readObject(path.join(updatesDir(root),'releases',version,'ready.json'));
if(meta.name==='openwolf'&&meta.version===version&&meta.openwolfRuntime?.protocol===RUNTIME_PROTOCOL&&ready.version===version)return pkg;
}
// Windows reports uid=0 for ordinary files; it is not POSIX root ownership.
// Protected authority on Windows is unsupported and remains disabled by its verifier.
const protectedRuntime=()=>process.platform!=='win32'&&fs.statSync(fileURLToPath(import.meta.url)).uid===0;
/** Never select a new version for an existing session (including resume). */
export function pinRuntime(root:string,session:string):string|undefined {
if(!session || !stableVersion(installedVersion(root)) || protectedRuntime())return; // protected runtime or no identity => installed
const dir=path.join(updatesDir(root),'pins');
const file=path.join(dir,crypto.createHash('sha256').update(session).digest('hex')+'.json');
let pin=readObject(file);
if(!fs.existsSync(file)) {
const candidate=updateState(root).selected;
const version=updatePolicy(root)!=='off'&&stableVersion(candidate)&&newer(candidate,installedVersion(root))&&readyRelease(root,candidate)?candidate:null;
fs.mkdirSync(dir,{recursive:true});
const tmp=file+'.'+crypto.randomUUID()+'.tmp';
try{fs.writeFileSync(tmp,JSON.stringify({version}),{flag:'wx',mode:0o600});try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}}
pin=readObject(file);
}
return readyRelease(root,pin.version);
}
/** At most one detached, bounded worker per project/check interval. */
export function scheduleUpdate(root:string):void {
try {
if(updatePolicy(root)==='off'||protectedRuntime())return;
const state=updateState(root);
if(typeof state.checkedAt==='number'&&Date.now()-state.checkedAt<CHECK_INTERVAL)return;
const worker=path.join(root,'.wolf','hooks','update-worker.js');
if(!fs.existsSync(worker)||!stableVersion(installedVersion(root)))return;
const dir=updatesDir(root);fs.mkdirSync(dir,{recursive:true});
const request=path.join(dir,'requested');
try {if(Date.now()-fs.statSync(request).mtimeMs<300_000)return;fs.unlinkSync(request)}catch{}
try{fs.writeFileSync(request,'',{flag:'wx',mode:0o600})}catch{return}
const child=spawn(/^bun(?:\.exe)?$/.test(path.basename(process.execPath))?'node':process.execPath,[worker,root],{cwd:dir,detached:true,stdio:'ignore',windowsHide:true});
child.on('error',()=>{try{fs.unlinkSync(request)}catch{}});child.unref();
} catch {} // unavailable npm/network must never interrupt a coding session
}
const bridgeKey=Symbol.for('openwolf.hook.runtime');
export function bridgedInput():string|undefined{return (globalThis as any)[bridgeKey]?.input}
export async function delegateRuntime(root:string,hook:string,input:string):Promise<boolean> {
if((globalThis as any)[bridgeKey])return false;
if(!stableVersion(installedVersion(root)))return false;
// A protected runtime must not execute repository-selected code.
if(protectedRuntime())return false;
let payload:any;try{payload=JSON.parse(input)}catch{return false}
const pkg=pinRuntime(root,payload.session_id ?? '');
if(!pkg || !/^[a-z-]+$/.test(hook))return false;
const entry=path.join(pkg,'dist','hooks',hook+'.js');
if(!fs.existsSync(entry))return false;
(globalThis as any)[bridgeKey]={input};
try{await import(pathToFileURL(entry).href);return true}catch{delete (globalThis as any)[bridgeKey];return false}
}
export function updateNotice(root:string,session:string):string|undefined {
if(!session||updatePolicy(root)==='off'||visibilityMode(root)==='off')return;
const s=updateState(root);if(!stableVersion(s.latest)||!newer(s.latest,installedVersion(root)))return;
if(s.status!=='ready'&&s.status!=='available'&&s.status!=='unsupported')return;
const id=crypto.createHash('sha256').update(s.status+':'+s.latest).digest('hex');
const file=path.join(updatesDir(root),'notices',id);
try{fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'',{flag:'wx',mode:0o600})}catch{return}
if(s.status==='unsupported')return `OpenWolf ${s.latest} is available; this installation needs a runtime migration.`;
return s.status==='ready'?`OpenWolf ${s.latest} is ready for new sessions; this session keeps its pinned runtime.`:`OpenWolf ${s.latest} is available; automatic upgrade is outside this project's update policy.`;
}
@@ -0,0 +1,20 @@
/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */
export function normalizeSession(value: unknown, id: string, now = new Date().toISOString()): Record<string, any> {
const s: Record<string, any> = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
s.session_id = id;
if (typeof s.started !== "string" || !Number.isFinite(Date.parse(s.started))) s.started = now;
for (const key of ["files_read", "edit_counts", "reminders_sent", "injected_by_source"]) {
if (!s[key] || typeof s[key] !== "object" || Array.isArray(s[key])) s[key] = {};
}
for (const key of ["files_written", "pending_reminders"]) if (!Array.isArray(s[key])) s[key] = [];
for (const key of ["anatomy_hits", "anatomy_misses", "repeated_reads_warned", "stop_count", "cerebrum_warnings"]) {
if (!Number.isSafeInteger(s[key]) || s[key] < 0) s[key] = 0;
}
for (const [file, r] of Object.entries(s.files_read) as Array<[string, any]>) {
if (!r || typeof r !== "object") { delete s.files_read[file]; continue; }
r.count = Number.isSafeInteger(r.count) && r.count >= 0 ? r.count : 0;
r.tokens = Number.isFinite(r.tokens) && r.tokens >= 0 ? r.tokens : 0;
}
s.files_written = s.files_written.filter((w: any) => w && typeof w.file === "string").map((w: any) => ({ ...w, tokens: Number.isFinite(w.tokens) && w.tokens >= 0 ? w.tokens : 0 }));
return s;
}
+93
View File
@@ -0,0 +1,93 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, timestamp, readMarkdown, sessionFilePath, gcSessionFiles } from "./fs.js"
import type { SessionState } from "./types.js"
const sessions = new Map<string, SessionState>()
export function getSessionState(sessionId: string): SessionState | undefined {
return sessions.get(sessionId)
}
export function setSessionState(sessionId: string, state: SessionState): void {
sessions.set(sessionId, state)
}
export function deleteSession(sessionId: string): void {
sessions.delete(sessionId)
}
export function handleSessionStart(directory: string, sessionId: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
fs.mkdirSync(hooksDir, { recursive: true })
gcSessionFiles(hooksDir)
const sessionFile = sessionFilePath(hooksDir, sessionId)
const existing = readJSON<SessionState | null>(sessionFile, null)
if (existing?.session_id === sessionId) { sessions.set(sessionId, existing); return }
const state: SessionState = {
session_id: sessionId,
started: timestamp(),
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
}
sessions.set(sessionId, state)
writeJSON(sessionFile, state)
const memoryPath = path.join(wolfDir, "memory.md")
const now = new Date()
const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`
appendMarkdown(memoryPath, header)
try {
const cerebrumPath = path.join(wolfDir, "cerebrum.md")
const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8")
const stat = fs.statSync(cerebrumPath)
const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24)
const entryLines = cerebrumContent.split("\n").filter(l => {
const t = l.trim()
return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]"))
})
if (entryLines.length < 3) {
console.warn(`💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.`)
} else if (daysSinceUpdate > 3) {
console.warn(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`)
}
} catch {}
try {
const buglogPath = path.join(wolfDir, "buglog.json")
const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] })
if (buglog.bugs.length === 0) {
console.warn(`📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`)
}
} catch {}
// Count the new session in the lifetime totals. readJSON deep-merges the
// fallback, so a pre-2.0 ledger without `lifetime` still gets the default;
// the guard below covers a ledger where `lifetime` is not an object at all.
const ledgerPath = path.join(wolfDir, "token-ledger.json")
const ledger = readJSON<Record<string, unknown>>(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }) as {
version: number
lifetime: { total_sessions: number }
[key: string]: unknown
}
if (!ledger.lifetime || typeof ledger.lifetime !== "object") {
ledger.lifetime = { total_sessions: 0 }
}
if (typeof ledger.lifetime.total_sessions !== "number" || !isFinite(ledger.lifetime.total_sessions)) {
ledger.lifetime.total_sessions = 0
}
ledger.lifetime.total_sessions++
writeJSON(ledgerPath, ledger)
}
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
import { flushSessionToLedger as flushCanonicalLedger } from "./ledger.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, sessionFilePath } from "./fs.js"
import type { SessionState } from "./types.js"
// ─────────────────────────────────────────────────────────────────────────────
// Token-ledger writer (mirrors src/hooks/ledger.ts).
//
// Stop fires at the end of EVERY turn, so the ledger write must be idempotent:
// the session entry is UPSERTED by session id (replaced, never appended), and
// lifetime totals are derived — archived baseline + fold over the retained
// sessions — rather than incremented. The old increment-on-every-Stop scheme
// re-added turns 1..N on turn N, quadratically inflating every lifetime metric
// and duplicating session entries.
// ─────────────────────────────────────────────────────────────────────────────
export const MAX_LEDGER_SESSIONS = 200
interface SessionEntry {
id: string
agent: string
started: string
ended: string
reads: Array<{
file: string
tokens_estimated: number
was_repeated: boolean
anatomy_had_description: boolean
}>
writes: Array<{ file: string; tokens_estimated: number; action: string }>
totals: {
input_tokens_estimated: number
output_tokens_estimated: number
reads_count: number
writes_count: number
repeated_reads_blocked: number
repeated_reads_warned?: number
anatomy_lookups: number
anatomy_misses?: number
savings_estimated?: number
injection_tokens_estimated?: number
}
}
interface LifetimeTotals {
total_tokens_estimated: number
total_reads: number
total_writes: number
total_sessions: number
anatomy_hits: number
anatomy_misses: number
repeated_reads_blocked: number
repeated_reads_warned: number
estimated_savings_vs_bare_cli: number
injection_tokens_estimated: number
[key: string]: number
}
interface LedgerData {
version: number
created_at: string
lifetime: LifetimeTotals
/** Totals folded out of sessions that were rolled off the retained window. */
lifetime_baseline?: Partial<LifetimeTotals>
sessions: SessionEntry[]
[key: string]: unknown
}
function emptyLedger(): LedgerData {
return {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
}
}
/** Build the ledger entry for the current session state (idempotent). */
function buildSessionEntry(session: SessionState): SessionEntry {
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: data.anatomy_hit === true,
}))
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}))
return {
id: session.session_id,
agent: "opencode",
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: {
input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0),
output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0),
reads_count: reads.length,
writes_count: writes.length,
// Honest accounting: only reads the hook actually denied count as
// blocked (warnings do not prevent the read from happening).
repeated_reads_blocked: session.reads_denied ?? 0,
repeated_reads_warned: session.repeated_reads_warned ?? 0,
anatomy_lookups: session.anatomy_hits,
anatomy_misses: session.anatomy_misses,
// Honest savings: tokens of reads that were denied, nothing else.
savings_estimated: session.denied_tokens_saved ?? 0,
injection_tokens_estimated: session.injected_tokens_estimated ?? 0,
},
}
}
function addInto(target: Record<string, number>, key: string, value: number | undefined): void {
if (typeof value !== "number" || !isFinite(value)) return
target[key] = (target[key] ?? 0) + value
}
/** Copy only real numeric fields out of a possibly-partial totals object. */
function numericFields(source: Record<string, number | undefined> | undefined): Record<string, number> {
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(source ?? {})) {
if (typeof v === "number" && isFinite(v)) out[k] = v
}
return out
}
function foldEntry(acc: Record<string, number>, e: SessionEntry): void {
addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated)
addInto(acc, "total_reads", e.totals.reads_count)
addInto(acc, "total_writes", e.totals.writes_count)
addInto(acc, "anatomy_hits", e.totals.anatomy_lookups)
addInto(acc, "anatomy_misses", e.totals.anatomy_misses)
addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked)
addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned)
addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated)
addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated)
}
/**
* Derive lifetime = baseline + fold(sessions). total_sessions is intentionally
* NOT derived here — session start counts it once per new session.
*/
function recomputeLifetime(ledger: LedgerData): void {
const acc = numericFields(ledger.lifetime_baseline)
delete acc.total_sessions
for (const e of ledger.sessions) foldEntry(acc, e)
const totalSessions = ledger.lifetime?.total_sessions ?? 0
ledger.lifetime = {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
...acc,
total_sessions: totalSessions,
} as LifetimeTotals
}
/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */
function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void {
flushCanonicalLedger(wolfDir, entry as any)
}
export function handleStop(directory: string, sessionId: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const session = readJSON<SessionState>(sessionFile, {
session_id: "", started: "", files_read: {}, files_written: [],
edit_counts: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0, cerebrum_warnings: 0, stop_count: 0,
})
session.stop_count++
const readCount = Object.keys(session.files_read).length
const writeCount = session.files_written.length
if (readCount === 0 && writeCount === 0) {
writeJSON(sessionFile, session)
return
}
checkForMissingBugLogs(session)
// Idempotent ledger write: the entry for this session id is REPLACED, not
// appended — Stop fires every turn, and appending per turn is what used to
// duplicate sessions and quadratically inflate lifetime totals.
const entry = buildSessionEntry(session)
flushSessionToLedger(wolfDir, entry)
appendSessionSummary(wolfDir, session, readCount, writeCount)
writeJSON(sessionFile, session)
}
function checkForMissingBugLogs(session: SessionState): void {
if (!session.edit_counts) return
const multiEditFiles = Object.entries(session.edit_counts)
.filter(([, count]) => count >= 3)
.map(([file]) => path.basename(file))
if (multiEditFiles.length > 0) {
const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json"))
if (!buglogWritten) {
console.warn(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.`)
}
}
}
function appendSessionSummary(wolfDir: string, session: SessionState, readCount: number, writeCount: number): void {
if (writeCount > 0) {
try {
const inputTokens = Object.values(session.files_read).reduce((sum, r) => sum + r.tokens, 0)
const outputTokens = session.files_written.reduce((sum, w) => sum + w.tokens, 0)
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)))
const fileList = [...uniqueFiles].slice(0, 5).join(", ")
const memoryPath = path.join(wolfDir, "memory.md")
appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`)
} catch {}
}
}
@@ -0,0 +1,56 @@
import {recordReceipt} from './visibility.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
export interface MemoryApproval {
version: 1;
roots: string[];
approved_by: string;
approved_at: string;
deployment: 'managed-harness';
revoked_by?:string;revoked_at?:string;supersedes?:string;
documents: Record<string, {content: string; sha256: string; source: string}>;
}
export interface MemoryTrust {status: 'ready' | 'unavailable' | 'revoked'; reason: string; approval?: MemoryApproval}
const digest = (s: string) => crypto.createHash('sha256').update(s).digest('hex');
/** Owner and effective access both matter (ACLs may grant writes despite mode bits).
* Symlinks are rejected, including writable ancestors. No repository setting can
* grant approval. The authority is provisioned under a separate OS principal.
*/
export function protectedPath(target: string): boolean {
if (process.platform === 'win32' || !process.getuid || process.getuid() === 0) return false;
let p = path.resolve(target);
try {
for (;;) {
const st = fs.lstatSync(p);
if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) return false;
try { fs.accessSync(p, fs.constants.W_OK); return false; } catch {}
const parent = path.dirname(p);
if (parent === p) return true;
p = parent;
}
} catch { return false; }
}
export function memoryTrust(projectRoot: string): MemoryTrust {
const root = fs.realpathSync(projectRoot);
const store = process.env.OPENWOLF_TRUST_STORE ?? (process.platform === 'darwin' ? '/Library/Application Support/OpenWolf/trust' : '/var/lib/openwolf/trust');
// A protected store is insufficient if an agent can replace its verifier.
if (!protectedPath(fileURLToPath(import.meta.url))) return {status:'unavailable',reason:'Protected runtime and managed harness required; durable instruction injection is disabled.'};
const snapshot = path.join(store, digest(root) + '.json');
if (!protectedPath(snapshot)) return {status:'unavailable',reason:'No protected approval snapshot for this project.'};
try {
const a = JSON.parse(fs.readFileSync(snapshot,'utf8')) as MemoryApproval;
if (a.version !== 1 || a.deployment !== 'managed-harness' || !Array.isArray(a.roots) || !a.roots.includes(root) || !a.approved_by || !Number.isFinite(Date.parse(a.approved_at)) || !a.documents) throw new Error('Invalid approval');
if(a.revoked_at){if(!a.revoked_by||!Number.isFinite(Date.parse(a.revoked_at)))throw new Error('Invalid revocation');return {status:'revoked',reason:`Approval revoked by ${a.revoked_by} at ${a.revoked_at}; durable injection is disabled.`};}
for (const [name,d] of Object.entries(a.documents)) {
if (!/^[\w.-]+\.md$/.test(name) || typeof d.content !== 'string' || digest(d.content) !== d.sha256 || !d.source) throw new Error('Invalid approved document');
}
return {status:'ready',reason:'Protected approval snapshot loaded; managed harness is an operator prerequisite.',approval:a};
} catch { return {status:'unavailable',reason:'Approval snapshot failed verification.'}; }
}
export function approvedMemory(wolfDir: string, name: string): string {
try { const document=memoryTrust(path.dirname(wolfDir)).approval?.documents[name];if(document?.content)recordReceipt(path.dirname(wolfDir),{operation:'rule-verified',evidence:document.sha256});return document?.content??''; }
catch { return ''; }
}
+47
View File
@@ -0,0 +1,47 @@
export interface FileRead {
count: number
tokens: number
first_read: string
/** mtime of the file when it was read — repeat warnings only fire while unchanged (issue #41). */
read_mtime?: number
anatomy_hit?: boolean
}
export interface FileWrite {
file: string
action: string
tokens: number
at: string
}
export interface SessionState {
session_id: string
started: string
files_read: Record<string, FileRead>
files_written: FileWrite[]
edit_counts: Record<string, number>
anatomy_hits: number
anatomy_misses: number
repeated_reads_warned: number
reads_denied?: number
denied_tokens_saved?: number
cerebrum_warnings: number
injected_tokens_estimated?: number
stop_count: number
}
export type PartialSessionState = Partial<SessionState>
export interface FixDetection {
category: string
summary: string
rootCause: string
fix: string
context?: string
}
export interface AnatomyEntry {
file: string
description: string
tokens: number
}
+42
View File
@@ -0,0 +1,42 @@
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import * as fs from "node:fs"
import * as path from "node:path"
import * as crypto from "node:crypto"
/** Persist only usage metadata; never transcript text or private reasoning. */
export function recordUsage(directory: string, info: any): void {
if (info?.role !== "assistant" || !info.tokens || !info.id || !info.sessionID) return
const dir = path.join(directory, ".wolf", "usage", "opencode")
fs.mkdirSync(dir, { recursive: true })
const id = crypto.createHash("sha256").update(`${info.sessionID}:${info.id}`).digest("hex")
const file = path.join(dir, id + ".json")
const tmp = file + "." + crypto.randomUUID() + ".tmp"
const message = { id: info.id, sessionID: info.sessionID, role: info.role, providerID: info.providerID, modelID: info.modelID, tokens: info.tokens, time: info.time }
const committed = withFileLock(file + ".lock", HOOK_LOCK_BUDGET_MS, () => {
try {
const previous = JSON.parse(fs.readFileSync(file,"utf8"))?.message
if (previous?.time?.completed && !info.time?.completed) return true
if ((previous?.time?.completed ?? 0) > (info.time?.completed ?? 0)) return true
} catch {}
try {
const fd = fs.openSync(tmp, "wx", 0o600)
try { fs.writeFileSync(fd, JSON.stringify({ version: 1, directory, message })); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
fs.renameSync(tmp, file)
} finally { try { fs.unlinkSync(tmp) } catch {} }
return true
})
if (!committed) throw new Error("OpenCode usage busy; session reconciliation will retry")
}
/** Read-only SDK backfill recovers usage from before plugin installation. */
export async function reconcileOpenCode(directory: string, client: any, sessionId?: string): Promise<void> {
const unwrap = (response: any) => response?.data ?? response;
const sessions = sessionId ? [unwrap(await client.session.get({path:{id:sessionId}}))] : unwrap(await client.session.list({query:{directory}}));
if (!Array.isArray(sessions)) throw new Error("Unsupported OpenCode session list response");
for (const session of sessions) {
if (typeof session?.id !== "string" || typeof session.directory !== "string" || path.resolve(session.directory) !== path.resolve(directory)) continue;
const messages = unwrap(await client.session.messages({path:{id:session.id},query:{directory}}));
if (!Array.isArray(messages)) throw new Error("Unsupported OpenCode messages response");
for (const message of messages) recordUsage(directory,message.info);
}
}
+96
View File
@@ -0,0 +1,96 @@
/** Local activity receipts. Never add model context or change a tool decision. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import {createHash,randomUUID} from 'node:crypto';
import {mutateJSON} from './anatomy-lock.js';
export type VisibilityMode = 'off'|'quiet'|'verbose';
export type Operation = 'checkpoint-saved'|'context-restored'|'memory-archived'|'map-preserved'|'fix-retrieved'|'rule-verified'|'read-denied'|'runtime-ready';
export interface Receipt {schema:1;id:string;session:string;agent:string;operation:Operation;outcome:'completed';at:number;evidence:string;count:number;category:'routine'|'recovery';}
interface State {schema:1;membership?:string;seen:Record<string,number>;watermark:number;history:Receipt[];pending:Receipt[];counts:Partial<Record<Operation,number>>;lastAt:number;turns:Record<string,number>;startup:Record<string,boolean>;last?:{text:string;at:number;session:string;agent:string;transport:string};}
const hash=(s:string)=>createHash('sha256').update(s).digest('hex');
const dir=(root:string)=>path.join(root,'.wolf/activity');
const empty=():State=>({schema:1,seen:{},watermark:0,history:[],pending:[],counts:{},lastAt:0,turns:{},startup:{}});
const operations:Operation[]=['checkpoint-saved','context-restored','memory-archived','map-preserved','fix-retrieved','rule-verified','read-denied','runtime-ready'];
export function visibilityMode(root:string):VisibilityMode {
if(process.env.OPENWOLF_VISIBILITY==='off')return 'off';
try {const file=path.join(root,'.wolf/config.json');if(!fs.existsSync(file))return 'quiet';const mode=JSON.parse(fs.readFileSync(file,'utf8')).openwolf?.visibility?.mode;return mode===undefined?'quiet':mode==='quiet'||mode==='verbose'?mode:'off'}catch{return 'off'}
}
/** Caller supplies completed-operation evidence, never a pending write. Identity is hashed for privacy. */
export function recordReceipt(root:string,input:{operation:Operation;agent?:string;session?:string;evidence:string;count?:number;at?:number}):boolean {
try {
if(visibilityMode(root)==='off'||!fs.existsSync(path.join(root,'.wolf'))||!operations.includes(input.operation)||!input.evidence)return false;
const evidence=hash(input.evidence),id=hash(input.operation+':'+evidence);
const at=input.at??Date.now(),count=input.count??1;
if(!Number.isSafeInteger(count)||count<1||!Number.isFinite(at))return false;
const receipt:Receipt={schema:1,id,session:input.session?hash(input.session):'',agent:['claude','codex','opencode','grok'].includes(input.agent??'')?input.agent!:'project',operation:input.operation,outcome:'completed',at,evidence,count,category:input.operation==='context-restored'?'recovery':'routine'};
const queue=path.join(dir(root),'queue');fs.mkdirSync(queue,{recursive:true});
// Fixed 4096-slot spool: no directory scan, lock, sleep or unbounded backlog on tool paths.
// An occupied slot may drop a receipt, never an operational write. Counters are recorded actions only.
const file=path.join(queue,id.slice(0,3)+'.json'),tmp=file+'.'+randomUUID()+'.tmp';
try{fs.writeFileSync(tmp,JSON.stringify(receipt),{flag:'wx',mode:0o600});try{fs.linkSync(tmp,file);return true}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e;return false}}finally{try{fs.unlinkSync(tmp)}catch{}}
}catch{return false}
}
function readState(root:string):State {try{return JSON.parse(fs.readFileSync(path.join(dir(root),'state.json'),'utf8'))}catch{return empty()}}
function valid(r:Receipt):boolean{return r.schema===1&&/^[a-f0-9]{64}$/.test(r.id)&&/^[a-f0-9]{64}$/.test(r.evidence)&&operations.includes(r.operation)&&r.outcome==='completed'&&Number.isSafeInteger(r.count)&&r.count>0&&Number.isFinite(r.at)&&['claude','codex','opencode','grok','project'].includes(r.agent)&&(r.session===''||/^[a-f0-9]{64}$/.test(r.session))}
export function receiptText(r:Pick<Receipt,'operation'|'count'>):string {
const n=r.count;
const messages:Record<Operation,string>={
'checkpoint-saved':n===1?'Saved context for handover':`Saved ${n} context checkpoints`,
'context-restored':'Restored saved task context',
'memory-archived':`Archived ${n} old session${n===1?'':'s'} · restorable`,
'map-preserved':'Kept existing project map entries after a partial scan',
'fix-retrieved':n===1?'Found a relevant previous fix':`Found ${n} relevant previous fixes`,
'rule-verified':'Loaded reviewed project memory',
'read-denied':n===1?'Declined an unchanged file read':`Declined ${n} unchanged file reads`,
'runtime-ready':'Verified an update for new sessions',
};
return 'OpenWolf · '+messages[r.operation];
}
export type Surface='claude-statusline'|'claude-hook'|'codex-hook'|'opencode-toast'|'dashboard';
/** Boundaries/dashboard only. Zero lock waiting; failed delivery claims remain retryable. */
export function activityState(root:string,delivery?:{agent:string;session:string;turn:string;surface:Surface;startup?:boolean},now=Date.now()):{state:State;message?:string} {
const mode=visibilityMode(root);if(mode==='off'||!fs.existsSync(path.join(root,'.wolf')))return {state:readState(root)};
const queue=path.join(dir(root),'queue'),consumed:Array<{file:string;id:string}>=[];
let message:string|undefined;
try {
const state=mutateJSON<State>(path.join(dir(root),'state.json'),empty(),0,s=>{
const membership=Buffer.from(s.membership??Buffer.alloc(131072).toString('base64'),'base64');
if(membership.length!==131072)throw Error('Invalid receipt membership');
let names:string[]=[];try{names=fs.readdirSync(queue).filter(n=>/^[a-f0-9]{3}\.json$/.test(n)).slice(0,delivery?32:4096)}catch{}
for(const name of names){const file=path.join(queue,name);try {
if(fs.statSync(file).size>2048)continue;const r=JSON.parse(fs.readFileSync(file,'utf8')) as Receipt;if(!valid(r))continue;
const bits=[0,8,16,24].map(i=>parseInt(r.id.slice(i,i+8),16)%(131072*8));
const known=bits.every(bit=>(membership[bit>>3]&(1<<(bit&7)))!==0);
if(!known&&r.at<=now+60_000){for(const bit of bits)membership[bit>>3]|=1<<(bit&7);s.seen[r.id]=r.at;s.history.push(r);s.pending.push(r);s.counts[r.operation]=(s.counts[r.operation]??0)+r.count}
consumed.push({file,id:r.id});
}catch{}}
s.membership=membership.toString('base64');
s.history=s.history.sort((a,b)=>a.at-b.at||a.id.localeCompare(b.id)).slice(-100);
s.pending=s.pending.filter(r=>now-r.at<86400_000).slice(-256);
const seen=Object.entries(s.seen).sort((a,b)=>b[1]-a[1]);
if(seen.length>4096){s.watermark=Math.max(s.watermark,seen[4096][1]);s.seen=Object.fromEntries(seen.filter(([,at])=>at>s.watermark).slice(0,4096))}
if(!delivery||delivery.surface==='dashboard'||!delivery.session)return;
const d=delivery,key=hash(d.agent+':'+d.session),turn=hash(key+':'+d.turn);
if(s.lastAt&&now-s.lastAt<300_000||(s.turns[turn]??0)>=3||d.startup&&s.startup[key])return;
const candidates=s.pending.filter(r=>r.operation!=='read-denied'&&r.operation!=='runtime-ready'&&(!r.session||r.session===hash(d.session)&&r.agent===d.agent)&&(!d.startup||r.category==='recovery')&&
(d.surface!=='claude-hook'&&d.surface!=='codex-hook'||r.category==='recovery'||mode==='verbose'));
const first=candidates.find(r=>r.category==='recovery')??candidates[0];if(!first)return;
const batch=candidates.filter(r=>r.operation===first.operation);message=receiptText({...first,count:batch.reduce((n,r)=>n+r.count,0)});
const ids=new Set(batch.map(r=>r.id));s.pending=s.pending.filter(r=>!ids.has(r.id));s.lastAt=now;s.turns[turn]=(s.turns[turn]??0)+1;
s.turns=Object.fromEntries(Object.entries(s.turns).slice(-256));if(d.startup)s.startup[key]=true;s.startup=Object.fromEntries(Object.entries(s.startup).slice(-256));
s.last={text:message,at:now,session:hash(d.session),agent:d.agent,transport:d.surface};
});
if(!state)return {state:readState(root)};
for(const {file,id} of consumed)try{if(JSON.parse(fs.readFileSync(file,'utf8')).id===id)fs.unlinkSync(file)}catch{}
return {state,message};
}catch{return {state:readState(root)}}
}
export function hookReceipt(root:string,agent:string,input:{session_id?:string;prompt_id?:string},startup=false):string|undefined {
if(agent!=='claude'&&agent!=='codex')return;
return activityState(root,{agent,session:input.session_id??'',turn:input.prompt_id??'session',surface:agent==='claude'?'claude-hook':'codex-hook',startup}).message;
}
/** Missing/headless SDKs are silent; never await rendering or start a model turn. */
export function showActivityToast(client:any,message:string):void {
try{const result=client?.tui?.showToast?.({body:{title:'OpenWolf',message,variant:'info',duration:4000}});void Promise.resolve(result).catch(()=>{})}catch{}
}