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 +41 -13
- package/extensions/canon.ts +22 -18
- package/extensions/lib/retrieval.ts +23 -27
- package/extensions/lib/store.ts +8 -1
- package/extensions/lib/surfacing.ts +19 -17
- package/extensions/lib/tool.ts +58 -49
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
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
|
|
151
|
-
- **`retrieval`** ranks the
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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.
|
package/extensions/canon.ts
CHANGED
|
@@ -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
|
|
16
|
-
folded or compacted away surfaces again the next
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
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:
|
|
34
|
-
|
|
35
|
-
ninth of the suggestion volume.
|
|
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:
|
|
48
|
-
found good suggestions opened at
|
|
49
|
-
of four, so what the cutoff declines to spend is context on
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
4
|
-
nothing here changes that. Retrieval runs over the RESIDUE
|
|
5
|
-
address matches no asset,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
29
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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()) {
|
package/extensions/lib/store.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
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.
|
|
48
|
-
|
|
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
|
|
57
|
-
|
|
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
|
|
147
|
-
of the suggestion volume, and a store with nothing
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
volume; that is the default, and the
|
|
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.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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);
|
package/extensions/lib/tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/* The pi_canon tool: one tool,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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