diff --git a/.brain/BRAIN.md b/.brain/BRAIN.md new file mode 100644 index 0000000..d726d2b --- /dev/null +++ b/.brain/BRAIN.md @@ -0,0 +1,91 @@ +# `.brain/` — agent workflow rules + +This repo has a per-project second brain at `.brain/`. Hooks in +`.claude/settings.json` drive it automatically. Your job during a session: +keep the brain useful for the *next* session. + +## At session start (already automatic) + +The `SessionStart` hook runs `.brain/scripts/assemble-context.sh`, which +generates `.brain/_CONTEXT.md` and injects it into context. It contains: +- Pointers to CKIS (`_MEMORY.md`, `_overview.md`, architecture spec) +- Last 3 session summaries +- Open decisions and bugs +- Top of `GRAPH_REPORT.md` (if Graphify has run) + +You don't need to read `_CONTEXT.md` again — it's already in context. + +## During the session — what's auto-captured + +The `PostToolUse` hook runs `.brain/scripts/log-tool-event.sh` after every +Bash call and silently appends one line to `.brain/sessions/_active.md` +**only** for these objectively important events: + +- `npm run build` / `npm test` / `npm run lint` → success or failure (with last + ~8 lines of output on failure, so the error is preserved next session) +- `git commit` → SHA + message + diffstat + +The `UserPromptSubmit` hook runs `.brain/scripts/log-compact.sh` on every user +prompt but only acts on `/compact` commands — everything else is ignored. When +detected, it writes a timestamp breadcrumb to `.brain/.compact-triggers`. + +Everything else (file edits, reads, other shell commands, regular prompts) is +**not logged** — that would be noise. The Stop hook merges `_active.md` into +the final session log under `## Iterations`. This means: every build, every +test, every commit you make in a session is permanently in the brain — +searchable, visible to the next session, and impossible to lose to a `/clear`. + +When `/compact` runs, the Stop hook extracts the full summary from the session +JSONL transcript and writes it to `.brain/sessions/compacts/-compact.md`. +The session file gets a `## Compactions` section with a pointer and a 200-char +excerpt — the full summary is never auto-inlined to keep `_CONTEXT.md` lean. + +## During the session + +When the work warrants it, write to: + +- **`.brain/decisions/YYYY-MM-DD-.md`** — for any decision Eduardo + makes about Korvex Web (architecture, dependency, deploy, scope). Use the + CKIS decision-log format. See `.brain/decisions/README.md`. +- **`.brain/bugs/YYYY-MM-DD-.md`** — when a bug is found *and* fixed, + capture the lesson (root cause, why it happened, how to prevent it). The + patch lives in the commit; this file is for the *why*. + +Important decisions also get a one-line cross-post to +`~/Documents/Second Brain/00-inbox/_MEMORY.md` Open Decisions. + +## Before ending the session + +The Stop hook fills in the objective metadata automatically: +- Iterations (every build/test/lint/commit, with timestamps) +- Commits made, files changed, duration, branch state + +Your only job is the **narrative `## Summary`** — and only when it adds +value. If the session was a routine commit-and-iterate cycle, the iteration +log already tells the story. Fill in the Summary when: + +- A non-obvious decision was made mid-session +- A bug had a *why* worth remembering (root cause, not patch) +- Something is unfinished and the next session needs to know + +Format: 2–4 bullets max. What was done · What was decided · What's next. +Skip it if the iteration log + commits speak for themselves. + +## CKIS bridge — when to escalate + +| Situation | Goes to | +| --- | --- | +| Routine code change, bug fix, refactor | Just commit. No brain entry needed. | +| Decision about *this* project | `.brain/decisions/` | +| Bug worth a postmortem | `.brain/bugs/` | +| Strategic / cross-project / personal | CKIS `_MEMORY.md` + `02-projects/korvex/_overview.md` | +| Pattern reusable across projects | CKIS `03-knowledge/permanent-notes/` | + +When in doubt: project decision → `.brain/`. Strategic → CKIS. + +## Hard rules + +- Never modify `.brain/_CONTEXT.md` by hand — it's regenerated each session. +- Never delete files in `.brain/decisions/` or `.brain/bugs/` — supersede instead. +- `.brain/sessions/` is gitignored (personal); `decisions/` and `bugs/` are committed. +- Graphify rebuilds `.brain/graph/` on every commit (post-commit hook). Don't edit it. diff --git a/.brain/OPUS_BOOTSTRAP.md b/.brain/OPUS_BOOTSTRAP.md new file mode 100644 index 0000000..8839586 --- /dev/null +++ b/.brain/OPUS_BOOTSTRAP.md @@ -0,0 +1,331 @@ +--- +type: opus-bootstrap +project: sapientia-cli +created: 2026-06-03 +status: active +--- + +# OPUS ORCHESTRATOR BOOTSTRAP — sapientia-cli + +> Paste this as your first message after `claude --resume ` in the sapientia-cli tab. +> Model: `claude-opus-4-8` (set via `/model claude-opus-4-8` after resume). + +--- + +## YOUR ROLE + +You are the **Opus 4.8 orchestrator** for sapientia-cli. Your mission: take this monorepo from pre-release **v0.1.0** (architecture only) to a working, published **v1.0.0** — a CLI that searches and downloads books from open archives and shadow libraries. + +Vision: "Democratizar el acceso al conocimiento a toda la humanidad." This is Eduardo's most personal project — building a tool for everyone what he's done manually since age 13. + +v1.0.0 target: **open source adapters working E2E** (Project Gutenberg + Standard Ebooks), catalog indexing, download, local library browse. Shadow library adapters (Z-Library, LibGen) come after. + +--- + +## STEP 1 — LOAD CONTEXT FIRST + +```bash +cat .brain/_CONTEXT.md +cat graphify-out/GRAPH_REPORT.md +cat ~/Documents/Second\ Brain/02-projects/sapientia-cli/_overview.md +cat package.json +find packages/ -name "*.ts" -not -path "*/dist/*" -not -path "*/node_modules/*" | sort +``` + +Key facts (ground truth): +- **Current version:** v0.1.0 +- **Architecture:** pnpm monorepo with 4 packages: + - `packages/core` — schemas, config, adapter interface, normalize, rank, errors + - `packages/cli` — Commander entry point, execute.ts, runtime.ts, output.ts + - `packages/sources-open` — `GutenbergAdapter`, `StandardEbooksAdapter` (scaffolded) + - `packages/sources-shadow` — stub only (Z-Library, LibGen stubs) +- **Core god nodes:** `Catalog` (11), `resolvePaths()` (7), `createRuntime()` (7), `StandardEbooksAdapter` (7), `GutenbergAdapter` (7), `configFilePath()` (6), `runCommand()` (6), `CommandRegistry` (5), `adapterContext()` (5), `emitSuccess()` (5) +- **Key architecture:** `createRuntime()` wires adapters → `CommandRegistry` routes commands → `runCommand()` executes → `emitSuccess()`/`emitError()` output +- **License:** AGPL-3.0 + Dual Commercial (FINAL) +- **Tests:** 3 test files (rank.test.ts, paths.test.ts, normalize.test.ts) — core logic tested, adapters not yet + +--- + +## STEP 2 — ULTRAPLAN TARGET (v1.0.0) + +### A. Gutenberg Adapter — Full Implementation (GutenbergAdapter) + +`GutenbergAdapter` is scaffolded — implement fully: +- **Search:** Gutenberg catalog API: `https://gutenberg.org/ebooks/search/?query=TITLE&format=json` + - Parse results into `BookResult[]` schema (title, author, year, formats[], id, source) + - Support: `sapientia search "The Dispossessed" --source gutenberg` +- **Download:** `https://www.gutenberg.org/ebooks/{id}.epub.noimages` (EPUB preferred) or `.pdf` + - Stream download with progress bar + - Write to configured download directory (`~/Documents/Digital Library/` or configured path) + - Add to local catalog on download completion +- **Format preference:** EPUB > PDF > plain text (use `rank.ts` score) +- **Rate limiting:** Gutenberg has a 1 req/sec soft limit — respect it with a delay + +### B. Standard Ebooks Adapter — Full Implementation (StandardEbooksAdapter) + +`StandardEbooksAdapter` is scaffolded — implement fully: +- **Search:** Standard Ebooks Atom feed: `https://standardebooks.org/feeds/opds` + search endpoint + - OPDS protocol (Atom XML) — parse with a lightweight XML parser (e.g., `fast-xml-parser`) + - Map to `BookResult[]` schema +- **Download:** EPUB URL from OPDS entry (Standard Ebooks EPUBs are high quality — always prefer) + - Stream download with progress + - Add to catalog +- **Quality bonus:** Standard Ebooks gets a quality multiplier in `rank.ts` — their EPUBs are the best available + +### C. Local Catalog (Catalog god node — SQLite) + +`Catalog` (11 edges) is the core abstraction — implement fully: +- SQLite database at `~/.config/sapientia/catalog.db` +- Schema: `books(id, title, author, year, format, source, path, added_at, isbn?, language?)` +- `catalog.add(book)` — insert on download +- `catalog.search(query)` — FTS5 full-text search over title + author +- `catalog.list()` — list all books with filters (author, format, source, year range) +- `sapientia library` — browse local catalog with pagination (table display) +- `sapientia add ` — add an existing local file to catalog (metadata from filename + optional manual input) +- Import: `sapientia library --import ~/Documents/Digital\ Library/` — scan and index Eduardo's existing 1,201 files + +### D. Download Command + Progress + +`sapientia download `: +- Accept result ID from `sapientia search` output +- Show download progress (bytes, %, speed, ETA) +- Hash verification on completion (SHA-256 match against source-provided hash when available) +- Add to catalog automatically +- Open with system reader on completion: `--open` flag (optional) + +### E. Search UX — Unified Results + +`sapientia search "The Dispossessed Ursula"`: +- Query all configured sources in parallel (Promise.all) +- Rank results via `rank.ts` (source reliability + format quality + metadata completeness) +- Display unified ranked table: `| # | Title | Author | Year | Format | Source | Quality |` +- `--source gutenberg` / `--source standard-ebooks` for single-source queries +- `--format epub` to filter by format +- `sapientia search --json "query"` → JSON output for scripting + +### F. Sources Command +`sapientia sources`: +- List all configured sources with: name, type (open/shadow), status (reachable/unreachable), last checked +- `sapientia sources --check` — test connectivity to all sources + +### G. Config + Doctor +`sapientia config`: +- Download directory (`~/Documents/Digital Library/` as default) +- Format preference (epub/pdf/mobi) +- Language preference (es/en/any) +- Shadow library toggle (disabled by default) + +`sapientia doctor`: +- Download directory exists and is writable +- SQLite catalog accessible +- Network connectivity (ping gutenberg.org, standardebooks.org) +- All configured shadow sources reachable (if enabled) + +### H. Shadow Library Stubs → v1.1.0 + +`packages/sources-shadow` (Z-Library, LibGen) — keep stubs, mark as `experimental` in `sapientia sources`: +- Gate behind `--experimental` flag +- Add `SHADOW_SOURCES.md` disclaimer in the package +- These ship in v1.1.0 when access patterns are confirmed + +### I. `sapientia library --import` — Eduardo's 1,201 Books + +This is a first-run feature: +- Scan directory tree for supported formats (EPUB, PDF, MOBI, DJVU, AZW3, plain text) +- Extract metadata from filename (pattern: `Author - Title.epub`, `Title [Year].pdf`, etc.) +- Fuzzy-match against Gutenberg/Standard Ebooks for metadata enrichment (optional, `--enrich`) +- Map to Eduardo's category taxonomy (see CKIS overview — 11 top-level categories) +- Progress bar for large imports (1,201 files) + +--- + +## STEP 3 — SPRINT ATOMIZATION RULES + +**Monorepo execution order:** +1. `packages/core` changes first (schemas, adapters interface, catalog schema) +2. `packages/sources-open` adapter implementations +3. `packages/cli` command wiring +4. Integration tests last + +**Rules:** +1. **pnpm workspaces:** use `pnpm --filter @sapientia/core test` not `npm test` — know which package you're in +2. **Never import between packages except through their `index.ts`** — no deep imports +3. **SQLite catalog schema is immutable once shipped** — add columns carefully, never rename +4. **`createRuntime()` is the composition root** — adapter wiring changes go here +5. **`emitSuccess()` / `emitError()` are the output layer** — never bypass them +6. **Rate limiting tasks are separate from adapter implementation tasks** + +Example: +``` +TASK-02 [CORE]: Implement Catalog SQLite schema + CRUD (packages/core) + Files: packages/core/src/catalog/catalog.ts (new), packages/core/src/catalog/schema.sql (new), + packages/core/src/catalog/catalog.test.ts + Done: catalog.add(), catalog.search(), catalog.list() pass unit tests; + database file created at configured path on first call + Depends: TASK-01 (BookResult schema finalized) +``` + +--- + +## STEP 4 — WORKER ORCHESTRATION PROTOCOL + +**Worker briefing template:** +``` +You are a Sonnet execution agent for sapientia-cli — a pnpm monorepo ebook library tool. + +MONOREPO FACTS: +- 4 packages: core, cli, sources-open, sources-shadow +- Always use `pnpm --filter ` to scope commands +- Never deep-import across packages — only import from package index.ts +- createRuntime() in packages/cli/src/runtime.ts is the composition root — adapter wiring happens there +- emitSuccess() / emitError() in packages/cli/src/render/output.ts — all output through these +- Catalog (11 edges) is in packages/core — SQLite via better-sqlite3 (synchronous) +- EPUB is always the preferred format — rank.ts already encodes this + +TASK: [exact task spec with package scope] +CONTEXT: [relevant god nodes] +CONSTRAINTS: +- Read before edit +- Run pnpm build from root to verify all packages build together +- pnpm test from root to verify all packages pass +- Do NOT add shadow library adapters unless the task explicitly scopes sources-shadow +- Do NOT commit + +Report back: files changed + exact pnpm commands to verify done criterion. +``` + +--- + +## STEP 5 — AUDIT PROTOCOL + +```bash +# From monorepo root +pnpm build +pnpm test +npx tsc -p tsconfig.json --noEmit 2>/dev/null || pnpm --filter '*' exec tsc --noEmit +npm audit --audit-level=moderate +``` + +**Audit checklist:** +- [ ] `sapientia doctor` passes: SQLite writable, network reachable, download dir exists +- [ ] Gutenberg search returns results for "The Dispossessed" (live network test or recorded fixture) +- [ ] Standard Ebooks OPDS parsing handles malformed XML gracefully (try/catch + emitError) +- [ ] Catalog FTS5 search handles: empty query, special chars, accented chars (Spanish/French titles) +- [ ] Download: incomplete file cleaned up on abort/error (no partial EPUBs in library) +- [ ] `--json` flag valid JSON on: search, library, sources, doctor +- [ ] `sapientia library --import` handles: unrecognized formats (skip), permission errors (skip + warn), duplicate files (skip + log) +- [ ] Rate limiting: Gutenberg adapter has ≥1s delay between requests +- [ ] No shadow library code reachable without `--experimental` flag + +--- + +## STEP 6 — COMPLETION CRITERIA + COMMIT + +- [ ] `pnpm build` → all 4 packages build successfully +- [ ] `pnpm test` → all tests pass (rank, paths, normalize + new adapter + catalog tests) +- [ ] `npx tsc --noEmit` → 0 type errors across all packages +- [ ] `npm audit` → 0 moderate/high +- [ ] `sapientia doctor` → all checks pass +- [ ] `sapientia search "frankenstein"` → returns Gutenberg + Standard Ebooks results +- [ ] `sapientia download ` → EPUB lands in configured download directory + catalog entry created +- [ ] `npm publish --dry-run` (from packages/cli) → exits cleanly + +```bash +git add [specific files] +git commit -m "feat: sapientia-cli v1.0.0 — Gutenberg + Standard Ebooks, local catalog, download pipeline" +git tag v1.0.0 +git push origin main --tags +``` + +--- + +## STEP 7 — PERSONAL GUIDE (after v1.0.0 is pushed) + +This step runs **only after Step 6 is fully complete** — v1.0.0 tagged, pushed, and npm published. + +Write Eduardo's personal reference guide to: +`~/Documents/Second Brain/02-projects/sapientia-cli/personal-guide.md` + +**What this IS:** Eduardo's personal manual — how he uses Sapientia to find and download books, manage his 1,201-book digital library, and continue the habit he's had since age 13. Written in plain language with his real library structure and real search examples. +**What this is NOT:** developer docs or a README for strangers. + +### Guide structure: + +``` +--- +type: guide +subtype: personal-tool-reference +project: sapientia-cli +created: +modified: +tags: [guide, cli, personal-reference, sapientia, libros, biblioteca, ebooks, conocimiento] +related: + - "[[02-projects/sapientia-cli/_overview]]" +--- + +# sapientia — Mi Guía Personal +``` + +**Section 1 — ¿Qué es esto y por qué existe?** +Plain-language origin story: Eduardo ha descargado libros de internet desde los 13 años. Tiene 1,201 archivos en `~/Documents/Digital Library/`. Sapientia automatiza ese pipeline manual: antes buscaba en Gutenberg/LibGen/etc. manualmente, descargaba, movía al folder correcto. Ahora es un comando. La visión: "democratizar el acceso al conocimiento a toda la humanidad." + +**Section 2 — Mi biblioteca digital actual** +La estructura real de `~/Documents/Digital Library/`: las 11 categorías principales (Ciencias, Filosofía, Historia, Idiomas, Literatura, No-Ficción, Otros, Pensamiento Político, Psicología, Religión, Tecnología). Cómo Sapientia indexa esta estructura. Cómo ver el catálogo completo: `sapientia library`. + +**Section 3 — Todos los comandos con ejemplos reales** +Every command with Eduardo's actual usage: +- `sapientia search "The Dispossessed Ursula Le Guin"` — busca en todas las fuentes en paralelo. Cómo leer los resultados (Quality score, formato, fuente). +- `sapientia search --source standard-ebooks "Nietzsche"` — busca en una fuente específica +- `sapientia search --format epub "Dostoevsky"` — filtrar por formato +- `sapientia download ` — descargar el resultado seleccionado. Qué pasa: descarga, verifica hash, agrega al catálogo. +- `sapientia library` — navegar el catálogo local. Cómo filtrar (`--author`, `--format`, `--category`) +- `sapientia library --import ~/Documents/Digital\ Library/` — el primer setup: indexar los 1,201 libros existentes +- `sapientia add ` — agregar un ebook que ya tengo al catálogo manualmente +- `sapientia sources` — ver qué fuentes están configuradas y su estado (accesibles/no) +- `sapientia config` — el directorio de descarga, formato preferido, idioma +- `sapientia doctor` — verificar que todo funciona + +**Section 4 — Mis fuentes de libros y cómo las uso** +Explicado en simple, sin jerga técnica: +- **Project Gutenberg** — dominio público, clásicos de la literatura mundial. Gratis, confiable, siempre disponible. +- **Standard Ebooks** — lo mismo que Gutenberg pero formateados mucho mejor. Para los clásicos que Eduardo lee en e-reader, siempre prefiere Standard Ebooks. +- **Open Library (Internet Archive)** — millones de títulos, préstamo digital. Útil para libros más modernos que ya no tienen derechos de autor. +- **Fuentes shadow** (cuando las active): para acceso a libros que no están en dominio público. El `--experimental` flag las habilita. + +**Section 5 — El primer setup: importar los 1,201 libros** +Paso a paso de cómo Eduardo usa `sapientia library --import` por primera vez: +1. `sapientia library --import ~/Documents/Digital\ Library/` → escanea y crea el catálogo SQLite +2. Cuánto tiempo tarda (estimado), qué hace con cada archivo, qué hace si no puede leer el metadata +3. Después del import: `sapientia library` muestra todos los libros navegables +4. `sapientia library --import --enrich` — el flag opcional que busca metadata faltante en Gutenberg/Standard Ebooks + +**Section 6 — Integración con CKIS (el futuro)** +Cómo Sapientia conecta con el flujo de conocimiento más amplio: +- Sapientia es la capa de ADQUISICIÓN. Procesar los libros (extraer insights, crear notas permanentes) es el siguiente paso — la "CKIS Book Processing Pipeline" que Eduardo planea para después de v1.0.0. +- Por ahora: los libros están en el catálogo → Eduardo los busca con `sapientia search` → los lee → procesa insights manualmente en CKIS. +- La conexión futura: `sapientia export --to-ckis` generaría una nota en Obsidian lista para procesar. + +**Section 7 — Lo que aprendí** +- Por qué EPUB > PDF para leer: texto reflow, fuente ajustable, sin scroll horizontal +- Standard Ebooks tiene una calidad de formateo que Gutenberg no alcanza — cuando existe en Standard Ebooks, descarga de ahí +- El hash verification al descargar: cómo sé que el archivo no está corrupto +- Por qué el catálogo es SQLite y no JSON: FTS5 para búsqueda en texto completo de 1,200 libros + +**Section 8 — Referencia rápida** +Cheat sheet de todos los comandos. Incluir la búsqueda favorita de Eduardo (filosofía en español, libros técnicos en inglés). + +### After writing the guide: +1. Write the file to the exact CKIS path above +2. Add `[[personal-guide]]` wikilink to `02-projects/sapientia-cli/_overview.md` +3. Update `_overview.md`'s `modified:` to today + +--- + +## CKIS INTEGRATION REMINDER + +- Eduardo's existing 1,201-book library is in `~/Documents/Digital Library/` — the `--import` command is high-value +- `renderNote()` equivalent may be needed later for CKIS book processing pipeline (out of scope for v1.0.0) +- `.brain/decisions/` — ADR for: OPDS vs custom search API, SQLite vs JSON catalog, shadow library gating +- The CKIS overview describes Eduardo's full category taxonomy — use it as the default catalog taxonomy + +Begin by executing Step 1 (Load Context) now. diff --git a/.brain/README.md b/.brain/README.md new file mode 100644 index 0000000..28a887b --- /dev/null +++ b/.brain/README.md @@ -0,0 +1,52 @@ +--- +type: brain-readme +project: korvex-web +created: 2026-05-03 +modified: 2026-05-03 +tags: [brain, ckis-bridge] +--- + +# `.brain/` — Per-project second brain + +Project-level memory layer for Claude Code sessions. Bridges this repo to the +global CKIS vault at `~/Documents/Second Brain/`. + +## What lives here + +| Path | Purpose | Committed? | +| ---------------- | ------------------------------------------------ | --------------- | +| `_CONTEXT.md` | Auto-assembled session start context | No (regenerable) | +| `decisions/` | Decision logs (CKIS format — see skill card) | **Yes** | +| `bugs/` | Bug → fix narratives | **Yes** | +| `sessions/` | Per-session summaries written by Stop hook | No (personal) | +| `graph/` | Graphify output (`graph.json`, `GRAPH_REPORT.md`, vault/) | No (regenerable) | +| `scripts/` | Hooks + assembler scripts | **Yes** | +| `config.sh` | Per-project config (CKIS paths, project slug) | **Yes** | + +## How it works + +1. **SessionStart hook** runs `scripts/assemble-context.sh`: + - Concatenates latest 3 session summaries + open decisions + active bugs + - Pulls god-nodes section from `graph/GRAPH_REPORT.md` if Graphify has run + - Adds pointers to CKIS `_MEMORY.md` and project `_overview.md` + - Writes the result to `_CONTEXT.md` and emits it as session context. + +2. **Stop hook** runs `scripts/log-session.sh`: + - Records git diff, commits, branch, duration vs. session start. + - Creates `sessions/YYYY-MM-DD-HHMM-session.md` with a "Summary" section to fill in. + +3. **Graphify** rebuilds `graph/` automatically on every git commit + (via `graphify hook install`) and is symlinked from the CKIS vault under + `02-projects/korvex/graph/` so the graph view shows up alongside the + curated overview. + +## Bridge to CKIS + +- Strategic / cross-project state → `~/Documents/Second Brain/00-inbox/_MEMORY.md` +- Project overview (curated) → `~/Documents/Second Brain/02-projects/korvex/_overview.md` +- Architecture spec → `~/Documents/Second Brain/03-knowledge/permanent-notes/per-project-second-brain.md` + +## See also + +- `00-system/ckis/06-decision-execution-and-review-protocol.md` — decision-log format +- `.claude/skills/ckis-decision-log/` — skill that writes here diff --git a/.brain/bugs/README.md b/.brain/bugs/README.md new file mode 100644 index 0000000..d723e9b --- /dev/null +++ b/.brain/bugs/README.md @@ -0,0 +1,30 @@ +--- +type: index +created: 2026-05-03 +tags: [bugs, brain] +--- + +# Bugs + +Bug → fix narratives for `korvex-web`. Capture the *why*, not just the patch +(the patch is in the commit; this folder is for the lesson). + +## File naming + +`YYYY-MM-DD-.md`. + +## Required frontmatter + +```yaml +--- +type: bug +project: korvex +status: open | fixed | wontfix +date: YYYY-MM-DD +severity: low | medium | high +related-commits: [, ...] +tags: [bug, korvex] +--- +``` + +Bugs with `status: open` are surfaced in `_CONTEXT.md` at session start. diff --git a/.brain/config.sh b/.brain/config.sh new file mode 100644 index 0000000..a5e13be --- /dev/null +++ b/.brain/config.sh @@ -0,0 +1,29 @@ +# Per-project .brain/ configuration +# Sourced by every script in .brain/scripts/. + +# Project identity +PROJECT_SLUG="sapientia-cli" +PROJECT_NAME="Sapientia CLI" + +# CKIS vault paths (absolute — adjust per machine if vault relocates) +CKIS_VAULT="$HOME/Documents/Second Brain" +CKIS_MEMORY="$CKIS_VAULT/00-inbox/_MEMORY.md" +CKIS_PROJECT_OVERVIEW="$CKIS_VAULT/02-projects/$PROJECT_SLUG/_overview.md" +CKIS_ARCHITECTURE_NOTE="$CKIS_VAULT/03-knowledge/permanent-notes/per-project-second-brain.md" + +# Brain paths (relative to repo root) +BRAIN_DIR=".brain" +SESSIONS_DIR="$BRAIN_DIR/sessions" +DECISIONS_DIR="$BRAIN_DIR/decisions" +BUGS_DIR="$BRAIN_DIR/bugs" +GRAPH_DIR="$BRAIN_DIR/graph" +CONTEXT_FILE="$BRAIN_DIR/_CONTEXT.md" +SESSION_STATE="$BRAIN_DIR/.session-state" + +# How many recent session summaries to inline in _CONTEXT.md +RECENT_SESSIONS_LIMIT=3 + +# Dev Brain vault (Obsidian — code graph + wiki layer, separate from CKIS) +DEV_BRAIN_VAULT="$HOME/Documents/Dev Brain" +# Rebuild --obsidian vault every N commits (expensive: one .md per node) +OBSIDIAN_GRAPH_CADENCE=10 diff --git a/.brain/decisions/README.md b/.brain/decisions/README.md new file mode 100644 index 0000000..bc9d5c1 --- /dev/null +++ b/.brain/decisions/README.md @@ -0,0 +1,32 @@ +--- +type: index +created: 2026-05-03 +tags: [decisions, brain] +--- + +# Decisions + +Decision logs for `korvex-web`. CKIS format — see +`~/Documents/Second Brain/00-system/ckis/06-decision-execution-and-review-protocol.md`. + +## File naming + +`YYYY-MM-DD-.md` — one decision per file. + +## Required frontmatter + +```yaml +--- +type: decision +project: korvex +status: proposed | adopted | superseded +date: YYYY-MM-DD +reversal-cost: low | medium | high +review-by: YYYY-MM-DD or empty +tags: [decision, korvex] +--- +``` + +Decisions with `status: proposed` are surfaced in `_CONTEXT.md` at session start +and in CKIS `_MEMORY.md` Open Decisions. Important decisions are cross-posted +to `_MEMORY.md` as one-line entries pointing back here. diff --git a/.brain/graph/.gitkeep b/.brain/graph/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/scripts/assemble-context.sh b/.brain/scripts/assemble-context.sh new file mode 100755 index 0000000..d524902 --- /dev/null +++ b/.brain/scripts/assemble-context.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# assemble-context.sh — SessionStart hook +# +# Builds .brain/_CONTEXT.md from: +# - latest N session summaries +# - open decisions (status: proposed) +# - open bugs (status: open) +# - god-nodes section from .brain/graph/GRAPH_REPORT.md (if Graphify ran) +# - pointers to CKIS vault +# +# Records session start state to .brain/.session-state for the Stop hook. +# Emits the assembled context to stdout (Claude Code injects it into the session). + +set -euo pipefail + +# Resolve repo root from this script's location. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/.brain/config.sh" + +NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +NOW_LOCAL="$(date +"%Y-%m-%d %H:%M %Z")" +HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "no-git")" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "no-git")" + +# Record session-start state for the Stop hook. +mkdir -p "$BRAIN_DIR" +cat > "$SESSION_STATE" </dev/null || true +fi + +# Rotate orphaned compact-triggers (from a session that never reached Stop). +COMPACT_TRIGGERS="$BRAIN_DIR/.compact-triggers" +if [ -f "$COMPACT_TRIGGERS" ]; then + mkdir -p "$SESSIONS_DIR" + mv "$COMPACT_TRIGGERS" "$SESSIONS_DIR/_orphaned-compacts-$(date +%Y-%m-%d-%H%M).log" 2>/dev/null || true +fi + +# Sync GRAPH_REPORT.md into the CKIS vault (catch-up in case post-commit hook missed it). +bash "$REPO_ROOT/.brain/scripts/sync-graph-to-vault.sh" >/dev/null 2>&1 || true + +# Build _CONTEXT.md. +{ + echo "---" + echo "type: project-context" + echo "project: $PROJECT_SLUG" + echo "generated: $NOW" + echo "branch: $BRANCH" + echo "head: $HEAD_SHA" + echo "tags: [context, brain, $PROJECT_SLUG]" + echo "---" + echo + echo "# $PROJECT_NAME — Session Context" + echo + echo "> Auto-generated by \`.brain/scripts/assemble-context.sh\` at session start." + echo "> Do not hand-edit. Update sources in \`.brain/decisions/\`, \`.brain/bugs/\`, or \`.brain/sessions/\`." + echo + echo "**Branch:** \`$BRANCH\` · **HEAD:** \`$HEAD_SHA\` · **Started:** $NOW_LOCAL" + echo + echo "━━━" + echo + echo "## CKIS pointers" + echo + echo "- Live business state → \`$CKIS_MEMORY\`" + echo "- Project overview (curated) → \`$CKIS_PROJECT_OVERVIEW\`" + echo "- Architecture spec → \`$CKIS_ARCHITECTURE_NOTE\`" + echo + echo "━━━" + echo + echo "## Recent sessions (last $RECENT_SESSIONS_LIMIT)" + echo + if compgen -G "$SESSIONS_DIR/*.md" > /dev/null; then + # Newest first, take N. + mapfile -t recent < <(ls -1t "$SESSIONS_DIR"/*.md 2>/dev/null | head -n "$RECENT_SESSIONS_LIMIT") + for f in "${recent[@]}"; do + echo "### $(basename "$f" .md)" + echo + cat "$f" + echo + echo "---" + echo + done + else + echo "_No prior sessions logged yet._" + echo + fi + + echo "━━━" + echo + echo "## Open decisions" + echo + if compgen -G "$DECISIONS_DIR/*.md" > /dev/null; then + found=0 + for f in "$DECISIONS_DIR"/*.md; do + [ "$(basename "$f")" = "README.md" ] && continue + # Match `status: proposed` in frontmatter. + if awk '/^---$/{c++} c==1 && /^status:[[:space:]]*proposed/{print; exit}' "$f" | grep -q proposed; then + title="$(awk '/^# /{sub(/^# /,""); print; exit}' "$f")" + [ -z "$title" ] && title="$(basename "$f" .md)" + echo "- [$title]($f)" + found=1 + fi + done + [ "$found" = "0" ] && echo "_No open decisions._" + else + echo "_No decisions logged yet._" + fi + echo + + echo "━━━" + echo + echo "## Open bugs" + echo + if compgen -G "$BUGS_DIR/*.md" > /dev/null; then + found=0 + for f in "$BUGS_DIR"/*.md; do + [ "$(basename "$f")" = "README.md" ] && continue + if awk '/^---$/{c++} c==1 && /^status:[[:space:]]*open/{print; exit}' "$f" | grep -q open; then + title="$(awk '/^# /{sub(/^# /,""); print; exit}' "$f")" + [ -z "$title" ] && title="$(basename "$f" .md)" + echo "- [$title]($f)" + found=1 + fi + done + [ "$found" = "0" ] && echo "_No open bugs._" + else + echo "_No bugs logged yet._" + fi + echo + + echo "━━━" + echo + echo "## Code graph (Graphify)" + echo + REPORT="$GRAPH_DIR/GRAPH_REPORT.md" + if [ -f "$REPORT" ]; then + # Inline only the "God nodes" or "Surprising connections" section, capped. + awk ' + /^## (God nodes|Surprising connections|Suggested questions)/ { keep=1; print; next } + keep && /^## / { keep=0 } + keep { print } + ' "$REPORT" | head -n 80 + echo + echo "_Full graph report: \`$REPORT\`_" + else + echo "_Graphify has not run yet. Run: \`graphify .\` then \`graphify hook install\`._" + fi + echo +} > "$CONTEXT_FILE" + +# Emit to stdout so the SessionStart hook injects it as context. +cat "$CONTEXT_FILE" diff --git a/.brain/scripts/lib/compact-routing.sh b/.brain/scripts/lib/compact-routing.sh new file mode 100755 index 0000000..e07b671 --- /dev/null +++ b/.brain/scripts/lib/compact-routing.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# compact-routing.sh — shared helpers for routing compact summaries to Dev Brain. +# Source-only. No side effects on load. + +# route_compact_to_dev_brain +# Copies a compact .md to $DEV_BRAIN_VAULT/sessions/compacts//. +# Idempotent: skips if destination already exists with same content. +# Fail-safe: any error is swallowed; never affects caller exit status. +route_compact_to_dev_brain() { + local src="$1" project="$2" ts="$3" sid="${4:-unknown}" + local vault="${DEV_BRAIN_VAULT:-$HOME/Documents/Dev Brain}" + + [ -f "$src" ] || return 0 + [ -d "$vault" ] || return 0 + + local dest_dir="$vault/sessions/compacts/$project" + mkdir -p "$dest_dir" 2>/dev/null || return 0 + + local base + base="$(basename "$src")" + local dest="$dest_dir/$base" + + # Idempotency: skip if destination already exists with identical content. + if [ -f "$dest" ] && cmp -s "$src" "$dest" 2>/dev/null; then + return 0 + fi + + # Build dest content: source file + wikilinks footer (for Obsidian graph connectivity). + local tmp="$dest.tmp.$$" + { + cat "$src" + # Inject footer only if not already present. + if ! grep -q "\[\[wiki/$project\]\]" "$src" 2>/dev/null; then + echo "" + echo "---" + printf '[[wiki/%s]] · [[sessions/index]]\n' "$project" + fi + } > "$tmp" 2>/dev/null || return 0 + mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; } + + echo "[brain] Compact routed → $dest" >&2 + return 0 +} + +# route_all_session_compacts +# Reads the COMPACTS_TMP ledger (ts|path|excerpt) and routes each entry. +route_all_session_compacts() { + local tmp="$1" project="$2" sid="${3:-unknown}" + [ -f "$tmp" ] || return 0 + while IFS='|' read -r ts path excerpt; do + [ -n "$path" ] && route_compact_to_dev_brain "$path" "$project" "$ts" "$sid" + done < "$tmp" +} diff --git a/.brain/scripts/log-compact.sh b/.brain/scripts/log-compact.sh new file mode 100755 index 0000000..b0cdd7b --- /dev/null +++ b/.brain/scripts/log-compact.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# log-compact.sh — UserPromptSubmit hook +# +# Fires when the user submits "/compact" or "/compact ". +# At this moment, the NEW compact hasn't been generated yet — but any PRIOR +# compact from this session IS already in the transcript. We eagerly extract +# the most recent prior compact and route it to Dev Brain immediately, so +# long-running sessions don't accumulate un-mirrored compacts until session end. +# +# The Stop hook (log-session.sh) is the final catch-all — this is best-effort +# acceleration. Both use the same idempotent route_compact_to_dev_brain helper. +# +# Fail-safe: any error → silent no-op. UserPromptSubmit must not emit stdout. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +source "$REPO_ROOT/.brain/config.sh" 2>/dev/null || exit 0 + +PAYLOAD="" +[ ! -t 0 ] && PAYLOAD="$(cat)" +[ -z "$PAYLOAD" ] && exit 0 + +command -v jq >/dev/null 2>&1 || exit 0 + +PROMPT="$(echo "$PAYLOAD" | jq -r '.prompt // empty' 2>/dev/null || echo "")" +case "$PROMPT" in + "/compact"|"/compact "*) ;; + *) exit 0 ;; +esac + +TRANSCRIPT_PATH="$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")" +SESSION_ID="$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null || echo "")" +[ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ] || exit 0 + +mkdir -p "$SESSIONS_DIR/compacts" 2>/dev/null || exit 0 + +# Extract the most recent prior compact summary (if any). +LAST_COMPACT="$(jq -cs ' + def textify: + if type == "array" then + [ .[] | select(.type == "text") | .text ] | join("\n\n") + elif type == "string" then . + else "" end; + + [ .[] + | select(.isCompactSummary == true) + | {ts: (.timestamp // "unknown"), + content: (.message.content | textify)} + | select(.content != "") + ] | last // empty +' "$TRANSCRIPT_PATH" 2>/dev/null)" + +[ -z "$LAST_COMPACT" ] || [ "$LAST_COMPACT" = "null" ] && exit 0 + +TS="$(echo "$LAST_COMPACT" | jq -r '.ts')" +CONTENT="$(echo "$LAST_COMPACT" | jq -r '.content')" +SLUG="$(date -u -d "$TS" +"%Y-%m-%d-%H%M" 2>/dev/null || date -u +"%Y-%m-%d-%H%M")" +OUT_FILE="$SESSIONS_DIR/compacts/${SLUG}-compact.md" + +# Write compact file only if it doesn't already exist (idempotent). +if [ ! -f "$OUT_FILE" ]; then + { + echo "---" + echo "type: compact-summary" + echo "project: $PROJECT_SLUG" + echo "session-id: ${SESSION_ID:-unknown}" + echo "compacted-at: $TS" + echo "source: log-compact.sh (eager)" + echo "tags: [compact, $PROJECT_SLUG]" + echo "---" + echo + echo "# Compact Summary — $TS" + echo + echo "$CONTENT" + } > "$OUT_FILE" 2>/dev/null +fi + +# Route to Dev Brain. +if [ -f "$REPO_ROOT/.brain/scripts/lib/compact-routing.sh" ]; then + # shellcheck disable=SC1091 + source "$REPO_ROOT/.brain/scripts/lib/compact-routing.sh" + route_compact_to_dev_brain "$OUT_FILE" "$PROJECT_SLUG" "$TS" "${SESSION_ID:-unknown}" +fi 2>/dev/null || true + +# No stdout — UserPromptSubmit must not inject context. +exit 0 diff --git a/.brain/scripts/log-session.sh b/.brain/scripts/log-session.sh new file mode 100755 index 0000000..f08e701 --- /dev/null +++ b/.brain/scripts/log-session.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# log-session.sh — Stop hook +# +# Captures objective session metadata at end of session: +# - timestamp + duration +# - branch + git diff vs. session start +# - commits made during the session +# - /compact summaries extracted from JSONL transcript +# +# Writes .brain/sessions/YYYY-MM-DD-HHMM-session.md. +# Compact summaries go to .brain/sessions/compacts/ (separate files, pointer in session). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/.brain/config.sh" + +# Read stdin payload if present (Claude Code Stop hook sends JSON). +PAYLOAD="" +if [ ! -t 0 ]; then + PAYLOAD="$(cat)" +fi + +SESSION_ID="" +TRANSCRIPT_PATH="" +if [ -n "$PAYLOAD" ] && command -v jq >/dev/null 2>&1; then + SESSION_ID="$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null || true)" + TRANSCRIPT_PATH="$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null || true)" +fi + +NOW_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +NOW_LOCAL="$(date +"%Y-%m-%d %H:%M %Z")" +DATE_TAG="$(date +"%Y-%m-%d-%H%M")" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "no-git")" +HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "no-git")" + +# Load session-start state if recorded. +START_SHA="" +START_LOCAL="" +SESSION_START_UTC="" +DURATION="" +if [ -f "$SESSION_STATE" ]; then + # shellcheck disable=SC1090 + source "$SESSION_STATE" + START_SHA="${SESSION_START_SHA:-}" + START_LOCAL="${SESSION_START_LOCAL:-}" + SESSION_START_UTC="${SESSION_START_UTC:-}" + if [ -n "${SESSION_START_UTC:-}" ]; then + start_epoch="$(date -u -d "$SESSION_START_UTC" +%s 2>/dev/null || echo 0)" + end_epoch="$(date -u +%s)" + if [ "$start_epoch" -gt 0 ]; then + delta=$((end_epoch - start_epoch)) + DURATION="$((delta / 60)) min" + fi + fi +fi + +# Compute git activity during the session. +FILES_CHANGED="" +COMMITS_MADE="" +DIFFSTAT="" +if [ -n "$START_SHA" ] && [ "$START_SHA" != "$HEAD_SHA" ]; then + FILES_CHANGED="$(git diff --name-only "$START_SHA" HEAD 2>/dev/null || true)" + COMMITS_MADE="$(git log --oneline "$START_SHA..HEAD" 2>/dev/null || true)" + DIFFSTAT="$(git diff --stat "$START_SHA" HEAD 2>/dev/null | tail -n 1 || true)" +fi +WORKING_TREE="$(git status --short 2>/dev/null || true)" + +# Pull in iterations captured during the session by log-tool-event.sh. +ACTIVE_LOG="$SESSIONS_DIR/_active.md" +ITERATIONS="" +if [ -f "$ACTIVE_LOG" ]; then + ITERATIONS="$(grep -v '^" + echo + echo "## Iterations" + echo + echo "_Auto-captured by \`log-tool-event.sh\` during the session: builds, tests, lint, commits._" + echo + if [ -n "$ITERATIONS" ]; then + echo "$ITERATIONS" + else + echo "_No build/test/lint/commit events recorded._" + fi + echo + echo "## Compactions" + echo + if [ "${#COMPACT_LINES[@]}" -gt 0 ]; then + for line in "${COMPACT_LINES[@]}"; do + IFS='|' read -r ts file excerpt <<< "$line" + echo "- **$ts** → \`$file\`" + echo " > ${excerpt}…" + done + else + echo "_No /compact during this session._" + fi + echo + echo "## Commits made" + echo + if [ -n "$COMMITS_MADE" ]; then + echo '```' + echo "$COMMITS_MADE" + echo '```' + else + echo "_No commits made during this session._" + fi + echo + echo "## Files changed" + echo + if [ -n "$FILES_CHANGED" ]; then + echo '```' + echo "$FILES_CHANGED" + echo '```' + [ -n "$DIFFSTAT" ] && echo "**Diffstat:** $DIFFSTAT" + else + echo "_No tracked files changed via commits._" + fi + echo + echo "## Working tree at end" + echo + if [ -n "$WORKING_TREE" ]; then + echo '```' + echo "$WORKING_TREE" + echo '```' + else + echo "_Clean._" + fi + echo +} > "$OUT" + +# Cleanup transient session state. +rm -f "$SESSION_STATE" +rm -f "$ACTIVE_LOG" +rm -f "$COMPACTS_TMP" + +echo "[brain] Session logged → $OUT" >&2 + +# ── Dev Brain session index ─────────────────────────────────────────────────── +# Append a pointer to Dev Brain so any agent can query session history. +# Fails silently — Dev Brain indexing must never break the primary stop hook. +{ + DEV_BRAIN_VAULT="${DEV_BRAIN_VAULT:-$HOME/Documents/Dev Brain}" + if [ -d "$DEV_BRAIN_VAULT" ]; then + IDX="$DEV_BRAIN_VAULT/sessions/index.md" + # Hybrid summary: compact → commit → last assistant turn → diffstat → no-summary. + # All tiers routed through _sb_sanitize to keep the pipe-delimited index format intact. + _sb_sanitize() { + tr '\n\r\t|' ' ' | tr -s ' ' | sed 's/^ *//;s/ *$//' | head -c 120 + } + + SUMMARY_LINE="" + + # Tier 1: compact excerpt (LLM-distilled, highest signal when present) + if [ "${#COMPACT_LINES[@]}" -gt 0 ]; then + SUMMARY_LINE="$(printf '%s' "${COMPACT_LINES[0]}" | awk -F'|' '{for(i=3;i<=NF;i++)printf "%s%s",$i,(i/dev/null | _sb_sanitize)" + fi + + # Tier 4: diffstat as structural fallback + if [ -z "$SUMMARY_LINE" ] && [ -n "$DIFFSTAT" ]; then + SUMMARY_LINE="$(printf '%s' "$DIFFSTAT" | _sb_sanitize)" + fi + + SUMMARY_LINE="${SUMMARY_LINE:-no-summary}" + # Append one-liner to global index (pipe-separated for grep/awk) + echo "${NOW_UTC} | ${PROJECT_SLUG} | ${DURATION:-unknown} | ${HEAD_SHA} | ${SUMMARY_LINE} | ${OUT}" >> "$IDX" + echo "[brain] Session indexed → $IDX" >&2 + fi +} 2>/dev/null || true diff --git a/.brain/scripts/log-tool-event.sh b/.brain/scripts/log-tool-event.sh new file mode 100755 index 0000000..5d4aa29 --- /dev/null +++ b/.brain/scripts/log-tool-event.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# log-tool-event.sh — PostToolUse hook +# +# Captures objectively important Bash command outcomes (builds, tests, lint, +# commits) and appends them to the active session log: +# .brain/sessions/_active.md +# +# All other tool calls (Edit, Read, Write, etc.) and unrelated Bash commands +# are ignored. The Stop hook merges _active.md into the final session log. +# +# Fail-safe: any parse error or missing field results in a silent no-op so +# the user's session is never disrupted. + +set -uo pipefail # no -e: never break the user's session + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/.brain/config.sh" 2>/dev/null || exit 0 + +ACTIVE_LOG="$SESSIONS_DIR/_active.md" + +# Read JSON payload from stdin (Claude Code provides tool_name, tool_input, tool_response). +PAYLOAD="" +if [ ! -t 0 ]; then + PAYLOAD="$(cat)" +fi +[ -z "$PAYLOAD" ] && exit 0 + +# jq is required to parse the payload — degrade gracefully if missing. +command -v jq >/dev/null 2>&1 || exit 0 + +TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null || echo "") +[ "$TOOL_NAME" != "Bash" ] && exit 0 + +CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "") +[ -z "$CMD" ] && exit 0 + +# Filter: only log build / test / lint / commit. Everything else is noise. +EVENT_KIND="" +case "$CMD" in + *"npm run build"*|*"yarn build"*|*"pnpm build"*) EVENT_KIND="build" ;; + *"npm run test"*|*"npm test"*|*"yarn test"*|*"pnpm test"*) EVENT_KIND="test" ;; + *"npm run lint"*|*"yarn lint"*|*"pnpm lint"*) EVENT_KIND="lint" ;; + *"git commit"*) EVENT_KIND="commit" ;; + *) exit 0 ;; +esac + +# Extract result fields (defensive — schema may vary). +EXIT_CODE=$(echo "$PAYLOAD" | jq -r ' + .tool_response.exit_code // + .tool_response.exitCode // + .tool_response.returncode // + 0 +' 2>/dev/null || echo "0") + +OUTPUT=$(echo "$PAYLOAD" | jq -r ' + .tool_response.output // + .tool_response.stdout // + .tool_response.content // + empty +' 2>/dev/null || echo "") + +# Init active log if missing. +mkdir -p "$SESSIONS_DIR" +if [ ! -f "$ACTIVE_LOG" ]; then + { + echo "" + echo + } > "$ACTIVE_LOG" +fi + +NOW="$(date +"%H:%M")" +STATUS="✅" +[ "$EXIT_CODE" != "0" ] && STATUS="❌" + +case "$EVENT_KIND" in + commit) + if [ "$EXIT_CODE" = "0" ]; then + SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "?")" + MSG="$(git log -1 --pretty=%s 2>/dev/null | head -c 100 || echo "")" + FILES="$(git show --stat --format= HEAD 2>/dev/null | tail -n 1 | tr -s ' ' || echo "")" + { + echo "- [${NOW}] **commit** \`${SHA}\` · ${MSG}" + [ -n "$FILES" ] && echo " - ${FILES}" + } >> "$ACTIVE_LOG" + else + echo "- [${NOW}] ❌ **commit failed** (exit ${EXIT_CODE})" >> "$ACTIVE_LOG" + fi + ;; + build|test|lint) + # Truncate command for readability. + CMD_SHORT="$(echo "$CMD" | head -c 80 | tr '\n' ' ')" + if [ "$EXIT_CODE" = "0" ]; then + echo "- [${NOW}] ${STATUS} **${EVENT_KIND}** \`${CMD_SHORT}\`" >> "$ACTIVE_LOG" + else + # Capture last 8 lines of output on failure — that's where the error usually is. + TAIL="$(echo "$OUTPUT" | tail -n 8)" + { + echo "- [${NOW}] ${STATUS} **${EVENT_KIND} FAILED** \`${CMD_SHORT}\` (exit ${EXIT_CODE})" + if [ -n "$TAIL" ]; then + echo ' ```' + echo "$TAIL" | sed 's/^/ /' + echo ' ```' + fi + } >> "$ACTIVE_LOG" + fi + ;; +esac + +exit 0 diff --git a/.brain/scripts/register-to-dev-brain.sh b/.brain/scripts/register-to-dev-brain.sh new file mode 100755 index 0000000..144a3af --- /dev/null +++ b/.brain/scripts/register-to-dev-brain.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# register-to-dev-brain.sh — Register this project in Dev Brain's registry. +# Run once per project (idempotent). Updates projects.json + AGENT_README.md. +set -uo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$REPO_ROOT/.brain/config.sh" 2>/dev/null +bash "$HOME/Documents/Dev Brain/.scripts/register-project.sh" \ + "$PROJECT_SLUG" \ + "$PROJECT_NAME" \ + "$REPO_ROOT" diff --git a/.brain/scripts/sync-graph-to-vault.sh b/.brain/scripts/sync-graph-to-vault.sh new file mode 100755 index 0000000..cb3e821 --- /dev/null +++ b/.brain/scripts/sync-graph-to-vault.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# sync-graph-to-vault.sh — Copies GRAPH_REPORT.md into the CKIS vault. +# +# Called from: +# - assemble-context.sh (SessionStart) — catch-up on every session open +# - post-commit.brain git hook — low-latency sync after each commit +# +# Wraps the report in CKIS-standard frontmatter so Obsidian indexes it +# correctly. Skips if the file is already identical to avoid mtime churn. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +source "$REPO_ROOT/.brain/config.sh" 2>/dev/null || exit 0 + +SRC="$REPO_ROOT/$GRAPH_DIR/GRAPH_REPORT.md" +DEST_DIR="$CKIS_VAULT/02-projects/$PROJECT_SLUG" +DEST="$DEST_DIR/graph-report.md" + +[ -f "$SRC" ] || exit 0 +[ -d "$DEST_DIR" ] || exit 0 # vault not mounted on this machine — no-op + +NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +TMP="$(mktemp)" + +{ + echo "---" + echo "type: graph-report" + echo "project: $PROJECT_SLUG" + echo "source: \"$SRC\"" + echo "generated: $NOW" + echo "auto: true" + echo "tags: [graph, $PROJECT_SLUG, auto-generated]" + echo "---" + echo + echo "> Auto-synced from \`.brain/graph/GRAPH_REPORT.md\` — do not hand-edit." + echo + cat "$SRC" +} > "$TMP" + +# Skip write if content is identical (avoids triggering Obsidian re-index). +if [ -f "$DEST" ] && cmp -s "$TMP" "$DEST"; then + rm -f "$TMP" + exit 0 +fi + +mv "$TMP" "$DEST" +echo "[brain] graph-report synced → $DEST" >&2 diff --git a/.brain/scripts/sync-obsidian-graph.sh b/.brain/scripts/sync-obsidian-graph.sh new file mode 100755 index 0000000..157e188 --- /dev/null +++ b/.brain/scripts/sync-obsidian-graph.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# sync-obsidian-graph.sh — regenerates Graphify Obsidian notes for this project. +# +# Reads .brain/graph/graph.json and writes one .md file per code node into +# ~/Documents/Dev Brain/code-graph// using the graphify.export Python API +# (the graphify CLI's `update` command does not expose --obsidian; the flag only +# exists in the Claude skill's full pipeline, so we call the Python API directly). +# +# Called from: +# - post-commit.brain (cadence-gated: every OBSIDIAN_GRAPH_CADENCE commits) +# - Manually: bash .brain/scripts/sync-obsidian-graph.sh + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +source "$REPO_ROOT/.brain/config.sh" 2>/dev/null || exit 0 + +DEV_BRAIN_VAULT="${DEV_BRAIN_VAULT:-$HOME/Documents/Dev Brain}" +OBS_DIR="$DEV_BRAIN_VAULT/code-graph/$PROJECT_SLUG" +GRAPH_JSON="$REPO_ROOT/$GRAPH_DIR/graph.json" + +[ -d "$DEV_BRAIN_VAULT" ] || { echo "[brain] Dev Brain vault not found at $DEV_BRAIN_VAULT — skipping" >&2; exit 0; } +[ -f "$GRAPH_JSON" ] || { echo "[brain] graph.json not found — run graphify update . first" >&2; exit 0; } + +mkdir -p "$OBS_DIR" + +python3 - "$GRAPH_JSON" "$OBS_DIR" <<'PYEOF' +import sys, json, warnings +import networkx as nx +from pathlib import Path + +graph_path, obs_dir = Path(sys.argv[1]), sys.argv[2] + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + G = nx.node_link_graph(json.loads(graph_path.read_text()), edges="links") + +communities = {} +for node, data in G.nodes(data=True): + c = data.get("community", 0) + communities.setdefault(c, []).append(node) + +from graphify.export import to_obsidian +n = to_obsidian(G, communities, obs_dir) +print(f"[brain] {n} Obsidian notes written to {obs_dir}", file=sys.stderr) +PYEOF + +# ── Build Dev Brain wiki page for this project ──────────────────────────────── +BUILD_WIKI="$DEV_BRAIN_VAULT/.scripts/build-wiki-page.sh" +if [ -x "$BUILD_WIKI" ]; then + bash "$BUILD_WIKI" "$PROJECT_SLUG" 2>/dev/null || true +fi diff --git a/.brain/sessions/.gitkeep b/.brain/sessions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1f38971 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,67 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run build)", + "Bash(npm run dev)", + "Bash(npm run lint)", + "Bash(git *)", + "Bash(bash .brain/scripts/*)", + "Bash(graphify *)", + "Bash(bash .brain/scripts/sync-obsidian-graph.sh)" + ] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null)\" && bash .brain/scripts/assemble-context.sh'" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null)\" && bash .brain/scripts/log-tool-event.sh'" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null)\" && bash .brain/scripts/log-compact.sh'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'cd \"$(git rev-parse --show-toplevel 2>/dev/null)\" && bash .brain/scripts/log-session.sh'" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true ;; esac" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dd1d82..5fb9c79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,28 +6,47 @@ on: pull_request: branches: [main] +permissions: {} + jobs: build-test: name: build & test (node ${{ matrix.node }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] node: [20, 22] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} cache: pnpm - - name: Install - run: pnpm install --frozen-lockfile + # Zero-trust install: --ignore-scripts blocks postinstall worms. + # pnpm respects the ignore-scripts flag from .npmrc or via CLI. + - name: Install dependencies (zero-trust) + run: pnpm install --frozen-lockfile --ignore-scripts + + # better-sqlite3 ships native bindings compiled at install time. + # Whitelisted: source-available, embedded SQLite3, no network calls. + - name: Rebuild trusted native modules + run: pnpm rebuild better-sqlite3 + env: + npm_config_ignore_scripts: 'false' + + # pnpm audit for supply chain scanning + - name: Dependency security audit + run: pnpm audit --audit-level high - name: Build run: pnpm build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a2bab5f..f34a3e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,8 @@ on: schedule: - cron: "0 6 * * 1" +permissions: {} + jobs: analyze: name: Analyze @@ -20,8 +22,12 @@ jobs: matrix: language: ["javascript-typescript"] steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: github/codeql-action/init@d77b13a0df3134d64a457ea9003f600b09fa1c8a # v3 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/analyze@v3 + + - uses: github/codeql-action/analyze@d77b13a0df3134d64a457ea9003f600b09fa1c8a # v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f3320f..359bc92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,29 +5,51 @@ on: tags: - "v*" -permissions: - contents: write +permissions: {} jobs: publish: runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Enable Corepack run: corepack enable - - uses: actions/setup-node@v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 20 + node-version: '22' cache: pnpm registry-url: "https://registry.npmjs.org" - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm test - - name: Publish to npm - run: pnpm -r publish --access public --no-git-checks + + - name: Install dependencies (zero-trust) + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Rebuild trusted native modules + run: pnpm rebuild better-sqlite3 env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: GitHub Release - uses: softprops/action-gh-release@v2 + npm_config_ignore_scripts: 'false' + + - name: Dependency security audit + run: pnpm audit --audit-level high + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test + + - name: Create GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true + + - name: Publish to npm + run: pnpm -r publish --access public --no-git-checks --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..0e12f60 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,31 @@ +name: Security Audit + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday 09:00 UTC + workflow_dispatch: + +permissions: {} + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Enable Corepack + run: corepack enable + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install dependencies (zero-trust) + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Full dependency audit + run: pnpm audit --audit-level moderate diff --git a/.gitignore b/.gitignore index fd934ef..5ead21a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,15 @@ coverage/ # Private planning material — stripped before the public release. docs/planning/ + +# .brain/ — per-project second brain (runtime artifacts only; scripts/config committed) +# decisions/, bugs/, scripts/, BRAIN.md, README.md, config.sh are versioned. +.brain/_CONTEXT.md +.brain/.session-state +.brain/.compact-triggers +.brain/sessions/* +!.brain/sessions/.gitkeep +.brain/graph/* +!.brain/graph/.gitkeep +# graphify-out is a symlink → .brain/graph (regenerable, not committed) +graphify-out diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9ba4ec2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,16 @@ +# ── Security lockdown ───────────────────────────────────────────────────── +# Block lifecycle scripts during pnpm install. +# Prevents supply chain attacks from executing arbitrary code on install. +ignore-scripts=true + +# Pin to official registry +registry=https://registry.npmjs.org/ + +# Save exact versions when adding new packages +save-exact=true + +# pnpm: treat lockfile as source of truth — fail if out of sync (CI uses --frozen-lockfile) +# pnpm-specific: enable strict peer dependencies +strict-peer-dependencies=false + +fund=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de2d5a..2ab3b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +_Nothing yet — see the [roadmap](README.md#roadmap)._ + +## [0.1.0] — 2026-06-02 + ### Added - Monorepo scaffold (pnpm + TypeScript 5.5 + tsup + vitest). @@ -22,15 +26,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Repo-standard files: AGPL-3.0 `LICENSE`, `LICENSE-COMMERCIAL`, `README`, `CONTRIBUTING`, `SECURITY`, `CODE_OF_CONDUCT`, issue/PR templates, CI. -## Roadmap +### Fixed -- **v0.1.0** — first published release (this scaffold, hardened). -- **v0.2.0** — Open Library + Archive.org + arXiv adapters; ranking tuning. -- **v0.3.0** — `catalog import` of an existing library taxonomy; Ink TUI browse. -- **v0.4.0** — `completion`; richer `doctor`; persisted `sources enable/disable`. -- **v0.5.0** — plugin system + published opt-in `sources-shadow`. -- **v0.6.0** — full MCP parity + tool schemas. -- **v0.7.0–v0.9.0** — standalone binaries, Homebrew/Scoop, hardening. -- **v1.0.0** — frozen `--json` schemas, exit codes, and public API. +- CI workflow step order: `build` now runs before `typecheck` and `lint` so + inter-package TypeScript declaration files exist when needed (TS2307 fix). -[Unreleased]: https://github.com/sapientia/sapientia-cli/commits/main +[Unreleased]: https://github.com/aedneth/sapientia-cli/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/aedneth/sapientia-cli/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 577ae4d..70dad7e 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,25 @@ > **Democratizar el acceso al conocimiento a toda la humanidad.** > Multi-source ebook search, download, and local-library manager — for humans **and** AI agents. +

+ + CI + + + Release + + + License: AGPL-3.0 + +

+

Install · Quickstart · Agent-native · Commands · + Roadmap · + Contributing · License

@@ -24,6 +38,30 @@ existing folder taxonomy. It is part of the **Korvex** agent-native CLI suite. - 🤖 **Agent-native by design** — every flow has a non-interactive, `--json`, pipeable equivalent, plus a built-in **MCP server**. - 🧩 **Pluggable sources** — open archives bundled; shadow libraries are a separate, opt-in plugin (see [Legal & abuse posture](#legal--abuse-posture)). +## Demo + +``` +$ sapientia search "The Dispossessed Le Guin" --json | jq '.results[0]' +{ + "id": "gutenberg:63221", + "title": "The Dispossessed", + "authors": ["Ursula K. Le Guin"], + "year": 1974, + "format": "epub", + "source": "gutenberg", + "qualityScore": 0.92, + "downloadUrl": "https://www.gutenberg.org/ebooks/63221.epub.images" +} + +$ sapientia get gutenberg:63221 --to-catalog +✔ Downloaded The Dispossessed (1.2 MB) +✔ SHA-256 verified +✔ Added to catalog — id: gut-63221 + +$ sapientia library "Le Guin" --json | jq '[.results[].title]' +["The Dispossessed", "The Left Hand of Darkness", "A Wizard of Earthsea"] +``` + ## Install ```bash @@ -95,6 +133,26 @@ jurisdiction. Sapientia performs no bulk scraping, fetches content only on your explicit request, rate-limits sources, and never ships copyrighted material. See [SECURITY.md](SECURITY.md) for takedown contact. +## Roadmap + +| Version | Focus | +|---|---| +| **v0.1.0** | Agent-native foundation — core engine, open sources, MCP server *(current)* | +| **v0.2.0** | Open Library + Archive.org + arXiv adapters; ranking tuning | +| **v0.3.0** | `catalog import` for existing library taxonomy; Ink TUI browse mode | +| **v0.4.0** | Shell completions; richer `doctor`; persisted source enable/disable | +| **v0.5.0** | Plugin system + published opt-in `@sapientia/sources-shadow` | +| **v0.6.0** | Full MCP parity + versioned tool schemas | +| **v0.7.0–v0.9.0** | Standalone binaries, Homebrew/Scoop, hardening | +| **v1.0.0** | Frozen `--json` schemas, exit codes, and public API | + +See [CHANGELOG.md](CHANGELOG.md) for full release notes. + +## Contributing + +Contributions are welcome — bug reports, source adapter ideas, and PRs. +Please read [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow, commit conventions, and how to add a new source adapter. For security issues, see [SECURITY.md](SECURITY.md). + ## License **Dual-licensed — this is final and deliberate:** diff --git a/SECURITY.md b/SECURITY.md index 3b242d4..8ef98d0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,37 +1,38 @@ # Security Policy -## Reporting a vulnerability +## Supported Versions -Please report security vulnerabilities **privately**. Do not open a public issue -for security problems. +| Version | Supported | +|---------|-----------| +| latest | ✅ | +| < latest | ❌ — update to latest | -- Email: **eduardoa.borjas@gmail.com** with subject `SECURITY: sapientia-cli` -- Or use GitHub's **private vulnerability reporting** ("Report a vulnerability" - under the Security tab). +## Reporting a Vulnerability -We aim to acknowledge reports within 72 hours and to provide a remediation -timeline after triage. Please give us a reasonable window to release a fix before -public disclosure. +**Do not open a public GitHub issue for security vulnerabilities.** -## Supported versions +Email: eduardoa.borjas@gmail.com -During the `0.x` series, only the latest minor release receives security fixes. -Once `1.0.0` ships, this section will list supported version ranges. +Include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Any suggested fix (optional) -## Scope +You will receive a response within 48 hours. If confirmed, a patch will be released within 7 days. -In scope: the `sapientia` CLI, `@sapientia/core`, and bundled adapters. Issues -include (but aren't limited to) path traversal on download/catalog, unsafe -deserialization, SSRF via source URLs, and integrity-verification bypass. +## Supply Chain Security -## Copyright / takedown (DMCA) contact +This project implements zero-trust npm security: -Sapientia ships **no copyrighted content** and bundles adapters only for open, -public-domain, and legal archives. Shadow-library adapters are not part of this -repository (they live in a separate, opt-in package). +- **`ignore-scripts=true`** in `.npmrc` — blocks all postinstall/preinstall lifecycle scripts during `npm install`/`npm ci`. Prevents supply chain attacks via compromised transitive dependencies. +- **Explicit native module whitelist** — only named, reviewed native modules (listed in CI) are allowed to compile. All others are blocked. +- **Pinned GitHub Actions** — all Actions are pinned to a specific commit SHA, not a mutable tag. This prevents compromised Action tags from injecting malicious steps. +- **`npm publish --provenance`** — every published release includes a signed SLSA attestation linking the package to the exact GitHub Actions run that built it. Verify with: `npm audit signatures @` +- **`npm ci` in all CI jobs** — never `npm install`. Enforces exact cryptographic hash matching against `package-lock.json`. +- **Minimum permissions** — each CI job declares only the permissions it needs. Default is `permissions: {}` (deny all). +- **Weekly automated audit** — the Security Audit workflow runs every Monday at 09:00 UTC and fails on any moderate or higher vulnerability. -If you believe content reachable through a bundled source infringes your rights, -contact **eduardoa.borjas@gmail.com** with subject `DMCA: sapientia-cli`, -including the work, the source, and your contact details. Note that Sapientia is -a client that fetches from third-party archives on the user's explicit request; -takedowns generally must be directed to the hosting archive. +## Known Mitigations + +Any known vulnerability mitigations (e.g., transitive dependency overrides) are documented in the relevant CI workflow files with inline comments. diff --git a/package.json b/package.json index aefc176..0e22515 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sapientia-monorepo", - "version": "0.1.0", + "version": "1.0.0", "private": true, "description": "Multi-source ebook search/download/library manager — agent-native CLI.", "license": "AGPL-3.0-or-later", @@ -26,6 +26,13 @@ "tsx": "^4.16.0", "typescript": "^5.5.0", "typescript-eslint": "^8.0.0", - "vitest": "^2.0.0" + "vite": "^6.4.2", + "vitest": "^4.1.8" + }, + "pnpm": { + "overrides": { + "esbuild": ">=0.25.0", + "vite": "^6.4.2" + } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index c1c43f2..dd37ce7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sapientia/cli", - "version": "0.1.0", + "version": "1.0.0", "description": "Multi-source ebook search/download/library manager — agent-native CLI for humans and AI agents.", "license": "AGPL-3.0-or-later", "type": "module", @@ -24,5 +24,8 @@ "commander": "^13.0.0", "picocolors": "^1.0.0", "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.5.0" } } diff --git a/packages/cli/src/bin/sapientia.ts b/packages/cli/src/bin/sapientia.ts index 4988e34..3b08312 100644 --- a/packages/cli/src/bin/sapientia.ts +++ b/packages/cli/src/bin/sapientia.ts @@ -11,6 +11,24 @@ function argSignature(a: ArgDef): string { return a.required ? `<${inner}>` : `[${inner}]`; } +function camelCase(name: string): string { + return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +/** + * Commander stores hyphenated options under camelCased keys (`--to-catalog` → + * `toCatalog`). Handlers and the MCP server address flags by their declared name, + * so alias each declared flag's value back onto its hyphenated key. + */ +function normalizeFlags(def: CommandDef, raw: Record): Record { + const flags = { ...raw }; + for (const f of [...GLOBAL_FLAGS, ...def.flags]) { + const camel = camelCase(f.name); + if (camel !== f.name && camel in flags) flags[f.name] = flags[camel]; + } + return flags; +} + function addFlag(cmd: Command, f: FlagDef): void { const long = `--${f.name}`; const flags = @@ -45,7 +63,7 @@ function main(): void { cmd.action(async (...callArgs: unknown[]) => { const command = callArgs[callArgs.length - 1] as Command; const positionals = callArgs.slice(0, def.args.length); - const flags = command.optsWithGlobals(); + const flags = normalizeFlags(def, command.optsWithGlobals()); const args: Record = {}; def.args.forEach((a, i) => { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 52635e9..36892a5 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,5 +1,6 @@ import { catalogDbPath, configFilePath, resolvePaths, type CommandDef } from "@sapientia/core"; -import { access } from "node:fs/promises"; +import { constants } from "node:fs"; +import { access, mkdir } from "node:fs/promises"; import { adapterContext, createRuntime } from "../runtime.js"; export const doctorCommand: CommandDef = { @@ -13,14 +14,18 @@ export const doctorCommand: CommandDef = { const paths = resolvePaths(ctx.env); const checks: Array<{ name: string; ok: boolean; detail?: string }> = []; + const { config, sources } = await createRuntime(ctx.env); + checks.push({ name: "node", ok: true, detail: process.version }); checks.push({ name: "config", ok: true, detail: configFilePath(ctx.env) }); - for (const [k, dir] of Object.entries(paths)) { - checks.push({ name: `dir:${k}`, ok: await canAccess(dir), detail: dir }); + // App directories are meant to exist; create them if missing and confirm they + // are writable, so a fresh install is healed rather than reported as broken. + for (const [k, dir] of Object.entries({ ...paths, download: config.downloadDir! })) { + const w = await ensureWritable(dir); + checks.push({ name: `dir:${k}`, ok: w.ok, detail: w.ok ? dir : w.detail }); } checks.push({ name: "catalog", ok: true, detail: catalogDbPath(ctx.env) }); - const { config, sources } = await createRuntime(ctx.env); if (!ctx.flags.offline) { for (const a of sources.list()) { const h = await a.health(adapterContext(a, config)); @@ -33,11 +38,12 @@ export const doctorCommand: CommandDef = { }, }; -async function canAccess(p: string): Promise { +async function ensureWritable(dir: string): Promise<{ ok: boolean; detail?: string }> { try { - await access(p); - return true; - } catch { - return false; // missing dirs are created on first write; reported, not fatal + await mkdir(dir, { recursive: true }); + await access(dir, constants.W_OK); + return { ok: true }; + } catch (err) { + return { ok: false, detail: `not writable: ${String(err)}` }; } } diff --git a/packages/cli/src/commands/library.ts b/packages/cli/src/commands/library.ts index 5e1ad7e..5fba0bb 100644 --- a/packages/cli/src/commands/library.ts +++ b/packages/cli/src/commands/library.ts @@ -1,15 +1,41 @@ -import { catalogDbPath, Catalog, type CommandDef } from "@sapientia/core"; +import { + catalogDbPath, + Catalog, + importDirectory, + type CommandDef, +} from "@sapientia/core"; +import { resolve } from "node:path"; export const libraryCommand: CommandDef = { name: "library", - description: "Browse or full-text search the local catalog.", + description: "Browse, full-text search, or bulk-import the local catalog.", args: [{ name: "query", description: "Optional FTS query.", required: false }], - flags: [{ name: "limit", type: "number", description: "Max rows.", default: 50 }], - exitCodes: [0, 3, 6, 7], + flags: [ + { name: "limit", type: "number", description: "Max rows.", default: 50 }, + { + name: "import", + type: "string", + description: "Scan a directory tree and index its ebooks into the catalog.", + }, + ], + exitCodes: [0, 2, 3, 6, 7], agentSafe: true, async handler(ctx) { const catalog = await Catalog.open(catalogDbPath(ctx.env)); try { + if (ctx.flags.import) { + const root = resolve(String(ctx.flags.import)); + const showProgress = process.stderr.isTTY === true; + const summary = await importDirectory(catalog, root, { + onProgress: showProgress + ? (p) => process.stderr.write(`\r scanned ${p.scanned} · imported ${p.imported} `) + : undefined, + }); + if (showProgress) process.stderr.write("\n"); + for (const w of summary.warnings) ctx.warn(w); + return { root, total: catalog.count(), ...summary }; + } + const limit = (ctx.flags.limit as number | undefined) ?? 50; const q = ctx.args.query ? String(ctx.args.query) : undefined; const books = q ? catalog.search(q, limit) : catalog.list(limit); diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 6454f14..544267a 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,4 +1,5 @@ import { + ExitCode, rankResults, SearchQuerySchema, UsageError, @@ -55,9 +56,16 @@ export const searchCommand: CommandDef = { } }); - const reliability = (id: string) => (sources.get(id)?.kind === "open" ? 0.9 : 0.6); + // Standard Ebooks' hand-crafted EPUBs are the best available, so it edges out + // other open archives; shadow/scraper sources rank below all bundled archives. + const reliability = (id: string) => { + if (id === "standard-ebooks") return 1.0; + return sources.get(id)?.kind === "open" ? 0.9 : 0.6; + }; const ranked = rankResults(results, config.ranking.weights, reliability); + if (failures > 0) ctx.setExitCode(ExitCode.PARTIAL); + return { query: query.text, total: ranked.length, diff --git a/packages/cli/src/commands/sources.ts b/packages/cli/src/commands/sources.ts index e0e3ac4..2504dd9 100644 --- a/packages/cli/src/commands/sources.ts +++ b/packages/cli/src/commands/sources.ts @@ -1,4 +1,4 @@ -import { type CommandDef } from "@sapientia/core"; +import { UsageError, type CommandDef } from "@sapientia/core"; import { adapterContext, createRuntime } from "../runtime.js"; export const sourcesCommand: CommandDef = { @@ -18,9 +18,16 @@ export const sourcesCommand: CommandDef = { async handler(ctx) { const { config, sources } = await createRuntime(ctx.env); const action = (ctx.args.action as string) ?? "list"; + if (action === "enable" || action === "disable") { + // Persistent source toggling ships in v0.4 — return an error rather than + // silently returning ok:true (which would mislead agents branching on exit code). + throw new UsageError( + `'sources ${action}' is not yet implemented — persistence arrives in v0.4. ` + + `Track: https://github.com/aedneth/sapientia-cli/issues`, + ); + } if (action !== "list") { - // enable/disable mutate config; full impl lands with `config set` plumbing (v0.4). - ctx.warn(`'${action}' is recognized; persistence arrives in v0.4 via config set.`); + throw new UsageError(`Unknown sources action: ${action}. Valid actions: list.`); } const list = await Promise.all( diff --git a/packages/cli/src/execute.ts b/packages/cli/src/execute.ts index 48f49d7..eb7e131 100644 --- a/packages/cli/src/execute.ts +++ b/packages/cli/src/execute.ts @@ -23,6 +23,7 @@ export async function runCommand( }, ): Promise { const warnings: string[] = []; + let overrideExitCode = 0; try { const data = await cmd.handler({ args: opts.args, @@ -30,8 +31,9 @@ export async function runCommand( interactive: isInteractive(opts.flags, opts.env), env: opts.env, warn: (m) => warnings.push(m), + setExitCode: (code) => { overrideExitCode = code; }, }); - return { ok: true, data, warnings, exitCode: 0 }; + return { ok: true, data, warnings, exitCode: overrideExitCode }; } catch (err) { const e = toSapientiaError(err); return { diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index bf3a488..1d45d0c 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -39,8 +39,11 @@ export async function startMcpServer( ): Promise { const server = new McpServer({ name: "sapientia", version: VERSION }); + // Exclude mcp (avoid recursion) and config (exposes mutating `set` action — read config + // via manifest/config-path or direct file access instead). + const MCP_EXCLUDED = new Set(["mcp", "config"]); for (const cmd of registry.list()) { - if (!cmd.agentSafe || cmd.name === "mcp") continue; + if (!cmd.agentSafe || MCP_EXCLUDED.has(cmd.name)) continue; server.tool( cmd.name.replace(/\./g, "_"), cmd.description, diff --git a/packages/core/package.json b/packages/core/package.json index 57de898..b7257bd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sapientia/core", - "version": "0.1.0", + "version": "1.0.0", "description": "Domain engine for sapientia: adapters, normalization, ranking, download, catalog, config.", "license": "AGPL-3.0-or-later", "type": "module", @@ -18,12 +18,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "better-sqlite3": "^11.1.0", + "better-sqlite3": "^12.10.0", "env-paths": "^3.0.0", "undici": "^6.19.0", "zod": "^3.23.0" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.0" + "@types/better-sqlite3": "^7.6.0", + "typescript": "^5.5.0" } } diff --git a/packages/core/src/catalog/catalog.test.ts b/packages/core/src/catalog/catalog.test.ts new file mode 100644 index 0000000..c5492a4 --- /dev/null +++ b/packages/core/src/catalog/catalog.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Catalog } from "./catalog.js"; + +describe("Catalog", () => { + let dir: string; + let catalog: Catalog; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "sapientia-cat-")); + catalog = await Catalog.open(join(dir, "catalog.db")); + }); + afterEach(async () => { + catalog.close(); + await rm(dir, { recursive: true, force: true }); + }); + + it("round-trips a book with its authors and is counted", () => { + catalog.addBook({ + title: "The Dispossessed", + authors: ["Ursula K. Le Guin"], + format: "epub", + filePath: "/x/dispossessed.epub", + fileHashSha256: "abc123", + categoryPath: "Literatura/Science Fiction", + }); + expect(catalog.count()).toBe(1); + const [book] = catalog.list(); + expect(book!.title).toBe("The Dispossessed"); + expect(book!.authors).toEqual(["Ursula K. Le Guin"]); + }); + + it("preserves multiple authors via group_concat", () => { + catalog.addBook({ + title: "Anti-Oedipus", + authors: ["Gilles Deleuze", "Félix Guattari"], + filePath: "/x/anti-oedipus.epub", + fileHashSha256: "def456", + }); + expect(catalog.list()[0]!.authors).toEqual(["Gilles Deleuze", "Félix Guattari"]); + }); + + it("detects an existing file by hash", () => { + catalog.addBook({ title: "X", authors: [], filePath: "/x/x.epub", fileHashSha256: "hash-1" }); + expect(catalog.hasHash("hash-1")).toBe(true); + expect(catalog.hasHash("missing")).toBe(false); + }); + + it("full-text searches title and tolerates operator characters", () => { + catalog.addBook({ title: "Beyond Good and Evil", authors: ["Nietzsche"], fileHashSha256: "h2" }); + expect(catalog.search("evil").map((b) => b.title)).toContain("Beyond Good and Evil"); + expect(() => catalog.search("good (and) evil:")).not.toThrow(); + }); +}); diff --git a/packages/core/src/catalog/catalog.ts b/packages/core/src/catalog/catalog.ts index a02171d..9da9477 100644 --- a/packages/core/src/catalog/catalog.ts +++ b/packages/core/src/catalog/catalog.ts @@ -140,20 +140,32 @@ export class Catalog { .get(sha256); } + // Aggregate each book's authors into one column so a single query returns them. + private static readonly AUTHORS_SUBQUERY = `( + SELECT group_concat(a.name, char(31)) FROM book_authors ba + JOIN authors a ON a.id = ba.author_id WHERE ba.book_id = b.id + ) AS authors_concat`; + /** Full-text search across title/author/subjects. */ search(query: string, limit = 25): CatalogBook[] { + // Wrap in FTS5 quoted string so user input with operators (parentheses, colons, + // etc.) doesn't throw a SQLite parse error. Double internal quotes to escape. + const safeQuery = `"${query.replace(/"/g, '""')}"`; const rows = this.db .prepare( - `SELECT b.* FROM books_fts f JOIN books b ON b.id = f.rowid + `SELECT b.*, ${Catalog.AUTHORS_SUBQUERY} FROM books_fts f JOIN books b ON b.id = f.rowid WHERE books_fts MATCH ? ORDER BY rank LIMIT ?`, ) - .all(query, limit) as Array>; + .all(safeQuery, limit) as Array>; return rows.map(this.rowToBook); } list(limit = 50): CatalogBook[] { const rows = this.db - .prepare("SELECT * FROM books ORDER BY added_at DESC LIMIT ?") + .prepare( + `SELECT b.*, ${Catalog.AUTHORS_SUBQUERY} FROM books b + ORDER BY b.added_at DESC LIMIT ?`, + ) .all(limit) as Array>; return rows.map(this.rowToBook); } @@ -166,7 +178,7 @@ export class Catalog { id: r.id as number, title: r.title as string, subtitle: (r.subtitle as string) ?? undefined, - authors: [], + authors: r.authors_concat ? String(r.authors_concat).split("\x1f") : [], year: (r.year as number) ?? undefined, language: (r.language as string) ?? undefined, format: (r.format as string) ?? undefined, diff --git a/packages/core/src/download/download.ts b/packages/core/src/download/download.ts index 60cb2f8..fa08b9b 100644 --- a/packages/core/src/download/download.ts +++ b/packages/core/src/download/download.ts @@ -1,11 +1,11 @@ import { createHash } from "node:crypto"; -import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, stat } from "node:fs/promises"; +import { createReadStream, createWriteStream, truncate } from "node:fs"; +import { mkdir, stat, unlink } from "node:fs/promises"; import { dirname } from "node:path"; import { pipeline } from "node:stream/promises"; import type { SourceAdapter } from "../adapter/types.js"; import type { AdapterContext } from "../adapter/types.js"; -import { IntegrityError } from "../errors/errors.js"; +import { FilesystemError, IntegrityError, UsageError } from "../errors/errors.js"; import type { DownloadCandidate } from "../schemas/book.js"; export interface DownloadResult { @@ -23,6 +23,41 @@ export async function hashFile(path: string, algo = "sha256"): Promise { return hash.digest("hex"); } +/** Private IP ranges that must never be fetched (SSRF guard). */ +const BLOCKED_HOSTS = [ + /^localhost$/i, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, // link-local + /^\[::1\]$/, // IPv6 loopback + /^\[fc..:.*\]$/i, // IPv6 ULA +]; + +/** + * Validate a candidate URL before fetching. Rejects non-HTTP(S) schemes and + * private/loopback hosts to prevent SSRF from malicious adapter responses. + * Throws UsageError (exit 2) on violation. + */ +export function validateDownloadUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`Invalid download URL: ${url}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new UsageError( + `Download URL must use http(s): — got ${parsed.protocol} in ${url}`, + ); + } + const host = parsed.hostname.toLowerCase(); + if (BLOCKED_HOSTS.some((re) => re.test(host))) { + throw new UsageError(`Download URL targets a private or loopback host: ${host}`); + } +} + /** * Download a candidate through its adapter, hashing the stream as it lands and * verifying against any advertised hashes. Resumes a partial file via HTTP Range. @@ -34,6 +69,9 @@ export async function downloadCandidate( destPath: string, ctx: AdapterContext, ): Promise { + // Guard before any I/O so an agent can't steer us to internal endpoints. + validateDownloadUrl(candidate.url); + await mkdir(dirname(destPath), { recursive: true }); let start = 0; @@ -52,12 +90,31 @@ export async function downloadCandidate( start > 0 ? { start } : undefined, ); + // If we asked for a range but the server returned the full content, the file is now + // prefix + full body (corrupted). Detect by comparing actual size to expected. + if (start > 0 && candidate.fileSize) { + const finalStat = await stat(destPath); + if (finalStat.size > candidate.fileSize) { + // Truncate and surface a clear error rather than silently leaving corrupt data. + await new Promise((res, rej) => + truncate(destPath, 0, (e) => (e ? rej(e) : res())), + ); + throw new FilesystemError( + `Resume corrupted: server did not honor HTTP Range for ${candidate.url}. ` + + `File truncated — retry without a partial file.`, + { url: candidate.url, expectedSize: candidate.fileSize, actualSize: finalStat.size }, + ); + } + } + const sha256 = await hashFile(destPath, "sha256"); const verified: Record = {}; for (const [algo, expected] of Object.entries(candidate.expectedHashes)) { const actual = algo === "sha256" ? sha256 : await hashFile(destPath, algo); verified[algo] = actual.toLowerCase() === expected.toLowerCase(); if (!verified[algo]) { + // Remove the bad file so a retry starts clean. + await unlink(destPath).catch(() => undefined); throw new IntegrityError(`Hash mismatch (${algo}) for ${destPath}`, { algo, expected, diff --git a/packages/core/src/import/filename.test.ts b/packages/core/src/import/filename.test.ts new file mode 100644 index 0000000..885bb66 --- /dev/null +++ b/packages/core/src/import/filename.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + categoryFromPath, + isSupportedFile, + parseBookFilename, +} from "./filename.js"; + +describe("isSupportedFile", () => { + it("accepts known ebook extensions and rejects others", () => { + expect(isSupportedFile("book.epub")).toBe(true); + expect(isSupportedFile("BOOK.PDF")).toBe(true); + expect(isSupportedFile("notes.txt")).toBe(true); + expect(isSupportedFile("cover.jpg")).toBe(false); + expect(isSupportedFile("README")).toBe(false); + }); +}); + +describe("parseBookFilename", () => { + it("splits 'Author - Title' and sets format from extension", () => { + const p = parseBookFilename("Ursula K. Le Guin - The Dispossessed.epub"); + expect(p.authors).toEqual(["Ursula K. Le Guin"]); + expect(p.title).toBe("The Dispossessed"); + expect(p.format).toBe("epub"); + }); + + it("extracts a parenthesized or bracketed year", () => { + expect(parseBookFilename("Dostoevsky - The Idiot (1869).pdf")).toMatchObject({ + authors: ["Dostoevsky"], + title: "The Idiot", + year: 1869, + format: "pdf", + }); + expect(parseBookFilename("Sapiens [2011].epub").year).toBe(2011); + }); + + it("splits multiple authors on & / and / ;", () => { + expect(parseBookFilename("Deleuze & Guattari - Anti-Oedipus.epub").authors).toEqual([ + "Deleuze", + "Guattari", + ]); + }); + + it("treats a bare title with no separator as title-only", () => { + const p = parseBookFilename("Meditations.mobi"); + expect(p.title).toBe("Meditations"); + expect(p.authors).toEqual([]); + expect(p.format).toBe("mobi"); + }); + + it("converts underscores to spaces", () => { + expect(parseBookFilename("Carl_Sagan_-_Cosmos.epub")).toMatchObject({ + authors: ["Carl Sagan"], + title: "Cosmos", + }); + }); +}); + +describe("categoryFromPath", () => { + const root = "/home/e/Digital Library"; + it("maps nested directories to a category path", () => { + expect(categoryFromPath(root, `${root}/Filosofía/Ética/book.epub`)).toBe("Filosofía/Ética"); + }); + it("returns undefined for files directly in the root", () => { + expect(categoryFromPath(root, `${root}/book.epub`)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/import/filename.ts b/packages/core/src/import/filename.ts new file mode 100644 index 0000000..1a61447 --- /dev/null +++ b/packages/core/src/import/filename.ts @@ -0,0 +1,76 @@ +import { relative, sep } from "node:path"; +import { normalizeFormat } from "../normalize/normalize.js"; +import type { BookFormat } from "../schemas/book.js"; + +/** Ebook extensions the importer will index; everything else is skipped. */ +export const SUPPORTED_EXTENSIONS = ["epub", "pdf", "mobi", "azw3", "djvu", "txt"] as const; + +export function isSupportedFile(filename: string): boolean { + const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; + return !!ext && (SUPPORTED_EXTENSIONS as readonly string[]).includes(ext); +} + +export interface ParsedFilename { + title: string; + authors: string[]; + year?: number; + format: BookFormat; +} + +/** Split a joined author string ("A & B", "A and B", "A; B") into names. */ +function splitAuthors(s: string): string[] { + return s + .split(/\s*[&;]\s*|\s+and\s+/i) + .map((a) => a.trim()) + .filter(Boolean); +} + +/** + * Best-effort metadata from a bare filename. Handles the common shapes in a + * hand-curated library: "Author - Title", "Title (Year)", "Title [Year]", or + * just "Title". Underscores become spaces; the extension sets the format. + */ +export function parseBookFilename(filename: string): ParsedFilename { + const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; + const format = normalizeFormat(ext); + + let base = filename + .replace(/\.[a-z0-9]+$/i, "") + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim(); + + let year: number | undefined; + const yearM = base.match(/[([]\s*((?:1[5-9]|20)\d{2})\s*[)\]]/); + if (yearM) { + year = Number(yearM[1]); + base = base.replace(yearM[0], "").replace(/\s+/g, " ").trim(); + } + + let authors: string[] = []; + let title = base; + const dash = base.indexOf(" - "); + if (dash > 0) { + const left = base.slice(0, dash).trim(); + const right = base.slice(dash + 3).trim(); + if (left && right) { + authors = splitAuthors(left); + title = right; + } + } + + return { title: title.trim() || base, authors, year, format }; +} + +/** + * Derive a "/"-joined category path from a file's location under the import root + * (the directory hierarchy becomes the taxonomy). Returns undefined for files + * directly in the root. + */ +export function categoryFromPath(root: string, filePath: string): string | undefined { + const rel = relative(root, filePath); + const parts = rel.split(sep); + parts.pop(); // drop the filename + const dirs = parts.filter(Boolean); + return dirs.length ? dirs.join("/") : undefined; +} diff --git a/packages/core/src/import/importer.ts b/packages/core/src/import/importer.ts new file mode 100644 index 0000000..e9ab941 --- /dev/null +++ b/packages/core/src/import/importer.ts @@ -0,0 +1,93 @@ +import { readdir, stat } from "node:fs/promises"; +import { basename, join } from "node:path"; +import type { Catalog } from "../catalog/catalog.js"; +import { hashFile } from "../download/download.js"; +import { categoryFromPath, isSupportedFile, parseBookFilename } from "./filename.js"; + +export interface ImportProgress { + scanned: number; + imported: number; + current: string; +} + +export interface ImportOptions { + /** Called after each supported file is processed; for progress display. */ + onProgress?: (p: ImportProgress) => void; + /** Cap on warning strings retained in the summary (errors are still counted). */ + maxWarnings?: number; +} + +export interface ImportSummary { + /** Supported ebook files encountered. */ + scanned: number; + imported: number; + /** Already catalogued (hash match). */ + skipped: number; + /** Unreadable file, hash failure, or insert conflict. */ + failed: number; + warnings: string[]; +} + +/** Recursively yield file paths under `dir`, silently skipping unreadable dirs. */ +async function* walk(dir: string, onUnreadable: (d: string) => void): AsyncGenerator { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + onUnreadable(dir); + return; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) yield* walk(full, onUnreadable); + else if (e.isFile()) yield full; + } +} + +/** + * Scan a directory tree and index supported ebooks into the catalog. Metadata is + * derived from each filename and the directory hierarchy becomes the category + * taxonomy. Files already present (by SHA-256) are skipped; unreadable files are + * counted and reported rather than aborting the run. + */ +export async function importDirectory( + catalog: Catalog, + root: string, + opts: ImportOptions = {}, +): Promise { + const maxWarnings = opts.maxWarnings ?? 50; + const summary: ImportSummary = { scanned: 0, imported: 0, skipped: 0, failed: 0, warnings: [] }; + const note = (msg: string) => { + if (summary.warnings.length < maxWarnings) summary.warnings.push(msg); + }; + + for await (const file of walk(root, (d) => note(`unreadable directory: ${d}`))) { + if (!isSupportedFile(file)) continue; + summary.scanned++; + try { + const sha256 = await hashFile(file); + if (catalog.hasHash(sha256)) { + summary.skipped++; + } else { + const meta = parseBookFilename(basename(file)); + const size = (await stat(file)).size; + catalog.addBook({ + title: meta.title, + authors: meta.authors, + year: meta.year, + format: meta.format, + filePath: file, + fileHashSha256: sha256, + fileSize: size, + categoryPath: categoryFromPath(root, file), + }); + summary.imported++; + } + } catch (err) { + summary.failed++; + note(`${file}: ${String(err)}`); + } + opts.onProgress?.({ scanned: summary.scanned, imported: summary.imported, current: file }); + } + return summary; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4dbe147..748e673 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,10 @@ export * from "./download/download.js"; // Catalog export * from "./catalog/catalog.js"; +// Library import (bulk indexing of an existing collection) +export * from "./import/filename.js"; +export * from "./import/importer.js"; + // Command registry (agent-native spine) export * from "./registry/types.js"; export * from "./registry/registry.js"; diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index ff01cb5..c07c077 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -3,7 +3,6 @@ import type { CommandDef, FlagDef } from "./types.js"; /** Global flags every command inherits; documented once, applied everywhere. */ export const GLOBAL_FLAGS: FlagDef[] = [ { name: "json", type: "boolean", description: "Emit machine-readable JSON envelope." }, - { name: "ndjson", type: "boolean", description: "Stream results as newline-delimited JSON." }, { name: "yes", short: "y", type: "boolean", description: "Skip confirmation prompts." }, { name: "no-input", type: "boolean", description: "Never prompt; fail instead (agent mode)." }, { name: "accept-legal", type: "boolean", description: "Accept legal acknowledgment for gated sources." }, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 1210108..90863d7 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -30,6 +30,11 @@ export interface CommandContext { env: NodeJS.ProcessEnv; /** Emit a non-fatal warning into the result envelope. */ warn: (msg: string) => void; + /** + * Override the process exit code on success (e.g. PARTIAL = 8 for partial + * multi-source results). Has no effect when the handler throws. + */ + setExitCode: (code: number) => void; } export interface CommandDef { diff --git a/packages/sources-open/package.json b/packages/sources-open/package.json index 787cc19..7fd9478 100644 --- a/packages/sources-open/package.json +++ b/packages/sources-open/package.json @@ -1,6 +1,6 @@ { "name": "@sapientia/sources-open", - "version": "0.1.0", + "version": "1.0.0", "description": "Bundled open-archive source adapters (Project Gutenberg, Standard Ebooks).", "license": "AGPL-3.0-or-later", "type": "module", @@ -19,5 +19,8 @@ }, "dependencies": { "@sapientia/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.5.0" } } diff --git a/packages/sources-open/src/gutenberg.ts b/packages/sources-open/src/gutenberg.ts index 98590d9..55e0620 100644 --- a/packages/sources-open/src/gutenberg.ts +++ b/packages/sources-open/src/gutenberg.ts @@ -13,6 +13,18 @@ import type { Writable } from "node:stream"; const GUTENDEX = "https://gutendex.com/books"; +/** + * Project Gutenberg asks automated clients to stay under ~1 request/second. Keep a + * courteous gap between any network call this adapter makes (search and download). + */ +const MIN_INTERVAL_MS = 1000; +let lastRequestAt = 0; +async function throttle(): Promise { + const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastRequestAt = Date.now(); +} + interface GutendexBook { id: number; title: string; @@ -37,6 +49,7 @@ export class GutenbergAdapter implements SourceAdapter { const url = new URL(GUTENDEX); url.searchParams.set("search", [query.text, query.author].filter(Boolean).join(" ")); if (query.language) url.searchParams.set("languages", query.language); + await throttle(); const res = await ctx.fetch(url, { signal: ctx.signal, headers: { "user-agent": ctx.userAgent }, @@ -47,6 +60,7 @@ export class GutenbergAdapter implements SourceAdapter { } async resolve(sourceRef: string, ctx: AdapterContext): Promise { + await throttle(); const res = await ctx.fetch(`${GUTENDEX}/${sourceRef}`, { signal: ctx.signal, headers: { "user-agent": ctx.userAgent }, @@ -63,6 +77,7 @@ export class GutenbergAdapter implements SourceAdapter { ): Promise<{ bytesWritten: number }> { const headers: Record = { "user-agent": ctx.userAgent }; if (range) headers.range = `bytes=${range.start}-`; + await throttle(); const res = await ctx.fetch(candidate.url, { signal: ctx.signal, headers }); if (!res.ok || !res.body) throw new SourceUnavailableError(`Download failed: ${res.status}`); let bytesWritten = 0; diff --git a/packages/sources-open/src/standard-ebooks.test.ts b/packages/sources-open/src/standard-ebooks.test.ts new file mode 100644 index 0000000..e8a742e --- /dev/null +++ b/packages/sources-open/src/standard-ebooks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { StandardEbooksAdapter } from "./standard-ebooks.js"; + +// Trimmed but structurally faithful sample of the public catalog listing markup. +const LISTING = ` +
    +
  1. + +

    Frankenstein

    +

    Mary Shelley

    +
  2. +
  3. + +

    Beyond Good & Evil

    +

    Friedrich Nietzsche

    +
  4. +
`; + +describe("StandardEbooksAdapter.parseListing", () => { + const adapter = new StandardEbooksAdapter(); + const results = adapter.parseListing(LISTING); + + it("extracts one result per book entry", () => { + expect(results).toHaveLength(2); + }); + + it("parses a two-segment book ref, title, author and epub url", () => { + const fr = results[0]!; + expect(fr.sourceRef).toBe("mary-shelley/frankenstein"); + expect(fr.id).toBe("standard-ebooks:mary-shelley/frankenstein"); + expect(fr.title).toBe("Frankenstein"); + expect(fr.authors).toEqual(["Mary Shelley"]); + expect(fr.candidates[0]!.url).toBe( + "https://standardebooks.org/ebooks/mary-shelley/frankenstein/downloads/mary-shelley_frankenstein.epub?source=download", + ); + }); + + it("handles a translated (three-segment) ref and decodes entities", () => { + const be = results[1]!; + expect(be.sourceRef).toBe("friedrich-nietzsche/beyond-good-and-evil/helen-zimmern"); + expect(be.title).toBe("Beyond Good & Evil"); + expect(be.candidates[0]!.url).toBe( + "https://standardebooks.org/ebooks/friedrich-nietzsche/beyond-good-and-evil/helen-zimmern/downloads/friedrich-nietzsche_beyond-good-and-evil_helen-zimmern.epub?source=download", + ); + }); + + it("does not mistake the single-segment author link for a book", () => { + expect(results.every((r) => r.sourceRef.includes("/"))).toBe(true); + }); +}); diff --git a/packages/sources-open/src/standard-ebooks.ts b/packages/sources-open/src/standard-ebooks.ts index e21a477..693fcdd 100644 --- a/packages/sources-open/src/standard-ebooks.ts +++ b/packages/sources-open/src/standard-ebooks.ts @@ -9,37 +9,81 @@ import { } from "@sapientia/core"; import type { Writable } from "node:stream"; -const OPDS = "https://standardebooks.org/feeds/opds/all"; +const BASE = "https://standardebooks.org"; +const SEARCH = `${BASE}/ebooks`; + +/** + * Standard Ebooks throttles aggressive automated access, and its OPDS feed is now + * gated behind a paid Patrons Circle (HTTP 401). We instead read the public, + * anonymous HTML catalog — but keep a courteous ≥1s gap between requests. + */ +const MIN_INTERVAL_MS = 1000; +let lastRequestAt = 0; +async function throttle(): Promise { + const wait = lastRequestAt + MIN_INTERVAL_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastRequestAt = Date.now(); +} + +/** Decode the small set of HTML entities Standard Ebooks emits in titles/authors. */ +function decode(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/�?39;|'|’|’/g, "’") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))); +} + +/** + * Build the compatible-EPUB download URL for a book ref ("author/book[/translator]"). + * The bare `.epub` path serves a "download has started" interstitial; the actual + * binary requires the `?source=download` query the site's own links carry. + */ +function epubUrl(ref: string): string { + return `${BASE}/ebooks/${ref}/downloads/${ref.replace(/\//g, "_")}.epub?source=download`; +} /** * Standard Ebooks — public-domain works with hand-crafted, high-quality EPUBs. - * Uses the public OPDS catalog feed; filters client-side by query text. + * Discovery uses the anonymous HTML catalog; download URLs are deterministic from + * the book's slug, so search constructs candidates without an extra round-trip. */ export class StandardEbooksAdapter implements SourceAdapter { readonly id = "standard-ebooks"; readonly name = "Standard Ebooks"; readonly kind = "open" as const; - readonly capabilities = { search: true, download: true, formats: ["epub", "azw3"] }; + readonly capabilities = { search: true, download: true, formats: ["epub"] }; readonly legal = { note: "Public domain, quality-formatted EPUBs. Free to use and share.", requiresAcknowledgment: false, }; async search(query: SearchQuery, ctx: AdapterContext): Promise { - const res = await ctx.fetch(OPDS, { + const text = [query.text, query.author].filter(Boolean).join(" ").trim(); + const url = new URL(SEARCH); + if (text) url.searchParams.set("query", text); + await throttle(); + const res = await ctx.fetch(url, { signal: ctx.signal, - headers: { "user-agent": ctx.userAgent, accept: "application/atom+xml" }, + headers: { "user-agent": ctx.userAgent, accept: "text/html" }, }); if (!res.ok) throw new SourceUnavailableError(`Standard Ebooks returned ${res.status}`); - const xml = await res.text(); - return this.parseOpds(xml, query).slice(0, query.limit); + return this.parseListing(await res.text()).slice(0, query.limit); } async resolve(sourceRef: string, ctx: AdapterContext): Promise { - const all = await this.search({ text: "", limit: 10000 } as SearchQuery, ctx); - const found = all.find((b) => b.sourceRef === sourceRef); - if (!found) throw new SourceUnavailableError(`Unknown Standard Ebooks ref: ${sourceRef}`); - return found; + await throttle(); + const res = await ctx.fetch(`${BASE}/ebooks/${sourceRef}`, { + signal: ctx.signal, + headers: { "user-agent": ctx.userAgent, accept: "text/html" }, + }); + if (!res.ok) throw new SourceUnavailableError(`Unknown Standard Ebooks ref: ${sourceRef}`); + const html = await res.text(); + const title = decode(html.match(/

([^<]+)<\/h1>/)?.[1]?.trim() ?? sourceRef); + const authors = this.parseAuthors(html); + return this.toMetadata(sourceRef, title, authors); } async download( @@ -50,6 +94,7 @@ export class StandardEbooksAdapter implements SourceAdapter { ): Promise<{ bytesWritten: number }> { const headers: Record = { "user-agent": ctx.userAgent }; if (range) headers.range = `bytes=${range.start}-`; + await throttle(); const res = await ctx.fetch(candidate.url, { signal: ctx.signal, headers }); if (!res.ok || !res.body) throw new SourceUnavailableError(`Download failed: ${res.status}`); let bytesWritten = 0; @@ -57,14 +102,16 @@ export class StandardEbooksAdapter implements SourceAdapter { bytesWritten += chunk.byteLength; sink.write(chunk); } - await new Promise((resolve, reject) => sink.end((e?: Error) => (e ? reject(e) : resolve()))); + await new Promise((resolve, reject) => + sink.end((e?: Error) => (e ? reject(e) : resolve())), + ); return { bytesWritten }; } async health(ctx: AdapterContext) { const t = Date.now(); try { - const res = await ctx.fetch(OPDS, { + const res = await ctx.fetch(SEARCH, { signal: ctx.signal, method: "HEAD", headers: { "user-agent": ctx.userAgent }, @@ -75,33 +122,44 @@ export class StandardEbooksAdapter implements SourceAdapter { } } - /** Minimal OPDS/Atom parse — entries with title, author and an epub acquisition link. */ - private parseOpds(xml: string, query: SearchQuery): BookMetadata[] { - const entries = xml.split(//).slice(1); - const needle = `${query.text} ${query.author ?? ""}`.trim().toLowerCase(); + /** Parse the HTML catalog listing into normalized results. Exposed for tests. */ + parseListing(html: string): BookMetadata[] { const out: BookMetadata[] = []; - for (const e of entries) { - const title = e.match(/([^<]+)<\/title>/)?.[1]?.trim(); - const author = e.match(/<author>\s*<name>([^<]+)<\/name>/)?.[1]?.trim(); - const id = e.match(/<id>([^<]+)<\/id>/)?.[1]?.trim(); - const epub = e.match(/href="([^"]+\.epub)"/)?.[1]; - if (!title || !id) continue; - if (needle && !`${title} ${author ?? ""}`.toLowerCase().includes(needle)) continue; - const ref = id.split("/").pop() ?? id; - const candidates: DownloadCandidate[] = epub - ? [{ format: "epub", url: epub.startsWith("http") ? epub : `https://standardebooks.org${epub}`, expectedHashes: {} }] - : []; - out.push({ - id: makeResultId(this.id, ref), - sourceId: this.id, - sourceRef: ref, - title, - authors: author ? [author] : [], - formats: candidates.map((c) => c.format), - subjects: [], - candidates, - }); + const seen = new Set<string>(); + // Each result is an <li class="ebook"> … </li>; split and parse per block. + for (const block of html.split(/<li[ >]/).slice(1)) { + const bookM = block.match( + /<a href="(\/ebooks\/[a-z0-9-]+\/[a-z0-9-]+(?:\/[a-z0-9-]+)?)"[^>]*property="schema:url"[^>]*>\s*<span property="schema:name">([^<]+)<\/span>/, + ); + if (!bookM) continue; + const ref = bookM[1]!.replace(/^\/ebooks\//, ""); + if (seen.has(ref)) continue; + seen.add(ref); + out.push(this.toMetadata(ref, decode(bookM[2]!.trim()), this.parseAuthors(block))); } return out; } + + private parseAuthors(html: string): string[] { + return [ + ...html.matchAll( + /property="schema:author"[\s\S]*?<span property="schema:name">([^<]+)<\/span>/g, + ), + ].map((m) => decode(m[1]!.trim())); + } + + private toMetadata(ref: string, title: string, authors: string[]): BookMetadata { + const candidate: DownloadCandidate = { format: "epub", url: epubUrl(ref), expectedHashes: {} }; + return { + id: makeResultId(this.id, ref), + sourceId: this.id, + sourceRef: ref, + title, + authors, + language: "en", + subjects: [], + formats: ["epub"], + candidates: [candidate], + }; + } } diff --git a/packages/sources-shadow/package.json b/packages/sources-shadow/package.json index b4456bd..d66e97b 100644 --- a/packages/sources-shadow/package.json +++ b/packages/sources-shadow/package.json @@ -1,6 +1,6 @@ { "name": "@sapientia/sources-shadow", - "version": "0.1.0", + "version": "1.0.0", "description": "OPT-IN shadow-library source adapters. Installed and enabled separately; requires explicit legal acknowledgment.", "license": "AGPL-3.0-or-later", "type": "module", @@ -19,5 +19,8 @@ }, "dependencies": { "@sapientia/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.5.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b4b044..93ead98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild: '>=0.25.0' + vite: ^6.4.2 + importers: .: @@ -32,9 +36,12 @@ importers: typescript-eslint: specifier: ^8.0.0 version: 8.60.0(eslint@9.39.4)(typescript@5.9.3) + vite: + specifier: ^6.4.2 + version: 6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3) vitest: - specifier: ^2.0.0 - version: 2.1.9(@types/node@20.19.41) + specifier: ^4.1.8 + version: 4.1.8(@types/node@20.19.41)(vite@6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3)) packages/cli: dependencies: @@ -59,12 +66,16 @@ importers: zod: specifier: ^3.23.0 version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 packages/core: dependencies: better-sqlite3: - specifier: ^11.1.0 - version: 11.10.0 + specifier: ^12.10.0 + version: 12.10.0 env-paths: specifier: ^3.0.0 version: 3.0.0 @@ -78,27 +89,32 @@ importers: '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 + typescript: + specifier: ^5.5.0 + version: 5.9.3 packages/sources-open: dependencies: '@sapientia/core': specifier: workspace:* version: link:../core + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 packages/sources-shadow: dependencies: '@sapientia/core': specifier: workspace:* version: link:../core + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 packages: - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -111,12 +127,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -129,12 +139,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -147,12 +151,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -165,12 +163,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -183,12 +175,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -201,12 +187,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -219,12 +199,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -237,12 +211,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -255,12 +223,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -273,12 +235,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -291,12 +247,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -309,12 +259,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -327,12 +271,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -345,12 +283,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -363,12 +295,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -381,12 +307,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -411,12 +331,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -441,12 +355,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -471,12 +379,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -489,12 +391,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -507,12 +403,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -525,12 +415,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -755,9 +639,18 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -829,34 +722,34 @@ packages: resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: ^6.4.2 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -910,8 +803,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-sqlite3@11.10.0: - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -937,7 +831,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: '>=0.25.0' bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -959,18 +853,14 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1015,6 +905,9 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1044,10 +937,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1089,18 +978,13 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1389,6 +1273,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1407,9 +1361,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1486,6 +1437,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1524,16 +1478,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1715,8 +1662,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1758,20 +1705,16 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} toidentifier@1.0.1: @@ -1862,27 +1805,27 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -1897,24 +1840,44 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.4.2 peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -1954,153 +1917,102 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.21.5': - optional: true - '@esbuild/aix-ppc64@0.27.7': optional: true '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.21.5': - optional: true - '@esbuild/android-arm@0.27.7': optional: true '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.27.7': optional: true '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.27.7': optional: true '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.27.7': optional: true '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.27.7': optional: true '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.27.7': optional: true '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.27.7': optional: true '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.27.7': optional: true '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.27.7': optional: true '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.27.7': optional: true '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.27.7': optional: true '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.27.7': optional: true '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.27.7': optional: true '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.27.7': optional: true '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.27.7': optional: true @@ -2113,9 +2025,6 @@ snapshots: '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.27.7': optional: true @@ -2128,9 +2037,6 @@ snapshots: '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.27.7': optional: true @@ -2143,36 +2049,24 @@ snapshots: '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.27.7': optional: true '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.27.7': optional: true '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.27.7': optional: true '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.27.7': optional: true @@ -2356,10 +2250,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@standard-schema/spec@1.1.0': {} + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 20.19.41 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -2461,45 +2364,46 @@ snapshots: '@typescript-eslint/types': 8.60.0 eslint-visitor-keys: 5.0.1 - '@vitest/expect@2.1.9': + '@vitest/expect@4.1.8': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - tinyrainbow: 1.2.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.41))': + '@vitest/mocker@4.1.8(vite@6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@20.19.41) + vite: 6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3) - '@vitest/pretty-format@2.1.9': + '@vitest/pretty-format@4.1.8': dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 3.1.0 - '@vitest/runner@2.1.9': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + '@vitest/utils': 4.1.8 + pathe: 2.0.3 - '@vitest/snapshot@2.1.9': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 + '@vitest/spy@4.1.8': {} - '@vitest/utils@2.1.9': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 2.1.9 - loupe: 3.2.1 - tinyrainbow: 1.2.0 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 accepts@2.0.0: dependencies: @@ -2546,7 +2450,7 @@ snapshots: base64-js@1.5.1: {} - better-sqlite3@11.10.0: + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 @@ -2610,21 +2514,13 @@ snapshots: callsites@3.1.0: {} - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - check-error@2.1.3: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2653,6 +2549,8 @@ snapshots: content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2676,8 +2574,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -2706,38 +2602,12 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -3088,6 +2958,56 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3100,8 +3020,6 @@ snapshots: lodash.merge@4.6.2: {} - loupe@3.2.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3163,6 +3081,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3200,12 +3120,8 @@ snapshots: path-to-regexp@8.4.2: {} - pathe@1.1.2: {} - pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3424,7 +3340,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} string_decoder@1.3.0: dependencies: @@ -3475,16 +3391,14 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - - tinyrainbow@1.2.0: {} - - tinyspy@3.0.2: {} + tinyrainbow@3.1.0: {} toidentifier@1.0.1: {} @@ -3573,67 +3487,46 @@ snapshots: vary@1.1.2: {} - vite-node@2.1.9(@types/node@20.19.41): + vite@6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3): dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@20.19.41) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@20.19.41): - dependencies: - esbuild: 0.21.5 + esbuild: 0.28.0 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.15 rollup: 4.60.4 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.41 fsevents: 2.3.3 + lightningcss: 1.32.0 + tsx: 4.22.3 - vitest@2.1.9(@types/node@20.19.41): + vitest@4.1.8(@types/node@20.19.41)(vite@6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3)): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.41)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.19.41) - vite-node: 2.1.9(@types/node@20.19.41) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@20.19.41)(lightningcss@1.32.0)(tsx@4.22.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.41 transitivePeerDependencies: - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser which@2.0.2: dependencies: