docs: unclip the diagrams, and answer what compaction leaves behind
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 15s

Two problems reported against docs/observational-memory.md, one cosmetic and one
substantive. Both turned out to be worth more than the fix.

Clipping. Several boxes lost their bottom line of text in the viewer, and neither
the source nor my own render showed it. Cause: Mermaid measures a node label with
its own font metrics, commits to a box size, then renders that label as real HTML
in a <foreignObject> — so any host stylesheet touching line-height or font-size
inflates the text past a box that is already fixed, and the overflow is clipped.
The error accumulates per line, which is why it always eats the last line of the
tallest labels.

Rejected the obvious fix after testing it rather than assuming it:
%%{init: {'flowchart': {'htmlLabels': false}}}%% *is* honoured (labels switch from
16 foreignObject to 7 tspan) and clips identically, because inflated font-size
inherits into SVG text too. The fix that works is a hard limit of two short lines
per node, with the detail moved into prose under each diagram — one- and two-line
boxes have the vertical slack to absorb inflation, three- and four-line boxes do
not. The nodes were carrying paragraph-sized text; the diagrams are better for
losing it.

Regression harness: render every block with line-height: 1.7 !important forced
onto the label HTML, screenshot, read it. That caught two survivors of the rewrite
that looked fine in the clean render — a long unbreakable /opt path wrapping to a
third line, and a cylinder shape whose curved bottom leaves less room than a
rectangle for the same two lines.

New §4, because the document explained that compaction folds the ledger and never
said what that leaves in the context. Asked directly: is the session back to
knowing nothing? No. A verbatim tail survives, sized by keepRecentTokens (20k) and
cut only at turn boundaries; the system prompt and AGENTS.md were never in the
compacted region because they are rebuilt from disk each request; nothing is
deleted from disk, since compaction appends a compaction entry rather than
rewriting lines; and recall keeps resolving ids whose sources left the context
because it reads the full branch via sessionManager.getBranch() and never consults
the context window. Repeated compaction renders from live records, not from the
previous summary's prose, so there is no generation-loss spiral.

Also corrects this repo's own "compaction calls no model" to the steady-state
claim it actually is: with an empty ledger the hook returns nothing and explicitly
declines ownership, and pi's native model-based summariser runs. Snippet quoted in
the doc.

And a bug shipped in the first version: §9 said the ledger entries are
custom_message and specifically not custom. Exactly backwards, so the one grep
that section existed to get right was the one it got wrong. Verified empirically
against the live session file — 11 om.observations.recorded and 6
om.reflections.recorded, all "type":"custom", next to "type":"custom_message"
entries whose customType is mempalace-mailbox and mempalace-wakeup. That is where
the confusion came from, and the distinction is load-bearing rather than
cosmetic: the mailbox uses the context-visible append API, om's ledger uses the
invisible one. Which makes it a feature the doc now advertises — the ledger costs
zero context until it is folded.
This commit is contained in:
Joakim Persson
2026-08-27 14:23:12 +02:00
parent cdb6fc0950
commit 8a673ec143
2 changed files with 268 additions and 95 deletions
+74
View File
@@ -190,6 +190,80 @@ browser and read back as an image. Recorded because it generalises:
`mermaid.parse()` proves syntax and says nothing about layout, so a diagram is `mermaid.parse()` proves syntax and says nothing about layout, so a diagram is
unverified until someone has looked at it. unverified until someone has looked at it.
### … and rendering it in *my* browser was still not enough
Reported from a real viewer: several boxes had their bottom line of text sliced
off. Reproduced and root-caused rather than nudged — **Mermaid measures a node
label with its own font metrics, computes the box, then renders the label as real
HTML inside a `<foreignObject>`.** Any host stylesheet that touches the
`line-height` or `font-size` of that HTML makes the text taller than the box
already committed to, and the overflow is clipped at the box edge. Error
accumulates per line, so the loss always lands on the last line of the tallest
labels — which is exactly what was reported.
Two fixes were tried and only the second works:
- `%%{init: {'flowchart': {'htmlLabels': false}}}%%` — **rejected, and verified
ineffective rather than assumed so.** The directive *is* honoured (label
elements switch from 16 `foreignObject` to 7 `tspan`), and the clipping is
identical, because the inflated font-size still inherits into SVG text.
- **A hard limit of two short lines per node, with the detail moved into the prose
under each diagram.** One- and two-line boxes have enough vertical slack to
absorb the inflation; three- and four-line boxes do not. This is also better
documentation — the old nodes were carrying paragraph-sized text.
The regression harness is now the interesting artefact: render every block with a
deliberately inflated `line-height: 1.7 !important` on the label HTML, screenshot,
and read it. Two survivors of the rewrite were caught only by that harness — a
long unbreakable `/opt/pi-observational-memory` path silently wrapping to a third
line, and a cylinder (`[( )]`) shape, whose curved bottom leaves less room than a
rectangle for the same two lines.
### §4 answers the question the document left hanging: what compaction does to your context
Asked directly and worth writing down: *if the old conversation is folded away, is
the session back to knowing nothing?* No — and the specifics are all checkable
against pi 0.84.3's own `docs/compaction.md` and the extension's source:
- **A verbatim tail survives, sized by a token budget rather than a message
count.** Pi walks back from the newest entry until `keepRecentTokens` [20000],
and everything from that `firstKeptEntryId` onward is kept **unchanged**. Cut
points land on turn boundaries, never mid-tool-call.
- **The system prompt and `AGENTS.md` are not in the compacted region at all** —
they are rebuilt from disk on every request, so compaction cannot lose them.
- **Nothing is deleted from disk.** Compaction *appends* a `compaction` entry
carrying the summary and the cut pointer; no session line is rewritten in place.
- **`recall` therefore still resolves ids whose sources left the context**, because
it reads the full branch via `sessionManager.getBranch()` and never consults the
context window.
- **Repeated compaction does not summarise the summary.** The text is always
rendered from live observation/reflection records, so there is no
generation-loss spiral; the projection is incremental against the last full-fold
boundary and escalates to a true re-fold from the branch root at
`observationsPoolMaxTokens` [20000].
And one correction to this repo's own earlier claim: **"compaction calls no model"
is a steady-state property, not an absolute.** If the ledger is empty — compaction
firing before the observer has ever run — the hook returns nothing and explicitly
declines ownership (`// Decline ownership so Pi's native summarizer preserves the
pre-cut context.`), and pi's own model-based summariser runs. The doc now says so,
with the snippet.
### A shipped doc bug: the ledger entry type was stated exactly backwards
§9 told readers the entries are `custom_message` and specifically *not* `custom`.
It is the other way round, so the one grep the section existed to get right was
the one it got wrong. Corrected against the live session file — 11
`om.observations.recorded` and 6 `om.reflections.recorded` entries, all
`"type":"custom"`, alongside `"type":"custom_message"` entries whose `customType`
is `mempalace-mailbox` and `mempalace-wakeup`, which is precisely where the
confusion came from: **the mailbox uses the context-visible API, om's ledger uses
the invisible one.**
That is not a typo but a load-bearing distinction, and the fix turns it into a
feature the doc now advertises: `custom` entries *"do not participate in LLM
context"* (pi `docs/session-format.md`), so **the ledger costs zero context until
it is folded** — now a row in the cost table.
### Not covered by any of this ### Not covered by any of this
The opencode bridge is a separate write path the feeder hook never sees, and the The opencode bridge is a separate write path the feeder hook never sees, and the
+192 -93
View File
@@ -11,16 +11,18 @@ leave any of it switched on.
[`how-it-works.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/how-it-works.md) [`how-it-works.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/how-it-works.md)
(hooks and internals) and (hooks and internals) and
[`configuration.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/configuration.md) [`configuration.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/configuration.md)
(every setting). Those are normative; this document is the **deployment** view — (every setting). Pi's own compaction mechanics are in
what is pinned here, how it is wired, what it costs, and how it differs from `/usr/lib/node_modules/@earendil-works/pi-coding-agent/docs/compaction.md`.
MemPalace. For the palace, see Those are normative; this document is the **deployment** view — what is pinned
here, how it is wired, what it costs, and how it differs from MemPalace. For the
palace, see
[`mempalace-toolkit/docs/fleet-memory.md`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/docs/fleet-memory.md). [`mempalace-toolkit/docs/fleet-memory.md`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/docs/fleet-memory.md).
> Verified on pi-devbox **v1.8.9** (`release_tag v1.8.9`, source `aac4a1c`), > Verified on pi-devbox **v1.8.9** (`release_tag v1.8.9`, source `aac4a1c`),
> which bakes pi-observational-memory **v3.0.4** at commit `ce9fc98` — the value > which bakes pi-observational-memory **v3.0.4** at commit `ce9fc98` — the value
> in `/etc/pi-devbox/build-manifest.json` → `components.pi-observational-memory`. > in `/etc/pi-devbox/build-manifest.json` → `components.pi-observational-memory`.
> Every number below was read from that tree or from the live container, not > Every number below was read from that tree, from pi 0.84.3's own docs, or from
> from release notes. > the live container.
--- ---
@@ -30,7 +32,7 @@ A long pi session outgrows the model's context window. Pi's answer is
**compaction**: fold the older part of the conversation into a summary and keep **compaction**: fold the older part of the conversation into a summary and keep
recent messages verbatim. That is unavoidable, and it is where sessions go recent messages verbatim. That is unavoidable, and it is where sessions go
wrong — the summary is produced *at the moment of pressure*, by a model, about a wrong — the summary is produced *at the moment of pressure*, by a model, about a
transcript that is about to be discarded. transcript that is about to leave the context.
Observational memory changes *when* the remembering happens. Instead of Observational memory changes *when* the remembering happens. Instead of
summarising in a panic at the end, it keeps a small **ledger** up to date while summarising in a panic at the end, it keeps a small **ledger** up to date while
@@ -38,15 +40,21 @@ the session runs, and compaction then just folds that ledger.
```mermaid ```mermaid
flowchart LR flowchart LR
A0["<b>plain compaction</b>"] --> A1["long session,<br/>context filling"] A0["plain compaction"] --> A1["context fills"]
A1 --> A2["a model summarises<br/>the transcript<br/><i>at the moment of pressure</i>"] A1 --> A2["a model summarises<br/>under pressure"]
A2 --> A3["one prose summary:<br/>detail chosen in a hurry,<br/>no route back to the original"] A2 --> A3["prose summary,<br/>no way back"]
B0["<b>with observational memory</b>"] --> B1["long session,<br/>context filling"] B0["with observational<br/>memory"] --> B1["context fills"]
B1 --> B2["<i>while you work:</i><br/>a cheap background model appends<br/>observations + reflections<br/>to a ledger"] B1 --> B2["ledger written<br/>as you work"]
B2 --> B3["compaction folds the ledger<br/><b>no model call</b>"] B2 --> B3["compaction folds<br/>the ledger"]
B3 --> B4["every item keeps a 12-hex id —<br/><code>recall(id)</code> returns<br/>the exact source"] B3 --> B4["ids you can<br/>recall"]
``` ```
Top row is pi on its own: one model call at the worst possible moment, detail
chosen in a hurry, and the original wording gone from view. Bottom row is this
image's default: the thinking happened earlier on a cheap model, the fold is
deterministic, and every line in the result carries an id that resolves back to
the exact source.
## 2. The mental model: three layers and a ledger ## 2. The mental model: three layers and a ledger
| Layer | What it is | Example | | Layer | What it is | Example |
@@ -55,12 +63,18 @@ flowchart LR
| **Reflection** | a durable conclusion *backed by* observations | "the user optimises for avoiding 67-minute rebuilds" | | **Reflection** | a durable conclusion *backed by* observations | "the user optimises for avoiding 67-minute rebuilds" |
| **Drop** | a tombstone retiring an observation from active memory | the superseded detail of a bug that is now fixed | | **Drop** | a tombstone retiring an observation from active memory | the superseded detail of a bug that is now fixed |
These are appended as silent ledger entries (`om.observations.recorded`, These are appended to the session as silent ledger entries
`om.reflections.recorded`, `om.observations.dropped`) and **folded** — replayed (`om.observations.recorded`, `om.reflections.recorded`,
from the branch root — to produce the memory state. The ledger is the source of `om.observations.dropped`) and **folded** — replayed in order — to produce the
truth; the compaction summary is a rendering of it. memory state. The ledger is the source of truth; what you see in a compacted
session is a rendering of it.
Memory is **branch-local**. A pi session is a tree (resume, fork), and the fold Two properties follow, and both matter later:
- **The ledger itself costs no context.** Those entries are pi `custom` entries,
which *"do not participate in LLM context"* (pi `docs/session-format.md`). They
sit in the session file and reach the model only via the fold at compaction.
- **Memory is branch-local.** A pi session is a tree (resume, fork), and the fold
follows the current branch only, so a forked branch does not inherit another follows the current branch only, so a forked branch does not inherit another
branch's view. branch's view.
@@ -71,58 +85,133 @@ progress* rather than wall-clock time. Defaults in brackets.
```mermaid ```mermaid
flowchart TD flowchart TD
T(["turn_end"]) --> O{"tokens since last<br/>observation coverage<br/>≥ observeAfterTokens<br/>[10000]?"} T(["turn_end"]) --> O{"10k raw tokens<br/>since observing?"}
O -- yes --> OBS["<b>observer</b> runs:<br/>appends om.observations.recorded"] O -- yes --> OBS["<b>observer</b> runs"]
O -- "no (observer not due)" --> R{"tokens since last<br/>reflection<br/>≥ reflectAfterTokens<br/>[20000]?"} O -- "no" --> R{"20k tokens<br/>since reflecting?"}
R -- yes --> REF["<b>reflector</b> runs:<br/>appends om.reflections.recorded"] R -- yes --> REF["<b>reflector</b> runs"]
REF -- "non-empty reflection AND active pool<br/>&gt; observationsPoolTargetTokens [10000]" --> DR["<b>dropper</b> runs:<br/>appends om.observations.dropped"] REF -- "if pool over 10k" --> DR["<b>dropper</b> prunes"]
S(["agent_settled"]) --> C{"tokens since last compaction<br/>≥ compactAfterTokens [81000]<br/>and pi is idle?"} S(["agent_settled"]) --> C{"81k tokens<br/>since compacting?"}
C -- yes --> CP["extension calls ctx.compact()"] C -- yes --> CP["ctx.compact()"]
CP --> H(["session_before_compact"]) CP --> H(["session_before_compact"])
H --> F["<b>deterministic fold</b> of the ledger:<br/>no model call, no waiting on workers"] H --> F["fold the ledger<br/>no model call"]
F --> VIS["compacted memory the agent reads"] F --> VIS["compacted memory"]
``` ```
Two details worth carrying, because they are easy to get wrong: - **observer** — `observeAfterTokens` [10000]: writes observations for the
conversation it has not covered yet.
- **The dropper has no clock of its own.** It is post-reflection maintenance, - **reflector** — `reflectAfterTokens` [20000]: promotes patterns across
gated on a *successful same-turn* reflection — not a third worker on a third observations into durable reflections.
- **dropper** — no clock of its own. It is post-reflection maintenance, gated on
a *successful same-turn* reflection **and** an active pool above
`observationsPoolTargetTokens` [10000]. Not a third worker on a third
threshold. threshold.
- **Compaction itself calls no model.** That is the whole design: the expensive - **compaction** — `compactAfterTokens` [81000], checked when pi goes idle, so it
thinking happened earlier, in the background, on a cheap model. never interrupts a turn. Pi will also compact on its own when the context is
nearly full (`contextTokens > contextWindow - reserveTokens`, `reserveTokens`
[16384]).
## 4. What you actually get ## 4. What compaction actually does to your context
- **Compaction stops being a stall.** The latency path is deterministic work on This is the question the rest of the document used to leave hanging: if the old
ledger entries, not a summarisation call. conversation is folded away, is the session back to knowing nothing?
**No.** Compaction replaces *part* of the context, not all of it, and it deletes
nothing at all from disk.
```mermaid
flowchart LR
SYS["system prompt<br/>+ AGENTS.md"] --> CTX["what the model sees<br/>on the next turn"]
SUM["folded memory:<br/>reflections + observations"] --> CTX
TAIL["recent turns,<br/>verbatim"] --> CTX
DISK[("session .jsonl: all of it")] -. "recall(id)" .-> CTX
```
Where each piece comes from:
- **System prompt and `AGENTS.md` — never compacted, because they were never
conversation.** Pi rebuilds them from disk on every request
(`loadContextFileFromDir`), so they cannot be lost by compaction.
- **The verbatim tail — sized by a token budget, not a message count.** Pi walks
backwards from the newest entry accumulating token estimates until
`keepRecentTokens` [20000] is reached; that entry becomes `firstKeptEntryId`,
and *everything from there on is kept unchanged*. Cut points land on turn
boundaries, never mid-tool-call. So the most recent ~20k tokens of real work —
your last instructions, the diffs, the test output — survive word for word.
- **The folded memory — replaces only what came before that cut.** Rendered from
the ledger's records: reflections and observations, each with its 12-hex id.
- **The session file — untouched.** Compaction *appends* a `compaction` entry
(`{"type":"compaction", summary, firstKeptEntryId, tokensBefore, …}`) and
rebuilds context from it on later turns. Nothing is rewritten in place; the
only documented way to remove session content is deleting the whole `.jsonl`.
That last point is what makes the answer to "is the detail gone?" *no* rather
than *mostly*: `recall` does not read the context window at all. It calls
`sessionManager.getBranch()` — the full branch from the root — and resolves an
observation id back to the original entries. Detail that left the model's view
an hour ago is still one `recall` away.
**Repeated compaction does not summarise the summary.** The rendered text is
always built from live observation/reflection *records*, never from the previous
compaction's prose, so there is no generation-loss spiral. (Mechanically the
projection is incremental — it re-derives back to the last full-fold boundary and
carries the rest forward, escalating to a genuine re-fold from the branch root
when the observation pool reaches `observationsPoolMaxTokens` [20000].)
So the honest summary of the state after compaction: **the model keeps its
instructions, keeps recent work verbatim, trades older turns for a dense
id-carrying digest of them, and can pull any of it back on demand.** Not a fresh
start — a smaller, cheaper, still-navigable one.
### One caveat about "no model call"
If the ledger is empty — compaction fires before the observer has ever run — the
hook returns nothing and *declines ownership*, and pi's own model-based
summariser runs instead:
```ts
const summary = renderSummary(projection.reflections, projection.observations);
if (summary.length === 0) {
// Decline ownership so Pi's native summarizer preserves the pre-cut context.
return;
}
```
In steady state (any session old enough to have produced one observation) om's
hook wins and compaction is model-free. "Never calls a model" is true in practice
and false in principle; the fallback is deliberate, so an empty ledger degrades
to normal pi rather than to no summary at all.
## 5. What you actually get
- **Compaction stops being a stall.** In steady state the latency path is
deterministic work over ledger entries, not a summarisation call.
- **Nothing important vanishes silently.** Compaction is lossy by design, but - **Nothing important vanishes silently.** Compaction is lossy by design, but
every observation and reflection keeps a 12-character id, and `recall(<id>)` every item keeps a 12-character id, and `recall(<id>)` returns the exact
returns the exact evidence behind it — the original wording, the reasoning, evidence — original wording, reasoning, file path, error text.
the file path, the error text.
- **The bookkeeping runs on a cheaper model than your session.** In this image - **The bookkeeping runs on a cheaper model than your session.** In this image
that is deliberate and visible (§6): background workers on Haiku, the session that is deliberate and visible (§7): background workers on Haiku, session on
on Opus. Opus.
- **It is automatic.** There is no habit to maintain, unlike the palace protocol - **It is automatic.** No habit to maintain, unlike the palace protocol — which is
— which is exactly why the two systems complement each other (§10). exactly why the two complement each other (§11).
- **Forks stay clean.** Branch-local memory means a `fork` sub-agent's noise does - **Forks stay clean.** Branch-local memory means a `fork` sub-agent's noise does
not leak into the parent's folded memory. not leak into the parent's folded memory.
## 5. `recall` is not a search tool ## 6. `recall` is not a search tool
`recall` takes **one specific 12-hex id** that already appears in compacted `recall` takes **one specific 12-hex id** that already appears in compacted
memory or in `/om:view`, and looks it up in the ledger on the current branch. It memory or in `/om:view`. It cannot be given a topic. It can return an observation
cannot be given a topic. It can return an observation (marked `active` or (marked `active` or `dropped`), or a reflection together with the observations
`dropped`), or a reflection together with the observations supporting it. supporting it.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant M as compacted memory participant M as compacted memory
participant A as agent participant A as agent
participant L as ledger (current branch) participant L as ledger
M->>A: "[high] user rejected option B (a1b2c3d4e5f6)" M->>A: "[high] user rejected option B (a1b2c3d4e5f6)"
A->>L: recall("a1b2c3d4e5f6") A->>L: recall("a1b2c3d4e5f6")
L-->>A: exact observation + its source entry ids L-->>A: exact observation + source ids
Note over A: acts on the original wording,<br/>not on the compressed paraphrase Note over A: acts on the original wording
``` ```
The rule of thumb the agent skill uses: recall **before a load-bearing action** The rule of thumb the agent skill uses: recall **before a load-bearing action**
@@ -130,30 +219,30 @@ that rests on a compressed memory — shipping a change, asserting a fact,
answering "why do you believe that". One recall is cheap; redoing finished work answering "why do you believe that". One recall is cheap; redoing finished work
is not. is not.
## 6. How it is wired in this image ## 7. How it is wired in this image
```mermaid ```mermaid
flowchart TB flowchart TB
IMG["<b>image layer</b><br/>/opt/pi-observational-memory<br/>v3.0.4 @ ce9fc98 (pinned)"] --> REG["<b>~/.pi/agent/settings.json</b><br/>packages[]: ../../../../opt/pi-observational-memory<br/><i>the only source of truth for which copy loads</i>"] IMG["baked in the image:<br/>v3.0.4 @ ce9fc98"] --> REG["settings.json<br/>packages[]"]
REG --> SESS["<b>your pi session</b>"] REG --> SESS["your pi session"]
SESS -->|"your turns"| SM["session model<br/>claude-opus-5"] SESS -- "your turns" --> SM["session model:<br/>Opus"]
SESS -->|"observer / reflector / dropper"| WM["memory model<br/>claude-haiku-4-5 (Bedrock)"] SESS -- "observer, reflector,<br/>dropper" --> WM["memory model:<br/>Haiku"]
SESS -->|"ledger entries (custom_message)"| JL["~/.pi/agent/sessions/&lt;project&gt;/&lt;timestamp&gt;_&lt;uuid&gt;.jsonl"] SESS -- "ledger entries" --> JL["session .jsonl"]
JL --> VOL[("devbox-pi-config volume — docker-compose.yml:59<br/>survives --force-recreate")] JL --> VOL[("devbox-pi-config<br/>volume")]
``` ```
Four consequences of that wiring: Four consequences of that wiring:
1. **There is no separate database.** Memory *is* entries inside the ordinary pi 1. **There is no separate database.** Memory *is* entries inside the ordinary pi
session `.jsonl`. Nothing extra to back up, and nothing to migrate. session file (`~/.pi/agent/sessions/<project>/<timestamp>_<uuid>.jsonl`).
2. **It survives container recreate**, because `~/.pi` is the Nothing extra to back up, nothing to migrate.
`devbox-pi-config` named volume — the same one that keeps your pi config and 2. **It survives container recreate**, because `~/.pi` is the `devbox-pi-config`
sessions. named volume (`docker-compose.yml`) — the same one holding your pi config and
3. **`packages[]` is the only source of truth for which copy is loaded.** A session history.
clone at `/workspace/pi-observational-memory` may exist (and today matches 3. **`packages[]` is the only source of truth for which copy is loaded.** A clone
`/opt` byte-for-byte at `ce9fc98`) — its presence proves nothing. If you ever at `/workspace/pi-observational-memory` may exist (and today matches `/opt`
run a patched build, you point `packages[]` at it explicitly and start a new byte-for-byte at `ce9fc98`) — its presence proves nothing. To run a patched
session. build you point `packages[]` at it explicitly and start a new session.
4. **The worker model is a deliberate choice, and it is yours to change.** The 4. **The worker model is a deliberate choice, and it is yours to change.** The
seeded config sends background work to Haiku while your session runs Opus: seeded config sends background work to Haiku while your session runs Opus:
@@ -164,19 +253,20 @@ Four consequences of that wiring:
} }
``` ```
## 7. What it costs ## 8. What it costs
| Resource | Cost | | Resource | Cost |
|---|---| |---|---|
| Model calls | up to **three** background calls per consolidation pass (observer, reflector, dropper), each capped at `agentMaxTurns` [16], on the configured memory model — not your session model | | Model calls | up to **three** background calls per consolidation pass (observer, reflector, dropper), each capped at `agentMaxTurns` [16], on the configured memory model — not your session model |
| Latency in your turns | none by construction: workers run from `turn_end`, and the compaction hook does no model work | | Latency in your turns | none by construction: workers run from `turn_end`, compaction runs when pi is idle, and the fold itself does no model work |
| Disk | negligible — JSON lines inside a session file that would exist anyway (measured here: `~/.pi/agent/sessions` = 30 MB total, tens of `om.*` entries per session) | | Disk | negligible — JSON lines inside a session file that would exist anyway (measured here: `~/.pi/agent/sessions` = 30 MB total, tens of `om.*` entries per session) |
| Context window | **zero until compaction.** `custom` entries do not enter LLM context; only the folded summary does |
| Attention | none once configured; there is no protocol for you or the agent to remember | | Attention | none once configured; there is no protocol for you or the agent to remember |
If that is still more than you want on a given run, §8's `passive` switch turns If that is still more than you want on a given run, §9's `passive` switch turns
off all proactive work while keeping `recall` and `/om:*` usable. off all proactive work while keeping `recall` and `/om:*` usable.
## 8. Configuration ## 9. Configuration
Global: `~/.pi/agent/settings.json` (persisted in the volume). Per project: Global: `~/.pi/agent/settings.json` (persisted in the volume). Per project:
`<project>/.pi/settings.json`, which overrides global. Precedence is `<project>/.pi/settings.json`, which overrides global. Precedence is
@@ -188,7 +278,7 @@ project → global → environment, and the environment can only override `passi
| `reflectAfterTokens` | `20000` | reflector cadence (and thereby dropper opportunities) | | `reflectAfterTokens` | `20000` | reflector cadence (and thereby dropper opportunities) |
| `observerChunkMaxTokens` | 20% of the memory model's context window, else `60000` | cap on one observer run's input | | `observerChunkMaxTokens` | 20% of the memory model's context window, else `60000` | cap on one observer run's input |
| `compactAfterTokens` | `81000` | when proactive auto-compaction fires | | `compactAfterTokens` | `81000` | when proactive auto-compaction fires |
| `observationsPoolMaxTokens` | `20000` | pressure at which compaction does a full fold | | `observationsPoolMaxTokens` | `20000` | pool size at which compaction does a full re-fold from the branch root |
| `observationsPoolTargetTokens` | half of max (`10000`) | what the dropper aims back down to | | `observationsPoolTargetTokens` | half of max (`10000`) | what the dropper aims back down to |
| `agentMaxTurns` | `16` | shared turn cap for the three workers | | `agentMaxTurns` | `16` | shared turn cap for the three workers |
| `model` | unset → session model | send background work to a cheaper/faster model | | `model` | unset → session model | send background work to a cheaper/faster model |
@@ -196,6 +286,10 @@ project → global → environment, and the environment can only override `passi
| `passive` | `false` | **kill switch** for all proactive background work; `recall` and `/om:*` still work | | `passive` | `false` | **kill switch** for all proactive background work; `recall` and `/om:*` still work |
| `debugLog` | `false` | per-session NDJSON trace at `~/.pi/agent/observational-memory/debug/<session-id>.ndjson` | | `debugLog` | `false` | per-session NDJSON trace at `~/.pi/agent/observational-memory/debug/<session-id>.ndjson` |
Pi's own compaction knobs live under a separate `compaction` key —
`keepRecentTokens` [20000] sets the verbatim tail from §4, `reserveTokens`
[16384] the headroom that triggers pi's own compaction.
One-off passive run, no config edit: One-off passive run, no config edit:
```bash ```bash
@@ -206,7 +300,7 @@ Invalid values are ignored rather than fatal, so a typo degrades to the default
instead of breaking your session — which also means a typo is silent. Check with instead of breaking your session — which also means a typo is silent. Check with
`/om:status`. `/om:status`.
## 9. Confirming it is actually working ## 10. Confirming it is actually working
Do not infer health from the absence of a warning; look: Do not infer health from the absence of a warning; look:
@@ -216,34 +310,36 @@ Do not infer health from the absence of a warning; look:
/om:view # what the agent currently sees /om:view # what the agent currently sees
/om:view full # full ledger truth at the branch tip /om:view full # full ledger truth at the branch tip
# 2. from a shell — are ledger entries being written? # 2. from a shell — are ledger entries being written, and has it compacted?
grep -o '"customType":"om\.[a-z.]*"' \ grep -o '"customType":"om\.[a-z.]*"' \
"$(ls -t ~/.pi/agent/sessions/*/*.jsonl | head -1)" | sort | uniq -c "$(ls -t ~/.pi/agent/sessions/*/*.jsonl | head -1)" | sort | uniq -c
grep -c '"type":"compaction"' "$(ls -t ~/.pi/agent/sessions/*/*.jsonl | head -1)"
# 3. which copy is loaded, and at what commit # 3. which copy is loaded, and at what commit
python3 -c "import json;print(json.load(open('$HOME/.pi/agent/settings.json'))['packages'])" python3 -c "import json;print(json.load(open('$HOME/.pi/agent/settings.json'))['packages'])"
git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory rev-parse HEAD git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory rev-parse HEAD
``` ```
Note the entry type in the transcript is `custom_message` (with Ledger entries are `"type":"custom"` with `"customType":"om.…"`. Do not grep for
`customType: "om.…"`), not `custom` — an easy grep to get wrong and conclude `custom_message` — that is a *different* pi API for entries that **do** enter LLM
nothing is happening. context, used here by the MemPalace mailbox (`customType: "mempalace-mailbox"`),
not by om.
## 10. It is not the same thing as MemPalace ## 11. It is not the same thing as MemPalace
Both are called "memory" and they solve different problems. Nothing is wrong Both are called "memory" and they solve different problems. Nothing is wrong with
with running both — this image does, and they cover each other's failure modes. running both — this image does, and they cover each other's failure modes.
```mermaid ```mermaid
flowchart LR flowchart LR
O0["<b>observational memory</b>"] --> O1["horizon:<br/><b>this session</b>"] O0["observational<br/>memory"] --> O1["horizon:<br/>this session"]
O1 --> O2["scope:<br/>one branch, one machine"] O1 --> O2["scope: one branch,<br/>one machine"]
O2 --> O3["automatic —<br/>no habit required"] O2 --> O3["automatic"]
O3 --> O4["retrieval:<br/>an id you already hold<br/>→ <code>recall</code>"] O3 --> O4["retrieval:<br/>recall(id)"]
P0["<b>MemPalace</b>"] --> P1["horizon:<br/><b>sessions, months, machines</b>"] P0["MemPalace"] --> P1["horizon: months,<br/>machines"]
P1 --> P2["scope:<br/>the whole fleet, every harness"] P1 --> P2["scope:<br/>the fleet"]
P2 --> P3["protocol-driven —<br/>search first, diary at the end"] P2 --> P3["protocol-driven"]
P3 --> P4["retrieval:<br/>semantic search, entity+time,<br/>mailbox"] P3 --> P4["retrieval:<br/>search, KG, mailbox"]
``` ```
| Question | Answer | | Question | Answer |
@@ -255,18 +351,21 @@ flowchart LR
| "Why is compaction not losing my session?" | observational memory | | "Why is compaction not losing my session?" | observational memory |
The crisp version: **observational memory keeps a session coherent; the palace The crisp version: **observational memory keeps a session coherent; the palace
keeps the fleet coherent.** A container recreate wipes neither — but only keeps the fleet coherent.** A container recreate wipes neither — but only because
because `~/.pi` and the palace both live outside the container filesystem. `~/.pi` and the palace both live outside the container filesystem.
## 11. Gotchas ## 12. Gotchas
- **Branch-local means branch-local.** Resuming or forking changes which ledger - **Branch-local means branch-local.** Resuming or forking changes which ledger
is folded. Memory that "disappeared" is usually on another branch. is folded. Memory that "disappeared" is usually on another branch.
- **`recall` needs an id, not a topic.** If you only have a topic, that is a - **`recall` needs an id, not a topic.** If you only have a topic, that is a
palace search, not a recall. palace search, not a recall.
- **A `/workspace` clone is not evidence of what is loaded** — see §6.3. - **A `/workspace` clone is not evidence of what is loaded** — see §7.3.
- **`showWorkerNotifications: true` is not proof of work**; it reports runs, and - **`showWorkerNotifications: true` is not proof of work**; it reports runs, and
an observer that deliberately emits nothing writes no ledger entry and simply an observer that deliberately emits nothing writes no ledger entry and simply
retries after another `observeAfterTokens`. retries after another `observeAfterTokens`.
- **`git log` in the baked tree needs `safe.directory`** (`/opt` is - **A turn bigger than `keepRecentTokens` splits.** The cut then lands mid-turn at
root-owned): `git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory log`. an assistant message and pi merges two summaries — rare, but it is why a very
large single turn can lose more verbatim detail than you would expect.
- **`git log` in the baked tree needs `safe.directory`** (`/opt` is root-owned):
`git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory log`.