pi-canon 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shane Conner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # pi-canon
2
+
3
+ Canonical project memory for the [Pi coding agent](https://pi.dev). One article per asset at a knowable address, an append-only journal beneath it: pi-canon surfaces the governing article's capsule as the agent touches an asset, and reminds it to update the article after real changes.
4
+
5
+ ## Why
6
+
7
+ Agent knowledge bases rot in two ways. Agents cannot tell which article is THE article for a topic, so they scatter near duplicates and cite stale ones. And they treat the knowledge base as a diary, so ground truth drowns in event logs.
8
+
9
+ pi-canon answers both structurally:
10
+
11
+ - The article address IS the asset path. `src/core/config.ts` is governed by `articles/src/core/config.md`; a data lake path like `lake/fundamentals/market_cap` works the same way. One place to look, nothing to search.
12
+ - The journal is a separate, immutable tier. The source goes there as it happened, names and exact numbers included; articles hold only the current best understanding. An article can compress or drift, the journal entry underneath it cannot, so the original is always one hop away.
13
+
14
+ ## The store
15
+
16
+ .canon/
17
+ articles/
18
+ src/core/config.md article governing src/core/config.*
19
+ lake/prices.md articles are not limited to code
20
+ journal/
21
+ 2026-08-10-inception.md immutable, one file per entry
22
+
23
+ Articles are markdown with a few owned lines of front matter, each with a job:
24
+
25
+ ---
26
+ capsule: Loads layered config; env beats file; secrets never land here.
27
+ updated: 2026-08-10
28
+ ---
29
+ The body: dense current understanding of this asset.
30
+
31
+ `capsule` is the one dense line surfacing injects. `updated` is the date of the last write. Rename an asset by moving its article with it; lint names any wikilinks that go dead. Foreign front matter keys, such as Obsidian properties, ride through writes untouched.
32
+
33
+ The tree is plain markdown and a valid Obsidian vault; if you think of it as a project wiki, that is the right instinct, with one rule added: every article has exactly one canonical address. Commit it with your repo: git is the history, diff, blame, and time machine. pi-canon never runs git itself. Journal entries are ordinary files too: pi_canon only appends them; read them with normal file tools.
34
+
35
+ ## Surfacing
36
+
37
+ Each session opens with one orientation line: how many articles govern the project, or an invitation to write the first one. When a tool call touches an asset whose governing article has not been seen this session, pi-canon stages the capsule; each turn delivers everything staged as one bounded message, once per article per session, under a hard budget (pointers only once it is spent). Resolution walks up: the nearest existing ancestor article governs, so not every file needs an article. After the agent settles, touched but not updated articles draw a single reminder. `/pi-canon` prints a status line: articles, journal entries, and what surfacing has spent this session.
38
+
39
+ ## Tool
40
+
41
+ One tool, `pi_canon`, four actions:
42
+
43
+ | action | does |
44
+ |---|---|
45
+ | `read` | the article at an address; a miss points to the nearest governing ancestor |
46
+ | `write` | create or update an article; returns advisory lint, never refuses |
47
+ | `journal` | append an event entry, source details intact; pi_canon never rewrites one |
48
+ | `map` | list articles with their capsules |
49
+
50
+ Entries logged with `subject` addresses reappear as a one-line journal index when those articles are read, so event history is there to dig into without ever loading by default.
51
+
52
+ ## Options
53
+
54
+ import piCanon, { registerPiCanon } from "pi-canon"
55
+
56
+ piCanon(pi) defaults
57
+ registerPiCanon(pi, { root, surface, mounts }) the whole surface
58
+
59
+ Installed as a package, pi loads the default export with defaults; the named export is for an extension file of your own when you want options. `root` is where the project store lives (default `<project>/.canon`). `surface: false` disables nudging. `mounts` lists directories outside the project that carry their own `.canon` beside their assets: `mounts: ["/data/lake"]` serves articles as `lake:prices`, and two workspaces that mount the same directory share its knowledge, because the store lives with the assets it governs. Everything else is a constant on purpose.
60
+
61
+ ## Install
62
+
63
+ pi install npm:pi-canon (not yet published)
64
+
65
+ Or clone this repo into `~/.pi/agent/extensions/`. Node 22 or later, Pi 0.83 or later.
66
+
67
+ MIT. pi-canon is the long-term half of a four-tier memory stack: the journal is the episodic tier, the canon the semantic tier. [pi-fold](https://github.com/shaneconner/pi-fold) is a separate, optional package serving the working tier; the two compose but neither requires the other.
@@ -0,0 +1,113 @@
1
+ /* pi-canon: canonical project memory for Pi. Wiring only; mechanics live in lib/. */
2
+
3
+ import { basename, isAbsolute, join } from "node:path";
4
+ import { CanonStore } from "./lib/store.ts";
5
+ import { SESSION_BUDGET_CHARS, Surfacer, type Mount } from "./lib/surfacing.ts";
6
+ import { buildCanonTool, type CanonRuntime } from "./lib/tool.ts";
7
+
8
+ export interface CanonOptions {
9
+ /* Where the project store lives. Default: <project>/.canon */
10
+ root?: string;
11
+ /* Surface governing articles as tool calls touch assets. Default: true. */
12
+ surface?: boolean;
13
+ /* Directories outside the project that carry their own .canon beside their
14
+ assets, addressed by basename: mounts: ["/data/lake"] serves lake:prices.
15
+ Workspaces that mount the same directory share its knowledge. */
16
+ mounts?: string[];
17
+ }
18
+
19
+ export function registerPiCanon(pi: any, options: CanonOptions = {}): void {
20
+ const unknown = Object.keys(options).find((key) => key !== "root" && key !== "surface" && key !== "mounts");
21
+ if (unknown) {
22
+ throw new Error(
23
+ `pi-canon: unknown option "${unknown}". The options are root, surface, and mounts; everything else is a constant on purpose.`,
24
+ );
25
+ }
26
+ const surface = options.surface !== false;
27
+
28
+ let runtime: CanonRuntime | undefined;
29
+
30
+ const ready = (ctx: any): CanonRuntime => {
31
+ if (!runtime) {
32
+ const cwd: string = ctx?.cwd ?? process.cwd();
33
+ const root = options.root
34
+ ? isAbsolute(options.root)
35
+ ? options.root
36
+ : join(cwd, options.root)
37
+ : join(cwd, ".canon");
38
+ const store = new CanonStore(root);
39
+ const mounts: Mount[] = [
40
+ { name: "", dir: cwd, store },
41
+ ...(options.mounts ?? []).map((dir) => {
42
+ const abs = isAbsolute(dir) ? dir : join(cwd, dir);
43
+ return { name: basename(abs), dir: abs, store: new CanonStore(join(abs, ".canon")) };
44
+ }),
45
+ ];
46
+ runtime = { store, surfacer: new Surfacer(mounts), cwd, mounts };
47
+ }
48
+ return runtime;
49
+ };
50
+
51
+ pi.registerTool(buildCanonTool(ready));
52
+
53
+ /* One orientation line per session, riding the first turn: without it a fresh
54
+ or headless session never hears the doctrine, and the write-after reminder
55
+ (nextTurn at settle) cannot reach a session that ends when the agent does. */
56
+ pi.on("session_start", (_event: unknown, ctx: any) => {
57
+ runtime = undefined;
58
+ const { store } = ready(ctx);
59
+ if (!surface) return;
60
+ const count = store.list().length;
61
+ const text = count
62
+ ? `[pi-canon] ${count} ${count === 1 ? "article governs" : "articles govern"} this project. Read the governing ` +
63
+ "article before working on an asset; after real changes update it and journal the " +
64
+ "source: names, exact numbers, who said what. Articles distill; the journal keeps the original."
65
+ : "[pi-canon] No articles yet in .canon/. When work teaches you something durable about an " +
66
+ "asset, write its article with pi_canon and journal the source as it happened: names, " +
67
+ "exact numbers, who said what. Articles distill; the journal keeps the original.";
68
+ deliver(pi, text, "nextTurn");
69
+ });
70
+
71
+ /* Touches stage; turns flush. One steered message per turn rides the provider
72
+ round trip that was happening anyway. */
73
+ pi.on("tool_call", (event: any, ctx: any) => {
74
+ if (!surface || event?.toolName === "pi_canon") return;
75
+ const { surfacer } = ready(ctx);
76
+ surfacer.collect(surfacer.pathsIn(event?.input));
77
+ });
78
+
79
+ pi.on("turn_end", (_event: unknown, ctx: any) => {
80
+ if (!surface) return;
81
+ const text = ready(ctx).surfacer.flush();
82
+ if (text) deliver(pi, text, "steer");
83
+ });
84
+
85
+ pi.on("agent_settled", (_event: unknown, ctx: any) => {
86
+ if (!surface) return;
87
+ const { surfacer } = ready(ctx);
88
+ const text = [surfacer.flush(), surfacer.settleNudge()].filter(Boolean).join("\n");
89
+ if (text) deliver(pi, text, "nextTurn");
90
+ });
91
+
92
+ pi.registerCommand("pi-canon", {
93
+ description: "pi-canon status: articles, journal entries, surfacing this session",
94
+ handler: async (_args: string, ctx: any) => {
95
+ const { store, surfacer, mounts } = ready(ctx);
96
+ const { surfaced, spent } = surfacer.stats;
97
+ const mounted = mounts.length > 1 ? `, ${mounts.length - 1} mounted` : "";
98
+ ctx.ui.notify(
99
+ `pi-canon at ${store.root}${mounted}: ${store.list().length} articles, ${store.journalCount()} journal ` +
100
+ `entries; ${surfaced} seen this session (${spent} of ${SESSION_BUDGET_CHARS} capsule chars).`,
101
+ "info",
102
+ );
103
+ },
104
+ });
105
+ }
106
+
107
+ function deliver(pi: any, content: string, deliverAs: "steer" | "nextTurn"): void {
108
+ try {
109
+ pi.sendMessage({ customType: "pi-canon", content, display: false }, { deliverAs });
110
+ } catch {
111
+ /* a lost nudge must never break the turn */
112
+ }
113
+ }
@@ -0,0 +1,10 @@
1
+ /* Package entry. Pi calls the default export with the extension API; embedders use
2
+ the named export to pass options. */
3
+
4
+ import { registerPiCanon } from "./canon.ts";
5
+
6
+ export { registerPiCanon };
7
+
8
+ export default function piCanon(pi) {
9
+ return registerPiCanon(pi, {});
10
+ }
@@ -0,0 +1,80 @@
1
+ /* Advisory only: advice strings, never a refusal. A blocked write teaches an agent
2
+ to stop writing; a warning teaches it what to do next. */
3
+
4
+ import { normalize, type Article, type CanonStore } from "./store.ts";
5
+
6
+ export const BODY_WARN_CHARS = 8000;
7
+ export const BODY_LARGE_CHARS = 20000;
8
+ export const CAPSULE_CHARS = 1000;
9
+ const BODY_TINY_CHARS = 400;
10
+
11
+ const JOURNALISH = /(^|\/)(logs?|journal|sessions?|standups?|meetings?)(\/|$)|\d{4}-\d{2}-\d{2}/i;
12
+ const EVENTISH = /^(added|updated|fixed|changed|implemented|removed|refactored|renamed|migrated|verified)\b/i;
13
+
14
+ const CONSTRAINT = /\b(must|never|always|require[sd]?|do not|don't)\b/i;
15
+
16
+ export function advise(article: Article, store: CanonStore, priorBody?: string): string[] {
17
+ const advice: string[] = [];
18
+ const size = article.body.length;
19
+
20
+ /* The laundering guard: an agent that just violated a documented constraint will
21
+ faithfully update the article to describe the violation as current truth. Name
22
+ what disappeared; whether it still holds is the agent's call, stated out loud. */
23
+ if (priorBody !== undefined) {
24
+ const kept = article.body.replace(/\s+/g, " ");
25
+ const dropped = priorBody
26
+ .split(/\r?\n/)
27
+ .map((line) => line.replace(/^[-*\s]+/, "").trim())
28
+ .filter((line) => CONSTRAINT.test(line) && !kept.includes(line.replace(/\s+/g, " ")))
29
+ .slice(0, 2);
30
+ for (const line of dropped) {
31
+ advice.push(
32
+ `This write dropped constraint language: "${line.slice(0, 160)}". If it still holds, keep it; ` +
33
+ "if it genuinely changed, journal what changed it.",
34
+ );
35
+ }
36
+ }
37
+
38
+ if (size > BODY_LARGE_CHARS) {
39
+ advice.push(
40
+ `Body is ${size} chars (large past ${BODY_LARGE_CHARS}). Go hierarchical: keep this article ` +
41
+ `as the summary and router, and move detail into children under ${article.path}/ at chunks ` +
42
+ `worth loading separately.`,
43
+ );
44
+ } else if (size > BODY_WARN_CHARS) {
45
+ advice.push(`Body is ${size} chars (warn past ${BODY_WARN_CHARS}). Densify before it needs splitting.`);
46
+ } else if (size > 0 && size < BODY_TINY_CHARS) {
47
+ const parent = parentOf(article.path);
48
+ if (parent && store.read(parent)) {
49
+ advice.push(`Body is ${size} chars. Consider folding it into ${parent}; keep children only at real asset or chunk boundaries.`);
50
+ }
51
+ }
52
+
53
+ if (!article.capsule) {
54
+ advice.push("No capsule. Add one dense line of front matter; surfacing has nothing to inject without it.");
55
+ } else if (article.capsule.length > CAPSULE_CHARS) {
56
+ advice.push(
57
+ `Capsule is ${article.capsule.length} chars (cap ${CAPSULE_CHARS}). A capsule is one dense line, not a second body.`,
58
+ );
59
+ } else if (EVENTISH.test(article.capsule)) {
60
+ advice.push("The capsule reads like a change log. Capsules hold current truth; the event belongs in the journal.");
61
+ }
62
+
63
+ if (JOURNALISH.test(article.path)) {
64
+ advice.push(`The address ${article.path} reads like an event log. Articles hold current truth; the event belongs in the journal.`);
65
+ }
66
+
67
+ for (const match of article.body.matchAll(/\[\[([^\]|#]+)[^\]]*\]\]/g)) {
68
+ const target = match[1].trim().replace(/\.md$/, "");
69
+ if (!store.read(target) && !store.read(normalize(target))) {
70
+ advice.push(`Link [[${match[1]}]] resolves to no article.`);
71
+ }
72
+ }
73
+
74
+ return advice;
75
+ }
76
+
77
+ function parentOf(path: string): string {
78
+ const cut = path.lastIndexOf("/");
79
+ return cut === -1 ? "" : path.slice(0, cut);
80
+ }
@@ -0,0 +1,245 @@
1
+ /* Article and journal storage: a plain markdown tree under one root.
2
+ articles/<path>.md is the article governing asset <path>; journal/ holds immutable
3
+ entries. Front matter is a strict YAML subset (single line values, inline arrays)
4
+ so the tree stays hand editable and Obsidian readable with no parser dependency;
5
+ keys this package does not own are carried through writes untouched. */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export interface Article {
11
+ path: string;
12
+ capsule: string;
13
+ updated: string;
14
+ extra: string[];
15
+ body: string;
16
+ }
17
+
18
+ const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
19
+ const OWNED_KEYS = new Set(["capsule", "updated"]);
20
+
21
+ /* Keep an address inside the tree: .. resolves against its own segments, so
22
+ a/b/../c means a/c, and clamps at the root, so nothing ever escapes. */
23
+ function contain(path: string): string {
24
+ const parts: string[] = [];
25
+ for (const part of path.split("/")) {
26
+ if (!part || part === ".") continue;
27
+ if (part === "..") parts.pop();
28
+ else parts.push(part);
29
+ }
30
+ return parts.join("/");
31
+ }
32
+
33
+ /* An asset address: relative to the project, contained, file extension dropped so
34
+ src/core/config.ts shares its article's address. The drop happens once, here at
35
+ the boundary; the store itself never drops again, or config.test would lose its
36
+ .test on the way to disk. */
37
+ export function normalize(asset: string, cwd = ""): string {
38
+ let path = asset.trim().replace(/\\/g, "/");
39
+ if (cwd && (path === cwd || path.startsWith(`${cwd}/`))) path = path.slice(cwd.length);
40
+ path = contain(path);
41
+ /* Drop the extension only when something precedes the dot, so .env stays .env. */
42
+ const dot = path.lastIndexOf(".");
43
+ if (dot > path.lastIndexOf("/") + 1) path = path.slice(0, dot);
44
+ return path;
45
+ }
46
+
47
+ function parseFrontMatter(text: string): {
48
+ meta: Record<string, string | string[]>;
49
+ extra: string[];
50
+ body: string;
51
+ } {
52
+ const match = FRONT_MATTER.exec(text);
53
+ if (!match) return { meta: {}, extra: [], body: text };
54
+ const meta: Record<string, string | string[]> = {};
55
+ const extra: string[] = [];
56
+ let keepingForeign = false;
57
+ for (const line of match[1].split(/\r?\n/)) {
58
+ const pair = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
59
+ if (!pair) {
60
+ if (keepingForeign) extra.push(line);
61
+ continue;
62
+ }
63
+ /* An owned key with an empty value is a block list (Obsidian's aliases shape).
64
+ Blocks are not ours to parse, so the whole thing rides along as foreign. */
65
+ keepingForeign = !OWNED_KEYS.has(pair[1]) || !pair[2].trim();
66
+ if (keepingForeign) {
67
+ extra.push(line);
68
+ continue;
69
+ }
70
+ meta[pair[1]] = unscalar(pair[2].trim());
71
+ }
72
+ return { meta, extra, body: text.slice(match[0].length) };
73
+ }
74
+
75
+ /* Owned values are quoted only when YAML would misread them plain, so the tree
76
+ stays hand editable and Obsidian keeps parsing it as a vault. */
77
+ const NEEDS_QUOTES = /[:#[\]{}"'`,&*!|>%@\\]|^\s|\s$/;
78
+
79
+ function scalar(value: string): string {
80
+ return NEEDS_QUOTES.test(value) ? JSON.stringify(value) : value;
81
+ }
82
+
83
+ function unscalar(value: string): string {
84
+ if (value.startsWith('"') && value.endsWith('"')) {
85
+ try {
86
+ return JSON.parse(value);
87
+ } catch {
88
+ return value;
89
+ }
90
+ }
91
+ return value;
92
+ }
93
+
94
+ function today(): string {
95
+ return new Date().toISOString().slice(0, 10);
96
+ }
97
+
98
+ function serialize(article: Article): string {
99
+ const meta = [
100
+ article.capsule ? `capsule: ${scalar(article.capsule)}` : "",
101
+ `updated: ${article.updated}`,
102
+ ...article.extra,
103
+ ].filter(Boolean).join("\n");
104
+ return `---\n${meta}\n---\n${article.body.trimEnd()}\n`;
105
+ }
106
+
107
+ export class CanonStore {
108
+ readonly root: string;
109
+
110
+ constructor(root: string) {
111
+ this.root = root;
112
+ }
113
+
114
+ get articlesDir(): string {
115
+ return join(this.root, "articles");
116
+ }
117
+
118
+ get journalDir(): string {
119
+ return join(this.root, "journal");
120
+ }
121
+
122
+ private fileFor(path: string): string {
123
+ return join(this.articlesDir, `${path}.md`);
124
+ }
125
+
126
+ read(path: string): Article | undefined {
127
+ if (!path) return undefined;
128
+ const file = this.fileFor(path);
129
+ if (!existsSync(file)) return undefined;
130
+ const { meta, extra, body } = parseFrontMatter(readFileSync(file, "utf8"));
131
+ return {
132
+ path,
133
+ body,
134
+ extra,
135
+ capsule: typeof meta.capsule === "string" ? meta.capsule : "",
136
+ updated: typeof meta.updated === "string" ? meta.updated : "",
137
+ };
138
+ }
139
+
140
+ /* The closest existing article governs the asset: exact address, then the
141
+ nearest ancestor. */
142
+ resolve(asset: string, cwd = ""): Article | undefined {
143
+ let path = normalize(asset, cwd);
144
+ while (path) {
145
+ const article = this.read(path);
146
+ if (article) return article;
147
+ const cut = path.lastIndexOf("/");
148
+ path = cut === -1 ? "" : path.slice(0, cut);
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ list(): string[] {
154
+ const walk = (dir: string, prefix: string): string[] => {
155
+ if (!existsSync(dir)) return [];
156
+ const paths: string[] = [];
157
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
158
+ if (entry.isDirectory()) paths.push(...walk(join(dir, entry.name), `${prefix}${entry.name}/`));
159
+ else if (entry.name.endsWith(".md")) paths.push(`${prefix}${entry.name.slice(0, -3)}`);
160
+ }
161
+ return paths;
162
+ };
163
+ return walk(this.articlesDir, "").sort();
164
+ }
165
+
166
+ write(path: string, fields: { capsule?: string; body?: string }): Article {
167
+ path = contain(path);
168
+ const prior = this.read(path);
169
+ /* Agents sometimes paste a whole file as the body, front matter included; stored
170
+ verbatim that nests a second front matter block inside the article. Strip a
171
+ leading block only when its lines all look like front matter keys. */
172
+ let body = fields.body ?? prior?.body ?? "";
173
+ const block = FRONT_MATTER.exec(body);
174
+ if (block && block[1].split(/\r?\n/).every((line) => /^[\w-]+:\s|^\s*$/.test(line))) {
175
+ body = body.slice(block[0].length).trimStart();
176
+ }
177
+ const article: Article = {
178
+ path,
179
+ capsule: (fields.capsule ?? prior?.capsule ?? "").replace(/\s*\n\s*/g, " ").trim(),
180
+ updated: today(),
181
+ extra: prior?.extra ?? [],
182
+ body,
183
+ };
184
+ const file = this.fileFor(path);
185
+ mkdirSync(dirname(file), { recursive: true });
186
+ writeFileSync(file, serialize(article));
187
+ return article;
188
+ }
189
+
190
+ /* Journal entries are immutable: a fresh dated file per entry, wx so nothing is
191
+ ever overwritten. EEXIST is the retry signal, so concurrent writers each land
192
+ on their own file instead of one losing its entry. */
193
+ journal(entry: { body: string; slug?: string; subject?: string[] }): string {
194
+ const slug =
195
+ (entry.slug ?? "entry").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "entry";
196
+ mkdirSync(this.journalDir, { recursive: true });
197
+ const front = entry.subject?.length ? `---\nsubject: [${entry.subject.join(", ")}]\n---\n` : "";
198
+ const text = `${front}${entry.body.trimEnd()}\n`;
199
+ for (let n = 1; ; n += 1) {
200
+ const file = join(this.journalDir, `${today()}-${slug}${n > 1 ? `-${n}` : ""}.md`);
201
+ try {
202
+ writeFileSync(file, text, { flag: "wx" });
203
+ return file;
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
206
+ }
207
+ }
208
+ }
209
+
210
+ journalCount(): number {
211
+ try {
212
+ return readdirSync(this.journalDir).filter((name) => name.endsWith(".md")).length;
213
+ } catch {
214
+ return 0;
215
+ }
216
+ }
217
+
218
+ /* Journal entries whose subject names this address: the index a read surfaces
219
+ so the agent can dig into event history when it wants more than current truth. */
220
+ journalMentions(path: string): string[] {
221
+ try {
222
+ return readdirSync(this.journalDir)
223
+ .filter((name) => {
224
+ if (!name.endsWith(".md")) return false;
225
+ const subject = /^subject:\s*(.*)$/m.exec(readFileSync(join(this.journalDir, name), "utf8"))?.[1] ?? "";
226
+ return subject.replace(/^\[|\]$/g, "").split(",").some((s) => s.trim() === path);
227
+ })
228
+ .sort();
229
+ } catch {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ map(under = ""): string {
235
+ const paths = this.list().filter((path) => !under || path === under || path.startsWith(`${under}/`));
236
+ if (!paths.length) return under ? `No articles under ${under}.` : "No articles yet.";
237
+ return paths
238
+ .map((path) => {
239
+ const capsule = this.read(path)?.capsule;
240
+ return capsule ? `${path}: ${capsule}` : path;
241
+ })
242
+ .join("\n");
243
+ }
244
+
245
+ }
@@ -0,0 +1,160 @@
1
+ /* Surfacing: tool calls stage the articles governing what they touch; the staged
2
+ lines flush as ONE message per turn, once per article per session, under a hard
3
+ budget. Capsules first; when the budget is spent, pointers only. One message per
4
+ turn matters: pi's steering queue drains one message per provider round trip, so
5
+ a message per tool call would buy each nudge its own extra LLM call. */
6
+
7
+ import { appendFileSync, existsSync } from "node:fs";
8
+ import { dirname, isAbsolute, join } from "node:path";
9
+ import type { CanonStore } from "./store.ts";
10
+
11
+ export const SESSION_BUDGET_CHARS = 4000;
12
+ const MESSAGE_CHARS = 2000;
13
+
14
+ /* Observability, env-gated and inert otherwise: PI_CANON_TRACE=<file> appends one
15
+ JSON line per surfacing decision, so a harness can audit the staged -> flushed ->
16
+ seen funnel instead of guessing at it. */
17
+ function trace(kind: string, data: Record<string, unknown>): void {
18
+ const file = process.env.PI_CANON_TRACE;
19
+ if (!file) return;
20
+ try {
21
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), kind, ...data }) + "\n");
22
+ } catch {
23
+ /* tracing must never break surfacing */
24
+ }
25
+ }
26
+
27
+ const PATHLIKE = /(?:^|[\s"'`=:,([{])(\/?[\w.@-]+(?:\/[\w.@-]+)+)/g;
28
+
29
+ /* A store and the directory whose assets it governs. The project is the first,
30
+ unnamed mount; named mounts are outside directories (a data lake, a shared
31
+ corpus) whose articles address as name:path. */
32
+ export interface Mount {
33
+ name: string;
34
+ dir: string;
35
+ store: CanonStore;
36
+ }
37
+
38
+ export class Surfacer {
39
+ private mounts: Mount[];
40
+ private seen = new Set<string>();
41
+ private pendingUpdates = new Set<string>();
42
+ private staged = new Map<string, { capsule: string; stamp: string; asset: string }>();
43
+ private spent = 0;
44
+
45
+ constructor(mounts: Mount[]) {
46
+ this.mounts = mounts;
47
+ }
48
+
49
+ private get project(): Mount {
50
+ return this.mounts[0];
51
+ }
52
+
53
+ private mountFor(asset: string): Mount {
54
+ const path = asset.replace(/\\/g, "/");
55
+ const absolute = isAbsolute(path) ? path : join(this.project.dir, path);
56
+ for (const mount of this.mounts.slice(1)) {
57
+ if (absolute === mount.dir || absolute.startsWith(`${mount.dir}/`)) return mount;
58
+ }
59
+ return this.project;
60
+ }
61
+
62
+ markSeen(path: string): void {
63
+ if (this.staged.has(path)) trace("withdrawn", { path });
64
+ this.seen.add(path);
65
+ this.staged.delete(path);
66
+ }
67
+
68
+ markUpdated(path: string): void {
69
+ this.markSeen(path);
70
+ this.pendingUpdates.delete(path);
71
+ }
72
+
73
+ get stats(): { surfaced: number; spent: number } {
74
+ return { surfaced: this.seen.size, spent: this.spent };
75
+ }
76
+
77
+ /* Candidate asset paths in a tool call: string values that are paths, and path
78
+ shaped tokens inside them. A candidate needs to exist, or to have an existing
79
+ parent, so a file about to be created still surfaces its governing article. */
80
+ pathsIn(input: unknown): string[] {
81
+ const found = new Set<string>();
82
+ const consider = (candidate: string) => {
83
+ const path = candidate.replace(/\\/g, "/");
84
+ const absolute = isAbsolute(path) ? path : join(this.project.dir, path);
85
+ if (existsSync(absolute) || (path.includes("/") && existsSync(dirname(absolute)))) found.add(path);
86
+ };
87
+ const walk = (value: unknown): void => {
88
+ if (typeof value === "string") {
89
+ const whole = value.trim();
90
+ if (whole && whole.length < 512 && !whole.includes("\n")) consider(whole);
91
+ for (const match of value.matchAll(PATHLIKE)) consider(match[1]);
92
+ } else if (Array.isArray(value)) {
93
+ value.forEach(walk);
94
+ } else if (value && typeof value === "object") {
95
+ Object.values(value).forEach(walk);
96
+ }
97
+ };
98
+ walk(input);
99
+ return [...found];
100
+ }
101
+
102
+ /* Stage each newly touched governing article. Nothing is sent or spent here. */
103
+ collect(assets: string[]): void {
104
+ for (const asset of assets) {
105
+ const mount = this.mountFor(asset);
106
+ const article = mount.store.resolve(asset, mount.dir);
107
+ if (!article) continue;
108
+ const key = mount.name ? `${mount.name}:${article.path}` : article.path;
109
+ this.pendingUpdates.add(key);
110
+ if (this.seen.has(key) || this.staged.has(key)) continue;
111
+ const stamp = article.updated ? ` (updated ${article.updated})` : "";
112
+ this.staged.set(key, { capsule: article.capsule, stamp, asset });
113
+ trace("staged", { path: key, asset });
114
+ }
115
+ }
116
+
117
+ /* Everything staged since the last flush, as one bounded message. The budget is
118
+ charged here, not at staging, so a nudge withdrawn by markSeen costs nothing;
119
+ articles count as seen only once their line is part of a flushed message.
120
+ Overflow stays staged for the next turn. */
121
+ flush(): string | undefined {
122
+ if (!this.staged.size) return undefined;
123
+ const lines: string[] = [];
124
+ let size = 0;
125
+ for (const [path, entry] of this.staged) {
126
+ const useCapsule = entry.capsule && this.spent + entry.capsule.length <= SESSION_BUDGET_CHARS;
127
+ const line = useCapsule
128
+ ? `${path}${entry.stamp}: ${entry.capsule}`
129
+ : `${path}${entry.stamp}: article exists. Read it before relying on ${entry.asset}.`;
130
+ if (lines.length && size + line.length > MESSAGE_CHARS) {
131
+ lines.push(`${this.staged.size} more staged; they surface next turn.`);
132
+ break;
133
+ }
134
+ if (useCapsule) this.spent += entry.capsule.length;
135
+ size += line.length;
136
+ lines.push(line);
137
+ this.seen.add(path);
138
+ this.staged.delete(path);
139
+ }
140
+ const plural = lines.length > 1 ? "s" : "";
141
+ trace("flushed", { lines: lines.length, spent: this.spent });
142
+ return (
143
+ `[pi-canon] Governing article${plural} for what this turn touches. Read the full article with ` +
144
+ `pi_canon before depending on details; update it after real changes.\n${lines.join("\n")}`
145
+ );
146
+ }
147
+
148
+ /* The write-after half of the doctrine: every governing article touched since its
149
+ last update draws one reminder, then the slate clears for the next batch. */
150
+ settleNudge(): string | undefined {
151
+ const stale = [...this.pendingUpdates];
152
+ this.pendingUpdates.clear();
153
+ if (!stale.length) return undefined;
154
+ trace("settle-nudge", { paths: stale });
155
+ return (
156
+ `[pi-canon] Touched but not updated: ${stale.join(", ")}. If this work changed what is true, ` +
157
+ `update the article with pi_canon; if nothing durable changed, leave it.`
158
+ );
159
+ }
160
+ }
@@ -0,0 +1,147 @@
1
+ /* The pi_canon tool: one tool, four verbs. Read and update over create; the journal
2
+ for events; map to orient. */
3
+
4
+ import { basename } from "node:path";
5
+ import { advise } from "./lint.ts";
6
+ import { normalize, type CanonStore } from "./store.ts";
7
+ import type { Mount, Surfacer } from "./surfacing.ts";
8
+
9
+ export interface CanonRuntime {
10
+ store: CanonStore;
11
+ surfacer: Surfacer;
12
+ cwd: string;
13
+ mounts: Mount[];
14
+ }
15
+
16
+ /* A path routes to the mount it names (lake:prices), the mount whose directory
17
+ contains it, or the project. */
18
+ function route(runtime: CanonRuntime, raw: string): { mount: Mount; path: string } {
19
+ const qualified = /^([\w.-]+):(.*)$/.exec(raw);
20
+ if (qualified) {
21
+ const mount = runtime.mounts.find((m) => m.name === qualified[1]);
22
+ if (mount) return { mount, path: normalize(qualified[2], mount.dir) };
23
+ }
24
+ const slashed = raw.replace(/\\/g, "/");
25
+ for (const mount of runtime.mounts) {
26
+ if (mount.name && (slashed === mount.dir || slashed.startsWith(`${mount.dir}/`))) {
27
+ return { mount, path: normalize(slashed, mount.dir) };
28
+ }
29
+ }
30
+ return { mount: runtime.mounts[0], path: normalize(raw, runtime.cwd) };
31
+ }
32
+
33
+ export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime) {
34
+ return {
35
+ name: "pi_canon",
36
+ label: "pi-canon",
37
+ description:
38
+ "Canonical project memory. Every asset has at most one governing article at its own address " +
39
+ "(src/core/config, lake/prices). read the governing article before working on an asset; " +
40
+ "write it after real changes. journal appends an immutable event entry: record the source " +
41
+ "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. " +
43
+ "Creation is rare: prefer updating the article that already governs. " +
44
+ "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.",
46
+ parameters: {
47
+ type: "object",
48
+ properties: {
49
+ action: { type: "string", enum: ["read", "write", "journal", "map"] },
50
+ path: {
51
+ type: "string",
52
+ description: "Article address, e.g. src/core/config. Required for read and write; optional filter for map.",
53
+ },
54
+ body: {
55
+ type: "string",
56
+ description:
57
+ "write: the full article body; specifics beat summaries (who consumes what, exact " +
58
+ "limits, what breaks). journal: the event text, source details intact.",
59
+ },
60
+ capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
61
+ subject: {
62
+ type: "array",
63
+ items: { type: "string" },
64
+ description: "journal: article addresses this event concerns.",
65
+ },
66
+ slug: { type: "string", description: "journal: short name for the entry file." },
67
+ },
68
+ required: ["action"],
69
+ },
70
+ async execute(
71
+ _toolCallId: string,
72
+ params: Record<string, unknown>,
73
+ _signal: unknown,
74
+ _onUpdate: unknown,
75
+ ctx: unknown,
76
+ ) {
77
+ const text = run(ready(ctx), params);
78
+ return { content: [{ type: "text", text }], details: {} };
79
+ },
80
+ };
81
+ }
82
+
83
+ function run(runtime: CanonRuntime, params: Record<string, unknown>): string {
84
+ const { surfacer } = runtime;
85
+ const action = String(params.action ?? "");
86
+ const { mount, path } = typeof params.path === "string" ? route(runtime, params.path) : { mount: runtime.mounts[0], path: "" };
87
+ const store = mount.store;
88
+ const qualify = (p: string) => (mount.name ? `${mount.name}:${p}` : p);
89
+
90
+ switch (action) {
91
+ case "read": {
92
+ if (!path) return "read needs a path.";
93
+ const article = store.resolve(path);
94
+ if (!article) {
95
+ return `No article governs ${path}. If you are working on this asset, create its article with write after the task.`;
96
+ }
97
+ surfacer.markSeen(qualify(article.path));
98
+ const title = article.path === path ? qualify(article.path) : `${qualify(article.path)} governs ${qualify(path)}`;
99
+ const head = [
100
+ article.capsule ? `capsule: ${article.capsule}` : "",
101
+ article.updated ? `updated: ${article.updated}` : "",
102
+ ].filter(Boolean).join("\n");
103
+ /* Filenames only, newest three: the index invites digging, it never pays for it. */
104
+ const mentions = runtime.mounts[0].store.journalMentions(qualify(article.path));
105
+ const recent = mentions.slice(-3).reverse();
106
+ const earlier = mentions.length - recent.length;
107
+ const index = recent.length
108
+ ? `\n\njournal: ${recent.join(", ")}${earlier ? ` and ${earlier} earlier` : ""}`
109
+ : "";
110
+ return `${title}\n${head}\n\n${article.body}`.trim() + index;
111
+ }
112
+ case "write": {
113
+ if (!path) return "write needs a path.";
114
+ /* Blank means untouched: models fill declared string fields with "" routinely,
115
+ and a "" here would silently erase stored content. */
116
+ const priorBody = params.body ? store.read(path)?.body : undefined;
117
+ const article = store.write(path, {
118
+ capsule: params.capsule ? String(params.capsule) : undefined,
119
+ body: params.body ? String(params.body) : undefined,
120
+ });
121
+ surfacer.markUpdated(qualify(article.path));
122
+ return [`Wrote ${qualify(article.path)}.`, ...advise(article, store, priorBody)].join("\n");
123
+ }
124
+ case "journal": {
125
+ const body = typeof params.body === "string" ? params.body.trim() : "";
126
+ if (!body) return "journal needs a body: what happened, densely.";
127
+ /* Events are project history; the journal always lives in the project store,
128
+ with subjects qualified so mounted articles index them too. */
129
+ const subject = Array.isArray(params.subject)
130
+ ? params.subject.map((s) => {
131
+ const routed = route(runtime, String(s));
132
+ return routed.mount.name ? `${routed.mount.name}:${routed.path}` : routed.path;
133
+ })
134
+ : undefined;
135
+ const file = runtime.mounts[0].store.journal({
136
+ body,
137
+ subject,
138
+ slug: typeof params.slug === "string" ? params.slug : undefined,
139
+ });
140
+ return `Logged ${basename(file)}.`;
141
+ }
142
+ case "map":
143
+ return store.map(path);
144
+ default:
145
+ return `Unknown action "${action}". Actions: read, write, journal, map.`;
146
+ }
147
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "pi-canon",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "exports": {
7
+ ".": "./extensions/index.js"
8
+ },
9
+ "pi": {
10
+ "extensions": ["./extensions"]
11
+ },
12
+ "files": ["extensions", "README.md", "LICENSE"],
13
+ "engines": {
14
+ "node": ">=22.18"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": ">=0.83.0 <1"
18
+ },
19
+ "scripts": {
20
+ "test": "node tests/verify.mjs"
21
+ },
22
+ "keywords": ["pi-package", "pi", "pi-extension", "memory", "knowledge", "agent", "wiki"],
23
+ "author": "Shane Conner",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/shaneconner/pi-canon.git"
28
+ },
29
+ "bugs": "https://github.com/shaneconner/pi-canon/issues",
30
+ "homepage": "https://github.com/shaneconner/pi-canon#readme",
31
+ "devDependencies": {
32
+ "jiti": "^2.7.0"
33
+ }
34
+ }