- 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
251 lines
9.1 KiB
TypeScript
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 {}
|
|
}
|
|
}
|