@escalay/kit 0.0.0-stage → 1.7.0

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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Embeddings and vector search as apps built on the kit share them. Gemini Embedding 2 (through OpenRouter) puts text and
3
+ * images in one 768-dimension space, so an item gets up to two vectors:
4
+ * look its picture, flattened onto white at most 384 px: "looks like this", search by a pasted image
5
+ * meaning its words in Gemini's document format: what it shows and is about
6
+ * and a query, typed or pasted, is compared against either. Vectors come back unit length: a dot product is the cosine.
7
+ *
8
+ * A paid call is never lost: `embedMissing` appends each batch to an append-only JSONL ledger the moment it returns,
9
+ * keyed by a hash of model, size and content, so a crash, a rerun or another machine pays only for what changed. A
10
+ * 402/403 from OpenRouter (the key's cap or the account's credit) is a `SpendCapError` that stops the run.
11
+ *
12
+ * Plain fetch for texts and images alike: the AI SDK's embed() takes strings only.
13
+ *
14
+ * @example
15
+ * const embedder = createEmbedder({ apiKey: process.env.OPENROUTER_API_KEY! })
16
+ * await embedMissing({ file: 'vectors/meaning.jsonl', items: notes.map((note) => ({ key: note.id, content: documentText(note.client, [note.industry]) })), embed: embedder.documents })
17
+ * const meaning = vectorSpace(await readLedger('vectors/meaning.jsonl'))
18
+ * standouts(meaning, await embedder.query('a fox made of triangles'), { limit: 24 })
19
+ */
20
+ import { createHash } from 'node:crypto'
21
+ import { appendFile, readFile } from 'node:fs/promises'
22
+
23
+ export const EMBEDDING_MODEL = 'google/gemini-embedding-2'
24
+ export const DIMENSIONS = 768
25
+ export const IMAGE_PX = 384
26
+ /** The least a row must stand out (standard deviations above the query's mean) to count as a match; see `standouts`. */
27
+ export const MIN_Z = 3.5
28
+
29
+ const ENDPOINT = 'https://openrouter.ai/api/v1/embeddings'
30
+ // Gemini's batchEmbedContents limit for texts; images are heavier per request
31
+ const TEXTS_PER_REQUEST = 100
32
+ const IMAGES_PER_REQUEST = 32
33
+ const RETRY_ON = /\b(408|429|5\d\d)\b|fetch failed|ECONNRESET|ETIMEDOUT|terminated/
34
+
35
+ /** OpenRouter refused for money: the key's own cap or the account's credit ran out. Stops a build; a route answers 503. */
36
+ export class SpendCapError extends Error {}
37
+
38
+ /** A text, or an image as a data URL (`imageDataUrl`). */
39
+ export type EmbeddingInput = string | { image: string }
40
+ export type Embedded = { vectors: Float32Array[]; tokens: number; cost: number }
41
+ export type LedgerLine = { key: string; hash: string; vector: string }
42
+ export type VectorSpace = { keys: string[]; matrix: Float32Array; rowOf: Map<string, number> }
43
+ export type Match = { key: string; score: number }
44
+
45
+ /**
46
+ * A document in the format Gemini Embedding 2 reads (it has no task type; it reads these prefixes). Empty parts drop.
47
+ *
48
+ * @example
49
+ * documentText('Doctor with a patient', ['A consultation.', 'Category: Medical']) // 'title: Doctor with a patient | text: A consultation.. Category: Medical'
50
+ */
51
+ export const documentText = (title: string, parts: (string | null | undefined | false)[]) => `title: ${title} | text: ${parts.filter(Boolean).join('. ')}`
52
+
53
+ /** A search in Gemini's query format. @example queryText(' dark mode ') // 'task: search result | query: dark mode' */
54
+ export const queryText = (query: string) => `task: search result | query: ${query.trim()}`
55
+
56
+ /** Changes with the content, the model or the size, so the ledger re-embeds exactly what went stale. @example hashOf(documentText(…)) */
57
+ export const hashOf = (content: string | Uint8Array) => createHash('sha256').update(`${EMBEDDING_MODEL}|${DIMENSIONS}|`).update(content).digest('hex').slice(0, 16)
58
+
59
+ /**
60
+ * Any image → the JPEG data URL a look vector is made from: at most 384 px (258 tokens), transparency on white. Loads
61
+ * `sharp` on first use, so an app that embeds only text never needs it.
62
+ *
63
+ * @example
64
+ * await imageDataUrl(pngBuffer)
65
+ */
66
+ export const imageDataUrl = async (input: Uint8Array | string) => {
67
+ const { default: sharp } = await import('sharp')
68
+ const jpeg = await sharp(input).resize(IMAGE_PX, IMAGE_PX, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()
69
+ return `data:image/jpeg;base64,${jpeg.toString('base64')}`
70
+ }
71
+
72
+ const toUnitVector = (values: ArrayLike<number>) => {
73
+ const vector = Float32Array.from(values)
74
+ let length = 0
75
+ for (const value of vector) length += value * value
76
+ length = Math.sqrt(length) || 1
77
+ return vector.map((value) => value / length)
78
+ }
79
+
80
+ /** A vector as the ledger stores it, and back. @example decodeVector(encodeVector(v)) */
81
+ export const encodeVector = (vector: Float32Array) => Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength).toString('base64')
82
+ export const decodeVector = (base64: string) => {
83
+ const bytes = Buffer.from(base64, 'base64')
84
+ return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
85
+ }
86
+
87
+ const withRetries = async <T>(attempt: () => Promise<T>, tries = 6): Promise<T> => {
88
+ for (let n = 1; ; n++) {
89
+ try {
90
+ return await attempt()
91
+ } catch (error) {
92
+ if (error instanceof SpendCapError || n >= tries || !RETRY_ON.test(String(error))) throw error
93
+ await new Promise((resolve) => setTimeout(resolve, Math.min(60_000, 2 ** n * 500)))
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * One OpenRouter request: inputs → unit vectors in order, with what it says it cost. Retries rate limits and server
100
+ * errors with backoff; throws `SpendCapError` on 402/403.
101
+ *
102
+ * @example
103
+ * const { vectors, cost } = await embedRequest(key, [queryText('soup'), { image: await imageDataUrl(png) }])
104
+ */
105
+ export const embedRequest = (apiKey: string, inputs: EmbeddingInput[], fetchImpl: typeof fetch = fetch): Promise<Embedded> =>
106
+ withRetries(async () => {
107
+ const response = await fetchImpl(ENDPOINT, {
108
+ method: 'POST',
109
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
110
+ body: JSON.stringify({ model: EMBEDDING_MODEL, dimensions: DIMENSIONS, input: inputs.map((input) => (typeof input === 'string' ? input : { content: [{ type: 'image_url', image_url: { url: input.image } }] })) }),
111
+ })
112
+ const body = (await response.json().catch(() => ({}))) as { data?: { index: number; embedding: number[] }[]; usage?: { prompt_tokens?: number; cost?: number }; error?: { message?: string } }
113
+ if (response.status === 402 || response.status === 403) throw new SpendCapError(`OpenRouter ${response.status}: ${body.error?.message ?? 'refused'}`)
114
+ if (!response.ok || body.data?.length !== inputs.length) throw new Error(`OpenRouter ${response.status}: ${body.error?.message ?? `${body.data?.length ?? 0} of ${inputs.length} embeddings`}`)
115
+ return { vectors: [...body.data].sort((a, b) => a.index - b.index).map((item) => toUnitVector(item.embedding)), tokens: body.usage?.prompt_tokens ?? 0, cost: body.usage?.cost ?? 0 }
116
+ })
117
+
118
+ const inBatches = async (apiKey: string, fetchImpl: typeof fetch, inputs: EmbeddingInput[], size: number): Promise<Embedded> => {
119
+ const batches = await Promise.all(Array.from({ length: Math.ceil(inputs.length / size) }, (_, n) => embedRequest(apiKey, inputs.slice(n * size, (n + 1) * size), fetchImpl)))
120
+ return { vectors: batches.flatMap((batch) => batch.vectors), tokens: batches.reduce((sum, batch) => sum + batch.tokens, 0), cost: batches.reduce((sum, batch) => sum + batch.cost, 0) }
121
+ }
122
+
123
+ /**
124
+ * The embedder an app holds: documents and images in batches for a build, and queries (typed or a picture) for a
125
+ * search, each cached (the last `cacheSize`) so a repeated query costs nothing.
126
+ *
127
+ * @example
128
+ * const embedder = createEmbedder({ apiKey: env.OPENROUTER_API_KEY })
129
+ * await embedder.query('a lettermark with negative space') // Float32Array(768)
130
+ * await embedder.image(await imageDataUrl(upload)) // a picture as a query
131
+ */
132
+ export const createEmbedder = ({ apiKey, fetchImpl = fetch, cacheSize = 500 }: { apiKey: string; fetchImpl?: typeof fetch; cacheSize?: number }) => {
133
+ const cache = new Map<string, Float32Array>()
134
+ const cached = async (id: string, input: EmbeddingInput) => {
135
+ const hit = cache.get(id)
136
+ if (hit) return hit
137
+ const [vector] = (await embedRequest(apiKey, [input], fetchImpl)).vectors
138
+ if (cache.size >= cacheSize) cache.delete(cache.keys().next().value!)
139
+ cache.set(id, vector!)
140
+ return vector!
141
+ }
142
+ return {
143
+ /** Documents (`documentText`) → vectors in order, 100 per request. */
144
+ documents: (texts: string[]) => inBatches(apiKey, fetchImpl, texts, TEXTS_PER_REQUEST),
145
+ /** Image data URLs → vectors in order, 32 per request. */
146
+ images: (dataUrls: string[]) => inBatches(apiKey, fetchImpl, dataUrls.map((image) => ({ image })), IMAGES_PER_REQUEST),
147
+ /** A typed search in Gemini's query format; case and spacing don't make a new call. */
148
+ query: (query: string) => cached(`q:${query.trim().toLowerCase()}`, queryText(query.trim().toLowerCase())),
149
+ /** A picture as a query (`imageDataUrl` first). */
150
+ image: (dataUrl: string) => cached(`i:${hashOf(dataUrl)}`, { image: dataUrl }),
151
+ }
152
+ }
153
+
154
+ export type Embedder = ReturnType<typeof createEmbedder>
155
+
156
+ // ---- Ledgers: one JSONL per space, append-only, the last line per key wins ----
157
+
158
+ /** A space's ledger by key; a missing file is an empty ledger. @example const ledger = await readLedger('vectors/look.jsonl') */
159
+ export const readLedger = async (file: string): Promise<Map<string, LedgerLine>> => {
160
+ const text = await readFile(file, 'utf8').catch(() => '')
161
+ return new Map(text.split('\n').filter(Boolean).map((line) => JSON.parse(line) as LedgerLine).map((line) => [line.key, line]))
162
+ }
163
+
164
+ /** Adds lines to a ledger; never rewrites it. @example await appendLedger(file, [{ key, hash, vector: encodeVector(v) }]) */
165
+ export const appendLedger = (file: string, lines: LedgerLine[]) => appendFile(file, lines.map((line) => `${JSON.stringify(line)}\n`).join(''))
166
+
167
+ export type LedgerItem = {
168
+ key: string
169
+ /** What the vector is made from: a document's text, or an image's bytes. Its hash decides whether it is stale. */
170
+ content: string | Uint8Array
171
+ /** What to send, when it differs from `content` (an image's data URL); made only for stale items. */
172
+ input?: () => EmbeddingInput | Promise<EmbeddingInput>
173
+ }
174
+
175
+ export type EmbedRun = { embedded: number; fresh: number; failed: string[]; tokens: number; cost: number; stopped?: 'budget' | 'spend cap' }
176
+
177
+ /**
178
+ * Embeds the items whose vectors are missing or stale and appends each batch to the ledger as soon as it returns, so
179
+ * nothing paid for is lost. Starts no new batch once `budget` (USD, as OpenRouter reports it) is spent, and stops on a
180
+ * `SpendCapError`; a rerun picks up where it stopped. `limit` embeds at most that many (a pilot). An item whose input
181
+ * can't be made (a picture that won't render) is skipped and named in `failed`; the rest of its batch still goes.
182
+ *
183
+ * @example
184
+ * await embedMissing({ file: 'vectors/look.jsonl', items: notes.map((l) => ({ key: l.id, content: l.svg, input: async () => ({ image: await imageDataUrl(l.png) }) })), embed: (inputs) => embedRequest(key, inputs), batchSize: 32, budget: 2, limit: 200 })
185
+ */
186
+ export const embedMissing = async ({ file, items, embed, batchSize = 100, concurrency = 4, budget = Infinity, limit = Infinity, log = () => {} }: { file: string; items: LedgerItem[]; embed: (inputs: EmbeddingInput[]) => Promise<Embedded>; batchSize?: number; concurrency?: number; budget?: number; limit?: number; log?: (message: string) => void }): Promise<EmbedRun> => {
187
+ const ledger = await readLedger(file)
188
+ const outdated = items.map((item) => ({ item, hash: hashOf(item.content) })).filter(({ item, hash }) => ledger.get(item.key)?.hash !== hash)
189
+ const stale = outdated.slice(0, limit)
190
+ const run: EmbedRun = { embedded: 0, fresh: items.length - outdated.length, failed: [], tokens: 0, cost: 0 }
191
+ const batches = Array.from({ length: Math.ceil(stale.length / batchSize) }, (_, n) => stale.slice(n * batchSize, (n + 1) * batchSize))
192
+ let next = 0
193
+ const worker = async () => {
194
+ while (next < batches.length && !run.stopped) {
195
+ if (run.cost >= budget) {
196
+ run.stopped = 'budget'
197
+ return
198
+ }
199
+ const made = await Promise.allSettled(batches[next++]!.map(async ({ item, hash }) => ({ item, hash, input: await (item.input?.() ?? (typeof item.content === 'string' ? item.content : Promise.reject(new Error(`${item.key}: bytes need an input() that makes the image data URL`)))) })))
200
+ const batch = made.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
201
+ for (const result of made)
202
+ if (result.status === 'rejected') {
203
+ const reason = String((result.reason as Error)?.message ?? result.reason)
204
+ run.failed.push(reason)
205
+ log(`skipped: ${reason}`)
206
+ }
207
+ if (!batch.length) continue
208
+ const inputs = batch.map(({ input }) => input)
209
+ try {
210
+ const { vectors, tokens, cost } = await embed(inputs)
211
+ await appendLedger(file, batch.map(({ item, hash }, n) => ({ key: item.key, hash, vector: encodeVector(vectors[n]!) })))
212
+ run.embedded += batch.length
213
+ run.tokens += tokens
214
+ run.cost += cost
215
+ log(`${run.embedded}/${stale.length} embedded · ${run.tokens} tokens · $${run.cost.toFixed(4)}`)
216
+ } catch (error) {
217
+ if (!(error instanceof SpendCapError)) throw error
218
+ run.stopped = 'spend cap'
219
+ log(String(error.message))
220
+ }
221
+ }
222
+ }
223
+ await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker))
224
+ return run
225
+ }
226
+
227
+ // ---- Search ----
228
+
229
+ /**
230
+ * A ledger as a searchable matrix; `keep` drops keys no longer in the catalog.
231
+ *
232
+ * @example
233
+ * const look = vectorSpace(await readLedger('vectors/look.jsonl'), (key) => catalog.has(key))
234
+ */
235
+ export const vectorSpace = (ledger: Map<string, LedgerLine>, keep: (key: string) => boolean = () => true): VectorSpace => {
236
+ const keys = [...ledger.keys()].filter(keep)
237
+ const matrix = new Float32Array(keys.length * DIMENSIONS)
238
+ keys.forEach((key, row) => matrix.set(decodeVector(ledger.get(key)!.vector), row * DIMENSIONS))
239
+ return { keys, matrix, rowOf: new Map(keys.map((key, row) => [key, row])) }
240
+ }
241
+
242
+ /** One key's vector, or undefined. @example vectorOf(look, 'note:1') */
243
+ export const vectorOf = (space: VectorSpace, key: string) => {
244
+ const row = space.rowOf.get(key)
245
+ return row === undefined ? undefined : space.matrix.subarray(row * DIMENSIONS, (row + 1) * DIMENSIONS)
246
+ }
247
+
248
+ /** The normalised mean of some rows: a collection's style as one vector. @example centroid(look, pack.keys) */
249
+ export const centroid = (space: VectorSpace, keys: string[]) => {
250
+ const sum = new Float32Array(DIMENSIONS)
251
+ for (const key of keys) {
252
+ const row = vectorOf(space, key)
253
+ if (row) for (let d = 0; d < DIMENSIONS; d++) sum[d]! += row[d]!
254
+ }
255
+ return toUnitVector(sum)
256
+ }
257
+
258
+ const similarities = (space: VectorSpace, query: Float32Array) => {
259
+ const scores = new Float32Array(space.keys.length)
260
+ for (let row = 0, at = 0; row < scores.length; row++) {
261
+ let sum = 0
262
+ for (let d = 0; d < DIMENSIONS; d++, at++) sum += space.matrix[at]! * query[d]!
263
+ scores[row] = sum
264
+ }
265
+ return scores
266
+ }
267
+
268
+ type Pick = { limit: number; keep?: (key: string) => boolean; groupOf?: (key: string) => string; perGroup?: number }
269
+
270
+ // Sorts only the rows that pass, then caps each group (an app, a pack) so a page isn't one group's items
271
+ const best = (space: VectorSpace, scores: Float32Array, pass: (row: number) => boolean, { limit, keep = () => true, groupOf, perGroup = Infinity }: Pick) => {
272
+ const rows: number[] = []
273
+ for (let row = 0; row < scores.length; row++) if (pass(row) && keep(space.keys[row]!)) rows.push(row)
274
+ rows.sort((a, b) => scores[b]! - scores[a]!)
275
+ const taken = new Map<string, number>()
276
+ const picked: number[] = []
277
+ for (const row of rows) {
278
+ if (picked.length >= limit) break
279
+ const group = groupOf?.(space.keys[row]!)
280
+ if (group !== undefined) {
281
+ if ((taken.get(group) ?? 0) >= perGroup) continue
282
+ taken.set(group, (taken.get(group) ?? 0) + 1)
283
+ }
284
+ picked.push(row)
285
+ }
286
+ return picked
287
+ }
288
+
289
+ const rounded = (value: number) => Math.round(value * 1000) / 1000
290
+
291
+ /**
292
+ * The `limit` rows most like `query` that `keep` allows, best first, at most `perGroup` per `groupOf`. For "similar to
293
+ * this item", where there is always an answer.
294
+ *
295
+ * @example
296
+ * nearest(look, vectorOf(look, 'note:1')!, { limit: 12, keep: (key) => key !== 'note:1' })
297
+ */
298
+ export const nearest = (space: VectorSpace, query: Float32Array, pick: Pick): Match[] => {
299
+ const scores = similarities(space, query)
300
+ return best(space, scores, () => true, pick).map((row) => ({ key: space.keys[row]!, score: rounded(scores[row]!) }))
301
+ }
302
+
303
+ /**
304
+ * The rows that stand out for this query, best first, with how far they stand out (`z`). Gemini's similarities sit in
305
+ * a narrow band (a nonsense query scored 0.52–0.60 against everything, real matches 0.55–0.71), so no fixed floor
306
+ * separates them. Scoring every row gives the query's own distribution: a match is `minZ` standard deviations above its
307
+ * mean. Noise alone reaches about √(2 ln n) among n rows, so the default bar rises with the catalog (3.5 at 552 rows,
308
+ * 4.12 at 42k, measured on a 42k-row catalogue). `keep` applies after, so a small filtered set can still stand out.
309
+ * A query matching nothing returns nothing. `minScore` adds a raw-similarity floor for a corpus that is all about one
310
+ * subject, where everything stands out a little and only the score separates a real match.
311
+ *
312
+ * @example
313
+ * standouts(meaning, await embedder.query('a fox'), { limit: 24, groupOf: (key) => authorOf(key), perGroup: 3 })
314
+ */
315
+ export const standouts = (space: VectorSpace, query: Float32Array, { minZ, minScore = -Infinity, ...pick }: Pick & { minZ?: number; minScore?: number }): (Match & { z: number })[] => {
316
+ const scores = similarities(space, query)
317
+ if (scores.length < 2) return best(space, scores, () => true, pick).map((row) => ({ key: space.keys[row]!, score: rounded(scores[row]!), z: 0 }))
318
+ let mean = 0
319
+ for (const score of scores) mean += score / scores.length
320
+ let variance = 0
321
+ for (const score of scores) variance += (score - mean) ** 2 / scores.length
322
+ const deviation = Math.sqrt(variance) || 1
323
+ const bar = minZ ?? Math.max(MIN_Z, Math.sqrt(2 * Math.log(scores.length)) - 0.5)
324
+ const floor = Math.max(mean + bar * deviation, minScore)
325
+ return best(space, scores, (row) => scores[row]! >= floor, pick).map((row) => ({ key: space.keys[row]!, score: rounded(scores[row]!), z: rounded((scores[row]! - mean) / deviation) }))
326
+ }
@@ -0,0 +1,21 @@
1
+ # web
2
+
3
+ The front door: an app's own web pages served by the API. An optional public page at `/`, the browser app under
4
+ `/app` behind the sign-in, old addresses redirected, and hashed assets cached for good.
5
+
6
+ Needs: `core`, `@hono/node-server`.
7
+
8
+ ```ts
9
+ import { frontDoorOpen, serveFrontDoor } from './kit/index.js'
10
+
11
+ const web = { dir: join(APP_DIR, 'web/dist'), private: true }
12
+ const app = createAgentApi({ …, auth: { …, open: frontDoorOpen(web) } }) // the pages' files carry no data
13
+ // … routes, guide, MCP …
14
+ serveFrontDoor(app, web) // last: it answers what nothing else did
15
+ ```
16
+
17
+ ## Rules
18
+
19
+ - Register it last. Vite's hashed `/assets/` are served unversioned (a `?v=` on them loads each module twice).
20
+ - In private mode `/` redirects to `/app` and nothing public names the service.
21
+ - Not to be confused with the browser modules (`browser/`), which go into the frontend.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A browser app on the API's own origin, with a public front page in front of it:
3
+ * / `landing.html`: what the service is and how agents connect (no data), so scanners and agents find
4
+ * the discovery files; `Accept: text/markdown` still gets the agent guide (the kit answers it first)
5
+ * /app, /app/* the app: a file of that name when there is one, else `index.html`, so deep links open the app
6
+ * moved paths the app's addresses from before it moved under /app, and `/?query` deep links, redirect there
7
+ * Files at the top of `dir` (the landing's script and styles, a plain viewer's `app.js`) are served as they are.
8
+ * Single sign-on gates only `/app` (the only paths Access covers, with the OAuth approval pages); the app's data calls carry its cookie, so the browser never
9
+ * holds an API key. `frontDoorOpen` lists what the API's auth must leave open: the page files, never data.
10
+ *
11
+ * The pages' own scripts and stylesheets are linked with `?v=<content hash>`, so a CDN or browser that keeps static
12
+ * files for hours (Cloudflare's browser TTL overrides `no-cache`) still loads the new ones after a deploy. Hashed build
13
+ * output (`immutable`) keeps its plain name: its chunks import each other by that name, and a second URL for the same
14
+ * module would load it twice (two React Query clients, two routers).
15
+ *
16
+ * Call `serveFrontDoor` last, after every API route: anything they didn't answer is a page.
17
+ *
18
+ * @example
19
+ * const web = { dir: join(APP_DIR, 'public'), moved: ['/notes'] }
20
+ * const app = createAgentApi({ …, home: false, auth: { …, open: frontDoorOpen(web) } })
21
+ * serveFrontDoor(app, web)
22
+ */
23
+ import { createHash } from 'node:crypto'
24
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { serveStatic } from '@hono/node-server/serve-static'
27
+ import type { Hono } from 'hono'
28
+
29
+ export type FrontDoorConfig = {
30
+ /** The built app: `index.html`, optionally `landing.html`, and its files. */
31
+ dir: string
32
+ /** Where the app lives (default `/app`). */
33
+ base?: string
34
+ /** Addresses the app answered before it moved under `base`, redirected there with their path and query. */
35
+ moved?: string[]
36
+ /** Hashed build output that never changes under its name (default `/assets/`). */
37
+ immutable?: string
38
+ /**
39
+ * Private (with the API's `private`): no front page, `/` goes to the app (behind the sign-in), the app's files need
40
+ * the same credentials as the API and are never kept in a shared cache, and every page says `noindex`.
41
+ */
42
+ private?: boolean
43
+ }
44
+
45
+ const topFiles = (dir: string) => (existsSync(dir) ? readdirSync(dir).filter((name) => name !== 'index.html' && statSync(join(dir, name)).isFile()) : [])
46
+
47
+ /**
48
+ * The paths the API's auth leaves open for the front door: `/`, the files at the top of `dir`, the immutable build
49
+ * output and the old addresses (they only redirect). `/app` itself stays behind the sign-in.
50
+ *
51
+ * @example
52
+ * auth: { realm: 'notes', keys, access, open: frontDoorOpen({ dir, moved: ['/notes'] }) }
53
+ */
54
+ export const frontDoorOpen = ({ dir, moved = [], immutable = '/assets/', private: hidden = false }: FrontDoorConfig) =>
55
+ hidden ? ['/', ...moved.map((prefix) => `${prefix}*`)] : ['/', `${immutable}*`, ...topFiles(dir).map((name) => `/${name}`), ...moved.map((prefix) => `${prefix}*`)]
56
+
57
+ /**
58
+ * A page with its local `.js` and `.css` links versioned by content: `src="app.js"` → `src="app.js?v=1a2b3c4d"`. Files
59
+ * under `immutable` are left as they are (their names are hashes already).
60
+ *
61
+ * @example
62
+ * versionAssets('<script src="app.js"></script>', dir) // '<script src="app.js?v=…"></script>'
63
+ */
64
+ export const versionAssets = (html: string, dir: string, immutable = '/assets/') =>
65
+ html.replace(/\b(src|href)="(\/?)([\w./-]+\.(?:js|css))"/g, (match, attribute: string, slash: string, file: string) => {
66
+ const path = join(dir, file)
67
+ if (file.includes('..') || `/${file}`.startsWith(immutable) || !existsSync(path)) return match
68
+ return `${attribute}="${slash}${file}?v=${createHash('sha256').update(readFileSync(path)).digest('hex').slice(0, 8)}"`
69
+ })
70
+
71
+ /** Serves the front page, the app under `base` and the redirects; false when `dir` has no `index.html`. @example serveFrontDoor(app, { dir }) */
72
+ export const serveFrontDoor = (app: Hono<any, any, any>, { dir, base = '/app', moved = [], immutable = '/assets/', private: hidden = false }: FrontDoorConfig) => {
73
+ const index = join(dir, 'index.html')
74
+ if (!existsSync(index)) return false
75
+ const noindex = (page: string) => (hidden && !page.includes('name="robots"') ? page.replace(/<head>/i, '<head><meta name="robots" content="noindex, nofollow">') : page)
76
+ const html = noindex(versionAssets(readFileSync(index, 'utf8'), dir, immutable))
77
+ const landing = !hidden && existsSync(join(dir, 'landing.html')) ? versionAssets(readFileSync(join(dir, 'landing.html'), 'utf8'), dir, immutable) : null
78
+
79
+ app.use(`${immutable}*`, async (c, next) => {
80
+ await next()
81
+ if (c.res.ok) c.res.headers.set('cache-control', `${hidden ? 'private' : 'public'}, max-age=31536000, immutable`)
82
+ })
83
+ app.get('/', (c) => {
84
+ // `/?item=…` was a deep link into the app before it moved
85
+ const search = new URL(c.req.url).search
86
+ if (search || !landing) return c.redirect(`${base}${search}`, search ? 301 : 302)
87
+ return c.html(landing, 200, { 'cache-control': 'no-cache' })
88
+ })
89
+ // A plain viewer refers to its files relatively, so `/app/app.js` is `/app.js`; `/app` itself is the page below
90
+ const appFiles = serveStatic({ root: dir, rewriteRequestPath: (path) => path.slice(base.length), onFound: (_path, c) => c.header('cache-control', 'no-cache') })
91
+ app.use(`${base}/*`, (c, next) => (c.req.path === base || c.req.path === `${base}/` ? next() : appFiles(c, next)))
92
+ app.use('*', serveStatic({ root: dir, onFound: (path, c) => { if (!path.includes(immutable)) c.header('cache-control', 'no-cache') } }))
93
+ app.get('*', (c, next) => {
94
+ const path = c.req.path
95
+ if (path === base || path.startsWith(`${base}/`)) return c.html(html, 200, { 'cache-control': 'no-cache' })
96
+ if (moved.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return c.redirect(`${base}${path}${new URL(c.req.url).search}`, 301)
97
+ return next()
98
+ })
99
+ return true
100
+ }
@@ -0,0 +1,41 @@
1
+ # __title__
2
+
3
+ An agent-ready HTTP API built on [api-kit](https://github.com/escalay/api-kit) (the standard: its
4
+ `docs/STANDARD.md`). Every route is documented OpenAPI, every answer HAL, every error problem+json, and every route
5
+ an MCP tool.
6
+
7
+ ## Commands
8
+
9
+ `pnpm install` · `pnpm test` · `pnpm typecheck` · `pnpm guide` (after editing `guide/`)
10
+
11
+ - `pnpm dev`: the API on Node at http://localhost:4400 (`/docs`, `/llms.txt`, `/mcp`) <!-- runtime:node -->
12
+ - `pnpm __worker_dev__`: the Worker locally over a local D1 (`/docs`, `/llms.txt`, `/mcp`) <!-- runtime:workers -->
13
+ - `pnpm migrations` after adding a migration step; `pnpm run deploy` (not `pnpm deploy`, which is pnpm's own command): guide, migrations, `wrangler d1 migrations apply`, `wrangler deploy` <!-- runtime:workers -->
14
+
15
+ A new feature: `npx @escalay/kit feature <plural-name>`, then fill in its files.
16
+
17
+ ## Layout
18
+
19
+ | Path | What |
20
+ |---|---|
21
+ | `src/server.ts` | Node: env → `openDb` → `createApp` → listen | <!-- runtime:node -->
22
+ | `src/worker.ts`, `wrangler.jsonc` | Workers: the D1 binding → `openDb(drizzle(env.DB))` → the same `createApp`; bindings and secrets | <!-- runtime:workers -->
23
+ | `src/guide.gen.ts` | `guide/` as a module (`pnpm guide`), served the same on both runtimes; a test fails when it is older than `guide/` |
24
+ | `migrations/`, `scripts/migrations.ts` | Workers: one D1 migration file per kit and feature step, written by `pnpm migrations`, applied at deploy | <!-- runtime:workers -->
25
+ | `src/app.ts` | the wiring only: auth and OAuth, every feature, the guide, MCP |
26
+ | `src/features/index.ts` | `FEATURES`, the one list of features, and the `Feature` shape |
27
+ | `src/features/<name>/` | one feature: `schema.ts` (tables + migration steps) · `repo.ts` (the only queries) · `routes.ts` (OpenAPI contracts) · `handlers.ts` (request → repo → HAL) · `tools.ts` (MCP tools) · `index.ts` (register, tools, sample tool arguments) · `<name>.test.ts` |
28
+ | `src/kit/` | the copied kit, owned by this app; `kit.json` records its version, and each part's `README.md` says what it is for and its rules (start with `core`, `db`, `mcp`, `testing`). Update with `diff --patch`, then `update` |
29
+ | `guide/` | the agent guide: templates whose `{{api.endpoints}}` and `{{mcp.tools}}` are generated from the code |
30
+ | `test/api.test.ts` | every route linted, every MCP tool run (`toolsRun`), every guide placeholder filled |
31
+ | `container/entrypoint.sh` | the env allowlist: the API sees only the variables it reads | <!-- runtime:node -->
32
+
33
+ ## Rules
34
+
35
+ - Only a feature's `repo.ts` queries the database; handlers call the repo. That keeps a move off SQLite to one file
36
+ per feature (`openDb` takes any Drizzle SQLite database: Turso via `drizzle-orm/libsql`, D1 via `drizzle(env.DB)`).
37
+ - A migration step never changes once it has run anywhere; a change is a new step at the end of the list.
38
+ - On Workers, run `pnpm migrations` after adding a step (it refuses to rewrite a shipped one). On a fresh local D1, `wrangler d1 migrations apply DB --local` before the first `wrangler dev`: files replayed over steps `migrate()` already ran must be idempotent. <!-- runtime:workers -->
39
+ - Nothing runs at global scope that Workers refuse there (random values, I/O, timers): do it in `createApp` or a handler. <!-- runtime:workers -->
40
+ - A new secret: `wrangler secret put <NAME>`, and locally `.dev.vars` (copy `.dev.vars.example`). <!-- runtime:workers -->
41
+ - A new env var the API reads goes into `container/entrypoint.sh` too. <!-- runtime:node -->
@@ -0,0 +1,3 @@
1
+ # __title__
2
+
3
+ @AGENTS.md
@@ -0,0 +1,12 @@
1
+ FROM node:22-alpine
2
+ WORKDIR /app
3
+ RUN corepack enable
4
+ COPY package.json pnpm-lock.yaml* ./
5
+ RUN pnpm install --prod
6
+ COPY src src
7
+ COPY guide guide
8
+ COPY container container
9
+ ENV PORT=80 STATE_DIR=/state
10
+ EXPOSE 80
11
+ HEALTHCHECK CMD wget -qO- http://127.0.0.1/api/health || exit 1
12
+ ENTRYPOINT ["/app/container/entrypoint.sh"]
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # The API gets only the env it reads, never the platform's whole .env
3
+ set -e
4
+ exec env -i PATH="$PATH" HOME="${HOME:-/root}" ${PORT:+PORT="$PORT"} ${STATE_DIR:+STATE_DIR="$STATE_DIR"} ${DATABASE_FILE:+DATABASE_FILE="$DATABASE_FILE"} \
5
+ ${API_KEYS:+API_KEYS="$API_KEYS"} ${OAUTH_SIGNING_KEY:+OAUTH_SIGNING_KEY="$OAUTH_SIGNING_KEY"} ${ACCESS_TEAM:+ACCESS_TEAM="$ACCESS_TEAM"} \
6
+ ${ACCESS_AUD:+ACCESS_AUD="$ACCESS_AUD"} ${PUBLIC_HOST:+PUBLIC_HOST="$PUBLIC_HOST"} \
7
+ node_modules/.bin/tsx src/server.ts
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .state/
3
+ .wrangler/
4
+ .dev.vars
@@ -0,0 +1,11 @@
1
+ # __title__
2
+
3
+ > __title__: an agent-ready API. Send `Authorization: Bearer <key>` on every call; MCP clients connect to `/mcp`.
4
+
5
+ ## Endpoints
6
+
7
+ {{api.endpoints}}
8
+
9
+ ## MCP tools
10
+
11
+ {{mcp.tools}}
@@ -0,0 +1,5 @@
1
+ # Packages that ship prebuilt binaries as optional packages: nothing to build, so pnpm skips their scripts
2
+ allowBuilds:
3
+ esbuild: false
4
+ workerd: false
5
+ sharp: false
@@ -0,0 +1,8 @@
1
+ /** Writes src/guide.gen.ts from guide/: the guide as a module, served the same on Node and on Workers. Run after editing guide/. */
2
+ import { writeFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { guideFiles, guideModuleSource } from '../src/kit/index.js'
5
+
6
+ const files = guideFiles(fileURLToPath(new URL('../guide', import.meta.url)))
7
+ writeFileSync(new URL('../src/guide.gen.ts', import.meta.url), guideModuleSource(files))
8
+ console.log(`Wrote ${files.size} guide files to src/guide.gen.ts`)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * __title__: the kit's agent-ready API with this app's features on it. Each feature in `features/` brings its routes,
3
+ * tables and MCP tools; this file only wires them: auth and OAuth, every feature, the agent guide, MCP.
4
+ *
5
+ * The same on every runtime: `server.ts` (Node) and `worker.ts` (Cloudflare Workers) each open the database and call it.
6
+ *
7
+ * @example
8
+ * const app = await createApp({ db: await openDb('file:.state/state.sqlite'), env: process.env })
9
+ */
10
+ import { FEATURES } from './features/index.js'
11
+ import { GUIDE_FILES } from './guide.gen.js'
12
+ import { accessOwner, createAgentApi, createOAuth, dbGuardStore, dbOAuthStore, parseApiKeys, serveGuide, type Db } from './kit/index.js'
13
+ import { inProcess, mcpServerFactory, mountMcp } from './kit/mcp/index.js'
14
+
15
+ export type Env = { API_KEYS?: string; OAUTH_SIGNING_KEY?: string; ACCESS_TEAM?: string; ACCESS_AUD?: string; PUBLIC_HOST?: string }
16
+
17
+ export const VERSION = '0.1.0'
18
+ export const TOOLS = FEATURES.flatMap((feature) => feature.tools)
19
+
20
+ export const createApp = async ({ db, env }: { db: Db; env: Env }) => {
21
+ const access = env.ACCESS_TEAM && env.ACCESS_AUD ? { team: env.ACCESS_TEAM, audience: env.ACCESS_AUD } : undefined
22
+ const oauth = env.OAUTH_SIGNING_KEY ? createOAuth({ name: '__title__', signingKey: env.OAUTH_SIGNING_KEY, store: await dbOAuthStore(db), owner: access ? accessOwner(access) : async () => null }) : undefined
23
+ const app = createAgentApi({
24
+ title: '__title__',
25
+ version: VERSION,
26
+ description: ['__title__.', '', '- **Auth:** `Authorization: Bearer <key>`, or an OAuth token the owner approves.', '- **Agent guide:** [/llms.txt](/llms.txt). **MCP:** `/mcp`.'].join('\n'),
27
+ tags: FEATURES.map((feature) => feature.tag),
28
+ auth: { realm: '__name__', keys: parseApiKeys(env.API_KEYS), access },
29
+ oauth,
30
+ // In the database, so every instance (each Worker isolate) shares Idempotency-Key answers
31
+ guards: { store: await dbGuardStore(db) },
32
+ })
33
+ for (const feature of FEATURES) await feature.register(app, { db })
34
+ // guide/ as a module (`pnpm guide` writes it), so it serves the same on Workers, which can't read a folder
35
+ serveGuide(app, { files: GUIDE_FILES, vars: () => ({}), full: ['llms.txt'] })
36
+ const factory = mcpServerFactory({ info: { name: '__name__', title: '__title__', version: VERSION }, instructions: '__title__: read the guide at /llms.txt first.', tools: TOOLS, callApi: inProcess(app) })
37
+ mountMcp(app, { factory, allowedHostnames: [env.PUBLIC_HOST ?? 'localhost', 'localhost', '127.0.0.1'] })
38
+ return app
39
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Every feature of the app, one folder each (`kit feature <name>` makes one and lists it here):
3
+ * schema.ts Drizzle tables + the SQL migration steps
4
+ * repo.ts the only file with queries
5
+ * routes.ts OpenAPI contracts (createRoute)
6
+ * handlers.ts request → repo → HAL answer, errors as problems
7
+ * tools.ts MCP tools over the routes
8
+ * index.ts the feature: register, tools, sample tool arguments for the tests
9
+ */
10
+ import type { OpenAPIHono } from '@hono/zod-openapi'
11
+ import type { Db } from '../kit/index.js'
12
+ import type { McpTool } from '../kit/mcp/index.js'
13
+ // kit:imports
14
+
15
+ export type Feature = {
16
+ name: string
17
+ /** The OpenAPI tag its routes carry. */
18
+ tag: { name: string; description: string }
19
+ /** Its tables as SQL steps, in order (also written to `migrations/` for D1 by `pnpm migrations`). */
20
+ migrations: readonly string[]
21
+ /** Migrates its tables, then registers its routes. */
22
+ register: (app: OpenAPIHono, context: { db: Db }) => Promise<void>
23
+ tools: McpTool[]
24
+ /** Sample arguments for each of its tools (making what they need through the API), for `toolsRun`. */
25
+ samples: (request: (path: string, init?: RequestInit) => Promise<Response>) => Promise<Record<string, Record<string, unknown>>>
26
+ }
27
+
28
+ export const FEATURES: Feature[] = [
29
+ // kit:features
30
+ ]
@@ -0,0 +1,14 @@
1
+ /** Starts __title__: opens the database, builds the app, listens on PORT. */
2
+ import { mkdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { serve } from '@hono/node-server'
5
+ import { createApp } from './app.js'
6
+ import { openDb } from './kit/index.js'
7
+
8
+ const stateDir = process.env.STATE_DIR ?? '.state'
9
+ mkdirSync(stateDir, { recursive: true })
10
+ // A node:sqlite file by default; to move to Turso or D1, open its Drizzle database here and pass it in
11
+ const db = await openDb(process.env.DATABASE_FILE ?? `file:${join(stateDir, 'state.sqlite')}`)
12
+ const app = await createApp({ db, env: process.env })
13
+ const port = Number(process.env.PORT ?? 4400)
14
+ serve({ fetch: app.fetch, hostname: '0.0.0.0', port }, (info) => console.log(`__name__ → http://localhost:${info.port} (/docs, /llms.txt, /mcp)`))