diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 615ccb4..f496dd1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -22,7 +22,7 @@ body: id: version attributes: label: Mimir version - placeholder: "pnpm exec kb --version" + placeholder: "pnpm exec mimir --version" validations: required: true - type: textarea diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d50ead5..9121c8e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,5 +8,5 @@ ## Security -- [ ] No secrets, private documents, `.env` files, or generated `.kb/storage` files are included. -- [ ] Public documentation does not expose private project details. +- [ ] No secrets, private documents, `.env` files, generated `.kb/` or generated `.mimir/` state are included. +- [ ] Public documentation does not expose private project, customer, pricing, or validation details. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e15a27b..9fff711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [main] + branches: [main, develop] push: - branches: [main] + branches: [main, develop, "feature/**"] merge_group: permissions: @@ -51,7 +51,7 @@ jobs: run: pnpm smoke - name: Verify generated dist is committed - run: git diff --exit-code -- packages/mimir/dist packages/mimir-tts/dist + run: git diff --exit-code -- packages/mimir-core/dist packages/mimir-tts/dist - name: Verify npm package metadata run: pnpm package:check diff --git a/.github/workflows/native-app-build.yml b/.github/workflows/native-app-build.yml new file mode 100644 index 0000000..ed7f077 --- /dev/null +++ b/.github/workflows/native-app-build.yml @@ -0,0 +1,158 @@ +name: Native App Build + +on: + workflow_dispatch: + inputs: + target: + description: "Native target to build" + required: true + default: "linux" + type: choice + options: + - linux + - macos + - windows + - desktop-all + +permissions: + contents: read + +concurrency: + group: native-app-build-${{ github.ref }}-${{ inputs.target }} + cancel-in-progress: false + +jobs: + linux: + name: Build Linux AppImage and deb + if: inputs.target == 'linux' || inputs.target == 'desktop-all' + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Linux Tauri dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up pnpm + run: | + corepack enable + corepack prepare pnpm@11.9.0 --activate + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run release preflight + run: pnpm --filter @jcode.labs/mimir-app release:preflight -- --target linux --soft + + - name: Build native bundle + run: pnpm --filter @jcode.labs/mimir-app tauri:build:linux + + - name: Generate checksums + run: pnpm --filter @jcode.labs/mimir-app release:checksums + + - name: Generate release manifest + run: pnpm --filter @jcode.labs/mimir-app release:manifest -- --target linux + + - name: Upload native bundle + uses: actions/upload-artifact@v4 + with: + name: mimir-app-linux-${{ github.sha }} + path: packages/mimir-app/src-tauri/target/release/bundle/ + if-no-files-found: error + + macos: + name: Build macOS app and dmg + if: inputs.target == 'macos' || inputs.target == 'desktop-all' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up pnpm + run: | + corepack enable + corepack prepare pnpm@11.9.0 --activate + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run release preflight + run: pnpm --filter @jcode.labs/mimir-app release:preflight -- --target macos --soft + + - name: Build native bundle + run: pnpm --filter @jcode.labs/mimir-app tauri:build:macos + + - name: Generate checksums + run: pnpm --filter @jcode.labs/mimir-app release:checksums + + - name: Generate release manifest + run: pnpm --filter @jcode.labs/mimir-app release:manifest -- --target macos + + - name: Upload native bundle + uses: actions/upload-artifact@v4 + with: + name: mimir-app-macos-${{ github.sha }} + path: packages/mimir-app/src-tauri/target/release/bundle/ + if-no-files-found: error + + windows: + name: Build Windows NSIS and MSI + if: inputs.target == 'windows' || inputs.target == 'desktop-all' + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up pnpm + shell: bash + run: | + corepack enable + corepack prepare pnpm@11.9.0 --activate + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run release preflight + run: pnpm --filter @jcode.labs/mimir-app release:preflight -- --target windows --soft + + - name: Build native bundle + run: pnpm --filter @jcode.labs/mimir-app tauri:build:windows + + - name: Generate checksums + run: pnpm --filter @jcode.labs/mimir-app release:checksums + + - name: Generate release manifest + run: pnpm --filter @jcode.labs/mimir-app release:manifest -- --target windows + + - name: Upload native bundle + uses: actions/upload-artifact@v4 + with: + name: mimir-app-windows-${{ github.sha }} + path: packages/mimir-app/src-tauri/target/release/bundle/ + if-no-files-found: error diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 87947b2..19bc2e3 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -47,7 +47,7 @@ jobs: - name: Verify version input run: | - test "$(node -p "require('./packages/mimir/package.json').version")" = "${{ inputs.version }}" + test "$(node -p "require('./packages/mimir-core/package.json').version")" = "${{ inputs.version }}" test "$(node -p "require('./packages/mimir-tts/package.json').version")" = "${{ inputs.version }}" - name: Install dependencies @@ -69,7 +69,7 @@ jobs: run: pnpm smoke - name: Verify generated dist is committed - run: git diff --exit-code -- packages/mimir/dist packages/mimir-tts/dist + run: git diff --exit-code -- packages/mimir-core/dist packages/mimir-tts/dist - name: Verify npm package metadata run: pnpm package:check @@ -89,6 +89,6 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish Mimir - run: pnpm --dir packages/mimir publish --access public --provenance --no-git-checks + run: pnpm --dir packages/mimir-core publish --access public --provenance --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 81d3077..f8b4e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,21 @@ private/** .mimir/ *.tgz release-artifacts/ +*.pid + +packages/mimir-app/dist/ +packages/mimir-app/src-tauri/target/ +packages/mimir-app/src-tauri/gen/ +packages/mimir-landing/.astro/ +packages/mimir-landing/dist/ +packages/mimir-license-webhook/dist/ # Tracked synthetic examples. Keep generated example runtime state ignored. -!packages/mimir/examples/ -!packages/mimir/examples/**/ -!packages/mimir/examples/**/.kb/ -!packages/mimir/examples/**/.kb/config.json -!packages/mimir/examples/**/.kb/sources.txt -packages/mimir/examples/**/.kb/storage/ -packages/mimir/examples/**/.kb/access.log -packages/mimir/examples/**/.mimir/ +!packages/mimir-core/examples/ +!packages/mimir-core/examples/**/ +!packages/mimir-core/examples/**/.kb/ +!packages/mimir-core/examples/**/.kb/config.json +!packages/mimir-core/examples/**/.kb/sources.txt +packages/mimir-core/examples/**/.kb/storage/ +packages/mimir-core/examples/**/.kb/access.log +packages/mimir-core/examples/**/.mimir/ diff --git a/AGENTS.md b/AGENTS.md index 760a4e2..924e7b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ - The package is open source under the MIT License unless the user explicitly changes it. - This package must stay reusable across repositories. Resolve project data from the caller's working directory or explicit config, not from the package installation path. -- `kb init` and `kb install-skill` must keep generated local Mimir state ignored in target +- The public CLI name is `mimir`; keep `kb` only as a legacy compatibility alias. New docs, + generated agent configs, landing copy, and setup guidance should use `mimir ...` commands. +- `mimir init` and `mimir install-skill` must keep generated local Mimir state ignored in target repositories. By default, add `.kb/`, `.mimir/`, and private raw-document paths to the target repository `.gitignore`. - Keep confidentiality features low-friction: local-hash retrieval by default, optional @@ -23,29 +25,122 @@ - Keep public positioning focused on sovereign local RAG for confidential datasets and AI agents. Avoid claiming universal binary-file support; unsupported proprietary formats need extraction or dedicated parsers. -- Keep first-run UX centered on `kb setup` for full onboarding and `kb doctor --fix` for safe - repairs. `kb init`, `kb install-skill`, and `kb ingest` remain available as explicit lower-level - commands. +- Keep FR/EU sovereignty, GDPR, AI Act, and legal-vertical claims bounded by + `docs/fr-eu-sovereign-positioning.md`. Do not claim blanket compliance, legal advice, or regulated + sovereignty certification without a separate review. +- Keep first-run UX centered on `mimir setup` for full onboarding and `mimir doctor --fix` for safe + repairs. `mimir init`, `mimir install-skill`, and `mimir ingest` remain available as explicit + lower-level commands. - Keep product documentation canonical in the root `README.md`. Package README files under `packages/*/README.md` are intentionally minimal npm entrypoints and must link clearly to the GitHub root README because npm displays package README files separately. +- Keep long operational references in `docs/` when the root README can link to them cleanly. The + root README stays the canonical product entrypoint, not a dumping ground for every command table. +- Keep real-corpus dogfooding, business validation, pricing tests, customer ledgers, interview notes, + generated JSON, reports, screenshots, paths, and client details outside Git. Commit only public-safe + aggregate findings or synthetic reproductions. +- For private retrieval dogfooding, use `mimir evaluate --fail-under ` as the local recall + gate, and keep the real corpus, golden query files, and generated evaluation JSON outside Git. +- Use `mimir usage-report --days ` for metadata-only dogfooding summaries; do not read or commit + raw access logs when an aggregate report is enough. +- Keep user-facing titles and marketing surfaces branded as `Mimir`. Use `Mimir Core` only for the + technical core package and developer-facing metadata. +- Keep public repository surfaces safe to publish: no active checkout URLs, fake download/update URLs + under real Mimir domains, private documents, generated `.pid` files, committed secrets, internal + GTM/pricing ledgers, or wording that presents tracked MIT source as proprietary or closed source. + `pnpm public:smoke` enforces the cheap checks. +- `packages/mimir-ui` is the shared UI/style foundation adapted from the WorkoutGen landing/UI + approach. It provides the common Tailwind theme and React primitives for both the landing and the + Tauri app; do not import WorkoutGen product copy, assets, analytics, or secrets. +- Prefer the shared shadcn-style primitives from `packages/mimir-ui` for landing and app surfaces. + Tune reusable component variants or theme tokens before adding per-use raw color, typography, or + shape overrides; primary buttons should stay rounded pill buttons. +- Keep shadcn CLI configuration explicit in `packages/mimir-ui/components.json` and + `packages/mimir-landing/components.json`. Use `pnpm dlx shadcn@latest info -c + packages/mimir-ui` for shared primitives and `pnpm dlx shadcn@latest info -c + packages/mimir-landing` for the Astro landing surface; do not duplicate landing-local UI + components when an export from `@jcode.labs/mimir-ui` fits. +- `packages/mimir-landing` is the Astro static landing package. It must stay telemetry-free by + default; do not add PostHog. If analytics are needed later, prefer Cloudflare Web Analytics. +- The landing deploy target is Cloudflare Workers Static Assets through + `packages/mimir-landing/wrangler.jsonc` and the canonical domain `mimir.jcode.works`. Keep + Cloudflare account IDs, tokens, and analytics secrets out of the repository; use local dry-runs + before any protected-branch deployment. +- Mimir landing should keep the broad WorkoutGen landing signals when content changes: Astro i18n + routes, dark-first theme, self-hosted Inter, the shared `MimirBackground` port of WorkoutGen's + animated particle/canvas background, rounded pill nav, and language switching. Do not flatten it + into a generic single-language static page or replace the background with an unrelated imitation. +- `packages/mimir-app` is the cross-platform Tauri desktop/mobile shell. Root `pnpm build` validates + the frontend bundle only; native `tauri build`, `tauri ios *`, and `tauri android *` commands stay + explicit and are not part of npm release validation. +- Distribute the Mimir app through direct downloads and sideloadable installers, not App Store or + Play Store flows. Desktop installers and Android APK-style distribution are first-class; iOS stays + deferred until a compliant non-store channel is chosen. +- Keep Android release packaging on the APK/direct-sideload path with + `pnpm --filter @jcode.labs/mimir-app tauri:android:build`; do not add an iOS release build script + until a compliant non-store distribution path exists. +- Keep direct-download packaging and updater rules in `docs/app-distribution.md`; do not wire the + Tauri updater with placeholder keys or endpoints. +- Keep `packages/mimir-app` `release:updater-guard` passing. It must fail on partial or placeholder + Tauri updater configuration and stay part of release preflight for direct-download packaging. +- Native desktop CI artifacts may be built only through the manual `Native App Build` workflow. It + uploads artifacts for inspection but must not create GitHub releases, deploy, publish, or bypass + signing/checksum requirements. +- Before native app packaging, run `pnpm --filter @jcode.labs/mimir-app release:preflight -- --target + ` on the matching release machine. The preflight may check that + secret-bearing environment variables are present, but it must never print their values. +- Keep `packages/mimir-app` `release:preflight:smoke` passing. It verifies supported native release + targets, keeps iOS out of release packaging, and confirms secret-bearing preflight environment + values are not printed. +- Generate native artifact checksums with `pnpm --filter @jcode.labs/mimir-app release:checksums` + after Tauri packaging and before publishing direct-download files. The manual Native App Build + workflow uploads the generated `SHA256SUMS` with the bundle artifacts. +- Generate `mimir-app-release.json` with + `pnpm --filter @jcode.labs/mimir-app release:manifest -- --target ` + after checksums. The manifest is for static direct-download metadata and must not contain fake + checkout URLs or unsigned-artifact claims. +- App license validation is local and per-major. Keep private signing keys out of the repository; + only inject the public JWK at build time through `VITE_MIMIR_LICENSE_PUBLIC_KEY_JWK`, and use + `packages/mimir-app` `license:keypair` / `license:issue` scripts for local license operations. +- Lemon Squeezy integration stays offline until a real webhook service is intentionally deployed: + convert exported order/subscription JSON with `license:from-lemonsqueezy`, keep the unpublished + webhook handler in `packages/mimir-license-webhook`, and never commit API keys or webhook secrets. +- `packages/mimir-app/src/lib/project-registry.ts` owns the app-side local project registry. Store + selected project roots there and derive `private/` plus `.kb/storage`; keep ingest/query/index + truth in Mimir Core through the sidecar/CLI surface. +- The app's watched-folder feature is an opt-in polling layer over `mimir ingest`; do not add + background daemons unless the plan explicitly changes. The first Google Drive connector is an + opt-in local-sync folder flow using Google Drive for desktop files already present on disk; do not + add OAuth, Drive API calls, or cloud credentials by default. - Keep optional audio summaries separate from core ingestion/query behavior. The - `mimir-audio-summary` skill must prefer `kb audio` / `@jcode.labs/mimir-tts`, default to the + `mimir-audio-summary` skill must prefer `mimir audio` / `@jcode.labs/mimir-tts`, default to the Transformers.js WAV path for offline/confidential rendering, use the Edge MP3 path for global Voice Forge quality only when online TTS is explicitly acceptable, and keep generated audio under ignored local Mimir state. +- Keep offline TTS preload explicit: use non-sensitive text for the first remote-model render that + warms `.mimir/models/tts`, then use `--offline` for confidential narration. The operational guide + lives in `docs/offline-tts-preload.md`. - Keep report generation separate from core retrieval. The `mimir-markdown-report` skill writes cited Markdown reports under ignored `.mimir/reports/` by default and must distinguish evidence, inference, uncertainty, missing documents, and professional-review items. -- Ingestion must be explicit about files it did not index. Preserve `kb audit --unsupported`, +- Keep the public source boundary in `docs/source-boundary.md`: every tracked package is MIT source. + Commercial value can gate official signed builds, support, updates, and hosted license delivery, + but tracked app or webhook code must not be described as proprietary. +- Keep commercial distribution rules in `docs/commercial-distribution.md` and hosted checkout/webhook + rules in `docs/payment-webhook-architecture.md`. Do not introduce App Store, Play Store, hosted + document storage, committed payment/license secrets, public pricing tests, or customer validation + ledgers. +- Ingestion must be explicit about files it did not index. Preserve `mimir audit --unsupported`, unsupported-extension summaries, secret-like file skipping, max file size limits, and checksum-based stale detection. +- PDF OCR is opt-in only. Keep OCR behind `pdfOcrCommand` / `KB_PDF_OCR_COMMAND`, execute it without + a shell, require stdout text, and do not add heavy OCR dependencies or claim universal scan support. - Keep the repository as a simple pnpm workspace monorepo. Add Turbo only if multiple packages or apps start needing task caching/orchestration beyond `pnpm --filter`. - Keep Mimir core free of Ollama. `embeddingProvider: "local-hash"` supports ingestion, search, MCP, and cited retrieval without a model server, but it must not be described as equivalent to semantic retrieval. `embeddingProvider: "transformers"` is the optional semantic embedding path. -- Keep `packages/mimir/examples/sovereign-rag-demo` synthetic and safe to commit. It exists for +- Keep `packages/mimir-core/examples/sovereign-rag-demo` synthetic and safe to commit. It exists for package/user testing only; never place real confidential documents there. - Use Context7 before changing dependencies or public APIs that rely on external libraries. - Run `pnpm validate` before opening a release pull request or publishing. It covers @@ -54,6 +149,8 @@ - Do not publish from a local machine or direct push to `main`. npm releases must go through the protected manual `Publish npm` GitHub Actions workflow after `main` has green CI. The workflow publishes `@jcode.labs/mimir-tts` first, then `@jcode.labs/mimir`. +- Use Git Flow locally: `main` is production, `develop` is integration, feature work starts from + `develop` under `feature/*`. Do not deploy or publish from feature branches. ## Coding Conventions @@ -68,7 +165,7 @@ General principles (KISS, DRY, YAGNI, SOLID) as applied in this codebase. Match - No dead or obsolete code. Delete replaced code, unused exports, and commented-out blocks in the same change; a deletion must cover both source and the regenerated package `dist/`. - No magic strings or numbers. Name meaningful literals as constants, and put shared paths, provider - defaults, and ignore constants in `packages/mimir/src/defaults.ts` rather than copying them across + defaults, and ignore constants in `packages/mimir-core/src/defaults.ts` rather than copying them across modules. - Validate at the boundary, narrow inside. Use Zod at external edges (config in `config.ts`, MCP inputs in `mcp.ts`) and CLI parsers (`parsePositiveInt`); trust the types past that point. @@ -82,40 +179,61 @@ General principles (KISS, DRY, YAGNI, SOLID) as applied in this codebase. Match ## Architecture -- `packages/mimir` is the core package published as `@jcode.labs/mimir`. -- `packages/mimir/src/cli.ts` exposes the `kb` CLI. -- `packages/mimir/src/doctor.ts` owns the user-facing readiness diagnosis behind `kb doctor`. -- `packages/mimir/src/config.ts` resolves `.kb/config.json` from the target repository. -- `packages/mimir/src/defaults.ts` owns shared default paths, provider defaults, and generated-state ignore +- `packages/mimir-core` is Mimir Core, published as `@jcode.labs/mimir`. +- `packages/mimir-core/src/cli.ts` exposes the `mimir` CLI and keeps `kb` as a legacy alias. +- The `mimir` CLI supports global `--project-root ` for sidecar/app usage. Prefer it when a + process cannot or should not change cwd for each selected knowledge base. +- `packages/mimir-core/src/doctor.ts` owns the user-facing readiness diagnosis behind + `mimir doctor`. +- `packages/mimir-core/src/config.ts` resolves `.kb/config.json` from the target repository. +- `packages/mimir-core/src/defaults.ts` owns shared default paths, provider defaults, and generated-state ignore constants. Keep config/init/security/gitignore aligned through this module instead of copying literals. -- `packages/mimir/src/ingest.ts` parses supported files, chunks text, embeds chunks, and rebuilds the - local LanceDB table. -- `packages/mimir/src/query.ts` performs vector search and returns cited retrieval context; LLM synthesis belongs - outside Mimir core. -- `packages/mimir/src/mcp.ts` exposes Mimir as an MCP stdio server for agents. -- `packages/mimir-tts` is the standalone TTS package used by `kb audio`; it uses `edge-tts` for +- `packages/mimir-core/src/ingest.ts` parses supported files, chunks text, embeds chunks, and rebuilds the + local LanceDB table. Normal ingest is incremental and reuses rows whose checksum/provider/model + still match; `--rebuild` forces a full re-index. +- `packages/mimir-core/src/parsing.ts` uses proven parsers for high-risk Office formats: + Mammoth for `.docx` and SheetJS for `.xlsx`. Keep the lightweight XML ZIP parser for + `.pptx`, OpenDocument, and EPUB unless tests show fidelity gaps. +- `packages/mimir-core/src/query.ts` performs hybrid retrieval (vector candidates plus bounded lexical + BM25 scoring) and returns cited retrieval context; LLM synthesis belongs outside Mimir core. +- `packages/mimir-core/src/mcp.ts` exposes Mimir as an MCP stdio server for agents. +- `packages/mimir-tts` is the standalone TTS package used by `mimir audio`; it uses `edge-tts` for high-quality MP3 when available and Transformers.js for offline WAV rendering. -- `packages/mimir/src/gitignore.ts` owns target-repository `.gitignore` entries for local generated Mimir +- `packages/mimir-ui` owns shared React UI primitives and Tailwind theme tokens used by Mimir + product surfaces. +- `packages/mimir-landing` owns the static Astro landing page. +- `packages/mimir-app` owns the Tauri app shell for desktop and mobile. +- `packages/mimir-license-webhook` owns the unpublished MIT-licensed Cloudflare Worker handler for + Lemon Squeezy webhook signature verification, KV-backed idempotency records, and local `MIMIR1` + license issuance. It must stay undeployed until real provider variants, secrets, + storage/idempotency, and a release surface exist. Its `wrangler.jsonc` must keep placeholder KV + namespace IDs until real Cloudflare resources are provisioned; use `cf:dry-run` only before + protected deployment. +- The app integrates Mimir Core through the existing `mimir` CLI/MCP surface. Keep the sidecar + decision and command allowlist in `docs/app-sidecar-architecture.md`; the current native bridge is + the bounded `run_mimir_command` Tauri command, and `externalBin` stays deferred until real platform + sidecar binaries exist. +- `packages/mimir-core/src/gitignore.ts` owns target-repository `.gitignore` entries for local generated Mimir state. -- `packages/mimir/src/security.ts`, `packages/mimir/src/redaction.ts`, and - `packages/mimir/src/access-log.ts` own the +- `packages/mimir-core/src/security.ts`, `packages/mimir-core/src/redaction.ts`, and + `packages/mimir-core/src/access-log.ts` own the privacy and confidentiality hardening layer. -- `packages/mimir/skills/mimir/SKILL.md` is the bundled portable agent skill. -- `packages/mimir/skills/mimir-audio-summary/SKILL.md` is the optional bundled audio-summary skill. -- `packages/mimir/skills/mimir-markdown-report/SKILL.md` is the optional bundled Markdown-report +- `packages/mimir-core/skills/mimir/SKILL.md` is the bundled portable agent skill. +- `packages/mimir-core/skills/mimir-audio-summary/SKILL.md` is the optional bundled audio-summary skill. +- `packages/mimir-core/skills/mimir-markdown-report/SKILL.md` is the optional bundled Markdown-report skill. -- `kb setup` must keep generating agent-specific MCP helpers for easy local use: +- `mimir setup` must keep generating agent-specific MCP helpers for easy local use: `.mimir/claude-mcp-server.json` for `claude mcp add-json`, `.mimir/codex-mcp.toml` for Codex config layers, `.mimir/kimi-mcp.json` for Kimi, `.mimir/opencode.jsonc` for OpenCode, and `.mimir/cline-mcp.json` for Cline. -- `kb install-agent` owns native skill discovery for the main supported coding agents. Keep +- `mimir install-agent` owns native skill discovery for the main supported coding agents. Keep `--agents claude|codex|kimi|opencode|cline` targeted so a user can install only the agent they use, with project scope by default and user scope available through `--scope user`. - Keep `.mimir/skills/` as the canonical skill source in target repositories. Native agent folders - created by `kb install-agent` should link to that source by default; use copy mode only as a + created by `mimir install-agent` should link to that source by default; use copy mode only as a compatibility fallback for runtimes or filesystems that cannot follow symlinks. -- `packages/mimir/examples/sovereign-rag-demo` is the tracked synthetic test workspace for manual +- `packages/mimir-core/examples/sovereign-rag-demo` is the tracked synthetic test workspace for manual and package validation. - `.kb/`, `.mimir/`, and project `private/` folders are local user data or generated agent state in target repositories and must not be committed. diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5b333..32452c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Add optional Transformers.js semantic embeddings through `embeddingProvider: "transformers"`. - Remove Ollama providers and keep `embeddingProvider: "local-hash"` as the no-model default. - Move the repository to a simple pnpm workspace monorepo without adding Turbo. -- Move the core `@jcode.labs/mimir` package into `packages/mimir`. +- Move the core `@jcode.labs/mimir` package into `packages/mimir-core`. - Add `@jcode.labs/mimir-tts` for plug-and-play JS/ONNX WAV rendering without Python or ffmpeg. - Add `kb audio` and update the audio-summary skill to use Mimir TTS before advanced fallback engines. diff --git a/CLAUDE.md b/CLAUDE.md index be50e5d..c2f3057 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,14 @@ non-obvious traps that matter when editing here, without duplicating `AGENTS.md` ## Commands ```bash -pnpm build # builds packages/mimir-tts, then packages/mimir; package dist is committed -pnpm check # typecheck only (tsc --noEmit) +pnpm build # builds UI, app frontend, landing, TTS, then Mimir Core +pnpm check # typecheck UI/app/landing/TTS/core +pnpm dev:app # run the Vite frontend for the Tauri shell +pnpm dev:landing # run the Astro landing locally pnpm lint # Biome CI (format + lint check, no writes) pnpm lint:fix # Biome auto-fix pnpm format # Biome format --write -pnpm test # vitest run for packages/mimir-tts, then packages/mimir +pnpm test # vitest run for packages/mimir-tts, then packages/mimir-core pnpm smoke # build production CLI + MCP smoke test (scripts/smoke.mjs) pnpm validate # full release gate: lint + check + test + build + smoke + package:check + release:artifacts ``` @@ -27,17 +29,23 @@ Tests are colocated as `packages/*/src/*.test.ts` and run on the TypeScript sour ## Committed `dist/` — critical -`packages/mimir/dist/` and `packages/mimir-tts/dist/` are checked into Git. CI enforces -`git diff --exit-code -- packages/mimir/dist packages/mimir-tts/dist`. After any change under -`packages/mimir/src/` or `packages/mimir-tts/src/`, run `pnpm build` and commit the regenerated +`packages/mimir-core/dist/` and `packages/mimir-tts/dist/` are checked into Git. CI enforces +`git diff --exit-code -- packages/mimir-core/dist packages/mimir-tts/dist`. After any change under +`packages/mimir-core/src/` or `packages/mimir-tts/src/`, run `pnpm build` and commit the regenerated output in the same commit, or CI fails. This is the single easiest mistake to make in this repo. +`packages/mimir-app/dist/` and `packages/mimir-landing/dist/` are build artifacts and stay ignored. ## Naming map (the package has several names on purpose) -- Product / core package: **Mimir**, published as `@jcode.labs/mimir` from `packages/mimir`. +- Product name: **Mimir** on the landing, app, README title, and user-facing copy. +- Core package: **Mimir Core**, published as `@jcode.labs/mimir` from `packages/mimir-core`. - TTS package: **Mimir TTS**, published as `@jcode.labs/mimir-tts`. -- CLI binary: **`kb`** (`packages/mimir/bin.kb` -> `packages/mimir/dist/cli.js`). Commands: `init`, - `ingest`, `search`, `ask`, `audit`, `status`, `security-audit`, `destroy-index`, `audio`, +- UI package: **Mimir UI**, unpublished workspace package `@jcode.labs/mimir-ui`. +- Landing package: unpublished workspace package `@jcode.labs/mimir-landing`. +- App package: unpublished workspace package `@jcode.labs/mimir-app`. +- CLI binary: **`mimir`** (`packages/mimir-core/bin.mimir` -> `packages/mimir-core/dist/cli.js`). + The `kb` binary remains only as a legacy compatibility alias. Commands: `init`, `ingest`, + `models pull`, `search`, `ask`, `audit`, `status`, `security-audit`, `destroy-index`, `audio`, `doctor`, `serve-mcp`, `skill-path`, `install-skill`. - TTS CLI binary: **`mimir-tts`** (`packages/mimir-tts/dist/cli.js`). Commands: `doctor`, `render`. - Project config/state in the target repo: **`.kb/`** (`config.json`, `sources.txt`, `access.log`, @@ -48,24 +56,35 @@ output in the same commit, or CI fails. This is the single easiest mistake to ma ## Architecture and data flow -This is a pnpm workspace monorepo with the core package in `packages/mimir` and TTS in -`packages/mimir-tts`. Do not add Turbo unless `pnpm --filter` stops being enough. +This is a pnpm workspace monorepo. `packages/mimir-core` and `packages/mimir-tts` are the published +npm packages. `packages/mimir-ui`, `packages/mimir-landing`, and `packages/mimir-app` are +unpublished workspace packages for product surfaces. Do not add Turbo unless `pnpm --filter` stops +being enough. +`@jcode.labs/mimir` depends on `@jcode.labs/mimir-tts` (`workspace:*`), so release builds still keep +TTS and core in sync. The core package is an ESM-only TypeScript library + CLI + MCP server. Same core, three entry -points: `packages/mimir/src/cli.ts` (commander), `packages/mimir/src/index.ts` (public library -exports), `packages/mimir/src/mcp.ts` (stdio MCP server). +points: `packages/mimir-core/src/cli.ts` (commander), `packages/mimir-core/src/index.ts` (public library +exports), `packages/mimir-core/src/mcp.ts` (stdio MCP server). -The ingest pipeline (`packages/mimir/src/ingest.ts`) chains single-responsibility modules: +The ingest pipeline (`packages/mimir-core/src/ingest.ts`) chains single-responsibility modules: `files.ts` (discover supported files via fast-glob, with sha256 checksums) → `parsing.ts` (extract text per format: PDF/Office/HTML/etc.) → `redaction.ts` (strip secrets/PII *before* anything is embedded) → `chunking.ts` (split into overlapping chunks) → -`embeddings.ts` (vectorize) → `store.ts` (LanceDB). `query.ts` embeds the query and runs vector -search; `ask` returns cited passages only (no LLM synthesis in core). +`embeddings.ts` (vectorize) → `store.ts` (LanceDB). `query.ts` embeds the query, combines vector +candidates with bounded lexical BM25 scoring, and `ask` returns cited passages only (no LLM +synthesis in core). `packages/mimir-tts` is a separate ESM package. It defaults to Transformers.js for offline WAV rendering without Python or ffmpeg, and uses `edge-tts` for high-quality MP3 only when explicitly -requested. Core `kb audio` imports it dynamically. +requested. Core `mimir audio` imports it dynamically. + +`packages/mimir-ui` is the shared Tailwind 4 + React UI layer adapted from the WorkoutGen UI/landing +foundation, but with Mimir tokens and no WorkoutGen product copy, analytics, CDN paths, or secrets. +`packages/mimir-landing` is an Astro static site using that UI package. `packages/mimir-app` is a +Tauri v2 shell using the same UI package; root build validates its Vite frontend, while native Tauri +desktop/mobile builds are explicit `pnpm --filter @jcode.labs/mimir-app tauri:*` commands. Key behaviors to keep in mind before editing: @@ -74,11 +93,12 @@ Key behaviors to keep in mind before editing: working directory, never from its own install path. Zod validates config; `KB_*` env vars override. - **Two embedding providers, not interchangeable at runtime.** `local-hash` (default) is a 384-dim sha256 lexical embedding — fully offline, no model, *not semantic*. `transformers` lazily - `import()`s `@huggingface/transformers` with `allowRemoteModels` off by default. The two produce - different vectors, so **switching providers requires a full re-ingest**. -- **Ingest always full-rebuilds** the LanceDB table (`mode: "overwrite"`). The `--rebuild` flag is a - no-op kept for compatibility. There is no incremental indexing; `audit` only *reports* missing/stale - files against the current index. + `import()`s `@huggingface/transformers` with `allowRemoteModels` off by default. `mimir models pull` + is the explicit one-time remote-download path for preloading the configured embedding model. The + two providers produce different vectors, so **switching providers requires `mimir ingest --rebuild`**. +- **Ingest is incremental by default.** It reuses rows whose checksum, embedding provider, and model + still match, then overwrites the LanceDB table with reused + rebuilt rows. Use `--rebuild` to force + every supported file through parsing, redaction, chunking, and embedding again. - **Privacy is a feature, not a side effect.** Redaction runs before embedding, the access log stores query hashes/metadata only (`access-log.ts`), MCP top-K is clamped to `mcpMaxTopK`, and `gitignore.ts` keeps `.kb/`, `.mimir/`, `private/**` ignored in target repos. `security-audit` @@ -88,8 +108,9 @@ Coding conventions (KISS, DRY, YAGNI, SOLID as applied here) live in `AGENTS.md` ## Toolchain constraints -- Strict TypeScript with `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`; module mode is - `NodeNext`, so relative imports use `.js` extensions even from `.ts` sources. +- Strict TypeScript (`tsconfig.base.json`) with `noUncheckedIndexedAccess`, + `exactOptionalPropertyTypes`, `noUnusedLocals`, and `noUnusedParameters`; module mode is `NodeNext`, + so relative imports use `.js` extensions even from `.ts` sources. - Biome is the formatter and linter (not ESLint/Prettier): 2-space indent, width 100, double quotes, semicolons as-needed, trailing commas all. - Conventional Commits are enforced by commitlint in CI. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a14d25..b86f959 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,8 +18,8 @@ package metadata checks. - Open pull requests against `main`. - Keep changes focused and include tests or smoke coverage for behavior changes. -- Do not commit private documents, generated vector stores, environment files, tokens, or - credentials. +- Do not commit private documents, generated vector stores, generated `.mimir/` state, environment + files, tokens, credentials, customer ledgers, pricing tests, or interview notes. - Use conventional commit messages such as `feat: add source parser` or `fix: handle empty index`. diff --git a/README.md b/README.md index 4149662..43dd772 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,32 @@ This root README is the canonical product documentation for the public npm packa | Package | Role | | --- | --- | | `@jcode.labs/mimir` | Mimir Core: CLI, library, MCP server, bundled agent skills, and synthetic examples. | -| `@jcode.labs/mimir-tts` | Mimir add-on for Edge-quality MP3 and offline Transformers.js WAV rendering through `kb audio`. | +| `@jcode.labs/mimir-tts` | Mimir add-on for Edge-quality MP3 and offline Transformers.js WAV rendering through `mimir audio`. | +| `@jcode.labs/mimir-ui` | Unpublished workspace UI package adapted from the WorkoutGen design foundation for Mimir surfaces. | +| `@jcode.labs/mimir-landing` | Unpublished Astro static landing package. Product-facing titles stay `Mimir`. | +| `@jcode.labs/mimir-app` | Unpublished Tauri desktop/mobile shell package. Native builds are explicit app commands. Core integration uses a bounded native command around the `mimir` CLI, with packaged sidecar distribution still planned. | +| `@jcode.labs/mimir-license-webhook` | Unpublished, undeployed MIT-licensed Cloudflare Worker handler for future Lemon Squeezy webhooks and local `MIMIR1` license issuance. | The package README files are intentionally short because npm displays each package README separately. They point npm readers back to this GitHub documentation. +The product name visible to users is **Mimir**. The technical core package is **Mimir Core** and now +lives under `packages/mimir-core`; the public npm package name remains `@jcode.labs/mimir`. + +The public source and commercial distribution boundary is tracked in +[`docs/source-boundary.md`](./docs/source-boundary.md) and +[`docs/commercial-distribution.md`](./docs/commercial-distribution.md). No checkout URL, production +download URL, customer data, or license secret is committed to this repository. + ## Open Source Mimir is a public open-source project under the MIT License. It is designed to be inspectable, forkable, and usable without a JCode Labs account. +Every tracked package in this repository is visible source. Commercial Mimir app distribution can +gate official signed builds, support, updates, and hosted license delivery, but it does not make the +tracked Tauri app or webhook source proprietary. + Contributions are welcome through pull requests. Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md). Security reports should stay private and follow [`SECURITY.md`](./SECURITY.md). @@ -57,6 +73,25 @@ Suggested GitHub Sponsors tiers: Early public package. APIs may evolve before `1.0.0`. +## Desktop Client Preview + +Mimir Core is the open-source product you can use today through the CLI, library, MCP server, and +portable agent skills. + +A cross-platform Mimir desktop/mobile client is being developed in `packages/mimir-app`. Its goal is +to make local confidential workspaces easier for non-CLI workflows: register a local dossier, run +setup and ingest, ask questions with cited local passages, inspect privacy posture, and preload +embedding models explicitly. Google Drive support is implemented as an opt-in local-sync folder flow +over files already present on disk, not as a default cloud API integration. + +The native client is not released, signed, or commercially distributed yet. There is no checkout, +waitlist, or hosted account flow in this repository. When released, it is planned for direct +downloads and sideloadable installers, not App Store or Play Store distribution. + +The canonical landing and future direct-download release URL is +[`mimir.jcode.works`](https://mimir.jcode.works). It is prepared as a Cloudflare Workers Static Assets +site, but public deployment remains a separate release action. + ## What Mimir Is For - Build a local RAG knowledge base inside any repository. @@ -65,6 +100,8 @@ Early public package. APIs may evolve before `1.0.0`. retrieval layer. - Retrieve grounded local evidence through CLI, library calls, MCP tools, or bundled agent skills. - Optionally create listenable MP3/WAV summaries or cited Markdown reports with bundled skills. +- Prepare legal-dossier summaries, chronologies, clause reviews, and professional-review handoffs + with the optional bundled legal skill. Mimir is not a hosted SaaS, not a remote vector database, and not a certified high-assurance system. For regulated or state-grade environments, pair it with encrypted disks, controlled machines, @@ -96,7 +133,8 @@ context. - A repository where generated local folders can be ignored by Git. - No model runtime is required for the default `embeddingProvider: "local-hash"` mode. - Optional semantic embeddings use Transformers.js with local model files under `.mimir/models` by - default. + default. Use `mimir models pull` when remote model download is acceptable, then keep + `transformersAllowRemoteModels` false for confidential indexing. - Generated answers are intentionally outside Mimir core. Use Claude, Codex, OpenAI, a local model MCP server, or another trusted model runtime to synthesize from Mimir's cited context. - Optional audio summaries use `@jcode.labs/mimir-tts`. For highest-quality MP3, install the @@ -136,10 +174,10 @@ Initialize a repository, install the portable agent kit, run readiness checks, a when supported files are already present: ```bash -pnpm exec kb setup +pnpm exec mimir setup ``` -`kb setup` creates or updates: +`mimir setup` creates or updates: ```plain text private/ # raw documents to ingest @@ -148,6 +186,7 @@ private/ # raw documents to ingest .mimir/skills/mimir/SKILL.md # portable agent skill .mimir/skills/mimir-audio-summary/SKILL.md .mimir/skills/mimir-markdown-report/SKILL.md +.mimir/skills/mimir-legal-dossier/SKILL.md .mimir/mcp.json # generic MCP server config snippet .mimir/claude-mcp-server.json # Claude Code add-json payload .mimir/codex-mcp.toml # Codex config.toml snippet with MCP and skills.config @@ -159,18 +198,18 @@ private/ # raw documents to ingest ``` It detects the repository package manager and writes the MCP helper files with the right command: -`pnpm exec kb serve-mcp`, `npx kb serve-mcp`, `yarn exec kb serve-mcp`, or `bunx kb serve-mcp`. +`pnpm exec mimir serve-mcp`, `npx mimir serve-mcp`, `yarn exec mimir serve-mcp`, or `bunx mimir serve-mcp`. Check readiness at any time: ```bash -pnpm exec kb doctor +pnpm exec mimir doctor ``` If files are missing from the index, stale, or the setup is incomplete, run: ```bash -pnpm exec kb doctor --fix +pnpm exec mimir doctor --fix ``` `doctor --fix` performs safe repairs: missing scaffolding, Git ignore entries, agent kit install, and @@ -197,41 +236,64 @@ private/ Build the local index: ```bash -pnpm exec kb ingest -pnpm exec kb doctor +pnpm exec mimir ingest +pnpm exec mimir doctor ``` -When the index is ready, `kb doctor` prints `ready=true`. `kb ingest` and `kb audit` also report +When the index is ready, `mimir doctor` prints `ready=true`. `mimir ingest` and `mimir audit` also report files that were discovered but not indexed because the type is unsupported, the file is too large, or the file name looks like a secret/private key. List skipped paths explicitly: ```bash -pnpm exec kb audit --unsupported +pnpm exec mimir audit --unsupported +``` + +Summarize recent metadata-only usage without exposing raw queries or local paths: + +```bash +pnpm exec mimir usage-report --days 7 ``` Retrieve exact passages: ```bash -pnpm exec kb search "approval for offline operation" +pnpm exec mimir search "approval for offline operation" ``` Return cited retrieval context for an agent or model: ```bash -pnpm exec kb ask "What evidence supports offline operation?" +pnpm exec mimir ask "What evidence supports offline operation?" +``` + +Measure recall against a golden query file: + +```bash +pnpm exec mimir evaluate --golden golden-queries.json ``` +For private dogfooding, keep the real corpus and golden query file outside Git or under an ignored +local path, then use a threshold that matches the evaluation phase: + +```bash +pnpm exec mimir --project-root /path/to/workspace ingest +pnpm exec mimir --project-root /path/to/workspace evaluate --golden private/golden-queries.json --fail-under 0.8 --json +``` + +The JSON report includes the active `embeddingProvider` and `embeddingModel`, so you can compare +default local-hash recall with a private Transformers semantic run without storing the report in Git. + Mimir does not synthesize an LLM answer. It returns cited local passages; your chosen agent or model does the writing around those passages. With npm, use `npx` after installing the package: ```bash -npx kb setup -npx kb doctor -npx kb search "approval for offline operation" +npx mimir setup +npx mimir doctor +npx mimir search "approval for offline operation" ``` ## Choose A Retrieval Mode @@ -254,12 +316,12 @@ lexical/hash-based, not semantic. Commands: ```bash -pnpm exec kb ingest -pnpm exec kb search "offline retrieval approval" -pnpm exec kb ask "What evidence supports offline operation?" +pnpm exec mimir ingest +pnpm exec mimir search "offline retrieval approval" +pnpm exec mimir ask "What evidence supports offline operation?" ``` -`kb ask` always returns cited retrieved passages instead of a generated synthesis. You can pass those +`mimir ask` always returns cited retrieved passages instead of a generated synthesis. You can pass those passages to any LLM or agent you trust. ### Optional Semantic Embeddings With Transformers.js @@ -280,181 +342,40 @@ Use this when you want better semantic retrieval while keeping Mimir core free o Commands: ```bash -pnpm exec kb ingest -pnpm exec kb ask "Which passages support offline operation?" +pnpm exec mimir models pull --enable +pnpm exec mimir ingest +pnpm exec mimir ask "Which passages support offline operation?" ``` -Keep `transformersAllowRemoteModels` false for confidential or air-gapped work and preload model -files into `embeddingModelPath`. Set it to true only when you explicitly allow Transformers.js to -download model files from Hugging Face. +`mimir models pull` intentionally allows a one-time download from Hugging Face into +`embeddingModelPath`. With `--enable`, it also switches `.kb/config.json` to +`embeddingProvider: "transformers"` while keeping `transformersAllowRemoteModels` false for +confidential or air-gapped indexing. Re-run `mimir ingest --rebuild` after changing embedding +provider or model so stored vectors match the active configuration. ## Agent Skills And MCP Mimir ships with portable agent skills and a standard MCP server. -If `kb setup` was not used, install the agent kit into a repository: - -```bash -pnpm exec kb install-skill -``` - -This creates: - -```plain text -.mimir/skills/mimir/SKILL.md -.mimir/skills/mimir-audio-summary/SKILL.md -.mimir/skills/mimir-markdown-report/SKILL.md -.mimir/mcp.json -.mimir/claude-mcp-server.json -.mimir/codex-mcp.toml -.mimir/kimi-mcp.json -.mimir/opencode.jsonc -.mimir/cline-mcp.json -.mimir/agent-setup.md -.mimir/README.md -``` - -Agents that support skill folders can load `.mimir/skills/mimir/` for deep local RAG usage. Load -`.mimir/skills/mimir-audio-summary/` only when an optional spoken summary is needed. Load -`.mimir/skills/mimir-markdown-report/` when the user asks for a cited Markdown report, dossier, -audit memo, or planning note. Other agents can read the generated `.mimir/README.md` and use the MCP -config snippet. - -For native discovery in a specific agent, install only the agent you use: - -```bash -pnpm exec kb install-agent --agents claude -pnpm exec kb install-agent --agents kimi -pnpm exec kb install-agent --agents claude,codex,kimi,opencode,cline -``` - -By default, `install-agent` writes project-scope skill folders as links back to `.mimir/skills/`. -That keeps one original version of every skill. Add `--scope user` for global installations, or -`--mode copy` only when an agent/runtime cannot follow symlinked skill directories. - -| Agent | Project skill directory | Main MCP helper | -| --- | --- | --- | -| Claude Code | `.claude/skills/` | `.mimir/claude-mcp-server.json` | -| Codex | `.codex/skills/` plus `skills.config` | `.mimir/codex-mcp.toml` | -| Kimi Code CLI | `.kimi/skills/` | `.mimir/kimi-mcp.json` | -| OpenCode | `.opencode/skills/` | `.mimir/opencode.jsonc` | -| Cline | `.cline/skills/` | `.mimir/cline-mcp.json` | - -Start the MCP server from the repository root: - -```bash -pnpm exec kb serve-mcp -``` - -MCP tools exposed: - -- `mimir_status` -- `mimir_search` -- `mimir_ask` -- `mimir_audit` -- `mimir_security_audit` - -This MCP layer is the recommended way to let any compatible LLM or agent query the same local -knowledge base. The LLM does not need to know about LanceDB or the raw file layout; it asks Mimir for -ranked passages or cited context and uses the returned citations. - -### Claude Code - -From the target repository root: - -```bash -pnpm exec kb setup -pnpm exec kb install-agent --agents claude -claude mcp add-json --scope local mimir "$(cat .mimir/claude-mcp-server.json)" -``` - -Claude Code provides the active project path to MCP servers through `CLAUDE_PROJECT_DIR`; Mimir uses -that value when serving MCP, so the same installed npm package can work inside each repository where -`kb setup` was run. Keep the MCP scope local unless you intentionally want to share the server -config. - -### Codex - -From the target repository root: - -```bash -pnpm exec kb setup -pnpm exec kb install-agent --agents codex -cat .mimir/codex-mcp.toml -``` - -Copy the printed TOML into `~/.codex/config.toml` or another trusted Codex config layer. The snippet -contains the repository `cwd`, the Mimir MCP server, and `skills.config` entries for the bundled -skills. - -### Kimi Code CLI - -From the target repository root: +Use `mimir setup` for the normal path, or install only the agent layer later: ```bash -pnpm exec kb setup -pnpm exec kb install-agent --agents kimi -kimi --mcp-config-file .mimir/kimi-mcp.json +pnpm exec mimir install-skill +pnpm exec mimir install-agent --agents claude,codex,kimi,opencode,cline ``` -Kimi can discover project skills from `.kimi/skills/`. The MCP config can also be installed in -Kimi's global MCP file if you intentionally want a global setup. If you prefer not to create a -`.kimi/skills/` discovery folder, Kimi can also be launched directly with -`kimi --skills-dir .mimir/skills --mcp-config-file .mimir/kimi-mcp.json`. - -### OpenCode - -From the target repository root: +Start the MCP server from the repository root when a compatible agent needs tool access: ```bash -pnpm exec kb setup -pnpm exec kb install-agent --agents opencode -cat .mimir/opencode.jsonc +pnpm exec mimir serve-mcp ``` -Copy or merge the generated snippet into the OpenCode config layer you use for the project. - -### Cline - -From the target repository root: - -```bash -pnpm exec kb setup -pnpm exec kb install-agent --agents cline -cat .mimir/cline-mcp.json -``` +The MCP server exposes `mimir_status`, `mimir_search`, `mimir_ask`, `mimir_audit`, +`mimir_evaluate`, `mimir_usage_report`, and `mimir_security_audit`. The LLM does not need to know +about LanceDB or the raw file layout; it asks Mimir for ranked passages, cited context, local recall +gates, or metadata-only usage summaries and uses the returned citations. -Cline can discover project skills from `.cline/skills/`. Add the generated MCP JSON under -`mcpServers` in Cline's MCP configuration when tool access is needed. - -For other MCP clients that cannot set `cwd`, set `MIMIR_PROJECT_ROOT=/absolute/path/to/repository` -when launching `kb serve-mcp`. - -### Agent Demo - -From a repository that already ran `kb setup` and has Mimir wired into the current agent, ask: - -```plain text -Use Mimir to audit the local evidence. First run mimir_status and mimir_audit. Then search for -"offline retrieval approval" and produce a cited Markdown report. Do not rely on memory if Mimir -does not contain enough evidence. -``` - -Agents that support skill folders should also load: - -```plain text -.mimir/skills/mimir/ -.mimir/skills/mimir-markdown-report/ -``` - -The Markdown report skill writes reports under `.mimir/reports/` by default, which stays ignored by -Git. - -Print the bundled skill path from the installed package: - -```bash -pnpm exec kb skill-path -``` +Per-agent setup details live in [`docs/agent-integration.md`](./docs/agent-integration.md). ## Audio Summaries @@ -463,9 +384,9 @@ Mimir includes a plug-and-play text-to-speech path for listenable summaries. For the same quality path as the global Voice Forge skill, install `edge-tts` and render MP3: ```bash -pnpm exec kb audio --doctor +pnpm exec mimir audio --doctor pipx install edge-tts -pnpm exec kb audio /tmp/MIMIR-SUMMARY-project.txt \ +pnpm exec mimir audio /tmp/MIMIR-SUMMARY-project.txt \ --engine edge \ --out .mimir/audio/project-summary.mp3 ``` @@ -474,11 +395,11 @@ The Edge path uses the online Microsoft Edge TTS service through the `edge-tts` when sending the narration text to that service is acceptable. MP3 output requires explicit `--engine edge` for this reason. -By default, `kb audio` uses the Transformers.js WAV path. For confidential or air-gapped work, -preload Transformers.js-compatible model files and render WAV offline: +By default, `mimir audio` uses the Transformers.js WAV path. For confidential or air-gapped work, +preload Transformers.js-compatible model files with non-sensitive text, then render WAV offline: ```bash -pnpm exec kb audio /tmp/MIMIR-SUMMARY-project.txt \ +pnpm exec mimir audio /tmp/MIMIR-SUMMARY-project.txt \ --engine transformers \ --offline \ --model-path .mimir/models/tts \ @@ -497,6 +418,9 @@ pnpm exec mimir-tts render /tmp/MIMIR-SUMMARY-project.txt \ The default standalone engine is `transformers`. The default Transformers.js model is `Xenova/mms-tts-fra`. Override it with `--model` or `MIMIR_TTS_MODEL`. +See [`docs/offline-tts-preload.md`](./docs/offline-tts-preload.md) for the exact preload and +offline-check workflow. + ## Data Boundary The package code lives in `node_modules` or in this repository. Project data stays in the repository @@ -511,7 +435,7 @@ your-project/ .kb/access.log # metadata-only access log ``` -The package never ships project documents. `kb setup` adds gitignore entries for `.kb/`, +The package never ships project documents. `mimir setup` adds gitignore entries for `.kb/`, `.mimir/`, and `private/**`. Generated indexes, agent files, and raw documents stay local to the target repository. @@ -526,19 +450,21 @@ Mimir is designed for private repositories and sensitive local evidence. - Redaction before indexing: common secrets and identifiers are redacted before chunks are embedded and stored. - Metadata-only access logs: query hashes and action metadata are logged, not raw queries. +- Metadata-only usage reports: `mimir usage-report --days 7` summarizes recent local activity + without exposing query text or local paths. - MCP is read-focused and bounded by `mcpMaxTopK`. - Generated local state is ignored by Git. Run: ```bash -pnpm exec kb security-audit --strict +pnpm exec mimir security-audit --strict ``` Remove the generated vector index: ```bash -pnpm exec kb destroy-index --yes +pnpm exec mimir destroy-index --yes ``` `destroy-index` does not securely erase SSD or copy-on-write storage. For strong deletion @@ -583,13 +509,16 @@ Custom UTF-8 text extensions can be enabled without changing code: Or through: ```bash -KB_INCLUDE_EXTENSIONS=".transcript,.evidence" pnpm exec kb ingest +KB_INCLUDE_EXTENSIONS=".transcript,.evidence" pnpm exec mimir ingest ``` -Images, scans, audio/video files, old proprietary Office binaries such as `.doc`, and other formats -that are not listed should be OCRed, transcribed, converted, or exported to text/PDF/HTML first. -Mimir intentionally avoids pretending that every binary format can be indexed safely without -extraction logic. +Images, audio/video files, old proprietary Office binaries such as `.doc`, and other formats that are +not listed are not useful to Mimir as-is. They can still be valuable source evidence, but they should +be OCRed, transcribed, converted, or exported to text/PDF/HTML first. `mimir audit --unsupported` +prints per-file recommendations for these skipped formats. Scanned PDFs can use an explicit +`pdfOcrCommand` wrapper when you accept running local OCR tooling. If a supported file parses to no +text, `mimir ingest --json` reports it under `emptyTextFiles`. Mimir intentionally avoids pretending +that every binary format can be indexed safely without extraction logic. Secret-like files such as `.env`, `.npmrc`, private keys, and certificates are skipped by default. Convert safe examples to a normal text format before ingestion. @@ -620,13 +549,15 @@ Default `.kb/config.json`: }, "accessLog": true, "mcpMaxTopK": 10, - "topK": 5, + "topK": 8, "chunkSize": 1200, - "chunkOverlap": 150, + "chunkOverlap": 200, "maxFileBytes": 50000000, "ingestConcurrency": 4, "embeddingBatchSize": 32, - "includeExtensions": [] + "includeExtensions": [], + "pdfOcrCommand": [], + "pdfOcrTimeoutMs": 120000 } ``` @@ -651,66 +582,27 @@ Environment overrides: - `KB_INGEST_CONCURRENCY` - `KB_EMBEDDING_BATCH_SIZE` - `KB_INCLUDE_EXTENSIONS` +- `KB_PDF_OCR_COMMAND` as a JSON array, for example `["mimir-pdf-ocr","{input}"]` +- `KB_PDF_OCR_TIMEOUT_MS` + +`pdfOcrCommand` is opt-in and only runs when normal PDF text extraction returns no text. The command +is executed without a shell, receives `MIMIR_PDF_PATH`, replaces `{input}` placeholders with the PDF +path, and must print UTF-8 text to stdout. Standalone image files are not OCRed directly by Mimir +Core; OCR them to a supported text file or convert them to an OCRed PDF before ingestion. ## CLI Reference Mimir ships two CLIs: -- `kb`: the main local RAG, MCP, skills, security, and audio command. -- `mimir-tts`: the standalone text-to-speech renderer used by `kb audio`. +- `mimir`: the main local RAG, MCP, skills, security, and audio command. `kb` remains a legacy alias for compatibility. +- `mimir-tts`: the standalone text-to-speech renderer used by `mimir audio`. -### Main Workflow +Most users start with `mimir setup`, `mimir doctor`, `mimir ingest`, `mimir search`, `mimir ask`, and +`mimir security-audit`. Use `mimir models pull --enable` before semantic offline ingestion when +remote model download is acceptable, and `mimir ingest --rebuild` after switching embedding provider +or model. -| Command | Use it when | -| --- | --- | -| `kb setup` | Initialize Mimir, install the agent kit, run doctor, and ingest when safe. | -| `kb init` | Create `.kb/config.json`, `.kb/sources.txt`, `private/`, and Git ignore rules. | -| `kb doctor` | Diagnose setup, index freshness, security warnings, and the next command to run. | -| `kb doctor --fix` | Create missing scaffolding, install skills/MCP config, and rebuild stale indexes when safe. | -| `kb ingest` | Parse source files, redact, chunk, embed, and rebuild the local LanceDB index. | -| `kb audit` | Check whether supported source files are missing from or stale in the index. | -| `kb audit --unsupported` | List files skipped because they are unsupported, too large, or secret-like. | -| `kb search ""` | Retrieve ranked passages without asking an LLM to write an answer. | -| `kb ask ""` | Return cited retrieval context for an agent or trusted model runtime. | -| `kb security-audit` | Inspect privacy posture: telemetry, providers, redaction, Git ignore, MCP. | -| `kb status` | Print raw config paths, provider settings, and indexed chunk count. | - -### Agent Integration - -| Command | Use it when | -| --- | --- | -| `kb install-skill` | Copy portable agent skills and an MCP config snippet into `.mimir/`. | -| `kb skill-path` | Print the package-bundled skill path for agents that load installed package skills. | -| `kb serve-mcp` | Start the MCP stdio server for compatible agents. | - -### Maintenance And Safety - -| Command | Use it when | -| --- | --- | -| `kb destroy-index --yes` | Delete generated `.kb/storage` index files. | -| `kb security-audit --strict` | Fail the command when privacy warnings are present. | - -### Audio - -| Command | Use it when | -| --- | --- | -| `kb audio --doctor` | Check TTS runtime readiness. | -| `kb audio --engine transformers --offline --out .mimir/audio/name.wav` | Render a confidential/offline WAV. | -| `kb audio --engine edge --out .mimir/audio/name.mp3` | Render a higher-quality online Edge MP3. | -| `mimir-tts doctor --json` | Inspect the standalone TTS package. | -| `mimir-tts render --offline --out .mimir/audio/name.wav` | Render directly through the TTS package. | - -### Important Options - -| Option | Applies to | Meaning | -| --- | --- | --- | -| `--top-k ` | `search`, `ask` | Number of passages to return. | -| `--json` | `doctor`, `audit`, `security-audit`, `audio --doctor`, `mimir-tts doctor` | Print machine-readable JSON. | -| `--unsupported` | `audit` | List skipped file paths and reasons. | -| `--strict` | `security-audit` | Exit non-zero when warnings exist. | -| `--offline` | `audio`, `mimir-tts render` | Disable remote model downloads and force the local Transformers.js path. | -| `--allow-remote-models` | `audio`, `mimir-tts render` | Explicitly allow model downloads for Transformers.js. | -| `--engine edge` | `audio`, `mimir-tts render` | Use online Edge TTS for MP3 output. | +The full command and option table lives in [`docs/cli-reference.md`](./docs/cli-reference.md). ## Library API @@ -722,157 +614,25 @@ const results = await search("vendor invoice status") const answer = await ask("What documents support the project timeline?") ``` +The full public TypeScript API reference lives in +[`docs/api-reference.md`](./docs/api-reference.md). + ## Troubleshooting -Use `kb doctor` first. It is the shortest path to the next useful action: +Use `mimir doctor` first. It is the shortest path to the next useful action: ```bash -pnpm exec kb doctor +pnpm exec mimir doctor ``` Use `doctor --fix` when you want Mimir to repair safe setup issues automatically: ```bash -pnpm exec kb doctor --fix -``` - -### `kb doctor` Says The Project Is Not Initialized - -Run: - -```bash -pnpm exec kb setup -pnpm exec kb doctor -``` - -Commit only safe scaffolding if this is a real repository. Do not commit private documents, -`.kb/storage`, `.mimir/`, env files, or credentials. - -### No Files Are Indexed - -Check that supported files exist under `private/`: - -```bash -find private -maxdepth 2 -type f -pnpm exec kb ingest -pnpm exec kb doctor -``` - -If documents live elsewhere, add one path per line to `.kb/sources.txt`. Relative paths resolve from -the project root. - -If files exist but are not supported yet, inspect the skipped inventory: - -```bash -pnpm exec kb audit --unsupported +pnpm exec mimir doctor --fix ``` -Then either convert them to a supported format, OCR/transcribe them, or add a safe custom UTF-8 text -extension with `includeExtensions` / `KB_INCLUDE_EXTENSIONS`. - -### Search Returns Weak Results - -The default `local-hash` provider is dependency-light and offline, but it is lexical/hash retrieval, -not semantic retrieval. - -For better semantic retrieval, configure Transformers.js embeddings and preload the model when -working offline: - -```json -{ - "embeddingProvider": "transformers", - "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", - "embeddingModelPath": ".mimir/models", - "transformersAllowRemoteModels": false -} -``` - -Switching providers requires a full re-ingest: - -```bash -pnpm exec kb ingest -pnpm exec kb doctor -``` - -### `kb audit` Reports Missing Or Stale Files - -Run: - -```bash -pnpm exec kb ingest -pnpm exec kb audit -``` - -Or let doctor perform the safe rebuild: - -```bash -pnpm exec kb doctor --fix -``` - -Mimir rebuilds the index on each ingest. The `--rebuild` flag is accepted for compatibility, but -ingest already rebuilds. - -### `security-audit --strict` Fails - -Read the warning lines. Common causes: - -- `.kb/`, `.mimir/`, or `private/**` are not ignored by Git. -- Redaction was disabled. -- Transformers.js remote model loading was enabled. - -Run the safe repair command if Git ignore entries are missing: - -```bash -pnpm exec kb doctor --fix -pnpm exec kb security-audit --strict -``` - -### MP3 Audio Fails Without `--engine edge` - -This is intentional. MP3 output uses online Edge TTS and requires explicit consent: - -```bash -pnpm exec kb audio /tmp/summary.txt \ - --engine edge \ - --out .mimir/audio/summary.mp3 -``` - -For confidential or offline work, use WAV: - -```bash -pnpm exec kb audio /tmp/summary.txt \ - --engine transformers \ - --offline \ - --out .mimir/audio/summary.wav -``` - -### Edge TTS Is Not Installed - -Install the external CLI: - -```bash -pipx install edge-tts -pnpm exec kb audio --doctor -``` - -Only use Edge TTS when sending narration text to the online service is acceptable. - -### `mimir-tts --offline` Cannot Render - -Offline rendering requires model files to already exist under `.mimir/models/tts` or the path passed -with `--model-path`. - -For a first online setup on non-sensitive text: - -```bash -pnpm exec mimir-tts render /tmp/test.txt --out .mimir/audio/test.wav -``` - -Then reuse the cached files with: - -```bash -pnpm exec mimir-tts render /tmp/test.txt --offline --out .mimir/audio/test.wav -``` +Common fixes for empty indexes, weak search, strict security audit failures, and TTS setup live in +[`docs/troubleshooting.md`](./docs/troubleshooting.md). ## Dependency Footprint @@ -885,7 +645,7 @@ core features: | LanceDB | Local vector storage and nearest-neighbor retrieval. | | MCP SDK | MCP server for compatible agents. | | fast-glob | Safe source-file discovery. | -| unpdf, html-to-text, yaml, fflate | Document parsing for PDF, HTML, YAML, Office/OpenDocument ZIP files. | +| unpdf, mammoth, xlsx, html-to-text, yaml, fflate | Document parsing for PDF, Office, HTML, YAML, OpenDocument, and EPUB files. | | commander, zod, picocolors | CLI, config validation, readable terminal output. | Removing more dependencies is possible only by dropping features or replacing them with smaller @@ -895,7 +655,7 @@ choose `local-hash`, while preserving richer parsing, MCP support, and optional ## Example Test Workspace This repository includes a synthetic example under -[`packages/mimir/examples/sovereign-rag-demo`](./packages/mimir/examples/sovereign-rag-demo). It can +[`packages/mimir-core/examples/sovereign-rag-demo`](./packages/mimir-core/examples/sovereign-rag-demo). It can be used to test ingestion, retrieval, `security-audit`, and custom text extensions without using private documents. @@ -903,10 +663,12 @@ From a local checkout: ```bash pnpm build -cd packages/mimir/examples/sovereign-rag-demo +cd packages/mimir-core/examples/sovereign-rag-demo node ../../dist/cli.js security-audit node ../../dist/cli.js ingest node ../../dist/cli.js search "offline retrieval approval" +node ../../dist/cli.js evaluate --golden golden-queries.json +node ../../dist/cli.js evaluate --golden golden-queries.json --fail-under 1 node ../../dist/cli.js audit ``` @@ -926,13 +688,17 @@ Useful filtered commands: ```bash pnpm --filter @jcode.labs/mimir test +pnpm --filter @jcode.labs/mimir mcp:smoke pnpm --filter @jcode.labs/mimir-tts test +pnpm --filter @jcode.labs/mimir-app build +pnpm --filter @jcode.labs/mimir-landing build pnpm --filter @jcode.labs/mimir build pnpm --filter @jcode.labs/mimir-tts build ``` -`packages/mimir/dist/` and `packages/mimir-tts/dist/` are committed. After changing TypeScript -sources, run: +`packages/mimir-core/dist/` and `packages/mimir-tts/dist/` are committed. `packages/mimir-app/dist/` +and `packages/mimir-landing/dist/` are ignored build artifacts. After changing TypeScript sources in +published packages, run: ```bash pnpm build @@ -957,20 +723,32 @@ pnpm build Use a local checkout in another repository: ```bash -pnpm add -D file:../jcode-mimir/packages/mimir +pnpm add -D file:../jcode-mimir/packages/mimir-core ``` Create a local npm tarball: ```bash pnpm build -pnpm --dir packages/mimir pack +pnpm --dir packages/mimir-core pack ``` ## Supporting Documents - [`SECURITY-HARDENING.md`](./SECURITY-HARDENING.md): threat model, offline operation, release verification, and high-assurance deployment notes. +- [`docs/api-reference.md`](./docs/api-reference.md): public TypeScript API functions, result types, + and MCP tool inputs. +- [`docs/fr-eu-sovereign-positioning.md`](./docs/fr-eu-sovereign-positioning.md): bounded FR/EU + sovereignty, GDPR, AI Act, and legal-vertical positioning. +- [`docs/source-boundary.md`](./docs/source-boundary.md): what the public MIT repository contains, + and what must stay outside Git. +- [`docs/commercial-distribution.md`](./docs/commercial-distribution.md): public-safe commercial + distribution rules for signed builds, licenses, and support. +- [`docs/offline-tts-preload.md`](./docs/offline-tts-preload.md): preload and verify the offline + Transformers.js TTS cache before rendering confidential audio. +- [`docs/payment-webhook-architecture.md`](./docs/payment-webhook-architecture.md): direct-download + checkout, webhook, and local-license architecture for future commercial app distribution. - [`docs/ux-dx-audit.md`](./docs/ux-dx-audit.md): current UX/DX findings, fixes, and remaining product risks. diff --git a/RELEASING.md b/RELEASING.md index 0616e7b..2dafce8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,7 +11,7 @@ and approved by Jean-Baptiste Thery through the protected `npm-publish` environm 2. Wait for the required CI checks to pass. 3. Merge only after approval and green checks. 4. Trigger the `Publish npm` workflow manually from `main`. -5. Enter the version already committed in `packages/mimir/package.json` and +5. Enter the version already committed in `packages/mimir-core/package.json` and `packages/mimir-tts/package.json`. 6. Approve the protected `npm-publish` environment when GitHub asks for review. diff --git a/SECURITY-HARDENING.md b/SECURITY-HARDENING.md index 58f15b7..73057e7 100644 --- a/SECURITY-HARDENING.md +++ b/SECURITY-HARDENING.md @@ -212,7 +212,7 @@ workspace packages with provenance: ```bash pnpm --dir packages/mimir-tts publish --access public --provenance --no-git-checks -pnpm --dir packages/mimir publish --access public --provenance --no-git-checks +pnpm --dir packages/mimir-core publish --access public --provenance --no-git-checks ``` Release artifacts include: diff --git a/SECURITY.md b/SECURITY.md index 4472845..6c3d77d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,4 +18,5 @@ or private document disclosure. ## Data Boundary Mimir is designed to index local project documents. Raw project documents, -`.kb/storage/`, environment files, and credentials must remain outside commits. +`.kb/`, `.mimir/`, environment files, credentials, customer records, and commercial validation notes +must remain outside commits. diff --git a/biome.json b/biome.json index 749fded..32b45b6 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,13 @@ "useIgnoreFile": true }, "files": { - "includes": ["*.cjs", "*.json", "scripts/**/*.mjs", "packages/*/src/**/*.ts"] + "includes": [ + "*.cjs", + "*.json", + "scripts/**/*.mjs", + "packages/*/scripts/**/*.mjs", + "packages/*/src/**/*.{ts,tsx}" + ] }, "formatter": { "enabled": true, diff --git a/docs/agent-integration.md b/docs/agent-integration.md new file mode 100644 index 0000000..faef8e5 --- /dev/null +++ b/docs/agent-integration.md @@ -0,0 +1,178 @@ +# Agent Integration + +Mimir ships with portable agent skills and a standard MCP server. + +If `mimir setup` was not used, install the agent kit into a repository: + +```bash +pnpm exec mimir install-skill +``` + +This creates: + +```plain text +.mimir/skills/mimir/SKILL.md +.mimir/skills/mimir-audio-summary/SKILL.md +.mimir/skills/mimir-markdown-report/SKILL.md +.mimir/skills/mimir-legal-dossier/SKILL.md +.mimir/mcp.json +.mimir/claude-mcp-server.json +.mimir/codex-mcp.toml +.mimir/kimi-mcp.json +.mimir/opencode.jsonc +.mimir/cline-mcp.json +.mimir/agent-setup.md +.mimir/README.md +``` + +Agents that support skill folders can load `.mimir/skills/mimir/` for deep local RAG usage. Load +`.mimir/skills/mimir-audio-summary/` only when an optional spoken summary is needed. Load +`.mimir/skills/mimir-markdown-report/` when the user asks for a cited Markdown report, dossier, +audit memo, or planning note. Load `.mimir/skills/mimir-legal-dossier/` when the user asks for a +legal chronology, clause review, evidence table, or professional-review handoff. Other agents can +read the generated `.mimir/README.md` and use the MCP config snippet. + +For native discovery in a specific agent, install only the agent you use: + +```bash +pnpm exec mimir install-agent --agents claude +pnpm exec mimir install-agent --agents kimi +pnpm exec mimir install-agent --agents claude,codex,kimi,opencode,cline +``` + +By default, `install-agent` writes project-scope skill folders as links back to `.mimir/skills/`. +That keeps one original version of every skill. Add `--scope user` for global installations, or +`--mode copy` only when an agent/runtime cannot follow symlinked skill directories. + +| Agent | Project skill directory | Main MCP helper | +| --- | --- | --- | +| Claude Code | `.claude/skills/` | `.mimir/claude-mcp-server.json` | +| Codex | `.codex/skills/` plus `skills.config` | `.mimir/codex-mcp.toml` | +| Kimi Code CLI | `.kimi/skills/` | `.mimir/kimi-mcp.json` | +| OpenCode | `.opencode/skills/` | `.mimir/opencode.jsonc` | +| Cline | `.cline/skills/` | `.mimir/cline-mcp.json` | + +Start the MCP server from the repository root: + +```bash +pnpm exec mimir serve-mcp +``` + +For a repository-level protocol smoke test, run the synthetic demo client: + +```bash +pnpm --filter @jcode.labs/mimir mcp:smoke +``` + +MCP tools exposed: + +- `mimir_status` +- `mimir_search` +- `mimir_ask` +- `mimir_audit` +- `mimir_evaluate` +- `mimir_usage_report` +- `mimir_security_audit` + +This MCP layer is the recommended way to let any compatible LLM or agent query the same local +knowledge base. The LLM does not need to know about LanceDB or the raw file layout; it asks Mimir for +ranked passages, cited context, local recall gates, or metadata-only usage summaries and uses the +returned citations. + +## Claude Code + +From the target repository root: + +```bash +pnpm exec mimir setup +pnpm exec mimir install-agent --agents claude +claude mcp add-json --scope local mimir "$(cat .mimir/claude-mcp-server.json)" +``` + +Claude Code provides the active project path to MCP servers through `CLAUDE_PROJECT_DIR`; Mimir uses +that value when serving MCP, so the same installed npm package can work inside each repository where +`mimir setup` was run. Keep the MCP scope local unless you intentionally want to share the server +config. + +## Codex + +From the target repository root: + +```bash +pnpm exec mimir setup +pnpm exec mimir install-agent --agents codex +cat .mimir/codex-mcp.toml +``` + +Copy the printed TOML into `~/.codex/config.toml` or another trusted Codex config layer. The snippet +contains the repository `cwd`, the Mimir MCP server, and `skills.config` entries for the bundled +skills. + +## Kimi Code CLI + +From the target repository root: + +```bash +pnpm exec mimir setup +pnpm exec mimir install-agent --agents kimi +kimi --mcp-config-file .mimir/kimi-mcp.json +``` + +Kimi can discover project skills from `.kimi/skills/`. The MCP config can also be installed in +Kimi's global MCP file if you intentionally want a global setup. If you prefer not to create a +`.kimi/skills/` discovery folder, Kimi can also be launched directly with +`kimi --skills-dir .mimir/skills --mcp-config-file .mimir/kimi-mcp.json`. + +## OpenCode + +From the target repository root: + +```bash +pnpm exec mimir setup +pnpm exec mimir install-agent --agents opencode +cat .mimir/opencode.jsonc +``` + +Copy or merge the generated snippet into the OpenCode config layer you use for the project. + +## Cline + +From the target repository root: + +```bash +pnpm exec mimir setup +pnpm exec mimir install-agent --agents cline +cat .mimir/cline-mcp.json +``` + +Cline can discover project skills from `.cline/skills/`. Add the generated MCP JSON under +`mcpServers` in Cline's MCP configuration when tool access is needed. + +For other MCP clients that cannot set `cwd`, set `MIMIR_PROJECT_ROOT=/absolute/path/to/repository` +when launching `mimir serve-mcp`. + +## Agent Demo + +From a repository that already ran `mimir setup` and has Mimir wired into the current agent, ask: + +```plain text +Use Mimir to audit the local evidence. First run mimir_status and mimir_audit. Then search for +"offline retrieval approval" and produce a cited Markdown report. Do not rely on memory if Mimir +does not contain enough evidence. +``` + +Agents that support skill folders should also load: + +```plain text +.mimir/skills/mimir/ +.mimir/skills/mimir-markdown-report/ +``` + +The Markdown report skill writes reports under `.mimir/reports/` by default, which stays ignored by +Git. + +Print the bundled skill path from the installed package: + +```bash +pnpm exec mimir skill-path +``` diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..abbc183 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,350 @@ +# Mimir Core API Reference + +This reference covers the public TypeScript API exported by `@jcode.labs/mimir`. It is for +developers embedding Mimir Core in local tools, scripts, desktop shells, MCP launchers, or tests. + +Mimir Core does not call an LLM and does not write final generated answers. Retrieval APIs return +cited local context; answer synthesis belongs to the agent or model runtime you choose around that +context. + +## Import Surface + +Use named imports only: + +```ts +import { ask, doctor, ingest, search, securityAudit } from "@jcode.labs/mimir" +``` + +Most project-scoped functions accept an optional `cwd` pointing at the target workspace. If omitted, +Mimir resolves the project from `process.cwd()`. + +```ts +await ingest({ cwd: "/path/to/local/workspace" }) +const results = await search("offline approval", { cwd: "/path/to/local/workspace", topK: 5 }) +``` + +## Project Setup + +### `initProject(cwd?)` + +Creates the local Mimir scaffolding: + +- `.kb/config.json` +- `.kb/sources.txt` +- `private/` +- `.gitignore` entries for `.kb/`, `.mimir/`, and `private/**` + +```ts +import { initProject } from "@jcode.labs/mimir" + +const created = await initProject("/path/to/workspace") +``` + +Returns `string[]` with relative paths created or updated. + +### `setupProject(options?)` + +Runs the normal first-run workflow: initialize the project, install the portable agent kit, run +doctor, and auto-ingest only when supported files are present and the privacy posture has no +warnings. + +```ts +import { setupProject } from "@jcode.labs/mimir" + +const result = await setupProject({ cwd: "/path/to/workspace", ingest: true }) +``` + +Useful result fields: + +| Field | Meaning | +| --- | --- | +| `created` | Relative scaffolding files created by setup. | +| `agentKit` | Paths to generated skills and MCP helper files. | +| `ingested` | `IngestResult` when auto-ingest ran; otherwise `null`. | +| `doctor` | Final readiness report. | +| `nextSteps` | User-facing next actions. | + +### `loadConfig(start?)` + +Finds `.kb/config.json` by walking upward from `start`, applies defaults and `KB_*` environment +overrides, and returns resolved absolute paths. + +```ts +import { loadConfig } from "@jcode.labs/mimir" + +const config = await loadConfig("/path/to/workspace/subdir") +console.log(config.projectRoot) +``` + +## Ingestion And Retrieval + +### `ingest(options?)` + +Discovers supported source files, parses them, redacts configured patterns, chunks text, embeds +chunks, and writes the local LanceDB table. + +```ts +import { ingest } from "@jcode.labs/mimir" + +const result = await ingest({ cwd: "/path/to/workspace" }) +``` + +Use `rebuild: true` after changing the embedding provider or model: + +```ts +await ingest({ cwd: "/path/to/workspace", rebuild: true }) +``` + +`IngestResult` includes discovered/supported/skipped file counts, rebuilt/reused file counts, +unsupported-extension summaries, redaction counts, chunk count, `emptyTextFiles` for supported files +that produced no indexable text, and per-file parsing errors. + +### `audit(cwd?)` + +Compares supported files on disk with the current index. + +```ts +import { audit } from "@jcode.labs/mimir" + +const report = await audit("/path/to/workspace") +``` + +Use `missingFromIndex` and `staleInIndex` to decide whether to run `ingest` or `ingest({ rebuild: +true })`. + +### `search(query, options?)` + +Returns ranked cited passages. Mimir combines vector candidates with bounded lexical scoring. + +```ts +import { search } from "@jcode.labs/mimir" + +const passages = await search("Who approved offline operation?", { + cwd: "/path/to/workspace", + topK: 8, +}) +``` + +Each `SearchResult` includes: + +| Field | Meaning | +| --- | --- | +| `relativePath` | Source path relative to the Mimir project root. | +| `source` | Source category used by discovery. | +| `chunkIndex` | Chunk number inside that source file. | +| `text` | Retrieved redacted chunk text. | +| `distance` | Vector distance when available; `null` for lexical-only rows. | + +### `ask(query, options?)` + +Returns retrieval context formatted for an agent or LLM, plus the same cited source list as +`search`. + +```ts +import { ask } from "@jcode.labs/mimir" + +const answer = await ask("What evidence supports the project timeline?", { + cwd: "/path/to/workspace", +}) +``` + +`AskResult.answer` is not an LLM synthesis. It is a deterministic retrieval-only text block that +lists cited passages. + +## Semantic Embeddings + +### `pullEmbeddingModel(config)` + +Downloads or warms the configured Transformers.js embedding model into `embeddingModelPath`. + +```ts +import { loadConfig, pullEmbeddingModel } from "@jcode.labs/mimir" + +const config = await loadConfig("/path/to/workspace") +await pullEmbeddingModel(config) +``` + +This intentionally allows remote model loading for the bootstrap call. Keep +`transformersAllowRemoteModels` false for confidential indexing after the model files are present. + +### `enableSemanticEmbeddings(cwd?)` + +Switches `.kb/config.json` to the safe semantic path: + +- `embeddingProvider: "transformers"` +- existing or default `embeddingModel` +- existing or default `embeddingModelPath` +- `transformersAllowRemoteModels: false` + +```ts +import { enableSemanticEmbeddings, ingest } from "@jcode.labs/mimir" + +await enableSemanticEmbeddings("/path/to/workspace") +await ingest({ cwd: "/path/to/workspace", rebuild: true }) +``` + +The CLI shortcut `mimir models pull --enable` combines model preload with this config update. + +## Readiness And Safety + +### `doctor(cwd?)` + +Returns a readiness report combining setup state, index freshness, security warnings, and next +steps. + +```ts +import { doctor } from "@jcode.labs/mimir" + +const report = await doctor("/path/to/workspace") +if (!report.ready) { + console.log(report.nextSteps) +} +``` + +### `securityAudit(cwd?)` + +Returns local privacy posture: provider settings, redaction status, access-log behavior, generated +state Git ignore coverage, MCP bounds, and warnings. + +```ts +import { securityAudit } from "@jcode.labs/mimir" + +const report = await securityAudit("/path/to/workspace") +``` + +`accessLog.storesRawQueries` is always `false`. Mimir's access log stores query hashes and metadata, +not raw query strings. + +### `accessLogUsageReport(options?)` + +Summarizes recent metadata-only access-log activity. It returns counts by action, unique query-hash +count, average result count, invalid-line count, and the latest event timestamp without exposing raw +queries or local paths. + +```ts +import { accessLogUsageReport } from "@jcode.labs/mimir" + +const report = await accessLogUsageReport({ cwd: "/path/to/workspace", days: 7 }) +``` + +### `redactText(input, config)` + +Applies built-in and custom redaction patterns to text before indexing. + +```ts +import { loadConfig, redactText } from "@jcode.labs/mimir" + +const config = await loadConfig("/path/to/workspace") +const redacted = redactText("contact: user@example.com", config) +``` + +Returns `{ text, counts }`. + +### `destroyIndex(cwd?)` + +Deletes generated `.kb/storage` index files. + +```ts +import { destroyIndex } from "@jcode.labs/mimir" + +await destroyIndex("/path/to/workspace") +``` + +This does not make forensic deletion claims. Use encrypted volumes and key destruction for stronger +at-rest guarantees. + +## Agent And MCP Integration + +### `installSkill(options?)` + +Installs the portable Mimir skill pack and MCP helper files under `.mimir/`. + +```ts +import { installSkill } from "@jcode.labs/mimir" + +const result = await installSkill({ cwd: "/path/to/workspace" }) +``` + +The installed skills are: + +- `mimir` +- `mimir-audio-summary` +- `mimir-markdown-report` +- `mimir-legal-dossier` + +### `installAgentSkills(options?)` + +Creates native agent discovery folders for selected agents and links or copies the `.mimir/skills` +source. + +```ts +import { installAgentSkills } from "@jcode.labs/mimir" + +await installAgentSkills({ + cwd: "/path/to/workspace", + agents: ["claude", "codex"], + scope: "project", + mode: "link", +}) +``` + +Supported agents are exported as `SUPPORTED_AGENT_TARGETS`. + +### `parseAgentTargets(value)` + +Parses CLI-style comma-separated agent names into supported agent identifiers. + +```ts +import { parseAgentTargets } from "@jcode.labs/mimir" + +const agents = parseAgentTargets("claude,codex,kimi") +``` + +### `serveMcp(cwd?)` + +Starts the MCP stdio server. It is normally called by the CLI, not directly inside a long-running +application process. + +```ts +import { serveMcp } from "@jcode.labs/mimir" + +await serveMcp("/path/to/workspace") +``` + +MCP tools exposed by the server: + +| Tool | Input | +| --- | --- | +| `mimir_status` | `{}` | +| `mimir_search` | `{ query: string, topK?: number }` | +| `mimir_ask` | `{ query: string, topK?: number }` | +| `mimir_audit` | `{}` | +| `mimir_evaluate` | `{ goldenPath: string, topK?: number, failUnder?: number }` | +| `mimir_usage_report` | `{ days?: number }` | +| `mimir_security_audit` | `{}` | + +`topK` is bounded by `mcpMaxTopK` from config. `mimir_evaluate` also requires `goldenPath` to stay +inside the MCP project root. + +## Package Manager Helpers + +### `detectPackageManager(cwd?)` + +Detects `pnpm`, `npm`, `yarn`, or `bun` from package metadata and lockfiles. + +### `mimirCommand(cwd, args)` + +Builds the package-manager-specific command that runs `mimir`. + +```ts +import { mimirCommand } from "@jcode.labs/mimir" + +const command = await mimirCommand("/path/to/workspace", ["doctor"]) +console.log(command.display) +``` + +`kbCommand` remains available as a legacy compatibility alias. + +## Version + +`VERSION` exports the package version compiled into the package. diff --git a/docs/app-distribution.md b/docs/app-distribution.md new file mode 100644 index 0000000..4553ace --- /dev/null +++ b/docs/app-distribution.md @@ -0,0 +1,117 @@ +# App Distribution + +Mimir app releases are planned as direct downloads and sideloadable installers. Do not design the +release path around App Store or Play Store review, hosted store accounts, or store license flows. + +## Channels + +| Platform | Initial artifact | Notes | +| --- | --- | --- | +| macOS | `.dmg` plus `.app` bundle | Requires Apple Developer signing and notarization before public distribution. | +| Windows | NSIS and MSI installers | Requires Authenticode signing; OV is acceptable for the first public release. | +| Linux | AppImage and Debian package | Signing/checksum publication still matters even when OS signing differs by distro. | +| Android | APK-style sideload artifact | Keep Play Store assumptions out of copy and release planning. | +| iOS | Deferred | Broad direct installation is constrained; choose a compliant non-store channel before promising it. | + +## Native Build Commands + +Run native packaging explicitly from the app package: + +```bash +pnpm --filter @jcode.labs/mimir-app tauri:build:macos +pnpm --filter @jcode.labs/mimir-app tauri:build:windows +pnpm --filter @jcode.labs/mimir-app tauri:build:linux +pnpm --filter @jcode.labs/mimir-app tauri:android:build +``` + +Desktop bundles can also be built through the manual **Native App Build** GitHub Actions workflow. +It uploads CI artifacts for macOS, Windows, and Linux, but it does not create a release, deploy, or +publish. Public distribution still requires the signing and checksum steps below. + +The root `pnpm build` intentionally validates only the frontend bundle for `packages/mimir-app`. +Native Tauri builds require the platform toolchain, Rust/Cargo, and the platform signing setup. +The Android release script builds APK artifacts for sideload/direct distribution. iOS has no release +script until a compliant non-store channel is selected. + +## Release Requirements + +Before publishing a public direct download: + +- Run `pnpm validate` from the repository root. +- Run `pnpm --filter @jcode.labs/mimir-app release:preflight -- --target ` + on the matching release machine before building native artifacts. +- Run `pnpm --filter @jcode.labs/mimir-app release:preflight:smoke` after changing preflight logic; + it verifies supported targets, rejects iOS release packaging, and checks that secret-bearing + environment values are reported only by variable name. +- Run `pnpm --filter @jcode.labs/mimir-app release:updater-guard` whenever Tauri updater config + changes; `release:preflight` also runs the guard before native packaging. +- Run `pnpm --filter @jcode.labs/mimir-app release:updater-guard:smoke` after changing the guard + logic; it verifies the disabled, placeholder, and fully configured updater paths with temporary + synthetic config files. +- Build the target platform artifact on the matching release machine or CI runner. +- Sign macOS and Windows artifacts with release credentials that are never committed. +- Generate checksums with `pnpm --filter @jcode.labs/mimir-app release:checksums` and publish + `SHA256SUMS` next to every downloadable artifact. +- Generate a download manifest with + `pnpm --filter @jcode.labs/mimir-app release:manifest -- --target ` + after `SHA256SUMS`; publish `mimir-app-release.json` next to the artifacts so the static landing + or release page can render verified direct-download metadata without hardcoded file names. +- Keep generated release artifacts under ignored output folders until an explicit release upload. +- Keep app license private keys outside the repository; only the public license JWK may be injected + into the frontend build. + +## Signing Checklist + +macOS direct downloads require Apple Developer signing and notarization before public release: + +- Run `pnpm --filter @jcode.labs/mimir-app release:preflight -- --target macos`. +- Install or import the Developer ID Application certificate into the release keychain. +- Resolve the signing identity with `security find-identity -v -p codesigning`. +- Pass the identity through `APPLE_SIGNING_IDENTITY` or the Tauri macOS signing config. +- Store Apple account credentials, app-specific password, certificate, and certificate password only + in the release machine keychain or CI secrets. +- Notarize and staple public `.dmg` / `.app` artifacts before publishing. + +Windows direct downloads require Authenticode signing before public release: + +- Run `pnpm --filter @jcode.labs/mimir-app release:preflight -- --target windows`. +- Use an OV certificate first; EV is optional and mainly improves initial SmartScreen reputation. +- Keep the certificate private key in the Windows certificate store, hardware token, or signing + service, not in the repository. +- Configure the release build with the certificate thumbprint, SHA-256 digest, and a trusted + timestamp URL. +- Verify the resulting NSIS/MSI signatures before publishing. + +Linux artifacts do not use the same platform signing flow, but published checksums are still +required for every AppImage and Debian package. Run +`pnpm --filter @jcode.labs/mimir-app release:preflight -- --target linux` on the Linux release +machine before `tauri:build:linux`, or run the manual Native App Build workflow with target `linux` +to produce Linux CI artifacts. + +The manual Native App Build workflow generates `SHA256SUMS` and `mimir-app-release.json` inside the +uploaded native bundle artifact. For local builds, run the checksum and manifest commands after the +native build and before moving files to a public download surface. + +Android APK artifacts require an Android SDK and JDK on the release machine. Run +`pnpm --filter @jcode.labs/mimir-app release:preflight -- --target android` before +`tauri:android:build`. + +## Updater Policy + +Tauri's updater is the right path for direct-download desktop updates, but it must not be configured +with placeholder keys or fake endpoints. + +Enable it only after these inputs exist: + +- A real updater signing keypair generated for Mimir app releases. +- The updater public key committed in Tauri config. +- The private key supplied only through `TAURI_SIGNING_PRIVATE_KEY` or + `TAURI_SIGNING_PRIVATE_KEY_PATH` in the release environment. +- A HTTPS update endpoint or static release manifest URL. +- A signed update manifest generated by the release workflow. + +Until then, updates are manual direct downloads. Do not claim automatic updates in product copy. + +The updater guard enforces the deferred state. It passes while no updater config exists, and fails if +`bundle.createUpdaterArtifacts` or `plugins.updater` is added without a real public key, HTTPS +endpoint, and release signing key environment for desktop packaging. diff --git a/docs/app-sidecar-architecture.md b/docs/app-sidecar-architecture.md new file mode 100644 index 0000000..544a59f --- /dev/null +++ b/docs/app-sidecar-architecture.md @@ -0,0 +1,83 @@ +# App Sidecar Architecture + +## Decision + +The Mimir app embeds Mimir Core through the existing `mimir` CLI/MCP surface, with a packaged +Node sidecar as the intended distribution path. Do not rewrite Mimir Core as Rust bindings for v1. + +## Rationale + +- Mimir Core already owns parsing, redaction, embeddings, LanceDB storage, query, MCP, and audit + behavior. +- Reusing `mimir` keeps the MIT core and the app shell on the same tested implementation. +- A Rust rewrite would duplicate LanceDB and Transformers.js integration risk before product demand + is validated. +- The app can keep a narrow native boundary: project selection, process execution, progress/status, + and local file permissions. + +## Tauri Boundary + +The current app uses a narrow custom Tauri command, `run_mimir_command`, implemented in +`packages/mimir-app/src-tauri/src/lib.rs`. It does not expose a general shell. The command accepts a +fixed enum of Mimir workflows, always prepends `--project-root `, always requests `--json`, and +executes the `mimir` binary from `PATH` or `MIMIR_CLI_BIN`. The native boundary rejects empty, +relative, or non-existent project roots before running the CLI. + +The future packaged sidecar path remains: + +1. Build or package a platform-specific Mimir Core sidecar binary that exposes bounded `mimir` + workflows. +2. Add that binary to `bundle.externalBin` in `packages/mimir-app/src-tauri/tauri.conf.json`. +3. Add `@tauri-apps/plugin-shell` on the frontend and `tauri-plugin-shell` on the Rust side only if + the packaged sidecar needs the official shell-plugin path. +4. Grant only explicit sidecar permissions in + `packages/mimir-app/src-tauri/capabilities/default.json`. +5. Call the sidecar through `Command.sidecar(...)`, with a fixed command allowlist. + +Do not add `externalBin` before the actual sidecar binary exists for the native target triples. +Doing so would make native Tauri builds fail without adding product value. + +Direct-download packaging, signing, and updater constraints live in +[`app-distribution.md`](./app-distribution.md). Keep updater setup deferred until a real release +public key, private signing key path, and HTTPS update endpoint exist. + +## Initial Command Surface + +The app should start with a small allowlist: + +| Workflow | Sidecar command | +| --- | --- | +| Readiness | `mimir doctor --json` | +| Safe repair | `mimir doctor --fix --json` | +| Status | `mimir status --json` | +| Ingest | `mimir ingest --json` | +| Force rebuild | `mimir ingest --rebuild --json` | +| Search | `mimir search "" --json` | +| Ask context | `mimir ask "" --json` | +| Privacy audit | `mimir security-audit --json` | +| Unsupported files | `mimir audit --unsupported --json` | +| Model preload | `mimir models pull --enable --json` | +| Audio report | `mimir audio "" --offline --json` | + +The UI must pass an explicit project root for each selected knowledge base with +`mimir --project-root "" ...` and keep generated state inside that project (`.kb/`, `.mimir/`) +unless the user intentionally chooses another local folder. + +For audio reports, `run_mimir_command` writes the current retrieval report text under ignored +`.mimir/audio/` first, then passes that generated text file to `mimir audio --offline --json`. + +For watched folders, the app does not expose a broader filesystem watcher. It stores an opt-in flag +per registered local project and periodically calls the existing incremental `mimir ingest --json` +workflow through the same bounded command surface. + +The Google Drive connector is the same local path flow with a distinct source label: the user selects +a folder already synchronized by Google Drive for desktop, and the app enables local auto-ingest for +that folder. It does not add OAuth, Drive API calls, or provider credentials to the sidecar surface. + +## Deferred Work + +- Native sidecar binary build pipeline. +- Progress events for long ingests. +- Signed macOS/Windows packaging. +- Tauri updater wiring after release signing keys and update endpoint are ready. +- Hosted cloud connector APIs beyond local sync folders. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2b8e34f..52eb4c6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,8 +1,111 @@ # CLI Reference -The canonical CLI reference now lives in the root README: +Mimir ships two CLIs: -https://github.com/jcode-works/jcode-mimir#cli-reference +- `mimir`: the main local RAG, MCP, skills, security, and audio command. `kb` remains a legacy alias. +- `mimir-tts`: the standalone text-to-speech renderer used by `mimir audio`. -Keep product documentation in the root README so GitHub and npm readers land on the same source of -truth. +## Main Workflow + +| Command | Use it when | +| --- | --- | +| `mimir setup` | Initialize Mimir, install the agent kit, run doctor, and ingest when safe. | +| `mimir init` | Create `.kb/config.json`, `.kb/sources.txt`, `private/`, and Git ignore rules. | +| `mimir doctor` | Diagnose setup, index freshness, security warnings, and the next command to run. | +| `mimir doctor --fix` | Create missing scaffolding, install skills/MCP config, and update stale indexes when safe. | +| `mimir models pull` | Download the configured Transformers.js embedding model into `embeddingModelPath`. | +| `mimir models pull --enable` | Download the embedding model and switch `.kb/config.json` to safe Transformers embeddings. | +| `mimir ingest` | Parse changed source files, redact, chunk, embed, and update the local LanceDB index. | +| `mimir ingest --rebuild` | Force a full re-index, required after switching embedding provider or model. | +| `mimir audit` | Check whether supported source files are missing from or stale in the index. | +| `mimir audit --unsupported` | List files skipped because they are unsupported, too large, or secret-like. | +| `mimir search ""` | Retrieve ranked passages without asking an LLM to write an answer. | +| `mimir ask ""` | Return cited retrieval context for an agent or trusted model runtime. | +| `mimir evaluate --golden golden-queries.json` | Measure retrieval recall against expected source paths. | +| `mimir security-audit` | Inspect privacy posture: telemetry, providers, redaction, Git ignore, MCP. | +| `mimir usage-report` | Summarize metadata-only local access-log activity for recent private dogfooding without query text or local paths. | +| `mimir status` | Print raw config paths, provider settings, and indexed chunk count. | + +## Agent Integration + +| Command | Use it when | +| --- | --- | +| `mimir install-skill` | Copy portable agent skills and an MCP config snippet into `.mimir/`. | +| `mimir skill-path` | Print the package-bundled skill path for agents that load installed package skills. | +| `mimir serve-mcp` | Start the MCP stdio server for compatible agents. | + +## Maintenance And Safety + +| Command | Use it when | +| --- | --- | +| `mimir destroy-index --yes` | Delete generated `.kb/storage` index files. | +| `mimir security-audit --strict` | Fail the command when privacy warnings are present. | + +## Audio + +| Command | Use it when | +| --- | --- | +| `mimir audio --doctor` | Check TTS runtime readiness. | +| `mimir audio /tmp/preload.txt --engine transformers --allow-remote-models --model-path .mimir/models/tts --out .mimir/audio/preload-check.wav` | Preload the TTS model with non-sensitive text. | +| `mimir audio --engine transformers --offline --out .mimir/audio/name.wav` | Render a confidential/offline WAV. | +| `mimir audio --engine edge --out .mimir/audio/name.mp3` | Render a higher-quality online Edge MP3. | +| `mimir-tts doctor --json` | Inspect the standalone TTS package. | +| `mimir-tts render --offline --out .mimir/audio/name.wav` | Render directly through the TTS package. | + +## Important Options + +| Option | Applies to | Meaning | +| --- | --- | --- | +| `--project-root ` | all project-scoped `mimir` commands | Run against a specific local workspace instead of the current directory. | +| `--top-k ` | `search`, `ask`, `evaluate` | Number of passages to return. | +| `--fail-under ` | `evaluate` | Exit non-zero only when recall is below a threshold from `0` to `1`; without this option evaluation remains strict and fails on any miss. | +| `--days ` | `usage-report` | Number of recent days to include in the metadata-only usage summary. | +| `--json` | `doctor`, `ingest`, `search`, `ask`, `evaluate`, `audit`, `usage-report`, `status`, `security-audit`, `audio --doctor`, `mimir-tts doctor` | Print machine-readable JSON. | +| `--unsupported` | `audit` | List skipped file paths and reasons. | +| `--strict` | `security-audit` | Exit non-zero when warnings exist. | +| `--offline` | `audio`, `mimir-tts render` | Disable remote model downloads and force the local Transformers.js path. | +| `--allow-remote-models` | `audio`, `mimir-tts render` | Explicitly allow model downloads for Transformers.js. | +| `--engine edge` | `audio`, `mimir-tts render` | Use online Edge TTS for MP3 output. | + +See [`offline-tts-preload.md`](./offline-tts-preload.md) before using `--offline` on a fully +air-gapped machine. + +## OCR Configuration + +PDF OCR is intentionally configuration-based rather than a default CLI flag. Add a local wrapper that +prints OCR text to stdout: + +```json +{ + "pdfOcrCommand": ["mimir-pdf-ocr", "{input}"], + "pdfOcrTimeoutMs": 120000 +} +``` + +Or set `KB_PDF_OCR_COMMAND` to a JSON array. Mimir only invokes it for PDFs where embedded-text +extraction returns no text. When a supported document still yields no indexable text, +`mimir ingest --json` reports the relative paths under `emptyTextFiles`. + +Standalone image files such as `.png`, `.jpg`, `.heic`, and `.tiff` are not OCRed directly. Keep +OCR tooling local and save extracted text as a supported text file, or convert scans to OCRed PDFs. +`mimir audit --unsupported` prints per-file recommendations for image, audio, video, oversized, and +secret-like skipped files. + +## Retrieval Evaluation Gates + +`mimir evaluate` expects a JSON golden query file with queries and expected relative source paths. +Use the default strict behavior for synthetic examples and release checks: + +```bash +mimir evaluate --golden golden-queries.json +``` + +For private dogfooding, keep the real corpus and golden query file outside Git or under an ignored +local path, then choose an explicit recall threshold: + +```bash +mimir --project-root /path/to/workspace evaluate --golden private/golden-queries.json --fail-under 0.8 --json +``` + +The JSON output includes `embeddingProvider` and `embeddingModel`. Use those fields when comparing a +default local-hash run with a private Transformers semantic run. diff --git a/docs/commercial-distribution.md b/docs/commercial-distribution.md new file mode 100644 index 0000000..8c08d7d --- /dev/null +++ b/docs/commercial-distribution.md @@ -0,0 +1,49 @@ +# Commercial Distribution + +Mimir can be sold or supported commercially while the repository remains MIT open source. + +The commercial product boundary is distribution and service, not hidden source code in this repo. +Official paid channels may provide signed builds, verified downloads, support, onboarding, update +eligibility, and license delivery. The underlying tracked source remains MIT unless the license is +changed explicitly. + +## Distribution + +Distribute Mimir app builds through direct downloads and sideloadable installers: + +- macOS: signed and notarized `.dmg` / `.app` artifacts. +- Windows: Authenticode-signed `.exe` / `.msi` artifacts. +- Linux: `.AppImage` and `.deb` artifacts with published checksums. +- Android: APK-style sideload artifacts when mobile packaging is ready. +- iOS: deferred until a compliant non-store channel is chosen. + +Do not present App Store or Play Store distribution as the primary release path. + +## Payment And Licenses + +Hosted payment, webhook handling, and license delivery must stay metadata-only. They must not +receive local document paths, queries, retrieved passages, generated reports, audio, embeddings, or +vector rows. + +Runtime secrets stay outside the repository: + +- payment provider API keys; +- webhook signing secrets; +- private license signing keys; +- customer ledgers and order exports; +- generated production licenses. + +The public repository may contain source code for the license tooling and webhook handler, provided +it uses synthetic fixtures and placeholder infrastructure IDs. + +## Public Copy Rules + +Until official signed builds and a real payment path exist: + +- present Mimir Core as the usable product; +- describe the app as in development; +- avoid active checkout URLs; +- avoid active direct-download URLs under real Mimir domains; +- avoid claiming automatic updates, notarized artifacts, or signed releases before they exist. + +Business validation data belongs in private systems, not in this repository. diff --git a/docs/fr-eu-sovereign-positioning.md b/docs/fr-eu-sovereign-positioning.md new file mode 100644 index 0000000..5a429d5 --- /dev/null +++ b/docs/fr-eu-sovereign-positioning.md @@ -0,0 +1,115 @@ +# FR/EU Sovereign Positioning + +This document keeps Mimir's sovereignty, privacy, and legal-vertical positioning precise. It is +public product guidance, not legal advice or a compliance certificate. + +Sources checked on 2026-06-29: + +- European Commission, [Legal framework of EU data protection](https://commission.europa.eu/law/law-topic/data-protection/data-protection-eu_en) +- CNIL, [Les six grands principes du RGPD](https://www.cnil.fr/fr/comprendre-le-rgpd/les-six-grands-principes-du-rgpd) +- CNIL, [IA : comment etre en conformite avec le RGPD ?](https://www.cnil.fr/fr/intelligence-artificielle/ia-comment-etre-en-conformite-avec-le-rgpd) +- European Commission, [AI Act](https://digital-strategy.ec.europa.eu/en/policies/regulatory-framework-ai) + +## Core Position + +Mimir is positioned as sovereign local retrieval for confidential dossiers: + +- Documents stay in user-selected local folders. +- Indexes, reports, audio files, agent configs, and access logs stay under ignored local Mimir state. +- There is no hosted Mimir document store, no hosted vector database, and no product telemetry by + default. +- Redaction runs before indexing. +- Remote model downloads are explicit; confidential workflows should keep remote model loading + disabled and use preloaded local models. +- Retrieval returns cited passages and source paths; final professional conclusions remain outside + Mimir Core. +- Mimir Desktop is distributed through direct downloads and sideloadable installers, not App Store or + Play Store listings. + +This is stronger and more defensible than claiming generic "GDPR compliant AI". Mimir can help a +customer reduce data exposure, but each organization still owns its own processing purpose, legal +basis, retention, security controls, and professional obligations. + +## Claims To Use + +Use these claims in landing pages, sales calls, and documentation: + +- "Local-first retrieval for confidential documents." +- "No document upload to a hosted Mimir service." +- "No product telemetry by default." +- "Metadata-only access logs; raw queries are not stored by default." +- "Redaction before indexing." +- "Explicit model downloads; remote loading is disabled for confidential indexing by default." +- "Designed to support GDPR-conscious workflows through minimization, transparency, and local + control." +- "Legal-dossier workflows prepare cited work products for professional review; they do not replace + legal advice." +- "French-language support and FR/EU-oriented onboarding can be part of official support offers." + +## Claims To Avoid + +Do not use these claims unless a separate legal/security review proves them for a specific release: + +- "GDPR compliant" as a blanket product guarantee. +- "AI Act compliant" as a blanket product guarantee. +- "Certified sovereign", "SecNumCloud", "HDS", "eIDAS", or equivalent regulated certification. +- "Attorney-client privilege guaranteed" or "secret professionnel guaranteed". +- "No risk", "fully private", or "zero compliance work". +- "Automated legal advice" or "lawyer replacement". +- Any promise that Android or iOS distribution goes through official stores. + +## GDPR-Oriented Product Evidence + +Mimir should keep these evidence points easy to show during buyer review: + +| GDPR-oriented theme | Mimir evidence | +| --- | --- | +| Purpose and minimization | Users choose explicit folders; unsupported files are reported; remote models require an explicit action. | +| Local control | Raw documents, `.kb/`, `.mimir/`, reports, audio, and agent configs remain local and ignored by Git. | +| Security and confidentiality | No hosted document store, no default telemetry, redaction before indexing, metadata-only access logs. | +| Transparency | CLI and app expose `mimir doctor`, `mimir audit`, `mimir audit --unsupported`, and `mimir security-audit`. | +| Retention | Users can delete generated `.kb/` and `.mimir/` state locally; Mimir should not retain hosted copies. | +| Accountability | Public README, security hardening notes, source boundary, and reproducible local validation commands. | + +For support, if JCode ever receives customer documents, excerpts, logs, or screenshots that may +contain personal data, that support flow is a separate operational process. Keep it outside this +repository and define access, retention, deletion, and customer approval before accepting the data. + +## AI Act-Oriented Position + +Mimir Core is a retrieval layer. It indexes local documents, searches them, and returns cited context. +It does not train a general-purpose model, provide a hosted AI system, or automate legal decisions by +default. + +Reassess the AI Act posture before shipping any of the following: + +- embedded local generation that writes final answers without an external agent; +- vertical workflows that could be used for employment, education, credit, migration, law + enforcement, healthcare, or other high-risk decision contexts; +- hosted inference, hosted evaluation, or model training on customer data; +- public claims that generated legal outputs are authoritative. + +If embedded generation ships later, keep human review explicit, label generated content clearly, and +document whether Mimir is acting as a provider of an AI system, a deployer tool, or only a local +interface over user-controlled models. + +## Legal Vertical Packaging + +The legal vertical can be sold as workflow packaging, not legal judgment: + +- cited dossier summaries; +- chronologies with evidence confidence; +- clause review tables; +- evidence gap lists; +- redaction and professional-review checklists; +- French-language onboarding and support. + +The `mimir-legal-dossier` skill must stay aligned with this boundary: it prepares structured, +cited work products and flags professional-review items. It must not present conclusions as legal +advice. + +## Legal Vertical Validation + +Keep names, matters, case references, emails, invoices, customer notes, and exact validation outcomes +in a private system outside this repository. Public updates should use only aggregated, sanitized +findings and synthetic fixtures. diff --git a/docs/offline-tts-preload.md b/docs/offline-tts-preload.md new file mode 100644 index 0000000..71baeb1 --- /dev/null +++ b/docs/offline-tts-preload.md @@ -0,0 +1,94 @@ +# Offline TTS Preload + +`mimir audio --offline` disables remote model downloads. It only works after the +Transformers.js TTS model has already been cached under `.mimir/models/tts` or the path passed with +`--model-path`. + +Use this workflow when you want confidential audio summaries to render without network access. + +## Boundary + +- Preload with a short non-sensitive sentence while network access is acceptable. +- Render confidential narration only after the offline check succeeds. +- Keep `.mimir/models/tts/` and `.mimir/audio/` untracked. +- Do not use `--engine edge` for confidential content unless online TTS is explicitly acceptable. + +The preload step downloads public model files. It should not need to send narration text to a remote +TTS service, but using synthetic text keeps the operation easy to audit. + +## Main CLI + +Create a synthetic input outside the repository: + +```bash +printf 'Mimir offline speech preload check.' > /tmp/mimir-tts-preload.txt +mkdir -p .mimir/audio +``` + +Preload the default Transformers.js model: + +```bash +pnpm exec mimir audio /tmp/mimir-tts-preload.txt \ + --engine transformers \ + --allow-remote-models \ + --model-path .mimir/models/tts \ + --out .mimir/audio/preload-check.wav +``` + +Then prove the cache works with remote loading disabled: + +```bash +pnpm exec mimir audio /tmp/mimir-tts-preload.txt \ + --engine transformers \ + --offline \ + --model-path .mimir/models/tts \ + --out .mimir/audio/offline-check.wav +``` + +After that, render confidential narration offline: + +```bash +pnpm exec mimir audio /tmp/MIMIR-SUMMARY-project.txt \ + --engine transformers \ + --offline \ + --model-path .mimir/models/tts \ + --out .mimir/audio/project-summary.wav +``` + +## Standalone TTS CLI + +The standalone package uses the same model cache: + +```bash +pnpm exec mimir-tts render /tmp/mimir-tts-preload.txt \ + --engine transformers \ + --allow-remote-models \ + --model-path .mimir/models/tts \ + --out .mimir/audio/preload-check.wav + +pnpm exec mimir-tts render /tmp/mimir-tts-preload.txt \ + --offline \ + --model-path .mimir/models/tts \ + --out .mimir/audio/offline-check.wav +``` + +## Air-Gapped Machines + +If the target machine cannot touch the network: + +1. Run the preload command on a trusted internet-connected machine with the same Mimir TTS version, + model ID, and `--model-path`. +2. Copy the resulting `.mimir/models/tts/` directory to the target machine through an approved local + transfer path. +3. Run the offline check on the target machine before rendering real narration. + +Do not commit the model cache to Git or include it in npm packages. + +## Troubleshooting + +If offline render fails, check: + +- The preload and offline render use the same `--model`, `--model-path`, and package version. +- The target machine received the full `.mimir/models/tts/` directory. +- The output path ends with `.wav`; MP3 requires the online Edge TTS path. +- `MIMIR_TTS_MODEL_PATH` does not point to a different cache directory. diff --git a/docs/payment-webhook-architecture.md b/docs/payment-webhook-architecture.md new file mode 100644 index 0000000..2d92aac --- /dev/null +++ b/docs/payment-webhook-architecture.md @@ -0,0 +1,130 @@ +# Payment And License Webhook Architecture + +Mimir Desktop uses direct downloads and sideloadable installers. Payments and license delivery must +therefore stay independent from App Store or Play Store account, review, receipt, and entitlement +systems. + +This document defines a future hosted payment path without adding secrets or deploying a service from +this repository. + +## Release Shape + +- The landing stays static and links to hosted Lemon Squeezy checkout URLs only after the app is + signed, packaged, and ready to sell. +- Lemon Squeezy remains the default payment provider because it can host checkout and act as merchant + of record. +- Paddle remains the fallback if Lemon Squeezy cannot satisfy the final tax, payout, or license + workflow. +- Mimir does not add a hosted document service for payment or activation. +- The app keeps local per-major license validation through `MIMIR1..` keys. + +## Hosted Components + +The minimum hosted surface is a small webhook service. A Cloudflare Worker is the preferred shape for +the first implementation because it matches the landing infrastructure and can keep provider secrets +outside the repository. + +The service owns: + +- provider webhook signature verification; +- provider event normalization; +- license issuance through the same payload rules used by `license:from-lemonsqueezy`; +- minimal purchase metadata storage for idempotency, refunds, support, and re-delivery; +- customer-facing license delivery by email or a short-lived license retrieval URL. + +The service must not receive, store, or request local document paths, queries, retrieved passages, +embeddings, vector rows, generated reports, generated audio, or MCP context. + +## Event Flow + +1. A public Mimir release page links to a Lemon Squeezy hosted checkout variant. +2. Lemon Squeezy collects payment and tax details on the provider-hosted checkout. +3. Lemon Squeezy sends purchase, subscription, renewal, refund, and cancellation events to the webhook + service. +4. The webhook verifies the event signature before any state change. +5. The webhook normalizes the event into the Mimir license payload model. +6. The webhook signs a `MIMIR1` license with a private key stored only in the hosted secret manager. +7. The customer receives the direct app download link plus the license key. +8. The installed app validates the license locally with the public JWK injected at build time through + `VITE_MIMIR_LICENSE_PUBLIC_KEY_JWK`. + +## License Mapping + +| Provider event | Mimir license behavior | +| --- | --- | +| One-time purchase | Issue a perpetual per-major license with the configured update window. | +| Subscription created or renewed | Issue or refresh a license whose runtime and update windows follow the provider renewal date. | +| Subscription expired or cancelled | Stop refreshing future licenses; existing local behavior follows the last signed expiration. | +| Refund or chargeback | Mark the purchase as revoked for support, re-delivery, updates, and future online checks. | +| Duplicate webhook delivery | Return success after confirming the previously issued license record. | + +Local validation remains the default app behavior. If later online activation is added, it must be +metadata-only and must not become a document telemetry channel. + +## Secrets + +The repository must never contain: + +- Lemon Squeezy API keys; +- Lemon Squeezy webhook signing secrets; +- private license signing keys; +- customer emails, invoices, tax identifiers, or order exports; +- generated production license keys. + +Use environment variables, CI secrets, a worker secret store, or a dedicated key-management service. +Only the public license verification JWK may be committed or injected into public build artifacts. + +## Local Validation Path + +Until the webhook exists, use the offline adapter with exported provider JSON: + +```bash +pnpm --filter @jcode.labs/mimir-app license:from-lemonsqueezy \ + --event lemon-event.json \ + --private-key .mimir/license-private.jwk \ + --major-version 0 \ + --json +``` + +Keep exported provider JSON, private JWK files, generated licenses, and customer ledgers under +ignored local Mimir state or another private system. + +The adapter has a synthetic smoke test that generates a temporary local signing key and converts +order plus subscription fixtures without provider credentials: + +```bash +pnpm --filter @jcode.labs/mimir-app license:lemonsqueezy:smoke +``` + +## Webhook Handler Package + +`packages/mimir-license-webhook` contains the unpublished Cloudflare Worker handler for the future hosted +path. It verifies Lemon Squeezy's `X-Signature` header against the raw request body, issues local +`MIMIR1` keys for eligible order/subscription events, and returns metadata-only records for +cancellation/refund-style events. It requires a KV-compatible `MIMIR_LICENSE_RECORDS` binding so +duplicate webhook deliveries can return the stored response instead of signing a second license key. + +The package has a synthetic smoke test with generated local keys and no provider credentials: + +```bash +pnpm --filter @jcode.labs/mimir-license-webhook smoke +pnpm --filter @jcode.labs/mimir-license-webhook cf:dry-run +``` + +This package is not a deployment target yet. Keep `LEMONSQUEEZY_WEBHOOK_SECRET`, +`MIMIR_LICENSE_PRIVATE_KEY_JWK`, KV record exports, customer data, order exports, and generated +production licenses out of the repository and out of public build artifacts. + +## Release Gates + +Commercial checkout and license delivery can be considered release-ready only after all of these are +true: + +- real Lemon Squeezy product variants exist for the released offer; +- a hosted checkout URL is linked from the release surface after the app is signed and packaged; +- the webhook verifies provider signatures and is deployed with secrets outside the repository; +- a test-mode purchase issues a valid local `MIMIR1` license; +- duplicate webhook deliveries are idempotent; +- refunds, cancellations, and subscription expirations update license metadata; +- the app download page publishes signed artifacts, `SHA256SUMS`, and `mimir-app-release.json`; +- no App Store, Play Store, or store entitlement path is required for purchase or activation. diff --git a/docs/source-boundary.md b/docs/source-boundary.md new file mode 100644 index 0000000..17efade --- /dev/null +++ b/docs/source-boundary.md @@ -0,0 +1,62 @@ +# Source Boundary + +Mimir is a public MIT-licensed repository. Treat every tracked source file, package, workflow, +example, and document as visible, forkable, modifiable, and reusable by anyone under the MIT License. + +## What Is Open Source Here + +The repository intentionally contains: + +- Mimir Core: CLI, library, MCP server, bundled agent skills, and synthetic examples. +- Mimir TTS: optional audio rendering package. +- Mimir UI and landing source. +- The Tauri app shell source. +- Direct-download release tooling, checksum tooling, manifest tooling, and updater guards. +- The undeployed license webhook source and synthetic smoke tests. + +The `private: true` flag in some `package.json` files means "not published to npm", not "private +source". It does not override the repository license. + +## Commercial Distribution Boundary + +Commercial value can exist around this open source code, but not as hidden proprietary source inside +this repository. + +Good commercial boundaries for this repo: + +- signed desktop/mobile builds; +- verified direct-download artifacts; +- support, onboarding, and maintenance; +- hosted payment and license delivery using secrets stored outside Git; +- optional service-level commitments around official builds. + +Bad boundaries for this repo: + +- claiming proprietary or closed-source status for a tracked package; +- storing business ledgers, prospect notes, order exports, customer files, or private pricing notes; +- relying on client-side license checks as a source-code protection boundary; +- committing real checkout, download, updater, webhook, or license secrets. + +Local license validation can gate official signed builds, support, updates, and paid distribution +channels. It cannot prevent a user from forking MIT source code. + +If Mimir later needs truly proprietary source code, that code must live outside this MIT repository +or the licensing model must be changed deliberately before publication. + +## Public-Repo Hygiene + +Keep the public repository limited to: + +- public product documentation; +- implementation details that are safe to inspect; +- synthetic fixtures and generated-free examples; +- security hardening guidance; +- release runbooks that use placeholder IDs and secret stores. + +Keep outside Git: + +- private documents and client corpora; +- `.kb/`, `.mimir/`, `.pid`, raw reports, audio files, and vector stores; +- API keys, webhook secrets, signing keys, certificates, and environment files; +- customer names, emails, invoices, order exports, and support evidence; +- internal pricing tests, pre-sales ledgers, interview notes, and GO/NO-GO records. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7789673..57ff775 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,8 +1,189 @@ # Troubleshooting -The canonical troubleshooting guide now lives in the root README: +Use `mimir doctor` first. It is the shortest path to the next useful action: -https://github.com/jcode-works/jcode-mimir#troubleshooting +```bash +pnpm exec mimir doctor +``` -Keep product documentation in the root README so GitHub and npm readers land on the same source of -truth. +Use `doctor --fix` when you want Mimir to repair safe setup issues automatically: + +```bash +pnpm exec mimir doctor --fix +``` + +## `mimir doctor` Says The Project Is Not Initialized + +Run: + +```bash +pnpm exec mimir setup +pnpm exec mimir doctor +``` + +Commit only safe scaffolding if this is a real repository. Do not commit private documents, +`.kb/storage`, `.mimir/`, env files, or credentials. + +## No Files Are Indexed + +Check that supported files exist under `private/`: + +```bash +find private -maxdepth 2 -type f +pnpm exec mimir ingest +pnpm exec mimir doctor +``` + +If documents live elsewhere, add one path per line to `.kb/sources.txt`. Relative paths resolve from +the project root. + +If files exist but are not supported yet, inspect the skipped inventory: + +```bash +pnpm exec mimir audit --unsupported +``` + +Then follow the per-file recommendation: convert unsupported binaries to a supported format, +OCR/transcribe them, or add a safe custom UTF-8 text extension with `includeExtensions` / +`KB_INCLUDE_EXTENSIONS`. + +## Scanned PDFs Produce No Text + +Mimir extracts embedded PDF text by default. For scanned/image-only PDFs, configure an explicit local +OCR wrapper that prints UTF-8 text to stdout: + +```json +{ + "pdfOcrCommand": ["mimir-pdf-ocr", "{input}"], + "pdfOcrTimeoutMs": 120000 +} +``` + +The command runs only when normal PDF extraction returns no text. It is executed without a shell, +receives `MIMIR_PDF_PATH`, and may use `{input}` in its arguments for the PDF path. Keep OCR tooling +local for confidential documents. + +If ingestion finishes but a scanned PDF still has no text, `mimir ingest --json` lists it under +`emptyTextFiles`. Standalone image files are skipped as unsupported; OCR them to text or convert them +to OCRed PDFs before ingesting. + +## Search Returns Weak Results + +The default `local-hash` provider is dependency-light and offline, but it is lexical/hash retrieval, +not semantic retrieval. + +For better semantic retrieval, configure Transformers.js embeddings and preload the model when +working offline: + +```json +{ + "embeddingProvider": "transformers", + "embeddingModel": "mixedbread-ai/mxbai-embed-xsmall-v1", + "embeddingModelPath": ".mimir/models", + "transformersAllowRemoteModels": false +} +``` + +When remote download is acceptable, preload the configured embedding model first: + +```bash +pnpm exec mimir models pull --enable +``` + +Switching providers requires a full re-ingest: + +```bash +pnpm exec mimir ingest --rebuild +pnpm exec mimir doctor +``` + +## `mimir audit` Reports Missing Or Stale Files + +Run: + +```bash +pnpm exec mimir ingest +pnpm exec mimir audit +``` + +Or let doctor perform the safe incremental update: + +```bash +pnpm exec mimir doctor --fix +``` + +Mimir incrementally reuses unchanged indexed rows on normal `mimir ingest`. Use `mimir ingest --rebuild` +after switching embedding provider/model, after changing chunking settings, or when you want to +discard and recreate the whole local index. + +## `security-audit --strict` Fails + +Read the warning lines. Common causes: + +- `.kb/`, `.mimir/`, or `private/**` are not ignored by Git. +- Redaction was disabled. +- Transformers.js remote model loading was enabled. + +Run the safe repair command if Git ignore entries are missing: + +```bash +pnpm exec mimir doctor --fix +pnpm exec mimir security-audit --strict +``` + +## MP3 Audio Fails Without `--engine edge` + +This is intentional. MP3 output uses online Edge TTS and requires explicit consent: + +```bash +pnpm exec mimir audio /tmp/summary.txt \ + --engine edge \ + --out .mimir/audio/summary.mp3 +``` + +For confidential or offline work, use WAV: + +```bash +pnpm exec mimir audio /tmp/summary.txt \ + --engine transformers \ + --offline \ + --out .mimir/audio/summary.wav +``` + +## Edge TTS Is Not Installed + +Install the external CLI: + +```bash +pipx install edge-tts +pnpm exec mimir audio --doctor +``` + +Only use Edge TTS when sending narration text to the online service is acceptable. + +## `mimir-tts --offline` Cannot Render + +Offline rendering requires model files to already exist under `.mimir/models/tts` or the path passed +with `--model-path`. + +For a first online setup, use non-sensitive text: + +```bash +printf 'Mimir offline speech preload check.' > /tmp/mimir-tts-preload.txt +pnpm exec mimir-tts render /tmp/mimir-tts-preload.txt \ + --engine transformers \ + --allow-remote-models \ + --model-path .mimir/models/tts \ + --out .mimir/audio/preload-check.wav +``` + +Then reuse the cached files with: + +```bash +pnpm exec mimir-tts render /tmp/mimir-tts-preload.txt \ + --offline \ + --model-path .mimir/models/tts \ + --out .mimir/audio/offline-check.wav +``` + +The full workflow is documented in [`offline-tts-preload.md`](./offline-tts-preload.md). diff --git a/docs/ux-dx-audit.md b/docs/ux-dx-audit.md index 9b134c7..64c8fdb 100644 --- a/docs/ux-dx-audit.md +++ b/docs/ux-dx-audit.md @@ -6,10 +6,10 @@ developer and agent workflow around installation, indexing, querying, safety, au ## Evidence Reviewed - Root product README: `README.md` -- npm package entrypoint READMEs: `packages/mimir/README.md`, `packages/mimir-tts/README.md` -- CLI implementation: `packages/mimir/src/cli.ts` +- npm package entrypoint READMEs: `packages/mimir-core/README.md`, `packages/mimir-tts/README.md` +- CLI implementation: `packages/mimir-core/src/cli.ts` - TTS implementation: `packages/mimir-tts/src/index.ts` -- Agent skills: `packages/mimir/skills/**/SKILL.md` +- Agent skills: `packages/mimir-core/skills/**/SKILL.md` - Security docs: `SECURITY-HARDENING.md`, `SECURITY.md` - Release workflow: `.github/workflows/ci.yml`, `.github/workflows/npm-publish.yml` - Runtime smoke path through a temporary repository @@ -18,8 +18,8 @@ developer and agent workflow around installation, indexing, querying, safety, au | Area | Finding | Status | | --- | --- | --- | -| First run | `kb init` created useful files but did not tell users what to do next. | Fixed: `kb init` now prints next steps. | -| Readiness | Users had to combine `status`, `audit`, and `security-audit` manually. | Fixed: `kb doctor` summarizes readiness and next steps. | +| First run | `mimir init` created useful files but did not tell users what to do next. | Fixed: `mimir init` now prints next steps. | +| Readiness | Users had to combine `status`, `audit`, and `security-audit` manually. | Fixed: `mimir doctor` summarizes readiness and next steps. | | Generated helper files | `private/README.md` was indexed and could pollute retrieval results. | Fixed: generated private README is skipped by source discovery. | | Audio confidentiality | `auto` could select online Edge TTS when installed. | Fixed: default path is Transformers.js WAV; Edge MP3 requires `--engine edge`. | | Documentation shape | The package README had too much tutorial, reference, and explanation mixed together. | Fixed: the root README is canonical; package README files are minimal npm entrypoints. | @@ -27,6 +27,8 @@ developer and agent workflow around installation, indexing, querying, safety, au | Ingestion visibility | Unsupported files were ignored silently, which made users overestimate coverage. | Fixed: `ingest`, `audit`, and `audit --unsupported` report skipped files by reason. | | Report generation | Users had audio summaries but no dedicated Markdown-report workflow. | Fixed: `mimir-markdown-report` skill writes cited reports under ignored local state. | | Stale detection | Audit compared paths but did not detect changed file content. | Fixed: audit now uses stored checksums to flag stale indexed content. | +| Semantic model preload | Users had to infer how to warm the Transformers.js cache. | Fixed: `mimir models pull` downloads the configured embedding model into `embeddingModelPath`. | +| TTS model preload | Users had to infer how `--offline` relates to the Transformers.js TTS cache. | Fixed: `docs/offline-tts-preload.md` documents non-sensitive preload, offline verification, and air-gapped transfer. | ## DX Findings @@ -34,8 +36,8 @@ developer and agent workflow around installation, indexing, querying, safety, au | --- | --- | --- | | Local validation | `pnpm validate` already covers lint, typecheck, tests, build, smoke, package checks, and artifacts. | Good. | | Release safety | npm publish is protected by CI, environment approval, provenance, and explicit version input. | Good. | -| API clarity | Core exports are small and named, but the README only shows a minimal API snippet. | Partially improved by CLI docs; deeper API docs remain future work. | -| MCP reference | Tool names and an agent demo prompt are documented, but tool schemas are not deeply documented. | Partially improved. | +| API clarity | Core exports are small and named, but the README only shows a minimal API snippet. | Fixed: `docs/api-reference.md` documents the public TypeScript API and result types. | +| MCP reference | Tool names and an agent demo prompt are documented, but tool schemas are not deeply documented. | Improved: `docs/api-reference.md` documents the MCP tool names and input shapes. | | Error guidance | Common setup and audio errors were not centralized. | Fixed in the root README troubleshooting section. | | Dist workflow | `dist/` is committed and documented in `CLAUDE.md`; this is unusual but CI-enforced. | Good for this repo, but keep documenting it. | @@ -43,17 +45,13 @@ developer and agent workflow around installation, indexing, querying, safety, au - `local-hash` is intentionally low-friction but not semantic. The docs must continue to say this clearly so users do not overtrust retrieval quality. -- Transformers.js offline TTS still depends on preloaded model files. The install path is easy, but - fully air-gapped operation requires a documented model-preload workflow. - MCP access is read-focused but still exposes private retrieved passages to the connected agent. Team/RBAC support remains out of scope. - `audit --unsupported` intentionally lists relative paths only; users still need to avoid pasting sensitive path names into public issue reports. -- The library API is usable, but a dedicated API reference page would help external developers. +- The library API is usable and now documented, but examples should grow with real external usage. ## Recommended Next Pass -1. Add API reference docs for exported functions and result types. -2. Add MCP tool schema examples for agent developers. -3. Add a model-preload guide for semantic embeddings and offline TTS. -4. Add deeper API reference docs for external library consumers once the public API grows. +1. Add example-driven API guides once real external library usage appears. +2. Add richer MCP client examples if users integrate non-Claude/Codex agents. diff --git a/package.json b/package.json index 3c2e8c8..b68b147 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,24 @@ { "name": "jcode-mimir", - "version": "0.4.10", + "version": "0.4.11", "private": true, "description": "Monorepo for Mimir Core and open-source Mimir add-ons.", "type": "module", "license": "MIT", "packageManager": "pnpm@11.9.0", "scripts": { - "build": "pnpm --filter @jcode.labs/mimir-tts build && pnpm --filter @jcode.labs/mimir build", - "check": "pnpm --filter @jcode.labs/mimir-tts check && pnpm --filter @jcode.labs/mimir check", + "build": "pnpm --filter @jcode.labs/mimir-ui build && pnpm --filter @jcode.labs/mimir-app build && pnpm --filter @jcode.labs/mimir-landing build && pnpm --filter @jcode.labs/mimir-license-webhook build && pnpm --filter @jcode.labs/mimir-tts build && pnpm --filter @jcode.labs/mimir build", + "check": "pnpm --filter @jcode.labs/mimir-ui check && pnpm --filter @jcode.labs/mimir-app check && pnpm --filter @jcode.labs/mimir-landing check && pnpm --filter @jcode.labs/mimir-license-webhook check && pnpm --filter @jcode.labs/mimir-tts check && pnpm --filter @jcode.labs/mimir check", "commitlint": "commitlint --from=HEAD~1 --to=HEAD --verbose", + "dev:app": "pnpm --filter @jcode.labs/mimir-app dev", + "dev:landing": "pnpm --filter @jcode.labs/mimir-landing dev", "format": "biome format --write .", "lint": "biome ci .", "lint:fix": "biome check --write .", "package:check": "pnpm --filter @jcode.labs/mimir-tts package:check && pnpm --filter @jcode.labs/mimir package:check", + "public:smoke": "node scripts/public-surface-smoke.mjs", "release:artifacts": "node scripts/release-artifacts.mjs", - "smoke": "node scripts/smoke.mjs && pnpm --filter @jcode.labs/mimir-tts smoke", + "smoke": "pnpm public:smoke && node scripts/smoke.mjs && pnpm --filter @jcode.labs/mimir mcp:smoke && pnpm --filter @jcode.labs/mimir-tts smoke && pnpm --filter @jcode.labs/mimir-app license:lemonsqueezy:smoke && pnpm --filter @jcode.labs/mimir-app release:preflight:smoke && pnpm --filter @jcode.labs/mimir-app release:updater-guard:smoke && pnpm --filter @jcode.labs/mimir-app release:checksums:smoke && pnpm --filter @jcode.labs/mimir-app release:manifest:smoke && pnpm --filter @jcode.labs/mimir-app release:bundle:verify:smoke && pnpm --filter @jcode.labs/mimir-license-webhook smoke && pnpm --filter @jcode.labs/mimir-license-webhook cf:dry-run", "test": "pnpm --filter @jcode.labs/mimir-tts test && pnpm --filter @jcode.labs/mimir test", "validate": "pnpm lint && pnpm check && pnpm test && pnpm build && pnpm smoke && pnpm package:check && pnpm release:artifacts" }, @@ -23,6 +26,6 @@ "@biomejs/biome": "^2.5.1", "@commitlint/cli": "^21.1.0", "@commitlint/config-conventional": "^21.1.0", - "yaml": "^2.8.1" + "yaml": "^2.9.0" } } diff --git a/packages/mimir-app/README.md b/packages/mimir-app/README.md new file mode 100644 index 0000000..a509039 --- /dev/null +++ b/packages/mimir-app/README.md @@ -0,0 +1,116 @@ +# Mimir App + +Unpublished Tauri desktop/mobile shell for Mimir. + +Root `pnpm build` validates the Vite frontend bundle. Native desktop/mobile builds stay explicit: + +```bash +pnpm --filter @jcode.labs/mimir-app tauri:dev +pnpm --filter @jcode.labs/mimir-app tauri:build +pnpm --filter @jcode.labs/mimir-app tauri:build:macos +pnpm --filter @jcode.labs/mimir-app tauri:build:windows +pnpm --filter @jcode.labs/mimir-app tauri:build:linux +pnpm --filter @jcode.labs/mimir-app tauri:ios:init +pnpm --filter @jcode.labs/mimir-app tauri:ios:dev +pnpm --filter @jcode.labs/mimir-app tauri:android:init +pnpm --filter @jcode.labs/mimir-app tauri:android:dev +pnpm --filter @jcode.labs/mimir-app tauri:android:build +``` + +Run a release-machine preflight before native packaging: + +```bash +pnpm --filter @jcode.labs/mimir-app release:preflight -- --target macos +pnpm --filter @jcode.labs/mimir-app release:preflight -- --target windows +pnpm --filter @jcode.labs/mimir-app release:preflight -- --target linux +pnpm --filter @jcode.labs/mimir-app release:preflight -- --target android +pnpm --filter @jcode.labs/mimir-app release:preflight:smoke +``` + +Generate checksums after a native bundle exists: + +```bash +pnpm --filter @jcode.labs/mimir-app release:checksums +pnpm --filter @jcode.labs/mimir-app release:manifest -- --target macos +``` + +`release:manifest` reads `SHA256SUMS` and writes `mimir-app-release.json` next to native artifacts +so the static direct-download surface can render verified artifact metadata without hardcoded file +names. + +The app uses `@jcode.labs/mimir-ui` for shared styling and should keep privacy controls visible by +default. + +Mimir Core integration is a bounded native Tauri command around the existing `mimir` CLI/MCP +surface. In local native runs, set `MIMIR_CLI_BIN` when the `mimir` binary is not on `PATH`. See +[`../../docs/app-sidecar-architecture.md`](../../docs/app-sidecar-architecture.md). + +The current shell consumes JSON from `mimir doctor`, `mimir status`, `mimir ingest`, +`mimir ask`, `mimir security-audit`, `mimir models pull --enable`, and offline `mimir audio` for +project status, cited retrieval, privacy posture, explicit semantic model setup, Markdown reports, +and local audio report rendering. + +Registered projects can opt into watched-folder mode from the Projects view. This is a local polling +layer over incremental `mimir ingest`: it re-indexes the selected project every 5 minutes, stores the +setting only in local app storage, and does not add a cloud connector or background daemon. + +Google Drive support is intentionally implemented as a local-sync connector: select the folder made +available on disk by Google Drive for desktop, and the app marks it as a Google Drive source with +local auto-ingest enabled. It does not use OAuth, call the Drive API, or store Google credentials. + +## Distribution + +The app is designed for direct downloads and sideloadable installers, not App Store or Play Store +distribution. Desktop installers and Android APK-style releases are the initial target channels; iOS +distribution remains deferred until a compliant non-store path is selected. + +There is intentionally no iOS release build script yet. Keep iOS limited to local init/dev commands +until a compliant non-store distribution channel is selected. + +See [`../../docs/app-distribution.md`](../../docs/app-distribution.md) for the direct-download +release runbook. + +## Local License Validation + +The app validates signed per-major licenses locally. The private signing key must stay outside the +repository. + +Generate a keypair into an ignored local folder: + +```bash +pnpm --filter @jcode.labs/mimir-app license:keypair \ + --private-key .mimir/license-private.jwk \ + --public-key .mimir/license-public.jwk +``` + +The command writes key files with owner-only permissions and does not print private key material. + +Build the app with the public JWK only: + +```bash +VITE_MIMIR_LICENSE_PUBLIC_KEY_JWK="$(cat .mimir/license-public.jwk)" pnpm --filter @jcode.labs/mimir-app build +``` + +Issue a license key from the private JWK: + +```bash +pnpm --filter @jcode.labs/mimir-app license:issue \ + --private-key .mimir/license-private.jwk \ + --holder "Customer Name" \ + --tier solo \ + --major-version 0 +``` + +Convert a Lemon Squeezy order/subscription JSON export or webhook payload into the same local +license format: + +```bash +pnpm --filter @jcode.labs/mimir-app license:from-lemonsqueezy \ + --event lemon-event.json \ + --private-key .mimir/license-private.jwk \ + --major-version 0 \ + --json +``` + +The adapter runs offline. It does not call the Lemon Squeezy API and it does not store provider +secrets in the repository. diff --git a/packages/mimir-app/index.html b/packages/mimir-app/index.html new file mode 100644 index 0000000..ed1f073 --- /dev/null +++ b/packages/mimir-app/index.html @@ -0,0 +1,13 @@ + + + + + + + Mimir + + +
+ + + diff --git a/packages/mimir-app/package.json b/packages/mimir-app/package.json new file mode 100644 index 0000000..71bc4dd --- /dev/null +++ b/packages/mimir-app/package.json @@ -0,0 +1,56 @@ +{ + "name": "@jcode.labs/mimir-app", + "version": "0.4.10", + "private": true, + "description": "Cross-platform Mimir desktop and mobile shell built with Tauri.", + "type": "module", + "license": "MIT", + "scripts": { + "build": "tsc -p tsconfig.json --noEmit && vite build", + "check": "tsc -p tsconfig.json --noEmit", + "dev": "vite", + "license:from-lemonsqueezy": "node scripts/license-from-lemonsqueezy.mjs", + "license:lemonsqueezy:smoke": "node scripts/license-from-lemonsqueezy-smoke.mjs", + "license:issue": "node scripts/license-issue.mjs", + "license:keypair": "node scripts/license-keypair.mjs", + "preview": "vite preview", + "release:bundle:verify": "node scripts/native-bundle-verify.mjs", + "release:bundle:verify:smoke": "node scripts/native-bundle-verify-smoke.mjs", + "release:checksums": "node scripts/native-checksums.mjs", + "release:checksums:smoke": "node scripts/native-checksums-smoke.mjs", + "release:manifest": "node scripts/native-release-manifest.mjs", + "release:manifest:smoke": "node scripts/native-release-manifest-smoke.mjs", + "release:preflight": "node scripts/release-preflight.mjs", + "release:preflight:smoke": "node scripts/release-preflight-smoke.mjs", + "release:updater-guard": "node scripts/updater-guard.mjs", + "release:updater-guard:smoke": "node scripts/updater-guard-smoke.mjs", + "tauri": "tauri", + "tauri:android:build": "tauri android build --apk", + "tauri:android:dev": "tauri android dev", + "tauri:android:init": "tauri android init", + "tauri:build": "tauri build", + "tauri:build:linux": "tauri build --ci --bundles deb,appimage", + "tauri:build:macos": "tauri build --ci --bundles app,dmg", + "tauri:build:windows": "tauri build --ci --bundles nsis,msi", + "tauri:dev": "tauri dev", + "tauri:ios:dev": "tauri ios dev", + "tauri:ios:init": "tauri ios init" + }, + "dependencies": { + "@jcode.labs/mimir-ui": "workspace:*", + "@tauri-apps/api": "^2.10.0", + "lucide-react": "^1.21.0", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.10.0", + "@tailwindcss/vite": "^4.3.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "tailwindcss": "^4.3.1", + "typescript": "^5.9.3", + "vite": "^8.0.13" + } +} diff --git a/packages/mimir-app/public/favicon.svg b/packages/mimir-app/public/favicon.svg new file mode 100644 index 0000000..875f342 --- /dev/null +++ b/packages/mimir-app/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/mimir-app/scripts/license-core.mjs b/packages/mimir-app/scripts/license-core.mjs new file mode 100644 index 0000000..81d3986 --- /dev/null +++ b/packages/mimir-app/scripts/license-core.mjs @@ -0,0 +1,108 @@ +import { randomUUID, webcrypto } from "node:crypto" +import { readFile } from "node:fs/promises" + +export const PRODUCT_ID = "mimir-desktop" +export const LICENSE_FORMAT = "MIMIR1" +export const LICENSE_TIERS = new Set(["solo", "team", "company"]) + +export async function readPrivateKey(values) { + if (values["private-key"]) { + return JSON.parse(await readFile(values["private-key"], "utf8")) + } + if (process.env.MIMIR_LICENSE_PRIVATE_KEY_JWK) { + return JSON.parse(process.env.MIMIR_LICENSE_PRIVATE_KEY_JWK) + } + throw new Error("Provide --private-key or MIMIR_LICENSE_PRIVATE_KEY_JWK.") +} + +export async function signLicensePayload(payload, privateKeyJwk) { + const encodedPayload = base64Url(JSON.stringify(payload)) + const key = await webcrypto.subtle.importKey( + "jwk", + privateKeyJwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ) + const signature = await webcrypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + new TextEncoder().encode(encodedPayload), + ) + + return `${LICENSE_FORMAT}.${encodedPayload}.${base64Url(signature)}` +} + +export function licensePayload(values) { + const holder = required(values.holder, "--holder is required.") + return { + product: PRODUCT_ID, + licenseId: values["license-id"] ?? randomUUID(), + holder, + tier: licenseTier(values.tier), + majorVersion: positiveInteger(values["major-version"] ?? "0", "major-version"), + issuedAt: isoDate(values["issued-at"] ?? new Date().toISOString(), "issued-at"), + updatesUntil: isoDate(values["updates-until"] ?? yearsFromNow(2), "updates-until"), + ...(values["expires-at"] ? { expiresAt: isoDate(values["expires-at"], "expires-at") } : {}), + } +} + +export function licenseTier(value) { + const tier = value ?? "solo" + if (LICENSE_TIERS.has(tier)) { + return tier + } + throw new Error("tier must be solo, team, or company.") +} + +export function positiveInteger(value, label) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${label} must be a positive integer.`) + } + return parsed +} + +export function isoDate(value, label) { + const date = new Date(value) + if (!Number.isFinite(date.getTime())) { + throw new Error(`${label} must be a valid date.`) + } + return date.toISOString() +} + +export function yearsFromNow(years, from = new Date()) { + const date = new Date(from) + date.setFullYear(date.getFullYear() + years) + return date.toISOString() +} + +export function required(value, message) { + if (!value) { + throw new Error(message) + } + return value +} + +export function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = "true" + continue + } + parsed[key] = next + index += 1 + } + return parsed +} + +function base64Url(value) { + const bytes = + typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(new Uint8Array(value)) + return bytes.toString("base64url") +} diff --git a/packages/mimir-app/scripts/license-from-lemonsqueezy-smoke.mjs b/packages/mimir-app/scripts/license-from-lemonsqueezy-smoke.mjs new file mode 100644 index 0000000..c207b27 --- /dev/null +++ b/packages/mimir-app/scripts/license-from-lemonsqueezy-smoke.mjs @@ -0,0 +1,173 @@ +import { spawnSync } from "node:child_process" +import { webcrypto } from "node:crypto" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const CONVERTER_SCRIPT = join(SCRIPT_DIR, "license-from-lemonsqueezy.mjs") +const tempDir = await mkdtemp(join(tmpdir(), "mimir-lemonsqueezy-smoke-")) + +try { + const { privateKeyJwk, publicKeyJwk } = await generateKeyPair() + const privateKeyPath = join(tempDir, "private.jwk") + await writeFile(privateKeyPath, `${JSON.stringify(privateKeyJwk)}\n`, { mode: 0o600 }) + + const orderOutput = await convertEvent({ + privateKeyPath, + eventName: "order-created.json", + event: syntheticOrderEvent(), + }) + await assertLicenseOutput(orderOutput, publicKeyJwk, { + eventName: "order_created", + sourceType: "orders", + tier: "solo", + licenseId: "lemonsqueezy:order:order-synthetic-001", + expiresAt: null, + }) + + const subscriptionOutput = await convertEvent({ + privateKeyPath, + eventName: "subscription-created.json", + event: syntheticSubscriptionEvent(), + }) + await assertLicenseOutput(subscriptionOutput, publicKeyJwk, { + eventName: "subscription_created", + sourceType: "subscriptions", + tier: "team", + licenseId: "lemonsqueezy:subscription:sub-synthetic-001", + expiresAt: "2026-08-01T00:00:00.000Z", + }) + + console.log("Lemon Squeezy license smoke passed.") +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +async function generateKeyPair() { + const keyPair = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]) + return { + privateKeyJwk: await webcrypto.subtle.exportKey("jwk", keyPair.privateKey), + publicKeyJwk: await webcrypto.subtle.exportKey("jwk", keyPair.publicKey), + } +} + +async function convertEvent({ privateKeyPath, eventName, event }) { + const eventPath = join(tempDir, eventName) + await writeFile(eventPath, `${JSON.stringify(event, null, 2)}\n`, { mode: 0o600 }) + + const result = spawnSync( + process.execPath, + [ + CONVERTER_SCRIPT, + "--event", + eventPath, + "--private-key", + privateKeyPath, + "--major-version", + "0", + "--json", + ], + { encoding: "utf8", shell: false }, + ) + + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || "license conversion failed") + } + + return JSON.parse(result.stdout) +} + +async function assertLicenseOutput(output, publicKeyJwk, expected) { + assertEqual(output.eventName, expected.eventName, "eventName") + assertEqual(output.sourceType, expected.sourceType, "sourceType") + assertEqual(output.tier, expected.tier, "tier") + assertEqual(output.licenseId, expected.licenseId, "licenseId") + assertEqual(output.expiresAt ?? null, expected.expiresAt, "expiresAt") + + const payload = await verifyLicenseKey(output.licenseKey, publicKeyJwk) + assertEqual(payload.product, "mimir-desktop", "payload.product") + assertEqual(payload.holder, "Synthetic Buyer", "payload.holder") + assertEqual(payload.tier, expected.tier, "payload.tier") + assertEqual(payload.licenseId, expected.licenseId, "payload.licenseId") + assertEqual(payload.expiresAt ?? null, expected.expiresAt, "payload.expiresAt") +} + +async function verifyLicenseKey(licenseKey, publicKeyJwk) { + const parts = String(licenseKey).split(".") + if (parts.length !== 3 || parts[0] !== "MIMIR1") { + throw new Error("license key must use MIMIR1.payload.signature format") + } + + const [, encodedPayload, encodedSignature] = parts + const key = await webcrypto.subtle.importKey( + "jwk", + publicKeyJwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ) + const ok = await webcrypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + Buffer.from(encodedSignature, "base64url"), + new TextEncoder().encode(encodedPayload), + ) + + if (!ok) { + throw new Error("license signature verification failed") + } + + return JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) +} + +function syntheticOrderEvent() { + return { + meta: { event_name: "order_created" }, + data: { + type: "orders", + id: "order-synthetic-id", + attributes: { + identifier: "order-synthetic-001", + status: "paid", + created_at: "2026-06-01T00:00:00.000Z", + user_name: "Synthetic Buyer", + user_email: "buyer@example.test", + product_name: "Mimir Desktop", + variant_name: "Mimir Desktop Solo", + }, + }, + } +} + +function syntheticSubscriptionEvent() { + return { + meta: { event_name: "subscription_created" }, + data: { + type: "subscriptions", + id: "sub-synthetic-001", + attributes: { + status: "active", + created_at: "2026-06-01T00:00:00.000Z", + renews_at: "2026-08-01T00:00:00.000Z", + user_name: "Synthetic Buyer", + user_email: "buyer@example.test", + product_name: "Mimir Desktop", + variant_name: "Mimir Desktop Team", + custom_data: { + mimir_tier: "team", + }, + }, + }, + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label} mismatch: expected ${expected}, got ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/license-from-lemonsqueezy.mjs b/packages/mimir-app/scripts/license-from-lemonsqueezy.mjs new file mode 100644 index 0000000..5ca4996 --- /dev/null +++ b/packages/mimir-app/scripts/license-from-lemonsqueezy.mjs @@ -0,0 +1,195 @@ +import { readFile } from "node:fs/promises" +import { + isoDate, + licensePayload, + licenseTier, + parseArgs, + positiveInteger, + readPrivateKey, + required, + signLicensePayload, + yearsFromNow, +} from "./license-core.mjs" + +const SUBSCRIPTION_ACTIVE_STATUSES = new Set(["active", "on_trial", "past_due"]) +const DEFAULT_UPDATE_YEARS = 2 + +const args = parseArgs(process.argv.slice(2)) +const event = await readEvent(args.event) +const draft = licenseDraftFromLemonSqueezy(event, args) +const privateKeyJwk = await readPrivateKey(args) +const key = await signLicensePayload(draft.payload, privateKeyJwk) + +if (args.json) { + console.log( + JSON.stringify( + { + eventName: draft.eventName, + sourceType: draft.sourceType, + holder: draft.payload.holder, + tier: draft.payload.tier, + licenseId: draft.payload.licenseId, + updatesUntil: draft.payload.updatesUntil, + expiresAt: draft.payload.expiresAt ?? null, + licenseKey: key, + }, + null, + 2, + ), + ) +} else { + console.log(key) +} + +async function readEvent(target) { + const raw = + !target || target === "-" + ? await new Promise((resolve, reject) => { + let data = "" + process.stdin.setEncoding("utf8") + process.stdin.on("data", (chunk) => { + data += chunk + }) + process.stdin.on("end", () => resolve(data)) + process.stdin.on("error", reject) + }) + : await readFile(target, "utf8") + return JSON.parse(raw) +} + +function licenseDraftFromLemonSqueezy(event, values) { + const data = record(event.data ?? event) + const attributes = record(data.attributes ?? event.attributes ?? event) + const sourceType = stringValue(data.type ?? event.type ?? attributes.type ?? "unknown") + const eventName = stringValue(event.meta?.event_name ?? event.event_name ?? values["event-name"]) + + if (sourceType === "subscriptions" || eventName.startsWith("subscription_")) { + return subscriptionLicenseDraft(attributes, data.id, eventName, sourceType, values) + } + + return orderLicenseDraft(attributes, data.id, eventName, sourceType, values) +} + +function orderLicenseDraft(attributes, id, eventName, sourceType, values) { + const status = stringValue(attributes.status) + if (status && status !== "paid" && !values["allow-unpaid"]) { + throw new Error(`Refusing to issue a license for unpaid order status: ${status}.`) + } + + const issuedAt = isoDate( + stringValue(attributes.created_at) || new Date().toISOString(), + "created_at", + ) + const updateYears = positiveInteger( + values["updates-years"] ?? String(DEFAULT_UPDATE_YEARS), + "updates-years", + ) + const holder = holderName(attributes) + const licenseValues = { + holder, + tier: values.tier ?? inferTier(attributes), + "major-version": values["major-version"] ?? "0", + "license-id": + values["license-id"] ?? lemonLicenseId("order", firstNonEmpty(attributes.identifier, id)), + "issued-at": issuedAt, + "updates-until": values["updates-until"] ?? yearsFromNow(updateYears, issuedAt), + } + + return { + eventName, + sourceType, + payload: licensePayload(licenseValues), + } +} + +function subscriptionLicenseDraft(attributes, id, eventName, sourceType, values) { + const status = stringValue(attributes.status) + if (!SUBSCRIPTION_ACTIVE_STATUSES.has(status) && !values["allow-inactive"]) { + throw new Error(`Refusing to issue a license for subscription status: ${status || "unknown"}.`) + } + + const issuedAt = isoDate( + stringValue(attributes.created_at) || new Date().toISOString(), + "created_at", + ) + const expiresAt = firstNonEmpty(values["expires-at"], attributes.renews_at, attributes.ends_at) + const holder = holderName(attributes) + const licenseValues = { + holder, + tier: values.tier ?? inferTier(attributes), + "major-version": values["major-version"] ?? "0", + "license-id": + values["license-id"] ?? + lemonLicenseId("subscription", firstNonEmpty(id, attributes.order_id)), + "issued-at": issuedAt, + "updates-until": + values["updates-until"] ?? + required(expiresAt, "Subscription renews_at or expires-at is required."), + "expires-at": required(expiresAt, "Subscription renews_at or expires-at is required."), + } + + return { + eventName, + sourceType, + payload: licensePayload(licenseValues), + } +} + +function holderName(attributes) { + const userName = stringValue(attributes.user_name) + const userEmail = stringValue(attributes.user_email) + return required(userName || userEmail, "Lemon Squeezy user_name or user_email is required.") +} + +function inferTier(attributes) { + const explicitTier = stringValue(attributes.custom_data?.mimir_tier) + if (explicitTier) { + return licenseTier(explicitTier) + } + + const candidate = [ + attributes.variant_name, + attributes.product_name, + attributes.first_order_item?.variant_name, + attributes.first_order_item?.product_name, + ] + .map(stringValue) + .join(" ") + .toLowerCase() + + if (candidate.includes("company") || candidate.includes("enterprise")) return "company" + if (candidate.includes("team")) return "team" + return "solo" +} + +function lemonLicenseId(kind, value) { + const id = stringValue(value) + if (!id) { + throw new Error(`Lemon Squeezy ${kind} id is required.`) + } + return `lemonsqueezy:${kind}:${id}` +} + +function record(value) { + return typeof value === "object" && value !== null ? value : {} +} + +function firstNonEmpty(...values) { + for (const value of values) { + const text = stringValue(value) + if (text) { + return text + } + } + return "" +} + +function stringValue(value) { + if (typeof value === "string") { + return value.trim() + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value) + } + return "" +} diff --git a/packages/mimir-app/scripts/license-issue.mjs b/packages/mimir-app/scripts/license-issue.mjs new file mode 100644 index 0000000..ea26f48 --- /dev/null +++ b/packages/mimir-app/scripts/license-issue.mjs @@ -0,0 +1,6 @@ +import { licensePayload, parseArgs, readPrivateKey, signLicensePayload } from "./license-core.mjs" + +const args = parseArgs(process.argv.slice(2)) +const privateKeyJwk = await readPrivateKey(args) +const payload = licensePayload(args) +console.log(await signLicensePayload(payload, privateKeyJwk)) diff --git a/packages/mimir-app/scripts/license-keypair.mjs b/packages/mimir-app/scripts/license-keypair.mjs new file mode 100644 index 0000000..1a97f67 --- /dev/null +++ b/packages/mimir-app/scripts/license-keypair.mjs @@ -0,0 +1,57 @@ +import { webcrypto } from "node:crypto" +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" + +const args = parseArgs(process.argv.slice(2)) +const keyPair = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", +]) +const privateKey = await webcrypto.subtle.exportKey("jwk", keyPair.privateKey) +const publicKey = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey) +const privateKeyPath = args["private-key"] ?? ".mimir/license-private.jwk" +const publicKeyPath = args["public-key"] ?? ".mimir/license-public.jwk" +const writtenPrivateKey = await writeKey(privateKeyPath, JSON.stringify(privateKey, null, 2)) +const writtenPublicKey = await writeKey(publicKeyPath, JSON.stringify(publicKey, null, 2)) + +if (args.json) { + console.log( + JSON.stringify( + { + privateKeyPath: writtenPrivateKey, + publicKeyPath: writtenPublicKey, + vitePublicKeyEnv: JSON.stringify(publicKey), + }, + null, + 2, + ), + ) +} else { + console.log(`wrote ${writtenPrivateKey}`) + console.log(`wrote ${writtenPublicKey}`) + console.log("Private key material was written to disk and was not printed.") +} + +async function writeKey(target, content) { + const resolved = path.resolve(target) + await mkdir(path.dirname(resolved), { recursive: true }) + await writeFile(resolved, `${content}\n`, { mode: 0o600 }) + return resolved +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = "true" + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/scripts/native-bundle-verify-smoke.mjs b/packages/mimir-app/scripts/native-bundle-verify-smoke.mjs new file mode 100644 index 0000000..d2a52f6 --- /dev/null +++ b/packages/mimir-app/scripts/native-bundle-verify-smoke.mjs @@ -0,0 +1,97 @@ +import { spawnSync } from "node:child_process" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const CHECKSUMS_SCRIPT = join(SCRIPT_DIR, "native-checksums.mjs") +const MANIFEST_SCRIPT = join(SCRIPT_DIR, "native-release-manifest.mjs") +const VERIFY_SCRIPT = join(SCRIPT_DIR, "native-bundle-verify.mjs") +const tempDir = await mkdtemp(join(tmpdir(), "mimir-native-bundle-verify-smoke-")) + +try { + for (const target of ["linux", "macos", "windows", "android"]) { + const artifactsDir = join(tempDir, target) + await mkdir(artifactsDir, { recursive: true }) + await writeTargetArtifacts(artifactsDir, target) + runNode([CHECKSUMS_SCRIPT, "--artifacts-dir", artifactsDir]) + runNode([MANIFEST_SCRIPT, "--artifacts-dir", artifactsDir, "--target", target]) + + const result = runNode([ + VERIFY_SCRIPT, + "--artifacts-dir", + artifactsDir, + "--target", + target, + "--json", + ]) + const output = JSON.parse(result.stdout) + assertEqual(output.ok, true, `${target} verification`) + } + + const incompleteLinuxDir = join(tempDir, "incomplete-linux") + await mkdir(incompleteLinuxDir, { recursive: true }) + await writeFile(join(incompleteLinuxDir, "Mimir_0.0.0.AppImage"), "synthetic appimage\n", "utf8") + runNode([CHECKSUMS_SCRIPT, "--artifacts-dir", incompleteLinuxDir]) + runNode([MANIFEST_SCRIPT, "--artifacts-dir", incompleteLinuxDir, "--target", "linux"]) + assertFails( + [VERIFY_SCRIPT, "--artifacts-dir", incompleteLinuxDir, "--target", "linux", "--json"], + "Linux Debian package", + ) + + console.log("Native bundle verification smoke passed.") +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +async function writeTargetArtifacts(artifactsDir, target) { + if (target === "linux") { + await writeFile(join(artifactsDir, "Mimir_0.0.0.AppImage"), "synthetic appimage\n", "utf8") + await writeFile(join(artifactsDir, "mimir_0.0.0_amd64.deb"), "synthetic deb\n", "utf8") + return + } + if (target === "macos") { + await mkdir(join(artifactsDir, "Mimir.app", "Contents"), { recursive: true }) + await writeFile(join(artifactsDir, "Mimir_0.0.0.dmg"), "synthetic dmg\n", "utf8") + await writeFile( + join(artifactsDir, "Mimir.app", "Contents", "Info.plist"), + "synthetic plist\n", + "utf8", + ) + return + } + if (target === "windows") { + await writeFile(join(artifactsDir, "Mimir_0.0.0_x64-setup.exe"), "synthetic exe\n", "utf8") + await writeFile(join(artifactsDir, "Mimir_0.0.0_x64_en-US.msi"), "synthetic msi\n", "utf8") + return + } + await writeFile(join(artifactsDir, "mimir-universal-release.apk"), "synthetic apk\n", "utf8") +} + +function runNode(args) { + const result = spawnSync(process.execPath, args, { encoding: "utf8", shell: false }) + if (result.status !== 0) { + throw new Error( + `command failed: node ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + return result +} + +function assertFails(args, expectedOutput) { + const result = spawnSync(process.execPath, args, { encoding: "utf8", shell: false }) + if (result.status === 0) { + throw new Error(`command unexpectedly passed: node ${args.join(" ")}`) + } + const output = `${result.stdout}\n${result.stderr}` + if (!output.includes(expectedOutput)) { + throw new Error(`expected output to include ${expectedOutput}\n${output}`) + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/native-bundle-verify.mjs b/packages/mimir-app/scripts/native-bundle-verify.mjs new file mode 100644 index 0000000..2c19c41 --- /dev/null +++ b/packages/mimir-app/scripts/native-bundle-verify.mjs @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto" +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" + +const VALID_TARGETS = new Set(["macos", "windows", "linux", "android"]) +const args = parseArgs(process.argv.slice(2)) +const target = requiredTarget(args.target) +const artifactsDir = path.resolve(args["artifacts-dir"] ?? "src-tauri/target/release/bundle") +const checksumsPath = path.resolve(args.checksums ?? path.join(artifactsDir, "SHA256SUMS")) +const manifestPath = path.resolve( + args.manifest ?? path.join(artifactsDir, "mimir-app-release.json"), +) +const checksumEntries = await readChecksums(checksumsPath) +const manifest = JSON.parse(await readFile(manifestPath, "utf8")) +const files = await listFiles(artifactsDir) +const report = await verifyBundle({ + artifactsDir, + checksumEntries, + files, + manifest, + target, +}) + +if (args.json) { + console.log(JSON.stringify(report, null, 2)) +} else { + console.log(`Mimir native bundle verification: ${report.ok ? "ok" : "failed"}`) + for (const check of report.checks) { + console.log(`${check.ok ? "ok" : "missing"} ${check.label}: ${check.detail}`) + } +} + +if (!report.ok) { + process.exitCode = 1 +} + +async function verifyBundle({ artifactsDir, checksumEntries, files, manifest, target }) { + const checksumMap = new Map(checksumEntries.map((entry) => [entry.file, entry.sha256])) + const manifestFiles = Array.isArray(manifest.files) ? manifest.files : [] + const manifestMap = new Map(manifestFiles.map((entry) => [entry.file, entry])) + const artifactFiles = files.filter( + (file) => file !== "SHA256SUMS" && file !== "mimir-app-release.json", + ) + const checks = [ + { + label: "target", + ok: manifest.target === target, + detail: manifest.target === target ? target : `manifest target is ${manifest.target}`, + }, + { + label: "checksum entries", + ok: artifactFiles.length > 0 && sameSet(artifactFiles, [...checksumMap.keys()]), + detail: `${checksumMap.size} checksum entries for ${artifactFiles.length} artifact files`, + }, + { + label: "manifest entries", + ok: artifactFiles.length > 0 && sameSet(artifactFiles, [...manifestMap.keys()]), + detail: `${manifestMap.size} manifest entries for ${artifactFiles.length} artifact files`, + }, + ...targetChecks(target, artifactFiles), + ] + + for (const file of artifactFiles) { + const content = await readFile(path.join(artifactsDir, file)) + const sha256 = createHash("sha256").update(content).digest("hex") + const manifestEntry = manifestMap.get(file) + checks.push({ + label: `sha256 ${file}`, + ok: checksumMap.get(file) === sha256 && manifestEntry?.sha256 === sha256, + detail: "checksum and manifest must match file content", + }) + checks.push({ + label: `size ${file}`, + ok: manifestEntry?.sizeBytes === content.byteLength, + detail: `manifest size=${manifestEntry?.sizeBytes ?? "missing"} actual=${content.byteLength}`, + }) + checks.push({ + label: `download URL ${file}`, + ok: validOptionalDownloadUrl(manifestEntry?.downloadUrl), + detail: manifestEntry?.downloadUrl ? "HTTPS download URL" : "no download URL", + }) + } + + return { + ok: checks.every((check) => check.ok), + target, + artifactsDir, + checks, + } +} + +function targetChecks(target, files) { + if (target === "linux") { + return [ + expectedFile(files, ".AppImage", "Linux AppImage"), + expectedFile(files, ".deb", "Linux Debian package"), + ] + } + if (target === "macos") { + return [ + expectedFile(files, ".dmg", "macOS disk image"), + expectedPattern(files, /\.app\/Contents\/Info\.plist$/u, "macOS app bundle"), + ] + } + if (target === "windows") { + return [ + expectedFile(files, ".exe", "Windows NSIS installer"), + expectedFile(files, ".msi", "Windows MSI installer"), + ] + } + return [expectedFile(files, ".apk", "Android APK")] +} + +function expectedFile(files, suffix, label) { + return { + label, + ok: files.some((file) => file.endsWith(suffix)), + detail: `expected at least one ${suffix} artifact`, + } +} + +function expectedPattern(files, pattern, label) { + return { + label, + ok: files.some((file) => pattern.test(file)), + detail: `expected a file matching ${pattern}`, + } +} + +function validOptionalDownloadUrl(value) { + if (typeof value === "undefined") { + return true + } + if (typeof value !== "string") { + return false + } + try { + return new URL(value).protocol === "https:" + } catch { + return false + } +} + +async function readChecksums(filePath) { + const lines = (await readFile(filePath, "utf8")).split(/\r?\n/u) + const entries = [] + for (const line of lines) { + if (line.trim() === "") continue + const match = line.match(/^([a-f0-9]{64})\s{2}(.+)$/u) + if (!match) { + throw new Error(`Invalid checksum line: ${line}`) + } + const [, sha256, file] = match + if (path.isAbsolute(file) || file.split("/").includes("..")) { + throw new Error(`Checksum path must be relative and stay inside artifacts dir: ${file}`) + } + entries.push({ file, sha256 }) + } + return entries +} + +async function listFiles(root, base = root) { + const children = await readdir(root, { withFileTypes: true }) + const files = [] + + for (const child of children) { + const filePath = path.join(root, child.name) + if (child.isDirectory()) { + files.push(...(await listFiles(filePath, base))) + continue + } + if (child.isFile()) { + files.push(path.relative(base, filePath).split(path.sep).join("/")) + } + } + + return files.sort() +} + +function sameSet(left, right) { + if (left.length !== right.length) { + return false + } + const rightSet = new Set(right) + return left.every((entry) => rightSet.has(entry)) +} + +function requiredTarget(value) { + if (!VALID_TARGETS.has(value)) { + throw new Error(`--target must be one of ${Array.from(VALID_TARGETS).join(", ")}.`) + } + return value +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === "--") continue + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = true + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/scripts/native-checksums-smoke.mjs b/packages/mimir-app/scripts/native-checksums-smoke.mjs new file mode 100644 index 0000000..c71954e --- /dev/null +++ b/packages/mimir-app/scripts/native-checksums-smoke.mjs @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const CHECKSUMS_SCRIPT = join(SCRIPT_DIR, "native-checksums.mjs") +const tempDir = await mkdtemp(join(tmpdir(), "mimir-native-checksums-smoke-")) +const artifactsDir = join(tempDir, "bundle") +const nestedDir = join(artifactsDir, "app") +const manifestPath = join(artifactsDir, "SHA256SUMS") + +try { + await mkdir(nestedDir, { recursive: true }) + await writeFile(join(artifactsDir, "mimir_0.0.0_amd64.deb"), "synthetic deb\n", "utf8") + await writeFile(join(artifactsDir, "Mimir_0.0.0.AppImage"), "synthetic appimage\n", "utf8") + await writeFile(join(nestedDir, "mimir"), "synthetic binary\n", "utf8") + await writeFile(manifestPath, "stale manifest should be replaced\n", "utf8") + + const result = spawnSync( + process.execPath, + [CHECKSUMS_SCRIPT, "--artifacts-dir", artifactsDir, "--out", manifestPath, "--json"], + { encoding: "utf8", shell: false }, + ) + + if (result.status !== 0) { + throw new Error( + `native checksums smoke failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + + const output = JSON.parse(result.stdout) + assertEqual(output.artifactsDir, artifactsDir, "artifactsDir") + assertEqual(output.outputPath, manifestPath, "outputPath") + + const files = output.files.map((entry) => entry.file) + assertEqual( + JSON.stringify(files), + JSON.stringify(["app/mimir", "mimir_0.0.0_amd64.deb", "Mimir_0.0.0.AppImage"]), + "sorted manifest files", + ) + assertEqual(files.includes("SHA256SUMS"), false, "manifest should not checksum itself") + + const manifest = await readFile(manifestPath, "utf8") + for (const file of files) { + const content = await readFile(join(artifactsDir, file)) + const sha256 = createHash("sha256").update(content).digest("hex") + assertIncludes(manifest, `${sha256} ${file}`, `manifest entry for ${file}`) + } + + console.log("Native checksum smoke passed.") +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} + +function assertIncludes(actual, expected, label) { + if (!actual.includes(expected)) { + throw new Error(`${label}: expected ${expected} in ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/native-checksums.mjs b/packages/mimir-app/scripts/native-checksums.mjs new file mode 100644 index 0000000..5627b6b --- /dev/null +++ b/packages/mimir-app/scripts/native-checksums.mjs @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto" +import { readdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" + +const args = parseArgs(process.argv.slice(2)) +const artifactsDir = path.resolve(args["artifacts-dir"] ?? "src-tauri/target/release/bundle") +const outputPath = path.resolve(args.out ?? path.join(artifactsDir, "SHA256SUMS")) +const entries = await checksumEntries(artifactsDir, outputPath) + +if (entries.length === 0) { + throw new Error(`No files found under ${artifactsDir}.`) +} + +const content = `${entries.map((entry) => `${entry.sha256} ${entry.file}`).join("\n")}\n` +await writeFile(outputPath, content, "utf8") + +if (args.json) { + console.log( + JSON.stringify( + { + artifactsDir, + outputPath, + files: entries, + }, + null, + 2, + ), + ) +} else { + console.log(`Wrote ${path.relative(process.cwd(), outputPath) || outputPath}`) +} + +async function checksumEntries(root, output) { + const files = await listFiles(root) + const outputAbsolute = path.resolve(output) + const entries = [] + + for (const file of files) { + if (path.resolve(file) === outputAbsolute) { + continue + } + const buffer = await readFile(file) + entries.push({ + file: path.relative(root, file).split(path.sep).join("/"), + sha256: createHash("sha256").update(buffer).digest("hex"), + }) + } + + return entries.sort((a, b) => a.file.localeCompare(b.file)) +} + +async function listFiles(root) { + const children = await readdir(root, { withFileTypes: true }) + const files = [] + + for (const child of children) { + const filePath = path.join(root, child.name) + if (child.isDirectory()) { + files.push(...(await listFiles(filePath))) + continue + } + if (child.isFile()) { + files.push(filePath) + } + } + + return files +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === "--") continue + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = true + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/scripts/native-release-manifest-smoke.mjs b/packages/mimir-app/scripts/native-release-manifest-smoke.mjs new file mode 100644 index 0000000..2af2579 --- /dev/null +++ b/packages/mimir-app/scripts/native-release-manifest-smoke.mjs @@ -0,0 +1,104 @@ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const CHECKSUMS_SCRIPT = join(SCRIPT_DIR, "native-checksums.mjs") +const MANIFEST_SCRIPT = join(SCRIPT_DIR, "native-release-manifest.mjs") +const tempDir = await mkdtemp(join(tmpdir(), "mimir-native-release-manifest-smoke-")) +const artifactsDir = join(tempDir, "bundle") +const manifestPath = join(artifactsDir, "mimir-app-release.json") + +try { + await mkdir(artifactsDir, { recursive: true }) + await writeFile(join(artifactsDir, "mimir_0.0.0_amd64.deb"), "synthetic deb\n", "utf8") + await writeFile(join(artifactsDir, "Mimir_0.0.0.AppImage"), "synthetic appimage\n", "utf8") + + runNode([CHECKSUMS_SCRIPT, "--artifacts-dir", artifactsDir]) + assertFails( + [ + MANIFEST_SCRIPT, + "--artifacts-dir", + artifactsDir, + "--target", + "linux", + "--base-url", + "http://downloads.example.invalid/mimir/linux", + ], + "--base-url must use HTTPS.", + ) + const result = runNode([ + MANIFEST_SCRIPT, + "--artifacts-dir", + artifactsDir, + "--target", + "linux", + "--out", + manifestPath, + "--base-url", + "https://downloads.example.invalid/mimir/linux", + "--generated-at", + "2026-06-30T00:00:00.000Z", + "--json", + ]) + + const output = JSON.parse(result.stdout) + const manifest = output.manifest + assertEqual(output.outputPath, manifestPath, "outputPath") + assertEqual(manifest.schemaVersion, 1, "schemaVersion") + assertEqual(manifest.product, "Mimir", "product") + assertEqual(manifest.packageName, "@jcode.labs/mimir-app", "packageName") + assertEqual(manifest.target, "linux", "target") + assertEqual(manifest.generatedAt, "2026-06-30T00:00:00.000Z", "generatedAt") + assertEqual(manifest.files.length, 2, "files length") + + for (const entry of manifest.files) { + const content = await readFile(join(artifactsDir, entry.file)) + const sha256 = createHash("sha256").update(content).digest("hex") + assertEqual(entry.sha256, sha256, `sha256 for ${entry.file}`) + assertEqual(entry.sizeBytes, content.byteLength, `sizeBytes for ${entry.file}`) + assertEqual( + entry.downloadUrl, + `https://downloads.example.invalid/mimir/linux/${entry.file}`, + `downloadUrl for ${entry.file}`, + ) + } + + const writtenManifest = JSON.parse(await readFile(manifestPath, "utf8")) + assertEqual(writtenManifest.files.length, 2, "written files length") + + console.log("Native release manifest smoke passed.") +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +function runNode(args) { + const result = spawnSync(process.execPath, args, { encoding: "utf8", shell: false }) + if (result.status !== 0) { + throw new Error( + `command failed: node ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + return result +} + +function assertFails(args, expectedStderr) { + const result = spawnSync(process.execPath, args, { encoding: "utf8", shell: false }) + if (result.status === 0) { + throw new Error(`command unexpectedly passed: node ${args.join(" ")}`) + } + if (!result.stderr.includes(expectedStderr)) { + throw new Error( + `expected stderr to include ${expectedStderr}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/native-release-manifest.mjs b/packages/mimir-app/scripts/native-release-manifest.mjs new file mode 100644 index 0000000..43fe733 --- /dev/null +++ b/packages/mimir-app/scripts/native-release-manifest.mjs @@ -0,0 +1,122 @@ +import { readFile, stat, writeFile } from "node:fs/promises" +import path from "node:path" + +const VALID_TARGETS = new Set(["macos", "windows", "linux", "android"]) +const args = parseArgs(process.argv.slice(2)) +const artifactsDir = path.resolve(args["artifacts-dir"] ?? "src-tauri/target/release/bundle") +const checksumsPath = path.resolve(args.checksums ?? path.join(artifactsDir, "SHA256SUMS")) +const outputPath = path.resolve(args.out ?? path.join(artifactsDir, "mimir-app-release.json")) +const target = requiredTarget(args.target) +const generatedAt = args["generated-at"] ?? new Date().toISOString() +const baseUrl = optionalHttpsBaseUrl(args["base-url"]) +const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) +const entries = await manifestEntries(artifactsDir, checksumsPath, outputPath, baseUrl) + +if (entries.length === 0) { + throw new Error(`No checksum entries found in ${checksumsPath}.`) +} + +const manifest = { + schemaVersion: 1, + product: "Mimir", + packageName: packageJson.name, + version: packageJson.version, + target, + generatedAt, + files: entries, +} + +await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8") + +if (args.json) { + console.log(JSON.stringify({ outputPath, manifest }, null, 2)) +} else { + console.log(`Wrote ${path.relative(process.cwd(), outputPath) || outputPath}`) +} + +async function manifestEntries(root, checksums, output, baseUrl) { + const lines = (await readFile(checksums, "utf8")).split(/\r?\n/u) + const outputRelative = path.relative(root, output).split(path.sep).join("/") + const entries = [] + + for (const line of lines) { + if (line.trim() === "") continue + const match = line.match(/^([a-f0-9]{64})\s{2}(.+)$/u) + if (!match) { + throw new Error(`Invalid checksum line: ${line}`) + } + const [, sha256, file] = match + if (file === outputRelative || file === "mimir-app-release.json") { + continue + } + if (path.isAbsolute(file) || file.split("/").includes("..")) { + throw new Error(`Checksum path must be relative and stay inside artifacts dir: ${file}`) + } + const filePath = path.join(root, file) + const info = await stat(filePath) + if (!info.isFile()) { + throw new Error(`Checksum entry is not a file: ${file}`) + } + entries.push( + appendOptionalUrl( + { + file, + sizeBytes: info.size, + sha256, + }, + baseUrl, + ), + ) + } + + return entries.sort((a, b) => a.file.localeCompare(b.file)) +} + +function appendOptionalUrl(entry, baseUrl) { + if (!baseUrl) { + return entry + } + return { + ...entry, + downloadUrl: `${baseUrl.replace(/\/+$/u, "")}/${entry.file + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/")}`, + } +} + +function optionalHttpsBaseUrl(value) { + if (!value) { + return null + } + const parsed = new URL(value) + if (parsed.protocol !== "https:") { + throw new Error("--base-url must use HTTPS.") + } + return parsed.toString().replace(/\/+$/u, "") +} + +function requiredTarget(value) { + if (!VALID_TARGETS.has(value)) { + throw new Error(`--target must be one of ${Array.from(VALID_TARGETS).join(", ")}.`) + } + return value +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === "--") continue + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = true + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/scripts/release-preflight-smoke.mjs b/packages/mimir-app/scripts/release-preflight-smoke.mjs new file mode 100644 index 0000000..0345e56 --- /dev/null +++ b/packages/mimir-app/scripts/release-preflight-smoke.mjs @@ -0,0 +1,142 @@ +import { spawnSync } from "node:child_process" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const PREFLIGHT_SCRIPT = join(SCRIPT_DIR, "release-preflight.mjs") +const SECRET_VALUES = [ + "synthetic-apple-id@example.invalid", + "synthetic-apple-password", + "synthetic-apple-team-id", + "synthetic-apple-signing-identity", + "synthetic-windows-thumbprint", + "https://timestamp.example.invalid/mimir", + "/tmp/synthetic-android-sdk", + "/tmp/synthetic-java-home", +] + +for (const target of ["linux", "macos", "windows", "android"]) { + const result = runPreflight(["--target", target, "--soft", "--json"], envForTarget(target)) + assertEqual(result.status, 0, `${target} soft status`) + assertNoSecretValues(result, `${target} output`) + assertEqual(result.output.target, target, `${target} target`) + assertEqual(Array.isArray(result.output.checks), true, `${target} checks array`) + assertIncludes( + checkLabels(result.output), + "Tauri updater configuration", + `${target} updater guard`, + ) +} + +const macos = runPreflight(["--target", "macos", "--soft", "--json"], envForTarget("macos")) +assertIncludes(checkLabels(macos.output), "Apple notarization account", "macOS Apple account check") +assertIncludes(checkDetails(macos.output), "APPLE_ID is set", "macOS Apple ID detail") +assertIncludes(checkDetails(macos.output), "APPLE_PASSWORD is set", "macOS Apple password detail") +assertIncludes(checkDetails(macos.output), "APPLE_TEAM_ID is set", "macOS Apple team detail") + +const windows = runPreflight(["--target", "windows", "--soft", "--json"], envForTarget("windows")) +assertIncludes(checkLabels(windows.output), "certificate thumbprint", "Windows certificate check") +assertIncludes( + checkDetails(windows.output), + "WINDOWS_CERTIFICATE_THUMBPRINT is set", + "Windows thumbprint detail", +) +assertIncludes( + checkDetails(windows.output), + "WINDOWS_TIMESTAMP_URL is set", + "Windows timestamp detail", +) + +const android = runPreflight(["--target", "android", "--soft", "--json"], envForTarget("android")) +assertIncludes(checkLabels(android.output), "Android SDK root", "Android SDK check") +assertIncludes(checkDetails(android.output), "ANDROID_HOME is set", "Android SDK detail") +assertIncludes(checkDetails(android.output), "JAVA_HOME is set", "Android Java detail") + +const invalid = spawnSync( + process.execPath, + [PREFLIGHT_SCRIPT, "--target", "ios", "--soft", "--json"], + { + encoding: "utf8", + shell: false, + }, +) +assertEqual(invalid.status === 0, false, "iOS release target should fail") +assertIncludes( + invalid.stderr, + "target must be one of macos, windows, linux, android", + "invalid target stderr", +) + +console.log("Release preflight smoke passed.") + +function runPreflight(args, env = {}) { + const result = spawnSync(process.execPath, [PREFLIGHT_SCRIPT, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + shell: false, + }) + if (result.status !== 0) { + throw new Error( + `release preflight failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + } + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + output: JSON.parse(result.stdout), + } +} + +function envForTarget(target) { + if (target === "macos") { + return { + APPLE_ID: "synthetic-apple-id@example.invalid", + APPLE_PASSWORD: "synthetic-apple-password", + APPLE_SIGNING_IDENTITY: "synthetic-apple-signing-identity", + APPLE_TEAM_ID: "synthetic-apple-team-id", + } + } + if (target === "windows") { + return { + WINDOWS_CERTIFICATE_THUMBPRINT: "synthetic-windows-thumbprint", + WINDOWS_TIMESTAMP_URL: "https://timestamp.example.invalid/mimir", + } + } + if (target === "android") { + return { + ANDROID_HOME: "/tmp/synthetic-android-sdk", + JAVA_HOME: "/tmp/synthetic-java-home", + } + } + return {} +} + +function assertNoSecretValues(result, label) { + const output = `${result.stdout}\n${result.stderr}` + for (const value of SECRET_VALUES) { + if (output.includes(value)) { + throw new Error(`${label}: preflight output must not print secret-like value ${value}`) + } + } +} + +function checkLabels(output) { + return output.checks.map((check) => check.label).join("\n") +} + +function checkDetails(output) { + return output.checks.map((check) => check.detail).join("\n") +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} + +function assertIncludes(actual, expected, label) { + if (!actual.includes(expected)) { + throw new Error(`${label}: expected ${expected} in ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/release-preflight.mjs b/packages/mimir-app/scripts/release-preflight.mjs new file mode 100644 index 0000000..eac4faf --- /dev/null +++ b/packages/mimir-app/scripts/release-preflight.mjs @@ -0,0 +1,143 @@ +import { spawnSync } from "node:child_process" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const UPDATER_GUARD_SCRIPT = join(SCRIPT_DIR, "updater-guard.mjs") +const TARGETS = new Set(["macos", "windows", "linux", "android"]) +const args = parseArgs(process.argv.slice(2)) +const target = args.target ?? currentTarget() +const checks = releaseChecks(target) +const ok = checks.every((check) => check.ok) + +if (args.json) { + console.log(JSON.stringify({ target, ok, checks }, null, 2)) +} else { + console.log(`Mimir app release preflight: ${target}`) + for (const check of checks) { + console.log(`${check.ok ? "ok" : "missing"} ${check.label}: ${check.detail}`) + } +} + +if (!ok && !args.soft) { + process.exitCode = 1 +} + +function releaseChecks(releaseTarget) { + if (!TARGETS.has(releaseTarget)) { + throw new Error(`target must be one of ${Array.from(TARGETS).join(", ")}.`) + } + + const common = [ + commandCheck("pnpm", ["--version"], "pnpm workspace runner"), + commandCheck("cargo", ["--version"], "Rust/Cargo toolchain"), + commandCheck("rustc", ["--version"], "Rust compiler"), + commandCheck("pnpm", ["exec", "tauri", "--version"], "Tauri CLI"), + updaterGuardCheck(releaseTarget), + ] + + if (releaseTarget === "macos") { + return [ + ...common, + platformCheck("darwin", "macOS release builds must run on macOS."), + commandCheck("security", ["find-identity", "-v", "-p", "codesigning"], "Apple keychain"), + envCheck("APPLE_SIGNING_IDENTITY", "Developer ID Application identity name"), + envCheck("APPLE_ID", "Apple notarization account"), + envCheck("APPLE_PASSWORD", "Apple app-specific notarization password"), + envCheck("APPLE_TEAM_ID", "Apple notarization team"), + ] + } + + if (releaseTarget === "windows") { + return [ + ...common, + platformCheck("win32", "Windows release builds must run on Windows."), + commandCheck("signtool", ["sign", "/?"], "Windows Authenticode signing tool"), + envCheck("WINDOWS_CERTIFICATE_THUMBPRINT", "certificate thumbprint"), + envCheck("WINDOWS_TIMESTAMP_URL", "trusted timestamp server URL"), + ] + } + + if (releaseTarget === "linux") { + return [...common, platformCheck("linux", "Linux AppImage/deb builds must run on Linux.")] + } + + return [ + ...common, + commandCheck("rustup", ["--version"], "Rust target manager"), + envAnyCheck(["ANDROID_HOME", "ANDROID_SDK_ROOT"], "Android SDK root"), + envCheck("JAVA_HOME", "JDK for Android build tooling"), + ] +} + +function commandCheck(command, commandArgs, label) { + const result = spawnSync(command, commandArgs, { encoding: "utf8", shell: false }) + return { + label, + ok: result.status === 0, + detail: + result.status === 0 ? firstLine(result.stdout || result.stderr) : `${command} not available`, + } +} + +function updaterGuardCheck(releaseTarget) { + const commandArgs = [UPDATER_GUARD_SCRIPT] + if (releaseTarget !== "android") { + commandArgs.push("--require-private-key") + } + return commandCheck(process.execPath, commandArgs, "Tauri updater configuration") +} + +function platformCheck(expected, detail) { + return { + label: `platform ${expected}`, + ok: process.platform === expected, + detail: process.platform === expected ? process.platform : detail, + } +} + +function envCheck(name, label) { + return { + label, + ok: Boolean(process.env[name]), + detail: process.env[name] ? `${name} is set` : `${name} is not set`, + } +} + +function envAnyCheck(names, label) { + const found = names.find((name) => process.env[name]) + return { + label, + ok: Boolean(found), + detail: found ? `${found} is set` : `${names.join(" or ")} is not set`, + } +} + +function firstLine(value) { + return value.trim().split(/\r?\n/u).at(0) ?? "available" +} + +function currentTarget() { + if (process.platform === "darwin") return "macos" + if (process.platform === "win32") return "windows" + if (process.platform === "linux") return "linux" + return "android" +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === "--") continue + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = "true" + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/scripts/updater-guard-smoke.mjs b/packages/mimir-app/scripts/updater-guard-smoke.mjs new file mode 100644 index 0000000..601a9c8 --- /dev/null +++ b/packages/mimir-app/scripts/updater-guard-smoke.mjs @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const UPDATER_GUARD_SCRIPT = join(SCRIPT_DIR, "updater-guard.mjs") +const tempDir = await mkdtemp(join(tmpdir(), "mimir-updater-guard-smoke-")) + +try { + const disabledConfig = join(tempDir, "updater-disabled.json") + await writeJson(disabledConfig, { + productName: "Mimir", + version: "0.0.0", + bundle: {}, + plugins: {}, + }) + + const disabled = runGuard(["--config", disabledConfig, "--json"]) + assertEqual(disabled.status, 0, "disabled updater should pass") + assertEqual(disabled.output.ok, true, "disabled updater output") + assertEqual(disabled.output.checks[0]?.label, "updater disabled", "disabled updater check label") + + const placeholderConfig = join(tempDir, "updater-placeholder.json") + await writeJson(placeholderConfig, { + bundle: { createUpdaterArtifacts: true }, + plugins: { + updater: { + pubkey: "CONTENT FROM PUBLICKEY.PEM", + endpoints: ["https://example.com/latest.json"], + }, + }, + }) + + const placeholder = runGuard(["--config", placeholderConfig, "--json", "--require-private-key"]) + assertEqual(placeholder.status, 1, "placeholder updater should fail") + assertEqual(placeholder.output.ok, false, "placeholder updater output") + assertIncludes( + placeholder.output.checks.map((check) => check.label).join("\n"), + "updater pubkey", + "placeholder updater should audit the public key", + ) + assertIncludes( + placeholder.output.checks.map((check) => check.label).join("\n"), + "updater private key", + "placeholder updater should require a private key for desktop packaging", + ) + + const readyConfig = join(tempDir, "updater-ready.json") + await writeJson(readyConfig, { + bundle: { createUpdaterArtifacts: true }, + plugins: { + updater: { + pubkey: "mimir-updater-public-key-for-smoke-test-0001", + endpoints: ["https://updates.example.invalid/mimir/latest.json"], + }, + }, + }) + + const ready = runGuard(["--config", readyConfig, "--json", "--require-private-key"], { + TAURI_SIGNING_PRIVATE_KEY: "smoke-test-private-key", + }) + assertEqual(ready.status, 0, "ready updater should pass") + assertEqual(ready.output.ok, true, "ready updater output") + + console.log("Updater guard smoke passed.") +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8") +} + +function runGuard(args, env = {}) { + const result = spawnSync(process.execPath, [UPDATER_GUARD_SCRIPT, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + shell: false, + }) + return { + status: result.status, + output: JSON.parse(result.stdout), + stderr: result.stderr, + } +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`) + } +} + +function assertIncludes(actual, expected, label) { + if (!actual.includes(expected)) { + throw new Error(`${label}: expected ${expected} in ${actual}`) + } +} diff --git a/packages/mimir-app/scripts/updater-guard.mjs b/packages/mimir-app/scripts/updater-guard.mjs new file mode 100644 index 0000000..b4b2557 --- /dev/null +++ b/packages/mimir-app/scripts/updater-guard.mjs @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const DEFAULT_CONFIG_PATH = resolve(SCRIPT_DIR, "../src-tauri/tauri.conf.json") +const PLACEHOLDER_PATTERNS = [ + /CONTENT FROM PUBLICKEY\.PEM/iu, + /\b(change-?me|placeholder|todo|fake|dummy)\b/iu, + /releases\.myapp\.com/iu, + /github\.com\/user\/repo/iu, + /example\.(com|org|net)/iu, + /localhost|127\.0\.0\.1|0\.0\.0\.0/iu, +] + +const args = parseArgs(process.argv.slice(2)) +const configPath = resolve(process.cwd(), args.config ?? DEFAULT_CONFIG_PATH) +const config = readConfig(configPath) +const checks = auditUpdaterConfig(config, { + requirePrivateKey: Boolean(args["require-private-key"]), +}) +const ok = checks.every((check) => check.ok) + +if (args.json) { + console.log(JSON.stringify({ configPath, ok, checks }, null, 2)) +} else { + console.log(`Mimir app updater config: ${ok ? "ok" : "needs attention"}`) + for (const check of checks) { + console.log(`${check.ok ? "ok" : "missing"} ${check.label}: ${check.detail}`) + } +} + +if (!ok) { + process.exitCode = 1 +} + +function readConfig(path) { + try { + return JSON.parse(readFileSync(path, "utf8")) + } catch (error) { + throw new Error(`failed to read Tauri config at ${path}: ${error.message}`) + } +} + +function auditUpdaterConfig(value, options) { + const bundle = isRecord(value.bundle) ? value.bundle : {} + const plugins = isRecord(value.plugins) ? value.plugins : {} + const updater = isRecord(plugins.updater) ? plugins.updater : undefined + const createUpdaterArtifacts = bundle.createUpdaterArtifacts + const updaterSignals = Boolean(updater) || typeof createUpdaterArtifacts !== "undefined" + + if (!updaterSignals) { + return [ + { + label: "updater disabled", + ok: true, + detail: "manual direct-download updates remain the active release path", + }, + ] + } + + const checks = [ + { + label: "bundle.createUpdaterArtifacts", + ok: createUpdaterArtifacts === true, + detail: + createUpdaterArtifacts === true + ? "v2 updater artifacts enabled" + : "must be true when the updater is configured", + }, + { + label: "plugins.updater", + ok: Boolean(updater), + detail: updater ? "updater plugin config present" : "missing updater plugin config", + }, + ] + + if (updater) { + checks.push(...updaterChecks(updater)) + } + + if (options.requirePrivateKey && updaterSignals) { + const hasPrivateKey = + Boolean(process.env.TAURI_SIGNING_PRIVATE_KEY) || + Boolean(process.env.TAURI_SIGNING_PRIVATE_KEY_PATH) + checks.push({ + label: "updater private key", + ok: hasPrivateKey, + detail: hasPrivateKey + ? "TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH is set" + : "set TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH in release secrets", + }) + } + + return checks +} + +function updaterChecks(updater) { + const pubkey = typeof updater.pubkey === "string" ? updater.pubkey.trim() : "" + const endpoints = Array.isArray(updater.endpoints) ? updater.endpoints : [] + + return [ + { + label: "updater pubkey", + ok: isRealValue(pubkey) && pubkey.length >= 32, + detail: + isRealValue(pubkey) && pubkey.length >= 32 + ? "public updater key present" + : "commit a real Tauri updater public key before enabling updates", + }, + { + label: "updater endpoints", + ok: + endpoints.length > 0 && + endpoints.every((endpoint) => typeof endpoint === "string" && isValidEndpoint(endpoint)), + detail: + endpoints.length > 0 + ? "all endpoints must be non-placeholder HTTPS URLs" + : "add at least one HTTPS update endpoint or static manifest URL", + }, + ] +} + +function isValidEndpoint(value) { + if (!isRealValue(value)) return false + + try { + const url = new URL(value) + return url.protocol === "https:" && !isPlaceholder(value) + } catch { + return false + } +} + +function isRealValue(value) { + return value.trim().length > 0 && !isPlaceholder(value) +} + +function isPlaceholder(value) { + return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)) +} + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function parseArgs(values) { + const parsed = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === "--") continue + if (!value?.startsWith("--")) continue + const key = value.slice(2) + const next = values[index + 1] + if (!next || next.startsWith("--")) { + parsed[key] = "true" + continue + } + parsed[key] = next + index += 1 + } + return parsed +} diff --git a/packages/mimir-app/src-tauri/Cargo.toml b/packages/mimir-app/src-tauri/Cargo.toml new file mode 100644 index 0000000..1dc53d5 --- /dev/null +++ b/packages/mimir-app/src-tauri/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mimir-app" +version = "0.4.10" +description = "Mimir desktop and mobile shell" +authors = ["Jean-Baptiste Thery"] +edition = "2021" +license = "MIT" + +[lib] +name = "mimir_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } diff --git a/packages/mimir-app/src-tauri/build.rs b/packages/mimir-app/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/packages/mimir-app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/packages/mimir-app/src-tauri/capabilities/default.json b/packages/mimir-app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..13cae5e --- /dev/null +++ b/packages/mimir-app/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default Mimir app capabilities.", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/packages/mimir-app/src-tauri/src/lib.rs b/packages/mimir-app/src-tauri/src/lib.rs new file mode 100644 index 0000000..17d6719 --- /dev/null +++ b/packages/mimir-app/src-tauri/src/lib.rs @@ -0,0 +1,166 @@ +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::PathBuf, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MimirCommandRequest { + project_root: String, + command: MimirCommandKind, + query: Option, + text: Option, + rebuild: Option, + top_k: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum MimirCommandKind { + Doctor, + DoctorFix, + Status, + Ingest, + Search, + Ask, + SecurityAudit, + AuditUnsupported, + ModelsPull, + AudioSummary, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MimirCommandOutput { + status: i32, + stdout: String, + stderr: String, +} + +#[tauri::command] +fn run_mimir_command(request: MimirCommandRequest) -> Result { + let project_root = request.project_root.trim(); + if project_root.is_empty() { + return Err("Project root is required.".into()); + } + let project_root_path = PathBuf::from(project_root); + if !project_root_path.is_absolute() { + return Err("Project root must be an absolute path.".into()); + } + if !project_root_path.is_dir() { + return Err("Project root must be an existing directory.".into()); + } + let project_root = project_root_path.to_string_lossy().into_owned(); + + let cli_bin = std::env::var("MIMIR_CLI_BIN").unwrap_or_else(|_| "mimir".into()); + let args = mimir_args(&request, &project_root)?; + let output = Command::new(cli_bin) + .args(args) + .output() + .map_err(|error| format!("Unable to run Mimir CLI: {error}"))?; + + Ok(MimirCommandOutput { + status: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn mimir_args(request: &MimirCommandRequest, project_root: &str) -> Result, String> { + let mut args = vec!["--project-root".into(), project_root.into()]; + + match request.command { + MimirCommandKind::Doctor => args.extend(["doctor".into(), "--json".into()]), + MimirCommandKind::DoctorFix => { + args.extend(["doctor".into(), "--fix".into(), "--json".into()]) + } + MimirCommandKind::Status => args.extend(["status".into(), "--json".into()]), + MimirCommandKind::Ingest => { + args.extend(["ingest".into(), "--json".into()]); + if request.rebuild.unwrap_or(false) { + args.push("--rebuild".into()); + } + } + MimirCommandKind::Search => { + let query = required_query(request)?; + args.extend(["search".into(), query, "--json".into()]); + push_top_k(&mut args, request.top_k); + } + MimirCommandKind::Ask => { + let query = required_query(request)?; + args.extend(["ask".into(), query, "--json".into()]); + push_top_k(&mut args, request.top_k); + } + MimirCommandKind::SecurityAudit => { + args.extend(["security-audit".into(), "--json".into()]); + } + MimirCommandKind::AuditUnsupported => { + args.extend(["audit".into(), "--unsupported".into(), "--json".into()]); + } + MimirCommandKind::ModelsPull => { + args.extend([ + "models".into(), + "pull".into(), + "--enable".into(), + "--json".into(), + ]); + } + MimirCommandKind::AudioSummary => { + let text_file = write_audio_summary_text(request, project_root)?; + args.extend(["audio".into(), text_file, "--offline".into(), "--json".into()]); + } + } + + Ok(args) +} + +fn required_query(request: &MimirCommandRequest) -> Result { + let query = request.query.as_deref().unwrap_or("").trim(); + if query.is_empty() { + return Err("Query is required.".into()); + } + Ok(query.into()) +} + +fn required_text(request: &MimirCommandRequest) -> Result { + let text = request.text.as_deref().unwrap_or("").trim(); + if text.is_empty() { + return Err("Audio text is required.".into()); + } + Ok(text.into()) +} + +fn write_audio_summary_text( + request: &MimirCommandRequest, + project_root: &str, +) -> Result { + let text = required_text(request)?; + let audio_dir = PathBuf::from(project_root).join(".mimir").join("audio"); + fs::create_dir_all(&audio_dir) + .map_err(|error| format!("Unable to prepare audio dir: {error}"))?; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("Unable to create audio timestamp: {error}"))? + .as_secs(); + let text_file = audio_dir.join(format!("retrieval-report-{timestamp}.txt")); + fs::write(&text_file, text).map_err(|error| format!("Unable to write audio text: {error}"))?; + Ok(text_file.to_string_lossy().into_owned()) +} + +fn push_top_k(args: &mut Vec, top_k: Option) { + if let Some(top_k) = top_k { + args.extend(["--top-k".into(), top_k.to_string()]); + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![run_mimir_command]) + .run(tauri::generate_context!()) + .expect("error while running Mimir") +} diff --git a/packages/mimir-app/src-tauri/src/main.rs b/packages/mimir-app/src-tauri/src/main.rs new file mode 100644 index 0000000..59c1d4d --- /dev/null +++ b/packages/mimir-app/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + mimir_app_lib::run() +} diff --git a/packages/mimir-app/src-tauri/tauri.conf.json b/packages/mimir-app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..ca3211b --- /dev/null +++ b/packages/mimir-app/src-tauri/tauri.conf.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Mimir", + "version": "0.4.10", + "identifier": "works.jcode.mimir", + "build": { + "beforeDevCommand": "pnpm dev", + "beforeBuildCommand": "pnpm build", + "devUrl": "http://localhost:5173", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Mimir", + "width": 1180, + "height": 820, + "minWidth": 390, + "minHeight": 720 + } + ], + "security": { + "csp": { + "default-src": "'self'", + "connect-src": "ipc: http://ipc.localhost", + "font-src": "'self'", + "img-src": "'self' asset: http://asset.localhost blob: data:", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'" + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "category": "Productivity", + "shortDescription": "Sovereign local RAG for confidential dossiers.", + "longDescription": "Mimir is a local-first document retrieval app for confidential dossiers and AI agents." + } +} diff --git a/packages/mimir-app/src/app.tsx b/packages/mimir-app/src/app.tsx new file mode 100644 index 0000000..edc3916 --- /dev/null +++ b/packages/mimir-app/src/app.tsx @@ -0,0 +1,1522 @@ +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Progress, + Textarea, +} from "@jcode.labs/mimir-ui" +import { + Activity, + Brain, + CheckCircle2, + Cloud, + Database, + Download, + ExternalLink, + FileSearch, + FolderOpen, + FolderPlus, + HardDrive, + KeyRound, + LockKeyhole, + MessageSquareText, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + TriangleAlert, + Volume2, +} from "lucide-react" +import { type DragEvent, type FormEvent, useEffect, useRef, useState } from "react" +import { + clearLicenseKey, + type LicenseValidation, + loadLicenseKey, + saveLicenseKey, + validateLicenseKey, +} from "./lib/license.js" +import { + type AskResult, + type DoctorReport, + runAsk, + runAudioSummary, + runDoctor, + runIngest, + runModelsPull, + runSecurityAudit, + runStatus, + type SecurityAuditReport, + type StatusReport, +} from "./lib/mimir-sidecar.js" +import { + createProject, + joinProjectPath, + loadActiveProjectId, + loadProjects, + type MimirProject, + normalizeProjectRoot, + type ProjectSourceKind, + type ProjectStatus, + removeProject, + saveActiveProjectId, + saveProjects, + upsertProject, +} from "./lib/project-registry.js" + +type View = "projects" | "retrieval" | "privacy" | "license" + +const EMPTY_LICENSE_VALIDATION: LicenseValidation = { + status: "empty", + message: "No license key is installed.", +} +const AUTO_INGEST_POLL_MS = 30_000 +const AUTO_INGEST_INTERVAL_MS = 5 * 60 * 1000 + +export function App(): React.JSX.Element { + const [view, setView] = useState("projects") + const [projects, setProjects] = useState(() => loadProjects()) + const [activeProjectId, setActiveProjectId] = useState(() => loadActiveProjectId()) + const [projectRoot, setProjectRoot] = useState("") + const [googleDriveRoot, setGoogleDriveRoot] = useState("") + const [dropStatus, setDropStatus] = useState("Drop a folder or paste its local path.") + const [runtimeMessage, setRuntimeMessage] = useState("Native Mimir runtime is idle.") + const [isRunning, setIsRunning] = useState(false) + const [question, setQuestion] = useState("") + const [askResult, setAskResult] = useState(null) + const [securityReport, setSecurityReport] = useState(null) + const [statusReport, setStatusReport] = useState(null) + const [licenseKeyInput, setLicenseKeyInput] = useState(() => loadLicenseKey()) + const [licenseValidation, setLicenseValidation] = + useState(EMPTY_LICENSE_VALIDATION) + const [isLicenseChecking, setIsLicenseChecking] = useState(false) + const projectsRef = useRef(projects) + const isRunningRef = useRef(isRunning) + const autoIngestRunnerRef = useRef<(project: MimirProject) => Promise>(async () => {}) + const activeProject = projects.find((project) => project.id === activeProjectId) ?? null + autoIngestRunnerRef.current = runAutoIngestProject + + useEffect(() => { + let isCurrent = true + validateLicenseKey(loadLicenseKey()).then((validation) => { + if (isCurrent) { + setLicenseValidation(validation) + } + }) + return () => { + isCurrent = false + } + }, []) + + useEffect(() => { + saveProjects(projects) + projectsRef.current = projects + }, [projects]) + + useEffect(() => { + isRunningRef.current = isRunning + }, [isRunning]) + + useEffect(() => { + const timerId = window.setInterval(() => { + if (isRunningRef.current) { + return + } + const project = projectsRef.current.find(shouldAutoIngestProject) + if (project) { + void autoIngestRunnerRef.current(project) + } + }, AUTO_INGEST_POLL_MS) + + return () => window.clearInterval(timerId) + }, []) + + useEffect(() => { + if (activeProjectId && projects.some((project) => project.id === activeProjectId)) { + saveActiveProjectId(activeProjectId) + return + } + + const firstProjectId = projects.at(0)?.id ?? null + setActiveProjectId(firstProjectId) + saveActiveProjectId(firstProjectId) + }, [activeProjectId, projects]) + + function registerProject( + root: string, + sourceKind: ProjectSourceKind = "local-folder", + autoIngestEnabled = false, + ): void { + const normalizedRoot = normalizeProjectRoot(root) + const existingProject = projects.find((project) => project.projectRoot === normalizedRoot) + const now = new Date().toISOString() + const project = existingProject + ? { + ...existingProject, + sourceKind, + autoIngestEnabled: autoIngestEnabled || existingProject.autoIngestEnabled, + updatedAt: now, + } + : createProject({ projectRoot: normalizedRoot, sourceKind, autoIngestEnabled }) + setProjects((currentProjects) => upsertProject(currentProjects, project)) + selectProject(project.id) + setProjectRoot("") + setGoogleDriveRoot("") + setDropStatus( + sourceKind === "google-drive" + ? `${project.name} is connected as an opt-in Google Drive sync folder.` + : `${project.name} is registered locally.`, + ) + } + + function handleProjectSubmit(event: FormEvent): void { + event.preventDefault() + try { + registerProject(projectRoot) + } catch (error) { + setDropStatus(error instanceof Error ? error.message : "Project root is required.") + } + } + + function handleGoogleDriveSubmit(event: FormEvent): void { + event.preventDefault() + try { + registerProject(googleDriveRoot, "google-drive", true) + setRuntimeMessage("Google Drive sync folder connected. Auto-ingest is enabled locally.") + } catch (error) { + setDropStatus( + error instanceof Error ? error.message : "Google Drive folder path is required.", + ) + } + } + + function handleDrop(event: DragEvent): void { + event.preventDefault() + const droppedPath = droppedProjectPath(event.dataTransfer) + if (droppedPath) { + try { + registerProject(droppedPath) + } catch (error) { + setDropStatus(error instanceof Error ? error.message : "Dropped project path is invalid.") + } + return + } + + const itemCount = event.dataTransfer.items.length || event.dataTransfer.files.length + setDropStatus( + itemCount > 0 + ? `${itemCount} local item${itemCount === 1 ? "" : "s"} detected. Paste the absolute folder path if the native shell did not expose it.` + : "Paste the absolute folder path when the native shell does not expose drag-drop paths.", + ) + } + + function handleRemoveProject(projectId: string): void { + if (projectId === activeProjectId) { + setAskResult(null) + setSecurityReport(null) + setStatusReport(null) + } + setProjects((currentProjects) => removeProject(currentProjects, projectId)) + } + + function selectProject(projectId: string): void { + setActiveProjectId(projectId) + setAskResult(null) + setSecurityReport(null) + setStatusReport(null) + } + + async function handleLicenseSubmit(event: FormEvent): Promise { + event.preventDefault() + setIsLicenseChecking(true) + try { + const validation = await validateLicenseKey(licenseKeyInput) + setLicenseValidation(validation) + if (validation.status === "valid") { + saveLicenseKey(licenseKeyInput.trim()) + } + setRuntimeMessage(validation.message) + } finally { + setIsLicenseChecking(false) + } + } + + function handleClearLicense(): void { + clearLicenseKey() + setLicenseKeyInput("") + setLicenseValidation(EMPTY_LICENSE_VALIDATION) + setRuntimeMessage("License removed from local app storage.") + } + + async function handleRefreshProject(project: MimirProject): Promise { + await runProjectCommand("Refreshing project status", project, async () => { + const [report, status] = await Promise.all([ + runDoctor(project.projectRoot), + runStatus(project.projectRoot), + ]) + updateProjectFromDoctor(project, report) + setStatusReport(status) + setRuntimeMessage( + report.ready ? "Project is ready." : (report.nextSteps.at(0) ?? "Review project status."), + ) + }) + } + + async function handleRepairProject(project: MimirProject): Promise { + await runProjectCommand("Running safe repair", project, async () => { + const report = await runDoctor(project.projectRoot, true) + const status = await runStatus(project.projectRoot) + updateProjectFromDoctor(project, report) + setStatusReport(status) + setRuntimeMessage( + report.ready + ? "Repair complete. Project is ready." + : (report.nextSteps.at(0) ?? "Repair complete."), + ) + }) + } + + async function handleIngestProject(project: MimirProject): Promise { + replaceProject({ ...project, status: "indexing", progress: Math.max(project.progress, 35) }) + await runProjectCommand("Ingesting project documents", project, async () => { + const ingestResult = await runIngest(project.projectRoot) + const [report, status] = await Promise.all([ + runDoctor(project.projectRoot), + runStatus(project.projectRoot), + ]) + updateProjectFromDoctor(project, report) + setStatusReport(status) + setRuntimeMessage( + `Ingest complete: ${ingestResult.indexedFiles} indexed files, ${ingestResult.chunks} chunks.`, + ) + }) + } + + async function runAutoIngestProject(project: MimirProject): Promise { + const startedAt = new Date().toISOString() + const watchedProject = { + ...project, + status: "indexing" as const, + progress: Math.max(project.progress, 35), + lastAutoIngestAt: startedAt, + } + replaceProject(watchedProject) + await runProjectCommand("Auto-ingesting watched folder", watchedProject, async () => { + const ingestResult = await runIngest(project.projectRoot) + const [report, status] = await Promise.all([ + runDoctor(project.projectRoot), + runStatus(project.projectRoot), + ]) + updateProjectFromDoctor(watchedProject, report, { lastAutoIngestAt: startedAt }) + setStatusReport(status) + setRuntimeMessage( + `Watched folder indexed: ${ingestResult.indexedFiles} indexed files, ${ingestResult.chunks} chunks.`, + ) + }) + } + + function handleToggleAutoIngest(project: MimirProject): void { + const autoIngestEnabled = !project.autoIngestEnabled + replaceProject({ + ...project, + autoIngestEnabled, + lastAutoIngestAt: autoIngestEnabled ? project.lastAutoIngestAt : null, + updatedAt: new Date().toISOString(), + }) + setRuntimeMessage( + autoIngestEnabled + ? `${project.name} will auto-ingest local changes every 5 minutes.` + : `${project.name} auto-ingest is disabled.`, + ) + } + + async function handleAskSubmit(event: FormEvent): Promise { + event.preventDefault() + if (!activeProject) { + setRuntimeMessage("Select a project before asking Mimir.") + return + } + + const trimmedQuestion = question.trim() + if (!trimmedQuestion) { + setRuntimeMessage("Question is required.") + return + } + + await runProjectCommand("Running retrieval", activeProject, async () => { + const result = await runAsk(activeProject.projectRoot, trimmedQuestion) + setAskResult(result) + setRuntimeMessage( + `Retrieved ${result.sources.length} cited source${result.sources.length === 1 ? "" : "s"}.`, + ) + }) + } + + function handleExportMarkdown(): void { + if (!activeProject || !askResult) { + setRuntimeMessage("Run retrieval before exporting a Markdown report.") + return + } + + downloadTextFile( + `${safeFilename(activeProject.name)}-retrieval-report.md`, + retrievalReportMarkdown(activeProject, askResult), + "text/markdown;charset=utf-8", + ) + setRuntimeMessage("Markdown report exported from the current retrieval.") + } + + async function handleRenderAudio(): Promise { + if (!activeProject || !askResult) { + setRuntimeMessage("Run retrieval before rendering an audio report.") + return + } + + await runProjectCommand("Rendering offline audio report", activeProject, async () => { + const result = await runAudioSummary( + activeProject.projectRoot, + retrievalReportMarkdown(activeProject, askResult), + ) + setRuntimeMessage(`Audio report written to ${result.outputPath}.`) + }) + } + + async function handlePullModels(): Promise { + if (!activeProject) { + setRuntimeMessage("Select a project before preloading the embedding model.") + return + } + + await runProjectCommand("Preloading embedding model", activeProject, async () => { + const model = await runModelsPull(activeProject.projectRoot) + const status = await runStatus(activeProject.projectRoot) + setStatusReport(status) + setRuntimeMessage(`Semantic embeddings enabled: ${model.embeddingModel}.`) + }) + } + + async function handleSecurityAudit(): Promise { + if (!activeProject) { + setRuntimeMessage("Select a project before running the privacy audit.") + return + } + + await runProjectCommand("Running privacy audit", activeProject, async () => { + const [report, status] = await Promise.all([ + runSecurityAudit(activeProject.projectRoot), + runStatus(activeProject.projectRoot), + ]) + setSecurityReport(report) + setStatusReport(status) + setRuntimeMessage( + report.warnings.length === 0 + ? "Privacy audit passed." + : `Privacy audit found ${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"}.`, + ) + }) + } + + async function runProjectCommand( + label: string, + project: MimirProject, + action: () => Promise, + ): Promise { + setIsRunning(true) + setRuntimeMessage(`${label}...`) + try { + await action() + } catch (error) { + replaceProject({ ...project, status: "needs-review", progress: project.progress }) + setRuntimeMessage(error instanceof Error ? error.message : "Native Mimir runtime failed.") + } finally { + setIsRunning(false) + } + } + + function updateProjectFromDoctor( + project: MimirProject, + report: DoctorReport, + updates: Partial> = {}, + ): void { + replaceProject({ + ...project, + ...updates, + rawDir: report.rawDir, + storageDir: report.storageDir, + filesIndexed: report.indexedFiles, + chunksIndexed: report.chunksIndexed, + progress: projectProgress(report), + status: projectStatusFromDoctor(report), + updatedAt: new Date().toISOString(), + }) + } + + function replaceProject(project: MimirProject): void { + setProjects((currentProjects) => + currentProjects.map((entry) => (entry.id === project.id ? project : entry)), + ) + } + + return ( +
+
+ + +
+
+
+
+ Desktop + mobile shell +

+ Local dossiers, cited retrieval. +

+

+ {activeProject + ? `${activeProject.name} keeps generated state under ${activeProject.storageDir}.` + : "Project state stays under the selected workspace."} +

+
+ +
+
+ + {view === "projects" ? ( + + ) : null} + {view === "retrieval" ? ( + + ) : null} + {view === "privacy" ? ( + + ) : null} + {view === "license" ? ( + + ) : null} +
+
+
+ ) +} + +interface ProjectsViewProps { + activeProjectId: string | null + activeProject: MimirProject | null + dropStatus: string + googleDriveRoot: string + isRunning: boolean + onDrop: (event: DragEvent) => void + onGoogleDriveRootChange: (projectRoot: string) => void + onGoogleDriveSubmit: (event: FormEvent) => void + onIngestProject: (project: MimirProject) => Promise + onPullModels: () => Promise + onProjectRootChange: (projectRoot: string) => void + onProjectSubmit: (event: FormEvent) => void + onRefreshProject: (project: MimirProject) => Promise + onRemoveProject: (projectId: string) => void + onRepairProject: (project: MimirProject) => Promise + onSelectProject: (projectId: string) => void + onToggleAutoIngest: (project: MimirProject) => void + projectRoot: string + projects: MimirProject[] + statusReport: StatusReport | null +} + +function ProjectsView({ + activeProjectId, + activeProject, + dropStatus, + googleDriveRoot, + isRunning, + onDrop, + onGoogleDriveRootChange, + onGoogleDriveSubmit, + onIngestProject, + onPullModels, + onProjectRootChange, + onProjectSubmit, + onRefreshProject, + onRemoveProject, + onRepairProject, + onSelectProject, + onToggleAutoIngest, + projectRoot, + projects, + statusReport, +}: ProjectsViewProps): React.JSX.Element { + const modelRows = modelStatusRows(statusReport) + + return ( +
+ + + Projects + Local knowledge bases stored per workspace. + + + {projects.length === 0 ? ( +
+ Add the root folder of a Mimir workspace to start tracking it here. +
+ ) : null} + + {projects.map((project) => ( +
+
+ + +
+
+
+ {project.filesIndexed} files + {project.chunksIndexed} chunks +
+ +
+ + + + +
+

{autoIngestLabel(project)}

+
+
+ ))} +
+
+ + + + Intake + Folders become local Mimir workspaces. + + +
+ onProjectRootChange(event.currentTarget.value)} + placeholder="/Users/me/Projects/client-rfp" + value={projectRoot} + /> + +
+ + + +
+
+
+
+
+

Google Drive local sync

+

+ Connect the local folder created by Google Drive for desktop. Mimir indexes only + the files available on this machine and enables local auto-ingest for that folder. +

+
+
+
+ onGoogleDriveRootChange(event.currentTarget.value)} + placeholder="/Users/me/Library/CloudStorage/GoogleDrive-me@example.com/My Drive/client-rfp" + value={googleDriveRoot} + /> + +
+
+ +
+
+
+

Embedding model

+

+ {activeProject + ? "Preload the configured Transformers.js model and enable semantic retrieval." + : "Select a project to inspect and preload its configured model."} +

+
+ +
+
+ {modelRows.map((row) => ( +
+

{row.label}

+

{row.value}

+

{row.detail}

+
+ ))} +
+
+
+
+
+ ) +} + +interface ProjectPanelProps { + activeProject: MimirProject | null +} + +interface RetrievalViewProps extends ProjectPanelProps { + askResult: AskResult | null + isRunning: boolean + onExportMarkdown: () => void + onRenderAudio: () => Promise + onAskSubmit: (event: FormEvent) => Promise + onQuestionChange: (question: string) => void + question: string +} + +function RetrievalView({ + activeProject, + askResult, + isRunning, + onExportMarkdown, + onRenderAudio, + onAskSubmit, + onQuestionChange, + question, +}: RetrievalViewProps): React.JSX.Element { + const retrievedContext = + askResult?.answer ?? + (activeProject + ? `No retrieval has been run for ${activeProject.name} in this app session.` + : "Select a local project before running retrieval.") + + return ( +
+ + +
+
+ Ask + Retrieval context with source citations. +
+
+ + +
+
+
+ +
+ onQuestionChange(event.currentTarget.value)} + placeholder="What proves offline operation?" + value={question} + /> + +
+