pi-canon 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -11
- package/extensions/canon.ts +104 -13
- 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/extensions/lib/tool.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/* The pi_canon tool: one tool, four verbs. Read and update over create; the journal
|
|
2
2
|
for events; map to orient. */
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { advise, unretained } from "./lint.ts";
|
|
7
|
+
import { contained, normalize, type CanonStore } from "./store.ts";
|
|
8
|
+
import { type Candidate, LexicalRetriever, RULE_SCOPE } from "./retrieval.ts";
|
|
7
9
|
import type { Mount, Surfacer } from "./surfacing.ts";
|
|
8
10
|
|
|
9
11
|
export interface CanonRuntime {
|
|
@@ -11,6 +13,7 @@ export interface CanonRuntime {
|
|
|
11
13
|
surfacer: Surfacer;
|
|
12
14
|
cwd: string;
|
|
13
15
|
mounts: Mount[];
|
|
16
|
+
retrieval: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/* A path routes to the mount it names (lake:prices), the mount whose directory
|
|
@@ -19,18 +22,31 @@ function route(runtime: CanonRuntime, raw: string): { mount: Mount; path: string
|
|
|
19
22
|
const qualified = /^([\w.-]+):(.*)$/.exec(raw);
|
|
20
23
|
if (qualified) {
|
|
21
24
|
const mount = runtime.mounts.find((m) => m.name === qualified[1]);
|
|
22
|
-
if (mount) return { mount, path:
|
|
25
|
+
if (mount) return { mount, path: settle(mount, qualified[2], mount.dir) };
|
|
23
26
|
}
|
|
24
27
|
const slashed = raw.replace(/\\/g, "/");
|
|
25
28
|
for (const mount of runtime.mounts) {
|
|
26
29
|
if (mount.name && (slashed === mount.dir || slashed.startsWith(`${mount.dir}/`))) {
|
|
27
|
-
return { mount, path:
|
|
30
|
+
return { mount, path: settle(mount, slashed, mount.dir) };
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
|
-
return { mount: runtime.mounts[0], path:
|
|
33
|
+
return { mount: runtime.mounts[0], path: settle(runtime.mounts[0], raw, runtime.cwd) };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
/* An address that already names an article wins over canonicalising it a second time.
|
|
37
|
+
normalize drops one extension at the asset boundary, so the article governing
|
|
38
|
+
src/core/config.test.ts lives at src/core/config.test, which is the address map
|
|
39
|
+
prints. Feeding that address back to read used to normalize again down to
|
|
40
|
+
src/core/config and silently return a DIFFERENT article whenever the parent existed
|
|
41
|
+
(Sol Pro, 2026-08-13). An asset on disk still wins, so a real file named config.test
|
|
42
|
+
is not mistaken for the canonical address of config.test.ts. */
|
|
43
|
+
function settle(mount: Mount, raw: string, cwd: string): string {
|
|
44
|
+
const exact = contained(raw, cwd);
|
|
45
|
+
if (exact && !existsSync(join(mount.dir, exact)) && mount.store.read(exact)) return exact;
|
|
46
|
+
return normalize(raw, cwd);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval = "none") {
|
|
34
50
|
return {
|
|
35
51
|
name: "pi_canon",
|
|
36
52
|
label: "pi-canon",
|
|
@@ -39,14 +55,17 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime) {
|
|
|
39
55
|
"(src/core/config, lake/prices). read the governing article before working on an asset; " +
|
|
40
56
|
"write it after real changes. journal appends an immutable event entry: record the source " +
|
|
41
57
|
"as it happened, names and exact numbers included, because articles distill and only the " +
|
|
42
|
-
"journal keeps the original
|
|
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. " +
|
|
43
62
|
"Creation is rare: prefer updating the article that already governs. " +
|
|
44
63
|
"File a constraint at the asset it governs, or the shared parent when it spans assets, not " +
|
|
45
|
-
"the asset you happened to edit
|
|
64
|
+
"the asset you happened to edit. " + filingTail(retrieval),
|
|
46
65
|
parameters: {
|
|
47
66
|
type: "object",
|
|
48
67
|
properties: {
|
|
49
|
-
action: { type: "string", enum: ["read", "write", "journal", "map"] },
|
|
68
|
+
action: { type: "string", enum: ["read", "write", "journal", "map", "search"] },
|
|
50
69
|
path: {
|
|
51
70
|
type: "string",
|
|
52
71
|
description: "Article address, e.g. src/core/config. Required for read and write; optional filter for map.",
|
|
@@ -58,6 +77,15 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime) {
|
|
|
58
77
|
"limits, what breaks). journal: the event text, source details intact.",
|
|
59
78
|
},
|
|
60
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
|
+
},
|
|
61
89
|
subject: {
|
|
62
90
|
type: "array",
|
|
63
91
|
items: { type: "string" },
|
|
@@ -80,6 +108,108 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime) {
|
|
|
80
108
|
};
|
|
81
109
|
}
|
|
82
110
|
|
|
111
|
+
/* The last clause of the filing rule is configuration dependent, and getting it wrong
|
|
112
|
+
in either direction costs knowledge. With no retriever an article at an address that
|
|
113
|
+
governs no asset is genuinely unreachable, so the doctrine must not invite one: the
|
|
114
|
+
only honest instruction is to keep everything on the asset path. With a retriever
|
|
115
|
+
that same article is reachable by relevance, and the instruction inverts, because the
|
|
116
|
+
alternative is filing a constraint that spans unrelated packages at their only shared
|
|
117
|
+
parent, which is the root, and a root article surfaces on every touch of anything.
|
|
118
|
+
|
|
119
|
+
Taken from the caller, which built the retriever, rather than read back off the
|
|
120
|
+
runtime: the runtime is created from the session's ctx, and forcing it into existence
|
|
121
|
+
at registration to answer this would pin it to the wrong working directory. */
|
|
122
|
+
function filingTail(retrieval: string): string {
|
|
123
|
+
if (retrieval === "none") {
|
|
124
|
+
return "Knowledge filed off the asset path never surfaces.";
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
"A constraint that governs many assets and owns none belongs at its own address, one " +
|
|
128
|
+
"naming the rule rather than any asset, because the only parent unrelated packages share " +
|
|
129
|
+
"is the root and a root article surfaces on every touch of anything. Those articles are " +
|
|
130
|
+
"reached by relevance to the work rather than by address, so give them a capsule that " +
|
|
131
|
+
"reads like the situation it governs, and write them with scope rule so a rule on purpose " +
|
|
132
|
+
"is not mistaken for an article whose asset went missing."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Agent-solicited search, over articles AND the journal.
|
|
137
|
+
|
|
138
|
+
Three channels reach this memory and their scopes differ on purpose. Surfacing on touch is
|
|
139
|
+
ADDRESS ONLY: you touched an asset, you get the article governing it. Search is ANY: the
|
|
140
|
+
agent asked, so nothing is withheld, and a journal entry is a first class result even
|
|
141
|
+
though it has no address at all.
|
|
142
|
+
|
|
143
|
+
Every result carries what SCOPES it, which is the one thing a result cannot be useful
|
|
144
|
+
without. A study of a 259 KB flat memory found sessions receiving every fact they needed
|
|
145
|
+
and still answering wrong, because a grep returned 201 answers to one question with nothing
|
|
146
|
+
saying which situation each applied to. For an article the scope is its address; for a
|
|
147
|
+
journal entry it is the instant and the subjects it named. Neither is decoration.
|
|
148
|
+
|
|
149
|
+
Ranking reuses LexicalRetriever rather than growing a second notion of relevance, so search
|
|
150
|
+
and recommendation cannot drift apart. */
|
|
151
|
+
const SEARCH_RESULTS = 10;
|
|
152
|
+
|
|
153
|
+
function search(store: CanonStore, query: string): string {
|
|
154
|
+
if (!query.trim()) return "search needs a query.";
|
|
155
|
+
const articles: Candidate[] = [];
|
|
156
|
+
for (const path of store.list()) {
|
|
157
|
+
const article = store.read(path);
|
|
158
|
+
if (article) {
|
|
159
|
+
articles.push({
|
|
160
|
+
path,
|
|
161
|
+
capsule: article.capsule,
|
|
162
|
+
body: article.body,
|
|
163
|
+
updated: article.updated,
|
|
164
|
+
declared: article.scope === RULE_SCOPE,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/* Journal entries enter the same index under a `journal/` key so one ranking covers both.
|
|
169
|
+
The key is an index handle, never an address: it is not something `read` accepts. */
|
|
170
|
+
const entries = store.journalEntries();
|
|
171
|
+
const byKey = new Map<string, { logged: string; subjects: string[]; body: string }>();
|
|
172
|
+
const journal: Candidate[] = entries.map((entry) => {
|
|
173
|
+
const key = `journal/${entry.name.replace(/\.md$/, "")}`;
|
|
174
|
+
byKey.set(key, entry);
|
|
175
|
+
return {
|
|
176
|
+
path: key,
|
|
177
|
+
capsule: entry.subjects.join(", "),
|
|
178
|
+
body: entry.body,
|
|
179
|
+
updated: entry.logged,
|
|
180
|
+
declared: false,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const all = [...articles, ...journal];
|
|
185
|
+
if (!all.length) return "Nothing in the canon yet.";
|
|
186
|
+
const retriever = new LexicalRetriever();
|
|
187
|
+
retriever.index(all);
|
|
188
|
+
const scored = retriever.score(query, all);
|
|
189
|
+
const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
|
|
190
|
+
if (!ranked.length) return `Nothing matches "${query}".`;
|
|
191
|
+
|
|
192
|
+
const lines = ranked.slice(0, SEARCH_RESULTS).map(([key]) => {
|
|
193
|
+
const entry = byKey.get(key);
|
|
194
|
+
if (entry) {
|
|
195
|
+
const subjects = entry.subjects.length ? ` (${entry.subjects.join(", ")})` : "";
|
|
196
|
+
return `journal ${entry.logged}${subjects}: ${excerpt(entry.body)}`;
|
|
197
|
+
}
|
|
198
|
+
const article = store.read(key);
|
|
199
|
+
return `${key}: ${article?.capsule || excerpt(article?.body ?? "")}`;
|
|
200
|
+
});
|
|
201
|
+
/* Say what was dropped. A silent cap reads as "that is everything". */
|
|
202
|
+
if (ranked.length > SEARCH_RESULTS) {
|
|
203
|
+
lines.push(`... ${ranked.length - SEARCH_RESULTS} more matched; narrow the query to see them.`);
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function excerpt(body: string): string {
|
|
209
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
210
|
+
return flat.length > 160 ? `${flat.slice(0, 157)}...` : flat;
|
|
211
|
+
}
|
|
212
|
+
|
|
83
213
|
function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
|
|
84
214
|
const { surfacer } = runtime;
|
|
85
215
|
const action = String(params.action ?? "");
|
|
@@ -90,11 +220,18 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
|
|
|
90
220
|
switch (action) {
|
|
91
221
|
case "read": {
|
|
92
222
|
if (!path) return "read needs a path.";
|
|
93
|
-
|
|
223
|
+
/* Exact address first, ancestors only if nothing governs it directly. resolve()
|
|
224
|
+
normalizes what it is given, and route has already produced a canonical
|
|
225
|
+
address, so handing it straight to resolve drops a second extension and
|
|
226
|
+
answers src/core/config.test with src/core/config. */
|
|
227
|
+
const article = store.read(path) ?? store.resolve(path);
|
|
94
228
|
if (!article) {
|
|
95
229
|
return `No article governs ${path}. If you are working on this asset, create its article with write after the task.`;
|
|
96
230
|
}
|
|
97
|
-
|
|
231
|
+
/* Everything this result is about to put in the window, so presence is tested
|
|
232
|
+
against the body it delivered rather than the capsule it happens to share with
|
|
233
|
+
a one-line surfaced nudge. An article whose body folds away is not present. */
|
|
234
|
+
surfacer.markSeen(qualify(article.path), `${article.capsule}\n${article.body}`);
|
|
98
235
|
const title = article.path === path ? qualify(article.path) : `${qualify(article.path)} governs ${qualify(path)}`;
|
|
99
236
|
const head = [
|
|
100
237
|
article.capsule ? `capsule: ${article.capsule}` : "",
|
|
@@ -113,13 +250,32 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
|
|
|
113
250
|
if (!path) return "write needs a path.";
|
|
114
251
|
/* Blank means untouched: models fill declared string fields with "" routinely,
|
|
115
252
|
and a "" here would silently erase stored content. */
|
|
116
|
-
|
|
253
|
+
/* The stored body BEFORE this write, whether or not this write supplies one. Read
|
|
254
|
+
only when a body was supplied, it was undefined on every capsule-only write, so
|
|
255
|
+
the scope question tested its trigger against "" and re-fired on an article that
|
|
256
|
+
had carried the same rule for weeks. What the advice needs is the article's real
|
|
257
|
+
prior state; which fields this call happened to set is a separate question and is
|
|
258
|
+
answered separately below. */
|
|
259
|
+
const prior = store.read(path);
|
|
117
260
|
const article = store.write(path, {
|
|
118
261
|
capsule: params.capsule ? String(params.capsule) : undefined,
|
|
119
262
|
body: params.body ? String(params.body) : undefined,
|
|
263
|
+
/* "asset" is the way back. The enum is the only vocabulary the model has, so
|
|
264
|
+
without a second value an article declared `scope: rule` could never stop being
|
|
265
|
+
one: every other input falls through to undefined, which means untouched. It
|
|
266
|
+
stores empty, which is the default state, the address being the claim. */
|
|
267
|
+
scope: params.scope === "asset" ? "" : params.scope ? String(params.scope) : undefined,
|
|
120
268
|
});
|
|
121
|
-
|
|
122
|
-
|
|
269
|
+
/* What this write put in the window, which is what the agent supplied, not the
|
|
270
|
+
merged article: a capsule-only write does not deliver the stored body. */
|
|
271
|
+
surfacer.markUpdated(
|
|
272
|
+
qualify(article.path),
|
|
273
|
+
[params.capsule, params.body].filter(Boolean).map(String).join("\n"),
|
|
274
|
+
);
|
|
275
|
+
return [
|
|
276
|
+
`Wrote ${qualify(article.path)}.`,
|
|
277
|
+
...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }),
|
|
278
|
+
].join("\n");
|
|
123
279
|
}
|
|
124
280
|
case "journal": {
|
|
125
281
|
const body = typeof params.body === "string" ? params.body.trim() : "";
|
|
@@ -137,11 +293,35 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
|
|
|
137
293
|
subject,
|
|
138
294
|
slug: typeof params.slug === "string" ? params.slug : undefined,
|
|
139
295
|
});
|
|
140
|
-
|
|
296
|
+
/* The article was written before this entry (agents write then journal), so this
|
|
297
|
+
is the first moment both exist. Report what the source kept and the article did
|
|
298
|
+
not: capbase measured the journal holding every value and the article keeping a
|
|
299
|
+
quarter of them, and cap1 measured an article without its values scoring exactly
|
|
300
|
+
what no article scores. */
|
|
301
|
+
const missed: string[] = [];
|
|
302
|
+
for (const address of subject ?? []) {
|
|
303
|
+
const routed = route(runtime, address);
|
|
304
|
+
const governing = routed.mount.store.read(routed.path);
|
|
305
|
+
for (const value of unretained(body, governing)) {
|
|
306
|
+
missed.push(`${address} is missing ${value}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return [
|
|
310
|
+
`Logged ${basename(file)}.`,
|
|
311
|
+
...(missed.length
|
|
312
|
+
? [
|
|
313
|
+
`This entry records values its article does not carry: ${missed.slice(0, 6).join("; ")}. ` +
|
|
314
|
+
"The article is what surfaces on a touch; the journal is not. If those values " +
|
|
315
|
+
"matter beyond this event, put them in the article verbatim.",
|
|
316
|
+
]
|
|
317
|
+
: []),
|
|
318
|
+
].join("\n");
|
|
141
319
|
}
|
|
142
320
|
case "map":
|
|
143
321
|
return store.map(path);
|
|
322
|
+
case "search":
|
|
323
|
+
return search(store, String(params.query ?? ""));
|
|
144
324
|
default:
|
|
145
|
-
return `Unknown action "${action}". Actions: read, write, journal, map.`;
|
|
325
|
+
return `Unknown action "${action}". Actions: read, write, journal, map, search.`;
|
|
146
326
|
}
|
|
147
327
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-canon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Canonical project memory for the Pi coding agent: one article per asset at a knowable address, an append-only journal beneath it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./extensions/index.js"
|
|
7
|
+
".": "./extensions/index.js",
|
|
8
|
+
"./package.json": "./package.json"
|
|
8
9
|
},
|
|
9
10
|
"pi": {
|
|
10
11
|
"extensions": [
|