- 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
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
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 {}
|
||
}
|
||
}
|