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,26 @@
|
|||||||
|
---
|
||||||
|
description: Screenshot-based design review of the running app via openwolf designqc
|
||||||
|
argument-hint: [--url <url>] [--routes <routes>]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Evaluate and improve the design/UI of this app:
|
||||||
|
|
||||||
|
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
|
||||||
|
- The command auto-detects a running dev server, or starts one from package.json if needed.
|
||||||
|
- Use `--url <url>` only if auto-detection fails.
|
||||||
|
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
|
||||||
|
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
|
||||||
|
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
|
||||||
|
- Spacing and whitespace consistency
|
||||||
|
- Typography hierarchy and readability
|
||||||
|
- Color contrast and accessibility (WCAG)
|
||||||
|
- Visual hierarchy and focal points
|
||||||
|
- Component consistency
|
||||||
|
- Whether the design looks generic ("white-coded", no personality)
|
||||||
|
4. Provide specific, actionable feedback with fix suggestions.
|
||||||
|
5. If the user approves, implement the fixes directly in their code.
|
||||||
|
6. Re-run `openwolf designqc` to verify the improvement.
|
||||||
|
|
||||||
|
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
|
||||||
|
|
||||||
|
Build it from the session's actual state, not from memory of the conversation alone:
|
||||||
|
|
||||||
|
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
|
||||||
|
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
|
||||||
|
3. Skim the latest session block of `.wolf/memory.md` for the action log.
|
||||||
|
|
||||||
|
Then rewrite `.wolf/STATUS.md` with:
|
||||||
|
|
||||||
|
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
|
||||||
|
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
|
||||||
|
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
|
||||||
|
- Bump the date.
|
||||||
|
|
||||||
|
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
|
||||||
|
|
||||||
|
|
||||||
|
For Claude ↔ Codex transfer, also preserve session-specific evidence:
|
||||||
|
|
||||||
|
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
|
||||||
|
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
|
||||||
|
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
|
||||||
|
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
|
||||||
|
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
|
||||||
|
argument-hint: [migrate [framework] | audit [target] | fix [target]]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
|
||||||
|
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
|
||||||
|
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
|
||||||
|
|
||||||
|
Pick the mode from the arguments (default: `migrate` if a framework is named or the
|
||||||
|
user is choosing one; otherwise ask which mode they want):
|
||||||
|
|
||||||
|
## Mode: migrate [framework]
|
||||||
|
Framework selection and migration.
|
||||||
|
1. If no framework is named, ask the Decision Questions from the knowledge file
|
||||||
|
(stop early once the answer narrows to 1–2 options) and recommend one.
|
||||||
|
2. Use that framework's prompt from the knowledge file, adapted to this project's
|
||||||
|
real structure via `.wolf/anatomy.md`.
|
||||||
|
3. The Design Principles override anything generic in the prompt: no template
|
||||||
|
hero→features→CTA structures, no stock palettes — distinctive by default.
|
||||||
|
|
||||||
|
## Mode: audit [target]
|
||||||
|
Walk the target (default: the whole UI) and flag every match against the AI-tell
|
||||||
|
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
|
||||||
|
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
|
||||||
|
every role, template SaaS structure, filler microcopy. Produce a findings table —
|
||||||
|
component, tell matched, severity, specific replacement direction — and end with the
|
||||||
|
3 changes that would most increase distinctiveness.
|
||||||
|
|
||||||
|
## Mode: fix [target]
|
||||||
|
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
|
||||||
|
away: typography chosen with intent, a palette derived from the product's actual
|
||||||
|
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
|
||||||
|
does, density appropriate to the audience. Preserve the existing framework and
|
||||||
|
component APIs — this is a design pass, not a rewrite. After each fix, state what
|
||||||
|
changed and why it reads as designed-on-purpose.
|
||||||
|
|
||||||
|
Acceptance criterion for every mode: **if the result could be swapped onto any other
|
||||||
|
product without anyone noticing, it fails.**
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
|
||||||
|
argument-hint: [path or scope, e.g. src/api — omit for whole project]
|
||||||
|
---
|
||||||
|
|
||||||
|
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
|
||||||
|
|
||||||
|
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
|
||||||
|
`.wolf/buglog.json` for previously found security issues before re-reporting them.
|
||||||
|
|
||||||
|
Work through the layers in order. For each, report findings before moving on:
|
||||||
|
|
||||||
|
## Layer 1 — Dependencies
|
||||||
|
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
|
||||||
|
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
|
||||||
|
|
||||||
|
## Layer 2 — Secrets
|
||||||
|
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
|
||||||
|
private keys. Check committed env files, config files, and test fixtures. Verify
|
||||||
|
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
|
||||||
|
|
||||||
|
## Layer 3 — Injection surfaces
|
||||||
|
Find every place external input reaches an interpreter: shell commands built by string
|
||||||
|
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
|
||||||
|
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
|
||||||
|
of untrusted data, SSRF (user-controlled URLs fetched server-side).
|
||||||
|
|
||||||
|
## Layer 4 — AuthN / AuthZ
|
||||||
|
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
|
||||||
|
IDs without ownership checks), privilege escalation paths, session handling, CORS and
|
||||||
|
CSRF posture, servers bound to 0.0.0.0 without auth.
|
||||||
|
|
||||||
|
## Layer 5 — Report
|
||||||
|
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
|
||||||
|
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
|
||||||
|
"security". Offer to fix Critical and High items immediately.
|
||||||
|
|
||||||
|
Rules: verify each finding against the actual code before reporting (no
|
||||||
|
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
|
||||||
|
security to silence a warning.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: OpenWolf protocol enforcement, active on all files
|
||||||
|
globs: **/*
|
||||||
|
---
|
||||||
|
|
||||||
|
- To locate a symbol or file, run `openwolf find <name>` first (ranked shortlist, under 1k tokens). For one file's description and symbol ranges: `openwolf find --file <path>`. Never read .wolf/anatomy.md whole; it is an index.
|
||||||
|
- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code (grep "## Do-Not-Repeat"); after a user correction, update cerebrum.md immediately.
|
||||||
|
- Do NOT manually update .wolf/anatomy.md or .wolf/memory.md; the OpenWolf hooks maintain them.
|
||||||
|
- BEFORE fixing any bug: run `openwolf bug search "<error>"` or grep .wolf/buglog.json. AFTER fixing one: log it there (error_message, root_cause, fix, tags).
|
||||||
|
- When resuming a session, read .wolf/STATUS.md first; regenerate it with /handoff when a quest finishes.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Read",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Read",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolBatch": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-batch.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"statusLine": {
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/visibility-statusline.js\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: openwolf
|
||||||
|
description: OpenWolf operating protocol for this project. Load when starting multi-file work, resuming a session, wrapping up a session, or when unsure how to use .wolf/ state files (anatomy, cerebrum, memory, buglog, STATUS).
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenWolf Operating Protocol
|
||||||
|
|
||||||
|
You are working in an OpenWolf-managed project. The hooks handle bookkeeping automatically: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed.
|
||||||
|
|
||||||
|
## Session resume
|
||||||
|
|
||||||
|
`.wolf/STATUS.md` is the handoff document. Read it first when resuming; it replaces re-reading memory, plans, and code to reconstruct context. Regenerate it on demand with `/handoff` when a quest finishes or before suggesting `/clear`.
|
||||||
|
|
||||||
|
## File navigation
|
||||||
|
|
||||||
|
1. To locate a symbol or file by name, run `openwolf find <query>` first: a ranked shortlist from the index (~1k tokens max), cheaper than grepping the world. For one file's description, size, and symbol line ranges: `openwolf find --file <path>`.
|
||||||
|
2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit; the pre-read hook surfaces the largest sections with line ranges, and `openwolf map` prints a token-budgeted overview of the most important files.
|
||||||
|
3. Never read `.wolf/anatomy.md` whole; it is an index. Grep it only for a single path's line when `find` is unavailable.
|
||||||
|
4. If a file is not indexed, search with Grep/Glob. Regenerate the index with `openwolf scan`.
|
||||||
|
|
||||||
|
## Code generation and learning
|
||||||
|
|
||||||
|
1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`.
|
||||||
|
2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session. The bar is LOW; a missing entry repeats the discovery next session.
|
||||||
|
|
||||||
|
## Bug logging
|
||||||
|
|
||||||
|
Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known.
|
||||||
|
|
||||||
|
After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right.
|
||||||
|
|
||||||
|
## Token discipline
|
||||||
|
|
||||||
|
- Never re-read a file already read this session unless it changed since.
|
||||||
|
- Prefer anatomy descriptions and targeted Grep over full file reads.
|
||||||
|
- If appending to a file, do not read the entire file first.
|
||||||
|
|
||||||
|
## Session end
|
||||||
|
|
||||||
|
Before wrapping up: run `/handoff` (or update `.wolf/STATUS.md` by hand), write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs in cerebrum.md / buglog.json.
|
||||||
|
|
||||||
|
## On-demand skills
|
||||||
|
|
||||||
|
- `/handoff`: regenerate .wolf/STATUS.md from the session's actual state.
|
||||||
|
- `/designqc`: screenshot-based design review of the running app.
|
||||||
|
- `/reframe`: UI framework selection, migration, and anti-generic design audits.
|
||||||
|
- `/security-audit`: security review of the project.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[features]
|
||||||
|
hooks = true
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume|clear",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf session bootstrap"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf shell precheck"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Read",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf read precheck"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write|MultiEdit|apply_patch",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf write precheck"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "OpenWolf shell tracking"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Read",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf read tracking"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write|MultiEdit|apply_patch",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "OpenWolf anatomy update"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf compaction snapshot"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"",
|
||||||
|
"timeout": 3,
|
||||||
|
"statusMessage": "OpenWolf final flush"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "OpenWolf context refresh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "OpenWolf session wrap-up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Screenshot-based design review of the running app via openwolf designqc
|
||||||
|
argument-hint: [--url <url>] [--routes <routes>]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Evaluate and improve the design/UI of this app:
|
||||||
|
|
||||||
|
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
|
||||||
|
- The command auto-detects a running dev server, or starts one from package.json if needed.
|
||||||
|
- Use `--url <url>` only if auto-detection fails.
|
||||||
|
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
|
||||||
|
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
|
||||||
|
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
|
||||||
|
- Spacing and whitespace consistency
|
||||||
|
- Typography hierarchy and readability
|
||||||
|
- Color contrast and accessibility (WCAG)
|
||||||
|
- Visual hierarchy and focal points
|
||||||
|
- Component consistency
|
||||||
|
- Whether the design looks generic ("white-coded", no personality)
|
||||||
|
4. Provide specific, actionable feedback with fix suggestions.
|
||||||
|
5. If the user approves, implement the fixes directly in their code.
|
||||||
|
6. Re-run `openwolf designqc` to verify the improvement.
|
||||||
|
|
||||||
|
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
|
||||||
|
|
||||||
|
Build it from the session's actual state, not from memory of the conversation alone:
|
||||||
|
|
||||||
|
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
|
||||||
|
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
|
||||||
|
3. Skim the latest session block of `.wolf/memory.md` for the action log.
|
||||||
|
|
||||||
|
Then rewrite `.wolf/STATUS.md` with:
|
||||||
|
|
||||||
|
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
|
||||||
|
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
|
||||||
|
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
|
||||||
|
- Bump the date.
|
||||||
|
|
||||||
|
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
|
||||||
|
|
||||||
|
|
||||||
|
For Claude ↔ Codex transfer, also preserve session-specific evidence:
|
||||||
|
|
||||||
|
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
|
||||||
|
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
|
||||||
|
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
|
||||||
|
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
|
||||||
|
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
|
||||||
|
argument-hint: [migrate [framework] | audit [target] | fix [target]]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
|
||||||
|
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
|
||||||
|
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
|
||||||
|
|
||||||
|
Pick the mode from the arguments (default: `migrate` if a framework is named or the
|
||||||
|
user is choosing one; otherwise ask which mode they want):
|
||||||
|
|
||||||
|
## Mode: migrate [framework]
|
||||||
|
Framework selection and migration.
|
||||||
|
1. If no framework is named, ask the Decision Questions from the knowledge file
|
||||||
|
(stop early once the answer narrows to 1–2 options) and recommend one.
|
||||||
|
2. Use that framework's prompt from the knowledge file, adapted to this project's
|
||||||
|
real structure via `.wolf/anatomy.md`.
|
||||||
|
3. The Design Principles override anything generic in the prompt: no template
|
||||||
|
hero→features→CTA structures, no stock palettes — distinctive by default.
|
||||||
|
|
||||||
|
## Mode: audit [target]
|
||||||
|
Walk the target (default: the whole UI) and flag every match against the AI-tell
|
||||||
|
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
|
||||||
|
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
|
||||||
|
every role, template SaaS structure, filler microcopy. Produce a findings table —
|
||||||
|
component, tell matched, severity, specific replacement direction — and end with the
|
||||||
|
3 changes that would most increase distinctiveness.
|
||||||
|
|
||||||
|
## Mode: fix [target]
|
||||||
|
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
|
||||||
|
away: typography chosen with intent, a palette derived from the product's actual
|
||||||
|
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
|
||||||
|
does, density appropriate to the audience. Preserve the existing framework and
|
||||||
|
component APIs — this is a design pass, not a rewrite. After each fix, state what
|
||||||
|
changed and why it reads as designed-on-purpose.
|
||||||
|
|
||||||
|
Acceptance criterion for every mode: **if the result could be swapped onto any other
|
||||||
|
product without anyone noticing, it fails.**
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
|
||||||
|
argument-hint: [path or scope, e.g. src/api — omit for whole project]
|
||||||
|
---
|
||||||
|
|
||||||
|
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
|
||||||
|
|
||||||
|
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
|
||||||
|
`.wolf/buglog.json` for previously found security issues before re-reporting them.
|
||||||
|
|
||||||
|
Work through the layers in order. For each, report findings before moving on:
|
||||||
|
|
||||||
|
## Layer 1 — Dependencies
|
||||||
|
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
|
||||||
|
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
|
||||||
|
|
||||||
|
## Layer 2 — Secrets
|
||||||
|
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
|
||||||
|
private keys. Check committed env files, config files, and test fixtures. Verify
|
||||||
|
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
|
||||||
|
|
||||||
|
## Layer 3 — Injection surfaces
|
||||||
|
Find every place external input reaches an interpreter: shell commands built by string
|
||||||
|
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
|
||||||
|
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
|
||||||
|
of untrusted data, SSRF (user-controlled URLs fetched server-side).
|
||||||
|
|
||||||
|
## Layer 4 — AuthN / AuthZ
|
||||||
|
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
|
||||||
|
IDs without ownership checks), privilege escalation paths, session handling, CORS and
|
||||||
|
CSRF posture, servers bound to 0.0.0.0 without auth.
|
||||||
|
|
||||||
|
## Layer 5 — Report
|
||||||
|
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
|
||||||
|
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
|
||||||
|
"security". Offer to fix Critical and High items immediately.
|
||||||
|
|
||||||
|
Rules: verify each finding against the actual code before reporting (no
|
||||||
|
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
|
||||||
|
security to silence a warning.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: OpenWolf project protocol (context management via .wolf/)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenWolf
|
||||||
|
|
||||||
|
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pcap
|
||||||
|
*.cap
|
||||||
|
.sabia-audit.log
|
||||||
|
node_modules/
|
||||||
|
apps/gui/src-tauri/target
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Screenshot-based design review of the running app via openwolf designqc
|
||||||
|
argument-hint: [--url <url>] [--routes <routes>]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Evaluate and improve the design/UI of this app:
|
||||||
|
|
||||||
|
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
|
||||||
|
- The command auto-detects a running dev server, or starts one from package.json if needed.
|
||||||
|
- Use `--url <url>` only if auto-detection fails.
|
||||||
|
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
|
||||||
|
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
|
||||||
|
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
|
||||||
|
- Spacing and whitespace consistency
|
||||||
|
- Typography hierarchy and readability
|
||||||
|
- Color contrast and accessibility (WCAG)
|
||||||
|
- Visual hierarchy and focal points
|
||||||
|
- Component consistency
|
||||||
|
- Whether the design looks generic ("white-coded", no personality)
|
||||||
|
4. Provide specific, actionable feedback with fix suggestions.
|
||||||
|
5. If the user approves, implement the fixes directly in their code.
|
||||||
|
6. Re-run `openwolf designqc` to verify the improvement.
|
||||||
|
|
||||||
|
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
|
||||||
|
|
||||||
|
Build it from the session's actual state, not from memory of the conversation alone:
|
||||||
|
|
||||||
|
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
|
||||||
|
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
|
||||||
|
3. Skim the latest session block of `.wolf/memory.md` for the action log.
|
||||||
|
|
||||||
|
Then rewrite `.wolf/STATUS.md` with:
|
||||||
|
|
||||||
|
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
|
||||||
|
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
|
||||||
|
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
|
||||||
|
- Bump the date.
|
||||||
|
|
||||||
|
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
|
||||||
|
|
||||||
|
|
||||||
|
For Claude ↔ Codex transfer, also preserve session-specific evidence:
|
||||||
|
|
||||||
|
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
|
||||||
|
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
|
||||||
|
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
|
||||||
|
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
|
||||||
|
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
|
||||||
|
argument-hint: [migrate [framework] | audit [target] | fix [target]]
|
||||||
|
---
|
||||||
|
|
||||||
|
Arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
|
||||||
|
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
|
||||||
|
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
|
||||||
|
|
||||||
|
Pick the mode from the arguments (default: `migrate` if a framework is named or the
|
||||||
|
user is choosing one; otherwise ask which mode they want):
|
||||||
|
|
||||||
|
## Mode: migrate [framework]
|
||||||
|
Framework selection and migration.
|
||||||
|
1. If no framework is named, ask the Decision Questions from the knowledge file
|
||||||
|
(stop early once the answer narrows to 1–2 options) and recommend one.
|
||||||
|
2. Use that framework's prompt from the knowledge file, adapted to this project's
|
||||||
|
real structure via `.wolf/anatomy.md`.
|
||||||
|
3. The Design Principles override anything generic in the prompt: no template
|
||||||
|
hero→features→CTA structures, no stock palettes — distinctive by default.
|
||||||
|
|
||||||
|
## Mode: audit [target]
|
||||||
|
Walk the target (default: the whole UI) and flag every match against the AI-tell
|
||||||
|
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
|
||||||
|
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
|
||||||
|
every role, template SaaS structure, filler microcopy. Produce a findings table —
|
||||||
|
component, tell matched, severity, specific replacement direction — and end with the
|
||||||
|
3 changes that would most increase distinctiveness.
|
||||||
|
|
||||||
|
## Mode: fix [target]
|
||||||
|
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
|
||||||
|
away: typography chosen with intent, a palette derived from the product's actual
|
||||||
|
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
|
||||||
|
does, density appropriate to the audience. Preserve the existing framework and
|
||||||
|
component APIs — this is a design pass, not a rewrite. After each fix, state what
|
||||||
|
changed and why it reads as designed-on-purpose.
|
||||||
|
|
||||||
|
Acceptance criterion for every mode: **if the result could be swapped onto any other
|
||||||
|
product without anyone noticing, it fails.**
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
|
||||||
|
argument-hint: [path or scope, e.g. src/api — omit for whole project]
|
||||||
|
---
|
||||||
|
|
||||||
|
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
|
||||||
|
|
||||||
|
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
|
||||||
|
`.wolf/buglog.json` for previously found security issues before re-reporting them.
|
||||||
|
|
||||||
|
Work through the layers in order. For each, report findings before moving on:
|
||||||
|
|
||||||
|
## Layer 1 — Dependencies
|
||||||
|
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
|
||||||
|
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
|
||||||
|
|
||||||
|
## Layer 2 — Secrets
|
||||||
|
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
|
||||||
|
private keys. Check committed env files, config files, and test fixtures. Verify
|
||||||
|
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
|
||||||
|
|
||||||
|
## Layer 3 — Injection surfaces
|
||||||
|
Find every place external input reaches an interpreter: shell commands built by string
|
||||||
|
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
|
||||||
|
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
|
||||||
|
of untrusted data, SSRF (user-controlled URLs fetched server-side).
|
||||||
|
|
||||||
|
## Layer 4 — AuthN / AuthZ
|
||||||
|
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
|
||||||
|
IDs without ownership checks), privilege escalation paths, session handling, CORS and
|
||||||
|
CSRF posture, servers bound to 0.0.0.0 without auth.
|
||||||
|
|
||||||
|
## Layer 5 — Report
|
||||||
|
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
|
||||||
|
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
|
||||||
|
"security". Offer to fix Critical and High items immediately.
|
||||||
|
|
||||||
|
Rules: verify each finding against the actual code before reporting (no
|
||||||
|
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
|
||||||
|
security to silence a warning.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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). */
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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:'';
|
||||||
|
}
|
||||||
@@ -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;}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("; ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 ''; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# OpenWolf: machine-local state (committed state: cerebrum.md, STATUS.md,
|
||||||
|
# memory.md, buglog.json, anatomy.md, anatomy-index.json, config.json, OPENWOLF.md)
|
||||||
|
hooks/
|
||||||
|
backups/
|
||||||
|
cache/
|
||||||
|
daemon.log
|
||||||
|
daemon.pid
|
||||||
|
dashboard-token
|
||||||
|
token-ledger.json
|
||||||
|
usage-report.json
|
||||||
|
updates/
|
||||||
|
handoff/
|
||||||
|
activity/
|
||||||
|
usage/
|
||||||
|
archive/
|
||||||
|
session-memory/
|
||||||
|
ledger-pending/
|
||||||
|
ledger-sessions/
|
||||||
|
bug-pending/
|
||||||
|
*.pending/
|
||||||
|
suggestions.json
|
||||||
|
cron-state.json
|
||||||
|
_scan-state.json
|
||||||
|
_*.json
|
||||||
|
*.lock
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# OpenWolf Operating Protocol
|
||||||
|
|
||||||
|
You are working in an OpenWolf-managed project. These rules apply every turn.
|
||||||
|
|
||||||
|
OpenWolf's hooks handle the bookkeeping: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed (Gemini CLI, Cursor).
|
||||||
|
|
||||||
|
## STATUS.md: read first, keep fresh
|
||||||
|
|
||||||
|
`.wolf/STATUS.md` is the handoff document. Read it FIRST when resuming a session; it replaces re-reading memory, plans, and code to reconstruct context.
|
||||||
|
|
||||||
|
Keep it fresh: when the user signals a quest is done ("done", "ship it", "next phase", "/clear", "wrap up"), move finished items to the done section, write the next quest (objective, files, decisions), and bump the date. Do this before responding "done" on any multi-file task and before suggesting `/clear`. A stale STATUS.md wastes the next session.
|
||||||
|
|
||||||
|
## File navigation
|
||||||
|
|
||||||
|
1. Before reading an unfamiliar file, grep `.wolf/anatomy.md` for its path to get a one-line description and token estimate. Do NOT read anatomy.md whole; it is an index, not a document.
|
||||||
|
2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit over whole-file reads.
|
||||||
|
3. If a file is not in anatomy.md, search with Grep/Glob. Regenerate the index with `openwolf scan`.
|
||||||
|
|
||||||
|
## Code generation
|
||||||
|
|
||||||
|
1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`.
|
||||||
|
2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session, a significant decision and its why. The bar is LOW; a redundant entry costs nothing, a missing one repeats the discovery next session.
|
||||||
|
|
||||||
|
## Bug logging
|
||||||
|
|
||||||
|
Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known.
|
||||||
|
|
||||||
|
After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. The threshold is LOW.
|
||||||
|
|
||||||
|
## Token discipline
|
||||||
|
|
||||||
|
- Never re-read a file already read this session unless it changed since.
|
||||||
|
- Prefer anatomy descriptions and targeted Grep over full file reads.
|
||||||
|
- If appending to a file, do not read the entire file first.
|
||||||
|
|
||||||
|
## Session end
|
||||||
|
|
||||||
|
Before wrapping up: update `.wolf/STATUS.md`, write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs from the session in cerebrum.md / buglog.json.
|
||||||
|
|
||||||
|
## On-demand skills
|
||||||
|
|
||||||
|
- `/designqc`: screenshot-based design review of the running app (uses `openwolf designqc`).
|
||||||
|
- `/reframe`: UI framework selection, migration, and anti-generic design audits.
|
||||||
|
- `/security-audit`: security review of the project.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
description: session handoff, regenerate with /handoff when a quest finishes
|
||||||
|
budget_tokens: 1000
|
||||||
|
---
|
||||||
|
# STATUS — Sabiá
|
||||||
|
|
||||||
|
> Single source of truth for resuming work. Read this FIRST when starting a session.
|
||||||
|
> Update this file at the end of every work phase so the next `/clear` resumes in 1 read.
|
||||||
|
> Last updated: 2026-10-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Done
|
||||||
|
|
||||||
|
<!-- Move items here from "🚀 Next phase" when finished. Group by area. -->
|
||||||
|
|
||||||
|
- (nothing yet — fill in as work completes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Next phase
|
||||||
|
|
||||||
|
**Goal:** _<what we're building next, in 1 sentence>_
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
1. _<concrete user-visible outcome>_
|
||||||
|
2. _<...>_
|
||||||
|
|
||||||
|
### Files to create / edit
|
||||||
|
| Type | File | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| new | `path/to/file.ts` | _what it does_ |
|
||||||
|
|
||||||
|
### Closed decisions
|
||||||
|
- _<choice + reasoning>_
|
||||||
|
|
||||||
|
### Open decisions
|
||||||
|
- _<question to ask the user before coding>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Active architecture
|
||||||
|
|
||||||
|
- **Stack:** _<frameworks, libraries, runtime>_
|
||||||
|
- **Key tables / modules:** _<list>_
|
||||||
|
- **Patterns:** _<conventions enforced project-wide>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ External blockers (don't block coding)
|
||||||
|
|
||||||
|
- _<env vars, secrets, external accounts, manual steps>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# add the most-used commands here so the next session has them ready
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References (read IF needed)
|
||||||
|
|
||||||
|
- `.wolf/cerebrum.md` — User Preferences + Do-Not-Repeat + Decision Log
|
||||||
|
- `.wolf/anatomy.md` — token-efficient file index
|
||||||
|
- `.wolf/buglog.json` — known bugs + fixes
|
||||||
@@ -0,0 +1,907 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"meta": {
|
||||||
|
"lastScanned": "2026-10-23T11:21:49.966Z",
|
||||||
|
"fileCount": 22,
|
||||||
|
"hits": 0,
|
||||||
|
"misses": 0,
|
||||||
|
"renderedHash": "2030278ebfc7a2591c4824586f15125940d579ecad61b4e8cb5a9a9bf6b3a399",
|
||||||
|
"storeUpdatedAt": "2026-10-23T11:21:49.967Z",
|
||||||
|
"rootHash": "77f386ab99dd2782"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
".gitignore": {
|
||||||
|
"description": "Git ignore rules",
|
||||||
|
"tokens": 24,
|
||||||
|
"hash": "465569a17f6c1fcd",
|
||||||
|
"size": 89,
|
||||||
|
"mtimeMs": 1792754328319.2644,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.944Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"AGENTS.md": {
|
||||||
|
"description": "OpenWolf",
|
||||||
|
"tokens": 75,
|
||||||
|
"hash": "e401f79554a8d796",
|
||||||
|
"size": 297,
|
||||||
|
"mtimeMs": 1792754075050.7603,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.944Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"Cargo.toml": {
|
||||||
|
"description": "Rust package manifest",
|
||||||
|
"tokens": 100,
|
||||||
|
"hash": "c445aa952b469eef",
|
||||||
|
"size": 351,
|
||||||
|
"mtimeMs": 1792754084001.563,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.945Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"CLAUDE.md": {
|
||||||
|
"description": "OpenWolf",
|
||||||
|
"tokens": 99,
|
||||||
|
"hash": "074d6b42b583729a",
|
||||||
|
"size": 395,
|
||||||
|
"mtimeMs": 1792754068779.6982,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.945Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-cli/Cargo.toml": {
|
||||||
|
"description": "Rust package manifest",
|
||||||
|
"tokens": 125,
|
||||||
|
"hash": "dc1447c043cb895b",
|
||||||
|
"size": 436,
|
||||||
|
"mtimeMs": 1792754292996.8179,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.945Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-cli/src/main.rs": {
|
||||||
|
"description": "Lista interfaces de rede e mostra quais são Wi-Fi.",
|
||||||
|
"tokens": 2193,
|
||||||
|
"hash": "2f37e8d34c3f1e7d",
|
||||||
|
"size": 7696,
|
||||||
|
"mtimeMs": 1792754462287.5278,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.945Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 1,
|
||||||
|
"endLine": 1,
|
||||||
|
"tokens": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cli",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 12,
|
||||||
|
"endLine": 15,
|
||||||
|
"tokens": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cmd",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 18,
|
||||||
|
"endLine": 49,
|
||||||
|
"tokens": 285
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IfaceAction",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 52,
|
||||||
|
"endLine": 56,
|
||||||
|
"tokens": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OnOff",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 59,
|
||||||
|
"endLine": 62,
|
||||||
|
"tokens": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audit_path",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 64,
|
||||||
|
"endLine": 66,
|
||||||
|
"tokens": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 68,
|
||||||
|
"endLine": 74,
|
||||||
|
"tokens": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "run",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 76,
|
||||||
|
"endLine": 216,
|
||||||
|
"tokens": 1643
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"skeleton": "mod web;\nstruct Cli {\nenum Cmd {\nenum IfaceAction {\nenum OnOff {\nfn audit_path() -> PathBuf {\nfn main() {\nfn run(cli: Cli) -> sabia_core::Result<()> {",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-cli/src/web/mod.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-cli/src/web/index.html": {
|
||||||
|
"description": "Sabiá",
|
||||||
|
"tokens": 1684,
|
||||||
|
"hash": "4344229aaa5e2db1",
|
||||||
|
"size": 6334,
|
||||||
|
"mtimeMs": 1792754445269.6348,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-cli/src/web/mod.rs": {
|
||||||
|
"description": "Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —",
|
||||||
|
"tokens": 2254,
|
||||||
|
"hash": "a6aa02205c19274e",
|
||||||
|
"size": 7896,
|
||||||
|
"mtimeMs": 1792754470284.2668,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "Session",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 17,
|
||||||
|
"endLine": 20,
|
||||||
|
"tokens": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AppState",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 23,
|
||||||
|
"endLine": 25,
|
||||||
|
"tokens": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "serve",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 27,
|
||||||
|
"endLine": 47,
|
||||||
|
"tokens": 190
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gen_token",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 49,
|
||||||
|
"endLine": 53,
|
||||||
|
"tokens": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "open_browser",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 55,
|
||||||
|
"endLine": 57,
|
||||||
|
"tokens": 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Request",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 59,
|
||||||
|
"endLine": 64,
|
||||||
|
"tokens": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parse_request",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 66,
|
||||||
|
"endLine": 94,
|
||||||
|
"tokens": 286
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "urldecode",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 96,
|
||||||
|
"endLine": 98,
|
||||||
|
"tokens": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "handle",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 100,
|
||||||
|
"endLine": 119,
|
||||||
|
"tokens": 227
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "respond",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 121,
|
||||||
|
"endLine": 131,
|
||||||
|
"tokens": 119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 133,
|
||||||
|
"endLine": 174,
|
||||||
|
"tokens": 531
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start_scan",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 176,
|
||||||
|
"endLine": 215,
|
||||||
|
"tokens": 439
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stop_scan",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 217,
|
||||||
|
"endLine": 222,
|
||||||
|
"tokens": 60
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"skeleton": "struct Session {\nstruct AppState {\npub fn serve(port: u16) -> sabia_core::Result<()> {\nfn gen_token() -> String {\nfn open_browser(url: &str) -> std::io::Result<()> {\nstruct Request {\nfn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {\nfn urldecode(s: &str) -> String {\nfn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {\nfn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {\nfn api(state: &Arc<AppState>, req: &Request) -> String {\nfn start_scan(state: &Arc<AppState>, name: String) -> String {\nfn stop_scan(state: &Arc<AppState>, name: &str) {",
|
||||||
|
"importance": 0.2825
|
||||||
|
},
|
||||||
|
"crates/sabia-core/Cargo.toml": {
|
||||||
|
"description": "Rust package manifest",
|
||||||
|
"tokens": 190,
|
||||||
|
"hash": "09752951731f7901",
|
||||||
|
"size": 670,
|
||||||
|
"mtimeMs": 1792754101085.2576,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/audit.rs": {
|
||||||
|
"description": "Trilha de auditoria encadeada por hash de toda ação sensível (ligar",
|
||||||
|
"tokens": 1151,
|
||||||
|
"hash": "5b60f2b256cb178b",
|
||||||
|
"size": 4052,
|
||||||
|
"mtimeMs": 1792754254667.1384,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "AuditEntry",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 20,
|
||||||
|
"endLine": 27,
|
||||||
|
"tokens": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AuditLog",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 29,
|
||||||
|
"endLine": 32,
|
||||||
|
"tokens": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AuditLog",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 36,
|
||||||
|
"endLine": 114,
|
||||||
|
"tokens": 828
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AuditLog.open",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 37,
|
||||||
|
"endLine": 57,
|
||||||
|
"tokens": 214
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AuditLog.record",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 59,
|
||||||
|
"endLine": 83,
|
||||||
|
"tokens": 271
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AuditLog.verify",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 87,
|
||||||
|
"endLine": 113,
|
||||||
|
"tokens": 305
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/capture.rs": {
|
||||||
|
"description": "Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.",
|
||||||
|
"tokens": 1114,
|
||||||
|
"hash": "b2f202d231708f43",
|
||||||
|
"size": 3915,
|
||||||
|
"mtimeMs": 1792754351620.4128,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "RawCapture",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 19,
|
||||||
|
"endLine": 21,
|
||||||
|
"tokens": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RawCapture",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 23,
|
||||||
|
"endLine": 94,
|
||||||
|
"tokens": 726
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RawCapture.open",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 26,
|
||||||
|
"endLine": 58,
|
||||||
|
"tokens": 310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RawCapture.set_read_timeout",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 62,
|
||||||
|
"endLine": 80,
|
||||||
|
"tokens": 184
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RawCapture.recv",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 83,
|
||||||
|
"endLine": 93,
|
||||||
|
"tokens": 126
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AsRawFd",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 96,
|
||||||
|
"endLine": 100,
|
||||||
|
"tokens": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AsRawFd.as_raw_fd",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 97,
|
||||||
|
"endLine": 99,
|
||||||
|
"tokens": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Drop",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 102,
|
||||||
|
"endLine": 106,
|
||||||
|
"tokens": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Drop.drop",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 103,
|
||||||
|
"endLine": 105,
|
||||||
|
"tokens": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "if_index",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 108,
|
||||||
|
"endLine": 115,
|
||||||
|
"tokens": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "check_capabilities",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 119,
|
||||||
|
"endLine": 122,
|
||||||
|
"tokens": 28
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/dot11.rs": {
|
||||||
|
"description": "Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe",
|
||||||
|
"tokens": 1655,
|
||||||
|
"hash": "f6ce71057b70544a",
|
||||||
|
"size": 5804,
|
||||||
|
"mtimeMs": 1792754184829.32,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.946Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "FrameKind",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 5,
|
||||||
|
"endLine": 14,
|
||||||
|
"tokens": 41
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Security",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 17,
|
||||||
|
"endLine": 23,
|
||||||
|
"tokens": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ManagementInfo",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 26,
|
||||||
|
"endLine": 30,
|
||||||
|
"tokens": 37
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mac_to_string",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 32,
|
||||||
|
"endLine": 34,
|
||||||
|
"tokens": 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frame",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 37,
|
||||||
|
"endLine": 46,
|
||||||
|
"tokens": 96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parse",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 48,
|
||||||
|
"endLine": 83,
|
||||||
|
"tokens": 389
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bssid_str",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 86,
|
||||||
|
"endLine": 88,
|
||||||
|
"tokens": 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "src_str",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 90,
|
||||||
|
"endLine": 92,
|
||||||
|
"tokens": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "management_info",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 96,
|
||||||
|
"endLine": 162,
|
||||||
|
"tokens": 635
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_eapol",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 167,
|
||||||
|
"endLine": 183,
|
||||||
|
"tokens": 160
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"importance": 0.2361
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/error.rs": {
|
||||||
|
"description": "[derive(Debug, thiserror::Error)]",
|
||||||
|
"tokens": 225,
|
||||||
|
"hash": "02139521fcac88db",
|
||||||
|
"size": 797,
|
||||||
|
"mtimeMs": 1792754108274.089,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.947Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 1
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/iface.rs": {
|
||||||
|
"description": "Descoberta de interfaces de rede e identificação das que são Wi-Fi.",
|
||||||
|
"tokens": 973,
|
||||||
|
"hash": "bc45dfdc47a527fc",
|
||||||
|
"size": 3427,
|
||||||
|
"mtimeMs": 1792754124447.7615,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.947Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "Interface",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 11,
|
||||||
|
"endLine": 18,
|
||||||
|
"tokens": 59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mode",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 21,
|
||||||
|
"endLine": 25,
|
||||||
|
"tokens": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "net_root",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 27,
|
||||||
|
"endLine": 29,
|
||||||
|
"tokens": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_all",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 32,
|
||||||
|
"endLine": 44,
|
||||||
|
"tokens": 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_wireless",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 47,
|
||||||
|
"endLine": 49,
|
||||||
|
"tokens": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 51,
|
||||||
|
"endLine": 57,
|
||||||
|
"tokens": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read_interface",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 59,
|
||||||
|
"endLine": 98,
|
||||||
|
"tokens": 372
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read_wireless_mode",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 104,
|
||||||
|
"endLine": 110,
|
||||||
|
"tokens": 62
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.2361
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/lib.rs": {
|
||||||
|
"description": "`sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11",
|
||||||
|
"tokens": 140,
|
||||||
|
"hash": "e65164872526b1e7",
|
||||||
|
"size": 495,
|
||||||
|
"mtimeMs": 1792754288188.87,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.947Z",
|
||||||
|
"source": "scan",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/audit.rs",
|
||||||
|
"crates/sabia-core/src/dot11.rs",
|
||||||
|
"crates/sabia-core/src/error.rs",
|
||||||
|
"crates/sabia-core/src/iface.rs",
|
||||||
|
"crates/sabia-core/src/netlink.rs",
|
||||||
|
"crates/sabia-core/src/pcapwriter.rs",
|
||||||
|
"crates/sabia-core/src/radiotap.rs",
|
||||||
|
"crates/sabia-core/src/scan.rs",
|
||||||
|
"crates/sabia-core/src/update.rs",
|
||||||
|
"crates/sabia-core/src/capture.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/netlink.rs": {
|
||||||
|
"description": "Controle de modo (managed/monitor) e canal via `iw`.",
|
||||||
|
"tokens": 996,
|
||||||
|
"hash": "6a27b03cef2c283e",
|
||||||
|
"size": 3500,
|
||||||
|
"mtimeMs": 1792754142459.2986,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.947Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "run",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 13,
|
||||||
|
"endLine": 34,
|
||||||
|
"tokens": 246
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "iw_dev_type",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 36,
|
||||||
|
"endLine": 49,
|
||||||
|
"tokens": 123
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "supported_channels",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 52,
|
||||||
|
"endLine": 80,
|
||||||
|
"tokens": 294
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_monitor_mode",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 83,
|
||||||
|
"endLine": 89,
|
||||||
|
"tokens": 84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "set_channel",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 92,
|
||||||
|
"endLine": 95,
|
||||||
|
"tokens": 42
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs",
|
||||||
|
"crates/sabia-core/src/iface.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/pcapwriter.rs": {
|
||||||
|
"description": "Escritor de arquivos `.pcap` (formato clássico, link-type 127 =",
|
||||||
|
"tokens": 507,
|
||||||
|
"hash": "2d6921c66f9e7506",
|
||||||
|
"size": 1781,
|
||||||
|
"mtimeMs": 1792754240112.29,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.947Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "PcapWriter",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 13,
|
||||||
|
"endLine": 15,
|
||||||
|
"tokens": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PcapWriter",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 17,
|
||||||
|
"endLine": 47,
|
||||||
|
"tokens": 379
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PcapWriter.create",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 18,
|
||||||
|
"endLine": 30,
|
||||||
|
"tokens": 186
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PcapWriter.write_frame",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 33,
|
||||||
|
"endLine": 41,
|
||||||
|
"tokens": 140
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PcapWriter.flush",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 43,
|
||||||
|
"endLine": 46,
|
||||||
|
"tokens": 26
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/radiotap.rs": {
|
||||||
|
"description": "Parser mínimo de cabeçalho Radiotap — só os campos que usamos",
|
||||||
|
"tokens": 881,
|
||||||
|
"hash": "3dc5e23c73e7e66a",
|
||||||
|
"size": 3104,
|
||||||
|
"mtimeMs": 1792754360436.5854,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.948Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "RadiotapInfo",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 6,
|
||||||
|
"endLine": 10,
|
||||||
|
"tokens": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parse",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 20,
|
||||||
|
"endLine": 94,
|
||||||
|
"tokens": 626
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "freq_to_channel",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 96,
|
||||||
|
"endLine": 103,
|
||||||
|
"tokens": 73
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"importance": 0.2361
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/scan.rs": {
|
||||||
|
"description": "Agregação de quadros capturados em uma tabela de APs e clientes vistos,",
|
||||||
|
"tokens": 1128,
|
||||||
|
"hash": "bcd7f984398bfdc4",
|
||||||
|
"size": 3963,
|
||||||
|
"mtimeMs": 1792754229534.2896,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.948Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "AccessPoint",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 11,
|
||||||
|
"endLine": 20,
|
||||||
|
"tokens": 76
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ScanTable",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 23,
|
||||||
|
"endLine": 25,
|
||||||
|
"tokens": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ScanTable",
|
||||||
|
"kind": "section",
|
||||||
|
"startLine": 27,
|
||||||
|
"endLine": 102,
|
||||||
|
"tokens": 842
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ScanTable.new",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 28,
|
||||||
|
"endLine": 30,
|
||||||
|
"tokens": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ScanTable.ingest",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 36,
|
||||||
|
"endLine": 95,
|
||||||
|
"tokens": 696
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ScanTable.access_points",
|
||||||
|
"kind": "method",
|
||||||
|
"startLine": 97,
|
||||||
|
"endLine": 101,
|
||||||
|
"tokens": 57
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "security_label",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 104,
|
||||||
|
"endLine": 112,
|
||||||
|
"tokens": 77
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/dot11.rs",
|
||||||
|
"crates/sabia-core/src/radiotap.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"crates/sabia-core/src/update.rs": {
|
||||||
|
"description": "Auto-atualização simples: verifica a última release no GitHub, baixa o",
|
||||||
|
"tokens": 1772,
|
||||||
|
"hash": "03ad228ca62475e3",
|
||||||
|
"size": 6236,
|
||||||
|
"mtimeMs": 1792754282046.0845,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.948Z",
|
||||||
|
"source": "scan",
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"name": "Release",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 18,
|
||||||
|
"endLine": 21,
|
||||||
|
"tokens": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Asset",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 24,
|
||||||
|
"endLine": 27,
|
||||||
|
"tokens": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "UpdateStatus",
|
||||||
|
"kind": "class",
|
||||||
|
"startLine": 30,
|
||||||
|
"endLine": 34,
|
||||||
|
"tokens": 38
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "curl_text",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 36,
|
||||||
|
"endLine": 48,
|
||||||
|
"tokens": 152
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "curl_download",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 50,
|
||||||
|
"endLine": 61,
|
||||||
|
"tokens": 138
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "asset_name_for_platform",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 63,
|
||||||
|
"endLine": 73,
|
||||||
|
"tokens": 91
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "check",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 77,
|
||||||
|
"endLine": 99,
|
||||||
|
"tokens": 260
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apply",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 103,
|
||||||
|
"endLine": 162,
|
||||||
|
"tokens": 693
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "find_binary",
|
||||||
|
"kind": "fn",
|
||||||
|
"startLine": 164,
|
||||||
|
"endLine": 173,
|
||||||
|
"tokens": 109
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbolSource": "ts",
|
||||||
|
"imports": [
|
||||||
|
"crates/sabia-core/src/error.rs"
|
||||||
|
],
|
||||||
|
"importance": 0.1657
|
||||||
|
},
|
||||||
|
"GEMINI.md": {
|
||||||
|
"description": "OpenWolf",
|
||||||
|
"tokens": 75,
|
||||||
|
"hash": "e401f79554a8d796",
|
||||||
|
"size": 297,
|
||||||
|
"mtimeMs": 1792754075057.7603,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.948Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"description": "Project documentation",
|
||||||
|
"tokens": 462,
|
||||||
|
"hash": "04a8387e4a13cf9f",
|
||||||
|
"size": 1887,
|
||||||
|
"mtimeMs": 1792754508878.1196,
|
||||||
|
"updatedAt": "2026-10-23T11:21:49.949Z",
|
||||||
|
"source": "scan",
|
||||||
|
"importance": 0.1527
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preamble": [
|
||||||
|
"> Project structure index. Auto-maintained by OpenWolf hooks and daemon.",
|
||||||
|
"> Run `openwolf scan` to generate, or wait for the first Claude Code session.",
|
||||||
|
"> Status: Pending initial scan"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# anatomy.md
|
||||||
|
|
||||||
|
> Auto-maintained by OpenWolf. Last scanned: 2026-10-23T11:21:49.966Z
|
||||||
|
> Files: 22 tracked | Anatomy hits: 0 | Misses: 0
|
||||||
|
|
||||||
|
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
|
||||||
|
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
|
||||||
|
> Status: Pending initial scan
|
||||||
|
|
||||||
|
## ./
|
||||||
|
|
||||||
|
- `.gitignore` — Git ignore rules (~24 tok)
|
||||||
|
- `AGENTS.md` — OpenWolf (~75 tok)
|
||||||
|
- `Cargo.toml` — Rust package manifest (~100 tok)
|
||||||
|
- `CLAUDE.md` — OpenWolf (~99 tok)
|
||||||
|
- `GEMINI.md` — OpenWolf (~75 tok)
|
||||||
|
- `README.md` — Project documentation (~462 tok)
|
||||||
|
|
||||||
|
## crates/sabia-cli/
|
||||||
|
|
||||||
|
- `Cargo.toml` — Rust package manifest (~125 tok)
|
||||||
|
|
||||||
|
## crates/sabia-cli/src/
|
||||||
|
|
||||||
|
- `main.rs` — Lista interfaces de rede e mostra quais são Wi-Fi. (~2193 tok)
|
||||||
|
|
||||||
|
## crates/sabia-cli/src/web/
|
||||||
|
|
||||||
|
- `index.html` — Sabiá (~1684 tok)
|
||||||
|
- `mod.rs` — Servidor local da interface: a mesma filosofia do Guará (`guara ui`) — (~2254 tok)
|
||||||
|
|
||||||
|
## crates/sabia-core/
|
||||||
|
|
||||||
|
- `Cargo.toml` — Rust package manifest (~190 tok)
|
||||||
|
|
||||||
|
## crates/sabia-core/src/
|
||||||
|
|
||||||
|
- `audit.rs` — Trilha de auditoria encadeada por hash de toda ação sensível (ligar (~1151 tok)
|
||||||
|
- `capture.rs` — Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap. (~1114 tok)
|
||||||
|
- `dot11.rs` — Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe (~1655 tok)
|
||||||
|
- `error.rs` — [derive(Debug, thiserror::Error)] (~225 tok)
|
||||||
|
- `iface.rs` — Descoberta de interfaces de rede e identificação das que são Wi-Fi. (~973 tok)
|
||||||
|
- `lib.rs` — `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11 (~140 tok)
|
||||||
|
- `netlink.rs` — Controle de modo (managed/monitor) e canal via `iw`. (~996 tok)
|
||||||
|
- `pcapwriter.rs` — Escritor de arquivos `.pcap` (formato clássico, link-type 127 = (~507 tok)
|
||||||
|
- `radiotap.rs` — Parser mínimo de cabeçalho Radiotap — só os campos que usamos (~881 tok)
|
||||||
|
- `scan.rs` — Agregação de quadros capturados em uma tabela de APs e clientes vistos, (~1128 tok)
|
||||||
|
- `update.rs` — Auto-atualização simples: verifica a última release no GitHub, baixa o (~1772 tok)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"bugs": [
|
||||||
|
{
|
||||||
|
"error_message": "Wrong reference: Read should be Write",
|
||||||
|
"file": "crates/sabia-cli/src/web/mod.rs",
|
||||||
|
"root_cause": "Used \"Read\" instead of \"Write\"",
|
||||||
|
"fix": "Changed Read → Write",
|
||||||
|
"tags": [
|
||||||
|
"auto-detected",
|
||||||
|
"wrong-reference",
|
||||||
|
"rs"
|
||||||
|
],
|
||||||
|
"status": "candidate",
|
||||||
|
"observed_worktree": "/home/lucas/Documents/Pessoal/dev/Sabiá",
|
||||||
|
"id": "bug-001",
|
||||||
|
"timestamp": "2026-10-23T11:21:10.367Z",
|
||||||
|
"last_seen": "2026-10-23T11:21:10.367Z",
|
||||||
|
"occurrences": 1,
|
||||||
|
"related_bugs": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"applied_observations": {
|
||||||
|
"0c11d054-2b71-4e02-a624-0e73a302bc1e": true
|
||||||
|
},
|
||||||
|
"next_id": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: learned preferences, project conventions, and Do-Not-Repeat rules
|
||||||
|
budget_tokens: 2000
|
||||||
|
---
|
||||||
|
# Cerebrum
|
||||||
|
|
||||||
|
> OpenWolf's learning memory. Updated automatically as the AI learns from interactions.
|
||||||
|
> Do not edit manually unless correcting an error.
|
||||||
|
> Last updated: 2026-10-23
|
||||||
|
|
||||||
|
## User Preferences
|
||||||
|
|
||||||
|
<!-- How the user likes things done. Code style, tools, patterns, communication. -->
|
||||||
|
|
||||||
|
## Key Learnings
|
||||||
|
|
||||||
|
- **Project:** Sabiá
|
||||||
|
|
||||||
|
## Do-Not-Repeat
|
||||||
|
|
||||||
|
<!-- Mistakes made and corrected. Each entry prevents the same mistake recurring. -->
|
||||||
|
<!-- Format: [YYYY-MM-DD] Description of what went wrong and what to do instead. -->
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
<!-- Significant technical decisions with rationale. Why X was chosen over Y. -->
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"openwolf": {
|
||||||
|
"enabled": true,
|
||||||
|
"reads": {
|
||||||
|
"duplicate_mode": "warn",
|
||||||
|
"skeleton_hints": true
|
||||||
|
},
|
||||||
|
"bash": {
|
||||||
|
"filter_mode": "suggest",
|
||||||
|
"governor": {
|
||||||
|
"mode": "replace",
|
||||||
|
"threshold_tokens": 2000,
|
||||||
|
"families": {
|
||||||
|
"grep_flood": "replace",
|
||||||
|
"file_print": "replace",
|
||||||
|
"git_show": "replace",
|
||||||
|
"test": "suggest",
|
||||||
|
"build": "suggest",
|
||||||
|
"unknown": "suggest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"anatomy": {
|
||||||
|
"auto_scan_on_init": true,
|
||||||
|
"rescan_interval_hours": 6,
|
||||||
|
"max_description_length": 100,
|
||||||
|
"max_files": 500,
|
||||||
|
"exclude_patterns": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
".turbo",
|
||||||
|
".vercel",
|
||||||
|
".netlify",
|
||||||
|
".output",
|
||||||
|
"coverage",
|
||||||
|
"__pycache__",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"site-packages",
|
||||||
|
".pytest_cache",
|
||||||
|
".mypy_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
".tox",
|
||||||
|
"*.pyc",
|
||||||
|
".gradle",
|
||||||
|
"target",
|
||||||
|
".m2",
|
||||||
|
".cargo",
|
||||||
|
".git",
|
||||||
|
".wolf",
|
||||||
|
".cache",
|
||||||
|
".vscode",
|
||||||
|
".idea",
|
||||||
|
".DS_Store",
|
||||||
|
"Thumbs.db",
|
||||||
|
"*.min.js",
|
||||||
|
"*.min.css",
|
||||||
|
"*.map"
|
||||||
|
],
|
||||||
|
"respect_gitignore": true
|
||||||
|
},
|
||||||
|
"token_audit": {
|
||||||
|
"enabled": true,
|
||||||
|
"report_frequency": "weekly",
|
||||||
|
"waste_threshold_percent": 15,
|
||||||
|
"chars_per_token_code": 3.5,
|
||||||
|
"chars_per_token_prose": 4
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_retry_attempts": 3,
|
||||||
|
"dead_letter_enabled": true,
|
||||||
|
"heartbeat_interval_minutes": 30
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"consolidation_after_days": 7,
|
||||||
|
"max_entries_before_consolidation": 200
|
||||||
|
},
|
||||||
|
"cerebrum": {
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"reflection_frequency": "weekly"
|
||||||
|
},
|
||||||
|
"daemon": {
|
||||||
|
"port": 18830,
|
||||||
|
"log_level": "info"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 18831,
|
||||||
|
"host": "127.0.0.1"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"session_digest_budget_tokens": 1500,
|
||||||
|
"reinjection_interval": 25,
|
||||||
|
"state_budgets": {
|
||||||
|
".wolf/cerebrum.md": 2000,
|
||||||
|
".wolf/STATUS.md": 1000
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"claude": 1500,
|
||||||
|
"codex": 1200,
|
||||||
|
"gemini": 1200,
|
||||||
|
"opencode": 1200,
|
||||||
|
"cursor": 800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"mode": "quiet"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"mode": "compatible"
|
||||||
|
},
|
||||||
|
"agents": [
|
||||||
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"opencode",
|
||||||
|
"gemini",
|
||||||
|
"cursor",
|
||||||
|
"antigravity"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "anatomy-rescan",
|
||||||
|
"name": "Full anatomy rescan",
|
||||||
|
"schedule": "0 */6 * * *",
|
||||||
|
"description": "Re-scans project filesystem and reconciles anatomy.md",
|
||||||
|
"action": {
|
||||||
|
"type": "scan_project"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 3,
|
||||||
|
"backoff": "exponential",
|
||||||
|
"base_delay_seconds": 30
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "log_and_continue",
|
||||||
|
"alert_after_consecutive_failures": 2,
|
||||||
|
"dead_letter": true
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "memory-consolidation",
|
||||||
|
"name": "Consolidate old memory",
|
||||||
|
"schedule": "0 2 * * *",
|
||||||
|
"description": "Compress memory.md entries older than 7 days",
|
||||||
|
"action": {
|
||||||
|
"type": "consolidate_memory",
|
||||||
|
"params": {
|
||||||
|
"older_than_days": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff": "exponential",
|
||||||
|
"base_delay_seconds": 60
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "skip_and_retry_next_cycle",
|
||||||
|
"dead_letter": false
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "token-audit",
|
||||||
|
"name": "Token audit report",
|
||||||
|
"schedule": "0 0 * * 1",
|
||||||
|
"description": "Weekly waste pattern detection",
|
||||||
|
"action": {
|
||||||
|
"type": "generate_token_report"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff": "linear",
|
||||||
|
"base_delay_seconds": 60
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "log_and_continue",
|
||||||
|
"dead_letter": true
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: chronological action log per session, consolidated weekly
|
||||||
|
---
|
||||||
|
# Memory
|
||||||
|
|
||||||
|
> Chronological action log. Hooks and AI append to this file automatically.
|
||||||
|
> Old sessions are consolidated by the daemon weekly.
|
||||||
|
| 08:14 | Created Cargo.toml | — | ~94 |
|
||||||
|
| 08:15 | Created crates/sabia-core/Cargo.toml | — | ~177 |
|
||||||
|
| 08:15 | Created crates/sabia-core/src/error.rs | — | ~210 |
|
||||||
|
| 08:15 | Created crates/sabia-core/src/iface.rs | — | ~908 |
|
||||||
|
| 08:15 | Created crates/sabia-core/src/netlink.rs | — | ~930 |
|
||||||
|
| 08:16 | Created crates/sabia-core/src/radiotap.rs | — | ~828 |
|
||||||
|
| 08:16 | Created crates/sabia-core/src/dot11.rs | — | ~1544 |
|
||||||
|
| 08:16 | Created crates/sabia-core/src/capture.rs | — | ~1069 |
|
||||||
|
| 08:16 | Edited crates/sabia-core/src/capture.rs | 3→3 lines | ~17 |
|
||||||
|
| 08:16 | Edited crates/sabia-core/src/capture.rs | — | ~0 |
|
||||||
|
| 08:17 | Created crates/sabia-core/src/scan.rs | — | ~1053 |
|
||||||
|
| 08:17 | Created crates/sabia-core/src/pcapwriter.rs | — | ~474 |
|
||||||
|
| 08:17 | Created crates/sabia-core/src/audit.rs | — | ~1074 |
|
||||||
|
| 08:18 | Created crates/sabia-core/src/update.rs | — | ~1654 |
|
||||||
|
| 08:18 | Created crates/sabia-core/src/lib.rs | — | ~131 |
|
||||||
|
| 08:18 | Created crates/sabia-cli/Cargo.toml | — | ~117 |
|
||||||
|
| 08:18 | Created crates/sabia-cli/src/main.rs | — | ~1987 |
|
||||||
|
| 08:18 | Created .gitignore | — | ~24 |
|
||||||
|
| 08:19 | Edited crates/sabia-core/src/capture.rs | inline fix | ~18 |
|
||||||
|
| 08:19 | Edited crates/sabia-core/src/radiotap.rs | modified len() | ~47 |
|
||||||
|
| 08:20 | Created crates/sabia-cli/src/web/mod.rs | — | ~2105 |
|
||||||
|
| 08:20 | Created crates/sabia-cli/src/web/index.html | — | ~1684 |
|
||||||
|
| 08:20 | Edited crates/sabia-cli/src/main.rs | 6→8 lines | ~72 |
|
||||||
|
| 08:20 | Edited crates/sabia-cli/src/main.rs | 3→8 lines | ~63 |
|
||||||
|
| 08:21 | Edited crates/sabia-cli/src/main.rs | 5→9 lines | ~83 |
|
||||||
|
| 08:21 | Edited crates/sabia-cli/src/web/mod.rs | inline fix | ~11 |
|
||||||
|
| 08:21 | Created README.md | — | ~493 |
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- openwolf:begin -->
|
||||||
|
# OpenWolf
|
||||||
|
|
||||||
|
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
|
||||||
|
<!-- openwolf:end -->
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# OpenWolf
|
||||||
|
|
||||||
|
This project uses OpenWolf for context management. The always-on rules live in `.claude/rules/openwolf.md`; the hooks handle bookkeeping (anatomy index, memory log, read tracking) automatically.
|
||||||
|
|
||||||
|
For the full operating protocol (session handoff, memory discipline, bug logging), load the `openwolf` skill, or read `.wolf/OPENWOLF.md`. Regenerate the session handoff with `/handoff`.
|
||||||
Generated
+495
@@ -0,0 +1,495 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctrlc"
|
||||||
|
version = "3.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"nix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dispatch2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.191"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-encode"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sabia-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"ctrlc",
|
||||||
|
"sabia-core",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sabia-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/sabia-core", "crates/sabia-cli"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/lucasrguerra/sabia"
|
||||||
|
authors = ["Sabiá contributors"]
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- openwolf:begin -->
|
||||||
|
# OpenWolf
|
||||||
|
|
||||||
|
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
|
||||||
|
<!-- openwolf:end -->
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Sabiá
|
||||||
|
|
||||||
|
Auditoria, monitoramento e pentest de redes Wi-Fi — terminal e interface
|
||||||
|
gráfica sobre o mesmo núcleo, nos moldes do [Guará](../Guará): a parte
|
||||||
|
complexa (netlink, radiotap, parsing de quadro 802.11) fica escondida em
|
||||||
|
`sabia-core`; CLI e GUI só consomem.
|
||||||
|
|
||||||
|
> Use apenas em redes próprias ou com autorização explícita do responsável
|
||||||
|
> pela rede (engajamento de pentest formal). Toda ativação de modo monitor
|
||||||
|
> e sessão de captura é registrada numa trilha de auditoria local
|
||||||
|
> encadeada por hash (`.sabia-audit.log`).
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/sabia-core biblioteca: netlink (iw), radiotap, parsing 802.11,
|
||||||
|
captura crua (AF_PACKET), auditoria, auto-atualização
|
||||||
|
crates/sabia-cli binário `sabia`: comandos de terminal + servidor
|
||||||
|
local da interface (`sabia ui`)
|
||||||
|
apps/gui/ui mesma página HTML/JS servida pelo `sabia ui`
|
||||||
|
apps/gui/src-tauri shell desktop (Tauri) — esqueleto, igual ao Guará
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
sabia iface list # lista interfaces, marca as Wi-Fi
|
||||||
|
sabia iface monitor <if> on # liga modo monitor (precisa root)
|
||||||
|
sabia scan <if> --out captura.pcap # varre canais, mostra APs/clientes
|
||||||
|
sabia ui # abre a interface gráfica local
|
||||||
|
sabia update # verifica atualização
|
||||||
|
sabia update --apply # baixa e aplica a atualização
|
||||||
|
```
|
||||||
|
|
||||||
|
Capturar quadros 802.11 exige `CAP_NET_RAW`/`CAP_NET_ADMIN` — normalmente
|
||||||
|
root (`sudo sabia ...`).
|
||||||
|
|
||||||
|
## Dependências externas
|
||||||
|
|
||||||
|
`iw` e `ip` (iproute2) para controlar modo monitor e canal — é uma ponte
|
||||||
|
deliberada até existir uma implementação nl80211 nativa. A captura em si
|
||||||
|
(AF_PACKET) e todo o parsing de quadro são Rust puro, sem libpcap.
|
||||||
|
|
||||||
|
## Hardware testado
|
||||||
|
|
||||||
|
Adaptador USB RTL8812AU (driver `rtw88_8812au`), suporta modo monitor e
|
||||||
|
injeção.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "sabia-cli"
|
||||||
|
description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "sabia"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sabia-core = { path = "../sabia-core" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
ctrlc = "3"
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
mod web;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "sabia", about = "Sabiá — auditoria e monitoramento de redes Wi-Fi", version)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Lista interfaces de rede e mostra quais são Wi-Fi.
|
||||||
|
Iface {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: IfaceAction,
|
||||||
|
},
|
||||||
|
/// Varre o ar na interface (já em modo monitor) e mostra APs/clientes vistos.
|
||||||
|
Scan {
|
||||||
|
iface: String,
|
||||||
|
/// Canal fixo; sem isso, pula entre todos os canais suportados.
|
||||||
|
#[arg(long)]
|
||||||
|
channel: Option<u32>,
|
||||||
|
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
|
||||||
|
#[arg(long)]
|
||||||
|
seconds: Option<u64>,
|
||||||
|
/// Salva tudo que foi capturado em um .pcap (inclui handshakes).
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Verifica e aplica atualizações do próprio sabia.
|
||||||
|
Update {
|
||||||
|
#[arg(long)]
|
||||||
|
apply: bool,
|
||||||
|
},
|
||||||
|
/// Confere a integridade da trilha de auditoria.
|
||||||
|
AuditVerify { path: PathBuf },
|
||||||
|
/// Abre a interface gráfica (servidor local + navegador).
|
||||||
|
Ui {
|
||||||
|
#[arg(long, default_value = "4337")]
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum IfaceAction {
|
||||||
|
List,
|
||||||
|
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||||
|
Monitor { iface: String, #[arg(value_enum)] state: OnOff },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||||
|
enum OnOff {
|
||||||
|
On,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audit_path() -> PathBuf {
|
||||||
|
PathBuf::from(".sabia-audit.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
if let Err(e) = run(cli) {
|
||||||
|
eprintln!("erro: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||||
|
match cli.cmd {
|
||||||
|
Cmd::Iface { action } => match action {
|
||||||
|
IfaceAction::List => {
|
||||||
|
for i in iface::list_all()? {
|
||||||
|
let tag = if i.phy.is_some() { "wifi" } else { "—" };
|
||||||
|
println!(
|
||||||
|
"{:<16} {:<18} {:<6} {:<10} {}",
|
||||||
|
i.name,
|
||||||
|
i.mac,
|
||||||
|
tag,
|
||||||
|
format!("{:?}", i.mode),
|
||||||
|
if i.up { "up" } else { "down" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IfaceAction::Monitor { iface: name, state } => {
|
||||||
|
let enable = matches!(state, OnOff::On);
|
||||||
|
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
|
||||||
|
netlink::set_monitor_mode(&name, enable)?;
|
||||||
|
let mut log = AuditLog::open(audit_path())?;
|
||||||
|
log.record(
|
||||||
|
"monitor_mode",
|
||||||
|
&name,
|
||||||
|
&format!("{{\"enable\": {enable}}}"),
|
||||||
|
)?;
|
||||||
|
println!("{name}: modo monitor {}", if enable { "ativado" } else { "desativado" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Cmd::Scan { iface: name, channel, seconds, out } => {
|
||||||
|
let info = iface::get(&name)?;
|
||||||
|
if info.mode != iface::Mode::Monitor {
|
||||||
|
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut log = AuditLog::open(audit_path())?;
|
||||||
|
log.record("scan_start", &name, &format!("{{\"channel\": {channel:?}}}"))?;
|
||||||
|
|
||||||
|
let cap = RawCapture::open(&name)?;
|
||||||
|
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||||
|
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let r = running.clone();
|
||||||
|
ctrlc::set_handler(move || r.store(false, Ordering::SeqCst))
|
||||||
|
.expect("falha ao instalar handler de Ctrl+C");
|
||||||
|
|
||||||
|
let mut table = ScanTable::new();
|
||||||
|
let mut writer = match &out {
|
||||||
|
Some(p) => Some(PcapWriter::create(p)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channels: Vec<u32> = match channel {
|
||||||
|
Some(c) => vec![c],
|
||||||
|
None => netlink::supported_channels(&name).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
let mut chan_idx = 0usize;
|
||||||
|
let mut last_hop = std::time::Instant::now();
|
||||||
|
|
||||||
|
let deadline = seconds.map(|s| std::time::Instant::now() + Duration::from_secs(s));
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
|
||||||
|
println!("capturando em {name}... (Ctrl+C para parar)");
|
||||||
|
while running.load(Ordering::SeqCst) {
|
||||||
|
if let Some(d) = deadline {
|
||||||
|
if std::time::Instant::now() >= d {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel.is_none() && !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||||
|
chan_idx = (chan_idx + 1) % channels.len();
|
||||||
|
let _ = netlink::set_channel(&name, channels[chan_idx]);
|
||||||
|
last_hop = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
match cap.recv(&mut buf)? {
|
||||||
|
Some(n) => {
|
||||||
|
let frame = &buf[..n];
|
||||||
|
let had_eapol = table.ingest(frame);
|
||||||
|
if had_eapol {
|
||||||
|
eprintln!("[!] handshake EAPOL visto");
|
||||||
|
}
|
||||||
|
if let Some(w) = writer.as_mut() {
|
||||||
|
w.write_frame(frame)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => continue, // timeout, sem quadro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(w) = writer.as_mut() {
|
||||||
|
w.flush()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.record("scan_stop", &name, &format!("{{\"aps_found\": {}}}", table.access_points().len()))?;
|
||||||
|
|
||||||
|
println!("\n{:<18} {:<24} {:<8} {:<8} {:<6} {}", "BSSID", "SSID", "CANAL", "SINAL", "SEG", "CLIENTES");
|
||||||
|
for ap in table.access_points() {
|
||||||
|
println!(
|
||||||
|
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
|
||||||
|
ap.bssid,
|
||||||
|
ap.ssid.unwrap_or_default(),
|
||||||
|
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||||
|
ap.last_signal_dbm.map(|s| format!("{s}dBm")).unwrap_or_default(),
|
||||||
|
ap.security.unwrap_or_default(),
|
||||||
|
ap.clients.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::Update { apply } => {
|
||||||
|
let current = env!("CARGO_PKG_VERSION");
|
||||||
|
if apply {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let version = sabia_core::update::apply(&exe)?;
|
||||||
|
println!("atualizado para {version}");
|
||||||
|
} else {
|
||||||
|
let status = sabia_core::update::check(current)?;
|
||||||
|
if status.update_available {
|
||||||
|
println!(
|
||||||
|
"atualização disponível: {} -> {} (rode `sabia update --apply`)",
|
||||||
|
status.current_version,
|
||||||
|
status.latest_version.unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("já na última versão ({})", status.current_version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cmd::AuditVerify { path } => match sabia_core::audit::AuditLog::verify(&path)? {
|
||||||
|
None => println!("cadeia de auditoria íntegra"),
|
||||||
|
Some(idx) => println!("cadeia ROMPIDA na entrada {idx}"),
|
||||||
|
},
|
||||||
|
|
||||||
|
Cmd::Ui { port } => {
|
||||||
|
web::serve(port)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sabiá</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --line: #262b35; --text: #e7e9ee;
|
||||||
|
--muted: #8b93a3; --accent: #3ddc97; --warn: #e2b23d; --danger: #e2543d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--text); font: 14px/1.4 system-ui, sans-serif; }
|
||||||
|
header { display:flex; align-items:center; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--line); }
|
||||||
|
header h1 { font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; }
|
||||||
|
header h1 span { color:var(--accent); }
|
||||||
|
main { padding:1.25rem; max-width:1080px; margin:0 auto; }
|
||||||
|
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:1rem 1.1rem; margin-bottom:1.1rem; }
|
||||||
|
.row { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
|
||||||
|
select, button { background:#1e222b; color:var(--text); border:1px solid var(--line); border-radius:7px; padding:.5rem .7rem; font-size:.9rem; }
|
||||||
|
button { cursor:pointer; }
|
||||||
|
button.primary { background:var(--accent); color:#06231a; border-color:var(--accent); font-weight:600; }
|
||||||
|
button.danger { background:var(--danger); color:#2a0b06; border-color:var(--danger); }
|
||||||
|
button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.pill { font-size:.75rem; padding:.15rem .55rem; border-radius:999px; border:1px solid var(--line); color:var(--muted); }
|
||||||
|
.pill.on { color:var(--accent); border-color:var(--accent); }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
|
||||||
|
th, td { text-align:left; padding:.45rem .5rem; border-bottom:1px solid var(--line); font-size:.85rem; }
|
||||||
|
th { color:var(--muted); font-weight:500; text-transform:uppercase; font-size:.7rem; letter-spacing:.04em; }
|
||||||
|
td.sec-open { color:var(--danger); } td.sec-wep { color:var(--warn); } td.sec-wpa, td.sec-wpa2, td.sec-wpa3 { color:var(--accent); }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.warning { background:#241a10; border:1px solid #4a3414; color:#e2b23d; border-radius:8px; padding:.6rem .8rem; font-size:.82rem; margin-bottom:1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Sabi<span>á</span></h1>
|
||||||
|
<span class="muted">auditoria de redes Wi-Fi</span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="warning">Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura é registrada na trilha de auditoria local.</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<label class="muted">Interface</label>
|
||||||
|
<select id="iface"></select>
|
||||||
|
<span id="mode" class="pill">—</span>
|
||||||
|
<button id="btnMonitor">ativar modo monitor</button>
|
||||||
|
<button id="btnScan" class="primary" disabled>iniciar captura</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<strong>Redes vistas</strong>
|
||||||
|
<span id="count" class="muted">0</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>BSSID</th><th>SSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
let currentIface = null;
|
||||||
|
let monitorOn = false;
|
||||||
|
let scanning = false;
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const url = new URL(path, location.origin);
|
||||||
|
url.searchParams.set('token', token);
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIfaces() {
|
||||||
|
const list = await api('/api/ifaces');
|
||||||
|
const sel = document.getElementById('iface');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const i of list) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i.name;
|
||||||
|
opt.textContent = `${i.name} (${i.mac})`;
|
||||||
|
opt.dataset.mode = i.mode;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (list.length) onIfaceChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIfaceChange() {
|
||||||
|
const sel = document.getElementById('iface');
|
||||||
|
const opt = sel.selectedOptions[0];
|
||||||
|
currentIface = sel.value;
|
||||||
|
monitorOn = opt && opt.dataset.mode === 'Monitor';
|
||||||
|
refreshModeUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshModeUi() {
|
||||||
|
const pill = document.getElementById('mode');
|
||||||
|
pill.textContent = monitorOn ? 'modo monitor' : 'modo managed';
|
||||||
|
pill.className = 'pill' + (monitorOn ? ' on' : '');
|
||||||
|
document.getElementById('btnMonitor').textContent = monitorOn ? 'desativar modo monitor' : 'ativar modo monitor';
|
||||||
|
document.getElementById('btnScan').disabled = !monitorOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('iface').addEventListener('change', onIfaceChange);
|
||||||
|
|
||||||
|
document.getElementById('btnMonitor').addEventListener('click', async () => {
|
||||||
|
await api(`/api/monitor&iface=${encodeURIComponent(currentIface)}&enable=${!monitorOn}`.replace('&', '?'), { method: 'POST' });
|
||||||
|
monitorOn = !monitorOn;
|
||||||
|
refreshModeUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnScan').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('btnScan');
|
||||||
|
if (!scanning) {
|
||||||
|
await api(`/api/scan/start?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
||||||
|
scanning = true;
|
||||||
|
btn.textContent = 'parar captura';
|
||||||
|
btn.classList.add('danger');
|
||||||
|
} else {
|
||||||
|
await api(`/api/scan/stop?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
|
||||||
|
scanning = false;
|
||||||
|
btn.textContent = 'iniciar captura';
|
||||||
|
btn.classList.remove('danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function secClass(sec) {
|
||||||
|
if (!sec) return '';
|
||||||
|
return 'sec-' + sec.toLowerCase().replace('á', 'a');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (scanning && currentIface) {
|
||||||
|
const aps = await api(`/api/scan?iface=${encodeURIComponent(currentIface)}`);
|
||||||
|
const rows = document.getElementById('rows');
|
||||||
|
rows.innerHTML = '';
|
||||||
|
document.getElementById('count').textContent = aps.length;
|
||||||
|
for (const ap of aps) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${ap.bssid}</td><td>${ap.ssid || '<em class="muted">oculto</em>'}</td>` +
|
||||||
|
`<td>${ap.channel ?? '—'}</td><td>${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>` +
|
||||||
|
`<td class="${secClass(ap.security)}">${ap.security || '—'}</td><td>${ap.clients.length}</td>`;
|
||||||
|
rows.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(poll, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIfaces();
|
||||||
|
poll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —
|
||||||
|
//! uma única página HTML/JS servida em `127.0.0.1`, protegida por um token
|
||||||
|
//! gerado na hora, falando com o núcleo por uma API JSON simples. A mesma
|
||||||
|
//! página é o que o shell desktop (Tauri, em `apps/gui`) vai carregar depois.
|
||||||
|
|
||||||
|
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const INDEX_HTML: &str = include_str!("index.html");
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
table: Arc<Mutex<ScanTable>>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AppState {
|
||||||
|
sessions: Mutex<HashMap<String, Session>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||||
|
let token = gen_token();
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||||
|
let state = Arc::new(AppState::default());
|
||||||
|
|
||||||
|
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||||
|
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||||
|
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let stream = match stream {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let state = state.clone();
|
||||||
|
let token = token.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = handle(stream, &state, &token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gen_token() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
format!("{nanos:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||||
|
std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Request {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: HashMap<String, String>,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||||
|
let mut reader = BufReader::new(stream.try_clone()?);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line)?;
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let method = parts.next().unwrap_or("GET").to_string();
|
||||||
|
let full_path = parts.next().unwrap_or("/").to_string();
|
||||||
|
|
||||||
|
// consome o resto dos headers
|
||||||
|
loop {
|
||||||
|
let mut l = String::new();
|
||||||
|
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, query_str) = match full_path.split_once('?') {
|
||||||
|
Some((p, q)) => (p.to_string(), q.to_string()),
|
||||||
|
None => (full_path, String::new()),
|
||||||
|
};
|
||||||
|
let mut query = HashMap::new();
|
||||||
|
for pair in query_str.split('&') {
|
||||||
|
if let Some((k, v)) = pair.split_once('=') {
|
||||||
|
query.insert(urldecode(k), urldecode(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = query.get("token").cloned();
|
||||||
|
Ok(Request { method, path, query, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urldecode(s: &str) -> String {
|
||||||
|
s.replace('+', " ").replace("%3A", ":").replace("%2F", "/").replace("%20", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {
|
||||||
|
let req = parse_request(&mut stream)?;
|
||||||
|
|
||||||
|
if req.path == "/" {
|
||||||
|
if req.token.as_deref() != Some(expected_token) {
|
||||||
|
return respond(&mut stream, 401, "text/plain", b"token invalido");
|
||||||
|
}
|
||||||
|
return respond(&mut stream, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.path.starts_with("/api/") {
|
||||||
|
return respond(&mut stream, 404, "text/plain", b"not found");
|
||||||
|
}
|
||||||
|
if req.token.as_deref() != Some(expected_token) {
|
||||||
|
return respond(&mut stream, 401, "application/json", b"{\"error\":\"token invalido\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = api(state, &req);
|
||||||
|
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {
|
||||||
|
let status = match code {
|
||||||
|
200 => "OK",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
404 => "Not Found",
|
||||||
|
_ => "Error",
|
||||||
|
};
|
||||||
|
write!(stream, "HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())?;
|
||||||
|
stream.write_all(body)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api(state: &Arc<AppState>, req: &Request) -> String {
|
||||||
|
match (req.method.as_str(), req.path.as_str()) {
|
||||||
|
("GET", "/api/ifaces") => {
|
||||||
|
let list = iface::list_wireless().unwrap_or_default();
|
||||||
|
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
||||||
|
}
|
||||||
|
("POST", "/api/monitor") => {
|
||||||
|
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||||
|
let enable = req.query.get("enable").map(|v| v == "true").unwrap_or(false);
|
||||||
|
match netlink::set_monitor_mode(&name, enable) {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(mut log) = AuditLog::open(".sabia-audit.log") {
|
||||||
|
let _ = log.record("monitor_mode", &name, &format!("{{\"enable\":{enable}}}"));
|
||||||
|
}
|
||||||
|
"{\"ok\":true}".into()
|
||||||
|
}
|
||||||
|
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("POST", "/api/scan/start") => {
|
||||||
|
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||||
|
start_scan(state, name)
|
||||||
|
}
|
||||||
|
("POST", "/api/scan/stop") => {
|
||||||
|
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||||
|
stop_scan(state, &name);
|
||||||
|
"{\"ok\":true}".into()
|
||||||
|
}
|
||||||
|
("GET", "/api/scan") => {
|
||||||
|
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||||
|
let sessions = state.sessions.lock().unwrap();
|
||||||
|
match sessions.get(&name) {
|
||||||
|
Some(s) => {
|
||||||
|
let table = s.table.lock().unwrap();
|
||||||
|
serde_json::to_string(&table.access_points()).unwrap_or_else(|_| "[]".into())
|
||||||
|
}
|
||||||
|
None => "[]".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "{\"error\":\"rota desconhecida\"}".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_scan(state: &Arc<AppState>, name: String) -> String {
|
||||||
|
let mut sessions = state.sessions.lock().unwrap();
|
||||||
|
if sessions.contains_key(&name) {
|
||||||
|
return "{\"ok\":true,\"already_running\":true}".into();
|
||||||
|
}
|
||||||
|
let table = Arc::new(Mutex::new(ScanTable::new()));
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let t = table.clone();
|
||||||
|
let r = running.clone();
|
||||||
|
let iface_name = name.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let cap = match RawCapture::open(&iface_name) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("falha ao abrir captura em {iface_name}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = cap.set_read_timeout(Duration::from_millis(500));
|
||||||
|
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
|
||||||
|
let mut idx = 0usize;
|
||||||
|
let mut last_hop = std::time::Instant::now();
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
while r.load(Ordering::SeqCst) {
|
||||||
|
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
|
||||||
|
idx = (idx + 1) % channels.len();
|
||||||
|
let _ = netlink::set_channel(&iface_name, channels[idx]);
|
||||||
|
last_hop = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
if let Ok(Some(n)) = cap.recv(&mut buf) {
|
||||||
|
let mut table = t.lock().unwrap();
|
||||||
|
table.ingest(&buf[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions.insert(name, Session { table, running });
|
||||||
|
"{\"ok\":true}".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_scan(state: &Arc<AppState>, name: &str) {
|
||||||
|
let mut sessions = state.sessions.lock().unwrap();
|
||||||
|
if let Some(s) = sessions.remove(name) {
|
||||||
|
s.running.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "sabia-core"
|
||||||
|
description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
thiserror = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||||
|
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
|
||||||
|
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
|
||||||
|
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
|
||||||
|
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
|
||||||
|
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
|
||||||
|
//!
|
||||||
|
//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha
|
||||||
|
//! evidência de o quê foi feito, quando e contra qual rede/interface —
|
||||||
|
//! não para decidir sozinho se uma ação é autorizada (isso é sempre do
|
||||||
|
//! operador).
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditEntry {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub action: String,
|
||||||
|
pub target: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub prev_hash: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuditLog {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
last_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
impl AuditLog {
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let last_hash = if path.exists() {
|
||||||
|
let file = OpenOptions::new().read(true).open(&path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut last = GENESIS.to_string();
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line?;
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(entry) = serde_json::from_str::<AuditEntry>(&line) {
|
||||||
|
last = entry.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last
|
||||||
|
} else {
|
||||||
|
GENESIS.to_string()
|
||||||
|
};
|
||||||
|
Ok(Self { path, last_hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&mut self, action: &str, target: &str, detail: &str) -> Result<AuditEntry> {
|
||||||
|
let timestamp = OffsetDateTime::now_utc().to_string();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(self.last_hash.as_bytes());
|
||||||
|
hasher.update(timestamp.as_bytes());
|
||||||
|
hasher.update(action.as_bytes());
|
||||||
|
hasher.update(target.as_bytes());
|
||||||
|
hasher.update(detail.as_bytes());
|
||||||
|
let hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
let entry = AuditEntry {
|
||||||
|
timestamp,
|
||||||
|
action: action.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
detail: detail.to_string(),
|
||||||
|
prev_hash: self.last_hash.clone(),
|
||||||
|
hash: hash.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||||
|
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
|
||||||
|
|
||||||
|
self.last_hash = hash;
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica a integridade de toda a cadeia; retorna o índice da
|
||||||
|
/// primeira entrada inválida, se houver.
|
||||||
|
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
|
||||||
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut prev = GENESIS.to_string();
|
||||||
|
for (i, line) in reader.lines().enumerate() {
|
||||||
|
let line = line?;
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry: AuditEntry = serde_json::from_str(&line)?;
|
||||||
|
if entry.prev_hash != prev {
|
||||||
|
return Ok(Some(i));
|
||||||
|
}
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(entry.prev_hash.as_bytes());
|
||||||
|
hasher.update(entry.timestamp.as_bytes());
|
||||||
|
hasher.update(entry.action.as_bytes());
|
||||||
|
hasher.update(entry.target.as_bytes());
|
||||||
|
hasher.update(entry.detail.as_bytes());
|
||||||
|
let expected = format!("{:x}", hasher.finalize());
|
||||||
|
if expected != entry.hash {
|
||||||
|
return Ok(Some(i));
|
||||||
|
}
|
||||||
|
prev = entry.hash;
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
|
||||||
|
//!
|
||||||
|
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
|
||||||
|
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
|
||||||
|
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
|
||||||
|
//! remove uma dependência de sistema.
|
||||||
|
|
||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
|
use crate::error::{Result, SabiaError};
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::io;
|
||||||
|
use std::mem;
|
||||||
|
use std::os::fd::{AsRawFd, RawFd};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const ETH_P_ALL: u16 = 0x0003;
|
||||||
|
|
||||||
|
pub struct RawCapture {
|
||||||
|
fd: RawFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawCapture {
|
||||||
|
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
|
||||||
|
/// Exige `CAP_NET_RAW` (tipicamente root).
|
||||||
|
pub fn open(iface: &str) -> Result<Self> {
|
||||||
|
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
|
||||||
|
if fd < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
return Err(if err.raw_os_error() == Some(libc::EPERM) {
|
||||||
|
SabiaError::PermissionDenied
|
||||||
|
} else {
|
||||||
|
SabiaError::Io(err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ifindex = if_index(iface)?;
|
||||||
|
|
||||||
|
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
|
||||||
|
sll.sll_family = libc::AF_PACKET as u16;
|
||||||
|
sll.sll_protocol = (ETH_P_ALL as u16).to_be();
|
||||||
|
sll.sll_ifindex = ifindex;
|
||||||
|
|
||||||
|
let ret = unsafe {
|
||||||
|
libc::bind(
|
||||||
|
fd,
|
||||||
|
&sll as *const libc::sockaddr_ll as *const libc::sockaddr,
|
||||||
|
mem::size_of::<libc::sockaddr_ll>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ret < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
unsafe { libc::close(fd) };
|
||||||
|
return Err(SabiaError::Io(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { fd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define um timeout de recepção, para o loop de captura poder checar
|
||||||
|
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
|
||||||
|
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
|
||||||
|
let tv = libc::timeval {
|
||||||
|
tv_sec: timeout.as_secs() as libc::time_t,
|
||||||
|
tv_usec: timeout.subsec_micros() as libc::suseconds_t,
|
||||||
|
};
|
||||||
|
let ret = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
self.fd,
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_RCVTIMEO,
|
||||||
|
&tv as *const _ as *const libc::c_void,
|
||||||
|
mem::size_of::<libc::timeval>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ret < 0 {
|
||||||
|
return Err(SabiaError::Io(io::Error::last_os_error()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
|
||||||
|
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
|
||||||
|
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
|
||||||
|
if n < 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
if matches!(err.raw_os_error(), Some(libc::EAGAIN)) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(SabiaError::Io(err));
|
||||||
|
}
|
||||||
|
Ok(Some(n as usize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRawFd for RawCapture {
|
||||||
|
fn as_raw_fd(&self) -> RawFd {
|
||||||
|
self.fd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RawCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { libc::close(self.fd) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn if_index(name: &str) -> Result<i32> {
|
||||||
|
let cname = CString::new(name).map_err(|_| SabiaError::InterfaceNotFound(name.to_string()))?;
|
||||||
|
let idx = unsafe { libc::if_nametoindex(cname.as_ptr()) };
|
||||||
|
if idx == 0 {
|
||||||
|
return Err(SabiaError::InterfaceNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
Ok(idx as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
|
||||||
|
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
|
||||||
|
pub fn check_capabilities() -> bool {
|
||||||
|
let euid = unsafe { libc::geteuid() };
|
||||||
|
euid == 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe
|
||||||
|
//! request/response) e detecção de EAPOL (handshake WPA/WPA2).
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FrameKind {
|
||||||
|
Beacon,
|
||||||
|
ProbeRequest,
|
||||||
|
ProbeResponse,
|
||||||
|
AssociationRequest,
|
||||||
|
Deauth,
|
||||||
|
Disassoc,
|
||||||
|
Data,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Security {
|
||||||
|
Open,
|
||||||
|
Wep,
|
||||||
|
Wpa,
|
||||||
|
Wpa2,
|
||||||
|
Wpa3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ManagementInfo {
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
pub security: Option<Security>,
|
||||||
|
pub channel_ie: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mac_to_string(mac: &[u8]) -> String {
|
||||||
|
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quadro 802.11 já sem o cabeçalho radiotap.
|
||||||
|
pub struct Frame<'a> {
|
||||||
|
pub kind: FrameKind,
|
||||||
|
pub bssid: Option<[u8; 6]>,
|
||||||
|
pub src: Option<[u8; 6]>,
|
||||||
|
pub dst: Option<[u8; 6]>,
|
||||||
|
pub body: &'a [u8],
|
||||||
|
/// offset, dentro de `body` completo original, de onde começam os
|
||||||
|
/// "information elements" (só preenchido para beacon/probe response).
|
||||||
|
ie_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
|
||||||
|
if buf.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let fc = u16::from_le_bytes([buf[0], buf[1]]);
|
||||||
|
let frame_type = (fc >> 2) & 0b11;
|
||||||
|
let subtype = (fc >> 4) & 0b1111;
|
||||||
|
|
||||||
|
let kind = match (frame_type, subtype) {
|
||||||
|
(0, 8) => FrameKind::Beacon,
|
||||||
|
(0, 4) => FrameKind::ProbeRequest,
|
||||||
|
(0, 5) => FrameKind::ProbeResponse,
|
||||||
|
(0, 0) => FrameKind::AssociationRequest,
|
||||||
|
(0, 12) => FrameKind::Deauth,
|
||||||
|
(0, 10) => FrameKind::Disassoc,
|
||||||
|
(2, _) => FrameKind::Data,
|
||||||
|
_ => FrameKind::Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
if buf.len() < 24 {
|
||||||
|
// sem addr1/2/3 completos (comum em alguns quadros de controle)
|
||||||
|
return Some(Frame { kind, bssid: None, src: None, dst: None, body: buf, ie_offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr1: [u8; 6] = buf[4..10].try_into().ok()?;
|
||||||
|
let addr2: [u8; 6] = buf[10..16].try_into().ok()?;
|
||||||
|
let addr3: [u8; 6] = buf[16..22].try_into().ok()?;
|
||||||
|
|
||||||
|
let (dst, src, bssid) = (addr1, addr2, addr3);
|
||||||
|
|
||||||
|
// Para beacon/probe response, corpo fixo de 12 bytes (timestamp +
|
||||||
|
// interval + capability) antecede os information elements.
|
||||||
|
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) { 24 + 12 } else { 24 };
|
||||||
|
|
||||||
|
Some(Frame { kind, bssid: Some(bssid), src: Some(src), dst: Some(dst), body: buf, ie_offset })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Frame<'a> {
|
||||||
|
pub fn bssid_str(&self) -> Option<String> {
|
||||||
|
self.bssid.map(|m| mac_to_string(&m))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn src_str(&self) -> Option<String> {
|
||||||
|
self.src.map(|m| mac_to_string(&m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faz parsing dos "information elements" (TLV) de um beacon/probe
|
||||||
|
/// response, extraindo SSID, canal e indício de cifragem.
|
||||||
|
pub fn management_info(&self) -> ManagementInfo {
|
||||||
|
let mut info = ManagementInfo::default();
|
||||||
|
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
let privacy = {
|
||||||
|
// bit "Privacy" no campo capability, 2 bytes antes do fim do
|
||||||
|
// cabeçalho fixo (offset 24+10, 2 bytes).
|
||||||
|
if self.body.len() >= 24 + 12 {
|
||||||
|
let cap = u16::from_le_bytes([self.body[24 + 10], self.body[24 + 11]]);
|
||||||
|
cap & 0x0010 != 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rsn_present = false;
|
||||||
|
let mut wpa1_present = false;
|
||||||
|
|
||||||
|
let ies = &self.body[self.ie_offset..];
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 2 <= ies.len() {
|
||||||
|
let tag = ies[i];
|
||||||
|
let len = ies[i + 1] as usize;
|
||||||
|
let start = i + 2;
|
||||||
|
if start + len > ies.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let val = &ies[start..start + len];
|
||||||
|
match tag {
|
||||||
|
0 => {
|
||||||
|
// SSID; pode ser "oculto" (len 0 ou só zeros)
|
||||||
|
if len > 0 && val.iter().any(|&b| b != 0) {
|
||||||
|
info.ssid = Some(String::from_utf8_lossy(val).into_owned());
|
||||||
|
} else {
|
||||||
|
info.ssid = Some(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
if len == 1 {
|
||||||
|
info.channel_ie = Some(val[0] as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
48 => rsn_present = true, // RSN IE = WPA2/WPA3
|
||||||
|
221 => {
|
||||||
|
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
|
||||||
|
if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 {
|
||||||
|
wpa1_present = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i = start + len;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.security = Some(if rsn_present {
|
||||||
|
Security::Wpa2
|
||||||
|
} else if wpa1_present {
|
||||||
|
Security::Wpa
|
||||||
|
} else if privacy {
|
||||||
|
Security::Wep
|
||||||
|
} else {
|
||||||
|
Security::Open
|
||||||
|
});
|
||||||
|
|
||||||
|
info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detecta se um quadro de dados carrega EAPOL (handshake 4-way), olhando o
|
||||||
|
/// cabeçalho LLC/SNAP (ethertype 0x888E) após o cabeçalho 802.11 de dados.
|
||||||
|
pub fn is_eapol(body: &[u8]) -> bool {
|
||||||
|
// Cabeçalho de dados pode ter 24 ou 26 bytes (QoS); LLC/SNAP tem 8
|
||||||
|
// bytes (AA AA 03 00 00 00) + ethertype de 2 bytes.
|
||||||
|
for hdr_len in [24usize, 26] {
|
||||||
|
if body.len() < hdr_len + 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let llc = &body[hdr_len..hdr_len + 8];
|
||||||
|
if llc[0] == 0xAA && llc[1] == 0xAA && llc[2] == 0x03 {
|
||||||
|
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
|
||||||
|
if ethertype == 0x888E {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SabiaError {
|
||||||
|
#[error("interface `{0}` não encontrada")]
|
||||||
|
InterfaceNotFound(String),
|
||||||
|
|
||||||
|
#[error("interface `{0}` não é uma interface Wi-Fi (sem phy80211)")]
|
||||||
|
NotWireless(String),
|
||||||
|
|
||||||
|
#[error("falha ao executar `{cmd}`: {detail}")]
|
||||||
|
CommandFailed { cmd: String, detail: String },
|
||||||
|
|
||||||
|
#[error("permissão insuficiente — operações de captura e modo monitor exigem root (CAP_NET_ADMIN/CAP_NET_RAW)")]
|
||||||
|
PermissionDenied,
|
||||||
|
|
||||||
|
#[error("captura crua só é suportada no Linux (AF_PACKET)")]
|
||||||
|
UnsupportedPlatform,
|
||||||
|
|
||||||
|
#[error("erro de E/S: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error("erro ao (de)serializar: {0}")]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, SabiaError>;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Descoberta de interfaces de rede e identificação das que são Wi-Fi.
|
||||||
|
//!
|
||||||
|
//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o
|
||||||
|
//! próprio `iw`/`ip` fazem por baixo.
|
||||||
|
|
||||||
|
use crate::error::{Result, SabiaError};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct Interface {
|
||||||
|
pub name: String,
|
||||||
|
pub mac: String,
|
||||||
|
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
|
||||||
|
pub phy: Option<String>,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub up: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub enum Mode {
|
||||||
|
Managed,
|
||||||
|
Monitor,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn net_root() -> &'static Path {
|
||||||
|
Path::new("/sys/class/net")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
|
||||||
|
pub fn list_all() -> Result<Vec<Interface>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for entry in fs::read_dir(net_root())? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name == "lo" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(read_interface(&name)?);
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
|
||||||
|
pub fn list_wireless() -> Result<Vec<Interface>> {
|
||||||
|
Ok(list_all()?.into_iter().filter(|i| i.phy.is_some()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(name: &str) -> Result<Interface> {
|
||||||
|
let iface = read_interface(name)?;
|
||||||
|
if iface.phy.is_none() {
|
||||||
|
return Err(SabiaError::NotWireless(name.to_string()));
|
||||||
|
}
|
||||||
|
Ok(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_interface(name: &str) -> Result<Interface> {
|
||||||
|
let base: PathBuf = net_root().join(name);
|
||||||
|
if !base.exists() {
|
||||||
|
return Err(SabiaError::InterfaceNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mac = fs::read_to_string(base.join("address"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let up = fs::read_to_string(base.join("operstate"))
|
||||||
|
.map(|s| s.trim() == "up")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let phy80211 = base.join("phy80211");
|
||||||
|
let phy = if phy80211.is_dir() {
|
||||||
|
fs::read_link(&phy80211)
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
|
||||||
|
.or_else(|| {
|
||||||
|
// em alguns kernels phy80211 é um diretório real, não symlink;
|
||||||
|
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
|
||||||
|
fs::read_dir(&phy80211)
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut rd| rd.next())
|
||||||
|
.and_then(|e| e.ok())
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mode = if phy.is_some() {
|
||||||
|
read_wireless_mode(name)
|
||||||
|
} else {
|
||||||
|
Mode::Unknown
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Interface { name: name.to_string(), mac, phy, mode, up })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
|
||||||
|
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
|
||||||
|
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
|
||||||
|
/// mantendo aqui um melhor-esforço local para não depender de root.
|
||||||
|
fn read_wireless_mode(name: &str) -> Mode {
|
||||||
|
match crate::netlink::iw_dev_type(name) {
|
||||||
|
Some(Mode::Monitor) => Mode::Monitor,
|
||||||
|
Some(Mode::Managed) => Mode::Managed,
|
||||||
|
_ => Mode::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11
|
||||||
|
//! e mantém a trilha de auditoria. A CLI e a interface gráfica são só
|
||||||
|
//! consumidoras disto — a parte complexa (netlink, radiotap, parsing de
|
||||||
|
//! frame) fica inteira aqui dentro.
|
||||||
|
|
||||||
|
pub mod audit;
|
||||||
|
pub mod dot11;
|
||||||
|
pub mod error;
|
||||||
|
pub mod iface;
|
||||||
|
pub mod netlink;
|
||||||
|
pub mod pcapwriter;
|
||||||
|
pub mod radiotap;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod capture;
|
||||||
|
|
||||||
|
pub use error::{Result, SabiaError};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! Controle de modo (managed/monitor) e canal via `iw`.
|
||||||
|
//!
|
||||||
|
//! Isso é uma ponte deliberada: o protocolo real é nl80211 por netlink, e
|
||||||
|
//! trocar este módulo por uma implementação nativa (sem depender do binário
|
||||||
|
//! `iw` instalado) é o próximo passo natural. Por ora, `iw` é amplamente
|
||||||
|
//! disponível, testado e evita reimplementar genl/nl80211 antes de validar
|
||||||
|
//! o resto do pipeline com o adaptador real.
|
||||||
|
|
||||||
|
use crate::error::{Result, SabiaError};
|
||||||
|
use crate::iface::Mode;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn run(cmd: &str, args: &[&str]) -> Result<String> {
|
||||||
|
let out = Command::new(cmd).args(args).output().map_err(|e| SabiaError::CommandFailed {
|
||||||
|
cmd: format!("{cmd} {}", args.join(" ")),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
let detail = if detail.is_empty() {
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
} else {
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
if detail.contains("Operation not permitted") || detail.contains("nl80211 not found") {
|
||||||
|
return Err(SabiaError::PermissionDenied);
|
||||||
|
}
|
||||||
|
return Err(SabiaError::CommandFailed {
|
||||||
|
cmd: format!("{cmd} {}", args.join(" ")),
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iw_dev_type(iface: &str) -> Option<Mode> {
|
||||||
|
let out = run("iw", &["dev", iface, "info"]).ok()?;
|
||||||
|
for line in out.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(t) = line.strip_prefix("type ") {
|
||||||
|
return Some(match t.trim() {
|
||||||
|
"monitor" => Mode::Monitor,
|
||||||
|
"managed" => Mode::Managed,
|
||||||
|
_ => Mode::Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista (canal, frequência) suportados pelo phy da interface.
|
||||||
|
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
|
||||||
|
let out = run("iw", &["dev", iface, "info"])?;
|
||||||
|
let phy = out
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.trim().strip_prefix("wiphy "))
|
||||||
|
.map(|n| format!("phy{n}"))
|
||||||
|
.ok_or_else(|| SabiaError::CommandFailed {
|
||||||
|
cmd: "iw dev info".into(),
|
||||||
|
detail: "wiphy não encontrado na saída".into(),
|
||||||
|
})?;
|
||||||
|
let info = run("iw", &["phy", &phy, "info"])?;
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
for line in info.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
// formato: "* 2412 MHz [1] (20.0 dBm)"
|
||||||
|
if let Some(rest) = line.strip_prefix("* ") {
|
||||||
|
if let Some(start) = rest.find('[') {
|
||||||
|
if let Some(end) = rest[start..].find(']') {
|
||||||
|
if let Ok(ch) = rest[start + 1..start + end].parse::<u32>() {
|
||||||
|
channels.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channels.sort_unstable();
|
||||||
|
channels.dedup();
|
||||||
|
Ok(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
|
||||||
|
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
|
||||||
|
run("ip", &["link", "set", iface, "down"])?;
|
||||||
|
let target = if enable { "monitor" } else { "managed" };
|
||||||
|
run("iw", &["dev", iface, "set", "type", target])?;
|
||||||
|
run("ip", &["link", "set", iface, "up"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Troca o canal atual da interface (precisa já estar em modo monitor).
|
||||||
|
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
|
||||||
|
run("iw", &["dev", iface, "set", "channel", &channel.to_string()])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
|
||||||
|
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
|
||||||
|
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const LINKTYPE_IEEE802_11_RADIOTAP: u32 = 127;
|
||||||
|
|
||||||
|
pub struct PcapWriter {
|
||||||
|
out: BufWriter<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PcapWriter {
|
||||||
|
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let file = File::create(path)?;
|
||||||
|
let mut out = BufWriter::new(file);
|
||||||
|
// Cabeçalho global do pcap clássico.
|
||||||
|
out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic
|
||||||
|
out.write_all(&2u16.to_le_bytes())?; // version major
|
||||||
|
out.write_all(&4u16.to_le_bytes())?; // version minor
|
||||||
|
out.write_all(&0i32.to_le_bytes())?; // thiszone
|
||||||
|
out.write_all(&0u32.to_le_bytes())?; // sigfigs
|
||||||
|
out.write_all(&65535u32.to_le_bytes())?; // snaplen
|
||||||
|
out.write_all(&LINKTYPE_IEEE802_11_RADIOTAP.to_le_bytes())?;
|
||||||
|
Ok(Self { out })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
|
||||||
|
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||||
|
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
|
||||||
|
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
|
||||||
|
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
|
||||||
|
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original
|
||||||
|
self.out.write_all(raw)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> Result<()> {
|
||||||
|
self.out.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Parser mínimo de cabeçalho Radiotap — só os campos que usamos
|
||||||
|
//! (canal/frequência e força de sinal). Especificação:
|
||||||
|
//! <https://www.radiotap.org/>
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct RadiotapInfo {
|
||||||
|
pub freq_mhz: Option<u16>,
|
||||||
|
pub signal_dbm: Option<i8>,
|
||||||
|
pub header_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Índices de bit no bitmap "present" que nos interessam.
|
||||||
|
const BIT_FLAGS: u32 = 1 << 1;
|
||||||
|
const BIT_RATE: u32 = 1 << 2;
|
||||||
|
const BIT_CHANNEL: u32 = 1 << 3;
|
||||||
|
const BIT_ANTSIGNAL: u32 = 1 << 5;
|
||||||
|
|
||||||
|
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
|
||||||
|
/// chamador saber onde o frame 802.11 propriamente começa).
|
||||||
|
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
|
||||||
|
if buf.len() < 8 || buf[0] != 0 {
|
||||||
|
return None; // version deve ser 0
|
||||||
|
}
|
||||||
|
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
|
||||||
|
if header_len > buf.len() || header_len < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pode haver múltiplas palavras de "present" (bit 31 = continua).
|
||||||
|
let mut present_words = Vec::new();
|
||||||
|
let mut offset = 4;
|
||||||
|
loop {
|
||||||
|
if offset + 4 > buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let word = u32::from_le_bytes([buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]]);
|
||||||
|
present_words.push(word);
|
||||||
|
offset += 4;
|
||||||
|
if word & (1 << 31) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let present = present_words[0]; // só o primeiro conjunto de campos padrão nos interessa
|
||||||
|
|
||||||
|
let mut info = RadiotapInfo { header_len, ..Default::default() };
|
||||||
|
let mut cursor = offset;
|
||||||
|
|
||||||
|
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
|
||||||
|
// campos simplificamos assumindo captura via mac80211 (ordem padrão:
|
||||||
|
// TSFT, Flags, Rate, Channel, ..., AntSignal). Avançamos campo a campo
|
||||||
|
// respeitando alinhamento, sem travar se algum bit não estiver setado.
|
||||||
|
macro_rules! align {
|
||||||
|
($a:expr) => {{
|
||||||
|
let rem = cursor % $a;
|
||||||
|
if rem != 0 {
|
||||||
|
cursor += $a - rem;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
if present & (1 << 0) != 0 {
|
||||||
|
align!(8);
|
||||||
|
cursor += 8; // TSFT
|
||||||
|
}
|
||||||
|
if present & BIT_FLAGS != 0 {
|
||||||
|
align!(1);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if present & BIT_RATE != 0 {
|
||||||
|
align!(1);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
if present & BIT_CHANNEL != 0 {
|
||||||
|
align!(2);
|
||||||
|
if cursor + 4 <= buf.len() {
|
||||||
|
let freq = u16::from_le_bytes([buf[cursor], buf[cursor + 1]]);
|
||||||
|
info.freq_mhz = Some(freq);
|
||||||
|
}
|
||||||
|
cursor += 4;
|
||||||
|
}
|
||||||
|
// FHSS (bit 4) — raro, ignorado.
|
||||||
|
if present & (1 << 4) != 0 {
|
||||||
|
align!(2);
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
if present & BIT_ANTSIGNAL != 0 {
|
||||||
|
align!(1);
|
||||||
|
if cursor < buf.len() {
|
||||||
|
info.signal_dbm = Some(buf[cursor] as i8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
|
||||||
|
match freq_mhz {
|
||||||
|
2412..=2472 => Some(((freq_mhz - 2412) / 5 + 1) as u32),
|
||||||
|
2484 => Some(14),
|
||||||
|
5000..=5900 => Some(((freq_mhz - 5000) / 5) as u32),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Agregação de quadros capturados em uma tabela de APs e clientes vistos,
|
||||||
|
//! no espírito do `airodump-ng` — mas como uma struct reaproveitável tanto
|
||||||
|
//! pela CLI quanto pela interface gráfica.
|
||||||
|
|
||||||
|
use crate::dot11::{self, FrameKind, Security};
|
||||||
|
use crate::radiotap;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AccessPoint {
|
||||||
|
pub bssid: String,
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
pub channel: Option<u32>,
|
||||||
|
pub security: Option<String>,
|
||||||
|
pub last_signal_dbm: Option<i8>,
|
||||||
|
pub beacons_seen: u64,
|
||||||
|
pub clients: Vec<String>,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ScanTable {
|
||||||
|
aps: HashMap<String, AccessPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScanTable {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processa um quadro radiotap+802.11 crú, atualizando a tabela.
|
||||||
|
/// Retorna `true` se o quadro continha um pacote EAPOL (indício de
|
||||||
|
/// handshake WPA em andamento — útil para o chamador decidir salvar em
|
||||||
|
/// pcap).
|
||||||
|
pub fn ingest(&mut self, raw: &[u8]) -> bool {
|
||||||
|
let rt = match radiotap::parse(raw) {
|
||||||
|
Some(rt) => rt,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
if rt.header_len >= raw.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let dot11_buf = &raw[rt.header_len..];
|
||||||
|
let frame = match dot11::parse(dot11_buf) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||||
|
|
||||||
|
match frame.kind {
|
||||||
|
FrameKind::Beacon | FrameKind::ProbeResponse => {
|
||||||
|
if let Some(bssid) = frame.bssid_str() {
|
||||||
|
let info = frame.management_info();
|
||||||
|
let channel =
|
||||||
|
info.channel_ie.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
|
||||||
|
let entry = self.aps.entry(bssid.clone()).or_insert_with(|| AccessPoint {
|
||||||
|
bssid,
|
||||||
|
ssid: None,
|
||||||
|
channel: None,
|
||||||
|
security: None,
|
||||||
|
last_signal_dbm: None,
|
||||||
|
beacons_seen: 0,
|
||||||
|
clients: Vec::new(),
|
||||||
|
last_seen_unix: now,
|
||||||
|
});
|
||||||
|
if let Some(ssid) = info.ssid {
|
||||||
|
entry.ssid = Some(ssid);
|
||||||
|
}
|
||||||
|
if channel.is_some() {
|
||||||
|
entry.channel = channel;
|
||||||
|
}
|
||||||
|
entry.security = info.security.map(security_label);
|
||||||
|
entry.last_signal_dbm = rt.signal_dbm;
|
||||||
|
entry.beacons_seen += 1;
|
||||||
|
entry.last_seen_unix = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrameKind::Data => {
|
||||||
|
// Associa o cliente (endereço de origem) ao BSSID observado,
|
||||||
|
// quando a direção do quadro indicar que vem de uma estação.
|
||||||
|
if let (Some(bssid), Some(src)) = (frame.bssid_str(), frame.src_str()) {
|
||||||
|
if let Some(ap) = self.aps.get_mut(&bssid) {
|
||||||
|
if src != bssid && !ap.clients.contains(&src) {
|
||||||
|
ap.clients.push(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dot11::is_eapol(frame.body);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn access_points(&self) -> Vec<AccessPoint> {
|
||||||
|
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
||||||
|
v.sort_by(|a, b| b.beacons_seen.cmp(&a.beacons_seen));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn security_label(s: Security) -> String {
|
||||||
|
match s {
|
||||||
|
Security::Open => "Aberta".into(),
|
||||||
|
Security::Wep => "WEP".into(),
|
||||||
|
Security::Wpa => "WPA".into(),
|
||||||
|
Security::Wpa2 => "WPA2".into(),
|
||||||
|
Security::Wpa3 => "WPA3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! Auto-atualização simples: verifica a última release no GitHub, baixa o
|
||||||
|
//! binário certo para a plataforma atual, confere o checksum e troca o
|
||||||
|
//! executável em uso — sem o usuário precisar baixar nada manualmente.
|
||||||
|
//!
|
||||||
|
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
|
||||||
|
//! HTTP/TLS inteira como dependência só para isso.
|
||||||
|
|
||||||
|
use crate::error::{Result, SabiaError};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const REPO: &str = "lucasrguerra/sabia";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
tag_name: String,
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct UpdateStatus {
|
||||||
|
pub current_version: String,
|
||||||
|
pub latest_version: Option<String>,
|
||||||
|
pub update_available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curl_text(url: &str) -> Result<String> {
|
||||||
|
let out = Command::new("curl")
|
||||||
|
.args(["-fsSL", "-H", "User-Agent: sabia-updater", url])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(SabiaError::CommandFailed {
|
||||||
|
cmd: format!("curl {url}"),
|
||||||
|
detail: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
||||||
|
let status = Command::new("curl")
|
||||||
|
.args(["-fsSL", "-H", "User-Agent: sabia-updater", "-o"])
|
||||||
|
.arg(dest)
|
||||||
|
.arg(url)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: "download falhou".into() });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_name_for_platform() -> String {
|
||||||
|
let os = if cfg!(target_os = "linux") {
|
||||||
|
"linux"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"macos"
|
||||||
|
} else {
|
||||||
|
"windows"
|
||||||
|
};
|
||||||
|
let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" };
|
||||||
|
format!("sabia-{os}-{arch}.tar.gz")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consulta a última release publicada e compara com a versão atual
|
||||||
|
/// (`CARGO_PKG_VERSION` do binário chamador).
|
||||||
|
pub fn check(current_version: &str) -> Result<UpdateStatus> {
|
||||||
|
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||||
|
let body = match curl_text(&url) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => {
|
||||||
|
// Sem rede, ou repositório ainda sem releases publicadas: não é
|
||||||
|
// um erro fatal, só significa "sem atualização disponível".
|
||||||
|
return Ok(UpdateStatus {
|
||||||
|
current_version: current_version.to_string(),
|
||||||
|
latest_version: None,
|
||||||
|
update_available: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let release: Release = serde_json::from_str(&body)?;
|
||||||
|
let latest = release.tag_name.trim_start_matches('v').to_string();
|
||||||
|
let update_available = latest != current_version;
|
||||||
|
Ok(UpdateStatus {
|
||||||
|
current_version: current_version.to_string(),
|
||||||
|
latest_version: Some(latest),
|
||||||
|
update_available,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
|
||||||
|
/// Retorna a versão instalada.
|
||||||
|
pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
||||||
|
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
|
||||||
|
let body = curl_text(&url)?;
|
||||||
|
let release: Release = serde_json::from_str(&body)?;
|
||||||
|
|
||||||
|
let wanted = asset_name_for_platform();
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == wanted)
|
||||||
|
.ok_or_else(|| SabiaError::CommandFailed {
|
||||||
|
cmd: "update".into(),
|
||||||
|
detail: format!("nenhum asset `{wanted}` na release {}", release.tag_name),
|
||||||
|
})?;
|
||||||
|
let checksum_asset = release.assets.iter().find(|a| a.name == format!("{}.sha256", asset.name));
|
||||||
|
|
||||||
|
let tmp_dir = std::env::temp_dir().join("sabia-update");
|
||||||
|
fs::create_dir_all(&tmp_dir)?;
|
||||||
|
let archive_path = tmp_dir.join(&asset.name);
|
||||||
|
curl_download(&asset.browser_download_url, &archive_path)?;
|
||||||
|
|
||||||
|
if let Some(checksum_asset) = checksum_asset {
|
||||||
|
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||||
|
let expected_hash = expected.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||||
|
let data = fs::read(&archive_path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&data);
|
||||||
|
let actual_hash = format!("{:x}", hasher.finalize());
|
||||||
|
if actual_hash != expected_hash {
|
||||||
|
return Err(SabiaError::CommandFailed {
|
||||||
|
cmd: "update".into(),
|
||||||
|
detail: "checksum SHA-256 não confere — atualização descartada".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrai o tar.gz e substitui o binário atual atomicamente.
|
||||||
|
let extract_dir = tmp_dir.join("extracted");
|
||||||
|
let _ = fs::remove_dir_all(&extract_dir);
|
||||||
|
fs::create_dir_all(&extract_dir)?;
|
||||||
|
let status = Command::new("tar").args(["xzf"]).arg(&archive_path).arg("-C").arg(&extract_dir).status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(SabiaError::CommandFailed { cmd: "tar xzf".into(), detail: "extração falhou".into() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_binary = find_binary(&extract_dir)?;
|
||||||
|
let backup = current_exe.with_extension("bak");
|
||||||
|
let _ = fs::remove_file(&backup);
|
||||||
|
fs::rename(current_exe, &backup)?;
|
||||||
|
fs::copy(&new_binary, current_exe)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(current_exe)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(current_exe, perms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(release.tag_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name == "sabia" || name == "sabia.exe" {
|
||||||
|
return Ok(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SabiaError::CommandFailed { cmd: "update".into(), detail: "binário não encontrado no pacote baixado".into() })
|
||||||
|
}
|
||||||
Reference in new issue
Block a user