pi-canon 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,18 +2,57 @@
2
2
 
3
3
  Canonical project memory for the [Pi coding agent](https://pi.dev). Every asset has at most one governing article, at an address computed from the asset's own path: `src/core/config.ts` is governed by `articles/src/core/config.md`. Beneath the articles sits an append-only journal, one file per event. When a tool call touches a governed asset, that article's one dense line arrives in the session unasked, so the agent does not have to know there was something to look up. Detecting the path in a tool call is best effort; resolving it to an article is not.
4
4
 
5
+ **One setup measured, others welcome.** pi-canon was developed and tested under one configuration: Codex, with GPT 5.6 as the worker model, on an OpenAI subscription. Every number in this README was measured there. Other models, other providers, and API-metered access are untested. If you run it under a different setup, feedback is welcome and so are pull requests.
6
+
5
7
  ![The store drawn as a graph, articles tethered to the assets they govern](https://raw.githubusercontent.com/shaneconner/pi-canon/main/docs/assets/pi-canon-constellation.gif)
6
8
 
7
9
  *An illustrative store: 33 articles, 20 journal entries, 40 files. Discs are articles, rings are journal entries hanging under the article each was distilled into, and a square tethered beneath a disc is the asset that article was named for. Six of the articles match no asset and hang untethered, because free knowledge is not a special case here. Selecting a node opens what it holds, what it points at, and what points at it.*
8
10
 
9
11
  ## Install
10
12
 
13
+ ### Pi
14
+
11
15
  ```
12
16
  pi install npm:pi-canon
13
17
  ```
14
18
 
15
19
  Or clone this repo into `~/.pi/agent/extensions/`. Node 22.18 or later, Pi 0.83 or later on the 0.x line. Nothing to configure: the store is created on first write at `<project>/.canon`. The package imports `node:fs` and `node:path` and nothing else, makes no network calls, runs no git, and loads under plain node with no build step.
16
20
 
21
+ ### Codex
22
+
23
+ The repository is a Codex marketplace. Add it once, then install the plugin at user scope:
24
+
25
+ ```sh
26
+ codex plugin marketplace add shaneconner/pi-canon
27
+ codex plugin add pi-canon@pi-canon
28
+ ```
29
+
30
+ For a local checkout under development, replace `shaneconner/pi-canon` with its absolute path. Start a new Codex thread after installing or updating it.
31
+
32
+ ### Claude Code
33
+
34
+ The same repository is also a Claude Code marketplace:
35
+
36
+ ```sh
37
+ claude plugin marketplace add shaneconner/pi-canon --scope user
38
+ claude plugin install pi-canon@pi-canon --scope user
39
+ ```
40
+
41
+ Again, an absolute checkout path works for local development. Start a new Claude Code session after installing or updating it.
42
+
43
+ Both plugins launch the same dependency-free MCP server and expose the same `pi_canon` actions as Pi. Codex surfaces after each tool result. Claude Code deduplicates one capsule packet across each parallel tool batch, immediately before the next model request, which avoids repeated message framing without delaying the agent's next decision. Both give one write-after reminder before the agent stops. An article surfaces at most once per compaction cycle: a compact starts a new cycle, while resuming the same uncompacted session does not. One session may contain several compaction cycles. Compaction discards prior touch state and replays nothing. After it, only a fresh tool-input path can surface that asset's exact or nearest-ancestor article; children and unrelated articles do not ride along. The hooks are inert in projects without `.canon/articles`, and they never create a store merely because a session opened. Review and approve the plugin hooks when the client asks. Journal entries written through the MCP server carry explicit `harness` provenance and a session identifier when the client exposes one.
44
+
45
+ ## Defaults
46
+
47
+ What you have after installing, with nothing configured:
48
+
49
+ - **Addressing and surfacing on touch are on.** Touch a governed asset with a tool call and its article's capsule arrives unasked. This is the pairing the measurements kept: addressing is what survives store growth, and surfacing is what makes addressing discoverable. In the sizing study, every session that asked for the decisive address scored and 0 of 30 that did not ask scored, so recall that waits to be asked stops working exactly when the store outgrows what a session already knows.
50
+ - **The journal is append-only and quiet.** It is never read whole and never surfaced unsolicited; search is the only channel that reaches it. Treating history as memory was priced at a median 340,119 tokens per session against 21,309 for a distilled document, for no more correctness.
51
+ - **Recommendation is off.** `retrieval: "none"` is the default because the channel pays only where the store holds knowledge the address spine cannot reach, declared rules governing no asset, and the package cannot know which kind of store it faces. It also needs a retriever this package deliberately does not choose for you. Turn it on with `retrieval: "lexical"`, and the standout gate arrives at its measured default of 1.4 with the drained-store guard.
52
+ - **Nothing greets the session.** The orientation line and the session-end check-back were removed by their own measurements: presenting the memory surface alone, with nothing behind it, took first-pass correctness from 25 of 32 to 8 of 32, and the check-back's reminder was delivered and never acted on.
53
+
54
+ The whole option surface is six keys: `root`, `surface`, `resurface`, `retrieval`, `standout`, `mounts`. Each is documented under [Options](#options) with the measurement that set its default. Everything else is a constant on purpose.
55
+
17
56
  ## The first article
18
57
 
19
58
  A session is not greeted: through 0.2.0 every session opened with an orientation line. A 2x2 experiment with an inert implementation found significant negative main effects for both that line and the tool schema, but their 19/32 versus 21/32 comparison did not resolve which component cost more. Study 3 detected no benefit loss when the line was removed, so 0.2.1 deleted it. The tool description carries the doctrine instead. The first article takes one tool call:
@@ -50,7 +89,7 @@ updated: 2026-08-11
50
89
  Resolution order is defaults, then config.toml, then environment. ...
51
90
  ```
52
91
 
53
- That is the whole storage format. `capsule` is the one dense line surfacing sends, collapsed to a single line on write whatever the agent sent. `updated` is the date of the last write, which is not the date the content last changed, and nothing compares it against the asset. Those two keys are the only ones pi-canon owns. Every other key in the block, Obsidian properties included, is carried through writes verbatim, and owned values are quoted only where plain YAML would misread them, so the tree stays editable by hand.
92
+ That is the whole storage format. `capsule` is the one dense line surfacing sends, collapsed to a single line on write whatever the agent sent. `updated` is the date of the last write that changed something: a write identical to the stored article is a no-op that touches nothing, so the stamp is not refreshed by restatements, and nothing compares it against the asset. Those two keys are the only ones pi-canon owns. Every other key in the block, Obsidian properties included, is carried through writes verbatim, and owned values are quoted only where plain YAML would misread them, so the tree stays editable by hand.
54
93
 
55
94
  The result is plain Markdown and a valid Obsidian vault. Commit it with your repo: git is the history, diff, blame, and time machine, and pi-canon never runs git itself. Journal entries are ordinary files too. The tool only appends them; read them with normal file tools.
56
95
 
@@ -105,26 +144,69 @@ One tool, `pi_canon`, five actions: `read`, `write`, `journal`, `map`, and `sear
105
144
  | action | parameters | does |
106
145
  |---|---|---|
107
146
  | `read` | `path` | Returns the governing article: title, `capsule`, `updated`, body, and a one-line journal index. A miss returns a sentence naming the address and inviting a write after the task. When an ancestor answers, the title reads `<ancestor> governs <address>`, so the altitude is visible. |
108
- | `write` | `path`, `capsule`, `body`, `scope` | Creates or updates the article, then returns `Wrote <address>.` and any advisory lint. Never refuses. An empty string means untouched, not erase. |
147
+ | `write` | `path`, `capsule`, `body`, `scope` | Creates or updates the article, then returns `Wrote <address>.` and any advisory lint. Never refuses. An empty string means untouched, not erase. A write identical to the stored article is reported as already current and touches nothing, so `updated` keeps meaning the date the content last changed. |
109
148
  | `journal` | `body`, `subject`, `slug` | Appends a dated entry as its own file, `<date>-<slug>[-n].md`. pi_canon can never rewrite one. An empty body gets a sentence back asking what happened. |
110
149
  | `map` | `path` (optional prefix) | One line per article as `address: capsule`, or a sentence when the store or the filter is empty. Output is unbounded. |
111
- | `search` | `query` | Ranks articles and journal entries against the words in one pass, ten results, each carrying what scopes it: an article its address and capsule, a journal entry its instant and subjects. Says how many matches the cap dropped. The one action that reaches the journal's content. |
150
+ | `search` | `query`, `journal` | Ranks articles against the words, ten results, each carrying what scopes it: an article its address and capsule. The journal is opt-in via `journal: true`, because events are history rather than current truth, and measured on real stores journal entries about an event crowd out the article carrying it; a default search never reads an entry body and says the journal exists. Opted in, journal entries are first class results scoped by instant and subjects, articles keep half the window, and a short side cedes its slots. Says how many matches the cap dropped. The one action that reaches the journal's content. |
112
151
 
113
152
  `subject` is an array of addresses. A subject passed as a bare string is ignored and the entry lands with none at all.
114
153
 
115
154
  Entries logged with `subject` addresses come back as a one-line index of filenames, newest three, when those articles are read: history on offer, never loaded by default. The index carries filenames only and never entry content, and matching is exact, so an entry filed at `src/core/config` does not appear when `src/core` is read. The journal always lives in the project store.
116
155
 
117
- Lint on a write is advisory strings appended to the response, never a refusal, because a blocked write teaches an agent to stop writing while a warning teaches it what to do next. It warns past 8,000 characters of body and suggests going hierarchical past 20,000. It names a missing capsule, one over 1,000 characters, or one written as a change log. An address carrying a `log`, `journal`, `session`, `standup` or `meeting` segment, or an ISO date, draws a redirect to the journal. Dead wikilinks are named one line each.
156
+ Lint on a write is advisory strings appended to the response, never a refusal, because a blocked write teaches an agent to stop writing while a warning teaches it what to do next. The one exception is a rule the store itself declared `required` in its schema, documented in [The article schema](#the-article-schema) below. It warns past 8,000 characters of body and suggests going hierarchical past 20,000. It names a missing capsule, one over 1,000 characters, or one written as a change log. An address carrying a `log`, `journal`, `session`, `standup` or `meeting` segment, or an ISO date, draws a redirect to the journal. Dead wikilinks are named one line each. An article whose asset went missing is named on read and on write: a nested address whose parent directory exists on disk while nothing matches the asset draws the orphan question, with the ways out spelled (move the article, fold it into the parent, or declare `scope: rule`). Root-level addresses, declared rules, and stores whose addresses never mapped to files stay silent.
157
+
158
+ A rewrite that grows the body says so in its own result: `Body grew 812 -> 1304 bytes.`, followed by the reminder that an article carries current state and narrated history (old values, transitions) belongs in the journal. Any growth fires; creation is not growth, and a capsule-only write never grows the stored body. This line is measured, and the measurement is a direction rather than a size. Across three two-arm captures over byte-identical eight-session lineages, the arm whose tool spoke it ended with fewer superseded values standing every time: 51 against 88 of 96 on one model, 45 against 87 on a second, and 71 against 85 when the first model was run again with the arm order reversed in half the lineages. Take the direction and not the magnitude. That third capture kept the direction and lost most of the gap, and re-running a matched untreated cell two days later moved its median store 39 percent, so the instrument does not measure its own magnitudes reliably. Store size never carried either: it fell a fifth, then barely moved, then fell two fifths, and across the paired lineages the direction of that change is not distinguishable from chance in the first two captures. Two readers out of 96 were harmed by a stale value, one from each arm, so the line is not known to protect readers. The same doctrine in a system prompt cleared one superseded value in thirty-two, though that arm ran in a separate one-session study and the two were never placed side by side.
118
159
 
119
160
  One lint line is different in kind. When a write supplies a body and an article was already there, the new body is compared against the prior one, and a line that carried constraint language and disappeared is quoted back at the write that removed it. The vocabulary is fixed: `must`, `never`, `always`, `require` in its `requires` and `required` forms, `do not`, and `don't`. At most two lines are named per write, each cut to its first 160 characters, with the note that if the constraint still holds it should stay, and if it genuinely changed, the change belongs in the journal. The quote is a prefix rather than a summary. It is an advisory: the write already landed, and nothing can make an agent put the line back.
120
161
 
162
+ ## The article schema
163
+
164
+ Every store carries its contract as a file: `schema.json` at the store root, written with the shipped defaults made explicit the first time the store persists anything. It is data rather than configuration, so it travels with the store, differs per project, and any other tool reading the store can enforce the same contract from the same file. The shipped defaults require nothing and mirror the advisory caps, so an untouched file changes no behavior; it only makes the contract visible and editable.
165
+
166
+ Three fields can carry rules: `capsule` (the front matter line surfacing injects), `title` (the body's leading `#` heading; there is deliberately no separate title input, so the rule checks the one place a title can live), and `body`. Four rule keys: `required`, `min_chars`, `max_chars`, `hint`.
167
+
168
+ Enforcement is asymmetric on purpose, and the asymmetry is measured: across five write-quality model captures in this project's lab, rules held at the tool boundary erred at zero while rules left to model judgment erred at about one percent. So the class of miss that matters is enforced rather than advised:
169
+
170
+ - A rule marked `required` rejects the write that violates it, hint included, and nothing touches disk. A write is judged on what it changes, plus everything when the article is first created, so a capsule-only update is never held hostage to a legacy body.
171
+ - Every other violation warns: the write lands and the message names what to fix.
172
+ - A read never rejects, but it reports standing issues, because the agent holding a noncompliant article is the one positioned to heal it.
173
+ - A malformed `schema.json` fails open and loud: rules stop being enforced and every write says so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
174
+ - A bound the schema declares owns its message: the built-in advisory line for the same aspect stays quiet instead of saying it twice.
175
+
176
+ A store that requires every article to open with a heading:
177
+
178
+ ```json
179
+ {
180
+ "schema_version": 1,
181
+ "article": {
182
+ "capsule": { "required": true, "max_chars": 1000, "hint": "One dense line of current truth." },
183
+ "title": { "required": true, "hint": "Start the body with a # heading naming the asset." }
184
+ }
185
+ }
186
+ ```
187
+
188
+ Delete a rule to drop it; delete the file to disable schema checks entirely. An edited file is never overwritten.
189
+
190
+ Relations rules live in the same file under `relations`, because the reference graph is part of the contract too; each tool enforces the rules it can see, so one file governs every tool that reads the store. This package sees an article's own citations at the write boundary and enforces `refs`: `required` rejects a write whose body cites nothing, judged like the field rules (the write that changed the reference set, or a creation), and `min_count` warns under a floor, with code-fenced examples and case-folded duplicates never counted. The two graph-wide rules parse here and are enforced by graph-reading tools such as [canon-atlas](https://github.com/shaneconner/canon-atlas): `orphan.warn` warns when no other article references this one, and `children.listed` warns when an article does not reference each direct child under its address.
191
+
192
+ ```json
193
+ {
194
+ "schema_version": 1,
195
+ "relations": {
196
+ "refs": { "required": true, "hint": "Name what this concerns." },
197
+ "orphan": { "warn": true },
198
+ "children": { "listed": true }
199
+ }
200
+ }
201
+ ```
202
+
121
203
  ## Surfacing
122
204
 
123
205
  A tool call stages the governing article for whatever it touched and sends nothing. Each turn end flushes everything staged as a single message, because pi's steering queue drains one message per provider round trip and a message per tool call would buy every nudge its own model call. An article with a presence mark surfaces at most once while that mark remains in the context the provider receives; one folded or compacted away returns to surfacing and rides again on its asset's next touch (the `resurface` option below). Delivered text shorter than 24 normalized characters cannot be tested safely and conservatively stays seen for the session. Nothing persists across sessions: a new session re-surfaces everything.
124
206
 
125
207
  No character count decides any of this. A capsule is written to fit 1,000 characters, and that is a target handed to the agent at write time, not a gate at read time: an article whose governing asset a turn touched surfaces whole or does not surface. Earlier versions charged capsule text against a session allowance and degraded the overflow to bare pointers. That allowance was removed in 2.0. It was a constant guessing at a policy nobody had measured, and what it decided was how much an agent got to see. What stands in its place is measurement: every surfaced line records what it cost the window, so context taken can be read against relevance afterwards instead of a constant ruling on it in advance. The one remaining reason a line is not capsule text is an article that has no capsule, which surfaces as a pointer naming the address and telling the agent to read it.
126
208
 
127
- Reading an article through `pi_canon` withdraws the line staged for it before the message goes out, so pull preempts push. Reading the asset file itself does not, because reading a file is not reading what is known about it, and the capsule may hold exactly the constraint the file does not contain. After the agent settles, articles touched but not updated draw one reminder naming them, once per batch, re-armed by the next touch.
209
+ Reading an article through `pi_canon` withdraws the line staged for it before the message goes out, so pull preempts push. Reading the asset file itself does not, because reading a file is not reading what is known about it, and the capsule may hold exactly the constraint the file does not contain. A read-only session exits quietly. After a successful write, edit, patch, or recognized mutating shell call names a governed asset, settling draws one reminder for its article if the article was not updated, once per batch and re-armed by the next modifying call. Unknown tools still surface knowledge when they name a path, but do not invent an update obligation without positive mutation evidence.
128
210
 
129
211
  Finding a path in a tool call is best effort. Only the input of a tool call is scanned. Results are never scanned, and neither is the model's prose. Inputs are scanned for whole short strings and path-shaped tokens that exist on disk or whose parent directory does, so a file about to be created still surfaces its governing ancestor, and a path with a space inside a longer string is missed. What that feeds, resolution from a path to a governing article, is deterministic. The two claims stay separate on purpose.
130
212
 
@@ -145,6 +227,8 @@ export default function (pi) {
145
227
 
146
228
  Six keys, and any other throws at registration by name, because everything else is a constant on purpose.
147
229
 
230
+ The four behavior keys (`surface`, `resurface`, `retrieval`, `standout`) can also come from `~/.config/pi-canon/settings.json`, which the `/canon-settings` command edits from inside the TUI: booleans and retrieval cycle, the standout cutoff steps along its lattice with left/right and takes an exact value on Enter, and every applied change saves immediately through the same validation registration uses. Explicit options win over the file. `root` and `mounts` are per-project topology and stay code-only; they have no row in the editor and no place in the file.
231
+
148
232
  - **`root`** places the store. Absolute is used as given, relative joins the project cwd. Default `<project>/.canon`.
149
233
  - **`surface: false`** silences the per-turn flush and the settle reminder. The `pi_canon` tool and `/pi-canon` stay registered and working.
150
234
  - **`resurface: false`** returns an article to surfacing at most once per session however long ago it left the window. The default is `true`: an article with a presence mark counts as seen only while that mark remains in the context the provider receives, so one folded or compacted away surfaces again the next time its asset is touched. Text shorter than 24 normalized characters has no safe mark and conservatively retains the once-per-session behavior. A fresh touch is what brings a marked article back, so nothing re-surfaces on its own.
@@ -171,7 +255,9 @@ Held by the runtime:
171
255
  - Once a path is in hand it resolves to exactly one article, walking to the nearest ancestor that has one, or to nothing at all.
172
256
  - An article surfaces whole, with no character count able to truncate it or hold it back.
173
257
  - An article with a presence mark surfaces at most once while that mark remains in the context the provider receives. Presence is read from that projection rather than remembered, so folding or compaction returns a marked article to surfacing; an untestably short delivery or a harness that reports no projection degrades to at most once per session.
258
+ - The Codex and Claude Code hooks cannot inspect that projection. They use an explicit compaction cycle instead: session start, context clear, and compaction discard prior touch state; resume does not. None of those events surfaces an article. A later asset touch does. Codex delivers after each tool result; Claude Code combines the current parallel batch into one packet before the next model request.
174
259
  - Reading an article through the tool withdraws its staged capsule before the message goes out.
260
+ - Read-only tools can surface an article but never arm its write-after reminder. A successful modifying tool can.
175
261
 
176
262
  Asked of the agent, and checked by nothing:
177
263
 
@@ -197,53 +283,48 @@ What the package does not do, stated so nothing above reads as more than it is:
197
283
 
198
284
  ## Evidence
199
285
 
200
- The population comes before the numbers: five author-built chains, development-exposed and reused by the confirmatory run, four eligible trap designs, each repeated five times, one worker model, under a protocol frozen with a hash manifest before that run.
201
-
202
- The unit is a cell: a fresh worktree holding a small fictional repository, run through four sessions that share it. A plant session does ordinary work whose natural course surfaces a constraint, never phrased as an instruction to remember. A distractor session comes in between. A probe session then gets a task whose obvious solution violates that constraint in a way that compiles, runs, and fails a grader the agent never sees. A recall session answers an auditor afterwards, one judge call per fact.
203
-
204
- Four arms run every cell. `canon` is stock Pi plus this package at 0.1.0, the build the study measured. `canondoc` is canon plus a static doctrine file beside it. `agents.md` is a self-maintained convention file preloaded with 99 lines of mature-project noise. `bare` loads no memory extension, and it is a stronger floor than the name suggests: prior-session transcripts land in its worktree before the recall session and its agent is on record reading them, so it is a no-extension floor at probe time and a transcript baseline at recall.
205
-
206
- | arm | trap cells (of 20) | all checks (of 110) | plant-only recall (of 45) | median recall tokens | total chain cost |
207
- |---|---|---|---|---|---|
208
- | canon | 19 | 109 | 41 | 20,775 | $0.5454 |
209
- | agents.md | 18 | 107 | 42 | 64,568 | $0.6227 |
210
- | canondoc | 16 | 105 | 40 | 13,991 | $0.4639 |
211
- | bare | 8 | 85 | 40 | 61,006 | $0.5345 |
212
-
213
- ![Every eligible probe cell as a square, four trap designs by five repetitions, per arm](https://raw.githubusercontent.com/shaneconner/pi-canon/main/docs/assets/fig-trap.png)
214
-
215
- *One square per eligible probe cell: four trap designs across, five repetitions within each, one row per arm. Every consumer-contract cell is a loss for bare. canon loses one cell in the whole grid, chain 04 repetition 1, which is the design that costs every arm at least one.*
216
-
217
- Read the unconditioned column beside the headline one. The trap metric is conditioned on the floor arm's cold failures, which is the strongest objection to it, so the unconditioned count scores all 110 intended checks whether or not a cold worker had already failed them, and the ordering survives. That count is check-level rather than an unconditioned version of the cell metric, and it was computed after the run rather than frozen with the protocol. Read the 18 before the 8: a self-maintained convention file, deliberately burdened with 99 lines of noise, finished one repeated cell behind the package, and quoting the gap against the floor without that number would be managing the reader rather than informing them.
218
-
219
- Recall is a wash and has to be reported as one. Plant-only recall, 45 judged facts per arm: agents.md 42, canon 41, canondoc 40, bare 40. One fact flagged as paraphrase-sensitive before the freeze carries 9 of the 17 misses across arms, and striking it leaves canon level with bare. An ordering that moves when one judged item is removed is not an ordering.
220
-
221
- Where the arms separate is the price of the answer. Median recall session tokens ran canondoc 13,991, canon 20,775, bare 61,006, agents.md 64,568, so canon answers at 0.34x bare's median. That does not make it the cheapest arm end to end. Total chain cost ran canondoc $0.4639, bare $0.5345, canon $0.5454, agents.md $0.6227, so canon is not the cheapest arm overall, and canondoc is lowest on both metered measures while passing three fewer trap cells. Every dollar figure is metered worker-session cost at that day's rates; the judge calls sit outside all of them, in equal number per arm. A package-level study offers no account of why.
222
-
223
- The result that changed the roadmap is not in that run at all. A forensic pass over a development run classified 14 recall misses by where each first went wrong.
224
-
225
- ![Fourteen misses classified by first failure point, thirteen of them at the write desk](https://raw.githubusercontent.com/shaneconner/pi-canon/main/docs/assets/fig-writedesk.png)
226
-
227
- *The 14 recall misses from a development run, each placed at the point it first went wrong: 8 never captured into any tier, 5 captured and then overwritten by a later rewrite, 1 judge error, and 0 lost at retrieval or surfacing.*
228
-
229
- That is development evidence over two arms of one run and it carries no confirmatory weight, but 13 of 14 is not a close call and it points somewhere specific. None of the misses was a fact sitting in the store that recall failed to reach, which is the failure a retrieval-shaped design would predict. A store that surfaces perfectly cannot surface what was never written down, so on this evidence the open problem is write-side fidelity rather than recall coverage: the hard moment is when an agent has just learned something, is mid-task, and has a live prompt in front of it asking for something else. The constraint guard is a first answer to the rewrite half of that, and an incomplete one.
230
-
231
- ### What the run does not establish
232
-
233
- - The five chains are development-exposed. The product changed in response to failures on these same chains, and the confirmatory run reuses them, so the freeze confirms disciplined execution rather than generalization to unseen tasks.
234
- - The result is package-level. It attributes nothing to the journal, the spine, or surfacing separately. A later development probe tried to: a sham arm carrying this package's exact tool schema and orientation line with an inert implementation, so the surface is present and no memory work happens behind it. On one chain at 15 repetitions, first-pass correctness ran bare 12/15, canon 8/15, sham 7/15. canon and sham are indistinguishable (Fisher exact, p=1.0000) and neither separates from bare at that size (p=0.25 and p=0.13). Fifteen repetitions cannot establish equivalence, so read it as the absence of a signal rather than the presence of a null. What it does say is that any account of this package's costs has to start with the tool surface, because nothing behind the surface has yet been shown to contribute to them.
235
- - No evaluated arm is a search-driven LLM wiki, so nothing here is a comparison against one.
236
- - The `agents.md` arm is one construct, a self-maintained file under author-designed preload noise, with no clean-file or human-maintained counterpart run beside it.
237
- - Eligibility is model-relative. A check counts as a trap only where a cold run of the worker failed it, so every number built on it moves when the worker does.
238
- - One author wrote the package, the chains, the traps, and the graders.
239
- - Five repetitions of one trap design are five looks at one design, so no uncertainty interval is attached to any pooled count.
286
+ This package is measured rather than asserted, and the measurements live in
287
+ their own repository: [canon-bench](https://github.com/shaneconner/canon-bench),
288
+ the benchmark and evidence repo for this line of work.
289
+
290
+ The headline study runs five multi-session chains in a shared worktree, each
291
+ planting a constraint in one session and probing it in a later one, and scores
292
+ whether the agent still honors it. Against a no-extension floor that gets the
293
+ prior transcripts and is on record reading them, this package avoided 19 of 20
294
+ trap cells to the floor's 8, and answered recall audits at about a third of the
295
+ floor's median token cost. Recall accuracy itself was a wash across arms, and a
296
+ static doctrine file was cheaper on both metered measures while passing three
297
+ fewer trap cells. Full tables, the arms, and the limitations are in
298
+ [RESULTS.md](https://github.com/shaneconner/canon-bench/blob/main/RESULTS.md).
299
+
300
+ That study's forensic pass is what set the current research direction: of
301
+ fourteen recall misses, thirteen first went wrong at the write desk (never
302
+ captured, or captured and later overwritten) and none at retrieval. The
303
+ write-side programme that followed is in
304
+ [write-desk/](https://github.com/shaneconner/canon-bench/tree/main/write-desk),
305
+ and it is where the growth line documented above comes from: two arms over
306
+ byte-identical eight-session histories, where the arm whose tool names article
307
+ growth ended with fewer superseded values standing in all three captures. The
308
+ third of those is counterbalanced and is the one to read first, because it kept
309
+ the direction and took most of the magnitude back.
310
+
311
+ ## Papers
312
+
313
+ Each carries its per-cell artifact trail on Zenodo. Every DOI below is a concept
314
+ DOI, so it resolves to the newest version of that paper rather than to one frozen
315
+ deposit.
316
+
317
+ - **Mutable Canonical Memory over an Immutable Journal, with Recall by Surfacing**, [doi:10.5281/zenodo.21890647](https://doi.org/10.5281/zenodo.21890647). The first campaign, and the one that asks whether the design holds up at all: one governing article per asset, an append-only journal beneath it, and recall that arrives on a touch, measured against a no-extension floor that received the prior transcripts and is on record reading them.
318
+ - **Pricing Recall in Long-Term Memory for AI Agents**, [doi:10.5281/zenodo.21960350](https://doi.org/10.5281/zenodo.21960350). Six studies on what recall costs and which parts of it earn their keep. It priced the orientation line and the tool schema (both negative, both deleted), set the `standout` cutoff at a measured operating point, and found the store size past which recall that waits to be asked stops working.
319
+ - **The Write Desk**, [doi:10.5281/zenodo.22057257](https://doi.org/10.5281/zenodo.22057257). The first two papers measured recall and took for granted that what the store holds is true. This one tests that and finds it does not hold: writers repeatedly left superseded values in records whose contract is to state what is true now. A condition where the tool speaks at the write boundary ended lower on that endpoint in 20 of 24 capture-lineage comparisons, tied in 2 and higher in 2, but the size did not survive a counterbalanced repeat and is withdrawn rather than qualified. It also freezes the retrieval benchmark that had been reading its corpus live, and reports the cost of two defects found in that freezing by review.
240
320
 
241
321
  ## More
242
322
 
243
- - The paper, with the per-cell artifact trail: [doi:10.5281/zenodo.21890647](https://doi.org/10.5281/zenodo.21890647).
244
- - The benchmark, drivers, frozen protocol, and the verifier that recomputes the paper's quantitative claims from the artifacts: [canon-bench](https://github.com/shaneconner/canon-bench).
245
- - Interactive versions of every figure and the full measurement story: [shaneconner.com/projects/pi-canon](https://shaneconner.com/projects/pi-canon/).
246
- - The narrative version: [My agents' wiki was written faster than it was read](https://medium.com/@shane.conner/my-agents-wiki-was-written-faster-than-it-was-read-and-what-was-read-sold-me-back-debt-i-had-a8085319c68b).
323
+ - The benchmark, drivers, frozen protocol, and the verifier that recomputes each paper's quantitative claims from the artifacts: [canon-bench](https://github.com/shaneconner/canon-bench).
324
+ - [canon-atlas](https://github.com/shaneconner/canon-atlas), a viewer for a store like this one: the article graph, what each node points at, and what points back. It was built with pi-canon in mind and kept package-agnostic, so it reads any directory of structured Markdown serving as memory. It also enforces the two graph-wide `relations` rules this package parses but cannot see on its own, `orphan.warn` and `children.listed`.
325
+ - Interactive versions of the figures and the full measurement story, Part 1 and Part 2: [shaneconner.com/projects/pi-canon](https://shaneconner.com/projects/pi-canon/).
326
+ - The first campaign's narrative version: [My agents' wiki was written faster than it was read](https://medium.com/@shane.conner/my-agents-wiki-was-written-faster-than-it-was-read-and-what-was-read-sold-me-back-debt-i-had-a8085319c68b).
327
+ - The second campaign's, on pricing recall: [Pricing recall in long-term memory for AI agents](https://medium.com/@shane.conner/pricing-recall-in-long-term-memory-for-ai-agents-7d73f6418c17).
247
328
  - [pi-fold](https://github.com/shaneconner/pi-fold), a separate optional package serving the working tier. pi-canon ships the two persistent tiers of the same four-tier stack: the journal is the episodic tier, the canon the semantic tier. The two compose, neither requires the other, and neither knows what the other has spent.
248
329
 
249
- MIT. In a clone of this repo, `node tests/verify.mjs` runs the gate suite: every invariant prints by name and the run must end `all N gates green`, 142 of them at this release.
330
+ MIT. In a clone of this repo, `node tests/verify.mjs` runs the gate suite: every invariant prints by name and the run must end `all N gates green`, 174 of them at this release.
@@ -4,7 +4,7 @@ import { appendFileSync } from "node:fs";
4
4
  import { basename, isAbsolute, join } from "node:path";
5
5
  import { buildRetriever, type RetrievalOption } from "./lib/retrieval.ts";
6
6
  import { CanonStore } from "./lib/store.ts";
7
- import { Surfacer, type Mount } from "./lib/surfacing.ts";
7
+ import { changesAssets, Surfacer, type Mount } from "./lib/surfacing.ts";
8
8
  import { buildCanonTool, type CanonRuntime } from "./lib/tool.ts";
9
9
 
10
10
  export interface CanonOptions {
@@ -87,6 +87,7 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
87
87
  }
88
88
 
89
89
  let runtime: CanonRuntime | undefined;
90
+ const modifyingCalls = new Map<string, string[]>();
90
91
 
91
92
  const ready = (ctx: any): CanonRuntime => {
92
93
  if (!runtime) {
@@ -124,6 +125,7 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
124
125
  doctrine rides the tool description, which every session carries anyway. */
125
126
  pi.on("session_start", (_event: unknown, ctx: any) => {
126
127
  runtime = undefined;
128
+ modifyingCalls.clear();
127
129
  ready(ctx);
128
130
  });
129
131
 
@@ -141,8 +143,21 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
141
143
  pi.on("tool_call", (event: any, ctx: any) => {
142
144
  if (!surface || event?.toolName === "pi_canon") return;
143
145
  const { surfacer } = ready(ctx);
144
- surfacer.collect(surfacer.pathsIn(event?.input));
146
+ const assets = surfacer.pathsIn(event?.input);
147
+ surfacer.collect(assets);
145
148
  surfacer.noteIntent(event?.toolName, event?.input);
149
+ if (typeof event?.toolCallId === "string" && changesAssets(event?.toolName, event?.input)) {
150
+ modifyingCalls.set(event.toolCallId, assets);
151
+ }
152
+ });
153
+
154
+ /* A change-capable call arms the reminder only after it actually returns without an
155
+ error. Calls blocked before execution and failed edits therefore stay read-only. */
156
+ pi.on("tool_result", (event: any, ctx: any) => {
157
+ if (!surface || typeof event?.toolCallId !== "string") return;
158
+ const assets = modifyingCalls.get(event.toolCallId);
159
+ modifyingCalls.delete(event.toolCallId);
160
+ if (assets && event?.isError !== true) ready(ctx).surfacer.markChanged(assets);
146
161
  });
147
162
 
148
163
  pi.on("turn_end", (_event: unknown, ctx: any) => {
@@ -1,10 +1,16 @@
1
1
  /* Package entry. Pi calls the default export with the extension API; embedders use
2
- the named export to pass options. */
2
+ the named export to pass options.
3
3
 
4
+ The behavior options (surface, resurface, retrieval, standout) may also come
5
+ from ~/.config/pi-canon/settings.json, which /canon-settings edits; explicit
6
+ options win over the file. root and mounts are per-project and stay code-only. */
7
+
8
+ import { registerCanonSettings, loadCanonSettingsFile } from "./settings.ts";
4
9
  import { registerPiCanon } from "./canon.ts";
5
10
 
6
- export { registerPiCanon };
11
+ export { registerPiCanon, registerCanonSettings, loadCanonSettingsFile };
7
12
 
8
13
  export default function piCanon(pi) {
9
- return registerPiCanon(pi, {});
14
+ registerCanonSettings(pi);
15
+ return registerPiCanon(pi, loadCanonSettingsFile());
10
16
  }
@@ -1,7 +1,10 @@
1
1
  /* Advisory only: advice strings, never a refusal. A blocked write teaches an agent
2
2
  to stop writing; a warning teaches it what to do next. */
3
3
 
4
+ import { statSync } from "node:fs";
5
+ import { join } from "node:path";
4
6
  import { governsAnAsset, RULE_SCOPE } from "./retrieval.ts";
7
+ import type { CanonSchema } from "./schema.ts";
5
8
  import { normalize, type Article, type CanonStore } from "./store.ts";
6
9
 
7
10
  export const BODY_WARN_CHARS = 8000;
@@ -93,9 +96,19 @@ export function advise(
93
96
  store: CanonStore,
94
97
  priorBody?: string,
95
98
  reach?: Reach,
99
+ schema?: CanonSchema,
96
100
  ): string[] {
97
101
  const advice: string[] = [];
98
102
  const size = article.body.length;
103
+ /* A schema-declared aspect owns its message: when the store's schema.json bounds a
104
+ field, the schema check reports the violation with the owner's own hint, and the
105
+ built-in line for the same aspect stays quiet instead of saying it twice. */
106
+ const declared = {
107
+ capsuleRequired: schema?.capsule?.required === true,
108
+ capsuleMax: schema?.capsule?.max_chars !== undefined,
109
+ bodyMax: schema?.body?.max_chars !== undefined,
110
+ bodyMin: schema?.body?.min_chars !== undefined,
111
+ };
99
112
 
100
113
  /* The laundering guard: an agent that just violated a documented constraint will
101
114
  faithfully update the article to describe the violation as current truth. Name
@@ -161,15 +174,15 @@ export function advise(
161
174
  );
162
175
  }
163
176
 
164
- if (size > BODY_LARGE_CHARS) {
177
+ if (size > BODY_LARGE_CHARS && !declared.bodyMax) {
165
178
  advice.push(
166
179
  `Body is ${size} chars (large past ${BODY_LARGE_CHARS}). Go hierarchical: keep this article ` +
167
180
  `as the summary and router, and move detail into children under ${article.path}/ at chunks ` +
168
181
  `worth loading separately.`,
169
182
  );
170
- } else if (size > BODY_WARN_CHARS) {
183
+ } else if (size > BODY_WARN_CHARS && size <= BODY_LARGE_CHARS) {
171
184
  advice.push(`Body is ${size} chars (warn past ${BODY_WARN_CHARS}). Densify before it needs splitting.`);
172
- } else if (size > 0 && size < BODY_TINY_CHARS) {
185
+ } else if (size > 0 && size < BODY_TINY_CHARS && !declared.bodyMin) {
173
186
  const parent = parentOf(article.path);
174
187
  if (parent && store.read(parent)) {
175
188
  advice.push(`Body is ${size} chars. Consider folding it into ${parent}; keep children only at real asset or chunk boundaries.`);
@@ -177,8 +190,10 @@ export function advise(
177
190
  }
178
191
 
179
192
  if (!article.capsule) {
180
- advice.push("No capsule. Add one dense line of front matter; surfacing has nothing to inject without it.");
181
- } else if (article.capsule.length > CAPSULE_CHARS) {
193
+ if (!declared.capsuleRequired) {
194
+ advice.push("No capsule. Add one dense line of front matter; surfacing has nothing to inject without it.");
195
+ }
196
+ } else if (article.capsule.length > CAPSULE_CHARS && !declared.capsuleMax) {
182
197
  advice.push(
183
198
  `Capsule is ${article.capsule.length} chars (cap ${CAPSULE_CHARS}). A capsule is one dense line, not a second body.`,
184
199
  );
@@ -204,3 +219,30 @@ function parentOf(path: string): string {
204
219
  const cut = path.lastIndexOf("/");
205
220
  return cut === -1 ? "" : path.slice(0, cut);
206
221
  }
222
+
223
+ /* The article whose asset went missing: the complement scope: rule was designed
224
+ against, finally checked from the other side. Fires only when the address is
225
+ nested and its PARENT directory really exists on disk, so a store of purely
226
+ conceptual addresses (a knowledge base whose articles never mapped to files)
227
+ stays silent: the rename-or-delete case this catches is precisely an article
228
+ whose neighborhood is real while its asset is not. Root-level addresses are
229
+ conventional (a project or concept name) and are never questioned. Advisory
230
+ on read and write, never a refusal, because the agent holding the article is
231
+ the one positioned to heal it. */
232
+ export function orphaned(dir: string, article: Article): string | undefined {
233
+ if (article.scope === RULE_SCOPE) return undefined;
234
+ const cut = article.path.lastIndexOf("/");
235
+ if (cut === -1) return undefined;
236
+ try {
237
+ if (!statSync(join(dir, article.path.slice(0, cut))).isDirectory()) return undefined;
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ if (governsAnAsset(dir, article.path)) return undefined;
242
+ return (
243
+ `No asset on disk matches ${article.path}, though its parent directory exists. ` +
244
+ "If the asset moved, move this article to the new address; if the asset is gone, " +
245
+ "fold what still matters into the parent article and journal the retirement; if the " +
246
+ "address is deliberate, write it with scope rule."
247
+ );
248
+ }