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:
commit
e989413018
76 files changed
+8386
No files matched your search
@@ -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("; ")
|
||||
}
|
||||
Reference in new issue
Block a user