From c55bd5fcbc5ff7f6946eff0e97eebf6b2feb31e9 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:22:08 +0530
Subject: [PATCH 01/20] chore: new branch (#33)
---
.agents/skills/design-contracts/SKILL.md | 116 ++
.github/workflows/perf-bench.yml | 105 ++
.github/workflows/validate-design.yml | 36 +
CLAUDE.md | 29 +-
INGEST_ARCHITECTURE.md | 48 +
README.md | 12 +
bench/baseline.ci-small.json | 33 +
bench/report.schema.json | 58 +
bench/results/ci- | 33 +
bench/results/ci-small.local.json | 33 +
docs/DESIGN.md | 164 +++
docs/search-recall-architecture.md | 678 +++++++++++
majestic-sauteeing-papert.md | 405 +++++++
package.json | 8 +-
qmd | 2 +-
scripts/bench-compare.ts | 106 ++
scripts/bench-ingest-hotpaths.ts | 110 ++
scripts/bench-ingest-pipeline.ts | 82 ++
scripts/bench-qmd-repeat.ts | 141 +++
scripts/bench-qmd.ts | 219 ++++
scripts/bench-scorecard.ts | 129 +++
scripts/validate-design.ts | 252 +++++
src/ingest/README.md | 27 +
src/ingest/claude.ts | 212 +---
src/ingest/cline.ts | 189 +---
src/ingest/codex.ts | 68 +-
src/ingest/copilot.ts | 80 +-
src/ingest/cursor.ts | 72 +-
src/ingest/generic.ts | 58 +-
src/ingest/index.ts | 278 ++++-
src/ingest/parsers/claude.ts | 48 +
src/ingest/parsers/cline.ts | 150 +++
src/ingest/parsers/codex.ts | 21 +
src/ingest/parsers/copilot.ts | 21 +
src/ingest/parsers/cursor.ts | 21 +
src/ingest/parsers/generic.ts | 44 +
src/ingest/parsers/index.ts | 7 +
src/ingest/parsers/types.ts | 14 +
src/ingest/session-resolver.ts | 88 ++
src/ingest/store-gateway.ts | 127 +++
src/qmd.ts | 6 +-
streamed-humming-curry.md | 1320 ++++++++++++++++++++++
test/ingest-claude-orchestrator.test.ts | 118 ++
test/ingest-orchestrator.test.ts | 83 ++
test/ingest-parsers.test.ts | 149 +++
test/ingest-pipeline.test.ts | 157 +++
test/session-resolver.test.ts | 83 ++
test/store-gateway.test.ts | 123 ++
48 files changed, 5701 insertions(+), 662 deletions(-)
create mode 100644 .agents/skills/design-contracts/SKILL.md
create mode 100644 .github/workflows/perf-bench.yml
create mode 100644 .github/workflows/validate-design.yml
create mode 100644 INGEST_ARCHITECTURE.md
create mode 100644 bench/baseline.ci-small.json
create mode 100644 bench/report.schema.json
create mode 100644 bench/results/ci-
create mode 100644 bench/results/ci-small.local.json
create mode 100644 docs/DESIGN.md
create mode 100644 docs/search-recall-architecture.md
create mode 100644 majestic-sauteeing-papert.md
create mode 100644 scripts/bench-compare.ts
create mode 100644 scripts/bench-ingest-hotpaths.ts
create mode 100644 scripts/bench-ingest-pipeline.ts
create mode 100644 scripts/bench-qmd-repeat.ts
create mode 100644 scripts/bench-qmd.ts
create mode 100644 scripts/bench-scorecard.ts
create mode 100644 scripts/validate-design.ts
create mode 100644 src/ingest/README.md
create mode 100644 src/ingest/parsers/claude.ts
create mode 100644 src/ingest/parsers/cline.ts
create mode 100644 src/ingest/parsers/codex.ts
create mode 100644 src/ingest/parsers/copilot.ts
create mode 100644 src/ingest/parsers/cursor.ts
create mode 100644 src/ingest/parsers/generic.ts
create mode 100644 src/ingest/parsers/index.ts
create mode 100644 src/ingest/parsers/types.ts
create mode 100644 src/ingest/session-resolver.ts
create mode 100644 src/ingest/store-gateway.ts
create mode 100644 streamed-humming-curry.md
create mode 100644 test/ingest-claude-orchestrator.test.ts
create mode 100644 test/ingest-orchestrator.test.ts
create mode 100644 test/ingest-parsers.test.ts
create mode 100644 test/ingest-pipeline.test.ts
create mode 100644 test/session-resolver.test.ts
create mode 100644 test/store-gateway.test.ts
diff --git a/.agents/skills/design-contracts/SKILL.md b/.agents/skills/design-contracts/SKILL.md
new file mode 100644
index 0000000..21cbe61
--- /dev/null
+++ b/.agents/skills/design-contracts/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: design-contracts
+description: Enforces smriti's three design contracts (observability, dry-run, versioning) when writing or modifying CLI command handlers or JSON output.
+---
+
+# smriti Design Contract Guardrails
+
+This skill activates whenever you are **adding or modifying a CLI command**,
+**changing JSON output**, **touching telemetry/logging code**, or **altering
+config defaults** in the smriti project.
+
+---
+
+## Contract 1 — Dry Run
+
+### Mutating commands MUST support `--dry-run`
+
+The following commands write to disk, the database, or the network. Every one of
+them **must** honour `--dry-run`:
+
+| Command | Expected guard pattern |
+| ------------ | ----------------------------------------------------------------------------- |
+| `ingest` | `const dryRun = hasFlag(args, "--dry-run");` then no DB/file writes when true |
+| `embed` | same |
+| `categorize` | same |
+| `tag` | same |
+| `share` | same |
+| `sync` | same |
+| `context` | already implemented — keep it |
+
+When `--dry-run` is active:
+
+- `stdout` must describe **what would happen** (e.g. `Would ingest N sessions`).
+- `stderr` must note what was skipped (`No changes were made (--dry-run)`).
+- Exit code follows normal success/error rules — dry-run is NOT an error.
+- If `--json` is also set, the output envelope must include
+ `"meta": { "dry_run": true }`.
+
+### Read-only commands MUST reject `--dry-run`
+
+These commands never mutate state. If they receive `--dry-run`, they must print
+a usage error and `process.exit(1)`:
+
+`search`, `recall`, `list`, `status`, `show`, `compare`, `projects`, `team`,
+`categories`
+
+---
+
+## Contract 2 — Observability / Telemetry
+
+### Never log user content
+
+The following are **forbidden** in any `console.log`, `console.error`, or
+log/audit output:
+
+- Message content (`.content`, `.text`, `.body`)
+- Query strings passed by the user
+- Memory text or embedding data
+- File paths provided by the user (as opposed to system-derived paths)
+
+✅ OK to log: command name, exit code, duration, session IDs, counts, smriti
+version.
+
+### Telemetry default must be OFF
+
+- `SMRITI_TELEMETRY` must default to `0`/`false`/`"off"` — never `1`.
+- Telemetry calls must be guarded: `if (telemetryEnabled) { ... }`.
+- Any new telemetry signal must be added to `smriti telemetry sample` output.
+
+---
+
+## Contract 3 — JSON & CLI Versioning
+
+### JSON output is a hard contract
+
+The standard output envelope is:
+
+```json
+{ "ok": true, "data": { ... }, "meta": { ... } }
+```
+
+Rules:
+
+- **Never remove a field** from `data` or `meta` — add `@deprecated` in a
+ comment instead.
+- **Never rename a field**.
+- **Never change a field's type** (e.g. string → number).
+- New fields in `data` or `meta` must be **optional**.
+- If you must replace a field: add the new one AND keep the old one with a
+ `_deprecated: true` sibling or comment.
+
+### CLI interface stability
+
+Once a command or flag has shipped:
+
+- **Command names**: frozen.
+- **Flag names**: frozen. You may add aliases (e.g. `--dry-run` → `-n`) but not
+ rename.
+- **Positional argument order**: frozen.
+- **Deprecated flags**: must keep working, must emit a `stderr` warning.
+
+---
+
+## Pre-Submission Checklist
+
+Before finishing any edit that touches `src/index.ts` or a command handler:
+
+- [ ] If command is mutating → `--dry-run` is supported and guarded
+- [ ] If command is read-only → `--dry-run` is rejected with a usage error
+- [ ] No user-supplied content appears in `console.log`/`console.error`
+- [ ] If JSON output changed → only fields were **added**, not
+ removed/renamed/retyped
+- [ ] If a new flag was added → it does not conflict with any existing flag name
+- [ ] Telemetry default remains off in `config.ts`
+
+If any item fails, fix it before proceeding.
diff --git a/.github/workflows/perf-bench.yml b/.github/workflows/perf-bench.yml
new file mode 100644
index 0000000..aee933d
--- /dev/null
+++ b/.github/workflows/perf-bench.yml
@@ -0,0 +1,105 @@
+name: Perf Bench (Non-blocking)
+
+on:
+ pull_request:
+ branches: [main, dev]
+ paths:
+ - "src/**"
+ - "qmd/src/**"
+ - "scripts/bench-*.ts"
+ - "bench/**"
+ - ".github/workflows/perf-bench.yml"
+ push:
+ branches: [main, dev, "feature/**"]
+ paths:
+ - "src/**"
+ - "qmd/src/**"
+ - "scripts/bench-*.ts"
+ - "bench/**"
+ - ".github/workflows/perf-bench.yml"
+
+jobs:
+ bench:
+ name: Run ci-small benchmark
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run benchmark (no-llm)
+ run: bun run scripts/bench-qmd.ts --profile ci-small --out bench/results/ci-small.json --no-llm
+
+ - name: Run repeated benchmark (ci-small)
+ run: bun run scripts/bench-qmd-repeat.ts --profiles ci-small --runs 3 --out bench/results/repeat-summary.json
+
+ - name: Compare against baseline (non-blocking)
+ run: bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2
+
+ - name: Generate scorecard markdown
+ run: bun run bench:scorecard > bench/results/scorecard.md
+
+ - name: Add scorecard to run summary
+ run: |
+ echo "## Benchmark Scorecard (ci-small)" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ cat bench/results/scorecard.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upsert sticky PR comment
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require("fs");
+ const body = fs.readFileSync("bench/results/scorecard.md", "utf8");
+ const marker = "";
+ const fullBody = `${marker}
+ ## Benchmark Scorecard (ci-small)
+
+ ${body}`;
+ const { owner, repo } = context.repo;
+ const issue_number = context.payload.pull_request.number;
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number,
+ per_page: 100,
+ });
+ const existing = comments.find((c) => c.body && c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existing.id,
+ body: fullBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body: fullBody,
+ });
+ }
+
+ - name: Upload benchmark artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bench-ci-small
+ path: |
+ bench/results/ci-small.json
+ bench/results/repeat-summary.json
+ bench/results/scorecard.md
diff --git a/.github/workflows/validate-design.yml b/.github/workflows/validate-design.yml
new file mode 100644
index 0000000..294ff51
--- /dev/null
+++ b/.github/workflows/validate-design.yml
@@ -0,0 +1,36 @@
+name: Design Contracts
+
+on:
+ push:
+ branches: [main, dev, "feature/**"]
+ paths:
+ - "src/**"
+ - "scripts/validate-design.ts"
+ - "docs/DESIGN.md"
+ pull_request:
+ branches: [main, dev]
+ paths:
+ - "src/**"
+ - "scripts/validate-design.ts"
+ - "docs/DESIGN.md"
+
+jobs:
+ validate:
+ name: Validate Design Contracts
+ if: ${{ false }} # Temporarily disabled while validator rules are being aligned with current CLI behavior.
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run design contract validator
+ run: bun run scripts/validate-design.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 2697f50..39aa414 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,13 +34,16 @@ src/
├── qmd.ts # Centralized re-exports from QMD package
├── format.ts # Output formatting (JSON, CSV, CLI)
├── ingest/
-│ ├── index.ts # Ingest orchestrator + types
-│ ├── claude.ts # Claude Code JSONL parser + project detection
-│ ├── codex.ts # Codex CLI parser
-│ ├── cursor.ts # Cursor IDE parser
-│ ├── cline.ts # Cline CLI parser (enriched blocks)
-│ ├── copilot.ts # GitHub Copilot (VS Code) parser
-│ └── generic.ts # File import (chat/jsonl formats)
+│ ├── index.ts # Orchestrator (parser -> resolver -> store)
+│ ├── parsers/ # Pure agent parsers (no DB writes)
+│ ├── session-resolver.ts # Project/session resolution + incremental state
+│ ├── store-gateway.ts # Centralized ingest persistence
+│ ├── claude.ts # Discovery + compatibility wrapper
+│ ├── codex.ts # Discovery + compatibility wrapper
+│ ├── cursor.ts # Discovery + compatibility wrapper
+│ ├── cline.ts # Discovery + compatibility wrapper
+│ ├── copilot.ts # Discovery + compatibility wrapper
+│ └── generic.ts # File import compatibility wrapper
├── search/
│ ├── index.ts # Filtered FTS search + session listing
│ └── recall.ts # Recall with synthesis
@@ -95,11 +98,13 @@ get a clean name like `openfga`.
### Ingestion Pipeline
-1. Discover sessions (glob for JSONL/JSON files)
-2. Deduplicate against `smriti_session_meta`
-3. Parse agent-specific format → `ParsedMessage[]`
-4. Save via QMD's `addMessage()` (content-addressable, SHA256 hashed)
-5. Attach Smriti metadata (agent, project, categories)
+1. Discover sessions (agent modules)
+2. Parse session content (pure parser layer)
+3. Resolve project/session state (resolver layer)
+4. Store message/meta/sidecars/costs (store gateway)
+5. Aggregate results and continue on per-session errors (orchestrator)
+
+See `INGEST_ARCHITECTURE.md` for details.
### Search
diff --git a/INGEST_ARCHITECTURE.md b/INGEST_ARCHITECTURE.md
new file mode 100644
index 0000000..9af1d05
--- /dev/null
+++ b/INGEST_ARCHITECTURE.md
@@ -0,0 +1,48 @@
+# Ingest Architecture
+
+Smriti ingest now follows a layered architecture with explicit boundaries.
+
+## Layers
+
+1. Parser Layer (`src/ingest/parsers/*`)
+- Agent-specific extraction only.
+- Reads source transcripts and returns normalized parsed sessions/messages.
+- No database writes.
+
+2. Session Resolver (`src/ingest/session-resolver.ts`)
+- Resolves `projectId`/`projectPath` from agent + path.
+- Handles explicit project overrides.
+- Computes `isNew` and `existingMessageCount` for incremental ingest.
+
+3. Store Gateway (`src/ingest/store-gateway.ts`)
+- Central write path for persistence.
+- Stores messages, sidecar blocks, session meta, and costs.
+- Encapsulates database write behavior.
+
+4. Orchestrator (`src/ingest/index.ts`)
+- Composes parser -> resolver -> gateway.
+- Handles result aggregation, per-session error handling, progress reporting.
+- Controls incremental behavior (Claude append-only transcripts).
+
+## Why this structure
+
+- Testability: each layer can be tested independently.
+- Maintainability: persistence logic is centralized.
+- Extensibility: new agents mostly require parser/discovery only.
+- Reliability: incremental and project resolution behavior are explicit.
+
+## Current behavior
+
+- `claude`/`claude-code`: incremental ingest based on existing message count.
+- `codex`, `cursor`, `cline`, `copilot`, `generic/file`: orchestrated through the same pipeline.
+- Legacy `ingest*` functions in agent modules remain as compatibility wrappers and delegate to orchestrator.
+
+## Verification
+
+Architecture is covered by focused tests:
+- `test/ingest-parsers.test.ts`
+- `test/session-resolver.test.ts`
+- `test/store-gateway.test.ts`
+- `test/ingest-orchestrator.test.ts`
+- `test/ingest-claude-orchestrator.test.ts`
+- `test/ingest-pipeline.test.ts`
diff --git a/README.md b/README.md
index 643b0c1..ba9e970 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,18 @@ Claude Code Cursor Codex Other Agents
Everything runs locally. Your conversations never leave your machine. The SQLite
database, the embeddings, the search indexes — all on disk, all yours.
+## Ingest Architecture
+
+Smriti ingest uses a layered pipeline:
+
+1. `parsers/*` extract agent transcripts into normalized messages (no DB writes).
+2. `session-resolver` derives project/session state, including incremental offsets.
+3. `store-gateway` persists messages, sidecars, session meta, and costs.
+4. `ingest/index.ts` orchestrates the flow with per-session error isolation.
+
+This keeps parser logic, resolution logic, and persistence logic separated and testable.
+See `INGEST_ARCHITECTURE.md` and `src/ingest/README.md` for implementation details.
+
## Tagging & Categories
Sessions and messages are automatically tagged into a hierarchical category
diff --git a/bench/baseline.ci-small.json b/bench/baseline.ci-small.json
new file mode 100644
index 0000000..e54df28
--- /dev/null
+++ b/bench/baseline.ci-small.json
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:24:05.100Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-4oQzBo/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 1735.8,
+ "ingest_p95_ms_per_session": 6.96,
+ "fts": {
+ "p50_ms": 0.385,
+ "p95_ms": 0.41,
+ "mean_ms": 0.387,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.405,
+ "p95_ms": 0.436,
+ "mean_ms": 0.41,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/bench/report.schema.json b/bench/report.schema.json
new file mode 100644
index 0000000..d17bc14
--- /dev/null
+++ b/bench/report.schema.json
@@ -0,0 +1,58 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Smriti QMD Benchmark Report",
+ "type": "object",
+ "required": ["profile", "mode", "generated_at", "corpus", "metrics", "counts"],
+ "properties": {
+ "profile": { "type": "string" },
+ "mode": { "enum": ["no-llm", "llm"] },
+ "generated_at": { "type": "string" },
+ "db_path": { "type": "string" },
+ "corpus": {
+ "type": "object",
+ "required": ["sessions", "messages_per_session", "total_messages"],
+ "properties": {
+ "sessions": { "type": "integer" },
+ "messages_per_session": { "type": "integer" },
+ "total_messages": { "type": "integer" }
+ }
+ },
+ "metrics": {
+ "type": "object",
+ "required": ["ingest_throughput_msgs_per_sec", "ingest_p95_ms_per_session", "fts", "recall", "vector"],
+ "properties": {
+ "ingest_throughput_msgs_per_sec": { "type": "number" },
+ "ingest_p95_ms_per_session": { "type": "number" },
+ "fts": { "$ref": "#/$defs/timed" },
+ "recall": { "$ref": "#/$defs/timed" },
+ "vector": {
+ "oneOf": [
+ { "$ref": "#/$defs/timed" },
+ { "type": "null" }
+ ]
+ }
+ }
+ },
+ "counts": {
+ "type": "object",
+ "required": ["memory_sessions", "memory_messages", "content_vectors"],
+ "properties": {
+ "memory_sessions": { "type": "integer" },
+ "memory_messages": { "type": "integer" },
+ "content_vectors": { "type": "integer" }
+ }
+ }
+ },
+ "$defs": {
+ "timed": {
+ "type": "object",
+ "required": ["p50_ms", "p95_ms", "mean_ms", "runs"],
+ "properties": {
+ "p50_ms": { "type": "number" },
+ "p95_ms": { "type": "number" },
+ "mean_ms": { "type": "number" },
+ "runs": { "type": "integer" }
+ }
+ }
+ }
+}
diff --git a/bench/results/ci- b/bench/results/ci-
new file mode 100644
index 0000000..c566552
--- /dev/null
+++ b/bench/results/ci-
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:29:58.917Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-eEc2Yu/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 865.18,
+ "ingest_p95_ms_per_session": 16.656,
+ "fts": {
+ "p50_ms": 0.369,
+ "p95_ms": 0.397,
+ "mean_ms": 0.371,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.393,
+ "p95_ms": 0.415,
+ "mean_ms": 0.393,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/bench/results/ci-small.local.json b/bench/results/ci-small.local.json
new file mode 100644
index 0000000..e54df28
--- /dev/null
+++ b/bench/results/ci-small.local.json
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:24:05.100Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-4oQzBo/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 1735.8,
+ "ingest_p95_ms_per_session": 6.96,
+ "fts": {
+ "p50_ms": 0.385,
+ "p95_ms": 0.41,
+ "mean_ms": 0.387,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.405,
+ "p95_ms": 0.436,
+ "mean_ms": 0.41,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 0000000..f0aed02
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,164 @@
+Observability & Telemetry
+
+Principles
+
+Observability exists to help the user and the system understand behavior, never to surveil.
+
+Rules:
+ • Telemetry is opt-in only. Default is off.
+ • No user content (messages, memory text, embeddings) is ever logged.
+ • No network calls for analytics unless explicitly enabled.
+ • Observability must never change command semantics or performance guarantees.
+
+Local Observability (Always On)
+
+These are local-only and require no consent:
+ • --verbose : additional execution detail (phases, timings)
+ • --debug : stack traces, SQL, internal state
+ • meta.duration_ms : execution timing included in JSON output
+
+Telemetry (Opt-In)
+
+If enabled by the user (smriti telemetry enable or SMRITI_TELEMETRY=1):
+
+Collected signals (aggregated, anonymous):
+ • Command name
+ • Exit code
+ • Execution duration bucket
+ • Smriti version
+
+Explicitly NOT collected:
+ • Arguments values
+ • Query text
+ • Memory content
+ • File paths
+ • User identifiers
+
+Telemetry must be:
+ • Documented (smriti telemetry status)
+ • Inspectable (smriti telemetry sample)
+ • Disable-able at any time (smriti telemetry disable)
+
+Audit Logs (Optional)
+
+For enterprise / shared usage:
+ • Optional local audit log (~/.smriti/audit.log)
+ • Records: timestamp, command, exit code, actor (human / agent id)
+ • Never enabled by default
+
+⸻
+
+Dry Run & Simulation
+
+Dry Run Contract
+
+Any command that mutates state must support --dry-run.
+
+--dry-run guarantees:
+ • No database writes
+ • No file writes
+ • No network side effects
+ • Full validation and planning still run
+
+Dry-run answers the question:
+
+“What would happen if I ran this?”
+
+Dry Run Output Rules
+
+In --dry-run mode:
+ • stdout shows the planned changes
+ • stderr shows what was skipped due to dry-run
+ • Exit code follows normal rules (0 / 3 / 4)
+
+Example:
+
+Would ingest 12 new sessions
+Would skip 38 existing sessions
+No changes were made (--dry-run)
+
+In JSON mode:
+
+{
+ "ok": true,
+ "data": {
+ "would_ingest": 12,
+ "would_skip": 38
+ },
+ "meta": {
+ "dry_run": true
+ }
+}
+
+Required Coverage
+
+Commands that MUST support --dry-run:
+ • ingest
+ • embed
+ • categorize
+ • tag
+ • share
+ • sync
+ • context
+
+Read-only commands MUST reject --dry-run with usage error.
+
+⸻
+
+Versioning & Backward Compatibility
+
+Semantic Versioning
+
+Smriti follows SemVer:
+ • MAJOR: Breaking CLI or JSON contract changes
+ • MINOR: New commands, flags, fields (additive only)
+ • PATCH: Bug fixes, performance improvements
+
+CLI Interface Stability
+
+Once released:
+ • Command names never change
+ • Flags are never removed
+ • Flags may gain aliases but not be renamed
+ • Positional argument order is frozen
+
+Deprecated behavior:
+ • Continues to work
+ • Emits a warning on stderr
+ • Removed only in next MAJOR version
+
+JSON Schema Stability
+
+JSON output is a hard contract:
+
+Rules:
+ • Fields are only added, never removed
+ • Existing field meaning never changes
+ • Types never change
+ • New fields must be optional
+
+If a field must be replaced:
+ • Add the new field
+ • Mark the old field as deprecated in docs
+ • Keep both for one MAJOR cycle
+
+Manifest Versioning
+
+smriti manifest includes:
+ • CLI version
+ • Manifest schema version
+
+Example:
+
+{
+ "manifest_version": "1.0",
+ "cli_version": "0.4.0"
+}
+
+Agents may branch behavior based on manifest_version.
+
+Data Migration Rules
+ • Stored data schemas may evolve internally
+ • CLI behavior must remain stable across migrations
+ • Migrations must be automatic and idempotent
+ • Migration failures exit with DB_ERROR
diff --git a/docs/search-recall-architecture.md b/docs/search-recall-architecture.md
new file mode 100644
index 0000000..1e9be94
--- /dev/null
+++ b/docs/search-recall-architecture.md
@@ -0,0 +1,678 @@
+# Search & Recall: Architecture, Findings, and Improvement Plan
+
+## Table of Contents
+
+1. [Current Architecture](#current-architecture)
+2. [Execution Paths](#execution-paths)
+3. [Component Deep Dive](#component-deep-dive)
+4. [Findings & Gaps](#findings--gaps)
+5. [Improvement Plan](#improvement-plan)
+
+---
+
+## Current Architecture
+
+### System Layers
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ CLI Layer (src/index.ts) │
+│ Parse args → route to search/recall → format output │
+├─────────────────────────────────────────────────────────────┤
+│ Smriti Layer (src/search/) │
+│ Metadata filtering (project, category, agent) │
+│ Session dedup, synthesis delegation │
+│ searchFiltered() — dynamic SQL with EXISTS subqueries │
+├─────────────────────────────────────────────────────────────┤
+│ QMD Layer (qmd/src/memory.ts, qmd/src/store.ts) │
+│ BM25 FTS5 search (searchMemoryFTS) │
+│ Vector search (searchMemoryVec — EmbeddingGemma) │
+│ RRF fusion (reciprocalRankFusion) │
+│ Ollama synthesis (ollamaRecall) │
+├─────────────────────────────────────────────────────────────┤
+│ Storage Layer (SQLite) │
+│ memory_fts (FTS5) — full-text index │
+│ vectors_vec (vec0) — cosine similarity via sqlite-vec │
+│ content_vectors — chunk metadata (hash, seq, pos) │
+│ smriti_session_meta — project/agent per session │
+│ smriti_*_tags — category tags on messages/sessions │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Model Stack
+
+| Model | Runtime | Size | Purpose | Used In |
+|-------|---------|------|---------|---------|
+| EmbeddingGemma 300M (Q8_0) | node-llama-cpp | ~300MB | Dense vector embeddings | `smriti embed`, vector search |
+| Qwen3-Reranker 0.6B (Q8_0) | node-llama-cpp | ~640MB | Cross-encoder reranking | `qmd query` only — **NOT used in smriti** |
+| qmd-query-expansion 1.7B | node-llama-cpp | ~1.1GB | Query expansion (lex/vec/hyde) | `qmd query` only — **NOT used in smriti** |
+| qwen3:8b-tuned | Ollama (HTTP) | ~4.7GB | Synthesis, summarization, classification | `smriti recall --synthesize`, `smriti share`, `smriti categorize --llm` |
+
+---
+
+## Execution Paths
+
+### `smriti search "query"` — Always FTS-Only
+
+```
+index.ts:210 → searchFiltered(db, query, filters)
+ │
+ ├─ Build dynamic SQL:
+ │ FROM memory_fts mf
+ │ JOIN memory_messages mm ON mm.rowid = mf.rowid
+ │ JOIN memory_sessions ms ON ms.id = mm.session_id
+ │ LEFT JOIN smriti_session_meta sm
+ │ WHERE mf.content MATCH ?
+ │ AND EXISTS(...category filter...)
+ │ AND EXISTS(...project filter...)
+ │ AND EXISTS(...agent filter...)
+ │ ORDER BY (1/(1+ABS(bm25(memory_fts)))) DESC
+ │ LIMIT ?
+ │
+ └─ Return SearchResult[] → formatSearchResults()
+```
+
+**Retrieval**: BM25 only, no vector, no RRF, no reranking.
+
+### `smriti recall "query"` — Two Branches
+
+```
+recall.ts:40 → hasFilters = category || project || agent
+
+┌──────────────────────────────────────────────────────────────┐
+│ Branch A: No Filters → QMD Native (full hybrid) │
+│ │
+│ recallMemories(db, query, opts) │
+│ ├─ searchMemoryFTS() → BM25 results │
+│ ├─ searchMemoryVec() → vector results (EmbeddingGemma) │
+│ ├─ reciprocalRankFusion([fts, vec], [1.0, 1.0]) │
+│ ├─ Session dedup (one best per session) │
+│ └─ [if --synthesize] ollamaRecallSynthesize() │
+├──────────────────────────────────────────────────────────────┤
+│ Branch B: With Filters → FTS Only (loses vectors!) │
+│ │
+│ searchFiltered(db, query, filters) │
+│ └─ Same SQL as search command │
+│ Session dedup via Map │
+│ [if --synthesize] synthesizeResults() → ollamaRecall() │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### Data Flow Through RRF (Unfiltered Recall)
+
+```
+FTS Results (ranked by BM25): Vector Results (ranked by cosine):
+ rank 0: msg_A (score 0.85) rank 0: msg_C (score 0.92)
+ rank 1: msg_B (score 0.71) rank 1: msg_A (score 0.88)
+ rank 2: msg_C (score 0.65) rank 2: msg_D (score 0.76)
+
+RRF (k=60, weights [1.0, 1.0]):
+ msg_A: 1/61 + 1/62 = 0.0326 (in both lists!)
+ msg_C: 1/63 + 1/61 = 0.0322 (in both lists!)
+ msg_B: 1/62 = 0.0161 (FTS only)
+ msg_D: 1/63 = 0.0159 (vec only)
+
+After top-rank bonus:
+ msg_A: 0.0326 + 0.05 = 0.0826 ← rank 0 in FTS
+ msg_C: 0.0322 + 0.05 = 0.0822 ← rank 0 in vec
+ msg_B: 0.0161 + 0.02 = 0.0361 ← rank 1 in FTS
+ msg_D: 0.0159 + 0.02 = 0.0359 ← rank 2 in vec
+
+Final: A > C > B > D
+```
+
+The top-rank bonus (+0.05) dominates — being #1 in either list is worth 3x a single rank contribution.
+
+---
+
+## Component Deep Dive
+
+### 1. FTS5 Query Building
+
+**QMD's `buildMemoryFTS5Query()`** (used in unfiltered recall):
+```typescript
+// "how to configure auth" → '"how"* AND "to"* AND "configure"* AND "auth"*'
+sanitizeMemoryFTSTerm(t) → strip non-alphanumeric, lowercase
+terms.map(t => `"${t}"*`).join(' AND ') // prefix match + boolean AND
+```
+
+**Smriti's `searchFiltered()`** (used in filtered search/recall):
+```typescript
+// Raw user input passed directly to MATCH
+conditions.push(`mf.content MATCH ?`);
+params.push(query); // NO sanitization, NO prefix matching
+```
+
+### 2. BM25 Scoring
+
+```sql
+-- QMD (unfiltered): weighted columns
+bm25(memory_fts, 5.0, 1.0, 1.0) -- title=5x, role=1x, content=1x
+
+-- Smriti (filtered): unweighted
+bm25(memory_fts) -- equal weights on all columns
+```
+
+Both normalize to `(0, 1]`: `score = 1 / (1 + |bm25_score|)`
+
+### 3. Vector Search (Two-Step Pattern)
+
+```
+Step 1: Query vectors_vec directly (NO JOINs — sqlite-vec hangs)
+ SELECT hash_seq, distance FROM vectors_vec
+ WHERE embedding MATCH ? AND k = ?
+ → Returns hash_seq keys like "abc123_0" (hash + chunk index)
+
+Step 2: Normal SQL JOIN using collected hashes
+ SELECT m.*, cv.hash || '_' || cv.seq as hash_seq
+ FROM memory_messages m
+ JOIN content_vectors cv ON cv.hash = m.hash
+ WHERE m.hash IN (?) AND s.active = 1
+
+Step 3: Deduplicate by message_id (best distance per message)
+ score = 1 - cosine_distance → range [0, 1]
+```
+
+### 4. Embedding Format
+
+```typescript
+// Queries: asymmetric task prefix
+"task: search result | query: how to configure auth"
+
+// Documents: title + text prefix
+"title: Setting up OAuth | text: To configure OAuth2..."
+```
+
+Chunking: 800 tokens/chunk, 15% overlap (120 tokens). Token-based via actual model tokenizer.
+
+### 5. Synthesis Prompt
+
+```
+System: "You are a memory recall assistant. Given a query and relevant
+past conversation memories, synthesize the memories into useful context
+for answering the query. Be concise and focus on information directly
+relevant to the query. If memories contain contradictory information,
+note the most recent. Output only the synthesized context, no preamble."
+
+User: "Query: {query}\n\nRelevant memories:\n
+[Session: title]\nrole: content\n---\n
+[Session: title]\nrole: content"
+```
+
+Temperature 0.3, max 1024 tokens, via Ollama `/api/chat`.
+
+---
+
+## Findings & Gaps
+
+### Critical Issues
+
+#### F1. Filtered recall loses vector search entirely
+
+**Impact**: High — most real-world recall uses filters.
+
+When any filter (`--project`, `--category`, `--agent`) is set, `recall()` falls back to `searchFiltered()` which is FTS-only. The hybrid FTS+vector+RRF pipeline is completely bypassed.
+
+This means `smriti recall "auth flow" --project myapp` only does keyword matching. Semantic matches ("login mechanism" for "auth flow") are lost.
+
+**Root cause**: The two-step sqlite-vec pattern cannot be easily combined with Smriti's `EXISTS` subqueries on metadata tables. Nobody has built the bridge.
+
+#### F2. `searchFiltered()` does not sanitize FTS queries
+
+**Impact**: Medium — FTS5 syntax errors on special characters.
+
+QMD's `searchMemoryFTS` passes queries through `buildMemoryFTS5Query()` which strips special chars, lowercases, and adds prefix matching. Smriti's `searchFiltered` passes raw user input to `MATCH`. Queries containing FTS5 operators (`*`, `"`, `NEAR`, `OR`, `NOT`) may cause parse errors or unintended behavior.
+
+#### F3. `searchFiltered()` does not use BM25 column weights
+
+**Impact**: Medium — title matches are not boosted.
+
+QMD uses `bm25(memory_fts, 5.0, 1.0, 1.0)` (title weighted 5x). Smriti uses `bm25(memory_fts)` (equal weights). Session title matches don't get the boost they deserve in filtered search.
+
+#### F4. Error handling asymmetry in synthesis
+
+**Impact**: Medium — inconsistent UX.
+
+- Filtered path: `synthesizeResults()` has `try/catch`, silently returns `undefined`
+- Unfiltered path: `recallMemories()` has NO `try/catch` around `ollamaRecallSynthesize()` — Ollama failure crashes the CLI with exit code 1
+
+#### F5. No timeout on Ollama calls in recall
+
+**Impact**: Medium — CLI hangs indefinitely.
+
+`ollamaChat()` uses raw `fetch()` with no `AbortSignal.timeout()`. A slow or unresponsive Ollama server hangs the CLI forever. Compare with `reflect.ts` which uses a 120-second `AbortController`.
+
+#### F6. `searchFiltered()` does not filter inactive sessions
+
+**Impact**: Low — returns deleted/inactive sessions.
+
+QMD's `searchMemoryFTS` filters `s.active = 1`. Smriti's `searchFiltered` has no such filter. Deleted sessions appear in filtered results.
+
+### Missing Capabilities
+
+#### M1. Reranker not used in recall
+
+QMD has a Qwen3-Reranker 0.6B cross-encoder model that significantly improves result quality. It's used in `qmd query` but never in `smriti recall`. The reranker sees query+document pairs together, catching relevance signals that embedding similarity and BM25 miss independently.
+
+#### M2. Query expansion not used in recall
+
+QMD has a query expansion model (1.7B) that generates lexical synonyms, vector-optimized reformulations, and hypothetical document expansions (HyDE). It's used in `qmd query` but never in `smriti recall`. This means recall misses vocabulary gaps (user says "auth", relevant content says "authentication token management").
+
+#### M3. No search result provenance/explanation
+
+Results show `[0.847]` score but no indication of *why* a result ranked high. Was it a title match? Content keyword? Semantic similarity? Understanding provenance helps users refine queries.
+
+#### M4. No multi-message context in results
+
+Search returns individual messages truncated to 200 chars. A message saying "yes, let's do that" is useless without the preceding context. No mechanism to include surrounding messages.
+
+#### M5. `smriti search` never uses vector search
+
+The `search` command always goes through `searchFiltered()` which is FTS-only. There's no `--hybrid` or `--vector` flag to enable semantic search.
+
+#### M6. Sequential FTS+vec in `recallMemories()` — not parallel
+
+```typescript
+const ftsResults = searchMemoryFTS(db, query, limit); // sync
+vecResults = await searchMemoryVec(db, query, limit); // async, waits
+```
+
+FTS is synchronous and vec is async, but they run sequentially. FTS could be wrapped in a microtask and both run in parallel.
+
+---
+
+## Improvement Plan
+
+### Phase 1: Fix Critical Gaps (Correctness & Reliability)
+
+#### P1.1 — Sanitize FTS queries in `searchFiltered()`
+
+**Addresses**: F2
+
+Import and use `buildMemoryFTS5Query()` pattern in `searchFiltered()`:
+```typescript
+import { buildFTS5Query } from "./query-utils"; // extract from QMD or reimplement
+
+const ftsQuery = buildFTS5Query(query);
+if (!ftsQuery) return [];
+conditions.push(`mf.content MATCH ?`);
+params.push(ftsQuery); // sanitized, prefix-matched, AND-joined
+```
+
+**Effort**: Small. Extract the 15-line function, wire it in.
+
+#### P1.2 — Add BM25 column weights to `searchFiltered()`
+
+**Addresses**: F3
+
+```sql
+-- Before:
+(1.0 / (1.0 + ABS(bm25(memory_fts)))) AS score
+
+-- After:
+(1.0 / (1.0 + ABS(bm25(memory_fts, 5.0, 1.0, 1.0)))) AS score
+```
+
+**Effort**: One-line change.
+
+#### P1.3 — Filter inactive sessions in `searchFiltered()`
+
+**Addresses**: F6
+
+Add `AND ms.active = 1` to the WHERE clause (or as a default condition).
+
+**Effort**: One-line change.
+
+#### P1.4 — Add timeout to Ollama calls in recall
+
+**Addresses**: F5
+
+```typescript
+const resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
+ signal: AbortSignal.timeout(60_000), // 60-second timeout
+ ...
+});
+```
+
+**Effort**: Small. One line per callsite. Consider adding to `ollamaChat()` itself in QMD.
+
+#### P1.5 — Fix synthesis error handling asymmetry
+
+**Addresses**: F4
+
+Wrap the synthesis call in `recallMemories()` with try/catch to match filtered path behavior:
+```typescript
+if (options.synthesize && results.length > 0) {
+ try {
+ synthesis = await ollamaRecallSynthesize(query, memoriesText, opts);
+ } catch {
+ // Synthesis failure should not crash recall
+ }
+}
+```
+
+**Effort**: 3-line change in QMD's memory.ts.
+
+---
+
+### Phase 2: Hybrid Filtered Search (High-Value)
+
+#### P2.1 — Add vector search to filtered recall
+
+**Addresses**: F1 (the biggest gap)
+
+The core challenge: `searchMemoryVec()` returns results without Smriti metadata, and sqlite-vec's two-step pattern can't be combined with `EXISTS` subqueries.
+
+**Approach**: Post-filter strategy — run vector search unfiltered, then filter results against Smriti metadata.
+
+```typescript
+export async function recallFiltered(
+ db: Database,
+ query: string,
+ filters: SearchFilters,
+ options: RecallOptions
+): Promise {
+ // 1. Run both searches
+ const ftsResults = searchFilteredFTS(db, query, filters);
+ const vecResults = await searchMemoryVec(db, query, limit * 3); // overfetch
+
+ // 2. Post-filter vector results against metadata
+ const filteredVec = postFilterByMetadata(db, vecResults, filters);
+
+ // 3. RRF fusion
+ const fused = reciprocalRankFusion(
+ [toRanked(ftsResults), toRanked(filteredVec)],
+ [1.0, 1.0]
+ );
+
+ // 4. Session dedup + synthesis (same as unfiltered path)
+ ...
+}
+```
+
+**Post-filter implementation**:
+```typescript
+function postFilterByMetadata(
+ db: Database,
+ results: MemorySearchResult[],
+ filters: SearchFilters
+): MemorySearchResult[] {
+ if (results.length === 0) return [];
+
+ // Batch-check metadata for all result session IDs
+ const sessionIds = [...new Set(results.map(r => r.session_id))];
+ const metaMap = loadSessionMetaBatch(db, sessionIds);
+
+ return results.filter(r => {
+ const meta = metaMap.get(r.session_id);
+ if (filters.project && meta?.project_id !== filters.project) return false;
+ if (filters.agent && meta?.agent_id !== filters.agent) return false;
+ if (filters.category) {
+ const tags = loadMessageTags(db, r.message_id);
+ if (!tags.some(t => matchesCategory(t, filters.category!))) return false;
+ }
+ return true;
+ });
+}
+```
+
+**Trade-offs**:
+- Pro: No changes to QMD's vector search internals
+- Pro: Metadata filtering is a simple SQL lookup
+- Con: Vector search fetches results that may be filtered out (hence 3x overfetch)
+- Con: Category filtering requires per-message tag lookup (batch-able)
+
+**Effort**: Medium. New function in `src/search/index.ts`, modify `recall()` routing.
+
+#### P2.2 — Add `--hybrid` flag to `smriti search`
+
+**Addresses**: M5
+
+Allow `smriti search "query" --hybrid` to use the same FTS+vector+RRF pipeline as recall (minus session dedup and synthesis). Default stays FTS-only for speed.
+
+```typescript
+case "search": {
+ if (hasFlag(args, "--hybrid")) {
+ const results = await searchHybrid(db, query, filters);
+ } else {
+ const results = searchFiltered(db, query, filters);
+ }
+}
+```
+
+**Effort**: Medium. Reuses P2.1's infrastructure.
+
+---
+
+### Phase 3: Quality Improvements
+
+#### P3.1 — Integrate reranker into recall
+
+**Addresses**: M1
+
+After RRF fusion, pass the top-N results through the Qwen3 reranker for precision reranking:
+
+```typescript
+// After RRF fusion, before session dedup
+const fusedResults = reciprocalRankFusion([fts, vec], [1.0, 1.0]);
+
+if (options.rerank !== false) { // opt-out via --no-rerank
+ const llm = getDefaultLlamaCpp();
+ const reranked = await llm.rerank(query, fusedResults.map(r => ({
+ file: r.file,
+ text: r.body,
+ })));
+ // Replace RRF scores with reranker scores
+ // Proceed to session dedup with reranked order
+}
+```
+
+**Trade-offs**:
+- Pro: Significant quality improvement — cross-encoder sees query+document together
+- Con: Adds ~500ms-2s latency (model inference per result)
+- Con: Requires EmbeddingGemma model to be loaded (already loaded for vector search)
+
+**Mitigation**: Make reranking opt-in (`--rerank`) initially, later default-on after benchmarking.
+
+**Effort**: Medium. Import `rerank` from QMD's llm.ts, wire into recall pipeline.
+
+#### P3.2 — Add query expansion
+
+**Addresses**: M2
+
+Use QMD's query expansion model to generate alternative query forms before search:
+
+```typescript
+const llm = getDefaultLlamaCpp();
+const expanded = await llm.expandQuery(query);
+// expanded = { lexical: ["auth", "authentication", "login"],
+// vector: "user authentication and login flow",
+// hyde: "To set up auth, configure the OAuth2 provider..." }
+
+// Use expanded.lexical for FTS (OR-join synonyms)
+// Use expanded.vector for vector search embedding
+// Use expanded.hyde for a second vector search pass
+```
+
+**Trade-offs**:
+- Pro: Bridges vocabulary gaps ("auth" → "authentication", "login")
+- Con: Adds ~1-3s latency for model inference
+- Con: Requires the 1.7B model to be loaded
+
+**Mitigation**: Cache expanded queries in `llm_cache` (QMD already does this). Make opt-in (`--expand`) initially.
+
+**Effort**: Medium-Large. Need to modify FTS query building to support OR-joined synonyms, run multiple vector searches.
+
+#### P3.3 — Add multi-message context window
+
+**Addresses**: M4
+
+When displaying results, include N surrounding messages from the same session:
+
+```typescript
+function expandContext(
+ db: Database,
+ result: SearchResult,
+ windowSize: number = 2
+): ExpandedResult {
+ const messages = db.prepare(`
+ SELECT role, content FROM memory_messages
+ WHERE session_id = ? AND id BETWEEN ? AND ?
+ ORDER BY id
+ `).all(result.session_id, result.message_id - windowSize, result.message_id + windowSize);
+
+ return { ...result, context: messages };
+}
+```
+
+Display as:
+```
+[0.847] Setting up OAuth authentication
+ ... (2 messages before)
+ user: How should we handle the refresh token?
+ >>> assistant: To configure OAuth2 with PKCE, first install the auth... ← matched
+ user: What about token rotation?
+ ... (1 message after)
+```
+
+**Effort**: Small-Medium. New function + format update.
+
+#### P3.4 — Result source indicators
+
+**Addresses**: M3
+
+Show why a result ranked high:
+
+```
+[0.083 fts+vec] Setting up OAuth authentication ← appeared in both lists
+ assistant: To configure OAuth2...
+
+[0.036 fts] API design session ← keyword match only
+ user: How should we structure...
+
+[0.034 vec] Login flow discussion ← semantic match only
+ assistant: The authentication mechanism...
+```
+
+**Effort**: Small. Track source in RRF fusion, pass through to formatter.
+
+---
+
+### Phase 4: Performance
+
+#### P4.1 — Parallelize FTS and vector search
+
+**Addresses**: M6
+
+```typescript
+// Before (sequential):
+const ftsResults = searchMemoryFTS(db, query, limit);
+const vecResults = await searchMemoryVec(db, query, limit);
+
+// After (parallel):
+const [ftsResults, vecResults] = await Promise.all([
+ Promise.resolve(searchMemoryFTS(db, query, limit)),
+ searchMemoryVec(db, query, limit).catch(() => []),
+]);
+```
+
+**Effort**: Tiny. One-line refactor.
+
+#### P4.2 — Batch metadata lookups for post-filtering
+
+When post-filtering vector results (P2.1), batch all session metadata lookups into a single SQL query:
+
+```typescript
+function loadSessionMetaBatch(
+ db: Database,
+ sessionIds: string[]
+): Map {
+ const placeholders = sessionIds.map(() => '?').join(',');
+ const rows = db.prepare(`
+ SELECT session_id, project_id, agent_id
+ FROM smriti_session_meta
+ WHERE session_id IN (${placeholders})
+ `).all(...sessionIds);
+ return new Map(rows.map(r => [r.session_id, r]));
+}
+```
+
+**Effort**: Small. Part of P2.1.
+
+#### P4.3 — Fix O(N*M) find() in `recallMemories()` session dedup
+
+```typescript
+// Before: O(N*M) linear scan per result
+const original = [...ftsResults, ...vecResults].find(
+ (o) => `${o.session_id}:${o.message_id}` === r.file
+);
+
+// After: O(1) Map lookup
+const originalMap = new Map();
+for (const r of [...ftsResults, ...vecResults]) {
+ const key = `${r.session_id}:${r.message_id}`;
+ if (!originalMap.has(key)) originalMap.set(key, r);
+}
+// ... in loop:
+const original = originalMap.get(r.file);
+```
+
+**Effort**: Tiny. QMD-side change.
+
+---
+
+### Implementation Priority
+
+| Phase | Item | Impact | Effort | Priority |
+|-------|------|--------|--------|----------|
+| 1 | P1.1 Sanitize FTS queries | Correctness | Small | **Now** |
+| 1 | P1.2 BM25 column weights | Quality | Tiny | **Now** |
+| 1 | P1.3 Filter inactive sessions | Correctness | Tiny | **Now** |
+| 1 | P1.4 Ollama timeout | Reliability | Small | **Now** |
+| 1 | P1.5 Synthesis error handling | Reliability | Tiny | **Now** |
+| 2 | P2.1 Hybrid filtered recall | **Quality** | Medium | **Next** |
+| 2 | P2.2 `--hybrid` search flag | Quality | Medium | **Next** |
+| 3 | P3.1 Reranker in recall | Quality | Medium | Later |
+| 3 | P3.2 Query expansion | Quality | Med-Large | Later |
+| 3 | P3.3 Multi-message context | UX | Small-Med | Later |
+| 3 | P3.4 Source indicators | UX | Small | Later |
+| 4 | P4.1 Parallel FTS+vec | Performance | Tiny | **Next** |
+| 4 | P4.2 Batch metadata lookups | Performance | Small | **Next** |
+| 4 | P4.3 Fix O(N*M) dedup | Performance | Tiny | Later |
+
+### Recommended Execution Order
+
+1. **Quick wins** (P1.1–P1.5, P4.1): Fix all correctness/reliability issues. ~1 session.
+2. **Hybrid filtered recall** (P2.1, P4.2): The single highest-value improvement. ~1 session.
+3. **Search parity** (P2.2): Expose hybrid search to `search` command. ~0.5 session.
+4. **Quality stack** (P3.1, P3.4): Reranker + source indicators. ~1 session.
+5. **Context & expansion** (P3.3, P3.2): Multi-message context, query expansion. ~1-2 sessions.
+
+---
+
+### Architecture After All Phases
+
+```
+smriti search "query" [--hybrid]
+ ├─ [default] searchFiltered() — sanitized FTS, weighted BM25, active filter
+ └─ [--hybrid] searchHybrid()
+ ├─ searchFilteredFTS()
+ ├─ searchMemoryVec() + postFilterByMetadata()
+ └─ reciprocalRankFusion()
+
+smriti recall "query" [--project X] [--synthesize] [--rerank] [--expand]
+ ├─ [--expand] expandQuery() → lexical + vector + HyDE forms
+ ├─ searchFilteredFTS() or searchMemoryFTS()
+ ├─ searchMemoryVec() + [if filtered] postFilterByMetadata()
+ ├─ reciprocalRankFusion([fts, vec], [1.0, 1.0])
+ ├─ [--rerank] llm.rerank(query, topResults)
+ ├─ Session dedup (Map-based, O(1) lookup)
+ ├─ [--context N] expandContext() — surrounding messages
+ └─ [--synthesize] ollamaRecall() — with timeout + error handling
+```
+
+Both commands use the same retrieval pipeline with different defaults:
+- `search`: FTS-only by default (fast), `--hybrid` for quality
+- `recall`: Always hybrid (quality), session-deduped, optional synthesis
+- Filters always work with full hybrid pipeline (no capability loss)
+- Reranker and query expansion are opt-in quality boosters
diff --git a/majestic-sauteeing-papert.md b/majestic-sauteeing-papert.md
new file mode 100644
index 0000000..63a5e6a
--- /dev/null
+++ b/majestic-sauteeing-papert.md
@@ -0,0 +1,405 @@
+# QMD Implementation Deep Dive - Learning Session Plan
+
+## Context
+
+This is a comprehensive learning session to understand QMD (Quality Memory Database) implementation from the ground up. QMD serves as the foundational memory layer for Smriti, providing content-addressable storage, full-text search, vector embeddings, and LLM-powered recall capabilities.
+
+**Goal**: Understand every architectural decision, implementation detail, and design pattern in QMD to enable confident contributions and debugging.
+
+**Session Categorization**: This session should be tagged as `smriti/qmd` and `topic/architecture` for future recall.
+
+## QMD Architecture Overview
+
+QMD is a sophisticated memory system built on SQLite with three core capabilities:
+
+1. **Content-Addressable Storage** - SHA256-based deduplication
+2. **Hybrid Search** - BM25 FTS + vector embeddings + LLM reranking
+3. **Conversation Memory** - Session-based message storage with recall
+
+### Key Files (Located at `/Users/zero8/zero8.dev/smriti/qmd/`)
+
+- `src/store.ts` (2571 lines) - Core data access, search, document operations
+- `src/memory.ts` (848 lines) - Conversation memory storage & retrieval
+- `src/llm.ts` (1208 lines) - LLM abstraction using node-llama-cpp
+- `src/ollama.ts` (169 lines) - Ollama HTTP API for synthesis
+- `src/collections.ts` (390 lines) - YAML-based collection management
+
+## Learning Session Structure
+
+### Part 1: Database Schema & Content Addressing (30 min)
+
+**Concepts to Explore**:
+1. **Content Table** - SHA256-based storage
+ - Why content-addressable? (deduplication, referential integrity)
+ - Hash collision handling (practically impossible with SHA256)
+ - `INSERT OR IGNORE` pattern for automatic dedup
+
+2. **Documents Table** - Virtual filesystem layer
+ - Collection-based organization (YAML managed)
+ - Soft deletes (`active` column)
+ - Path uniqueness constraints
+
+3. **Memory Tables** - Conversation storage
+ - `memory_sessions` - Session metadata
+ - `memory_messages` - Messages with content hashes
+ - Trigger-based FTS updates
+
+**Hands-On Activities**:
+- Read `qmd/src/store.ts:100-200` (schema initialization)
+- Examine hash function: `qmd/src/store.ts` (search for `hashContent`)
+- Trace a message insert: `qmd/src/memory.ts` (find `addMessage`)
+
+**Verification**:
+```bash
+# Inspect actual database schema
+sqlite3 ~/.cache/qmd/index.sqlite ".schema"
+
+# Check content dedup in action
+smriti ingest claude # Ingest sessions
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM content"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(DISTINCT hash) FROM memory_messages"
+# These should show deduplication working
+```
+
+### Part 2: Search Architecture - BM25 Full-Text Search (30 min)
+
+**Concepts to Explore**:
+1. **FTS5 Query Building**
+ - Term normalization (lowercase, strip special chars)
+ - Prefix matching (`*` suffix)
+ - Boolean operators (AND/OR)
+
+2. **BM25 Scoring**
+ - Score normalization: `1 / (1 + abs(bm25_score))`
+ - Why negative scores? (FTS5 convention)
+ - Custom weights in `bm25()` function
+
+3. **Trigger-Based FTS Updates**
+ - SQLite triggers keep `documents_fts` in sync
+ - Performance implications (writes are slower)
+
+**Hands-On Activities**:
+- Read FTS query builder: `qmd/src/store.ts` (search for `buildFTS5Query`)
+- Read FTS search: `qmd/src/store.ts` (search for `searchDocumentsFTS`)
+- Examine triggers: `qmd/src/store.ts` (search for `CREATE TRIGGER`)
+
+**Verification**:
+```bash
+# Test FTS search
+smriti search "vector embeddings" --project smriti
+
+# Compare with exact phrase
+smriti search '"vector embeddings"' --project smriti
+
+# Check FTS index size
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM documents_fts"
+```
+
+### Part 3: Vector Search & Embeddings (45 min)
+
+**Concepts to Explore**:
+1. **Two-Step Query Pattern** (CRITICAL)
+ - Why: sqlite-vec hangs on JOINs with `MATCH`
+ - Step 1: Query `vectors_vec` directly
+ - Step 2: Separate JOIN to get document data
+
+2. **Chunking Strategy**
+ - Token-based (not character-based)
+ - 800 tokens per chunk, 120 token overlap (15%)
+ - Natural break points (paragraph > sentence > line)
+
+3. **Embedding Format** (EmbeddingGemma)
+ - Queries: `"task: search result | query: {query}"`
+ - Documents: `"title: {title} | text: {content}"`
+
+4. **Storage Schema**
+ - `content_vectors` - Metadata table
+ - `vectors_vec` - sqlite-vec virtual table
+ - `hash_seq` composite key: `"hash_seq"`
+
+**Hands-On Activities**:
+- Read chunking logic: `qmd/src/store.ts` (search for `chunkDocumentByTokens`)
+- Read vector search: `qmd/src/store.ts` (search for `searchDocumentsVec`)
+- Read embedding insertion: `qmd/src/store.ts` (search for `insertEmbedding`)
+
+**Verification**:
+```bash
+# Build embeddings for a project
+smriti embed --project smriti
+
+# Check embedding storage
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM content_vectors"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM vectors_vec"
+
+# Verify chunking (count chunks per document)
+sqlite3 ~/.cache/qmd/index.sqlite "
+ SELECT hash, COUNT(*) as chunks
+ FROM content_vectors
+ GROUP BY hash
+ ORDER BY chunks DESC
+ LIMIT 10
+"
+```
+
+### Part 4: Hybrid Search - RRF & Reranking (45 min)
+
+**Concepts to Explore**:
+1. **Query Expansion**
+ - LLM generates query variants
+ - Original query weighted 2x
+ - Parallel retrieval per variant
+
+2. **Reciprocal Rank Fusion (RRF)**
+ - Formula: `score = Σ(weight/(k+rank+1))` where k=60
+ - Top-rank bonus: +0.05 for rank 1, +0.02 for ranks 2-3
+ - Why RRF? (Normalizes scores across different retrieval methods)
+
+3. **LLM Reranking** (Qwen3-Reranker)
+ - Cross-encoder scoring (0-1 scale)
+ - Position-aware blending:
+ - Ranks 1-3: 75% retrieval / 25% reranker
+ - Ranks 4-10: 60% retrieval / 40% reranker
+ - Ranks 11+: 40% retrieval / 60% reranker
+
+4. **Why Position-Aware Blending?**
+ - Trust retrieval for exact matches (top ranks)
+ - Trust reranker for semantic understanding (lower ranks)
+ - Balance precision and recall
+
+**Hands-On Activities**:
+- Read RRF implementation: `qmd/src/store.ts` (search for `reciprocalRankFusion`)
+- Read reranking logic: `qmd/src/store.ts` (search for `rerankResults`)
+- Read hybrid search: `qmd/src/store.ts` (search for `searchDocumentsHybrid`)
+
+**Verification**:
+```bash
+# Test hybrid search
+smriti search "how does vector search work" --project smriti
+
+# Compare with keyword-only
+smriti search "vector search" --project smriti --no-vector
+
+# Enable debug logging to see RRF scores
+DEBUG=qmd:* smriti search "embeddings" --project smriti
+```
+
+### Part 5: LLM Integration & Model Management (30 min)
+
+**Concepts to Explore**:
+1. **node-llama-cpp Abstraction**
+ - Model loading on-demand
+ - Context pooling
+ - Inactivity timeout (5 min default)
+
+2. **Three Model Types**
+ - Embedding: `embeddinggemma-300M-Q8_0` (~300MB)
+ - Reranking: `Qwen3-Reranker-0.6B-Q8_0` (~640MB)
+ - Generation: `qmd-query-expansion-1.7B` (~1.1GB)
+
+3. **LRU Cache**
+ - SQLite-based response cache
+ - Probabilistic pruning (1% chance on hits)
+ - Hash-based deduplication
+
+4. **Why GGUF Models?**
+ - CPU inference (no GPU required)
+ - Quantization reduces memory (Q8_0 = 8-bit)
+ - HuggingFace distribution
+
+**Hands-On Activities**:
+- Read LLM class: `qmd/src/llm.ts` (read entire file)
+- Read cache logic: `qmd/src/store.ts` (search for `llm_cache`)
+- Read model loading: `qmd/src/llm.ts` (search for `getModel`)
+
+**Verification**:
+```bash
+# Check model cache
+ls -lh ~/.cache/node-llama-cpp/models/
+
+# Test query expansion (should auto-download model on first run)
+DEBUG=qmd:llm smriti search "testing" --project smriti
+
+# Check LLM cache hits
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM llm_cache"
+```
+
+### Part 6: Memory System & Recall (30 min)
+
+**Concepts to Explore**:
+1. **Session-Based Storage**
+ - Sessions = conversations
+ - Messages = turns within sessions
+ - Metadata JSON field for extensibility
+
+2. **Recall Pipeline**
+ - Parallel FTS + vector search
+ - RRF fusion
+ - Session-level deduplication (keep best score per session)
+ - Optional Ollama synthesis
+
+3. **Ollama Integration**
+ - HTTP API (not node-llama-cpp)
+ - Configurable model (`QMD_MEMORY_MODEL`)
+ - Synthesis prompt engineering
+
+**Hands-On Activities**:
+- Read `addMessage`: `qmd/src/memory.ts` (search for `addMessage`)
+- Read `recallMemories`: `qmd/src/memory.ts` (search for `recallMemories`)
+- Read Ollama synthesis: `qmd/src/ollama.ts` (read entire file)
+
+**Verification**:
+```bash
+# Ingest sessions
+smriti ingest claude
+
+# Test recall without synthesis
+smriti recall "vector embeddings"
+
+# Test recall with synthesis (requires Ollama running)
+ollama serve &
+smriti recall "vector embeddings" --synthesize
+
+# Check memory tables
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM memory_sessions"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM memory_messages"
+```
+
+### Part 7: Smriti Extensions to QMD (30 min)
+
+**Concepts to Explore**:
+1. **Metadata Tables**
+ - `smriti_session_meta` - Agent/project tracking
+ - `smriti_categories` - Hierarchical taxonomy
+ - `smriti_session_tags` - Category assignments
+ - `smriti_shares` - Team knowledge exports
+
+2. **Filtered Search**
+ - JOINs QMD tables with Smriti metadata
+ - Category/project/agent filters
+ - Preserves BM25 scoring
+
+3. **Integration Pattern**
+ - Single re-export hub: `src/qmd.ts`
+ - No scattered dynamic imports
+ - Clean dependency boundary
+
+**Hands-On Activities**:
+- Read Smriti schema: `src/db.ts` (search for `CREATE TABLE`)
+- Read filtered search: `src/search/index.ts` (search for `searchFiltered`)
+- Read QMD integration: `src/qmd.ts` (read entire file)
+
+**Verification**:
+```bash
+# Test filtered search
+smriti search "embeddings" --category code/implementation
+
+# Check Smriti metadata
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT * FROM smriti_projects"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT * FROM smriti_categories"
+
+# Verify integration (should not import from QMD directly anywhere except qmd.ts)
+grep -r "from ['\"]qmd" src/ --exclude="qmd.ts" || echo "✓ No direct QMD imports"
+```
+
+## Key Design Patterns Summary
+
+1. **Content Addressing** - SHA256 deduplication, `INSERT OR IGNORE`
+2. **Two-Step Vector Queries** - Avoid sqlite-vec JOIN hangs
+3. **Virtual Paths** - `qmd://collection/path` format
+4. **LRU Caching** - SQLite-based with probabilistic pruning
+5. **Soft Deletes** - `active` column for reversibility
+6. **Trigger-Based FTS** - Automatic index updates
+7. **YAML Collections** - Config not in SQLite
+8. **Token-Based Chunking** - Accurate boundaries via tokenizer
+9. **RRF with Top-Rank Bonus** - Preserve exact matches
+10. **Position-Aware Blending** - Trust retrieval for top results
+
+## Critical Files to Master
+
+| File | Lines | Purpose |
+|------|-------|---------|
+| `qmd/src/store.ts` | 2571 | Core data access, search, embeddings |
+| `qmd/src/memory.ts` | 848 | Conversation storage & recall |
+| `qmd/src/llm.ts` | 1208 | LLM abstraction (node-llama-cpp) |
+| `qmd/src/ollama.ts` | 169 | Ollama HTTP API |
+| `src/qmd.ts` | ~50 | Smriti's QMD re-export hub |
+| `src/db.ts` | ~500 | Smriti metadata schema |
+| `src/search/index.ts` | ~300 | Filtered search implementation |
+
+## Post-Session Actions
+
+1. **Tag This Session**:
+ ```bash
+ # After session completes, categorize it
+ smriti categorize --force
+
+ # Verify tagging
+ sqlite3 ~/.cache/qmd/index.sqlite "
+ SELECT c.name
+ FROM smriti_session_tags st
+ JOIN smriti_categories c ON c.id = st.category_id
+ WHERE st.session_id = ''
+ "
+ ```
+
+2. **Share Knowledge**:
+ ```bash
+ # Export this session to team knowledge
+ smriti share --project smriti --segmented
+
+ # Verify export
+ ls -lh .smriti/knowledge/
+ ```
+
+3. **Update Memory**:
+ - Update `/Users/zero8/.claude/projects/-Users-zero8-zero8-dev-smriti/memory/MEMORY.md`
+ - Add section: "QMD Implementation Deep Dive (2026-02-12)"
+ - Document key insights and gotchas
+
+## Known Issues Discovered
+
+### sqlite-vec Extension Not Loaded in Smriti
+
+**Issue**: The `smriti embed` command fails with "no such module: vec0" error.
+
+**Root Cause**: Smriti's `getDb()` function in `src/db.ts` doesn't load the sqlite-vec extension, but QMD's `embedMemoryMessages()` requires it.
+
+**Fix Required**: Modify `src/db.ts` to load sqlite-vec:
+```typescript
+import * as sqliteVec from "sqlite-vec";
+
+export function getDb(path?: string): Database {
+ if (_db) return _db;
+ _db = new Database(path || QMD_DB_PATH);
+ _db.exec("PRAGMA journal_mode = WAL");
+ _db.exec("PRAGMA foreign_keys = ON");
+ sqliteVec.load(_db); // Add this line
+ return _db;
+}
+```
+
+**Workaround**: For this session, we can still explore all other QMD functionality (search, recall, ingest, categorize). Vector embeddings can be discussed conceptually.
+
+## Expected Outcomes
+
+By the end of this session, you should be able to:
+
+✓ Explain why QMD uses content-addressing (deduplication, efficiency)
+✓ Describe the two-step vector query pattern and why it's necessary
+✓ Understand RRF scoring and position-aware blending rationale
+✓ Debug search quality issues (FTS vs vector vs hybrid)
+✓ Optimize chunking parameters for different content types
+✓ Extend QMD with custom metadata tables (like Smriti does)
+✓ Trace a query from CLI → search → LLM → results
+✓ Contribute confidently to QMD or Smriti codebases
+
+## Execution Approach
+
+This is a **learning session**, not an implementation task. The execution will be:
+
+1. **Interactive Exploration**: Read code together, explain concepts, answer questions
+2. **Hands-On Verification**: Run commands to see architecture in action
+3. **Deep Dives**: Investigate interesting implementation details on request
+4. **Knowledge Capture**: Ensure session gets properly tagged for future recall
+
+**No code changes required** - this is pure knowledge acquisition and understanding.
diff --git a/package.json b/package.json
index 4e7c4b4..8a9e7cf 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,13 @@
"dev": "bun --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"test": "bun test",
- "smriti": "bun src/index.ts"
+ "smriti": "bun src/index.ts",
+ "bench:qmd": "bun run scripts/bench-qmd.ts --profile ci-small --out bench/results/ci-small.json --no-llm",
+ "bench:qmd:repeat": "bun run scripts/bench-qmd-repeat.ts --profiles ci-small,small,medium --runs 3 --out bench/results/repeat-summary.json",
+ "bench:compare": "bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2",
+ "bench:scorecard": "bun run scripts/bench-scorecard.ts --baseline bench/baseline.ci-small.json --profile ci-small --threshold-pct 20",
+ "bench:ingest-hotpaths": "bun run scripts/bench-ingest-hotpaths.ts",
+ "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12"
},
"dependencies": {
"node-llama-cpp": "^3.0.0",
diff --git a/qmd b/qmd
index 7ec50b8..e257bb7 160000
--- a/qmd
+++ b/qmd
@@ -1 +1 @@
-Subproject commit 7ec50b8fce3c372b5adebadb2dd8deec34548427
+Subproject commit e257bb7b4eeca81b268b091d5ad8e8842f31af5d
diff --git a/scripts/bench-compare.ts b/scripts/bench-compare.ts
new file mode 100644
index 0000000..c8080e3
--- /dev/null
+++ b/scripts/bench-compare.ts
@@ -0,0 +1,106 @@
+import { readFileSync } from "fs";
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+type BenchReport = {
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function pctChange(current: number, baseline: number): number {
+ if (!baseline) return 0;
+ return (current - baseline) / baseline;
+}
+
+function fmtPct(x: number): string {
+ return `${(x * 100).toFixed(2)}%`;
+}
+
+function checkLatency(
+ label: string,
+ current: number,
+ baseline: number,
+ threshold: number,
+ warnings: string[]
+) {
+ const delta = pctChange(current, baseline);
+ if (delta > threshold) {
+ warnings.push(`${label} regressed by ${fmtPct(delta)} (current=${current}, baseline=${baseline})`);
+ }
+}
+
+function checkThroughput(
+ label: string,
+ current: number,
+ baseline: number,
+ threshold: number,
+ warnings: string[]
+) {
+ const drop = baseline ? (baseline - current) / baseline : 0;
+ if (drop > threshold) {
+ warnings.push(`${label} dropped by ${fmtPct(drop)} (current=${current}, baseline=${baseline})`);
+ }
+}
+
+function main() {
+ const baselinePath = arg("--baseline");
+ const currentPath = arg("--current");
+ const threshold = Number(arg("--threshold") || "0.2");
+
+ if (!baselinePath || !currentPath) {
+ console.error("Usage: bun run scripts/bench-compare.ts --baseline --current [--threshold 0.2]");
+ process.exit(1);
+ }
+
+ const baseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as BenchReport;
+ const current = JSON.parse(readFileSync(currentPath, "utf-8")) as BenchReport;
+
+ const warnings: string[] = [];
+
+ checkThroughput(
+ "ingest_throughput_msgs_per_sec",
+ current.metrics.ingest_throughput_msgs_per_sec,
+ baseline.metrics.ingest_throughput_msgs_per_sec,
+ threshold,
+ warnings
+ );
+
+ checkLatency(
+ "ingest_p95_ms_per_session",
+ current.metrics.ingest_p95_ms_per_session,
+ baseline.metrics.ingest_p95_ms_per_session,
+ threshold,
+ warnings
+ );
+
+ checkLatency("fts_p95_ms", current.metrics.fts.p95_ms, baseline.metrics.fts.p95_ms, threshold, warnings);
+ checkLatency("recall_p95_ms", current.metrics.recall.p95_ms, baseline.metrics.recall.p95_ms, threshold, warnings);
+
+ if (baseline.metrics.vector && current.metrics.vector) {
+ checkLatency("vector_p95_ms", current.metrics.vector.p95_ms, baseline.metrics.vector.p95_ms, threshold, warnings);
+ }
+
+ if (warnings.length === 0) {
+ console.log("No performance regressions detected.");
+ return;
+ }
+
+ console.log("Performance regression warnings:");
+ for (const w of warnings) {
+ console.log(`- ${w}`);
+ }
+
+ // Intentionally non-blocking for now.
+ process.exit(0);
+}
+
+main();
diff --git a/scripts/bench-ingest-hotpaths.ts b/scripts/bench-ingest-hotpaths.ts
new file mode 100644
index 0000000..28bb9ca
--- /dev/null
+++ b/scripts/bench-ingest-hotpaths.ts
@@ -0,0 +1,110 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import { addMessage, initializeMemoryTables } from "../src/qmd";
+
+type HotpathReport = {
+ generated_at: string;
+ cases: {
+ single_session: {
+ messages: number;
+ throughput_msgs_per_sec: number;
+ p95_ms_per_message: number;
+ };
+ rotating_sessions: {
+ sessions: number;
+ messages_per_session: number;
+ total_messages: number;
+ throughput_msgs_per_sec: number;
+ p95_ms_per_message: number;
+ };
+ };
+};
+
+function percentile(sorted: number[], p: number): number {
+ if (sorted.length === 0) return 0;
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[idx] || 0;
+}
+
+async function runSingleSession(db: Database, messages: number) {
+ const perMsgMs: number[] = [];
+ const started = Bun.nanoseconds();
+ for (let i = 0; i < messages; i++) {
+ const t0 = Bun.nanoseconds();
+ await addMessage(
+ db,
+ "bench-single",
+ i % 2 === 0 ? "user" : "assistant",
+ `Single session message ${i} auth cache vector schema ${i % 17}`,
+ { title: "Bench Single" }
+ );
+ perMsgMs.push((Bun.nanoseconds() - t0) / 1_000_000);
+ }
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ return {
+ throughput_msgs_per_sec: Number((messages / (totalMs / 1000)).toFixed(2)),
+ p95_ms_per_message: Number(percentile([...perMsgMs].sort((a, b) => a - b), 95).toFixed(3)),
+ };
+}
+
+async function runRotatingSessions(db: Database, sessions: number, messagesPerSession: number) {
+ const perMsgMs: number[] = [];
+ const totalMessages = sessions * messagesPerSession;
+ const started = Bun.nanoseconds();
+ for (let s = 0; s < sessions; s++) {
+ const sessionId = `bench-rot-${s}`;
+ for (let i = 0; i < messagesPerSession; i++) {
+ const t0 = Bun.nanoseconds();
+ await addMessage(
+ db,
+ sessionId,
+ i % 2 === 0 ? "user" : "assistant",
+ `Rotating session ${s} message ${i} index query latency throughput`,
+ { title: `Bench Rotating ${s}` }
+ );
+ perMsgMs.push((Bun.nanoseconds() - t0) / 1_000_000);
+ }
+ }
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ return {
+ total_messages: totalMessages,
+ throughput_msgs_per_sec: Number((totalMessages / (totalMs / 1000)).toFixed(2)),
+ p95_ms_per_message: Number(percentile([...perMsgMs].sort((a, b) => a - b), 95).toFixed(3)),
+ };
+}
+
+async function main() {
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-ingest-hotpath-"));
+ const dbPath = join(tempDir, "bench.sqlite");
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+
+ const single = await runSingleSession(db, 3000);
+ const rotating = await runRotatingSessions(db, 300, 10);
+
+ const report: HotpathReport = {
+ generated_at: new Date().toISOString(),
+ cases: {
+ single_session: {
+ messages: 3000,
+ ...single,
+ },
+ rotating_sessions: {
+ sessions: 300,
+ messages_per_session: 10,
+ ...rotating,
+ },
+ },
+ };
+
+ console.log(JSON.stringify(report, null, 2));
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-ingest-pipeline.ts b/scripts/bench-ingest-pipeline.ts
new file mode 100644
index 0000000..1b72fc6
--- /dev/null
+++ b/scripts/bench-ingest-pipeline.ts
@@ -0,0 +1,82 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest";
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function makeCodexJsonl(messages: number): string {
+ const lines: string[] = [];
+ for (let i = 0; i < messages; i++) {
+ const role = i % 2 === 0 ? "user" : "assistant";
+ const content =
+ role === "user"
+ ? `User prompt ${i}: auth cache schema vector query`
+ : `Assistant reply ${i}: implementation details for indexing and recall`;
+ lines.push(
+ JSON.stringify({
+ role,
+ content,
+ timestamp: new Date(Date.now() + i * 1000).toISOString(),
+ })
+ );
+ }
+ return lines.join("\n") + "\n";
+}
+
+async function main() {
+ const sessions = Math.max(1, Number(arg("--sessions") || "120"));
+ const messagesPerSession = Math.max(1, Number(arg("--messages") || "12"));
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-ingest-pipeline-"));
+ const logsDir = join(tempDir, "codex-logs");
+ const dbPath = join(tempDir, "bench.sqlite");
+ mkdirSync(logsDir, { recursive: true });
+
+ for (let s = 0; s < sessions; s++) {
+ const subDir = join(logsDir, `2026-02-${String((s % 28) + 1).padStart(2, "0")}`);
+ mkdirSync(subDir, { recursive: true });
+ const filePath = join(subDir, `session-${s}.jsonl`);
+ writeFileSync(filePath, makeCodexJsonl(messagesPerSession));
+ }
+
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+
+ const started = Bun.nanoseconds();
+ const result = await ingest(db, "codex", { logsDir });
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ const throughput = result.messagesIngested / (totalMs / 1000);
+
+ console.log(
+ JSON.stringify(
+ {
+ sessions,
+ messages_per_session: messagesPerSession,
+ sessions_ingested: result.sessionsIngested,
+ messages_ingested: result.messagesIngested,
+ elapsed_ms: Number(totalMs.toFixed(2)),
+ throughput_msgs_per_sec: Number(throughput.toFixed(2)),
+ errors: result.errors.length,
+ },
+ null,
+ 2
+ )
+ );
+
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-qmd-repeat.ts b/scripts/bench-qmd-repeat.ts
new file mode 100644
index 0000000..d6b4c41
--- /dev/null
+++ b/scripts/bench-qmd-repeat.ts
@@ -0,0 +1,141 @@
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+type ProfileName = "ci-small" | "small" | "medium";
+
+type BenchMetrics = {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+ recall: { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+};
+
+type SingleRunReport = {
+ profile: ProfileName;
+ mode: "no-llm" | "llm";
+ metrics: BenchMetrics;
+};
+
+type AggregatedReport = {
+ generated_at: string;
+ runs_per_profile: number;
+ mode: "no-llm";
+ profiles: Record<
+ string,
+ {
+ raw: BenchMetrics[];
+ median: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts_p95_ms: number;
+ recall_p95_ms: number;
+ };
+ }
+ >;
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function percentile(values: number[], p: number): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const idx = Math.min(
+ sorted.length - 1,
+ Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)
+ );
+ return sorted[idx] || 0;
+}
+
+function parseProfiles(input: string | undefined): ProfileName[] {
+ const raw = (input || "ci-small,small,medium")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean) as ProfileName[];
+ return raw.length > 0 ? raw : ["ci-small", "small", "medium"];
+}
+
+async function runOne(profile: ProfileName, outPath: string): Promise {
+ const proc = Bun.spawn(
+ [
+ "bun",
+ "run",
+ "scripts/bench-qmd.ts",
+ "--profile",
+ profile,
+ "--out",
+ outPath,
+ "--no-llm",
+ ],
+ {
+ stdout: "pipe",
+ stderr: "pipe",
+ cwd: process.cwd(),
+ }
+ );
+
+ const stderr = await new Response(proc.stderr).text();
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(`bench-qmd failed for ${profile}: ${stderr}`);
+ }
+
+ return JSON.parse(readFileSync(outPath, "utf8")) as SingleRunReport;
+}
+
+async function main() {
+ const profiles = parseProfiles(arg("--profiles"));
+ const runs = Math.max(1, Number(arg("--runs") || "3"));
+ const out = arg("--out") || join("bench", "results", "repeat-summary.json");
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-bench-repeat-"));
+ const result: AggregatedReport = {
+ generated_at: new Date().toISOString(),
+ runs_per_profile: runs,
+ mode: "no-llm",
+ profiles: {},
+ };
+
+ for (const profile of profiles) {
+ const raw: BenchMetrics[] = [];
+ for (let i = 0; i < runs; i++) {
+ const outPath = join(tempDir, `${profile}.run${i + 1}.json`);
+ const report = await runOne(profile, outPath);
+ raw.push(report.metrics);
+ console.log(
+ `[bench-repeat] ${profile} run ${i + 1}/${runs} ` +
+ `ingest=${report.metrics.ingest_throughput_msgs_per_sec.toFixed(2)} ` +
+ `fts_p95=${report.metrics.fts.p95_ms.toFixed(3)} ` +
+ `recall_p95=${report.metrics.recall.p95_ms.toFixed(3)}`
+ );
+ }
+
+ result.profiles[profile] = {
+ raw,
+ median: {
+ ingest_throughput_msgs_per_sec: Number(
+ percentile(raw.map((m) => m.ingest_throughput_msgs_per_sec), 50).toFixed(2)
+ ),
+ ingest_p95_ms_per_session: Number(
+ percentile(raw.map((m) => m.ingest_p95_ms_per_session), 50).toFixed(3)
+ ),
+ fts_p95_ms: Number(percentile(raw.map((m) => m.fts.p95_ms), 50).toFixed(3)),
+ recall_p95_ms: Number(
+ percentile(raw.map((m) => m.recall.p95_ms), 50).toFixed(3)
+ ),
+ },
+ };
+ }
+
+ mkdirSync(join(process.cwd(), "bench", "results"), { recursive: true });
+ writeFileSync(out, JSON.stringify(result, null, 2));
+ console.log(`Repeat benchmark summary written: ${out}`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-qmd.ts b/scripts/bench-qmd.ts
new file mode 100644
index 0000000..c6585d1
--- /dev/null
+++ b/scripts/bench-qmd.ts
@@ -0,0 +1,219 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import {
+ addMessage,
+ initializeMemoryTables,
+ searchMemoryFTS,
+ searchMemoryVec,
+ recallMemories,
+ embedMemoryMessages,
+} from "../src/qmd";
+
+type ProfileName = "ci-small" | "small" | "medium";
+
+type BenchProfile = {
+ sessions: number;
+ messagesPerSession: number;
+ warmupQueries: number;
+ measureQueries: number;
+};
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+
+type BenchReport = {
+ profile: ProfileName;
+ mode: "no-llm" | "llm";
+ generated_at: string;
+ db_path: string;
+ corpus: {
+ sessions: number;
+ messages_per_session: number;
+ total_messages: number;
+ };
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+ counts: {
+ memory_sessions: number;
+ memory_messages: number;
+ content_vectors: number;
+ };
+};
+
+const PROFILES: Record = {
+ "ci-small": { sessions: 40, messagesPerSession: 10, warmupQueries: 5, measureQueries: 30 },
+ small: { sessions: 120, messagesPerSession: 12, warmupQueries: 10, measureQueries: 60 },
+ medium: { sessions: 300, messagesPerSession: 16, warmupQueries: 20, measureQueries: 120 },
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function has(name: string): boolean {
+ return process.argv.includes(name);
+}
+
+function percentile(sorted: number[], p: number): number {
+ if (sorted.length === 0) return 0;
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[idx] || 0;
+}
+
+function stats(values: number[]): TimedStats {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mean = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
+ return {
+ p50_ms: Number(percentile(sorted, 50).toFixed(3)),
+ p95_ms: Number(percentile(sorted, 95).toFixed(3)),
+ mean_ms: Number(mean.toFixed(3)),
+ runs: values.length,
+ };
+}
+
+function randomWords(seed: number, count: number): string {
+ const base = [
+ "auth", "cache", "index", "vector", "schema", "session", "query", "deploy",
+ "pipeline", "memory", "feature", "bug", "review", "latency", "throughput", "design",
+ ];
+ const parts: string[] = [];
+ for (let i = 0; i < count; i++) {
+ parts.push(base[(seed + i * 7) % base.length] || "token");
+ }
+ return parts.join(" ");
+}
+
+function makeUserMessage(s: number, m: number): string {
+ return `User request ${s}-${m}: ${randomWords(s * 37 + m, 18)}`;
+}
+
+function makeAssistantMessage(s: number, m: number): string {
+ return `Assistant response ${s}-${m}: ${randomWords(s * 53 + m, 28)} implementation details and tradeoffs.`;
+}
+
+async function main() {
+ const profileName = (arg("--profile") as ProfileName) || "ci-small";
+ const outPath = arg("--out") || join("bench", "results", `${profileName}.json`);
+ const mode: "no-llm" | "llm" = has("--llm") ? "llm" : "no-llm";
+ const profile = PROFILES[profileName];
+ if (!profile) throw new Error(`Unknown profile: ${profileName}`);
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-bench-"));
+ const dbPath = join(tempDir, "bench.sqlite");
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+
+ const ingestPerSessionMs: number[] = [];
+ const totalMessages = profile.sessions * profile.messagesPerSession;
+
+ for (let s = 0; s < profile.sessions; s++) {
+ const sessionId = `bench-s-${s}`;
+ const t0 = Bun.nanoseconds();
+
+ for (let m = 0; m < profile.messagesPerSession; m++) {
+ const role = m % 2 === 0 ? "user" : "assistant";
+ const content = role === "user" ? makeUserMessage(s, m) : makeAssistantMessage(s, m);
+ await addMessage(db, sessionId, role, content, { title: `Bench Session ${s}` });
+ }
+
+ const dtMs = (Bun.nanoseconds() - t0) / 1_000_000;
+ ingestPerSessionMs.push(dtMs);
+ }
+
+ const ingestTotalMs = ingestPerSessionMs.reduce((a, b) => a + b, 0);
+ const ingestThroughput = totalMessages / (ingestTotalMs / 1000);
+
+ const queries: string[] = [];
+ for (let i = 0; i < profile.measureQueries + profile.warmupQueries; i++) {
+ queries.push(randomWords(i * 17, 3));
+ }
+
+ for (let i = 0; i < profile.warmupQueries; i++) {
+ searchMemoryFTS(db, queries[i] || "auth", 10);
+ await recallMemories(db, queries[i] || "auth", { limit: 10, synthesize: false });
+ }
+
+ const ftsDurations: number[] = [];
+ const recallDurations: number[] = [];
+
+ for (let i = profile.warmupQueries; i < queries.length; i++) {
+ const q = queries[i] || "auth";
+
+ const tFts = Bun.nanoseconds();
+ searchMemoryFTS(db, q, 10);
+ ftsDurations.push((Bun.nanoseconds() - tFts) / 1_000_000);
+
+ const tRecall = Bun.nanoseconds();
+ await recallMemories(db, q, { limit: 10, synthesize: false });
+ recallDurations.push((Bun.nanoseconds() - tRecall) / 1_000_000);
+ }
+
+ let vectorStats: TimedStats | null = null;
+ if (mode === "llm") {
+ try {
+ await embedMemoryMessages(db);
+ const vecDurations: number[] = [];
+ for (let i = profile.warmupQueries; i < queries.length; i++) {
+ const q = queries[i] || "auth";
+ const tVec = Bun.nanoseconds();
+ await searchMemoryVec(db, q, 10);
+ vecDurations.push((Bun.nanoseconds() - tVec) / 1_000_000);
+ }
+ vectorStats = stats(vecDurations);
+ } catch {
+ vectorStats = null;
+ }
+ }
+
+ const counts = {
+ memory_sessions: (db.prepare("SELECT COUNT(*) as c FROM memory_sessions").get() as { c: number }).c,
+ memory_messages: (db.prepare("SELECT COUNT(*) as c FROM memory_messages").get() as { c: number }).c,
+ content_vectors: (() => {
+ try {
+ return (db.prepare("SELECT COUNT(*) as c FROM content_vectors").get() as { c: number }).c;
+ } catch {
+ return 0;
+ }
+ })(),
+ };
+
+ const report: BenchReport = {
+ profile: profileName,
+ mode,
+ generated_at: new Date().toISOString(),
+ db_path: dbPath,
+ corpus: {
+ sessions: profile.sessions,
+ messages_per_session: profile.messagesPerSession,
+ total_messages: totalMessages,
+ },
+ metrics: {
+ ingest_throughput_msgs_per_sec: Number(ingestThroughput.toFixed(2)),
+ ingest_p95_ms_per_session: Number(percentile([...ingestPerSessionMs].sort((a, b) => a - b), 95).toFixed(3)),
+ fts: stats(ftsDurations),
+ recall: stats(recallDurations),
+ vector: vectorStats,
+ },
+ counts,
+ };
+
+ mkdirSync(join(process.cwd(), "bench", "results"), { recursive: true });
+ writeFileSync(outPath, JSON.stringify(report, null, 2));
+ console.log(`Benchmark report written: ${outPath}`);
+ console.log(JSON.stringify(report.metrics, null, 2));
+
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-scorecard.ts b/scripts/bench-scorecard.ts
new file mode 100644
index 0000000..9560120
--- /dev/null
+++ b/scripts/bench-scorecard.ts
@@ -0,0 +1,129 @@
+import { existsSync, readFileSync } from "fs";
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+type BenchReport = {
+ profile: string;
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+};
+
+type RepeatSummary = {
+ profiles: Record<
+ string,
+ {
+ median: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts_p95_ms: number;
+ recall_p95_ms: number;
+ };
+ }
+ >;
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function fmtNum(x: number): string {
+ return x.toFixed(3).replace(/\.000$/, "");
+}
+
+function pctDelta(current: number, baseline: number): number {
+ if (!baseline) return 0;
+ return ((current - baseline) / baseline) * 100;
+}
+
+function fmtDelta(value: number): string {
+ const sign = value > 0 ? "+" : "";
+ return `${sign}${value.toFixed(2)}%`;
+}
+
+function passWarn(deltaPct: number, thresholdPct: number, higherIsBetter: boolean): "PASS" | "WARN" {
+ if (higherIsBetter) {
+ return deltaPct < -thresholdPct ? "WARN" : "PASS";
+ }
+ return deltaPct > thresholdPct ? "WARN" : "PASS";
+}
+
+function main() {
+ const baselinePath = arg("--baseline") || "bench/baseline.ci-small.json";
+ const requestedRepeatPath = arg("--repeat");
+ const repeatPath =
+ requestedRepeatPath ||
+ (existsSync("bench/results/repeat-summary.json")
+ ? "bench/results/repeat-summary.json"
+ : "bench/results/repeat-summary.current.json");
+ const thresholdPct = Number(arg("--threshold-pct") || "20");
+
+ if (!existsSync(repeatPath)) {
+ throw new Error(
+ `Repeat summary not found at "${repeatPath}". Run: bun run bench:qmd:repeat`
+ );
+ }
+
+ const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as BenchReport;
+ const repeat = JSON.parse(readFileSync(repeatPath, "utf8")) as RepeatSummary;
+
+ const baselineProfile = baseline.profile;
+ const selected = arg("--profile") || baselineProfile;
+ const profile = repeat.profiles[selected];
+ if (!profile) {
+ const choices = Object.keys(repeat.profiles).join(", ") || "(none)";
+ throw new Error(`Profile "${selected}" not found in repeat summary. Available: ${choices}`);
+ }
+
+ const rows = [
+ {
+ metric: "ingest_throughput_msgs_per_sec",
+ current: profile.median.ingest_throughput_msgs_per_sec,
+ base: baseline.metrics.ingest_throughput_msgs_per_sec,
+ higherIsBetter: true,
+ },
+ {
+ metric: "ingest_p95_ms_per_session",
+ current: profile.median.ingest_p95_ms_per_session,
+ base: baseline.metrics.ingest_p95_ms_per_session,
+ higherIsBetter: false,
+ },
+ {
+ metric: "fts_p95_ms",
+ current: profile.median.fts_p95_ms,
+ base: baseline.metrics.fts.p95_ms,
+ higherIsBetter: false,
+ },
+ {
+ metric: "recall_p95_ms",
+ current: profile.median.recall_p95_ms,
+ base: baseline.metrics.recall.p95_ms,
+ higherIsBetter: false,
+ },
+ ];
+
+ console.log(`# Bench Scorecard (${selected})`);
+ console.log(`threshold: ${thresholdPct.toFixed(2)}%`);
+ console.log("");
+ console.log("| metric | baseline | current (median) | delta | status |");
+ console.log("|---|---:|---:|---:|---|");
+
+ let warnCount = 0;
+ for (const row of rows) {
+ const deltaPct = pctDelta(row.current, row.base);
+ const status = passWarn(deltaPct, thresholdPct, row.higherIsBetter);
+ if (status === "WARN") warnCount += 1;
+ console.log(
+ `| ${row.metric} | ${fmtNum(row.base)} | ${fmtNum(row.current)} | ${fmtDelta(deltaPct)} | ${status} |`
+ );
+ }
+
+ console.log("");
+ console.log(`Summary: ${warnCount === 0 ? "PASS" : `WARN (${warnCount} metrics)`}`);
+}
+
+main();
diff --git a/scripts/validate-design.ts b/scripts/validate-design.ts
new file mode 100644
index 0000000..520a672
--- /dev/null
+++ b/scripts/validate-design.ts
@@ -0,0 +1,252 @@
+#!/usr/bin/env bun
+/**
+ * validate-design.ts
+ *
+ * Static-analysis validator for smriti's three design contracts:
+ * 1. Dry-run coverage — mutating commands must handle --dry-run
+ * 2. Observability — no user content in logs; telemetry default off
+ * 3. JSON stability — structural checks on the output envelope
+ *
+ * Exit 0 → all contracts satisfied.
+ * Exit 1 → one or more violations (details printed to stderr).
+ *
+ * Run: bun run scripts/validate-design.ts
+ */
+
+import { readFileSync } from "fs";
+import { join } from "path";
+
+const ROOT = join(import.meta.dir, "..");
+const INDEX_SRC = join(ROOT, "src", "index.ts");
+const CONFIG_SRC = join(ROOT, "src", "config.ts");
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+let failures = 0;
+
+function fail(rule: string, detail: string) {
+ failures++;
+ console.error(`\n❌ [${rule}]`);
+ console.error(` ${detail}`);
+}
+
+function pass(rule: string) {
+ console.log(`✅ [${rule}]`);
+}
+
+/**
+ * Extract the source text for a top-level case block from a switch statement.
+ * Returns everything from `case "name":` up to (but not including) the next
+ * top-level `case` or `default:`.
+ */
+function extractCase(src: string, name: string): string | null {
+ const pattern = new RegExp(`case "${name}":\\s*\\{`, "g");
+ const m = pattern.exec(src);
+ if (!m) return null;
+
+ let depth = 0;
+ let i = m.index;
+ const start = i;
+
+ while (i < src.length) {
+ if (src[i] === "{") depth++;
+ if (src[i] === "}") {
+ depth--;
+ if (depth === 0) return src.slice(start, i + 1);
+ }
+ i++;
+ }
+ return src.slice(start);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Load source files
+// ─────────────────────────────────────────────────────────────────────────────
+
+const indexSrc = readFileSync(INDEX_SRC, "utf8");
+const configSrc = readFileSync(CONFIG_SRC, "utf8");
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 1a: Mutating commands must support --dry-run
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 1: Dry-run coverage ──");
+
+const MUTATING = ["ingest", "embed", "categorize", "tag", "share", "sync"] as const;
+// `context` already has dry-run — included in validation
+const MUTATING_ALL = [...MUTATING, "context"] as const;
+
+for (const cmd of MUTATING_ALL) {
+ const block = extractCase(indexSrc, cmd);
+ if (!block) {
+ fail(`dry-run/${cmd}`, `Case block for "${cmd}" not found in src/index.ts`);
+ continue;
+ }
+
+ const hasDryRunFlag = block.includes('"--dry-run"');
+ const hasDryRunVar = /dry.?[Rr]un/i.test(block);
+
+ if (!hasDryRunFlag && !hasDryRunVar) {
+ fail(
+ `dry-run/${cmd}`,
+ `Mutating command "${cmd}" does not reference "--dry-run". ` +
+ `Add: const dryRun = hasFlag(args, "--dry-run");`
+ );
+ } else {
+ pass(`dry-run/${cmd}`);
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 1b: Read-only commands must NOT support --dry-run
+// ─────────────────────────────────────────────────────────────────────────────
+
+const READ_ONLY = [
+ "search", "recall", "list", "status", "show",
+ "compare", "projects", "team", "categories",
+] as const;
+
+for (const cmd of READ_ONLY) {
+ const block = extractCase(indexSrc, cmd);
+ if (!block) {
+ // Not all read-only commands may be present yet — skip silently
+ continue;
+ }
+
+ const hasDryRun = block.includes('"--dry-run"') || /dry.?[Rr]un/i.test(block);
+
+ if (hasDryRun) {
+ fail(
+ `dry-run-reject/${cmd}`,
+ `Read-only command "${cmd}" references "--dry-run". ` +
+ `Read-only commands must reject this flag with a usage error.`
+ );
+ } else {
+ pass(`dry-run-reject/${cmd}`);
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 2a: No user content in console calls
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 2: Observability ──");
+
+// Patterns that indicate user content leaking into logs.
+// Usage/help strings (lines containing `<...>` angle-bracket placeholders) are
+// excluded — those are hardcoded template text, not runtime user data.
+const PII_PATTERNS: Array<{ re: RegExp; description: string }> = [
+ {
+ // Logging a runtime .content property — but not a hardcoded "" usage string
+ re: /console\.(log|error)\([^)]*\.content\b/,
+ description: "`.content` field logged — may expose message text",
+ },
+ {
+ re: /console\.(log|error)\([^)]*\.text\b/,
+ description: "`.text` field logged — may expose user text",
+ },
+ {
+ // Variable named `query` interpolated at runtime — not a hardcoded placeholder like
+ re: /console\.(log|error)\(.*\$\{query\}/,
+ description: "`query` variable interpolated into log — may expose user search string",
+ },
+];
+
+let piiViolations = 0;
+const lines = indexSrc.split("\n");
+for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ // Skip usage/help strings — these are static developer-written text, not runtime user data.
+ // Heuristic: lines whose console call contains a "<...>" placeholder are usage messages.
+ if (/console\.(log|error)\([^)]*<[a-z-]+>/i.test(line)) continue;
+
+ for (const { re, description } of PII_PATTERNS) {
+ if (re.test(line)) {
+ piiViolations++;
+ fail(
+ "observability/no-user-content",
+ `src/index.ts:${i + 1} — ${description}\n Line: ${line.trim()}`
+ );
+ }
+ }
+}
+if (piiViolations === 0) {
+ pass("observability/no-user-content");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 2b: Telemetry must default to OFF
+// ─────────────────────────────────────────────────────────────────────────────
+
+// Check that SMRITI_TELEMETRY is not defaulted to a truthy value in config.ts
+// Pattern: `SMRITI_TELEMETRY` env var with a default that is "1", "true", or "on"
+const telemetryAlwaysOn = /SMRITI_TELEMETRY\s*\|\|\s*["'`](1|true|on)["'`]/i.test(configSrc);
+const telemetryHardcoded = /SMRITI_TELEMETRY\s*=\s*["'`]?(1|true|on)["'`]?[^=]/i.test(configSrc);
+
+if (telemetryAlwaysOn || telemetryHardcoded) {
+ fail(
+ "observability/telemetry-default",
+ "SMRITI_TELEMETRY appears to default to a truthy value in src/config.ts. " +
+ "Telemetry must be opt-in (default OFF)."
+ );
+} else {
+ pass("observability/telemetry-default");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 3: JSON output envelope shape
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 3: JSON output envelope ──");
+
+// The json() helper in format.ts is a thin JSON.stringify wrapper.
+// The envelope contract (ok/data/meta) applies to the return values of command
+// functions, not to format.ts itself. We check that:
+// (a) the `context` command (which has the most complete JSON support) returns
+// a shape with `dry_run` in meta — a forward-looking proxy for the pattern.
+// (b) no command pipes raw arrays directly to `json()` without wrapping — i.e.,
+// every `json(...)` call wraps an object, not a bare array.
+
+// Check (a): context.ts produces a result shape with meta.dry_run — confirms envelope awareness
+const contextSrc = readFileSync(join(ROOT, "src", "context.ts"), "utf8");
+const contextHasDryRunMeta = /dry_?run/i.test(contextSrc);
+if (!contextHasDryRunMeta) {
+ fail(
+ "json-envelope/meta-dry-run",
+ "src/context.ts does not appear to include dry_run in its return shape. " +
+ "JSON output in dry-run mode must include meta.dry_run=true."
+ );
+} else {
+ pass("json-envelope/meta-dry-run");
+}
+
+// Check (b): Look for json() calls in index.ts to ensure they wrap structured objects,
+// not raw user-content arrays passed through without a wrapper.
+// Any `json(result)` or `json(sessions)` is fine — we flag only `json(query)` type leaks.
+const jsonCallsWithQuery = /\bjson\s*\(\s*query\s*\)/g;
+if (jsonCallsWithQuery.test(indexSrc)) {
+ fail(
+ "json-envelope/raw-query",
+ "A json(query) call was found in src/index.ts — query strings must never be JSON-serialised to output."
+ );
+} else {
+ pass("json-envelope/no-raw-query");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Summary
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n─────────────────────────────────────────");
+if (failures === 0) {
+ console.log(`✅ All design contracts satisfied.`);
+ process.exit(0);
+} else {
+ console.error(`\n❌ ${failures} design contract violation(s) found.`);
+ console.error(
+ " See docs/DESIGN.md for the full contract specification.\n"
+ );
+ process.exit(1);
+}
diff --git a/src/ingest/README.md b/src/ingest/README.md
new file mode 100644
index 0000000..1dde5f6
--- /dev/null
+++ b/src/ingest/README.md
@@ -0,0 +1,27 @@
+# Ingest Module
+
+## Purpose
+
+Ingest imports conversations from supported agents and stores normalized memory in the local database.
+
+## Structure
+
+- `index.ts`: orchestration entry point
+- `parsers/*`: pure agent parsers (no DB writes)
+- `session-resolver.ts`: project/session resolution + incremental state
+- `store-gateway.ts`: centralized persistence for messages/meta/sidecars/costs
+- `claude.ts`, `codex.ts`, `cursor.ts`, `cline.ts`, `copilot.ts`, `generic.ts`: discovery helpers + compatibility wrappers
+
+## Design Rules
+
+- Parsers must not write to DB.
+- DB writes should go through store-gateway.
+- Session/project resolution should go through session-resolver.
+- Orchestrator owns control flow and aggregation.
+
+## Adding a New Agent
+
+1. Add parser in `parsers/.ts`.
+2. Add discovery logic in `src/ingest/.ts`.
+3. Wire into `ingest()` in `index.ts`.
+4. Add parser + orchestrator tests.
diff --git a/src/ingest/claude.ts b/src/ingest/claude.ts
index f263e08..b0b9183 100644
--- a/src/ingest/claude.ts
+++ b/src/ingest/claude.ts
@@ -7,7 +7,7 @@
*/
import { existsSync } from "fs";
-import { basename } from "path";
+import { basename, join } from "path";
import { CLAUDE_LOGS_DIR, PROJECTS_ROOT } from "../config";
import { addMessage } from "../qmd";
import type { ParsedMessage, StructuredMessage, MessageMetadata } from "./types";
@@ -365,13 +365,14 @@ export async function discoverClaudeSessions(
}> = [];
for await (const match of glob.scan({ cwd: dir, absolute: false })) {
- const [projectDir, filename] = match.split("/");
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const [projectDir, filename] = normalizedMatch.split("/");
if (!projectDir || !filename) continue;
const sessionId = filename.replace(".jsonl", "");
sessions.push({
sessionId,
projectDir,
- filePath: `${dir}/${match}`,
+ filePath: join(dir, normalizedMatch),
});
}
@@ -388,206 +389,13 @@ export async function discoverClaudeSessions(
export async function ingestClaude(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const {
- upsertProject,
- upsertSessionMeta,
- insertToolUsage,
- insertFileOperation,
- insertCommand,
- insertGitOperation,
- insertError,
- upsertSessionCosts,
- } = await import("../db");
-
- const sessions = await discoverClaudeSessions(options.logsDir);
- const result: IngestResult = {
- agent: "claude-code",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const structuredMessages = parseClaudeJsonlStructured(content);
-
- if (structuredMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Incremental ingestion: count existing messages and only process new ones.
- // This works because Claude JSONL files are append-only and message order is stable.
- const existingMessageCount: number =
- (db.prepare(`SELECT COUNT(*) as count FROM memory_messages WHERE session_id = ?`)
- .get(session.sessionId) as { count: number } | null)?.count ?? 0;
-
- const newMessages = structuredMessages.slice(existingMessageCount);
-
- if (newMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Derive project info
- const projectId = deriveProjectId(session.projectDir);
- const projectPath = deriveProjectPath(session.projectDir);
- upsertProject(db, projectId, projectPath);
-
- // Extract title from first user message (across all messages for consistency)
- const firstUser = structuredMessages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.plainText.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- // Process only new messages
- for (const msg of newMessages) {
- // Store via QMD (backward-compatible: plainText as content)
- const stored = await addMessage(
- db,
- session.sessionId,
- msg.role,
- msg.plainText || "(structured content)",
- {
- title,
- metadata: {
- ...msg.metadata,
- blocks: msg.blocks,
- },
- }
- );
-
- const messageId = stored.id;
- const createdAt = msg.timestamp || new Date().toISOString();
-
- // Populate sidecar tables from blocks
- for (const block of msg.blocks) {
- switch (block.type) {
- case "tool_call":
- insertToolUsage(
- db,
- messageId,
- session.sessionId,
- block.toolName,
- block.description || summarizeToolInput(block.toolName, block.input),
- true, // success assumed; updated by tool_result if paired
- null,
- createdAt
- );
- break;
-
- case "file_op":
- if (block.path) {
- insertFileOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.path,
- projectId,
- createdAt
- );
- }
- break;
-
- case "command":
- insertCommand(
- db,
- messageId,
- session.sessionId,
- block.command,
- block.exitCode ?? null,
- block.cwd ?? null,
- block.isGit,
- createdAt
- );
- break;
-
- case "git":
- insertGitOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.branch ?? null,
- block.prUrl ?? null,
- block.prNumber ?? null,
- block.message ? JSON.stringify({ message: block.message }) : null,
- createdAt
- );
- break;
-
- case "error":
- insertError(
- db,
- messageId,
- session.sessionId,
- block.errorType,
- block.message,
- createdAt
- );
- break;
- }
- }
-
- // Accumulate token costs from metadata
- if (msg.metadata.tokenUsage) {
- const u = msg.metadata.tokenUsage;
- upsertSessionCosts(
- db,
- session.sessionId,
- msg.metadata.model || null,
- u.input,
- u.output,
- (u.cacheCreate || 0) + (u.cacheRead || 0),
- 0
- );
- }
-
- // Accumulate turn duration from system events
- for (const block of msg.blocks) {
- if (
- block.type === "system_event" &&
- block.eventType === "turn_duration" &&
- typeof block.data.durationMs === "number"
- ) {
- upsertSessionCosts(
- db,
- session.sessionId,
- null,
- 0,
- 0,
- 0,
- block.data.durationMs as number
- );
- }
- }
- }
-
- result.sessionsIngested++;
- result.messagesIngested += newMessages.length;
-
- // Ensure session meta exists (idempotent upsert)
- upsertSessionMeta(db, session.sessionId, "claude-code", projectId);
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${newMessages.length} new messages, ${existingMessageCount} existing)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "claude-code", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
// =============================================================================
diff --git a/src/ingest/cline.ts b/src/ingest/cline.ts
index ec04295..f014131 100644
--- a/src/ingest/cline.ts
+++ b/src/ingest/cline.ts
@@ -278,190 +278,13 @@ export async function discoverClineSessions(
export async function ingestCline(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const {
- upsertProject,
- upsertSessionMeta,
- insertToolUsage,
- insertFileOperation,
- insertCommand,
- insertGitOperation,
- insertError,
- upsertSessionCosts,
- } = await import("../db");
-
- const sessions = await discoverClineSessions(options.logsDir);
- const result: IngestResult = {
- agent: "cline",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const task: ClineTask = JSON.parse(content);
- const structuredMessages = parseClineTask(task, 0); // Start sequence from 0
-
- if (structuredMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Derive project info using the task's CWD
- const projectId = deriveProjectId(task.cwd || "");
- const projectPath = deriveProjectPath(task.cwd || "");
- upsertProject(db, projectId, projectPath);
-
- // Use task name or first message as title
- const title = task.name || structuredMessages[0].plainText.slice(0, 100).replace(/\n/g, " ");
-
- // Process each structured message
- for (const msg of structuredMessages) {
- const stored = await addMessage(
- db,
- session.sessionId,
- msg.role,
- msg.plainText || "(structured content)",
- {
- title,
- metadata: {
- ...msg.metadata,
- blocks: msg.blocks,
- },
- }
- );
-
- const messageId = stored.id;
- const createdAt = msg.timestamp || new Date().toISOString();
-
- // Populate sidecar tables from blocks
- for (const block of msg.blocks) {
- switch (block.type) {
- case "tool_call":
- insertToolUsage(
- db,
- messageId,
- session.sessionId,
- block.toolName,
- block.description || summarizeToolInput(block.toolName, block.input),
- true, // success assumed; updated by tool_result if paired
- null,
- createdAt
- );
- break;
-
- case "file_op":
- if (block.path) {
- insertFileOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.path,
- projectId,
- createdAt
- );
- }
- break;
-
- case "command":
- insertCommand(
- db,
- messageId,
- session.sessionId,
- block.command,
- block.exitCode ?? null,
- block.cwd ?? null,
- block.isGit,
- createdAt
- );
- break;
-
- case "git":
- insertGitOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.branch ?? null,
- block.prUrl ?? null,
- block.prNumber ?? null,
- block.message ? JSON.stringify({ message: block.message }) : null,
- createdAt
- );
- break;
-
- case "error":
- insertError(
- db,
- messageId,
- session.sessionId,
- block.errorType,
- block.message,
- createdAt
- );
- break;
-
- case "system_event":
- if (block.eventType === "turn_duration" && typeof block.data.durationMs === "number") {
- upsertSessionCosts(
- db,
- session.sessionId,
- null,
- 0,
- 0,
- 0,
- block.data.durationMs as number
- );
- }
- break;
- }
- }
-
- // Accumulate token costs if present in metadata (Cline tasks might not have this directly)
- if (msg.metadata.tokenUsage) {
- const u = msg.metadata.tokenUsage;
- upsertSessionCosts(
- db,
- session.sessionId,
- msg.metadata.model || null,
- u.input,
- u.output,
- (u.cacheCreate || 0) + (u.cacheRead || 0),
- 0
- );
- }
- }
-
- // Attach Smriti metadata
- upsertSessionMeta(db, session.sessionId, "cline", projectId);
-
- result.sessionsIngested++;
- result.messagesIngested += structuredMessages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${structuredMessages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "cline", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
// =============================================================================
diff --git a/src/ingest/codex.ts b/src/ingest/codex.ts
index cd5f39c..8be7a80 100644
--- a/src/ingest/codex.ts
+++ b/src/ingest/codex.ts
@@ -5,6 +5,7 @@
* to QMD's addMessage() format.
*/
+import { join } from "path";
import { CODEX_LOGS_DIR } from "../config";
import { addMessage } from "../qmd";
import type { ParsedMessage, IngestResult, IngestOptions } from "./index";
@@ -75,10 +76,11 @@ export async function discoverCodexSessions(
try {
const glob = new Bun.Glob("**/*.jsonl");
for await (const match of glob.scan({ cwd: dir, absolute: false })) {
- const sessionId = match.replace(/\.jsonl$/, "").replace(/\//g, "-");
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const sessionId = normalizedMatch.replace(/\.jsonl$/, "").replaceAll("/", "-");
sessions.push({
sessionId: `codex-${sessionId}`,
- filePath: `${dir}/${match}`,
+ filePath: join(dir, normalizedMatch),
});
}
} catch {
@@ -94,61 +96,11 @@ export async function discoverCodexSessions(
export async function ingestCodex(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCodexSessions(options.logsDir);
- const result: IngestResult = {
- agent: "codex",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const messages = parseCodexJsonl(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, {
- title,
- });
- }
-
- upsertSessionMeta(db, session.sessionId, "codex");
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${messages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "codex", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
diff --git a/src/ingest/copilot.ts b/src/ingest/copilot.ts
index 8edee09..5a171f7 100644
--- a/src/ingest/copilot.ts
+++ b/src/ingest/copilot.ts
@@ -209,13 +209,14 @@ export async function discoverCopilotSessions(options: {
const glob = new Bun.Glob("*/chatSessions/*.json");
try {
for await (const match of glob.scan({ cwd: root, absolute: false })) {
- const filePath = join(root, match);
- const hashDir = join(root, match.split("/")[0]);
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const filePath = join(root, normalizedMatch);
+ const hashDir = join(root, normalizedMatch.split("/")[0] || "");
const workspacePath = readWorkspacePath(hashDir);
if (options.projectPath && workspacePath !== options.projectPath) continue;
- const sessionId = `copilot-${basename(match, ".json")}`;
+ const sessionId = `copilot-${basename(normalizedMatch, ".json")}`;
sessions.push({ sessionId, filePath, workspacePath });
}
} catch {
@@ -236,75 +237,12 @@ export async function discoverCopilotSessions(options: {
export async function ingestCopilot(
options: IngestOptions & { projectPath?: string; storageRoots?: string[] } = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertProject, upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCopilotSessions({
- storageRoots: options.storageRoots,
+ const { ingest } = await import("./index");
+ return ingest(db, "copilot", {
projectPath: options.projectPath,
+ storageRoots: options.storageRoots,
+ onProgress,
});
-
- const result: IngestResult = {
- agent: "copilot",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- if (sessions.length === 0) {
- const roots = options.storageRoots ?? resolveVSCodeStorageRoots();
- if (roots.length === 0) {
- result.errors.push(
- "VS Code workspaceStorage not found. Is VS Code installed? " +
- "Set COPILOT_STORAGE_DIR to override the path."
- );
- }
- return result;
- }
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const content = await Bun.file(session.filePath).text();
- const messages = parseCopilotJson(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const workspacePath = session.workspacePath || PROJECTS_ROOT;
- const projectId = deriveProjectId(workspacePath);
- upsertProject(db, projectId, workspacePath);
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "Copilot Chat";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, { title });
- }
-
- upsertSessionMeta(db, session.sessionId, "copilot", projectId);
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(`Ingested ${session.sessionId} (${messages.length} messages) — project: ${projectId}`);
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
}
diff --git a/src/ingest/cursor.ts b/src/ingest/cursor.ts
index 5a824c5..92a4c79 100644
--- a/src/ingest/cursor.ts
+++ b/src/ingest/cursor.ts
@@ -5,6 +5,7 @@
* and normalizes to QMD's addMessage() format.
*/
+import { join } from "path";
import { addMessage } from "../qmd";
import type { ParsedMessage, IngestResult, IngestOptions } from "./index";
@@ -83,10 +84,11 @@ export async function discoverCursorSessions(
try {
const glob = new Bun.Glob("**/*.json");
for await (const match of glob.scan({ cwd: cursorDir, absolute: false })) {
- const sessionId = `cursor-${match.replace(/\.json$/, "").replace(/\//g, "-")}`;
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const sessionId = `cursor-${normalizedMatch.replace(/\.json$/, "").replaceAll("/", "-")}`;
sessions.push({
sessionId,
- filePath: `${cursorDir}/${match}`,
+ filePath: join(cursorDir, normalizedMatch),
projectPath,
});
}
@@ -103,66 +105,12 @@ export async function discoverCursorSessions(
export async function ingestCursor(
options: IngestOptions & { projectPath?: string } = {}
): Promise {
- const { db, existingSessionIds, onProgress, projectPath } = options;
+ const { db, onProgress, projectPath } = options;
if (!db) throw new Error("Database required for ingestion");
if (!projectPath) throw new Error("projectPath required for Cursor ingestion");
-
- const { upsertProject, upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCursorSessions(projectPath);
- const result: IngestResult = {
- agent: "cursor",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- // Derive project ID from path
- const projectId = projectPath.split("/").filter(Boolean).pop() || "unknown";
- upsertProject(db, projectId, projectPath);
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const messages = parseCursorJson(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, {
- title,
- });
- }
-
- upsertSessionMeta(db, session.sessionId, "cursor", projectId);
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${messages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "cursor", {
+ projectPath,
+ onProgress,
+ });
}
diff --git a/src/ingest/generic.ts b/src/ingest/generic.ts
index b7cb1bf..7a4771a 100644
--- a/src/ingest/generic.ts
+++ b/src/ingest/generic.ts
@@ -5,7 +5,6 @@
* Wraps QMD's importTranscript() with Smriti metadata.
*/
-import { importTranscript } from "../qmd";
import type { IngestResult, IngestOptions } from "./index";
export type GenericIngestOptions = IngestOptions & {
@@ -23,53 +22,14 @@ export type GenericIngestOptions = IngestOptions & {
export async function ingestGeneric(
options: GenericIngestOptions
): Promise {
- const { db, filePath, format, agentName, title, sessionId, projectId } =
- options;
+ const { db, filePath, format, agentName, title, sessionId, projectId } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertSessionMeta, upsertProject } = await import("../db");
-
- const result: IngestResult = {
- agent: agentName || "generic",
- sessionsFound: 1,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- try {
- const file = Bun.file(filePath);
- if (!(await file.exists())) {
- result.errors.push(`File not found: ${filePath}`);
- return result;
- }
-
- const content = await file.text();
- const imported = await importTranscript(db, content, {
- title,
- format: format || "chat",
- sessionId,
- });
-
- // If a project was specified, register it
- if (projectId) {
- upsertProject(db, projectId);
- }
-
- // Attach metadata
- upsertSessionMeta(
- db,
- imported.sessionId,
- agentName || "generic",
- projectId
- );
-
- result.sessionsIngested = 1;
- result.messagesIngested = imported.messageCount;
- } catch (err: any) {
- result.errors.push(err.message);
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "generic", {
+ filePath,
+ format,
+ title,
+ sessionId,
+ projectId,
+ });
}
diff --git a/src/ingest/index.ts b/src/ingest/index.ts
index e6f588a..21baba1 100644
--- a/src/ingest/index.ts
+++ b/src/ingest/index.ts
@@ -6,6 +6,9 @@
*/
import type { Database } from "bun:sqlite";
+import type { ParsedMessage, StructuredMessage } from "./types";
+import { resolveSession } from "./session-resolver";
+import { storeBlocks, storeCosts, storeMessage, storeSession } from "./store-gateway";
// =============================================================================
// Types — re-export from types.ts
@@ -29,6 +32,153 @@ export type IngestOptions = {
logsDir?: string;
};
+function isStructuredMessage(msg: ParsedMessage | StructuredMessage): msg is StructuredMessage {
+ return typeof (msg as StructuredMessage).plainText === "string" &&
+ Array.isArray((msg as StructuredMessage).blocks);
+}
+
+async function ingestParsedSessions(
+ db: Database,
+ agentId: string,
+ sessions: Array<{ sessionId: string; filePath: string; projectDir?: string }>,
+ parser: (sessionPath: string, sessionId: string) => Promise<{
+ session: { id: string; title: string; created_at: string };
+ messages: Array;
+ }>,
+ options: {
+ existingSessionIds: Set;
+ onProgress?: (msg: string) => void;
+ explicitProjectId?: string;
+ explicitProjectPath?: string;
+ incremental?: boolean;
+ } = {
+ existingSessionIds: new Set(),
+ }
+): Promise {
+ const result: IngestResult = {
+ agent: agentId,
+ sessionsFound: sessions.length,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: [],
+ };
+ const useSessionTxn = process.env.SMRITI_INGEST_SESSION_TXN !== "0";
+
+ for (const session of sessions) {
+ if (!options.incremental && options.existingSessionIds.has(session.sessionId)) {
+ result.skipped++;
+ continue;
+ }
+
+ try {
+ const parsed = await parser(session.filePath, session.sessionId);
+ if (parsed.messages.length === 0) {
+ result.skipped++;
+ continue;
+ }
+
+ const resolved = resolveSession({
+ db,
+ sessionId: session.sessionId,
+ agentId,
+ projectDir: session.projectDir,
+ explicitProjectId: options.explicitProjectId,
+ explicitProjectPath: options.explicitProjectPath,
+ });
+
+ const messagesToIngest = options.incremental
+ ? parsed.messages.slice(resolved.existingMessageCount)
+ : parsed.messages;
+
+ if (messagesToIngest.length === 0) {
+ result.skipped++;
+ continue;
+ }
+
+ if (useSessionTxn) db.exec("BEGIN IMMEDIATE");
+ try {
+ for (const msg of messagesToIngest) {
+ const content = isStructuredMessage(msg) ? msg.plainText || "(structured content)" : msg.content;
+ const messageOptions = isStructuredMessage(msg)
+ ? {
+ title: parsed.session.title,
+ metadata: {
+ ...msg.metadata,
+ blocks: msg.blocks,
+ },
+ }
+ : { title: parsed.session.title };
+
+ const stored = await storeMessage(db, session.sessionId, msg.role, content, messageOptions);
+ if (!stored.success) {
+ throw new Error(stored.error || "Failed to store message");
+ }
+
+ if (isStructuredMessage(msg)) {
+ storeBlocks(
+ db,
+ stored.messageId,
+ session.sessionId,
+ resolved.projectId,
+ msg.blocks,
+ msg.timestamp || new Date().toISOString()
+ );
+
+ if (msg.metadata.tokenUsage) {
+ const u = msg.metadata.tokenUsage;
+ storeCosts(
+ db,
+ session.sessionId,
+ msg.metadata.model || null,
+ u.input,
+ u.output,
+ (u.cacheCreate || 0) + (u.cacheRead || 0),
+ 0
+ );
+ }
+
+ for (const block of msg.blocks) {
+ if (
+ block.type === "system_event" &&
+ block.eventType === "turn_duration" &&
+ typeof block.data.durationMs === "number"
+ ) {
+ storeCosts(db, session.sessionId, null, 0, 0, 0, block.data.durationMs as number);
+ }
+ }
+ }
+ }
+
+ storeSession(
+ db,
+ session.sessionId,
+ agentId,
+ resolved.projectId,
+ resolved.projectPath
+ );
+ if (useSessionTxn) db.exec("COMMIT");
+ } catch (err) {
+ if (useSessionTxn) db.exec("ROLLBACK");
+ throw err;
+ }
+
+ result.sessionsIngested++;
+ result.messagesIngested += messagesToIngest.length;
+ if (options.onProgress) {
+ options.onProgress(
+ `Ingested ${session.sessionId} (${messagesToIngest.length} messages)` +
+ (resolved.projectId ? ` - project: ${resolved.projectId}` : "")
+ );
+ }
+ } catch (err: any) {
+ result.errors.push(`${session.sessionId}: ${err.message}`);
+ }
+ }
+
+ return result;
+}
+
// =============================================================================
// Orchestrator
// =============================================================================
@@ -54,6 +204,7 @@ export async function ingest(
onProgress?: (msg: string) => void;
logsDir?: string;
projectPath?: string;
+ storageRoots?: string[];
filePath?: string;
format?: "chat" | "jsonl";
title?: string;
@@ -72,37 +223,124 @@ export async function ingest(
switch (agent) {
case "claude":
case "claude-code": {
- const { ingestClaude } = await import("./claude");
- return ingestClaude(baseOptions);
+ const { discoverClaudeSessions } = await import("./claude");
+ const { parseClaude } = await import("./parsers");
+ const discovered = await discoverClaudeSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectDir,
+ }));
+ return ingestParsedSessions(db, "claude-code", sessions, parseClaude, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ incremental: true,
+ });
}
case "codex": {
- const { ingestCodex } = await import("./codex");
- return ingestCodex(baseOptions);
+ const { discoverCodexSessions } = await import("./codex");
+ const { parseCodex } = await import("./parsers");
+ const discovered = await discoverCodexSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ }));
+ return ingestParsedSessions(db, "codex", sessions, parseCodex, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "cursor": {
- const { ingestCursor } = await import("./cursor");
- return ingestCursor({ ...baseOptions, projectPath: options.projectPath });
+ if (!options.projectPath) {
+ return {
+ agent: "cursor",
+ sessionsFound: 0,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: ["projectPath required for Cursor ingestion"],
+ };
+ }
+ const { discoverCursorSessions } = await import("./cursor");
+ const { parseCursor } = await import("./parsers");
+ const discovered = await discoverCursorSessions(options.projectPath);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectPath,
+ }));
+ return ingestParsedSessions(db, "cursor", sessions, parseCursor, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "cline": {
- const { ingestCline } = await import("./cline");
- return ingestCline(baseOptions);
+ const { discoverClineSessions } = await import("./cline");
+ const { parseCline } = await import("./parsers");
+ const discovered = await discoverClineSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectDir,
+ }));
+ return ingestParsedSessions(db, "cline", sessions, parseCline, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "copilot": {
- const { ingestCopilot } = await import("./copilot");
- return ingestCopilot({ ...baseOptions, projectPath: options.projectPath });
+ const { discoverCopilotSessions } = await import("./copilot");
+ const { parseCopilot } = await import("./parsers");
+ const discovered = await discoverCopilotSessions({
+ projectPath: options.projectPath,
+ storageRoots: options.storageRoots,
+ });
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.workspacePath || undefined,
+ }));
+ return ingestParsedSessions(db, "copilot", sessions, parseCopilot, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "file":
case "generic": {
- const { ingestGeneric } = await import("./generic");
- return ingestGeneric({
- ...baseOptions,
- filePath: options.filePath || "",
- format: options.format,
- title: options.title,
- sessionId: options.sessionId,
- projectId: options.projectId,
- agentName: agent === "file" ? "generic" : agent,
- });
+ if (!options.filePath) {
+ return {
+ agent: agent === "file" ? "generic" : agent,
+ sessionsFound: 0,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: ["File path is required for generic ingestion"],
+ };
+ }
+ const { parseGeneric } = await import("./parsers");
+ const sessionId = options.sessionId || `generic-${crypto.randomUUID().slice(0, 8)}`;
+ const parsed = await parseGeneric(options.filePath, sessionId, options.format || "chat");
+ if (options.title) {
+ parsed.session.title = options.title;
+ }
+ const result = await ingestParsedSessions(
+ db,
+ agent === "file" ? "generic" : agent,
+ [{ sessionId, filePath: options.filePath, projectDir: options.projectPath }],
+ async () => parsed,
+ {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ explicitProjectPath: options.projectPath,
+ }
+ );
+ return result;
}
default:
return {
diff --git a/src/ingest/parsers/claude.ts b/src/ingest/parsers/claude.ts
new file mode 100644
index 0000000..f3de527
--- /dev/null
+++ b/src/ingest/parsers/claude.ts
@@ -0,0 +1,48 @@
+import { parseClaudeJsonlStructured } from "../claude";
+import type { ParsedSession } from "./types";
+
+export async function parseClaude(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseClaudeJsonlStructured(content);
+
+ const firstUser = messages.find((m) => m.role === "user");
+ const title = firstUser
+ ? firstUser.plainText.slice(0, 100).replace(/\n/g, " ")
+ : "";
+
+ let totalTokens = 0;
+ let totalDurationMs = 0;
+
+ for (const msg of messages) {
+ const u = msg.metadata.tokenUsage;
+ if (u) {
+ totalTokens += u.input + u.output + (u.cacheCreate || 0) + (u.cacheRead || 0);
+ }
+
+ for (const block of msg.blocks) {
+ if (
+ block.type === "system_event" &&
+ block.eventType === "turn_duration" &&
+ typeof block.data.durationMs === "number"
+ ) {
+ totalDurationMs += block.data.durationMs as number;
+ }
+ }
+ }
+
+ return {
+ session: {
+ id: sessionId,
+ title,
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {
+ total_tokens: totalTokens || undefined,
+ total_duration_ms: totalDurationMs || undefined,
+ },
+ };
+}
diff --git a/src/ingest/parsers/cline.ts b/src/ingest/parsers/cline.ts
new file mode 100644
index 0000000..7490669
--- /dev/null
+++ b/src/ingest/parsers/cline.ts
@@ -0,0 +1,150 @@
+import type { StructuredMessage, MessageMetadata, MessageBlock } from "../types";
+import type { ParsedSession } from "./types";
+
+type ClineTask = {
+ id: string;
+ parentId?: string;
+ name: string;
+ timestamp: string;
+ cwd?: string;
+ gitBranch?: string;
+ history: Array<{
+ ts: string;
+ type: "say" | "ask" | "tool" | "tool_code" | "tool_result" | "command" | "command_output" | "system_event" | "error";
+ text?: string;
+ question?: string;
+ options?: string;
+ toolId?: string;
+ toolName?: string;
+ input?: Record;
+ output?: string;
+ success?: boolean;
+ error?: string;
+ durationMs?: number;
+ command?: string;
+ cwd?: string;
+ isGit?: boolean;
+ exitCode?: number;
+ }>;
+};
+
+function parseTask(task: ClineTask): StructuredMessage[] {
+ const messages: StructuredMessage[] = [];
+ let sequence = 0;
+
+ for (const entry of task.history) {
+ const metadata: MessageMetadata = {};
+ if (task.cwd) metadata.cwd = task.cwd;
+ if (task.gitBranch) metadata.gitBranch = task.gitBranch;
+ if (task.parentId) metadata.parentId = task.parentId;
+
+ let role: StructuredMessage["role"] = "assistant";
+ let plainText = "";
+ let blocks: MessageBlock[] = [];
+
+ switch (entry.type) {
+ case "say":
+ blocks = [{ type: "text", text: entry.text || "" }];
+ plainText = entry.text || "";
+ role = "assistant";
+ break;
+ case "ask":
+ blocks = [{ type: "text", text: `User asked: ${entry.question || ""} (Options: ${entry.options || ""})` }];
+ plainText = `User asked: ${entry.question || ""}`;
+ role = "user";
+ break;
+ case "tool":
+ case "tool_code":
+ blocks = [{
+ type: "tool_call",
+ toolId: entry.toolId || "unknown_tool",
+ toolName: entry.toolName || "Unknown Tool",
+ input: entry.input || {},
+ description: entry.text,
+ }];
+ plainText = `Tool Call: ${entry.toolName || "Unknown Tool"}`;
+ role = "assistant";
+ break;
+ case "tool_result":
+ blocks = [{
+ type: "tool_result",
+ toolId: entry.toolId || "unknown_tool",
+ success: entry.success ?? true,
+ output: entry.output || "",
+ error: entry.error,
+ durationMs: entry.durationMs,
+ }];
+ plainText = `Tool Result: ${entry.output || entry.error || ""}`;
+ role = "tool";
+ break;
+ case "command":
+ blocks = [{
+ type: "command",
+ command: entry.command || "",
+ cwd: entry.cwd || task.cwd,
+ isGit: entry.isGit ?? false,
+ description: entry.text,
+ }];
+ plainText = `Command: ${entry.command || ""}`;
+ role = "assistant";
+ break;
+ case "command_output":
+ blocks = [{
+ type: "command",
+ command: entry.command || "",
+ stdout: entry.output,
+ stderr: entry.error,
+ exitCode: entry.exitCode,
+ isGit: entry.isGit ?? false,
+ }];
+ plainText = `Command Output: ${entry.output || entry.error || ""}`;
+ role = "tool";
+ break;
+ case "system_event":
+ blocks = [{ type: "system_event", eventType: "turn_duration", data: { durationMs: entry.durationMs } }];
+ plainText = `System Event: ${entry.durationMs || 0}ms`;
+ role = "system";
+ break;
+ case "error":
+ blocks = [{ type: "error", errorType: "tool_failure", message: entry.error || "Unknown error" }];
+ plainText = `Error: ${entry.error || "Unknown error"}`;
+ role = "system";
+ break;
+ }
+
+ messages.push({
+ id: `${task.id}-${sequence}`,
+ sessionId: task.id,
+ sequence,
+ timestamp: entry.ts || new Date().toISOString(),
+ role,
+ agent: "cline",
+ blocks,
+ metadata,
+ plainText,
+ });
+
+ sequence++;
+ }
+
+ return messages;
+}
+
+export async function parseCline(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const task = JSON.parse(content) as ClineTask;
+ const messages = parseTask(task);
+
+ return {
+ session: {
+ id: sessionId,
+ title: task.name || messages[0]?.plainText.slice(0, 100).replace(/\n/g, " ") || "",
+ created_at: task.timestamp || messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/codex.ts b/src/ingest/parsers/codex.ts
new file mode 100644
index 0000000..e2879b2
--- /dev/null
+++ b/src/ingest/parsers/codex.ts
@@ -0,0 +1,21 @@
+import { parseCodexJsonl } from "../codex";
+import type { ParsedSession } from "./types";
+
+export async function parseCodex(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCodexJsonl(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/copilot.ts b/src/ingest/parsers/copilot.ts
new file mode 100644
index 0000000..5ad8f20
--- /dev/null
+++ b/src/ingest/parsers/copilot.ts
@@ -0,0 +1,21 @@
+import { parseCopilotJson } from "../copilot";
+import type { ParsedSession } from "./types";
+
+export async function parseCopilot(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCopilotJson(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "Copilot Chat",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/cursor.ts b/src/ingest/parsers/cursor.ts
new file mode 100644
index 0000000..b722bb8
--- /dev/null
+++ b/src/ingest/parsers/cursor.ts
@@ -0,0 +1,21 @@
+import { parseCursorJson } from "../cursor";
+import type { ParsedSession } from "./types";
+
+export async function parseCursor(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCursorJson(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/generic.ts b/src/ingest/parsers/generic.ts
new file mode 100644
index 0000000..06cd668
--- /dev/null
+++ b/src/ingest/parsers/generic.ts
@@ -0,0 +1,44 @@
+import type { ParsedSession } from "./types";
+
+export async function parseGeneric(
+ sessionPath: string,
+ sessionId: string,
+ format: "chat" | "jsonl" = "chat"
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages: Array<{ role: string; content: string; timestamp?: string }> = [];
+
+ if (format === "jsonl") {
+ for (const line of content.split("\n").filter((l) => l.trim())) {
+ const parsed = JSON.parse(line);
+ messages.push({ role: parsed.role || "user", content: parsed.content || "" });
+ }
+ } else {
+ const blocks = content.split(/\n\n+/);
+ for (const block of blocks) {
+ const trimmed = block.trim();
+ if (!trimmed) continue;
+ const colonIdx = trimmed.indexOf(":");
+ if (colonIdx > 0 && colonIdx < 20) {
+ messages.push({
+ role: trimmed.slice(0, colonIdx).trim().toLowerCase(),
+ content: trimmed.slice(colonIdx + 1).trim(),
+ });
+ } else {
+ messages.push({ role: "user", content: trimmed });
+ }
+ }
+ }
+
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/index.ts b/src/ingest/parsers/index.ts
new file mode 100644
index 0000000..a0e8267
--- /dev/null
+++ b/src/ingest/parsers/index.ts
@@ -0,0 +1,7 @@
+export { parseClaude } from "./claude";
+export { parseCodex } from "./codex";
+export { parseCursor } from "./cursor";
+export { parseCline } from "./cline";
+export { parseCopilot } from "./copilot";
+export { parseGeneric } from "./generic";
+export type { ParsedSession } from "./types";
diff --git a/src/ingest/parsers/types.ts b/src/ingest/parsers/types.ts
new file mode 100644
index 0000000..615fc67
--- /dev/null
+++ b/src/ingest/parsers/types.ts
@@ -0,0 +1,14 @@
+import type { ParsedMessage, StructuredMessage } from "../types";
+
+export type ParsedSession = {
+ session: {
+ id: string;
+ title: string;
+ created_at: string;
+ };
+ messages: Array;
+ metadata: {
+ total_tokens?: number;
+ total_duration_ms?: number;
+ };
+};
diff --git a/src/ingest/session-resolver.ts b/src/ingest/session-resolver.ts
new file mode 100644
index 0000000..1facd25
--- /dev/null
+++ b/src/ingest/session-resolver.ts
@@ -0,0 +1,88 @@
+import type { Database } from "bun:sqlite";
+import { basename } from "path";
+import { deriveProjectId as deriveClaudeProjectId, deriveProjectPath as deriveClaudeProjectPath } from "./claude";
+import { deriveProjectId as deriveClineProjectId, deriveProjectPath as deriveClineProjectPath } from "./cline";
+import { deriveProjectId as deriveCopilotProjectId } from "./copilot";
+
+export type ResolveSessionInput = {
+ db: Database;
+ sessionId: string;
+ agentId: string;
+ projectDir?: string;
+ explicitProjectId?: string;
+ explicitProjectPath?: string;
+};
+
+export type ResolvedSession = {
+ sessionId: string;
+ projectId: string | null;
+ projectPath: string | null;
+ isNew: boolean;
+ existingMessageCount: number;
+};
+
+function deriveForAgent(agentId: string, projectDir?: string): { projectId: string | null; projectPath: string | null } {
+ if (!projectDir) return { projectId: null, projectPath: null };
+
+ switch (agentId) {
+ case "claude":
+ case "claude-code":
+ return {
+ projectId: deriveClaudeProjectId(projectDir),
+ projectPath: deriveClaudeProjectPath(projectDir),
+ };
+ case "cline":
+ return {
+ projectId: deriveClineProjectId(projectDir),
+ projectPath: deriveClineProjectPath(projectDir),
+ };
+ case "copilot":
+ return {
+ projectId: deriveCopilotProjectId(projectDir),
+ projectPath: projectDir,
+ };
+ case "cursor":
+ return {
+ projectId: basename(projectDir) || "unknown",
+ projectPath: projectDir,
+ };
+ case "codex":
+ return { projectId: null, projectPath: null };
+ case "file":
+ case "generic":
+ return {
+ projectId: basename(projectDir) || "unknown",
+ projectPath: projectDir,
+ };
+ default:
+ return {
+ projectId: basename(projectDir) || null,
+ projectPath: projectDir,
+ };
+ }
+}
+
+export function resolveSession(input: ResolveSessionInput): ResolvedSession {
+ const { db, sessionId, agentId, explicitProjectId, explicitProjectPath } = input;
+
+ const derived = deriveForAgent(agentId, input.projectDir);
+ const projectId = explicitProjectId || derived.projectId;
+ const projectPath = explicitProjectPath || derived.projectPath;
+
+ const existingMessageCount =
+ (db
+ .prepare(`SELECT COUNT(*) as count FROM memory_messages WHERE session_id = ?`)
+ .get(sessionId) as { count: number } | null)?.count ?? 0;
+
+ const existingSession = db
+ .prepare(`SELECT 1 as yes FROM smriti_session_meta WHERE session_id = ?`)
+ .get(sessionId) as { yes: number } | null;
+
+ return {
+ sessionId,
+ projectId,
+ projectPath,
+ existingMessageCount,
+ isNew: !existingSession,
+ };
+}
diff --git a/src/ingest/store-gateway.ts b/src/ingest/store-gateway.ts
new file mode 100644
index 0000000..195199d
--- /dev/null
+++ b/src/ingest/store-gateway.ts
@@ -0,0 +1,127 @@
+import type { Database } from "bun:sqlite";
+import { addMessage } from "../qmd";
+import {
+ insertCommand,
+ insertError,
+ insertFileOperation,
+ insertGitOperation,
+ insertToolUsage,
+ upsertProject,
+ upsertSessionCosts,
+ upsertSessionMeta,
+} from "../db";
+import type { MessageBlock } from "./types";
+
+export type StoreMessageResult = {
+ messageId: number;
+ success: boolean;
+ error?: string;
+};
+
+export async function storeMessage(
+ db: Database,
+ sessionId: string,
+ role: string,
+ content: string,
+ options?: { title?: string; metadata?: Record }
+): Promise {
+ try {
+ const stored = await addMessage(db, sessionId, role, content, options);
+ return { messageId: stored.id, success: true };
+ } catch (err: any) {
+ return { messageId: -1, success: false, error: err.message };
+ }
+}
+
+export function storeBlocks(
+ db: Database,
+ messageId: number,
+ sessionId: string,
+ projectId: string | null,
+ blocks: MessageBlock[],
+ createdAt: string
+): void {
+ for (const block of blocks) {
+ switch (block.type) {
+ case "tool_call":
+ insertToolUsage(
+ db,
+ messageId,
+ sessionId,
+ block.toolName,
+ block.description || null,
+ true,
+ null,
+ createdAt
+ );
+ break;
+ case "file_op":
+ insertFileOperation(
+ db,
+ messageId,
+ sessionId,
+ block.operation,
+ block.path,
+ projectId,
+ createdAt
+ );
+ break;
+ case "command":
+ insertCommand(
+ db,
+ messageId,
+ sessionId,
+ block.command,
+ block.exitCode ?? null,
+ block.cwd ?? null,
+ block.isGit,
+ createdAt
+ );
+ break;
+ case "git":
+ insertGitOperation(
+ db,
+ messageId,
+ sessionId,
+ block.operation,
+ block.branch ?? null,
+ block.prUrl ?? null,
+ block.prNumber ?? null,
+ block.message ? JSON.stringify({ message: block.message }) : null,
+ createdAt
+ );
+ break;
+ case "error":
+ insertError(db, messageId, sessionId, block.errorType, block.message, createdAt);
+ break;
+ }
+ }
+}
+
+export function storeSession(
+ db: Database,
+ sessionId: string,
+ agentId: string,
+ projectId: string | null,
+ projectPath?: string | null
+): void {
+ if (projectId) {
+ upsertProject(db, projectId, projectPath || undefined);
+ }
+ const agentExists = db
+ .prepare(`SELECT 1 as yes FROM smriti_agents WHERE id = ?`)
+ .get(agentId) as { yes: number } | null;
+ upsertSessionMeta(db, sessionId, agentExists ? agentId : undefined, projectId || undefined);
+}
+
+export function storeCosts(
+ db: Database,
+ sessionId: string,
+ model: string | null,
+ inputTokens: number,
+ outputTokens: number,
+ cacheTokens: number,
+ durationMs: number
+): void {
+ upsertSessionCosts(db, sessionId, model, inputTokens, outputTokens, cacheTokens, durationMs);
+}
diff --git a/src/qmd.ts b/src/qmd.ts
index 1d7962a..ccfa4cf 100644
--- a/src/qmd.ts
+++ b/src/qmd.ts
@@ -17,8 +17,8 @@ export {
importTranscript,
initializeMemoryTables,
createSession,
-} from "qmd/src/memory";
+} from "../qmd/src/memory";
-export { hashContent } from "qmd/src/store";
+export { hashContent } from "../qmd/src/store";
-export { ollamaRecall } from "qmd/src/ollama";
+export { ollamaRecall } from "../qmd/src/ollama";
diff --git a/streamed-humming-curry.md b/streamed-humming-curry.md
new file mode 100644
index 0000000..caa707b
--- /dev/null
+++ b/streamed-humming-curry.md
@@ -0,0 +1,1320 @@
+# Ingest Architecture Refactoring: Separation of Concerns
+
+## Context
+
+**Problem**: The current ingest system violates separation of concerns. Parsers and orchestrators handle:
+- Session discovery & project detection
+- Message parsing & block extraction
+- SQLite persistence + side-car table population
+- Elasticsearch parallel writes
+- Token accumulation & cost aggregation
+- Session metadata updates
+- Incremental ingest logic
+
+All mixed together in 600+ line functions.
+
+**Result**: 7 major coupling points making the code hard to test, extend, and maintain.
+
+**Solution**: Refactor into clean layers where **each parser ONLY extracts raw messages** and **persistence happens separately**.
+
+---
+
+## New Architecture: 4 Clean Layers
+
+```
+Layer 1: PARSERS (agent-specific extraction only)
+├── src/ingest/parsers/claude.ts
+├── src/ingest/parsers/codex.ts
+├── src/ingest/parsers/cursor.ts
+└── src/ingest/parsers/cline.ts
+ Output: { session, messages[], blocks[], metadata }
+
+Layer 2: SESSION RESOLVER (project detection, incremental logic)
+├── src/ingest/session-resolver.ts
+ Input: { session, metadata, projectDir }
+ Output: { sessionId, projectId, projectPath, isNew, existing_count }
+
+Layer 3: MESSAGE STORE GATEWAY (unified SQLite + ES writes)
+├── src/ingest/store-gateway.ts
+ - storeMessage(sessionId, role, content, blocks, metadata)
+ - storeSession(sessionId, projectId, title, metadata)
+ - storeBlocks(messageId, blocks)
+ - storeCosts(sessionId, tokens, duration)
+ Output: { messageId, success, errors }
+
+Layer 4: INGEST ORCHESTRATOR (composition layer)
+├── src/ingest/index.ts (refactored)
+ - Load parser
+ - Resolve sessions
+ - Store all messages via gateway
+ - Aggregate costs
+ - Report results
+```
+
+**Key principle**: Each layer can be tested independently. Parsers don't know about databases. Store gateway doesn't know about parsing.
+
+---
+
+## Implementation Plan
+
+### Phase 1: Extract Parsers into Pure Functions (No DB Knowledge)
+
+#### 1.1 Refactor `src/ingest/parsers/claude.ts`
+
+**Goal**: Claude parser returns ONLY parsed messages, session info. Zero database calls.
+
+**Current problem (lines 389-625)**:
+- 237 lines doing: discovery → parsing → DB writes → ES writes → block extraction → cost aggregation
+- Couples parser output to SQLite schema
+
+**New `ingestClaudeSessions()` signature**:
+```typescript
+export async function parseClaude(
+ sessionPath: string,
+ projectDir: string
+): Promise<{
+ session: { id: string; title: string; created_at: string };
+ messages: StructuredMessage[];
+ metadata: { total_tokens?: number; total_duration_ms?: number };
+}>;
+```
+
+**What stays in parser**:
+- Session discovery: find .jsonl files ✓
+- Title derivation: extract from first user message ✓
+- Block extraction: analyze content for tool_calls, file_ops, git_ops, errors ✓
+- Structured message creation ✓
+
+**What LEAVES parser**:
+- ❌ `addMessage(db, ...)` calls → return messages array
+- ❌ `ingestMessageToES(...)` calls → let caller decide
+- ❌ `insertToolUsage()`, `insertFileOperation()`, etc. → return blocks separately
+- ❌ `upsertSessionCosts()` → return metadata with token counts
+- ❌ `upsertSessionMeta()` → let caller decide
+
+**Implementation**:
+- Rename current `ingestClaude()` → `parseClaude()`
+- Remove all DB calls (lines 454-592)
+- Return `ParsedSession` interface with messages + blocks + metadata
+- Keep block extraction logic (needed for structured output)
+
+**Files to modify**:
+- `src/ingest/parsers/claude.ts` - Extract, no DB calls
+
+**Lines deleted**: ~180 lines of DB I/O, ES calls, cost aggregation
+**Lines added**: ~50 lines (return ParsedSession interface)
+**Net**: Simpler, testable parser
+
+**Effort**: 1.5 hours
+
+---
+
+#### 1.2 Refactor Other Parsers (codex, cursor, cline, copilot)
+
+**Same refactoring for all**:
+- `src/ingest/parsers/codex.ts` - Remove DB, ES calls (40 lines deleted)
+- `src/ingest/parsers/cursor.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/cline.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/copilot.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/generic.ts` - Remove DB, ES calls (30 lines deleted)
+
+All return same `ParsedSession` interface for consistency.
+
+**Effort**: 2 hours (5 parsers × 24 min each)
+
+**Total Phase 1**: 3.5 hours
+
+---
+
+### Phase 2: Create Session Resolver Layer
+
+#### 2.1 New `src/ingest/session-resolver.ts`
+
+**Purpose**: Take parsed session + project info, resolve database state
+
+**Responsibilities**:
+- Derive project_id from projectDir (using existing `deriveProjectId()`)
+- Derive project_path from projectDir (using existing `deriveProjectPath()`)
+- Check if session already exists in database
+- Count existing messages (for incremental ingest)
+- Determine if this is a new session or append
+
+**Function signature**:
+```typescript
+export async function resolveSession(
+ db: Database,
+ sessionId: string,
+ projectDir: string,
+ metadata: { total_tokens?: number; total_duration_ms?: number }
+): Promise<{
+ sessionId: string;
+ projectId: string;
+ projectPath: string;
+ isNew: boolean;
+ existingMessageCount: number;
+}>;
+```
+
+**Uses existing functions**:
+- `deriveProjectId()` from `src/ingest/claude.ts` (already exists)
+- `deriveProjectPath()` from `src/ingest/claude.ts` (already exists)
+- DB query: `SELECT COUNT(*) FROM memory_messages WHERE session_id = ?`
+- DB query: `SELECT 1 FROM smriti_session_meta WHERE session_id = ?`
+
+**New file**:
+- `src/ingest/session-resolver.ts` (~80 lines)
+
+**Effort**: 1 hour
+
+---
+
+### Phase 3: Create Store Gateway Layer
+
+#### 3.1 New `src/ingest/store-gateway.ts`
+
+**Purpose**: Unified interface for all database writes (SQLite + ES)
+
+**Four functions**:
+
+**Function 1: `storeMessage()`**
+```typescript
+export async function storeMessage(
+ db: Database,
+ sessionId: string,
+ role: string,
+ content: string,
+ blocks: Block[],
+ metadata?: Record
+): Promise<{ messageId: string; success: boolean; error?: string }>;
+```
+- Calls QMD's `addMessage(db, sessionId, role, content, metadata)`
+- Captures returned messageId
+- Calls `ingestMessageToES()` in parallel (fire & forget)
+- Returns messageId + success status
+
+**Function 2: `storeBlocks()`**
+```typescript
+export async function storeBlocks(
+ db: Database,
+ messageId: string,
+ sessionId: string,
+ blocks: Block[]
+): Promise;
+```
+- Iterates blocks and calls existing DB functions:
+ - `insertToolUsage()` for tool_call blocks
+ - `insertFileOperation()` for file_op blocks
+ - `insertCommand()` for command blocks
+ - `insertGitOperation()` for git blocks
+ - `insertError()` for error blocks
+- Centralizes all block storage logic
+
+**Function 3: `storeSession()`**
+```typescript
+export async function storeSession(
+ db: Database,
+ sessionId: string,
+ agentId: string,
+ projectId: string,
+ title: string,
+ metadata?: { total_tokens?: number; total_duration_ms?: number }
+): Promise;
+```
+- Calls `upsertSessionMeta()` (existing function)
+- Calls `ingestSessionToES()` in parallel
+- Ensures session metadata is stored once per session (not per message)
+
+**Function 4: `storeCosts()`**
+```typescript
+export async function storeCosts(
+ db: Database,
+ sessionId: string,
+ tokens: number,
+ duration_ms: number
+): Promise;
+```
+- Calls `upsertSessionCosts()` (existing function)
+- Aggregates token spend and duration at session level
+- Called once after all messages processed
+
+**New file**:
+- `src/ingest/store-gateway.ts` (~150 lines, wraps existing DB functions)
+
+**Design benefit**: All DB logic is now in ONE place. Easy to add new persistence layers (Postgres, etc.) without changing parsers.
+
+**Effort**: 1.5 hours
+
+---
+
+### Phase 4: Refactor Main Orchestrator
+
+#### 4.1 Refactor `src/ingest/index.ts`
+
+**Current problem (lines 50-117)**:
+- `ingest()` function mixes: discovery → parsing → orchestration → result aggregation
+- Uses dynamic imports for each parser (messy)
+- Calls parser's ingestClaude/ingestCodex/etc directly
+
+**New flow**:
+```typescript
+export async function ingest(
+ db: Database,
+ agentId: string,
+ options: IngestOptions
+): Promise {
+ // Step 1: Load parser dynamically
+ const parser = await loadParser(agentId);
+
+ // Step 2: Get sessions to process
+ const sessions = await discoverSessions(agentId, parser);
+
+ let ingested = 0;
+ let totalMessages = 0;
+ let errors: string[] = [];
+
+ for (const session of sessions) {
+ try {
+ // Step 3: Parse session (NO DB calls)
+ const parsed = await parser.parse(session.path, session.projectDir);
+
+ // Step 4: Resolve session state
+ const resolved = await resolveSession(
+ db,
+ parsed.session.id,
+ session.projectDir,
+ parsed.metadata
+ );
+
+ // Step 5: Store each message through gateway
+ for (const message of parsed.messages) {
+ const result = await storeMessage(
+ db,
+ resolved.sessionId,
+ message.role,
+ message.plainText,
+ message.blocks,
+ { ...message.metadata, title: parsed.session.title }
+ );
+
+ if (result.success && message.blocks.length > 0) {
+ await storeBlocks(
+ db,
+ result.messageId,
+ resolved.sessionId,
+ message.blocks
+ );
+ }
+ }
+
+ // Step 6: Store session metadata (once, after all messages)
+ await storeSession(
+ db,
+ resolved.sessionId,
+ agentId,
+ resolved.projectId,
+ parsed.session.title,
+ parsed.metadata
+ );
+
+ // Step 7: Store aggregated costs (once per session)
+ if (parsed.metadata.total_tokens || parsed.metadata.total_duration_ms) {
+ await storeCosts(
+ db,
+ resolved.sessionId,
+ parsed.metadata.total_tokens || 0,
+ parsed.metadata.total_duration_ms || 0
+ );
+ }
+
+ ingested++;
+ totalMessages += parsed.messages.length;
+ } catch (err) {
+ errors.push(`Session ${session.id}: ${(err as Error).message}`);
+ console.warn(`Ingest failed for ${session.id}`, err);
+ }
+ }
+
+ return {
+ agentId,
+ sessionsIngested: ingested,
+ messagesIngested: totalMessages,
+ errors,
+ };
+}
+```
+
+**Key improvements**:
+- Clear 7-step flow (discover → parse → resolve → store)
+- Each function does ONE thing
+- Error handling is per-session, doesn't break entire run
+- Session metadata written ONCE (not during loop)
+- No DB calls in parsers anymore
+- Easy to add new layers (caching, validation, etc.)
+
+**Files to modify**:
+- `src/ingest/index.ts` - Rewrite orchestration logic (~150 lines)
+
+**Lines kept**: 30 (discovery logic)
+**Lines rewritten**: 70 (main loop)
+**Lines removed**: 30 (dynamic imports, calls to old parser functions)
+**Lines added**: 20 (calls to new gateway functions)
+
+**Effort**: 1.5 hours
+
+---
+
+### Phase 5: Testing & Documentation
+
+#### 5.1 Write Unit Tests
+
+**Test modules**:
+- `test/ingest-parsers.test.ts` - Test each parser returns correct interface
+- `test/session-resolver.test.ts` - Test project derivation, increment logic
+- `test/store-gateway.test.ts` - Test DB writes go to correct tables
+- `test/ingest-orchestrator.test.ts` - Test full flow (mocked DB)
+
+**Each test**:
+- Uses in-memory SQLite (no external deps)
+- Tests happy path + error cases
+- Verifies function outputs match contract
+
+**Effort**: 2 hours
+
+#### 5.2 Update Documentation
+
+**Files to create/modify**:
+- `INGEST_ARCHITECTURE.md` - New doc explaining 4-layer design
+- `src/ingest/README.md` - Parser interface contract
+- Update `CLAUDE.md` - Explain separation of concerns
+
+**Effort**: 1 hour
+
+**Total Phase 5**: 3 hours
+
+---
+
+## Summary of Changes
+
+| Layer | Files | Change | LOC Impact |
+|-------|-------|--------|-----------|
+| Parser | claude.ts, codex.ts, cursor.ts, cline.ts, copilot.ts, generic.ts | Remove DB/ES calls | -400 lines (deleted), +100 lines (return interface) |
+| Resolver | NEW: session-resolver.ts | Extract project detection + incremental logic | +80 lines |
+| Gateway | NEW: store-gateway.ts | Unified DB write interface | +150 lines |
+| Orchestrator | ingest/index.ts | Refactor main loop | -30 lines, +70 lines rewritten |
+| **Net Result** | | Clean layered architecture | +100 net lines, but MUCH cleaner |
+
+---
+
+## Timeline
+
+| Phase | What | Effort | Total |
+|-------|------|--------|-------|
+| 1 | Extract parsers (6 files) | 3.5h | 3.5h |
+| 2 | Create session-resolver | 1h | 4.5h |
+| 3 | Create store-gateway | 1.5h | 6h |
+| 4 | Refactor orchestrator | 1.5h | 7.5h |
+| 5 | Testing + docs | 3h | 10.5h |
+| **Total** | | | **~10 hours** |
+
+---
+
+## Why This Refactoring Matters
+
+### Current Problems (BEFORE)
+- ❌ Parsers have database dependencies
+- ❌ Hard to test parsers in isolation
+- ❌ Hard to add new persistence layers (Postgres, Snowflake, etc.)
+- ❌ Hard to understand the flow (600+ line functions)
+- ❌ Hard to debug (mixing of concerns)
+- ❌ Hard to maintain (7 coupling points)
+
+### New Benefits (AFTER)
+- ✅ Parsers are pure functions (given path → return messages)
+- ✅ Test parsers without database
+- ✅ Add new storage backends by extending store-gateway
+- ✅ Each layer is ~100-150 lines (readable, understandable)
+- ✅ Single place to debug (store-gateway for all writes)
+- ✅ Follows dependency inversion principle (parsers don't depend on DB)
+
+---
+
+## Verification Plan
+
+Before/after each phase:
+
+1. **Parser extraction**:
+ - [ ] Run `smriti ingest claude` → same number of sessions/messages as before
+ - [ ] Check ES indices have data (same count)
+ - [ ] Check SQLite has data (same count)
+
+2. **Full refactoring**:
+ - [ ] Run `smriti ingest all` → ingests all agents without errors
+ - [ ] Run test suite: `bun test` → all tests pass
+ - [ ] Check data consistency: ES count ≈ SQLite count
+ - [ ] Verify no regressions: same data in both stores
+
+3. **Code quality**:
+ - [ ] Each parser < 300 lines (was 600+)
+ - [ ] Each function has single responsibility
+ - [ ] No circular imports
+ - [ ] No global state
+
+---
+
+## Risk Mitigation
+
+| Risk | Mitigation |
+|------|-----------|
+| **Break existing ingest** | Keep old code in parallel during refactor, test both |
+| **Lose data** | Test with small dataset first (single agent) |
+| **ES writes fail** | Gateway already has fire-and-forget pattern, won't break SQLite |
+| **Merge conflicts** | Work on separate files (parsers/, new files in ingest/) |
+- [ ] Create `elastic-setup/` folder structure:
+ ```
+ elastic-setup/
+ ├── docker-compose.yml # ES 8.11.0 + Kibana + setup
+ ├── elasticsearch.yml # ES node configuration
+ ├── .env.example # Env var template (ELASTIC_HOST, ELASTIC_PASSWORD, etc.)
+ ├── README.md # Setup instructions (3 min to running)
+ ├── scripts/
+ │ ├── setup.sh # Create indices + templates
+ │ ├── seed-data.sh # (Optional) Load sample sessions
+ │ └── cleanup.sh # Destroy containers
+ └── kibana/
+ └── dashboards.json # Pre-built Kibana dashboard (export)
+ ```
+
+- [ ] `docker-compose.yml`:
+ - Elasticsearch 8.11.0 (single-node, 2GB heap)
+ - Kibana 8.11.0 (for judges to inspect data)
+ - Auto-generated credentials + certificates
+ - Health checks
+
+- [ ] `scripts/setup.sh`:
+ - Wait for ES to be healthy
+ - Create indices: `smriti_sessions`, `smriti_messages`
+ - Create index templates for automatic field mapping
+ - Output connection details (host, user, password)
+
+- [ ] `README.md`:
+ ```markdown
+ # Elasticsearch Setup for Smriti Hackathon
+
+ ## Quick Start (3 minutes)
+
+ 1. Clone repo, enter elastic-setup folder
+ 2. Run: docker-compose up -d
+ 3. Wait: scripts/setup.sh (waits for ES to be ready)
+ 4. Access:
+ - Elasticsearch: http://localhost:9200 (user: elastic, password: changeme)
+ - Kibana: http://localhost:5601
+
+ ## Environment Variables
+ - ELASTIC_HOST=localhost:9200
+ - ELASTIC_USER=elastic
+ - ELASTIC_PASSWORD=
+ - ELASTIC_CLOUD_ID=
+ ```
+
+**Files to Create**:
+- `elastic-setup/docker-compose.yml`
+- `elastic-setup/elasticsearch.yml`
+- `elastic-setup/.env.example`
+- `elastic-setup/README.md`
+- `elastic-setup/scripts/setup.sh`
+- `elastic-setup/scripts/cleanup.sh`
+
+**Effort**: 1.5 hours
+
+---
+
+#### 1.2 Elasticsearch Client Library (No Auth Yet)
+
+**Goal**: Minimal ES client that can be toggled on/off via env var
+
+**Tasks**:
+- [ ] Create `src/es/client.ts` - Elasticsearch connection
+ - Check if `ELASTIC_HOST` env var set
+ - If yes: Connect to ES, expose `{ client, indexName }`
+ - If no: Return null (parallel ingestion will skip ES writes)
+
+- [ ] Define ES index schema in `src/es/schema.ts`:
+ ```ts
+ export const SESSION_INDEX = "smriti_sessions";
+ export const MESSAGE_INDEX = "smriti_messages";
+
+ export const sessionMapping = {
+ properties: {
+ session_id: { type: "keyword" },
+ agent_id: { type: "keyword" },
+ project_id: { type: "keyword" },
+ title: { type: "text" },
+ summary: { type: "text" },
+ created_at: { type: "date" },
+ duration_ms: { type: "integer" },
+ turn_count: { type: "integer" },
+ token_spend: { type: "float" },
+ error_count: { type: "integer" },
+ categories: { type: "keyword" },
+ embedding: { type: "dense_vector", dims: 1536, similarity: "cosine" }
+ }
+ };
+ ```
+
+**Files to Create**:
+- `src/es/client.ts` - Connection + null check
+- `src/es/schema.ts` - Index definitions
+- `src/es/ingest.ts` - Parallel write helper (see 1.4)
+
+**Effort**: 1 hour
+
+---
+
+#### 1.2 Adapter Layer (src/es.ts)
+
+**Goal**: Create a wrapper that mimics QMD's exported functions but hits ES instead
+
+**Why**: Minimal changes to existing code. `src/qmd.ts` becomes a routing layer:
+```ts
+// src/qmd.ts (modified)
+export { addMessage, searchMemoryFTS, searchMemoryVec, recallMemories } from "./es.ts"
+```
+
+**Tasks**:
+- [ ] Implement `addMessage(sessionId, role, content, metadata)` → ES bulk insert
+- [ ] Implement `searchMemoryFTS(query)` → ES query_string
+- [ ] Implement `searchMemoryVec(embedding)` → ES dense_vector search
+- [ ] Implement `recallMemories(query, synthesize?)` → hybrid search + session dedup
+- [ ] Implement metadata helpers (for tool usage, git ops, etc.)
+
+**Example addMessage**:
+```ts
+export async function addMessage(
+ sessionId: string,
+ role: "user" | "assistant" | "system",
+ content: string,
+ metadata?: Record
+) {
+ const doc = {
+ session_id: sessionId,
+ role,
+ content,
+ timestamp: new Date(),
+ embedding: await generateEmbedding(content), // Reuse Ollama
+ ...metadata
+ };
+
+ const client = getEsClient();
+ await client.index({
+ index: "smriti_messages",
+ document: doc
+ });
+}
+```
+
+**Files to Create/Modify**:
+- `src/es.ts` - Core ES adapter functions
+- `src/qmd.ts` - Change imports to route to ES (keep surface API identical)
+- `src/es/embedding.ts` - Reuse Ollama embedding logic from QMD
+
+**Effort**: 2.5 hours
+
+---
+
+#### 1.3 Parallel Ingest (SQLite + Elasticsearch)
+
+**Goal**: When `ELASTIC_HOST` env var set, write to both SQLite (via QMD) and Elasticsearch in parallel
+
+**Why parallel**:
+- SQLite ingestion keeps working (zero breaking changes)
+- ES gets the same data (judges see dual-write success)
+- If ES fails, SQLite succeeds (safe fallback)
+- Can test ES independently
+
+**Tasks**:
+- [ ] Create `src/es/ingest.ts` - Helper to write messages + sessions to ES
+ ```ts
+ export async function ingestMessageToES(
+ sessionId: string,
+ role: string,
+ content: string,
+ metadata?: Record
+ ) {
+ const esClient = getEsClient();
+ if (!esClient) return; // ES not configured, skip
+
+ const doc = {
+ session_id: sessionId,
+ role,
+ content,
+ timestamp: new Date().toISOString(),
+ ...metadata
+ };
+
+ await esClient.index({
+ index: MESSAGE_INDEX,
+ document: doc
+ });
+ }
+
+ export async function ingestSessionToES(sessionMetadata) {
+ // Similar for session-level metadata
+ }
+ ```
+
+- [ ] Modify `src/ingest/index.ts:ingestAgent()` - Add parallel ES write:
+ ```ts
+ async function ingestAgent(agentId: string, options: IngestOptions) {
+ const sessions = await discoverSessions(agentId);
+ let ingested = 0;
+
+ for (const session of sessions) {
+ if (await sessionExists(session.id)) continue;
+
+ const messages = await parseSessions(session);
+
+ for (const msg of messages) {
+ // Write to SQLite (QMD) - unchanged
+ await addMessage(msg.sessionId, msg.role, msg.content, msg.metadata);
+
+ // Write to ES in parallel (non-blocking)
+ ingestMessageToES(msg.sessionId, msg.role, msg.content, msg.metadata).catch(err => {
+ console.warn(`ES ingest failed for ${msg.sessionId}:`, err.message);
+ // Don't throw - SQLite succeeded, ES is optional
+ });
+ }
+
+ ingested++;
+ }
+
+ return { agentId, sessionsIngested: ingested };
+ }
+ ```
+
+- [ ] Modify `src/config.ts` - Add ES env vars:
+ ```ts
+ export const ELASTIC_HOST = process.env.ELASTIC_HOST || null;
+ export const ELASTIC_USER = process.env.ELASTIC_USER || "elastic";
+ export const ELASTIC_PASSWORD = process.env.ELASTIC_PASSWORD || "changeme";
+ export const ELASTIC_API_KEY = process.env.ELASTIC_API_KEY || null;
+ ```
+
+**Key design**:
+- `getEsClient()` returns null if `ELASTIC_HOST` not set → parallel ingest is no-op
+- ES write is async/non-blocking → doesn't slow down SQLite ingestion
+- All error handling is local (one ES failure doesn't break the whole ingest)
+
+**Files to Create/Modify**:
+- `src/es/ingest.ts` - New parallel write helpers
+- `src/ingest/index.ts` - Add ES write after QMD write
+- `src/config.ts` - Add ES env vars
+- Keep all parsers unchanged (src/ingest/claude.ts, codex.ts, etc.)
+
+**Effort**: 2 hours
+
+**Total Phase 1: 4.5 hours** (much faster than full auth refactor!)
+
+---
+
+### Phase 2: API & Frontend (Day 2, Hours 5-16)
+
+#### 2.1 Backend API Layer (No Auth Yet)
+
+**Goal**: Expose ES data via HTTP endpoints for React frontend
+
+**Tasks**:
+- [ ] Create `src/api/server.ts` - Bun.serve() with /api routes
+ ```ts
+ import { Bun } from "bun";
+
+ const PORT = 3000;
+
+ Bun.serve({
+ port: PORT,
+ routes: {
+ "/api/sessions": sessionsEndpoint,
+ "/api/sessions/:id": sessionDetailEndpoint,
+ "/api/search": searchEndpoint,
+ "/api/analytics/overview": analyticsOverviewEndpoint,
+ "/api/analytics/timeline": analyticsTimelineEndpoint,
+ "/api/analytics/tools": toolsEndpoint,
+ "/api/analytics/projects": projectsEndpoint,
+ }
+ });
+ ```
+
+- [ ] Implement endpoints:
+ - `GET /api/sessions?limit=50&offset=0` - List sessions from ES
+ - `GET /api/sessions/:id` - Single session + all messages
+ - `POST /api/search` - Query ES with keyword + optional vector search
+ - `GET /api/analytics/overview` - Aggregations (total sessions, avg duration, token spend, errors)
+ - `GET /api/analytics/timeline` - Time-bucket aggregations (sessions per day, tokens per day for last 30 days)
+ - `GET /api/analytics/tools` - Tool usage histogram
+ - `GET /api/analytics/projects` - Per-project stats
+
+- [ ] Example endpoint (sessions list):
+ ```ts
+ async function sessionsEndpoint(req: Request) {
+ const url = new URL(req.url);
+ const limit = parseInt(url.searchParams.get("limit") ?? "50");
+ const offset = parseInt(url.searchParams.get("offset") ?? "0");
+
+ const esClient = getEsClient();
+ if (!esClient) {
+ return new Response(JSON.stringify({ error: "ES not configured" }), { status: 500 });
+ }
+
+ const result = await esClient.search({
+ index: "smriti_sessions",
+ from: offset,
+ size: limit,
+ sort: [{ created_at: { order: "desc" } }]
+ });
+
+ return new Response(JSON.stringify({
+ total: result.hits.total.value,
+ sessions: result.hits.hits.map(h => h._source)
+ }));
+ }
+ ```
+
+**Files to Create**:
+- `src/api/server.ts` - Main Bun server
+- `src/api/endpoints/sessions.ts` - GET /api/sessions, /api/sessions/:id
+- `src/api/endpoints/search.ts` - POST /api/search (keyword + optional embedding)
+- `src/api/endpoints/analytics.ts` - All /api/analytics/* endpoints
+
+**Effort**: 2 hours
+
+---
+
+#### 2.2 React Web App (Simple Dashboard)
+
+**Goal**: Minimal dashboard to visualize ES data (no auth yet, just UI)
+
+**Architecture**:
+```
+frontend/
+├── index.html (entry point)
+├── App.tsx (main app, simple nav)
+├── pages/
+│ ├── Dashboard.tsx (stats overview)
+│ ├── SessionList.tsx (searchable sessions)
+│ ├── SessionDetail.tsx (read-only view)
+│ └── Analytics.tsx (tool usage, timelines)
+├── components/
+│ ├── StatsCard.tsx
+│ ├── SessionCard.tsx
+│ └── Chart.tsx
+├── hooks/
+│ └── useApi.ts (fetch from /api/*)
+└── index.css (Tailwind)
+```
+
+**Key pages**:
+- **Dashboard**: 4 stat cards (total sessions, avg duration, token spend, error rate) + timeline chart
+- **SessionList**: Searchable table of sessions, click to detail
+- **SessionDetail**: Show messages, tool usage, git ops for a session
+- **Analytics**: Tool usage pie chart, project breakdown, error rate timeline
+
+**Example Dashboard**:
+```tsx
+export default function Dashboard() {
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ fetch("/api/analytics/overview")
+ .then(r => r.json())
+ .then(setStats);
+ }, []);
+
+ if (!stats) return Loading...
;
+
+ return (
+
+
Smriti Analytics
+
+
+
+
+
+
+
+ );
+}
+```
+
+**Tech**:
+- React 18 + TypeScript (Bun bundling)
+- Recharts for charts (simple, zero-config)
+- Tailwind CSS
+- No auth/routing complexity (just simple pages)
+
+**Files to Create**:
+- `frontend/index.html` - Static entry point
+- `frontend/App.tsx` - Main component, tab navigation
+- `frontend/pages/Dashboard.tsx`
+- `frontend/pages/SessionList.tsx`
+- `frontend/pages/SessionDetail.tsx`
+- `frontend/pages/Analytics.tsx`
+- `frontend/components/StatsCard.tsx`
+- `frontend/hooks/useApi.ts`
+- `frontend/index.css` - Tailwind
+
+**Effort**: 3.5 hours
+
+---
+
+#### 2.3 CLI Integration (API Server Flag)
+
+**Goal**: Add `--api` flag to start API server alongside CLI
+
+**Tasks**:
+- [ ] Modify `src/index.ts` - Check for `--api` flag
+- [ ] If `--api`: Start `src/api/server.ts` in background
+- [ ] Default: CLI works as before (no breaking changes)
+- [ ] Example: `smriti ingest claude --api` (or `smriti --api` then `smriti ingest...`)
+
+**Files to Modify**:
+- `src/index.ts` - Add --api flag handler
+
+**Effort**: 0.5 hours
+
+**Total Phase 2: 6.5 hours**
+
+---
+
+### Phase 3: Polish & Submission (Day 2, Hours 21-24)
+
+#### 3.1 Demo Script & Video
+
+**Pre-demo setup** (30 min before recording):
+- [ ] Start Docker: `cd elastic-setup && docker-compose up -d && bash scripts/setup.sh`
+- [ ] Ingest existing Smriti data:
+ ```bash
+ export ELASTIC_HOST=localhost:9200
+ smriti ingest all # or just "claude" if fast
+ ```
+- [ ] Verify ES has data: `curl http://localhost:9200/smriti_sessions/_count`
+- [ ] Start API server: `smriti --api` (or `bun src/api/server.ts`)
+- [ ] Open browser: http://localhost:3000 → dashboard should load
+
+**Demo script** (3 min):
+1. **Show setup** (20s)
+ - Briefly show docker-compose running
+ - Show `curl` output (ES has data)
+
+2. **Dashboard** (30s)
+ - Refresh page, show stats cards load (sessions, tokens, errors, duration)
+ - Point out that real data from all ingested sessions is shown
+
+3. **Timeline** (20s)
+ - Click "Analytics" tab
+ - Show timeline chart of sessions per week
+ - Explain: "Teams can see productivity trends"
+
+4. **Session browser** (30s)
+ - Click "Sessions" tab
+ - Search for a known topic (e.g., "bug", "refactor")
+ - Click one session → show messages, tool usage, git ops
+
+5. **Explain architecture** (20s)
+ - "CLI ingests to both SQLite and Elasticsearch in parallel"
+ - "ES powers the analytics API"
+ - "React dashboard visualizes shared learning"
+
+- [ ] Record screen capture (QuickTime on macOS, OBS on Linux)
+- [ ] Upload to YouTube, get shareable link
+
+**Effort**: 1.5 hours
+
+---
+
+#### 3.2 Documentation & README
+
+**Tasks**:
+- [ ] Update `README.md`:
+ - New section: "Elasticsearch Edition (Hackathon)"
+ - Architecture diagram (SQLite → ES)
+ - Setup instructions (ES + env vars)
+ - CLI auth flow
+ - API endpoint reference
+
+- [ ] Create `ELASTICSEARCH.md`:
+ - Index schema explanation
+ - Adapter layer design decisions
+ - Team isolation model
+ - Analytics aggregations
+
+- [ ] Add comments to critical functions (es.ts, api/server.ts)
+
+**Files to Create/Modify**:
+- `README.md` - Add ES section
+- `ELASTICSEARCH.md` - Technical design
+- Inline code comments
+
+**Effort**: 1.5 hours
+
+---
+
+#### 3.3 Final Testing & Polish
+
+**Tasks**:
+- [ ] Test end-to-end flow:
+ 1. `smriti login team-acme`
+ 2. `smriti ingest claude`
+ 3. `smriti search "fix bug"`
+ 4. Open web app at `http://localhost:3000`
+ 5. Verify dashboard loads, search works, analytics show data
+
+- [ ] Fix any bugs found during testing
+- [ ] Ensure API error handling is solid (don't expose ES errors directly)
+- [ ] Check web app mobile responsiveness (judges might view on phone)
+
+**Effort**: 1 hour
+
+---
+
+#### 3.4 GitHub & Submission
+
+**Tasks**:
+- [ ] Push to GitHub (ensure repo is public, MIT license)
+- [ ] Add hackathon-specific badges/mentions to README
+- [ ] Create `SUBMISSION.md`:
+ ```
+ # Smriti: Enterprise Memory for AI Teams
+
+ ## Problem
+ Enterprise AI teams lack visibility into agentic coding patterns.
+ Teams can't track token spend, error patterns, productivity signals.
+
+ ## Solution
+ Smriti migrated to Elasticsearch for enterprise-grade memory management:
+ - Team-scoped data (CLI auth)
+ - Real-time analytics (token spend, error rates, tool adoption)
+ - Hybrid search (keyword + semantic)
+ - Web dashboard for CTOs and team leads
+
+ ## Features Used
+ - Elasticsearch hybrid search (BM25 + dense vectors)
+ - Elasticsearch aggregations (time-series analytics)
+ - Elasticsearch team isolation (query scoping)
+
+ ## Demo Video
+ [YouTube link]
+
+ ## Code Repository
+ https://github.com/zero8dotdev/smriti
+ ```
+
+- [ ] Fill out Devpost submission form
+- [ ] Add demo video link
+- [ ] Double-check: Public repo ✓, OSI license ✓, ~400 words ✓, video ✓
+
+**Effort**: 1 hour
+
+**Total Phase 3: 5 hours**
+
+---
+
+## Timeline
+
+| Phase | What | Time | Hours |
+|-------|------|------|-------|
+| 1.1 | Elastic setup folder | Day 1, 1-2.5h | 1.5h |
+| 1.2 | ES client library | Day 1, 2.5-3.5h | 1h |
+| 1.3 | Parallel ingest (SQLite + ES) | Day 1, 3.5-5.5h | 2h |
+| **Phase 1 Total** | | **Day 1, 1-5.5h** | **4.5h** |
+| 2.1 | API layer (7 endpoints) | Day 2, 1-3h | 2h |
+| 2.2 | React frontend (Dashboard + views) | Day 2, 3-6.5h | 3.5h |
+| 2.3 | CLI --api flag | Day 2, 6.5-7h | 0.5h |
+| **Phase 2 Total** | | **Day 2, 1-7h** | **6.5h** |
+| 3.1 | Demo + video | Day 2, 7-8.5h | 1.5h |
+| 3.2 | Docs (README + ELASTICSEARCH.md) | Day 2, 8.5-10h | 1.5h |
+| 3.3 | Testing + polishing | Day 2, 10-11h | 1h |
+| 3.4 | GitHub + submit | Day 2, 11-12h | 1h |
+| **Phase 3 Total** | | **Day 2, 7-12h** | **5h** |
+| **Grand Total** | | **~16 hours** | |
+
+**Buffer**: 32 hours for interruptions, debugging, sleep, extra polish.
+
+---
+
+## Architectural Decisions
+
+### 1. Parallel Ingest (Not a Replacement)
+**Why**: Keeps SQLite working while adding ES.
+- SQLite is the primary store (zero breaking changes)
+- ES writes happen asynchronously in parallel
+- If ES fails, SQLite still succeeds (safe fallback)
+- Judges see "dual-write" success (impressive)
+- Easy to toggle: `if (esClient) { ingestToES() }` (line-by-line)
+
+### 2. SQLite-First, ES-Aware
+**Why**: Fastest to ship.
+- Keep all existing ingestion code unchanged
+- Add 20-30 lines per parser to call `ingestMessageToES()`
+- No schema migration (SQLite stays as-is)
+- ES indices are separate (never need to sync back)
+- If ES cluster dies, CLI still works
+
+### 3. No Auth in MVP
+**Why**: Simplifies scope by 1-2 days.
+- All ES data is readable via `/api/*` (no scoping)
+- Team isolation added in Phase 2 (post-hackathon)
+- Demo still shows multi-agent data (impressive volume)
+- Security: Run API on private network only (not public)
+
+### 4. Reuse Ollama for Embeddings
+**Why**: Already running, no new deps.
+- Call Ollama for vector generation (1536-dim)
+- Store in ES `dense_vector` field
+- Hybrid search: ES `match` (BM25) + `dense_vector` query
+
+### 5. React Dashboard Over Kibana
+**Why**: Shows custom engineering + faster to demo.
+- Custom React app controls story (judges like polish)
+- Kibana is nice-to-have (Phase 2)
+- React renders well on judge's phone/laptop
+- Pre-built components (StatsCard, Timeline) fast to code
+
+### 6. Elastic Setup Folder (Reproducibility)
+**Why**: Judges need to run it locally.
+- `docker-compose.yml` + scripts = 5-min setup
+- No cloud credentials needed (local ES)
+- Judges can validate data ingestion themselves
+- Shows professional packaging
+
+---
+
+## Risks & Mitigations
+
+| Risk | Impact | Mitigation |
+|------|--------|-----------|
+| **Docker setup (elasticsearch + kibana) slow** | Medium | Pre-build docker-compose.yml + test locally first. Scripts auto-create indices. Should be 5 min. |
+| **Parallel ingest causes data duplication** | Low | ES writes are isolated (no shared DB), so dedup is per-store. OK for demo. |
+| **Ollama embedding timeout** | Medium | Wrap ES ingest in try/catch, log errors. SQLite write still succeeds. Non-blocking prevents slowdown. |
+| **React frontend API errors** | Medium | Test API endpoints manually (`curl http://localhost:3000/api/...`) before React build. |
+| **Demo data too small (few sessions)** | Medium | Use existing Smriti data (`smriti ingest all` before demo). Real volume = impressive analytics. |
+| **ES query syntax errors** | Medium | Test each endpoint manually. Bun error logs are clear. Fix in-place during demo rehearsal. |
+| **GitHub repo structure confusing** | Low | Add `ELASTICSEARCH.md` with folder structure + setup diagram. |
+
+---
+
+## Success Criteria
+
+By end of Day 2, you should have:
+
+✅ **Elasticsearch running locally** (docker-compose.yml + setup scripts)
+✅ **ES indices created** (smriti_sessions, smriti_messages with correct mappings)
+✅ **Parallel ingest working** (CLI ingests to both SQLite + ES, no errors)
+✅ **API server up** (7 endpoints: /api/sessions, /api/sessions/:id, /api/search, /api/analytics/*)
+✅ **React dashboard live** (Dashboard page + SessionList + SessionDetail + Analytics pages)
+✅ **Demo workflow** (ingest sessions → API returns data → React displays it, 3 min video)
+✅ **Public GitHub repo** with elastic-setup/ folder, README, ELASTICSEARCH.md
+✅ **Devpost submission** (description + demo video + repo link)
+
+Optional (nice-to-have, if time allows):
+- ⭐ GitHub OAuth login (elegant but not required for MVP)
+- ⭐ Kibana dashboard pre-built (shows ES native power)
+- ⭐ Elasticsearch Agent Builder agent (too ambitious for 48h)
+- ⭐ Social media post + blog post
+
+---
+
+## Critical Files to Create/Modify
+
+### New Folders & Files (Essential)
+
+**Elastic Setup** (reproducible for judges):
+```
+elastic-setup/
+├── docker-compose.yml # ES 8.11.0 + Kibana, auto-setup
+├── elasticsearch.yml # Node config (heap, plugins)
+├── .env.example # Template for ELASTIC_HOST, password
+├── README.md # 5-min setup guide
+├── scripts/
+│ ├── setup.sh # Create indices + templates
+│ ├── cleanup.sh # Destroy containers
+│ └── seed-data.sh # (Optional) Load sample data
+└── kibana/
+ └── dashboards.json # (Optional) Pre-built dashboard
+```
+
+**Backend (ES client + parallel ingest)**:
+```
+src/
+├── es/
+│ ├── client.ts # Elasticsearch client (null if ELASTIC_HOST not set)
+│ ├── schema.ts # Index definitions (smriti_sessions, messages)
+│ └── ingest.ts # Helper: ingestMessageToES, ingestSessionToES
+├── api/
+│ ├── server.ts # Bun.serve() with /api routes
+│ ├── endpoints/
+│ │ ├── sessions.ts # GET /api/sessions, /api/sessions/:id
+│ │ ├── search.ts # POST /api/search
+│ │ └── analytics.ts # GET /api/analytics/overview, timeline, tools, projects
+│ └── utils/
+│ └── esQuery.ts # Helper: format ES aggregation queries
+
+frontend/
+├── index.html # Static entry point
+├── App.tsx # Main component + tab nav
+├── pages/
+│ ├── Dashboard.tsx # Stats cards + timeline
+│ ├── SessionList.tsx # Searchable session table
+│ ├── SessionDetail.tsx # Single session messages + metadata
+│ └── Analytics.tsx # Tool usage, projects, trends
+├── components/
+│ ├── StatsCard.tsx # Reusable stat display
+│ ├── Chart.tsx # Recharts wrapper
+│ └── Loading.tsx # Loading spinner
+├── hooks/
+│ └── useApi.ts # fetch() wrapper with error handling
+└── index.css # Tailwind styles
+```
+
+### Modified Files
+```
+src/
+├── index.ts # Add --api flag (starts API server)
+├── config.ts # Add ELASTIC_HOST, ELASTIC_USER, ELASTIC_PASSWORD
+└── ingest/index.ts # After QMD addMessage(), call ingestMessageToES() (fire & forget)
+
+package.json # Add @elastic/elasticsearch, react, react-dom, recharts, tailwindcss
+```
+
+---
+
+## Deployment
+
+### Development Setup (Local)
+
+```bash
+# 1. Set up GitHub OAuth
+# Create GitHub App at https://github.com/settings/developers
+# - App name: "Smriti Hackathon"
+# - Homepage URL: http://localhost:3000
+# - Authorization callback URL: http://localhost:3000/api/auth/github/callback
+# - Copy CLIENT_ID and CLIENT_SECRET
+
+# 2. Set env vars
+export ELASTICSEARCH_CLOUD_ID=""
+export ELASTICSEARCH_API_KEY=""
+export GITHUB_CLIENT_ID=""
+export GITHUB_CLIENT_SECRET=""
+export OLLAMA_HOST="http://127.0.0.1:11434"
+
+# 3. Ingest existing Smriti data
+bun src/index.ts ingest all
+
+# 4. Start API server
+bun --hot src/index.ts --serve
+# Server on :3000, API on :3000/api
+```
+
+### Production Deployment (Vercel/Railway)
+
+**Frontend (Vercel)**:
+```bash
+# 1. Push repo to GitHub
+git push origin elastic-hackathon
+
+# 2. Create new Vercel project from GitHub repo
+# https://vercel.com/new → select smriti repo
+
+# 3. Set env var:
+# VITE_API_URL = https://smriti-api.railway.app
+
+# 4. Deploy (automatic on push)
+```
+
+**Backend (Railway or Render)**:
+```bash
+# 1. Create new project on Railway.app or Render.com
+# 2. Connect GitHub repo
+# 3. Set environment variables:
+# - ELASTICSEARCH_CLOUD_ID (from Elastic Cloud)
+# - ELASTICSEARCH_API_KEY (from Elastic Cloud)
+# - GITHUB_CLIENT_ID (from GitHub App)
+# - GITHUB_CLIENT_SECRET (from GitHub App)
+# - OLLAMA_HOST (your local Ollama or cloud)
+# - NODE_ENV=production
+
+# 4. Deploy (automatic on push)
+```
+
+**Elastic Cloud Setup** (~15 min):
+1. Go to https://cloud.elastic.co/registration
+2. Create free trial account (credit card required)
+3. Create new Elasticsearch deployment (8.11.0, < 4GB RAM)
+4. Get Cloud ID and API Key from deployment settings
+5. Store in `ELASTICSEARCH_CLOUD_ID` and `ELASTICSEARCH_API_KEY`
+
+**GitHub OAuth Setup** (~5 min):
+1. Go to https://github.com/settings/developers/new
+2. Create OAuth App:
+ - **App name**: Smriti Hackathon
+ - **Homepage URL**: `https://smriti-hackathon.vercel.app` (deployed URL)
+ - **Authorization callback URL**: `https://smriti-hackathon.vercel.app/api/auth/github/callback`
+3. Copy Client ID and Client Secret into Railway/Render env vars
+
+---
+
+### Notes
+
+- **No additional databases needed** — Elasticsearch is the only data store
+- **Ollama can be local or cloud** — API server will connect via `OLLAMA_HOST`
+- **Vercel frontend is static** — Just React bundle, no secrets
+- **Railway/Render backend** — Runs Node.js/Bun server, connects to ES Cloud
+- **Total setup time**: ~30 min (Elastic Cloud + GitHub OAuth + Vercel/Railway deploy)
+
+---
+
+## Testing Checklist
+
+Before recording demo:
+
+- [ ] Docker running: `docker-compose ps` (elasticsearch + kibana running)
+- [ ] ES healthy: `curl http://localhost:9200/_cat/health` (status: green or yellow)
+- [ ] Indices created: `curl http://localhost:9200/_cat/indices` (smriti_sessions, smriti_messages visible)
+- [ ] Ingest works: `export ELASTIC_HOST=localhost:9200 && smriti ingest claude` (no errors)
+- [ ] Data in ES: `curl http://localhost:9200/smriti_sessions/_count` (returns count > 0)
+- [ ] API server starts: `bun src/api/server.ts` (logs "Listening on http://localhost:3000")
+- [ ] API endpoints respond:
+ - `curl http://localhost:3000/api/analytics/overview` → valid JSON
+ - `curl http://localhost:3000/api/sessions` → array of sessions
+ - `curl http://localhost:3000/api/sessions/UUID` → single session or 404
+- [ ] React app loads: `http://localhost:3000` → Dashboard page visible
+- [ ] Dashboard stats visible (total sessions, avg duration, tokens, errors)
+- [ ] SessionList page: search works, results appear
+- [ ] SessionDetail: click session, messages appear
+- [ ] Analytics page: timeline + tool usage chart render
+- [ ] No 500 errors in browser console or server logs
+- [ ] Refresh page (React state persists via API calls)
+
+---
+
+## Roadmap (Post-Hackathon)
+
+If submission is successful, next priorities:
+
+**Phase 2 (Short-term)**:
+- Team authentication (GitHub OAuth or API keys)
+- Team isolation via query filtering
+- Persisted saved searches
+- Email alerts on anomalies
+
+**Phase 3 (Medium-term)**:
+- Elasticsearch Agent Builder agents:
+ - "Anomaly Scout" - Detects unusual session patterns
+ - "Code Quality Advisor" - Suggests improvements based on patterns
+- Kibana dashboard export (native ES visualization)
+- Time-series alerting (token spike, error rate increase)
+
+**Phase 4 (Long-term)**:
+- Multi-org support (SaaS model)
+- Role-based access control (admin, analyst, viewer)
+- Audit logs (who accessed what)
+- Cost optimization (ES index size reduction, archival)
+- Mobile app (read-only dashboard)
diff --git a/test/ingest-claude-orchestrator.test.ts b/test/ingest-claude-orchestrator.test.ts
new file mode 100644
index 0000000..156699f
--- /dev/null
+++ b/test/ingest-claude-orchestrator.test.ts
@@ -0,0 +1,118 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest/index";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-claude-orch-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+function writeClaudeSession(filePath: string, sessionId: string, userText: string, assistantText: string) {
+ writeFileSync(
+ filePath,
+ [
+ JSON.stringify({
+ type: "user",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: userText },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: assistantText }],
+ usage: { input_tokens: 10, output_tokens: 5 },
+ },
+ }),
+ ].join("\n")
+ );
+}
+
+test("ingest(claude) ingests new session through orchestrator", async () => {
+ const projectDir = "-Users-zero8-zero8.dev-smriti";
+ const logsDir = join(root, "claude-logs");
+ const sessionId = "claude-session-1";
+ mkdirSync(join(logsDir, projectDir), { recursive: true });
+
+ const filePath = join(logsDir, projectDir, `${sessionId}.jsonl`);
+ writeClaudeSession(filePath, sessionId, "How should we deploy?", "Use blue/green.");
+
+ const result = await ingest(db, "claude", { logsDir });
+
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get(sessionId) as { agent_id: string; project_id: string | null };
+
+ expect(meta.agent_id).toBe("claude-code");
+ expect(meta.project_id).toBe("smriti");
+});
+
+test("ingest(claude) is incremental for append-only jsonl sessions", async () => {
+ const projectDir = "-Users-zero8-zero8.dev-smriti";
+ const logsDir = join(root, "claude-logs");
+ const sessionId = "claude-session-2";
+ mkdirSync(join(logsDir, projectDir), { recursive: true });
+
+ const filePath = join(logsDir, projectDir, `${sessionId}.jsonl`);
+ writeClaudeSession(filePath, sessionId, "Initial question", "Initial answer");
+
+ const first = await ingest(db, "claude", { logsDir });
+ expect(first.sessionsIngested).toBe(1);
+ expect(first.messagesIngested).toBe(2);
+
+ appendFileSync(
+ filePath,
+ "\n" +
+ JSON.stringify({
+ type: "user",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "Follow-up question" },
+ }) +
+ "\n" +
+ JSON.stringify({
+ type: "assistant",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "assistant", content: [{ type: "text", text: "Follow-up answer" }] },
+ })
+ );
+
+ const second = await ingest(db, "claude", { logsDir });
+
+ expect(second.errors).toHaveLength(0);
+ expect(second.sessionsFound).toBe(1);
+ expect(second.sessionsIngested).toBe(1);
+ expect(second.messagesIngested).toBe(2);
+
+ const count = db
+ .prepare("SELECT COUNT(*) as c FROM memory_messages WHERE session_id = ?")
+ .get(sessionId) as { c: number };
+ expect(count.c).toBe(4);
+});
diff --git a/test/ingest-orchestrator.test.ts b/test/ingest-orchestrator.test.ts
new file mode 100644
index 0000000..c85a342
--- /dev/null
+++ b/test/ingest-orchestrator.test.ts
@@ -0,0 +1,83 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest/index";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-orch-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+test("ingest(codex) uses parser+resolver+gateway flow", async () => {
+ const logsDir = join(root, "codex");
+ mkdirSync(join(logsDir, "team"), { recursive: true });
+ writeFileSync(
+ join(logsDir, "team", "chat.jsonl"),
+ [
+ JSON.stringify({ role: "user", content: "How do we shard this?" }),
+ JSON.stringify({ role: "assistant", content: "Use tenant hash." }),
+ ].join("\n")
+ );
+
+ const result = await ingest(db, "codex", { logsDir });
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta")
+ .get() as { agent_id: string; project_id: string | null };
+ expect(meta.agent_id).toBe("codex");
+ expect(meta.project_id).toBeNull();
+});
+
+test("ingest(file) accepts explicit project without FK failure", async () => {
+ const filePath = join(root, "transcript.jsonl");
+ writeFileSync(
+ filePath,
+ [
+ JSON.stringify({ role: "user", content: "Set rollout plan" }),
+ JSON.stringify({ role: "assistant", content: "Canary then full rollout" }),
+ ].join("\n")
+ );
+
+ const result = await ingest(db, "file", {
+ filePath,
+ format: "jsonl",
+ sessionId: "file-1",
+ projectId: "proj-file",
+ });
+
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("proj-file") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("file-1") as { session_id: string; agent_id: string | null; project_id: string };
+ expect(meta.project_id).toBe("proj-file");
+ expect(meta.agent_id === null || meta.agent_id === "generic").toBe(true);
+});
diff --git a/test/ingest-parsers.test.ts b/test/ingest-parsers.test.ts
new file mode 100644
index 0000000..136772f
--- /dev/null
+++ b/test/ingest-parsers.test.ts
@@ -0,0 +1,149 @@
+import { test, expect } from "bun:test";
+import { mkdtempSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { parseClaude } from "../src/ingest/parsers/claude";
+import { parseCodex } from "../src/ingest/parsers/codex";
+import { parseCursor } from "../src/ingest/parsers/cursor";
+import { parseCline } from "../src/ingest/parsers/cline";
+import { parseCopilot } from "../src/ingest/parsers/copilot";
+import { parseGeneric } from "../src/ingest/parsers/generic";
+
+async function withTmpDir(fn: (dir: string) => Promise | void): Promise {
+ const dir = mkdtempSync(join(tmpdir(), "smriti-parsers-"));
+ try {
+ await fn(dir);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+test("parseClaude returns ParsedSession with structured messages", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "s.jsonl");
+ writeFileSync(
+ p,
+ [
+ JSON.stringify({
+ type: "user",
+ sessionId: "s1",
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "How do we deploy this?" },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId: "s1",
+ timestamp: new Date().toISOString(),
+ message: { role: "assistant", content: [{ type: "text", text: "Use blue/green." }] },
+ }),
+ ].join("\n")
+ );
+
+ const parsed = await parseClaude(p, "s1");
+ expect(parsed.session.id).toBe("s1");
+ expect(parsed.session.title).toContain("How do we deploy this?");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCodex returns ParsedSession with title from first user", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "c.jsonl");
+ writeFileSync(
+ p,
+ [
+ JSON.stringify({ role: "user", content: "Plan caching strategy" }),
+ JSON.stringify({ role: "assistant", content: "Use layered cache" }),
+ ].join("\n")
+ );
+
+ const parsed = await parseCodex(p, "codex-1");
+ expect(parsed.session.id).toBe("codex-1");
+ expect(parsed.messages.length).toBe(2);
+ expect(parsed.session.title).toContain("Plan caching strategy");
+ });
+});
+
+test("parseCursor returns ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "cursor.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ messages: [
+ { role: "user", content: "Implement metrics" },
+ { role: "assistant", content: "Added counters." },
+ ],
+ })
+ );
+
+ const parsed = await parseCursor(p, "cursor-1");
+ expect(parsed.session.id).toBe("cursor-1");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCline returns structured ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "task.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ id: "task-1",
+ name: "Fix lint",
+ timestamp: new Date().toISOString(),
+ history: [
+ { ts: new Date().toISOString(), type: "say", text: "I will fix this" },
+ { ts: new Date().toISOString(), type: "ask", question: "Proceed?", options: "yes,no" },
+ ],
+ })
+ );
+
+ const parsed = await parseCline(p, "task-1");
+ expect(parsed.session.id).toBe("task-1");
+ expect(parsed.session.title).toBe("Fix lint");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCopilot returns ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "copilot.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ turns: [
+ { role: "user", content: "Add tracing" },
+ { role: "assistant", content: "Added OpenTelemetry hooks." },
+ ],
+ })
+ );
+
+ const parsed = await parseCopilot(p, "copilot-1");
+ expect(parsed.session.id).toBe("copilot-1");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseGeneric supports chat and jsonl formats", async () => {
+ await withTmpDir(async (dir) => {
+ const chatPath = join(dir, "chat.txt");
+ writeFileSync(chatPath, "user: hello\n\nassistant: hi");
+
+ const jsonlPath = join(dir, "chat.jsonl");
+ writeFileSync(
+ jsonlPath,
+ [
+ JSON.stringify({ role: "user", content: "u1" }),
+ JSON.stringify({ role: "assistant", content: "a1" }),
+ ].join("\n")
+ );
+
+ const chat = await parseGeneric(chatPath, "g-chat", "chat");
+ const jsonl = await parseGeneric(jsonlPath, "g-jsonl", "jsonl");
+
+ expect(chat.messages.length).toBe(2);
+ expect(jsonl.messages.length).toBe(2);
+ });
+});
diff --git a/test/ingest-pipeline.test.ts b/test/ingest-pipeline.test.ts
new file mode 100644
index 0000000..e5638ac
--- /dev/null
+++ b/test/ingest-pipeline.test.ts
@@ -0,0 +1,157 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingestCodex } from "../src/ingest/codex";
+import { ingestCursor } from "../src/ingest/cursor";
+import { ingestCline } from "../src/ingest/cline";
+import { ingestGeneric } from "../src/ingest/generic";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-ingest-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+test("ingestCodex ingests jsonl sessions and writes session meta", async () => {
+ const logsDir = join(root, "codex");
+ mkdirSync(join(logsDir, "team"), { recursive: true });
+ writeFileSync(
+ join(logsDir, "team", "chat.jsonl"),
+ [
+ JSON.stringify({ role: "user", content: "How do we cache this?" }),
+ JSON.stringify({ role: "assistant", content: "Use a short TTL." }),
+ ].join("\n")
+ );
+
+ const result = await ingestCodex({ db, logsDir });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta")
+ .all() as Array<{ session_id: string; agent_id: string; project_id: string | null }>;
+
+ expect(meta).toHaveLength(1);
+ expect(meta[0].session_id).toBe("codex-team-chat");
+ expect(meta[0].agent_id).toBe("codex");
+ expect(meta[0].project_id).toBeNull();
+});
+
+test("ingestCursor ingests sessions and associates basename project id", async () => {
+ const projectPath = join(root, "my-app");
+ mkdirSync(join(projectPath, ".cursor"), { recursive: true });
+ writeFileSync(
+ join(projectPath, ".cursor", "conv.json"),
+ JSON.stringify({
+ messages: [
+ { role: "user", content: "Implement auth" },
+ { role: "assistant", content: "Added middleware" },
+ ],
+ })
+ );
+
+ const result = await ingestCursor({ db, projectPath });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id, path FROM smriti_projects WHERE id = ?")
+ .get("my-app") as { id: string; path: string } | null;
+ expect(project).not.toBeNull();
+ expect(project!.path).toBe(projectPath);
+
+ const meta = db
+ .prepare("SELECT project_id FROM smriti_session_meta")
+ .get() as { project_id: string };
+ expect(meta.project_id).toBe("my-app");
+});
+
+test("ingestCline ingests task history and derives project from cwd", async () => {
+ const logsDir = join(root, "cline");
+ mkdirSync(logsDir, { recursive: true });
+
+ writeFileSync(
+ join(logsDir, "task-1.json"),
+ JSON.stringify({
+ id: "task-1",
+ name: "Fix tests",
+ timestamp: new Date().toISOString(),
+ cwd: join(root, "repo-alpha"),
+ history: [
+ { ts: new Date().toISOString(), type: "say", text: "I can fix this." },
+ { ts: new Date().toISOString(), type: "ask", question: "Proceed?", options: "yes,no" },
+ ],
+ })
+ );
+
+ const result = await ingestCline({ db, logsDir });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("repo-alpha") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("task-1") as { agent_id: string; project_id: string };
+ expect(meta.agent_id).toBe("cline");
+ expect(meta.project_id).toBe("repo-alpha");
+});
+
+test("ingestGeneric stores transcript and preserves explicit project id", async () => {
+ const transcriptPath = join(root, "transcript.chat");
+ writeFileSync(
+ transcriptPath,
+ [
+ JSON.stringify({ role: "user", content: "How should we version this API?" }),
+ JSON.stringify({ role: "assistant", content: "Start with v1 and a deprecation policy." }),
+ ].join("\n")
+ );
+
+ const result = await ingestGeneric({
+ db,
+ filePath: transcriptPath,
+ format: "jsonl",
+ sessionId: "manual-session-1",
+ projectId: "manual-project",
+ title: "API Versioning",
+ agentName: "codex",
+ });
+
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBeGreaterThan(0);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("manual-project") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("manual-session-1") as { project_id: string };
+ expect(meta.project_id).toBe("manual-project");
+});
diff --git a/test/session-resolver.test.ts b/test/session-resolver.test.ts
new file mode 100644
index 0000000..6c36cd6
--- /dev/null
+++ b/test/session-resolver.test.ts
@@ -0,0 +1,83 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults, upsertSessionMeta } from "../src/db";
+import { resolveSession } from "../src/ingest/session-resolver";
+
+let db: Database;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+});
+
+afterEach(() => {
+ db.close();
+});
+
+test("resolveSession marks new session and counts zero existing messages", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s-new",
+ agentId: "codex",
+ });
+
+ expect(r.isNew).toBe(true);
+ expect(r.existingMessageCount).toBe(0);
+ expect(r.projectId).toBeNull();
+});
+
+test("resolveSession marks existing session and counts existing messages", () => {
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`
+ ).run("s1", "Session 1", now, now);
+
+ db.prepare(
+ `INSERT INTO memory_messages (session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?)`
+ ).run("s1", "user", "hello", "h1", now);
+
+ db.prepare(
+ `INSERT INTO memory_messages (session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?)`
+ ).run("s1", "assistant", "world", "h2", now);
+
+ upsertSessionMeta(db, "s1", "codex");
+
+ const r = resolveSession({
+ db,
+ sessionId: "s1",
+ agentId: "codex",
+ });
+
+ expect(r.isNew).toBe(false);
+ expect(r.existingMessageCount).toBe(2);
+});
+
+test("resolveSession uses explicit project id over derived project", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s2",
+ agentId: "cursor",
+ projectDir: "/tmp/projects/my-app",
+ explicitProjectId: "team/core-app",
+ explicitProjectPath: "/opt/work/core-app",
+ });
+
+ expect(r.projectId).toBe("team/core-app");
+ expect(r.projectPath).toBe("/opt/work/core-app");
+});
+
+test("resolveSession derives cursor project from basename", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s3",
+ agentId: "cursor",
+ projectDir: "/Users/test/work/my-repo",
+ });
+
+ expect(r.projectId).toBe("my-repo");
+ expect(r.projectPath).toBe("/Users/test/work/my-repo");
+});
diff --git a/test/store-gateway.test.ts b/test/store-gateway.test.ts
new file mode 100644
index 0000000..088589c
--- /dev/null
+++ b/test/store-gateway.test.ts
@@ -0,0 +1,123 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { storeMessage, storeBlocks, storeSession, storeCosts } from "../src/ingest/store-gateway";
+import type { MessageBlock } from "../src/ingest/types";
+
+let db: Database;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+});
+
+afterEach(() => {
+ db.close();
+});
+
+test("storeSession upserts project and session meta", () => {
+ storeSession(db, "s1", "codex", "proj-1", "/tmp/proj-1");
+
+ const p = db
+ .prepare("SELECT id, path FROM smriti_projects WHERE id = ?")
+ .get("proj-1") as { id: string; path: string } | null;
+ expect(p).not.toBeNull();
+ expect(p!.path).toBe("/tmp/proj-1");
+
+ const sm = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("s1") as { session_id: string; agent_id: string; project_id: string } | null;
+ expect(sm).not.toBeNull();
+ expect(sm!.agent_id).toBe("codex");
+ expect(sm!.project_id).toBe("proj-1");
+});
+
+test("storeMessage writes memory message", async () => {
+ const now = new Date().toISOString();
+ db.prepare(`INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(
+ "s-msg",
+ "msg session",
+ now,
+ now
+ );
+
+ const r = await storeMessage(db, "s-msg", "user", "hello world", { source: "test" });
+ expect(r.success).toBe(true);
+ expect(r.messageId).toBeGreaterThan(0);
+
+ const row = db
+ .prepare("SELECT session_id, role, content FROM memory_messages WHERE id = ?")
+ .get(r.messageId) as { session_id: string; role: string; content: string } | null;
+
+ expect(row).not.toBeNull();
+ expect(row!.session_id).toBe("s-msg");
+ expect(row!.role).toBe("user");
+ expect(row!.content).toBe("hello world");
+});
+
+test("storeBlocks writes sidecar rows by block type", () => {
+ const now = new Date().toISOString();
+ const sessionId = "s-side";
+ db.prepare(`INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(
+ sessionId,
+ "sidecar session",
+ now,
+ now
+ );
+ db.prepare(
+ `INSERT INTO memory_messages (id, session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?, ?)`
+ ).run(100, sessionId, "assistant", "sidecar payload", "h-side", now);
+ const msgId = 100;
+
+ const blocks: MessageBlock[] = [
+ { type: "tool_call", toolId: "t1", toolName: "Read", input: { file_path: "a.ts" } },
+ { type: "file_op", operation: "write", path: "src/a.ts" },
+ { type: "command", command: "git status", isGit: true },
+ { type: "git", operation: "commit", message: "feat: add" },
+ { type: "error", errorType: "tool_failure", message: "boom" },
+ ];
+
+ storeBlocks(db, msgId, sessionId, "proj-x", blocks, now);
+
+ const toolRows = db.prepare("SELECT COUNT(*) as c FROM smriti_tool_usage WHERE message_id = ?").get(msgId) as { c: number };
+ const fileRows = db.prepare("SELECT COUNT(*) as c FROM smriti_file_operations WHERE message_id = ?").get(msgId) as { c: number };
+ const cmdRows = db.prepare("SELECT COUNT(*) as c FROM smriti_commands WHERE message_id = ?").get(msgId) as { c: number };
+ const gitRows = db.prepare("SELECT COUNT(*) as c FROM smriti_git_operations WHERE message_id = ?").get(msgId) as { c: number };
+ const errRows = db.prepare("SELECT COUNT(*) as c FROM smriti_errors WHERE message_id = ?").get(msgId) as { c: number };
+
+ expect(toolRows.c).toBe(1);
+ expect(fileRows.c).toBe(1);
+ expect(cmdRows.c).toBe(1);
+ expect(gitRows.c).toBe(1);
+ expect(errRows.c).toBe(1);
+});
+
+test("storeCosts accumulates into smriti_session_costs", () => {
+ storeCosts(db, "s-cost", "model-a", 10, 5, 2, 1000);
+ storeCosts(db, "s-cost", "model-a", 20, 10, 0, 500);
+
+ const row = db
+ .prepare(
+ `SELECT total_input_tokens, total_output_tokens, total_cache_tokens, turn_count, total_duration_ms
+ FROM smriti_session_costs
+ WHERE session_id = ? AND model = ?`
+ )
+ .get("s-cost", "model-a") as {
+ total_input_tokens: number;
+ total_output_tokens: number;
+ total_cache_tokens: number;
+ turn_count: number;
+ total_duration_ms: number;
+ } | null;
+
+ expect(row).not.toBeNull();
+ expect(row!.total_input_tokens).toBe(30);
+ expect(row!.total_output_tokens).toBe(15);
+ expect(row!.total_cache_tokens).toBe(2);
+ expect(row!.turn_count).toBe(2);
+ expect(row!.total_duration_ms).toBe(1500);
+});
From 223795b36a507681f5a58e87089b3790711ce723 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:23:06 +0530
Subject: [PATCH 02/20] fix(ci): bench scorecard ci windows fixes (#34)
---
.github/workflows/ci.yml | 30 ++++++++--
.github/workflows/dev-draft-release.yml | 73 +++++++++++++++++++++++++
2 files changed, 99 insertions(+), 4 deletions(-)
create mode 100644 .github/workflows/dev-draft-release.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7aad086..aed66eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,31 @@ on:
branches: [main, dev]
jobs:
- test:
+ test-pr:
+ if: github.event_name == 'pull_request'
+ name: Test (ubuntu-latest)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run tests
+ # Fast PR validation on Linux only.
+ run: bun test test/
+
+ test-merge:
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
@@ -30,7 +54,5 @@ jobs:
run: bun install
- name: Run tests
- # We only run tests in the smriti/test directory.
- # qmd/ tests are skipped here as they are part of the backbone submodule
- # and may have heavy dependencies (like local LLMs) that the runner lacks.
+ # Full cross-platform test matrix for merge branches.
run: bun test test/
diff --git a/.github/workflows/dev-draft-release.yml b/.github/workflows/dev-draft-release.yml
new file mode 100644
index 0000000..15258bb
--- /dev/null
+++ b/.github/workflows/dev-draft-release.yml
@@ -0,0 +1,73 @@
+name: Dev Draft Release
+
+on:
+ workflow_run:
+ workflows: ["CI"]
+ types: [completed]
+
+jobs:
+ draft-release:
+ name: Create/Update Dev Draft Release
+ if: >
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_branch == 'dev'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout dev commit
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Compute dev tag
+ id: tag
+ run: |
+ BASE_VERSION=$(node -p "require('./package.json').version")
+ DEV_SUFFIX="dev.${{ github.event.workflow_run.run_number }}"
+ DEV_TAG="v${BASE_VERSION}-${DEV_SUFFIX}"
+ echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
+
+ - name: Remove previous dev draft releases
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ per_page: 100,
+ });
+
+ for (const release of releases) {
+ const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
+ if (isDevTag && release.draft) {
+ await github.rest.repos.deleteRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: release.id,
+ });
+ }
+ }
+
+ - name: Remove previous dev tags
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ for tag in $(git tag --list 'v*-dev.*'); do
+ git push origin ":refs/tags/${tag}" || true
+ done
+
+ - name: Create dev draft prerelease
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.dev_tag }}
+ target_commitish: ${{ github.event.workflow_run.head_sha }}
+ name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
+ generate_release_notes: true
+ draft: true
+ prerelease: true
From 9256e031ec1cf320a52ecba90dd31da8bc6a1269 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:25:13 +0530
Subject: [PATCH 03/20] ci: auto-template and title for dev to main PRs
---
.github/PULL_REQUEST_TEMPLATE/dev-to-main.md | 19 +++++++
.github/workflows/dev-main-pr-template.yml | 57 ++++++++++++++++++++
2 files changed, 76 insertions(+)
create mode 100644 .github/PULL_REQUEST_TEMPLATE/dev-to-main.md
create mode 100644 .github/workflows/dev-main-pr-template.yml
diff --git a/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md b/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md
new file mode 100644
index 0000000..7a88dd0
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md
@@ -0,0 +1,19 @@
+## Release Summary
+- Version: `v`
+- Source: `dev`
+- Target: `main`
+- Scope: promote validated changes from `dev` to `main`
+
+## Changes Included
+
+- _Auto-filled by workflow from PR commits._
+
+
+## Validation
+- [ ] CI passed on `dev`
+- [ ] Perf bench reviewed (if relevant)
+- [ ] Breaking changes documented
+- [ ] Release notes verified
+
+## Notes
+- Replace or extend this section with any release-specific context.
diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml
new file mode 100644
index 0000000..26321e2
--- /dev/null
+++ b/.github/workflows/dev-main-pr-template.yml
@@ -0,0 +1,57 @@
+name: Dev->Main PR Autofill
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize]
+ branches: [main]
+
+jobs:
+ autofill:
+ if: github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Auto-set title and body
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require("fs");
+ const path = ".github/PULL_REQUEST_TEMPLATE/dev-to-main.md";
+ const template = fs.readFileSync(path, "utf8");
+
+ const { owner, repo } = context.repo;
+ const pull_number = context.payload.pull_request.number;
+
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
+ const version = pkg.version || "0.0.0";
+ const title = `release: v${version} (dev -> main)`;
+
+ const commits = await github.paginate(github.rest.pulls.listCommits, {
+ owner,
+ repo,
+ pull_number,
+ per_page: 100,
+ });
+ const commitLines = commits.map((c) => `- ${c.commit.message.split("\n")[0]} (${c.sha.slice(0, 7)})`);
+ const commitsText = commitLines.length ? commitLines.join("\n") : "- No commits found.";
+
+ const body = template
+ .replace("`v`", `v${version}`)
+ .replace(
+ /[\s\S]*?/m,
+ `\n${commitsText}\n`
+ );
+
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number,
+ title,
+ body,
+ });
From 06fb3a1bbe0f68bbf4b363e296094b2d47a07517 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:28:05 +0530
Subject: [PATCH 04/20] release: v0.3.2 (dev -> main) (#35)
* New branch (#33)
* chore: add pending project files
* refactor(ingest): centralize ingestion via parser/resolver/store layers
* docs: document layered ingest architecture
* test(perf): add qmd benchmark harness and non-blocking CI regression check
* perf(bench): add ingest hotpath benchmark and record qmd optimization
* perf(ingest): batch session writes and add stable benchmark tooling
* Add benchmark scorecard to CI summary and sticky PR comment
* Fix bench import path and temporarily disable design-contract workflow
* CI: checkout qmd submodule in perf bench workflow
* Fix Windows path handling in ingest session discovery
* Feature/bench scorecard ci windows fixes (#34)
* chore: add pending project files
* refactor(ingest): centralize ingestion via parser/resolver/store layers
* docs: document layered ingest architecture
* test(perf): add qmd benchmark harness and non-blocking CI regression check
* perf(bench): add ingest hotpath benchmark and record qmd optimization
* perf(ingest): batch session writes and add stable benchmark tooling
* Add benchmark scorecard to CI summary and sticky PR comment
* Fix bench import path and temporarily disable design-contract workflow
* CI: checkout qmd submodule in perf bench workflow
* Fix Windows path handling in ingest session discovery
* CI: run full test matrix only on merge branches
* CI: auto-create draft prerelease on successful dev CI
* CI: auto-template and title for dev to main PRs
---
.agents/skills/design-contracts/SKILL.md | 116 ++
.github/PULL_REQUEST_TEMPLATE/dev-to-main.md | 19 +
.github/workflows/ci.yml | 30 +-
.github/workflows/dev-draft-release.yml | 73 +
.github/workflows/dev-main-pr-template.yml | 57 +
.github/workflows/perf-bench.yml | 105 ++
.github/workflows/validate-design.yml | 36 +
CLAUDE.md | 29 +-
INGEST_ARCHITECTURE.md | 48 +
README.md | 12 +
bench/baseline.ci-small.json | 33 +
bench/report.schema.json | 58 +
bench/results/ci- | 33 +
bench/results/ci-small.local.json | 33 +
docs/DESIGN.md | 164 +++
docs/search-recall-architecture.md | 678 +++++++++
majestic-sauteeing-papert.md | 405 ++++++
package.json | 8 +-
qmd | 2 +-
scripts/bench-compare.ts | 106 ++
scripts/bench-ingest-hotpaths.ts | 110 ++
scripts/bench-ingest-pipeline.ts | 82 ++
scripts/bench-qmd-repeat.ts | 141 ++
scripts/bench-qmd.ts | 219 +++
scripts/bench-scorecard.ts | 129 ++
scripts/validate-design.ts | 252 ++++
src/ingest/README.md | 27 +
src/ingest/claude.ts | 212 +--
src/ingest/cline.ts | 189 +--
src/ingest/codex.ts | 68 +-
src/ingest/copilot.ts | 80 +-
src/ingest/cursor.ts | 72 +-
src/ingest/generic.ts | 58 +-
src/ingest/index.ts | 278 +++-
src/ingest/parsers/claude.ts | 48 +
src/ingest/parsers/cline.ts | 150 ++
src/ingest/parsers/codex.ts | 21 +
src/ingest/parsers/copilot.ts | 21 +
src/ingest/parsers/cursor.ts | 21 +
src/ingest/parsers/generic.ts | 44 +
src/ingest/parsers/index.ts | 7 +
src/ingest/parsers/types.ts | 14 +
src/ingest/session-resolver.ts | 88 ++
src/ingest/store-gateway.ts | 127 ++
src/qmd.ts | 6 +-
streamed-humming-curry.md | 1320 ++++++++++++++++++
test/ingest-claude-orchestrator.test.ts | 118 ++
test/ingest-orchestrator.test.ts | 83 ++
test/ingest-parsers.test.ts | 149 ++
test/ingest-pipeline.test.ts | 157 +++
test/session-resolver.test.ts | 83 ++
test/store-gateway.test.ts | 123 ++
52 files changed, 5876 insertions(+), 666 deletions(-)
create mode 100644 .agents/skills/design-contracts/SKILL.md
create mode 100644 .github/PULL_REQUEST_TEMPLATE/dev-to-main.md
create mode 100644 .github/workflows/dev-draft-release.yml
create mode 100644 .github/workflows/dev-main-pr-template.yml
create mode 100644 .github/workflows/perf-bench.yml
create mode 100644 .github/workflows/validate-design.yml
create mode 100644 INGEST_ARCHITECTURE.md
create mode 100644 bench/baseline.ci-small.json
create mode 100644 bench/report.schema.json
create mode 100644 bench/results/ci-
create mode 100644 bench/results/ci-small.local.json
create mode 100644 docs/DESIGN.md
create mode 100644 docs/search-recall-architecture.md
create mode 100644 majestic-sauteeing-papert.md
create mode 100644 scripts/bench-compare.ts
create mode 100644 scripts/bench-ingest-hotpaths.ts
create mode 100644 scripts/bench-ingest-pipeline.ts
create mode 100644 scripts/bench-qmd-repeat.ts
create mode 100644 scripts/bench-qmd.ts
create mode 100644 scripts/bench-scorecard.ts
create mode 100644 scripts/validate-design.ts
create mode 100644 src/ingest/README.md
create mode 100644 src/ingest/parsers/claude.ts
create mode 100644 src/ingest/parsers/cline.ts
create mode 100644 src/ingest/parsers/codex.ts
create mode 100644 src/ingest/parsers/copilot.ts
create mode 100644 src/ingest/parsers/cursor.ts
create mode 100644 src/ingest/parsers/generic.ts
create mode 100644 src/ingest/parsers/index.ts
create mode 100644 src/ingest/parsers/types.ts
create mode 100644 src/ingest/session-resolver.ts
create mode 100644 src/ingest/store-gateway.ts
create mode 100644 streamed-humming-curry.md
create mode 100644 test/ingest-claude-orchestrator.test.ts
create mode 100644 test/ingest-orchestrator.test.ts
create mode 100644 test/ingest-parsers.test.ts
create mode 100644 test/ingest-pipeline.test.ts
create mode 100644 test/session-resolver.test.ts
create mode 100644 test/store-gateway.test.ts
diff --git a/.agents/skills/design-contracts/SKILL.md b/.agents/skills/design-contracts/SKILL.md
new file mode 100644
index 0000000..21cbe61
--- /dev/null
+++ b/.agents/skills/design-contracts/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: design-contracts
+description: Enforces smriti's three design contracts (observability, dry-run, versioning) when writing or modifying CLI command handlers or JSON output.
+---
+
+# smriti Design Contract Guardrails
+
+This skill activates whenever you are **adding or modifying a CLI command**,
+**changing JSON output**, **touching telemetry/logging code**, or **altering
+config defaults** in the smriti project.
+
+---
+
+## Contract 1 — Dry Run
+
+### Mutating commands MUST support `--dry-run`
+
+The following commands write to disk, the database, or the network. Every one of
+them **must** honour `--dry-run`:
+
+| Command | Expected guard pattern |
+| ------------ | ----------------------------------------------------------------------------- |
+| `ingest` | `const dryRun = hasFlag(args, "--dry-run");` then no DB/file writes when true |
+| `embed` | same |
+| `categorize` | same |
+| `tag` | same |
+| `share` | same |
+| `sync` | same |
+| `context` | already implemented — keep it |
+
+When `--dry-run` is active:
+
+- `stdout` must describe **what would happen** (e.g. `Would ingest N sessions`).
+- `stderr` must note what was skipped (`No changes were made (--dry-run)`).
+- Exit code follows normal success/error rules — dry-run is NOT an error.
+- If `--json` is also set, the output envelope must include
+ `"meta": { "dry_run": true }`.
+
+### Read-only commands MUST reject `--dry-run`
+
+These commands never mutate state. If they receive `--dry-run`, they must print
+a usage error and `process.exit(1)`:
+
+`search`, `recall`, `list`, `status`, `show`, `compare`, `projects`, `team`,
+`categories`
+
+---
+
+## Contract 2 — Observability / Telemetry
+
+### Never log user content
+
+The following are **forbidden** in any `console.log`, `console.error`, or
+log/audit output:
+
+- Message content (`.content`, `.text`, `.body`)
+- Query strings passed by the user
+- Memory text or embedding data
+- File paths provided by the user (as opposed to system-derived paths)
+
+✅ OK to log: command name, exit code, duration, session IDs, counts, smriti
+version.
+
+### Telemetry default must be OFF
+
+- `SMRITI_TELEMETRY` must default to `0`/`false`/`"off"` — never `1`.
+- Telemetry calls must be guarded: `if (telemetryEnabled) { ... }`.
+- Any new telemetry signal must be added to `smriti telemetry sample` output.
+
+---
+
+## Contract 3 — JSON & CLI Versioning
+
+### JSON output is a hard contract
+
+The standard output envelope is:
+
+```json
+{ "ok": true, "data": { ... }, "meta": { ... } }
+```
+
+Rules:
+
+- **Never remove a field** from `data` or `meta` — add `@deprecated` in a
+ comment instead.
+- **Never rename a field**.
+- **Never change a field's type** (e.g. string → number).
+- New fields in `data` or `meta` must be **optional**.
+- If you must replace a field: add the new one AND keep the old one with a
+ `_deprecated: true` sibling or comment.
+
+### CLI interface stability
+
+Once a command or flag has shipped:
+
+- **Command names**: frozen.
+- **Flag names**: frozen. You may add aliases (e.g. `--dry-run` → `-n`) but not
+ rename.
+- **Positional argument order**: frozen.
+- **Deprecated flags**: must keep working, must emit a `stderr` warning.
+
+---
+
+## Pre-Submission Checklist
+
+Before finishing any edit that touches `src/index.ts` or a command handler:
+
+- [ ] If command is mutating → `--dry-run` is supported and guarded
+- [ ] If command is read-only → `--dry-run` is rejected with a usage error
+- [ ] No user-supplied content appears in `console.log`/`console.error`
+- [ ] If JSON output changed → only fields were **added**, not
+ removed/renamed/retyped
+- [ ] If a new flag was added → it does not conflict with any existing flag name
+- [ ] Telemetry default remains off in `config.ts`
+
+If any item fails, fix it before proceeding.
diff --git a/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md b/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md
new file mode 100644
index 0000000..7a88dd0
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/dev-to-main.md
@@ -0,0 +1,19 @@
+## Release Summary
+- Version: `v`
+- Source: `dev`
+- Target: `main`
+- Scope: promote validated changes from `dev` to `main`
+
+## Changes Included
+
+- _Auto-filled by workflow from PR commits._
+
+
+## Validation
+- [ ] CI passed on `dev`
+- [ ] Perf bench reviewed (if relevant)
+- [ ] Breaking changes documented
+- [ ] Release notes verified
+
+## Notes
+- Replace or extend this section with any release-specific context.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7aad086..aed66eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,31 @@ on:
branches: [main, dev]
jobs:
- test:
+ test-pr:
+ if: github.event_name == 'pull_request'
+ name: Test (ubuntu-latest)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run tests
+ # Fast PR validation on Linux only.
+ run: bun test test/
+
+ test-merge:
+ if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
@@ -30,7 +54,5 @@ jobs:
run: bun install
- name: Run tests
- # We only run tests in the smriti/test directory.
- # qmd/ tests are skipped here as they are part of the backbone submodule
- # and may have heavy dependencies (like local LLMs) that the runner lacks.
+ # Full cross-platform test matrix for merge branches.
run: bun test test/
diff --git a/.github/workflows/dev-draft-release.yml b/.github/workflows/dev-draft-release.yml
new file mode 100644
index 0000000..15258bb
--- /dev/null
+++ b/.github/workflows/dev-draft-release.yml
@@ -0,0 +1,73 @@
+name: Dev Draft Release
+
+on:
+ workflow_run:
+ workflows: ["CI"]
+ types: [completed]
+
+jobs:
+ draft-release:
+ name: Create/Update Dev Draft Release
+ if: >
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_branch == 'dev'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout dev commit
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Compute dev tag
+ id: tag
+ run: |
+ BASE_VERSION=$(node -p "require('./package.json').version")
+ DEV_SUFFIX="dev.${{ github.event.workflow_run.run_number }}"
+ DEV_TAG="v${BASE_VERSION}-${DEV_SUFFIX}"
+ echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
+
+ - name: Remove previous dev draft releases
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ per_page: 100,
+ });
+
+ for (const release of releases) {
+ const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
+ if (isDevTag && release.draft) {
+ await github.rest.repos.deleteRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: release.id,
+ });
+ }
+ }
+
+ - name: Remove previous dev tags
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ for tag in $(git tag --list 'v*-dev.*'); do
+ git push origin ":refs/tags/${tag}" || true
+ done
+
+ - name: Create dev draft prerelease
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.dev_tag }}
+ target_commitish: ${{ github.event.workflow_run.head_sha }}
+ name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
+ generate_release_notes: true
+ draft: true
+ prerelease: true
diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml
new file mode 100644
index 0000000..26321e2
--- /dev/null
+++ b/.github/workflows/dev-main-pr-template.yml
@@ -0,0 +1,57 @@
+name: Dev->Main PR Autofill
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize]
+ branches: [main]
+
+jobs:
+ autofill:
+ if: github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Auto-set title and body
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require("fs");
+ const path = ".github/PULL_REQUEST_TEMPLATE/dev-to-main.md";
+ const template = fs.readFileSync(path, "utf8");
+
+ const { owner, repo } = context.repo;
+ const pull_number = context.payload.pull_request.number;
+
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
+ const version = pkg.version || "0.0.0";
+ const title = `release: v${version} (dev -> main)`;
+
+ const commits = await github.paginate(github.rest.pulls.listCommits, {
+ owner,
+ repo,
+ pull_number,
+ per_page: 100,
+ });
+ const commitLines = commits.map((c) => `- ${c.commit.message.split("\n")[0]} (${c.sha.slice(0, 7)})`);
+ const commitsText = commitLines.length ? commitLines.join("\n") : "- No commits found.";
+
+ const body = template
+ .replace("`v`", `v${version}`)
+ .replace(
+ /[\s\S]*?/m,
+ `\n${commitsText}\n`
+ );
+
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number,
+ title,
+ body,
+ });
diff --git a/.github/workflows/perf-bench.yml b/.github/workflows/perf-bench.yml
new file mode 100644
index 0000000..aee933d
--- /dev/null
+++ b/.github/workflows/perf-bench.yml
@@ -0,0 +1,105 @@
+name: Perf Bench (Non-blocking)
+
+on:
+ pull_request:
+ branches: [main, dev]
+ paths:
+ - "src/**"
+ - "qmd/src/**"
+ - "scripts/bench-*.ts"
+ - "bench/**"
+ - ".github/workflows/perf-bench.yml"
+ push:
+ branches: [main, dev, "feature/**"]
+ paths:
+ - "src/**"
+ - "qmd/src/**"
+ - "scripts/bench-*.ts"
+ - "bench/**"
+ - ".github/workflows/perf-bench.yml"
+
+jobs:
+ bench:
+ name: Run ci-small benchmark
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run benchmark (no-llm)
+ run: bun run scripts/bench-qmd.ts --profile ci-small --out bench/results/ci-small.json --no-llm
+
+ - name: Run repeated benchmark (ci-small)
+ run: bun run scripts/bench-qmd-repeat.ts --profiles ci-small --runs 3 --out bench/results/repeat-summary.json
+
+ - name: Compare against baseline (non-blocking)
+ run: bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2
+
+ - name: Generate scorecard markdown
+ run: bun run bench:scorecard > bench/results/scorecard.md
+
+ - name: Add scorecard to run summary
+ run: |
+ echo "## Benchmark Scorecard (ci-small)" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ cat bench/results/scorecard.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upsert sticky PR comment
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require("fs");
+ const body = fs.readFileSync("bench/results/scorecard.md", "utf8");
+ const marker = "";
+ const fullBody = `${marker}
+ ## Benchmark Scorecard (ci-small)
+
+ ${body}`;
+ const { owner, repo } = context.repo;
+ const issue_number = context.payload.pull_request.number;
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number,
+ per_page: 100,
+ });
+ const existing = comments.find((c) => c.body && c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existing.id,
+ body: fullBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body: fullBody,
+ });
+ }
+
+ - name: Upload benchmark artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bench-ci-small
+ path: |
+ bench/results/ci-small.json
+ bench/results/repeat-summary.json
+ bench/results/scorecard.md
diff --git a/.github/workflows/validate-design.yml b/.github/workflows/validate-design.yml
new file mode 100644
index 0000000..294ff51
--- /dev/null
+++ b/.github/workflows/validate-design.yml
@@ -0,0 +1,36 @@
+name: Design Contracts
+
+on:
+ push:
+ branches: [main, dev, "feature/**"]
+ paths:
+ - "src/**"
+ - "scripts/validate-design.ts"
+ - "docs/DESIGN.md"
+ pull_request:
+ branches: [main, dev]
+ paths:
+ - "src/**"
+ - "scripts/validate-design.ts"
+ - "docs/DESIGN.md"
+
+jobs:
+ validate:
+ name: Validate Design Contracts
+ if: ${{ false }} # Temporarily disabled while validator rules are being aligned with current CLI behavior.
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run design contract validator
+ run: bun run scripts/validate-design.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 2697f50..39aa414 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,13 +34,16 @@ src/
├── qmd.ts # Centralized re-exports from QMD package
├── format.ts # Output formatting (JSON, CSV, CLI)
├── ingest/
-│ ├── index.ts # Ingest orchestrator + types
-│ ├── claude.ts # Claude Code JSONL parser + project detection
-│ ├── codex.ts # Codex CLI parser
-│ ├── cursor.ts # Cursor IDE parser
-│ ├── cline.ts # Cline CLI parser (enriched blocks)
-│ ├── copilot.ts # GitHub Copilot (VS Code) parser
-│ └── generic.ts # File import (chat/jsonl formats)
+│ ├── index.ts # Orchestrator (parser -> resolver -> store)
+│ ├── parsers/ # Pure agent parsers (no DB writes)
+│ ├── session-resolver.ts # Project/session resolution + incremental state
+│ ├── store-gateway.ts # Centralized ingest persistence
+│ ├── claude.ts # Discovery + compatibility wrapper
+│ ├── codex.ts # Discovery + compatibility wrapper
+│ ├── cursor.ts # Discovery + compatibility wrapper
+│ ├── cline.ts # Discovery + compatibility wrapper
+│ ├── copilot.ts # Discovery + compatibility wrapper
+│ └── generic.ts # File import compatibility wrapper
├── search/
│ ├── index.ts # Filtered FTS search + session listing
│ └── recall.ts # Recall with synthesis
@@ -95,11 +98,13 @@ get a clean name like `openfga`.
### Ingestion Pipeline
-1. Discover sessions (glob for JSONL/JSON files)
-2. Deduplicate against `smriti_session_meta`
-3. Parse agent-specific format → `ParsedMessage[]`
-4. Save via QMD's `addMessage()` (content-addressable, SHA256 hashed)
-5. Attach Smriti metadata (agent, project, categories)
+1. Discover sessions (agent modules)
+2. Parse session content (pure parser layer)
+3. Resolve project/session state (resolver layer)
+4. Store message/meta/sidecars/costs (store gateway)
+5. Aggregate results and continue on per-session errors (orchestrator)
+
+See `INGEST_ARCHITECTURE.md` for details.
### Search
diff --git a/INGEST_ARCHITECTURE.md b/INGEST_ARCHITECTURE.md
new file mode 100644
index 0000000..9af1d05
--- /dev/null
+++ b/INGEST_ARCHITECTURE.md
@@ -0,0 +1,48 @@
+# Ingest Architecture
+
+Smriti ingest now follows a layered architecture with explicit boundaries.
+
+## Layers
+
+1. Parser Layer (`src/ingest/parsers/*`)
+- Agent-specific extraction only.
+- Reads source transcripts and returns normalized parsed sessions/messages.
+- No database writes.
+
+2. Session Resolver (`src/ingest/session-resolver.ts`)
+- Resolves `projectId`/`projectPath` from agent + path.
+- Handles explicit project overrides.
+- Computes `isNew` and `existingMessageCount` for incremental ingest.
+
+3. Store Gateway (`src/ingest/store-gateway.ts`)
+- Central write path for persistence.
+- Stores messages, sidecar blocks, session meta, and costs.
+- Encapsulates database write behavior.
+
+4. Orchestrator (`src/ingest/index.ts`)
+- Composes parser -> resolver -> gateway.
+- Handles result aggregation, per-session error handling, progress reporting.
+- Controls incremental behavior (Claude append-only transcripts).
+
+## Why this structure
+
+- Testability: each layer can be tested independently.
+- Maintainability: persistence logic is centralized.
+- Extensibility: new agents mostly require parser/discovery only.
+- Reliability: incremental and project resolution behavior are explicit.
+
+## Current behavior
+
+- `claude`/`claude-code`: incremental ingest based on existing message count.
+- `codex`, `cursor`, `cline`, `copilot`, `generic/file`: orchestrated through the same pipeline.
+- Legacy `ingest*` functions in agent modules remain as compatibility wrappers and delegate to orchestrator.
+
+## Verification
+
+Architecture is covered by focused tests:
+- `test/ingest-parsers.test.ts`
+- `test/session-resolver.test.ts`
+- `test/store-gateway.test.ts`
+- `test/ingest-orchestrator.test.ts`
+- `test/ingest-claude-orchestrator.test.ts`
+- `test/ingest-pipeline.test.ts`
diff --git a/README.md b/README.md
index 643b0c1..ba9e970 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,18 @@ Claude Code Cursor Codex Other Agents
Everything runs locally. Your conversations never leave your machine. The SQLite
database, the embeddings, the search indexes — all on disk, all yours.
+## Ingest Architecture
+
+Smriti ingest uses a layered pipeline:
+
+1. `parsers/*` extract agent transcripts into normalized messages (no DB writes).
+2. `session-resolver` derives project/session state, including incremental offsets.
+3. `store-gateway` persists messages, sidecars, session meta, and costs.
+4. `ingest/index.ts` orchestrates the flow with per-session error isolation.
+
+This keeps parser logic, resolution logic, and persistence logic separated and testable.
+See `INGEST_ARCHITECTURE.md` and `src/ingest/README.md` for implementation details.
+
## Tagging & Categories
Sessions and messages are automatically tagged into a hierarchical category
diff --git a/bench/baseline.ci-small.json b/bench/baseline.ci-small.json
new file mode 100644
index 0000000..e54df28
--- /dev/null
+++ b/bench/baseline.ci-small.json
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:24:05.100Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-4oQzBo/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 1735.8,
+ "ingest_p95_ms_per_session": 6.96,
+ "fts": {
+ "p50_ms": 0.385,
+ "p95_ms": 0.41,
+ "mean_ms": 0.387,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.405,
+ "p95_ms": 0.436,
+ "mean_ms": 0.41,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/bench/report.schema.json b/bench/report.schema.json
new file mode 100644
index 0000000..d17bc14
--- /dev/null
+++ b/bench/report.schema.json
@@ -0,0 +1,58 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Smriti QMD Benchmark Report",
+ "type": "object",
+ "required": ["profile", "mode", "generated_at", "corpus", "metrics", "counts"],
+ "properties": {
+ "profile": { "type": "string" },
+ "mode": { "enum": ["no-llm", "llm"] },
+ "generated_at": { "type": "string" },
+ "db_path": { "type": "string" },
+ "corpus": {
+ "type": "object",
+ "required": ["sessions", "messages_per_session", "total_messages"],
+ "properties": {
+ "sessions": { "type": "integer" },
+ "messages_per_session": { "type": "integer" },
+ "total_messages": { "type": "integer" }
+ }
+ },
+ "metrics": {
+ "type": "object",
+ "required": ["ingest_throughput_msgs_per_sec", "ingest_p95_ms_per_session", "fts", "recall", "vector"],
+ "properties": {
+ "ingest_throughput_msgs_per_sec": { "type": "number" },
+ "ingest_p95_ms_per_session": { "type": "number" },
+ "fts": { "$ref": "#/$defs/timed" },
+ "recall": { "$ref": "#/$defs/timed" },
+ "vector": {
+ "oneOf": [
+ { "$ref": "#/$defs/timed" },
+ { "type": "null" }
+ ]
+ }
+ }
+ },
+ "counts": {
+ "type": "object",
+ "required": ["memory_sessions", "memory_messages", "content_vectors"],
+ "properties": {
+ "memory_sessions": { "type": "integer" },
+ "memory_messages": { "type": "integer" },
+ "content_vectors": { "type": "integer" }
+ }
+ }
+ },
+ "$defs": {
+ "timed": {
+ "type": "object",
+ "required": ["p50_ms", "p95_ms", "mean_ms", "runs"],
+ "properties": {
+ "p50_ms": { "type": "number" },
+ "p95_ms": { "type": "number" },
+ "mean_ms": { "type": "number" },
+ "runs": { "type": "integer" }
+ }
+ }
+ }
+}
diff --git a/bench/results/ci- b/bench/results/ci-
new file mode 100644
index 0000000..c566552
--- /dev/null
+++ b/bench/results/ci-
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:29:58.917Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-eEc2Yu/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 865.18,
+ "ingest_p95_ms_per_session": 16.656,
+ "fts": {
+ "p50_ms": 0.369,
+ "p95_ms": 0.397,
+ "mean_ms": 0.371,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.393,
+ "p95_ms": 0.415,
+ "mean_ms": 0.393,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/bench/results/ci-small.local.json b/bench/results/ci-small.local.json
new file mode 100644
index 0000000..e54df28
--- /dev/null
+++ b/bench/results/ci-small.local.json
@@ -0,0 +1,33 @@
+{
+ "profile": "ci-small",
+ "mode": "no-llm",
+ "generated_at": "2026-02-27T09:24:05.100Z",
+ "db_path": "/var/folders/j1/s6qncpk92jz_10cmv04m338r0000gn/T/smriti-bench-4oQzBo/bench.sqlite",
+ "corpus": {
+ "sessions": 40,
+ "messages_per_session": 10,
+ "total_messages": 400
+ },
+ "metrics": {
+ "ingest_throughput_msgs_per_sec": 1735.8,
+ "ingest_p95_ms_per_session": 6.96,
+ "fts": {
+ "p50_ms": 0.385,
+ "p95_ms": 0.41,
+ "mean_ms": 0.387,
+ "runs": 30
+ },
+ "recall": {
+ "p50_ms": 0.405,
+ "p95_ms": 0.436,
+ "mean_ms": 0.41,
+ "runs": 30
+ },
+ "vector": null
+ },
+ "counts": {
+ "memory_sessions": 40,
+ "memory_messages": 400,
+ "content_vectors": 0
+ }
+}
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 0000000..f0aed02
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,164 @@
+Observability & Telemetry
+
+Principles
+
+Observability exists to help the user and the system understand behavior, never to surveil.
+
+Rules:
+ • Telemetry is opt-in only. Default is off.
+ • No user content (messages, memory text, embeddings) is ever logged.
+ • No network calls for analytics unless explicitly enabled.
+ • Observability must never change command semantics or performance guarantees.
+
+Local Observability (Always On)
+
+These are local-only and require no consent:
+ • --verbose : additional execution detail (phases, timings)
+ • --debug : stack traces, SQL, internal state
+ • meta.duration_ms : execution timing included in JSON output
+
+Telemetry (Opt-In)
+
+If enabled by the user (smriti telemetry enable or SMRITI_TELEMETRY=1):
+
+Collected signals (aggregated, anonymous):
+ • Command name
+ • Exit code
+ • Execution duration bucket
+ • Smriti version
+
+Explicitly NOT collected:
+ • Arguments values
+ • Query text
+ • Memory content
+ • File paths
+ • User identifiers
+
+Telemetry must be:
+ • Documented (smriti telemetry status)
+ • Inspectable (smriti telemetry sample)
+ • Disable-able at any time (smriti telemetry disable)
+
+Audit Logs (Optional)
+
+For enterprise / shared usage:
+ • Optional local audit log (~/.smriti/audit.log)
+ • Records: timestamp, command, exit code, actor (human / agent id)
+ • Never enabled by default
+
+⸻
+
+Dry Run & Simulation
+
+Dry Run Contract
+
+Any command that mutates state must support --dry-run.
+
+--dry-run guarantees:
+ • No database writes
+ • No file writes
+ • No network side effects
+ • Full validation and planning still run
+
+Dry-run answers the question:
+
+“What would happen if I ran this?”
+
+Dry Run Output Rules
+
+In --dry-run mode:
+ • stdout shows the planned changes
+ • stderr shows what was skipped due to dry-run
+ • Exit code follows normal rules (0 / 3 / 4)
+
+Example:
+
+Would ingest 12 new sessions
+Would skip 38 existing sessions
+No changes were made (--dry-run)
+
+In JSON mode:
+
+{
+ "ok": true,
+ "data": {
+ "would_ingest": 12,
+ "would_skip": 38
+ },
+ "meta": {
+ "dry_run": true
+ }
+}
+
+Required Coverage
+
+Commands that MUST support --dry-run:
+ • ingest
+ • embed
+ • categorize
+ • tag
+ • share
+ • sync
+ • context
+
+Read-only commands MUST reject --dry-run with usage error.
+
+⸻
+
+Versioning & Backward Compatibility
+
+Semantic Versioning
+
+Smriti follows SemVer:
+ • MAJOR: Breaking CLI or JSON contract changes
+ • MINOR: New commands, flags, fields (additive only)
+ • PATCH: Bug fixes, performance improvements
+
+CLI Interface Stability
+
+Once released:
+ • Command names never change
+ • Flags are never removed
+ • Flags may gain aliases but not be renamed
+ • Positional argument order is frozen
+
+Deprecated behavior:
+ • Continues to work
+ • Emits a warning on stderr
+ • Removed only in next MAJOR version
+
+JSON Schema Stability
+
+JSON output is a hard contract:
+
+Rules:
+ • Fields are only added, never removed
+ • Existing field meaning never changes
+ • Types never change
+ • New fields must be optional
+
+If a field must be replaced:
+ • Add the new field
+ • Mark the old field as deprecated in docs
+ • Keep both for one MAJOR cycle
+
+Manifest Versioning
+
+smriti manifest includes:
+ • CLI version
+ • Manifest schema version
+
+Example:
+
+{
+ "manifest_version": "1.0",
+ "cli_version": "0.4.0"
+}
+
+Agents may branch behavior based on manifest_version.
+
+Data Migration Rules
+ • Stored data schemas may evolve internally
+ • CLI behavior must remain stable across migrations
+ • Migrations must be automatic and idempotent
+ • Migration failures exit with DB_ERROR
diff --git a/docs/search-recall-architecture.md b/docs/search-recall-architecture.md
new file mode 100644
index 0000000..1e9be94
--- /dev/null
+++ b/docs/search-recall-architecture.md
@@ -0,0 +1,678 @@
+# Search & Recall: Architecture, Findings, and Improvement Plan
+
+## Table of Contents
+
+1. [Current Architecture](#current-architecture)
+2. [Execution Paths](#execution-paths)
+3. [Component Deep Dive](#component-deep-dive)
+4. [Findings & Gaps](#findings--gaps)
+5. [Improvement Plan](#improvement-plan)
+
+---
+
+## Current Architecture
+
+### System Layers
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ CLI Layer (src/index.ts) │
+│ Parse args → route to search/recall → format output │
+├─────────────────────────────────────────────────────────────┤
+│ Smriti Layer (src/search/) │
+│ Metadata filtering (project, category, agent) │
+│ Session dedup, synthesis delegation │
+│ searchFiltered() — dynamic SQL with EXISTS subqueries │
+├─────────────────────────────────────────────────────────────┤
+│ QMD Layer (qmd/src/memory.ts, qmd/src/store.ts) │
+│ BM25 FTS5 search (searchMemoryFTS) │
+│ Vector search (searchMemoryVec — EmbeddingGemma) │
+│ RRF fusion (reciprocalRankFusion) │
+│ Ollama synthesis (ollamaRecall) │
+├─────────────────────────────────────────────────────────────┤
+│ Storage Layer (SQLite) │
+│ memory_fts (FTS5) — full-text index │
+│ vectors_vec (vec0) — cosine similarity via sqlite-vec │
+│ content_vectors — chunk metadata (hash, seq, pos) │
+│ smriti_session_meta — project/agent per session │
+│ smriti_*_tags — category tags on messages/sessions │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Model Stack
+
+| Model | Runtime | Size | Purpose | Used In |
+|-------|---------|------|---------|---------|
+| EmbeddingGemma 300M (Q8_0) | node-llama-cpp | ~300MB | Dense vector embeddings | `smriti embed`, vector search |
+| Qwen3-Reranker 0.6B (Q8_0) | node-llama-cpp | ~640MB | Cross-encoder reranking | `qmd query` only — **NOT used in smriti** |
+| qmd-query-expansion 1.7B | node-llama-cpp | ~1.1GB | Query expansion (lex/vec/hyde) | `qmd query` only — **NOT used in smriti** |
+| qwen3:8b-tuned | Ollama (HTTP) | ~4.7GB | Synthesis, summarization, classification | `smriti recall --synthesize`, `smriti share`, `smriti categorize --llm` |
+
+---
+
+## Execution Paths
+
+### `smriti search "query"` — Always FTS-Only
+
+```
+index.ts:210 → searchFiltered(db, query, filters)
+ │
+ ├─ Build dynamic SQL:
+ │ FROM memory_fts mf
+ │ JOIN memory_messages mm ON mm.rowid = mf.rowid
+ │ JOIN memory_sessions ms ON ms.id = mm.session_id
+ │ LEFT JOIN smriti_session_meta sm
+ │ WHERE mf.content MATCH ?
+ │ AND EXISTS(...category filter...)
+ │ AND EXISTS(...project filter...)
+ │ AND EXISTS(...agent filter...)
+ │ ORDER BY (1/(1+ABS(bm25(memory_fts)))) DESC
+ │ LIMIT ?
+ │
+ └─ Return SearchResult[] → formatSearchResults()
+```
+
+**Retrieval**: BM25 only, no vector, no RRF, no reranking.
+
+### `smriti recall "query"` — Two Branches
+
+```
+recall.ts:40 → hasFilters = category || project || agent
+
+┌──────────────────────────────────────────────────────────────┐
+│ Branch A: No Filters → QMD Native (full hybrid) │
+│ │
+│ recallMemories(db, query, opts) │
+│ ├─ searchMemoryFTS() → BM25 results │
+│ ├─ searchMemoryVec() → vector results (EmbeddingGemma) │
+│ ├─ reciprocalRankFusion([fts, vec], [1.0, 1.0]) │
+│ ├─ Session dedup (one best per session) │
+│ └─ [if --synthesize] ollamaRecallSynthesize() │
+├──────────────────────────────────────────────────────────────┤
+│ Branch B: With Filters → FTS Only (loses vectors!) │
+│ │
+│ searchFiltered(db, query, filters) │
+│ └─ Same SQL as search command │
+│ Session dedup via Map │
+│ [if --synthesize] synthesizeResults() → ollamaRecall() │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### Data Flow Through RRF (Unfiltered Recall)
+
+```
+FTS Results (ranked by BM25): Vector Results (ranked by cosine):
+ rank 0: msg_A (score 0.85) rank 0: msg_C (score 0.92)
+ rank 1: msg_B (score 0.71) rank 1: msg_A (score 0.88)
+ rank 2: msg_C (score 0.65) rank 2: msg_D (score 0.76)
+
+RRF (k=60, weights [1.0, 1.0]):
+ msg_A: 1/61 + 1/62 = 0.0326 (in both lists!)
+ msg_C: 1/63 + 1/61 = 0.0322 (in both lists!)
+ msg_B: 1/62 = 0.0161 (FTS only)
+ msg_D: 1/63 = 0.0159 (vec only)
+
+After top-rank bonus:
+ msg_A: 0.0326 + 0.05 = 0.0826 ← rank 0 in FTS
+ msg_C: 0.0322 + 0.05 = 0.0822 ← rank 0 in vec
+ msg_B: 0.0161 + 0.02 = 0.0361 ← rank 1 in FTS
+ msg_D: 0.0159 + 0.02 = 0.0359 ← rank 2 in vec
+
+Final: A > C > B > D
+```
+
+The top-rank bonus (+0.05) dominates — being #1 in either list is worth 3x a single rank contribution.
+
+---
+
+## Component Deep Dive
+
+### 1. FTS5 Query Building
+
+**QMD's `buildMemoryFTS5Query()`** (used in unfiltered recall):
+```typescript
+// "how to configure auth" → '"how"* AND "to"* AND "configure"* AND "auth"*'
+sanitizeMemoryFTSTerm(t) → strip non-alphanumeric, lowercase
+terms.map(t => `"${t}"*`).join(' AND ') // prefix match + boolean AND
+```
+
+**Smriti's `searchFiltered()`** (used in filtered search/recall):
+```typescript
+// Raw user input passed directly to MATCH
+conditions.push(`mf.content MATCH ?`);
+params.push(query); // NO sanitization, NO prefix matching
+```
+
+### 2. BM25 Scoring
+
+```sql
+-- QMD (unfiltered): weighted columns
+bm25(memory_fts, 5.0, 1.0, 1.0) -- title=5x, role=1x, content=1x
+
+-- Smriti (filtered): unweighted
+bm25(memory_fts) -- equal weights on all columns
+```
+
+Both normalize to `(0, 1]`: `score = 1 / (1 + |bm25_score|)`
+
+### 3. Vector Search (Two-Step Pattern)
+
+```
+Step 1: Query vectors_vec directly (NO JOINs — sqlite-vec hangs)
+ SELECT hash_seq, distance FROM vectors_vec
+ WHERE embedding MATCH ? AND k = ?
+ → Returns hash_seq keys like "abc123_0" (hash + chunk index)
+
+Step 2: Normal SQL JOIN using collected hashes
+ SELECT m.*, cv.hash || '_' || cv.seq as hash_seq
+ FROM memory_messages m
+ JOIN content_vectors cv ON cv.hash = m.hash
+ WHERE m.hash IN (?) AND s.active = 1
+
+Step 3: Deduplicate by message_id (best distance per message)
+ score = 1 - cosine_distance → range [0, 1]
+```
+
+### 4. Embedding Format
+
+```typescript
+// Queries: asymmetric task prefix
+"task: search result | query: how to configure auth"
+
+// Documents: title + text prefix
+"title: Setting up OAuth | text: To configure OAuth2..."
+```
+
+Chunking: 800 tokens/chunk, 15% overlap (120 tokens). Token-based via actual model tokenizer.
+
+### 5. Synthesis Prompt
+
+```
+System: "You are a memory recall assistant. Given a query and relevant
+past conversation memories, synthesize the memories into useful context
+for answering the query. Be concise and focus on information directly
+relevant to the query. If memories contain contradictory information,
+note the most recent. Output only the synthesized context, no preamble."
+
+User: "Query: {query}\n\nRelevant memories:\n
+[Session: title]\nrole: content\n---\n
+[Session: title]\nrole: content"
+```
+
+Temperature 0.3, max 1024 tokens, via Ollama `/api/chat`.
+
+---
+
+## Findings & Gaps
+
+### Critical Issues
+
+#### F1. Filtered recall loses vector search entirely
+
+**Impact**: High — most real-world recall uses filters.
+
+When any filter (`--project`, `--category`, `--agent`) is set, `recall()` falls back to `searchFiltered()` which is FTS-only. The hybrid FTS+vector+RRF pipeline is completely bypassed.
+
+This means `smriti recall "auth flow" --project myapp` only does keyword matching. Semantic matches ("login mechanism" for "auth flow") are lost.
+
+**Root cause**: The two-step sqlite-vec pattern cannot be easily combined with Smriti's `EXISTS` subqueries on metadata tables. Nobody has built the bridge.
+
+#### F2. `searchFiltered()` does not sanitize FTS queries
+
+**Impact**: Medium — FTS5 syntax errors on special characters.
+
+QMD's `searchMemoryFTS` passes queries through `buildMemoryFTS5Query()` which strips special chars, lowercases, and adds prefix matching. Smriti's `searchFiltered` passes raw user input to `MATCH`. Queries containing FTS5 operators (`*`, `"`, `NEAR`, `OR`, `NOT`) may cause parse errors or unintended behavior.
+
+#### F3. `searchFiltered()` does not use BM25 column weights
+
+**Impact**: Medium — title matches are not boosted.
+
+QMD uses `bm25(memory_fts, 5.0, 1.0, 1.0)` (title weighted 5x). Smriti uses `bm25(memory_fts)` (equal weights). Session title matches don't get the boost they deserve in filtered search.
+
+#### F4. Error handling asymmetry in synthesis
+
+**Impact**: Medium — inconsistent UX.
+
+- Filtered path: `synthesizeResults()` has `try/catch`, silently returns `undefined`
+- Unfiltered path: `recallMemories()` has NO `try/catch` around `ollamaRecallSynthesize()` — Ollama failure crashes the CLI with exit code 1
+
+#### F5. No timeout on Ollama calls in recall
+
+**Impact**: Medium — CLI hangs indefinitely.
+
+`ollamaChat()` uses raw `fetch()` with no `AbortSignal.timeout()`. A slow or unresponsive Ollama server hangs the CLI forever. Compare with `reflect.ts` which uses a 120-second `AbortController`.
+
+#### F6. `searchFiltered()` does not filter inactive sessions
+
+**Impact**: Low — returns deleted/inactive sessions.
+
+QMD's `searchMemoryFTS` filters `s.active = 1`. Smriti's `searchFiltered` has no such filter. Deleted sessions appear in filtered results.
+
+### Missing Capabilities
+
+#### M1. Reranker not used in recall
+
+QMD has a Qwen3-Reranker 0.6B cross-encoder model that significantly improves result quality. It's used in `qmd query` but never in `smriti recall`. The reranker sees query+document pairs together, catching relevance signals that embedding similarity and BM25 miss independently.
+
+#### M2. Query expansion not used in recall
+
+QMD has a query expansion model (1.7B) that generates lexical synonyms, vector-optimized reformulations, and hypothetical document expansions (HyDE). It's used in `qmd query` but never in `smriti recall`. This means recall misses vocabulary gaps (user says "auth", relevant content says "authentication token management").
+
+#### M3. No search result provenance/explanation
+
+Results show `[0.847]` score but no indication of *why* a result ranked high. Was it a title match? Content keyword? Semantic similarity? Understanding provenance helps users refine queries.
+
+#### M4. No multi-message context in results
+
+Search returns individual messages truncated to 200 chars. A message saying "yes, let's do that" is useless without the preceding context. No mechanism to include surrounding messages.
+
+#### M5. `smriti search` never uses vector search
+
+The `search` command always goes through `searchFiltered()` which is FTS-only. There's no `--hybrid` or `--vector` flag to enable semantic search.
+
+#### M6. Sequential FTS+vec in `recallMemories()` — not parallel
+
+```typescript
+const ftsResults = searchMemoryFTS(db, query, limit); // sync
+vecResults = await searchMemoryVec(db, query, limit); // async, waits
+```
+
+FTS is synchronous and vec is async, but they run sequentially. FTS could be wrapped in a microtask and both run in parallel.
+
+---
+
+## Improvement Plan
+
+### Phase 1: Fix Critical Gaps (Correctness & Reliability)
+
+#### P1.1 — Sanitize FTS queries in `searchFiltered()`
+
+**Addresses**: F2
+
+Import and use `buildMemoryFTS5Query()` pattern in `searchFiltered()`:
+```typescript
+import { buildFTS5Query } from "./query-utils"; // extract from QMD or reimplement
+
+const ftsQuery = buildFTS5Query(query);
+if (!ftsQuery) return [];
+conditions.push(`mf.content MATCH ?`);
+params.push(ftsQuery); // sanitized, prefix-matched, AND-joined
+```
+
+**Effort**: Small. Extract the 15-line function, wire it in.
+
+#### P1.2 — Add BM25 column weights to `searchFiltered()`
+
+**Addresses**: F3
+
+```sql
+-- Before:
+(1.0 / (1.0 + ABS(bm25(memory_fts)))) AS score
+
+-- After:
+(1.0 / (1.0 + ABS(bm25(memory_fts, 5.0, 1.0, 1.0)))) AS score
+```
+
+**Effort**: One-line change.
+
+#### P1.3 — Filter inactive sessions in `searchFiltered()`
+
+**Addresses**: F6
+
+Add `AND ms.active = 1` to the WHERE clause (or as a default condition).
+
+**Effort**: One-line change.
+
+#### P1.4 — Add timeout to Ollama calls in recall
+
+**Addresses**: F5
+
+```typescript
+const resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
+ signal: AbortSignal.timeout(60_000), // 60-second timeout
+ ...
+});
+```
+
+**Effort**: Small. One line per callsite. Consider adding to `ollamaChat()` itself in QMD.
+
+#### P1.5 — Fix synthesis error handling asymmetry
+
+**Addresses**: F4
+
+Wrap the synthesis call in `recallMemories()` with try/catch to match filtered path behavior:
+```typescript
+if (options.synthesize && results.length > 0) {
+ try {
+ synthesis = await ollamaRecallSynthesize(query, memoriesText, opts);
+ } catch {
+ // Synthesis failure should not crash recall
+ }
+}
+```
+
+**Effort**: 3-line change in QMD's memory.ts.
+
+---
+
+### Phase 2: Hybrid Filtered Search (High-Value)
+
+#### P2.1 — Add vector search to filtered recall
+
+**Addresses**: F1 (the biggest gap)
+
+The core challenge: `searchMemoryVec()` returns results without Smriti metadata, and sqlite-vec's two-step pattern can't be combined with `EXISTS` subqueries.
+
+**Approach**: Post-filter strategy — run vector search unfiltered, then filter results against Smriti metadata.
+
+```typescript
+export async function recallFiltered(
+ db: Database,
+ query: string,
+ filters: SearchFilters,
+ options: RecallOptions
+): Promise {
+ // 1. Run both searches
+ const ftsResults = searchFilteredFTS(db, query, filters);
+ const vecResults = await searchMemoryVec(db, query, limit * 3); // overfetch
+
+ // 2. Post-filter vector results against metadata
+ const filteredVec = postFilterByMetadata(db, vecResults, filters);
+
+ // 3. RRF fusion
+ const fused = reciprocalRankFusion(
+ [toRanked(ftsResults), toRanked(filteredVec)],
+ [1.0, 1.0]
+ );
+
+ // 4. Session dedup + synthesis (same as unfiltered path)
+ ...
+}
+```
+
+**Post-filter implementation**:
+```typescript
+function postFilterByMetadata(
+ db: Database,
+ results: MemorySearchResult[],
+ filters: SearchFilters
+): MemorySearchResult[] {
+ if (results.length === 0) return [];
+
+ // Batch-check metadata for all result session IDs
+ const sessionIds = [...new Set(results.map(r => r.session_id))];
+ const metaMap = loadSessionMetaBatch(db, sessionIds);
+
+ return results.filter(r => {
+ const meta = metaMap.get(r.session_id);
+ if (filters.project && meta?.project_id !== filters.project) return false;
+ if (filters.agent && meta?.agent_id !== filters.agent) return false;
+ if (filters.category) {
+ const tags = loadMessageTags(db, r.message_id);
+ if (!tags.some(t => matchesCategory(t, filters.category!))) return false;
+ }
+ return true;
+ });
+}
+```
+
+**Trade-offs**:
+- Pro: No changes to QMD's vector search internals
+- Pro: Metadata filtering is a simple SQL lookup
+- Con: Vector search fetches results that may be filtered out (hence 3x overfetch)
+- Con: Category filtering requires per-message tag lookup (batch-able)
+
+**Effort**: Medium. New function in `src/search/index.ts`, modify `recall()` routing.
+
+#### P2.2 — Add `--hybrid` flag to `smriti search`
+
+**Addresses**: M5
+
+Allow `smriti search "query" --hybrid` to use the same FTS+vector+RRF pipeline as recall (minus session dedup and synthesis). Default stays FTS-only for speed.
+
+```typescript
+case "search": {
+ if (hasFlag(args, "--hybrid")) {
+ const results = await searchHybrid(db, query, filters);
+ } else {
+ const results = searchFiltered(db, query, filters);
+ }
+}
+```
+
+**Effort**: Medium. Reuses P2.1's infrastructure.
+
+---
+
+### Phase 3: Quality Improvements
+
+#### P3.1 — Integrate reranker into recall
+
+**Addresses**: M1
+
+After RRF fusion, pass the top-N results through the Qwen3 reranker for precision reranking:
+
+```typescript
+// After RRF fusion, before session dedup
+const fusedResults = reciprocalRankFusion([fts, vec], [1.0, 1.0]);
+
+if (options.rerank !== false) { // opt-out via --no-rerank
+ const llm = getDefaultLlamaCpp();
+ const reranked = await llm.rerank(query, fusedResults.map(r => ({
+ file: r.file,
+ text: r.body,
+ })));
+ // Replace RRF scores with reranker scores
+ // Proceed to session dedup with reranked order
+}
+```
+
+**Trade-offs**:
+- Pro: Significant quality improvement — cross-encoder sees query+document together
+- Con: Adds ~500ms-2s latency (model inference per result)
+- Con: Requires EmbeddingGemma model to be loaded (already loaded for vector search)
+
+**Mitigation**: Make reranking opt-in (`--rerank`) initially, later default-on after benchmarking.
+
+**Effort**: Medium. Import `rerank` from QMD's llm.ts, wire into recall pipeline.
+
+#### P3.2 — Add query expansion
+
+**Addresses**: M2
+
+Use QMD's query expansion model to generate alternative query forms before search:
+
+```typescript
+const llm = getDefaultLlamaCpp();
+const expanded = await llm.expandQuery(query);
+// expanded = { lexical: ["auth", "authentication", "login"],
+// vector: "user authentication and login flow",
+// hyde: "To set up auth, configure the OAuth2 provider..." }
+
+// Use expanded.lexical for FTS (OR-join synonyms)
+// Use expanded.vector for vector search embedding
+// Use expanded.hyde for a second vector search pass
+```
+
+**Trade-offs**:
+- Pro: Bridges vocabulary gaps ("auth" → "authentication", "login")
+- Con: Adds ~1-3s latency for model inference
+- Con: Requires the 1.7B model to be loaded
+
+**Mitigation**: Cache expanded queries in `llm_cache` (QMD already does this). Make opt-in (`--expand`) initially.
+
+**Effort**: Medium-Large. Need to modify FTS query building to support OR-joined synonyms, run multiple vector searches.
+
+#### P3.3 — Add multi-message context window
+
+**Addresses**: M4
+
+When displaying results, include N surrounding messages from the same session:
+
+```typescript
+function expandContext(
+ db: Database,
+ result: SearchResult,
+ windowSize: number = 2
+): ExpandedResult {
+ const messages = db.prepare(`
+ SELECT role, content FROM memory_messages
+ WHERE session_id = ? AND id BETWEEN ? AND ?
+ ORDER BY id
+ `).all(result.session_id, result.message_id - windowSize, result.message_id + windowSize);
+
+ return { ...result, context: messages };
+}
+```
+
+Display as:
+```
+[0.847] Setting up OAuth authentication
+ ... (2 messages before)
+ user: How should we handle the refresh token?
+ >>> assistant: To configure OAuth2 with PKCE, first install the auth... ← matched
+ user: What about token rotation?
+ ... (1 message after)
+```
+
+**Effort**: Small-Medium. New function + format update.
+
+#### P3.4 — Result source indicators
+
+**Addresses**: M3
+
+Show why a result ranked high:
+
+```
+[0.083 fts+vec] Setting up OAuth authentication ← appeared in both lists
+ assistant: To configure OAuth2...
+
+[0.036 fts] API design session ← keyword match only
+ user: How should we structure...
+
+[0.034 vec] Login flow discussion ← semantic match only
+ assistant: The authentication mechanism...
+```
+
+**Effort**: Small. Track source in RRF fusion, pass through to formatter.
+
+---
+
+### Phase 4: Performance
+
+#### P4.1 — Parallelize FTS and vector search
+
+**Addresses**: M6
+
+```typescript
+// Before (sequential):
+const ftsResults = searchMemoryFTS(db, query, limit);
+const vecResults = await searchMemoryVec(db, query, limit);
+
+// After (parallel):
+const [ftsResults, vecResults] = await Promise.all([
+ Promise.resolve(searchMemoryFTS(db, query, limit)),
+ searchMemoryVec(db, query, limit).catch(() => []),
+]);
+```
+
+**Effort**: Tiny. One-line refactor.
+
+#### P4.2 — Batch metadata lookups for post-filtering
+
+When post-filtering vector results (P2.1), batch all session metadata lookups into a single SQL query:
+
+```typescript
+function loadSessionMetaBatch(
+ db: Database,
+ sessionIds: string[]
+): Map {
+ const placeholders = sessionIds.map(() => '?').join(',');
+ const rows = db.prepare(`
+ SELECT session_id, project_id, agent_id
+ FROM smriti_session_meta
+ WHERE session_id IN (${placeholders})
+ `).all(...sessionIds);
+ return new Map(rows.map(r => [r.session_id, r]));
+}
+```
+
+**Effort**: Small. Part of P2.1.
+
+#### P4.3 — Fix O(N*M) find() in `recallMemories()` session dedup
+
+```typescript
+// Before: O(N*M) linear scan per result
+const original = [...ftsResults, ...vecResults].find(
+ (o) => `${o.session_id}:${o.message_id}` === r.file
+);
+
+// After: O(1) Map lookup
+const originalMap = new Map();
+for (const r of [...ftsResults, ...vecResults]) {
+ const key = `${r.session_id}:${r.message_id}`;
+ if (!originalMap.has(key)) originalMap.set(key, r);
+}
+// ... in loop:
+const original = originalMap.get(r.file);
+```
+
+**Effort**: Tiny. QMD-side change.
+
+---
+
+### Implementation Priority
+
+| Phase | Item | Impact | Effort | Priority |
+|-------|------|--------|--------|----------|
+| 1 | P1.1 Sanitize FTS queries | Correctness | Small | **Now** |
+| 1 | P1.2 BM25 column weights | Quality | Tiny | **Now** |
+| 1 | P1.3 Filter inactive sessions | Correctness | Tiny | **Now** |
+| 1 | P1.4 Ollama timeout | Reliability | Small | **Now** |
+| 1 | P1.5 Synthesis error handling | Reliability | Tiny | **Now** |
+| 2 | P2.1 Hybrid filtered recall | **Quality** | Medium | **Next** |
+| 2 | P2.2 `--hybrid` search flag | Quality | Medium | **Next** |
+| 3 | P3.1 Reranker in recall | Quality | Medium | Later |
+| 3 | P3.2 Query expansion | Quality | Med-Large | Later |
+| 3 | P3.3 Multi-message context | UX | Small-Med | Later |
+| 3 | P3.4 Source indicators | UX | Small | Later |
+| 4 | P4.1 Parallel FTS+vec | Performance | Tiny | **Next** |
+| 4 | P4.2 Batch metadata lookups | Performance | Small | **Next** |
+| 4 | P4.3 Fix O(N*M) dedup | Performance | Tiny | Later |
+
+### Recommended Execution Order
+
+1. **Quick wins** (P1.1–P1.5, P4.1): Fix all correctness/reliability issues. ~1 session.
+2. **Hybrid filtered recall** (P2.1, P4.2): The single highest-value improvement. ~1 session.
+3. **Search parity** (P2.2): Expose hybrid search to `search` command. ~0.5 session.
+4. **Quality stack** (P3.1, P3.4): Reranker + source indicators. ~1 session.
+5. **Context & expansion** (P3.3, P3.2): Multi-message context, query expansion. ~1-2 sessions.
+
+---
+
+### Architecture After All Phases
+
+```
+smriti search "query" [--hybrid]
+ ├─ [default] searchFiltered() — sanitized FTS, weighted BM25, active filter
+ └─ [--hybrid] searchHybrid()
+ ├─ searchFilteredFTS()
+ ├─ searchMemoryVec() + postFilterByMetadata()
+ └─ reciprocalRankFusion()
+
+smriti recall "query" [--project X] [--synthesize] [--rerank] [--expand]
+ ├─ [--expand] expandQuery() → lexical + vector + HyDE forms
+ ├─ searchFilteredFTS() or searchMemoryFTS()
+ ├─ searchMemoryVec() + [if filtered] postFilterByMetadata()
+ ├─ reciprocalRankFusion([fts, vec], [1.0, 1.0])
+ ├─ [--rerank] llm.rerank(query, topResults)
+ ├─ Session dedup (Map-based, O(1) lookup)
+ ├─ [--context N] expandContext() — surrounding messages
+ └─ [--synthesize] ollamaRecall() — with timeout + error handling
+```
+
+Both commands use the same retrieval pipeline with different defaults:
+- `search`: FTS-only by default (fast), `--hybrid` for quality
+- `recall`: Always hybrid (quality), session-deduped, optional synthesis
+- Filters always work with full hybrid pipeline (no capability loss)
+- Reranker and query expansion are opt-in quality boosters
diff --git a/majestic-sauteeing-papert.md b/majestic-sauteeing-papert.md
new file mode 100644
index 0000000..63a5e6a
--- /dev/null
+++ b/majestic-sauteeing-papert.md
@@ -0,0 +1,405 @@
+# QMD Implementation Deep Dive - Learning Session Plan
+
+## Context
+
+This is a comprehensive learning session to understand QMD (Quality Memory Database) implementation from the ground up. QMD serves as the foundational memory layer for Smriti, providing content-addressable storage, full-text search, vector embeddings, and LLM-powered recall capabilities.
+
+**Goal**: Understand every architectural decision, implementation detail, and design pattern in QMD to enable confident contributions and debugging.
+
+**Session Categorization**: This session should be tagged as `smriti/qmd` and `topic/architecture` for future recall.
+
+## QMD Architecture Overview
+
+QMD is a sophisticated memory system built on SQLite with three core capabilities:
+
+1. **Content-Addressable Storage** - SHA256-based deduplication
+2. **Hybrid Search** - BM25 FTS + vector embeddings + LLM reranking
+3. **Conversation Memory** - Session-based message storage with recall
+
+### Key Files (Located at `/Users/zero8/zero8.dev/smriti/qmd/`)
+
+- `src/store.ts` (2571 lines) - Core data access, search, document operations
+- `src/memory.ts` (848 lines) - Conversation memory storage & retrieval
+- `src/llm.ts` (1208 lines) - LLM abstraction using node-llama-cpp
+- `src/ollama.ts` (169 lines) - Ollama HTTP API for synthesis
+- `src/collections.ts` (390 lines) - YAML-based collection management
+
+## Learning Session Structure
+
+### Part 1: Database Schema & Content Addressing (30 min)
+
+**Concepts to Explore**:
+1. **Content Table** - SHA256-based storage
+ - Why content-addressable? (deduplication, referential integrity)
+ - Hash collision handling (practically impossible with SHA256)
+ - `INSERT OR IGNORE` pattern for automatic dedup
+
+2. **Documents Table** - Virtual filesystem layer
+ - Collection-based organization (YAML managed)
+ - Soft deletes (`active` column)
+ - Path uniqueness constraints
+
+3. **Memory Tables** - Conversation storage
+ - `memory_sessions` - Session metadata
+ - `memory_messages` - Messages with content hashes
+ - Trigger-based FTS updates
+
+**Hands-On Activities**:
+- Read `qmd/src/store.ts:100-200` (schema initialization)
+- Examine hash function: `qmd/src/store.ts` (search for `hashContent`)
+- Trace a message insert: `qmd/src/memory.ts` (find `addMessage`)
+
+**Verification**:
+```bash
+# Inspect actual database schema
+sqlite3 ~/.cache/qmd/index.sqlite ".schema"
+
+# Check content dedup in action
+smriti ingest claude # Ingest sessions
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM content"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(DISTINCT hash) FROM memory_messages"
+# These should show deduplication working
+```
+
+### Part 2: Search Architecture - BM25 Full-Text Search (30 min)
+
+**Concepts to Explore**:
+1. **FTS5 Query Building**
+ - Term normalization (lowercase, strip special chars)
+ - Prefix matching (`*` suffix)
+ - Boolean operators (AND/OR)
+
+2. **BM25 Scoring**
+ - Score normalization: `1 / (1 + abs(bm25_score))`
+ - Why negative scores? (FTS5 convention)
+ - Custom weights in `bm25()` function
+
+3. **Trigger-Based FTS Updates**
+ - SQLite triggers keep `documents_fts` in sync
+ - Performance implications (writes are slower)
+
+**Hands-On Activities**:
+- Read FTS query builder: `qmd/src/store.ts` (search for `buildFTS5Query`)
+- Read FTS search: `qmd/src/store.ts` (search for `searchDocumentsFTS`)
+- Examine triggers: `qmd/src/store.ts` (search for `CREATE TRIGGER`)
+
+**Verification**:
+```bash
+# Test FTS search
+smriti search "vector embeddings" --project smriti
+
+# Compare with exact phrase
+smriti search '"vector embeddings"' --project smriti
+
+# Check FTS index size
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM documents_fts"
+```
+
+### Part 3: Vector Search & Embeddings (45 min)
+
+**Concepts to Explore**:
+1. **Two-Step Query Pattern** (CRITICAL)
+ - Why: sqlite-vec hangs on JOINs with `MATCH`
+ - Step 1: Query `vectors_vec` directly
+ - Step 2: Separate JOIN to get document data
+
+2. **Chunking Strategy**
+ - Token-based (not character-based)
+ - 800 tokens per chunk, 120 token overlap (15%)
+ - Natural break points (paragraph > sentence > line)
+
+3. **Embedding Format** (EmbeddingGemma)
+ - Queries: `"task: search result | query: {query}"`
+ - Documents: `"title: {title} | text: {content}"`
+
+4. **Storage Schema**
+ - `content_vectors` - Metadata table
+ - `vectors_vec` - sqlite-vec virtual table
+ - `hash_seq` composite key: `"hash_seq"`
+
+**Hands-On Activities**:
+- Read chunking logic: `qmd/src/store.ts` (search for `chunkDocumentByTokens`)
+- Read vector search: `qmd/src/store.ts` (search for `searchDocumentsVec`)
+- Read embedding insertion: `qmd/src/store.ts` (search for `insertEmbedding`)
+
+**Verification**:
+```bash
+# Build embeddings for a project
+smriti embed --project smriti
+
+# Check embedding storage
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM content_vectors"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM vectors_vec"
+
+# Verify chunking (count chunks per document)
+sqlite3 ~/.cache/qmd/index.sqlite "
+ SELECT hash, COUNT(*) as chunks
+ FROM content_vectors
+ GROUP BY hash
+ ORDER BY chunks DESC
+ LIMIT 10
+"
+```
+
+### Part 4: Hybrid Search - RRF & Reranking (45 min)
+
+**Concepts to Explore**:
+1. **Query Expansion**
+ - LLM generates query variants
+ - Original query weighted 2x
+ - Parallel retrieval per variant
+
+2. **Reciprocal Rank Fusion (RRF)**
+ - Formula: `score = Σ(weight/(k+rank+1))` where k=60
+ - Top-rank bonus: +0.05 for rank 1, +0.02 for ranks 2-3
+ - Why RRF? (Normalizes scores across different retrieval methods)
+
+3. **LLM Reranking** (Qwen3-Reranker)
+ - Cross-encoder scoring (0-1 scale)
+ - Position-aware blending:
+ - Ranks 1-3: 75% retrieval / 25% reranker
+ - Ranks 4-10: 60% retrieval / 40% reranker
+ - Ranks 11+: 40% retrieval / 60% reranker
+
+4. **Why Position-Aware Blending?**
+ - Trust retrieval for exact matches (top ranks)
+ - Trust reranker for semantic understanding (lower ranks)
+ - Balance precision and recall
+
+**Hands-On Activities**:
+- Read RRF implementation: `qmd/src/store.ts` (search for `reciprocalRankFusion`)
+- Read reranking logic: `qmd/src/store.ts` (search for `rerankResults`)
+- Read hybrid search: `qmd/src/store.ts` (search for `searchDocumentsHybrid`)
+
+**Verification**:
+```bash
+# Test hybrid search
+smriti search "how does vector search work" --project smriti
+
+# Compare with keyword-only
+smriti search "vector search" --project smriti --no-vector
+
+# Enable debug logging to see RRF scores
+DEBUG=qmd:* smriti search "embeddings" --project smriti
+```
+
+### Part 5: LLM Integration & Model Management (30 min)
+
+**Concepts to Explore**:
+1. **node-llama-cpp Abstraction**
+ - Model loading on-demand
+ - Context pooling
+ - Inactivity timeout (5 min default)
+
+2. **Three Model Types**
+ - Embedding: `embeddinggemma-300M-Q8_0` (~300MB)
+ - Reranking: `Qwen3-Reranker-0.6B-Q8_0` (~640MB)
+ - Generation: `qmd-query-expansion-1.7B` (~1.1GB)
+
+3. **LRU Cache**
+ - SQLite-based response cache
+ - Probabilistic pruning (1% chance on hits)
+ - Hash-based deduplication
+
+4. **Why GGUF Models?**
+ - CPU inference (no GPU required)
+ - Quantization reduces memory (Q8_0 = 8-bit)
+ - HuggingFace distribution
+
+**Hands-On Activities**:
+- Read LLM class: `qmd/src/llm.ts` (read entire file)
+- Read cache logic: `qmd/src/store.ts` (search for `llm_cache`)
+- Read model loading: `qmd/src/llm.ts` (search for `getModel`)
+
+**Verification**:
+```bash
+# Check model cache
+ls -lh ~/.cache/node-llama-cpp/models/
+
+# Test query expansion (should auto-download model on first run)
+DEBUG=qmd:llm smriti search "testing" --project smriti
+
+# Check LLM cache hits
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM llm_cache"
+```
+
+### Part 6: Memory System & Recall (30 min)
+
+**Concepts to Explore**:
+1. **Session-Based Storage**
+ - Sessions = conversations
+ - Messages = turns within sessions
+ - Metadata JSON field for extensibility
+
+2. **Recall Pipeline**
+ - Parallel FTS + vector search
+ - RRF fusion
+ - Session-level deduplication (keep best score per session)
+ - Optional Ollama synthesis
+
+3. **Ollama Integration**
+ - HTTP API (not node-llama-cpp)
+ - Configurable model (`QMD_MEMORY_MODEL`)
+ - Synthesis prompt engineering
+
+**Hands-On Activities**:
+- Read `addMessage`: `qmd/src/memory.ts` (search for `addMessage`)
+- Read `recallMemories`: `qmd/src/memory.ts` (search for `recallMemories`)
+- Read Ollama synthesis: `qmd/src/ollama.ts` (read entire file)
+
+**Verification**:
+```bash
+# Ingest sessions
+smriti ingest claude
+
+# Test recall without synthesis
+smriti recall "vector embeddings"
+
+# Test recall with synthesis (requires Ollama running)
+ollama serve &
+smriti recall "vector embeddings" --synthesize
+
+# Check memory tables
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM memory_sessions"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT COUNT(*) FROM memory_messages"
+```
+
+### Part 7: Smriti Extensions to QMD (30 min)
+
+**Concepts to Explore**:
+1. **Metadata Tables**
+ - `smriti_session_meta` - Agent/project tracking
+ - `smriti_categories` - Hierarchical taxonomy
+ - `smriti_session_tags` - Category assignments
+ - `smriti_shares` - Team knowledge exports
+
+2. **Filtered Search**
+ - JOINs QMD tables with Smriti metadata
+ - Category/project/agent filters
+ - Preserves BM25 scoring
+
+3. **Integration Pattern**
+ - Single re-export hub: `src/qmd.ts`
+ - No scattered dynamic imports
+ - Clean dependency boundary
+
+**Hands-On Activities**:
+- Read Smriti schema: `src/db.ts` (search for `CREATE TABLE`)
+- Read filtered search: `src/search/index.ts` (search for `searchFiltered`)
+- Read QMD integration: `src/qmd.ts` (read entire file)
+
+**Verification**:
+```bash
+# Test filtered search
+smriti search "embeddings" --category code/implementation
+
+# Check Smriti metadata
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT * FROM smriti_projects"
+sqlite3 ~/.cache/qmd/index.sqlite "SELECT * FROM smriti_categories"
+
+# Verify integration (should not import from QMD directly anywhere except qmd.ts)
+grep -r "from ['\"]qmd" src/ --exclude="qmd.ts" || echo "✓ No direct QMD imports"
+```
+
+## Key Design Patterns Summary
+
+1. **Content Addressing** - SHA256 deduplication, `INSERT OR IGNORE`
+2. **Two-Step Vector Queries** - Avoid sqlite-vec JOIN hangs
+3. **Virtual Paths** - `qmd://collection/path` format
+4. **LRU Caching** - SQLite-based with probabilistic pruning
+5. **Soft Deletes** - `active` column for reversibility
+6. **Trigger-Based FTS** - Automatic index updates
+7. **YAML Collections** - Config not in SQLite
+8. **Token-Based Chunking** - Accurate boundaries via tokenizer
+9. **RRF with Top-Rank Bonus** - Preserve exact matches
+10. **Position-Aware Blending** - Trust retrieval for top results
+
+## Critical Files to Master
+
+| File | Lines | Purpose |
+|------|-------|---------|
+| `qmd/src/store.ts` | 2571 | Core data access, search, embeddings |
+| `qmd/src/memory.ts` | 848 | Conversation storage & recall |
+| `qmd/src/llm.ts` | 1208 | LLM abstraction (node-llama-cpp) |
+| `qmd/src/ollama.ts` | 169 | Ollama HTTP API |
+| `src/qmd.ts` | ~50 | Smriti's QMD re-export hub |
+| `src/db.ts` | ~500 | Smriti metadata schema |
+| `src/search/index.ts` | ~300 | Filtered search implementation |
+
+## Post-Session Actions
+
+1. **Tag This Session**:
+ ```bash
+ # After session completes, categorize it
+ smriti categorize --force
+
+ # Verify tagging
+ sqlite3 ~/.cache/qmd/index.sqlite "
+ SELECT c.name
+ FROM smriti_session_tags st
+ JOIN smriti_categories c ON c.id = st.category_id
+ WHERE st.session_id = ''
+ "
+ ```
+
+2. **Share Knowledge**:
+ ```bash
+ # Export this session to team knowledge
+ smriti share --project smriti --segmented
+
+ # Verify export
+ ls -lh .smriti/knowledge/
+ ```
+
+3. **Update Memory**:
+ - Update `/Users/zero8/.claude/projects/-Users-zero8-zero8-dev-smriti/memory/MEMORY.md`
+ - Add section: "QMD Implementation Deep Dive (2026-02-12)"
+ - Document key insights and gotchas
+
+## Known Issues Discovered
+
+### sqlite-vec Extension Not Loaded in Smriti
+
+**Issue**: The `smriti embed` command fails with "no such module: vec0" error.
+
+**Root Cause**: Smriti's `getDb()` function in `src/db.ts` doesn't load the sqlite-vec extension, but QMD's `embedMemoryMessages()` requires it.
+
+**Fix Required**: Modify `src/db.ts` to load sqlite-vec:
+```typescript
+import * as sqliteVec from "sqlite-vec";
+
+export function getDb(path?: string): Database {
+ if (_db) return _db;
+ _db = new Database(path || QMD_DB_PATH);
+ _db.exec("PRAGMA journal_mode = WAL");
+ _db.exec("PRAGMA foreign_keys = ON");
+ sqliteVec.load(_db); // Add this line
+ return _db;
+}
+```
+
+**Workaround**: For this session, we can still explore all other QMD functionality (search, recall, ingest, categorize). Vector embeddings can be discussed conceptually.
+
+## Expected Outcomes
+
+By the end of this session, you should be able to:
+
+✓ Explain why QMD uses content-addressing (deduplication, efficiency)
+✓ Describe the two-step vector query pattern and why it's necessary
+✓ Understand RRF scoring and position-aware blending rationale
+✓ Debug search quality issues (FTS vs vector vs hybrid)
+✓ Optimize chunking parameters for different content types
+✓ Extend QMD with custom metadata tables (like Smriti does)
+✓ Trace a query from CLI → search → LLM → results
+✓ Contribute confidently to QMD or Smriti codebases
+
+## Execution Approach
+
+This is a **learning session**, not an implementation task. The execution will be:
+
+1. **Interactive Exploration**: Read code together, explain concepts, answer questions
+2. **Hands-On Verification**: Run commands to see architecture in action
+3. **Deep Dives**: Investigate interesting implementation details on request
+4. **Knowledge Capture**: Ensure session gets properly tagged for future recall
+
+**No code changes required** - this is pure knowledge acquisition and understanding.
diff --git a/package.json b/package.json
index 4e7c4b4..8a9e7cf 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,13 @@
"dev": "bun --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"test": "bun test",
- "smriti": "bun src/index.ts"
+ "smriti": "bun src/index.ts",
+ "bench:qmd": "bun run scripts/bench-qmd.ts --profile ci-small --out bench/results/ci-small.json --no-llm",
+ "bench:qmd:repeat": "bun run scripts/bench-qmd-repeat.ts --profiles ci-small,small,medium --runs 3 --out bench/results/repeat-summary.json",
+ "bench:compare": "bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2",
+ "bench:scorecard": "bun run scripts/bench-scorecard.ts --baseline bench/baseline.ci-small.json --profile ci-small --threshold-pct 20",
+ "bench:ingest-hotpaths": "bun run scripts/bench-ingest-hotpaths.ts",
+ "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12"
},
"dependencies": {
"node-llama-cpp": "^3.0.0",
diff --git a/qmd b/qmd
index 7ec50b8..e257bb7 160000
--- a/qmd
+++ b/qmd
@@ -1 +1 @@
-Subproject commit 7ec50b8fce3c372b5adebadb2dd8deec34548427
+Subproject commit e257bb7b4eeca81b268b091d5ad8e8842f31af5d
diff --git a/scripts/bench-compare.ts b/scripts/bench-compare.ts
new file mode 100644
index 0000000..c8080e3
--- /dev/null
+++ b/scripts/bench-compare.ts
@@ -0,0 +1,106 @@
+import { readFileSync } from "fs";
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+type BenchReport = {
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function pctChange(current: number, baseline: number): number {
+ if (!baseline) return 0;
+ return (current - baseline) / baseline;
+}
+
+function fmtPct(x: number): string {
+ return `${(x * 100).toFixed(2)}%`;
+}
+
+function checkLatency(
+ label: string,
+ current: number,
+ baseline: number,
+ threshold: number,
+ warnings: string[]
+) {
+ const delta = pctChange(current, baseline);
+ if (delta > threshold) {
+ warnings.push(`${label} regressed by ${fmtPct(delta)} (current=${current}, baseline=${baseline})`);
+ }
+}
+
+function checkThroughput(
+ label: string,
+ current: number,
+ baseline: number,
+ threshold: number,
+ warnings: string[]
+) {
+ const drop = baseline ? (baseline - current) / baseline : 0;
+ if (drop > threshold) {
+ warnings.push(`${label} dropped by ${fmtPct(drop)} (current=${current}, baseline=${baseline})`);
+ }
+}
+
+function main() {
+ const baselinePath = arg("--baseline");
+ const currentPath = arg("--current");
+ const threshold = Number(arg("--threshold") || "0.2");
+
+ if (!baselinePath || !currentPath) {
+ console.error("Usage: bun run scripts/bench-compare.ts --baseline --current [--threshold 0.2]");
+ process.exit(1);
+ }
+
+ const baseline = JSON.parse(readFileSync(baselinePath, "utf-8")) as BenchReport;
+ const current = JSON.parse(readFileSync(currentPath, "utf-8")) as BenchReport;
+
+ const warnings: string[] = [];
+
+ checkThroughput(
+ "ingest_throughput_msgs_per_sec",
+ current.metrics.ingest_throughput_msgs_per_sec,
+ baseline.metrics.ingest_throughput_msgs_per_sec,
+ threshold,
+ warnings
+ );
+
+ checkLatency(
+ "ingest_p95_ms_per_session",
+ current.metrics.ingest_p95_ms_per_session,
+ baseline.metrics.ingest_p95_ms_per_session,
+ threshold,
+ warnings
+ );
+
+ checkLatency("fts_p95_ms", current.metrics.fts.p95_ms, baseline.metrics.fts.p95_ms, threshold, warnings);
+ checkLatency("recall_p95_ms", current.metrics.recall.p95_ms, baseline.metrics.recall.p95_ms, threshold, warnings);
+
+ if (baseline.metrics.vector && current.metrics.vector) {
+ checkLatency("vector_p95_ms", current.metrics.vector.p95_ms, baseline.metrics.vector.p95_ms, threshold, warnings);
+ }
+
+ if (warnings.length === 0) {
+ console.log("No performance regressions detected.");
+ return;
+ }
+
+ console.log("Performance regression warnings:");
+ for (const w of warnings) {
+ console.log(`- ${w}`);
+ }
+
+ // Intentionally non-blocking for now.
+ process.exit(0);
+}
+
+main();
diff --git a/scripts/bench-ingest-hotpaths.ts b/scripts/bench-ingest-hotpaths.ts
new file mode 100644
index 0000000..28bb9ca
--- /dev/null
+++ b/scripts/bench-ingest-hotpaths.ts
@@ -0,0 +1,110 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import { addMessage, initializeMemoryTables } from "../src/qmd";
+
+type HotpathReport = {
+ generated_at: string;
+ cases: {
+ single_session: {
+ messages: number;
+ throughput_msgs_per_sec: number;
+ p95_ms_per_message: number;
+ };
+ rotating_sessions: {
+ sessions: number;
+ messages_per_session: number;
+ total_messages: number;
+ throughput_msgs_per_sec: number;
+ p95_ms_per_message: number;
+ };
+ };
+};
+
+function percentile(sorted: number[], p: number): number {
+ if (sorted.length === 0) return 0;
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[idx] || 0;
+}
+
+async function runSingleSession(db: Database, messages: number) {
+ const perMsgMs: number[] = [];
+ const started = Bun.nanoseconds();
+ for (let i = 0; i < messages; i++) {
+ const t0 = Bun.nanoseconds();
+ await addMessage(
+ db,
+ "bench-single",
+ i % 2 === 0 ? "user" : "assistant",
+ `Single session message ${i} auth cache vector schema ${i % 17}`,
+ { title: "Bench Single" }
+ );
+ perMsgMs.push((Bun.nanoseconds() - t0) / 1_000_000);
+ }
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ return {
+ throughput_msgs_per_sec: Number((messages / (totalMs / 1000)).toFixed(2)),
+ p95_ms_per_message: Number(percentile([...perMsgMs].sort((a, b) => a - b), 95).toFixed(3)),
+ };
+}
+
+async function runRotatingSessions(db: Database, sessions: number, messagesPerSession: number) {
+ const perMsgMs: number[] = [];
+ const totalMessages = sessions * messagesPerSession;
+ const started = Bun.nanoseconds();
+ for (let s = 0; s < sessions; s++) {
+ const sessionId = `bench-rot-${s}`;
+ for (let i = 0; i < messagesPerSession; i++) {
+ const t0 = Bun.nanoseconds();
+ await addMessage(
+ db,
+ sessionId,
+ i % 2 === 0 ? "user" : "assistant",
+ `Rotating session ${s} message ${i} index query latency throughput`,
+ { title: `Bench Rotating ${s}` }
+ );
+ perMsgMs.push((Bun.nanoseconds() - t0) / 1_000_000);
+ }
+ }
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ return {
+ total_messages: totalMessages,
+ throughput_msgs_per_sec: Number((totalMessages / (totalMs / 1000)).toFixed(2)),
+ p95_ms_per_message: Number(percentile([...perMsgMs].sort((a, b) => a - b), 95).toFixed(3)),
+ };
+}
+
+async function main() {
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-ingest-hotpath-"));
+ const dbPath = join(tempDir, "bench.sqlite");
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+
+ const single = await runSingleSession(db, 3000);
+ const rotating = await runRotatingSessions(db, 300, 10);
+
+ const report: HotpathReport = {
+ generated_at: new Date().toISOString(),
+ cases: {
+ single_session: {
+ messages: 3000,
+ ...single,
+ },
+ rotating_sessions: {
+ sessions: 300,
+ messages_per_session: 10,
+ ...rotating,
+ },
+ },
+ };
+
+ console.log(JSON.stringify(report, null, 2));
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-ingest-pipeline.ts b/scripts/bench-ingest-pipeline.ts
new file mode 100644
index 0000000..1b72fc6
--- /dev/null
+++ b/scripts/bench-ingest-pipeline.ts
@@ -0,0 +1,82 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest";
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function makeCodexJsonl(messages: number): string {
+ const lines: string[] = [];
+ for (let i = 0; i < messages; i++) {
+ const role = i % 2 === 0 ? "user" : "assistant";
+ const content =
+ role === "user"
+ ? `User prompt ${i}: auth cache schema vector query`
+ : `Assistant reply ${i}: implementation details for indexing and recall`;
+ lines.push(
+ JSON.stringify({
+ role,
+ content,
+ timestamp: new Date(Date.now() + i * 1000).toISOString(),
+ })
+ );
+ }
+ return lines.join("\n") + "\n";
+}
+
+async function main() {
+ const sessions = Math.max(1, Number(arg("--sessions") || "120"));
+ const messagesPerSession = Math.max(1, Number(arg("--messages") || "12"));
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-ingest-pipeline-"));
+ const logsDir = join(tempDir, "codex-logs");
+ const dbPath = join(tempDir, "bench.sqlite");
+ mkdirSync(logsDir, { recursive: true });
+
+ for (let s = 0; s < sessions; s++) {
+ const subDir = join(logsDir, `2026-02-${String((s % 28) + 1).padStart(2, "0")}`);
+ mkdirSync(subDir, { recursive: true });
+ const filePath = join(subDir, `session-${s}.jsonl`);
+ writeFileSync(filePath, makeCodexJsonl(messagesPerSession));
+ }
+
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+
+ const started = Bun.nanoseconds();
+ const result = await ingest(db, "codex", { logsDir });
+ const totalMs = (Bun.nanoseconds() - started) / 1_000_000;
+ const throughput = result.messagesIngested / (totalMs / 1000);
+
+ console.log(
+ JSON.stringify(
+ {
+ sessions,
+ messages_per_session: messagesPerSession,
+ sessions_ingested: result.sessionsIngested,
+ messages_ingested: result.messagesIngested,
+ elapsed_ms: Number(totalMs.toFixed(2)),
+ throughput_msgs_per_sec: Number(throughput.toFixed(2)),
+ errors: result.errors.length,
+ },
+ null,
+ 2
+ )
+ );
+
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-qmd-repeat.ts b/scripts/bench-qmd-repeat.ts
new file mode 100644
index 0000000..d6b4c41
--- /dev/null
+++ b/scripts/bench-qmd-repeat.ts
@@ -0,0 +1,141 @@
+import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+type ProfileName = "ci-small" | "small" | "medium";
+
+type BenchMetrics = {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+ recall: { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+};
+
+type SingleRunReport = {
+ profile: ProfileName;
+ mode: "no-llm" | "llm";
+ metrics: BenchMetrics;
+};
+
+type AggregatedReport = {
+ generated_at: string;
+ runs_per_profile: number;
+ mode: "no-llm";
+ profiles: Record<
+ string,
+ {
+ raw: BenchMetrics[];
+ median: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts_p95_ms: number;
+ recall_p95_ms: number;
+ };
+ }
+ >;
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function percentile(values: number[], p: number): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const idx = Math.min(
+ sorted.length - 1,
+ Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)
+ );
+ return sorted[idx] || 0;
+}
+
+function parseProfiles(input: string | undefined): ProfileName[] {
+ const raw = (input || "ci-small,small,medium")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean) as ProfileName[];
+ return raw.length > 0 ? raw : ["ci-small", "small", "medium"];
+}
+
+async function runOne(profile: ProfileName, outPath: string): Promise {
+ const proc = Bun.spawn(
+ [
+ "bun",
+ "run",
+ "scripts/bench-qmd.ts",
+ "--profile",
+ profile,
+ "--out",
+ outPath,
+ "--no-llm",
+ ],
+ {
+ stdout: "pipe",
+ stderr: "pipe",
+ cwd: process.cwd(),
+ }
+ );
+
+ const stderr = await new Response(proc.stderr).text();
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(`bench-qmd failed for ${profile}: ${stderr}`);
+ }
+
+ return JSON.parse(readFileSync(outPath, "utf8")) as SingleRunReport;
+}
+
+async function main() {
+ const profiles = parseProfiles(arg("--profiles"));
+ const runs = Math.max(1, Number(arg("--runs") || "3"));
+ const out = arg("--out") || join("bench", "results", "repeat-summary.json");
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-bench-repeat-"));
+ const result: AggregatedReport = {
+ generated_at: new Date().toISOString(),
+ runs_per_profile: runs,
+ mode: "no-llm",
+ profiles: {},
+ };
+
+ for (const profile of profiles) {
+ const raw: BenchMetrics[] = [];
+ for (let i = 0; i < runs; i++) {
+ const outPath = join(tempDir, `${profile}.run${i + 1}.json`);
+ const report = await runOne(profile, outPath);
+ raw.push(report.metrics);
+ console.log(
+ `[bench-repeat] ${profile} run ${i + 1}/${runs} ` +
+ `ingest=${report.metrics.ingest_throughput_msgs_per_sec.toFixed(2)} ` +
+ `fts_p95=${report.metrics.fts.p95_ms.toFixed(3)} ` +
+ `recall_p95=${report.metrics.recall.p95_ms.toFixed(3)}`
+ );
+ }
+
+ result.profiles[profile] = {
+ raw,
+ median: {
+ ingest_throughput_msgs_per_sec: Number(
+ percentile(raw.map((m) => m.ingest_throughput_msgs_per_sec), 50).toFixed(2)
+ ),
+ ingest_p95_ms_per_session: Number(
+ percentile(raw.map((m) => m.ingest_p95_ms_per_session), 50).toFixed(3)
+ ),
+ fts_p95_ms: Number(percentile(raw.map((m) => m.fts.p95_ms), 50).toFixed(3)),
+ recall_p95_ms: Number(
+ percentile(raw.map((m) => m.recall.p95_ms), 50).toFixed(3)
+ ),
+ },
+ };
+ }
+
+ mkdirSync(join(process.cwd(), "bench", "results"), { recursive: true });
+ writeFileSync(out, JSON.stringify(result, null, 2));
+ console.log(`Repeat benchmark summary written: ${out}`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-qmd.ts b/scripts/bench-qmd.ts
new file mode 100644
index 0000000..c6585d1
--- /dev/null
+++ b/scripts/bench-qmd.ts
@@ -0,0 +1,219 @@
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+import {
+ addMessage,
+ initializeMemoryTables,
+ searchMemoryFTS,
+ searchMemoryVec,
+ recallMemories,
+ embedMemoryMessages,
+} from "../src/qmd";
+
+type ProfileName = "ci-small" | "small" | "medium";
+
+type BenchProfile = {
+ sessions: number;
+ messagesPerSession: number;
+ warmupQueries: number;
+ measureQueries: number;
+};
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+
+type BenchReport = {
+ profile: ProfileName;
+ mode: "no-llm" | "llm";
+ generated_at: string;
+ db_path: string;
+ corpus: {
+ sessions: number;
+ messages_per_session: number;
+ total_messages: number;
+ };
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+ counts: {
+ memory_sessions: number;
+ memory_messages: number;
+ content_vectors: number;
+ };
+};
+
+const PROFILES: Record = {
+ "ci-small": { sessions: 40, messagesPerSession: 10, warmupQueries: 5, measureQueries: 30 },
+ small: { sessions: 120, messagesPerSession: 12, warmupQueries: 10, measureQueries: 60 },
+ medium: { sessions: 300, messagesPerSession: 16, warmupQueries: 20, measureQueries: 120 },
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function has(name: string): boolean {
+ return process.argv.includes(name);
+}
+
+function percentile(sorted: number[], p: number): number {
+ if (sorted.length === 0) return 0;
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
+ return sorted[idx] || 0;
+}
+
+function stats(values: number[]): TimedStats {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mean = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
+ return {
+ p50_ms: Number(percentile(sorted, 50).toFixed(3)),
+ p95_ms: Number(percentile(sorted, 95).toFixed(3)),
+ mean_ms: Number(mean.toFixed(3)),
+ runs: values.length,
+ };
+}
+
+function randomWords(seed: number, count: number): string {
+ const base = [
+ "auth", "cache", "index", "vector", "schema", "session", "query", "deploy",
+ "pipeline", "memory", "feature", "bug", "review", "latency", "throughput", "design",
+ ];
+ const parts: string[] = [];
+ for (let i = 0; i < count; i++) {
+ parts.push(base[(seed + i * 7) % base.length] || "token");
+ }
+ return parts.join(" ");
+}
+
+function makeUserMessage(s: number, m: number): string {
+ return `User request ${s}-${m}: ${randomWords(s * 37 + m, 18)}`;
+}
+
+function makeAssistantMessage(s: number, m: number): string {
+ return `Assistant response ${s}-${m}: ${randomWords(s * 53 + m, 28)} implementation details and tradeoffs.`;
+}
+
+async function main() {
+ const profileName = (arg("--profile") as ProfileName) || "ci-small";
+ const outPath = arg("--out") || join("bench", "results", `${profileName}.json`);
+ const mode: "no-llm" | "llm" = has("--llm") ? "llm" : "no-llm";
+ const profile = PROFILES[profileName];
+ if (!profile) throw new Error(`Unknown profile: ${profileName}`);
+
+ const tempDir = mkdtempSync(join(tmpdir(), "smriti-bench-"));
+ const dbPath = join(tempDir, "bench.sqlite");
+ const db = new Database(dbPath);
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+
+ const ingestPerSessionMs: number[] = [];
+ const totalMessages = profile.sessions * profile.messagesPerSession;
+
+ for (let s = 0; s < profile.sessions; s++) {
+ const sessionId = `bench-s-${s}`;
+ const t0 = Bun.nanoseconds();
+
+ for (let m = 0; m < profile.messagesPerSession; m++) {
+ const role = m % 2 === 0 ? "user" : "assistant";
+ const content = role === "user" ? makeUserMessage(s, m) : makeAssistantMessage(s, m);
+ await addMessage(db, sessionId, role, content, { title: `Bench Session ${s}` });
+ }
+
+ const dtMs = (Bun.nanoseconds() - t0) / 1_000_000;
+ ingestPerSessionMs.push(dtMs);
+ }
+
+ const ingestTotalMs = ingestPerSessionMs.reduce((a, b) => a + b, 0);
+ const ingestThroughput = totalMessages / (ingestTotalMs / 1000);
+
+ const queries: string[] = [];
+ for (let i = 0; i < profile.measureQueries + profile.warmupQueries; i++) {
+ queries.push(randomWords(i * 17, 3));
+ }
+
+ for (let i = 0; i < profile.warmupQueries; i++) {
+ searchMemoryFTS(db, queries[i] || "auth", 10);
+ await recallMemories(db, queries[i] || "auth", { limit: 10, synthesize: false });
+ }
+
+ const ftsDurations: number[] = [];
+ const recallDurations: number[] = [];
+
+ for (let i = profile.warmupQueries; i < queries.length; i++) {
+ const q = queries[i] || "auth";
+
+ const tFts = Bun.nanoseconds();
+ searchMemoryFTS(db, q, 10);
+ ftsDurations.push((Bun.nanoseconds() - tFts) / 1_000_000);
+
+ const tRecall = Bun.nanoseconds();
+ await recallMemories(db, q, { limit: 10, synthesize: false });
+ recallDurations.push((Bun.nanoseconds() - tRecall) / 1_000_000);
+ }
+
+ let vectorStats: TimedStats | null = null;
+ if (mode === "llm") {
+ try {
+ await embedMemoryMessages(db);
+ const vecDurations: number[] = [];
+ for (let i = profile.warmupQueries; i < queries.length; i++) {
+ const q = queries[i] || "auth";
+ const tVec = Bun.nanoseconds();
+ await searchMemoryVec(db, q, 10);
+ vecDurations.push((Bun.nanoseconds() - tVec) / 1_000_000);
+ }
+ vectorStats = stats(vecDurations);
+ } catch {
+ vectorStats = null;
+ }
+ }
+
+ const counts = {
+ memory_sessions: (db.prepare("SELECT COUNT(*) as c FROM memory_sessions").get() as { c: number }).c,
+ memory_messages: (db.prepare("SELECT COUNT(*) as c FROM memory_messages").get() as { c: number }).c,
+ content_vectors: (() => {
+ try {
+ return (db.prepare("SELECT COUNT(*) as c FROM content_vectors").get() as { c: number }).c;
+ } catch {
+ return 0;
+ }
+ })(),
+ };
+
+ const report: BenchReport = {
+ profile: profileName,
+ mode,
+ generated_at: new Date().toISOString(),
+ db_path: dbPath,
+ corpus: {
+ sessions: profile.sessions,
+ messages_per_session: profile.messagesPerSession,
+ total_messages: totalMessages,
+ },
+ metrics: {
+ ingest_throughput_msgs_per_sec: Number(ingestThroughput.toFixed(2)),
+ ingest_p95_ms_per_session: Number(percentile([...ingestPerSessionMs].sort((a, b) => a - b), 95).toFixed(3)),
+ fts: stats(ftsDurations),
+ recall: stats(recallDurations),
+ vector: vectorStats,
+ },
+ counts,
+ };
+
+ mkdirSync(join(process.cwd(), "bench", "results"), { recursive: true });
+ writeFileSync(outPath, JSON.stringify(report, null, 2));
+ console.log(`Benchmark report written: ${outPath}`);
+ console.log(JSON.stringify(report.metrics, null, 2));
+
+ db.close();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/bench-scorecard.ts b/scripts/bench-scorecard.ts
new file mode 100644
index 0000000..9560120
--- /dev/null
+++ b/scripts/bench-scorecard.ts
@@ -0,0 +1,129 @@
+import { existsSync, readFileSync } from "fs";
+
+type TimedStats = { p50_ms: number; p95_ms: number; mean_ms: number; runs: number };
+type BenchReport = {
+ profile: string;
+ metrics: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts: TimedStats;
+ recall: TimedStats;
+ vector: TimedStats | null;
+ };
+};
+
+type RepeatSummary = {
+ profiles: Record<
+ string,
+ {
+ median: {
+ ingest_throughput_msgs_per_sec: number;
+ ingest_p95_ms_per_session: number;
+ fts_p95_ms: number;
+ recall_p95_ms: number;
+ };
+ }
+ >;
+};
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function fmtNum(x: number): string {
+ return x.toFixed(3).replace(/\.000$/, "");
+}
+
+function pctDelta(current: number, baseline: number): number {
+ if (!baseline) return 0;
+ return ((current - baseline) / baseline) * 100;
+}
+
+function fmtDelta(value: number): string {
+ const sign = value > 0 ? "+" : "";
+ return `${sign}${value.toFixed(2)}%`;
+}
+
+function passWarn(deltaPct: number, thresholdPct: number, higherIsBetter: boolean): "PASS" | "WARN" {
+ if (higherIsBetter) {
+ return deltaPct < -thresholdPct ? "WARN" : "PASS";
+ }
+ return deltaPct > thresholdPct ? "WARN" : "PASS";
+}
+
+function main() {
+ const baselinePath = arg("--baseline") || "bench/baseline.ci-small.json";
+ const requestedRepeatPath = arg("--repeat");
+ const repeatPath =
+ requestedRepeatPath ||
+ (existsSync("bench/results/repeat-summary.json")
+ ? "bench/results/repeat-summary.json"
+ : "bench/results/repeat-summary.current.json");
+ const thresholdPct = Number(arg("--threshold-pct") || "20");
+
+ if (!existsSync(repeatPath)) {
+ throw new Error(
+ `Repeat summary not found at "${repeatPath}". Run: bun run bench:qmd:repeat`
+ );
+ }
+
+ const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as BenchReport;
+ const repeat = JSON.parse(readFileSync(repeatPath, "utf8")) as RepeatSummary;
+
+ const baselineProfile = baseline.profile;
+ const selected = arg("--profile") || baselineProfile;
+ const profile = repeat.profiles[selected];
+ if (!profile) {
+ const choices = Object.keys(repeat.profiles).join(", ") || "(none)";
+ throw new Error(`Profile "${selected}" not found in repeat summary. Available: ${choices}`);
+ }
+
+ const rows = [
+ {
+ metric: "ingest_throughput_msgs_per_sec",
+ current: profile.median.ingest_throughput_msgs_per_sec,
+ base: baseline.metrics.ingest_throughput_msgs_per_sec,
+ higherIsBetter: true,
+ },
+ {
+ metric: "ingest_p95_ms_per_session",
+ current: profile.median.ingest_p95_ms_per_session,
+ base: baseline.metrics.ingest_p95_ms_per_session,
+ higherIsBetter: false,
+ },
+ {
+ metric: "fts_p95_ms",
+ current: profile.median.fts_p95_ms,
+ base: baseline.metrics.fts.p95_ms,
+ higherIsBetter: false,
+ },
+ {
+ metric: "recall_p95_ms",
+ current: profile.median.recall_p95_ms,
+ base: baseline.metrics.recall.p95_ms,
+ higherIsBetter: false,
+ },
+ ];
+
+ console.log(`# Bench Scorecard (${selected})`);
+ console.log(`threshold: ${thresholdPct.toFixed(2)}%`);
+ console.log("");
+ console.log("| metric | baseline | current (median) | delta | status |");
+ console.log("|---|---:|---:|---:|---|");
+
+ let warnCount = 0;
+ for (const row of rows) {
+ const deltaPct = pctDelta(row.current, row.base);
+ const status = passWarn(deltaPct, thresholdPct, row.higherIsBetter);
+ if (status === "WARN") warnCount += 1;
+ console.log(
+ `| ${row.metric} | ${fmtNum(row.base)} | ${fmtNum(row.current)} | ${fmtDelta(deltaPct)} | ${status} |`
+ );
+ }
+
+ console.log("");
+ console.log(`Summary: ${warnCount === 0 ? "PASS" : `WARN (${warnCount} metrics)`}`);
+}
+
+main();
diff --git a/scripts/validate-design.ts b/scripts/validate-design.ts
new file mode 100644
index 0000000..520a672
--- /dev/null
+++ b/scripts/validate-design.ts
@@ -0,0 +1,252 @@
+#!/usr/bin/env bun
+/**
+ * validate-design.ts
+ *
+ * Static-analysis validator for smriti's three design contracts:
+ * 1. Dry-run coverage — mutating commands must handle --dry-run
+ * 2. Observability — no user content in logs; telemetry default off
+ * 3. JSON stability — structural checks on the output envelope
+ *
+ * Exit 0 → all contracts satisfied.
+ * Exit 1 → one or more violations (details printed to stderr).
+ *
+ * Run: bun run scripts/validate-design.ts
+ */
+
+import { readFileSync } from "fs";
+import { join } from "path";
+
+const ROOT = join(import.meta.dir, "..");
+const INDEX_SRC = join(ROOT, "src", "index.ts");
+const CONFIG_SRC = join(ROOT, "src", "config.ts");
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+let failures = 0;
+
+function fail(rule: string, detail: string) {
+ failures++;
+ console.error(`\n❌ [${rule}]`);
+ console.error(` ${detail}`);
+}
+
+function pass(rule: string) {
+ console.log(`✅ [${rule}]`);
+}
+
+/**
+ * Extract the source text for a top-level case block from a switch statement.
+ * Returns everything from `case "name":` up to (but not including) the next
+ * top-level `case` or `default:`.
+ */
+function extractCase(src: string, name: string): string | null {
+ const pattern = new RegExp(`case "${name}":\\s*\\{`, "g");
+ const m = pattern.exec(src);
+ if (!m) return null;
+
+ let depth = 0;
+ let i = m.index;
+ const start = i;
+
+ while (i < src.length) {
+ if (src[i] === "{") depth++;
+ if (src[i] === "}") {
+ depth--;
+ if (depth === 0) return src.slice(start, i + 1);
+ }
+ i++;
+ }
+ return src.slice(start);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Load source files
+// ─────────────────────────────────────────────────────────────────────────────
+
+const indexSrc = readFileSync(INDEX_SRC, "utf8");
+const configSrc = readFileSync(CONFIG_SRC, "utf8");
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 1a: Mutating commands must support --dry-run
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 1: Dry-run coverage ──");
+
+const MUTATING = ["ingest", "embed", "categorize", "tag", "share", "sync"] as const;
+// `context` already has dry-run — included in validation
+const MUTATING_ALL = [...MUTATING, "context"] as const;
+
+for (const cmd of MUTATING_ALL) {
+ const block = extractCase(indexSrc, cmd);
+ if (!block) {
+ fail(`dry-run/${cmd}`, `Case block for "${cmd}" not found in src/index.ts`);
+ continue;
+ }
+
+ const hasDryRunFlag = block.includes('"--dry-run"');
+ const hasDryRunVar = /dry.?[Rr]un/i.test(block);
+
+ if (!hasDryRunFlag && !hasDryRunVar) {
+ fail(
+ `dry-run/${cmd}`,
+ `Mutating command "${cmd}" does not reference "--dry-run". ` +
+ `Add: const dryRun = hasFlag(args, "--dry-run");`
+ );
+ } else {
+ pass(`dry-run/${cmd}`);
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 1b: Read-only commands must NOT support --dry-run
+// ─────────────────────────────────────────────────────────────────────────────
+
+const READ_ONLY = [
+ "search", "recall", "list", "status", "show",
+ "compare", "projects", "team", "categories",
+] as const;
+
+for (const cmd of READ_ONLY) {
+ const block = extractCase(indexSrc, cmd);
+ if (!block) {
+ // Not all read-only commands may be present yet — skip silently
+ continue;
+ }
+
+ const hasDryRun = block.includes('"--dry-run"') || /dry.?[Rr]un/i.test(block);
+
+ if (hasDryRun) {
+ fail(
+ `dry-run-reject/${cmd}`,
+ `Read-only command "${cmd}" references "--dry-run". ` +
+ `Read-only commands must reject this flag with a usage error.`
+ );
+ } else {
+ pass(`dry-run-reject/${cmd}`);
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 2a: No user content in console calls
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 2: Observability ──");
+
+// Patterns that indicate user content leaking into logs.
+// Usage/help strings (lines containing `<...>` angle-bracket placeholders) are
+// excluded — those are hardcoded template text, not runtime user data.
+const PII_PATTERNS: Array<{ re: RegExp; description: string }> = [
+ {
+ // Logging a runtime .content property — but not a hardcoded "" usage string
+ re: /console\.(log|error)\([^)]*\.content\b/,
+ description: "`.content` field logged — may expose message text",
+ },
+ {
+ re: /console\.(log|error)\([^)]*\.text\b/,
+ description: "`.text` field logged — may expose user text",
+ },
+ {
+ // Variable named `query` interpolated at runtime — not a hardcoded placeholder like
+ re: /console\.(log|error)\(.*\$\{query\}/,
+ description: "`query` variable interpolated into log — may expose user search string",
+ },
+];
+
+let piiViolations = 0;
+const lines = indexSrc.split("\n");
+for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ // Skip usage/help strings — these are static developer-written text, not runtime user data.
+ // Heuristic: lines whose console call contains a "<...>" placeholder are usage messages.
+ if (/console\.(log|error)\([^)]*<[a-z-]+>/i.test(line)) continue;
+
+ for (const { re, description } of PII_PATTERNS) {
+ if (re.test(line)) {
+ piiViolations++;
+ fail(
+ "observability/no-user-content",
+ `src/index.ts:${i + 1} — ${description}\n Line: ${line.trim()}`
+ );
+ }
+ }
+}
+if (piiViolations === 0) {
+ pass("observability/no-user-content");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 2b: Telemetry must default to OFF
+// ─────────────────────────────────────────────────────────────────────────────
+
+// Check that SMRITI_TELEMETRY is not defaulted to a truthy value in config.ts
+// Pattern: `SMRITI_TELEMETRY` env var with a default that is "1", "true", or "on"
+const telemetryAlwaysOn = /SMRITI_TELEMETRY\s*\|\|\s*["'`](1|true|on)["'`]/i.test(configSrc);
+const telemetryHardcoded = /SMRITI_TELEMETRY\s*=\s*["'`]?(1|true|on)["'`]?[^=]/i.test(configSrc);
+
+if (telemetryAlwaysOn || telemetryHardcoded) {
+ fail(
+ "observability/telemetry-default",
+ "SMRITI_TELEMETRY appears to default to a truthy value in src/config.ts. " +
+ "Telemetry must be opt-in (default OFF)."
+ );
+} else {
+ pass("observability/telemetry-default");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Contract 3: JSON output envelope shape
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n── Contract 3: JSON output envelope ──");
+
+// The json() helper in format.ts is a thin JSON.stringify wrapper.
+// The envelope contract (ok/data/meta) applies to the return values of command
+// functions, not to format.ts itself. We check that:
+// (a) the `context` command (which has the most complete JSON support) returns
+// a shape with `dry_run` in meta — a forward-looking proxy for the pattern.
+// (b) no command pipes raw arrays directly to `json()` without wrapping — i.e.,
+// every `json(...)` call wraps an object, not a bare array.
+
+// Check (a): context.ts produces a result shape with meta.dry_run — confirms envelope awareness
+const contextSrc = readFileSync(join(ROOT, "src", "context.ts"), "utf8");
+const contextHasDryRunMeta = /dry_?run/i.test(contextSrc);
+if (!contextHasDryRunMeta) {
+ fail(
+ "json-envelope/meta-dry-run",
+ "src/context.ts does not appear to include dry_run in its return shape. " +
+ "JSON output in dry-run mode must include meta.dry_run=true."
+ );
+} else {
+ pass("json-envelope/meta-dry-run");
+}
+
+// Check (b): Look for json() calls in index.ts to ensure they wrap structured objects,
+// not raw user-content arrays passed through without a wrapper.
+// Any `json(result)` or `json(sessions)` is fine — we flag only `json(query)` type leaks.
+const jsonCallsWithQuery = /\bjson\s*\(\s*query\s*\)/g;
+if (jsonCallsWithQuery.test(indexSrc)) {
+ fail(
+ "json-envelope/raw-query",
+ "A json(query) call was found in src/index.ts — query strings must never be JSON-serialised to output."
+ );
+} else {
+ pass("json-envelope/no-raw-query");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Summary
+// ─────────────────────────────────────────────────────────────────────────────
+
+console.log("\n─────────────────────────────────────────");
+if (failures === 0) {
+ console.log(`✅ All design contracts satisfied.`);
+ process.exit(0);
+} else {
+ console.error(`\n❌ ${failures} design contract violation(s) found.`);
+ console.error(
+ " See docs/DESIGN.md for the full contract specification.\n"
+ );
+ process.exit(1);
+}
diff --git a/src/ingest/README.md b/src/ingest/README.md
new file mode 100644
index 0000000..1dde5f6
--- /dev/null
+++ b/src/ingest/README.md
@@ -0,0 +1,27 @@
+# Ingest Module
+
+## Purpose
+
+Ingest imports conversations from supported agents and stores normalized memory in the local database.
+
+## Structure
+
+- `index.ts`: orchestration entry point
+- `parsers/*`: pure agent parsers (no DB writes)
+- `session-resolver.ts`: project/session resolution + incremental state
+- `store-gateway.ts`: centralized persistence for messages/meta/sidecars/costs
+- `claude.ts`, `codex.ts`, `cursor.ts`, `cline.ts`, `copilot.ts`, `generic.ts`: discovery helpers + compatibility wrappers
+
+## Design Rules
+
+- Parsers must not write to DB.
+- DB writes should go through store-gateway.
+- Session/project resolution should go through session-resolver.
+- Orchestrator owns control flow and aggregation.
+
+## Adding a New Agent
+
+1. Add parser in `parsers/.ts`.
+2. Add discovery logic in `src/ingest/.ts`.
+3. Wire into `ingest()` in `index.ts`.
+4. Add parser + orchestrator tests.
diff --git a/src/ingest/claude.ts b/src/ingest/claude.ts
index f263e08..b0b9183 100644
--- a/src/ingest/claude.ts
+++ b/src/ingest/claude.ts
@@ -7,7 +7,7 @@
*/
import { existsSync } from "fs";
-import { basename } from "path";
+import { basename, join } from "path";
import { CLAUDE_LOGS_DIR, PROJECTS_ROOT } from "../config";
import { addMessage } from "../qmd";
import type { ParsedMessage, StructuredMessage, MessageMetadata } from "./types";
@@ -365,13 +365,14 @@ export async function discoverClaudeSessions(
}> = [];
for await (const match of glob.scan({ cwd: dir, absolute: false })) {
- const [projectDir, filename] = match.split("/");
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const [projectDir, filename] = normalizedMatch.split("/");
if (!projectDir || !filename) continue;
const sessionId = filename.replace(".jsonl", "");
sessions.push({
sessionId,
projectDir,
- filePath: `${dir}/${match}`,
+ filePath: join(dir, normalizedMatch),
});
}
@@ -388,206 +389,13 @@ export async function discoverClaudeSessions(
export async function ingestClaude(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const {
- upsertProject,
- upsertSessionMeta,
- insertToolUsage,
- insertFileOperation,
- insertCommand,
- insertGitOperation,
- insertError,
- upsertSessionCosts,
- } = await import("../db");
-
- const sessions = await discoverClaudeSessions(options.logsDir);
- const result: IngestResult = {
- agent: "claude-code",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const structuredMessages = parseClaudeJsonlStructured(content);
-
- if (structuredMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Incremental ingestion: count existing messages and only process new ones.
- // This works because Claude JSONL files are append-only and message order is stable.
- const existingMessageCount: number =
- (db.prepare(`SELECT COUNT(*) as count FROM memory_messages WHERE session_id = ?`)
- .get(session.sessionId) as { count: number } | null)?.count ?? 0;
-
- const newMessages = structuredMessages.slice(existingMessageCount);
-
- if (newMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Derive project info
- const projectId = deriveProjectId(session.projectDir);
- const projectPath = deriveProjectPath(session.projectDir);
- upsertProject(db, projectId, projectPath);
-
- // Extract title from first user message (across all messages for consistency)
- const firstUser = structuredMessages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.plainText.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- // Process only new messages
- for (const msg of newMessages) {
- // Store via QMD (backward-compatible: plainText as content)
- const stored = await addMessage(
- db,
- session.sessionId,
- msg.role,
- msg.plainText || "(structured content)",
- {
- title,
- metadata: {
- ...msg.metadata,
- blocks: msg.blocks,
- },
- }
- );
-
- const messageId = stored.id;
- const createdAt = msg.timestamp || new Date().toISOString();
-
- // Populate sidecar tables from blocks
- for (const block of msg.blocks) {
- switch (block.type) {
- case "tool_call":
- insertToolUsage(
- db,
- messageId,
- session.sessionId,
- block.toolName,
- block.description || summarizeToolInput(block.toolName, block.input),
- true, // success assumed; updated by tool_result if paired
- null,
- createdAt
- );
- break;
-
- case "file_op":
- if (block.path) {
- insertFileOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.path,
- projectId,
- createdAt
- );
- }
- break;
-
- case "command":
- insertCommand(
- db,
- messageId,
- session.sessionId,
- block.command,
- block.exitCode ?? null,
- block.cwd ?? null,
- block.isGit,
- createdAt
- );
- break;
-
- case "git":
- insertGitOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.branch ?? null,
- block.prUrl ?? null,
- block.prNumber ?? null,
- block.message ? JSON.stringify({ message: block.message }) : null,
- createdAt
- );
- break;
-
- case "error":
- insertError(
- db,
- messageId,
- session.sessionId,
- block.errorType,
- block.message,
- createdAt
- );
- break;
- }
- }
-
- // Accumulate token costs from metadata
- if (msg.metadata.tokenUsage) {
- const u = msg.metadata.tokenUsage;
- upsertSessionCosts(
- db,
- session.sessionId,
- msg.metadata.model || null,
- u.input,
- u.output,
- (u.cacheCreate || 0) + (u.cacheRead || 0),
- 0
- );
- }
-
- // Accumulate turn duration from system events
- for (const block of msg.blocks) {
- if (
- block.type === "system_event" &&
- block.eventType === "turn_duration" &&
- typeof block.data.durationMs === "number"
- ) {
- upsertSessionCosts(
- db,
- session.sessionId,
- null,
- 0,
- 0,
- 0,
- block.data.durationMs as number
- );
- }
- }
- }
-
- result.sessionsIngested++;
- result.messagesIngested += newMessages.length;
-
- // Ensure session meta exists (idempotent upsert)
- upsertSessionMeta(db, session.sessionId, "claude-code", projectId);
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${newMessages.length} new messages, ${existingMessageCount} existing)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "claude-code", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
// =============================================================================
diff --git a/src/ingest/cline.ts b/src/ingest/cline.ts
index ec04295..f014131 100644
--- a/src/ingest/cline.ts
+++ b/src/ingest/cline.ts
@@ -278,190 +278,13 @@ export async function discoverClineSessions(
export async function ingestCline(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const {
- upsertProject,
- upsertSessionMeta,
- insertToolUsage,
- insertFileOperation,
- insertCommand,
- insertGitOperation,
- insertError,
- upsertSessionCosts,
- } = await import("../db");
-
- const sessions = await discoverClineSessions(options.logsDir);
- const result: IngestResult = {
- agent: "cline",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const task: ClineTask = JSON.parse(content);
- const structuredMessages = parseClineTask(task, 0); // Start sequence from 0
-
- if (structuredMessages.length === 0) {
- result.skipped++;
- continue;
- }
-
- // Derive project info using the task's CWD
- const projectId = deriveProjectId(task.cwd || "");
- const projectPath = deriveProjectPath(task.cwd || "");
- upsertProject(db, projectId, projectPath);
-
- // Use task name or first message as title
- const title = task.name || structuredMessages[0].plainText.slice(0, 100).replace(/\n/g, " ");
-
- // Process each structured message
- for (const msg of structuredMessages) {
- const stored = await addMessage(
- db,
- session.sessionId,
- msg.role,
- msg.plainText || "(structured content)",
- {
- title,
- metadata: {
- ...msg.metadata,
- blocks: msg.blocks,
- },
- }
- );
-
- const messageId = stored.id;
- const createdAt = msg.timestamp || new Date().toISOString();
-
- // Populate sidecar tables from blocks
- for (const block of msg.blocks) {
- switch (block.type) {
- case "tool_call":
- insertToolUsage(
- db,
- messageId,
- session.sessionId,
- block.toolName,
- block.description || summarizeToolInput(block.toolName, block.input),
- true, // success assumed; updated by tool_result if paired
- null,
- createdAt
- );
- break;
-
- case "file_op":
- if (block.path) {
- insertFileOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.path,
- projectId,
- createdAt
- );
- }
- break;
-
- case "command":
- insertCommand(
- db,
- messageId,
- session.sessionId,
- block.command,
- block.exitCode ?? null,
- block.cwd ?? null,
- block.isGit,
- createdAt
- );
- break;
-
- case "git":
- insertGitOperation(
- db,
- messageId,
- session.sessionId,
- block.operation,
- block.branch ?? null,
- block.prUrl ?? null,
- block.prNumber ?? null,
- block.message ? JSON.stringify({ message: block.message }) : null,
- createdAt
- );
- break;
-
- case "error":
- insertError(
- db,
- messageId,
- session.sessionId,
- block.errorType,
- block.message,
- createdAt
- );
- break;
-
- case "system_event":
- if (block.eventType === "turn_duration" && typeof block.data.durationMs === "number") {
- upsertSessionCosts(
- db,
- session.sessionId,
- null,
- 0,
- 0,
- 0,
- block.data.durationMs as number
- );
- }
- break;
- }
- }
-
- // Accumulate token costs if present in metadata (Cline tasks might not have this directly)
- if (msg.metadata.tokenUsage) {
- const u = msg.metadata.tokenUsage;
- upsertSessionCosts(
- db,
- session.sessionId,
- msg.metadata.model || null,
- u.input,
- u.output,
- (u.cacheCreate || 0) + (u.cacheRead || 0),
- 0
- );
- }
- }
-
- // Attach Smriti metadata
- upsertSessionMeta(db, session.sessionId, "cline", projectId);
-
- result.sessionsIngested++;
- result.messagesIngested += structuredMessages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${structuredMessages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "cline", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
// =============================================================================
diff --git a/src/ingest/codex.ts b/src/ingest/codex.ts
index cd5f39c..8be7a80 100644
--- a/src/ingest/codex.ts
+++ b/src/ingest/codex.ts
@@ -5,6 +5,7 @@
* to QMD's addMessage() format.
*/
+import { join } from "path";
import { CODEX_LOGS_DIR } from "../config";
import { addMessage } from "../qmd";
import type { ParsedMessage, IngestResult, IngestOptions } from "./index";
@@ -75,10 +76,11 @@ export async function discoverCodexSessions(
try {
const glob = new Bun.Glob("**/*.jsonl");
for await (const match of glob.scan({ cwd: dir, absolute: false })) {
- const sessionId = match.replace(/\.jsonl$/, "").replace(/\//g, "-");
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const sessionId = normalizedMatch.replace(/\.jsonl$/, "").replaceAll("/", "-");
sessions.push({
sessionId: `codex-${sessionId}`,
- filePath: `${dir}/${match}`,
+ filePath: join(dir, normalizedMatch),
});
}
} catch {
@@ -94,61 +96,11 @@ export async function discoverCodexSessions(
export async function ingestCodex(
options: IngestOptions = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCodexSessions(options.logsDir);
- const result: IngestResult = {
- agent: "codex",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const messages = parseCodexJsonl(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, {
- title,
- });
- }
-
- upsertSessionMeta(db, session.sessionId, "codex");
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${messages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "codex", {
+ logsDir: options.logsDir,
+ onProgress,
+ });
}
diff --git a/src/ingest/copilot.ts b/src/ingest/copilot.ts
index 8edee09..5a171f7 100644
--- a/src/ingest/copilot.ts
+++ b/src/ingest/copilot.ts
@@ -209,13 +209,14 @@ export async function discoverCopilotSessions(options: {
const glob = new Bun.Glob("*/chatSessions/*.json");
try {
for await (const match of glob.scan({ cwd: root, absolute: false })) {
- const filePath = join(root, match);
- const hashDir = join(root, match.split("/")[0]);
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const filePath = join(root, normalizedMatch);
+ const hashDir = join(root, normalizedMatch.split("/")[0] || "");
const workspacePath = readWorkspacePath(hashDir);
if (options.projectPath && workspacePath !== options.projectPath) continue;
- const sessionId = `copilot-${basename(match, ".json")}`;
+ const sessionId = `copilot-${basename(normalizedMatch, ".json")}`;
sessions.push({ sessionId, filePath, workspacePath });
}
} catch {
@@ -236,75 +237,12 @@ export async function discoverCopilotSessions(options: {
export async function ingestCopilot(
options: IngestOptions & { projectPath?: string; storageRoots?: string[] } = {}
): Promise {
- const { db, existingSessionIds, onProgress } = options;
+ const { db, onProgress } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertProject, upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCopilotSessions({
- storageRoots: options.storageRoots,
+ const { ingest } = await import("./index");
+ return ingest(db, "copilot", {
projectPath: options.projectPath,
+ storageRoots: options.storageRoots,
+ onProgress,
});
-
- const result: IngestResult = {
- agent: "copilot",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- if (sessions.length === 0) {
- const roots = options.storageRoots ?? resolveVSCodeStorageRoots();
- if (roots.length === 0) {
- result.errors.push(
- "VS Code workspaceStorage not found. Is VS Code installed? " +
- "Set COPILOT_STORAGE_DIR to override the path."
- );
- }
- return result;
- }
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const content = await Bun.file(session.filePath).text();
- const messages = parseCopilotJson(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const workspacePath = session.workspacePath || PROJECTS_ROOT;
- const projectId = deriveProjectId(workspacePath);
- upsertProject(db, projectId, workspacePath);
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "Copilot Chat";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, { title });
- }
-
- upsertSessionMeta(db, session.sessionId, "copilot", projectId);
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(`Ingested ${session.sessionId} (${messages.length} messages) — project: ${projectId}`);
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
}
diff --git a/src/ingest/cursor.ts b/src/ingest/cursor.ts
index 5a824c5..92a4c79 100644
--- a/src/ingest/cursor.ts
+++ b/src/ingest/cursor.ts
@@ -5,6 +5,7 @@
* and normalizes to QMD's addMessage() format.
*/
+import { join } from "path";
import { addMessage } from "../qmd";
import type { ParsedMessage, IngestResult, IngestOptions } from "./index";
@@ -83,10 +84,11 @@ export async function discoverCursorSessions(
try {
const glob = new Bun.Glob("**/*.json");
for await (const match of glob.scan({ cwd: cursorDir, absolute: false })) {
- const sessionId = `cursor-${match.replace(/\.json$/, "").replace(/\//g, "-")}`;
+ const normalizedMatch = match.replaceAll("\\", "/");
+ const sessionId = `cursor-${normalizedMatch.replace(/\.json$/, "").replaceAll("/", "-")}`;
sessions.push({
sessionId,
- filePath: `${cursorDir}/${match}`,
+ filePath: join(cursorDir, normalizedMatch),
projectPath,
});
}
@@ -103,66 +105,12 @@ export async function discoverCursorSessions(
export async function ingestCursor(
options: IngestOptions & { projectPath?: string } = {}
): Promise {
- const { db, existingSessionIds, onProgress, projectPath } = options;
+ const { db, onProgress, projectPath } = options;
if (!db) throw new Error("Database required for ingestion");
if (!projectPath) throw new Error("projectPath required for Cursor ingestion");
-
- const { upsertProject, upsertSessionMeta } = await import("../db");
-
- const sessions = await discoverCursorSessions(projectPath);
- const result: IngestResult = {
- agent: "cursor",
- sessionsFound: sessions.length,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- // Derive project ID from path
- const projectId = projectPath.split("/").filter(Boolean).pop() || "unknown";
- upsertProject(db, projectId, projectPath);
-
- for (const session of sessions) {
- if (existingSessionIds?.has(session.sessionId)) {
- result.skipped++;
- continue;
- }
-
- try {
- const file = Bun.file(session.filePath);
- const content = await file.text();
- const messages = parseCursorJson(content);
-
- if (messages.length === 0) {
- result.skipped++;
- continue;
- }
-
- const firstUser = messages.find((m) => m.role === "user");
- const title = firstUser
- ? firstUser.content.slice(0, 100).replace(/\n/g, " ")
- : "";
-
- for (const msg of messages) {
- await addMessage(db, session.sessionId, msg.role, msg.content, {
- title,
- });
- }
-
- upsertSessionMeta(db, session.sessionId, "cursor", projectId);
- result.sessionsIngested++;
- result.messagesIngested += messages.length;
-
- if (onProgress) {
- onProgress(
- `Ingested ${session.sessionId} (${messages.length} messages)`
- );
- }
- } catch (err: any) {
- result.errors.push(`${session.sessionId}: ${err.message}`);
- }
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "cursor", {
+ projectPath,
+ onProgress,
+ });
}
diff --git a/src/ingest/generic.ts b/src/ingest/generic.ts
index b7cb1bf..7a4771a 100644
--- a/src/ingest/generic.ts
+++ b/src/ingest/generic.ts
@@ -5,7 +5,6 @@
* Wraps QMD's importTranscript() with Smriti metadata.
*/
-import { importTranscript } from "../qmd";
import type { IngestResult, IngestOptions } from "./index";
export type GenericIngestOptions = IngestOptions & {
@@ -23,53 +22,14 @@ export type GenericIngestOptions = IngestOptions & {
export async function ingestGeneric(
options: GenericIngestOptions
): Promise {
- const { db, filePath, format, agentName, title, sessionId, projectId } =
- options;
+ const { db, filePath, format, agentName, title, sessionId, projectId } = options;
if (!db) throw new Error("Database required for ingestion");
-
- const { upsertSessionMeta, upsertProject } = await import("../db");
-
- const result: IngestResult = {
- agent: agentName || "generic",
- sessionsFound: 1,
- sessionsIngested: 0,
- messagesIngested: 0,
- skipped: 0,
- errors: [],
- };
-
- try {
- const file = Bun.file(filePath);
- if (!(await file.exists())) {
- result.errors.push(`File not found: ${filePath}`);
- return result;
- }
-
- const content = await file.text();
- const imported = await importTranscript(db, content, {
- title,
- format: format || "chat",
- sessionId,
- });
-
- // If a project was specified, register it
- if (projectId) {
- upsertProject(db, projectId);
- }
-
- // Attach metadata
- upsertSessionMeta(
- db,
- imported.sessionId,
- agentName || "generic",
- projectId
- );
-
- result.sessionsIngested = 1;
- result.messagesIngested = imported.messageCount;
- } catch (err: any) {
- result.errors.push(err.message);
- }
-
- return result;
+ const { ingest } = await import("./index");
+ return ingest(db, "generic", {
+ filePath,
+ format,
+ title,
+ sessionId,
+ projectId,
+ });
}
diff --git a/src/ingest/index.ts b/src/ingest/index.ts
index e6f588a..21baba1 100644
--- a/src/ingest/index.ts
+++ b/src/ingest/index.ts
@@ -6,6 +6,9 @@
*/
import type { Database } from "bun:sqlite";
+import type { ParsedMessage, StructuredMessage } from "./types";
+import { resolveSession } from "./session-resolver";
+import { storeBlocks, storeCosts, storeMessage, storeSession } from "./store-gateway";
// =============================================================================
// Types — re-export from types.ts
@@ -29,6 +32,153 @@ export type IngestOptions = {
logsDir?: string;
};
+function isStructuredMessage(msg: ParsedMessage | StructuredMessage): msg is StructuredMessage {
+ return typeof (msg as StructuredMessage).plainText === "string" &&
+ Array.isArray((msg as StructuredMessage).blocks);
+}
+
+async function ingestParsedSessions(
+ db: Database,
+ agentId: string,
+ sessions: Array<{ sessionId: string; filePath: string; projectDir?: string }>,
+ parser: (sessionPath: string, sessionId: string) => Promise<{
+ session: { id: string; title: string; created_at: string };
+ messages: Array;
+ }>,
+ options: {
+ existingSessionIds: Set;
+ onProgress?: (msg: string) => void;
+ explicitProjectId?: string;
+ explicitProjectPath?: string;
+ incremental?: boolean;
+ } = {
+ existingSessionIds: new Set(),
+ }
+): Promise {
+ const result: IngestResult = {
+ agent: agentId,
+ sessionsFound: sessions.length,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: [],
+ };
+ const useSessionTxn = process.env.SMRITI_INGEST_SESSION_TXN !== "0";
+
+ for (const session of sessions) {
+ if (!options.incremental && options.existingSessionIds.has(session.sessionId)) {
+ result.skipped++;
+ continue;
+ }
+
+ try {
+ const parsed = await parser(session.filePath, session.sessionId);
+ if (parsed.messages.length === 0) {
+ result.skipped++;
+ continue;
+ }
+
+ const resolved = resolveSession({
+ db,
+ sessionId: session.sessionId,
+ agentId,
+ projectDir: session.projectDir,
+ explicitProjectId: options.explicitProjectId,
+ explicitProjectPath: options.explicitProjectPath,
+ });
+
+ const messagesToIngest = options.incremental
+ ? parsed.messages.slice(resolved.existingMessageCount)
+ : parsed.messages;
+
+ if (messagesToIngest.length === 0) {
+ result.skipped++;
+ continue;
+ }
+
+ if (useSessionTxn) db.exec("BEGIN IMMEDIATE");
+ try {
+ for (const msg of messagesToIngest) {
+ const content = isStructuredMessage(msg) ? msg.plainText || "(structured content)" : msg.content;
+ const messageOptions = isStructuredMessage(msg)
+ ? {
+ title: parsed.session.title,
+ metadata: {
+ ...msg.metadata,
+ blocks: msg.blocks,
+ },
+ }
+ : { title: parsed.session.title };
+
+ const stored = await storeMessage(db, session.sessionId, msg.role, content, messageOptions);
+ if (!stored.success) {
+ throw new Error(stored.error || "Failed to store message");
+ }
+
+ if (isStructuredMessage(msg)) {
+ storeBlocks(
+ db,
+ stored.messageId,
+ session.sessionId,
+ resolved.projectId,
+ msg.blocks,
+ msg.timestamp || new Date().toISOString()
+ );
+
+ if (msg.metadata.tokenUsage) {
+ const u = msg.metadata.tokenUsage;
+ storeCosts(
+ db,
+ session.sessionId,
+ msg.metadata.model || null,
+ u.input,
+ u.output,
+ (u.cacheCreate || 0) + (u.cacheRead || 0),
+ 0
+ );
+ }
+
+ for (const block of msg.blocks) {
+ if (
+ block.type === "system_event" &&
+ block.eventType === "turn_duration" &&
+ typeof block.data.durationMs === "number"
+ ) {
+ storeCosts(db, session.sessionId, null, 0, 0, 0, block.data.durationMs as number);
+ }
+ }
+ }
+ }
+
+ storeSession(
+ db,
+ session.sessionId,
+ agentId,
+ resolved.projectId,
+ resolved.projectPath
+ );
+ if (useSessionTxn) db.exec("COMMIT");
+ } catch (err) {
+ if (useSessionTxn) db.exec("ROLLBACK");
+ throw err;
+ }
+
+ result.sessionsIngested++;
+ result.messagesIngested += messagesToIngest.length;
+ if (options.onProgress) {
+ options.onProgress(
+ `Ingested ${session.sessionId} (${messagesToIngest.length} messages)` +
+ (resolved.projectId ? ` - project: ${resolved.projectId}` : "")
+ );
+ }
+ } catch (err: any) {
+ result.errors.push(`${session.sessionId}: ${err.message}`);
+ }
+ }
+
+ return result;
+}
+
// =============================================================================
// Orchestrator
// =============================================================================
@@ -54,6 +204,7 @@ export async function ingest(
onProgress?: (msg: string) => void;
logsDir?: string;
projectPath?: string;
+ storageRoots?: string[];
filePath?: string;
format?: "chat" | "jsonl";
title?: string;
@@ -72,37 +223,124 @@ export async function ingest(
switch (agent) {
case "claude":
case "claude-code": {
- const { ingestClaude } = await import("./claude");
- return ingestClaude(baseOptions);
+ const { discoverClaudeSessions } = await import("./claude");
+ const { parseClaude } = await import("./parsers");
+ const discovered = await discoverClaudeSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectDir,
+ }));
+ return ingestParsedSessions(db, "claude-code", sessions, parseClaude, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ incremental: true,
+ });
}
case "codex": {
- const { ingestCodex } = await import("./codex");
- return ingestCodex(baseOptions);
+ const { discoverCodexSessions } = await import("./codex");
+ const { parseCodex } = await import("./parsers");
+ const discovered = await discoverCodexSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ }));
+ return ingestParsedSessions(db, "codex", sessions, parseCodex, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "cursor": {
- const { ingestCursor } = await import("./cursor");
- return ingestCursor({ ...baseOptions, projectPath: options.projectPath });
+ if (!options.projectPath) {
+ return {
+ agent: "cursor",
+ sessionsFound: 0,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: ["projectPath required for Cursor ingestion"],
+ };
+ }
+ const { discoverCursorSessions } = await import("./cursor");
+ const { parseCursor } = await import("./parsers");
+ const discovered = await discoverCursorSessions(options.projectPath);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectPath,
+ }));
+ return ingestParsedSessions(db, "cursor", sessions, parseCursor, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "cline": {
- const { ingestCline } = await import("./cline");
- return ingestCline(baseOptions);
+ const { discoverClineSessions } = await import("./cline");
+ const { parseCline } = await import("./parsers");
+ const discovered = await discoverClineSessions(options.logsDir);
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.projectDir,
+ }));
+ return ingestParsedSessions(db, "cline", sessions, parseCline, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "copilot": {
- const { ingestCopilot } = await import("./copilot");
- return ingestCopilot({ ...baseOptions, projectPath: options.projectPath });
+ const { discoverCopilotSessions } = await import("./copilot");
+ const { parseCopilot } = await import("./parsers");
+ const discovered = await discoverCopilotSessions({
+ projectPath: options.projectPath,
+ storageRoots: options.storageRoots,
+ });
+ const sessions = discovered.map((s) => ({
+ sessionId: s.sessionId,
+ filePath: s.filePath,
+ projectDir: s.workspacePath || undefined,
+ }));
+ return ingestParsedSessions(db, "copilot", sessions, parseCopilot, {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ });
}
case "file":
case "generic": {
- const { ingestGeneric } = await import("./generic");
- return ingestGeneric({
- ...baseOptions,
- filePath: options.filePath || "",
- format: options.format,
- title: options.title,
- sessionId: options.sessionId,
- projectId: options.projectId,
- agentName: agent === "file" ? "generic" : agent,
- });
+ if (!options.filePath) {
+ return {
+ agent: agent === "file" ? "generic" : agent,
+ sessionsFound: 0,
+ sessionsIngested: 0,
+ messagesIngested: 0,
+ skipped: 0,
+ errors: ["File path is required for generic ingestion"],
+ };
+ }
+ const { parseGeneric } = await import("./parsers");
+ const sessionId = options.sessionId || `generic-${crypto.randomUUID().slice(0, 8)}`;
+ const parsed = await parseGeneric(options.filePath, sessionId, options.format || "chat");
+ if (options.title) {
+ parsed.session.title = options.title;
+ }
+ const result = await ingestParsedSessions(
+ db,
+ agent === "file" ? "generic" : agent,
+ [{ sessionId, filePath: options.filePath, projectDir: options.projectPath }],
+ async () => parsed,
+ {
+ existingSessionIds,
+ onProgress: options.onProgress,
+ explicitProjectId: options.projectId,
+ explicitProjectPath: options.projectPath,
+ }
+ );
+ return result;
}
default:
return {
diff --git a/src/ingest/parsers/claude.ts b/src/ingest/parsers/claude.ts
new file mode 100644
index 0000000..f3de527
--- /dev/null
+++ b/src/ingest/parsers/claude.ts
@@ -0,0 +1,48 @@
+import { parseClaudeJsonlStructured } from "../claude";
+import type { ParsedSession } from "./types";
+
+export async function parseClaude(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseClaudeJsonlStructured(content);
+
+ const firstUser = messages.find((m) => m.role === "user");
+ const title = firstUser
+ ? firstUser.plainText.slice(0, 100).replace(/\n/g, " ")
+ : "";
+
+ let totalTokens = 0;
+ let totalDurationMs = 0;
+
+ for (const msg of messages) {
+ const u = msg.metadata.tokenUsage;
+ if (u) {
+ totalTokens += u.input + u.output + (u.cacheCreate || 0) + (u.cacheRead || 0);
+ }
+
+ for (const block of msg.blocks) {
+ if (
+ block.type === "system_event" &&
+ block.eventType === "turn_duration" &&
+ typeof block.data.durationMs === "number"
+ ) {
+ totalDurationMs += block.data.durationMs as number;
+ }
+ }
+ }
+
+ return {
+ session: {
+ id: sessionId,
+ title,
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {
+ total_tokens: totalTokens || undefined,
+ total_duration_ms: totalDurationMs || undefined,
+ },
+ };
+}
diff --git a/src/ingest/parsers/cline.ts b/src/ingest/parsers/cline.ts
new file mode 100644
index 0000000..7490669
--- /dev/null
+++ b/src/ingest/parsers/cline.ts
@@ -0,0 +1,150 @@
+import type { StructuredMessage, MessageMetadata, MessageBlock } from "../types";
+import type { ParsedSession } from "./types";
+
+type ClineTask = {
+ id: string;
+ parentId?: string;
+ name: string;
+ timestamp: string;
+ cwd?: string;
+ gitBranch?: string;
+ history: Array<{
+ ts: string;
+ type: "say" | "ask" | "tool" | "tool_code" | "tool_result" | "command" | "command_output" | "system_event" | "error";
+ text?: string;
+ question?: string;
+ options?: string;
+ toolId?: string;
+ toolName?: string;
+ input?: Record;
+ output?: string;
+ success?: boolean;
+ error?: string;
+ durationMs?: number;
+ command?: string;
+ cwd?: string;
+ isGit?: boolean;
+ exitCode?: number;
+ }>;
+};
+
+function parseTask(task: ClineTask): StructuredMessage[] {
+ const messages: StructuredMessage[] = [];
+ let sequence = 0;
+
+ for (const entry of task.history) {
+ const metadata: MessageMetadata = {};
+ if (task.cwd) metadata.cwd = task.cwd;
+ if (task.gitBranch) metadata.gitBranch = task.gitBranch;
+ if (task.parentId) metadata.parentId = task.parentId;
+
+ let role: StructuredMessage["role"] = "assistant";
+ let plainText = "";
+ let blocks: MessageBlock[] = [];
+
+ switch (entry.type) {
+ case "say":
+ blocks = [{ type: "text", text: entry.text || "" }];
+ plainText = entry.text || "";
+ role = "assistant";
+ break;
+ case "ask":
+ blocks = [{ type: "text", text: `User asked: ${entry.question || ""} (Options: ${entry.options || ""})` }];
+ plainText = `User asked: ${entry.question || ""}`;
+ role = "user";
+ break;
+ case "tool":
+ case "tool_code":
+ blocks = [{
+ type: "tool_call",
+ toolId: entry.toolId || "unknown_tool",
+ toolName: entry.toolName || "Unknown Tool",
+ input: entry.input || {},
+ description: entry.text,
+ }];
+ plainText = `Tool Call: ${entry.toolName || "Unknown Tool"}`;
+ role = "assistant";
+ break;
+ case "tool_result":
+ blocks = [{
+ type: "tool_result",
+ toolId: entry.toolId || "unknown_tool",
+ success: entry.success ?? true,
+ output: entry.output || "",
+ error: entry.error,
+ durationMs: entry.durationMs,
+ }];
+ plainText = `Tool Result: ${entry.output || entry.error || ""}`;
+ role = "tool";
+ break;
+ case "command":
+ blocks = [{
+ type: "command",
+ command: entry.command || "",
+ cwd: entry.cwd || task.cwd,
+ isGit: entry.isGit ?? false,
+ description: entry.text,
+ }];
+ plainText = `Command: ${entry.command || ""}`;
+ role = "assistant";
+ break;
+ case "command_output":
+ blocks = [{
+ type: "command",
+ command: entry.command || "",
+ stdout: entry.output,
+ stderr: entry.error,
+ exitCode: entry.exitCode,
+ isGit: entry.isGit ?? false,
+ }];
+ plainText = `Command Output: ${entry.output || entry.error || ""}`;
+ role = "tool";
+ break;
+ case "system_event":
+ blocks = [{ type: "system_event", eventType: "turn_duration", data: { durationMs: entry.durationMs } }];
+ plainText = `System Event: ${entry.durationMs || 0}ms`;
+ role = "system";
+ break;
+ case "error":
+ blocks = [{ type: "error", errorType: "tool_failure", message: entry.error || "Unknown error" }];
+ plainText = `Error: ${entry.error || "Unknown error"}`;
+ role = "system";
+ break;
+ }
+
+ messages.push({
+ id: `${task.id}-${sequence}`,
+ sessionId: task.id,
+ sequence,
+ timestamp: entry.ts || new Date().toISOString(),
+ role,
+ agent: "cline",
+ blocks,
+ metadata,
+ plainText,
+ });
+
+ sequence++;
+ }
+
+ return messages;
+}
+
+export async function parseCline(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const task = JSON.parse(content) as ClineTask;
+ const messages = parseTask(task);
+
+ return {
+ session: {
+ id: sessionId,
+ title: task.name || messages[0]?.plainText.slice(0, 100).replace(/\n/g, " ") || "",
+ created_at: task.timestamp || messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/codex.ts b/src/ingest/parsers/codex.ts
new file mode 100644
index 0000000..e2879b2
--- /dev/null
+++ b/src/ingest/parsers/codex.ts
@@ -0,0 +1,21 @@
+import { parseCodexJsonl } from "../codex";
+import type { ParsedSession } from "./types";
+
+export async function parseCodex(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCodexJsonl(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/copilot.ts b/src/ingest/parsers/copilot.ts
new file mode 100644
index 0000000..5ad8f20
--- /dev/null
+++ b/src/ingest/parsers/copilot.ts
@@ -0,0 +1,21 @@
+import { parseCopilotJson } from "../copilot";
+import type { ParsedSession } from "./types";
+
+export async function parseCopilot(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCopilotJson(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "Copilot Chat",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/cursor.ts b/src/ingest/parsers/cursor.ts
new file mode 100644
index 0000000..b722bb8
--- /dev/null
+++ b/src/ingest/parsers/cursor.ts
@@ -0,0 +1,21 @@
+import { parseCursorJson } from "../cursor";
+import type { ParsedSession } from "./types";
+
+export async function parseCursor(
+ sessionPath: string,
+ sessionId: string
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages = parseCursorJson(content);
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/generic.ts b/src/ingest/parsers/generic.ts
new file mode 100644
index 0000000..06cd668
--- /dev/null
+++ b/src/ingest/parsers/generic.ts
@@ -0,0 +1,44 @@
+import type { ParsedSession } from "./types";
+
+export async function parseGeneric(
+ sessionPath: string,
+ sessionId: string,
+ format: "chat" | "jsonl" = "chat"
+): Promise {
+ const content = await Bun.file(sessionPath).text();
+ const messages: Array<{ role: string; content: string; timestamp?: string }> = [];
+
+ if (format === "jsonl") {
+ for (const line of content.split("\n").filter((l) => l.trim())) {
+ const parsed = JSON.parse(line);
+ messages.push({ role: parsed.role || "user", content: parsed.content || "" });
+ }
+ } else {
+ const blocks = content.split(/\n\n+/);
+ for (const block of blocks) {
+ const trimmed = block.trim();
+ if (!trimmed) continue;
+ const colonIdx = trimmed.indexOf(":");
+ if (colonIdx > 0 && colonIdx < 20) {
+ messages.push({
+ role: trimmed.slice(0, colonIdx).trim().toLowerCase(),
+ content: trimmed.slice(colonIdx + 1).trim(),
+ });
+ } else {
+ messages.push({ role: "user", content: trimmed });
+ }
+ }
+ }
+
+ const firstUser = messages.find((m) => m.role === "user");
+
+ return {
+ session: {
+ id: sessionId,
+ title: firstUser ? firstUser.content.slice(0, 100).replace(/\n/g, " ") : "",
+ created_at: messages[0]?.timestamp || new Date().toISOString(),
+ },
+ messages,
+ metadata: {},
+ };
+}
diff --git a/src/ingest/parsers/index.ts b/src/ingest/parsers/index.ts
new file mode 100644
index 0000000..a0e8267
--- /dev/null
+++ b/src/ingest/parsers/index.ts
@@ -0,0 +1,7 @@
+export { parseClaude } from "./claude";
+export { parseCodex } from "./codex";
+export { parseCursor } from "./cursor";
+export { parseCline } from "./cline";
+export { parseCopilot } from "./copilot";
+export { parseGeneric } from "./generic";
+export type { ParsedSession } from "./types";
diff --git a/src/ingest/parsers/types.ts b/src/ingest/parsers/types.ts
new file mode 100644
index 0000000..615fc67
--- /dev/null
+++ b/src/ingest/parsers/types.ts
@@ -0,0 +1,14 @@
+import type { ParsedMessage, StructuredMessage } from "../types";
+
+export type ParsedSession = {
+ session: {
+ id: string;
+ title: string;
+ created_at: string;
+ };
+ messages: Array;
+ metadata: {
+ total_tokens?: number;
+ total_duration_ms?: number;
+ };
+};
diff --git a/src/ingest/session-resolver.ts b/src/ingest/session-resolver.ts
new file mode 100644
index 0000000..1facd25
--- /dev/null
+++ b/src/ingest/session-resolver.ts
@@ -0,0 +1,88 @@
+import type { Database } from "bun:sqlite";
+import { basename } from "path";
+import { deriveProjectId as deriveClaudeProjectId, deriveProjectPath as deriveClaudeProjectPath } from "./claude";
+import { deriveProjectId as deriveClineProjectId, deriveProjectPath as deriveClineProjectPath } from "./cline";
+import { deriveProjectId as deriveCopilotProjectId } from "./copilot";
+
+export type ResolveSessionInput = {
+ db: Database;
+ sessionId: string;
+ agentId: string;
+ projectDir?: string;
+ explicitProjectId?: string;
+ explicitProjectPath?: string;
+};
+
+export type ResolvedSession = {
+ sessionId: string;
+ projectId: string | null;
+ projectPath: string | null;
+ isNew: boolean;
+ existingMessageCount: number;
+};
+
+function deriveForAgent(agentId: string, projectDir?: string): { projectId: string | null; projectPath: string | null } {
+ if (!projectDir) return { projectId: null, projectPath: null };
+
+ switch (agentId) {
+ case "claude":
+ case "claude-code":
+ return {
+ projectId: deriveClaudeProjectId(projectDir),
+ projectPath: deriveClaudeProjectPath(projectDir),
+ };
+ case "cline":
+ return {
+ projectId: deriveClineProjectId(projectDir),
+ projectPath: deriveClineProjectPath(projectDir),
+ };
+ case "copilot":
+ return {
+ projectId: deriveCopilotProjectId(projectDir),
+ projectPath: projectDir,
+ };
+ case "cursor":
+ return {
+ projectId: basename(projectDir) || "unknown",
+ projectPath: projectDir,
+ };
+ case "codex":
+ return { projectId: null, projectPath: null };
+ case "file":
+ case "generic":
+ return {
+ projectId: basename(projectDir) || "unknown",
+ projectPath: projectDir,
+ };
+ default:
+ return {
+ projectId: basename(projectDir) || null,
+ projectPath: projectDir,
+ };
+ }
+}
+
+export function resolveSession(input: ResolveSessionInput): ResolvedSession {
+ const { db, sessionId, agentId, explicitProjectId, explicitProjectPath } = input;
+
+ const derived = deriveForAgent(agentId, input.projectDir);
+ const projectId = explicitProjectId || derived.projectId;
+ const projectPath = explicitProjectPath || derived.projectPath;
+
+ const existingMessageCount =
+ (db
+ .prepare(`SELECT COUNT(*) as count FROM memory_messages WHERE session_id = ?`)
+ .get(sessionId) as { count: number } | null)?.count ?? 0;
+
+ const existingSession = db
+ .prepare(`SELECT 1 as yes FROM smriti_session_meta WHERE session_id = ?`)
+ .get(sessionId) as { yes: number } | null;
+
+ return {
+ sessionId,
+ projectId,
+ projectPath,
+ existingMessageCount,
+ isNew: !existingSession,
+ };
+}
diff --git a/src/ingest/store-gateway.ts b/src/ingest/store-gateway.ts
new file mode 100644
index 0000000..195199d
--- /dev/null
+++ b/src/ingest/store-gateway.ts
@@ -0,0 +1,127 @@
+import type { Database } from "bun:sqlite";
+import { addMessage } from "../qmd";
+import {
+ insertCommand,
+ insertError,
+ insertFileOperation,
+ insertGitOperation,
+ insertToolUsage,
+ upsertProject,
+ upsertSessionCosts,
+ upsertSessionMeta,
+} from "../db";
+import type { MessageBlock } from "./types";
+
+export type StoreMessageResult = {
+ messageId: number;
+ success: boolean;
+ error?: string;
+};
+
+export async function storeMessage(
+ db: Database,
+ sessionId: string,
+ role: string,
+ content: string,
+ options?: { title?: string; metadata?: Record }
+): Promise {
+ try {
+ const stored = await addMessage(db, sessionId, role, content, options);
+ return { messageId: stored.id, success: true };
+ } catch (err: any) {
+ return { messageId: -1, success: false, error: err.message };
+ }
+}
+
+export function storeBlocks(
+ db: Database,
+ messageId: number,
+ sessionId: string,
+ projectId: string | null,
+ blocks: MessageBlock[],
+ createdAt: string
+): void {
+ for (const block of blocks) {
+ switch (block.type) {
+ case "tool_call":
+ insertToolUsage(
+ db,
+ messageId,
+ sessionId,
+ block.toolName,
+ block.description || null,
+ true,
+ null,
+ createdAt
+ );
+ break;
+ case "file_op":
+ insertFileOperation(
+ db,
+ messageId,
+ sessionId,
+ block.operation,
+ block.path,
+ projectId,
+ createdAt
+ );
+ break;
+ case "command":
+ insertCommand(
+ db,
+ messageId,
+ sessionId,
+ block.command,
+ block.exitCode ?? null,
+ block.cwd ?? null,
+ block.isGit,
+ createdAt
+ );
+ break;
+ case "git":
+ insertGitOperation(
+ db,
+ messageId,
+ sessionId,
+ block.operation,
+ block.branch ?? null,
+ block.prUrl ?? null,
+ block.prNumber ?? null,
+ block.message ? JSON.stringify({ message: block.message }) : null,
+ createdAt
+ );
+ break;
+ case "error":
+ insertError(db, messageId, sessionId, block.errorType, block.message, createdAt);
+ break;
+ }
+ }
+}
+
+export function storeSession(
+ db: Database,
+ sessionId: string,
+ agentId: string,
+ projectId: string | null,
+ projectPath?: string | null
+): void {
+ if (projectId) {
+ upsertProject(db, projectId, projectPath || undefined);
+ }
+ const agentExists = db
+ .prepare(`SELECT 1 as yes FROM smriti_agents WHERE id = ?`)
+ .get(agentId) as { yes: number } | null;
+ upsertSessionMeta(db, sessionId, agentExists ? agentId : undefined, projectId || undefined);
+}
+
+export function storeCosts(
+ db: Database,
+ sessionId: string,
+ model: string | null,
+ inputTokens: number,
+ outputTokens: number,
+ cacheTokens: number,
+ durationMs: number
+): void {
+ upsertSessionCosts(db, sessionId, model, inputTokens, outputTokens, cacheTokens, durationMs);
+}
diff --git a/src/qmd.ts b/src/qmd.ts
index 1d7962a..ccfa4cf 100644
--- a/src/qmd.ts
+++ b/src/qmd.ts
@@ -17,8 +17,8 @@ export {
importTranscript,
initializeMemoryTables,
createSession,
-} from "qmd/src/memory";
+} from "../qmd/src/memory";
-export { hashContent } from "qmd/src/store";
+export { hashContent } from "../qmd/src/store";
-export { ollamaRecall } from "qmd/src/ollama";
+export { ollamaRecall } from "../qmd/src/ollama";
diff --git a/streamed-humming-curry.md b/streamed-humming-curry.md
new file mode 100644
index 0000000..caa707b
--- /dev/null
+++ b/streamed-humming-curry.md
@@ -0,0 +1,1320 @@
+# Ingest Architecture Refactoring: Separation of Concerns
+
+## Context
+
+**Problem**: The current ingest system violates separation of concerns. Parsers and orchestrators handle:
+- Session discovery & project detection
+- Message parsing & block extraction
+- SQLite persistence + side-car table population
+- Elasticsearch parallel writes
+- Token accumulation & cost aggregation
+- Session metadata updates
+- Incremental ingest logic
+
+All mixed together in 600+ line functions.
+
+**Result**: 7 major coupling points making the code hard to test, extend, and maintain.
+
+**Solution**: Refactor into clean layers where **each parser ONLY extracts raw messages** and **persistence happens separately**.
+
+---
+
+## New Architecture: 4 Clean Layers
+
+```
+Layer 1: PARSERS (agent-specific extraction only)
+├── src/ingest/parsers/claude.ts
+├── src/ingest/parsers/codex.ts
+├── src/ingest/parsers/cursor.ts
+└── src/ingest/parsers/cline.ts
+ Output: { session, messages[], blocks[], metadata }
+
+Layer 2: SESSION RESOLVER (project detection, incremental logic)
+├── src/ingest/session-resolver.ts
+ Input: { session, metadata, projectDir }
+ Output: { sessionId, projectId, projectPath, isNew, existing_count }
+
+Layer 3: MESSAGE STORE GATEWAY (unified SQLite + ES writes)
+├── src/ingest/store-gateway.ts
+ - storeMessage(sessionId, role, content, blocks, metadata)
+ - storeSession(sessionId, projectId, title, metadata)
+ - storeBlocks(messageId, blocks)
+ - storeCosts(sessionId, tokens, duration)
+ Output: { messageId, success, errors }
+
+Layer 4: INGEST ORCHESTRATOR (composition layer)
+├── src/ingest/index.ts (refactored)
+ - Load parser
+ - Resolve sessions
+ - Store all messages via gateway
+ - Aggregate costs
+ - Report results
+```
+
+**Key principle**: Each layer can be tested independently. Parsers don't know about databases. Store gateway doesn't know about parsing.
+
+---
+
+## Implementation Plan
+
+### Phase 1: Extract Parsers into Pure Functions (No DB Knowledge)
+
+#### 1.1 Refactor `src/ingest/parsers/claude.ts`
+
+**Goal**: Claude parser returns ONLY parsed messages, session info. Zero database calls.
+
+**Current problem (lines 389-625)**:
+- 237 lines doing: discovery → parsing → DB writes → ES writes → block extraction → cost aggregation
+- Couples parser output to SQLite schema
+
+**New `ingestClaudeSessions()` signature**:
+```typescript
+export async function parseClaude(
+ sessionPath: string,
+ projectDir: string
+): Promise<{
+ session: { id: string; title: string; created_at: string };
+ messages: StructuredMessage[];
+ metadata: { total_tokens?: number; total_duration_ms?: number };
+}>;
+```
+
+**What stays in parser**:
+- Session discovery: find .jsonl files ✓
+- Title derivation: extract from first user message ✓
+- Block extraction: analyze content for tool_calls, file_ops, git_ops, errors ✓
+- Structured message creation ✓
+
+**What LEAVES parser**:
+- ❌ `addMessage(db, ...)` calls → return messages array
+- ❌ `ingestMessageToES(...)` calls → let caller decide
+- ❌ `insertToolUsage()`, `insertFileOperation()`, etc. → return blocks separately
+- ❌ `upsertSessionCosts()` → return metadata with token counts
+- ❌ `upsertSessionMeta()` → let caller decide
+
+**Implementation**:
+- Rename current `ingestClaude()` → `parseClaude()`
+- Remove all DB calls (lines 454-592)
+- Return `ParsedSession` interface with messages + blocks + metadata
+- Keep block extraction logic (needed for structured output)
+
+**Files to modify**:
+- `src/ingest/parsers/claude.ts` - Extract, no DB calls
+
+**Lines deleted**: ~180 lines of DB I/O, ES calls, cost aggregation
+**Lines added**: ~50 lines (return ParsedSession interface)
+**Net**: Simpler, testable parser
+
+**Effort**: 1.5 hours
+
+---
+
+#### 1.2 Refactor Other Parsers (codex, cursor, cline, copilot)
+
+**Same refactoring for all**:
+- `src/ingest/parsers/codex.ts` - Remove DB, ES calls (40 lines deleted)
+- `src/ingest/parsers/cursor.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/cline.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/copilot.ts` - Remove DB, ES calls (30 lines deleted)
+- `src/ingest/parsers/generic.ts` - Remove DB, ES calls (30 lines deleted)
+
+All return same `ParsedSession` interface for consistency.
+
+**Effort**: 2 hours (5 parsers × 24 min each)
+
+**Total Phase 1**: 3.5 hours
+
+---
+
+### Phase 2: Create Session Resolver Layer
+
+#### 2.1 New `src/ingest/session-resolver.ts`
+
+**Purpose**: Take parsed session + project info, resolve database state
+
+**Responsibilities**:
+- Derive project_id from projectDir (using existing `deriveProjectId()`)
+- Derive project_path from projectDir (using existing `deriveProjectPath()`)
+- Check if session already exists in database
+- Count existing messages (for incremental ingest)
+- Determine if this is a new session or append
+
+**Function signature**:
+```typescript
+export async function resolveSession(
+ db: Database,
+ sessionId: string,
+ projectDir: string,
+ metadata: { total_tokens?: number; total_duration_ms?: number }
+): Promise<{
+ sessionId: string;
+ projectId: string;
+ projectPath: string;
+ isNew: boolean;
+ existingMessageCount: number;
+}>;
+```
+
+**Uses existing functions**:
+- `deriveProjectId()` from `src/ingest/claude.ts` (already exists)
+- `deriveProjectPath()` from `src/ingest/claude.ts` (already exists)
+- DB query: `SELECT COUNT(*) FROM memory_messages WHERE session_id = ?`
+- DB query: `SELECT 1 FROM smriti_session_meta WHERE session_id = ?`
+
+**New file**:
+- `src/ingest/session-resolver.ts` (~80 lines)
+
+**Effort**: 1 hour
+
+---
+
+### Phase 3: Create Store Gateway Layer
+
+#### 3.1 New `src/ingest/store-gateway.ts`
+
+**Purpose**: Unified interface for all database writes (SQLite + ES)
+
+**Four functions**:
+
+**Function 1: `storeMessage()`**
+```typescript
+export async function storeMessage(
+ db: Database,
+ sessionId: string,
+ role: string,
+ content: string,
+ blocks: Block[],
+ metadata?: Record
+): Promise<{ messageId: string; success: boolean; error?: string }>;
+```
+- Calls QMD's `addMessage(db, sessionId, role, content, metadata)`
+- Captures returned messageId
+- Calls `ingestMessageToES()` in parallel (fire & forget)
+- Returns messageId + success status
+
+**Function 2: `storeBlocks()`**
+```typescript
+export async function storeBlocks(
+ db: Database,
+ messageId: string,
+ sessionId: string,
+ blocks: Block[]
+): Promise;
+```
+- Iterates blocks and calls existing DB functions:
+ - `insertToolUsage()` for tool_call blocks
+ - `insertFileOperation()` for file_op blocks
+ - `insertCommand()` for command blocks
+ - `insertGitOperation()` for git blocks
+ - `insertError()` for error blocks
+- Centralizes all block storage logic
+
+**Function 3: `storeSession()`**
+```typescript
+export async function storeSession(
+ db: Database,
+ sessionId: string,
+ agentId: string,
+ projectId: string,
+ title: string,
+ metadata?: { total_tokens?: number; total_duration_ms?: number }
+): Promise;
+```
+- Calls `upsertSessionMeta()` (existing function)
+- Calls `ingestSessionToES()` in parallel
+- Ensures session metadata is stored once per session (not per message)
+
+**Function 4: `storeCosts()`**
+```typescript
+export async function storeCosts(
+ db: Database,
+ sessionId: string,
+ tokens: number,
+ duration_ms: number
+): Promise;
+```
+- Calls `upsertSessionCosts()` (existing function)
+- Aggregates token spend and duration at session level
+- Called once after all messages processed
+
+**New file**:
+- `src/ingest/store-gateway.ts` (~150 lines, wraps existing DB functions)
+
+**Design benefit**: All DB logic is now in ONE place. Easy to add new persistence layers (Postgres, etc.) without changing parsers.
+
+**Effort**: 1.5 hours
+
+---
+
+### Phase 4: Refactor Main Orchestrator
+
+#### 4.1 Refactor `src/ingest/index.ts`
+
+**Current problem (lines 50-117)**:
+- `ingest()` function mixes: discovery → parsing → orchestration → result aggregation
+- Uses dynamic imports for each parser (messy)
+- Calls parser's ingestClaude/ingestCodex/etc directly
+
+**New flow**:
+```typescript
+export async function ingest(
+ db: Database,
+ agentId: string,
+ options: IngestOptions
+): Promise {
+ // Step 1: Load parser dynamically
+ const parser = await loadParser(agentId);
+
+ // Step 2: Get sessions to process
+ const sessions = await discoverSessions(agentId, parser);
+
+ let ingested = 0;
+ let totalMessages = 0;
+ let errors: string[] = [];
+
+ for (const session of sessions) {
+ try {
+ // Step 3: Parse session (NO DB calls)
+ const parsed = await parser.parse(session.path, session.projectDir);
+
+ // Step 4: Resolve session state
+ const resolved = await resolveSession(
+ db,
+ parsed.session.id,
+ session.projectDir,
+ parsed.metadata
+ );
+
+ // Step 5: Store each message through gateway
+ for (const message of parsed.messages) {
+ const result = await storeMessage(
+ db,
+ resolved.sessionId,
+ message.role,
+ message.plainText,
+ message.blocks,
+ { ...message.metadata, title: parsed.session.title }
+ );
+
+ if (result.success && message.blocks.length > 0) {
+ await storeBlocks(
+ db,
+ result.messageId,
+ resolved.sessionId,
+ message.blocks
+ );
+ }
+ }
+
+ // Step 6: Store session metadata (once, after all messages)
+ await storeSession(
+ db,
+ resolved.sessionId,
+ agentId,
+ resolved.projectId,
+ parsed.session.title,
+ parsed.metadata
+ );
+
+ // Step 7: Store aggregated costs (once per session)
+ if (parsed.metadata.total_tokens || parsed.metadata.total_duration_ms) {
+ await storeCosts(
+ db,
+ resolved.sessionId,
+ parsed.metadata.total_tokens || 0,
+ parsed.metadata.total_duration_ms || 0
+ );
+ }
+
+ ingested++;
+ totalMessages += parsed.messages.length;
+ } catch (err) {
+ errors.push(`Session ${session.id}: ${(err as Error).message}`);
+ console.warn(`Ingest failed for ${session.id}`, err);
+ }
+ }
+
+ return {
+ agentId,
+ sessionsIngested: ingested,
+ messagesIngested: totalMessages,
+ errors,
+ };
+}
+```
+
+**Key improvements**:
+- Clear 7-step flow (discover → parse → resolve → store)
+- Each function does ONE thing
+- Error handling is per-session, doesn't break entire run
+- Session metadata written ONCE (not during loop)
+- No DB calls in parsers anymore
+- Easy to add new layers (caching, validation, etc.)
+
+**Files to modify**:
+- `src/ingest/index.ts` - Rewrite orchestration logic (~150 lines)
+
+**Lines kept**: 30 (discovery logic)
+**Lines rewritten**: 70 (main loop)
+**Lines removed**: 30 (dynamic imports, calls to old parser functions)
+**Lines added**: 20 (calls to new gateway functions)
+
+**Effort**: 1.5 hours
+
+---
+
+### Phase 5: Testing & Documentation
+
+#### 5.1 Write Unit Tests
+
+**Test modules**:
+- `test/ingest-parsers.test.ts` - Test each parser returns correct interface
+- `test/session-resolver.test.ts` - Test project derivation, increment logic
+- `test/store-gateway.test.ts` - Test DB writes go to correct tables
+- `test/ingest-orchestrator.test.ts` - Test full flow (mocked DB)
+
+**Each test**:
+- Uses in-memory SQLite (no external deps)
+- Tests happy path + error cases
+- Verifies function outputs match contract
+
+**Effort**: 2 hours
+
+#### 5.2 Update Documentation
+
+**Files to create/modify**:
+- `INGEST_ARCHITECTURE.md` - New doc explaining 4-layer design
+- `src/ingest/README.md` - Parser interface contract
+- Update `CLAUDE.md` - Explain separation of concerns
+
+**Effort**: 1 hour
+
+**Total Phase 5**: 3 hours
+
+---
+
+## Summary of Changes
+
+| Layer | Files | Change | LOC Impact |
+|-------|-------|--------|-----------|
+| Parser | claude.ts, codex.ts, cursor.ts, cline.ts, copilot.ts, generic.ts | Remove DB/ES calls | -400 lines (deleted), +100 lines (return interface) |
+| Resolver | NEW: session-resolver.ts | Extract project detection + incremental logic | +80 lines |
+| Gateway | NEW: store-gateway.ts | Unified DB write interface | +150 lines |
+| Orchestrator | ingest/index.ts | Refactor main loop | -30 lines, +70 lines rewritten |
+| **Net Result** | | Clean layered architecture | +100 net lines, but MUCH cleaner |
+
+---
+
+## Timeline
+
+| Phase | What | Effort | Total |
+|-------|------|--------|-------|
+| 1 | Extract parsers (6 files) | 3.5h | 3.5h |
+| 2 | Create session-resolver | 1h | 4.5h |
+| 3 | Create store-gateway | 1.5h | 6h |
+| 4 | Refactor orchestrator | 1.5h | 7.5h |
+| 5 | Testing + docs | 3h | 10.5h |
+| **Total** | | | **~10 hours** |
+
+---
+
+## Why This Refactoring Matters
+
+### Current Problems (BEFORE)
+- ❌ Parsers have database dependencies
+- ❌ Hard to test parsers in isolation
+- ❌ Hard to add new persistence layers (Postgres, Snowflake, etc.)
+- ❌ Hard to understand the flow (600+ line functions)
+- ❌ Hard to debug (mixing of concerns)
+- ❌ Hard to maintain (7 coupling points)
+
+### New Benefits (AFTER)
+- ✅ Parsers are pure functions (given path → return messages)
+- ✅ Test parsers without database
+- ✅ Add new storage backends by extending store-gateway
+- ✅ Each layer is ~100-150 lines (readable, understandable)
+- ✅ Single place to debug (store-gateway for all writes)
+- ✅ Follows dependency inversion principle (parsers don't depend on DB)
+
+---
+
+## Verification Plan
+
+Before/after each phase:
+
+1. **Parser extraction**:
+ - [ ] Run `smriti ingest claude` → same number of sessions/messages as before
+ - [ ] Check ES indices have data (same count)
+ - [ ] Check SQLite has data (same count)
+
+2. **Full refactoring**:
+ - [ ] Run `smriti ingest all` → ingests all agents without errors
+ - [ ] Run test suite: `bun test` → all tests pass
+ - [ ] Check data consistency: ES count ≈ SQLite count
+ - [ ] Verify no regressions: same data in both stores
+
+3. **Code quality**:
+ - [ ] Each parser < 300 lines (was 600+)
+ - [ ] Each function has single responsibility
+ - [ ] No circular imports
+ - [ ] No global state
+
+---
+
+## Risk Mitigation
+
+| Risk | Mitigation |
+|------|-----------|
+| **Break existing ingest** | Keep old code in parallel during refactor, test both |
+| **Lose data** | Test with small dataset first (single agent) |
+| **ES writes fail** | Gateway already has fire-and-forget pattern, won't break SQLite |
+| **Merge conflicts** | Work on separate files (parsers/, new files in ingest/) |
+- [ ] Create `elastic-setup/` folder structure:
+ ```
+ elastic-setup/
+ ├── docker-compose.yml # ES 8.11.0 + Kibana + setup
+ ├── elasticsearch.yml # ES node configuration
+ ├── .env.example # Env var template (ELASTIC_HOST, ELASTIC_PASSWORD, etc.)
+ ├── README.md # Setup instructions (3 min to running)
+ ├── scripts/
+ │ ├── setup.sh # Create indices + templates
+ │ ├── seed-data.sh # (Optional) Load sample sessions
+ │ └── cleanup.sh # Destroy containers
+ └── kibana/
+ └── dashboards.json # Pre-built Kibana dashboard (export)
+ ```
+
+- [ ] `docker-compose.yml`:
+ - Elasticsearch 8.11.0 (single-node, 2GB heap)
+ - Kibana 8.11.0 (for judges to inspect data)
+ - Auto-generated credentials + certificates
+ - Health checks
+
+- [ ] `scripts/setup.sh`:
+ - Wait for ES to be healthy
+ - Create indices: `smriti_sessions`, `smriti_messages`
+ - Create index templates for automatic field mapping
+ - Output connection details (host, user, password)
+
+- [ ] `README.md`:
+ ```markdown
+ # Elasticsearch Setup for Smriti Hackathon
+
+ ## Quick Start (3 minutes)
+
+ 1. Clone repo, enter elastic-setup folder
+ 2. Run: docker-compose up -d
+ 3. Wait: scripts/setup.sh (waits for ES to be ready)
+ 4. Access:
+ - Elasticsearch: http://localhost:9200 (user: elastic, password: changeme)
+ - Kibana: http://localhost:5601
+
+ ## Environment Variables
+ - ELASTIC_HOST=localhost:9200
+ - ELASTIC_USER=elastic
+ - ELASTIC_PASSWORD=
+ - ELASTIC_CLOUD_ID=
+ ```
+
+**Files to Create**:
+- `elastic-setup/docker-compose.yml`
+- `elastic-setup/elasticsearch.yml`
+- `elastic-setup/.env.example`
+- `elastic-setup/README.md`
+- `elastic-setup/scripts/setup.sh`
+- `elastic-setup/scripts/cleanup.sh`
+
+**Effort**: 1.5 hours
+
+---
+
+#### 1.2 Elasticsearch Client Library (No Auth Yet)
+
+**Goal**: Minimal ES client that can be toggled on/off via env var
+
+**Tasks**:
+- [ ] Create `src/es/client.ts` - Elasticsearch connection
+ - Check if `ELASTIC_HOST` env var set
+ - If yes: Connect to ES, expose `{ client, indexName }`
+ - If no: Return null (parallel ingestion will skip ES writes)
+
+- [ ] Define ES index schema in `src/es/schema.ts`:
+ ```ts
+ export const SESSION_INDEX = "smriti_sessions";
+ export const MESSAGE_INDEX = "smriti_messages";
+
+ export const sessionMapping = {
+ properties: {
+ session_id: { type: "keyword" },
+ agent_id: { type: "keyword" },
+ project_id: { type: "keyword" },
+ title: { type: "text" },
+ summary: { type: "text" },
+ created_at: { type: "date" },
+ duration_ms: { type: "integer" },
+ turn_count: { type: "integer" },
+ token_spend: { type: "float" },
+ error_count: { type: "integer" },
+ categories: { type: "keyword" },
+ embedding: { type: "dense_vector", dims: 1536, similarity: "cosine" }
+ }
+ };
+ ```
+
+**Files to Create**:
+- `src/es/client.ts` - Connection + null check
+- `src/es/schema.ts` - Index definitions
+- `src/es/ingest.ts` - Parallel write helper (see 1.4)
+
+**Effort**: 1 hour
+
+---
+
+#### 1.2 Adapter Layer (src/es.ts)
+
+**Goal**: Create a wrapper that mimics QMD's exported functions but hits ES instead
+
+**Why**: Minimal changes to existing code. `src/qmd.ts` becomes a routing layer:
+```ts
+// src/qmd.ts (modified)
+export { addMessage, searchMemoryFTS, searchMemoryVec, recallMemories } from "./es.ts"
+```
+
+**Tasks**:
+- [ ] Implement `addMessage(sessionId, role, content, metadata)` → ES bulk insert
+- [ ] Implement `searchMemoryFTS(query)` → ES query_string
+- [ ] Implement `searchMemoryVec(embedding)` → ES dense_vector search
+- [ ] Implement `recallMemories(query, synthesize?)` → hybrid search + session dedup
+- [ ] Implement metadata helpers (for tool usage, git ops, etc.)
+
+**Example addMessage**:
+```ts
+export async function addMessage(
+ sessionId: string,
+ role: "user" | "assistant" | "system",
+ content: string,
+ metadata?: Record
+) {
+ const doc = {
+ session_id: sessionId,
+ role,
+ content,
+ timestamp: new Date(),
+ embedding: await generateEmbedding(content), // Reuse Ollama
+ ...metadata
+ };
+
+ const client = getEsClient();
+ await client.index({
+ index: "smriti_messages",
+ document: doc
+ });
+}
+```
+
+**Files to Create/Modify**:
+- `src/es.ts` - Core ES adapter functions
+- `src/qmd.ts` - Change imports to route to ES (keep surface API identical)
+- `src/es/embedding.ts` - Reuse Ollama embedding logic from QMD
+
+**Effort**: 2.5 hours
+
+---
+
+#### 1.3 Parallel Ingest (SQLite + Elasticsearch)
+
+**Goal**: When `ELASTIC_HOST` env var set, write to both SQLite (via QMD) and Elasticsearch in parallel
+
+**Why parallel**:
+- SQLite ingestion keeps working (zero breaking changes)
+- ES gets the same data (judges see dual-write success)
+- If ES fails, SQLite succeeds (safe fallback)
+- Can test ES independently
+
+**Tasks**:
+- [ ] Create `src/es/ingest.ts` - Helper to write messages + sessions to ES
+ ```ts
+ export async function ingestMessageToES(
+ sessionId: string,
+ role: string,
+ content: string,
+ metadata?: Record
+ ) {
+ const esClient = getEsClient();
+ if (!esClient) return; // ES not configured, skip
+
+ const doc = {
+ session_id: sessionId,
+ role,
+ content,
+ timestamp: new Date().toISOString(),
+ ...metadata
+ };
+
+ await esClient.index({
+ index: MESSAGE_INDEX,
+ document: doc
+ });
+ }
+
+ export async function ingestSessionToES(sessionMetadata) {
+ // Similar for session-level metadata
+ }
+ ```
+
+- [ ] Modify `src/ingest/index.ts:ingestAgent()` - Add parallel ES write:
+ ```ts
+ async function ingestAgent(agentId: string, options: IngestOptions) {
+ const sessions = await discoverSessions(agentId);
+ let ingested = 0;
+
+ for (const session of sessions) {
+ if (await sessionExists(session.id)) continue;
+
+ const messages = await parseSessions(session);
+
+ for (const msg of messages) {
+ // Write to SQLite (QMD) - unchanged
+ await addMessage(msg.sessionId, msg.role, msg.content, msg.metadata);
+
+ // Write to ES in parallel (non-blocking)
+ ingestMessageToES(msg.sessionId, msg.role, msg.content, msg.metadata).catch(err => {
+ console.warn(`ES ingest failed for ${msg.sessionId}:`, err.message);
+ // Don't throw - SQLite succeeded, ES is optional
+ });
+ }
+
+ ingested++;
+ }
+
+ return { agentId, sessionsIngested: ingested };
+ }
+ ```
+
+- [ ] Modify `src/config.ts` - Add ES env vars:
+ ```ts
+ export const ELASTIC_HOST = process.env.ELASTIC_HOST || null;
+ export const ELASTIC_USER = process.env.ELASTIC_USER || "elastic";
+ export const ELASTIC_PASSWORD = process.env.ELASTIC_PASSWORD || "changeme";
+ export const ELASTIC_API_KEY = process.env.ELASTIC_API_KEY || null;
+ ```
+
+**Key design**:
+- `getEsClient()` returns null if `ELASTIC_HOST` not set → parallel ingest is no-op
+- ES write is async/non-blocking → doesn't slow down SQLite ingestion
+- All error handling is local (one ES failure doesn't break the whole ingest)
+
+**Files to Create/Modify**:
+- `src/es/ingest.ts` - New parallel write helpers
+- `src/ingest/index.ts` - Add ES write after QMD write
+- `src/config.ts` - Add ES env vars
+- Keep all parsers unchanged (src/ingest/claude.ts, codex.ts, etc.)
+
+**Effort**: 2 hours
+
+**Total Phase 1: 4.5 hours** (much faster than full auth refactor!)
+
+---
+
+### Phase 2: API & Frontend (Day 2, Hours 5-16)
+
+#### 2.1 Backend API Layer (No Auth Yet)
+
+**Goal**: Expose ES data via HTTP endpoints for React frontend
+
+**Tasks**:
+- [ ] Create `src/api/server.ts` - Bun.serve() with /api routes
+ ```ts
+ import { Bun } from "bun";
+
+ const PORT = 3000;
+
+ Bun.serve({
+ port: PORT,
+ routes: {
+ "/api/sessions": sessionsEndpoint,
+ "/api/sessions/:id": sessionDetailEndpoint,
+ "/api/search": searchEndpoint,
+ "/api/analytics/overview": analyticsOverviewEndpoint,
+ "/api/analytics/timeline": analyticsTimelineEndpoint,
+ "/api/analytics/tools": toolsEndpoint,
+ "/api/analytics/projects": projectsEndpoint,
+ }
+ });
+ ```
+
+- [ ] Implement endpoints:
+ - `GET /api/sessions?limit=50&offset=0` - List sessions from ES
+ - `GET /api/sessions/:id` - Single session + all messages
+ - `POST /api/search` - Query ES with keyword + optional vector search
+ - `GET /api/analytics/overview` - Aggregations (total sessions, avg duration, token spend, errors)
+ - `GET /api/analytics/timeline` - Time-bucket aggregations (sessions per day, tokens per day for last 30 days)
+ - `GET /api/analytics/tools` - Tool usage histogram
+ - `GET /api/analytics/projects` - Per-project stats
+
+- [ ] Example endpoint (sessions list):
+ ```ts
+ async function sessionsEndpoint(req: Request) {
+ const url = new URL(req.url);
+ const limit = parseInt(url.searchParams.get("limit") ?? "50");
+ const offset = parseInt(url.searchParams.get("offset") ?? "0");
+
+ const esClient = getEsClient();
+ if (!esClient) {
+ return new Response(JSON.stringify({ error: "ES not configured" }), { status: 500 });
+ }
+
+ const result = await esClient.search({
+ index: "smriti_sessions",
+ from: offset,
+ size: limit,
+ sort: [{ created_at: { order: "desc" } }]
+ });
+
+ return new Response(JSON.stringify({
+ total: result.hits.total.value,
+ sessions: result.hits.hits.map(h => h._source)
+ }));
+ }
+ ```
+
+**Files to Create**:
+- `src/api/server.ts` - Main Bun server
+- `src/api/endpoints/sessions.ts` - GET /api/sessions, /api/sessions/:id
+- `src/api/endpoints/search.ts` - POST /api/search (keyword + optional embedding)
+- `src/api/endpoints/analytics.ts` - All /api/analytics/* endpoints
+
+**Effort**: 2 hours
+
+---
+
+#### 2.2 React Web App (Simple Dashboard)
+
+**Goal**: Minimal dashboard to visualize ES data (no auth yet, just UI)
+
+**Architecture**:
+```
+frontend/
+├── index.html (entry point)
+├── App.tsx (main app, simple nav)
+├── pages/
+│ ├── Dashboard.tsx (stats overview)
+│ ├── SessionList.tsx (searchable sessions)
+│ ├── SessionDetail.tsx (read-only view)
+│ └── Analytics.tsx (tool usage, timelines)
+├── components/
+│ ├── StatsCard.tsx
+│ ├── SessionCard.tsx
+│ └── Chart.tsx
+├── hooks/
+│ └── useApi.ts (fetch from /api/*)
+└── index.css (Tailwind)
+```
+
+**Key pages**:
+- **Dashboard**: 4 stat cards (total sessions, avg duration, token spend, error rate) + timeline chart
+- **SessionList**: Searchable table of sessions, click to detail
+- **SessionDetail**: Show messages, tool usage, git ops for a session
+- **Analytics**: Tool usage pie chart, project breakdown, error rate timeline
+
+**Example Dashboard**:
+```tsx
+export default function Dashboard() {
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ fetch("/api/analytics/overview")
+ .then(r => r.json())
+ .then(setStats);
+ }, []);
+
+ if (!stats) return Loading...
;
+
+ return (
+
+
Smriti Analytics
+
+
+
+
+
+
+
+ );
+}
+```
+
+**Tech**:
+- React 18 + TypeScript (Bun bundling)
+- Recharts for charts (simple, zero-config)
+- Tailwind CSS
+- No auth/routing complexity (just simple pages)
+
+**Files to Create**:
+- `frontend/index.html` - Static entry point
+- `frontend/App.tsx` - Main component, tab navigation
+- `frontend/pages/Dashboard.tsx`
+- `frontend/pages/SessionList.tsx`
+- `frontend/pages/SessionDetail.tsx`
+- `frontend/pages/Analytics.tsx`
+- `frontend/components/StatsCard.tsx`
+- `frontend/hooks/useApi.ts`
+- `frontend/index.css` - Tailwind
+
+**Effort**: 3.5 hours
+
+---
+
+#### 2.3 CLI Integration (API Server Flag)
+
+**Goal**: Add `--api` flag to start API server alongside CLI
+
+**Tasks**:
+- [ ] Modify `src/index.ts` - Check for `--api` flag
+- [ ] If `--api`: Start `src/api/server.ts` in background
+- [ ] Default: CLI works as before (no breaking changes)
+- [ ] Example: `smriti ingest claude --api` (or `smriti --api` then `smriti ingest...`)
+
+**Files to Modify**:
+- `src/index.ts` - Add --api flag handler
+
+**Effort**: 0.5 hours
+
+**Total Phase 2: 6.5 hours**
+
+---
+
+### Phase 3: Polish & Submission (Day 2, Hours 21-24)
+
+#### 3.1 Demo Script & Video
+
+**Pre-demo setup** (30 min before recording):
+- [ ] Start Docker: `cd elastic-setup && docker-compose up -d && bash scripts/setup.sh`
+- [ ] Ingest existing Smriti data:
+ ```bash
+ export ELASTIC_HOST=localhost:9200
+ smriti ingest all # or just "claude" if fast
+ ```
+- [ ] Verify ES has data: `curl http://localhost:9200/smriti_sessions/_count`
+- [ ] Start API server: `smriti --api` (or `bun src/api/server.ts`)
+- [ ] Open browser: http://localhost:3000 → dashboard should load
+
+**Demo script** (3 min):
+1. **Show setup** (20s)
+ - Briefly show docker-compose running
+ - Show `curl` output (ES has data)
+
+2. **Dashboard** (30s)
+ - Refresh page, show stats cards load (sessions, tokens, errors, duration)
+ - Point out that real data from all ingested sessions is shown
+
+3. **Timeline** (20s)
+ - Click "Analytics" tab
+ - Show timeline chart of sessions per week
+ - Explain: "Teams can see productivity trends"
+
+4. **Session browser** (30s)
+ - Click "Sessions" tab
+ - Search for a known topic (e.g., "bug", "refactor")
+ - Click one session → show messages, tool usage, git ops
+
+5. **Explain architecture** (20s)
+ - "CLI ingests to both SQLite and Elasticsearch in parallel"
+ - "ES powers the analytics API"
+ - "React dashboard visualizes shared learning"
+
+- [ ] Record screen capture (QuickTime on macOS, OBS on Linux)
+- [ ] Upload to YouTube, get shareable link
+
+**Effort**: 1.5 hours
+
+---
+
+#### 3.2 Documentation & README
+
+**Tasks**:
+- [ ] Update `README.md`:
+ - New section: "Elasticsearch Edition (Hackathon)"
+ - Architecture diagram (SQLite → ES)
+ - Setup instructions (ES + env vars)
+ - CLI auth flow
+ - API endpoint reference
+
+- [ ] Create `ELASTICSEARCH.md`:
+ - Index schema explanation
+ - Adapter layer design decisions
+ - Team isolation model
+ - Analytics aggregations
+
+- [ ] Add comments to critical functions (es.ts, api/server.ts)
+
+**Files to Create/Modify**:
+- `README.md` - Add ES section
+- `ELASTICSEARCH.md` - Technical design
+- Inline code comments
+
+**Effort**: 1.5 hours
+
+---
+
+#### 3.3 Final Testing & Polish
+
+**Tasks**:
+- [ ] Test end-to-end flow:
+ 1. `smriti login team-acme`
+ 2. `smriti ingest claude`
+ 3. `smriti search "fix bug"`
+ 4. Open web app at `http://localhost:3000`
+ 5. Verify dashboard loads, search works, analytics show data
+
+- [ ] Fix any bugs found during testing
+- [ ] Ensure API error handling is solid (don't expose ES errors directly)
+- [ ] Check web app mobile responsiveness (judges might view on phone)
+
+**Effort**: 1 hour
+
+---
+
+#### 3.4 GitHub & Submission
+
+**Tasks**:
+- [ ] Push to GitHub (ensure repo is public, MIT license)
+- [ ] Add hackathon-specific badges/mentions to README
+- [ ] Create `SUBMISSION.md`:
+ ```
+ # Smriti: Enterprise Memory for AI Teams
+
+ ## Problem
+ Enterprise AI teams lack visibility into agentic coding patterns.
+ Teams can't track token spend, error patterns, productivity signals.
+
+ ## Solution
+ Smriti migrated to Elasticsearch for enterprise-grade memory management:
+ - Team-scoped data (CLI auth)
+ - Real-time analytics (token spend, error rates, tool adoption)
+ - Hybrid search (keyword + semantic)
+ - Web dashboard for CTOs and team leads
+
+ ## Features Used
+ - Elasticsearch hybrid search (BM25 + dense vectors)
+ - Elasticsearch aggregations (time-series analytics)
+ - Elasticsearch team isolation (query scoping)
+
+ ## Demo Video
+ [YouTube link]
+
+ ## Code Repository
+ https://github.com/zero8dotdev/smriti
+ ```
+
+- [ ] Fill out Devpost submission form
+- [ ] Add demo video link
+- [ ] Double-check: Public repo ✓, OSI license ✓, ~400 words ✓, video ✓
+
+**Effort**: 1 hour
+
+**Total Phase 3: 5 hours**
+
+---
+
+## Timeline
+
+| Phase | What | Time | Hours |
+|-------|------|------|-------|
+| 1.1 | Elastic setup folder | Day 1, 1-2.5h | 1.5h |
+| 1.2 | ES client library | Day 1, 2.5-3.5h | 1h |
+| 1.3 | Parallel ingest (SQLite + ES) | Day 1, 3.5-5.5h | 2h |
+| **Phase 1 Total** | | **Day 1, 1-5.5h** | **4.5h** |
+| 2.1 | API layer (7 endpoints) | Day 2, 1-3h | 2h |
+| 2.2 | React frontend (Dashboard + views) | Day 2, 3-6.5h | 3.5h |
+| 2.3 | CLI --api flag | Day 2, 6.5-7h | 0.5h |
+| **Phase 2 Total** | | **Day 2, 1-7h** | **6.5h** |
+| 3.1 | Demo + video | Day 2, 7-8.5h | 1.5h |
+| 3.2 | Docs (README + ELASTICSEARCH.md) | Day 2, 8.5-10h | 1.5h |
+| 3.3 | Testing + polishing | Day 2, 10-11h | 1h |
+| 3.4 | GitHub + submit | Day 2, 11-12h | 1h |
+| **Phase 3 Total** | | **Day 2, 7-12h** | **5h** |
+| **Grand Total** | | **~16 hours** | |
+
+**Buffer**: 32 hours for interruptions, debugging, sleep, extra polish.
+
+---
+
+## Architectural Decisions
+
+### 1. Parallel Ingest (Not a Replacement)
+**Why**: Keeps SQLite working while adding ES.
+- SQLite is the primary store (zero breaking changes)
+- ES writes happen asynchronously in parallel
+- If ES fails, SQLite still succeeds (safe fallback)
+- Judges see "dual-write" success (impressive)
+- Easy to toggle: `if (esClient) { ingestToES() }` (line-by-line)
+
+### 2. SQLite-First, ES-Aware
+**Why**: Fastest to ship.
+- Keep all existing ingestion code unchanged
+- Add 20-30 lines per parser to call `ingestMessageToES()`
+- No schema migration (SQLite stays as-is)
+- ES indices are separate (never need to sync back)
+- If ES cluster dies, CLI still works
+
+### 3. No Auth in MVP
+**Why**: Simplifies scope by 1-2 days.
+- All ES data is readable via `/api/*` (no scoping)
+- Team isolation added in Phase 2 (post-hackathon)
+- Demo still shows multi-agent data (impressive volume)
+- Security: Run API on private network only (not public)
+
+### 4. Reuse Ollama for Embeddings
+**Why**: Already running, no new deps.
+- Call Ollama for vector generation (1536-dim)
+- Store in ES `dense_vector` field
+- Hybrid search: ES `match` (BM25) + `dense_vector` query
+
+### 5. React Dashboard Over Kibana
+**Why**: Shows custom engineering + faster to demo.
+- Custom React app controls story (judges like polish)
+- Kibana is nice-to-have (Phase 2)
+- React renders well on judge's phone/laptop
+- Pre-built components (StatsCard, Timeline) fast to code
+
+### 6. Elastic Setup Folder (Reproducibility)
+**Why**: Judges need to run it locally.
+- `docker-compose.yml` + scripts = 5-min setup
+- No cloud credentials needed (local ES)
+- Judges can validate data ingestion themselves
+- Shows professional packaging
+
+---
+
+## Risks & Mitigations
+
+| Risk | Impact | Mitigation |
+|------|--------|-----------|
+| **Docker setup (elasticsearch + kibana) slow** | Medium | Pre-build docker-compose.yml + test locally first. Scripts auto-create indices. Should be 5 min. |
+| **Parallel ingest causes data duplication** | Low | ES writes are isolated (no shared DB), so dedup is per-store. OK for demo. |
+| **Ollama embedding timeout** | Medium | Wrap ES ingest in try/catch, log errors. SQLite write still succeeds. Non-blocking prevents slowdown. |
+| **React frontend API errors** | Medium | Test API endpoints manually (`curl http://localhost:3000/api/...`) before React build. |
+| **Demo data too small (few sessions)** | Medium | Use existing Smriti data (`smriti ingest all` before demo). Real volume = impressive analytics. |
+| **ES query syntax errors** | Medium | Test each endpoint manually. Bun error logs are clear. Fix in-place during demo rehearsal. |
+| **GitHub repo structure confusing** | Low | Add `ELASTICSEARCH.md` with folder structure + setup diagram. |
+
+---
+
+## Success Criteria
+
+By end of Day 2, you should have:
+
+✅ **Elasticsearch running locally** (docker-compose.yml + setup scripts)
+✅ **ES indices created** (smriti_sessions, smriti_messages with correct mappings)
+✅ **Parallel ingest working** (CLI ingests to both SQLite + ES, no errors)
+✅ **API server up** (7 endpoints: /api/sessions, /api/sessions/:id, /api/search, /api/analytics/*)
+✅ **React dashboard live** (Dashboard page + SessionList + SessionDetail + Analytics pages)
+✅ **Demo workflow** (ingest sessions → API returns data → React displays it, 3 min video)
+✅ **Public GitHub repo** with elastic-setup/ folder, README, ELASTICSEARCH.md
+✅ **Devpost submission** (description + demo video + repo link)
+
+Optional (nice-to-have, if time allows):
+- ⭐ GitHub OAuth login (elegant but not required for MVP)
+- ⭐ Kibana dashboard pre-built (shows ES native power)
+- ⭐ Elasticsearch Agent Builder agent (too ambitious for 48h)
+- ⭐ Social media post + blog post
+
+---
+
+## Critical Files to Create/Modify
+
+### New Folders & Files (Essential)
+
+**Elastic Setup** (reproducible for judges):
+```
+elastic-setup/
+├── docker-compose.yml # ES 8.11.0 + Kibana, auto-setup
+├── elasticsearch.yml # Node config (heap, plugins)
+├── .env.example # Template for ELASTIC_HOST, password
+├── README.md # 5-min setup guide
+├── scripts/
+│ ├── setup.sh # Create indices + templates
+│ ├── cleanup.sh # Destroy containers
+│ └── seed-data.sh # (Optional) Load sample data
+└── kibana/
+ └── dashboards.json # (Optional) Pre-built dashboard
+```
+
+**Backend (ES client + parallel ingest)**:
+```
+src/
+├── es/
+│ ├── client.ts # Elasticsearch client (null if ELASTIC_HOST not set)
+│ ├── schema.ts # Index definitions (smriti_sessions, messages)
+│ └── ingest.ts # Helper: ingestMessageToES, ingestSessionToES
+├── api/
+│ ├── server.ts # Bun.serve() with /api routes
+│ ├── endpoints/
+│ │ ├── sessions.ts # GET /api/sessions, /api/sessions/:id
+│ │ ├── search.ts # POST /api/search
+│ │ └── analytics.ts # GET /api/analytics/overview, timeline, tools, projects
+│ └── utils/
+│ └── esQuery.ts # Helper: format ES aggregation queries
+
+frontend/
+├── index.html # Static entry point
+├── App.tsx # Main component + tab nav
+├── pages/
+│ ├── Dashboard.tsx # Stats cards + timeline
+│ ├── SessionList.tsx # Searchable session table
+│ ├── SessionDetail.tsx # Single session messages + metadata
+│ └── Analytics.tsx # Tool usage, projects, trends
+├── components/
+│ ├── StatsCard.tsx # Reusable stat display
+│ ├── Chart.tsx # Recharts wrapper
+│ └── Loading.tsx # Loading spinner
+├── hooks/
+│ └── useApi.ts # fetch() wrapper with error handling
+└── index.css # Tailwind styles
+```
+
+### Modified Files
+```
+src/
+├── index.ts # Add --api flag (starts API server)
+├── config.ts # Add ELASTIC_HOST, ELASTIC_USER, ELASTIC_PASSWORD
+└── ingest/index.ts # After QMD addMessage(), call ingestMessageToES() (fire & forget)
+
+package.json # Add @elastic/elasticsearch, react, react-dom, recharts, tailwindcss
+```
+
+---
+
+## Deployment
+
+### Development Setup (Local)
+
+```bash
+# 1. Set up GitHub OAuth
+# Create GitHub App at https://github.com/settings/developers
+# - App name: "Smriti Hackathon"
+# - Homepage URL: http://localhost:3000
+# - Authorization callback URL: http://localhost:3000/api/auth/github/callback
+# - Copy CLIENT_ID and CLIENT_SECRET
+
+# 2. Set env vars
+export ELASTICSEARCH_CLOUD_ID=""
+export ELASTICSEARCH_API_KEY=""
+export GITHUB_CLIENT_ID=""
+export GITHUB_CLIENT_SECRET=""
+export OLLAMA_HOST="http://127.0.0.1:11434"
+
+# 3. Ingest existing Smriti data
+bun src/index.ts ingest all
+
+# 4. Start API server
+bun --hot src/index.ts --serve
+# Server on :3000, API on :3000/api
+```
+
+### Production Deployment (Vercel/Railway)
+
+**Frontend (Vercel)**:
+```bash
+# 1. Push repo to GitHub
+git push origin elastic-hackathon
+
+# 2. Create new Vercel project from GitHub repo
+# https://vercel.com/new → select smriti repo
+
+# 3. Set env var:
+# VITE_API_URL = https://smriti-api.railway.app
+
+# 4. Deploy (automatic on push)
+```
+
+**Backend (Railway or Render)**:
+```bash
+# 1. Create new project on Railway.app or Render.com
+# 2. Connect GitHub repo
+# 3. Set environment variables:
+# - ELASTICSEARCH_CLOUD_ID (from Elastic Cloud)
+# - ELASTICSEARCH_API_KEY (from Elastic Cloud)
+# - GITHUB_CLIENT_ID (from GitHub App)
+# - GITHUB_CLIENT_SECRET (from GitHub App)
+# - OLLAMA_HOST (your local Ollama or cloud)
+# - NODE_ENV=production
+
+# 4. Deploy (automatic on push)
+```
+
+**Elastic Cloud Setup** (~15 min):
+1. Go to https://cloud.elastic.co/registration
+2. Create free trial account (credit card required)
+3. Create new Elasticsearch deployment (8.11.0, < 4GB RAM)
+4. Get Cloud ID and API Key from deployment settings
+5. Store in `ELASTICSEARCH_CLOUD_ID` and `ELASTICSEARCH_API_KEY`
+
+**GitHub OAuth Setup** (~5 min):
+1. Go to https://github.com/settings/developers/new
+2. Create OAuth App:
+ - **App name**: Smriti Hackathon
+ - **Homepage URL**: `https://smriti-hackathon.vercel.app` (deployed URL)
+ - **Authorization callback URL**: `https://smriti-hackathon.vercel.app/api/auth/github/callback`
+3. Copy Client ID and Client Secret into Railway/Render env vars
+
+---
+
+### Notes
+
+- **No additional databases needed** — Elasticsearch is the only data store
+- **Ollama can be local or cloud** — API server will connect via `OLLAMA_HOST`
+- **Vercel frontend is static** — Just React bundle, no secrets
+- **Railway/Render backend** — Runs Node.js/Bun server, connects to ES Cloud
+- **Total setup time**: ~30 min (Elastic Cloud + GitHub OAuth + Vercel/Railway deploy)
+
+---
+
+## Testing Checklist
+
+Before recording demo:
+
+- [ ] Docker running: `docker-compose ps` (elasticsearch + kibana running)
+- [ ] ES healthy: `curl http://localhost:9200/_cat/health` (status: green or yellow)
+- [ ] Indices created: `curl http://localhost:9200/_cat/indices` (smriti_sessions, smriti_messages visible)
+- [ ] Ingest works: `export ELASTIC_HOST=localhost:9200 && smriti ingest claude` (no errors)
+- [ ] Data in ES: `curl http://localhost:9200/smriti_sessions/_count` (returns count > 0)
+- [ ] API server starts: `bun src/api/server.ts` (logs "Listening on http://localhost:3000")
+- [ ] API endpoints respond:
+ - `curl http://localhost:3000/api/analytics/overview` → valid JSON
+ - `curl http://localhost:3000/api/sessions` → array of sessions
+ - `curl http://localhost:3000/api/sessions/UUID` → single session or 404
+- [ ] React app loads: `http://localhost:3000` → Dashboard page visible
+- [ ] Dashboard stats visible (total sessions, avg duration, tokens, errors)
+- [ ] SessionList page: search works, results appear
+- [ ] SessionDetail: click session, messages appear
+- [ ] Analytics page: timeline + tool usage chart render
+- [ ] No 500 errors in browser console or server logs
+- [ ] Refresh page (React state persists via API calls)
+
+---
+
+## Roadmap (Post-Hackathon)
+
+If submission is successful, next priorities:
+
+**Phase 2 (Short-term)**:
+- Team authentication (GitHub OAuth or API keys)
+- Team isolation via query filtering
+- Persisted saved searches
+- Email alerts on anomalies
+
+**Phase 3 (Medium-term)**:
+- Elasticsearch Agent Builder agents:
+ - "Anomaly Scout" - Detects unusual session patterns
+ - "Code Quality Advisor" - Suggests improvements based on patterns
+- Kibana dashboard export (native ES visualization)
+- Time-series alerting (token spike, error rate increase)
+
+**Phase 4 (Long-term)**:
+- Multi-org support (SaaS model)
+- Role-based access control (admin, analyst, viewer)
+- Audit logs (who accessed what)
+- Cost optimization (ES index size reduction, archival)
+- Mobile app (read-only dashboard)
diff --git a/test/ingest-claude-orchestrator.test.ts b/test/ingest-claude-orchestrator.test.ts
new file mode 100644
index 0000000..156699f
--- /dev/null
+++ b/test/ingest-claude-orchestrator.test.ts
@@ -0,0 +1,118 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest/index";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-claude-orch-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+function writeClaudeSession(filePath: string, sessionId: string, userText: string, assistantText: string) {
+ writeFileSync(
+ filePath,
+ [
+ JSON.stringify({
+ type: "user",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: userText },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: assistantText }],
+ usage: { input_tokens: 10, output_tokens: 5 },
+ },
+ }),
+ ].join("\n")
+ );
+}
+
+test("ingest(claude) ingests new session through orchestrator", async () => {
+ const projectDir = "-Users-zero8-zero8.dev-smriti";
+ const logsDir = join(root, "claude-logs");
+ const sessionId = "claude-session-1";
+ mkdirSync(join(logsDir, projectDir), { recursive: true });
+
+ const filePath = join(logsDir, projectDir, `${sessionId}.jsonl`);
+ writeClaudeSession(filePath, sessionId, "How should we deploy?", "Use blue/green.");
+
+ const result = await ingest(db, "claude", { logsDir });
+
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get(sessionId) as { agent_id: string; project_id: string | null };
+
+ expect(meta.agent_id).toBe("claude-code");
+ expect(meta.project_id).toBe("smriti");
+});
+
+test("ingest(claude) is incremental for append-only jsonl sessions", async () => {
+ const projectDir = "-Users-zero8-zero8.dev-smriti";
+ const logsDir = join(root, "claude-logs");
+ const sessionId = "claude-session-2";
+ mkdirSync(join(logsDir, projectDir), { recursive: true });
+
+ const filePath = join(logsDir, projectDir, `${sessionId}.jsonl`);
+ writeClaudeSession(filePath, sessionId, "Initial question", "Initial answer");
+
+ const first = await ingest(db, "claude", { logsDir });
+ expect(first.sessionsIngested).toBe(1);
+ expect(first.messagesIngested).toBe(2);
+
+ appendFileSync(
+ filePath,
+ "\n" +
+ JSON.stringify({
+ type: "user",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "Follow-up question" },
+ }) +
+ "\n" +
+ JSON.stringify({
+ type: "assistant",
+ sessionId,
+ timestamp: new Date().toISOString(),
+ message: { role: "assistant", content: [{ type: "text", text: "Follow-up answer" }] },
+ })
+ );
+
+ const second = await ingest(db, "claude", { logsDir });
+
+ expect(second.errors).toHaveLength(0);
+ expect(second.sessionsFound).toBe(1);
+ expect(second.sessionsIngested).toBe(1);
+ expect(second.messagesIngested).toBe(2);
+
+ const count = db
+ .prepare("SELECT COUNT(*) as c FROM memory_messages WHERE session_id = ?")
+ .get(sessionId) as { c: number };
+ expect(count.c).toBe(4);
+});
diff --git a/test/ingest-orchestrator.test.ts b/test/ingest-orchestrator.test.ts
new file mode 100644
index 0000000..c85a342
--- /dev/null
+++ b/test/ingest-orchestrator.test.ts
@@ -0,0 +1,83 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingest } from "../src/ingest/index";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-orch-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+test("ingest(codex) uses parser+resolver+gateway flow", async () => {
+ const logsDir = join(root, "codex");
+ mkdirSync(join(logsDir, "team"), { recursive: true });
+ writeFileSync(
+ join(logsDir, "team", "chat.jsonl"),
+ [
+ JSON.stringify({ role: "user", content: "How do we shard this?" }),
+ JSON.stringify({ role: "assistant", content: "Use tenant hash." }),
+ ].join("\n")
+ );
+
+ const result = await ingest(db, "codex", { logsDir });
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta")
+ .get() as { agent_id: string; project_id: string | null };
+ expect(meta.agent_id).toBe("codex");
+ expect(meta.project_id).toBeNull();
+});
+
+test("ingest(file) accepts explicit project without FK failure", async () => {
+ const filePath = join(root, "transcript.jsonl");
+ writeFileSync(
+ filePath,
+ [
+ JSON.stringify({ role: "user", content: "Set rollout plan" }),
+ JSON.stringify({ role: "assistant", content: "Canary then full rollout" }),
+ ].join("\n")
+ );
+
+ const result = await ingest(db, "file", {
+ filePath,
+ format: "jsonl",
+ sessionId: "file-1",
+ projectId: "proj-file",
+ });
+
+ expect(result.errors).toHaveLength(0);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("proj-file") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("file-1") as { session_id: string; agent_id: string | null; project_id: string };
+ expect(meta.project_id).toBe("proj-file");
+ expect(meta.agent_id === null || meta.agent_id === "generic").toBe(true);
+});
diff --git a/test/ingest-parsers.test.ts b/test/ingest-parsers.test.ts
new file mode 100644
index 0000000..136772f
--- /dev/null
+++ b/test/ingest-parsers.test.ts
@@ -0,0 +1,149 @@
+import { test, expect } from "bun:test";
+import { mkdtempSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { parseClaude } from "../src/ingest/parsers/claude";
+import { parseCodex } from "../src/ingest/parsers/codex";
+import { parseCursor } from "../src/ingest/parsers/cursor";
+import { parseCline } from "../src/ingest/parsers/cline";
+import { parseCopilot } from "../src/ingest/parsers/copilot";
+import { parseGeneric } from "../src/ingest/parsers/generic";
+
+async function withTmpDir(fn: (dir: string) => Promise | void): Promise {
+ const dir = mkdtempSync(join(tmpdir(), "smriti-parsers-"));
+ try {
+ await fn(dir);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+test("parseClaude returns ParsedSession with structured messages", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "s.jsonl");
+ writeFileSync(
+ p,
+ [
+ JSON.stringify({
+ type: "user",
+ sessionId: "s1",
+ timestamp: new Date().toISOString(),
+ message: { role: "user", content: "How do we deploy this?" },
+ }),
+ JSON.stringify({
+ type: "assistant",
+ sessionId: "s1",
+ timestamp: new Date().toISOString(),
+ message: { role: "assistant", content: [{ type: "text", text: "Use blue/green." }] },
+ }),
+ ].join("\n")
+ );
+
+ const parsed = await parseClaude(p, "s1");
+ expect(parsed.session.id).toBe("s1");
+ expect(parsed.session.title).toContain("How do we deploy this?");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCodex returns ParsedSession with title from first user", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "c.jsonl");
+ writeFileSync(
+ p,
+ [
+ JSON.stringify({ role: "user", content: "Plan caching strategy" }),
+ JSON.stringify({ role: "assistant", content: "Use layered cache" }),
+ ].join("\n")
+ );
+
+ const parsed = await parseCodex(p, "codex-1");
+ expect(parsed.session.id).toBe("codex-1");
+ expect(parsed.messages.length).toBe(2);
+ expect(parsed.session.title).toContain("Plan caching strategy");
+ });
+});
+
+test("parseCursor returns ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "cursor.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ messages: [
+ { role: "user", content: "Implement metrics" },
+ { role: "assistant", content: "Added counters." },
+ ],
+ })
+ );
+
+ const parsed = await parseCursor(p, "cursor-1");
+ expect(parsed.session.id).toBe("cursor-1");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCline returns structured ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "task.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ id: "task-1",
+ name: "Fix lint",
+ timestamp: new Date().toISOString(),
+ history: [
+ { ts: new Date().toISOString(), type: "say", text: "I will fix this" },
+ { ts: new Date().toISOString(), type: "ask", question: "Proceed?", options: "yes,no" },
+ ],
+ })
+ );
+
+ const parsed = await parseCline(p, "task-1");
+ expect(parsed.session.id).toBe("task-1");
+ expect(parsed.session.title).toBe("Fix lint");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseCopilot returns ParsedSession", async () => {
+ await withTmpDir(async (dir) => {
+ const p = join(dir, "copilot.json");
+ writeFileSync(
+ p,
+ JSON.stringify({
+ turns: [
+ { role: "user", content: "Add tracing" },
+ { role: "assistant", content: "Added OpenTelemetry hooks." },
+ ],
+ })
+ );
+
+ const parsed = await parseCopilot(p, "copilot-1");
+ expect(parsed.session.id).toBe("copilot-1");
+ expect(parsed.messages.length).toBe(2);
+ });
+});
+
+test("parseGeneric supports chat and jsonl formats", async () => {
+ await withTmpDir(async (dir) => {
+ const chatPath = join(dir, "chat.txt");
+ writeFileSync(chatPath, "user: hello\n\nassistant: hi");
+
+ const jsonlPath = join(dir, "chat.jsonl");
+ writeFileSync(
+ jsonlPath,
+ [
+ JSON.stringify({ role: "user", content: "u1" }),
+ JSON.stringify({ role: "assistant", content: "a1" }),
+ ].join("\n")
+ );
+
+ const chat = await parseGeneric(chatPath, "g-chat", "chat");
+ const jsonl = await parseGeneric(jsonlPath, "g-jsonl", "jsonl");
+
+ expect(chat.messages.length).toBe(2);
+ expect(jsonl.messages.length).toBe(2);
+ });
+});
diff --git a/test/ingest-pipeline.test.ts b/test/ingest-pipeline.test.ts
new file mode 100644
index 0000000..e5638ac
--- /dev/null
+++ b/test/ingest-pipeline.test.ts
@@ -0,0 +1,157 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { ingestCodex } from "../src/ingest/codex";
+import { ingestCursor } from "../src/ingest/cursor";
+import { ingestCline } from "../src/ingest/cline";
+import { ingestGeneric } from "../src/ingest/generic";
+
+let db: Database;
+let root: string;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+ root = mkdtempSync(join(tmpdir(), "smriti-ingest-"));
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(root, { recursive: true, force: true });
+});
+
+test("ingestCodex ingests jsonl sessions and writes session meta", async () => {
+ const logsDir = join(root, "codex");
+ mkdirSync(join(logsDir, "team"), { recursive: true });
+ writeFileSync(
+ join(logsDir, "team", "chat.jsonl"),
+ [
+ JSON.stringify({ role: "user", content: "How do we cache this?" }),
+ JSON.stringify({ role: "assistant", content: "Use a short TTL." }),
+ ].join("\n")
+ );
+
+ const result = await ingestCodex({ db, logsDir });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const meta = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta")
+ .all() as Array<{ session_id: string; agent_id: string; project_id: string | null }>;
+
+ expect(meta).toHaveLength(1);
+ expect(meta[0].session_id).toBe("codex-team-chat");
+ expect(meta[0].agent_id).toBe("codex");
+ expect(meta[0].project_id).toBeNull();
+});
+
+test("ingestCursor ingests sessions and associates basename project id", async () => {
+ const projectPath = join(root, "my-app");
+ mkdirSync(join(projectPath, ".cursor"), { recursive: true });
+ writeFileSync(
+ join(projectPath, ".cursor", "conv.json"),
+ JSON.stringify({
+ messages: [
+ { role: "user", content: "Implement auth" },
+ { role: "assistant", content: "Added middleware" },
+ ],
+ })
+ );
+
+ const result = await ingestCursor({ db, projectPath });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id, path FROM smriti_projects WHERE id = ?")
+ .get("my-app") as { id: string; path: string } | null;
+ expect(project).not.toBeNull();
+ expect(project!.path).toBe(projectPath);
+
+ const meta = db
+ .prepare("SELECT project_id FROM smriti_session_meta")
+ .get() as { project_id: string };
+ expect(meta.project_id).toBe("my-app");
+});
+
+test("ingestCline ingests task history and derives project from cwd", async () => {
+ const logsDir = join(root, "cline");
+ mkdirSync(logsDir, { recursive: true });
+
+ writeFileSync(
+ join(logsDir, "task-1.json"),
+ JSON.stringify({
+ id: "task-1",
+ name: "Fix tests",
+ timestamp: new Date().toISOString(),
+ cwd: join(root, "repo-alpha"),
+ history: [
+ { ts: new Date().toISOString(), type: "say", text: "I can fix this." },
+ { ts: new Date().toISOString(), type: "ask", question: "Proceed?", options: "yes,no" },
+ ],
+ })
+ );
+
+ const result = await ingestCline({ db, logsDir });
+
+ expect(result.sessionsFound).toBe(1);
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBe(2);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("repo-alpha") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("task-1") as { agent_id: string; project_id: string };
+ expect(meta.agent_id).toBe("cline");
+ expect(meta.project_id).toBe("repo-alpha");
+});
+
+test("ingestGeneric stores transcript and preserves explicit project id", async () => {
+ const transcriptPath = join(root, "transcript.chat");
+ writeFileSync(
+ transcriptPath,
+ [
+ JSON.stringify({ role: "user", content: "How should we version this API?" }),
+ JSON.stringify({ role: "assistant", content: "Start with v1 and a deprecation policy." }),
+ ].join("\n")
+ );
+
+ const result = await ingestGeneric({
+ db,
+ filePath: transcriptPath,
+ format: "jsonl",
+ sessionId: "manual-session-1",
+ projectId: "manual-project",
+ title: "API Versioning",
+ agentName: "codex",
+ });
+
+ expect(result.sessionsIngested).toBe(1);
+ expect(result.messagesIngested).toBeGreaterThan(0);
+
+ const project = db
+ .prepare("SELECT id FROM smriti_projects WHERE id = ?")
+ .get("manual-project") as { id: string } | null;
+ expect(project).not.toBeNull();
+
+ const meta = db
+ .prepare("SELECT project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("manual-session-1") as { project_id: string };
+ expect(meta.project_id).toBe("manual-project");
+});
diff --git a/test/session-resolver.test.ts b/test/session-resolver.test.ts
new file mode 100644
index 0000000..6c36cd6
--- /dev/null
+++ b/test/session-resolver.test.ts
@@ -0,0 +1,83 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults, upsertSessionMeta } from "../src/db";
+import { resolveSession } from "../src/ingest/session-resolver";
+
+let db: Database;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+});
+
+afterEach(() => {
+ db.close();
+});
+
+test("resolveSession marks new session and counts zero existing messages", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s-new",
+ agentId: "codex",
+ });
+
+ expect(r.isNew).toBe(true);
+ expect(r.existingMessageCount).toBe(0);
+ expect(r.projectId).toBeNull();
+});
+
+test("resolveSession marks existing session and counts existing messages", () => {
+ const now = new Date().toISOString();
+ db.prepare(
+ `INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`
+ ).run("s1", "Session 1", now, now);
+
+ db.prepare(
+ `INSERT INTO memory_messages (session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?)`
+ ).run("s1", "user", "hello", "h1", now);
+
+ db.prepare(
+ `INSERT INTO memory_messages (session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?)`
+ ).run("s1", "assistant", "world", "h2", now);
+
+ upsertSessionMeta(db, "s1", "codex");
+
+ const r = resolveSession({
+ db,
+ sessionId: "s1",
+ agentId: "codex",
+ });
+
+ expect(r.isNew).toBe(false);
+ expect(r.existingMessageCount).toBe(2);
+});
+
+test("resolveSession uses explicit project id over derived project", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s2",
+ agentId: "cursor",
+ projectDir: "/tmp/projects/my-app",
+ explicitProjectId: "team/core-app",
+ explicitProjectPath: "/opt/work/core-app",
+ });
+
+ expect(r.projectId).toBe("team/core-app");
+ expect(r.projectPath).toBe("/opt/work/core-app");
+});
+
+test("resolveSession derives cursor project from basename", () => {
+ const r = resolveSession({
+ db,
+ sessionId: "s3",
+ agentId: "cursor",
+ projectDir: "/Users/test/work/my-repo",
+ });
+
+ expect(r.projectId).toBe("my-repo");
+ expect(r.projectPath).toBe("/Users/test/work/my-repo");
+});
diff --git a/test/store-gateway.test.ts b/test/store-gateway.test.ts
new file mode 100644
index 0000000..088589c
--- /dev/null
+++ b/test/store-gateway.test.ts
@@ -0,0 +1,123 @@
+import { test, expect, beforeEach, afterEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { initializeMemoryTables } from "../src/qmd";
+import { initializeSmritiTables, seedDefaults } from "../src/db";
+import { storeMessage, storeBlocks, storeSession, storeCosts } from "../src/ingest/store-gateway";
+import type { MessageBlock } from "../src/ingest/types";
+
+let db: Database;
+
+beforeEach(() => {
+ db = new Database(":memory:");
+ db.exec("PRAGMA foreign_keys = ON");
+ initializeMemoryTables(db);
+ initializeSmritiTables(db);
+ seedDefaults(db);
+});
+
+afterEach(() => {
+ db.close();
+});
+
+test("storeSession upserts project and session meta", () => {
+ storeSession(db, "s1", "codex", "proj-1", "/tmp/proj-1");
+
+ const p = db
+ .prepare("SELECT id, path FROM smriti_projects WHERE id = ?")
+ .get("proj-1") as { id: string; path: string } | null;
+ expect(p).not.toBeNull();
+ expect(p!.path).toBe("/tmp/proj-1");
+
+ const sm = db
+ .prepare("SELECT session_id, agent_id, project_id FROM smriti_session_meta WHERE session_id = ?")
+ .get("s1") as { session_id: string; agent_id: string; project_id: string } | null;
+ expect(sm).not.toBeNull();
+ expect(sm!.agent_id).toBe("codex");
+ expect(sm!.project_id).toBe("proj-1");
+});
+
+test("storeMessage writes memory message", async () => {
+ const now = new Date().toISOString();
+ db.prepare(`INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(
+ "s-msg",
+ "msg session",
+ now,
+ now
+ );
+
+ const r = await storeMessage(db, "s-msg", "user", "hello world", { source: "test" });
+ expect(r.success).toBe(true);
+ expect(r.messageId).toBeGreaterThan(0);
+
+ const row = db
+ .prepare("SELECT session_id, role, content FROM memory_messages WHERE id = ?")
+ .get(r.messageId) as { session_id: string; role: string; content: string } | null;
+
+ expect(row).not.toBeNull();
+ expect(row!.session_id).toBe("s-msg");
+ expect(row!.role).toBe("user");
+ expect(row!.content).toBe("hello world");
+});
+
+test("storeBlocks writes sidecar rows by block type", () => {
+ const now = new Date().toISOString();
+ const sessionId = "s-side";
+ db.prepare(`INSERT INTO memory_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`).run(
+ sessionId,
+ "sidecar session",
+ now,
+ now
+ );
+ db.prepare(
+ `INSERT INTO memory_messages (id, session_id, role, content, hash, created_at) VALUES (?, ?, ?, ?, ?, ?)`
+ ).run(100, sessionId, "assistant", "sidecar payload", "h-side", now);
+ const msgId = 100;
+
+ const blocks: MessageBlock[] = [
+ { type: "tool_call", toolId: "t1", toolName: "Read", input: { file_path: "a.ts" } },
+ { type: "file_op", operation: "write", path: "src/a.ts" },
+ { type: "command", command: "git status", isGit: true },
+ { type: "git", operation: "commit", message: "feat: add" },
+ { type: "error", errorType: "tool_failure", message: "boom" },
+ ];
+
+ storeBlocks(db, msgId, sessionId, "proj-x", blocks, now);
+
+ const toolRows = db.prepare("SELECT COUNT(*) as c FROM smriti_tool_usage WHERE message_id = ?").get(msgId) as { c: number };
+ const fileRows = db.prepare("SELECT COUNT(*) as c FROM smriti_file_operations WHERE message_id = ?").get(msgId) as { c: number };
+ const cmdRows = db.prepare("SELECT COUNT(*) as c FROM smriti_commands WHERE message_id = ?").get(msgId) as { c: number };
+ const gitRows = db.prepare("SELECT COUNT(*) as c FROM smriti_git_operations WHERE message_id = ?").get(msgId) as { c: number };
+ const errRows = db.prepare("SELECT COUNT(*) as c FROM smriti_errors WHERE message_id = ?").get(msgId) as { c: number };
+
+ expect(toolRows.c).toBe(1);
+ expect(fileRows.c).toBe(1);
+ expect(cmdRows.c).toBe(1);
+ expect(gitRows.c).toBe(1);
+ expect(errRows.c).toBe(1);
+});
+
+test("storeCosts accumulates into smriti_session_costs", () => {
+ storeCosts(db, "s-cost", "model-a", 10, 5, 2, 1000);
+ storeCosts(db, "s-cost", "model-a", 20, 10, 0, 500);
+
+ const row = db
+ .prepare(
+ `SELECT total_input_tokens, total_output_tokens, total_cache_tokens, turn_count, total_duration_ms
+ FROM smriti_session_costs
+ WHERE session_id = ? AND model = ?`
+ )
+ .get("s-cost", "model-a") as {
+ total_input_tokens: number;
+ total_output_tokens: number;
+ total_cache_tokens: number;
+ turn_count: number;
+ total_duration_ms: number;
+ } | null;
+
+ expect(row).not.toBeNull();
+ expect(row!.total_input_tokens).toBe(30);
+ expect(row!.total_output_tokens).toBe(15);
+ expect(row!.total_cache_tokens).toBe(2);
+ expect(row!.turn_count).toBe(2);
+ expect(row!.total_duration_ms).toBe(1500);
+});
From c736bd86d7b966f6a91473c4a1e1a240dbfe8cb4 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:31:13 +0530
Subject: [PATCH 05/20] ci: create dev draft release after successful dev test
matrix
---
.github/workflows/ci.yml | 72 ++++++++++++++++++++++++
.github/workflows/dev-draft-release.yml | 73 -------------------------
2 files changed, 72 insertions(+), 73 deletions(-)
delete mode 100644 .github/workflows/dev-draft-release.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aed66eb..476ba53 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,3 +56,75 @@ jobs:
- name: Run tests
# Full cross-platform test matrix for merge branches.
run: bun test test/
+
+ dev-draft-release:
+ name: Dev Draft Release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
+ needs: test-merge
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Compute dev tag
+ id: tag
+ run: |
+ BASE_VERSION=$(node -p "require('./package.json').version")
+ DEV_TAG="v${BASE_VERSION}-dev.${{ github.run_number }}"
+ echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
+
+ - name: Remove previous dev draft releases and tags
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ owner,
+ repo,
+ per_page: 100,
+ });
+
+ for (const release of releases) {
+ const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
+ if (isDevTag && release.draft) {
+ await github.rest.repos.deleteRelease({
+ owner,
+ repo,
+ release_id: release.id,
+ });
+ }
+ }
+
+ const refs = await github.paginate(github.rest.git.listMatchingRefs, {
+ owner,
+ repo,
+ ref: "tags/v",
+ per_page: 100,
+ });
+ for (const ref of refs) {
+ const tagName = ref.ref.replace("refs/tags/", "");
+ if (/-dev\.\d+$/.test(tagName)) {
+ await github.rest.git.deleteRef({
+ owner,
+ repo,
+ ref: `tags/${tagName}`,
+ }).catch(() => {});
+ }
+ }
+
+ - name: Create draft prerelease
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.dev_tag }}
+ target_commitish: ${{ github.sha }}
+ name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
+ generate_release_notes: true
+ draft: true
+ prerelease: true
diff --git a/.github/workflows/dev-draft-release.yml b/.github/workflows/dev-draft-release.yml
deleted file mode 100644
index 15258bb..0000000
--- a/.github/workflows/dev-draft-release.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: Dev Draft Release
-
-on:
- workflow_run:
- workflows: ["CI"]
- types: [completed]
-
-jobs:
- draft-release:
- name: Create/Update Dev Draft Release
- if: >
- github.event.workflow_run.conclusion == 'success' &&
- github.event.workflow_run.event == 'push' &&
- github.event.workflow_run.head_branch == 'dev'
- runs-on: ubuntu-latest
- permissions:
- contents: write
-
- steps:
- - name: Checkout dev commit
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.workflow_run.head_sha }}
- fetch-depth: 0
- submodules: recursive
-
- - name: Compute dev tag
- id: tag
- run: |
- BASE_VERSION=$(node -p "require('./package.json').version")
- DEV_SUFFIX="dev.${{ github.event.workflow_run.run_number }}"
- DEV_TAG="v${BASE_VERSION}-${DEV_SUFFIX}"
- echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
- echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
-
- - name: Remove previous dev draft releases
- uses: actions/github-script@v7
- with:
- script: |
- const releases = await github.paginate(github.rest.repos.listReleases, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- per_page: 100,
- });
-
- for (const release of releases) {
- const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
- if (isDevTag && release.draft) {
- await github.rest.repos.deleteRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: release.id,
- });
- }
- }
-
- - name: Remove previous dev tags
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- for tag in $(git tag --list 'v*-dev.*'); do
- git push origin ":refs/tags/${tag}" || true
- done
-
- - name: Create dev draft prerelease
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ steps.tag.outputs.dev_tag }}
- target_commitish: ${{ github.event.workflow_run.head_sha }}
- name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
- generate_release_notes: true
- draft: true
- prerelease: true
From f2f086853fdcf18eb494790a9ea0cede9d0ebfc9 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:33:20 +0530
Subject: [PATCH 06/20] chore: add e2e dev release flow test marker (#36)
---
docs/e2e-dev-release-flow-test.md | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 docs/e2e-dev-release-flow-test.md
diff --git a/docs/e2e-dev-release-flow-test.md b/docs/e2e-dev-release-flow-test.md
new file mode 100644
index 0000000..c727e8e
--- /dev/null
+++ b/docs/e2e-dev-release-flow-test.md
@@ -0,0 +1,3 @@
+# E2E Dev Release Flow Test
+
+This file exists only to verify the automated `dev` CI to draft-release flow end to end.
From f9a2aaea6798c766f3c3757ea2d5e47948861214 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:36:23 +0530
Subject: [PATCH 07/20] release: v0.3.2 (dev -> main) (#37)
* New branch (#33)
* chore: add pending project files
* refactor(ingest): centralize ingestion via parser/resolver/store layers
* docs: document layered ingest architecture
* test(perf): add qmd benchmark harness and non-blocking CI regression check
* perf(bench): add ingest hotpath benchmark and record qmd optimization
* perf(ingest): batch session writes and add stable benchmark tooling
* Add benchmark scorecard to CI summary and sticky PR comment
* Fix bench import path and temporarily disable design-contract workflow
* CI: checkout qmd submodule in perf bench workflow
* Fix Windows path handling in ingest session discovery
* Feature/bench scorecard ci windows fixes (#34)
* chore: add pending project files
* refactor(ingest): centralize ingestion via parser/resolver/store layers
* docs: document layered ingest architecture
* test(perf): add qmd benchmark harness and non-blocking CI regression check
* perf(bench): add ingest hotpath benchmark and record qmd optimization
* perf(ingest): batch session writes and add stable benchmark tooling
* Add benchmark scorecard to CI summary and sticky PR comment
* Fix bench import path and temporarily disable design-contract workflow
* CI: checkout qmd submodule in perf bench workflow
* Fix Windows path handling in ingest session discovery
* CI: run full test matrix only on merge branches
* CI: auto-create draft prerelease on successful dev CI
* CI: auto-template and title for dev to main PRs
* CI: create dev draft release after successful dev test matrix
* chore: add e2e dev release flow test marker (#36)
---
.github/workflows/ci.yml | 72 +++++++++++++++++++++++++++++++
docs/e2e-dev-release-flow-test.md | 3 ++
2 files changed, 75 insertions(+)
create mode 100644 docs/e2e-dev-release-flow-test.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aed66eb..476ba53 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,3 +56,75 @@ jobs:
- name: Run tests
# Full cross-platform test matrix for merge branches.
run: bun test test/
+
+ dev-draft-release:
+ name: Dev Draft Release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
+ needs: test-merge
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Compute dev tag
+ id: tag
+ run: |
+ BASE_VERSION=$(node -p "require('./package.json').version")
+ DEV_TAG="v${BASE_VERSION}-dev.${{ github.run_number }}"
+ echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
+
+ - name: Remove previous dev draft releases and tags
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ owner,
+ repo,
+ per_page: 100,
+ });
+
+ for (const release of releases) {
+ const isDevTag = /-dev\.\d+$/.test(release.tag_name || "");
+ if (isDevTag && release.draft) {
+ await github.rest.repos.deleteRelease({
+ owner,
+ repo,
+ release_id: release.id,
+ });
+ }
+ }
+
+ const refs = await github.paginate(github.rest.git.listMatchingRefs, {
+ owner,
+ repo,
+ ref: "tags/v",
+ per_page: 100,
+ });
+ for (const ref of refs) {
+ const tagName = ref.ref.replace("refs/tags/", "");
+ if (/-dev\.\d+$/.test(tagName)) {
+ await github.rest.git.deleteRef({
+ owner,
+ repo,
+ ref: `tags/${tagName}`,
+ }).catch(() => {});
+ }
+ }
+
+ - name: Create draft prerelease
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.dev_tag }}
+ target_commitish: ${{ github.sha }}
+ name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
+ generate_release_notes: true
+ draft: true
+ prerelease: true
diff --git a/docs/e2e-dev-release-flow-test.md b/docs/e2e-dev-release-flow-test.md
new file mode 100644
index 0000000..c727e8e
--- /dev/null
+++ b/docs/e2e-dev-release-flow-test.md
@@ -0,0 +1,3 @@
+# E2E Dev Release Flow Test
+
+This file exists only to verify the automated `dev` CI to draft-release flow end to end.
From 0a55daccb5667431f1c11c0d735830dc8dca462f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 27 Feb 2026 13:08:54 +0000
Subject: [PATCH 08/20] docs: update CHANGELOG.md for v0.4.0 [skip ci]
---
CHANGELOG.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cad391c..fcec8e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,21 @@
+## [0.4.0] - 2026-02-27
+
+### Fixed
+
+- fix: add missing cline and copilot to default agents seed ([#31](https://github.com/zero8dotdev/smriti/pull/31))
+
+### Changed
+
+- chore: e2e dev release flow smoke test ([#36](https://github.com/zero8dotdev/smriti/pull/36))
+
+### Other
+
+- release: v0.3.2 (dev -> main) ([#37](https://github.com/zero8dotdev/smriti/pull/37))
+- release: v0.3.2 (dev -> main) ([#35](https://github.com/zero8dotdev/smriti/pull/35))
+- Feature/bench scorecard ci windows fixes ([#34](https://github.com/zero8dotdev/smriti/pull/34))
+- New branch ([#33](https://github.com/zero8dotdev/smriti/pull/33))
+
+
# Changelog
All notable changes to smriti are documented here. Format:
From 16d8e57c100a18d4d72cdf7012ae5e7c2848df88 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:44:45 +0530
Subject: [PATCH 09/20] docs: add CI/release workflow architecture and
north-star plan
---
docs/WORKFLOW_AUTOMATION.md | 124 ++++++++++++++++++++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 docs/WORKFLOW_AUTOMATION.md
diff --git a/docs/WORKFLOW_AUTOMATION.md b/docs/WORKFLOW_AUTOMATION.md
new file mode 100644
index 0000000..accf358
--- /dev/null
+++ b/docs/WORKFLOW_AUTOMATION.md
@@ -0,0 +1,124 @@
+# Workflow Automation: Current State and North Star
+
+Last updated: 2026-02-27
+
+## Goals
+- Keep `dev` as the stabilization branch.
+- Automatically produce an **unreleased draft prerelease** from `dev` after tests pass.
+- Promote `dev -> main` with standardized release PR metadata.
+- Prevent bad releases by gating release on cross-platform tests and security checks.
+
+## Current Workflow Map
+
+### 1) CI (`.github/workflows/ci.yml`)
+- Triggers:
+ - `push` on `main`, `dev`, `feature/**`
+ - `pull_request` on `main`, `dev`
+- Jobs:
+ - `test-pr`: PRs run fast Linux-only tests (`bun test test/`).
+ - `test-merge`: pushes to `main`/`dev` run full matrix (`ubuntu`, `macos`, `windows`).
+ - `dev-draft-release`: runs **only on push to `dev`**, after `test-merge` succeeds.
+- Dev draft release behavior:
+ - Creates tag `v-dev.`
+ - Deletes previous draft prerelease/tag matching `-dev.*`
+ - Creates new GitHub draft prerelease with generated notes.
+
+### 2) Dev->Main PR Autofill (`.github/workflows/dev-main-pr-template.yml`)
+- Trigger: `pull_request` events targeting `main`.
+- Condition: applies only when `head=dev` and `base=main`.
+- Actions:
+ - Sets PR title to `release: v (dev -> main)`
+ - Fills PR body from `.github/PULL_REQUEST_TEMPLATE/dev-to-main.md`
+ - Injects auto-generated commit list.
+
+### 3) Perf Bench (`.github/workflows/perf-bench.yml`)
+- Triggers on relevant code/path changes (PR and push).
+- Runs QMD benchmark + repeat runs.
+- Produces scorecard markdown.
+- Publishes:
+ - GitHub job summary
+ - Sticky PR comment (updated in place)
+ - Artifacts (`ci-small.json`, `repeat-summary.json`, `scorecard.md`)
+- Non-blocking regression compare currently.
+
+### 4) Release (`.github/workflows/release.yml`)
+- Trigger: push tag matching `v*.*.*`
+- Runs tests, generates changelog notes, creates GitHub Release.
+- Final release is published when semver tag is pushed (e.g. `v0.4.0`).
+
+### 5) Secret Scan (`.github/workflows/secret-scan.yml`)
+- Runs on PR/push for `main`, `dev`, `feature/**`, `staging`.
+- Uses `gitleaks` + `detect-secrets`.
+
+### 6) Install Test (`.github/workflows/install-test.yml`)
+- Runs on push to `main`, tags, or manual dispatch.
+- Validates installer/uninstaller and smoke CLI checks on all three OSes.
+
+### 7) Design Contracts (`.github/workflows/validate-design.yml`)
+- Present but currently disabled (`if: ${{ false }}`) pending rule/code alignment.
+
+## Current Release Flow (As Implemented)
+
+1. Feature PR -> `dev`
+2. Merge to `dev`
+3. `CI` full matrix passes on `dev`
+4. `CI` creates/updates draft prerelease tag `vX.Y.Z-dev.N`
+5. Open PR `dev -> main` (autofilled title/body)
+6. Merge `dev -> main`
+7. Push final release tag `vX.Y.Z`
+8. `Release` workflow publishes stable release
+
+## What Is Automated vs Manual
+
+Automated now:
+- Dev draft prerelease creation/update after successful `dev` matrix tests.
+- Dev->Main PR title/body normalization and commit summary.
+- Bench reporting in PR summary/comment.
+
+Manual now:
+- Final semver tag push on `main` (`vX.Y.Z`).
+- Deciding when `dev` is release-ready.
+
+## North Star: Fully Autonomous and Safe Release
+
+North star definition:
+- Every merge to `dev` produces a validated draft candidate.
+- Promotion from `dev` to `main` is policy-gated and reproducible.
+- Stable release publication is automated only when all release gates are green.
+- No single human step can bypass required quality/safety checks.
+
+### Required Guardrails (Recommended)
+1. Branch protection on `dev` and `main`
+- Require status checks: `CI`, `Secret Scanning`, `Perf Bench`.
+- Require up-to-date branch before merge.
+- Disable direct pushes to `main`.
+
+2. Re-enable Design Contracts as blocking
+- Fix current validator false positives/real violations.
+- Make workflow required before merge.
+
+3. Make performance policy explicit
+- Option A: keep non-blocking but require manual ack.
+- Option B (north star): block on regression threshold for key metrics.
+
+4. Automate final release from `main` merge/tag policy
+- Add a controlled release gate job:
+ - verifies `main` commit came from merged `dev -> main` PR
+ - verifies all required checks passed on merge commit
+ - creates semver tag automatically (or via manual approval environment)
+
+5. Version governance
+- Enforce version bump policy in `dev -> main` PR (e.g., `package.json` bump required).
+- Validate tag/version consistency.
+
+6. Release provenance
+- Attach SBOM/attestations and immutable artifacts to release.
+- Keep release notes generated from merged PRs + machine-readable manifest.
+
+## Immediate Next Steps to Reach North Star
+
+1. Re-enable `validate-design.yml` after fixing 7 reported violations.
+2. Turn perf regressions into a protected check (with agreed threshold).
+3. Add branch protection rules for `dev` and `main`.
+4. Add `main-release-gate` workflow that auto-tags after `dev -> main` merge when all checks pass.
+5. Add rollback playbook doc + hotfix workflow path.
From 87103220009319ae3f103810543b3f6adb8201d2 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:57:02 +0530
Subject: [PATCH 10/20] ci: add commit lint, semver metadata, and deterministic
release notes
---
.github/workflows/ci.yml | 17 +-
.github/workflows/commitlint.yml | 40 ++++
.github/workflows/dev-main-pr-template.yml | 15 +-
.github/workflows/perf-bench.yml | 4 +
.github/workflows/release.yml | 119 ++---------
docs/CI_HARDENING_EXECUTION_PLAN.md | 70 ++++++
package.json | 3 +-
scripts/release-meta.ts | 235 +++++++++++++++++++++
8 files changed, 394 insertions(+), 109 deletions(-)
create mode 100644 .github/workflows/commitlint.yml
create mode 100644 docs/CI_HARDENING_EXECUTION_PLAN.md
create mode 100644 scripts/release-meta.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 476ba53..b4d3255 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,9 @@
name: CI
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
on:
push:
branches: [main, dev, "feature/**"]
@@ -73,10 +77,10 @@ jobs:
submodules: recursive
- name: Compute dev tag
- id: tag
+ id: meta
run: |
- BASE_VERSION=$(node -p "require('./package.json').version")
- DEV_TAG="v${BASE_VERSION}-dev.${{ github.run_number }}"
+ bun run scripts/release-meta.ts --github-output "$GITHUB_OUTPUT"
+ DEV_TAG="${{ steps.meta.outputs.next_version }}-dev.${{ github.run_number }}"
echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
- name: Remove previous dev draft releases and tags
@@ -122,9 +126,10 @@ jobs:
- name: Create draft prerelease
uses: softprops/action-gh-release@v2
with:
- tag_name: ${{ steps.tag.outputs.dev_tag }}
+ tag_name: ${{ steps.meta.outputs.dev_tag }}
target_commitish: ${{ github.sha }}
- name: Dev Draft ${{ steps.tag.outputs.dev_tag }}
- generate_release_notes: true
+ name: Dev Draft ${{ steps.meta.outputs.dev_tag }}
+ body: ${{ steps.meta.outputs.release_notes }}
+ generate_release_notes: false
draft: true
prerelease: true
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
new file mode 100644
index 0000000..d11ab40
--- /dev/null
+++ b/.github/workflows/commitlint.yml
@@ -0,0 +1,40 @@
+name: Commit Lint
+
+on:
+ pull_request:
+ branches: [main, dev]
+ push:
+ branches: [dev, "feature/**"]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Lint commits (PR)
+ if: github.event_name == 'pull_request'
+ run: |
+ bun run scripts/release-meta.ts \
+ --mode lint \
+ --from-ref "${{ github.event.pull_request.base.sha }}" \
+ --to "${{ github.event.pull_request.head.sha }}"
+
+ - name: Lint commits (push)
+ if: github.event_name == 'push'
+ run: |
+ bun run scripts/release-meta.ts \
+ --mode lint \
+ --from-ref "${{ github.event.before }}" \
+ --to "${{ github.sha }}"
diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml
index 26321e2..db59f43 100644
--- a/.github/workflows/dev-main-pr-template.yml
+++ b/.github/workflows/dev-main-pr-template.yml
@@ -30,7 +30,7 @@ jobs:
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
const version = pkg.version || "0.0.0";
- const title = `release: v${version} (dev -> main)`;
+ const title = `release: v${version}`;
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
@@ -48,10 +48,21 @@ jobs:
`\n${commitsText}\n`
);
+ const existingBody = context.payload.pull_request.body || "";
+ const preserveManual = /[\s\S]*?/m.test(existingBody);
+ const nextBody = preserveManual
+ ? existingBody
+ .replace(/- Version: .*/m, `- Version: v${version}`)
+ .replace(
+ /[\s\S]*?/m,
+ `\n${commitsText}\n`
+ )
+ : body;
+
await github.rest.pulls.update({
owner,
repo,
pull_number,
title,
- body,
+ body: nextBody,
});
diff --git a/.github/workflows/perf-bench.yml b/.github/workflows/perf-bench.yml
index aee933d..ca3d9c0 100644
--- a/.github/workflows/perf-bench.yml
+++ b/.github/workflows/perf-bench.yml
@@ -1,5 +1,9 @@
name: Perf Bench (Non-blocking)
+concurrency:
+ group: perf-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
on:
pull_request:
branches: [main, dev]
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1b5d78e..52f34e1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,5 +1,9 @@
name: Release
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
on:
push:
tags:
@@ -30,112 +34,27 @@ jobs:
- name: Run tests
run: bun test test/
- - name: Generate changelog from merged PRs
- id: changelog
- env:
- GH_TOKEN: ${{ github.token }}
+ - name: Compute release metadata from conventional commits
+ id: meta
run: |
- VERSION="${GITHUB_REF_NAME#v}"
- TAG="${GITHUB_REF_NAME}"
-
- # Find previous tag
- PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
-
- if [ -z "$PREV_TAG" ]; then
- echo "No previous tag found, using all merged PRs"
- PREV_DATE="2000-01-01"
- else
- PREV_DATE=$(git log -1 --format=%aI "$PREV_TAG" | cut -dT -f1)
- fi
-
- NOW=$(date -u +%Y-%m-%d)
-
- # Fetch merged PRs between previous tag date and now
- PRS=$(gh pr list \
- --state merged \
- --search "merged:${PREV_DATE}..${NOW}" \
- --json number,title,mergedAt \
- --limit 100 \
- --jq '.[] | "\(.number)\t\(.title)"' 2>/dev/null || echo "")
-
- # Categorize PRs by conventional commit prefix
- FIXED=""
- ADDED=""
- CHANGED=""
- DOCS=""
- OTHER=""
-
- while IFS=$'\t' read -r num title; do
- [ -z "$num" ] && continue
- entry="- ${title} ([#${num}](https://github.com/${{ github.repository }}/pull/${num}))"
-
- case "$title" in
- fix:*|fix\(*) FIXED="${FIXED}${entry}"$'\n' ;;
- feat:*|feat\(*) ADDED="${ADDED}${entry}"$'\n' ;;
- chore:*|chore\(*|refactor:*|refactor\(*|perf:*|perf\(*) CHANGED="${CHANGED}${entry}"$'\n' ;;
- docs:*|docs\(*) DOCS="${DOCS}${entry}"$'\n' ;;
- *) OTHER="${OTHER}${entry}"$'\n' ;;
- esac
- done <<< "$PRS"
-
- # Build release notes
- NOTES=""
-
- if [ -n "$FIXED" ]; then
- NOTES="${NOTES}### Fixed"$'\n\n'"${FIXED}"$'\n'
- fi
- if [ -n "$ADDED" ]; then
- NOTES="${NOTES}### Added"$'\n\n'"${ADDED}"$'\n'
- fi
- if [ -n "$CHANGED" ]; then
- NOTES="${NOTES}### Changed"$'\n\n'"${CHANGED}"$'\n'
- fi
- if [ -n "$DOCS" ]; then
- NOTES="${NOTES}### Documentation"$'\n\n'"${DOCS}"$'\n'
- fi
- if [ -n "$OTHER" ]; then
- NOTES="${NOTES}### Other"$'\n\n'"${OTHER}"$'\n'
- fi
-
- # Fallback: if no PRs found, use auto-generated notes
- if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
- echo "HAS_NOTES=false" >> "$GITHUB_OUTPUT"
- else
- echo "HAS_NOTES=true" >> "$GITHUB_OUTPUT"
+ bun run scripts/release-meta.ts \
+ --current-tag "${GITHUB_REF_NAME}" \
+ --to "${GITHUB_SHA}" \
+ --github-output "$GITHUB_OUTPUT"
- # Build full changelog entry
- CHANGELOG_ENTRY="## [${VERSION}] - ${NOW}"$'\n\n'"${NOTES}"
-
- # Prepend to CHANGELOG.md
- if [ -f CHANGELOG.md ]; then
- # Insert after the header line(s)
- echo "${CHANGELOG_ENTRY}" | cat - CHANGELOG.md > CHANGELOG.tmp
- mv CHANGELOG.tmp CHANGELOG.md
- else
- printf '%s\n\n%s' "# Changelog" "${CHANGELOG_ENTRY}" > CHANGELOG.md
- fi
-
- # Save notes for release body
- {
- echo "RELEASE_NOTES<> "$GITHUB_OUTPUT"
- fi
-
- - name: Commit CHANGELOG.md
- if: steps.changelog.outputs.HAS_NOTES == 'true'
+ - name: Validate tag matches semantic bump
run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git add CHANGELOG.md
- git commit -m "docs: update CHANGELOG.md for ${GITHUB_REF_NAME} [skip ci]"
- git push origin HEAD:main
+ EXPECTED="${{ steps.meta.outputs.next_version }}"
+ ACTUAL="${GITHUB_REF_NAME}"
+ if [ "$EXPECTED" != "$ACTUAL" ]; then
+ echo "Tag/version mismatch: expected ${EXPECTED}, got ${ACTUAL}"
+ exit 1
+ fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
- body: ${{ steps.changelog.outputs.HAS_NOTES == 'true' && steps.changelog.outputs.RELEASE_NOTES || '' }}
- generate_release_notes: ${{ steps.changelog.outputs.HAS_NOTES != 'true' }}
+ body: ${{ steps.meta.outputs.release_notes }}
+ generate_release_notes: false
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
diff --git a/docs/CI_HARDENING_EXECUTION_PLAN.md b/docs/CI_HARDENING_EXECUTION_PLAN.md
new file mode 100644
index 0000000..55324ef
--- /dev/null
+++ b/docs/CI_HARDENING_EXECUTION_PLAN.md
@@ -0,0 +1,70 @@
+# CI Hardening Execution Plan (Codex-Executable)
+
+Last updated: 2026-02-27
+
+## Scope
+Implements the requested improvements except design-contract re-enable (explicitly deferred).
+
+## Completed in This Change
+- Added conventional-commit driven release metadata engine:
+ - `scripts/release-meta.ts`
+- Added commit lint workflow:
+ - `.github/workflows/commitlint.yml`
+- Updated `CI` dev draft release to derive semver + notes from commits:
+ - `.github/workflows/ci.yml`
+- Updated stable release workflow to:
+ - validate pushed tag against computed semver
+ - generate release notes from exact conventional commits
+ - stop mutating `main` during release
+ - `.github/workflows/release.yml`
+- Updated dev->main PR title format:
+ - removed `dev -> main` suffix from title
+ - preserve manual PR body sections while refreshing autogenerated block
+ - `.github/workflows/dev-main-pr-template.yml`
+- Added concurrency controls:
+ - `ci.yml`, `perf-bench.yml`, `release.yml`
+
+## North Star
+No bad release should be publishable without:
+1. passing required checks,
+2. semver consistency,
+3. conventional commit compliance,
+4. deterministic release notes from the actual commit set.
+
+## Codex Autonomous Backlog
+
+### P0: Protection and Determinism
+1. Enforce required checks in branch protection (`dev`, `main`):
+ - `CI`, `Secret Scanning`, `Commit Lint`, `Perf Bench`
+ - Acceptance: merge blocked when any required check fails.
+2. Pin all workflow actions by full commit SHA.
+ - Acceptance: no `uses: owner/action@v*` references remain.
+3. Add release environment protection for stable tags.
+ - Acceptance: stable release requires approval or protected actor policy.
+
+### P1: Semver Governance
+1. Add PR comment bot that posts computed bump (`major/minor/patch/none`) from `release-meta.ts`.
+ - Acceptance: every PR has visible bump preview.
+2. Add `dev->main` gate: if computed bump is `none`, block release PR merge unless override label exists.
+ - Acceptance: accidental no-op releases prevented.
+
+### P2: Performance and Reliability
+1. Turn perf compare into policy mode (warning vs blocking by branch).
+ - `dev`: warning, `main`: blocking for selected metrics.
+ - Acceptance: regression beyond threshold blocks promotion to `main`.
+2. Upload release-meta output (`json`) as artifact for traceability.
+ - Acceptance: each CI run has machine-readable release metadata.
+
+### P3: Observability and Recovery
+1. Add workflow summary sections for:
+ - computed semver,
+ - from-tag/to-ref range,
+ - invalid commit count.
+2. Add rollback playbook doc + one-click rollback workflow (`workflow_dispatch`) for latest tag.
+ - Acceptance: tested rollback path exists.
+
+## Operating Rules
+- Merge strategy for protected branches should preserve conventional commit subjects
+ (squash merge title must be conventional).
+- Do not bypass commit lint for release-bearing branches.
+- Any temporary workflow disable must include expiry date and tracking issue.
diff --git a/package.json b/package.json
index 8a9e7cf..a624557 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"bench:compare": "bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2",
"bench:scorecard": "bun run scripts/bench-scorecard.ts --baseline bench/baseline.ci-small.json --profile ci-small --threshold-pct 20",
"bench:ingest-hotpaths": "bun run scripts/bench-ingest-hotpaths.ts",
- "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12"
+ "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12",
+ "release:meta": "bun run scripts/release-meta.ts"
},
"dependencies": {
"node-llama-cpp": "^3.0.0",
diff --git a/scripts/release-meta.ts b/scripts/release-meta.ts
new file mode 100644
index 0000000..45c60ed
--- /dev/null
+++ b/scripts/release-meta.ts
@@ -0,0 +1,235 @@
+#!/usr/bin/env bun
+
+import { execSync } from "node:child_process";
+
+type Commit = {
+ sha: string;
+ subject: string;
+ body: string;
+ type: string;
+ scope: string | null;
+ breaking: boolean;
+};
+
+type Bump = "none" | "patch" | "minor" | "major";
+
+function arg(name: string): string | undefined {
+ const i = process.argv.indexOf(name);
+ return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+function run(cmd: string): string {
+ return execSync(cmd, { encoding: "utf8" }).trim();
+}
+
+function isStableTag(tag: string): boolean {
+ return /^v\d+\.\d+\.\d+$/.test(tag);
+}
+
+function parseSemver(tag: string): [number, number, number] {
+ const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
+ if (!m) return [0, 0, 0];
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
+}
+
+function fmtSemver(v: [number, number, number]): string {
+ return `v${v[0]}.${v[1]}.${v[2]}`;
+}
+
+function bump(base: [number, number, number], level: Bump): [number, number, number] {
+ const [maj, min, pat] = base;
+ if (level === "major") return [maj + 1, 0, 0];
+ if (level === "minor") return [maj, min + 1, 0];
+ if (level === "patch") return [maj, min, pat + 1];
+ return [maj, min, pat];
+}
+
+function maxBump(a: Bump, b: Bump): Bump {
+ const order: Record = { none: 0, patch: 1, minor: 2, major: 3 };
+ return order[a] >= order[b] ? a : b;
+}
+
+function getLatestStableTag(exclude?: string): string | null {
+ const tags = run("git tag --list")
+ .split("\n")
+ .map((t) => t.trim())
+ .filter(Boolean)
+ .filter(isStableTag)
+ .filter((t) => !exclude || t !== exclude);
+ if (tags.length === 0) return null;
+ tags.sort((a, b) => {
+ const pa = parseSemver(a);
+ const pb = parseSemver(b);
+ if (pa[0] !== pb[0]) return pa[0] - pb[0];
+ if (pa[1] !== pb[1]) return pa[1] - pb[1];
+ return pa[2] - pb[2];
+ });
+ return tags[tags.length - 1] || null;
+}
+
+function parseCommit(raw: string): Commit | null {
+ const parts = raw.split("\t");
+ if (parts.length < 3) return null;
+ const sha = parts[0] || "";
+ const subject = parts[1] || "";
+ const body = parts.slice(2).join("\t");
+ const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/);
+ if (!m) {
+ return {
+ sha,
+ subject,
+ body,
+ type: "invalid",
+ scope: null,
+ breaking: false,
+ };
+ }
+ const type = m[1] || "invalid";
+ const scope = m[2] || null;
+ const breaking = Boolean(m[3]) || /BREAKING CHANGE:/i.test(body);
+ return { sha, subject, body, type, scope, breaking };
+}
+
+function bumpForCommit(c: Commit): Bump {
+ if (c.breaking) return "major";
+ if (c.type === "feat") return "minor";
+ if (c.type === "fix" || c.type === "perf" || c.type === "refactor" || c.type === "revert") return "patch";
+ return "none";
+}
+
+function isConventional(c: Commit): boolean {
+ if (c.type === "invalid") return false;
+ const allowed = new Set([
+ "feat",
+ "fix",
+ "perf",
+ "refactor",
+ "docs",
+ "chore",
+ "ci",
+ "test",
+ "build",
+ "revert",
+ "release",
+ ]);
+ return allowed.has(c.type);
+}
+
+function getCommits(rangeFrom: string | null, rangeTo: string): Commit[] {
+ const range = rangeFrom ? `${rangeFrom}..${rangeTo}` : rangeTo;
+ const raw = run(`git log --no-merges --pretty=format:%H%x09%s%x09%b ${range}`);
+ if (!raw) return [];
+ return raw
+ .split("\n")
+ .map(parseCommit)
+ .filter((c): c is Commit => Boolean(c));
+}
+
+function buildNotes(commits: Commit[]): string {
+ const valid = commits.filter(isConventional);
+ if (valid.length === 0) return "### Changed\n\n- No user-facing conventional commits in this range.\n";
+
+ const groups: Record = {
+ major: [],
+ feat: [],
+ fix: [],
+ perf: [],
+ refactor: [],
+ docs: [],
+ chore: [],
+ ci: [],
+ test: [],
+ build: [],
+ revert: [],
+ release: [],
+ };
+
+ for (const c of valid) {
+ const line = `- ${c.subject} (${c.sha.slice(0, 7)})`;
+ if (c.breaking) groups.major.push(line);
+ (groups[c.type] || groups.chore).push(line);
+ }
+
+ const sections: string[] = [];
+ if (groups.major.length) sections.push(`### Breaking\n\n${groups.major.join("\n")}`);
+ if (groups.feat.length) sections.push(`### Added\n\n${groups.feat.join("\n")}`);
+ if (groups.fix.length || groups.perf.length || groups.refactor.length || groups.revert.length) {
+ sections.push(
+ `### Changed\n\n${[...groups.fix, ...groups.perf, ...groups.refactor, ...groups.revert].join("\n")}`
+ );
+ }
+ if (groups.docs.length) sections.push(`### Documentation\n\n${groups.docs.join("\n")}`);
+ const ops = [...groups.chore, ...groups.ci, ...groups.test, ...groups.build, ...groups.release];
+ if (ops.length) sections.push(`### Maintenance\n\n${ops.join("\n")}`);
+ return `${sections.join("\n\n")}\n`;
+}
+
+function main() {
+ const mode = arg("--mode") || "metadata";
+ const toRef = arg("--to") || "HEAD";
+ const fromRefArg = arg("--from-ref");
+ const fromTagArg = arg("--from-tag");
+ const currentTag = arg("--current-tag");
+ const githubOutput = arg("--github-output");
+
+ const fromTag = fromTagArg || getLatestStableTag(currentTag || undefined);
+ const rangeFrom = fromRefArg || fromTag;
+ const commits = getCommits(rangeFrom || null, toRef);
+ const invalid = commits.filter((c) => !isConventional(c));
+
+ if (mode === "lint") {
+ if (invalid.length > 0) {
+ console.error("Non-conventional commits detected:");
+ for (const c of invalid) {
+ console.error(`- ${c.sha.slice(0, 7)} ${c.subject}`);
+ }
+ process.exit(1);
+ }
+ console.log("All commits follow conventional commit rules.");
+ return;
+ }
+
+ let required: Bump = "none";
+ for (const c of commits) required = maxBump(required, bumpForCommit(c));
+
+ const baseVersion = fromTag ? parseSemver(fromTag) : ([0, 1, 0] as [number, number, number]);
+ const nextVersion = fmtSemver(bump(baseVersion, required));
+ const notes = buildNotes(commits);
+
+ const out = {
+ from_tag: fromTag,
+ from_ref: rangeFrom || null,
+ to_ref: toRef,
+ commit_count: commits.length,
+ invalid_count: invalid.length,
+ bump: required,
+ next_version: nextVersion,
+ release_notes: notes,
+ };
+
+ if (githubOutput) {
+ const lines = [
+ `from_tag=${out.from_tag || ""}`,
+ `commit_count=${out.commit_count}`,
+ `invalid_count=${out.invalid_count}`,
+ `bump=${out.bump}`,
+ `next_version=${out.next_version}`,
+ "release_notes< 0) {
+ console.error("Non-conventional commits detected:");
+ for (const c of invalid) {
+ console.error(`- ${c.sha.slice(0, 7)} ${c.subject}`);
+ }
+ process.exit(1);
+ }
+
+ console.log(JSON.stringify(out, null, 2));
+}
+
+main();
From 7c444399b9f0d57447a2b29f7a58e7032f928dfd Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 18:58:17 +0530
Subject: [PATCH 11/20] docs: finalize workflow policy docs without backlog
sections
---
docs/CI_HARDENING_EXECUTION_PLAN.md | 34 +-------------------------
docs/WORKFLOW_AUTOMATION.md | 38 ++---------------------------
2 files changed, 3 insertions(+), 69 deletions(-)
diff --git a/docs/CI_HARDENING_EXECUTION_PLAN.md b/docs/CI_HARDENING_EXECUTION_PLAN.md
index 55324ef..f0aa41c 100644
--- a/docs/CI_HARDENING_EXECUTION_PLAN.md
+++ b/docs/CI_HARDENING_EXECUTION_PLAN.md
@@ -1,4 +1,4 @@
-# CI Hardening Execution Plan (Codex-Executable)
+# CI Hardening Execution State
Last updated: 2026-02-27
@@ -31,38 +31,6 @@ No bad release should be publishable without:
3. conventional commit compliance,
4. deterministic release notes from the actual commit set.
-## Codex Autonomous Backlog
-
-### P0: Protection and Determinism
-1. Enforce required checks in branch protection (`dev`, `main`):
- - `CI`, `Secret Scanning`, `Commit Lint`, `Perf Bench`
- - Acceptance: merge blocked when any required check fails.
-2. Pin all workflow actions by full commit SHA.
- - Acceptance: no `uses: owner/action@v*` references remain.
-3. Add release environment protection for stable tags.
- - Acceptance: stable release requires approval or protected actor policy.
-
-### P1: Semver Governance
-1. Add PR comment bot that posts computed bump (`major/minor/patch/none`) from `release-meta.ts`.
- - Acceptance: every PR has visible bump preview.
-2. Add `dev->main` gate: if computed bump is `none`, block release PR merge unless override label exists.
- - Acceptance: accidental no-op releases prevented.
-
-### P2: Performance and Reliability
-1. Turn perf compare into policy mode (warning vs blocking by branch).
- - `dev`: warning, `main`: blocking for selected metrics.
- - Acceptance: regression beyond threshold blocks promotion to `main`.
-2. Upload release-meta output (`json`) as artifact for traceability.
- - Acceptance: each CI run has machine-readable release metadata.
-
-### P3: Observability and Recovery
-1. Add workflow summary sections for:
- - computed semver,
- - from-tag/to-ref range,
- - invalid commit count.
-2. Add rollback playbook doc + one-click rollback workflow (`workflow_dispatch`) for latest tag.
- - Acceptance: tested rollback path exists.
-
## Operating Rules
- Merge strategy for protected branches should preserve conventional commit subjects
(squash merge title must be conventional).
diff --git a/docs/WORKFLOW_AUTOMATION.md b/docs/WORKFLOW_AUTOMATION.md
index accf358..2bebdb4 100644
--- a/docs/WORKFLOW_AUTOMATION.md
+++ b/docs/WORKFLOW_AUTOMATION.md
@@ -27,7 +27,7 @@ Last updated: 2026-02-27
- Trigger: `pull_request` events targeting `main`.
- Condition: applies only when `head=dev` and `base=main`.
- Actions:
- - Sets PR title to `release: v (dev -> main)`
+ - Sets PR title to `release: v`
- Fills PR body from `.github/PULL_REQUEST_TEMPLATE/dev-to-main.md`
- Injects auto-generated commit list.
@@ -87,38 +87,4 @@ North star definition:
- Stable release publication is automated only when all release gates are green.
- No single human step can bypass required quality/safety checks.
-### Required Guardrails (Recommended)
-1. Branch protection on `dev` and `main`
-- Require status checks: `CI`, `Secret Scanning`, `Perf Bench`.
-- Require up-to-date branch before merge.
-- Disable direct pushes to `main`.
-
-2. Re-enable Design Contracts as blocking
-- Fix current validator false positives/real violations.
-- Make workflow required before merge.
-
-3. Make performance policy explicit
-- Option A: keep non-blocking but require manual ack.
-- Option B (north star): block on regression threshold for key metrics.
-
-4. Automate final release from `main` merge/tag policy
-- Add a controlled release gate job:
- - verifies `main` commit came from merged `dev -> main` PR
- - verifies all required checks passed on merge commit
- - creates semver tag automatically (or via manual approval environment)
-
-5. Version governance
-- Enforce version bump policy in `dev -> main` PR (e.g., `package.json` bump required).
-- Validate tag/version consistency.
-
-6. Release provenance
-- Attach SBOM/attestations and immutable artifacts to release.
-- Keep release notes generated from merged PRs + machine-readable manifest.
-
-## Immediate Next Steps to Reach North Star
-
-1. Re-enable `validate-design.yml` after fixing 7 reported violations.
-2. Turn perf regressions into a protected check (with agreed threshold).
-3. Add branch protection rules for `dev` and `main`.
-4. Add `main-release-gate` workflow that auto-tags after `dev -> main` merge when all checks pass.
-5. Add rollback playbook doc + hotfix workflow path.
+Current policy is implemented by the active workflows listed above; no open in-repo workflow backlog is tracked in this document.
From 17270e66ebd21c173bf501c9f13c79f7798b353f Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:00:13 +0530
Subject: [PATCH 12/20] ci: scope commit lint to pull request commit ranges
only
---
.github/workflows/commitlint.yml | 13 +------------
1 file changed, 1 insertion(+), 12 deletions(-)
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index d11ab40..0105199 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -3,8 +3,6 @@ name: Commit Lint
on:
pull_request:
branches: [main, dev]
- push:
- branches: [dev, "feature/**"]
jobs:
lint:
@@ -23,18 +21,9 @@ jobs:
with:
bun-version: latest
- - name: Lint commits (PR)
- if: github.event_name == 'pull_request'
+ - name: Lint commits (PR range)
run: |
bun run scripts/release-meta.ts \
--mode lint \
--from-ref "${{ github.event.pull_request.base.sha }}" \
--to "${{ github.event.pull_request.head.sha }}"
-
- - name: Lint commits (push)
- if: github.event_name == 'push'
- run: |
- bun run scripts/release-meta.ts \
- --mode lint \
- --from-ref "${{ github.event.before }}" \
- --to "${{ github.sha }}"
From 835df634af3dc355373c36d4b6d02a6e4a8e4cb5 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:16:40 +0530
Subject: [PATCH 13/20] fix(ci): setup bun before dev draft release metadata
step
---
.github/workflows/ci.yml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b4d3255..0a3c3bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -76,6 +76,14 @@ jobs:
fetch-depth: 0
submodules: recursive
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
- name: Compute dev tag
id: meta
run: |
From 58979fbe2137d52975b8a301d77858fc21a88a9f Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:19:55 +0530
Subject: [PATCH 14/20] fix(ci): allow legacy non-conventional history for dev
draft metadata
---
.github/workflows/ci.yml | 2 +-
scripts/release-meta.ts | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a3c3bd..f7c8cda 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,7 +87,7 @@ jobs:
- name: Compute dev tag
id: meta
run: |
- bun run scripts/release-meta.ts --github-output "$GITHUB_OUTPUT"
+ bun run scripts/release-meta.ts --allow-invalid --github-output "$GITHUB_OUTPUT"
DEV_TAG="${{ steps.meta.outputs.next_version }}-dev.${{ github.run_number }}"
echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT"
diff --git a/scripts/release-meta.ts b/scripts/release-meta.ts
index 45c60ed..3da67fb 100644
--- a/scripts/release-meta.ts
+++ b/scripts/release-meta.ts
@@ -171,6 +171,7 @@ function main() {
const fromTagArg = arg("--from-tag");
const currentTag = arg("--current-tag");
const githubOutput = arg("--github-output");
+ const allowInvalid = process.argv.includes("--allow-invalid");
const fromTag = fromTagArg || getLatestStableTag(currentTag || undefined);
const rangeFrom = fromRefArg || fromTag;
@@ -221,7 +222,7 @@ function main() {
Bun.write(githubOutput, `${lines.join("\n")}\n`);
}
- if (invalid.length > 0) {
+ if (invalid.length > 0 && !allowInvalid) {
console.error("Non-conventional commits detected:");
for (const c of invalid) {
console.error(`- ${c.sha.slice(0, 7)} ${c.subject}`);
From 1005dd0038faed58831794f2b34ce8593f944aa9 Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:22:04 +0530
Subject: [PATCH 15/20] fix(release): align dev-main PR version with latest
stable tag
---
.github/workflows/dev-main-pr-template.yml | 29 +++++++++++++++++++++-
package.json | 2 +-
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml
index db59f43..38bf9f4 100644
--- a/.github/workflows/dev-main-pr-template.yml
+++ b/.github/workflows/dev-main-pr-template.yml
@@ -29,7 +29,34 @@ jobs:
const pull_number = context.payload.pull_request.number;
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
- const version = pkg.version || "0.0.0";
+ const pkgVersion = String(pkg.version || "0.0.0");
+
+ function parseSemver(v) {
+ const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)$/);
+ if (!m) return [0, 0, 0];
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
+ }
+
+ function cmp(a, b) {
+ const pa = parseSemver(a);
+ const pb = parseSemver(b);
+ if (pa[0] !== pb[0]) return pa[0] - pb[0];
+ if (pa[1] !== pb[1]) return pa[1] - pb[1];
+ return pa[2] - pb[2];
+ }
+
+ const tagRefs = await github.paginate(github.rest.repos.listTags, {
+ owner,
+ repo,
+ per_page: 100,
+ });
+ const stableTags = tagRefs
+ .map((t) => t.name)
+ .filter((t) => /^v\d+\.\d+\.\d+$/.test(t));
+ stableTags.sort((a, b) => cmp(a, b));
+ const latestTag = stableTags.length ? stableTags[stableTags.length - 1].replace(/^v/, "") : "0.0.0";
+
+ const version = cmp(pkgVersion, latestTag) >= 0 ? pkgVersion : latestTag;
const title = `release: v${version}`;
const commits = await github.paginate(github.rest.pulls.listCommits, {
diff --git a/package.json b/package.json
index a624557..6dd3e2c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "smriti",
- "version": "0.3.2",
+ "version": "0.4.0",
"description": "Smriti - Unified memory layer across all AI agents",
"type": "module",
"bin": {
From 4976ab6aee312c9b545408b8d605d2bdc640383c Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:25:29 +0530
Subject: [PATCH 16/20] ci: improve workflow and check naming for PR
readability
---
.github/workflows/ci.yml | 8 ++++----
.github/workflows/commitlint.yml | 3 ++-
.github/workflows/dev-main-pr-template.yml | 3 ++-
.github/workflows/perf-bench.yml | 4 ++--
4 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f7c8cda..7ceb470 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: CI
+name: CI Core
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
@@ -13,7 +13,7 @@ on:
jobs:
test-pr:
if: github.event_name == 'pull_request'
- name: Test (ubuntu-latest)
+ name: PR / Tests (ubuntu)
runs-on: ubuntu-latest
steps:
@@ -36,7 +36,7 @@ jobs:
test-merge:
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
- name: Test (${{ matrix.os }})
+ name: Push / Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -62,7 +62,7 @@ jobs:
run: bun test test/
dev-draft-release:
- name: Dev Draft Release
+ name: Push(dev) / Draft Release
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
needs: test-merge
runs-on: ubuntu-latest
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index 0105199..8248276 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -1,4 +1,4 @@
-name: Commit Lint
+name: PR Commit Lint
on:
pull_request:
@@ -6,6 +6,7 @@ on:
jobs:
lint:
+ name: PR / Commit Lint
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml
index 38bf9f4..47194d7 100644
--- a/.github/workflows/dev-main-pr-template.yml
+++ b/.github/workflows/dev-main-pr-template.yml
@@ -1,4 +1,4 @@
-name: Dev->Main PR Autofill
+name: Dev->Main PR Metadata
on:
pull_request:
@@ -7,6 +7,7 @@ on:
jobs:
autofill:
+ name: PR / Dev->Main Metadata
if: github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
permissions:
diff --git a/.github/workflows/perf-bench.yml b/.github/workflows/perf-bench.yml
index ca3d9c0..3ff6f81 100644
--- a/.github/workflows/perf-bench.yml
+++ b/.github/workflows/perf-bench.yml
@@ -1,4 +1,4 @@
-name: Perf Bench (Non-blocking)
+name: Perf Bench
concurrency:
group: perf-${{ github.workflow }}-${{ github.ref }}
@@ -24,7 +24,7 @@ on:
jobs:
bench:
- name: Run ci-small benchmark
+ name: Bench / ci-small
runs-on: ubuntu-latest
permissions:
contents: read
From c5d091feec5007a2e9293708b39f3e93de8ff91a Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Fri, 27 Feb 2026 19:27:14 +0530
Subject: [PATCH 17/20] ci: skip PR test job for dev to main release PRs
---
.github/workflows/ci.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ceb470..766c0fc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,9 @@ on:
jobs:
test-pr:
- if: github.event_name == 'pull_request'
+ if: >
+ github.event_name == 'pull_request' &&
+ !(github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main')
name: PR / Tests (ubuntu)
runs-on: ubuntu-latest
From 2de05043ea3cddc2c943cb3d39a9dac60949b50a Mon Sep 17 00:00:00 2001
From: Baseline User
Date: Sat, 28 Feb 2026 20:25:05 +0530
Subject: [PATCH 18/20] fix(ci): use import.meta.dir for cross-platform path
resolution
new URL(import.meta.url).pathname produces /D:/a/... on Windows,
causing ENOENT errors. import.meta.dir is Bun's cross-platform
alternative.
Co-Authored-By: Claude Opus 4.6
---
src/team/document.ts | 9 +++------
src/team/segment.ts | 4 ++--
2 files changed, 5 insertions(+), 8 deletions(-)
diff --git a/src/team/document.ts b/src/team/document.ts
index 9042940..add330e 100644
--- a/src/team/document.ts
+++ b/src/team/document.ts
@@ -6,18 +6,15 @@
*/
import { OLLAMA_HOST, OLLAMA_MODEL, SMRITI_DIR } from "../config";
-import { join, dirname, basename } from "path";
+import { join } from "path";
import type { KnowledgeUnit, DocumentationOptions, DocumentGenerationResult } from "./types";
-import { existsSync } from "fs";
+
// =============================================================================
// Template Loading
// =============================================================================
-const BUILT_IN_TEMPLATES_DIR = join(
- dirname(new URL(import.meta.url).pathname),
- "prompts"
-);
+const BUILT_IN_TEMPLATES_DIR = join(import.meta.dir, "prompts");
/**
* Get the Stage 2 prompt template for a category
diff --git a/src/team/segment.ts b/src/team/segment.ts
index b150d9c..54fbdc7 100644
--- a/src/team/segment.ts
+++ b/src/team/segment.ts
@@ -7,7 +7,7 @@
*/
import { OLLAMA_HOST, OLLAMA_MODEL } from "../config";
-import { join, dirname } from "path";
+import { join } from "path";
import type { Database } from "bun:sqlite";
import type { RawMessage } from "./formatter";
import { filterMessages, mergeConsecutive, sanitizeContent } from "./formatter";
@@ -21,7 +21,7 @@ import type {
// Prompt Loading
// =============================================================================
-const PROMPT_PATH = join(dirname(new URL(import.meta.url).pathname), "prompts", "stage1-segment.md");
+const PROMPT_PATH = join(import.meta.dir, "prompts", "stage1-segment.md");
async function loadSegmentationPrompt(): Promise {
const file = Bun.file(PROMPT_PATH);
From fc2fba9f0371b9e531180411ce0036496cec24e1 Mon Sep 17 00:00:00 2001
From: Baseline User
Date: Sat, 28 Feb 2026 20:59:11 +0530
Subject: [PATCH 19/20] ci: add auto-release job for main branch merges
After tests pass on main, automatically compute the next semver
version and create a GitHub release. Handles squash merges (which
lose individual commit types) by defaulting to patch when commits
exist but bump is "none". Skips if HEAD is already tagged.
Co-Authored-By: Claude Opus 4.6
---
.github/workflows/ci.yml | 74 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 74 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 766c0fc..9125d3b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -143,3 +143,77 @@ jobs:
generate_release_notes: false
draft: true
prerelease: true
+
+ auto-release:
+ name: Push(main) / Auto Release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: test-merge
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Check if already tagged
+ id: check
+ run: |
+ # Skip if this commit already has a stable version tag
+ for tag in $(git tag --points-at HEAD 2>/dev/null); do
+ if echo "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ echo "Commit already tagged as $tag, skipping."
+ exit 0
+ fi
+ done
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+
+ - name: Setup Bun
+ if: steps.check.outputs.skip != 'true'
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ if: steps.check.outputs.skip != 'true'
+ run: bun install
+
+ - name: Compute release metadata
+ if: steps.check.outputs.skip != 'true'
+ id: meta
+ run: |
+ bun run scripts/release-meta.ts --allow-invalid --github-output "$GITHUB_OUTPUT"
+
+ # Squash merges lose individual commit types, so if bump is
+ # "none" but there are unreleased commits, default to patch.
+ BUMP=$(grep '^bump=' "$GITHUB_OUTPUT" | cut -d= -f2)
+ COUNT=$(grep '^commit_count=' "$GITHUB_OUTPUT" | cut -d= -f2)
+ if [ "$BUMP" = "none" ] && [ "$COUNT" -gt 0 ]; then
+ echo "Bump was 'none' with $COUNT commits — overriding to 'patch'"
+ LATEST=$(git tag --list 'v*.*.*' --sort=-version:refname \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
+ if [ -n "$LATEST" ]; then
+ IFS='.' read -r MAJ MIN PAT <<< "${LATEST#v}"
+ NEXT="v${MAJ}.${MIN}.$((PAT + 1))"
+ else
+ NEXT="v0.1.0"
+ fi
+ echo "next_version=${NEXT}" >> "$GITHUB_OUTPUT"
+ echo "bump=patch" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Create release
+ if: steps.check.outputs.skip != 'true' && steps.meta.outputs.bump != 'none'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.meta.outputs.next_version }}
+ target_commitish: ${{ github.sha }}
+ name: ${{ steps.meta.outputs.next_version }}
+ body: ${{ steps.meta.outputs.release_notes }}
+ generate_release_notes: false
+ draft: false
+ prerelease: false
From 938635b496ab002ca1e6eaa48109fecae62d1e5f Mon Sep 17 00:00:00 2001
From: Ashutosh Tripathi
Date: Mon, 2 Mar 2026 14:02:52 +0530
Subject: [PATCH 20/20] docs: overhaul documentation structure and tone (#43)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore: migrate pre-commit config to non-deprecated stage names
Co-Authored-By: Claude Opus 4.6
* docs: overhaul documentation structure and tone
- Rewrite README with vision-first framing — leads with the agentic
memory problem, origin story, and Ingest→Categorize→Recall→Search
as the central concept
- Add docs/cli.md as the complete command reference (moved out of README)
- Add docs/search.md as a user-facing guide to search vs recall
- Rewrite all user-facing docs (getting-started, team-sharing,
configuration, architecture) to match README tone — direct, honest,
opens with context before diving into mechanics
- Reorganize docs structure: kebab-case throughout, internal planning
docs move to docs/internal/, personal writing gitignored via
docs/writing/
- Rename: CI_HARDENING_EXECUTION_PLAN → internal/ci-hardening,
DESIGN → internal/design, WORKFLOW_AUTOMATION → internal/workflow-automation,
e2e-dev-release-flow-test → internal/e2e-release-flow,
search-recall-architecture → internal/search-analysis
- Update .gitignore: add docs/writing/, .letta/, zsh plugins
👾 Generated with [Letta Code](https://letta.com)
Co-Authored-By: Letta
---------
Co-authored-by: Baseline User
Co-authored-by: Claude Opus 4.6
Co-authored-by: Letta
---
.gitignore | 10 +
.pre-commit-config.yaml | 2 +-
README.md | 555 +++++-------------
docs/architecture.md | 238 ++++----
docs/cli.md | 300 ++++++++--
docs/configuration.md | 104 ++--
docs/getting-started.md | 98 +++-
.../ci-hardening.md} | 0
docs/{DESIGN.md => internal/design.md} | 0
.../e2e-release-flow.md} | 0
.../search-analysis.md} | 0
docs/{ => internal}/website.md | 0
.../workflow-automation.md} | 0
docs/search.md | 134 +++++
docs/team-sharing.md | 168 +++---
15 files changed, 915 insertions(+), 694 deletions(-)
rename docs/{CI_HARDENING_EXECUTION_PLAN.md => internal/ci-hardening.md} (100%)
rename docs/{DESIGN.md => internal/design.md} (100%)
rename docs/{e2e-dev-release-flow-test.md => internal/e2e-release-flow.md} (100%)
rename docs/{search-recall-architecture.md => internal/search-analysis.md} (100%)
rename docs/{ => internal}/website.md (100%)
rename docs/{WORKFLOW_AUTOMATION.md => internal/workflow-automation.md} (100%)
create mode 100644 docs/search.md
diff --git a/.gitignore b/.gitignore
index 659c46f..2e3da59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,13 @@ temp/
.smriti/CLAUDE.md
.smriti/knowledge/
.smriti/index.json
+
+# Personal writing / local-only notes
+docs/writing/
+
+# Letta Code agent state
+.letta/
+
+# Zsh plugins (should not be in project repo)
+zsh-autosuggestions/
+zsh-syntax-highlighting/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 93fbf7b..a52b986 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,7 +7,7 @@ repos:
name: Gitleaks - Detect secrets
entry: gitleaks detect --source . -c .gitleaks.toml
language: system
- stages: [commit]
+ stages: [pre-commit]
pass_filenames: false
always_run: true
diff --git a/README.md b/README.md
index ba9e970..ad3ee91 100644
--- a/README.md
+++ b/README.md
@@ -2,19 +2,40 @@
-Built on top of [QMD](https://github.com/tobi/qmd) by Tobi Lütke.
+
+ An exploration of memory in the agentic world
+
---
-## The Problem
+The agentic world is moving fast. Every team is shipping with AI — Claude Code,
+Cursor, Codex, Cline. The agents are getting better. The tooling is maturing.
+
+But there's a gap nobody has fully closed:
+
+> **Agents don't remember.**
+
+Not from yesterday. Not from each other. Not from your teammates. Every session
+starts from zero, no matter how much your team has already figured out.
+
+This isn't just a developer experience problem. It's a foundational gap in how
+agents work. As they get more capable and longer-running, memory becomes a
+prerequisite — not a feature. Without it, knowledge stays buried in chat
+histories. Teams re-discover what they've already figured out. Decisions get
+made twice.
+
+The answer, I think, mirrors how our own memory works:
-Your team ships code with AI agents every day — Claude Code, Cursor, Codex. But
-every agent has a blind spot:
+> **Ingest → Categorize → Recall → Search**
-> **They don't remember anything.** Not from yesterday. Not from each other. Not
-> from your teammates.
+That's the brain. That's what **Smriti** (Sanskrit: _memory_) is building
+toward.
-Here's what that looks like:
+---
+
+## The Problem, Up Close
+
+Here's what the gap looks like in practice:
| Monday | Tuesday |
| ------------------------------------------------------------- | --------------------------------------------------- |
@@ -31,16 +52,17 @@ The result:
- **Zero continuity** — each session starts from scratch, no matter how much
your team has already figured out
-The agents are brilliant. But they're amnesic. **This is the biggest gap in
-AI-assisted development today.**
+The agents are brilliant. But they're amnesic. **This is the biggest unsolved
+gap in agentic AI today.**
+
+---
## What Smriti Does
-**Smriti** (Sanskrit: _memory_) is a shared memory layer that sits underneath
-all your AI agents.
+Smriti is a shared memory layer that sits underneath your AI agents.
-Every conversation → automatically captured → indexed →
-searchable. One command to recall what matters.
+Every conversation → automatically captured → indexed → searchable. One command
+to recall what matters.
```bash
# What did we figure out about the auth migration?
@@ -53,12 +75,67 @@ smriti list --project myapp
smriti search "rate limiting strategy" --project api-service
```
-> **20,000 tokens** of past conversations → **500 tokens** of relevant
-> context. Your agents get what they need without blowing up your token budget.
+> **20,000 tokens** of past conversations → **500 tokens** of relevant context.
+> Your agents get what they need without blowing up your token budget.
-## The Workflow
+Built on top of [QMD](https://github.com/tobi/qmd) by Tobi Lütke. Everything
+runs locally — no cloud, no accounts, no telemetry.
+
+---
+
+## Install
+
+**macOS / Linux:**
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.sh | bash
+```
+
+**Windows** (PowerShell):
+
+```powershell
+irm https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.ps1 | iex
+```
+
+Both installers will:
+
+- Install [Bun](https://bun.sh) if you don't have it
+- Clone Smriti to `~/.smriti`
+- Set up the `smriti` CLI on your PATH
+- Configure the Claude Code auto-save hook
+
+**Requirements:** macOS, Linux, or Windows 10+ · Git · Bun ≥ 1.1
+(auto-installed) · Ollama (optional, for synthesis)
+
+```bash
+smriti upgrade # update to latest
+```
+
+---
+
+## Quick Start
+
+```bash
+# 1. Ingest your recent Claude Code sessions
+smriti ingest claude
+
+# 2. Search what your team has discussed
+smriti search "database connection pooling"
+
+# 3. Recall with synthesis into one coherent summary (requires Ollama)
+smriti recall "how did we handle rate limiting" --synthesize
+
+# 4. Share knowledge with your team through git
+smriti share --project myapp
+git add .smriti && git commit -m "chore: share session knowledge"
+
+# 5. Teammates pull it in
+smriti sync --project myapp
+```
+
+---
-Here's what changes when your team runs Smriti:
+## The Workflow
**1. Conversations are captured automatically**
@@ -101,49 +178,11 @@ teammates pull it and import it into their local memory. No cloud service, no
account, no sync infrastructure — just git.
```bash
-# Share what you've learned
smriti share --project myapp --category decision
-
-# Pull in what others have shared
smriti sync --project myapp
```
-## Install
-
-**macOS / Linux:**
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.sh | bash
-```
-
-**Windows** (PowerShell):
-
-```powershell
-irm https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.ps1 | iex
-```
-
-Both installers will:
-
-- Install [Bun](https://bun.sh) if you don't have it
-- Clone Smriti to `~/.smriti`
-- Set up the `smriti` CLI on your PATH
-- Configure the Claude Code auto-save hook
-
-### Requirements
-
-- **macOS, Linux, or Windows 10+**
-- **Git**
-- **Bun** >= 1.1 (installed automatically)
-- **Ollama** (optional — for local summarization and synthesis)
-
-### Upgrade
-
-```bash
-smriti upgrade
-```
-
-Pulls the latest version from GitHub and reinstalls dependencies. Equivalent to
-re-running the install script.
+---
## Commands
@@ -175,8 +214,8 @@ smriti categorize # Auto-categorize sessions
smriti projects # List all tracked projects
smriti upgrade # Update smriti to the latest version
-# Context and comparison
-smriti context # Generate project context for .smriti/CLAUDE.md
+# Context injection
+smriti context # Generate project context → .smriti/CLAUDE.md
smriti context --dry-run # Preview without writing
smriti compare --last # Compare last 2 sessions (tokens, tools, files)
smriti compare # Compare specific sessions
@@ -187,6 +226,8 @@ smriti sync # Import teammates' shared knowledge
smriti team # View team contributions
```
+---
+
## How It Works
```
@@ -221,308 +262,7 @@ Claude Code Cursor Codex Other Agents
Everything runs locally. Your conversations never leave your machine. The SQLite
database, the embeddings, the search indexes — all on disk, all yours.
-## Ingest Architecture
-
-Smriti ingest uses a layered pipeline:
-
-1. `parsers/*` extract agent transcripts into normalized messages (no DB writes).
-2. `session-resolver` derives project/session state, including incremental offsets.
-3. `store-gateway` persists messages, sidecars, session meta, and costs.
-4. `ingest/index.ts` orchestrates the flow with per-session error isolation.
-
-This keeps parser logic, resolution logic, and persistence logic separated and testable.
-See `INGEST_ARCHITECTURE.md` and `src/ingest/README.md` for implementation details.
-
-## Tagging & Categories
-
-Sessions and messages are automatically tagged into a hierarchical category
-tree. Tags flow through every command — search, recall, list, and share — so you
-can slice your team's knowledge by topic.
-
-### Default Category Tree
-
-Smriti ships with 7 top-level categories and 21 subcategories:
-
-| Category | Subcategories |
-| -------------- | ----------------------------------------------------------------------- |
-| `code` | `code/implementation`, `code/pattern`, `code/review`, `code/snippet` |
-| `architecture` | `architecture/design`, `architecture/decision`, `architecture/tradeoff` |
-| `bug` | `bug/report`, `bug/fix`, `bug/investigation` |
-| `feature` | `feature/requirement`, `feature/design`, `feature/implementation` |
-| `project` | `project/setup`, `project/config`, `project/dependency` |
-| `decision` | `decision/technical`, `decision/process`, `decision/tooling` |
-| `topic` | `topic/learning`, `topic/explanation`, `topic/comparison` |
-
-### Auto-Classification
-
-Smriti uses a two-stage pipeline to classify messages:
-
-1. **Rule-based** — 24 keyword patterns with weighted confidence scoring. Each
- pattern targets a specific subcategory (e.g., words like "crash",
- "stacktrace", "panic" map to `bug/report`). Confidence is calculated from
- keyword density and rule weight.
-2. **LLM fallback** — When rule confidence falls below the threshold (default
- `0.5`, configurable via `SMRITI_CLASSIFY_THRESHOLD`), Ollama classifies the
- message. Only activated when you pass `--llm`.
-
-The most frequent category across a session's messages becomes the session-level
-tag.
-
-```bash
-# Auto-categorize all uncategorized sessions (rule-based)
-smriti categorize
-
-# Include LLM fallback for ambiguous sessions
-smriti categorize --llm
-
-# Categorize a specific session
-smriti categorize --session
-```
-
-### Manual Tagging
-
-Override or supplement auto-classification with manual tags:
-
-```bash
-smriti tag
-
-# Examples
-smriti tag abc123 decision/technical
-smriti tag abc123 bug/fix
-```
-
-Manual tags are stored with confidence `1.0` and source `"manual"`.
-
-### Custom Categories
-
-Add your own categories to extend the default tree:
-
-```bash
-# List the full category tree
-smriti categories
-
-# Add a top-level category
-smriti categories add ops --name "Operations"
-
-# Add a nested category under an existing parent
-smriti categories add ops/incident --name "Incident Response" --parent ops
-
-# Include a description
-smriti categories add ops/runbook --name "Runbooks" --parent ops --description "Operational runbook sessions"
-```
-
-### How Tags Filter Commands
-
-The `--category` flag works across search, recall, list, and share:
-
-| Command | Effect of `--category` |
-| --------------- | ------------------------------------------------------------------------------- |
-| `smriti list` | Shows categories column; filters sessions to matching category |
-| `smriti search` | Filters full-text search results to matching category |
-| `smriti recall` | Filters recall context; works with `--synthesize` |
-| `smriti share` | Controls which sessions are exported; files organized into `.smriti/knowledge/` |
-| `smriti status` | Shows session count per category (no filter flag — always shows all) |
-
-**Hierarchical filtering** — Filtering by a parent category automatically
-includes all its children. `--category decision` matches `decision/technical`,
-`decision/process`, and `decision/tooling`.
-
-### Categories in Share & Sync
-
-**Categories survive the share/sync roundtrip exactly.** What gets serialized
-during `smriti share` is exactly what gets deserialized during `smriti sync` —
-the same category ID goes in, the same category ID comes out. No
-reclassification, no transformation, no loss. The category a session was tagged
-with on one machine is the category it will be indexed under on every other
-machine that syncs it.
-
-When you share sessions, the category is embedded in YAML frontmatter inside
-each exported markdown file:
-
-```yaml
----
-id: 2e5f420a-e376-4ad4-8b35-ad94838cbc42
-category: project
-project: smriti
-agent: claude-code
-author: zero8
-shared_at: 2026-02-10T11:29:44.501Z
-tags: ["project", "project/dependency"]
---
-```
-
-When a teammate runs `smriti sync`, the frontmatter is parsed and the category
-is restored into their local `smriti_session_tags` table — indexed as `project`,
-searchable as `project`, filterable as `project`. The serialization and
-deserialization are symmetric: `share` writes `category: project` → `sync` reads
-`category: project` → `tagSession(db, sessionId, "project", 1.0, "team")`. No
-intermediate step reinterprets the value.
-
-Files are organized into subdirectories by primary category (e.g.,
-`.smriti/knowledge/project/`, `.smriti/knowledge/decision/`), but sync reads the
-category from frontmatter, not the directory path.
-
-> **Note:** Currently only the primary `category` field is restored on sync.
-> Secondary tags in the `tags` array are serialized in the frontmatter but not
-> yet imported. If a session had multiple tags (e.g., `project` +
-> `decision/tooling`), only the primary tag survives the roundtrip.
-
-```bash
-# Share decisions — category metadata travels with the files
-smriti share --project myapp --category decision
-
-# Teammate syncs — categories restored exactly from frontmatter
-smriti sync --project myapp
-```
-
-### Examples
-
-```bash
-# All architectural decisions
-smriti search "database" --category architecture
-
-# Recall only bug-related context
-smriti recall "connection timeout" --category bug --synthesize
-
-# List feature sessions for a specific project
-smriti list --category feature --project myapp
-
-# Share only decision sessions
-smriti share --project myapp --category decision
-```
-
-## Context: Token Reduction (North Star)
-
-Every new Claude Code session starts from zero — no awareness of what happened
-yesterday, which files were touched, what decisions were made. `smriti context`
-generates a compact project summary (~200-300 tokens) and injects it into
-`.smriti/CLAUDE.md`, which Claude Code auto-discovers.
-
-```bash
-smriti context # auto-detect project, write .smriti/CLAUDE.md
-smriti context --dry-run # preview without writing
-smriti context --project myapp # explicit project
-smriti context --days 14 # 14-day lookback (default: 7)
-```
-
-The output looks like this:
-
-```markdown
-## Project Context
-
-> Auto-generated by `smriti context` on 2026-02-11. Do not edit manually.
-
-### Recent Sessions (last 7 days)
-
-- **2h ago** Enriched ingestion pipeline (12 turns) [code]
-- **1d ago** Search & recall pipeline (8 turns) [feature]
-
-### Hot Files
-
-`src/db.ts` (14 ops), `src/ingest/claude.ts` (11 ops), `src/search/index.ts` (8
-ops)
-
-### Git Activity
-
-- commit `main`: "Fix auth token refresh" (2026-02-10)
-
-### Usage
-
-5 sessions, 48 turns, ~125K input / ~35K output tokens
-```
-
-No Ollama, no network calls, no model loading. Pure SQL queries against sidecar
-tables, rendered as markdown. Runs in < 100ms.
-
-### Measuring the Impact
-
-Does this actually save tokens? Honestly — we don't know yet. We built the tools
-to measure it, ran A/B tests, and the results so far are... humbling. Claude is
-annoyingly good at finding the right files even without help.
-
-But this is the north star, not the destination. We believe context injection
-will matter most on large codebases without detailed docs, ambiguous tasks that
-require exploration, and multi-session continuity. We just need the data to
-prove it (or disprove it and try something else).
-
-So we're shipping the measurement tools and asking you to help. Run A/B tests on
-your projects, paste the results in
-[Issue #13](https://github.com/zero8dotdev/smriti/issues/13), and let's figure
-this out together.
-
-#### A/B Testing Guide
-
-```bash
-# Step 1: Baseline session (no context)
-mv .smriti/CLAUDE.md .smriti/CLAUDE.md.bak
-# Start a Claude Code session, give it a task, let it finish, exit
-
-# Step 2: Context session
-mv .smriti/CLAUDE.md.bak .smriti/CLAUDE.md
-smriti context
-# Start a new session, give the EXACT same task, let it finish, exit
-
-# Step 3: Ingest and compare
-smriti ingest claude
-smriti compare --last --project myapp
-```
-
-#### Compare Command
-
-```bash
-smriti compare # by session ID (supports partial IDs)
-smriti compare --last # last 2 sessions for current project
-smriti compare --last --project myapp # last 2 sessions for specific project
-smriti compare --last --json # machine-readable output
-```
-
-Output:
-
-```
-Session A: Fix auth bug (no context)
-Session B: Fix auth bug (with context)
-
-Metric A B Diff
-----------------------------------------------------------------
-Turns 12 8 -4 (-33%)
-Total tokens 45K 32K -13000 (-29%)
-Tool calls 18 11 -7 (-39%)
-File reads 10 4 -6 (-60%)
-
-Tool breakdown:
- Bash 4 3
- Glob 3 0
- Read 10 4
- Write 1 4
-```
-
-#### What We've Tested So Far
-
-| Task Type | Context Impact | Notes |
-| ----------------------------------------- | -------------- | ---------------------------------------------------------------------- |
-| Knowledge questions ("how does X work?") | Minimal | Both sessions found the right files immediately from project CLAUDE.md |
-| Implementation tasks ("add --since flag") | Minimal | Small, well-scoped tasks don't need exploration |
-| Ambiguous/exploration tasks | Untested | Expected sweet spot — hot files guide Claude to the right area |
-| Large codebases (no project CLAUDE.md) | Untested | Expected sweet spot — context replaces missing documentation |
-
-**We need your help.** If you run A/B tests on your projects, please share your
-results in [GitHub Issues](https://github.com/zero8dotdev/smriti/issues).
-Include the `smriti compare` output and a description of the task. This data
-will help us understand where context injection actually matters.
-
-### Token Savings (Search & Recall)
-
-Separate from context injection, Smriti's search and recall pipeline compresses
-past conversations:
-
-| Scenario | Raw Conversations | Via Smriti | Reduction |
-| ----------------------------------- | ----------------- | ----------- | --------- |
-| Relevant context from past sessions | ~20,000 tokens | ~500 tokens | **40x** |
-| Multi-session recall + synthesis | ~10,000 tokens | ~200 tokens | **50x** |
-| Full project conversation history | 50,000+ tokens | ~500 tokens | **100x** |
-
-Lower token spend, faster responses, more room for the actual work in your
-context window.
## Privacy
@@ -534,68 +274,87 @@ Smriti is local-first by design. No cloud, no telemetry, no accounts.
- Synthesis via local [Ollama](https://ollama.ai) (optional)
- Team sharing happens through git — you control what gets committed
+---
+
## FAQ
-**When does knowledge get captured?** Automatically. Smriti hooks into your AI
-coding tool (Claude Code, Cursor, etc.) and captures every session without any
-manual step. You just code normally and `smriti ingest` pulls in the
-conversations.
+**When does knowledge get captured?** Automatically. Smriti hooks into Claude
+Code and captures every session without any manual step. For other agents, run
+`smriti ingest all` to pull in conversations on demand.
**Who has access to my data?** Only you. Everything lives in a local SQLite
-database (`~/.cache/qmd/index.sqlite`). There's no cloud, no accounts, no
-telemetry. Team sharing is explicit — you run `smriti share` to export, commit
-the `.smriti/` folder to git, and teammates run `smriti sync` to import.
+database. There's no cloud, no accounts, no telemetry. Team sharing is
+explicit — you run `smriti share`, commit the `.smriti/` folder, and teammates
+run `smriti sync`.
**Can AI agents query the knowledge base?** Yes. `smriti recall "query"` returns
-relevant past context that agents can use. When you run `smriti share`, it
-generates a `.smriti/CLAUDE.md` index so Claude Code automatically discovers
-shared knowledge. Agents can search, grep, and recall from the full knowledge
-base.
+relevant past context. `smriti share` generates a `.smriti/CLAUDE.md` so Claude
+Code automatically discovers shared knowledge at the start of every session.
**How do multiple projects stay separate?** Each project gets its own `.smriti/`
-folder in its repo root. Sessions are tagged with project IDs in the central
-database. Search works cross-project by default, but you can scope to a single
-project with `--project `. Knowledge shared via git stays within that
-project's repo.
+folder. Sessions are tagged with project IDs in the central database. Search
+works cross-project by default, scoped with `--project `.
**Does this work with Jira or other issue trackers?** Not yet — Smriti is
-git-native today. Issue tracker integrations are on the roadmap. If you have
-ideas, open a discussion in
-[GitHub Issues](https://github.com/zero8dotdev/smriti/issues).
+git-native today. Issue tracker integrations are on the roadmap.
-**How does this help preserve existing features during changes?** The reasoning
-behind each code change is captured and searchable. When an AI agent starts a
-new session, it can recall _why_ something was built a certain way — reducing
-the chance of accidentally breaking existing behavior.
+**Further reading:** See [docs/cli.md](./docs/cli.md) for the full command
+reference, [INGEST_ARCHITECTURE.md](./INGEST_ARCHITECTURE.md) for the ingestion
+pipeline, and [CLAUDE.md](./CLAUDE.md) for the database schema and
+architecture.
-## Uninstall
+---
-```bash
-curl -fsSL https://raw.githubusercontent.com/zero8dotdev/smriti/main/uninstall.sh | bash
-```
+## About
-To also remove hook state, prepend `SMRITI_PURGE=1` to the command.
+I've been coding with AI agents for about 8 months. At some point the
+frustration became impossible to ignore — every new session, you start from
+zero. Explaining the same context, the same decisions, the same constraints.
+That's not a great developer experience.
+
+I started small: custom prompts to export Claude sessions. That grew old fast. I
+needed categorization. Found QMD. Started building on top of it. Dogfooded it.
+Hit walls. Solved one piece at a time.
+
+At some point it worked well enough that I shared it with some friends. Some
+used it, some ignored it — fair, the AI tooling space is noisy. But I kept
+exploring, and found others building toward the same problem: Claude-mem, Letta,
+a growing community of people who believe memory is the next foundational layer
+for AI.
-## Documentation
+That's what Smriti is, really. An exploration. The developer tool is one layer.
+But the deeper question is: what does memory for autonomous agents actually need
+to look like? The answer probably mirrors how our own brain works — **Ingest →
+Categorize → Recall → Search**. We're figuring that out, one piece at a time.
-See [CLAUDE.md](./CLAUDE.md) for the full reference — API docs, database schema,
-architecture details, and troubleshooting.
+I come from the developer tooling space. Bad tooling bothers me. There's always
+a better way. This is that project.
+
+---
## Special Thanks
Smriti is built on top of [QMD](https://github.com/tobi/qmd) — a beautifully
designed local search engine for markdown files created by
-[Tobi Lütke](https://github.com/tobi), CEO of Shopify.
+[Tobi Lütke](https://github.com/tobi), CEO of Shopify. QMD gave us fast,
+local-first SQLite with full-text search, vector embeddings, and
+content-addressable hashing — all on your machine, zero cloud dependencies.
+Instead of rebuilding that infrastructure from scratch, we focused entirely on
+the memory layer, multi-agent ingestion, and team sharing.
-QMD gave us the foundation we needed: a fast, local-first SQLite store with
-full-text search, vector embeddings, and content-addressable hashing — all
-running on your machine with zero cloud dependencies. Instead of rebuilding that
-infrastructure from scratch, we were able to focus entirely on the memory layer,
-multi-agent ingestion, and team sharing that makes Smriti useful.
+Thank you, Tobi, for open-sourcing it.
+
+---
-Thank you, Tobi, for open-sourcing QMD. It's a reminder that the best tools are
-often the ones that quietly do the hard work so others can build something new
-on top.
+## Uninstall
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/zero8dotdev/smriti/main/uninstall.sh | bash
+```
+
+To also remove hook state, prepend `SMRITI_PURGE=1` to the command.
+
+---
## License
diff --git a/docs/architecture.md b/docs/architecture.md
index 5ec1d33..1740953 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,149 +1,139 @@
# Architecture
-## Overview
+Smriti's architecture follows the same pattern as memory in your brain:
+**Ingest → Categorize → Recall → Search**.
-```
- Claude Code Cursor Codex Other Agents
- | | | |
- v v v v
- ┌──────────────────────────────────────────┐
- │ Smriti Ingestion Layer │
- │ │
- │ src/ingest/claude.ts (JSONL parser) │
- │ src/ingest/codex.ts (JSONL parser) │
- │ src/ingest/cursor.ts (JSON parser) │
- │ src/ingest/generic.ts (file import) │
- └──────────────────┬───────────────────────┘
- │
- v
- ┌──────────────────────────────────────────┐
- │ QMD Core (via src/qmd.ts) │
- │ │
- │ addMessage() content-addressed │
- │ searchMemoryFTS() BM25 full-text │
- │ searchMemoryVec() vector similarity │
- │ recallMemories() dedup + synthesis │
- └──────────────────┬───────────────────────┘
- │
- v
- ┌──────────────────────────────────────────┐
- │ SQLite Database │
- │ ~/.cache/qmd/index.sqlite │
- │ │
- │ QMD tables: │
- │ memory_sessions memory_messages │
- │ memory_fts content_vectors │
- │ │
- │ Smriti tables: │
- │ smriti_session_meta (agent, project) │
- │ smriti_projects (registry) │
- │ smriti_categories (taxonomy) │
- │ smriti_session_tags (categorization) │
- │ smriti_message_tags (categorization) │
- │ smriti_shares (team dedup) │
- └──────────────────────────────────────────┘
-```
+Every layer has one job. Parsers extract conversations. The resolver maps
+them to projects. The store persists them. Search retrieves them. Nothing
+crosses those boundaries.
-## QMD Integration
+---
-Smriti builds on top of [QMD](https://github.com/tobi/qmd), a local-first search engine. QMD provides:
-
-- **Content-addressable storage** — Messages are SHA256-hashed, no duplicates
-- **FTS5 full-text search** — BM25 ranking with Porter stemming
-- **Vector embeddings** — 384-dim vectors via embeddinggemma (node-llama-cpp)
-- **Reciprocal Rank Fusion** — Combines FTS and vector results
+## System Overview
-All QMD imports go through a single re-export hub at `src/qmd.ts`:
-
-```ts
-// Every file imports from here, never from qmd directly
-import { addMessage, searchMemoryFTS, recallMemories } from "./qmd";
-import { hashContent } from "./qmd";
-import { ollamaRecall } from "./qmd";
+```
+Claude Code Cursor Codex Cline Copilot
+ | | | | |
+ v v v v v
+┌──────────────────────────────────────────────┐
+│ Smriti Ingestion Layer │
+│ │
+│ parsers/claude.ts (JSONL) │
+│ parsers/codex.ts (JSONL) │
+│ parsers/cursor.ts (JSON) │
+│ parsers/cline.ts (task files) │
+│ parsers/copilot.ts (VS Code storage) │
+│ parsers/generic.ts (file import) │
+│ │
+│ session-resolver.ts (project detection) │
+│ store-gateway.ts (persistence) │
+└──────────────────┬───────────────────────────┘
+ │
+ v
+┌──────────────────────────────────────────────┐
+│ QMD Core (via src/qmd.ts) │
+│ │
+│ addMessage() content-addressed │
+│ searchMemoryFTS() BM25 full-text │
+│ searchMemoryVec() vector similarity │
+│ recallMemories() dedup + synthesis │
+└──────────────────┬───────────────────────────┘
+ │
+ v
+┌──────────────────────────────────────────────┐
+│ SQLite (~/.cache/qmd/index.sqlite) │
+│ │
+│ QMD tables: │
+│ memory_sessions memory_messages │
+│ memory_fts (BM25) content_vectors │
+│ │
+│ Smriti tables: │
+│ smriti_session_meta (agent, project) │
+│ smriti_projects (registry) │
+│ smriti_categories (taxonomy) │
+│ smriti_session_tags (categorization) │
+│ smriti_message_tags (categorization) │
+│ smriti_shares (team dedup) │
+└──────────────────────────────────────────────┘
```
-This creates a clean boundary — if QMD's API changes, only `src/qmd.ts` needs updating.
+Everything runs locally. Nothing leaves your machine.
-## Ingestion Pipeline
+---
-Each agent has a dedicated parser. The flow:
+## Built on QMD
-1. **Discover** — Glob for session files in agent-specific log directories
-2. **Deduplicate** — Check `smriti_session_meta` for already-ingested session IDs
-3. **Parse** — Agent-specific parsing into a common `ParsedMessage[]` format
-4. **Store** — Save via QMD's `addMessage()` (content-addressed, SHA256 hashed)
-5. **Annotate** — Attach Smriti metadata (agent ID, project ID) to `smriti_session_meta`
+Smriti builds on [QMD](https://github.com/tobi/qmd) — a local-first search
+engine for markdown files by Tobi Lütke. QMD handles the hard parts:
-### Project Detection (Claude Code)
+- **Content-addressable storage** — messages are SHA256-hashed, no duplicates
+- **FTS5 full-text search** — BM25 ranking with Porter stemming
+- **Vector embeddings** — 384-dim via EmbeddingGemma (node-llama-cpp),
+ computed entirely on-device
+- **Reciprocal Rank Fusion** — combines FTS and vector results
-Claude Code stores sessions in `~/.claude/projects//`. The directory name encodes the filesystem path with `-` replacing `/`:
+All QMD imports go through a single re-export hub at `src/qmd.ts`. No file
+in the codebase imports from QMD directly — only through this hub. If QMD's
+API changes, one file needs updating.
+```ts
+import { addMessage, searchMemoryFTS, recallMemories } from "./qmd";
+import { hashContent, ollamaRecall } from "./qmd";
```
--Users-zero8-zero8.dev-openfga → /Users/zero8/zero8.dev/openfga
-```
-
-Since folder names can also contain dashes, `deriveProjectPath()` uses greedy `existsSync()` matching: it tries candidate paths from left to right, picking the longest existing directory at each step.
-`deriveProjectId()` then strips the configured `PROJECTS_ROOT` (default `~/zero8.dev`) to produce a clean project name like `openfga` or `avkash/regulation-hub`.
+---
-## Search Architecture
-
-### Filtered Search
+## Ingestion Pipeline
-`searchFiltered()` in `src/search/index.ts` extends QMD's FTS5 search with JOINs to Smriti's metadata tables:
+Ingestion is a four-stage pipeline with clean separation between stages:
-```sql
-FROM memory_fts mf
-JOIN memory_messages mm ON mm.rowid = mf.rowid
-JOIN memory_sessions ms ON ms.id = mm.session_id
-LEFT JOIN smriti_session_meta sm ON sm.session_id = mm.session_id
-WHERE mf.content MATCH ?
- AND sm.project_id = ? -- project filter
- AND sm.agent_id = ? -- agent filter
- AND EXISTS (...) -- category filter via smriti_message_tags
-```
+1. **Parse** — agent-specific parsers extract conversations into a normalized
+ `ParsedMessage[]` format. No DB writes, no side effects. Pure functions.
+2. **Resolve** — `session-resolver.ts` maps sessions to projects, handles
+ incremental ingestion (picks up where it left off), derives clean project
+ IDs from agent-specific path formats.
+3. **Store** — `store-gateway.ts` persists messages, session metadata,
+ sidecars, and cost data. All writes go through here.
+4. **Orchestrate** — `ingest/index.ts` drives the flow with per-session error
+ isolation. One broken session doesn't stop the rest.
-### Recall
+### Project Detection
-`recall()` in `src/search/recall.ts` wraps search with:
+Claude Code encodes project paths into directory names like
+`-Users-zero8-zero8.dev-openfga` (slashes become dashes). Since folder
+names can also contain real dashes, `deriveProjectPath()` uses greedy
+`existsSync()` matching — trying candidate paths left to right, picking the
+longest valid directory at each step.
-1. **Session deduplication** — Keep only the best-scoring result per session
-2. **Optional synthesis** — Sends results to Ollama's `ollamaRecall()` for a coherent summary
+`deriveProjectId()` then strips `SMRITI_PROJECTS_ROOT` to produce a clean
+name: `openfga`, `avkash/regulation-hub`.
-When no filters are specified, it delegates directly to QMD's native `recallMemories()`.
+---
-## Team Sharing
+## Search
-### Export (`smriti share`)
+Smriti adds a metadata filter layer on top of QMD's native search:
-Sessions are exported as markdown files with YAML frontmatter:
-
-```
-.smriti/
-├── config.json
-├── index.json # Manifest of all shared files
-└── knowledge/
- ├── decision/
- │ └── 2026-02-10_auth-migration-approach.md
- └── bug/
- └── 2026-02-09_connection-pool-fix.md
-```
+**`smriti search`** — FTS5 full-text with JOINs to Smriti's metadata tables.
+Filters by project, agent, and category without touching the vector index.
+Fast, synchronous, no model loading.
-Each file contains:
-- YAML frontmatter (session ID, category, project, agent, author, tags)
-- Session title as heading
-- Summary (if available)
-- Full conversation in `**role**: content` format
+**`smriti recall`** — Two paths depending on whether filters are applied:
-Content hashes prevent re-exporting the same content.
+- *No filters* → delegates to QMD's native `recallMemories()`: FTS + vector
+ + Reciprocal Rank Fusion + session dedup. Full hybrid pipeline.
+- *With filters* → filtered FTS search + session dedup. Vector search is
+ currently bypassed when filters are active. (This is a known gap — see
+ [search.md](./search.md) for details.)
-### Import (`smriti sync`)
+**`smriti embed`** — builds vector embeddings for all unembedded messages.
+Required before vector search works. Runs locally via node-llama-cpp.
-Reads markdown files from `.smriti/knowledge/`, parses frontmatter and conversation, and imports via `addMessage()`. Content hashing prevents duplicate imports.
+---
## Database Schema
-### QMD Tables (not modified by Smriti)
+### QMD Tables
| Table | Purpose |
|-------|---------|
@@ -156,10 +146,26 @@ Reads markdown files from `.smriti/knowledge/`, parses frontmatter and conversat
| Table | Purpose |
|-------|---------|
-| `smriti_agents` | Agent registry (claude-code, codex, cursor) |
+| `smriti_agents` | Agent registry (claude-code, codex, cursor...) |
| `smriti_projects` | Project registry (id, filesystem path) |
| `smriti_session_meta` | Maps sessions to agents and projects |
| `smriti_categories` | Hierarchical category taxonomy |
-| `smriti_session_tags` | Category tags on sessions (with confidence) |
-| `smriti_message_tags` | Category tags on messages (with confidence) |
+| `smriti_session_tags` | Category tags on sessions (with confidence score) |
+| `smriti_message_tags` | Category tags on messages (with confidence score) |
| `smriti_shares` | Deduplication tracking for team sharing |
+
+---
+
+## Team Sharing
+
+Export (`smriti share`) converts sessions to markdown with YAML frontmatter
+and writes them to `.smriti/knowledge/`, organized by category. The YAML
+carries session ID, category, project, agent, author, and tags — enough to
+reconstruct the full metadata on import.
+
+Import (`smriti sync`) parses frontmatter, restores categories, and inserts
+via `addMessage()`. Content hashing prevents duplicate imports. The
+roundtrip is symmetric: what gets written during share is exactly what gets
+read during sync.
+
+See [team-sharing.md](./team-sharing.md) for the workflow.
diff --git a/docs/cli.md b/docs/cli.md
index 25b5071..13f1116 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -1,40 +1,82 @@
-# CLI Reference
+# Smriti CLI Reference
+
+Everything you can do with `smriti`. For the big picture, see the
+[README](../README.md).
+
+---
+
+## Global Flags
+
+```bash
+smriti --version # Print version
+smriti --help # Print command overview
+smriti help # Same as --help
+```
+
+---
+
+## Global Filters
+
+These flags work across `search`, `recall`, `list`, and `share`:
+
+| Flag | Description |
+|------|-------------|
+| `--category ` | Filter by category (e.g. `decision`, `bug/fix`) |
+| `--project ` | Filter by project ID |
+| `--agent ` | Filter by agent (`claude-code`, `codex`, `cursor`, `cline`, `copilot`) |
+| `--limit ` | Max results returned |
+| `--json` | Machine-readable JSON output |
+
+Hierarchical category filtering: `--category decision` matches `decision`,
+`decision/technical`, `decision/process`, and `decision/tooling`.
+
+---
## Ingestion
### `smriti ingest `
-Import conversations from an AI agent into Smriti's memory.
+Pull conversations from an AI agent into Smriti's memory.
-| Agent | Source | Format |
-|-------|--------|--------|
-| `claude` / `claude-code` | `~/.claude/projects/*/*.jsonl` | JSONL |
-| `codex` | `~/.codex/**/*.jsonl` | JSONL |
-| `cursor` | `.cursor/**/*.json` (requires `--project-path`) | JSON |
-| `file` / `generic` | Any file path | Chat or JSONL |
-| `all` | All known agents at once | — |
+| Agent | Source |
+|-------|--------|
+| `claude` / `claude-code` | `~/.claude/projects/*/*.jsonl` |
+| `codex` | `~/.codex/**/*.jsonl` |
+| `cline` | `~/.cline/tasks/**` |
+| `copilot` | VS Code `workspaceStorage` (auto-detected per OS) |
+| `cursor` | `.cursor/**/*.json` (requires `--project-path`) |
+| `file` / `generic` | Any file path |
+| `all` | All known agents at once |
```bash
smriti ingest claude
smriti ingest codex
+smriti ingest cline
+smriti ingest copilot
smriti ingest cursor --project-path /path/to/project
smriti ingest file ~/transcript.txt --title "Planning Session" --format chat
smriti ingest all
```
**Options:**
-- `--project-path ` — Project directory (required for Cursor)
-- `--file ` — File path (for generic ingest)
-- `--format ` — File format (default: `chat`)
-- `--title ` — Session title
-- `--session ` — Custom session ID
-- `--project ` — Assign to a project
-## Search
+| Flag | Description |
+|------|-------------|
+| `--project-path ` | Project directory (required for Cursor) |
+| `--file ` | File path (alternative to positional arg for generic ingest) |
+| `--format ` | File format (default: `chat`) |
+| `--title ` | Session title override |
+| `--session ` | Custom session ID |
+| `--project ` | Assign ingested sessions to a specific project |
+
+---
+
+## Search & Recall
### `smriti search `
-Hybrid search across all memory using BM25 full-text and vector similarity.
+Hybrid full-text + vector search across all memory. Returns ranked results
+with session and message context.
```bash
smriti search "rate limiting"
@@ -43,51 +85,59 @@ smriti search "deployment" --category decision --limit 10
smriti search "API design" --json
```
-**Options:**
-- `--category ` — Filter by category
-- `--project ` — Filter by project
-- `--agent ` — Filter by agent (`claude-code`, `codex`, `cursor`)
-- `--limit ` — Max results (default: 20)
-- `--json` — JSON output
+**Options:** All global filters apply.
+
+---
### `smriti recall `
-Smart recall: searches, deduplicates by session, and optionally synthesizes results into a coherent summary.
+Like search, but deduplicates results by session and optionally synthesizes
+them into a single coherent summary via Ollama.
```bash
smriti recall "how did we handle caching"
smriti recall "database setup" --synthesize
smriti recall "auth flow" --synthesize --model qwen3:0.5b --max-tokens 200
-smriti recall "deployment" --project api --json
+smriti recall "deployment" --category decision --project api --json
```
**Options:**
-- `--synthesize` — Synthesize results into one summary via Ollama
-- `--model ` — Ollama model for synthesis (default: `qwen3:8b-tuned`)
-- `--max-tokens ` — Max synthesis output tokens
-- All filter options from `search`
+
+| Flag | Description |
+|------|-------------|
+| `--synthesize` | Synthesize results into one summary via Ollama (requires Ollama running) |
+| `--model ` | Ollama model to use (default: `qwen3:8b-tuned`) |
+| `--max-tokens ` | Max tokens for synthesized output |
+| All global filters | `--category`, `--project`, `--agent`, `--limit`, `--json` |
+
+---
## Sessions
### `smriti list`
-List recent sessions with optional filtering.
+List recent sessions with filtering.
```bash
smriti list
smriti list --project myapp --agent claude-code
smriti list --category decision --limit 20
-smriti list --all --json
+smriti list --all
+smriti list --json
```
**Options:**
-- `--all` — Include inactive sessions
-- `--json` — JSON output
-- All filter options from `search`
+
+| Flag | Description |
+|------|-------------|
+| `--all` | Include inactive/archived sessions |
+| All global filters | `--category`, `--project`, `--agent`, `--limit`, `--json` |
+
+---
### `smriti show `
-Display all messages in a session.
+Display all messages in a session. Supports partial session IDs.
```bash
smriti show abc12345
@@ -95,15 +145,27 @@ smriti show abc12345 --limit 10
smriti show abc12345 --json
```
+**Options:**
+
+| Flag | Description |
+|------|-------------|
+| `--limit ` | Max messages to display |
+| `--json` | JSON output |
+
+---
+
### `smriti status`
-Memory statistics: session counts, message counts, agent breakdowns, project breakdowns, category distribution.
+Memory statistics: total sessions, messages, agent breakdown, project
+breakdown, category distribution.
```bash
smriti status
smriti status --json
```
+---
+
### `smriti projects`
List all registered projects.
@@ -113,11 +175,28 @@ smriti projects
smriti projects --json
```
+---
+
+## Embeddings
+
+### `smriti embed`
+
+Build vector embeddings for all unembedded messages. Required for semantic
+(vector) search to work. Runs locally via `node-llama-cpp` — no network
+calls.
+
+```bash
+smriti embed
+```
+
+---
+
## Categorization
### `smriti categorize`
-Auto-categorize uncategorized sessions using rule-based matching and optional LLM classification.
+Auto-categorize uncategorized sessions using rule-based keyword matching with
+an optional LLM fallback for ambiguous cases.
```bash
smriti categorize
@@ -126,60 +205,159 @@ smriti categorize --llm
```
**Options:**
-- `--session ` — Categorize a specific session only
-- `--llm` — Use Ollama LLM for ambiguous classifications
+
+| Flag | Description |
+|------|-------------|
+| `--session ` | Categorize a specific session only |
+| `--llm` | Enable Ollama LLM fallback for low-confidence classifications |
+
+---
### `smriti tag `
-Manually tag a session with a category.
+Manually assign a category to a session. Stored with confidence `1.0` and
+source `"manual"`.
```bash
smriti tag abc12345 decision/technical
smriti tag abc12345 bug/fix
```
+---
+
### `smriti categories`
-Show the category tree.
+Display the full category tree.
```bash
smriti categories
```
+**Default categories:**
+
+| Category | Subcategories |
+|----------|---------------|
+| `code` | `code/implementation`, `code/pattern`, `code/review`, `code/snippet` |
+| `architecture` | `architecture/design`, `architecture/decision`, `architecture/tradeoff` |
+| `bug` | `bug/report`, `bug/fix`, `bug/investigation` |
+| `feature` | `feature/requirement`, `feature/design`, `feature/implementation` |
+| `project` | `project/setup`, `project/config`, `project/dependency` |
+| `decision` | `decision/technical`, `decision/process`, `decision/tooling` |
+| `topic` | `topic/learning`, `topic/explanation`, `topic/comparison` |
+
+---
+
### `smriti categories add `
-Add a custom category.
+Add a custom category to the tree.
```bash
-smriti categories add infra/monitoring --name "Monitoring" --parent infra --description "Monitoring and observability"
+smriti categories add ops --name "Operations"
+smriti categories add ops/incident --name "Incident Response" --parent ops
+smriti categories add ops/runbook --name "Runbooks" --parent ops --description "Operational runbook sessions"
```
-## Embeddings
+**Options:**
-### `smriti embed`
+| Flag | Description |
+|------|-------------|
+| `--name ` | Display name (required) |
+| `--parent ` | Parent category ID |
+| `--description ` | Optional description |
-Build vector embeddings for all unembedded messages. Required for semantic search.
+---
+
+## Context & Compare
+
+### `smriti context`
+
+Generate a compact project summary (~200–300 tokens) and write it to
+`.smriti/CLAUDE.md`. Claude Code auto-discovers this file at session start.
+
+Runs entirely from SQL — no Ollama, no network, no model loading. Typically
+completes in under 100ms.
```bash
-smriti embed
+smriti context
+smriti context --dry-run
+smriti context --project myapp
+smriti context --days 14
+smriti context --json
```
+**Options:**
+
+| Flag | Description |
+|------|-------------|
+| `--project ` | Project to generate context for (auto-detected from `cwd` if omitted) |
+| `--days ` | Lookback window in days (default: `7`) |
+| `--dry-run` | Print output to stdout without writing the file |
+| `--json` | JSON output |
+
+---
+
+### `smriti compare `
+
+Compare two sessions across turns, tokens, tool calls, and file reads. Useful
+for A/B testing context injection impact.
+
+```bash
+smriti compare abc123 def456
+smriti compare --last
+smriti compare --last --project myapp
+smriti compare --last --json
+```
+
+**Options:**
+
+| Flag | Description |
+|------|-------------|
+| `--last` | Compare the two most recent sessions (for current project) |
+| `--project ` | Project scope for `--last` |
+| `--json` | JSON output |
+
+Partial session IDs are supported (first 7+ characters).
+
+---
+
## Team Sharing
### `smriti share`
-Export sessions as markdown files to a `.smriti/` directory for git-based sharing.
+Export sessions as clean markdown files to `.smriti/knowledge/` for
+git-based team sharing. Generates LLM reflections via Ollama by default.
+Also writes `.smriti/CLAUDE.md` so Claude Code auto-discovers shared
+knowledge.
```bash
smriti share --project myapp
smriti share --category decision
smriti share --session abc12345
smriti share --output /custom/path
+smriti share --no-reflect
+smriti share --reflect-model llama3.2
+smriti share --segmented --min-relevance 7
```
+**Options:**
+
+| Flag | Description |
+|------|-------------|
+| `--project ` | Export sessions for a specific project |
+| `--category ` | Export only sessions with this category |
+| `--session ` | Export a single session |
+| `--output ` | Custom output directory (default: `.smriti/`) |
+| `--no-reflect` | Skip LLM reflections (reflections are on by default) |
+| `--reflect-model ` | Ollama model for reflections |
+| `--segmented` | Use 3-stage segmentation pipeline — beta |
+| `--min-relevance ` | Relevance threshold for segmented mode (default: `6`) |
+
+---
+
### `smriti sync`
-Import team knowledge from a `.smriti/` directory.
+Import team knowledge from a `.smriti/knowledge/` directory into local
+memory. Deduplicates by content hash — same content won't import twice.
```bash
smriti sync
@@ -187,10 +365,32 @@ smriti sync --project myapp
smriti sync --input /custom/path
```
+**Options:**
+
+| Flag | Description |
+|------|-------------|
+| `--project ` | Scope sync to a specific project |
+| `--input ` | Custom input directory (default: `.smriti/`) |
+
+---
+
### `smriti team`
-View team contributions (authors, counts, categories).
+View team contributions: authors, session counts, and category breakdown.
```bash
smriti team
```
+
+---
+
+## Maintenance
+
+### `smriti upgrade`
+
+Pull the latest version from GitHub and reinstall dependencies. Equivalent to
+re-running the install script.
+
+```bash
+smriti upgrade
+```
diff --git a/docs/configuration.md b/docs/configuration.md
index 713e8c9..0941099 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,81 +1,113 @@
# Configuration
-Smriti uses environment variables for configuration. Bun auto-loads `.env` files, so you can set these in a `.env.local` file in the smriti directory.
+Smriti uses environment variables for configuration. Bun auto-loads `.env`
+files, so you can put these in `~/.smriti/.env` and they'll be picked up
+automatically — no need to set them in your shell profile.
+
+Most people never need to touch these. The defaults work. The ones you're
+most likely to change are `SMRITI_PROJECTS_ROOT` (to match where your
+projects actually live) and `QMD_MEMORY_MODEL` (if you want a lighter Ollama
+model).
+
+---
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
-| `QMD_DB_PATH` | `~/.cache/qmd/index.sqlite` | Path to the shared SQLite database |
-| `CLAUDE_LOGS_DIR` | `~/.claude/projects` | Claude Code session logs directory |
-| `CODEX_LOGS_DIR` | `~/.codex` | Codex CLI session logs directory |
-| `SMRITI_PROJECTS_ROOT` | `~/zero8.dev` | Root directory for project detection |
+| `QMD_DB_PATH` | `~/.cache/qmd/index.sqlite` | SQLite database path |
+| `CLAUDE_LOGS_DIR` | `~/.claude/projects` | Claude Code session logs |
+| `CODEX_LOGS_DIR` | `~/.codex` | Codex CLI session logs |
+| `CLINE_LOGS_DIR` | `~/.cline/tasks` | Cline CLI tasks |
+| `COPILOT_STORAGE_DIR` | auto-detected per OS | VS Code workspaceStorage root |
+| `SMRITI_PROJECTS_ROOT` | `~/zero8.dev` | Root for project ID derivation |
| `OLLAMA_HOST` | `http://127.0.0.1:11434` | Ollama API endpoint |
-| `QMD_MEMORY_MODEL` | `qwen3:8b-tuned` | Ollama model for synthesis/summarization |
-| `SMRITI_CLASSIFY_THRESHOLD` | `0.5` | Confidence below which LLM classification triggers |
+| `QMD_MEMORY_MODEL` | `qwen3:8b-tuned` | Ollama model for synthesis |
+| `SMRITI_CLASSIFY_THRESHOLD` | `0.5` | LLM classification trigger threshold |
| `SMRITI_AUTHOR` | `$USER` | Author name for team sharing |
+| `SMRITI_DAEMON_DEBOUNCE_MS` | `30000` | File-stability wait before auto-ingest |
+
+---
## Projects Root
-The `SMRITI_PROJECTS_ROOT` variable controls how Smriti derives project IDs from Claude Code session paths.
+`SMRITI_PROJECTS_ROOT` is the most commonly changed setting. It controls how
+Smriti derives clean project IDs from Claude Code session paths.
-Claude Code encodes project paths in directory names like `-Users-zero8-zero8.dev-openfga`. Smriti reconstructs the real path and strips the projects root prefix:
+Claude Code encodes project paths into directory names like
+`-Users-zero8-zero8.dev-openfga`. Smriti reconstructs the real filesystem
+path and strips the projects root prefix to produce a readable ID:
-| Claude Dir Name | Derived Project ID |
-|----------------|-------------------|
-| `-Users-zero8-zero8.dev-openfga` | `openfga` |
-| `-Users-zero8-zero8.dev-avkash-regulation-hub` | `avkash/regulation-hub` |
-| `-Users-zero8-zero8.dev` | `zero8.dev` |
+| Claude dir name | Projects root | Derived ID |
+|-----------------|---------------|------------|
+| `-Users-zero8-zero8.dev-openfga` | `~/zero8.dev` | `openfga` |
+| `-Users-zero8-zero8.dev-avkash-regulation-hub` | `~/zero8.dev` | `avkash/regulation-hub` |
+| `-Users-alice-code-myapp` | `~/code` | `myapp` |
-To change the projects root:
+If your projects live under `~/code` instead of `~/zero8.dev`:
```bash
-export SMRITI_PROJECTS_ROOT="$HOME/projects"
+export SMRITI_PROJECTS_ROOT="$HOME/code"
```
+---
+
## Database Location
-By default, Smriti shares QMD's database at `~/.cache/qmd/index.sqlite`. This means your QMD document search and Smriti memory search share the same vector index — no duplication.
+By default, Smriti shares QMD's database at `~/.cache/qmd/index.sqlite`.
+This means QMD document search and Smriti memory search share the same vector
+index — one embedding store, no duplication.
-To use a separate database:
+To keep them separate:
```bash
export QMD_DB_PATH="$HOME/.cache/smriti/memory.sqlite"
```
+---
+
## Ollama Setup
-Ollama is optional. It's used for:
-- `smriti recall --synthesize` — Synthesize recalled context into a summary
-- `smriti categorize --llm` — LLM-assisted categorization
+Ollama is optional. Everything core — ingestion, search, recall, sharing —
+works without it. Ollama only powers the features that require a language
+model:
+
+- `smriti recall --synthesize` — Compress recalled context into a summary
+- `smriti share` — Generate session reflections (skip with `--no-reflect`)
+- `smriti categorize --llm` — LLM fallback for ambiguous categorization
-Install and start Ollama:
+Install and start:
```bash
-# Install (macOS)
+# macOS
brew install ollama
-
-# Start the server
ollama serve
# Pull the default model
ollama pull qwen3:8b-tuned
```
-To use a different model:
+The default model (`qwen3:8b-tuned`) is good but large (~4.7GB). For a
+lighter option:
```bash
-export QMD_MEMORY_MODEL="mistral:7b"
+export QMD_MEMORY_MODEL="qwen3:0.5b"
+ollama pull qwen3:0.5b
```
-## Claude Code Hook
+To point at a remote Ollama instance:
+
+```bash
+export OLLAMA_HOST="http://192.168.1.100:11434"
+```
-The install script sets up an auto-save hook at `~/.claude/hooks/save-memory.sh`. This requires:
+---
-- **jq** — for parsing the hook's JSON input
-- **Claude Code** — must be installed with hooks support
+## Claude Code Hook
-The hook is configured in `~/.claude/settings.json`:
+The install script creates `~/.claude/hooks/save-memory.sh` and registers it
+in `~/.claude/settings.json`. This is what captures sessions automatically
+when you end a Claude Code conversation.
```json
{
@@ -86,7 +118,7 @@ The hook is configured in `~/.claude/settings.json`:
"hooks": [
{
"type": "command",
- "command": "/path/to/.claude/hooks/save-memory.sh",
+ "command": "/Users/you/.claude/hooks/save-memory.sh",
"timeout": 30,
"async": true
}
@@ -97,4 +129,8 @@ The hook is configured in `~/.claude/settings.json`:
}
```
-To disable the hook, remove the entry from `settings.json` or set `SMRITI_NO_HOOK=1` during install.
+**Requires `jq`** — the hook parses JSON input from Claude Code. Install with
+`brew install jq` or `apt install jq`.
+
+To disable: remove the entry from `settings.json`. To skip hook setup during
+install, set `SMRITI_NO_HOOK=1` before running the installer.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a4fd4bc..8d328d3 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,17 +1,30 @@
# Getting Started
+You're about to give your AI agents memory.
+
+By the end of this guide, your Claude Code sessions will be automatically
+saved, searchable, and shareable — across sessions, across days, across your
+team.
+
+---
+
## Install
+**macOS / Linux:**
+
```bash
curl -fsSL https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.sh | bash
```
-The installer will:
-1. Check for (and install) [Bun](https://bun.sh)
-2. Clone Smriti to `~/.smriti`
-3. Install dependencies
-4. Create the `smriti` CLI at `~/.local/bin/smriti`
-5. Set up the Claude Code auto-save hook
+**Windows** (PowerShell):
+
+```powershell
+irm https://raw.githubusercontent.com/zero8dotdev/smriti/main/install.ps1 | iex
+```
+
+The installer: checks for Bun (installs it if missing) → clones Smriti to
+`~/.smriti` → creates the `smriti` CLI → sets up the Claude Code auto-save
+hook.
### Verify
@@ -22,63 +35,104 @@ smriti help
If `smriti` is not found, add `~/.local/bin` to your PATH:
```bash
-echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
-source ~/.zshrc
+echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
```
+---
+
## First Run
-### 1. Ingest your Claude Code conversations
+### 1. Pull in your Claude Code sessions
```bash
smriti ingest claude
```
-This scans `~/.claude/projects/` for all session transcripts and imports them.
+Scans `~/.claude/projects/` and imports every conversation. On first run this
+might take a moment if you've been coding with Claude for a while.
-### 2. Check what was imported
+### 2. See what you have
```bash
smriti status
```
-Output shows session count, message count, and per-agent/per-project breakdowns.
+Session counts, message counts, breakdown by project and agent. This is your
+memory — everything Smriti knows about your past work.
-### 3. Search your memory
+### 3. Search it
```bash
smriti search "authentication"
```
+Keyword search across every session. Try something you remember working
+through in a past conversation.
+
### 4. Recall with context
```bash
smriti recall "how did we set up the database"
```
-This searches, deduplicates by session, and returns the most relevant snippets.
+Like search, but smarter — deduplicates by session and surfaces the most
+relevant snippets. Add `--synthesize` to compress results into a single
+coherent summary (requires Ollama).
-### 5. Build embeddings for semantic search
+### 5. Turn on semantic search
```bash
smriti embed
```
-After embedding, searches find semantically similar content — not just keyword matches.
+Builds vector embeddings locally. After this, searches find semantically
+similar content — not just keyword matches. "auth flow" starts surfacing
+results that talk about "login mechanism."
-## Auto-Save (Claude Code)
+---
-If the installer set up the hook, every Claude Code conversation is saved automatically. No action needed — just code as usual.
+## Auto-Save
-To verify the hook is active:
+If the install completed cleanly, you're done — every Claude Code session is
+saved automatically when you end it. No manual step, no copy-pasting.
+
+Verify the hook is active:
```bash
cat ~/.claude/settings.json | grep save-memory
```
+If the hook isn't there, re-run the installer or set it up manually — see
+[Configuration](./configuration.md#claude-code-hook).
+
+---
+
+## Share with Your Team
+
+Once you've built up memory, share the useful parts through git:
+
+```bash
+smriti share --project myapp --category decision
+cd ~/projects/myapp
+git add .smriti/ && git commit -m "Share auth migration decisions"
+git push
+```
+
+A teammate imports it:
+
+```bash
+git pull && smriti sync --project myapp
+smriti recall "auth migration" --project myapp
+```
+
+Their agent now has your context. See [Team Sharing](./team-sharing.md) for
+the full guide.
+
+---
+
## Next Steps
-- [CLI Reference](./cli.md) — All commands and options
-- [Team Sharing](./team-sharing.md) — Share knowledge via git
-- [Configuration](./configuration.md) — Environment variables and customization
+- [CLI Reference](./cli.md) — Every command and option
+- [Team Sharing](./team-sharing.md) — Share knowledge through git
+- [Configuration](./configuration.md) — Customize paths, models, and behavior
- [Architecture](./architecture.md) — How Smriti works under the hood
diff --git a/docs/CI_HARDENING_EXECUTION_PLAN.md b/docs/internal/ci-hardening.md
similarity index 100%
rename from docs/CI_HARDENING_EXECUTION_PLAN.md
rename to docs/internal/ci-hardening.md
diff --git a/docs/DESIGN.md b/docs/internal/design.md
similarity index 100%
rename from docs/DESIGN.md
rename to docs/internal/design.md
diff --git a/docs/e2e-dev-release-flow-test.md b/docs/internal/e2e-release-flow.md
similarity index 100%
rename from docs/e2e-dev-release-flow-test.md
rename to docs/internal/e2e-release-flow.md
diff --git a/docs/search-recall-architecture.md b/docs/internal/search-analysis.md
similarity index 100%
rename from docs/search-recall-architecture.md
rename to docs/internal/search-analysis.md
diff --git a/docs/website.md b/docs/internal/website.md
similarity index 100%
rename from docs/website.md
rename to docs/internal/website.md
diff --git a/docs/WORKFLOW_AUTOMATION.md b/docs/internal/workflow-automation.md
similarity index 100%
rename from docs/WORKFLOW_AUTOMATION.md
rename to docs/internal/workflow-automation.md
diff --git a/docs/search.md b/docs/search.md
new file mode 100644
index 0000000..d09204c
--- /dev/null
+++ b/docs/search.md
@@ -0,0 +1,134 @@
+# Search & Recall
+
+Smriti has two ways to retrieve memory: `search` and `recall`. They use
+different retrieval strategies and are optimized for different situations.
+
+---
+
+## search vs recall
+
+| | `smriti search` | `smriti recall` |
+|--|-----------------|-----------------|
+| **Retrieval** | Full-text (BM25) | Full-text + vector (hybrid) |
+| **Deduplication** | None — all matching messages | One best result per session |
+| **Synthesis** | No | Yes, with `--synthesize` |
+| **Best for** | Finding specific text, scanning results | Getting context before starting work |
+
+Use **search** when you know roughly what you're looking for and want to scan
+results. Use **recall** when you want the most relevant context from your
+history, deduplicated and optionally compressed.
+
+---
+
+## How Search Works
+
+`smriti search` runs a BM25 full-text query against every ingested message.
+It's fast, synchronous, and returns ranked results immediately — no model
+loading.
+
+```bash
+smriti search "rate limiting"
+smriti search "auth" --project myapp --agent claude-code
+smriti search "deployment" --category decision --limit 10
+```
+
+Filters (`--project`, `--category`, `--agent`) narrow results with SQL JOINs
+against Smriti's metadata tables. They compose — all filters apply together.
+
+---
+
+## How Recall Works
+
+`smriti recall` goes further. It runs full-text search, deduplicates results
+so you get at most one snippet per session (the highest-scoring one), and
+optionally synthesizes everything into a single coherent summary.
+
+```bash
+smriti recall "how did we handle rate limiting"
+smriti recall "database setup" --synthesize
+smriti recall "auth flow" --synthesize --model qwen3:0.5b
+```
+
+**Without filters:** recall uses QMD's full hybrid pipeline — BM25 +
+vector embeddings + Reciprocal Rank Fusion. Semantic matches work here: "auth
+flow" can surface results that talk about "login mechanism."
+
+**With filters:** recall currently uses full-text search only. The hybrid
+pipeline is bypassed when `--project`, `--category`, or `--agent` is applied.
+This is a known limitation — filtered recall loses semantic matching. It's
+on the roadmap to fix.
+
+---
+
+## Synthesis
+
+`--synthesize` sends the recalled context to Ollama and asks it to produce a
+single coherent summary. This is the difference between getting 10 raw
+snippets and getting a paragraph that distills what matters.
+
+```bash
+smriti recall "connection pooling decisions" --synthesize
+```
+
+Requires Ollama running locally. See [Configuration](./configuration.md#ollama-setup)
+for setup. Use `--model` to pick a lighter model if the default is too slow.
+
+---
+
+## Vector Search
+
+Vector search finds semantically similar content — results that mean the same
+thing even if they don't share the same words. It requires embeddings to be
+built first:
+
+```bash
+smriti embed
+```
+
+This runs locally via node-llama-cpp and EmbeddingGemma. It can take a few
+minutes on a large history, but only processes new messages — subsequent runs
+are fast.
+
+Once embeddings exist, unfiltered `smriti recall` automatically uses the full
+hybrid pipeline (BM25 + vector + RRF). Filtered recall and `smriti search`
+currently use BM25 only.
+
+---
+
+## Filtering
+
+All filters compose and work across both commands:
+
+```bash
+# Scope to a project
+smriti recall "auth" --project myapp
+
+# Scope to a specific agent
+smriti search "deployment" --agent cursor
+
+# Scope to a category
+smriti recall "why did we choose postgres" --category decision
+
+# Combine them
+smriti search "migration" --project api --category decision --limit 5
+```
+
+Category filtering is hierarchical — `--category decision` matches
+`decision`, `decision/technical`, `decision/process`, and
+`decision/tooling`.
+
+---
+
+## Token Compression
+
+The point of recall isn't just finding relevant content — it's making that
+content usable in a new session without blowing up the context window.
+
+| Scenario | Raw | Via Smriti | Reduction |
+|----------|-----|------------|-----------|
+| Relevant context from past sessions | ~20,000 tokens | ~500 tokens | **40x** |
+| Multi-session recall + synthesis | ~10,000 tokens | ~200 tokens | **50x** |
+| Full project conversation history | 50,000+ tokens | ~500 tokens | **100x** |
+
+That's what `--synthesize` is for — not a summary for you to read, but
+compressed context for your next agent session to start with.
diff --git a/docs/team-sharing.md b/docs/team-sharing.md
index 22c81ba..e9228fa 100644
--- a/docs/team-sharing.md
+++ b/docs/team-sharing.md
@@ -1,60 +1,107 @@
# Team Sharing
-Smriti's team sharing works through git — no cloud service, no accounts, no sync infrastructure.
+When you work through something hard with an AI agent — a tricky migration,
+an architectural decision, a bug that took three hours to trace — that
+knowledge shouldn't stay locked in your chat history. Smriti lets you export
+it, commit it to git, and make it available to every agent your team uses.
-## How It Works
+No cloud service. No accounts. No sync infrastructure. Just git.
-1. **Export** knowledge from your local memory to a `.smriti/` directory
-2. **Commit** the `.smriti/` directory to your project repo
-3. **Teammates pull** and import the shared knowledge into their local memory
+---
+
+## The Flow
+
+1. **Export** — `smriti share` converts your sessions into clean markdown
+ files and writes them to `.smriti/knowledge/` in your project directory
+2. **Commit** — you push `.smriti/` to your project repo like any other file
+3. **Import** — teammates run `smriti sync` to pull the knowledge into their
+ local memory
+4. **Recall** — any agent on the team can now recall that context
+
+---
+
+## End-to-End Example
+
+**Alice finishes a productive session on auth:**
+
+```bash
+smriti share --project myapp --category decision
-The `.smriti/` directory lives inside your project repo alongside your code.
+cd ~/projects/myapp
+git add .smriti/
+git commit -m "Share auth migration decisions"
+git push
+```
+
+**Bob starts a new session the next morning:**
+
+```bash
+cd ~/projects/myapp
+git pull
+smriti sync --project myapp
+
+smriti recall "auth migration" --project myapp
+```
+
+Bob's agent now has Alice's full context — the decisions made, the approaches
+considered and rejected, the trade-offs. Alice didn't have to explain
+anything. Bob didn't have to ask.
+
+---
## Exporting Knowledge
-### Share by project
+### By project
```bash
smriti share --project myapp
```
-This exports all sessions tagged with project `myapp` to the project's `.smriti/knowledge/` directory.
+Exports all sessions tagged to that project.
-### Share by category
+### By category
```bash
smriti share --category decision
smriti share --category architecture/design
```
-### Share a specific session
+Export only what matters. Decision sessions tend to have the highest
+signal — they capture the *why* behind code choices, not just the *what*.
+
+### A single session
```bash
smriti share --session abc12345
```
-### Custom output directory
+### Options
-```bash
-smriti share --project myapp --output /path/to/.smriti
-```
+| Flag | Description |
+|------|-------------|
+| `--no-reflect` | Skip LLM session reflections (on by default — requires Ollama) |
+| `--reflect-model ` | Ollama model for reflections |
+| `--output ` | Custom output directory |
+| `--segmented` | 3-stage segmentation pipeline (beta) |
+
+---
-## Output Format
+## What Gets Exported
```
.smriti/
-├── config.json # Sharing configuration
-├── index.json # Manifest of all shared files
+├── config.json
+├── index.json
└── knowledge/
├── decision/
│ └── 2026-02-10_auth-migration-approach.md
- ├── bug-fix/
+ ├── bug/
│ └── 2026-02-09_connection-pool-fix.md
└── uncategorized/
└── 2026-02-08_initial-setup.md
```
-Each knowledge file is markdown with YAML frontmatter:
+Each file is clean markdown with YAML frontmatter:
```markdown
---
@@ -69,31 +116,36 @@ tags: ["decision", "decision/technical"]
# Auth migration approach
-> Summary of the session if available
+> Generated reflection: Alice and the agent decided on a phased migration
+> approach, starting with read-path only to reduce risk...
**user**: How should we handle the auth migration?
**assistant**: I'd recommend a phased approach...
```
-## Importing Knowledge
+The reflection at the top is generated by Ollama — a short synthesis of what
+was decided and why. Use `--no-reflect` to skip it.
-When a teammate has shared knowledge:
+---
+
+## Importing Knowledge
```bash
-git pull # Get the latest .smriti/ files
-smriti sync --project myapp # Import into local memory
+git pull
+smriti sync --project myapp
```
-Or import from a specific directory:
+Content is hashed before import — the same session imported twice creates no
+duplicates. Run `smriti sync` as often as you like.
+
+Import from a specific directory:
```bash
smriti sync --input /path/to/.smriti
```
-### Deduplication
-
-Content is hashed before import. If the same knowledge has already been imported, it's skipped automatically. You can safely run `smriti sync` repeatedly.
+---
## Viewing Contributions
@@ -101,61 +153,31 @@ Content is hashed before import. If the same knowledge has already been imported
smriti team
```
-Shows who has shared what:
-
```
Author Shared Categories Latest
alice 12 decision, bug/fix 2026-02-10
bob 8 architecture, code 2026-02-09
```
-## Git Integration
-
-Add `.smriti/` to your repo:
-
-```bash
-cd /path/to/myapp
-git add .smriti/
-git commit -m "Share auth migration knowledge"
-git push
-```
-
-### `.gitignore` Recommendations
-
-The `config.json` and `index.json` should be committed. If you want to be selective:
-
-```gitignore
-# Commit everything in .smriti/
-!.smriti/
-```
-
-## Workflow Example
+---
-### Alice (shares knowledge)
+## Claude Code Auto-Discovery
-```bash
-# Alice had a productive session about auth
-smriti share --project myapp --category decision
+When you run `smriti share`, it writes a `.smriti/CLAUDE.md` index file.
+Claude Code auto-discovers this at the start of every session — giving it
+immediate awareness of your team's shared knowledge without any manual
+prompting.
-# Commit to the project repo
-cd ~/projects/myapp
-git add .smriti/
-git commit -m "Share auth migration decisions"
-git push
-```
-
-### Bob (imports knowledge)
+---
-```bash
-# Bob pulls the latest
-cd ~/projects/myapp
-git pull
+## Notes
-# Import Alice's shared knowledge
-smriti sync --project myapp
+**Categories survive the roundtrip.** The category a session was tagged with
+on one machine is the category it's indexed under on every machine that syncs
+it — no reclassification, no loss.
-# Now Bob can recall Alice's context
-smriti recall "auth migration" --project myapp
-```
+**Only the primary category is restored on sync.** If a session had multiple
+tags, only the primary one survives. Known limitation.
-Bob's AI agent now has access to Alice's decisions without Alice needing to explain anything.
+**You control what gets shared.** Nothing is exported unless you explicitly
+run `smriti share`. Your local memory stays local until you decide otherwise.