canon-atlas 0.2.0 → 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 CHANGED
@@ -4,7 +4,15 @@ An atlas over a catalog of markdown documents that reference each other. Point i
4
4
 
5
5
  It is agnostic to what wrote the catalog. It was built as the human viewport onto [pi-canon](https://github.com/shaneconner/pi-canon) project memory, where mutable articles carry current ground truth and an immutable journal carries the journey to it, but any folder of markdown with `[[wikilinks]]` or relative links works: an Obsidian vault, a wiki, a notes directory, an agent memory store.
6
6
 
7
- ![The constellation over a pi-canon memory store](docs/constellation.png)
7
+ ![The constellation over a knowledge store of two thousand documents](docs/constellation.png)
8
+
9
+ Click a star and the camera flies to it, its neighborhood lights while the rest of the sky dims, and the reader opens. Follow a link and you hop the connection; back walks the trail home.
10
+
11
+ ![Navigating the atlas: gliding into a cluster, opening a document, hopping a connection](docs/atlas.gif)
12
+
13
+ The same footage plays smoother as [a short video](docs/atlas.mp4).
14
+
15
+ ![A pinned document: the neighborhood lit, the sky dimmed](docs/selection.png)
8
16
 
9
17
  ## Quick start
10
18
 
@@ -62,7 +70,7 @@ References that resolve nowhere are not errors: they render as dashed, unwritten
62
70
 
63
71
  ### The article schema
64
72
 
65
- A store may carry a `schema.json` beside its collections ([pi-canon](https://github.com/shaneconner/pi-canon) writes one when it creates a store), declaring rules for an article's `capsule`, `title`, and `body`: `required`, `min_chars`, `max_chars`, and a `hint`. The atlas enforces the same contract at every one of its write doors. Enforcement is asymmetric on purpose: a `required` rule rejects a save that touches the field (and everything on create), with nothing written and the editor keeping your text while it tells you what to correct; every other rule warns, and a document already in violation shows heal notes in the reader instead of errors, so a body edit is never held hostage to a legacy omission. A malformed `schema.json` fails open and loud: nothing is enforced, and the map panel and every save say so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
73
+ A store may carry a `schema.json` beside its collections ([pi-canon](https://github.com/shaneconner/pi-canon) writes one when it creates a store), declaring rules for an article's `capsule`, `title`, and `body`: `required`, `min_chars`, `max_chars`, and a `hint`. The reference graph is part of the contract too, under `relations`: `refs` (`required`, `min_count`) judges a document's own citations at the write door, counting body links and refs frontmatter once each with code examples stripped, and touching means the reference set changed, so a body edit that keeps its citations is never re-litigated; `orphan.warn` notes a document nothing references; `children.listed` notes a document that fails to reference a direct child under its address. The graph rules are exactly what a whole-graph reader is positioned to enforce, so the atlas enforces them and pi-canon enforces `refs` at its own boundary, both from the same file. The atlas enforces the same contract at every one of its write doors. Enforcement is asymmetric on purpose: a `required` rule rejects a save that touches the field (and everything on create), with nothing written and the editor keeping your text while it tells you what to correct; every other rule warns, and a document already in violation shows heal notes in the reader instead of errors, so a body edit is never held hostage to a legacy omission. A malformed `schema.json` fails open and loud: nothing is enforced, and the map panel and every save say so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
66
74
 
67
75
  ## Color
68
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canon-atlas",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An atlas over a catalog of markdown documents that reference each other: a constellation graph, search, a reader, and local editing. Agnostic to what wrote the catalog.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,11 @@
8
8
  },
9
9
  "exports": {
10
10
  ".": "./src/build.mjs",
11
+ "./serve": "./src/serve.mjs",
12
+ "./page": "./src/page.mjs",
13
+ "./pipeline": "./src/ui/pipeline.cjs",
14
+ "./assets/*": "./src/ui/*",
15
+ "./vendor/*": "./vendor/*",
11
16
  "./package.json": "./package.json"
12
17
  },
13
18
  "files": [
package/src/serve.mjs CHANGED
@@ -161,7 +161,7 @@ export function corpusApi(rootArg) {
161
161
  if (col.immutable) return [];
162
162
  /* A broken declaration enforces nothing, but every write says so. */
163
163
  if (!cfg.schema) return cfg.schemaProblems || [];
164
- const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields.summary);
164
+ const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields);
165
165
  if (r.rejects.length) {
166
166
  throw httpError(422, "Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
167
167
  }
@@ -248,6 +248,15 @@ var AtlasPipeline = (function () {
248
248
  const SCHEMA_FILE = "schema.json";
249
249
  const SCHEMA_FIELDS = ["capsule", "title", "body"];
250
250
  const SCHEMA_RULE_KEYS = ["required", "min_chars", "max_chars", "hint"];
251
+ /* Relations rules are about the reference graph, not any one field. The
252
+ contract declares them once; each tool enforces what it can see. This
253
+ page sees the whole graph, so all three hold here: refs at the write
254
+ door and on read, orphan and children as read-side heal notes. */
255
+ const RELATION_KEYS = {
256
+ refs: ["required", "min_count", "hint"],
257
+ orphan: ["warn", "hint"],
258
+ children: ["listed", "hint"],
259
+ };
251
260
 
252
261
  /* The store lives either at the corpus root or nested under .canon/; the
253
262
  schema file sits beside the collections, wherever they are. */
@@ -271,8 +280,9 @@ var AtlasPipeline = (function () {
271
280
  return { schema: undefined, problems: [SCHEMA_FILE + " must hold a JSON object; its rules are not being enforced."] };
272
281
  }
273
282
  const problems = [];
274
- const declared = raw.article;
275
- if (declared === undefined) return { schema: {}, problems };
283
+ /* A missing article block is an empty one, not an early exit: a schema
284
+ may carry only relations rules. */
285
+ const declared = raw.article ?? {};
276
286
  if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
277
287
  return { schema: undefined, problems: [SCHEMA_FILE + ': "article" must be an object; its rules are not being enforced.'] };
278
288
  }
@@ -297,9 +307,51 @@ var AtlasPipeline = (function () {
297
307
  }
298
308
  schema[name] = rule;
299
309
  }
310
+ const rel = raw.relations;
311
+ if (rel !== undefined) {
312
+ if (typeof rel !== "object" || rel === null || Array.isArray(rel)) {
313
+ problems.push(SCHEMA_FILE + ': "relations" must be an object and is ignored.');
314
+ } else {
315
+ const relations = {};
316
+ for (const [name, value] of Object.entries(rel)) {
317
+ const keys = RELATION_KEYS[name];
318
+ if (!keys) {
319
+ problems.push(SCHEMA_FILE + ': unknown relations field "' + name + '" is ignored (fields: refs, orphan, children).');
320
+ continue;
321
+ }
322
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
323
+ problems.push(SCHEMA_FILE + ': rule for "relations.' + name + '" must be an object and is ignored.');
324
+ continue;
325
+ }
326
+ const rule = {};
327
+ for (const [key, val] of Object.entries(value)) {
328
+ if (!keys.includes(key)) {
329
+ problems.push(SCHEMA_FILE + ': unknown rule key "relations.' + name + "." + key + '" is ignored (keys: ' + keys.join(", ") + ").");
330
+ } else if ((key === "required" || key === "warn" || key === "listed") && typeof val === "boolean") rule[key] = val;
331
+ else if (key === "min_count" && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
332
+ else if (key === "hint" && typeof val === "string") rule.hint = val;
333
+ else problems.push(SCHEMA_FILE + ': "relations.' + name + "." + key + '" has the wrong type and is ignored.');
334
+ }
335
+ relations[name] = rule;
336
+ }
337
+ schema.relations = relations;
338
+ }
339
+ }
300
340
  return { schema, problems };
301
341
  }
302
342
 
343
+ /* The outgoing references a document authors, as the write door can see them:
344
+ body links (code stripped by the extractor) plus the collection's refs
345
+ frontmatter, deduplicated case-insensitively. */
346
+ function outgoingOf(raw, refsKey) {
347
+ const { meta, body } = parseFrontmatter(raw);
348
+ const out = new Set();
349
+ for (const l of extractLinks(body)) out.add(l.target.trim().toLowerCase());
350
+ for (const r of asRefs(meta[refsKey])) out.add(String(r).trim().toLowerCase());
351
+ out.delete("");
352
+ return [...out].sort();
353
+ }
354
+
303
355
  /* The title the schema means: the body's leading # heading, nothing later. */
304
356
  function titleOf(body) {
305
357
  for (const line of body.split("\n")) {
@@ -311,15 +363,19 @@ var AtlasPipeline = (function () {
311
363
  }
312
364
 
313
365
  /* One save (or one read, when prevRaw === raw so nothing counts as touched)
314
- against the schema. summaryKey is the frontmatter key this collection
315
- carries its capsule under. A required violation rejects only when the save
316
- touched that field or created the document; a legacy violation the save
317
- left alone warns instead, so a body edit is never held hostage to an old
318
- missing capsule. */
319
- function checkDoc(raw, prevRaw, schema, summaryKey) {
366
+ against the schema. fields is the collection's frontmatter mapping (a bare
367
+ string is taken as the summary key, for callers predating relations). A
368
+ required violation rejects only when the save touched that field or
369
+ created the document; a legacy violation the save left alone warns
370
+ instead, so a body edit is never held hostage to an old missing capsule,
371
+ and a kept reference set is never re-litigated. */
372
+ function checkDoc(raw, prevRaw, schema, fields) {
320
373
  const rejects = [];
321
374
  const warns = [];
322
375
  if (!schema) return { rejects, warns };
376
+ const f = typeof fields === "string" ? { summary: fields } : fields || {};
377
+ const summaryKey = f.summary || "summary";
378
+ const refsKey = f.refs || "refs";
323
379
  const created = prevRaw == null;
324
380
  const extract = (text) => {
325
381
  const { meta, body } = parseFrontmatter(text);
@@ -348,6 +404,20 @@ var AtlasPipeline = (function () {
348
404
  warns.push("schema: " + name + " is over " + rule.max_chars + " characters (" + value.length + ")" + hint);
349
405
  }
350
406
  }
407
+ /* refs is judged from the save alone: touched means the reference SET
408
+ changed, so a body edit that keeps its citations passes untouched. */
409
+ const refsRule = schema.relations && schema.relations.refs;
410
+ if (refsRule) {
411
+ const cited = outgoingOf(raw, refsKey);
412
+ const touched = created || cited.join("\n") !== outgoingOf(prevRaw, refsKey).join("\n");
413
+ const hint = refsRule.hint ? " (" + refsRule.hint + ")" : "";
414
+ if (refsRule.required && cited.length === 0) {
415
+ if (touched) rejects.push("refs is required and this document references nothing" + hint);
416
+ else warns.push("schema: refs is required and this document references nothing; this document can be healed by editing" + hint);
417
+ } else if (refsRule.min_count != null && cited.length < refsRule.min_count) {
418
+ warns.push("schema: refs has " + cited.length + " outgoing (min " + refsRule.min_count + ")" + hint);
419
+ }
420
+ }
351
421
  return { rejects, warns };
352
422
  }
353
423
 
@@ -395,7 +465,7 @@ var AtlasPipeline = (function () {
395
465
  /* A read never rejects: a document in violation carries heal notes. */
396
466
  let schemaNotes;
397
467
  if (config.schema && !col.immutable) {
398
- const w = checkDoc(text, text, config.schema, f.summary).warns;
468
+ const w = checkDoc(text, text, config.schema, f).warns;
399
469
  if (w.length) schemaNotes = w;
400
470
  }
401
471
  return {
@@ -911,6 +981,46 @@ var AtlasPipeline = (function () {
911
981
 
912
982
  function buildData(config, graph, mode) {
913
983
  const { nodes, edges } = graph;
984
+ /* The graph-wide relations rules, which only a whole-graph reader can
985
+ judge: orphan (nothing references this document) and children (a parent
986
+ must reference each direct child under its address). Read-side heal
987
+ notes, never a wall, and only over mutable documents. */
988
+ const rel = config.schema && config.schema.relations;
989
+ if (rel && ((rel.orphan && rel.orphan.warn) || (rel.children && rel.children.listed))) {
990
+ const inbound = new Set();
991
+ const outTargets = new Map();
992
+ for (const e of edges) {
993
+ inbound.add(e.target);
994
+ if (!outTargets.has(e.source)) outTargets.set(e.source, new Set());
995
+ outTargets.get(e.source).add(e.target);
996
+ }
997
+ const byAddress = new Map();
998
+ nodes.forEach((n, i) => {
999
+ if (n.exists && !n.immutable && n.address) byAddress.set(n.address, i);
1000
+ });
1001
+ nodes.forEach((n, i) => {
1002
+ if (!n.exists || n.immutable) return;
1003
+ const notes = [];
1004
+ if (rel.orphan && rel.orphan.warn && !inbound.has(i)) {
1005
+ notes.push("schema: nothing references this document" + (rel.orphan.hint ? " (" + rel.orphan.hint + ")" : ""));
1006
+ }
1007
+ if (rel.children && rel.children.listed && n.address) {
1008
+ const prefix = n.address + "/";
1009
+ const named = outTargets.get(i) || new Set();
1010
+ const missing = [];
1011
+ for (const [addr, ci] of byAddress) {
1012
+ if (!addr.startsWith(prefix) || addr.slice(prefix.length).includes("/")) continue;
1013
+ if (!named.has(ci)) missing.push(addr);
1014
+ }
1015
+ if (missing.length) {
1016
+ missing.sort();
1017
+ const shown = missing.slice(0, 3).join(", ") + (missing.length > 3 ? " and " + (missing.length - 3) + " more" : "");
1018
+ notes.push("schema: children not referenced: " + shown + (rel.children.hint ? " (" + rel.children.hint + ")" : ""));
1019
+ }
1020
+ }
1021
+ if (notes.length) n.schemaNotes = (n.schemaNotes || []).concat(notes);
1022
+ });
1023
+ }
914
1024
  const { basis, clusters, assign } = assignClusters(nodes, edges, config.vectors || null);
915
1025
  const counts = new Map();
916
1026
  for (const n of nodes) {
package/src/ui/shell.js CHANGED
@@ -286,8 +286,7 @@
286
286
  if (col.immutable) return [];
287
287
  /* A broken declaration enforces nothing, but every write says so. */
288
288
  if (!storeSchema.schema) return storeSchema.problems || [];
289
- var summaryKey = (col.fields && col.fields.summary) || "summary";
290
- var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, summaryKey);
289
+ var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, col.fields || {});
291
290
  if (r.rejects.length) {
292
291
  throw new Error("Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
293
292
  }