@panaversity/ksor 0.0.19 → 0.0.21

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +501 -0
  2. package/dist/cli.mjs +99 -19
  3. package/docs/authorization.md +197 -0
  4. package/docs/index.md +4 -0
  5. package/package.json +1 -1
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
  8. package/templates/scaffold/AGENTS.md +52 -4
  9. package/templates/scaffold/instance.md +28 -20
  10. package/templates/scaffold/knowledge/governance-ladder.md +36 -0
  11. package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
  12. package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
  13. package/templates/scaffold/knowledge/surfaces/index.md +21 -0
  14. package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
  15. package/templates/scaffold/pnpm-lock.yaml +1198 -228
  16. package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
  17. package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
  18. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
  19. package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
  20. package/templates/scaffold/system/site/app/global.css +552 -9
  21. package/templates/scaffold/system/site/app/layout.tsx +23 -4
  22. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
  23. package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
  24. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
  25. package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
  26. package/templates/scaffold/system/site/components/governance.tsx +262 -0
  27. package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
  28. package/templates/scaffold/system/site/components/record-index.tsx +120 -0
  29. package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
  30. package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
  31. package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
  32. package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
  33. package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
  34. package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
  35. package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
  36. package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
  37. package/templates/scaffold/system/site/components.json +25 -0
  38. package/templates/scaffold/system/site/lib/governance.ts +432 -0
  39. package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
  40. package/templates/scaffold/system/site/lib/shared.ts +38 -0
  41. package/templates/scaffold/system/site/lib/source.ts +221 -5
  42. package/templates/scaffold/system/site/lib/utils.ts +6 -0
  43. package/templates/scaffold/system/site/package.json +9 -3
  44. package/templates/scaffold/knowledge/example.md +0 -23
package/dist/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
15
15
  import { execFileSync, spawnSync } from "node:child_process";
16
16
  import { parseArgs } from "node:util";
17
17
  import { readFile, readdir, stat } from "node:fs/promises";
18
- //#region ../content-gateway/dist/main-DDeyGVjK.mjs
18
+ //#region ../content-gateway/dist/main-DKA8sPwv.mjs
19
19
  /**
20
20
  * A connection could not be ESTABLISHED in time — retryable.
21
21
  *
@@ -3508,6 +3508,27 @@ const POS_TTL_S = 60;
3508
3508
  function isBadToken(err) {
3509
3509
  return err instanceof errors.JWTExpired || err instanceof errors.JWTClaimValidationFailed || err instanceof errors.JWTInvalid || err instanceof errors.JWSInvalid || err instanceof errors.JWSSignatureVerificationFailed || err instanceof errors.JOSEAlgNotAllowed || err instanceof errors.JOSENotSupported;
3510
3510
  }
3511
+ /**
3512
+ * The `iss` a token CLAIMS, read without verifying anything.
3513
+ *
3514
+ * Sound for exactly one purpose: REFUSING. A token that passes this check still
3515
+ * has its signature verified in full, so an attacker gains nothing by lying here
3516
+ * — the worst they achieve is being refused for a different reason. It must
3517
+ * never be used to admit anything.
3518
+ *
3519
+ * Returns null when the payload is not readable JSON, which sends the token down
3520
+ * the ordinary path rather than inventing a verdict about it.
3521
+ */
3522
+ function claimedIssuer(token) {
3523
+ try {
3524
+ const payload = token.split(".")[1];
3525
+ if (payload === void 0 || payload === "") return null;
3526
+ const iss = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).iss;
3527
+ return typeof iss === "string" && iss !== "" ? iss : null;
3528
+ } catch {
3529
+ return null;
3530
+ }
3531
+ }
3511
3532
  function describeError(err) {
3512
3533
  return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
3513
3534
  }
@@ -3571,6 +3592,13 @@ function createVerify(config, deps, jwksOf) {
3571
3592
  if (hit !== void 0 && hit.until > now()) return hit.identity;
3572
3593
  let claims = null;
3573
3594
  if (token.split(".").length === 3) {
3595
+ if (config.issuer !== null) {
3596
+ const claimed = claimedIssuer(token);
3597
+ if (claimed !== null && claimed !== config.issuer) {
3598
+ reject(key);
3599
+ throw new TokenVerifyError(`token iss ${JSON.stringify(claimed)} is not this record's authorization server ${JSON.stringify(config.issuer)}`, { transient: false });
3600
+ }
3601
+ }
3574
3602
  try {
3575
3603
  claims = await verifyJwt(token);
3576
3604
  } catch (err) {
@@ -4546,7 +4574,7 @@ async function withPgRetry(op, options = {}) {
4546
4574
  throw lastError;
4547
4575
  }
4548
4576
  //#endregion
4549
- //#region ../content/dist/commands-DcPJJlNb.mjs
4577
+ //#region ../content/dist/commands-FLQD4HUi.mjs
4550
4578
  /**
4551
4579
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4552
4580
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -6562,19 +6590,42 @@ async function allocateRun(client, opts) {
6562
6590
  *
6563
6591
  * Returns 0 when there is no complete embedded generation — the first ingest.
6564
6592
  */
6565
- async function bestCarrySource(client, opts) {
6566
- const gen = (await client.query(`
6567
- SELECT max(c.generation) AS gen FROM chunks c
6568
- JOIN ingestion_runs r ON r.tenant_id = c.tenant_id AND r.generation = c.generation
6569
- WHERE c.tenant_id = $1 AND r.corpus_id = $2
6570
- AND r.state IN ('ready', 'active', 'retired')
6571
- AND c.generation <> $3 AND c.embedding_status = 'embedded'
6593
+ /**
6594
+ * Every generation whose vectors this build may copy, best source FIRST.
6595
+ *
6596
+ * Ordered by how much the source has been vetted, then by recency:
6597
+ *
6598
+ * 1. complete runs (`ready` / `active` / `retired`), newest first
6599
+ * 2. ABANDONED runs (`building`), newest first
6600
+ *
6601
+ * The second group used to be excluded outright, and that threw away work an
6602
+ * operator had already paid for: a killed `ksor ingest` leaves its generation in
6603
+ * `building`, so the rerun carried NOTHING and re-embedded the whole corpus.
6604
+ * Reproduced live — an 81-document book killed at 4,736 of 6,963 chunks, whose
6605
+ * rerun reported `carried 0, pending 6963` (issue #97).
6606
+ *
6607
+ * Nothing about an abandoned run makes its vectors wrong. An embedding is a pure
6608
+ * function of (embed input, model); the match key in `carryForward` establishes
6609
+ * identity on its own; and carry only ever fills `pending` rows, so a later pass
6610
+ * can never overwrite an earlier, better-vetted one. The run's STATE therefore
6611
+ * decides priority, not eligibility — which is what ordering expresses and
6612
+ * exclusion could not.
6613
+ */
6614
+ async function carrySources(client, opts) {
6615
+ return (await client.query(`
6616
+ SELECT DISTINCT c.generation AS gen,
6617
+ CASE WHEN r.state IN ('ready','active','retired') THEN 0 ELSE 1 END AS rank
6618
+ FROM chunks c
6619
+ JOIN ingestion_runs r ON r.tenant_id = c.tenant_id AND r.generation = c.generation
6620
+ WHERE c.tenant_id = $1 AND r.corpus_id = $2
6621
+ AND r.state IN ('ready', 'active', 'retired', 'building')
6622
+ AND c.generation <> $3 AND c.embedding_status = 'embedded'
6623
+ ORDER BY rank, gen DESC
6572
6624
  `, [
6573
6625
  opts.tenantId,
6574
6626
  opts.corpusId,
6575
6627
  opts.excludeGeneration
6576
- ])).rows[0]?.gen ?? null;
6577
- return gen === null ? 0 : Number(gen);
6628
+ ])).rows.map((r) => Number(r.gen));
6578
6629
  }
6579
6630
  /**
6580
6631
  * Copy embeddings for chunks whose ENTIRE embed input is unchanged (hash +
@@ -8530,17 +8581,19 @@ async function buildStructure(client, opts) {
8530
8581
  fromGeneration: active,
8531
8582
  modelId
8532
8583
  });
8533
- const newest = await bestCarrySource(client, {
8584
+ for (const source of await carrySources(client, {
8534
8585
  tenantId,
8535
8586
  corpusId: opts.corpusId,
8536
8587
  excludeGeneration: generation
8537
- });
8538
- if (newest !== 0 && newest !== active) carried += await carryForward(client, {
8539
- tenantId,
8540
- generation,
8541
- fromGeneration: newest,
8542
- modelId
8543
- });
8588
+ })) {
8589
+ if (source === active) continue;
8590
+ carried += await carryForward(client, {
8591
+ tenantId,
8592
+ generation,
8593
+ fromGeneration: source,
8594
+ modelId
8595
+ });
8596
+ }
8544
8597
  const health = await generationHealth(client, {
8545
8598
  tenantId,
8546
8599
  generation
@@ -8852,6 +8905,31 @@ async function buildGeneration(pool, instance, options) {
8852
8905
  function pct(fraction) {
8853
8906
  return `${(fraction * 100).toFixed(0)}%`;
8854
8907
  }
8908
+ /**
8909
+ * Out-of-corpus probes scoring at or above the weakest in-corpus question,
8910
+ * worst first — the ones that decided the verdict. Empty when the measurement
8911
+ * separated, because then nothing held it open.
8912
+ */
8913
+ function overlappingProbes(report) {
8914
+ if (report.separable) return [];
8915
+ const weakest = report.low_tail[0]?.score;
8916
+ if (weakest === void 0) return [];
8917
+ return report.detail.filter((d) => !d.in_corpus && d.score >= weakest).toSorted((a, b) => b.score - a.score);
8918
+ }
8919
+ /**
8920
+ * The guidance itself, or null when there is nothing to say.
8921
+ *
8922
+ * Deliberately names BOTH readings. The overlapping probe is sometimes a
8923
+ * question the record covers — mislabelled, and the measurement is fine once it
8924
+ * moves — and sometimes a genuine near-miss the corpus simply cannot separate,
8925
+ * in which case the floor stays uncalibrated and that is the correct outcome.
8926
+ * Asserting either one alone would send half the readers the wrong way.
8927
+ */
8928
+ function overlapAdvice(report) {
8929
+ const overlapping = overlappingProbes(report);
8930
+ if (overlapping.length === 0) return null;
8931
+ return "these out-of-corpus probes scored at or above your weakest in-corpus question:\n" + overlapping.map((d) => ` ${d.score.toFixed(3)} ${d.query}\n`).join("") + " ^ look at these first. Either the record COVERS one — move it to the\n in-corpus side, because a probe the record answers is not out of corpus\n — or it genuinely does not separate, and the floor stays uncalibrated.\n";
8932
+ }
8855
8933
  /** One transaction (tenant GUC + ingest role): list collectables, then reap each. */
8856
8934
  async function runGc(pool, instance, options = {}) {
8857
8935
  const dryRun = options.dryRun === true;
@@ -9329,6 +9407,8 @@ async function calibrateCommand(args) {
9329
9407
  minChars: values["min-chars"] === void 0 ? void 0 : intFlag("--min-chars", values["min-chars"])
9330
9408
  }));
9331
9409
  process.stdout.write(renderReport(report) + "\n");
9410
+ const advice = overlapAdvice(report);
9411
+ if (advice !== null) process.stdout.write(advice);
9332
9412
  return 0;
9333
9413
  }
9334
9414
  async function grantCommand(args) {
@@ -0,0 +1,197 @@
1
+ ---
2
+ title: Authorization
3
+ status: draft
4
+ ---
5
+
6
+ # Putting the record behind an authorization server
7
+
8
+ `ksor serve` refuses to boot unauthenticated on a public bind. That is the whole
9
+ posture, and it means the last step of a deployment is standing up an
10
+ authorization server and pointing the door at it.
11
+
12
+ This page is two worked recipes, both executed against real servers rather than
13
+ written from their documentation, plus what an agent does to obtain a token. The
14
+ mechanism is standard OAuth 2.0 — nothing here is specific to either product, and
15
+ that is the point: two different implementations are shown because a single one
16
+ proves nothing about neutrality.
17
+
18
+ ## What the door needs
19
+
20
+ Three variables, and one more you should set even though it is optional:
21
+
22
+ | variable | what it is | where the value comes from |
23
+ | ---------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
24
+ | `KSOR_SSO_URL` | your authorization server's base URL | the AS itself; for OIDC it is the issuer, the URL whose `/.well-known/openid-configuration` answers |
25
+ | `KSOR_MCP_RESOURCE_URL` | **this record's** canonical URL — the identifier a token must be audienced at | you choose it; it is the public URL agents reach the door on |
26
+ | `KSOR_JWT_ALLOWED_AUDIENCES` | which audiences are accepted, comma-separated | normally exactly `KSOR_MCP_RESOURCE_URL` |
27
+ | `KSOR_SSO_ISSUER` | the issuer to enforce | the `issuer` field of the AS's discovery document |
28
+
29
+ `KSOR_MCP_RESOURCE_URL` is **not** a place the door fetches anything from. It is
30
+ the name of this resource, in the RFC 8707 sense: a token minted for a different
31
+ resource is refused even when the signature is perfect and the issuer is right.
32
+ That is what stops a token issued for some other service being replayed at your
33
+ record.
34
+
35
+ The door finds the signing keys by discovery, in this order, and says at boot
36
+ which one it used:
37
+
38
+ ```
39
+ 1. KSOR_JWKS_URL you stated the URI outright
40
+ 2. /.well-known/oauth-authorization-server RFC 8414 metadata
41
+ 3. /.well-known/openid-configuration OIDC discovery
42
+ 4. <KSOR_SSO_URL>/api/auth/jwks a vendor default, reported as a GUESS
43
+ ```
44
+
45
+ **Set `KSOR_SSO_ISSUER`.** Without it, a token from a _different_ authorization
46
+ server produces an unknown key id, which is indistinguishable from key-rotation
47
+ lag — so the door answers `503`, the client retries a credential that can never
48
+ work, and a misconfiguration reads as an outage. With the issuer declared, that
49
+ same token is refused `401` before any key is fetched.
50
+
51
+ ## Recipe: Keycloak
52
+
53
+ Run it:
54
+
55
+ ```sh
56
+ docker run -d --name kc -p 8180:8080 \
57
+ -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
58
+ quay.io/keycloak/keycloak:26.0 start-dev
59
+ ```
60
+
61
+ Get an admin token, then create a client for the agent. `client_credentials` is
62
+ the machine-to-machine shape — an agent is not a person and has no browser:
63
+
64
+ ```sh
65
+ KC=http://127.0.0.1:8180
66
+ ADM=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
67
+ -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \
68
+ | jq -r .access_token)
69
+
70
+ curl -s -X POST "$KC/admin/realms/master/clients" -H "Authorization: Bearer $ADM" \
71
+ -H 'Content-Type: application/json' -d '{
72
+ "clientId":"ksor-agent","protocol":"openid-connect","publicClient":false,
73
+ "serviceAccountsEnabled":true,"standardFlowEnabled":false,"secret":"agent-secret"}'
74
+ ```
75
+
76
+ Keycloak does not put your resource in the token's `aud` on its own. Add an
77
+ audience mapper — this is the step people miss, and its absence looks exactly
78
+ like a rejected token:
79
+
80
+ ```sh
81
+ CID=$(curl -s "$KC/admin/realms/master/clients?clientId=ksor-agent" \
82
+ -H "Authorization: Bearer $ADM" | jq -r '.[0].id')
83
+
84
+ curl -s -X POST "$KC/admin/realms/master/clients/$CID/protocol-mappers/models" \
85
+ -H "Authorization: Bearer $ADM" -H 'Content-Type: application/json' -d '{
86
+ "name":"ksor-resource-audience","protocol":"openid-connect",
87
+ "protocolMapper":"oidc-audience-mapper",
88
+ "config":{"included.custom.audience":"https://records.example.com/mcp",
89
+ "access.token.claim":"true"}}'
90
+ ```
91
+
92
+ Point the door at it:
93
+
94
+ ```sh
95
+ export KSOR_SSO_URL=http://127.0.0.1:8180/realms/master
96
+ export KSOR_SSO_ISSUER=http://127.0.0.1:8180/realms/master
97
+ export KSOR_MCP_RESOURCE_URL=https://records.example.com/mcp
98
+ export KSOR_JWT_ALLOWED_AUDIENCES=https://records.example.com/mcp
99
+ ksor serve --instance instance.md
100
+ ```
101
+
102
+ The boot block tells you whether discovery worked:
103
+
104
+ ```
105
+ auth bearer tokens, verified against the record's authorization server
106
+ keys openid-configuration — http://127.0.0.1:8180/realms/master/protocol/openid-connect/certs
107
+ ```
108
+
109
+ If that line says `guess` instead of naming a discovery document, the AS did not
110
+ publish metadata where the door looked, and you should set `KSOR_JWKS_URL`
111
+ yourself rather than rely on the vendor default.
112
+
113
+ ## Recipe: Ory Hydra
114
+
115
+ A different implementation, and a different way of asking for the audience —
116
+ which is the neutrality proof. Hydra takes the RFC 8707 `audience` parameter on
117
+ the token request, so no mapper is involved:
118
+
119
+ ```sh
120
+ docker run -d --name hydra -p 4444:4444 -p 4445:4445 \
121
+ -e DSN=memory -e URLS_SELF_ISSUER=http://127.0.0.1:4444 \
122
+ -e SECRETS_SYSTEM=change-me-0000000000000000000000 \
123
+ -e STRATEGIES_ACCESS_TOKEN=jwt \
124
+ oryd/hydra:v2.2.0 serve all --dev
125
+
126
+ curl -s -X POST http://127.0.0.1:4445/admin/clients -H 'Content-Type: application/json' -d '{
127
+ "client_id":"ksor-agent","client_secret":"agent-secret",
128
+ "grant_types":["client_credentials"],"token_endpoint_auth_method":"client_secret_post",
129
+ "audience":["https://records.example.com/mcp"],"access_token_strategy":"jwt"}'
130
+ ```
131
+
132
+ Only the two SSO variables change:
133
+
134
+ ```sh
135
+ export KSOR_SSO_URL=http://127.0.0.1:4444
136
+ export KSOR_SSO_ISSUER=http://127.0.0.1:4444
137
+ ```
138
+
139
+ Hydra publishes its keys at `/.well-known/jwks.json` rather than Keycloak's
140
+ `/protocol/openid-connect/certs`. Nothing in ksor knows that; discovery reads it
141
+ from the metadata document, which is why the door works against both unmodified.
142
+
143
+ ## What an agent does
144
+
145
+ Ask the token endpoint for a token audienced at the record, then send it as an
146
+ ordinary bearer:
147
+
148
+ ```sh
149
+ # Hydra — the audience is a request parameter
150
+ curl -s -X POST http://127.0.0.1:4444/oauth2/token \
151
+ -d grant_type=client_credentials -d client_id=ksor-agent -d client_secret=agent-secret \
152
+ -d audience=https://records.example.com/mcp
153
+
154
+ # Keycloak — the audience comes from the mapper, so the request is plain
155
+ curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
156
+ -d client_id=ksor-agent -d client_secret=agent-secret -d grant_type=client_credentials
157
+ ```
158
+
159
+ With the MCP TypeScript SDK:
160
+
161
+ ```ts
162
+ const transport = new StreamableHTTPClientTransport(new URL("https://records.example.com/mcp"), {
163
+ requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
164
+ });
165
+ ```
166
+
167
+ A client that does not know where to authenticate can find out: an unauthorized
168
+ request answers `401` with a pointer to this record's metadata, per RFC 9728.
169
+
170
+ ```
171
+ www-authenticate: Bearer resource_metadata="https://records.example.com/.well-known/oauth-protected-resource/mcp"
172
+ ```
173
+
174
+ ## What the door refuses, and how it says so
175
+
176
+ | what you send | answer |
177
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
178
+ | no token | `401`, with the `resource_metadata` pointer |
179
+ | a malformed or unsigned token | `401`, `error="invalid_token"` |
180
+ | a token for a **different resource** | `401` — the audience binding, and the reason it exists |
181
+ | a token from a **different issuer** (with `KSOR_SSO_ISSUER` set) | `401`, before any key is fetched |
182
+ | a token from a different issuer (issuer NOT set) | `503` — indistinguishable from rotation lag, which is why you should set it |
183
+ | an expired token | `401` |
184
+ | a valid token | the record answers, and the answer carries its citations |
185
+
186
+ A genuine key-rotation lag stays a `503` on purpose: it is transient, retrying is
187
+ the right response, and the refusal is never cached — a valid bearer is
188
+ re-admitted the instant the key set catches up.
189
+
190
+ ## Before a public bind
191
+
192
+ - Auth configured as above, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` set
193
+ deliberately — the door will not come up on a public address without one of
194
+ them, and the second is a decision, not a default.
195
+ - `KSOR_ALLOWED_HOSTS` set to the host you serve on.
196
+ - `KSOR_SNAPSHOT_KEYS` shared across every replica. Unset means a key per
197
+ process, so a citation minted by one replica fails on another.
package/docs/index.md CHANGED
@@ -32,6 +32,10 @@ instead of their training memory. The corpus grows with each implemented verb.
32
32
  export the manifest the site build reads), `ksor calibrate` (measure the
33
33
  abstention floor) and `ksor gc` (reap retired generations). Only `ksor dev` and `ksor build` remain designed, not
34
34
  implemented: each prints an honest notice and exits `2`.
35
+ - **[authorization.md](./authorization.md)** — putting the record behind an
36
+ authorization server, with worked recipes for two of them, executed rather
37
+ than written. `ksor serve` refuses to boot unauthenticated on a public bind,
38
+ so this is the last step of a deployment, not an optional hardening pass.
35
39
  - Exit codes are a contract: `1` refused (first stderr line is a stable
36
40
  slug such as `error: bad-name`, followed by a remedy), `2` designed but
37
41
  not implemented, `3` the environment cannot run ksor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
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",