Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
11d6632
feat(store): add a test seam to the store factory
beardthelion Sep 3, 2026
b53887e
fix(memory): make a crash mid-commit leave a consistent namespace
beardthelion Sep 3, 2026
1dadcd6
feat(memory): refuse a write whose base is out of date
beardthelion Sep 3, 2026
3d46329
feat(store): let the store declare whether delete erases
beardthelion Sep 3, 2026
2bf3b8f
fix(health): report liveness only, and probe the store at startup
beardthelion Sep 3, 2026
fe97dd5
feat(observability): log every refusal with a closed field set
beardthelion Sep 3, 2026
9fed0ad
refactor: consolidate Phase 0 duplication and correct stale docs
beardthelion Sep 3, 2026
86207f3
fix(memory): make reclaim non-fatal, complete, and actually tested
beardthelion Sep 3, 2026
4f9f450
fix(memory): stop one bad manifest hash from failing a whole read
beardthelion Sep 3, 2026
30228a7
fix(api): close the remaining review findings on the write contract
beardthelion Sep 4, 2026
341dfcb
docs: describe the contract this branch actually serves
beardthelion Sep 4, 2026
2358ce6
test: cover the whole stack end to end, and close the last gaps
beardthelion Sep 4, 2026
795cf9c
feat(client): send a write precondition and raise typed refusals
beardthelion Sep 4, 2026
dda4038
feat(mcp): say which memory system a fact belongs in
beardthelion Sep 4, 2026
eae08d3
feat(client): generate the setup card without touching the passphrase
beardthelion Sep 4, 2026
f9d36d7
feat(mcp): tell the model what was refused and what to do next
beardthelion Sep 4, 2026
a1ce501
feat(mcp): refuse to start on a configuration that would corrupt memory
beardthelion Sep 4, 2026
3f0ddb1
fix(client): stop a no-op push asking the server twice
beardthelion Sep 4, 2026
7ace5f1
fix(client): stop the write precondition failing open
beardthelion Sep 4, 2026
22ea704
fix(mcp): stop the startup check passing on a namespace it never read
beardthelion Sep 4, 2026
be81bc2
fix(mcp): stop telling the model a refused write was saved
beardthelion Sep 4, 2026
46c0e86
fix(mcp): make the guide and the pasted card agree on where memory goes
beardthelion Sep 4, 2026
0e4b489
feat(api): serve one entry without serving the whole namespace
beardthelion Sep 4, 2026
9a7a1cc
fix(client): bound every wait, every cache, and read one entry at a time
beardthelion Sep 4, 2026
fe69fa6
perf(mcp): prove the passphrase with one entry instead of the namespace
beardthelion Sep 4, 2026
433b1be
fix(client): refuse a setup card that cannot work
beardthelion Sep 4, 2026
1a7aa5e
fix(mcp): stop reporting lost memory as an empty namespace
beardthelion Sep 4, 2026
65e7f5e
docs: describe the routes, command and knob this phase added
beardthelion Sep 4, 2026
4118d24
fix(mcp): sanitize every string the server chooses, not just one of them
beardthelion Sep 4, 2026
fb1f55b
feat(build): publish something Node can actually run
beardthelion Sep 4, 2026
f4ce6b8
test(ci): read the artifact a consumer installs, not the working tree
beardthelion Sep 4, 2026
575f578
feat(release): ship binaries that serve the real guide, on an image t…
beardthelion Sep 4, 2026
98dd2f2
docs: describe integrations that exist
beardthelion Sep 4, 2026
e079366
docs: correct what the standalone binaries actually need
beardthelion Sep 5, 2026
4b0560e
fix(build): emit client entries Node can actually load
beardthelion Sep 5, 2026
19a2335
feat(mcp): read the passphrase from a file, not only the environment
beardthelion Sep 5, 2026
ef18824
fix(mcp): name an unexpanded passphrase-file reference as one
beardthelion Sep 5, 2026
b41746f
feat(cli): report the version, and check the bin runs under Node
beardthelion Sep 5, 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
24 changes: 24 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,28 @@ RATE_LIMIT_BURST=240
# Default namespace the MCP server reads/writes when a tool call omits one.
# MEMLAWB_NAMESPACE=user:me
# Secret-scan policy applied to plaintext before encryption: block|warn|off.
# The passphrase may come from a file instead, and the file wins when both are
# set. A host that launches the MCP server spreads its own environment into
# every stdio child, so a passphrase exported for memlawb is readable by every
# other MCP server; a path is not.
# MEMLAWB_PASSPHRASE_FILE=/run/secrets/memlawb-passphrase
# MEMLAWB_SCAN=block
# How long any single request may take before the client gives up.
# MEMLAWB_TIMEOUT_MS=120000

# ── Auth ────────────────────────────────────────────────────────────────
# Leave ALLOW_UNAUTHENTICATED=true for single-user self-host. For multi-tenant,
# set it false and supply ONE of the two below.
# "owner:key" pairs, comma separated. Self-host without Supabase.
# STATIC_API_KEYS=alice:sk_alice,bob:sk_bob
# MEMLAWB_SUPABASE_URL=
# MEMLAWB_SUPABASE_SECRET_KEY=

# ── S3-compatible store (STORE=s3) ──────────────────────────────────────
# Requires the Bun runtime: the adapter uses Bun's S3 client and has no Node
# equivalent, so STORE=s3 does not work from the Node build.
# S3_BUCKET=
# S3_ENDPOINT=
# S3_REGION=auto
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ jobs:
run: bun run type-check
- name: Test
run: bun test
- name: Build
run: bun run build

# The working tree always runs from source under Bun, so every packaging
# failure is invisible to the job above: it was green while Node could not
# import this package at all. This job is the only thing that reads the
# artifact a consumer actually installs.
package:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 20 is the declared engines floor, and is otherwise never exercised;
# 22 is what a consumer most likely has today.
node-version: ['20', '22']
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.2'
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: bun install --frozen-lockfile
- name: Packed tarball, installed and driven under Node
run: bun run test:package
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ jobs:
bun-version: '1.2'
- run: bun install --frozen-lockfile
- run: bunx biome ci && bun run type-check && bun test
# `npm publish` fires `prepack`, which builds, so `dist/` reaches the
# tarball without an explicit step here (verified with `npm publish
# --dry-run` after deleting `dist/`). This runs the packed-tarball check
# anyway: publishing a package no consumer can install is the one failure
# every in-repo gate is structurally blind to, and it is unrecoverable
# once a version is on the registry.
- name: Verify the artifact a consumer will install
run: bun run test:package

# npm
- uses: actions/setup-node@v4
Expand All @@ -52,6 +60,16 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# Standalone binaries, for a machine with neither Bun nor Node. Bun
# cross-compiles every target from one runner, so this is a single job
# rather than a matrix of them.
- name: Build standalone binaries
run: bun run build:binaries
- name: Attach binaries to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ needs.release-please.outputs.tag_name }}" binaries/* --clobber

# GHCR image
- uses: docker/login-action@v3
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ node_modules/
dist/
data/
.memlawb-key
binaries/
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM oven/bun:1.1-alpine
FROM oven/bun:1.2-alpine
WORKDIR /app

COPY package.json bun.lock ./
Expand Down
10 changes: 6 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ A later optional "searchable tier" can do server-side encrypted search (see §7)
```
agent (openclaude / Claude Code / Cursor / opencode / SDK)
│
├─ Path A native sync → speaks /team_memory contract (openclaude, ~0 changes)
├─ Path A native sync → speaks the /api/memory contract directly (not built)
├─ Path B MCP tools → memory_save / recall / search / list (universal)
└─ Path C local daemon → mirrors ~/.claude memdir to cloud (tool-agnostic)
│
Expand All @@ -72,8 +72,10 @@ Namespace = unit of sharing/scoping. Examples: `user:<id>` (private),
`repo:<owner>/<name>` (team), `agent:<id>`. Each namespace holds entries keyed by path
(`MEMORY.md`, `feedback/x.md`, ...), mirroring the memdir layout.

**Sync API (Path A — openclaude drop-in).** Mirror the existing contract so openclaude
works by changing a base URL:
**Sync API (Path A).** The HTTP contract a caller can speak directly. Note this is
not an openclaude drop-in: openclaude has no memlawb integration today, and the
one that is planned goes through the MCP tools (Path B) rather than this route.
The routes are:

- `GET /api/memory/:namespace` → full data + entryChecksums
- `GET /api/memory/:namespace?view=hashes`→ metadata + per-key checksums only
Expand Down Expand Up @@ -105,7 +107,7 @@ so the remote memlawb server still only sees ciphertext.
- Create `Gitlawb/memlawb` OSS repo (MIT, release-please + GHCR — match node).
- Lock crypto choices, API schema (Zod), namespace/ACL model.

**Phase 1 — Hosted MVP, openclaude drop-in (highest leverage).**
**Phase 1 — Hosted MVP (highest leverage).**
- memlawb server (Bun/Fly): auth, `/api/memory` sync contract, Postgres index,
Tigris/S3 private BlobStore. Server is crypto-blind (stores ciphertext).
- openclaude client change: configurable team-memory base URL + a thin E2E-encrypt
Expand Down
93 changes: 90 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,55 @@ bun run bin/memlawb.ts push ./my-memories user:me # encrypt + upload
bun run bin/memlawb.ts pull ./restored user:me # download + decrypt
```

To configure an agent rather than sync a directory, generate the block to paste:

```bash
# prints the MCP config block, plus a fresh passphrase shown once
MEMLAWB_API_KEY=<your key> bun run bin/memlawb.ts setup <owner> https://memory.gitlawb.com
```

The passphrase is generated locally and never leaves the machine: it is printed
for you to store, and the block carries a placeholder rather than the value. The
URL must be `https` unless it points at a loopback host.

That block is a public interface, not example output. The consumer integrations
point back at it rather than restating it, and the packaging test asserts that
every environment variable it emits is one the server still reads, so a rename
here fails in this repository instead of at spawn time in another one.

What lands on the server is ciphertext — `grep` your data dir for any plaintext
and you'll find nothing.

> ⚠️ **Zero-knowledge means no recovery.** If you lose your passphrase, the host
> cannot help you — the data is unreadable to everyone but the key holder. Back
> up your passphrase.

## Install

Three ways in, depending on what the machine already has.

```bash
npm install @gitlawb/memlawb # Node 20+, or any npm-compatible installer
bun add @gitlawb/memlawb # Bun resolves the TypeScript source directly
```

For a machine with neither runtime, each release attaches standalone binaries
with a `SHA256SUMS` file. The runtime is baked in, so they are large (tens of
MB). The glibc builds need nothing installed, verified on a stock `debian:12-slim`
with neither Bun nor Node present. The `-musl` builds are the exception and do
have a prerequisite: they link against `libstdc++` and `libgcc`, so on a bare
Alpine they fail to load until you `apk add libstdc++`.

```bash
curl -LO https://github.com/Gitlawb/memlawb/releases/latest/download/memlawb-linux-x64
chmod +x memlawb-linux-x64 && ./memlawb-linux-x64 setup <owner>
```

Under Node the client commands all work: `push`, `pull`, `setup` and `mcp`.
`memlawb serve` needs Bun, because the server uses Bun's HTTP and S3 APIs, and
says so rather than failing obscurely. Use `bunx @gitlawb/memlawb serve` or the
container image for self-hosting.

## Use as a library

```ts
Expand Down Expand Up @@ -167,15 +209,48 @@ All bodies are ciphertext; the server validates sizes/hashes without decrypting.

| Method | Route | Purpose |
|---|---|---|
| `GET` | `/health` | liveness + active store |
| `GET` | `/health` | liveness |
| `GET` | `/api/memory/:ns` | full data (ciphertext entries + checksums) |
| `GET` | `/api/memory/:ns?view=hashes` | per-key checksums only (for delta) |
| `PUT` | `/api/memory/:ns` | delta upsert `{ entries, deletions? }` |
| `DELETE` | `/api/memory/:ns?key=<entryKey>` | remove one entry |
| `GET` | `/api/memory/:ns?view=entry&key=<entryKey>` | one entry's ciphertext, without fetching the rest |
| `PUT` | `/api/memory/:ns` | delta upsert `{ entries, deletions?, base? }` |
| `DELETE` | `/api/memory/:ns?key=<entryKey>[&base=sha256:<hex>]` | remove one entry |

A *namespace* (`user:me`, `repo:owner/name`, `agent:intern`) is the unit of
scoping. Entry keys mirror the memdir layout (`MEMORY.md`, `feedback/x.md`).

The hashes view also reports `supports` (server capabilities a client can rely
on) and `erasure` (whether this deployment's store actually removes bytes on
delete); write responses carry `erasure` too.

The entry view answers with that key's base64 ciphertext and checksum, byte for
byte what the full read returns under the same key. It exists so a caller can
check one entry without downloading a namespace, and it distinguishes three
answers a single status would blur: `404 empty` (no such namespace),
`404 entry_not_found` (the namespace exists, the key does not), and
`503 entry_unreadable` (the manifest names the key and the store cannot produce
its body).

A write may be sent with a `base` mapping each touched key to the ciphertext
hash the client last saw, or `null` to assert the key must not exist. Only reads
a caller asked for fill that map, so the guarantee belongs to a long-lived
client, which in practice means the MCP server across a session. `memlawb push`
builds a fresh client per invocation and has read nothing, so it sends no base
and its writes are unconditional by design. The server
answers `409 stale_base_version` when that disagrees with its manifest, naming
the keys that moved. A write with no `base` is unconditional, so a client that
predates this keeps working.

`base` is an optional precondition: a map of entry key to the ciphertext hash
the caller believes that key holds, or `null` for "should not exist". A request
that disagrees with the stored manifest is refused with `409 stale_base_version`
and a `details.conflicts` map naming what each key actually holds. Omitting
`base` writes unconditionally, which is what a client that has not adopted it
still does. This guards the caller's own turn, not the moment between its last
read and its write, which is why the check is per entry rather than a namespace
version. A namespace whose manifest cannot be parsed answers
`503 manifest_unreadable` rather than appearing empty.

## Configuration

See [`.env.example`](./.env.example). Key knobs: `STORE` (`fs`|`s3`),
Expand All @@ -192,6 +267,18 @@ size/count limits.
- **Defense in depth:** a client-side secret scanner runs before encryption and
(by default) blocks uploads containing live-looking credentials. Override with
`MEMLAWB_SCAN=warn|off`.
- **Passphrase custody:** the MCP server accepts `MEMLAWB_PASSPHRASE_FILE`, a
path, as well as the value in `MEMLAWB_PASSPHRASE`, and the file wins when
both are set. Agent hosts commonly spread their own environment into every
stdio server they launch, so a passphrase exported for memlawb is readable by
all of them; a path is not, and a file can carry permissions an environment
cannot.
- **Startup refusal:** the MCP server checks its configuration against the
pinned namespace before serving a tool, and exits rather than start on one
that would corrupt stored memory: unexpanded template text in a secret, a
passphrase that cannot decrypt what is stored, a rejected key, an unauthorized
namespace, or a scan mode it does not recognize. Every wait is bounded;
`MEMLAWB_TIMEOUT_MS` raises the limit on a slow link.
- **Tenancy:** each API key maps to an owner who controls exactly their own
`user:<owner>` namespace subtree (strict segment match, no substring escapes);
per-account quotas and per-owner rate limits are enforced server-side.
Expand Down
5 changes: 4 additions & 1 deletion RELEASE-0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ Make the server safe to expose to strangers before anyone connects.
with explicit segment matching + an ACL hook stub. **Security-critical.**
- **Request hardening**: enforce `MAX_BODY_BYTES` even when `content-length` is
absent (stream cap), reject unknown methods early, add security headers.
- **Health/readiness**: `/health` already exists; add store round-trip check.
- **Health/readiness**: `/health` reports liveness only. The store round trip
moved to a startup probe rather than the route: `/health` is unauthenticated,
so a store check there is an anonymous write against the store holding every
tenant's ciphertext, and echoing the store's label leaked it to anyone.

### M2 — MCP server (the headline) · ~3–4d
`memlawb mcp` stdio subcommand wrapping the already-bundled `MemlawbClient`.
Expand Down
38 changes: 36 additions & 2 deletions bin/memlawb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Usage:
* memlawb push <dir> <namespace> encrypt + upload changed entries
* memlawb pull <dir> <namespace> download + decrypt into <dir>
* memlawb setup <owner> [url] print the config block + a new passphrase
* memlawb serve run the server (same as `bun run src/index.ts`)
*
* Env (client commands):
Expand All @@ -18,6 +19,8 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, sep } from 'node:path'
import { MemlawbClient } from '../client/index.ts'
import type { ScanMode } from '../client/secretscan.ts'
import { generatePassphrase, renderSetupCard } from '../client/setup.ts'
import { version } from '../src/version.ts'

async function walkMd(dir: string): Promise<string[]> {
const out: string[] = []
Expand Down Expand Up @@ -69,9 +72,32 @@ async function cmdPull(dir: string, namespace: string) {
console.log(`pulled ${namespace} v${r.version}: ${n} files → ${dir}`)
}

/**
* Print the paste-in block plus a freshly generated passphrase. The passphrase
* is produced here, on this machine, and shown once: nothing sends it anywhere
* and nothing can recover it, so the warning is the feature.
*/
function cmdSetup(owner: string, url: string | undefined) {
const card = renderSetupCard('openclaude', {
owner,
url: url ?? process.env.MEMLAWB_URL ?? 'https://memory.gitlawb.com',
apiKey: process.env.MEMLAWB_API_KEY ?? '<paste your service key here>',
})
console.log(card)
console.log(`Your new passphrase (shown once, back it up now):\n\n ${generatePassphrase()}\n`)
}

const [cmd, a, b] = process.argv.slice(2)
try {
switch (cmd) {
// Consumers pin a minimum: an older binary that cannot read
// MEMLAWB_PASSPHRASE_FILE fails with "no passphrase", which reads as the
// operator's mistake rather than a stale install, so they need a way to
// check. Read from the manifest so a release bump carries it.
case '--version':
case '-v':
console.log(version())
break
case 'push':
if (!a || !b) usage()
await cmdPush(a, b)
Expand All @@ -80,9 +106,16 @@ try {
if (!a || !b) usage()
await cmdPull(a, b)
break
case 'setup':
if (!a) usage()
cmdSetup(a, b)
break
case 'mcp':
// Stdio MCP server. Imported lazily so push/pull/serve don't pay for it.
await import('../src/mcp/server.ts')
// Stdio MCP server. Imported lazily so push/pull/serve don't pay for it,
// and CALLED rather than imported for effect: startup lives in a function
// so it can preflight, and an import alone would exit zero having served
// nothing.
await (await import('../src/mcp/server.ts')).main()
break
case 'serve':
await import('../src/index.ts')
Expand All @@ -102,6 +135,7 @@ function usage(): never {
'usage:\n' +
' memlawb push <dir> <namespace> encrypt + upload changed entries\n' +
' memlawb pull <dir> <namespace> download + decrypt into <dir>\n' +
' memlawb setup <owner> [url] print the paste-in config block + a passphrase\n' +
' memlawb mcp run the stdio MCP server (memory tools)\n' +
' memlawb serve run the memlawb server',
)
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading