Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4011461
docs(epic): record Phase 0 spike results, select mE5-small
NestorCanales Jul 6, 2026
85d444c
docs(epic): add Open Concerns & Plan Adjustments section
NestorCanales Jul 6, 2026
20b2a55
docs(bug): document PDF extraction passthrough defect
NestorCanales Jul 6, 2026
6207db9
feat(embeddings): Phase 1 — split Embedder into query/document + chun…
NestorCanales Jul 6, 2026
718c1b7
docs(epic): mark Phase 1 complete, entering Phase 2
NestorCanales Jul 7, 2026
9cda0bd
docs(epic): record Phase 2 file-layout decisions
NestorCanales Jul 7, 2026
95ec0df
feat(embeddings): Phase 2 — local ONNX embedder package (build-tag is…
NestorCanales Jul 7, 2026
325f023
docs(epic): mark Phase 2 complete, entering Phase 3
NestorCanales Jul 7, 2026
776fc91
docs(epic): add Phase 3 detailed plan (draft, pending Bo review)
NestorCanales Jul 7, 2026
712cd3b
feat(build): Phase 3a — bundle local model via go:embed + make assets
NestorCanales Jul 9, 2026
dffc7e7
feat(embeddings): Phase 3b — local is now the default provider + swit…
NestorCanales Jul 9, 2026
bca31fb
feat(embeddings): Phase 3c — provider reporting, dim-mismatch guard, …
NestorCanales Jul 10, 2026
488291f
docs(epic): mark Phase 3 complete, entering Phase 4
NestorCanales Jul 10, 2026
44f1f22
feat(frontend): Phase 4 — keyless onboarding + provider toggle
NestorCanales Jul 10, 2026
d1c1e7d
docs: Phase 4 — local is the default (offline, no key); OpenAI opt-in
NestorCanales Jul 10, 2026
49fcbd4
docs(epic): mark Phase 4 complete
NestorCanales Jul 10, 2026
00f1571
feat(embeddings): Phase 5 foundation — per-platform go:embed split
NestorCanales Jul 16, 2026
551a3e2
feat(embeddings): Phase 5 Linux — manifest entries, embed file, darwi…
NestorCanales Jul 17, 2026
afbc4c3
fix(watcher): merge debounced events per path so Linux Create isn't s…
NestorCanales Jul 17, 2026
bd025b6
docs(bugs): watcher create-event report — fix submitted as PR #5
NestorCanales Jul 17, 2026
4df770c
feat(embeddings): Phase 5 macOS x86_64 — source-built ORT, arch-safe …
NestorCanales Jul 17, 2026
bc0b2b3
feat(embeddings): Phase 5 Windows — DLL + source-built tokenizer, zip…
NestorCanales Jul 17, 2026
b172323
fix(build): link Windows NT/Winsock/crypto syscall libs for source-bu…
NestorCanales Jul 17, 2026
37e1b0c
fix(build): stage sqlite3.h for Windows build (sqlite-vec cgo needs it)
NestorCanales Jul 17, 2026
d314670
docs(epic): record Phase 5 Windows findings; Phase 5 targets all veri…
NestorCanales Jul 17, 2026
f25b318
docs(epic): Phase 6 checklist — record Phase 5 verification gaps
NestorCanales Jul 17, 2026
ecfd993
docs(epic): Windows full-suite run done — watcher assumption confirme…
NestorCanales Jul 17, 2026
436a4ef
docs(epic): linux-amd64 runtime smoke done on real x64 hardware
NestorCanales Jul 17, 2026
f6ab373
fix(app): per-OS Claude Desktop config path — Install button was mac-…
NestorCanales Jul 17, 2026
564fde9
fix(embeddings): reserve token budget for prefix/specials; truncate d…
NestorCanales Jul 18, 2026
d39b323
fix(engine): surface index failures in the activity log
NestorCanales Jul 18, 2026
50f5471
fix(app): close quits the app on non-mac platforms (no tray to hide i…
NestorCanales Jul 18, 2026
902911e
docs(bugs): token-budget overflow report (multi-chunk files silently …
NestorCanales Jul 18, 2026
44a49cf
docs(epic): Windows GUI field test done — 3 bugs found+fixed (PRs 8/9…
NestorCanales Jul 18, 2026
366e887
docs(epic): Linux GUI field test done — webkit2_41 packaging requirem…
NestorCanales Jul 20, 2026
a6fd9b8
docs(epic): Phase 6 executed — orphan hunt clean, architecture audit …
NestorCanales Jul 20, 2026
ce50af8
merge: watcher create-event fix (was PR #5)
NestorCanales Jul 27, 2026
27bb9ee
merge: index-failure visibility in activity log (was PR #8)
NestorCanales Jul 27, 2026
b279f8d
merge: close = quit on non-mac platforms (was PR #9)
NestorCanales Jul 27, 2026
df97e90
fix(engine,store): crash-safe shutdown — atomic index writes + Stop()…
NestorCanales Aug 12, 2026
a8b4db9
fix(engine,store): activity log — single-site error logging, per-path…
NestorCanales Aug 12, 2026
5962d9f
test(watcher): deterministic cross-platform debounce-merge coverage
NestorCanales Aug 12, 2026
05936dc
fix(engine): full embedding fingerprint guard in read-only search
NestorCanales Aug 12, 2026
388b930
polish(app): hasTray const owned by the tray build-tag pair
NestorCanales Aug 12, 2026
3252e03
docs(epics): define background-presence, mcp-search-filters, multi-re…
NestorCanales Aug 12, 2026
36df4be
fix: correctness follow-ups from /code-review self-review
NestorCanales Aug 13, 2026
5b34c15
cleanup: trim production-dead store methods; document actual distance…
NestorCanales Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ Desktop.ini

# Scratchpad
scratchpad/

# Local-embedding assets (downloaded by `make assets`, never committed).
# Runtime assets that get go:embed-ed into the localembed build:
internal/embeddings/local/embedded/
# Link-time static tokenizer lib (pulled in via CGO_LDFLAGS):
internal/embeddings/local/lib/
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
# Agent Memory

Local-first desktop app + MCP server for semantic file search. Watches directories, embeds file contents via OpenAI, stores vectors in SQLite (sqlite-vec), and provides KNN semantic search.
Local-first desktop app + MCP server for semantic file search. Watches directories, embeds file contents (a bundled local model by default; OpenAI opt-in), stores vectors in SQLite (sqlite-vec), and provides KNN semantic search.

## Build & Run

```bash
make build # wails build -skipbindings (DO NOT use plain `wails build` — hangs on binding generation due to CGo)
make assets # download pinned local model/tokenizer/ORT libs into assets/embedded/ (~150 MB, checksummed via assets/manifest.json)
make build # wails build -skipbindings, builds with -tags localembed (DO NOT use plain `wails build` — hangs on binding generation due to CGo)
make build-darwin-amd64 # cross-build the Intel-mac app from an arm64 Mac (swaps in darwin-amd64 assets)
make dev # hot-reload dev mode
make test # go test ./...
make test # go test ./... (default build, no localembed tag — stays native-lib-free)
make clean # rm -rf build/bin
```

> `make build` depends on `make assets`, which downloads ~150 MB of native artifacts (model,
> tokenizer, ONNX Runtime lib) the first time — needs network access. The build sets
> `-tags localembed` plus `CGO_LDFLAGS` to link the tokenizer library; the resulting binary is
> ~180 MB (it bundles the model). The native code is build-tag-isolated, so plain
> `go test ./...` and CI need no assets.

> **macOS 26+ gotcha:** Go ≤ 1.24 produces CGo binaries the kernel kills instantly on launch
> (exit 137, `dyld: missing LC_UUID`) — this breaks `make build`, the `wails` CLI, and the built
> app. Fix: `go env -w GOTOOLCHAIN=go1.26.4`, then `go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`.
Expand Down Expand Up @@ -44,7 +52,8 @@ Delivery (main.go, mcp/, tray.go, frontend/)
|---------|---------|
| `internal/engine` | Core orchestrator: scan → extract → chunk → embed → store |
| `internal/store` | SQLite + sqlite-vec persistence (config, directories, files, chunks, vectors) |
| `internal/embeddings` | OpenAI embedding client with batching (2048/req) and retry |
| `internal/embeddings` | Embedder interface + OpenAI client (opt-in) with batching (2048/req) and retry |
| `internal/embeddings/local` | Bundled in-process local embedder (default): ONNX Runtime + HF tokenizer, `multilingual-e5-small` (384-dim), offline |
| `internal/chunker` | Token-based text splitting (tiktoken, cl100k_base) |
| `internal/watcher` | fsnotify recursive directory watcher with 500ms debounce |
| `internal/extractor` | Multi-format content extraction (text, docx, xlsx, pptx, pdf, images) |
Expand Down
40 changes: 30 additions & 10 deletions Documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Delivery -> Service (Engine) -> Domain (interfaces) -> Infrastructure (imp

**Domain** — each package defines its own interface in `iface.go`. No cross-domain imports.

**Infrastructure** — concrete implementations that satisfy domain interfaces: SQLite, OpenAI API, fsnotify, file extractor.
**Infrastructure** — concrete implementations that satisfy domain interfaces: SQLite, the embedding providers (local ONNX model and OpenAI API), fsnotify, file extractor.

## Composition Root

Expand All @@ -30,7 +30,7 @@ Six interfaces, each in its own package:
| Interface | Package | Defined in | Implemented by |
|-----------|---------|------------|----------------|
| `Store` | `internal/store` | `iface.go` | `sqlite.go` (SQLite + sqlite-vec) |
| `Embedder` | `internal/embeddings` | `iface.go` | `openai.go` (OpenAI API) |
| `Embedder` | `internal/embeddings` | `iface.go` | `local/` (bundled ONNX model, **default**) and `openai.go` (OpenAI API, opt-in) |
| `Chunker` | `internal/chunker` | `iface.go` | `chunker.go` (tiktoken, cl100k_base) |
| `Watcher` | `internal/watcher` | `iface.go` | `fswatcher.go` (fsnotify) |
| `FileEventHandler` | `internal/watcher` | `iface.go` | `engine.go` (Engine implements OnCreate/OnModify/OnDelete) |
Expand Down Expand Up @@ -63,7 +63,8 @@ tray.go System tray (macOS CGo)
internal/
domain/types.go Shared types: Directory, File, Chunk, SearchResult, IndexStats, ActivityLogEntry
store/ Store interface + SQLite implementation
embeddings/ Embedder interface + OpenAI implementation
embeddings/ Embedder interface + OpenAI implementation (opt-in)
local/ Bundled ONNX local embedder (default provider)
chunker/ Chunker interface + token-based splitter
watcher/ Watcher + FileEventHandler interfaces + fsnotify implementation
extractor/ Extractor interface + multi-format file extraction
Expand Down Expand Up @@ -96,17 +97,36 @@ One binary, two modes controlled by the `--mcp` flag:

Both share the same SQLite database. WAL mode supports concurrent readers with one writer. The GUI writes, the stdio process reads.

## Embedding Model Change Flow
## Embedding Providers

When the user changes the embedding model in Settings:
Two infrastructure implementations satisfy the same `Embedder` interface, so the engine, store, watcher, and MCP layers stay provider-agnostic:

1. `app.SetConfig("embedding_model", newModel)` detects the change
2. Persists the new model to the config table
3. Creates a new embedder via the injected `EmbedderFactory` (so `app.go` never imports concrete embeddings)
4. Calls `engine.SetEmbedder()` to swap it
- **Local (default)** — `internal/embeddings/local/`. A bundled `multilingual-e5-small` ONNX model (384 dimensions) runs in-process on the CPU via ONNX Runtime (`yalue/onnxruntime_go`), with a Hugging Face tokenizer (`daulet/tokenizers`). The session is lazy-initialized on first use; inference tokenizes, runs the model, mean-pools over the attention mask, and L2-normalizes. In stdio (`--mcp`) mode the model loads lazily on the first search so the MCP handshake stays instant. Works fully offline.
- **OpenAI (opt-in)** — `openai.go`. Used only when the user supplies an API key and selects the OpenAI provider in Settings.

`main.go` picks the implementation from the `embedding_provider` config key; the local branch also wires the model-matched tokenizer into the chunker (via `chunker.WithTokenizer`) so chunk boundaries are measured in the model's own tokens.

### Asset Bundling

The native artifacts (ONNX model weights, tokenizer, ONNX Runtime shared library) are **not** committed to git. Instead:

- `assets/manifest.json` (in git) pins each artifact's URL and SHA-256 checksum.
- `make assets` downloads and checksum-verifies them into `assets/embedded/` (gitignored).
- The `localembed`-tagged build embeds them into the binary via `go:embed`; on first use they are extracted (atomically, checksum-verified) to `~/.agent-memory/runtime/` because the ORT library must be `dlopen`-ed from a real file path.

The native-lib-dependent code lives behind the `//go:build localembed` tag, so the default `go build`/`go test ./...` (and CI) stays lib-free and green; the pure-Go embedding pipeline is unit-testable without the tag. `make build` sets `-tags localembed` plus the `CGO_LDFLAGS` to link the tokenizer library.

## Embedding Provider / Model Change Flow

When the user changes the embedding provider or model in Settings:

1. `app.SetConfig` detects the change (`embedding_provider` or `embedding_model`)
2. Persists the new value to the config table
3. Creates a new embedder via the injected `EmbedderFactory` (so `app.go` never imports concrete embeddings) — the factory maps the provider to `local.New(...)` or `NewOpenAIEmbedder(...)`
4. Calls `engine.SetEmbedder()` (and, for a provider switch, swaps the matching chunker/tokenizer) to swap it
5. Calls `engine.Reset()` which stops the watcher, drops and recreates the vector table with the new dimension, and restarts

The same factory pattern applies when the API key changes — the embedder is swapped so new requests use the updated key immediately.
The same factory pattern applies when the OpenAI API key changes — the embedder is swapped so new requests use the updated key immediately.

## Testing

Expand Down
54 changes: 54 additions & 0 deletions Documentation/Bugs/fswatcher-create-event-swallowed-linux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Bug: watcher swallows Create events on Linux (debounce replaces instead of merges)

**Date found:** 2026-07-16
**Found during:** local-embeddings epic, Phase 5 Linux verification (first-ever test run on Linux)
**Status:** Fix submitted — PR #5 (`fix/watcher-create-swallowed-linux`, based on `main`, independent of the epic PR stack)
**Severity:** Low today (no user-visible breakage), latent correctness risk

## Symptom

`go test ./internal/watcher/` fails deterministically on Linux (3/3 runs, Docker
`golang:1.26-bookworm`, arm64):

```
--- FAIL: TestOnCreate (2.01s)
fswatcher_test.go:121: expected OnCreate to be called
```

`TestOnModify` and `TestOnDelete` pass. The full suite is green on macOS.

## Root cause

Writing a new file on Linux (inotify) emits **two events** for the same path within
milliseconds: `CREATE`, then `WRITE`. In `internal/watcher/fswatcher.go`,
`handleEvent` → `debounce(path, fn)` stores **one pending closure per path and
replaces it** on each new event (`fswatcher.go:114-128`): the `WRITE` event cancels
the `CREATE` timer and substitutes a closure that only sees `WRITE`. When the
debounce fires, the handler classifies the event as a modification —
`handler.OnCreate` is never called for newly written files.

macOS (fsnotify kqueue/FSEvents backend) delivers/coalesces these events differently,
so the swallow never manifests there.

## Impact

- **Today: effectively none for users.** `engine.OnCreate` and `engine.OnModify`
are identical (`engine.go:717-747` — both call `IndexFile`), so new files on
Linux are still indexed, merely misclassified as modifications.
- **Latent risk:** any future divergence between create and modify handling
(e.g. create-only bookkeeping, activity-log semantics, per-event UX) silently
breaks on Linux only.
- Blocks a fully green `go test ./...` on Linux (Phase 5 verify gate) until fixed.

## Fix (applied in PR #5)

In the debouncer, **accumulate the fsnotify op bits per path** instead of replacing
the closure — e.g. keep `pendingOps map[string]fsnotify.Op`, OR-ing each event's op;
when the timer fires, classify with precedence Remove/Rename > Create > Write/Chmod
from the merged bits, then clear the entry. Semantics on macOS are unchanged
(single-op case degenerates to today's behavior); Linux create+write merges to
Create. `TestOnCreate` then passes on both platforms.

Fix delivered as its own small PR per working rules (the watcher is outside the
local-embeddings epic's scope): **PR #5**. Verified there: watcher suite 3/3 pass
on Linux arm64 (was 3/3 fail) and 3/3 pass on macOS.
54 changes: 54 additions & 0 deletions Documentation/Bugs/local-embed-token-budget-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Bug: local embedder overflows the model context — every multi-chunk file silently dropped

**Date found:** 2026-07-17
**Found during:** Phase 5 Windows GUI field test (first real-world vault ever indexed)
**Status:** Fixed — branch `fix/local-embed-token-budget` (stacks on the epic PR chain)
**Severity:** Critical for the local provider — affects ALL platforms, latent since Phase 3

## Symptom

Indexing a real vault (109 files, D:\Brain) silently indexed only 26 files. No errors
anywhere user-visible (see the companion visibility bug, PR #8). The 26 survivors were
exactly the files ≤ 1.6 KB; every file ≥ 1.7 KB — i.e., anything needing more than one
chunk — was dropped. All previous platform verifications used tiny single-chunk test
files, so five platforms' smokes all passed while the pipeline was broken for real
workloads everywhere.

## Root cause (the "512 by 516" error)

ONNX Runtime fails hard when a sequence exceeds mE5-small's 512-token context:

```
BroadcastIterator::Append ... Attempting to broadcast an axis by a dimension other than 1. 512 by 516
```

`buildChunker` passed `embedder.MaxInputTokens()` (512) straight through as the chunk
budget. But the embedder prepends the E5 instruction prefix ("passage: ") and the
tokenizer adds special tokens **after** chunking, so a chunk cut at exactly 512 tokens
reached the model at 516. The epic specified the defense precisely ("effective chunk
size = min(chunk_size, MaxInputTokens − prefix − special) → default 480"); the
implementation skipped the reservation.

The unchunked **query path** was also exposed: `EmbedQuery` has no chunker, so a search
query longer than ~500 tokens crashed inference on every platform.

## Fix (two layers)

1. `app.go buildChunker`: chunk budget = `MaxInputTokens() − local.EmbedTokenReserve`
(32 → effective 480, matching the epic).
2. `LocalEmbedder.embedBatch`: defensively truncate any tokenized input to the model
limit, preserving the trailing EOS token — protects queries and any future caller
regardless of chunking correctness.

## Verification

- Unit: truncation table test + fake-session proof the model never receives >512 tokens.
- Integration (permanent): `TestIntegrationLargeDocument` — real pipeline, multi-chunk
document + over-long query. Failed before the fix on macOS AND Windows; passes on both.
- Field: clean re-index of the same vault on Windows → **105 files / 932 chunks, all 5
dossier files present, 79 multi-chunk files, zero errors** (was 26/26/0).

## Lesson recorded

Platform verifications used single-chunk corpora only; a large-document case is now a
permanent integration test, and the Phase 6 checklist gained a real-vault field test.
43 changes: 43 additions & 0 deletions Documentation/Bugs/pdf-extraction-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Bug: PDF "extraction" embeds raw bytes, not text

**Status:** Open · **Severity:** Medium (silent quality loss) · **Reported:** 2026-07-06
**Component:** `internal/extractor` · **Track:** separate from the local-embeddings epic (pre-existing)

## Summary

PDF files are treated as "supported," but their text is never actually extracted.
`extractPDFPassthrough` (`internal/extractor/extractor.go:177`) does `os.ReadFile(path)` and returns
the **raw PDF bytes** as the `Text` to chunk and embed. PDFs are binary (compressed content streams,
xref tables, object dictionaries), so what gets embedded is mostly non-text noise, not the
document's readable content.

## Impact

- Semantic search over PDFs is effectively broken — matches are against binary noise, not content.
- The README claims "PDF — text content extraction," which the code does not do.
- Under the local-embeddings epic, the same garbage would burn **local CPU** instead of API dollars.

## Evidence

`internal/extractor/extractor.go`:

```go
func extractPDFPassthrough(path string) (Result, error) {
data, err := os.ReadFile(path)
if err != nil { return Result{}, err }
return Result{Text: string(data)}, nil // raw PDF bytes, not extracted text
}
```

`.pdf` is registered in `binaryExtractors`, so the file is reported as supported and indexed.

## Proposed fix (separate task)

Replace the passthrough with real PDF text extraction (a pure-Go PDF text library returning
concatenated page text), and add a unit test with a small sample PDF. Keep this **out of the
local-embeddings epic scope** — it is an independent defect.

## Notes

Found during the Phase-0 "get familiar / try to break it" pass. Logged per the bug-report workflow;
not a blocker for the epic.
Loading