@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.
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/cli.mjs +432 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/package.json +58 -3
- package/registry.json +512 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- 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.
|
package/src/web/index.ts
ADDED
|
@@ -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,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,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)`))
|