@panaversity/ksor 0.0.52 → 0.0.54

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.
@@ -6,7 +6,7 @@ import { z, z as z$1 } from "zod";
6
6
  import path, { join } from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
8
  import pg from "pg";
9
- //#region ../content-gateway/dist/gateway-api-Ck1l_Sgs.mjs
9
+ //#region ../content-gateway/dist/gateway-api-uhx2l1kC.mjs
10
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
11
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
12
12
  /**
@@ -8899,7 +8899,7 @@ var FakeEmbeddingProvider = class {
8899
8899
  }
8900
8900
  reset() {}
8901
8901
  };
8902
- const DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
8902
+ const DEFAULT_BASE$1 = "https://generativelanguage.googleapis.com/v1beta";
8903
8903
  /**
8904
8904
  * An HTTP-shaped failure carrying the status the retry classifier reads.
8905
8905
  *
@@ -8917,7 +8917,7 @@ var GeminiHttpError = class extends Error {
8917
8917
  };
8918
8918
  /** One POST, with the key in a HEADER — never the query string, which is logged. */
8919
8919
  async function post(opts, apiKey, path, body, timeoutMs) {
8920
- const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE}${path}`, {
8920
+ const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE$1}${path}`, {
8921
8921
  method: "POST",
8922
8922
  headers: {
8923
8923
  "x-goog-api-key": apiKey,
@@ -8973,33 +8973,33 @@ function geminiRestEmbedClient(apiKey, opts = {}) {
8973
8973
  * Python-SDK failure mode with no JS equivalent; `reset()`
8974
8974
  * keeps its drop-never-close contract regardless.
8975
8975
  */
8976
- function httpStatusOf(exc) {
8976
+ function httpStatusOf$1(exc) {
8977
8977
  if (!(exc instanceof Error)) return void 0;
8978
8978
  const status = exc.status;
8979
8979
  return typeof status === "number" ? status : void 0;
8980
8980
  }
8981
- function isTransportBlip(exc, depth = 0) {
8981
+ function isTransportBlip$1(exc, depth = 0) {
8982
8982
  if (depth > 5 || !(exc instanceof Error)) return false;
8983
8983
  if (exc.name === "AbortError" || exc.name === "TimeoutError") return true;
8984
8984
  const code = exc.code;
8985
8985
  if (typeof code === "string" && /^E[A-Z0-9_]+$/.test(code)) return true;
8986
8986
  if (exc instanceof TypeError && exc.message.includes("fetch failed")) return true;
8987
- return isTransportBlip(exc.cause, depth + 1);
8987
+ return isTransportBlip$1(exc.cause, depth + 1);
8988
8988
  }
8989
8989
  /** The Gemini taxonomy for the INGEST plane: transport blips, 5xx, AND 429 —
8990
8990
  * batch work is resumable and has no user waiting. */
8991
- function isRetryable(exc) {
8992
- if (isTransportBlip(exc)) return true;
8993
- const status = httpStatusOf(exc);
8991
+ function isRetryable$1(exc) {
8992
+ if (isTransportBlip$1(exc)) return true;
8993
+ const status = httpStatusOf$1(exc);
8994
8994
  if (status === void 0) return false;
8995
8995
  return status >= 500 && status <= 599 || status === 429;
8996
8996
  }
8997
8997
  /** The READ path's predicate: transport blips + 5xx only — NEVER 429. A
8998
8998
  * rate-limited project stays rate-limited on the next second; the correct
8999
8999
  * move is to degrade to keyword-only NOW, not stall the read behind backoff. */
9000
- function isRetryableQuery(exc) {
9001
- if (isTransportBlip(exc)) return true;
9002
- const status = httpStatusOf(exc);
9000
+ function isRetryableQuery$1(exc) {
9001
+ if (isTransportBlip$1(exc)) return true;
9002
+ const status = httpStatusOf$1(exc);
9003
9003
  return status !== void 0 && status >= 500 && status <= 599;
9004
9004
  }
9005
9005
  /**
@@ -9049,6 +9049,160 @@ var GeminiEmbeddingProvider = class {
9049
9049
  }
9050
9050
  })).embeddings ?? []).map((e) => [...e.values ?? []]);
9051
9051
  }
9052
+ isRetryable(exc) {
9053
+ return isRetryable$1(exc);
9054
+ }
9055
+ isRetryableQuery(exc) {
9056
+ return isRetryableQuery$1(exc);
9057
+ }
9058
+ };
9059
+ /**
9060
+ * OpenAI's embedding endpoint, over `fetch` — no SDK.
9061
+ *
9062
+ * The same call decision 12's 2026-08-22 revision made for Gemini, for the same
9063
+ * reason: this is ONE HTTP call behind a structurally-typed slice, and a vendor
9064
+ * SDK would put megabytes and a transitive dependency tree into every
9065
+ * `ksor init` that never embeds anything. If a provider ever needs an SDK, the
9066
+ * seam takes one through `clientFactory`; nothing here forecloses that.
9067
+ *
9068
+ * WHAT DIFFERS FROM GEMINI, and it is worth knowing before choosing:
9069
+ *
9070
+ * Gemini embeds ASYMMETRICALLY — `RETRIEVAL_DOCUMENT` and `RETRIEVAL_QUERY`
9071
+ * produce different vectors for the same text, and the plane's intent picks
9072
+ * which. OpenAI has no task type: a query and a document are embedded
9073
+ * identically. The seam anticipates exactly this — "a provider whose two
9074
+ * vendor labels are equal can never mis-route a plane"
9075
+ * (`lib/embedding.ts`) — so both labels are the empty string and the intent
9076
+ * reaches this transport and is deliberately ignored.
9077
+ *
9078
+ * `dimensions` is supported on `text-embedding-3-*` only. Asking an older
9079
+ * model for a dimension is an error there, not a silent full-width vector,
9080
+ * which is the failure the framework's own width check would catch anyway.
9081
+ */
9082
+ /**
9083
+ * An HTTP-shaped failure carrying the status the retry classifier reads — and
9084
+ * the vendor's own error `type`, which the status alone does not distinguish.
9085
+ *
9086
+ * OpenAI answers a spent balance with **429**, the same status as a rate limit:
9087
+ * `{"error":{"type":"insufficient_quota","code":"credit_balance_exhausted"}}`
9088
+ * (observed live, 2026-09-01). One clears by waiting and one never will, so a
9089
+ * classifier reading only the status retries a billing problem five times with
9090
+ * exponential backoff and then reports it — slowly, and as if it had been
9091
+ * transient.
9092
+ */
9093
+ var OpenAiHttpError = class extends Error {
9094
+ status;
9095
+ /** The vendor's `error.type`, when it sends one. */
9096
+ kind;
9097
+ constructor(status, detail, kind = null) {
9098
+ super(`OpenAI API error ${status}: ${detail}`);
9099
+ this.name = "OpenAiHttpError";
9100
+ this.status = status;
9101
+ this.kind = kind;
9102
+ }
9103
+ };
9104
+ const DEFAULT_BASE = "https://api.openai.com/v1";
9105
+ function openAiRestEmbedClient(apiKey, opts = {}) {
9106
+ return { async embed(params) {
9107
+ const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE}/embeddings`, {
9108
+ method: "POST",
9109
+ headers: {
9110
+ authorization: `Bearer ${apiKey}`,
9111
+ "content-type": "application/json"
9112
+ },
9113
+ body: JSON.stringify({
9114
+ model: params.model,
9115
+ input: [...params.input],
9116
+ dimensions: params.dimensions
9117
+ }),
9118
+ signal: AbortSignal.timeout(params.timeoutMs)
9119
+ });
9120
+ const text = await res.text();
9121
+ if (!res.ok) {
9122
+ let detail = text.slice(0, 300);
9123
+ let kind = null;
9124
+ try {
9125
+ const err = JSON.parse(text).error;
9126
+ if (typeof err?.message === "string") detail = err.message;
9127
+ if (typeof err?.type === "string") kind = err.type;
9128
+ } catch {}
9129
+ throw new OpenAiHttpError(res.status, detail, kind);
9130
+ }
9131
+ const data = [...JSON.parse(text).data ?? []];
9132
+ data.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
9133
+ return { embeddings: data.map((d) => ({ values: d.embedding })) };
9134
+ } };
9135
+ }
9136
+ /** True for a transport blip with no HTTP status of its own. */
9137
+ function isTransportBlip(exc) {
9138
+ if (exc instanceof OpenAiHttpError) return false;
9139
+ const name = exc?.name;
9140
+ return name === "AbortError" || name === "TimeoutError" || name === "TypeError";
9141
+ }
9142
+ function httpStatusOf(exc) {
9143
+ return exc instanceof OpenAiHttpError ? exc.status : void 0;
9144
+ }
9145
+ /**
9146
+ * The INGEST plane's taxonomy: transport blips, 5xx, AND 429 — batch work is
9147
+ * resumable and has nobody waiting. Deliberately the same shape as the Gemini
9148
+ * adapter's, because the two planes are a property of ksor, not of a vendor.
9149
+ */
9150
+ function isRetryable(exc) {
9151
+ if (isTransportBlip(exc)) return true;
9152
+ if (exc instanceof OpenAiHttpError && exc.kind === "insufficient_quota") return false;
9153
+ const status = httpStatusOf(exc);
9154
+ if (status === void 0) return false;
9155
+ return status >= 500 && status <= 599 || status === 429;
9156
+ }
9157
+ /**
9158
+ * The READ plane's: transport blips + 5xx only, NEVER 429. A rate-limited
9159
+ * project stays rate-limited on the next second, so a search degrades to
9160
+ * keyword-only now rather than stalling a reader behind backoff.
9161
+ */
9162
+ function isRetryableQuery(exc) {
9163
+ if (isTransportBlip(exc)) return true;
9164
+ const status = httpStatusOf(exc);
9165
+ return status !== void 0 && status >= 500 && status <= 599;
9166
+ }
9167
+ var OpenAiEmbeddingProvider = class {
9168
+ providerId = "openai";
9169
+ modelId;
9170
+ dim;
9171
+ documentTaskLabel;
9172
+ queryTaskLabel;
9173
+ documentTimeoutMs;
9174
+ queryTimeoutMs;
9175
+ clientFactory;
9176
+ client = null;
9177
+ constructor(opts) {
9178
+ this.modelId = opts.modelId;
9179
+ this.dim = opts.dim;
9180
+ this.documentTaskLabel = opts.documentTaskLabel;
9181
+ this.queryTaskLabel = opts.queryTaskLabel;
9182
+ this.documentTimeoutMs = Math.trunc(opts.documentTimeoutS * 1e3);
9183
+ this.queryTimeoutMs = Math.trunc(opts.queryTimeoutS * 1e3);
9184
+ this.clientFactory = opts.clientFactory ?? (() => openAiRestEmbedClient(opts.apiKey));
9185
+ }
9186
+ get recipe() {
9187
+ return `${this.modelId}/d${this.dim}/${this.documentTaskLabel}`;
9188
+ }
9189
+ getClient() {
9190
+ this.client ??= this.clientFactory();
9191
+ return this.client;
9192
+ }
9193
+ /** DROP the client reference, never close it: in-flight calls finish on their
9194
+ * own reference and the next call rebuilds lazily. Idempotent. */
9195
+ reset() {
9196
+ this.client = null;
9197
+ }
9198
+ async embed(texts, opts) {
9199
+ return (await this.getClient().embed({
9200
+ model: this.modelId,
9201
+ input: texts,
9202
+ dimensions: this.dim,
9203
+ timeoutMs: opts.intent === "document" ? this.documentTimeoutMs : this.queryTimeoutMs
9204
+ })).embeddings.map((e) => [...e.values ?? []]);
9205
+ }
9052
9206
  isRetryable(exc) {
9053
9207
  return isRetryable(exc);
9054
9208
  }
@@ -9090,11 +9244,30 @@ var MissingProviderKeyError = class extends Error {
9090
9244
  const PROVIDERS = {
9091
9245
  gemini: {
9092
9246
  build: (opts) => new GeminiEmbeddingProvider(opts),
9093
- needsApiKey: true
9247
+ needsApiKey: true,
9248
+ keyEnv: "GEMINI_API_KEY",
9249
+ taskLabels: {
9250
+ document: EMBED_TASK_DOCUMENT,
9251
+ query: EMBED_TASK_QUERY
9252
+ }
9253
+ },
9254
+ openai: {
9255
+ build: (opts) => new OpenAiEmbeddingProvider(opts),
9256
+ needsApiKey: true,
9257
+ keyEnv: "OPENAI_API_KEY",
9258
+ taskLabels: {
9259
+ document: "",
9260
+ query: ""
9261
+ }
9094
9262
  },
9095
9263
  fake: {
9096
9264
  build: (opts) => new FakeEmbeddingProvider(opts),
9097
- needsApiKey: false
9265
+ needsApiKey: false,
9266
+ keyEnv: null,
9267
+ taskLabels: {
9268
+ document: EMBED_TASK_DOCUMENT,
9269
+ query: EMBED_TASK_QUERY
9270
+ }
9098
9271
  }
9099
9272
  };
9100
9273
  function entryFor(name) {
@@ -9103,6 +9276,13 @@ function entryFor(name) {
9103
9276
  return entry;
9104
9277
  }
9105
9278
  /**
9279
+ * The environment variable this provider's key comes from, or null when it
9280
+ * needs none. Unknown name → the same loud error as building it.
9281
+ */
9282
+ function providerKeyEnv(name) {
9283
+ return entryFor(name).keyEnv;
9284
+ }
9285
+ /**
9106
9286
  * The port door: the named provider bound to the DECLARED embedding space and
9107
9287
  * the framework's timeout knobs. `modelId`/`dim` omitted = the shipped
9108
9288
  * config space (EMBED_MODEL / EMBED_DIM, eval-locked); an instance may
@@ -9119,8 +9299,8 @@ function buildShippedProvider(name, opts) {
9119
9299
  apiKey: opts.apiKey ?? "",
9120
9300
  modelId: opts.modelId ?? "gemini-embedding-001",
9121
9301
  dim: opts.dim ?? 1536,
9122
- documentTaskLabel: EMBED_TASK_DOCUMENT,
9123
- queryTaskLabel: EMBED_TASK_QUERY,
9302
+ documentTaskLabel: entry.taskLabels.document,
9303
+ queryTaskLabel: entry.taskLabels.query,
9124
9304
  documentTimeoutS: EMBED_TIMEOUT_S(),
9125
9305
  queryTimeoutS: QUERY_EMBED_TIMEOUT_S()
9126
9306
  });
@@ -10955,4 +11135,4 @@ function readHandler(ctx) {
10955
11135
  };
10956
11136
  }
10957
11137
  //#endregion
10958
- export { parseViewer as A, tlsPosture as B, contentPoolMin as C, outlineHandler as D, keyRingFromEnv as E, runProbe as F, withPgRetry as H, searchHandler as I, servingPolicy as L, prewarmPool as M, readHandler as N, parseInstanceText as O, recordIsUndescribed as P, storedTextSearchConfig as R, contentPool as S, instancePathOf as T, withProbeDeadline as U, validateViewer as V, z$1 as W, assertGovernanceServable as _, GovernanceGateError as a, checkEmbeddingSpace as b, McpServer$1 as c, READ_ONLY as d, READ_OUTPUT as f, TextSearchConfigMismatch as g, TRUST_TIERS as h, FLOOR as i, pooledEndpointFor as j, parseTrustFloor as k, MissingProviderKeyError as l, SchemaVersionError as m, ContentStoreError as n, MAX_OUTLINE_LIMIT as o, SEARCH_OUTPUT as p, EmbeddingSpaceMismatch as r, MAX_SEARCH_K as s, AudienceError as t, OUTLINE_OUTPUT as u, assertSchemaCompatible as v, embedQueryVlit as w, composeInstructions as x, buildShippedProvider as y, tallyHandlers as z };
11138
+ export { parseViewer as A, tallyHandlers as B, contentPoolMin as C, outlineHandler as D, keyRingFromEnv as E, recordIsUndescribed as F, z$1 as G, validateViewer as H, runProbe as I, searchHandler as L, prewarmPool as M, providerKeyEnv as N, parseInstanceText as O, readHandler as P, servingPolicy as R, contentPool as S, instancePathOf as T, withPgRetry as U, tlsPosture as V, withProbeDeadline as W, assertGovernanceServable as _, GovernanceGateError as a, checkEmbeddingSpace as b, McpServer$1 as c, READ_ONLY as d, READ_OUTPUT as f, TextSearchConfigMismatch as g, TRUST_TIERS as h, FLOOR as i, pooledEndpointFor as j, parseTrustFloor as k, MissingProviderKeyError as l, SchemaVersionError as m, ContentStoreError as n, MAX_OUTLINE_LIMIT as o, SEARCH_OUTPUT as p, EmbeddingSpaceMismatch as r, MAX_SEARCH_K as s, AudienceError as t, OUTLINE_OUTPUT as u, assertSchemaCompatible as v, embedQueryVlit as w, composeInstructions as x, buildShippedProvider as y, storedTextSearchConfig as z };
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { D as outlineHandler, I as searchHandler, N as readHandler, P as recordIsUndescribed, W as z$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-Ck1l_Sgs-B-_wMYLB.mjs";
1
+ import { D as outlineHandler, F as recordIsUndescribed, G as z$1, L as searchHandler, P as readHandler, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-uhx2l1kC-C2BAxISt.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, TRUST_TIERS, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
package/docs/deploying.md CHANGED
@@ -141,14 +141,38 @@ What the image deliberately does NOT contain (see `.dockerignore`):
141
141
  > which is the door answering "Method Not Allowed" to a GET rather than a static
142
142
  > 404, and is how you tell the door is routed at all.
143
143
  >
144
- > **It does not depend on the Application Preset**, which is the first thing
145
- > everyone suspects and the reason to say so:
144
+ > **Set the Framework Preset to `Services` on the import screen.** Vercel's own
145
+ > guide states it as one of two necessary conditions:
146
+ >
147
+ > > "A project builds as services only when two conditions are both true: the
148
+ > > project's framework is set to Services, and `vercel.json` contains a
149
+ > > `services` key. If either is missing, Vercel falls back to its default
150
+ > > framework detection and ignores your services configuration."
151
+ > > — [vercel.com/kb/guide/vercel-services](https://vercel.com/kb/guide/vercel-services)
152
+ >
153
+ > That is the silent-404 shape exactly: preset `Other` → fallback detection →
154
+ > nothing detected → an empty output that deploys, reports Ready and takes the
155
+ > alias. And no file in your repository can set it for you: `framework` is not
156
+ > a valid top-level key while `services` is present, so this is a project
157
+ > setting or nothing.
158
+ >
159
+ > **One measurement here disagrees with that guide, and is recorded rather than
160
+ > reconciled.** On 2026-08-27 two projects were read back from the Vercel API,
161
+ > one reading preset `Services` and one reading `Other`, and BOTH built the
162
+ > `services` block and served:
146
163
  >
147
164
  > | project's preset | `services` block built | serves |
148
165
  > | ---------------- | ---------------------- | -------------------------------------- |
149
166
  > | `Services` | `site` + `door` | `/` 200 · `/llms.txt` 200 · `/mcp` 405 |
150
167
  > | `Other` | `site` + `door` | `/` 200 · `/llms.txt` 200 · `/mcp` 405 |
151
168
  >
169
+ > Both facts are real and they cannot both be the whole rule. Possible readings
170
+ > — the API's `framework` field being derived rather than the project setting,
171
+ > or Beta behaviour changing between the guide's 2026-08-12 revision and that
172
+ > measurement — are unverified, and guessing between them is what produced the
173
+ > earlier version of this page, which told you the preset was RULED OUT and so
174
+ > steered you away from the one step the vendor calls required. Set the preset.
175
+ >
152
176
  > **One failure has been seen that none of this explains.** On a 205-document
153
177
  > record (2026-08-26, issue #197) the install ran, `ksor build` ran, every route
154
178
  > prerendered — and Vercel collected nothing. The deployment reported **Ready**,
@@ -427,12 +451,21 @@ Plain `pnpm build` is `[public]`, so the safe thing is the default.
427
451
 
428
452
  Two supported answers, and a third that is yours.
429
453
 
430
- **Read through the door instead.** This is the one ksor is built for. The MCP
431
- surface already applies the audience scope **per request** and writes a
432
- `retrieval_log` row carrying the actor for every read — per-person governance
433
- with an audit trail, which a static site cannot have at any price. If the
434
- requirement is "who read what, and were they allowed to", that is the door, not
435
- the website.
454
+ **Run a door per audience.** The door does NOT decide per request: it serves
455
+ ONE viewer list, read from `KSOR_AUDIENCE` once at boot
456
+ (`content-gateway/src/compose.ts`) and validated against the policy before it
457
+ widens past `public`. Every caller holding a valid token for that door gets the
458
+ same view of the record `docs/authorization.md` says so plainly, and
459
+ per-request visibility filtering is named out of scope in
460
+ `specs/ksor/serve/spec.md`. So the answer is one process per viewer list, each
461
+ with its own `KSOR_AUDIENCE`, behind whatever routing already decides who
462
+ reaches which URL.
463
+
464
+ What the door gives that a static site cannot is the **audit**, not the
465
+ authorization: every read writes a `retrieval_log` row naming the verified
466
+ caller, so "who read what" is answerable afterwards. That is worth having, and
467
+ it is a different thing from "were they allowed to", which is still decided by
468
+ which door they can reach.
436
469
 
437
470
  **Or split the record.** Content needing per-person confidentiality inside one
438
471
  tier is usually content that belongs in its own record, with its own gate. That
package/docs/ingesting.md CHANGED
@@ -35,12 +35,12 @@ Ingest reads your markdown, sends each new chunk to an embedding provider, and
35
35
  writes the result to Postgres. So four things must be true, and none of them is
36
36
  created for you.
37
37
 
38
- | | what | how |
39
- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
40
- | **The corpus** | `knowledge/` at your repo root — CommonMark `.md`, one document per file, in the KSoR Profile of OKF: `type`, `title`, `description`, `status` and `ksor.audience` in frontmatter | `pnpm check` validates it and explains any violation; `ksor build` must have written a current `build.lock.json` before ingest will run |
41
- | **The database** | Postgres with **pgvector** — `CREATE EXTENSION vector;` | any managed host; the DDL below needs a role that can create tables |
42
- | **The provider key** | `GEMINI_API_KEY` — the default embedding provider is `gemini-embedding-001` | [aistudio.google.com](https://aistudio.google.com/apikey); the free tier covers a first corpus |
43
- | **The DSN** | `KSOR_DB_URL`, named by `instance.md`'s `database.dsn_env` | already named by `instance.md`'s `database:` block |
38
+ | | what | how |
39
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | **The corpus** | `knowledge/` at your repo root — CommonMark `.md`, one document per file, in the KSoR Profile of OKF: `type`, `title`, `description`, `status` and `ksor.audience` in frontmatter | `pnpm check` validates it and explains any violation; `ksor build` must have written a current `build.lock.json` before ingest will run |
41
+ | **The database** | Postgres with **pgvector** — `CREATE EXTENSION vector;` | any managed host; the DDL below needs a role that can create tables |
42
+ | **The provider key** | `GEMINI_API_KEY` — the default embedding provider is `gemini-embedding-001`. Declaring `embedding.provider: openai` reads `OPENAI_API_KEY` instead: each provider names its own variable | [aistudio.google.com](https://aistudio.google.com/apikey); free tier embedding input is free of charge |
43
+ | **The DSN** | `KSOR_DB_URL`, named by `instance.md`'s `database.dsn_env` | already named by `instance.md`'s `database:` block |
44
44
 
45
45
  Both variables go in `.env` beside `instance.md` — `ksor` reads it automatically,
46
46
  and `.env` is gitignored. Every command below is run **from your repository
@@ -197,8 +197,25 @@ measure until the corpus is in there.
197
197
  pnpm exec ksor calibrate --instance instance.md
198
198
  ```
199
199
 
200
- It ends with a block to paste into **`instance.md`**'s frontmatter, exactly as
201
- printed the floor, the measurement recorded beside it as a comment, and
200
+ **On a free-tier key, use the zero-LLM door instead.** The command above is the
201
+ SYNTHESIZED door: it writes one probe question per sampled passage with an LLM,
202
+ and a free key allows only a few generations a minute — a bigger corpus makes
203
+ that worse, not better. Write your own in-corpus questions, one per line, and
204
+ pass them:
205
+
206
+ ```sh
207
+ pnpm exec ksor calibrate --instance instance.md --queries-file questions.txt
208
+ ```
209
+
210
+ Six to ten real questions is enough. They should be things this record answers,
211
+ in the words someone would actually ask — the floor is set by the WEAKEST of
212
+ them, so a vague question drags it down and a question the record does not
213
+ answer invalidates the measurement. The door is recorded beside the number
214
+ (`door: queries-file`), because floors from the two doors are measured against
215
+ different distributions and must never be compared as interchangeable.
216
+
217
+ Either way it ends with a block to paste into **`instance.md`**'s frontmatter,
218
+ exactly as printed — the floor, the measurement recorded beside it as a comment, and
202
219
  `floor_digest`, the digest of the retrieval predicate the floor was measured
203
220
  through. Paste it, then restart `ksor serve`: the floor is read at boot.
204
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
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",
@@ -72,8 +72,8 @@
72
72
  "tsdown": "0.22.14",
73
73
  "typescript": "7.0.2",
74
74
  "vitest": "^4.1.10",
75
- "@panaversity/ksor-content-gateway": "0.0.0",
76
- "@panaversity/ksor-content": "0.0.0"
75
+ "@panaversity/ksor-content": "0.0.0",
76
+ "@panaversity/ksor-content-gateway": "0.0.0"
77
77
  },
78
78
  "engines": {
79
79
  "node": ">=24"
@@ -100,8 +100,11 @@ Stand it up in this order (each step's errors explain how to fix themselves):
100
100
  ```
101
101
 
102
102
  That is enough. `embedding:` is optional and already defaults to
103
- `provider: gemini`, `model: gemini-embedding-001`, `dim: 1536`; write it out
104
- only to pin the space explicitly or to change it and note that model and
103
+ `provider: gemini`, `model: gemini-embedding-001`, `dim: 1536`. The other
104
+ shipped vendor is OpenAI`provider: openai`, `model:
105
+ text-embedding-3-small`, `dim: 1536`, key in `OPENAI_API_KEY` — and each
106
+ provider names its own key variable, so nothing else changes. Write the block
107
+ out only to pin the space explicitly or to change it — and note that model and
105
108
  dim are the PERSISTED identity of the embedding space, so changing either
106
109
  later means re-embedding the whole corpus. Keep `dim` at or below 2000 — the
107
110
  schema indexes a `vector` column directly and pgvector's HNSW takes a
@@ -114,7 +117,33 @@ Stand it up in this order (each step's errors explain how to fix themselves):
114
117
  Leave `retrieval:` out for now — the gate is off and the server says so.
115
118
  Turning it on is step 4, AFTER the record is serving.
116
119
 
117
- 2. **Copy `.env.example` to `.env`** and fill it in `ksor` reads it
120
+ 2. **Get the database your agent can do this one.** `.mcp.json` at the repo
121
+ root declares the MCP servers this project may reach, and the first is Neon.
122
+ With it connected, ask:
123
+
124
+ > Using the Neon MCP server, create a project called `<your-record>` and
125
+ > enable the pgvector extension on it. Then create a branch called `dev`,
126
+ > and save that branch's connection string to `.env` as `KSOR_DB_URL`.
127
+ > Never print my API key. Show me the plan before you run anything.
128
+
129
+ That is `create_project`, `run_sql` (`CREATE EXTENSION vector`),
130
+ `create_branch` and `get_connection_string` — four real tools, no dashboard.
131
+ Prefer the OAuth flow: an API key pasted into `.mcp.json` would be committed,
132
+ because that file carries no secret and is not gitignored.
133
+
134
+ Any Postgres with pgvector works — Neon is the path with an MCP server, not a
135
+ requirement. Locally: `docker run -e POSTGRES_PASSWORD=x -p 5432:5432
136
+ pgvector/pgvector:pg17`.
137
+
138
+ **`GEMINI_API_KEY` is the one step no agent can do.** No vendor mints an API
139
+ key over a protocol; it comes from a browser at
140
+ [aistudio.google.com/apikey](https://aistudio.google.com/apikey) and a human
141
+ pastes it. An agent working through this list should ask for it by name and
142
+ stop, rather than trying — and should say that the FREE TIER is enough:
143
+ embedding input on `gemini-embedding-001` is free of charge, so this is a
144
+ signup, not a bill.
145
+
146
+ 3. **Copy `.env.example` to `.env`** and fill it in — `ksor` reads it
118
147
  automatically, so nothing needs exporting, and `.env` is already gitignored.
119
148
  A real environment variable still wins over the file, so CI and production
120
149
  overrides behave normally.
@@ -134,7 +163,7 @@ Stand it up in this order (each step's errors explain how to fix themselves):
134
163
  intended dev shape. A PUBLIC deployment configures the SSO door instead —
135
164
  see the comments in `.env.example` and "Serving safely" below.
136
165
 
137
- 3. **Bring it up.** Once, then every time:
166
+ 4. **Bring it up.** Once, then every time:
138
167
 
139
168
  ```sh
140
169
  pnpm provision # schema (or migrate) + grant — the privileged acts, run once
@@ -219,7 +248,7 @@ Stand it up in this order (each step's errors explain how to fix themselves):
219
248
  serves, or a DBA who holds the credentials that authorize ingest — not as a
220
249
  daily choice.
221
250
 
222
- 4. **Turn the abstention gate on — deliberately, once it serves.** This is the
251
+ 5. **Turn the abstention gate on — deliberately, once it serves.** This is the
223
252
  step that makes "not in this corpus" a real answer, and it is measured, never
224
253
  guessed:
225
254
 
@@ -171,7 +171,31 @@ That is the whole required config. `embedding:` defaults to Gemini at 1536
171
171
  dimensions, and `retrieval:` is written for you by step 3's `calibrate`. Change
172
172
  the variable name here only if you want a different one.
173
173
 
174
- ### 2. Fill in the environment
174
+ ### 2. Get a database — your agent can do this one
175
+
176
+ `.mcp.json` at the repo root declares the MCP servers this project may reach.
177
+ The first is Neon's. With it connected, ask your coding agent:
178
+
179
+ > Using the Neon MCP server, create a project called `<your-record>` and enable
180
+ > the pgvector extension on it. Then create a branch called `dev`, and save that
181
+ > branch's connection string to `.env` as `KSOR_DB_URL`. Never print my API key.
182
+ > Show me the plan before you run anything.
183
+
184
+ Prefer the OAuth flow. `.mcp.json` is committed and carries no secret; an API
185
+ key pasted into it would be.
186
+
187
+ Any Postgres with pgvector works — Neon is the path that has an MCP server, not
188
+ a requirement. Locally:
189
+ `docker run -e POSTGRES_PASSWORD=x -p 5432:5432 pgvector/pgvector:pg17`.
190
+
191
+ **`GEMINI_API_KEY` is the one step no agent can do for you** — no vendor mints
192
+ an API key over a protocol. Get it from
193
+ [aistudio.google.com/apikey](https://aistudio.google.com/apikey) and paste it.
194
+ **The free tier costs nothing and is enough**: embedding input on
195
+ `gemini-embedding-001` is free of charge, and a first corpus is a few thousand
196
+ tokens. This is a signup, not a bill.
197
+
198
+ ### 3. Fill in the environment
175
199
 
176
200
  ```sh
177
201
  cp .env.example .env
@@ -191,7 +215,7 @@ refusal tells you to _export_ a variable, putting it in `.env` is the same
191
215
  thing. `KSOR_AUTH=disabled-local` is required for a local run: serve refuses to
192
216
  boot unauthenticated on purpose, so a server is never open by accident.
193
217
 
194
- ### 3. Bring it up
218
+ ### 4. Bring it up
195
219
 
196
220
  ```sh
197
221
  pnpm provision # once: apply the schema, authorize ingest
@@ -273,7 +297,15 @@ Delete `.mcp.json`, or keep it — it holds no secret.
273
297
  Both surfaces on one domain, in about ten minutes:
274
298
 
275
299
  1. **Push the repository to GitHub.**
276
- 2. **Import it in Vercel**, then **set Root Directory to `./`.** Vercel
300
+ 2. **Import it in Vercel**, then set **Framework Preset** to **`Services`** and
301
+ **Root Directory** to `./`. The preset is not cosmetic: Vercel's own guide
302
+ says a project builds as services only when the preset is `Services` AND
303
+ `vercel.json` carries a `services` key, and that "if either is missing,
304
+ Vercel falls back to its default framework detection and ignores your
305
+ services configuration" — which is the silent 404 below. No file in this
306
+ repository can set it for you.
307
+
308
+ As for Root Directory: Vercel
277
309
  auto-fills it with `system/site`, because that is where it finds a framework
278
310
  — and the build then reads `system/site/vercel.json`, which does not exist,
279
311
  and fails with `Project framework is set to "services", but no services are
@@ -282,6 +314,19 @@ declared`. The services ARE declared, in `vercel.json` at the repo root,
282
314
  container from the root `Dockerfile`.
283
315
  3. **Set three environment variables** in Vercel: `KSOR_DB_URL`,
284
316
  `GEMINI_API_KEY`, and `KSOR_AUTH=disabled-public`.
317
+ 4. **Check it actually serves**, before you tell anyone the URL. A Ready
318
+ deployment that answers 404 everywhere looks identical to a good one from
319
+ the dashboard:
320
+
321
+ ```sh
322
+ B=https://your-record.vercel.app
323
+ curl -o /dev/null -w '%{http_code}\n' "$B/" # expect 200
324
+ curl -o /dev/null -w '%{http_code}\n' "$B/llms.txt" # expect 200
325
+ curl -sI "$B/mcp" | head -1 # expect 405
326
+ ```
327
+
328
+ `/mcp` answering 405 is the door refusing a GET — that is how you know it is
329
+ routed at all, and a 404 there means the `services` block was ignored.
285
330
 
286
331
  Three things catch people here. Two are the system being deliberate; the first
287
332
  is not, and it is the one that fails without saying so:
@@ -289,10 +334,13 @@ is not, and it is the one that fails without saying so:
289
334
  - **A deployment can report Ready and serve nothing.** The build succeeds,
290
335
  Vercel collects nothing, and the deployment takes your domain and answers
291
336
  `404: NOT_FOUND` everywhere — with one build-log line as the only signal:
292
- `WARNING! Build output contains no "functions" or "static" directory`. Seen
293
- once, on a large record, and **the cause is not established**; it is *not* the
294
- Application Preset, which was measured. The emitted `vercel.json` itself is
295
- verified working on the Git path. If you hit this, the fallback is the
337
+ `WARNING! Build output contains no "functions" or "static" directory`. That
338
+ warning is the FALLBACK collector finding nothing, which is what step 2's
339
+ preset exists to prevent check it first. One measurement of ours disagrees
340
+ with the vendor's rule and is recorded in
341
+ `node_modules/@panaversity/ksor/docs/deploying.md`; the honest state is that
342
+ the preset is necessary by the vendor's documentation and has once appeared
343
+ not to be. If you hit this, the fallback is the
296
344
  classic-keys form in `node_modules/@panaversity/ksor/docs/deploying.md` — read
297
345
  it there rather than guessing, because it **moves the door off your domain**
298
346
  and `KSOR_MCP_RESOURCE_URL` and your SSO API Identifier both have to move with
@@ -372,6 +420,29 @@ approved has to be the text that was written. That act is yours.
372
420
  Run `pnpm check` before you commit. It runs the rules as a program, and every
373
421
  failure it reports says what is wrong, why the rule exists, and how to fix it.
374
422
 
423
+ ### Names instead of handles
424
+
425
+ The record stores actors as identifiers — `human:you`, `team:legal-ops` — and a
426
+ page would otherwise lead with the slug: "Owner · human:bashiraziz". Put the
427
+ name beside the identifier in `.ksor/people.yaml` and pages print that instead:
428
+
429
+ ```yaml
430
+ people:
431
+ "human:bashiraziz": Bashir Aziz
432
+ "human:ciso": Ayesha Khan
433
+ ```
434
+
435
+ Keyed by the identifier exactly as the record stores it, quoted because it
436
+ contains a colon. There is no rule that turns a name into a handle — `ciso` is
437
+ nobody's squashed full name — so both are written down, and an actor with no
438
+ entry renders exactly as stored rather than being guessed at.
439
+
440
+ **This is presentation, and nothing else.** It grants no authority: who may
441
+ approve or withdraw is `.ksor/governance.yaml`, and the two files are not
442
+ checked against each other, because someone who leaves the authority list is
443
+ still the recorded approver of everything they approved. Optional — a record
444
+ that declares no names reads exactly as it did before.
445
+
375
446
  ### Presenting a document
376
447
 
377
448
  Ask your coding agent for slides and it writes them, from the document, into the
@@ -0,0 +1,13 @@
1
+ {
2
+ "mcpServers": {
3
+ "Neon": {
4
+ "type": "http",
5
+ "url": "https://mcp.neon.tech/mcp"
6
+ },
7
+ "agentfactory-system-of-record": {
8
+ "type": "http",
9
+ "url": "https://sor.panaversity.org/mcp",
10
+ "oauth": { "clientId": "zia-tutor-ai", "callbackPort": 3118 }
11
+ }
12
+ }
13
+ }