# Mohamed Ashraf Elsaed: full text of every post
> Mohamed Ashraf Elsaed is a technical lead in Cairo who builds open-source tools for AI coding agents (Aletheia, Loupe, Claude Kit) and writes about Laravel and Node.js.
---
Source: https://mohamedashrafelsaed.com/blog/a-benchmark-gate-that-catches-small-regressions/
Published: 2026-10-31
# A benchmark gate that catches small regressions
A benchmark you run once is a screenshot. A benchmark that fails a pull request is a guard. This post describes the regression gate in Aletheia: what it measures, how its baseline was set, and what it cannot tell you.
## The gate
`bun bench/gate.mjs` builds a seeded synthetic corpus of 50,000 rows across ten topics, runs 100 queries five times, and compares two numbers with a stored baseline:
- **Recall@5:** whether the right memory is in the top five results. Baseline 0.90.
- **p95 latency:** the time within which 95 percent of queries answer. Baseline about 7.8 ms (7.759 ms), recorded on 2026-10-28 on `linux x64 bun 1.3.10`. Each baseline figure is the median of five measurements.
The check fails if Recall@5 falls by more than 0.01, or if p95 latency rises by more than 20 percent. The latency check is enforced only when the baseline p95 is at least 5 ms.
## Why seeded
Random data gives a different answer each run, so a drop could be noise. A fixed seed makes the corpus identical every time. Any change in the numbers comes from the code.
## Calibrating on the real runner
A baseline set on one machine can fail on another. Two runs on a GitHub runner gave p95 latencies of 7.268 ms and 7.294 ms against the 7.759 ms baseline, about 6 percent below it. The 20 percent margin was chosen, and two runs are too few to prove it is the right width. The baseline file records the machine only as `linux x64 bun 1.3.10`.
## What the gate cannot tell you
- It measures retrieval on synthetic data. It does not measure whether an answer was correct.
- It runs on Linux only in CI.
- It has no trend check: each run is compared with the fixed baseline.
- A synthetic corpus can hide problems that real, messy text would show.
## What I would do differently
I would add a trend check. Against a fixed baseline, a slowdown that creeps up in small steps passes every run until the total passes 20 percent.
---
Source: https://mohamedashrafelsaed.com/blog/aletheia-mem-benefits-and-limits/
Published: 2026-10-31
# What aletheia-mem gives you, and what it does not
Aletheia (npm package `aletheia-mem`) is a free, local memory and rules hub for AI coding tools. This post lists what it gives you, with the numbers behind each claim, and then lists what it does not give you, because the second list matters as much as the first.
## The problem
Every coding agent forgets between sessions, and each one remembers differently. You explain the repository, the deploy rule and the odd naming convention on Monday, and on Tuesday the agent asks again. Switch from one tool to another and the memory does not follow you. Aletheia keeps the memory and the rules once, on your machine, and lets each tool read them in the format it already understands.
## Benefit 1: one store that many tools read
One SQLite file is read over MCP (standard input and HTTP), a REST API and a command line. Thirteen MCP tools cover search, timeline, full text by id, saving a memory, rules and a work-state list. Automatic capture through hooks is built for six tools and exercised end to end against recorded payloads. What has run on a real install is narrower, and the limits section below says exactly how.
The Home page for a demo project. Everything shown is stored in one local file.
## Benefit 2: private by default
- The server binds to loopback (`127.0.0.1`). A different bind turns on a login and prints a warning.
- There is no account and no telemetry. The default model provider is `none`, so extraction is rule-based and makes no network call.
- On POSIX systems the data directory has mode 0700, and the database, spool and key files have mode 0600. A few other files are looser (`alerts.db` is 0644 and the erasure export is 0664), and the 0700 directory keeps other users out.
- Hooks remove secrets and `` blocks before anything is stored, and a project can be excluded with `ALETHEIA_EXCLUDED_PROJECTS`.
- `aletheia erase` deletes on purpose and writes a signed receipt. The receipt proves it was not edited, not that the data is gone from disk: the pre-erase export keeps the erased text, Aletheia does not run `VACUUM`, and freed SQLite pages are not overwritten at once.
Nothing leaves your machine unless you start it: a model provider you configure, sync with a hub you run, an export you share, an alert webhook, or the one-time download of the optional embedding model. Two honest cautions: redaction is pattern based, so it is not complete, and the database is plain SQLite with no encryption at rest, so use disk encryption if you need it.
## Benefit 3: rules written once
A rule is a standing instruction for every tool. You write it once, and a preview-first command writes it into each tool's own file: `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, Cursor and Windsurf rules, Copilot and Aider files. Those writers were tested in temporary folders. No real tool has read a rules file that Aletheia wrote, and the Copilot and Aider writers have not been verified.
## Benefit 4: memory you can read, correct and take away
The Knowledge tab lets you search, edit, pin, hide and export. Dismiss hides a memory without deleting it, restore brings it back, and facts keep their history, so a query can ask what was true on a given date. If a note is wrong after a rewrite, you remove it from every future session in one click.
## Benefit 5: a small, predictable start for each session
The session-start block is an index with one line per memory, cut at 6,000 characters. An agent that needs more calls `search`, `timeline` and `get_observations`. Aletheia estimates the cost in tokens as characters divided by four and says so on the last line of the block. The estimate is not a saving claim: in a tiny demo store the block was larger than the text of the memories it listed (about 194 tokens against about 132), and a real saving needs memories with long narratives.
## Benefit 6: retrieval that is measured, with a published method
I measured retrieval on LoCoMo: the questions of categories 1 to 4 that have gold evidence, 1,536 questions over 10 conversations. Recall@5 is the share of a question's gold evidence turns found in the top five results, averaged over questions. It is retrieval only. Every number below was measured by me, with the commands in `docs/project/benchmarks.md`.
Recall@5, retrieval only. The hybrid rows need the optional embeddings package. The rows with the dataset's own facts use facts written by the benchmark's authors, not facts that Aletheia extracted.
| Run | Recall@5 | MRR | ms per query |
|---|---|---|---|
| claude-mem 15.2.0, raw turns, from its README | 36.0% | 0.285 | p50 1,699 ms, p95 2,824 ms (idle, 40 queries) |
| Aletheia, raw turns, keyword search (BM25, the default) | 55.3% | 0.482 | 2.00 |
| Aletheia, raw turns, hybrid | 57.4% | 0.497 | 3.72 |
| Aletheia hybrid + its own rule-extracted facts | 57.7% | 0.492 | 4.94 |
| Aletheia hybrid + the dataset's facts | 64.8% | 0.591 | 5.53 |
| Aletheia hybrid + the dataset's facts + rerank | 72.3% | 0.680 | 646.19 |
Read it carefully.
- **Compare the claude-mem row with the raw-turns rows only.** They are the only rows where both systems store the same raw text.
- **The claude-mem row has no result file.** It comes from the README of the project. The raw output of that run is not committed and its date is not recorded. It used claude-mem 15.2.0, saving each turn through claude-mem's save endpoint, and newer releases exist that I did not test.
- **The speed columns are not like for like.** Aletheia's figure is the mean time of an in-process ranking call. The claude-mem figures are a median and a 95th percentile for an HTTP request to a worker. They show an order of magnitude, not a ratio.
- **The hybrid rows need the optional embeddings package.** The default search is keyword search, the 55.3% row.
- **The rule extractor barely helps.** Its row is 57.7% with a lower MRR than raw turns (0.492 against 0.497). The dataset's own facts add 7.3 points; Aletheia's extractor does not reproduce that gain.
- **There is a known gap in the evidence.** A local log of the raw-turns run, which git ignores, shows 44.7% for the hybrid search. The committed result file, which the README uses, shows 57.4%. The other logs match their files. The cause is unknown and the run has not been repeated, so 57.4% is the published figure, not a settled one.
At 50,000 synthetic rows, keyword search p95 was 32.7 ms project-scoped and 58.6 ms across all projects (Linux, `bun bench/scale.mjs 50000 20000 --index tuned --queries 200 --seed 1`).
## What it does not give you
- **Answer accuracy.** Recall@5 asks whether the evidence is retrieved. It does not ask whether an answer was right. A script for it exists (`bench/accuracy.mjs`), but no accuracy result from a real model is published.
- **Proof on every tool.** Hooks have run in real sessions of Claude Code (two days of use), Command Code (one short session) and Codex CLI (one interactive session), on one machine for one user. Cursor, Gemini CLI and Windsurf are configuration written from their documentation, not tested on a real install, and no hook has run inside them or inside OpenCode.
- **A tested Windows setup.** Continuous integration runs the tests on Windows, but several checks are skipped there, including end-to-end install and capture, and no real tool was tried.
- **Complete redaction.** It misses secrets that do not look like a known pattern, such as `MY_API_KEY=...`, and it does not run on `POST /v1/memories` or the dashboard note box.
- **Tested team sync.** Sync and remote MCP are self-hosted and were tested on loopback and temporary folders, not across two real machines.
- **Stability.** The project is alpha.
- **Native-reviewed translations.** The ten README translations are machine translated.
## When it is worth using
It fits if you use more than one coding tool, want your memory in a file you can read and move, or cannot send session text to a hosted service. If one tool's built-in memory already covers you, you may not need another layer. If you need hosted team sharing with accounts, Aletheia's team mode is self-hosted and partial.
To try it in ten minutes in a throwaway folder, follow the how-to post for `aletheia-mem`.
## What I checked, and what I did not
The numbers come from the README, `docs/project/benchmarks.md` and the result files in `bench/results/2026-10-26`, which I read on 2026-10-31. The commands for the Aletheia rows are public, so anyone can repeat them. The claude-mem row has no result file, only the README. The token figures come from a run of `aletheia context` with aletheia-mem 0.1.10 in a throwaway folder on 2026-10-30. I did not rerun the LoCoMo benchmark for this post, and I did not measure claude-mem again.
## What I would do differently
1. **Publish an answer-accuracy result before the retrieval table.** A script exists, but no result from a real model is published, and readers hear "accuracy" when they see a percentage.
2. **Measure both sides the same way.** I mix an in-process mean and an HTTP median because that is what each run reported. I would rerun both over HTTP with one statistic, and commit the raw output, so the speed difference needs no footnote.
---
Source: https://mohamedashrafelsaed.com/blog/aletheia-vs-claude-mem-numbers/
Published: 2026-10-31
# Aletheia vs claude-mem: reproducible numbers
claude-mem had about 104,000 stars on 2026-10-29 and, excluding anonymous contributors, 297 contributors. Aletheia had 9 stars (GitHub API, 2026-10-29). If you came here for a winner on community, it is claude-mem, and it is not close. This post compares one narrow thing: retrieval quality on one dataset, with commands so you can repeat it.
## What was measured
The test is LoCoMo, a set of long conversations with questions that have gold evidence. For each of 1,536 questions (LoCoMo categories 1 to 4; category 5 is left out) we ask what share of the gold evidence turns is in the top five results (Recall@5) and how high the first one ranks (MRR). This is **retrieval**. It is not whether an answer was correct. Answer accuracy is not measured for either project.
| Run | Recall@5 | MRR | Time per query |
|---|---|---|---|
| claude-mem 15.2.0, raw turns, from the Aletheia README (no raw file committed, date not recorded) | 36.0% | 0.285 | p50 1,699 ms, p95 2,824 ms (idle, 40 queries) |
| Aletheia, raw turns, keyword search (BM25, the default) | 55.3% | 0.482 | 2.00 ms |
| Aletheia, raw turns, hybrid search (needs the optional embeddings package) | 57.4% | 0.497 | 3.72 ms |
| Aletheia hybrid, with facts it extracted itself (rules) | 57.7% | 0.492 | 4.94 ms |
| Aletheia hybrid, with the dataset's own facts (an experiment, not a feature) | 64.8% | 0.591 | 5.53 ms |
## Read this before you quote it
- **The time columns are not like for like.** Aletheia's figure is the mean of an in-process ranking call and leaves out the time to embed the question. claude-mem's is a median and a 95th percentile for an HTTP request to its worker. The result files record no machine, so do not compare across machines.
- **The rule-based fact extractor adds almost nothing.** It moved Recall@5 from 57.3% to 57.7%. The gain from facts needs a model, and a first run with a 1B local model failed.
- **One dataset.** A different dataset can change the order.
- **The claude-mem row is not backed by a committed raw file.** It comes from the README and is produced by `bench/claude-mem-baseline.mjs`, which saves each turn through claude-mem's save endpoint. Normally claude-mem ingests through its own model-based observer, so raw saves may understate it. The baseline used 15.2.0, and a newer release (15.6.0 on 2026-10-29) was not tested.
- **There is a known gap in the evidence.** A local log of the raw-turns run, which git ignores, shows 44.7% for the hybrid search, while the committed file shows 57.4%. The cause is unknown and the run was not repeated.
## Repeat it
```bash
git clone https://github.com/mohamed-ashraf-elsaed/aletheia-mem
cd aletheia-mem
bun scripts/compare-bench.mjs # prints the Aletheia rows and checks each claim row (not the claude-mem row)
bun bench/gate.mjs # synthetic Recall@5 and p95 latency, checked against bench/baseline.json
```
The LoCoMo dataset is a separate download, with a SHA-256 to check, and the hybrid rows need the optional embeddings package. The steps are on the benchmark page.
## Where claude-mem is ahead
A live viewer, many more integrations and a far larger community. If you want what most people use today, use claude-mem. Aletheia exists for people who want one store shared by several tools, rules synced to each tool's file, and numbers they can re-run.
## What I would do differently
I would have measured answer accuracy first, and I would commit the baseline's raw output so the comparison is more repeatable.
---
Source: https://mohamedashrafelsaed.com/blog/cross-platform-ci-for-a-bun-project/
Published: 2026-10-31
# CI on Linux, macOS and Windows for a Bun project
For its first two days, Aletheia's tests ran on Linux and macOS. A claim of "works on Windows" needs more than hope, so I added a Windows runner. It was red for three runs before it went green. This post lists what broke and what fixed it, in the order it happened.
## What broke
| Symptom | Cause | Fix |
|---|---|---|
| Tests failed removing temporary folders with `EBUSY` | Windows keeps a SQLite file open until the handle is released | One `removeDir` helper, used in every test that cleans a temporary folder. It retries on Windows only, three times, and then gives up quietly |
| A TOML config could not be uninstalled | A Windows path contains backslashes, which TOML escapes, and the uninstall code compared the escaped text with the raw path | Unescape the TOML string before comparing |
| Tests that run hook commands through `sh -c` failed | `sh -c` mangles Windows paths and command lines | Skip those tests on Windows and say so in the README |
| Python helper tests failed | The runner's `python3` was a shim | Prefer `python`, and set `PYTHONUTF8` |
| Three installer tests timed out at 5 seconds | A slow Windows runner | A 20-second default test timeout in `bunfig.toml` |
| A crash test sometimes saw exit code 1 from the writer | The writer sometimes failed to start on a loaded runner (seen once) | Retry the start up to three times and print stderr. This fix came later, on the main branch |
## The honest part
The runners are `ubuntu-latest`, `macos-latest` and `windows-latest`, with Bun 1.3.10. Several test groups are skipped on Windows, mostly those that run hooks through `sh -c`, and no real AI coding tool was tried there. The README says "CI tested" for Windows, not "works", and lists the main skipped checks. A green badge that hides skipped tests is worse than a red one.
## What I would do differently
I would have added the Windows runner on day one. Every problem above is small. Together they cost about an hour of CI rounds on one afternoon, because they surfaced one at a time.
---
Source: https://mohamedashrafelsaed.com/blog/hooks-that-never-block-your-agent/
Published: 2026-10-31
# Hooks designed to fail open for AI coding agents
A memory tool that blocks your coding agent is worse than no memory at all. That one rule shaped Aletheia's hooks: a hook should not stop the tool that called it, whatever goes wrong. The hooks are built to fail open. This post covers what that includes and what it does not.
## The rule
A hook is a command a tool runs at fixed moments. Aletheia's entry point is `aletheia hook `. It reads one JSON payload on standard input. If anything fails, it prints nothing and exits with code 0. The host sees success and carries on. (When it works, the session-start hook does print the context block.)
## What "anything" includes
| Failure | What happens |
|---|---|
| The database is locked by another process | The hook waits up to 50 ms, then writes the item to a spool file and exits 0 |
| The database file is missing or unreadable | The item goes to the spool |
| The payload is not valid JSON or is empty | Nothing is stored, exit 0 |
| The project is excluded by configuration | Nothing is stored, exit 0 |
The spool is a JSON-lines file in the data directory with mode 0600. The next hook that finds the database healthy drains it first. I tested both paths in a temporary home: a store made unavailable on purpose, and a write lock held by another process. In both, the next hook flushed the spool and a search found the rows. The payloads were written by hand, not sent by a real host.
One gap matters. A watchdog ends the hook after 150 ms. If it fires while a write is in flight, that item is not spooled and is lost (the hooks reference says so). I did not trigger it.
## What it costs
In my tests a post-tool hook took 0.04 to 0.05 seconds, and about 0.1 seconds while another process held the write lock. Those are single measurements on one machine, not a benchmark.
## The trade-off
Fail-open means a broken hook is silent. The answer is a check you run on purpose: `aletheia doctor` reports the server, the hooks, the spool and the last capture, and shows a failing spool as a failure. Silence in the hook, noise in the doctor.
## A host-side surprise
Some hosts ask you to approve a hook before it runs. Codex reviews non-managed hooks and skips new or changed ones until you trust them, using its `/hooks` screen. An installed hook that is not trusted yet does nothing, which is easy to mistake for a broken hook.
## What I would do differently
I would have built `doctor` before the hooks. Fail-open without a way to see failures is just hiding them.
---
Source: https://mohamedashrafelsaed.com/blog/system-design-claude-code-quality-gate/
Published: 2026-10-31
# Quality gate for AI-written Laravel: system design
Claude Kit is a Composer package that sets up Claude Code on a Laravel project with an installer command, and puts the same checks (PHPStan, Pint and Pest) at commit time, at the end of every Claude turn and in CI. The first two call one script; the generated CI runs the same tools directly. This post explains how the package is designed and where that sentence stops being true.
The project banner. Declared requirements: PHP 8.2 or newer and Laravel 11, 12 or 13. The package's own CI covers PHP 8.2 to 8.4 and Laravel 11 and 12; Laravel 13 is not in it.
## The problem
An AI coding agent can finish a turn with code that does not pass your own checks, and a team can hand-copy configuration between repositories until the copies drift apart. The design answers both: define the bar in one place, and run it wherever code can leave your control.
## Principle 1: one script behind the hooks, the same tools in CI
The two local hooks call quality-checks.sh. The generated CI workflows run the same tools directly, not through the script.
`runtime/quality-checks.sh` is the single source of truth for the local gate. It runs:
- **Pint** in test mode, when `pint.json` exists.
- **PHPStan**, when `phpstan.neon` exists, at the level you chose, with zero errors allowed.
- **Your test runner**, as recorded in `.claude/claude-kit.json`. For Pest with a coverage driver, it adds `--coverage --min=`. Without `pcov` or Xdebug the suite still runs and coverage is warned about, not enforced.
- **Frontend checks**, only for the scripts your `package.json` defines: `lint:check`, `format:check` and `types:check`.
The git pre-commit hook and Claude's Stop hook both call the script, so the bar is defined once for those two. CI is a separate path: the generated workflows run `composer lint:check`, `composer types:check`, `php artisan test --coverage --min=80` and the frontend checks themselves. The tools are the same, and the thresholds are written in the workflow files, so a change to your manifest does not change CI by itself.
`CLAUDE_KIT_CHECKS` selects a subset of the PHP checks (`pint`, `phpstan` and `tests`). The frontend checks always run, and the Stop hook uses the checks listed in `hooks.checks` in the manifest.
| Where | File | What it blocks |
|---|---|---|
| Commit time | `.githooks/pre-commit` | a bad `git commit` |
| End of a Claude turn | the Stop hook in `.claude/settings.json` | Claude finishing the turn |
| CI | `.github/workflows/*` | a bad merge (same tools, run directly) |
| Each edit | a PostToolUse hook that runs PHPStan on the edited file | nothing; see below |
## Principle 2: the agent has to pass, more than once
The Stop hook requires `hooks.min_passes` consecutive clean runs, between 1 and 5 and 2 by default, before a turn may finish. Any failure stops the loop immediately, exits with code 2 and feeds the report back to Claude to fix. The reason to ask for more than one clean run is that a fix can introduce a new failure.
The Stop hook also carries a feature-doc gate: a change under `app/`, `database/`, `routes/` or `resources/js/` must come with an added or updated document under `features//`. That keeps documents in step with code. It can be turned off with `CLAUDE_KIT_FEATURE_DOCS=0`.
The optional edit-time hook is honest about what it cannot do. A PostToolUse hook runs after the tool has run, so it cannot prevent or revert the edit. That is a Claude Code guarantee, not a Claude Kit choice. The mode only decides who hears about a failure: in block mode the errors go back to Claude with exit code 2, and in warn mode you see a warning and Claude is not interrupted. The Stop hook is what actually blocks a turn.
## Principle 3: machinery is referenced, content is copied
The rule: machinery goes in runtime/, content and opinion go in stubs/.
Everything the package ships falls into one of two buckets.
- **`runtime/` is referenced.** The host project calls it in place under `vendor/`, so a `composer update` fixes every project that uses it. It is treated as a public API, because the host's Stop hook points at those paths.
- **`stubs/` is copied.** `CLAUDE.md`, the rules, the skills, the agents, the feature templates and the linter configurations are written into your repository, so you can edit them freely.
`php artisan claude-kit:update` refreshes the copied content. A manifest records a hash for each file it published, so an untouched file is updated silently, and a file you edited is not replaced without asking, unless you pass `--force`. The edit-time PHPStan hook is the exception to "machinery is referenced": it is copied into your project, because it is rendered per project.
## Principle 4: do not clobber
Two write paths make the promise concrete.
| Path | Used for | If the file exists |
|---|---|---|
| `put` | Rules, skills, agents, configurations, hooks, templates | Identical: left alone. Matches its published hash: updated. Otherwise: ask (keep, replace or show a diff) |
| `putMerged` | `.claude/settings.json`, `CLAUDE.md`, `phpunit.xml` | Never asks: the new content already includes what was there |
`install --force` and `update --force` replace files without asking. `composer.json` and `package.json` are merged and keep your entries. The part of `CLAUDE.md` that Claude Kit owns sits between `claude-kit:begin` and `claude-kit:end` markers, so re-running refreshes it without touching your own additions.
## Principle 5: the installer is a configurator, and it is testable
The command layer owns every prompt. The installer and the file writer never prompt: they receive a resolver closure and call it when a file exists and differs. That separation keeps the scaffolder testable against a temporary directory. The choices become a read-only options object, and the same object can be rebuilt from the manifest for an update. Per-stack differences live mostly in one place, an enum for the frontend stack. Adding a stack takes an enum entry, a stub folder, a rule file and a case in the stack detector.
The installer detects your stack (Inertia with Vue or React, Livewire, Blade or API-only), asks what to install and works out the exact tooling your answers need. It offers to run the `composer require --dev` command and prints it otherwise. It never moves a version you already have and never adds `-W` by itself.
## Principle 6: generated architecture tests that actually run
Choose layers such as services, repositories, actions or contracts, and Claude Kit generates `tests/Arch/ArchTest.php` from per-layer fragments. It then patches your `phpunit.xml` to add a `tests/Arch` test suite, because a generated test that no suite runs protects nothing. A layer you decline gets no rule, no architecture test and no mention in `CLAUDE.md`.
## Trade-offs
- **The conventions are opinionated defaults.** The README says to read them and adapt them to your team.
- **Hooks are only as strong as the host.** The local gate relies on Claude Code honoring a Stop hook's exit code, and CI is the backstop. The two are defined separately, so keep their thresholds in step.
- **A Pest conflict on Laravel 13.** A check run for this post on 2026-10-31 installed a fresh Laravel 13 skeleton, which resolved PHPUnit 12.5.42. `composer require --dev pestphp/pest` failed there, even without Claude Kit. With `-W`, Composer settled on Pest 4.7.8 and PHPUnit 12.5.33. That downgrade is your decision, and Claude Kit does not make it for you.
- **Escape hatches exist.** `git commit --no-verify` bypasses the local gate. To lower the coverage floor, change `tests.coverage_min` in `.claude/claude-kit.json` (`null` means do not enforce). The `CLAUDE_KIT_MIN_COVERAGE` variable is only a fallback for installs that predate the manifest. CI still enforces its own thresholds.
## What I checked, and what I did not
On 2026-10-31 I read the README, `docs/Architecture.md`, `docs/Quality-Gate.md`, `docs/Configuration.md`, the changelog, the generated workflow files and the layout of the repository. A reviewer of this post also installed a fresh Laravel 13 application and tried Pest on it, which is where the Pest figures above come from. The repository runs its own gate through a committed pre-commit hook, including a changelog rule, and the documentation says coverage is enforced at 80% or more in CI.
I did not run the Claude Kit installer against a fresh Laravel application for this post, and I did not run `quality-checks.sh`. The behavior described here comes from the project's documentation and files. A reader who wants proof should run the commands in the README on a throwaway project. This post says nothing about other coding agents, because I tested none.
## What I would do differently
1. **Say the Pest conflict first.** The installer already explains it after Composer fails (`explainPestConflict`). I would say it before offering the command, so nobody reads a Composer error first.
2. **Make CI read the manifest.** Today the workflows carry their own thresholds. Reading `.claude/claude-kit.json` would make the three places agree without anyone remembering to keep them in step.
---
Source: https://mohamedashrafelsaed.com/blog/system-design-local-first-agent-memory/
Published: 2026-10-31
# Local-first memory for AI agents: system design
Aletheia (npm package `aletheia-mem`) is a local-first memory store for AI coding agents. This post explains how it is designed: what runs where, which choices keep it from ever blocking your tool, and where the design stops short.
## Four constraints
Four constraints drove every choice below.
1. A hook runs in the host tool's hot path, so it must never block that tool.
2. Memory can hold secrets, so nothing is stored before it is redacted.
3. Memory has to serve several tools, so the store is tool-neutral.
4. A new session receives context every time, and it costs tokens every time, so the context must be small and predictable.
## The pipeline in one picture
Capture, compress and recall run in three different places. Only the middle one needs the server.
Capture runs in a short-lived `aletheia hook ` process that the host starts. Compression runs in a background worker inside `aletheia-server`, or when you run `aletheia compress`. Recall runs in the SessionStart hook, in the MCP server or in `aletheia context`. The hook opens `hub.db` itself and does not call the server, so memory capture works while the server is stopped.
## Choice 1: the hook writes to SQLite, not to a server
A server in the capture path would add a failure mode to every prompt: if it is down, slow or starting, the hook has to wait or fail. Writing straight to SQLite removes that hop. The cost is that several short-lived processes can write to one file at once, so the hook waits at most 50 ms for a lock. The store itself opens with a 3,000 ms busy timeout (`src/hub/store.mjs`), and the hook lowers it to 50 ms after opening (`PRAGMA busy_timeout = 50`, `src/hub/hooks/run.mjs`). When the database is not available in time, the hook falls back to a spool.
## Choice 2: fail open, then spool
The rules for a hook are short:
- It always exits 0. Any internal error is swallowed.
- A watchdog ends the process after 150 ms (`setTimeout(() => process.exit(0), 150)`, `bin/aletheia.mjs`). It is a timer, so it can only fire while the event loop is free: it cannot interrupt a synchronous read or SQLite call.
- It makes no network call and no model call.
- If it cannot open or write the database, it appends the already-redacted item to `spool/hook-spool.jsonl` in the data directory, with file mode 0600. The next hook that reaches the database replays the spool first.
A hook pointed at a database that does not exist. It exits 0 and the item waits in the spool. Run with aletheia-mem 0.1.10 in a throwaway folder on 2026-10-30; three one-time permission notices on stderr are not shown.
During an outage the effect is delay, not loss, for items that reach the spool: the memory appears in search after the next hook that reaches a healthy database. If the watchdog fires while a write is in flight, that item is lost. The reference documentation says so, and I did not trigger it in a test.
## Choice 3: redact in a plain function, before the write
Redaction is a pure function (`src/hub/redact.mjs`) that runs in a fixed order:
1. Text between `` and `` becomes `[private]`.
2. Known secret shapes become a marker such as `[redacted:github-token]`: Anthropic, OpenAI-style and Stripe keys, GitLab, npm and GitHub tokens, JSON Web Tokens, AWS key ids, Slack tokens, Google API keys and PEM private keys.
3. Passwords inside URLs, `Authorization: Bearer` values of 12 or more characters, and `password`, `passwd`, `secret`, `token` or `api_key` assignments with a value of six or more characters become `[redacted]`.
The three groups are what matter. In the code the URL-password rule runs before the GitHub and AWS rules.
A fake key and a private block, stored as markers. Same run as above.
I chose patterns over a classifier because a pattern is predictable, fast and testable inside a 150 ms budget. The price is coverage. The documentation lists what slips through:
- A secret under a JSON key, because the quote sits between the key and the colon.
- Variable names with a prefix, such as `MY_API_KEY=` or `DB_PASSWORD=`.
- Values shorter than six characters, personal data, and an unclosed `` tag.
Redaction also does not run on every write path. Hooks, `aletheia save`, `aletheia capture`, `POST /v1/events`, the MCP `save_memory` tool, transcript import and `aletheia watch` redact. The REST route `POST /v1/memories`, the dashboard note box, edits, bundle imports and the claude-mem import store text exactly as sent. In a test on 2026-10-29 the first two stored a fake key unchanged.
## Choice 4: compress later, in a queue with leases
Raw prompts and tool events are cheap and noisy. Turning a finished session into facts and a summary is slower, so it never runs inside a hook.
1. The `Stop` hook adds the session to a small queue and extracts nothing.
2. The worker in `aletheia-server` ticks every 2 seconds and turns each entry into a job row of type `extract_session`.
3. It claims one job at a time with a 60 second lease (`LEASE_MS = 60_000`). If the process dies, the lease expires and the job is claimable again. The job lives in a row, so a killed process loses nothing.
4. A failed job retries with exponential backoff for at most five attempts (`MAX_ATTEMPTS = 5`). The waits are 1, 2, 4 and 8 seconds. The formula caps at 60 seconds, which five attempts never reach.
5. A job reads the `summary` and `note` memories that were not extracted yet, splits them into windows of 20 items and 12,000 characters by default, extracts facts, builds a session summary and a project summary, and merges near-duplicates at a similarity of 0.92 or higher.
The default extractor is rule-based and makes no network call. A model provider is optional. One negative result is worth knowing: a first local extraction run with a 1B model returned HTTP 500 after 4 minutes 41 seconds and produced no facts. The rule extractor added 1,672 facts and moved Recall@5 from 57.3% to 57.7%, while the benchmark's own facts added 7.3 points. The rule extractor's move is about 0.4 points (57.3% to 57.7% in the README text; its own table shows the baseline as 57.4%). Source: README, run of 2026-10-26.
## Choice 5: recall is a bounded, labelled index, not a dump
The block a new session receives, from four demo memories and one rule.
Each section has a cap: up to 50 pinned memories, 20 recent ones by default (100 at most), up to five recent session requests, up to ten active facts and every enabled rule that is not limited to other tools. By default (`context.facts` set to `auto`) the facts that qualify exclude rule-extracted facts, which are stored with a confidence of 0.45. A title is cut at 100 characters and the whole block is cut at 6,000 characters, from the end, so a long block loses its legend, hint and economics line first.
Three design details matter more than the caps.
- **Progressive disclosure.** The block is an index of one line per memory. The legend and the fetch hint teach the agent the pattern `search`, then `timeline`, then `get_observations`, so full text is fetched only when needed.
- **Data, not instructions.** The block sits inside `` tags with a notice that the content is data. A copy of the tag inside a stored memory is made inert, so a stored note cannot close the block early.
- **An honest cost line.** Aletheia estimates tokens as characters divided by four, and says so. In the demo above the block is about 194 tokens against about 132 for the text of the listed items, so it is larger. A real saving needs memories with long narratives.
## Choice 6: search is keyword search plus facts, merged by rank
Search ranking in the CLI. The REST and MCP routes do not fuse the two lists; REST returns them as separate arrays.
A query becomes content words. A word found in more than 20% of all memories is dropped when a rarer word remains, and at most 12 of the rarest words are kept. SQLite FTS5 ranks matches with BM25 over title, subtitle, narrative, facts and concepts, with the tokenizer `porter unicode61 remove_diacritics 2`, so "retries" matches "retry". Facts have their own index and come back newest first. The CLI merges the two lists with reciprocal rank fusion, weight 1 for facts and 0.9 for memories (`FUSION` in `src/hub/search.mjs`, where the memory weight is named `episodeWeight`). Filters apply before ranking, and recency, pins and importance scores do not change the order of memories. Semantic search with a local embedding model is optional, off by default and available in the CLI only.
## The data model in one table
| Layer | What it holds |
|---|---|
| Memories | One record each: prompt, tool event, summary, note or observation, with project, type and a content hash |
| Derived | Episodes, observations, facts (subject, predicate, object, dates), entities and generated summaries |
| Rules | Standing instructions, with scope and a recheck date |
Every row has a project and a scope, `project` by default or `global` after promotion. Dismiss hides a row without deleting it, and `aletheia erase` deletes on purpose and gives a signed receipt.
## What fails, and what happens
| Failure | Result |
|---|---|
| Database busy or missing during a hook | The redacted item goes to the spool; the next hook replays it |
| Payload empty or not valid JSON | No memory is stored and the hook exits 0. A Stop or SessionEnd still queues an entry for project `default`. |
| Watchdog fires while a write is in flight | That item is lost (documented, not tested) |
| Extraction provider errors | The job retries with backoff, at most five attempts |
| Worker process killed mid-job | The lease expires after 60 s and the job is claimed again |
| A stored note contains the closing context tag | The tag is made inert |
| A secret that matches no pattern | It is stored as typed. This is a known gap. |
## Numbers
All numbers are measured by the author and carry their source.
- At 50,000 synthetic rows, keyword search p95 was 32.7 ms project-scoped and 58.6 ms across all projects (Linux, 12 cores, 2026-10-27, `bun bench/scale.mjs 50000 20000 --index tuned --queries 200 --seed 1`).
- On the LoCoMo questions, raw turns reached Recall@5 of 57.4% at 3.72 ms per query. That row is the hybrid search (keywords plus embeddings), not the keyword-only search described above. Keyword-only was 55.3% at 2.00 ms. One log file shows 44.7% for raw turns, and the benchmark documentation says the cause is unresolved. All of it is retrieval, not answer accuracy.
## What I checked, and what I did not
I read the constants in this post in the code on 2026-10-31: the 150 ms watchdog in `bin/aletheia.mjs`, the 50 ms busy timeout in `src/hub/hooks/run.mjs`, the lease, the attempt limit and the backoff in `src/hub/queue.mjs`, the 0.92 merge threshold in `src/hub/dedupe.mjs`, the fusion weights in `src/hub/search.mjs` and the 6,000-character default in `src/hub/store.mjs`. The terminal outputs ran with aletheia-mem 0.1.10 in a throwaway folder on 2026-10-30.
I did not trigger the watchdog mid-write, so "that item is lost" is the documentation's statement, not mine. Hooks have run in real sessions of Claude Code, Command Code and Codex CLI, on one machine and for one user; no hook has run inside Cursor, Gemini CLI, Windsurf or OpenCode. Answer accuracy is not measured. Sync across two real machines is not tested.
## What I would do differently
1. **Redact on every write path from the start.** Today the REST route, the dashboard note box, edits and imports store text as sent.
2. **Make the settings match the code.** The Settings screen lists "Redact before storing" and "Excluded projects", and the capture code reads neither value. Redaction is always on in its paths, and exclusion works through `ALETHEIA_EXCLUDED_PROJECTS`. I would remove the two controls or wire them, so the screen cannot say something the code does not do.
3. **Fuse results the same way everywhere.** The CLI fuses facts and memories; REST and MCP do not. One ranking would be easier to reason about.
---
Source: https://mohamedashrafelsaed.com/blog/system-design-loupe-re-anchoring/
Published: 2026-10-31
# Loupe system design: pins that survive redeploys
Loupe is a visual feedback tool: a person pins a comment to an element of a live page, and an AI agent reads it over MCP. The hard part of its design is that a pin has to survive a redeploy that changes the markup, and that an agent has to be able to act on a comment without ever deciding that the work is finished.
## The shape of the system
The widget talks to an API; an MCP server gives an agent the same comments; a person resolves them.
| Component | Runtime | Role |
|---|---|---|
| `@loupekit/sdk` | Browser | The widget, in a Shadow DOM, on any page |
| `@loupekit/server` | Node 24 | HTTP API, embedded Postgres (PGlite) or a real one, blobs on disk |
| `@loupekit/dashboard` | Browser | The five-stage board |
| `@loupekit/mcp` | Node (24 documented) | A stdio MCP server with 19 tools, plus a local bridge |
| `@loupekit/extension` | Browser, Manifest V3 | The same SDK core, for sites you cannot change |
| `loupekit/laravel` | PHP 8.2+ | The API, dashboard and a 4-tool MCP server inside a Laravel app |
Locally one Node process runs the API, the database, the blob storage and the static hosting for the dashboard, demo and SDK bundle. A Laravel app can replace that process. Loupe Hub, a separate server you host, is optional and routes tickets between apps.
## A comment's journey
1. **Capture.** The SDK records an anchor, the element's outer HTML (up to 6,000 characters), selected computed styles and a screenshot.
2. **Storage adapter.** The widget calls a `StorageAdapter`. The HTTP adapter uploads the screenshot to `POST /v1/blobs` first and posts the comment with the returned URL. If the upload fails it keeps the inline data. An offline adapter writes to `localStorage` instead.
3. **API.** The server checks identity, normalizes the URL and upserts the comment by its client-generated id.
4. **Dashboard and MCP.** People triage on the board. An agent reads the same comments through the MCP server.
5. **Proposal or pull request.** The agent writes back a proposal (HTML, CSS and notes shown as a before-and-after preview) or a pull-request record.
6. **Review.** The agent moves the thread to In Review, and the widget and the board flag it as needing a person.
7. **Resolve.** A person resolves it.
Three credentials keep the paths apart. The widget sends a user identity signed with HMAC (`X-Loupe-User` and `X-Loupe-Hmac`). A Laravel app uses its own session and a CSRF token. The MCP server and the dashboard use the project secret in `X-Loupe-Admin`.
## The anchor: a fingerprint, not a selector
A CSS selector breaks the first time a class name changes. Loupe stores several signals about the element, called the anchor, and resolves it again on every load and every DOM change.
| Field | Content |
|---|---|
| `tag` | Lowercase tag name |
| `cssPath` | A CSS path rooted at the nearest stable id or test id |
| `xpath` | An XPath to the element |
| `testid` | `data-testid`, `data-test` or a stable `id` |
| `text` | Normalized text, up to 120 characters |
| `attrs` | `role`, `aria-label`, `name`, `type`, `alt`, `href`, `placeholder`, `title`, each up to 200 characters |
| `nthOfType` | Position among siblings of the same tag |
| `rect`, `viewport` | Document position and size, and the viewport at capture time |
## Resolving it again
The resolve order. The constants are in packages/sdk/src/fingerprint.ts.
The order is deliberate: strongest signals first.
1. A unique test id or id wins at a score of 0.98.
2. A CSS path rooted at a stable id, with one match and the same tag, wins at 0.9. This survives changed text.
3. Otherwise the elements selected by the CSS path and the XPath, and every element with the same tag, are candidates, and elements with matching text are added until there are more than 4,000. Each is scored by weighted similarity: text 0.34, attributes 0.22, test id 0.22, CSS path 0.2, tag 0.12, position 0.1.
4. If the best score is at least 0.5, the pin attaches to that element. Below 0.5 the pin detaches and the list shows a "moved" badge.
The threshold encodes a product decision: the documentation says a detached pin is safer than a confident pin on the wrong element. A confident wrong pin sends a developer to fix the wrong thing, while a detached one asks a person to look. The threshold lowers the chance of a wrong pin; a hit at 0.9 or 0.98, or a score above 0.5, can still be wrong.
Two filters keep the candidates honest. An id that looks framework-generated is ignored: longer than 40 characters, starting with `ember`, `react`, `radix`, `headlessui` or `:r`, or containing `:`. Hidden elements are skipped when the browser supports `checkVisibility`.
A pin on the demo page before a simulated redeploy. These two images come from Loupe's documentation and show an older, bottom-toolbar version of the widget; the count badge reads 1 here and 2 below.The same pin after the markup changed. The demo page has a Simulate redeploy button for this.
A region comment anchors to the smallest ancestor that covers at least 60% of the region, walking up from the element under the region's centre. `body` and `html` never qualify. The region also stores its rectangle as fractions of that anchor, so it can be placed again after a responsive reflow.
## The lifecycle, and who may move it
Five stages, so "not triaged", "waiting on me" and "waiting on a preview" can be told apart.
The rule is simple to state: an agent may move a comment to In Review, and only a person resolves it. The Node MCP tools carry it. `mark_thread_addressed`, the tool built for the handoff, has no status argument and always sets In Review. `update_status` states the same rule in its description and, since 0.14.1, refuses `resolved` without changing the comment.
Two caveats. Loupe's own tutorial says this is an instruction to the agent, not a permission check. And the Laravel package's `update-status` tool does not refuse `resolved`: it accepts it and saves it. In the Node server's PATCH handler I found no rule on status (I read the code and did not run it).
The widget also decides when a person has to act (`needsYou()`), in this order:
1. A resolved thread never needs anyone.
2. A failed agent run: "The agent's run failed".
3. The agent's last message ends in a question mark: "The agent asked you something".
4. The thread is In Review: "Waiting on your review".
A failure outranks a question, and both outrank a routine review.
## What redaction covers
Elements marked `data-loupe-redact` are left out of the screenshots that Loupe's built-in capture takes. That is images only: the anchor text and the element's outer HTML (up to 6,000 characters) can still contain the content, and a host that overrides capture has to do its own redaction. Screen recordings capture the real pixels, redacted elements included, and the documentation says so. The Node server and the dashboard run on your machine or on a host you choose, and the comments live in a database you control.
## Trade-offs
- **Best results need a stable handle.** Give important elements a `data-testid`. Without one, a pin depends on the tag, CSS path, text, attributes and position, which the documentation treats as weaker signals.
- **A detached pin costs a person's attention.** That is the price of lowering the chance of pinning to the wrong element.
- **The rule that only a person resolves lives in the Node MCP tools,** not in the Node server's status handler, and not in Laravel's `update-status` tool.
## What I checked, and what I did not
I read the anchor constants in `packages/sdk/src/fingerprint.ts` on a clone of Loupe 0.14.2 on 2026-10-31: the 0.98 and 0.9 scores, the 0.5 acceptance threshold and the way a unique test id and a CSS path are tried first. The architecture document describes 0.14.1, so the tool names and stage rules here come from that document. I started the local server from that clone and loaded the demo page and the dashboard.
I read the status handler and the Laravel tool in the source and did not run them. I did not run the Hub's two-way sync, the Laravel package or the browser extension for this post, and I did not test re-anchoring on a real redeploy of my own site. The before and after images come from Loupe's own documentation.
## What I would do differently
1. **Enforce the review rule on the server as well.** Today the Node tools carry it, and the tutorial calls it an instruction to the agent, not a permission check. Laravel's tool accepts `resolved` today.
2. **Tell teams about `data-testid` before the first comment.** The scoring is designed to survive change, but a stable handle is the strongest signal, and it costs a team one attribute.
---
Source: https://mohamedashrafelsaed.com/blog/use-aletheia-mem-memory-and-rules/
Published: 2026-10-31
# How to use aletheia-mem: memory for AI agents
aletheia-mem is the npm package for Aletheia, a free, local memory and rules hub for AI coding tools. It keeps memories in one SQLite file on your machine. Tools that speak MCP, and scripts that call the REST API or the command line, can read them; what has been checked on a real install is listed at the end. This tutorial takes a few minutes (I did not time it) and ends with a real look at what a new coding session would receive.
## Before you start
Aletheia needs Bun 1.1 or newer. The command line runs on Bun, not on Node. There is no account and no telemetry, and by default the server listens on `127.0.0.1` only.
```bash
npm install -g aletheia-mem
```
To try everything without touching your real setup, point Aletheia at throwaway folders first. Do this in each terminal you use:
```bash
export HOME=$(mktemp -d) ALETHEIA_DATA_DIR=$(mktemp -d) ALETHEIA_PORT=37999
```
Skip that line when you want to keep what you save. Nothing in this tutorial writes into a tool's configuration unless a step says `--write`.
I ran every command below with aletheia-mem 0.1.10 on 2026-10-30, inside throwaway folders. The memories are made-up demo data for an imaginary shop project.
## Step 1: start the server
```bash
aletheia-server
```
```text
aletheia-mem listening on http://localhost:37999
source db : ~/.claude-mem/claude-mem.db (read-only)
data dir : ~/.aletheia
refresh : off (set ALETHEIA_PROVIDER=claude-cli|openai to enable)
extraction: worker on (rule-based)
```
The paths are shortened. Your data folder differs if you used the throwaway line above.
The default port is 37790. Open the address in a browser. The first screen is **Home**: pick a project and see, in plain words, what Aletheia knows about it, the rules that apply and the tools connected.
Home for the demo project shop. The five tabs are Home, Activity, Knowledge, Rules and Connections.
The `aletheia` command also works while the server is stopped. It opens the same SQLite file.
## Step 2: save memories
```bash
aletheia save "The deploy script needs Node 20.19.6 on the staging server" --project shop --pin
aletheia save "We chose SQLite over Postgres because the hub must run with no extra service" --project shop --type decision
aletheia save "Webhook retries use exponential backoff, at most 5 attempts" --project shop --type gotcha
aletheia save "Fixed the login redirect loop when the session cookie was missing" --project shop --type bugfix
```
`--project` names the project, `--pin` keeps a memory at the top of session context, and `--type` labels it (`decision`, `gotcha`, `bugfix`, `note` and others).
Four memories saved, one of them pinned.
## Step 3: search
```bash
aletheia search "retry" --project shop --explain
```
Each result line is the memory id, the date, the type, the title, then the project and the tool that wrote it. `--explain` prints which search channels ran. Out of the box this is keyword search; semantic search is optional.
A search with --explain, then a rule added and listed.
## Step 4: see secrets removed before storing
Hooks redact secrets and `` blocks before anything is stored. You can test it by feeding a hook a recorded payload that carries a fake key. The hook takes the project name from the folder it runs in, so run it from a folder named `shop`:
```bash
mkdir -p shop && cd shop && git init -q
printf '%s' '{"hook_event_name":"UserPromptSubmit","session_id":"s1","cwd":"'$PWD'","prompt":"Deploy to staging with token sk-abcdefghijklmnopqrstuvwx1234 and customer list: Acme then run the tests"}' \
| aletheia hook claude-code UserPromptSubmit
aletheia search "staging" --project shop
```
The hook exits 0, and the stored text holds markers, not the key or the private block.
Redaction is pattern based, so it is not complete. It also does not run when you write through the REST route `POST /v1/memories` or the dashboard note box. Do not paste secrets into those two.
## Step 5: add a rule
A rule is a standing instruction for every tool. Write it once:
```bash
aletheia rules add "Run the tests before saying a task is done"
aletheia rules list
```
The **Rules** tab shows the same rules, with Confirm, Turn off, Edit and Replace. A separate step writes your rules into each tool's own file, such as `CLAUDE.md` or `AGENTS.md`, and it starts as a preview.
The Rules tab. The screenshot also shows two extra demo rules that are not in the commands above.
## Step 6: see what a new session receives
This is the block a coding tool gets when a session starts in the `shop` project:
```bash
aletheia context --project shop
```
The pinned memory comes first, then recent ones, then the rule. The block is wrapped in aletheia-memory tags with a note that its content is data, not instructions.
The last line is an estimate (characters divided by four). With four short memories the block is larger than the memories it lists, because the legend and the hint cost tokens. A real saving needs longer notes.
## Step 7: preview the connection to a tool
Connecting a real tool changes files, so the default is a dry run:
```bash
aletheia install claude-code
```
Three files would change. Paths are shortened to ~ and the throwaway data folder.
It lists the MCP entry in `~/.claude.json`, the hooks in `~/.claude/settings.json` and the rules in `~/.claude/CLAUDE.md`. Each changed file gets a timestamped backup. To apply it, run `aletheia install claude-code --write` with your real home folder. To undo it, run `aletheia uninstall claude-code --write`. The documentation says uninstall refuses if a file changed after install (it then needs `--force`) and has already cleaned the other files, so check the output.
## Look around the dashboard
The **Knowledge** tab is where you search, edit, pin, hide and export what Aletheia stored.
Knowledge, with filters and one card per memory.
## Where agents use it
Over MCP an agent gets 13 tools. The pattern the context block teaches is `search` for an index, `timeline` for the neighbours and `get_observations` for the full text. `save_memory`, `add_rule` and `session_start_context` are there too.
## What I checked, and what I did not
I ran the commands, except the two `--write` commands, and took the terminal outputs and dashboard screenshots myself, with aletheia-mem 0.1.10 on 2026-10-30, using a throwaway home folder and data folder so no real memory was touched. The connection step was a dry run. I did not run `aletheia uninstall`.
Status as the project states it: the MCP configuration was listed by the real command line of Claude Code, Command Code, Codex CLI and OpenCode on 2026-10-27. Hooks have run in real sessions of Claude Code (two days of use), Command Code (one short session) and Codex CLI (one interactive session), for one user on one machine. Cursor, Gemini CLI and Windsurf are configuration written from their documentation, not tested on a real install, and no hook has run inside them or inside OpenCode. Windows is tested in continuous integration only. The status is alpha, and answer accuracy is not measured.
## What I would do differently
1. **Redact on every write path.** I would not have to write the warning in step 4 if the REST route and the dashboard note box redacted too.
2. **Put the throwaway-folder line at the top of the README.** It is the safest way to try the tool, and today it sits in the quickstart instead of the first screen people read.
---
Source: https://mohamedashrafelsaed.com/blog/use-loupe-with-claude-code/
Published: 2026-10-31
# How to use Loupe: pin feedback for Claude Code
Loupe is an open-source visual feedback tool: you pin comments to elements of a live web page, and, according to Loupe's documentation, an AI agent such as Claude Code can pick them up over MCP (Model Context Protocol, an open-source standard for connecting AI applications to external systems). This post follows Loupe's own tutorial: a comment on a page, a card on a board, and the steps to let an agent read it. The section "What I checked" says exactly which steps I ran.
A Loupe comment thread on a demo page: the comment, its screenshot, a reply with a mention and a reaction.
## What you can do with it
- **Pin feedback where it belongs.** Comment on one element, drag a region, or leave a free note. Loupe tries to find the same element again after a redeploy; below a score of 0.5 the pin detaches and shows a "moved" badge.
- **Capture what you see.** Attach a screenshot or a short screen recording. Elements marked `data-loupe-redact` are left out of the screenshots Loupe's built-in capture takes (images only). Screen recordings capture the real pixels, redacted elements included.
- **Discuss in threads.** Reply under each comment, mention teammates with `@`, and react with an emoji.
- **Triage on a five-stage board.** Queue, To Do, In Progress, In Review and Resolved.
- **Hand the backlog to an agent.** The `@loupekit/mcp` server gives Claude Code 19 tools, including `list_comments`, `get_comment`, `propose_change` and `update_status`.
## Choose how to install
| Integration | Use it when |
|---|---|
| Script tag | You want the widget on any page without a build step |
| npm package | Your app uses a bundler (the guides show React and Vue 3 examples) |
| Laravel (`loupekit/laravel`) | Your app is built on Laravel 11, 12 or 13 (the declared Composer constraint, not tested here) |
| Browser extension | You want to comment on a site you cannot change |
| MCP clients | You want Claude Code or another MCP client to work through the comments |
| Local server and dashboard | You want the Node API and the board on your machine |
The rest of this post uses the local server, because it is the path Loupe's tutorial takes.
## Step 1: run the local server
You need Node 24 (the documented version), npm and git.
```bash
git clone https://github.com/mohamed-ashraf-elsaed/loupe.git
cd loupe
npm install
npm run build
npm run seed
npm start
```
`npm run seed` creates a demo project and prints its keys:
```text
Seeded project: pk_demo_acme
admin key (dashboard ?key= / X-Loupe-Admin): sk_demo_acme_0f3b9c
```
`npm start` prints the addresses and keeps running:
```text
[loupe] API + static on http://localhost:8787 (dashboard: /dashboard/ · demo: /demo/)
```
## Step 2: open the demo page
Open `http://localhost:8787/demo/`. You see a made-up analytics page with the Loupe panel docked on the right and a short tour.
The demo page with Loupe 0.14.2, as it loaded on my machine on 2026-10-31.
## Step 3: pin a comment on an element
1. Click **Skip** on the tour if it is still showing, because its card covers the button. On the **Home** tab, click **Pin feedback on this page**. The Inspect tool turns on.
2. Move the pointer over the **Send invite** button. Loupe outlines it and shows the tag name.
3. Click it. A small composer opens, and its header names the element you picked.
4. Type a title and a description. The **Comment** button stays disabled until both have text.
5. Leave **Attach screenshot** checked and click **Comment**.
The Inspect tool outlines the element under the pointer and names it.The composer. The comment keeps the element's HTML, selected styles and a screenshot.
A numbered pin appears on the button, and the comment shows at the top of the **Comments** list. Every comment is a thread: click it, reply, and mention people with `@`.
## Step 4: triage on the board
Open `http://localhost:8787/dashboard/?key=sk_demo_acme_0f3b9c` in a new tab. The key is the admin key that `npm run seed` printed. Your comment is a card in the **Queue** column. Use **Move forward** to send it to **To Do**.
The board has five stages. Every new comment starts in Queue.
## Step 5: connect Claude Code
Loupe's MCP server lets Claude Code read comments and write back proposed changes. In an empty project folder, create `.mcp.json`:
```json
{
"mcpServers": {
"loupe": {
"command": "npx",
"args": ["-y", "@loupekit/mcp"],
"env": {
"LOUPE_API": "http://localhost:8787",
"LOUPE_PROJECT_KEY": "pk_demo_acme",
"LOUPE_ADMIN_KEY": "sk_demo_acme_0f3b9c"
}
}
}
}
```
The three variables name your server, the project to read and the project secret. Keep this file out of version control if it ever holds a real secret. Start Claude Code in that folder, approve the `loupe` server, and run `/mcp`: `loupe` should show as connected. The dashboard's **Connect Claude** page shows the same snippet, already filled in with your address and project key.
The Connect Claude page. After you paste the snippet, replace the placeholder for the project secret, or every tool fails with 401.
## Step 6: let Claude read it and propose a fix
Ask Claude Code:
```text
List the Loupe comments.
```
It calls `list_comments` and answers in this shape:
```text
1 comment(s):
- [To Do] Medium · Other · # — Invite button is hard to see: Make the Send invite button stand out more.
↳ [data-testid="btn-invite"] on /demo/ (by Sara (PM))
```
Then ask it to get the full context and propose a change. `get_comment` returns the comment, the page, the element's HTML, its computed styles and the screenshot. `propose_change` saves HTML and CSS as a before-and-after preview on the card. The preview runs in a sandboxed frame with scripts turned off, so it shows markup and styles only.
The agent activity monitor, read-only, fed by Claude Code hooks. It shows what the agent did with your comment.
## Step 7: a person resolves it
The MCP tools are built so that an agent stops at **In Review**: `mark_thread_addressed` has no status argument and always sets In Review. The tutorial states plainly that this is an instruction to the agent, not a permission check. A person reviews the preview and clicks **Resolve** in the widget or on the board.
## What I checked, and what I did not
On 2026-10-31 I ran steps 1 and 2 and the server part of step 4 from a fresh clone of Loupe 0.14.2, with Node v26.7.0 (the documented version is Node 24): `npm install`, `npm run build`, `npm run seed` and `npm start`, on port 8795 (`PORT=8795 npm start`) to stay off the default port. The seed output matched the one above, and the demo page and the dashboard both answered with HTTP 200. The widget loaded in a real browser. The browser console showed three 404 responses (the favicon, `/v1/org` and `/v1/activity`), and the cause is in the code: the widget treats a 404 from `/v1/org` and `/v1/activity` as "this backend has none", and the Node server has no such routes and no favicon route.
I did not run Claude Code against this server for this post. The `.mcp.json` snippet, the `list_comments` output shape and the Laravel and Hub paths come from Loupe's own documentation. The other screenshots come from the project's documentation too. The published `@loupekit/mcp` 0.14.0 fails at start with `ERR_MODULE_NOT_FOUND`; the documentation says this is fixed in 0.14.1. The latest version on npm on 2026-10-31 was 0.14.1, and my clone is 0.14.2, which is not published, so `npx -y @loupekit/mcp` installs a version I did not run.
## What I would do differently
1. **Print the project secret less casually.** The demo secret appears in the tutorial, in the dashboard address and in `.mcp.json`. It is a demo value, but a real project's secret should not sit in a file that people might commit. I have not tested another way to pass it.
2. **Say "instruction, not permission" earlier.** The board shows five columns, and nothing on it shows that the rule about In Review lives in the MCP tools. I would put that sentence next to the columns.
---
Source: https://mohamedashrafelsaed.com/blog/why-ai-coding-agents-forget/
Published: 2026-10-31
# Why AI coding agents forget between sessions
In my experience, AI coding agents start each session without the last one. You explain the repository, the deploy rule and the odd naming convention on Monday, and on Tuesday the agent asks again. Switch from one tool to another and the memory does not follow you.
Aletheia (npm package `aletheia-mem`) is my answer: one SQLite file on your machine that every tool reads and writes. This post explains the design, what works today and what does not.
## The problem in three parts
1. **Sessions end.** The model's context is gone when the session closes.
2. **Each tool keeps its own instructions.** Claude Code, Codex, Cursor and Gemini CLI each read their own instruction file, such as `CLAUDE.md`, `AGENTS.md` and `GEMINI.md`.
3. **You cannot inspect or move it.** When memory lives inside a product, you cannot search it, edit it or take it with you.
## How Aletheia works
A hook is a small command that a tool runs at fixed moments, such as the start of a session or after a tool call. Aletheia installs hooks into each supported tool; only Claude Code, Command Code and Codex CLI have been run for real (see below). They do four things:
1. **Capture** what happened, where the tool offers the event: prompts, tool calls and the end of a turn.
2. **Redact** secrets and `` blocks before anything is stored. Redaction is pattern based, so it is not complete.
3. **Store** the result in one SQLite file with full-text search (FTS5).
4. **Return** a short context block when the next session starts.
Any tool can also read the same store over MCP, REST or the command line.
```bash
npm install -g aletheia-mem
aletheia-server
aletheia install claude-code # dry run: prints every file it would change
aletheia install claude-code --write # MCP entry, hooks and a rules block, with backups
```
## What I checked, and what I did not
Hooks have run in real sessions of Claude Code (two days of use), Command Code (one short session) and Codex CLI (one interactive session). That is one user on one machine, and it is the project's own record in its status and limits page. Cursor, Gemini CLI and Windsurf are config written from their documentation, not tested on a real install. Answer accuracy is not measured; the benchmarks measure retrieval only.
## What I would do differently
I would have written the claims table before the features. The documentation now says what was checked and what was not.
## Next
The next post compares Aletheia with claude-mem using numbers you can re-run for the Aletheia rows.