pi-canon 0.2.4 → 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.
- package/README.md +104 -51
- package/extensions/canon.ts +17 -2
- package/extensions/index.js +9 -3
- package/extensions/lib/lint.ts +47 -5
- package/extensions/lib/schema.ts +251 -0
- package/extensions/lib/store.ts +13 -3
- package/extensions/lib/surfacing.ts +60 -3
- package/extensions/lib/tool.ts +146 -25
- package/extensions/settings.ts +328 -0
- package/package.json +4 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/* Per-store article schema: schema.json at the store root, a data contract rather
|
|
2
|
+
than buried code. The file is created with these defaults made explicit the first
|
|
3
|
+
time the store persists anything, so its owner can see it, edit it per project,
|
|
4
|
+
and point other tools at the same contract this package enforces.
|
|
5
|
+
|
|
6
|
+
Enforcement is asymmetric on purpose. A rule marked required rejects the write
|
|
7
|
+
that violates it, because a missing required field is exactly the class of miss
|
|
8
|
+
that soft advice has demonstrably failed to prevent; every other rule warns, on
|
|
9
|
+
write and on read, so an agent reading a noncompliant article learns it can heal
|
|
10
|
+
what it is holding. A required violation the current write did not touch also
|
|
11
|
+
only warns: a capsule-only update must not be held hostage to a legacy body. */
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { Article } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
export const SCHEMA_FILE = "schema.json";
|
|
18
|
+
export const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export interface SchemaRule {
|
|
21
|
+
required?: boolean;
|
|
22
|
+
min_chars?: number;
|
|
23
|
+
max_chars?: number;
|
|
24
|
+
hint?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Relations rules are about the reference graph, not any one field. The store
|
|
28
|
+
declares them once here; each tool enforces the ones it can see. This package
|
|
29
|
+
sees an article's own outgoing [[references]] at the write boundary, so it
|
|
30
|
+
enforces refs; orphan and children need the whole graph and are enforced by
|
|
31
|
+
graph-reading tools (canon-atlas) from this same file. */
|
|
32
|
+
export interface RefsRule {
|
|
33
|
+
required?: boolean;
|
|
34
|
+
min_count?: number;
|
|
35
|
+
hint?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface OrphanRule {
|
|
38
|
+
warn?: boolean;
|
|
39
|
+
hint?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ChildrenRule {
|
|
42
|
+
listed?: boolean;
|
|
43
|
+
hint?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface CanonRelations {
|
|
46
|
+
refs?: RefsRule;
|
|
47
|
+
orphan?: OrphanRule;
|
|
48
|
+
children?: ChildrenRule;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CanonSchema {
|
|
52
|
+
capsule?: SchemaRule;
|
|
53
|
+
title?: SchemaRule;
|
|
54
|
+
body?: SchemaRule;
|
|
55
|
+
relations?: CanonRelations;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FIELD_NAMES = ["capsule", "title", "body"] as const;
|
|
59
|
+
const RULE_KEYS = new Set(["required", "min_chars", "max_chars", "hint"]);
|
|
60
|
+
const RELATION_KEYS: Record<string, Set<string>> = {
|
|
61
|
+
refs: new Set(["required", "min_count", "hint"]),
|
|
62
|
+
orphan: new Set(["warn", "hint"]),
|
|
63
|
+
children: new Set(["listed", "hint"]),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/* The shipped defaults, written into the file verbatim. Nothing is required and the
|
|
67
|
+
caps mirror what the advisory lint has always said, so a store that never edits
|
|
68
|
+
this file behaves as it always did, just with the contract visible on disk. */
|
|
69
|
+
const DEFAULT_FILE = `{
|
|
70
|
+
"schema_version": 1,
|
|
71
|
+
"about": "Article schema for this canon store. pi-canon enforces it at the tool boundary: a rule marked required rejects a write that violates it (judged on what the write touches, and on everything when the article is first created); every other rule warns on write and is reported on read, so agents can heal what they are holding. Other tools reading this store can enforce the same contract from this file. Fields: capsule (the front matter line surfaced on touch), title (the body's leading # heading), body. Rule keys: required, min_chars, max_chars, hint. Relations rules live under relations: refs (required, min_count) over an article's own outgoing references, orphan (warn) and children (listed) over the whole graph; each tool enforces the rules it can see, so refs holds here and the graph rules hold in graph-reading tools like canon-atlas. Delete a rule to drop it; delete this file to disable schema checks.",
|
|
72
|
+
"article": {
|
|
73
|
+
"capsule": { "required": false, "max_chars": 1000, "hint": "One dense line of current truth; surfacing injects it when the asset is touched." },
|
|
74
|
+
"title": { "required": false, "hint": "Start the body with a # heading naming the asset." },
|
|
75
|
+
"body": { "max_chars": 20000, "hint": "Past this, go hierarchical: keep this article as the summary and router, and move detail into children at addresses under it." }
|
|
76
|
+
},
|
|
77
|
+
"relations": {
|
|
78
|
+
"refs": { "required": false, "hint": "Outgoing [[references]] authored by this article. required rejects a write whose body cites nothing; min_count warns under a floor." },
|
|
79
|
+
"orphan": { "warn": false, "hint": "Warn when no other article references this one. Needs the whole graph, so graph-reading tools enforce it." },
|
|
80
|
+
"children": { "listed": false, "hint": "Warn when an article does not reference each direct child under its address. Enforced by graph-reading tools." }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/* Created only when absent, so an edited or deleted file is never fought over. */
|
|
86
|
+
export function ensureSchemaFile(root: string): void {
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(join(root, SCHEMA_FILE), DEFAULT_FILE, { flag: "wx" });
|
|
89
|
+
} catch {
|
|
90
|
+
/* Exists already, or the root is unwritable; either way not this call's problem. */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The schema as declared, plus every problem with the declaration itself. A malformed
|
|
95
|
+
file fails open and loud: rules stop being enforced, and the caller says so, because
|
|
96
|
+
a schema the owner believes is enforced while a typo disabled it is the worst state. */
|
|
97
|
+
export function loadSchema(root: string): { schema: CanonSchema | undefined; problems: string[] } {
|
|
98
|
+
const file = join(root, SCHEMA_FILE);
|
|
99
|
+
if (!existsSync(file)) return { schema: undefined, problems: [] };
|
|
100
|
+
let raw: unknown;
|
|
101
|
+
try {
|
|
102
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return {
|
|
105
|
+
schema: undefined,
|
|
106
|
+
problems: [`${SCHEMA_FILE} is not valid JSON (${(error as Error).message}); its rules are not being enforced.`],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
110
|
+
return { schema: undefined, problems: [`${SCHEMA_FILE} must hold a JSON object; its rules are not being enforced.`] };
|
|
111
|
+
}
|
|
112
|
+
const problems: string[] = [];
|
|
113
|
+
/* A missing article block is an empty one, not an early exit: a schema may
|
|
114
|
+
carry only relations rules. */
|
|
115
|
+
const declared = (raw as Record<string, unknown>).article ?? {};
|
|
116
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
117
|
+
return { schema: undefined, problems: [`${SCHEMA_FILE}: "article" must be an object; its rules are not being enforced.`] };
|
|
118
|
+
}
|
|
119
|
+
const schema: CanonSchema = {};
|
|
120
|
+
for (const [name, value] of Object.entries(declared as Record<string, unknown>)) {
|
|
121
|
+
if (!(FIELD_NAMES as readonly string[]).includes(name)) {
|
|
122
|
+
problems.push(`${SCHEMA_FILE}: unknown article field "${name}" is ignored (fields: ${FIELD_NAMES.join(", ")}).`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
126
|
+
problems.push(`${SCHEMA_FILE}: rule for "${name}" must be an object and is ignored.`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const rule: SchemaRule = {};
|
|
130
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
131
|
+
if (!RULE_KEYS.has(key)) {
|
|
132
|
+
problems.push(`${SCHEMA_FILE}: unknown rule key "${name}.${key}" is ignored (keys: required, min_chars, max_chars, hint).`);
|
|
133
|
+
} else if (key === "required" && typeof val === "boolean") rule.required = val;
|
|
134
|
+
else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
135
|
+
else if (key === "hint" && typeof val === "string") rule.hint = val;
|
|
136
|
+
else problems.push(`${SCHEMA_FILE}: "${name}.${key}" has the wrong type and is ignored.`);
|
|
137
|
+
}
|
|
138
|
+
schema[name as "capsule" | "title" | "body"] = rule;
|
|
139
|
+
}
|
|
140
|
+
const rel = (raw as Record<string, unknown>).relations;
|
|
141
|
+
if (rel !== undefined) {
|
|
142
|
+
if (typeof rel !== "object" || rel === null || Array.isArray(rel)) {
|
|
143
|
+
problems.push(`${SCHEMA_FILE}: "relations" must be an object and is ignored.`);
|
|
144
|
+
} else {
|
|
145
|
+
const relations: CanonRelations = {};
|
|
146
|
+
for (const [name, value] of Object.entries(rel as Record<string, unknown>)) {
|
|
147
|
+
const keys = RELATION_KEYS[name];
|
|
148
|
+
if (!keys) {
|
|
149
|
+
problems.push(`${SCHEMA_FILE}: unknown relations field "${name}" is ignored (fields: refs, orphan, children).`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
153
|
+
problems.push(`${SCHEMA_FILE}: rule for "relations.${name}" must be an object and is ignored.`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const rule: Record<string, boolean | number | string> = {};
|
|
157
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
158
|
+
if (!keys.has(key)) {
|
|
159
|
+
problems.push(`${SCHEMA_FILE}: unknown rule key "relations.${name}.${key}" is ignored (keys: ${[...keys].join(", ")}).`);
|
|
160
|
+
} else if ((key === "required" || key === "warn" || key === "listed") && typeof val === "boolean") rule[key] = val;
|
|
161
|
+
else if (key === "min_count" && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
162
|
+
else if (key === "hint" && typeof val === "string") rule[key] = val;
|
|
163
|
+
else problems.push(`${SCHEMA_FILE}: "relations.${name}.${key}" has the wrong type and is ignored.`);
|
|
164
|
+
}
|
|
165
|
+
relations[name as keyof CanonRelations] = rule;
|
|
166
|
+
}
|
|
167
|
+
schema.relations = relations;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { schema, problems };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* The outgoing references an article authors: [[wikilink]] targets in the body,
|
|
174
|
+
fenced and inline code stripped (examples are not citations), deduplicated
|
|
175
|
+
case-insensitively. The write boundary can always see these, whatever else it
|
|
176
|
+
cannot see of the graph. */
|
|
177
|
+
export function outgoingOf(body: string): string[] {
|
|
178
|
+
const stripped = body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
179
|
+
const out = new Set<string>();
|
|
180
|
+
for (const m of stripped.matchAll(/\[\[([^\]|#\n]+)(?:[|#][^\]\n]*)?\]\]/g)) {
|
|
181
|
+
const target = m[1].trim().toLowerCase();
|
|
182
|
+
if (target) out.add(target);
|
|
183
|
+
}
|
|
184
|
+
return [...out].sort();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* The title is the body's leading # heading; the write interface has no separate
|
|
188
|
+
title field on purpose, so the rule checks the one place a title can live. */
|
|
189
|
+
export function titleOf(body: string): string | undefined {
|
|
190
|
+
const first = body.split(/\r?\n/).find((line) => line.trim() !== "");
|
|
191
|
+
const heading = first === undefined ? undefined : /^#\s+(.+)$/.exec(first.trim());
|
|
192
|
+
return heading ? heading[1].trim() : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface Touched {
|
|
196
|
+
capsule: boolean;
|
|
197
|
+
body: boolean;
|
|
198
|
+
refs: boolean;
|
|
199
|
+
created: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* No write in flight: every violation is reportable but none can reject. */
|
|
203
|
+
export const READ_ONLY: Touched = { capsule: false, body: false, refs: false, created: false };
|
|
204
|
+
|
|
205
|
+
export interface Verdict {
|
|
206
|
+
rejections: string[];
|
|
207
|
+
warnings: string[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function checkArticle(article: Article, schema: CanonSchema, touched: Touched): Verdict {
|
|
211
|
+
const verdict: Verdict = { rejections: [], warnings: [] };
|
|
212
|
+
const fields: { name: keyof CanonSchema; value: string | undefined; missing: string; carrier: "capsule" | "body" }[] = [
|
|
213
|
+
{ name: "capsule", value: article.capsule || undefined, missing: "required and empty", carrier: "capsule" },
|
|
214
|
+
{ name: "title", value: titleOf(article.body), missing: "required and the body has no leading # heading", carrier: "body" },
|
|
215
|
+
{ name: "body", value: article.body || undefined, missing: "required and empty", carrier: "body" },
|
|
216
|
+
];
|
|
217
|
+
for (const field of fields) {
|
|
218
|
+
const rule = schema[field.name];
|
|
219
|
+
if (!rule) continue;
|
|
220
|
+
const hint = rule.hint ? ` ${rule.hint}` : "";
|
|
221
|
+
if (rule.required && field.value === undefined) {
|
|
222
|
+
const message = `${field.name}: ${field.missing}.${hint}`;
|
|
223
|
+
if (touched.created || touched[field.carrier]) verdict.rejections.push(message);
|
|
224
|
+
else verdict.warnings.push(message);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (field.value === undefined) continue;
|
|
228
|
+
if (rule.min_chars !== undefined && field.value.length < rule.min_chars) {
|
|
229
|
+
verdict.warnings.push(`${field.name}: ${field.value.length} chars (min ${rule.min_chars}).${hint}`);
|
|
230
|
+
}
|
|
231
|
+
if (rule.max_chars !== undefined && field.value.length > rule.max_chars) {
|
|
232
|
+
verdict.warnings.push(`${field.name}: ${field.value.length} chars (max ${rule.max_chars}).${hint}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/* The one relations rule this boundary can see whole: the article's own
|
|
236
|
+
citations. required follows the same asymmetry as the fields, rejecting only
|
|
237
|
+
the write that changed the reference set or created the article. */
|
|
238
|
+
const refsRule = schema.relations && schema.relations.refs;
|
|
239
|
+
if (refsRule) {
|
|
240
|
+
const cited = outgoingOf(article.body).length;
|
|
241
|
+
const hint = refsRule.hint ? ` ${refsRule.hint}` : "";
|
|
242
|
+
if (refsRule.required && cited === 0) {
|
|
243
|
+
const message = `refs: required and the body references nothing.${hint}`;
|
|
244
|
+
if (touched.created || touched.refs) verdict.rejections.push(message);
|
|
245
|
+
else verdict.warnings.push(message);
|
|
246
|
+
} else if (refsRule.min_count !== undefined && cited < refsRule.min_count) {
|
|
247
|
+
verdict.warnings.push(`refs: ${cited} outgoing (min ${refsRule.min_count}).${hint}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return verdict;
|
|
251
|
+
}
|
package/extensions/lib/store.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
|
+
import { ensureSchemaFile } from "./schema.ts";
|
|
9
10
|
|
|
10
11
|
export interface Article {
|
|
11
12
|
path: string;
|
|
@@ -235,7 +236,10 @@ export class CanonStore {
|
|
|
235
236
|
return parts.sort().join("|");
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
|
|
239
|
+
/* The article this write would store, without storing it. Public so the tool can
|
|
240
|
+
hold a would-be article against the store's schema and reject before anything
|
|
241
|
+
touches disk; write() persists exactly what compose() returns. */
|
|
242
|
+
compose(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
|
|
239
243
|
path = contain(path);
|
|
240
244
|
const prior = this.read(path);
|
|
241
245
|
/* Agents sometimes paste a whole file as the body, front matter included; stored
|
|
@@ -246,7 +250,7 @@ export class CanonStore {
|
|
|
246
250
|
if (block && block[1].split(/\r?\n/).every((line) => /^[\w-]+:\s|^\s*$/.test(line))) {
|
|
247
251
|
body = body.slice(block[0].length).trimStart();
|
|
248
252
|
}
|
|
249
|
-
|
|
253
|
+
return {
|
|
250
254
|
path,
|
|
251
255
|
capsule: (fields.capsule ?? prior?.capsule ?? "").replace(/\s*\n\s*/g, " ").trim(),
|
|
252
256
|
updated: today(),
|
|
@@ -254,8 +258,13 @@ export class CanonStore {
|
|
|
254
258
|
extra: prior?.extra ?? [],
|
|
255
259
|
body,
|
|
256
260
|
};
|
|
257
|
-
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
write(path: string, fields: { capsule?: string; body?: string; scope?: string }): Article {
|
|
264
|
+
const article = this.compose(path, fields);
|
|
265
|
+
const file = this.fileFor(article.path);
|
|
258
266
|
mkdirSync(dirname(file), { recursive: true });
|
|
267
|
+
ensureSchemaFile(this.root);
|
|
259
268
|
writeFileSync(file, serialize(article));
|
|
260
269
|
return article;
|
|
261
270
|
}
|
|
@@ -272,6 +281,7 @@ export class CanonStore {
|
|
|
272
281
|
const slug =
|
|
273
282
|
(entry.slug ?? "entry").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "entry";
|
|
274
283
|
mkdirSync(this.journalDir, { recursive: true });
|
|
284
|
+
ensureSchemaFile(this.root);
|
|
275
285
|
/* An explicit instant, because the filename cannot carry one. Entries are named by
|
|
276
286
|
date plus slug with -2, -3 for collisions, and sorting those lexicographically puts
|
|
277
287
|
-10 before -2 and the unsuffixed name after every suffixed one, so "the newest
|
|
@@ -30,6 +30,52 @@ function trace(kind: string, data: Record<string, unknown>): void {
|
|
|
30
30
|
|
|
31
31
|
const PATHLIKE = /(?:^|[\s"'`=:,([{])(\/?[\w.@-]+(?:\/[\w.@-]+)+)/g;
|
|
32
32
|
|
|
33
|
+
/* A touch says what knowledge should surface. It does not by itself say that current
|
|
34
|
+
truth changed. The write-after reminder needs positive evidence of a modifying tool,
|
|
35
|
+
otherwise Read, Grep, and inspection-only shell calls turn an optional maintenance
|
|
36
|
+
prompt into a false stop. This list stays deliberately small: an unknown tool may
|
|
37
|
+
surface an article, but it cannot create an update obligation merely by naming a path. */
|
|
38
|
+
const MUTATING_TOOL_SUFFIXES = [
|
|
39
|
+
"applypatch",
|
|
40
|
+
"write",
|
|
41
|
+
"writefile",
|
|
42
|
+
"edit",
|
|
43
|
+
"editfile",
|
|
44
|
+
"multiedit",
|
|
45
|
+
"notebookedit",
|
|
46
|
+
"createfile",
|
|
47
|
+
"deletefile",
|
|
48
|
+
"movefile",
|
|
49
|
+
"renamefile",
|
|
50
|
+
"replaceinfile",
|
|
51
|
+
"strreplaceeditor",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const SHELL_TOOL_SUFFIXES = ["bash", "shell", "exec", "execcommand", "runcommand", "terminal"];
|
|
55
|
+
|
|
56
|
+
function stringsIn(value: unknown): string[] {
|
|
57
|
+
if (typeof value === "string") return [value];
|
|
58
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn);
|
|
59
|
+
if (!value || typeof value !== "object") return [];
|
|
60
|
+
return Object.values(value).flatMap(stringsIn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function changesAssets(toolName: unknown, input: unknown): boolean {
|
|
64
|
+
if (typeof toolName !== "string" || !toolName) return false;
|
|
65
|
+
const compact = toolName.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
66
|
+
if (MUTATING_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
|
|
67
|
+
if (!SHELL_TOOL_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return false;
|
|
68
|
+
|
|
69
|
+
const command = stringsIn(input).join("\n");
|
|
70
|
+
if (/\btools\.apply_patch\s*\(/.test(command)) return true;
|
|
71
|
+
if (/(?:^|[\n;&|]\s*)(?:sudo\s+)?(?:[\w.-]+\/)*(?:apply_patch|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|install|truncate|dd|patch)\b/m.test(command)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (/\bsed\s+(?:-[a-z]*i[a-z]*\b|--in-place(?:=|\b))/i.test(command)) return true;
|
|
75
|
+
if (/\bperl\s+-[a-z]*pi[a-z]*\b/i.test(command)) return true;
|
|
76
|
+
return /\bgit\s+(?:apply|checkout|restore|reset|clean|mv|rm)\b/.test(command);
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/* Presence marks -----------------------------------------------------------------
|
|
34
80
|
|
|
35
81
|
"Seen" used to mean "we sent it once", which is only the same thing as "the agent
|
|
@@ -510,6 +556,18 @@ export class Surfacer {
|
|
|
510
556
|
}
|
|
511
557
|
}
|
|
512
558
|
|
|
559
|
+
/* Record a successful modifying call separately from a touch. A read still stages the
|
|
560
|
+
governing article, but it creates no update obligation. */
|
|
561
|
+
markChanged(assets: string[]): void {
|
|
562
|
+
for (const asset of assets) {
|
|
563
|
+
const { mount, absolute } = this.locate(asset);
|
|
564
|
+
const article = mount.store.resolve(absolute, mount.dir);
|
|
565
|
+
if (!article) continue;
|
|
566
|
+
const key = mount.name ? `${mount.name}:${article.path}` : article.path;
|
|
567
|
+
this.pendingUpdates.add(key);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
513
571
|
/* Stage each newly touched governing article. Nothing is sent or spent here. */
|
|
514
572
|
collect(assets: string[]): void {
|
|
515
573
|
for (const asset of assets) {
|
|
@@ -517,7 +575,6 @@ export class Surfacer {
|
|
|
517
575
|
const article = mount.store.resolve(absolute, mount.dir);
|
|
518
576
|
if (!article) continue;
|
|
519
577
|
const key = mount.name ? `${mount.name}:${article.path}` : article.path;
|
|
520
|
-
this.pendingUpdates.add(key);
|
|
521
578
|
if (this.seen.has(key) || this.staged.has(key)) continue;
|
|
522
579
|
const stamp = article.updated ? ` (updated ${article.updated})` : "";
|
|
523
580
|
this.staged.set(key, { capsule: article.capsule, stamp, asset });
|
|
@@ -607,8 +664,8 @@ export class Surfacer {
|
|
|
607
664
|
trace("delivery-undone", {});
|
|
608
665
|
}
|
|
609
666
|
|
|
610
|
-
/* The write-after half of the doctrine: every governing article
|
|
611
|
-
last update draws one reminder, then the slate clears
|
|
667
|
+
/* The write-after half of the doctrine: every governing article named by a successful
|
|
668
|
+
modifying call since its last update draws one reminder, then the slate clears. */
|
|
612
669
|
settleNudge(): string | undefined {
|
|
613
670
|
const stale = [...this.pendingUpdates];
|
|
614
671
|
this.lastNudge = stale;
|
package/extensions/lib/tool.ts
CHANGED
|
@@ -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";
|
|
@@ -33,6 +34,13 @@ export const CANON_TOOL_PARAMETERS = {
|
|
|
33
34
|
},
|
|
34
35
|
capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
|
|
35
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
|
+
},
|
|
36
44
|
scope: {
|
|
37
45
|
type: "string",
|
|
38
46
|
enum: ["rule", "asset"],
|
|
@@ -156,10 +164,19 @@ function filingTail(retrieval: string): string {
|
|
|
156
164
|
named. Neither is decoration.
|
|
157
165
|
|
|
158
166
|
Ranking reuses LexicalRetriever rather than growing a second notion of relevance, so search
|
|
159
|
-
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. */
|
|
160
176
|
const SEARCH_RESULTS = 10;
|
|
177
|
+
const ARTICLE_SLOTS = 5;
|
|
161
178
|
|
|
162
|
-
function search(store: CanonStore, query: string): string {
|
|
179
|
+
function search(store: CanonStore, query: string, includeJournal: boolean): string {
|
|
163
180
|
if (!query.trim()) return "search needs a query.";
|
|
164
181
|
const articles: Candidate[] = [];
|
|
165
182
|
for (const path of store.list()) {
|
|
@@ -175,20 +192,26 @@ function search(store: CanonStore, query: string): string {
|
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
194
|
/* Journal entries enter the same index under a `journal/` key so one ranking covers both.
|
|
178
|
-
The key is an index handle, never an address: it is not something `read` accepts.
|
|
179
|
-
|
|
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. */
|
|
180
197
|
const byKey = new Map<string, { logged: string; subjects: string[]; body: string }>();
|
|
181
|
-
const journal: Candidate[] =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
: "";
|
|
192
215
|
|
|
193
216
|
const all = [...articles, ...journal];
|
|
194
217
|
if (!all.length) return "Nothing in the canon yet.";
|
|
@@ -196,9 +219,18 @@ function search(store: CanonStore, query: string): string {
|
|
|
196
219
|
retriever.index(all);
|
|
197
220
|
const scored = retriever.score(query, all);
|
|
198
221
|
const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
|
|
199
|
-
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)];
|
|
200
232
|
|
|
201
|
-
const lines =
|
|
233
|
+
const lines = chosen.map(([key]) => {
|
|
202
234
|
const entry = byKey.get(key);
|
|
203
235
|
if (entry) {
|
|
204
236
|
const subjects = entry.subjects.length ? ` (${entry.subjects.join(", ")})` : "";
|
|
@@ -208,9 +240,10 @@ function search(store: CanonStore, query: string): string {
|
|
|
208
240
|
return `${key}: ${article?.capsule || excerpt(article?.body ?? "")}`;
|
|
209
241
|
});
|
|
210
242
|
/* Say what was dropped. A silent cap reads as "that is everything". */
|
|
211
|
-
if (ranked.length >
|
|
212
|
-
lines.push(`... ${ranked.length -
|
|
243
|
+
if (ranked.length > chosen.length) {
|
|
244
|
+
lines.push(`... ${ranked.length - chosen.length} more matched; narrow the query to see them.`);
|
|
213
245
|
}
|
|
246
|
+
if (invitation) lines.push(invitation);
|
|
214
247
|
return lines.join("\n");
|
|
215
248
|
}
|
|
216
249
|
|
|
@@ -253,7 +286,18 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
253
286
|
const index = recent.length
|
|
254
287
|
? `\n\njournal: ${recent.join(", ")}${earlier ? ` and ${earlier} earlier` : ""}`
|
|
255
288
|
: "";
|
|
256
|
-
|
|
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;
|
|
257
301
|
}
|
|
258
302
|
case "write": {
|
|
259
303
|
if (!path) return "write needs a path.";
|
|
@@ -266,7 +310,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
266
310
|
prior state; which fields this call happened to set is a separate question and is
|
|
267
311
|
answered separately below. */
|
|
268
312
|
const prior = store.read(path);
|
|
269
|
-
const
|
|
313
|
+
const fields = {
|
|
270
314
|
capsule: params.capsule ? String(params.capsule) : undefined,
|
|
271
315
|
body: params.body ? String(params.body) : undefined,
|
|
272
316
|
/* "asset" is the way back. The enum is the only vocabulary the model has, so
|
|
@@ -274,16 +318,93 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
274
318
|
one: every other input falls through to undefined, which means untouched. It
|
|
275
319
|
stores empty, which is the default state, the address being the claim. */
|
|
276
320
|
scope: params.scope === "asset" ? "" : params.scope ? String(params.scope) : undefined,
|
|
277
|
-
}
|
|
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
|
+
: [];
|
|
278
394
|
/* What this write put in the window, which is what the agent supplied, not the
|
|
279
395
|
merged article: a capsule-only write does not deliver the stored body. */
|
|
280
396
|
surfacer.markUpdated(
|
|
281
397
|
qualify(article.path),
|
|
282
398
|
[params.capsule, params.body].filter(Boolean).map(String).join("\n"),
|
|
283
399
|
);
|
|
400
|
+
const missingAsset = orphaned(mount.dir, article);
|
|
284
401
|
return [
|
|
285
402
|
`Wrote ${qualify(article.path)}.`,
|
|
286
|
-
...
|
|
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] : []),
|
|
287
408
|
].join("\n");
|
|
288
409
|
}
|
|
289
410
|
case "journal": {
|
|
@@ -330,7 +451,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
330
451
|
case "map":
|
|
331
452
|
return store.map(path);
|
|
332
453
|
case "search":
|
|
333
|
-
return search(store, String(params.query ?? ""));
|
|
454
|
+
return search(store, String(params.query ?? ""), params.journal === true);
|
|
334
455
|
default:
|
|
335
456
|
return `Unknown action "${action}". Actions: read, write, journal, map, search.`;
|
|
336
457
|
}
|