pi-canon 0.2.2 → 0.2.4

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
@@ -8,15 +8,41 @@ Canonical project memory for the [Pi coding agent](https://pi.dev). Every asset
8
8
 
9
9
  ## Install
10
10
 
11
+ ### Pi
12
+
11
13
  ```
12
14
  pi install npm:pi-canon
13
15
  ```
14
16
 
15
17
  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
18
 
19
+ ### Codex
20
+
21
+ The repository is a Codex marketplace. Add it once, then install the plugin at user scope:
22
+
23
+ ```sh
24
+ codex plugin marketplace add shaneconner/pi-canon
25
+ codex plugin add pi-canon@pi-canon
26
+ ```
27
+
28
+ For a local checkout under development, replace `shaneconner/pi-canon` with its absolute path. Start a new Codex thread after installing or updating it.
29
+
30
+ ### Claude Code
31
+
32
+ The same repository is also a Claude Code marketplace:
33
+
34
+ ```sh
35
+ claude plugin marketplace add shaneconner/pi-canon --scope user
36
+ claude plugin install pi-canon@pi-canon --scope user
37
+ ```
38
+
39
+ Again, an absolute checkout path works for local development. Start a new Claude Code session after installing or updating it.
40
+
41
+ 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.
42
+
17
43
  ## The first article
18
44
 
19
- A session is not greeted: through 0.2.0 every session opened with an orientation line, and a 2x2 experiment with an inert implementation priced that line at more first-pass correctness than the whole tool schema, so 0.2.1 deleted it. The tool description carries the doctrine instead. The first article takes one tool call:
45
+ 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:
20
46
 
21
47
  ```json
22
48
  { "action": "write",
@@ -82,7 +108,7 @@ pi-canon is an increment on the pattern rather than a replacement for it, and it
82
108
 
83
109
  **A spine**, the addressing convention. An article's address is computed from the asset instead of searched for, and nothing has to be configured for that mapping to hold, which makes the spine a convention rather than a mode. It is also why nothing in the RECALL path searches: when a touch already decided the address, there is nothing to find. The `search` action exists for the opposite direction, the agent that wants to ask, and it never runs unasked.
84
110
 
85
- **Surfacing**, push rather than pull. When a tool call is detected touching a governed asset, that article's capsule is staged for the session, at most once per article, so nobody has to think to ask. Detection of a path inside a tool call is best effort. Resolution, once a path is in hand, is not.
111
+ **Surfacing**, push rather than pull. When a tool call is detected touching a governed asset, that article's capsule is staged for the session, at most once per article while it stays in context (see Surfacing), so nobody has to think to ask. Detection of a path inside a tool call is best effort. Resolution, once a path is in hand, is not.
86
112
 
87
113
  The evaluation below does not test that lineage argument: no evaluated arm is a search-driven LLM wiki, so nothing here shows pi-canon beats a disciplined one.
88
114
 
@@ -96,7 +122,7 @@ A rename is a file move you make yourself. pi-canon does not watch the filesyste
96
122
 
97
123
  An article matching no asset is ordinary free knowledge. The spine guarantees an address for the assets a project already has; it does not confine the store to them. The tradeoff is worth stating in the same breath: surfacing is asset-scoped, so an off-spine article is reached by a link or an explicit read rather than pushed on a touch, or by relevance when a retriever is configured.
98
124
 
99
- Such an article may say so, with `scope: rule` on the write. Nothing filters on it, because a declaration the agent forgot must never cost it the only mechanism that can reach it. What it buys is that the two populations stop being one number: an article off the asset path is either a cross-cutting rule filed exactly where the doctrine asked for it, or an article whose asset was deleted under it, and those want opposite things. `scope: asset` takes the declaration back.
125
+ Such an article may say so, with `scope: rule` on the write. Forgetting the declaration never excludes an off-spine article from retrieval. Declaring it separates a rule filed on purpose from an article whose asset disappeared, and keeps that rule in the retrieval corpus if an asset later appears at the same address. `scope: asset` takes the declaration back.
100
126
 
101
127
  ## The tool
102
128
 
@@ -120,7 +146,7 @@ One lint line is different in kind. When a write supplies a body and an article
120
146
 
121
147
  ## Surfacing
122
148
 
123
- 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 surfaces at most once per session, and nothing about that persists: a new session re-surfaces everything.
149
+ 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
150
 
125
151
  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
152
 
@@ -147,10 +173,10 @@ Six keys, and any other throws at registration by name, because everything else
147
173
 
148
174
  - **`root`** places the store. Absolute is used as given, relative joins the project cwd. Default `<project>/.canon`.
149
175
  - **`surface: false`** silences the per-turn flush and the settle reminder. The `pi_canon` tool and `/pi-canon` stay registered and working.
150
- - **`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 counts as seen only while it is still in the context the provider receives, so one folded or compacted away surfaces again the next time its asset is touched. A fresh touch is what brings it back, so nothing re-surfaces on its own.
151
- - **`retrieval`** ranks the articles that govern no asset, the one category the address spine can never reach, against what the agent is doing. The default is `"none"`, which ranks nothing and surfaces nothing unaddressed: the spine alone, exactly as 1.0. `"lexical"` is BM25 over the standard library, no dependency and no model. Anything that needs a model is supplied here as `{ name, score, index? }`, so this package never carries one and never decides which you run. With a retriever configured the tool's filing rule changes with it, because the advice costs knowledge in either direction. On the default it says knowledge filed off the asset path never surfaces, which is true and is why you should not file it there. With a retriever it says the opposite: a constraint governing many assets and owning none belongs at its own address naming the rule, because the only parent unrelated packages share is the root and a root article surfaces on every touch of anything.
176
+ - **`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.
177
+ - **`retrieval`** ranks the retrieval corpus against what the agent is doing: every off-spine article, plus any article declared `scope: rule` so a rule stays reachable if an asset later appears at its address. Ordinary asset-scoped articles stay out because the address spine already reaches them. The default is `"none"`, which ranks and surfaces nothing by relevance: the spine alone, exactly as 1.0. `"lexical"` is BM25 over the standard library, no dependency and no model. Anything that needs a model is supplied here as `{ name, score, index? }`, so this package never carries one and never decides which you run. With a retriever configured the tool's filing rule changes with it, because the advice costs knowledge in either direction. On the default it says knowledge filed off the asset path never surfaces, which is true and is why you should not file it there. With a retriever it says the opposite: a constraint governing many assets and owning none belongs at its own address naming the rule, because the only parent unrelated packages share is the root and a root article surfaces on every touch of anything.
152
178
 
153
- - **`standout`** is how far the best-ranked article must beat the best one that will not ride anyway, meaning the fourth, the one the three-per-message cap was already going to leave behind. A multiple, not a score: `standout: 1.5` asks for the best to score half again what the first held-back rival scored. Default `1.4`, an operating point priced by a 120-cell benchmark rather than picked: it matched the uncut channel's delivery to within that benchmark's own noise floor while cutting suggestions from 26 a session to 3, raised the rate at which the agent acted on one from 0.17 to 0.82, and never fired at all on a store with nothing relevant to say, 0 rankings of 139. Precision is the side to protect, though the measured argument is tokens rather than attention: the same benchmark found good suggestions opened at the same rate buried among twenty-seven as one of four, so what the cutoff saves is the context it declines to spend on lines that were never going to be read. The other side has a price too, and the same benchmark paid it: a cutoff set past what a decisive ranking reaches silences the channel, and at 2.0 it delivered nothing and gave back everything the channel had won. `standout: 1` is no cutoff, the measurement setting for reading your own store's trace. Below `1` throws at registration, because it asks for the best article to be worse than its rival and is what a caller writes who is still thinking in scores.
179
+ - **`standout`** is how far the best-ranked article must beat the best one that will not ride anyway, meaning the fourth, the one the three-per-message cap was already going to leave behind. A multiple, not a score: `standout: 1.5` asks for the best to score half again what the first held-back rival scored. Default `1.4`, an operating point priced by a 120-cell benchmark rather than picked: its n=15 comparison with the uncut channel differed by -0.07 rule facts at p=1.0 while cutting suggestions from 26 a session to 3, raised the rate at which the agent acted on one from 0.17 to 0.82, and never fired at all on a store with nothing relevant to say, 0 rankings of 139. That small observed contrast is not a general detection bound. Precision is the side to protect, though the measured argument is tokens rather than attention: a companion 124-cell study found good suggestions opened at similar rates buried among twenty-seven as one of four, so what the cutoff saves is the context it declines to spend on lines that were never going to be read. The other side has a price too, and the same benchmark paid it: a cutoff set past what a decisive ranking reaches silences the channel, and at 2.0 it delivered nothing and gave back everything the channel had won. `standout: 1` is no cutoff, the measurement setting for reading your own store's trace. Below `1` throws at registration, because it asks for the best article to be worse than its rival and is what a caller writes who is still thinking in scores.
154
180
 
155
181
  **It is a ratio because a score is not the same quantity twice.** `lexical` normalizes against a saturation ceiling computed from the query, so a score is a fraction of the best match POSSIBLE for that query rather than of the best match available in your store, and it falls as the agent says more. On a 380-article store the same article at the same relevance scored 0.68 against a short question and 0.16 with a hundred words of tool output around it. Across two benchmark runs it was worse than unstable, it inverted: keeping the answers on one corpus needed a cutoff below 0.11, and silencing the other needed one above 0.73. Dividing by another score from the same query cancels both.
156
182
 
@@ -170,14 +196,15 @@ Held by the runtime:
170
196
  - A journal entry is created with the exclusive-create flag, so pi_canon never rewrites or deletes one, and a name collision increments a suffix rather than losing an entry. The files stay ordinary Markdown, so any other tool can still rewrite or delete one: append-only is a property of the tool, not of the filesystem.
171
197
  - 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
198
  - An article surfaces whole, with no character count able to truncate it or hold it back.
173
- - An article surfaces at most once while it is present in the context the provider receives. Presence is read from that projection rather than remembered, so folding or compaction returns the article to surfacing; a harness that reports no projection degrades to at most once per session.
199
+ - 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.
200
+ - 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
201
  - Reading an article through the tool withdraws its staged capsule before the message goes out.
175
202
 
176
203
  Asked of the agent, and checked by nothing:
177
204
 
178
205
  - Read the governing article before working on an asset, and update it after real changes. No write is gated on a prior read, and the settle reminder is a message rather than a gate.
179
206
  - Record the source as it arrived, names and exact numbers included, because articles distill and only the journal keeps the original.
180
- - File the entry under the right subject, and file a constraint at the asset it governs rather than the asset you happened to edit. Knowledge filed off the asset path never surfaces.
207
+ - File the entry under the right subject, and file a constraint at the asset it governs rather than the asset you happened to edit. On the default `retrieval: "none"`, knowledge filed off the asset path never surfaces; with a retriever configured, a cross-cutting rule can instead live at its own declared address and surface by relevance.
181
208
  - Open the article when a capsule or a pointer says there is one. A line in the context is not a read.
182
209
  - Decide whether a dropped constraint still holds. Then follow the rule, against a live prompt asking for something else.
183
210
 
@@ -186,14 +213,14 @@ Nothing in the package can compel an agent to keep a line it has decided to cut.
186
213
  What the package does not do, stated so nothing above reads as more than it is:
187
214
 
188
215
  - No search that runs unasked. `search` is an action the agent calls; touches resolve to articles by exact address or the ancestor walk, never by ranking, and no query ever fires on the agent's behalf.
189
- - No embeddings and no model. `retrieval: "lexical"` builds a BM25 index over the articles that govern no asset, and nothing else is ranked ever; any other ranker is a function the caller supplies.
216
+ - No embeddings and no model. `retrieval: "lexical"` builds a BM25 index over off-spine articles plus declared rules, and ordinary asset-scoped articles are never ranked; any other ranker is a function the caller supplies.
190
217
  - No filesystem watching, and no staleness detection: `updated` is the date of the last write and is never compared against the asset.
191
218
  - No delete and no rename. Removing or moving an article is a file operation you perform.
192
219
  - Articles are last write wins, with no lock, no merge, and no warning that someone else changed the file. Only journal entries get the collision retry.
193
220
  - No duplicate detection. One canonical address per asset is structural, not checked.
194
221
  - Nothing writes, summarizes, or compacts on its own, and nothing filters what goes in: no secrets scanning and no redaction. Every line pi-canon wrote came from an explicit tool call.
195
222
  - Nothing about surfacing persists between sessions. A new session re-surfaces everything.
196
- - Presence is tested by looking for the article's capsule in the projection, so a capsule too short to be distinctive is never expired, and a digest that does not carry the capsule counts as absent.
223
+ - Presence is tested against both the article address and the tail of what it actually placed in the provider projection. Any delivery shorter than 24 normalized characters has no safe mark and is conservatively kept present; that can be a tiny read, write, or exceptionally short surfaced line. For marked deliveries, a digest without the delivered tail counts as absent.
197
224
 
198
225
  ## Evidence
199
226
 
@@ -240,10 +267,11 @@ That is development evidence over two arms of one run and it carries no confirma
240
267
 
241
268
  ## More
242
269
 
243
- - The paper, with the per-cell artifact trail: [doi:10.5281/zenodo.21890647](https://doi.org/10.5281/zenodo.21890647).
270
+ - The 0.2 paper, with its six-study per-cell artifact trail: [doi:10.5281/zenodo.21960350](https://doi.org/10.5281/zenodo.21960350).
271
+ - The first paper, with its end-to-end per-cell artifact trail: [doi:10.5281/zenodo.21890647](https://doi.org/10.5281/zenodo.21890647).
244
272
  - 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
273
  - Interactive versions of every figure and the full measurement story: [shaneconner.com/projects/pi-canon](https://shaneconner.com/projects/pi-canon/).
246
274
  - 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).
247
275
  - [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
276
 
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.
277
+ 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`, 149 of them at this release.
@@ -12,8 +12,9 @@ export interface CanonOptions {
12
12
  root?: string;
13
13
  /* Surface governing articles as tool calls touch assets. Default: true. */
14
14
  surface?: boolean;
15
- /* Treat an article as seen only while it is still in the live context window, so one
16
- folded or compacted away surfaces again the next time its asset is touched.
15
+ /* Treat an article with a presence mark as seen only while that mark remains in the
16
+ live context window, so one folded or compacted away surfaces again on the next
17
+ touch. Untestably short delivered text has no mark and conservatively stays seen.
17
18
  Default: true. Set false for 1.0 behavior, where a surfaced article is never
18
19
  surfaced again however long ago it left the window. */
19
20
  resurface?: boolean;
@@ -21,18 +22,21 @@ export interface CanonOptions {
21
22
  assets, addressed by basename: mounts: ["/data/lake"] serves lake:prices.
22
23
  Workspaces that mount the same directory share its knowledge. */
23
24
  mounts?: string[];
24
- /* How articles that govern no asset are ranked against what the agent is doing, the
25
- one category the address spine can never reach. "none" is the default and is the
26
- 1.0 behavior exactly: nothing is ranked and nothing unaddressed ever surfaces.
27
- "lexical" is BM25 over the standard library. Anything needing a model is supplied
28
- here as { name, score, index? }, so this package never depends on one. */
25
+ /* How the retrieval corpus is ranked against what the agent is doing: every
26
+ off-spine article plus any declared rule, so a rule remains reachable if an asset
27
+ later appears at its address. Ordinary addressed articles stay out because the
28
+ spine already reaches them. "none" is the default and is the 1.0 behavior exactly:
29
+ nothing is ranked or surfaced by relevance. "lexical" is BM25 over the standard
30
+ library. Anything needing a model is supplied here as { name, score, index? }, so
31
+ this package never depends on one. */
29
32
  retrieval?: RetrievalOption;
30
33
  /* How far the best-ranked article must stand out from the rest of what this same
31
34
  query touched before it may ride a message. A multiple, not a score: 2 means the
32
35
  best must score twice the best article that will not ride, the one just past the
33
- per-turn cap. Default 1.4, the operating point a 120-cell study priced: it
34
- matched the uncut channel's delivery to within that study's own noise floor at a
35
- ninth of the suggestion volume. 1 is no cutoff and is the 1.0 behavior exactly.
36
+ per-turn cap. Default 1.4, the operating point a 120-cell study priced: its n=15
37
+ comparison with the uncut channel differed by -0.07 rule facts at p=1.0 while
38
+ using a ninth of the suggestion volume. That small observed contrast is not a
39
+ general detection bound. 1 is no cutoff and is the 1.0 behavior exactly.
36
40
 
37
41
  Relative rather than absolute because an absolute cutoff is not the same quantity
38
42
  twice. A lexical score is a fraction of the query's whole idf mass, so it falls as
@@ -44,10 +48,10 @@ export interface CanonOptions {
44
48
  and a number never did.
45
49
 
46
50
  Raising it trades recall for precision, and precision is the side to protect,
47
- though the measured argument is tokens rather than attention: the same study
48
- found good suggestions opened at the same rate buried among twenty-seven as one
49
- of four, so what the cutoff declines to spend is context on lines that were
50
- never going to be read. Ignored when retrieval is "none". */
51
+ though the measured argument is tokens rather than attention: a companion
52
+ 124-cell study found good suggestions opened at similar rates buried among
53
+ twenty-seven as one of four, so what the cutoff declines to spend is context on
54
+ lines that were never going to be read. Ignored when retrieval is "none". */
51
55
  standout?: number;
52
56
  }
53
57
 
@@ -114,10 +118,10 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
114
118
  pi.registerTool(buildCanonTool(ready, retriever.name));
115
119
 
116
120
  /* session_start only resets per-session state. Through 0.2.0 it also delivered an
117
- orientation line; a 2x2 with an inert implementation priced that line at more
118
- first-pass correctness than the whole tool schema, and the study that removed it
119
- found nothing the benefit side could see. The doctrine rides the tool
120
- description, which every session carries anyway. */
121
+ orientation line. A 2x2 with an inert implementation found significant negative
122
+ main effects for both that line and the tool schema, but did not resolve which
123
+ component cost more. Study 3 detected no benefit loss from removing the line. The
124
+ doctrine rides the tool description, which every session carries anyway. */
121
125
  pi.on("session_start", (_event: unknown, ctx: any) => {
122
126
  runtime = undefined;
123
127
  ready(ctx);
@@ -1,11 +1,11 @@
1
- /* Retrieval: the seam for knowledge the spine cannot address.
1
+ /* Retrieval: the seam for knowledge the ordinary spine cannot address.
2
2
 
3
- The spine answers every asset-scoped question deterministically and for free, and
4
- nothing here changes that. Retrieval runs over the RESIDUE, the articles whose
5
- address matches no asset, which is the category 1.0 named and left open ("knowledge
6
- filed off the asset path never surfaces"). That scoping is what makes an expensive
7
- ranker affordable: the corpus to rank is what has no home, not the whole store, and
8
- it stays small precisely because everything with a home is answered without ranking.
3
+ The spine answers ordinary asset-scoped questions deterministically and for free,
4
+ and nothing here changes that. Retrieval runs over the RESIDUE: every article whose
5
+ address matches no asset, plus an article declared `scope: rule` even if an asset
6
+ later appears at its address. That scoping completes the spine without competing
7
+ with it: ordinary addressed articles are answered without ranking, while a declared
8
+ cross-cutting rule cannot disappear because of a filesystem name collision.
9
9
 
10
10
  Two built-ins ship. `none` is 1.0 exactly and is the experiment's control. `lexical`
11
11
  is BM25 with nothing but the standard library. Anything that needs a model is
@@ -25,8 +25,9 @@ export interface Candidate {
25
25
  capsule: string;
26
26
  body: string;
27
27
  updated: string;
28
- /* Whether this article SAID it is a cross-cutting rule, rather than being inferred
29
- into the corpus by having no asset. See residue below. */
28
+ /* Whether this article SAID it is a cross-cutting rule. A declared rule qualifies
29
+ independently of the filesystem; an undeclared article qualifies only off-spine.
30
+ See residue below. */
30
31
  declared: boolean;
31
32
  }
32
33
 
@@ -290,24 +291,19 @@ export function buildRetriever(option: RetrievalOption | undefined): Retriever {
290
291
  );
291
292
  }
292
293
 
293
- /* The residue: articles the spine can never surface, because surfacing is triggered by
294
- touching an asset and there is nothing here to touch. Everything else is already
295
- answered for free and must not be ranked, or retrieval would compete with the address
296
- instead of completing it.
297
-
298
- Membership is still decided by having no asset, and that is deliberate: an article
299
- unreachable by address has exactly one mechanism left, and dropping it from that
300
- mechanism would lose it outright. But the set has always held two populations, and
301
- until now nothing could tell them apart (Codex, 2026-08-12). One is the deliberate
302
- cross-cutting rule the doctrine asks for, filed at an address naming the rule. The
303
- other is an accident: a typo in an address, or an article whose asset was deleted
304
- under it. Defining the corpus only by what it is not made those the same thing.
305
-
306
- So an article may DECLARE itself with `scope: rule`, and every candidate carries
307
- whether it did. Nothing here filters on it, because a declaration the agent forgot
308
- must not cost it the only mechanism that can reach it. What the flag buys is honesty:
309
- a declared article is a rule on purpose, an undeclared one is a question, and the two
310
- stop being counted as one number. */
294
+ /* The residue completes the spine. Every off-spine article qualifies because an asset
295
+ touch cannot surface it, and dropping it from retrieval would lose its only automatic
296
+ path. A declared `scope: rule` also qualifies if an asset later appears at the same
297
+ address, because the declaration says the article is cross-cutting rather than
298
+ governed by that coincident asset. Ordinary addressed articles stay out, or retrieval
299
+ would compete with deterministic address resolution.
300
+
301
+ The off-spine set itself holds two populations. One is the deliberate cross-cutting
302
+ rule the doctrine asks for, filed at an address naming the rule. The other is an
303
+ accident: a typo in an address, or an article whose asset was deleted under it. Every
304
+ candidate therefore carries whether it declared itself. Forgetting the declaration
305
+ never excludes an off-spine article; the flag makes the distinction visible and keeps
306
+ a declared rule eligible across a later filesystem collision. */
311
307
  export function residue(store: CanonStore, dir: string): Candidate[] {
312
308
  const out: Candidate[] = [];
313
309
  for (const path of store.list()) {
@@ -263,7 +263,12 @@ export class CanonStore {
263
263
  /* Journal entries are immutable: a fresh dated file per entry, wx so nothing is
264
264
  ever overwritten. EEXIST is the retry signal, so concurrent writers each land
265
265
  on their own file instead of one losing its entry. */
266
- journal(entry: { body: string; slug?: string; subject?: string[] }): string {
266
+ journal(entry: {
267
+ body: string;
268
+ slug?: string;
269
+ subject?: string[];
270
+ provenance?: { harness: string; sessionId?: string };
271
+ }): string {
267
272
  const slug =
268
273
  (entry.slug ?? "entry").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "entry";
269
274
  mkdirSync(this.journalDir, { recursive: true });
@@ -277,6 +282,8 @@ export class CanonStore {
277
282
  const front = [
278
283
  entry.subject?.length ? `subject: [${entry.subject.map(scalar).join(", ")}]` : "",
279
284
  `logged: ${stamp}`,
285
+ entry.provenance?.harness ? `harness: ${scalar(entry.provenance.harness)}` : "",
286
+ entry.provenance?.sessionId ? `session: ${scalar(entry.provenance.sessionId)}` : "",
280
287
  ].filter(Boolean).join("\n");
281
288
  const text = `---\n${front}\n---\n${entry.body.trimEnd()}\n`;
282
289
  for (let n = 1; ; n += 1) {
@@ -1,6 +1,6 @@
1
1
  /* Surfacing: tool calls stage the articles governing what they touch; the staged
2
- lines flush as ONE message per turn, once per article per session. One message per
3
- turn matters: pi's steering queue drains one message per provider round trip, so
2
+ lines flush as ONE message per turn, once while a testable presence mark remains.
3
+ One message per turn matters: pi's steering queue drains one message per provider round trip, so
4
4
  a message per tool call would buy each nudge its own extra LLM call.
5
5
 
6
6
  Nothing here is bounded by a character count. A session budget used to cap the
@@ -44,17 +44,16 @@ const PATHLIKE = /(?:^|[\s"'`=:,([{])(\/?[\w.@-]+(?:\/[\w.@-]+)+)/g;
44
44
  JSON escaping, whitespace rewrapping, and quoting differences between however the
45
45
  projection is rendered and however we wrote it. A short mark is not distinctive enough
46
46
  to test, so it is never expired; failing to expire only costs a re-surface that does
47
- not happen, while a false expiry would spam the window. A surfaced line always carries
48
- its own address and so is always long enough; only a read of a very small article is
49
- not.
47
+ not happen, while a false expiry would spam the window. The same floor applies to
48
+ surfaced lines, reads, and writes; an exceptionally terse value in any path stays seen.
50
49
 
51
50
  A mark has two parts and BOTH must be in the projection.
52
51
 
53
52
  IDENTITY is the article's own address, which is in the window whichever way the
54
53
  article got there: the surfaced line reads "path: capsule" and a read prints the
55
54
  address as its title. LIVENESS is the tail of whatever actually entered, the caller
56
- passing the whole of it. A surfaced line is its capsule and is held to the capsule;
57
- a read is capsule plus body and is held to the body.
55
+ passing the whole of it. A surfaced line is held to the tail of that exact line; a
56
+ read is capsule plus body and is held to the body.
58
57
 
59
58
  Both parts are needed because either alone is wrong in a way that matters. Identity
60
59
  alone cannot tell a one-line nudge from the full article, which is the defect that
@@ -143,9 +142,10 @@ export class Surfacer {
143
142
 
144
143
  /* How far the best must beat the rest of the same query before anything rides. See
145
144
  retrieve(). The default is an operating point priced by a 120-cell study rather than
146
- picked: at 1.4 a session kept every rule fact the uncut channel delivered at a ninth
147
- of the suggestion volume, and a store with nothing relevant never reached it. 1 turns
148
- the cutoff off. */
145
+ picked: at 1.4 the n=15 comparison with the uncut channel differed by -0.07 rule
146
+ facts at p=1.0 while using a ninth of the suggestion volume, and a store with nothing
147
+ relevant never reached it. That small observed contrast is not a general detection
148
+ bound. 1 turns the cutoff off. */
149
149
  private standout: number;
150
150
 
151
151
  constructor(mounts: Mount[], retriever: Retriever = NONE, resurface = true, standout = 1.4) {
@@ -282,7 +282,7 @@ export class Surfacer {
282
282
  /* This turn's intent, one entry per tool call. Kept separate from `collect` because
283
283
  they answer different questions: collect asks what asset was touched, which the
284
284
  spine answers by address, and this asks what the agent is trying to do, which is
285
- the only thing an unaddressed article can be ranked against. */
285
+ what every article in the retrieval corpus is ranked against. */
286
286
  noteIntent(toolName: unknown, input: unknown): void {
287
287
  if (typeof toolName === "string" && toolName) this.intent.push({ toolName, input });
288
288
  }
@@ -321,8 +321,10 @@ export class Surfacer {
321
321
  ceiling it means "at least one query term appears in this article at all", which is
322
322
  a property of the query rather than a constant someone picked. `standout` is the
323
323
  tuned one. A 120-cell study priced it on a corpus with something worth finding in
324
- its residue, and 1.4 held every fact the uncut channel delivered at a ninth of the
325
- volume; that is the default, and the caller moves it against their own trace.
324
+ its residue. At 1.4 the n=15 comparison with the uncut channel differed by -0.07
325
+ rule facts at p=1.0 while using a ninth of the volume; that is the default, and the
326
+ caller moves it against their own trace. The small contrast is an observation from
327
+ this study, not a general detection bound.
326
328
 
327
329
  There used to be an absolute threshold here, on the grounds that a study session was
328
330
  handed 28 ranked lines and opened 5, and the scores of the opened and the ignored
@@ -571,10 +573,10 @@ export class Surfacer {
571
573
  Both are the same mistake, which is testing presence against something other than
572
574
  what was delivered.
573
575
 
574
- This also retires the workaround the short-capsule case needed. A line always
575
- carries its own address, so it always clears MARK_MINIMUM: `Cache.` fingerprints to
576
- 5 characters and its line to 39. The guard still earns its place on the other call
577
- site, where a read of a tiny article really can be too short to test. */
576
+ This also retires the workaround the short-capsule case needed. The address and
577
+ date make ordinary lines testable: `Cache.` fingerprints to 5 characters and the
578
+ `src/core/terse` line to 39. The valid shortest case, address `a` and capsule `x`,
579
+ reaches only 22 and therefore keeps the conservative short-text behavior. */
578
580
  this.remember(path, line);
579
581
  this.lastFlush.set(path, entry);
580
582
  this.staged.delete(path);
@@ -1,5 +1,5 @@
1
- /* The pi_canon tool: one tool, four verbs. Read and update over create; the journal
2
- for events; map to orient. */
1
+ /* The pi_canon tool: one tool, five verbs. Read and update over create; the journal
2
+ for events; map to orient; search when the agent asks. */
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { basename, join } from "node:path";
@@ -14,6 +14,57 @@ export interface CanonRuntime {
14
14
  cwd: string;
15
15
  mounts: Mount[];
16
16
  retrieval: string;
17
+ provenance?: { harness: string; sessionId?: string };
18
+ }
19
+
20
+ export const CANON_TOOL_PARAMETERS = {
21
+ type: "object",
22
+ properties: {
23
+ action: { type: "string", enum: ["read", "write", "journal", "map", "search"] },
24
+ path: {
25
+ type: "string",
26
+ description: "Article address, e.g. src/core/config. Required for read and write; optional filter for map.",
27
+ },
28
+ body: {
29
+ type: "string",
30
+ description:
31
+ "write: the full article body; specifics beat summaries (who consumes what, exact " +
32
+ "limits, what breaks). journal: the event text, source details intact.",
33
+ },
34
+ capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
35
+ query: { type: "string", description: "search: words to look for, across articles and the journal." },
36
+ scope: {
37
+ type: "string",
38
+ enum: ["rule", "asset"],
39
+ description:
40
+ "write: 'rule' when this article names a cross-cutting rule instead of governing an " +
41
+ "asset, so it is a rule on purpose rather than an article whose asset went missing; " +
42
+ "'asset' to take that back, when the article governs an asset after all.",
43
+ },
44
+ subject: {
45
+ type: "array",
46
+ items: { type: "string" },
47
+ description: "journal: article addresses this event concerns.",
48
+ },
49
+ slug: { type: "string", description: "journal: short name for the entry file." },
50
+ },
51
+ required: ["action"],
52
+ } as const;
53
+
54
+ export function canonToolDescription(retrieval = "none"): string {
55
+ return (
56
+ "Canonical project memory. Every asset has at most one governing article at its own address " +
57
+ "(src/core/config, lake/prices). read the governing article before working on an asset; " +
58
+ "write it after real changes. journal appends an immutable event entry: record the source " +
59
+ "as it happened, names and exact numbers included, because articles distill and only the " +
60
+ "journal keeps the original, so distil the prose but carry exact values through verbatim: " +
61
+ "ids, keys, names, counts, limits and durations, every member of a named set and not " +
62
+ "just the one you are working on. A rule without its values is worth nothing to the " +
63
+ "session that needs it. map lists articles with their capsules. " +
64
+ "Creation is rare: prefer updating the article that already governs. " +
65
+ "File a constraint at the asset it governs, or the shared parent when it spans assets, not " +
66
+ "the asset you happened to edit. " + filingTail(retrieval)
67
+ );
17
68
  }
18
69
 
19
70
  /* A path routes to the mount it names (lake:prices), the mount whose directory
@@ -50,51 +101,8 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval
50
101
  return {
51
102
  name: "pi_canon",
52
103
  label: "pi-canon",
53
- description:
54
- "Canonical project memory. Every asset has at most one governing article at its own address " +
55
- "(src/core/config, lake/prices). read the governing article before working on an asset; " +
56
- "write it after real changes. journal appends an immutable event entry: record the source " +
57
- "as it happened, names and exact numbers included, because articles distill and only the " +
58
- "journal keeps the original, so distil the prose but carry exact values through verbatim: " +
59
- "ids, keys, names, counts, limits and durations, every member of a named set and not " +
60
- "just the one you are working on. A rule without its values is worth nothing to the " +
61
- "session that needs it. map lists articles with their capsules. " +
62
- "Creation is rare: prefer updating the article that already governs. " +
63
- "File a constraint at the asset it governs, or the shared parent when it spans assets, not " +
64
- "the asset you happened to edit. " + filingTail(retrieval),
65
- parameters: {
66
- type: "object",
67
- properties: {
68
- action: { type: "string", enum: ["read", "write", "journal", "map", "search"] },
69
- path: {
70
- type: "string",
71
- description: "Article address, e.g. src/core/config. Required for read and write; optional filter for map.",
72
- },
73
- body: {
74
- type: "string",
75
- description:
76
- "write: the full article body; specifics beat summaries (who consumes what, exact " +
77
- "limits, what breaks). journal: the event text, source details intact.",
78
- },
79
- capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
80
- query: { type: "string", description: "search: words to look for, across articles and the journal." },
81
- scope: {
82
- type: "string",
83
- enum: ["rule", "asset"],
84
- description:
85
- "write: 'rule' when this article names a cross-cutting rule instead of governing an " +
86
- "asset, so it is a rule on purpose rather than an article whose asset went missing; " +
87
- "'asset' to take that back, when the article governs an asset after all.",
88
- },
89
- subject: {
90
- type: "array",
91
- items: { type: "string" },
92
- description: "journal: article addresses this event concerns.",
93
- },
94
- slug: { type: "string", description: "journal: short name for the entry file." },
95
- },
96
- required: ["action"],
97
- },
104
+ description: canonToolDescription(retrieval),
105
+ parameters: CANON_TOOL_PARAMETERS,
98
106
  async execute(
99
107
  _toolCallId: string,
100
108
  params: Record<string, unknown>,
@@ -102,7 +110,7 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval
102
110
  _onUpdate: unknown,
103
111
  ctx: unknown,
104
112
  ) {
105
- const text = run(ready(ctx), params);
113
+ const text = runCanon(ready(ctx), params);
106
114
  return { content: [{ type: "text", text }], details: {} };
107
115
  },
108
116
  };
@@ -211,7 +219,7 @@ function excerpt(body: string): string {
211
219
  return flat.length > 160 ? `${flat.slice(0, 157)}...` : flat;
212
220
  }
213
221
 
214
- function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
222
+ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>): string {
215
223
  const { surfacer } = runtime;
216
224
  const action = String(params.action ?? "");
217
225
  const { mount, path } = typeof params.path === "string" ? route(runtime, params.path) : { mount: runtime.mounts[0], path: "" };
@@ -293,6 +301,7 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
293
301
  body,
294
302
  subject,
295
303
  slug: typeof params.slug === "string" ? params.slug : undefined,
304
+ provenance: runtime.provenance,
296
305
  });
297
306
  /* The article was written before this entry (agents write then journal), so this
298
307
  is the first moment both exist. Report what the source kept and the article did
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-canon",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Canonical project memory for the Pi coding agent: one article per asset at a knowable address, an append-only journal beneath it.",
5
5
  "type": "module",
6
6
  "exports": {