@panaversity/ksor 0.0.22 → 0.0.24
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 +71 -0
- package/dist/cli.mjs +259 -3277
- package/dist/gateway-api-BF06IsJ--D-eI--yB.mjs +3225 -0
- package/dist/gateway.d.mts +144 -0
- package/dist/gateway.mjs +2 -0
- package/docs/deploying.md +233 -0
- package/docs/index.md +12 -0
- package/docs/ingesting.md +159 -0
- package/docs/tool-surface.md +133 -0
- package/package.json +6 -1
- package/templates/scaffold/AGENTS.md +71 -0
- package/templates/scaffold/Dockerfile +36 -0
- package/templates/scaffold/README.md +58 -1
- package/templates/scaffold/dockerignore +26 -0
- package/templates/scaffold/pnpm-workspace.yaml +5 -9
- package/templates/scaffold/system/gateways/content.ts +162 -0
- package/templates/scaffold/vercel.json +23 -5
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CallToolResult, McpServer as McpServer$1, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z as z$1 } from "zod";
|
|
3
|
+
import pg from "pg";
|
|
4
|
+
//#region ../content-gateway/dist/gateway-api-D9COH1tR.d.mts
|
|
5
|
+
//#region src/instructions.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Has the owner said what this record is FOR yet?
|
|
8
|
+
*
|
|
9
|
+
* The MCP door already answers honestly when they have not — it replaces the
|
|
10
|
+
* template with a plain statement that the scope is unstated. But the operator
|
|
11
|
+
* starting the server was told nothing, so a record serving with no declared
|
|
12
|
+
* identity looked exactly like one that had been described. The boot report is
|
|
13
|
+
* where that belongs, beside the abstention posture: both are answers to "how
|
|
14
|
+
* much should I trust what this thing says".
|
|
15
|
+
*/
|
|
16
|
+
declare function recordIsUndescribed(authored: string): boolean;
|
|
17
|
+
declare function composeInstructions(authored: string): string;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region ../content/dist/index.d.mts
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/lib/abstain.d.ts
|
|
22
|
+
/**
|
|
23
|
+
* The abstention gates (oracle SC/lib/abstain.py, exact). "Not in this
|
|
24
|
+
* corpus" is a correct answer, never an error, never a licence to fall back
|
|
25
|
+
* on model knowledge.
|
|
26
|
+
*
|
|
27
|
+
* Floors come from the instance frontmatter, never module globals, and are
|
|
28
|
+
* calibrated per corpus per embedding space — never copy a calibrated
|
|
29
|
+
* constant between corpora.
|
|
30
|
+
*/
|
|
31
|
+
interface AbstainConfig {
|
|
32
|
+
/**
|
|
33
|
+
* A calibrated number gates (abstain below it); `null` = no gate declared,
|
|
34
|
+
* honest absence surfaced on /health; `"uncalibrated"` = a floor was
|
|
35
|
+
* DECLARED but not measured, which REFUSES every serve until it is pasted
|
|
36
|
+
* (the "fail closed once a floor is declared" invariant, representable).
|
|
37
|
+
*/
|
|
38
|
+
readonly vectorFloor: number | null | "uncalibrated";
|
|
39
|
+
/**
|
|
40
|
+
* Degraded-path ts_rank_cd floor; null = abstain only on zero matches.
|
|
41
|
+
* Recorded negative result (oracle, measured on 416 in-corpus gold + 38
|
|
42
|
+
* OOC probes): ts_rank_cd does NOT separate in/out-of-corpus — every
|
|
43
|
+
* leak-stopping floor false-abstained 67–98% (0.1333 → FA 255/379;
|
|
44
|
+
* 0.7 → FA 355/379). The shipped instance keeps keyword_floor null;
|
|
45
|
+
* never set one by intuition, recalibrate per corpus.
|
|
46
|
+
*/
|
|
47
|
+
readonly keywordFloor: number | null;
|
|
48
|
+
}
|
|
49
|
+
interface ContentInstance {
|
|
50
|
+
/** The corpus identity — instance.md's `name:` (the path/name IS the identity). */
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly corpusId: string;
|
|
53
|
+
readonly tenantId: string;
|
|
54
|
+
/** The NAME of the env var holding the DSN; the composition root resolves it. */
|
|
55
|
+
readonly dsnEnv: string;
|
|
56
|
+
readonly abstain: AbstainConfig;
|
|
57
|
+
readonly maximumResponseCharacters: number;
|
|
58
|
+
/** The authored agent-surface instructions (the body, edge-trimmed). */
|
|
59
|
+
readonly instructions: string;
|
|
60
|
+
/** Transport name (registry key, never persisted). */
|
|
61
|
+
readonly embeddingProvider: string;
|
|
62
|
+
/** The record's reader audiences, least- to most-restricted; empty = no model. */
|
|
63
|
+
/** The Postgres text-search configuration the keyword arm stems with. */
|
|
64
|
+
readonly textSearchConfig: string;
|
|
65
|
+
readonly audiences: readonly string[];
|
|
66
|
+
/** The tier a document takes when it declares none; null = none declared. */
|
|
67
|
+
readonly defaultVisibility: string | null;
|
|
68
|
+
/** model + dim are the persisted IDENTITY of the embedding space. */
|
|
69
|
+
readonly embeddingModel: string;
|
|
70
|
+
readonly embeddingDim: number;
|
|
71
|
+
}
|
|
72
|
+
interface KeyRing {
|
|
73
|
+
readonly keys: ReadonlyMap<string, Buffer>;
|
|
74
|
+
/** Mint with the active key; keep validating old ids until their tokens age out (≤ TTL). */
|
|
75
|
+
readonly active: string;
|
|
76
|
+
}
|
|
77
|
+
declare const MAX_SEARCH_K = 50;
|
|
78
|
+
interface ServiceContext {
|
|
79
|
+
readonly pool: pg.Pool;
|
|
80
|
+
readonly instance: ContentInstance;
|
|
81
|
+
readonly ring: KeyRing;
|
|
82
|
+
/** sha256 of instance.md — the deployment binding snapshots carry. */
|
|
83
|
+
readonly instanceDigest: string;
|
|
84
|
+
/** The query-embed pipeline (cache + breaker + timeout live behind it); returns a pgvector literal or a raw vector. */
|
|
85
|
+
readonly embedQuery: (query: string) => Promise<readonly number[] | string>;
|
|
86
|
+
/** The verified caller, or null → audited as "anonymous". */
|
|
87
|
+
readonly actor?: () => string | null;
|
|
88
|
+
/**
|
|
89
|
+
* The audience tier this door serves. null = the record's least-privileged
|
|
90
|
+
* tier, which is the safe default: a door that cannot establish who is asking
|
|
91
|
+
* must not hand out the restricted half of the record. Ignored entirely when
|
|
92
|
+
* the instance declares no `audiences:` model.
|
|
93
|
+
*/
|
|
94
|
+
readonly audience?: string | null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The largest outline a caller may ASK for. The tool schema and the service
|
|
98
|
+
* both derive from it, so the ceiling is one number rather than three
|
|
99
|
+
* hand-copied ones.
|
|
100
|
+
*/
|
|
101
|
+
declare const MAX_OUTLINE_LIMIT = 5e3;
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/tools.d.ts
|
|
104
|
+
/**
|
|
105
|
+
* The framework text every tool description must carry.
|
|
106
|
+
*
|
|
107
|
+
* A registration file puts its own prose ABOVE one of these. It is a template
|
|
108
|
+
* literal in adopter code, so nothing stops someone omitting it — which is why
|
|
109
|
+
* the door verifies its own served surface at boot rather than trusting that
|
|
110
|
+
* nobody did.
|
|
111
|
+
*/
|
|
112
|
+
declare const FLOOR: Readonly<Record<"search" | "outline" | "read", string>>;
|
|
113
|
+
/** Every ksor tool is a read: no writes, safe to retry, closed world. */
|
|
114
|
+
declare const READ_ONLY: {
|
|
115
|
+
readonly readOnlyHint: true;
|
|
116
|
+
readonly destructiveHint: false;
|
|
117
|
+
readonly idempotentHint: true;
|
|
118
|
+
readonly openWorldHint: false;
|
|
119
|
+
};
|
|
120
|
+
declare const SEARCH_OUTPUT: StandardSchemaWithJSON;
|
|
121
|
+
declare const OUTLINE_OUTPUT: StandardSchemaWithJSON;
|
|
122
|
+
declare const READ_OUTPUT: StandardSchemaWithJSON;
|
|
123
|
+
interface SearchArgs {
|
|
124
|
+
readonly query: string;
|
|
125
|
+
readonly k: number;
|
|
126
|
+
}
|
|
127
|
+
declare function searchHandler(ctx: ServiceContext): (args: SearchArgs) => Promise<CallToolResult>;
|
|
128
|
+
interface OutlineArgs {
|
|
129
|
+
readonly node?: string | undefined;
|
|
130
|
+
readonly depth?: number | undefined;
|
|
131
|
+
readonly limit: number;
|
|
132
|
+
readonly offset?: number | undefined;
|
|
133
|
+
}
|
|
134
|
+
declare function outlineHandler(ctx: ServiceContext): (args: OutlineArgs) => Promise<CallToolResult>;
|
|
135
|
+
interface ReadArgs {
|
|
136
|
+
readonly slug: string;
|
|
137
|
+
readonly heading?: string | undefined;
|
|
138
|
+
readonly from_heading?: string | undefined;
|
|
139
|
+
readonly snapshot_token?: string | undefined;
|
|
140
|
+
readonly token_budget?: number | undefined;
|
|
141
|
+
}
|
|
142
|
+
declare function readHandler(ctx: ServiceContext): (args: ReadArgs) => Promise<CallToolResult>;
|
|
143
|
+
//#endregion
|
|
144
|
+
export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, type OutlineArgs, READ_ONLY, READ_OUTPUT, type ReadArgs, SEARCH_OUTPUT, type SearchArgs, type ServiceContext, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
|
package/dist/gateway.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-BF06IsJ--D-eI--yB.mjs";
|
|
2
|
+
export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Deploying
|
|
3
|
+
status: draft
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deploying a Knowledge System of Record
|
|
7
|
+
|
|
8
|
+
A KSoR publishes two surfaces from one record, and they deploy differently
|
|
9
|
+
because they are different things:
|
|
10
|
+
|
|
11
|
+
| surface | what it is | how it deploys |
|
|
12
|
+
| ------------ | ------------------------------------------------ | -------------------------------------- |
|
|
13
|
+
| **the site** | a fully static export — HTML, `llms.txt`, search | upload a folder to any static host |
|
|
14
|
+
| **the door** | the MCP server, a live process reading Postgres | run a container that listens on a port |
|
|
15
|
+
|
|
16
|
+
`ksor init` emits everything both need: `Dockerfile` and `.dockerignore` for the
|
|
17
|
+
door, and a `vercel.json` that puts the two behind one domain. This page is the
|
|
18
|
+
walkthrough, executed rather than described.
|
|
19
|
+
|
|
20
|
+
**Publishing is a third thing, and it is not on this page's critical path.**
|
|
21
|
+
Neither surface ingests. The door serves whatever generation is already active
|
|
22
|
+
in the database, and the site renders `knowledge/` from the repository. Getting
|
|
23
|
+
content INTO the database is [ingesting.md](./ingesting.md), and it is a deploy
|
|
24
|
+
step you run, never something a booting container does.
|
|
25
|
+
|
|
26
|
+
## The shape
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
┌───────────────────────────────┐
|
|
30
|
+
a reader ────────▶ / the site │ static files
|
|
31
|
+
│ /docs/… │
|
|
32
|
+
├───────────────────────────────┤
|
|
33
|
+
an agent ────────▶ /mcp the door │ container ──▶ Postgres
|
|
34
|
+
│ /health /ready │
|
|
35
|
+
│ /.well-known/oauth-… │
|
|
36
|
+
└───────────────────────────────┘
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
One domain, two services. An agent that finds `https://your-host/mcp` and a
|
|
40
|
+
person who opens `https://your-host/` are reading the same record.
|
|
41
|
+
|
|
42
|
+
## The container is the portable artifact
|
|
43
|
+
|
|
44
|
+
The emitted `Dockerfile` names no host. It installs the pinned
|
|
45
|
+
`@panaversity/ksor` from your `package.json`, honours `$PORT`, and runs
|
|
46
|
+
`ksor serve`:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
docker build -t my-record .
|
|
50
|
+
docker run --rm -p 8080:80 --env-file .env my-record
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That runs on Cloud Run, Fly, Render, ECS, Kubernetes, or a VPS with no changes.
|
|
54
|
+
`vercel.json` **points at this same file** rather than replacing it, which is
|
|
55
|
+
what keeps the host a choice — the artifact is yours, and moving it is a
|
|
56
|
+
redeploy, not a rewrite.
|
|
57
|
+
|
|
58
|
+
What the image deliberately does NOT contain (see `.dockerignore`):
|
|
59
|
+
|
|
60
|
+
- **`.env`** — configuration arrives from the environment at run time. A DSN
|
|
61
|
+
baked into a layer is published to anyone who can pull the image.
|
|
62
|
+
- **`knowledge/`** — the door reads Postgres. Copying the corpus in would
|
|
63
|
+
suggest the container reads it, and it never does.
|
|
64
|
+
- **`system/`** — that is the other surface, built and hosted separately.
|
|
65
|
+
|
|
66
|
+
## Deploying both surfaces to Vercel
|
|
67
|
+
|
|
68
|
+
The emitted `vercel.json` declares both services and routes between them:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"services": {
|
|
73
|
+
"site": { "root": ".", "buildCommand": "pnpm build", "outputDirectory": "system/site/out" },
|
|
74
|
+
"door": { "root": ".", "runtime": "container", "entrypoint": "Dockerfile" }
|
|
75
|
+
},
|
|
76
|
+
"rewrites": [
|
|
77
|
+
{ "source": "/mcp(.*)", "destination": { "service": "door" } },
|
|
78
|
+
{ "source": "/.well-known/oauth-protected-resource(.*)", "destination": { "service": "door" } },
|
|
79
|
+
{ "source": "/health", "destination": { "service": "door" } },
|
|
80
|
+
{ "source": "/ready", "destination": { "service": "door" } },
|
|
81
|
+
{ "source": "/(.*)", "destination": { "service": "site" } }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Deploy from the repository **root**, never from `system/site/`:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
vercel deploy --prod
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Two things about this file are worth knowing before you edit it.
|
|
93
|
+
|
|
94
|
+
**The catch-all must stay last.** Rewrites match in order, so a `/(.*)` rule
|
|
95
|
+
moved above the door's routes silently sends `POST /mcp` to the static site,
|
|
96
|
+
where it becomes a 404 and reads like the door is down.
|
|
97
|
+
|
|
98
|
+
**Do not add a project-level `trailingSlash`.** The site's own Next config
|
|
99
|
+
already sets it and exports `index.html` directories, so it buys nothing — and
|
|
100
|
+
at the project level it applies to the door too, where it redirects `POST /mcp`
|
|
101
|
+
to `/mcp/` with a 308. Found exactly that way: every door route 308ed until the
|
|
102
|
+
setting came out.
|
|
103
|
+
|
|
104
|
+
## Configuration
|
|
105
|
+
|
|
106
|
+
The door takes everything from the environment. Set these on the deployment, not
|
|
107
|
+
in a file:
|
|
108
|
+
|
|
109
|
+
| variable | why |
|
|
110
|
+
| ----------------------- | ------------------------------------------------------------------------ |
|
|
111
|
+
| `KSOR_DB_URL` | the record's Postgres store |
|
|
112
|
+
| `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
|
|
113
|
+
| `KSOR_SNAPSHOT_KEYS` | `kid=secret` — **required in practice**, see below |
|
|
114
|
+
| `KSOR_ALLOWED_HOSTS` | the host you serve on (DNS-rebind defence) |
|
|
115
|
+
| `KSOR_MCP_RESOURCE_URL` | this record's canonical URL, e.g. `https://your-host/mcp` |
|
|
116
|
+
| auth, one of two | `KSOR_SSO_URL` + audiences, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` |
|
|
117
|
+
|
|
118
|
+
`KSOR_SNAPSHOT_KEYS` is listed as a production knob but behaves as a
|
|
119
|
+
requirement on any host that scales to zero. Unset, the signing key is generated
|
|
120
|
+
**per process** — so a citation minted before a scale-down stops validating
|
|
121
|
+
after it, with a single instance and no replicas involved. Generate one:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
KSOR_SNAPSHOT_KEYS="k1=$(openssl rand -hex 32)"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The value is `kid=secret`, not `kid:secret`, and the first entry is the active
|
|
128
|
+
one. Multiple entries let you rotate without invalidating outstanding
|
|
129
|
+
citations.
|
|
130
|
+
|
|
131
|
+
### The site build needs the DSN too
|
|
132
|
+
|
|
133
|
+
Once `instance.md` declares a `database:` block, `pnpm build` runs
|
|
134
|
+
`pnpm export-denylist` first — it asks the database what has been withdrawn and
|
|
135
|
+
writes `.ksor-denylist.json`. Without `KSOR_DB_URL` the build **refuses**:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
KSOR_DB_URL is unset, and instance.md declares a database
|
|
139
|
+
why: a takedown lives in that database. Without it this build cannot tell
|
|
140
|
+
'nothing is denied' from 'nobody asked'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
That refusal is the design working. A takedown reaches the door instantly (it is
|
|
144
|
+
a row) and reaches the site at its next build (it reads a file), so a site built
|
|
145
|
+
without the DSN would keep publishing what the door already refuses. Set
|
|
146
|
+
`KSOR_DB_URL` on the site build as well as on the door.
|
|
147
|
+
|
|
148
|
+
## Authorization, or the deliberate absence of it
|
|
149
|
+
|
|
150
|
+
`ksor serve` **refuses to boot unauthenticated on a public bind.** There is no
|
|
151
|
+
auth-off default, and that refusal is the last real step of a deployment. Two
|
|
152
|
+
ways past it:
|
|
153
|
+
|
|
154
|
+
- **Configure the SSO door** — `KSOR_SSO_URL`, `KSOR_MCP_RESOURCE_URL`,
|
|
155
|
+
`KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for two different authorization
|
|
156
|
+
servers: [authorization.md](./authorization.md).
|
|
157
|
+
- **Set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`** — a deliberate decision that
|
|
158
|
+
serves your whole record to anyone who can reach the port. Correct for a
|
|
159
|
+
genuinely public record, or behind your own gateway. Never as a way to get a
|
|
160
|
+
deploy green.
|
|
161
|
+
|
|
162
|
+
Check which one you got. `/health` says so plainly:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"corpus_id": "book",
|
|
167
|
+
"abstain_gate": "OFF (no floor declared — will not refuse out-of-corpus questions)",
|
|
168
|
+
"embedding_space": "gemini-embedding-001/d1536 ok",
|
|
169
|
+
"auth": "disabled"
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`"auth":"disabled"` on a public host means the second option is in effect.
|
|
174
|
+
|
|
175
|
+
## What a cold start costs
|
|
176
|
+
|
|
177
|
+
The door is built for a runtime that suspends it. It holds **no idle database
|
|
178
|
+
connections** — the pool minimum is 0 and an unused connection closes after 10s
|
|
179
|
+
— so a quiet instance keeps nothing open against a serverless Postgres, and the
|
|
180
|
+
first request after a suspend both wakes the database and retries the connect
|
|
181
|
+
rather than failing.
|
|
182
|
+
|
|
183
|
+
Measured against a live deployment on Vercel, Neon behind it, an 81-document
|
|
184
|
+
record of 6,963 chunks:
|
|
185
|
+
|
|
186
|
+
| | |
|
|
187
|
+
| -------------------------------------- | --------- |
|
|
188
|
+
| warm request | **~1.7s** |
|
|
189
|
+
| cold start (container + database wake) | **~9.1s** |
|
|
190
|
+
|
|
191
|
+
Most of the cold number is the two wakes, not ksor. If that matters for your
|
|
192
|
+
readers, the levers are your host's minimum instance count and your database's
|
|
193
|
+
suspend delay — both outside this tool.
|
|
194
|
+
|
|
195
|
+
`SIGTERM` drains and exits within 8s (`KSOR_DRAIN_TIMEOUT_MS`), inside the ~10s
|
|
196
|
+
a scale-to-zero runtime usually allows before `SIGKILL`.
|
|
197
|
+
|
|
198
|
+
## Verifying a deployment
|
|
199
|
+
|
|
200
|
+
In order, because each answers a different question:
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
B=https://your-host
|
|
204
|
+
|
|
205
|
+
curl -s $B/health # the door booted, and on which posture
|
|
206
|
+
curl -s $B/ready # the database answers
|
|
207
|
+
curl -s -o /dev/null -w '%{http_code}\n' $B/ # the site is served
|
|
208
|
+
|
|
209
|
+
curl -s -X POST $B/mcp \
|
|
210
|
+
-H 'content-type: application/json' \
|
|
211
|
+
-H 'accept: application/json, text/event-stream' \
|
|
212
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
|
213
|
+
"protocolVersion":"2025-11-25","capabilities":{},
|
|
214
|
+
"clientInfo":{"name":"probe","version":"1"}}}'
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If `/health` answers but a search returns nothing, the door is fine and the
|
|
218
|
+
**record was never published** — go to [ingesting.md](./ingesting.md). A first
|
|
219
|
+
deploy with no ingest serves an empty record, which is the single most common
|
|
220
|
+
"it deployed but does not work".
|
|
221
|
+
|
|
222
|
+
If `/mcp` returns HTML, the catch-all rewrite is matching before the door's
|
|
223
|
+
route.
|
|
224
|
+
|
|
225
|
+
## Deploying anywhere else
|
|
226
|
+
|
|
227
|
+
Nothing above is Vercel-specific except `vercel.json`. On any other host:
|
|
228
|
+
|
|
229
|
+
1. Build the site — `pnpm build` — and upload `system/site/out/` as static files.
|
|
230
|
+
2. Build and run the container, giving it the environment above.
|
|
231
|
+
3. Put the door at `/mcp` on the same domain, or a different one; if it is a
|
|
232
|
+
different domain, set `KSOR_MCP_RESOURCE_URL` to the door's real URL, since
|
|
233
|
+
that value is the record's identity in a token's audience, not a guess.
|
package/docs/index.md
CHANGED
|
@@ -32,6 +32,18 @@ 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
|
+
- **[tool-surface.md](./tool-surface.md)** — shaping what agents see.
|
|
36
|
+
`system/gateways/content.ts` is emitted, adopter-owned and deletable; it
|
|
37
|
+
decides tool names, what the record says it covers, and how much of the
|
|
38
|
+
caller's context an answer costs. Includes the measurements.
|
|
39
|
+
- **[deploying.md](./deploying.md)** — getting both surfaces onto a host. The
|
|
40
|
+
scaffold emits a `Dockerfile` that names no vendor, and a `vercel.json` that
|
|
41
|
+
points at it to put the site and the MCP door behind one domain. Includes
|
|
42
|
+
what a cold start costs, measured.
|
|
43
|
+
- **[ingesting.md](./ingesting.md)** — publishing the record and keeping it
|
|
44
|
+
current. Serving does not publish, so a first deploy with no ingest serves
|
|
45
|
+
an empty record; this is the page that explains why, where ingest belongs
|
|
46
|
+
(never inside the container), and how the abstention gate gets turned on.
|
|
35
47
|
- **[authorization.md](./authorization.md)** — putting the record behind an
|
|
36
48
|
authorization server, with worked recipes for two of them, executed rather
|
|
37
49
|
than written. `ksor serve` refuses to boot unauthenticated on a public bind,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Ingesting
|
|
3
|
+
status: draft
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Publishing the record — `ksor ingest`
|
|
7
|
+
|
|
8
|
+
Serving does not publish. That split is deliberate and it is the thing most
|
|
9
|
+
worth understanding before a first deployment: `ksor serve` opens a port against
|
|
10
|
+
whatever generation is already active, and `ksor ingest` is what makes a
|
|
11
|
+
generation exist. A container that ingested on boot would pay the whole record's
|
|
12
|
+
embedding cost on every cold start and would need write credentials at runtime.
|
|
13
|
+
|
|
14
|
+
So **a first deploy with no ingest serves an empty record.** It is not broken;
|
|
15
|
+
nothing was ever published to it.
|
|
16
|
+
|
|
17
|
+
## The order, once
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm provision # ksor schema --apply, then ksor grant
|
|
21
|
+
pnpm refresh # ksor ingest --flip, then ksor gc
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`provision` is separate because applying DDL and granting ingest are acts an
|
|
25
|
+
operator performs, not side effects of starting a server. Both are re-runnable
|
|
26
|
+
and report what they found — an applied schema says "already applied", an
|
|
27
|
+
existing grant says "already granted".
|
|
28
|
+
|
|
29
|
+
Then, after every change to `knowledge/`:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pnpm refresh
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What a generation is
|
|
36
|
+
|
|
37
|
+
Each ingest builds a **fresh generation** — invisible until activated — and
|
|
38
|
+
carries every unchanged embedding forward from the last complete one, matched by
|
|
39
|
+
content hash. Only changed or previously-failed chunks are re-embedded.
|
|
40
|
+
|
|
41
|
+
`--flip` swaps the active pointer. The previous generation stays as a rollback
|
|
42
|
+
target; `ksor gc` reaps the ones nothing points at any more.
|
|
43
|
+
|
|
44
|
+
Three consequences worth knowing:
|
|
45
|
+
|
|
46
|
+
- **Re-ingest is cheap.** An ordinary edit makes a handful of provider calls,
|
|
47
|
+
not a corpus-worth.
|
|
48
|
+
- **An unchanged record costs nothing at all.** Ingest compares what it just
|
|
49
|
+
read against the generation already serving and, when they are identical at
|
|
50
|
+
the same commit, consumes no generation and writes no rows:
|
|
51
|
+
`unchanged — generation N already serves this corpus`.
|
|
52
|
+
- **Reordering is free.** Changing `order:` frontmatter re-ingests with no
|
|
53
|
+
embedding at all; only the ordering moved.
|
|
54
|
+
|
|
55
|
+
A flip that would drop more than `KSOR_MAX_SHRINK` of the record (default
|
|
56
|
+
`0.15`, i.e. 15%) **refuses**. When a large deletion is intended, say so:
|
|
57
|
+
`KSOR_ALLOW_SHRINK=1`.
|
|
58
|
+
|
|
59
|
+
## Where ingest runs — not on the host
|
|
60
|
+
|
|
61
|
+
Ingest is a long job. It embeds every new chunk through the provider, and that
|
|
62
|
+
is bounded by the provider's throughput rather than by anything ksor does.
|
|
63
|
+
|
|
64
|
+
**Measured:** an 81-document book — 6,963 chunks — took **about 50 minutes**
|
|
65
|
+
against a remote Postgres on a first, cold ingest with nothing to carry forward.
|
|
66
|
+
|
|
67
|
+
Compare that with the request timeouts of the platforms people reach for first:
|
|
68
|
+
a serverless function caps out in the region of 300–800 seconds depending on
|
|
69
|
+
plan. Ingest is **an order of magnitude past that**, so it cannot be a route, a
|
|
70
|
+
build step, or anything else the platform is allowed to kill.
|
|
71
|
+
|
|
72
|
+
Run it where nothing is watching a clock:
|
|
73
|
+
|
|
74
|
+
- **From your machine**, with `KSOR_DB_URL` pointing at the deployment's
|
|
75
|
+
database. This is the honest default for a record one person maintains.
|
|
76
|
+
- **From CI**, as a job triggered on changes to `knowledge/` — the right shape
|
|
77
|
+
once more than one person edits, because it makes publishing an auditable
|
|
78
|
+
event rather than something someone did locally.
|
|
79
|
+
|
|
80
|
+
It does not matter whether the process that ingests is the process that serves.
|
|
81
|
+
They meet in the database and nowhere else.
|
|
82
|
+
|
|
83
|
+
### If an ingest is interrupted
|
|
84
|
+
|
|
85
|
+
Nothing is corrupted — an unactivated generation is invisible by construction.
|
|
86
|
+
Run it again. The next attempt finds the incomplete generation's work and
|
|
87
|
+
carries forward everything that was already embedded, including from a
|
|
88
|
+
generation that was still `building` when it died, so a resumed run pays only
|
|
89
|
+
for what the first one had not reached.
|
|
90
|
+
|
|
91
|
+
## Endpoints, poolers, and what actually matters
|
|
92
|
+
|
|
93
|
+
If your provider offers both a **pooled** and a **direct** endpoint (Neon's
|
|
94
|
+
`-pooler` host, or anything on port 6432), ksor detects which one you gave it
|
|
95
|
+
and says so in the boot report. That line is **informational**: it classifies,
|
|
96
|
+
it never transforms. The hazard it descends from — a transaction pooler and
|
|
97
|
+
server-side prepared statements — cannot arise here, because node-postgres does
|
|
98
|
+
not auto-prepare.
|
|
99
|
+
|
|
100
|
+
For the record: the 6,963-chunk ingest above ran through a **pooled** endpoint
|
|
101
|
+
without incident, and the same DSN serves. Use whichever your provider gives
|
|
102
|
+
you, and reach for the direct endpoint only if you actually hit pooler
|
|
103
|
+
connection limits under a parallel ingest — not pre-emptively.
|
|
104
|
+
|
|
105
|
+
`KSOR_DB_POOLED_ENDPOINT=1` forces the classification when your host name does
|
|
106
|
+
not announce itself.
|
|
107
|
+
|
|
108
|
+
## Turning the abstention gate on
|
|
109
|
+
|
|
110
|
+
This is a separate act, done once, **after** the record is serving — because it
|
|
111
|
+
is a measurement of this corpus in this embedding space, and there is nothing to
|
|
112
|
+
measure until the corpus is in there.
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
pnpm exec ksor calibrate --instance instance.md
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It prints a recommended `vector_floor`. Paste it in with the date you measured
|
|
119
|
+
it, and restart:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
retrieval:
|
|
123
|
+
vector_floor: 0.55 # measured by ksor calibrate on 2026-08-23
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Until you do, `/health` reports the gate as `OFF (no floor declared — will not
|
|
127
|
+
refuse out-of-corpus questions)` and every search envelope carries
|
|
128
|
+
`gate: "off"`. That is honest absence, and an agent reading the envelope knows
|
|
129
|
+
an answer is not evidence of coverage.
|
|
130
|
+
|
|
131
|
+
**Never copy a floor from another corpus.** The number means nothing away from
|
|
132
|
+
the corpus and embedding space it was measured in.
|
|
133
|
+
|
|
134
|
+
If calibrate reports `NOT separable`, read what it names underneath: it lists
|
|
135
|
+
the out-of-corpus probes that scored at or above your weakest in-corpus
|
|
136
|
+
question. Usually one of them is a question your record actually answers, and it
|
|
137
|
+
belongs on the in-corpus side — moving it separates the measurement. Sometimes
|
|
138
|
+
it is a genuine near-miss the corpus cannot separate, and then the floor
|
|
139
|
+
correctly stays uncalibrated.
|
|
140
|
+
|
|
141
|
+
## Withdrawing a document
|
|
142
|
+
|
|
143
|
+
A takedown is a row, not a file, so it reaches the door immediately:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
pnpm exec ksor takedown --instance instance.md <stable-id> \
|
|
147
|
+
--reason "legal request 2026-08" --actor "j.smith"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`--actor` is required, and there is no default. A name taken from the
|
|
151
|
+
environment reads like a person and is whatever the shell happened to be
|
|
152
|
+
(`runner` under CI, `root` in a container) — worse than no name at all in the
|
|
153
|
+
one row that exists to record who did this.
|
|
154
|
+
|
|
155
|
+
**The site stops at its next build.** It reads `.ksor-denylist.json`, which
|
|
156
|
+
`pnpm build` refreshes via `pnpm export-denylist`. So after a takedown, rebuild
|
|
157
|
+
and redeploy the site, or the human surface keeps publishing what the agent
|
|
158
|
+
surface already refuses. See [deploying.md](./deploying.md) for why that build
|
|
159
|
+
needs `KSOR_DB_URL`.
|