Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ BUDGET_WINDOW_MS=86400000

# Extra root CA (PEM content) trusted for the Postgres connection, for providers that pin a private root. Optional; verification stays on. (core)
# DATABASE_CA_CERT=

# Procedural memory (Memorable). Off unless MEMORABLE is a true value; any false value of
# QM_MEMORABLE forces it off regardless. Both are read once at startup. MEMORABLE_BIN names the binary to spawn (split on spaces,
# so "npx memorable" works); a missing binary is a silent no-op.
# See docs/procedural-memory.md.
#MEMORABLE=1
#QM_MEMORABLE=0
#MEMORABLE_BIN=memorable
# Where per-scope device sign-in is performed. Defaults to the public service.
#MEMORABLE_API_URL=
# Fallback key for scopes with no connected account of their own.
#MEMORABLE_API_KEY=
# The spawned CLI is given MEMORABLE_BACKEND=qm and MEMORABLE_DB_URL=DATABASE_URL
# unless you set them here, so procedures land in this deployment's own Postgres
# rather than a per-machine file. Set them only to override that.
#MEMORABLE_BACKEND=qm
#MEMORABLE_DB_URL=
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,47 @@ qm, cutting the branch from `upstream/main` and checking the outgoing diff, comm
messages, and screenshots for organization identifiers before it pushes. Nothing under
`deploy/layers/` ever travels upstream.

## Procedural memory (Memorable)

Optional, off by default, additive. QM can record _how_ a task was done and replay that
later, so a recurring job is not re-diagnosed from scratch every time. A prompt and the
tool calls that followed it become one _memorable_: which files changed, which commands
verified the work, in what order, with the real exit codes. A later prompt that one of
them already answers gets a short pointer injected into the system prompt.

`MEMORABLE=1` turns it on; unset, empty, or any false value means off, and
any false value of `QM_MEMORABLE` forces off even when `MEMORABLE=1`. Both are read once at
startup, so a change takes effect on restart. With it off, `buildApp` installs no
hook and passes no dependency, and the orchestrator's one added check short-circuits on
`undefined`. QM itself opens no socket and makes no HTTP call for this: it spawns the
[Memorable](https://memorable.sh) CLI, which does the network
work outside the QM process, writes only to scopes explicitly consented `read-write`, and
stores into QM's own `DATABASE_URL` Postgres rather than a second store.

Setup is `npm i -g memorable-cli` and `MEMORABLE=1`. From there, each person can connect
their own Memorable account: `POST /v1/memorable/connect` starts a browser sign-in for
their own scope, and the key that comes back is stored encrypted against it, so their
procedures land in their own organization. A sign-in can only ever be started for the
caller, because whoever opens the URL is whoever the key belongs to. Anything with no
account of its own, a shared channel included, uses the single `MEMORABLE_API_KEY` in the
server's environment, which is all a deployment that connects nobody ever needs.
Connecting is not consent: nothing is captured for a scope until someone sets it to
`read-write` through `POST /v1/memorable/consent`.

Five `memorable_*` tables appear in the database `DATABASE_URL` already points at, three
created by the CLI and two by QM's own `artifactMap`. QM ships no migration for any of
them.

[`docs/procedural-memory.md`](./docs/procedural-memory.md) has the rest: exactly which
guarantees are enforced by code in this repository and which are enforced by the binary,
what the injected block is allowed to contain, and where a model is and is not involved.

## Going deeper

- [`docs/getting-started.md`](./docs/getting-started.md) — first run, end to end
- [`cli/README.md`](./cli/README.md) — the `qm` CLI and the deployment directory contract
- [`docs/deploy-directory.md`](./docs/deploy-directory.md) — the deployment directory in full
- [`docs/procedural-memory.md`](./docs/procedural-memory.md) — the optional Memorable integration in full
- [`.env.example`](./.env.example) — every knob, documented in place
- [`plugins/`](./plugins) — the surfaces (Slack, web UI, admin, portal)

Expand Down
59 changes: 59 additions & 0 deletions adrs/procedural-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Procedural memory: let QM remember how it did something

QM remembers what was said. It does not remember how anything was done. So the same
recurring job gets re-diagnosed from scratch every time: find the file again, run the
same three commands again, discover the same exit code again.

What we would like to add: at the end of a run, take a prompt and the tool calls that
followed it, and store that as a small record. Which files changed, which commands
verified the work, what order, real exit codes. Later, when a prompt looks like one of
those records already answers it, put a short pointer in the system prompt saying where
the fix landed last time.

The reason it is worth doing at the harness level rather than in a skill: the tool calls
and their exit codes are already in `session_entries`. Nothing else has to be captured,
and nothing has to be inferred by a model to get the record. A skill can only tell an
agent a method; this replays work that actually ran and passed.

We have this working against QM already and can share the branch. Rough shape, so you
can tell us if it is the wrong shape before anyone writes more:

- One env flag, off by default. With it off, no hook is registered and the orchestrator
check short-circuits on an undefined dependency.
- Roughly 30 lines added to existing files (`config.ts`, `wiring.ts`, `orchestrator.ts`,
`orchestrator/types.ts`), nothing deleted or edited in place. The rest is new files
under `src/memorable/` and `test/`.
- QM opens no socket. It spawns a local binary; that binary does the network work and
holds the consent check. Storage lands in QM's own `DATABASE_URL` Postgres, not a
second store.
- The injected block is treated as untrusted input: envelope-checked, escape-stripped,
size-capped, dropped whole rather than truncated, and appended outside the prompt-cache
boundary.

Two things we already know are not free, so they should be part of the decision rather
than a surprise later. Recall sits on the turn's critical path behind a subprocess call
with a 15 second bound. And the relay holds no state, so a long session re-offers its
earlier work at each run end; the binary skips what it has already stored, but that is a
dedupe rather than an absence of the call.

The parts of this that are enforced by code you can read, and the parts that are
enforced by the binary and therefore taken on trust, are separated explicitly in
`docs/procedural-memory.md`, because that is the line we would want drawn if we were
reviewing it.

One account per deployment was the obvious first shape and it is the wrong one. QM is
multi-tenant; a single key means every scope's procedures land in one organization, and
whoever holds that key can read all of them. So each scope can connect its own account
instead, through a device authorization: QM asks the sign-in service for a code, hands the
human a URL, and stores whatever key comes back, encrypted under the same key material the
keychain already uses. QM never sees a password, and it cannot create an account for
someone who has not signed in themselves.

That does add the one outbound call this integration otherwise avoids, to two endpoints
that carry a scope label and an opaque code and nothing else. We think that is the right
trade against a shared credential, but it is the part of this change most obviously open
to argument, so it is called out here rather than buried. A deployment that connects
nobody keeps the single-key behavior and makes no such call.

Happy to cut it down, split it, or move any of it out of core if the answer is that it
does not belong here.
Loading