pi-canon 0.1.2 → 0.2.1

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
@@ -16,7 +16,7 @@ Or clone this repo into `~/.pi/agent/extensions/`. Node 22.18 or later, Pi 0.83
16
16
 
17
17
  ## The first article
18
18
 
19
- Every session opens with one orientation line saying how many articles govern the project, or inviting the first one when the store is empty. From there it takes one tool call:
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:
20
20
 
21
21
  ```json
22
22
  { "action": "write",
@@ -80,9 +80,9 @@ pi-canon is an increment on the pattern rather than a replacement for it, and it
80
80
 
81
81
  **A journal**, append-only, one file per event. Agents log whether you want them to or not, and that impulse has to land somewhere that is not the reference page. The instruction on the way in is to record the source as it arrived, names and exact numbers included, because articles distill and only the journal keeps the original.
82
82
 
83
- **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 no part of the package searches: there is nothing to find when the path already decided the address.
83
+ **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
84
 
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 and under a session allowance, 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.
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.
86
86
 
87
87
  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
88
 
@@ -94,16 +94,18 @@ Resolution tries the exact address, then walks up one path segment at a time to
94
94
 
95
95
  A rename is a file move you make yourself. pi-canon does not watch the filesystem and has no rename action. Move the article to the address the new path derives. Lint checks the wikilinks inside whatever article is written next, so a link left pointing at the old address is named the next time that article is written, not at the moment of the move.
96
96
 
97
- An article matching no asset is ordinary free knowledge, and no flag distinguishes it. 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.
97
+ 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
+
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.
98
100
 
99
101
  ## The tool
100
102
 
101
- One tool, `pi_canon`, four actions.
103
+ One tool, `pi_canon`, five actions: `read`, `write`, `journal`, `map`, and `search`.
102
104
 
103
105
  | action | parameters | does |
104
106
  |---|---|---|
105
107
  | `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. |
106
- | `write` | `path`, `capsule`, `body` | Creates or updates the article, then returns `Wrote <address>.` and any advisory lint. Never refuses. An empty string means untouched, not erase. |
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. |
107
109
  | `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. |
108
110
  | `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. |
109
111
 
@@ -119,13 +121,13 @@ One lint line is different in kind. When a write supplies a body and an article
119
121
 
120
122
  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.
121
123
 
122
- Three numbers, all constants in the code. A capsule is written to fit 1,000 characters, a flushed message aims at 2,000, and capsule text has a 4,000 character session allowance. The message target is not a cap, because the first line always goes out whole, and anything that does not fit stays staged, so the message ends with a count of what is still waiting and those lines go out on a later turn. Only the session allowance is a refusal, and it is tested per capsule against what is left, so a short capsule can still land after a longer one was turned away. Past the allowance an article does not disappear: it surfaces as a pointer naming the address and telling the agent to read it. Pointers, message headers, and reminders sit outside the counter.
124
+ 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.
123
125
 
124
126
  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.
125
127
 
126
128
  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.
127
129
 
128
- `/pi-canon` prints one status line: store root, the mount count when there is one, article count, journal entries, articles seen this session, and capsule characters spent against 4,000. It goes to the UI and sends the model nothing, so asking costs no context. `PI_CANON_TRACE=<file>` appends one JSON line per surfacing decision, and is inert when the variable is unset.
130
+ `/pi-canon` prints one status line: store root, the mount count when there is one, article count, journal entries, articles surfaced this session, and how many of those are still in context and what they occupy. It goes to the UI and sends the model nothing, so asking costs no context. `PI_CANON_TRACE=<file>` appends one JSON line per surfacing decision, and is inert when the variable is unset.
129
131
 
130
132
  ## Options
131
133
 
@@ -140,10 +142,22 @@ export default function (pi) {
140
142
  }
141
143
  ```
142
144
 
143
- Three keys, and any other throws at registration by name, because everything else is a constant on purpose.
145
+ Six keys, and any other throws at registration by name, because everything else is a constant on purpose.
144
146
 
145
147
  - **`root`** places the store. Absolute is used as given, relative joins the project cwd. Default `<project>/.canon`.
146
- - **`surface: false`** silences the orientation line, the per-turn flush, and the settle reminder. The `pi_canon` tool and `/pi-canon` stay registered and working.
148
+ - **`surface: false`** silences the per-turn flush and the settle reminder. The `pi_canon` tool and `/pi-canon` stay registered and working.
149
+ - **`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.
150
+ - **`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.
151
+
152
+ - **`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 kept every rule fact the uncut channel delivered 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: an unsolicited line that is usually noise teaches the agent to skip the next one, and suggestion fatigue costs more than the tokens do. 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.
153
+
154
+ **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.
155
+
156
+ **The comparison is against the top of the ranking, not a quantile of it**, because an agent's turn is long and touches nearly everything: 377 of 378 articles in a benchmark session, so a tenth of the way down the list is deep in the mass that shares one common word, and the ratio to it describes your corpus rather than this query. Measured there, ordinary queries reached 2.64 to 3.28 and the query that actually had something to find reached 3.10, inside that range rather than above it. In the first few ranks the same sessions separated cleanly: every ranking carrying a decisive article beat its first held-back rival by 1.68 to 1.81, and every ranking that did not stayed between 1.00 and 1.28. Still measure on your own store: set `PI_CANON_TRACE` and read the `ranked` lines, which record what each query reached and whether it passed.
157
+
158
+ **A drained store does not turn the ratio into a free pass.** Late in a long session, once most of what a small store had to say has been delivered, the still-eligible articles are a tail of near-zero scores, and a ratio over leftovers alone would ride junk on tiny numbers. So while the cutoff is active, the rival the best must beat is floored at the strongest already-delivered article this same query raised: what is left has to beat what the query would have re-raised if it could. A genuinely new topic clears that floor because the delivered articles score weakly on its query; leftovers do not. Replayed over a real 33-article store that the unfloored ratio had emptied entirely into the window, 33 articles down to a minimum ride score of 0.002, the floor cut the session to 15 rides with a floor of 0.075, and the strongest ride improved, because the store had not been spent on junk before the query that deserved it arrived.
159
+
160
+ The query is intent and never evidence: the user's own words out of the live context, newest first and bounded, keeping both ends of a message too long to carry whole because neither end is reliably the ask, plus this turn's tool calls by name and first argument. Tool results never reach it, and neither does the model's prose, and pi-canon's own nudges are excluded so an article cannot score highly for having been surfaced already. Relevance and transport are bounded separately: `standout` rules on whether the query gets an answer at all, and on top of it at most three ranked articles ride one message, best score first, with articles reached by address never counted against that, because an address is a certainty and a score is a guess. A ranked article is paid for by new intent rather than by another turn passing, so an unchanged question does not keep releasing three more until the residue runs out. The trace records the best score held back against the worst one sent. A retriever that throws costs the turn its ranking and nothing else.
147
161
  - **`mounts`** lists directories outside the project that carry their own `.canon` beside their assets. `mounts: ["/data/lake"]` serves articles as `lake:prices`, addressable by that name or by any absolute path inside the mount. Two workspaces that mount the same directory read and write the same store, because the store lives with the assets it governs and sharing needs no protocol. A mount has no journal of its own: events are project history and every entry lands in the project store.
148
162
 
149
163
  ## What the code holds, and what it asks for
@@ -154,7 +168,8 @@ Held by the runtime:
154
168
 
155
169
  - 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.
156
170
  - 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.
157
- - An article surfaces at most once per session, with capsule text bounded at 4,000 characters for the session.
171
+ - An article surfaces whole, with no character count able to truncate it or hold it back.
172
+ - 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.
158
173
  - Reading an article through the tool withdraws its staged capsule before the message goes out.
159
174
 
160
175
  Asked of the agent, and checked by nothing:
@@ -169,14 +184,15 @@ Nothing in the package can compel an agent to keep a line it has decided to cut.
169
184
 
170
185
  What the package does not do, stated so nothing above reads as more than it is:
171
186
 
172
- - No search. There is no query action, no index, and no grep. `map` is the only listing, and retrieval is by exact address or by the ancestor walk.
173
- - No embeddings, no similarity, no ranking.
187
+ - 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.
188
+ - 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.
174
189
  - No filesystem watching, and no staleness detection: `updated` is the date of the last write and is never compared against the asset.
175
190
  - No delete and no rename. Removing or moving an article is a file operation you perform.
176
191
  - 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.
177
192
  - No duplicate detection. One canonical address per asset is structural, not checked.
178
193
  - 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.
179
- - Nothing about surfacing persists between sessions. A new session re-surfaces everything and gets a fresh allowance.
194
+ - Nothing about surfacing persists between sessions. A new session re-surfaces everything.
195
+ - 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.
180
196
 
181
197
  ## Evidence
182
198
 
@@ -214,7 +230,7 @@ That is development evidence over two arms of one run and it carries no confirma
214
230
  ### What the run does not establish
215
231
 
216
232
  - 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.
217
- - The result is package-level. It attributes nothing to the journal, the spine, or surfacing separately.
233
+ - 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.
218
234
  - No evaluated arm is a search-driven LLM wiki, so nothing here is a comparison against one.
219
235
  - 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.
220
236
  - 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.
@@ -229,4 +245,4 @@ That is development evidence over two arms of one run and it carries no confirma
229
245
  - 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).
230
246
  - [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.
231
247
 
232
- MIT. In a clone of this repo, `node tests/verify.mjs` runs the gate suite: it prints 67 named invariants and ends with `all 67 gates green`.
248
+ 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.
@@ -1,8 +1,10 @@
1
1
  /* pi-canon: canonical project memory for Pi. Wiring only; mechanics live in lib/. */
2
2
 
3
+ import { appendFileSync } from "node:fs";
3
4
  import { basename, isAbsolute, join } from "node:path";
5
+ import { buildRetriever, type RetrievalOption } from "./lib/retrieval.ts";
4
6
  import { CanonStore } from "./lib/store.ts";
5
- import { SESSION_BUDGET_CHARS, Surfacer, type Mount } from "./lib/surfacing.ts";
7
+ import { Surfacer, type Mount } from "./lib/surfacing.ts";
6
8
  import { buildCanonTool, type CanonRuntime } from "./lib/tool.ts";
7
9
 
8
10
  export interface CanonOptions {
@@ -10,20 +12,73 @@ export interface CanonOptions {
10
12
  root?: string;
11
13
  /* Surface governing articles as tool calls touch assets. Default: true. */
12
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.
17
+ Default: true. Set false for 1.0 behavior, where a surfaced article is never
18
+ surfaced again however long ago it left the window. */
19
+ resurface?: boolean;
13
20
  /* Directories outside the project that carry their own .canon beside their
14
21
  assets, addressed by basename: mounts: ["/data/lake"] serves lake:prices.
15
22
  Workspaces that mount the same directory share its knowledge. */
16
23
  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. */
29
+ retrieval?: RetrievalOption;
30
+ /* How far the best-ranked article must stand out from the rest of what this same
31
+ query touched before it may ride a message. A multiple, not a score: 2 means the
32
+ 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 kept
34
+ every fact the uncut channel delivered at a ninth of the suggestion volume. 1 is
35
+ no cutoff and is the 1.0 behavior exactly.
36
+
37
+ Relative rather than absolute because an absolute cutoff is not the same quantity
38
+ twice. A lexical score is a fraction of the query's whole idf mass, so it falls as
39
+ the agent says more, and the same article, equally relevant, scored 0.68 against a
40
+ short question and 0.16 with a hundred words of tool output around it. Across
41
+ corpora it is worse than unstable, it inverts: the cutoff that silenced one study's
42
+ residue was six times the one that would have cut the other study's answers.
43
+ Dividing by another score from the same query cancels both, which is why this ports
44
+ and a number never did.
45
+
46
+ Raising it trades recall for precision, and precision is the side that matters: an
47
+ unsolicited line that is usually noise teaches the agent to skip the next one, and
48
+ that costs more than the tokens do. Ignored when retrieval is "none". */
49
+ standout?: number;
17
50
  }
18
51
 
19
52
  export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
20
- const unknown = Object.keys(options).find((key) => key !== "root" && key !== "surface" && key !== "mounts");
53
+ const known = new Set(["root", "surface", "mounts", "resurface", "retrieval", "standout"]);
54
+ const unknown = Object.keys(options).find((key) => !known.has(key));
21
55
  if (unknown) {
22
56
  throw new Error(
23
- `pi-canon: unknown option "${unknown}". The options are root, surface, and mounts; everything else is a constant on purpose.`,
57
+ `pi-canon: unknown option "${unknown}". The options are root, surface, mounts, resurface, retrieval, and standout; everything else is a constant on purpose.`,
24
58
  );
25
59
  }
26
60
  const surface = options.surface !== false;
61
+ const resurface = options.resurface !== false;
62
+ /* Built here rather than at first use, so a bad retrieval option throws at
63
+ registration beside the unknown-option check instead of mid-session. */
64
+ const retriever = buildRetriever(options.retrieval);
65
+ /* Validated here for the same reason, and strictly: a cutoff silently coerced from a
66
+ string or waved through as NaN would compare false against every score and turn
67
+ retrieval off without saying so, which is the one failure a tuning knob must not
68
+ have. Below 1 is refused rather than clamped, because it asks for the best article
69
+ to be WORSE than the crowd before it may ride, which nobody means. A caller who
70
+ wrote 0.4 was thinking of a score and wants to be told, not handed silence. Omitted
71
+ entirely, the Surfacer's own default applies, so the shipped value lives in exactly
72
+ one place. */
73
+ const standout = options.standout;
74
+ if (standout !== undefined
75
+ && (typeof standout !== "number" || !Number.isFinite(standout) || standout < 1)) {
76
+ throw new Error(
77
+ `pi-canon: standout must be a number of at least 1, a multiple of what the rest of the query scored rather than a score; got ${
78
+ typeof standout === "number" ? standout : typeof standout
79
+ }.`,
80
+ );
81
+ }
27
82
 
28
83
  let runtime: CanonRuntime | undefined;
29
84
 
@@ -43,29 +98,36 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
43
98
  return { name: basename(abs), dir: abs, store: new CanonStore(join(abs, ".canon")) };
44
99
  }),
45
100
  ];
46
- runtime = { store, surfacer: new Surfacer(mounts), cwd, mounts };
101
+ runtime = {
102
+ store,
103
+ surfacer: new Surfacer(mounts, retriever, resurface, standout),
104
+ cwd,
105
+ mounts,
106
+ retrieval: retriever.name,
107
+ };
47
108
  }
48
109
  return runtime;
49
110
  };
50
111
 
51
- pi.registerTool(buildCanonTool(ready));
112
+ pi.registerTool(buildCanonTool(ready, retriever.name));
52
113
 
53
- /* One orientation line per session, riding the first turn: without it a fresh
54
- or headless session never hears the doctrine, and the write-after reminder
55
- (nextTurn at settle) cannot reach a session that ends when the agent does. */
114
+ /* session_start only resets per-session state. Through 0.2.0 it also delivered an
115
+ orientation line; a 2x2 with an inert implementation priced that line at more
116
+ first-pass correctness than the whole tool schema, and the study that removed it
117
+ found nothing the benefit side could see. The doctrine rides the tool
118
+ description, which every session carries anyway. */
56
119
  pi.on("session_start", (_event: unknown, ctx: any) => {
57
120
  runtime = undefined;
58
- const { store } = ready(ctx);
121
+ ready(ctx);
122
+ });
123
+
124
+ /* The window the provider is about to receive, which is the only definition of what
125
+ the agent can see: folded and compacted material is already gone from it, so
126
+ nothing here has to know how it left or who took it. Read only; pi-canon never
127
+ modifies the projection. */
128
+ pi.on("context", (event: any, ctx: any) => {
59
129
  if (!surface) return;
60
- const count = store.list().length;
61
- const text = count
62
- ? `[pi-canon] ${count} ${count === 1 ? "article governs" : "articles govern"} this project. Read the governing ` +
63
- "article before working on an asset; after real changes update it and journal the " +
64
- "source: names, exact numbers, who said what. Articles distill; the journal keeps the original."
65
- : "[pi-canon] No articles yet in .canon/. When work teaches you something durable about an " +
66
- "asset, write its article with pi_canon and journal the source as it happened: names, " +
67
- "exact numbers, who said what. Articles distill; the journal keeps the original.";
68
- deliver(pi, text, "nextTurn");
130
+ ready(ctx).surfacer.observe(event?.messages);
69
131
  });
70
132
 
71
133
  /* Touches stage; turns flush. One steered message per turn rides the provider
@@ -74,40 +136,61 @@ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
74
136
  if (!surface || event?.toolName === "pi_canon") return;
75
137
  const { surfacer } = ready(ctx);
76
138
  surfacer.collect(surfacer.pathsIn(event?.input));
139
+ surfacer.noteIntent(event?.toolName, event?.input);
77
140
  });
78
141
 
79
142
  pi.on("turn_end", (_event: unknown, ctx: any) => {
80
143
  if (!surface) return;
81
- const text = ready(ctx).surfacer.flush();
82
- if (text) deliver(pi, text, "steer");
144
+ const { surfacer } = ready(ctx);
145
+ surfacer.retrieve();
146
+ const text = surfacer.flush();
147
+ if (text && !deliver(pi, text, "steer")) surfacer.undoFlush();
83
148
  });
84
149
 
85
150
  pi.on("agent_settled", (_event: unknown, ctx: any) => {
86
151
  if (!surface) return;
87
152
  const { surfacer } = ready(ctx);
88
153
  const text = [surfacer.flush(), surfacer.settleNudge()].filter(Boolean).join("\n");
89
- if (text) deliver(pi, text, "nextTurn");
154
+ if (text && !deliver(pi, text, "nextTurn")) surfacer.undoFlush();
90
155
  });
91
156
 
92
157
  pi.registerCommand("pi-canon", {
93
158
  description: "pi-canon status: articles, journal entries, surfacing this session",
94
159
  handler: async (_args: string, ctx: any) => {
95
160
  const { store, surfacer, mounts } = ready(ctx);
96
- const { surfaced, spent } = surfacer.stats;
161
+ const { surfaced, present, chars } = surfacer.stats;
97
162
  const mounted = mounts.length > 1 ? `, ${mounts.length - 1} mounted` : "";
98
163
  ctx.ui.notify(
99
164
  `pi-canon at ${store.root}${mounted}: ${store.list().length} articles, ${store.journalCount()} journal ` +
100
- `entries; ${surfaced} seen this session (${spent} of ${SESSION_BUDGET_CHARS} capsule chars).`,
165
+ `entries; ${surfaced} surfaced this session, ${present} still in context taking ${chars} chars.`,
101
166
  "info",
102
167
  );
103
168
  },
104
169
  });
105
170
  }
106
171
 
107
- function deliver(pi: any, content: string, deliverAs: "steer" | "nextTurn"): void {
172
+ /* Same env-gated sink as surfacing.ts; inert without PI_CANON_TRACE. */
173
+ function trace(kind: string, data: Record<string, unknown>): void {
174
+ const file = process.env.PI_CANON_TRACE;
175
+ if (!file) return;
108
176
  try {
109
- pi.sendMessage({ customType: "pi-canon", content, display: false }, { deliverAs });
177
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), kind, ...data }) + "\n");
110
178
  } catch {
111
- /* a lost nudge must never break the turn */
179
+ /* tracing must never break a turn */
180
+ }
181
+ }
182
+
183
+ /* Reports whether the message actually went, so a caller can decide whether to keep the
184
+ state that assumed it did. A lost nudge must never break the turn, but swallowing the
185
+ failure silently made a delivery fault indistinguishable from an agent that read the
186
+ nudge and ignored it (Codex, 2026-08-13), which is the difference between a bug and a
187
+ behaviour. */
188
+ function deliver(pi: any, content: string, deliverAs: "steer" | "nextTurn"): boolean {
189
+ try {
190
+ pi.sendMessage({ customType: "pi-canon", content, display: false }, { deliverAs });
191
+ return true;
192
+ } catch (error) {
193
+ trace("delivery-failed", { deliverAs, error: String(error) });
194
+ return false;
112
195
  }
113
196
  }
@@ -1,6 +1,7 @@
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 { governsAnAsset, RULE_SCOPE } from "./retrieval.ts";
4
5
  import { normalize, type Article, type CanonStore } from "./store.ts";
5
6
 
6
7
  export const BODY_WARN_CHARS = 8000;
@@ -13,14 +14,96 @@ const EVENTISH = /^(added|updated|fixed|changed|implemented|removed|refactored|r
13
14
 
14
15
  const CONSTRAINT = /\b(must|never|always|require[sd]?|do not|don't)\b/i;
15
16
 
16
- export function advise(article: Article, store: CanonStore, priorBody?: string): string[] {
17
+ /* Where the article sits relative to the tree, and whether anything can reach an
18
+ article that sits off it. Absent means do not raise the scope question at all: with no
19
+ retriever an off-path article is unreachable, so the advice would be advice to lose
20
+ information. */
21
+ export interface Reach {
22
+ dir: string;
23
+ retrieval: string;
24
+ }
25
+
26
+ /* Value-shaped tokens. Not an attempt to understand the text: these are the shapes a
27
+ fact takes when it cannot be paraphrased without being destroyed. An id, a key, a
28
+ count, a duration. Prose survives distillation; these are what it drops. */
29
+ const CARDINAL =
30
+ "(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|" +
31
+ "fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|" +
32
+ "eighty|ninety|hundred|thousand|million)";
33
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
34
+
35
+ const VALUE_SHAPES: RegExp[] = [
36
+ /`([^`\n]{2,60})`/g, // backticked
37
+ /"([^"\n]{2,60})"/g, // quoted
38
+ /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\b/gi, // hyphenated id: nightly-dispatch
39
+ /\b([A-Z][A-Z0-9]*_[A-Z0-9_]+)\b/g, // SNAKE_CASE
40
+ /\b([a-z][a-z0-9]*\.[a-z][a-z0-9]+)\b/g, // dotted: period.close
41
+ /\b(\d[\d,]*(?:\.\d+)?\s*%?)/g, // counts and limits: 40,000
42
+ new RegExp(`\\b(${CARDINAL}\\s+[a-z]{3,})\\b`, "gi"), // spelled durations: eleven weeks
43
+ ];
44
+
45
+ const VALUE_CAP = 6;
46
+
47
+ function valuesIn(text: string): string[] {
48
+ const found = new Map<string, string>();
49
+ for (const shape of VALUE_SHAPES) {
50
+ for (const match of text.matchAll(shape)) {
51
+ const value = (match[1] ?? "").trim();
52
+ if (value.length < 2 || ISO_DATE.test(value)) continue;
53
+ found.set(value.toLowerCase(), value);
54
+ }
55
+ }
56
+ return [...found.values()];
57
+ }
58
+
59
+ /* The values this journal entry recorded that its article did not keep.
60
+ cap1 measured what this is for: an article stating a rule's shape without its values
61
+ scores exactly what no article scores, and capbase found the journal holding every
62
+ value 48/48 while the article kept 13/48. The journal is the provenance, so the check
63
+ is a literal diff and needs no model: what did you just write down that the article
64
+ someone else will read does not carry. */
65
+ export function unretained(journalBody: string, article: Article | undefined): string[] {
66
+ if (!article) return [];
67
+ const kept = `${article.capsule} ${article.body}`.toLowerCase().replace(/\s+/g, " ");
68
+ /* Bounded containment, not bare substring: a journal value of 42 must not count as
69
+ kept because the article happens to say 142, which is the failure mode a guard
70
+ about exact values can least afford. A value may still sit inside a larger
71
+ identifier at a symbol boundary, so billing-close counts inside
72
+ system:billing-close. */
73
+ const holds = (value: string): boolean => {
74
+ const needle = value.toLowerCase().replace(/\s+/g, " ");
75
+ let from = 0;
76
+ for (;;) {
77
+ const at = kept.indexOf(needle, from);
78
+ if (at === -1) return false;
79
+ const before = kept[at - 1] ?? " ";
80
+ const after = kept[at + needle.length] ?? " ";
81
+ if (!/[a-z0-9]/.test(before) && !/[a-z0-9]/.test(after)) return true;
82
+ from = at + 1;
83
+ }
84
+ };
85
+ return valuesIn(journalBody)
86
+ .filter((value) => !holds(value))
87
+ .sort((a, b) => b.length - a.length)
88
+ .slice(0, VALUE_CAP);
89
+ }
90
+
91
+ export function advise(
92
+ article: Article,
93
+ store: CanonStore,
94
+ priorBody?: string,
95
+ reach?: Reach,
96
+ ): string[] {
17
97
  const advice: string[] = [];
18
98
  const size = article.body.length;
19
99
 
20
100
  /* The laundering guard: an agent that just violated a documented constraint will
21
101
  faithfully update the article to describe the violation as current truth. Name
22
102
  what disappeared; whether it still holds is the agent's call, stated out loud. */
23
- if (priorBody !== undefined) {
103
+ /* Only when this write actually replaced the body. priorBody is now the article's real
104
+ prior state on every write, including capsule-only ones, because the scope question
105
+ below needs it; a capsule-only write drops nothing, so it has nothing to launder. */
106
+ if (priorBody !== undefined && article.body !== priorBody) {
24
107
  const kept = article.body.replace(/\s+/g, " ");
25
108
  const dropped = priorBody
26
109
  .split(/\r?\n/)
@@ -35,6 +118,49 @@ export function advise(article: Article, store: CanonStore, priorBody?: string):
35
118
  }
36
119
  }
37
120
 
121
+ /* The scope question, asked once per article at the moment it first becomes a rule.
122
+ Filing a constraint at the asset you happened to be editing is the addressing
123
+ version of the paraphrase failure: the rule survives, in full, at an address
124
+ nothing else resolves to. A run-2 miss lost "docs claim 1000" by wording; this
125
+ loses a house rule by placement, and neither is visible to the agent that did it.
126
+
127
+ Not a classifier. Nothing here can tell a rule about this asset from a rule about
128
+ every asset, and guessing wrong in the quiet direction is the expensive way to be
129
+ wrong. So it asks rather than decides, and it asks only on the write that turns an
130
+ article into one carrying a rule, so a store being maintained stays quiet. */
131
+ if (
132
+ reach && reach.retrieval !== "none" &&
133
+ CONSTRAINT.test(article.body) && !CONSTRAINT.test(priorBody ?? "") &&
134
+ governsAnAsset(reach.dir, article.path)
135
+ ) {
136
+ advice.push(
137
+ `This article now carries a rule, and it lives at ${article.path}, which governs an asset. ` +
138
+ `Anything working on a different asset resolves to its own article and never reaches this one. ` +
139
+ `If the rule holds beyond ${article.path}, give it its own address naming the rule instead, ` +
140
+ "where relevance to the work can find it.",
141
+ );
142
+ }
143
+
144
+ /* The complement of the scope question above. That one fires when a rule lands at an
145
+ address that governs an asset and says move it off. This one fires when a rule
146
+ lands where no asset lives, which is exactly where the doctrine asked for it, and
147
+ says name it as such. Undeclared, that article is indistinguishable from one whose
148
+ asset was deleted under it, and the two want opposite things: one is the design
149
+ working, the other is knowledge quietly going stale. Same trigger as the scope
150
+ question, so an article is asked once and a store being maintained stays quiet.
151
+ It cannot catch a stale article that never carried a rule, and nothing here can. */
152
+ if (
153
+ reach && reach.retrieval !== "none" &&
154
+ CONSTRAINT.test(article.body) && !CONSTRAINT.test(priorBody ?? "") &&
155
+ !governsAnAsset(reach.dir, article.path) && article.scope !== RULE_SCOPE
156
+ ) {
157
+ advice.push(
158
+ `${article.path} governs no asset on disk, so relevance is the only thing that reaches it. ` +
159
+ "If that is deliberate and this names a rule, write it again with scope rule; an undeclared " +
160
+ "article here reads the same as one whose asset was deleted under it.",
161
+ );
162
+ }
163
+
38
164
  if (size > BODY_LARGE_CHARS) {
39
165
  advice.push(
40
166
  `Body is ${size} chars (large past ${BODY_LARGE_CHARS}). Go hierarchical: keep this article ` +