Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
b51c7f5
docs: drop em dashes from comments and test titles
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
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ 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.

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

Expand Down Expand Up @@ -167,15 +178,44 @@ 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. 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 +232,12 @@ 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`.
- **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
29 changes: 27 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,7 @@ 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'

async function walkMd(dir: string): Promise<string[]> {
const out: string[] = []
Expand Down Expand Up @@ -69,6 +71,21 @@ 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) {
Expand All @@ -80,9 +97,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 +126,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
Loading
Loading