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 +32 -16
- package/extensions/canon.ts +109 -26
- package/extensions/lib/lint.ts +128 -2
- package/extensions/lib/retrieval.ts +355 -0
- package/extensions/lib/store.ts +209 -17
- package/extensions/lib/surfacing.ts +496 -35
- package/extensions/lib/tool.ts +197 -17
- package/package.json +3 -2
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
173
|
-
- No embeddings
|
|
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
|
|
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:
|
|
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.
|
package/extensions/canon.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
-
/*
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
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
|
|
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,
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), kind, ...data }) + "\n");
|
|
110
178
|
} catch {
|
|
111
|
-
/*
|
|
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
|
}
|
package/extensions/lib/lint.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ` +
|