@panaversity/ksor 0.0.15 → 0.0.17
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 +148 -0
- package/dist/cli.mjs +202 -31
- package/package.json +1 -2
- package/templates/scaffold/system/site/lib/denial-rule.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,153 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.17
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 44feada: Installing ksor no longer pulls 32 MB of vendor SDK
|
|
8
|
+
|
|
9
|
+
`npx @panaversity/ksor init` installed 54 MB across 52 packages. 32 MB of that
|
|
10
|
+
was `@google/genai` and its dependencies — carried by every adopter, including
|
|
11
|
+
the ones who only ever run `init` and `dev` and never reach a served rung.
|
|
12
|
+
|
|
13
|
+
It existed to make two HTTP calls, both already wrapped behind one
|
|
14
|
+
structurally-typed client boundary. Those calls are now spoken directly:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
before 54 MB 52 packages
|
|
18
|
+
after 22 MB 22 packages
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Nothing about the embedding changed, and that was checked first rather than
|
|
22
|
+
assumed: the SDK and the REST endpoint return **byte-identical vectors** for the
|
|
23
|
+
same text, model, dimensionality and task type — a maximum per-component
|
|
24
|
+
difference of 0.000e+0 at 1536 dimensions. So stored embeddings stay valid and a
|
|
25
|
+
calibrated `vector_floor` keeps its meaning. Had they differed by a rounding
|
|
26
|
+
step, this would have quietly invalidated abstention on every existing record.
|
|
27
|
+
|
|
28
|
+
The provider seam is unchanged: a deployment that prefers an SDK can still
|
|
29
|
+
supply one through `clientFactory`. The single live call to the real vendor
|
|
30
|
+
stays where it was, as the tripwire for API drift, and now meets Gemini with
|
|
31
|
+
nothing in between.
|
|
32
|
+
|
|
33
|
+
## 0.0.16
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- 144aba8: Ingest says when a document's ordering key is one this record does not read
|
|
38
|
+
|
|
39
|
+
A record's reading order comes from the governed `order:` key alone. A corpus
|
|
40
|
+
arriving from Docusaurus, Hugo or Jekyll carries its own — `sidebar_position`,
|
|
41
|
+
`weight`, `nav_order` — and ksor ignored them in silence, falling back to file
|
|
42
|
+
name. That is a WRONG order, not a missing one, and it is the order served to
|
|
43
|
+
`llms.txt`, the rendered sidebar and the MCP `outline` alike.
|
|
44
|
+
|
|
45
|
+
Found on a real 81-document book where 73 files declared `sidebar_position`. Its
|
|
46
|
+
second chapter came out ninth; its preface came out eleventh. Nothing said why.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
plain-tree: 73 document(s) declare `sidebar_position`, which this record does not
|
|
50
|
+
read — reading order fell back to file name (about.md, how-to-sell.md,
|
|
51
|
+
thesis.md, and 70 more). Rename it to `order:` to keep the intended sequence.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It reports on the same channel the adapter already uses for skipped files, where
|
|
55
|
+
the principle was already written down: a skip is reported, never silent. A
|
|
56
|
+
document that declares BOTH keys says nothing — `order:` wins, so nothing fell
|
|
57
|
+
back, and a warning there would only teach the reader to ignore the channel.
|
|
58
|
+
|
|
59
|
+
- 2e9c987: Ingest says what the navigation rule now is, not what it used to be
|
|
60
|
+
|
|
61
|
+
0.0.15 changed how a section is judged to be navigation — shape rather than
|
|
62
|
+
length — and left every sentence describing it behind. So a fresh `ksor ingest`
|
|
63
|
+
reported:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
not searchable: 1 of 5 chunk(s) (20%) are shorter than the navigation threshold
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
There is no navigation threshold any more, and the page in question was not
|
|
70
|
+
short: it was an index of links, which is exactly what the rule now catches. The
|
|
71
|
+
remedy was wrong in the same way — "lengthen these sections" is no longer how a
|
|
72
|
+
page becomes searchable, and padding a link list would not have made it one.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
not searchable: 1 of 5 chunk(s) (20%) read as navigation rather than content
|
|
76
|
+
FOUND ONLY BY NAME: knowledge/index — no searchable chunk at all; a page of
|
|
77
|
+
links reads as navigation; give it prose of its own, or reach it by slug
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Found by running the published artifact rather than by reading the diff. The
|
|
81
|
+
same stale description was corrected in the three other places it had been
|
|
82
|
+
copied to.
|
|
83
|
+
|
|
84
|
+
- 1e26c07: A YAML list in frontmatter no longer costs the document its title
|
|
85
|
+
|
|
86
|
+
The frontmatter reader emptied a document's ENTIRE metadata whenever a top-level
|
|
87
|
+
value opened with `[ { | > & * !`. One `authors: ["…"]` line beside the title,
|
|
88
|
+
and the title went with it — along with `order:` and `sor_id:`.
|
|
89
|
+
|
|
90
|
+
Found on a real 81-document book, where four chapters were served under names
|
|
91
|
+
derived from their filenames:
|
|
92
|
+
|
|
93
|
+
| served as | declared |
|
|
94
|
+
| ---------------------------- | --------------------------------------------------------------- |
|
|
95
|
+
| `Preface Agent Native` | `Preface: The Right Side of the Line` |
|
|
96
|
+
| `System Of Context` | `The System of Context: Connecting the Records to Real Work` |
|
|
97
|
+
| `Designing The Vertical Sor` | `Designing the Vertical System of Record from First Principles` |
|
|
98
|
+
|
|
99
|
+
Titles reach the site, `llms.txt` and the MCP `outline`, so this was wrong on
|
|
100
|
+
every surface at once, and silently.
|
|
101
|
+
|
|
102
|
+
The reader is documented as PyYAML-compatible and empties the map only where
|
|
103
|
+
PyYAML raises. PyYAML does not raise on a flow sequence — it parses it. Two
|
|
104
|
+
different things were being conflated:
|
|
105
|
+
|
|
106
|
+
- **invalid** — an unquoted `a: b: c`, a trailing `:`. PyYAML raises; the map is
|
|
107
|
+
still emptied, unchanged.
|
|
108
|
+
- **valid but not modelled here** — a flow sequence or mapping, a block scalar,
|
|
109
|
+
an anchor. PyYAML parses these. Only the KEY is beyond the reader now; the
|
|
110
|
+
document survives.
|
|
111
|
+
|
|
112
|
+
**One identity change to know about.** A document that declares `sor_id:`
|
|
113
|
+
_alongside_ such a value previously had that override silently dropped, so its
|
|
114
|
+
stable_id fell back to the path. The override now stands, on both surfaces
|
|
115
|
+
together — so re-ingesting changes the stable_id of exactly those documents, and
|
|
116
|
+
any takedown row keyed on the old path-derived id must be re-pointed. The site
|
|
117
|
+
and the kernel change in step, which is the property `stable-id-conformance`
|
|
118
|
+
exists to hold.
|
|
119
|
+
|
|
120
|
+
One governance guard gets quieter and no weaker: ingest used to REFUSE a
|
|
121
|
+
document declaring `visibility:` beside a flow list, because the map was emptied
|
|
122
|
+
and the tier silently defaulted. The cause is gone, so it ingests with the right
|
|
123
|
+
visibility; the refusal still stands for frontmatter PyYAML genuinely rejects.
|
|
124
|
+
|
|
125
|
+
- d4334c7: A quiz no longer swallows the explanation that precedes it
|
|
126
|
+
|
|
127
|
+
The previous release moved navigation from a length test to a shape test, so a
|
|
128
|
+
short fact stopped being mistaken for a link list. The rule that decides whether
|
|
129
|
+
a whole section is _a widget_ — a quiz, a slide embed — was left on the old
|
|
130
|
+
threshold: under 250 characters of teaching before the widget, and the entire
|
|
131
|
+
section was labelled `assessment` or `embed`, neither of which any search
|
|
132
|
+
returns.
|
|
133
|
+
|
|
134
|
+
So a section carrying a complete 180-character explanation followed by a
|
|
135
|
+
knowledge check lost the explanation too. Same defect as the last one, one path
|
|
136
|
+
over.
|
|
137
|
+
|
|
138
|
+
Both paths now ask the same question: is what comes BEFORE the widget actually
|
|
139
|
+
navigation-shaped? A heading with only a quiz under it is still a quiz. A link
|
|
140
|
+
list before a quiz is still a quiz. An explanation before a quiz is an
|
|
141
|
+
explanation, and stays searchable.
|
|
142
|
+
|
|
143
|
+
Found by ingesting a real 81-document curriculum corpus, where 610 chunks landed
|
|
144
|
+
as `assessment` and 186 as `embed` — together 79% of everything unsearchable in
|
|
145
|
+
that record.
|
|
146
|
+
|
|
147
|
+
`CHUNK_POLICY` moves to v7 (persisted provenance; the labels it names changed),
|
|
148
|
+
and `NAV_MAX_CHARS` is deleted — nothing reads it now. **Re-run `ksor ingest` to
|
|
149
|
+
pick this up**; unchanged content is not re-embedded.
|
|
150
|
+
|
|
3
151
|
## 0.0.15
|
|
4
152
|
|
|
5
153
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -7,7 +7,6 @@ import { z } from "zod";
|
|
|
7
7
|
import pg from "pg";
|
|
8
8
|
import path, { basename, dirname, join, resolve, sep } from "node:path";
|
|
9
9
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
-
import { GoogleGenAI } from "@google/genai";
|
|
11
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
11
|
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
13
12
|
import { serve } from "@hono/node-server";
|
|
@@ -16,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
15
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
16
|
import { parseArgs } from "node:util";
|
|
18
17
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
18
|
+
//#region ../content-gateway/dist/main-BtKmcm72.mjs
|
|
20
19
|
/**
|
|
21
20
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
22
21
|
*
|
|
@@ -430,7 +429,7 @@ const EMBED_DIM$1 = 1536;
|
|
|
430
429
|
const EMBED_TASK_DOCUMENT$1 = "RETRIEVAL_DOCUMENT";
|
|
431
430
|
const EMBED_TASK_QUERY$1 = "RETRIEVAL_QUERY";
|
|
432
431
|
/** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
|
|
433
|
-
const CHUNK_POLICY$1 = "heading-aware-1500-content-only-
|
|
432
|
+
const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v7";
|
|
434
433
|
/**
|
|
435
434
|
* The kernel's view of `instance.md` (adapted from oracle SC/instance.py
|
|
436
435
|
* under decision 11 — an adaptation, not a port: ksor has ONE instance file
|
|
@@ -1526,8 +1525,58 @@ var FakeEmbeddingProvider$1 = class {
|
|
|
1526
1525
|
}
|
|
1527
1526
|
reset() {}
|
|
1528
1527
|
};
|
|
1528
|
+
const DEFAULT_BASE$1 = "https://generativelanguage.googleapis.com/v1beta";
|
|
1529
1529
|
/**
|
|
1530
|
-
*
|
|
1530
|
+
* An HTTP-shaped failure carrying the status the retry classifier reads.
|
|
1531
|
+
*
|
|
1532
|
+
* `isRetryable` in `gemini.ts` asks for a numeric `status` and nothing else, by
|
|
1533
|
+
* design — it was written to survive SDK refactors. This keeps that contract
|
|
1534
|
+
* when the SDK is gone.
|
|
1535
|
+
*/
|
|
1536
|
+
var GeminiHttpError$1 = class extends Error {
|
|
1537
|
+
status;
|
|
1538
|
+
constructor(status, detail) {
|
|
1539
|
+
super(`Gemini API error ${status}: ${detail}`);
|
|
1540
|
+
this.name = "GeminiHttpError";
|
|
1541
|
+
this.status = status;
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
/** One POST, with the key in a HEADER — never the query string, which is logged. */
|
|
1545
|
+
async function post$1(opts, apiKey, path, body, timeoutMs) {
|
|
1546
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE$1}${path}`, {
|
|
1547
|
+
method: "POST",
|
|
1548
|
+
headers: {
|
|
1549
|
+
"x-goog-api-key": apiKey,
|
|
1550
|
+
"content-type": "application/json"
|
|
1551
|
+
},
|
|
1552
|
+
body: JSON.stringify(body),
|
|
1553
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1554
|
+
});
|
|
1555
|
+
const text = await res.text();
|
|
1556
|
+
if (!res.ok) {
|
|
1557
|
+
let detail = text.slice(0, 300);
|
|
1558
|
+
try {
|
|
1559
|
+
const message = JSON.parse(text).error?.message;
|
|
1560
|
+
if (typeof message === "string") detail = message;
|
|
1561
|
+
} catch {}
|
|
1562
|
+
throw new GeminiHttpError$1(res.status, detail);
|
|
1563
|
+
}
|
|
1564
|
+
return JSON.parse(text);
|
|
1565
|
+
}
|
|
1566
|
+
/** The embedding half of the slice, spoken over `batchEmbedContents`. */
|
|
1567
|
+
function geminiRestEmbedClient$1(apiKey, opts = {}) {
|
|
1568
|
+
return { models: { async embedContent(params) {
|
|
1569
|
+
const payload = { requests: params.contents.map((text) => ({
|
|
1570
|
+
model: `models/${params.model}`,
|
|
1571
|
+
content: { parts: [{ text }] },
|
|
1572
|
+
taskType: params.config.taskType,
|
|
1573
|
+
outputDimensionality: params.config.outputDimensionality
|
|
1574
|
+
})) };
|
|
1575
|
+
return { embeddings: (await post$1(opts, apiKey, `/models/${params.model}:batchEmbedContents`, payload, params.config.httpOptions.timeout)).embeddings ?? [] };
|
|
1576
|
+
} } };
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The Gemini transport — the ONE place the vendor is spoken to (converted
|
|
1531
1580
|
* from the oracle's sor_content/lib/providers/gemini.py; decision 6).
|
|
1532
1581
|
* Identity (model, dim, task labels) is CONSTRUCTOR-INJECTED — this module
|
|
1533
1582
|
* never imports config, so the same adapter serves any Gemini embedding
|
|
@@ -1547,7 +1596,7 @@ var FakeEmbeddingProvider$1 = class {
|
|
|
1547
1596
|
* clock. (The oracle's one divergence — a sync query-intent embed keeping
|
|
1548
1597
|
* the batch clock, an eval-harness case — has no TS call site.)
|
|
1549
1598
|
* - The oracle's "has been closed" stale-client RuntimeError predicate is a
|
|
1550
|
-
* Python-SDK failure mode with no
|
|
1599
|
+
* Python-SDK failure mode with no JS equivalent; `reset()`
|
|
1551
1600
|
* keeps its drop-never-close contract regardless.
|
|
1552
1601
|
*/
|
|
1553
1602
|
function httpStatusOf$1(exc) {
|
|
@@ -1601,7 +1650,7 @@ var GeminiEmbeddingProvider$1 = class {
|
|
|
1601
1650
|
this.queryTaskLabel = opts.queryTaskLabel;
|
|
1602
1651
|
this.documentTimeoutMs = Math.trunc(opts.documentTimeoutS * 1e3);
|
|
1603
1652
|
this.queryTimeoutMs = Math.trunc(opts.queryTimeoutS * 1e3);
|
|
1604
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
1653
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestEmbedClient$1(opts.apiKey));
|
|
1605
1654
|
}
|
|
1606
1655
|
get recipe() {
|
|
1607
1656
|
return `${this.modelId}/d${this.dim}/${this.documentTaskLabel}`;
|
|
@@ -4452,7 +4501,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4452
4501
|
throw lastError;
|
|
4453
4502
|
}
|
|
4454
4503
|
//#endregion
|
|
4455
|
-
//#region ../content/dist/commands-
|
|
4504
|
+
//#region ../content/dist/commands-CXqK2c2f.mjs
|
|
4456
4505
|
/**
|
|
4457
4506
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4458
4507
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -4467,7 +4516,7 @@ const EMBED_DIM = 1536;
|
|
|
4467
4516
|
const EMBED_TASK_DOCUMENT = "RETRIEVAL_DOCUMENT";
|
|
4468
4517
|
const EMBED_TASK_QUERY = "RETRIEVAL_QUERY";
|
|
4469
4518
|
/** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
|
|
4470
|
-
const CHUNK_POLICY = "heading-aware-1500-content-only-
|
|
4519
|
+
const CHUNK_POLICY = "heading-aware-1500-content-only-v7";
|
|
4471
4520
|
const MAX_CHARS = 1500;
|
|
4472
4521
|
/** < Gemini's 2048-token embed input. */
|
|
4473
4522
|
const HARD_MAX_CHARS = 4e3;
|
|
@@ -5423,8 +5472,72 @@ var FakeEmbeddingProvider = class {
|
|
|
5423
5472
|
}
|
|
5424
5473
|
reset() {}
|
|
5425
5474
|
};
|
|
5475
|
+
const DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
5426
5476
|
/**
|
|
5427
|
-
*
|
|
5477
|
+
* An HTTP-shaped failure carrying the status the retry classifier reads.
|
|
5478
|
+
*
|
|
5479
|
+
* `isRetryable` in `gemini.ts` asks for a numeric `status` and nothing else, by
|
|
5480
|
+
* design — it was written to survive SDK refactors. This keeps that contract
|
|
5481
|
+
* when the SDK is gone.
|
|
5482
|
+
*/
|
|
5483
|
+
var GeminiHttpError = class extends Error {
|
|
5484
|
+
status;
|
|
5485
|
+
constructor(status, detail) {
|
|
5486
|
+
super(`Gemini API error ${status}: ${detail}`);
|
|
5487
|
+
this.name = "GeminiHttpError";
|
|
5488
|
+
this.status = status;
|
|
5489
|
+
}
|
|
5490
|
+
};
|
|
5491
|
+
/** One POST, with the key in a HEADER — never the query string, which is logged. */
|
|
5492
|
+
async function post(opts, apiKey, path, body, timeoutMs) {
|
|
5493
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE}${path}`, {
|
|
5494
|
+
method: "POST",
|
|
5495
|
+
headers: {
|
|
5496
|
+
"x-goog-api-key": apiKey,
|
|
5497
|
+
"content-type": "application/json"
|
|
5498
|
+
},
|
|
5499
|
+
body: JSON.stringify(body),
|
|
5500
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
5501
|
+
});
|
|
5502
|
+
const text = await res.text();
|
|
5503
|
+
if (!res.ok) {
|
|
5504
|
+
let detail = text.slice(0, 300);
|
|
5505
|
+
try {
|
|
5506
|
+
const message = JSON.parse(text).error?.message;
|
|
5507
|
+
if (typeof message === "string") detail = message;
|
|
5508
|
+
} catch {}
|
|
5509
|
+
throw new GeminiHttpError(res.status, detail);
|
|
5510
|
+
}
|
|
5511
|
+
return JSON.parse(text);
|
|
5512
|
+
}
|
|
5513
|
+
/** The embedding half of the slice, spoken over `batchEmbedContents`. */
|
|
5514
|
+
function geminiRestEmbedClient(apiKey, opts = {}) {
|
|
5515
|
+
return { models: { async embedContent(params) {
|
|
5516
|
+
const payload = { requests: params.contents.map((text) => ({
|
|
5517
|
+
model: `models/${params.model}`,
|
|
5518
|
+
content: { parts: [{ text }] },
|
|
5519
|
+
taskType: params.config.taskType,
|
|
5520
|
+
outputDimensionality: params.config.outputDimensionality
|
|
5521
|
+
})) };
|
|
5522
|
+
return { embeddings: (await post(opts, apiKey, `/models/${params.model}:batchEmbedContents`, payload, params.config.httpOptions.timeout)).embeddings ?? [] };
|
|
5523
|
+
} } };
|
|
5524
|
+
}
|
|
5525
|
+
/** The text half of the slice, spoken over `generateContent`. */
|
|
5526
|
+
function geminiRestTextClient(apiKey, opts = {}) {
|
|
5527
|
+
return { models: { async generateContent(params) {
|
|
5528
|
+
const payload = {
|
|
5529
|
+
contents: [{ parts: [{ text: params.contents }] }],
|
|
5530
|
+
generationConfig: {
|
|
5531
|
+
temperature: params.config.temperature,
|
|
5532
|
+
maxOutputTokens: params.config.maxOutputTokens,
|
|
5533
|
+
thinkingConfig: params.config.thinkingConfig
|
|
5534
|
+
}
|
|
5535
|
+
};
|
|
5536
|
+
return { text: ((await post(opts, apiKey, `/models/${params.model}:generateContent`, payload, 12e4)).candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join("") };
|
|
5537
|
+
} } };
|
|
5538
|
+
}
|
|
5539
|
+
/**
|
|
5540
|
+
* The Gemini transport — the ONE place the vendor is spoken to (converted
|
|
5428
5541
|
* from the oracle's sor_content/lib/providers/gemini.py; decision 6).
|
|
5429
5542
|
* Identity (model, dim, task labels) is CONSTRUCTOR-INJECTED — this module
|
|
5430
5543
|
* never imports config, so the same adapter serves any Gemini embedding
|
|
@@ -5444,7 +5557,7 @@ var FakeEmbeddingProvider = class {
|
|
|
5444
5557
|
* clock. (The oracle's one divergence — a sync query-intent embed keeping
|
|
5445
5558
|
* the batch clock, an eval-harness case — has no TS call site.)
|
|
5446
5559
|
* - The oracle's "has been closed" stale-client RuntimeError predicate is a
|
|
5447
|
-
* Python-SDK failure mode with no
|
|
5560
|
+
* Python-SDK failure mode with no JS equivalent; `reset()`
|
|
5448
5561
|
* keeps its drop-never-close contract regardless.
|
|
5449
5562
|
*/
|
|
5450
5563
|
function httpStatusOf(exc) {
|
|
@@ -5498,7 +5611,7 @@ var GeminiEmbeddingProvider = class {
|
|
|
5498
5611
|
this.queryTaskLabel = opts.queryTaskLabel;
|
|
5499
5612
|
this.documentTimeoutMs = Math.trunc(opts.documentTimeoutS * 1e3);
|
|
5500
5613
|
this.queryTimeoutMs = Math.trunc(opts.queryTimeoutS * 1e3);
|
|
5501
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
5614
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestEmbedClient(opts.apiKey));
|
|
5502
5615
|
}
|
|
5503
5616
|
get recipe() {
|
|
5504
5617
|
return `${this.modelId}/d${this.dim}/${this.documentTaskLabel}`;
|
|
@@ -5541,7 +5654,7 @@ var GeminiTextGenerator = class {
|
|
|
5541
5654
|
client = null;
|
|
5542
5655
|
constructor(opts) {
|
|
5543
5656
|
this.model = opts.model ?? "gemini-2.5-flash";
|
|
5544
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
5657
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestTextClient(opts.apiKey));
|
|
5545
5658
|
}
|
|
5546
5659
|
getClient() {
|
|
5547
5660
|
this.client ??= this.clientFactory();
|
|
@@ -7004,6 +7117,17 @@ function buildManifestFromTree(root, options) {
|
|
|
7004
7117
|
const files = [];
|
|
7005
7118
|
const sources = /* @__PURE__ */ new Map();
|
|
7006
7119
|
const skipped = [];
|
|
7120
|
+
/** foreign ordering key -> the documents that declare it and no `order:`. */
|
|
7121
|
+
const foreignOrder = /* @__PURE__ */ new Map();
|
|
7122
|
+
const noteForeignOrder = (meta, path) => {
|
|
7123
|
+
if (meta["order"] !== void 0 && meta["order"] !== null) return;
|
|
7124
|
+
for (const key of FOREIGN_ORDER_KEYS) {
|
|
7125
|
+
if (meta[key] === void 0 || meta[key] === null) continue;
|
|
7126
|
+
const seen = foreignOrder.get(key) ?? [];
|
|
7127
|
+
seen.push(path);
|
|
7128
|
+
foreignOrder.set(key, seen);
|
|
7129
|
+
}
|
|
7130
|
+
};
|
|
7007
7131
|
const fullPath = (relSegs, name) => `${rootPath}/${[...relSegs, name].join("/")}`;
|
|
7008
7132
|
const addFile = (nodeSid, fileSegs) => {
|
|
7009
7133
|
const rel = fileSegs.join("/");
|
|
@@ -7028,8 +7152,10 @@ function buildManifestFromTree(root, options) {
|
|
|
7028
7152
|
continue;
|
|
7029
7153
|
}
|
|
7030
7154
|
if (INDEX_NAMES.includes(f.name)) continue;
|
|
7155
|
+
const fileMeta = frontmatterMeta(f.text);
|
|
7156
|
+
noteForeignOrder(fileMeta, fullPath(relSegs, f.name));
|
|
7031
7157
|
ordered.push({
|
|
7032
|
-
order: orderValue(
|
|
7158
|
+
order: orderValue(fileMeta["order"]),
|
|
7033
7159
|
tie: tieKey(f.name),
|
|
7034
7160
|
entry: f
|
|
7035
7161
|
});
|
|
@@ -7041,6 +7167,7 @@ function buildManifestFromTree(root, options) {
|
|
|
7041
7167
|
}
|
|
7042
7168
|
const index = indexOf(d, fullPath(relSegs, d.name));
|
|
7043
7169
|
const dirMeta = index === null ? {} : frontmatterMeta(index.text);
|
|
7170
|
+
if (index !== null) noteForeignOrder(dirMeta, fullPath(relSegs, `${d.name}/${index.name}`));
|
|
7044
7171
|
ordered.push({
|
|
7045
7172
|
order: orderValue(dirMeta["order"]),
|
|
7046
7173
|
tie: tieKey(d.name),
|
|
@@ -7100,6 +7227,12 @@ function buildManifestFromTree(root, options) {
|
|
|
7100
7227
|
}
|
|
7101
7228
|
walk(root, [], null);
|
|
7102
7229
|
for (const s of skipped) onSkip(`plain-tree: skipped ${s}`);
|
|
7230
|
+
for (const [key, paths] of foreignOrder) {
|
|
7231
|
+
const rel = paths.map((x) => x.startsWith(`${rootPath}/`) ? x.slice(rootPath.length + 1) : x);
|
|
7232
|
+
const shown = rel.slice(0, 3).join(", ");
|
|
7233
|
+
const more = rel.length - Math.min(3, rel.length);
|
|
7234
|
+
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.`);
|
|
7235
|
+
}
|
|
7103
7236
|
if (files.length === 0) throw new ManifestError(`plain-tree root ${rootPath} contains no Markdown`);
|
|
7104
7237
|
const manifest = {
|
|
7105
7238
|
format: 1,
|
|
@@ -7186,6 +7319,22 @@ function codePointCompare(a, b) {
|
|
|
7186
7319
|
}
|
|
7187
7320
|
return as.length - bs.length;
|
|
7188
7321
|
}
|
|
7322
|
+
/**
|
|
7323
|
+
* Ordering keys OTHER ecosystems read, which this record does not.
|
|
7324
|
+
*
|
|
7325
|
+
* Reading order here is the governed `order:` key alone (decision 9 retired the
|
|
7326
|
+
* predecessor's Docusaurus keys; the MCP door had been reading them). But a
|
|
7327
|
+
* corpus arriving from Docusaurus, Hugo or Jekyll carries its own, and ignoring
|
|
7328
|
+
* one silently produces a WRONG order rather than a missing one — filename
|
|
7329
|
+
* order, served to `llms.txt`, the sidebar and the `outline` tool alike. Found
|
|
7330
|
+
* on a real 81-document book where 73 files declared `sidebar_position` (#74).
|
|
7331
|
+
*/
|
|
7332
|
+
const FOREIGN_ORDER_KEYS = [
|
|
7333
|
+
"sidebar_position",
|
|
7334
|
+
"position",
|
|
7335
|
+
"weight",
|
|
7336
|
+
"nav_order"
|
|
7337
|
+
];
|
|
7189
7338
|
/** Re-exported so every reader of a document agrees where its frontmatter ENDS. */
|
|
7190
7339
|
const FRONTMATTER$1 = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
|
|
7191
7340
|
const YAML_BOOLS = {
|
|
@@ -7209,8 +7358,12 @@ const YAML_BOOLS = {
|
|
|
7209
7358
|
OFF: false
|
|
7210
7359
|
};
|
|
7211
7360
|
/**
|
|
7212
|
-
* Minimal PyYAML-compatible frontmatter reader
|
|
7213
|
-
* adapter consumes
|
|
7361
|
+
* Minimal PyYAML-compatible frontmatter reader. It parses every top-level
|
|
7362
|
+
* scalar; the adapter consumes `title`, `order` and `sor_id`, and reads the rest
|
|
7363
|
+
* only to WARN about them (see FOREIGN_ORDER_KEYS). The wording here named
|
|
7364
|
+
* `position` and `sidebar_position` until now, which is what this adapter read
|
|
7365
|
+
* before ordering became one governed key — the keys it names are the ones it
|
|
7366
|
+
* stopped reading. The
|
|
7214
7367
|
* kernel discards every other frontmatter key at build time (taxonomy comes
|
|
7215
7368
|
* from the manifest), so a YAML dependency would buy nothing (guard rule 5).
|
|
7216
7369
|
* Scope, deliberately narrow pending a shared markdown module: top-level
|
|
@@ -7268,11 +7421,11 @@ function scalarValue(raw) {
|
|
|
7268
7421
|
ok: true,
|
|
7269
7422
|
value: Number.parseFloat(plain.replaceAll("_", ""))
|
|
7270
7423
|
};
|
|
7271
|
-
if (
|
|
7272
|
-
ok:
|
|
7424
|
+
if (/^[|>&*!{[]/.test(plain)) return {
|
|
7425
|
+
ok: true,
|
|
7273
7426
|
value: null
|
|
7274
7427
|
};
|
|
7275
|
-
if (
|
|
7428
|
+
if (/:[ \t]/.test(plain) || plain.endsWith(":")) return {
|
|
7276
7429
|
ok: false,
|
|
7277
7430
|
value: null
|
|
7278
7431
|
};
|
|
@@ -7895,7 +8048,7 @@ const NAV_LINE = /^(?:[-*+]\s+|\d+[.)]\s+)?(?:\[[^\]]*\]\([^)]*\)|<https?:\/\/[^
|
|
|
7895
8048
|
* Is this segment NAVIGATION — a thing that points at content rather than
|
|
7896
8049
|
* being content?
|
|
7897
8050
|
*
|
|
7898
|
-
* The oracle answered this with length: under
|
|
8051
|
+
* The oracle answered this with length: under 250 code points meant nav.
|
|
7899
8052
|
* On the curriculum corpus it was tuned against, that proxy holds — a short
|
|
7900
8053
|
* segment there really is a link list. On a handbook it inverts, because a
|
|
7901
8054
|
* handbook's most valuable statements are its shortest ("Six months, with a
|
|
@@ -7919,22 +8072,40 @@ function isNavShaped(content) {
|
|
|
7919
8072
|
if (lines.filter((ln) => NAV_LINE.test(ln)).length * 2 > lines.length) return true;
|
|
7920
8073
|
return cpLen(lines.filter((ln) => !NAV_LINE.test(ln)).join(" ")) < 24;
|
|
7921
8074
|
}
|
|
8075
|
+
/**
|
|
8076
|
+
* Does a line-leading widget DOMINATE this span?
|
|
8077
|
+
*
|
|
8078
|
+
* The widget regexes match an opening tag only, so the tag's position is where
|
|
8079
|
+
* teaching stops and markup begins. The question is therefore about what comes
|
|
8080
|
+
* BEFORE it: if that is navigation-shaped, the span is the widget; if it is real
|
|
8081
|
+
* explanation, the widget is a minority of a teaching passage.
|
|
8082
|
+
*
|
|
8083
|
+
* This used to be a length test — 250 characters of teaching body before the
|
|
8084
|
+
* widget and the whole span became `assessment`, which no retrieval arm returns.
|
|
8085
|
+
* #55 moved navigation from length to shape and left this path behind, so a
|
|
8086
|
+
* section carrying 180 characters of real explanation before a `<Quiz>` lost the
|
|
8087
|
+
* explanation with it (issue #75).
|
|
8088
|
+
*/
|
|
8089
|
+
function dominantWidget(span) {
|
|
8090
|
+
for (const [re, label] of [[JSX_ASSESS, "assessment"], [JSX_EMBED, "embed"]]) {
|
|
8091
|
+
const m = re.exec(span);
|
|
8092
|
+
if (m !== null && isNavShaped(span.slice(0, m.index))) return label;
|
|
8093
|
+
}
|
|
8094
|
+
return null;
|
|
8095
|
+
}
|
|
7922
8096
|
function classify(content, headingPath) {
|
|
7923
|
-
|
|
8097
|
+
const widget = dominantWidget(content);
|
|
8098
|
+
if (widget !== null) return widget;
|
|
7924
8099
|
const leaf = headingPath.length > 0 ? headingPath[headingPath.length - 1] : "";
|
|
7925
|
-
if (
|
|
8100
|
+
if (content.includes("docs.google.com/presentation") || leaf.includes("Teaching Aid")) return "embed";
|
|
7926
8101
|
if (isNavShaped(content)) return "nav";
|
|
7927
8102
|
return "prose";
|
|
7928
8103
|
}
|
|
7929
|
-
/** A segment
|
|
7930
|
-
*
|
|
7931
|
-
*
|
|
8104
|
+
/** A segment dominated by a line-leading widget labels EVERY fragment — a
|
|
8105
|
+
* char-sliced widget must not leak as prose. Same question as `classify`, asked
|
|
8106
|
+
* of the whole segment rather than one piece of it. */
|
|
7932
8107
|
function segmentMarkerType(span) {
|
|
7933
|
-
|
|
7934
|
-
const m = re.exec(span);
|
|
7935
|
-
if (m !== null && cpLen(teachingBody(span.slice(0, m.index))) < 250) return label;
|
|
7936
|
-
}
|
|
7937
|
-
return null;
|
|
8108
|
+
return dominantWidget(span);
|
|
7938
8109
|
}
|
|
7939
8110
|
/** Walk lines; headings count only OUTSIDE fences; every line lands in exactly
|
|
7940
8111
|
* one segment (byte-exact). H1 records a title but never enters the path. */
|
|
@@ -8956,11 +9127,11 @@ async function ingestCommand(args) {
|
|
|
8956
9127
|
process.stdout.write(`ingest: generation ${report.generation} — ${report.nodes} nodes, ${report.chunks} chunks; embedded ${report.embedded}, carried ${report.carried}, failed ${report.failed}\n`);
|
|
8957
9128
|
if (report.unsearchable > 0) {
|
|
8958
9129
|
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}%)
|
|
9130
|
+
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
9131
|
if (report.unsearchableSources.length > 0) {
|
|
8961
9132
|
const named = report.unsearchableSources.slice(0, 10).join(", ");
|
|
8962
9133
|
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;
|
|
9134
|
+
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
9135
|
`);
|
|
8965
9136
|
}
|
|
8966
9137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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",
|
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
"provenance": true
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@google/genai": "^2.17.1",
|
|
55
54
|
"@hono/node-server": "2.1.1",
|
|
56
55
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
57
56
|
"@types/pg": "^8.21.0",
|
|
@@ -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
|
}
|