pi-canon 0.2.3 → 0.3.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.
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
- import { advise, unretained } from "./lint.ts";
6
+ import { advise, orphaned, unretained } from "./lint.ts";
7
+ import { checkArticle, loadSchema, outgoingOf, READ_ONLY, SCHEMA_FILE } from "./schema.ts";
7
8
  import { contained, normalize, type CanonStore } from "./store.ts";
8
9
  import { type Candidate, LexicalRetriever, RULE_SCOPE } from "./retrieval.ts";
9
10
  import type { Mount, Surfacer } from "./surfacing.ts";
@@ -14,6 +15,64 @@ export interface CanonRuntime {
14
15
  cwd: string;
15
16
  mounts: Mount[];
16
17
  retrieval: string;
18
+ provenance?: { harness: string; sessionId?: string };
19
+ }
20
+
21
+ export const CANON_TOOL_PARAMETERS = {
22
+ type: "object",
23
+ properties: {
24
+ action: { type: "string", enum: ["read", "write", "journal", "map", "search"] },
25
+ path: {
26
+ type: "string",
27
+ description: "Article address, e.g. src/core/config. Required for read and write; optional filter for map.",
28
+ },
29
+ body: {
30
+ type: "string",
31
+ description:
32
+ "write: the full article body; specifics beat summaries (who consumes what, exact " +
33
+ "limits, what breaks). journal: the event text, source details intact.",
34
+ },
35
+ capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
36
+ query: { type: "string", description: "search: words to look for, across articles and the journal." },
37
+ journal: {
38
+ type: "boolean",
39
+ description:
40
+ "search: true to include journal entries in the results. Off by default because " +
41
+ "events are history, not current truth; the result names how many entries matched " +
42
+ "so you can opt in when the history is the point.",
43
+ },
44
+ scope: {
45
+ type: "string",
46
+ enum: ["rule", "asset"],
47
+ description:
48
+ "write: 'rule' when this article names a cross-cutting rule instead of governing an " +
49
+ "asset, so it is a rule on purpose rather than an article whose asset went missing; " +
50
+ "'asset' to take that back, when the article governs an asset after all.",
51
+ },
52
+ subject: {
53
+ type: "array",
54
+ items: { type: "string" },
55
+ description: "journal: article addresses this event concerns.",
56
+ },
57
+ slug: { type: "string", description: "journal: short name for the entry file." },
58
+ },
59
+ required: ["action"],
60
+ } as const;
61
+
62
+ export function canonToolDescription(retrieval = "none"): string {
63
+ return (
64
+ "Canonical project memory. Every asset has at most one governing article at its own address " +
65
+ "(src/core/config, lake/prices). read the governing article before working on an asset; " +
66
+ "write it after real changes. journal appends an immutable event entry: record the source " +
67
+ "as it happened, names and exact numbers included, because articles distill and only the " +
68
+ "journal keeps the original, so distil the prose but carry exact values through verbatim: " +
69
+ "ids, keys, names, counts, limits and durations, every member of a named set and not " +
70
+ "just the one you are working on. A rule without its values is worth nothing to the " +
71
+ "session that needs it. map lists articles with their capsules. " +
72
+ "Creation is rare: prefer updating the article that already governs. " +
73
+ "File a constraint at the asset it governs, or the shared parent when it spans assets, not " +
74
+ "the asset you happened to edit. " + filingTail(retrieval)
75
+ );
17
76
  }
18
77
 
19
78
  /* A path routes to the mount it names (lake:prices), the mount whose directory
@@ -50,51 +109,8 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval
50
109
  return {
51
110
  name: "pi_canon",
52
111
  label: "pi-canon",
53
- description:
54
- "Canonical project memory. Every asset has at most one governing article at its own address " +
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
- },
112
+ description: canonToolDescription(retrieval),
113
+ parameters: CANON_TOOL_PARAMETERS,
98
114
  async execute(
99
115
  _toolCallId: string,
100
116
  params: Record<string, unknown>,
@@ -102,7 +118,7 @@ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval
102
118
  _onUpdate: unknown,
103
119
  ctx: unknown,
104
120
  ) {
105
- const text = run(ready(ctx), params);
121
+ const text = runCanon(ready(ctx), params);
106
122
  return { content: [{ type: "text", text }], details: {} };
107
123
  },
108
124
  };
@@ -148,10 +164,19 @@ function filingTail(retrieval: string): string {
148
164
  named. Neither is decoration.
149
165
 
150
166
  Ranking reuses LexicalRetriever rather than growing a second notion of relevance, so search
151
- and recommendation cannot drift apart. */
167
+ and recommendation cannot drift apart.
168
+
169
+ The journal is opt-in (Shane, 2026-08-20): events are history, not current truth,
170
+ and R1 measured what including them by default cost, journal entries about an
171
+ event crowding out the article that carries its current truth for 8 to 20 points
172
+ of governing-article recall at realistic query lengths. Default search ranks
173
+ articles alone, never reads a journal body, and says the journal exists; with
174
+ journal true the window is split, articles up to half, journal the rest,
175
+ whichever side runs short ceding its slots. Current truth first, always. */
152
176
  const SEARCH_RESULTS = 10;
177
+ const ARTICLE_SLOTS = 5;
153
178
 
154
- function search(store: CanonStore, query: string): string {
179
+ function search(store: CanonStore, query: string, includeJournal: boolean): string {
155
180
  if (!query.trim()) return "search needs a query.";
156
181
  const articles: Candidate[] = [];
157
182
  for (const path of store.list()) {
@@ -167,20 +192,26 @@ function search(store: CanonStore, query: string): string {
167
192
  }
168
193
  }
169
194
  /* Journal entries enter the same index under a `journal/` key so one ranking covers both.
170
- The key is an index handle, never an address: it is not something `read` accepts. */
171
- const entries = store.journalEntries();
195
+ The key is an index handle, never an address: it is not something `read` accepts.
196
+ Built only on opt-in, so a default search never pays for reading every entry body. */
172
197
  const byKey = new Map<string, { logged: string; subjects: string[]; body: string }>();
173
- const journal: Candidate[] = entries.map((entry) => {
174
- const key = `journal/${entry.name.replace(/\.md$/, "")}`;
175
- byKey.set(key, entry);
176
- return {
177
- path: key,
178
- capsule: entry.subjects.join(", "),
179
- body: entry.body,
180
- updated: entry.logged,
181
- declared: false,
182
- };
183
- });
198
+ const journal: Candidate[] = !includeJournal
199
+ ? []
200
+ : store.journalEntries().map((entry) => {
201
+ const key = `journal/${entry.name.replace(/\.md$/, "")}`;
202
+ byKey.set(key, entry);
203
+ return {
204
+ path: key,
205
+ capsule: entry.subjects.join(", "),
206
+ body: entry.body,
207
+ updated: entry.logged,
208
+ declared: false,
209
+ };
210
+ });
211
+ const invitation =
212
+ !includeJournal && store.journalCount() > 0
213
+ ? "The journal was not searched; pass journal true to search events too."
214
+ : "";
184
215
 
185
216
  const all = [...articles, ...journal];
186
217
  if (!all.length) return "Nothing in the canon yet.";
@@ -188,9 +219,18 @@ function search(store: CanonStore, query: string): string {
188
219
  retriever.index(all);
189
220
  const scored = retriever.score(query, all);
190
221
  const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
191
- if (!ranked.length) return `Nothing matches "${query}".`;
222
+ if (!ranked.length) return [`Nothing matches "${query}".`, invitation].filter(Boolean).join(" ");
223
+
224
+ const articleRanked = ranked.filter(([key]) => !byKey.has(key));
225
+ const journalRanked = ranked.filter(([key]) => byKey.has(key));
226
+ const articleQuota = Math.min(
227
+ articleRanked.length,
228
+ Math.max(ARTICLE_SLOTS, SEARCH_RESULTS - journalRanked.length),
229
+ );
230
+ const journalQuota = Math.min(journalRanked.length, SEARCH_RESULTS - articleQuota);
231
+ const chosen = [...articleRanked.slice(0, articleQuota), ...journalRanked.slice(0, journalQuota)];
192
232
 
193
- const lines = ranked.slice(0, SEARCH_RESULTS).map(([key]) => {
233
+ const lines = chosen.map(([key]) => {
194
234
  const entry = byKey.get(key);
195
235
  if (entry) {
196
236
  const subjects = entry.subjects.length ? ` (${entry.subjects.join(", ")})` : "";
@@ -200,9 +240,10 @@ function search(store: CanonStore, query: string): string {
200
240
  return `${key}: ${article?.capsule || excerpt(article?.body ?? "")}`;
201
241
  });
202
242
  /* Say what was dropped. A silent cap reads as "that is everything". */
203
- if (ranked.length > SEARCH_RESULTS) {
204
- lines.push(`... ${ranked.length - SEARCH_RESULTS} more matched; narrow the query to see them.`);
243
+ if (ranked.length > chosen.length) {
244
+ lines.push(`... ${ranked.length - chosen.length} more matched; narrow the query to see them.`);
205
245
  }
246
+ if (invitation) lines.push(invitation);
206
247
  return lines.join("\n");
207
248
  }
208
249
 
@@ -211,7 +252,7 @@ function excerpt(body: string): string {
211
252
  return flat.length > 160 ? `${flat.slice(0, 157)}...` : flat;
212
253
  }
213
254
 
214
- function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
255
+ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>): string {
215
256
  const { surfacer } = runtime;
216
257
  const action = String(params.action ?? "");
217
258
  const { mount, path } = typeof params.path === "string" ? route(runtime, params.path) : { mount: runtime.mounts[0], path: "" };
@@ -245,7 +286,18 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
245
286
  const index = recent.length
246
287
  ? `\n\njournal: ${recent.join(", ")}${earlier ? ` and ${earlier} earlier` : ""}`
247
288
  : "";
248
- return `${title}\n${head}\n\n${article.body}`.trim() + index;
289
+ /* Reads never reject, but they do report: an agent holding a noncompliant
290
+ article is the one agent positioned to heal it, and silence here is how a
291
+ store drifts out of its own contract one read at a time. */
292
+ const { schema, problems } = loadSchema(store.root);
293
+ const standing = schema ? checkArticle(article, schema, READ_ONLY) : { rejections: [], warnings: [] };
294
+ const issues = [...standing.warnings, ...problems];
295
+ const report = issues.length
296
+ ? `\n\nschema (${SCHEMA_FILE}): ${issues.join(" ")} This article can be healed with a write.`
297
+ : "";
298
+ const missing = orphaned(mount.dir, article);
299
+ const orphan = missing ? `\n\n${missing}` : "";
300
+ return `${title}\n${head}\n\n${article.body}`.trim() + index + report + orphan;
249
301
  }
250
302
  case "write": {
251
303
  if (!path) return "write needs a path.";
@@ -258,7 +310,7 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
258
310
  prior state; which fields this call happened to set is a separate question and is
259
311
  answered separately below. */
260
312
  const prior = store.read(path);
261
- const article = store.write(path, {
313
+ const fields = {
262
314
  capsule: params.capsule ? String(params.capsule) : undefined,
263
315
  body: params.body ? String(params.body) : undefined,
264
316
  /* "asset" is the way back. The enum is the only vocabulary the model has, so
@@ -266,16 +318,93 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
266
318
  one: every other input falls through to undefined, which means untouched. It
267
319
  stores empty, which is the default state, the address being the claim. */
268
320
  scope: params.scope === "asset" ? "" : params.scope ? String(params.scope) : undefined,
269
- });
321
+ };
322
+ /* The store's declared contract, held against the article this write WOULD
323
+ store, before anything touches disk. Only a required rule the write itself
324
+ touched (or a brand new article) rejects; everything else warns, so the write
325
+ still lands and the agent still learns. */
326
+ const { schema, problems } = loadSchema(store.root);
327
+ const composed = store.compose(path, fields);
328
+ const verdict = schema
329
+ ? checkArticle(composed, schema, {
330
+ capsule: fields.capsule !== undefined,
331
+ body: fields.body !== undefined,
332
+ /* Touched means the reference SET changed, not that a body was sent: a
333
+ body edit that keeps its citations must not re-litigate them. */
334
+ refs: outgoingOf(prior ? prior.body : "").join("\n") !== outgoingOf(composed.body).join("\n"),
335
+ created: !prior,
336
+ })
337
+ : { rejections: [], warnings: [] };
338
+ if (verdict.rejections.length) {
339
+ return [
340
+ `Write rejected by this store's ${SCHEMA_FILE}:`,
341
+ ...verdict.rejections.map((line) => `- ${line}`),
342
+ "Nothing was written. Fix the listed fields and write again.",
343
+ ].join("\n");
344
+ }
345
+ /* A write that changes nothing is a restatement, not a change. Measured (W1j):
346
+ restating the current state through the write path was the one store
347
+ corruption no content rule could catch, because no field differs; the only
348
+ thing it changed was the freshness stamp, which then lied. So equality is
349
+ checked here, mechanically, and `updated` keeps meaning what it says. */
350
+ if (
351
+ prior &&
352
+ composed.capsule === prior.capsule &&
353
+ composed.scope === prior.scope &&
354
+ composed.body.trimEnd() === prior.body.trimEnd()
355
+ ) {
356
+ surfacer.markUpdated(
357
+ qualify(composed.path),
358
+ [params.capsule, params.body].filter(Boolean).map(String).join("\n"),
359
+ );
360
+ return [
361
+ `${qualify(composed.path)} is already current: this write matches the stored article, ` +
362
+ `so nothing was rewritten and updated stays ${prior.updated}.`,
363
+ ...verdict.warnings.map((line) => `schema: ${line}`),
364
+ ...problems,
365
+ ].join("\n");
366
+ }
367
+ const article = store.write(path, fields);
368
+ /* When a rewrite grows the body, the result says so and restates the split.
369
+ Measured over three captures, two arms each, on byte-identical
370
+ eight-session lineages: writers narrate history into articles until the
371
+ store outgrows the raw transcripts it distills, and prompt-side guidance
372
+ does not change the habit. The arm that got this line ended with fewer
373
+ standing superseded values in all three, 51 and 45 and 71 of 96 against
374
+ 88 and 87 and 85. Take the DIRECTION and not the size. The third capture
375
+ counterbalanced the arm order and kept the direction while losing most of
376
+ the magnitude, and re-running a matched untreated cell moved its median
377
+ store 39 percent, so this instrument does not measure its own magnitudes
378
+ reliably. Two readers in 96 sessions were harmed by a stale value, one
379
+ from each arm, so this line is not known to protect readers. It is also
380
+ not one exposure: it fired after 117, 99, and 126 of that arm's 198, 184,
381
+ and 207 writes. Any growth fires; that exact behavior is what was
382
+ measured. Creation is not growth, and a capsule-only write never grows
383
+ the stored body. */
384
+ const priorBytes = prior ? Buffer.byteLength(prior.body.trimEnd()) : null;
385
+ const nextBytes = Buffer.byteLength(composed.body.trimEnd());
386
+ const growth =
387
+ priorBytes !== null && nextBytes > priorBytes
388
+ ? [
389
+ `Body grew ${priorBytes} -> ${nextBytes} bytes. An article carries current ` +
390
+ `state; if this growth is narrated history (old values, transitions), move ` +
391
+ `it to the journal and keep the article at what is true now.`,
392
+ ]
393
+ : [];
270
394
  /* What this write put in the window, which is what the agent supplied, not the
271
395
  merged article: a capsule-only write does not deliver the stored body. */
272
396
  surfacer.markUpdated(
273
397
  qualify(article.path),
274
398
  [params.capsule, params.body].filter(Boolean).map(String).join("\n"),
275
399
  );
400
+ const missingAsset = orphaned(mount.dir, article);
276
401
  return [
277
402
  `Wrote ${qualify(article.path)}.`,
278
- ...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }),
403
+ ...growth,
404
+ ...verdict.warnings.map((line) => `schema: ${line}`),
405
+ ...problems,
406
+ ...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }, schema),
407
+ ...(missingAsset ? [missingAsset] : []),
279
408
  ].join("\n");
280
409
  }
281
410
  case "journal": {
@@ -293,6 +422,7 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
293
422
  body,
294
423
  subject,
295
424
  slug: typeof params.slug === "string" ? params.slug : undefined,
425
+ provenance: runtime.provenance,
296
426
  });
297
427
  /* The article was written before this entry (agents write then journal), so this
298
428
  is the first moment both exist. Report what the source kept and the article did
@@ -321,7 +451,7 @@ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
321
451
  case "map":
322
452
  return store.map(path);
323
453
  case "search":
324
- return search(store, String(params.query ?? ""));
454
+ return search(store, String(params.query ?? ""), params.journal === true);
325
455
  default:
326
456
  return `Unknown action "${action}". Actions: read, write, journal, map, search.`;
327
457
  }