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.
@@ -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 { basename } from "node:path";
5
- import { advise } from "./lint.ts";
6
- import { normalize, type CanonStore } from "./store.ts";
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: normalize(qualified[2], mount.dir) };
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: normalize(slashed, mount.dir) };
30
+ return { mount, path: settle(mount, slashed, mount.dir) };
28
31
  }
29
32
  }
30
- return { mount: runtime.mounts[0], path: normalize(raw, runtime.cwd) };
33
+ return { mount: runtime.mounts[0], path: settle(runtime.mounts[0], raw, runtime.cwd) };
31
34
  }
32
35
 
33
- export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime) {
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. map lists articles with their capsules. " +
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; knowledge filed off the asset path never surfaces.",
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
- const article = store.resolve(path);
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
- surfacer.markSeen(qualify(article.path));
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
- const priorBody = params.body ? store.read(path)?.body : undefined;
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
- surfacer.markUpdated(qualify(article.path));
122
- return [`Wrote ${qualify(article.path)}.`, ...advise(article, store, priorBody)].join("\n");
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
- return `Logged ${basename(file)}.`;
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.1.2",
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": [