@panaversity/ksor 0.0.15 → 0.0.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 144aba8: Ingest says when a document's ordering key is one this record does not read
8
+
9
+ A record's reading order comes from the governed `order:` key alone. A corpus
10
+ arriving from Docusaurus, Hugo or Jekyll carries its own — `sidebar_position`,
11
+ `weight`, `nav_order` — and ksor ignored them in silence, falling back to file
12
+ name. That is a WRONG order, not a missing one, and it is the order served to
13
+ `llms.txt`, the rendered sidebar and the MCP `outline` alike.
14
+
15
+ Found on a real 81-document book where 73 files declared `sidebar_position`. Its
16
+ second chapter came out ninth; its preface came out eleventh. Nothing said why.
17
+
18
+ ```
19
+ plain-tree: 73 document(s) declare `sidebar_position`, which this record does not
20
+ read — reading order fell back to file name (about.md, how-to-sell.md,
21
+ thesis.md, and 70 more). Rename it to `order:` to keep the intended sequence.
22
+ ```
23
+
24
+ It reports on the same channel the adapter already uses for skipped files, where
25
+ the principle was already written down: a skip is reported, never silent. A
26
+ document that declares BOTH keys says nothing — `order:` wins, so nothing fell
27
+ back, and a warning there would only teach the reader to ignore the channel.
28
+
29
+ - 2e9c987: Ingest says what the navigation rule now is, not what it used to be
30
+
31
+ 0.0.15 changed how a section is judged to be navigation — shape rather than
32
+ length — and left every sentence describing it behind. So a fresh `ksor ingest`
33
+ reported:
34
+
35
+ ```
36
+ not searchable: 1 of 5 chunk(s) (20%) are shorter than the navigation threshold
37
+ ```
38
+
39
+ There is no navigation threshold any more, and the page in question was not
40
+ short: it was an index of links, which is exactly what the rule now catches. The
41
+ remedy was wrong in the same way — "lengthen these sections" is no longer how a
42
+ page becomes searchable, and padding a link list would not have made it one.
43
+
44
+ ```
45
+ not searchable: 1 of 5 chunk(s) (20%) read as navigation rather than content
46
+ FOUND ONLY BY NAME: knowledge/index — no searchable chunk at all; a page of
47
+ links reads as navigation; give it prose of its own, or reach it by slug
48
+ ```
49
+
50
+ Found by running the published artifact rather than by reading the diff. The
51
+ same stale description was corrected in the three other places it had been
52
+ copied to.
53
+
54
+ - 1e26c07: A YAML list in frontmatter no longer costs the document its title
55
+
56
+ The frontmatter reader emptied a document's ENTIRE metadata whenever a top-level
57
+ value opened with `[ { | > & * !`. One `authors: ["…"]` line beside the title,
58
+ and the title went with it — along with `order:` and `sor_id:`.
59
+
60
+ Found on a real 81-document book, where four chapters were served under names
61
+ derived from their filenames:
62
+
63
+ | served as | declared |
64
+ | ---------------------------- | --------------------------------------------------------------- |
65
+ | `Preface Agent Native` | `Preface: The Right Side of the Line` |
66
+ | `System Of Context` | `The System of Context: Connecting the Records to Real Work` |
67
+ | `Designing The Vertical Sor` | `Designing the Vertical System of Record from First Principles` |
68
+
69
+ Titles reach the site, `llms.txt` and the MCP `outline`, so this was wrong on
70
+ every surface at once, and silently.
71
+
72
+ The reader is documented as PyYAML-compatible and empties the map only where
73
+ PyYAML raises. PyYAML does not raise on a flow sequence — it parses it. Two
74
+ different things were being conflated:
75
+
76
+ - **invalid** — an unquoted `a: b: c`, a trailing `:`. PyYAML raises; the map is
77
+ still emptied, unchanged.
78
+ - **valid but not modelled here** — a flow sequence or mapping, a block scalar,
79
+ an anchor. PyYAML parses these. Only the KEY is beyond the reader now; the
80
+ document survives.
81
+
82
+ **One identity change to know about.** A document that declares `sor_id:`
83
+ _alongside_ such a value previously had that override silently dropped, so its
84
+ stable_id fell back to the path. The override now stands, on both surfaces
85
+ together — so re-ingesting changes the stable_id of exactly those documents, and
86
+ any takedown row keyed on the old path-derived id must be re-pointed. The site
87
+ and the kernel change in step, which is the property `stable-id-conformance`
88
+ exists to hold.
89
+
90
+ One governance guard gets quieter and no weaker: ingest used to REFUSE a
91
+ document declaring `visibility:` beside a flow list, because the map was emptied
92
+ and the tier silently defaulted. The cause is gone, so it ingests with the right
93
+ visibility; the refusal still stands for frontmatter PyYAML genuinely rejects.
94
+
95
+ - d4334c7: A quiz no longer swallows the explanation that precedes it
96
+
97
+ The previous release moved navigation from a length test to a shape test, so a
98
+ short fact stopped being mistaken for a link list. The rule that decides whether
99
+ a whole section is _a widget_ — a quiz, a slide embed — was left on the old
100
+ threshold: under 250 characters of teaching before the widget, and the entire
101
+ section was labelled `assessment` or `embed`, neither of which any search
102
+ returns.
103
+
104
+ So a section carrying a complete 180-character explanation followed by a
105
+ knowledge check lost the explanation too. Same defect as the last one, one path
106
+ over.
107
+
108
+ Both paths now ask the same question: is what comes BEFORE the widget actually
109
+ navigation-shaped? A heading with only a quiz under it is still a quiz. A link
110
+ list before a quiz is still a quiz. An explanation before a quiz is an
111
+ explanation, and stays searchable.
112
+
113
+ Found by ingesting a real 81-document curriculum corpus, where 610 chunks landed
114
+ as `assessment` and 186 as `embed` — together 79% of everything unsearchable in
115
+ that record.
116
+
117
+ `CHUNK_POLICY` moves to v7 (persisted provenance; the labels it names changed),
118
+ and `NAV_MAX_CHARS` is deleted — nothing reads it now. **Re-run `ksor ingest` to
119
+ pick this up**; unchanged content is not re-embedded.
120
+
3
121
  ## 0.0.15
4
122
 
5
123
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
16
16
  import { execFileSync, spawnSync } from "node:child_process";
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile, readdir, stat } from "node:fs/promises";
19
- //#region ../content-gateway/dist/main-wfyAlzsD.mjs
19
+ //#region ../content-gateway/dist/main-BFWvQ6vY.mjs
20
20
  /**
21
21
  * A connection could not be ESTABLISHED in time — retryable.
22
22
  *
@@ -430,7 +430,7 @@ const EMBED_DIM$1 = 1536;
430
430
  const EMBED_TASK_DOCUMENT$1 = "RETRIEVAL_DOCUMENT";
431
431
  const EMBED_TASK_QUERY$1 = "RETRIEVAL_QUERY";
432
432
  /** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
433
- const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v6";
433
+ const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v7";
434
434
  /**
435
435
  * The kernel's view of `instance.md` (adapted from oracle SC/instance.py
436
436
  * under decision 11 — an adaptation, not a port: ksor has ONE instance file
@@ -4452,7 +4452,7 @@ async function withPgRetry(op, options = {}) {
4452
4452
  throw lastError;
4453
4453
  }
4454
4454
  //#endregion
4455
- //#region ../content/dist/commands-_8HvMWfD.mjs
4455
+ //#region ../content/dist/commands-B3-NrLiw.mjs
4456
4456
  /**
4457
4457
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4458
4458
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -4467,7 +4467,7 @@ const EMBED_DIM = 1536;
4467
4467
  const EMBED_TASK_DOCUMENT = "RETRIEVAL_DOCUMENT";
4468
4468
  const EMBED_TASK_QUERY = "RETRIEVAL_QUERY";
4469
4469
  /** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
4470
- const CHUNK_POLICY = "heading-aware-1500-content-only-v6";
4470
+ const CHUNK_POLICY = "heading-aware-1500-content-only-v7";
4471
4471
  const MAX_CHARS = 1500;
4472
4472
  /** < Gemini's 2048-token embed input. */
4473
4473
  const HARD_MAX_CHARS = 4e3;
@@ -7004,6 +7004,17 @@ function buildManifestFromTree(root, options) {
7004
7004
  const files = [];
7005
7005
  const sources = /* @__PURE__ */ new Map();
7006
7006
  const skipped = [];
7007
+ /** foreign ordering key -> the documents that declare it and no `order:`. */
7008
+ const foreignOrder = /* @__PURE__ */ new Map();
7009
+ const noteForeignOrder = (meta, path) => {
7010
+ if (meta["order"] !== void 0 && meta["order"] !== null) return;
7011
+ for (const key of FOREIGN_ORDER_KEYS) {
7012
+ if (meta[key] === void 0 || meta[key] === null) continue;
7013
+ const seen = foreignOrder.get(key) ?? [];
7014
+ seen.push(path);
7015
+ foreignOrder.set(key, seen);
7016
+ }
7017
+ };
7007
7018
  const fullPath = (relSegs, name) => `${rootPath}/${[...relSegs, name].join("/")}`;
7008
7019
  const addFile = (nodeSid, fileSegs) => {
7009
7020
  const rel = fileSegs.join("/");
@@ -7028,8 +7039,10 @@ function buildManifestFromTree(root, options) {
7028
7039
  continue;
7029
7040
  }
7030
7041
  if (INDEX_NAMES.includes(f.name)) continue;
7042
+ const fileMeta = frontmatterMeta(f.text);
7043
+ noteForeignOrder(fileMeta, fullPath(relSegs, f.name));
7031
7044
  ordered.push({
7032
- order: orderValue(frontmatterMeta(f.text)["order"]),
7045
+ order: orderValue(fileMeta["order"]),
7033
7046
  tie: tieKey(f.name),
7034
7047
  entry: f
7035
7048
  });
@@ -7041,6 +7054,7 @@ function buildManifestFromTree(root, options) {
7041
7054
  }
7042
7055
  const index = indexOf(d, fullPath(relSegs, d.name));
7043
7056
  const dirMeta = index === null ? {} : frontmatterMeta(index.text);
7057
+ if (index !== null) noteForeignOrder(dirMeta, fullPath(relSegs, `${d.name}/${index.name}`));
7044
7058
  ordered.push({
7045
7059
  order: orderValue(dirMeta["order"]),
7046
7060
  tie: tieKey(d.name),
@@ -7100,6 +7114,12 @@ function buildManifestFromTree(root, options) {
7100
7114
  }
7101
7115
  walk(root, [], null);
7102
7116
  for (const s of skipped) onSkip(`plain-tree: skipped ${s}`);
7117
+ for (const [key, paths] of foreignOrder) {
7118
+ const rel = paths.map((x) => x.startsWith(`${rootPath}/`) ? x.slice(rootPath.length + 1) : x);
7119
+ const shown = rel.slice(0, 3).join(", ");
7120
+ const more = rel.length - Math.min(3, rel.length);
7121
+ onSkip(`plain-tree: ${rel.length} document(s) declare \`${key}\`, which this record does not read — reading order fell back to file name (${shown}${more > 0 ? `, and ${more} more` : ""}). Rename it to \`order:\` to keep the intended sequence.`);
7122
+ }
7103
7123
  if (files.length === 0) throw new ManifestError(`plain-tree root ${rootPath} contains no Markdown`);
7104
7124
  const manifest = {
7105
7125
  format: 1,
@@ -7186,6 +7206,22 @@ function codePointCompare(a, b) {
7186
7206
  }
7187
7207
  return as.length - bs.length;
7188
7208
  }
7209
+ /**
7210
+ * Ordering keys OTHER ecosystems read, which this record does not.
7211
+ *
7212
+ * Reading order here is the governed `order:` key alone (decision 9 retired the
7213
+ * predecessor's Docusaurus keys; the MCP door had been reading them). But a
7214
+ * corpus arriving from Docusaurus, Hugo or Jekyll carries its own, and ignoring
7215
+ * one silently produces a WRONG order rather than a missing one — filename
7216
+ * order, served to `llms.txt`, the sidebar and the `outline` tool alike. Found
7217
+ * on a real 81-document book where 73 files declared `sidebar_position` (#74).
7218
+ */
7219
+ const FOREIGN_ORDER_KEYS = [
7220
+ "sidebar_position",
7221
+ "position",
7222
+ "weight",
7223
+ "nav_order"
7224
+ ];
7189
7225
  /** Re-exported so every reader of a document agrees where its frontmatter ENDS. */
7190
7226
  const FRONTMATTER$1 = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
7191
7227
  const YAML_BOOLS = {
@@ -7209,8 +7245,12 @@ const YAML_BOOLS = {
7209
7245
  OFF: false
7210
7246
  };
7211
7247
  /**
7212
- * Minimal PyYAML-compatible frontmatter reader for the FOUR scalar keys this
7213
- * adapter consumes (`title`, `position`, `sidebar_position`, `sor_id`) the
7248
+ * Minimal PyYAML-compatible frontmatter reader. It parses every top-level
7249
+ * scalar; the adapter consumes `title`, `order` and `sor_id`, and reads the rest
7250
+ * only to WARN about them (see FOREIGN_ORDER_KEYS). The wording here named
7251
+ * `position` and `sidebar_position` until now, which is what this adapter read
7252
+ * before ordering became one governed key — the keys it names are the ones it
7253
+ * stopped reading. The
7214
7254
  * kernel discards every other frontmatter key at build time (taxonomy comes
7215
7255
  * from the manifest), so a YAML dependency would buy nothing (guard rule 5).
7216
7256
  * Scope, deliberately narrow pending a shared markdown module: top-level
@@ -7268,11 +7308,11 @@ function scalarValue(raw) {
7268
7308
  ok: true,
7269
7309
  value: Number.parseFloat(plain.replaceAll("_", ""))
7270
7310
  };
7271
- if (/:[ \t]/.test(plain) || plain.endsWith(":")) return {
7272
- ok: false,
7311
+ if (/^[|>&*!{[]/.test(plain)) return {
7312
+ ok: true,
7273
7313
  value: null
7274
7314
  };
7275
- if (/^[|>&*!{[]/.test(plain)) return {
7315
+ if (/:[ \t]/.test(plain) || plain.endsWith(":")) return {
7276
7316
  ok: false,
7277
7317
  value: null
7278
7318
  };
@@ -7895,7 +7935,7 @@ const NAV_LINE = /^(?:[-*+]\s+|\d+[.)]\s+)?(?:\[[^\]]*\]\([^)]*\)|<https?:\/\/[^
7895
7935
  * Is this segment NAVIGATION — a thing that points at content rather than
7896
7936
  * being content?
7897
7937
  *
7898
- * The oracle answered this with length: under NAV_MAX_CHARS (250) meant nav.
7938
+ * The oracle answered this with length: under 250 code points meant nav.
7899
7939
  * On the curriculum corpus it was tuned against, that proxy holds — a short
7900
7940
  * segment there really is a link list. On a handbook it inverts, because a
7901
7941
  * handbook's most valuable statements are its shortest ("Six months, with a
@@ -7919,22 +7959,40 @@ function isNavShaped(content) {
7919
7959
  if (lines.filter((ln) => NAV_LINE.test(ln)).length * 2 > lines.length) return true;
7920
7960
  return cpLen(lines.filter((ln) => !NAV_LINE.test(ln)).join(" ")) < 24;
7921
7961
  }
7962
+ /**
7963
+ * Does a line-leading widget DOMINATE this span?
7964
+ *
7965
+ * The widget regexes match an opening tag only, so the tag's position is where
7966
+ * teaching stops and markup begins. The question is therefore about what comes
7967
+ * BEFORE it: if that is navigation-shaped, the span is the widget; if it is real
7968
+ * explanation, the widget is a minority of a teaching passage.
7969
+ *
7970
+ * This used to be a length test — 250 characters of teaching body before the
7971
+ * widget and the whole span became `assessment`, which no retrieval arm returns.
7972
+ * #55 moved navigation from length to shape and left this path behind, so a
7973
+ * section carrying 180 characters of real explanation before a `<Quiz>` lost the
7974
+ * explanation with it (issue #75).
7975
+ */
7976
+ function dominantWidget(span) {
7977
+ for (const [re, label] of [[JSX_ASSESS, "assessment"], [JSX_EMBED, "embed"]]) {
7978
+ const m = re.exec(span);
7979
+ if (m !== null && isNavShaped(span.slice(0, m.index))) return label;
7980
+ }
7981
+ return null;
7982
+ }
7922
7983
  function classify(content, headingPath) {
7923
- if (JSX_ASSESS.test(content)) return "assessment";
7984
+ const widget = dominantWidget(content);
7985
+ if (widget !== null) return widget;
7924
7986
  const leaf = headingPath.length > 0 ? headingPath[headingPath.length - 1] : "";
7925
- if (JSX_EMBED.test(content) || content.includes("docs.google.com/presentation") || leaf.includes("Teaching Aid")) return "embed";
7987
+ if (content.includes("docs.google.com/presentation") || leaf.includes("Teaching Aid")) return "embed";
7926
7988
  if (isNavShaped(content)) return "nav";
7927
7989
  return "prose";
7928
7990
  }
7929
- /** A segment DOMINATED by a line-leading widget (with < NAV_MAX_CHARS of
7930
- * teaching body before it) labels EVERY fragment a char-sliced widget must
7931
- * not leak as prose. */
7991
+ /** A segment dominated by a line-leading widget labels EVERY fragment — a
7992
+ * char-sliced widget must not leak as prose. Same question as `classify`, asked
7993
+ * of the whole segment rather than one piece of it. */
7932
7994
  function segmentMarkerType(span) {
7933
- for (const [re, label] of [[JSX_ASSESS, "assessment"], [JSX_EMBED, "embed"]]) {
7934
- const m = re.exec(span);
7935
- if (m !== null && cpLen(teachingBody(span.slice(0, m.index))) < 250) return label;
7936
- }
7937
- return null;
7995
+ return dominantWidget(span);
7938
7996
  }
7939
7997
  /** Walk lines; headings count only OUTSIDE fences; every line lands in exactly
7940
7998
  * one segment (byte-exact). H1 records a title but never enters the path. */
@@ -8956,11 +9014,11 @@ async function ingestCommand(args) {
8956
9014
  process.stdout.write(`ingest: generation ${report.generation} — ${report.nodes} nodes, ${report.chunks} chunks; embedded ${report.embedded}, carried ${report.carried}, failed ${report.failed}\n`);
8957
9015
  if (report.unsearchable > 0) {
8958
9016
  const pct = Math.round(report.unsearchable / Math.max(report.chunks, 1) * 100);
8959
- process.stdout.write(` not searchable: ${report.unsearchable} of ${report.chunks} chunk(s) (${pct}%) are shorter than the navigation threshold — stored and readable, but no search returns them\n`);
9017
+ process.stdout.write(` not searchable: ${report.unsearchable} of ${report.chunks} chunk(s) (${pct}%) read as navigation rather than content — stored and readable, but no search returns them\n`);
8960
9018
  if (report.unsearchableSources.length > 0) {
8961
9019
  const named = report.unsearchableSources.slice(0, 10).join(", ");
8962
9020
  const more = report.unsearchableSources.length - Math.min(10, report.unsearchableSources.length);
8963
- process.stdout.write(` FOUND ONLY BY NAME: ${named}${more > 0 ? `, and ${more} more` : ""} — no searchable chunk at all; lengthen these sections or read them by slug
9021
+ process.stdout.write(` FOUND ONLY BY NAME: ${named}${more > 0 ? `, and ${more} more` : ""} — no searchable chunk at all — a page of links reads as navigation; give it prose of its own, or reach it by slug
8964
9022
  `);
8965
9023
  }
8966
9024
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -136,8 +136,16 @@ function readScalar(raw: string): ScalarRead {
136
136
  // override — which is what the kernel does. Kept in step with `scalarValue`
137
137
  // in ingest/adapters/plain-tree.ts and bound to it by
138
138
  // `stable-id-conformance.test.ts`.
139
+ // VALID YAML this reader does not model: a flow sequence or mapping, a block
140
+ // scalar, an anchor/alias/tag. PyYAML parses every one — the DOCUMENT is fine
141
+ // and only this KEY is beyond the reader, so it must not empty the map.
142
+ // Checked BEFORE the ": " test, because a flow mapping legitimately contains
143
+ // one (`meta: {a: 1}`).
144
+ // `typed` rather than `refused`: the key exists but is not a string, so this
145
+ // map (which holds strings) omits it and no override is taken — exactly what
146
+ // the kernel now does with `value: null` (issue #78).
147
+ if (/^[|>&*!{[]/.test(plain)) return { kind: "typed", value: "" };
139
148
  if (/:[ \t]/.test(plain) || plain.endsWith(":")) return { kind: "refused", value: "" };
140
- if (/^[|>&*!{[]/.test(plain)) return { kind: "refused", value: "" };
141
149
  if (YAML_TYPED.test(plain)) return { kind: "typed", value: "" };
142
150
  return { kind: "string", value: plain };
143
151
  }