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.
- package/README.md +131 -50
- 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 +21 -4
- package/extensions/lib/surfacing.ts +60 -3
- package/extensions/lib/tool.ts +202 -72
- package/extensions/settings.ts +328 -0
- package/package.json +4 -1
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";
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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[] =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 =
|
|
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 >
|
|
204
|
-
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.`);
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
}
|