Files
sabia/.opencode/plugin/openwolf/stop.ts
T
Lucas e989413018 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
2026-10-23 08:22:04 -03:00

251 lines
9.1 KiB
TypeScript

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 {}
}
}