@escalay/kit 0.0.0-stage → 1.8.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 +142 -2
- package/cli.mjs +438 -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/docs/releasing.md +97 -0
- package/package.json +59 -3
- package/registry.json +548 -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/npm-release/release-please-config.json +60 -0
- package/templates/npm-release/release-please-manifest.json +3 -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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks that keep an API agent-ready as it grows, run in each app's tests:
|
|
3
|
+
* lintOpenApi every operation is findable and self-explaining: a unique camelCase `operationId`, a summary and a
|
|
4
|
+
* description, declared tags, described parameters, a typed 2xx body, errors as problem+json, and
|
|
5
|
+
* every named schema described
|
|
6
|
+
* guideRoutes every `GET /api/…` the agent guide mentions is a real operation, so the docs fail the build before
|
|
7
|
+
* they go stale
|
|
8
|
+
* guideGaps placeholders a guide uses that neither the app nor the kit's generated parts fill
|
|
9
|
+
* toolRoutes MCP tools whose call doesn't land on a documented operation
|
|
10
|
+
* toolsRun MCP tools that fail when run: each called in-process with sample arguments, as an agent would
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const doc = await (await app.request('/openapi.json')).json()
|
|
14
|
+
* expect(lintOpenApi(doc)).toEqual([])
|
|
15
|
+
* expect(guideRoutes(guideFiles(AGENTS_DIR), doc)).toEqual([])
|
|
16
|
+
*/
|
|
17
|
+
import { fillTemplate } from '../guide/guide.js'
|
|
18
|
+
import type { ResourceLike, ToolLike } from '../core/discovery.js'
|
|
19
|
+
import { pathWith } from '../core/hal.js'
|
|
20
|
+
import { PROBLEM_JSON } from '../core/problem.js'
|
|
21
|
+
import { generatedVars, toolRoute, type OpenApiDoc } from '../guide/reference.js'
|
|
22
|
+
|
|
23
|
+
type Operation = {
|
|
24
|
+
operationId?: string
|
|
25
|
+
summary?: string
|
|
26
|
+
description?: string
|
|
27
|
+
tags?: string[]
|
|
28
|
+
parameters?: { name: string; in: string; description?: string; $ref?: string }[]
|
|
29
|
+
responses?: Record<string, { content?: Record<string, { schema?: unknown }> }>
|
|
30
|
+
requestBody?: unknown
|
|
31
|
+
security?: unknown[]
|
|
32
|
+
}
|
|
33
|
+
type Document = { tags?: { name: string }[]; paths?: Record<string, Record<string, Operation>>; components?: { schemas?: Record<string, { description?: string }> } }
|
|
34
|
+
|
|
35
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
|
|
36
|
+
|
|
37
|
+
export const lintOpenApi = (doc: Document) => {
|
|
38
|
+
const issues: string[] = []
|
|
39
|
+
const declared = new Set((doc.tags ?? []).map((tag) => tag.name))
|
|
40
|
+
const ids = new Map<string, string>()
|
|
41
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
42
|
+
for (const method of METHODS) {
|
|
43
|
+
const operation = item[method]
|
|
44
|
+
if (!operation) continue
|
|
45
|
+
const at = `${method.toUpperCase()} ${path}`
|
|
46
|
+
if (!operation.operationId) issues.push(`${at}: no operationId`)
|
|
47
|
+
else if (!/^[a-z][a-zA-Z0-9]*$/.test(operation.operationId)) issues.push(`${at}: operationId "${operation.operationId}" is not camelCase`)
|
|
48
|
+
else if (ids.has(operation.operationId)) issues.push(`${at}: operationId "${operation.operationId}" also used by ${ids.get(operation.operationId)}`)
|
|
49
|
+
else ids.set(operation.operationId, at)
|
|
50
|
+
if (!operation.summary) issues.push(`${at}: no summary`)
|
|
51
|
+
else if (operation.summary.length > 80) issues.push(`${at}: summary over 80 characters`)
|
|
52
|
+
if (!operation.description || operation.description.length < 20) issues.push(`${at}: no description an agent can choose by`)
|
|
53
|
+
if (!operation.tags?.length) issues.push(`${at}: no tag`)
|
|
54
|
+
for (const tag of operation.tags ?? []) if (!declared.has(tag)) issues.push(`${at}: tag "${tag}" is not declared`)
|
|
55
|
+
for (const parameter of operation.parameters ?? []) if (!parameter.$ref && !parameter.description) issues.push(`${at}: parameter ${parameter.in}.${parameter.name} has no description`)
|
|
56
|
+
const statuses = Object.keys(operation.responses ?? {})
|
|
57
|
+
const success = statuses.filter((status) => /^2\d\d$/.test(status))
|
|
58
|
+
if (!success.length) issues.push(`${at}: no 2xx response`)
|
|
59
|
+
for (const status of success) if (status !== '204' && status !== '202' && !operation.responses![status]!.content) issues.push(`${at}: ${status} has no body schema`)
|
|
60
|
+
for (const status of statuses.filter((status) => /^[45]\d\d$/.test(status))) if (!operation.responses![status]!.content?.[PROBLEM_JSON]) issues.push(`${at}: ${status} is not problem+json`)
|
|
61
|
+
// Anything that takes input or needs a key can fail on the caller's side, and says how
|
|
62
|
+
const canFail = (operation.parameters?.length ?? 0) > 0 || operation.requestBody !== undefined || !(Array.isArray(operation.security) && operation.security.length === 0)
|
|
63
|
+
if (canFail && !statuses.some((status) => status.startsWith('4'))) issues.push(`${at}: documents no 4xx error`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [name, schema] of Object.entries(doc.components?.schemas ?? {})) if (!schema.description) issues.push(`schema ${name}: no description`)
|
|
67
|
+
return issues
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `GET /api/v1/cooks/{id}/recipes?…` and `POST /api/v1/compare` in the guide → those not in the OpenAPI paths. */
|
|
71
|
+
export const guideRoutes = (files: Map<string, string>, doc: Document, prefix = '/api/') => {
|
|
72
|
+
const operations = Object.entries(doc.paths ?? {}).flatMap(([path, item]) => METHODS.filter((method) => item[method]).map((method) => ({ method: method.toUpperCase(), pattern: new RegExp(`^${path.replace(/\{[^}]+\}/g, '[^/]+')}$`) })))
|
|
73
|
+
const missing = new Set<string>()
|
|
74
|
+
for (const [file, text] of files) {
|
|
75
|
+
for (const match of text.matchAll(new RegExp(`\\b(GET|POST|PUT|PATCH|DELETE) (${prefix.replace(/\//g, '\\/')}[^\\s\`?)|]*)`, 'g'))) {
|
|
76
|
+
const [, method, raw] = match
|
|
77
|
+
const path = raw!.replace(/[.,;:]+$/, '').replace(/\{[^}]+\}/g, 'x').replace(/<[^>]+>/g, 'x')
|
|
78
|
+
if (!operations.some((operation) => operation.method === method && operation.pattern.test(path))) missing.add(`${file}: ${method} ${raw}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...missing]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Placeholders a guide uses that nothing fills: not `vars`, and not the kit's generated parts when `doc` (and `tools`)
|
|
86
|
+
* are given, so a typo like `{{api.endpoint}}` is caught too.
|
|
87
|
+
*/
|
|
88
|
+
export const guideGaps = (files: Map<string, string>, vars: Record<string, unknown>, { doc, tools, resources }: { doc?: OpenApiDoc; tools?: ToolLike[]; resources?: ResourceLike[] } = {}) => {
|
|
89
|
+
const generated = doc ? generatedVars({ origin: 'https://example.test', doc, tools, resources, files }) : {}
|
|
90
|
+
const skipped = doc ? [] : ['api.', 'mcp.', 'skills.']
|
|
91
|
+
return [...files].flatMap(([file, text]) =>
|
|
92
|
+
fillTemplate(text, { origin: 'https://example.test', ...generated, ...vars })
|
|
93
|
+
.missing.filter((name) => !skipped.some((root) => name.startsWith(root)))
|
|
94
|
+
.map((name) => `${file}: {{${name}}}`),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Tools whose `call` reaches no operation in the OpenAPI document (a renamed route, a typo in a path). */
|
|
99
|
+
export const toolRoutes = (tools: ToolLike[], doc: OpenApiDoc) => tools.filter((tool) => !toolRoute(tool, doc)).map((tool) => tool.name)
|
|
100
|
+
|
|
101
|
+
type RunnableTool = {
|
|
102
|
+
name: string
|
|
103
|
+
input: { parse: (args: unknown) => unknown }
|
|
104
|
+
call: (args: never) => { method?: string; path: string; query?: Record<string, unknown>; body?: unknown }
|
|
105
|
+
summarize: (body: never, args: never) => string
|
|
106
|
+
}
|
|
107
|
+
type RequestableApp = { request: (path: string, init?: RequestInit) => Response | Promise<Response> }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs every tool against the app, in order, the way an agent would: its sample arguments through its input schema, its
|
|
111
|
+
* call to the route, its summary of the answer. Returns what failed, `[]` when every tool works: a tool with no
|
|
112
|
+
* sample arguments, arguments its schema rejects, a call that doesn't answer 2xx, or a summary that is empty or shows
|
|
113
|
+
* `undefined` / `[object Object]`. `toolRoutes` only checks that a call names a route; this catches a call that names
|
|
114
|
+
* the right route and still fails (a mangled id, a wrong query parameter).
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* const board = await (await app.request('/api/boards', { method: 'POST', headers, body: '{"name":"Test"}' })).json()
|
|
118
|
+
* expect(await toolsRun(TOOLS, app, { search_notes: { q: 'soup' }, get_board: { id: board.id } }, { headers })).toEqual([])
|
|
119
|
+
*/
|
|
120
|
+
export const toolsRun = async (tools: RunnableTool[], app: RequestableApp, args: Record<string, Record<string, unknown>>, { headers = {} }: { headers?: Record<string, string> } = {}) => {
|
|
121
|
+
const failures: string[] = []
|
|
122
|
+
for (const tool of tools) {
|
|
123
|
+
if (!args[tool.name]) {
|
|
124
|
+
failures.push(`${tool.name}: no sample arguments`)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const parsed = (() => {
|
|
128
|
+
try {
|
|
129
|
+
return { ok: true as const, value: tool.input.parse(args[tool.name]) }
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { ok: false as const, error: error instanceof Error ? error.message : String(error) }
|
|
132
|
+
}
|
|
133
|
+
})()
|
|
134
|
+
if (!parsed.ok) {
|
|
135
|
+
failures.push(`${tool.name}: its input rejects the sample arguments: ${parsed.error.slice(0, 200)}`)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const { method = 'GET', path, query, body } = tool.call(parsed.value as never)
|
|
139
|
+
const response = await app.request(pathWith(path, query as Parameters<typeof pathWith>[1]), { method, headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
failures.push(`${tool.name}: ${method} ${path} answered ${response.status}: ${(await response.text()).slice(0, 200)}`)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
const json = response.status === 204 ? {} : await response.json().catch(() => null)
|
|
145
|
+
const summary = (() => {
|
|
146
|
+
try {
|
|
147
|
+
return { ok: true as const, text: tool.summarize(json as never, parsed.value as never) }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false as const, error: error instanceof Error ? error.message : String(error) }
|
|
150
|
+
}
|
|
151
|
+
})()
|
|
152
|
+
if (!summary.ok) {
|
|
153
|
+
failures.push(`${tool.name}: its summary threw on the ${response.status} answer${response.status === 202 ? ' (still working: give the route a finished run, or summarize a 202)' : ''}: ${summary.error.slice(0, 200)}`)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const text = summary.text
|
|
157
|
+
if (!text.trim()) failures.push(`${tool.name}: an empty summary`)
|
|
158
|
+
else if (/\bundefined\b|\[object Object\]/.test(text)) failures.push(`${tool.name}: its summary shows ${/\[object Object\]/.test(text) ? '[object Object]' : 'undefined'}: ${text.slice(0, 200)}`)
|
|
159
|
+
}
|
|
160
|
+
for (const name of Object.keys(args)) if (!tools.some((tool) => tool.name === name)) failures.push(`${name}: sample arguments for a tool that doesn't exist`)
|
|
161
|
+
return failures
|
|
162
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# vector-search
|
|
2
|
+
|
|
3
|
+
Search by words, by picture and by likeness, as three routes and their MCP tools, on the vectors module's spaces.
|
|
4
|
+
|
|
5
|
+
Needs: `vectors`, `mcp`.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { registerVectorSearch, vectorSearchTools } from './kit/vector-search/index.js'
|
|
9
|
+
|
|
10
|
+
registerVectorSearch(app, {
|
|
11
|
+
path: '/api/search', similarPath: '/api/recipes/{slug}/similar', name: 'Recipe', noun: 'recipes', tag: 'Search', item: RecipeCardSchema,
|
|
12
|
+
spaces: () => vectors, embedder: () => embedder, itemOf: (key, origin) => cardOf(key, origin), keyOf: (slug) => `recipe:${slug}`,
|
|
13
|
+
filters: { shape: { decade: z.string().optional() }, keep: ({ decade }) => decade ? (key) => byKey.get(key)?.decade === decade : undefined },
|
|
14
|
+
})
|
|
15
|
+
const tools = vectorSearchTools({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar', noun: 'recipes', label: (recipe) => recipe.title })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Routes: `GET {path}?q=&by=look|meaning|both` (words, one embedding), `POST {path}` (a picture: multipart or a JSON data
|
|
19
|
+
URL), `GET {similarPath}?by=` (likeness, free). Results are HAL, `_embedded.results[]` of `{ score, z, space, item }`;
|
|
20
|
+
rows below the bar come back as `_embedded.closest`.
|
|
21
|
+
|
|
22
|
+
## Rules
|
|
23
|
+
|
|
24
|
+
- Measure the bar on each catalog with real and nonsense queries (`minZ`, `minScore`) and write it in the guide's trust page.
|
|
25
|
+
- The similar route takes an item's id or its board ref (`recipe:soup`); it tries the id as given first, so ids that
|
|
26
|
+
hold a colon (`episode:<slug>`) work.
|
|
27
|
+
- Standard: `docs/STANDARD.md` §15.
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search by words, by picture and by likeness over an app's vectors (`vectors.ts`), as three documented routes and the
|
|
3
|
+
* MCP tools that call them:
|
|
4
|
+
* GET {path}?q=… items for a description; `by` look, meaning or both (one query embedding, cached)
|
|
5
|
+
* POST {path} items that look like a picture: a multipart `image` file or JSON `{ "image": … }`
|
|
6
|
+
* GET {similarPath} items like one item, by look or meaning (free: no embedding)
|
|
7
|
+
* Words and pictures keep only the rows that stand out from the whole catalog (`standouts`), so a query that matches
|
|
8
|
+
* nothing answers nothing; `both` merges the two spaces on how far each row stands out, not on raw cosine. Likeness
|
|
9
|
+
* always answers (`nearest`). Results are HAL: `_embedded.results[]` of `{ score, z, space, item }`.
|
|
10
|
+
*
|
|
11
|
+
* The app supplies its item schema and cards, its filters (declared on every route, so unknown ones stay a 400) and,
|
|
12
|
+
* for agents, which hosts a picture may be fetched from: a data URL always works, an `https` URL only on `imageHosts`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* registerVectorSearch(app, { path: '/api/search', similarPath: '/api/recipes/{slug}/similar', name: 'Recipe', noun: 'recipes', tag: 'Search', item: RecipeCardSchema,
|
|
16
|
+
* spaces: () => vectors, embedder: () => embedder, itemOf: (key, origin) => cardOf(key, origin), keyOf: (slug) => `recipe:${slug}` })
|
|
17
|
+
* mcpServerFactory({ tools: [...vectorSearchTools({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar', noun: 'recipes', label: (recipe) => recipe.title }), …], … })
|
|
18
|
+
*/
|
|
19
|
+
import { createRoute, z, type OpenAPIHono } from '@hono/zod-openapi'
|
|
20
|
+
import { rateLimit } from '../core/guards.js'
|
|
21
|
+
import { HAL_JSON, linksOf, LinksSchema, originOf, pathWith } from '../core/hal.js'
|
|
22
|
+
import { mcpTool, type McpTool } from '../mcp/index.js'
|
|
23
|
+
import { problemResponses, ProblemError } from '../core/problem.js'
|
|
24
|
+
import { imageDataUrl, nearest, SpendCapError, standouts, vectorOf, type Embedder, type VectorSpace } from '../vectors/index.js'
|
|
25
|
+
|
|
26
|
+
export type SearchSpace = 'look' | 'meaning'
|
|
27
|
+
type By = SearchSpace | 'both'
|
|
28
|
+
type Filters = Record<string, unknown>
|
|
29
|
+
type Result = { score: number; z: number | null; space: SearchSpace; item: unknown }
|
|
30
|
+
|
|
31
|
+
export type VectorSearchConfig = {
|
|
32
|
+
/** GET searches by words, POST by picture. */
|
|
33
|
+
path: string
|
|
34
|
+
/** One path parameter, the item's id: `/api/recipes/{slug}/similar`. */
|
|
35
|
+
similarPath?: string
|
|
36
|
+
/** Singular, for OpenAPI component and operation names: `Recipe`. */
|
|
37
|
+
name: string
|
|
38
|
+
/** Plural, for descriptions: `recipes`. */
|
|
39
|
+
noun: string
|
|
40
|
+
tag: string
|
|
41
|
+
/** The OpenAPI schema of one result's `item`. */
|
|
42
|
+
item: z.ZodTypeAny
|
|
43
|
+
/** The vectors searched now; a space not built yet is left out. */
|
|
44
|
+
spaces: () => Partial<Record<SearchSpace, VectorSpace>>
|
|
45
|
+
/** Undefined without a key: words and pictures answer 503, likeness still works. */
|
|
46
|
+
embedder: () => Embedder | undefined
|
|
47
|
+
/** The card for a vector's key, or undefined when it left the catalog (the row is skipped). */
|
|
48
|
+
itemOf: (key: string, origin: string) => unknown
|
|
49
|
+
/** The similar route's id → its vector key (default: the id itself). */
|
|
50
|
+
keyOf?: (id: string) => string
|
|
51
|
+
/** Query parameters every route accepts, and the keys they keep (undefined keeps all). */
|
|
52
|
+
filters?: { shape: z.ZodRawShape; keep: (filters: Filters) => ((key: string) => boolean) | undefined }
|
|
53
|
+
/** Caps results per group (an author, a collection) so one group doesn't fill the page. */
|
|
54
|
+
groupOf?: (key: string) => string
|
|
55
|
+
perGroup?: number
|
|
56
|
+
/**
|
|
57
|
+
* How far a row must stand out (measured on the app's own catalog; see `standouts`): per space for words, and
|
|
58
|
+
* `picture` for a picture, which always has look-alikes and so takes a lower bar (default 2.5).
|
|
59
|
+
*/
|
|
60
|
+
minZ?: Partial<Record<SearchSpace | 'picture', number>>
|
|
61
|
+
/** Per space: a raw-similarity floor on top of `minZ`, for a corpus all about one subject (see `standouts`). */
|
|
62
|
+
minScore?: Partial<Record<SearchSpace, number>>
|
|
63
|
+
/** Hosts a JSON `image` URL may come from (exact hostnames); data URLs always work. */
|
|
64
|
+
imageHosts?: string[]
|
|
65
|
+
/** Calls per caller per hour on the paid routes (words and pictures). */
|
|
66
|
+
hourlyLimit?: number
|
|
67
|
+
/** False for a catalog with no pictures: no picture route, no picture tool. */
|
|
68
|
+
pictures?: boolean
|
|
69
|
+
/** The example query in the spec and the guide: something this catalog answers well. */
|
|
70
|
+
example?: string
|
|
71
|
+
/** The spaces this catalog has at all (default both): a text-only catalog offers `['meaning']`, so `by=look` isn't advertised. */
|
|
72
|
+
offers?: SearchSpace[]
|
|
73
|
+
fetchImpl?: typeof fetch
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
|
|
77
|
+
const MAX_IMAGE = 8 * 1024 * 1024
|
|
78
|
+
|
|
79
|
+
/** A picture a request sent, as the data URL a look vector is made from: a multipart file, a data URL, or an https URL on the allowlist. */
|
|
80
|
+
/** The API's own origin, and a fetch that runs a path in-process with the caller's credentials. */
|
|
81
|
+
type Own = { origin: string; fetch: (path: string) => Promise<Response> }
|
|
82
|
+
|
|
83
|
+
const pictureOf = async (given: unknown, imageHosts: string[], fetchImpl: typeof fetch, own?: Own) => {
|
|
84
|
+
if (given instanceof File) {
|
|
85
|
+
if (!IMAGE_TYPES.includes(given.type)) throw new ProblemError('unsupported_media_type', { detail: `The image is ${given.type || 'untyped'}: send PNG, JPEG, WebP or GIF.` })
|
|
86
|
+
if (given.size > MAX_IMAGE) throw new ProblemError('payload_too_large', { detail: 'The image is over 8 MB: send a smaller one.' })
|
|
87
|
+
return imageDataUrl(new Uint8Array(await given.arrayBuffer()))
|
|
88
|
+
}
|
|
89
|
+
if (typeof given !== 'string') throw new ProblemError('bad_request', { detail: 'Send the picture: a multipart `image` file, or JSON `{ "image": "data:image/png;base64,…" }`.', errors: [{ in: 'body', path: 'image', message: 'required' }] })
|
|
90
|
+
if (given.startsWith('data:image/')) return imageDataUrl(Buffer.from(given.slice(given.indexOf(',') + 1), 'base64')).catch(() => {
|
|
91
|
+
throw new ProblemError('bad_request', { detail: 'The data URL is not an image this API can read (PNG, JPEG, WebP, GIF).', errors: [{ in: 'body', path: 'image', message: 'unreadable image' }] })
|
|
92
|
+
})
|
|
93
|
+
const url = URL.canParse(given) ? new URL(given) : null
|
|
94
|
+
// An image link this API handed out is read in-process, with the caller's own access
|
|
95
|
+
const ours = !!url && !!own && url.host === new URL(own.origin).host
|
|
96
|
+
if (!ours && (url?.protocol !== 'https:' || !imageHosts.includes(url.hostname)))
|
|
97
|
+
throw new ProblemError('bad_request', {
|
|
98
|
+
detail: `A picture by URL must be one of this API's own image links${imageHosts.length ? ` or https on ${imageHosts.join(', ')}` : ''}. Otherwise send a data URL or upload the file.`,
|
|
99
|
+
errors: [{ in: 'body', path: 'image', message: 'host not allowed' }],
|
|
100
|
+
})
|
|
101
|
+
const response = await (ours ? own!.fetch(url!.pathname + url!.search) : fetchImpl(url!, { redirect: 'error' })).catch(() => null)
|
|
102
|
+
const type = response?.headers.get('content-type')?.split(';')[0] ?? ''
|
|
103
|
+
if (!response?.ok || !IMAGE_TYPES.includes(type)) throw new ProblemError('bad_request', { detail: `Could not fetch an image from ${url!.href} (${response ? `${response.status} ${type}` : 'no answer'}).`, errors: [{ in: 'body', path: 'image', message: 'not an image' }] })
|
|
104
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
105
|
+
if (bytes.byteLength > MAX_IMAGE) throw new ProblemError('payload_too_large', { detail: 'The image is over 8 MB.' })
|
|
106
|
+
return imageDataUrl(bytes)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Embeds with the app's key, turning money and outages into problems an agent can act on. */
|
|
110
|
+
const embedding = async (embedder: Embedder | undefined, make: (embedder: Embedder) => Promise<Float32Array>) => {
|
|
111
|
+
if (!embedder) throw new ProblemError('unavailable', { detail: 'Search by words or picture needs this server’s embedding key, which is not set. Similar-by-id and every other route still work.' })
|
|
112
|
+
try {
|
|
113
|
+
return await make(embedder)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof SpendCapError) throw new ProblemError('spend_cap', { detail: 'The search budget is used up: describing a query or a picture costs money. Similar-by-id and every free route still work.' })
|
|
116
|
+
if (error instanceof ProblemError) throw error
|
|
117
|
+
throw new ProblemError('upstream', { detail: `The embedding service failed (${String((error as Error).message).slice(0, 200)}). Retry in a minute.` })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Rows that stand out in each asked space, merged on z (a row found in both keeps its better side). */
|
|
122
|
+
const PICTURE_MIN_Z = 2.5
|
|
123
|
+
|
|
124
|
+
/** How many of the nearest rows below the bar an empty answer shows, so an agent can judge for itself. */
|
|
125
|
+
const CLOSEST = 3
|
|
126
|
+
|
|
127
|
+
const rank = (config: VectorSearchConfig, spaces: Partial<Record<SearchSpace, VectorSpace>>, by: By, query: Float32Array, filters: Filters, limit: number, picture = false, loose = false) => {
|
|
128
|
+
const keep = config.filters?.keep(filters)
|
|
129
|
+
// Matches merge on how far they stand out; rows below the bar, on raw similarity
|
|
130
|
+
const measure = (row: { score: number; z: number | null }) => (loose ? row.score : (row.z ?? -Infinity))
|
|
131
|
+
const best = new Map<string, Omit<Result, 'item'> & { key: string }>()
|
|
132
|
+
for (const space of by === 'both' ? (['look', 'meaning'] as const) : [by]) {
|
|
133
|
+
const vectors = spaces[space]
|
|
134
|
+
if (!vectors) continue
|
|
135
|
+
const minZ = picture ? (config.minZ?.picture ?? PICTURE_MIN_Z) : config.minZ?.[space]
|
|
136
|
+
const minScore = picture ? undefined : config.minScore?.[space]
|
|
137
|
+
const pick = { limit: limit * 2, keep, groupOf: config.groupOf, perGroup: config.perGroup }
|
|
138
|
+
// Below the bar there is no meaningful z: the nearest by raw score
|
|
139
|
+
const matches = loose ? nearest(vectors, query, pick).map((match) => ({ ...match, z: null })) : standouts(vectors, query, { ...pick, minZ, minScore })
|
|
140
|
+
for (const match of matches) {
|
|
141
|
+
const held = best.get(match.key)
|
|
142
|
+
if (!held || measure(held) < measure(match)) best.set(match.key, { ...match, space })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...best.values()].sort((a, b) => measure(b) - measure(a)).slice(0, limit)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const spacesFor = (config: VectorSearchConfig, by: By) => {
|
|
149
|
+
const spaces = config.spaces()
|
|
150
|
+
const wanted = by === 'both' ? (['look', 'meaning'] as const) : [by]
|
|
151
|
+
if (!wanted.some((space) => spaces[space])) throw new ProblemError('unavailable', { detail: `The ${wanted.join(' and ')} vectors for ${config.noun} are not built yet. Try again later or use the other routes.`, headers: { 'retry-after': '3600' } })
|
|
152
|
+
return spaces
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Registers search by words (GET), by picture (POST) and likeness on the app, rate-limited per caller on the two that
|
|
157
|
+
* cost an embedding. Call it after `createAgentApi`.
|
|
158
|
+
*/
|
|
159
|
+
export const registerVectorSearch = (app: OpenAPIHono, config: VectorSearchConfig) => {
|
|
160
|
+
const { path, similarPath, name, noun, tag } = config
|
|
161
|
+
const filterShape = config.filters?.shape ?? {}
|
|
162
|
+
const fetchImpl = config.fetchImpl ?? fetch
|
|
163
|
+
const offers = config.offers ?? (['look', 'meaning'] as SearchSpace[])
|
|
164
|
+
const both = offers.length > 1
|
|
165
|
+
const by = (fallback: By, allowed: By[]) => {
|
|
166
|
+
const kept = both ? allowed : ([offers[0]] as By[])
|
|
167
|
+
return z.enum(kept as [By, ...By[]]).default(both ? fallback : offers[0]!).openapi({ description: both ? 'Compare by what it looks like (`look`), what it is about (`meaning`), or both, each match on its better side.' : `Only \`${offers[0]}\` here: this catalog has no other vectors.` })
|
|
168
|
+
}
|
|
169
|
+
const limit = z.coerce.number().int().min(1).max(100).default(24).openapi({ description: 'At most this many results (1–100, default 24).' })
|
|
170
|
+
const ResultsSchema = z
|
|
171
|
+
.object({
|
|
172
|
+
query: z.string().nullable().openapi({ description: 'The words searched for; null for a picture or an item.' }),
|
|
173
|
+
by: z.enum(['look', 'meaning', 'both']),
|
|
174
|
+
count: z.number().int(),
|
|
175
|
+
_links: LinksSchema,
|
|
176
|
+
_embedded: z.object({
|
|
177
|
+
results: z.array(
|
|
178
|
+
z.object({
|
|
179
|
+
score: z.number().openapi({ description: 'Cosine similarity (−1 to 1). Gemini’s scores sit in a narrow band; compare within one answer only.' }),
|
|
180
|
+
z: z.number().nullable().openapi({ description: 'How many standard deviations above the catalog’s mean this row scored; null for likeness.' }),
|
|
181
|
+
space: z.enum(['look', 'meaning']).openapi({ description: 'The space it matched in.' }),
|
|
182
|
+
item: config.item,
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
closest: z
|
|
186
|
+
.array(z.object({ score: z.number(), z: z.number().nullable(), space: z.enum(['look', 'meaning']), item: config.item }))
|
|
187
|
+
.optional()
|
|
188
|
+
.openapi({ description: `Only when nothing clears the bar: the ${CLOSEST} nearest ${noun} below it, so you can judge whether the catalog has anything close. They are not matches; say so if you use them.` }),
|
|
189
|
+
}),
|
|
190
|
+
})
|
|
191
|
+
.openapi(`${name}SearchResults`, { description: `${noun} that match, best first.` })
|
|
192
|
+
const answer = (origin: string, self: string, query: string | null, chosen: By, results: Result[], closest?: () => Result[]) => ({
|
|
193
|
+
query,
|
|
194
|
+
by: chosen,
|
|
195
|
+
count: results.length,
|
|
196
|
+
_links: linksOf(origin, { self, search: { path: `${path}?q={q}`, templated: true, title: `Search ${noun} by words` } }),
|
|
197
|
+
_embedded: { results, ...(!results.length && closest ? { closest: closest() } : {}) },
|
|
198
|
+
})
|
|
199
|
+
const withItems = (origin: string, rows: { key: string; score: number; z: number | null; space: SearchSpace }[]) =>
|
|
200
|
+
rows.flatMap(({ key, score, z, space }) => {
|
|
201
|
+
const item = config.itemOf(key, origin)
|
|
202
|
+
return item === undefined ? [] : [{ score, z, space, item }]
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if (config.hourlyLimit !== 0) app.use(path, rateLimit({ policy: `${noun}-search`, limit: config.hourlyLimit ?? 600, windowSeconds: 3600 }))
|
|
206
|
+
const ok = (description: string) => ({ 200: { description, content: { [HAL_JSON]: { schema: ResultsSchema } } } })
|
|
207
|
+
|
|
208
|
+
app.openapi(
|
|
209
|
+
createRoute({
|
|
210
|
+
method: 'get',
|
|
211
|
+
path,
|
|
212
|
+
operationId: `search${name}s`,
|
|
213
|
+
tags: [tag],
|
|
214
|
+
summary: `${noun[0]!.toUpperCase()}${noun.slice(1)} for a description`,
|
|
215
|
+
description: `${noun} that match words: what they show or are about (\`meaning\`) or what they look like (\`look\`). Only matches that stand out from the whole catalog come back, so an empty answer means nothing fits. Costs one query embedding; repeats are cached.`,
|
|
216
|
+
request: { query: z.object({ q: z.string().min(2).max(500).openapi({ description: 'What you are looking for, in words.', example: config.example ?? 'a fox made of triangles' }), by: by('both', ['both', 'look', 'meaning']), limit, ...filterShape }) },
|
|
217
|
+
responses: { ...ok('Matches, best first.'), ...problemResponses(400, 401, 429, 503) },
|
|
218
|
+
}),
|
|
219
|
+
async (c) => {
|
|
220
|
+
const { q, by: chosen, limit: count, ...filters } = c.req.valid('query') as { q: string; by: By; limit: number } & Filters
|
|
221
|
+
const spaces = spacesFor(config, chosen)
|
|
222
|
+
const query = await embedding(config.embedder(), (embedder) => embedder.query(q))
|
|
223
|
+
const origin = originOf(c)
|
|
224
|
+
const results = withItems(origin, rank(config, spaces, chosen, query, filters, count))
|
|
225
|
+
return c.json(answer(origin, pathWith(path, c.req.query()), q, chosen, results, () => withItems(origin, rank(config, spaces, chosen, query, filters, CLOSEST, false, true))), 200)
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if (config.pictures !== false) app.openapi(
|
|
230
|
+
createRoute({
|
|
231
|
+
method: 'post',
|
|
232
|
+
path,
|
|
233
|
+
operationId: `search${name}sByImage`,
|
|
234
|
+
tags: [tag],
|
|
235
|
+
summary: `${noun[0]!.toUpperCase()}${noun.slice(1)} that look like a picture`,
|
|
236
|
+
description: `${noun} like a picture you send (a sketch, a screenshot, a photo), PNG, JPEG, WebP or GIF up to 8 MB: \`multipart/form-data\` with an \`image\` file (\`curl -F image=@sketch.png\`), or JSON \`{ "image": "data:image/png;base64,…" }\` (or one of this API's own image links${config.imageHosts?.length ? `, or an https URL on ${config.imageHosts.join(', ')}` : ''}). \`by=meaning\` finds ${noun} about what the picture shows instead of ones that look like it. Filters go in the query string. Costs one image embedding (~$0.0001).`,
|
|
237
|
+
request: {
|
|
238
|
+
query: z.object({ by: by('look', ['look', 'meaning', 'both']), limit, ...filterShape }),
|
|
239
|
+
body: {
|
|
240
|
+
// Each content type validates its own body; `required` would run the JSON check on an upload too
|
|
241
|
+
required: false,
|
|
242
|
+
description: 'Required: the picture as a multipart `image` file, or JSON.',
|
|
243
|
+
content: {
|
|
244
|
+
'multipart/form-data': { schema: z.object({ image: z.any().openapi({ type: 'string', format: 'binary', description: 'The picture file.' }) }).openapi(`${name}ImageUpload`, { description: 'A picture file.' }) },
|
|
245
|
+
'application/json': { schema: z.object({ image: z.string().max(11_000_000).openapi({ description: `The picture as a data URL${config.imageHosts?.length ? ' or an https URL on an allowed host' : ''}.`, example: 'data:image/png;base64,iVBORw0KGgo…' }) }).strict().openapi(`${name}ImageQuery`, { description: 'A picture.' }) },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
responses: { ...ok('Lookalikes, best first.'), ...problemResponses(400, 401, 413, 415, 429, 503) },
|
|
250
|
+
}),
|
|
251
|
+
async (c) => {
|
|
252
|
+
const { by: chosen, limit: count, ...filters } = c.req.valid('query') as { by: By; limit: number } & Filters
|
|
253
|
+
const spaces = spacesFor(config, chosen)
|
|
254
|
+
const upload = (c.req.header('content-type') ?? '').startsWith('multipart/form-data')
|
|
255
|
+
const given = upload ? (await c.req.parseBody())['image'] : ((await c.req.json().catch(() => ({}))) as { image?: unknown }).image
|
|
256
|
+
const origin = originOf(c)
|
|
257
|
+
const credentials = Object.fromEntries(['authorization', 'cookie', 'cf-access-jwt-assertion'].flatMap((header) => (c.req.header(header) ? [[header, c.req.header(header)!]] : [])))
|
|
258
|
+
const picture = await pictureOf(given, config.imageHosts ?? [], fetchImpl, { origin, fetch: (own) => Promise.resolve(app.request(own, { headers: credentials })) })
|
|
259
|
+
const query = await embedding(config.embedder(), (embedder) => embedder.image(picture))
|
|
260
|
+
const results = withItems(origin, rank(config, spaces, chosen, query, filters, count, true))
|
|
261
|
+
return c.json(answer(origin, pathWith(path, c.req.query()), null, chosen, results, () => withItems(origin, rank(config, spaces, chosen, query, filters, CLOSEST, true, true))), 200)
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if (!similarPath) return
|
|
266
|
+
const param = similarPath.match(/\{(\w+)\}/)?.[1] ?? 'id'
|
|
267
|
+
app.openapi(
|
|
268
|
+
createRoute({
|
|
269
|
+
method: 'get',
|
|
270
|
+
path: similarPath,
|
|
271
|
+
operationId: `listSimilar${name}s`,
|
|
272
|
+
tags: [tag],
|
|
273
|
+
summary: `${noun[0]!.toUpperCase()}${noun.slice(1)} like one`,
|
|
274
|
+
description: `${noun} like this one: \`look\` finds the same forms and style, \`meaning\` the same subject in any style. Always answers, closest first. Free.`,
|
|
275
|
+
request: { params: z.object({ [param]: z.string().openapi({ description: 'The item’s id, or its board ref (`type:id`).' }) }), query: z.object({ by: z.enum(offers as [SearchSpace, ...SearchSpace[]]).optional().openapi({ description: both ? 'Compare by what it looks like (`look`, the default when look vectors exist) or what it is about (`meaning`).' : `Only \`${offers[0]}\` here.` }), limit, ...filterShape }) },
|
|
276
|
+
responses: { ...ok('Similar items, closest first.'), ...problemResponses(400, 401, 404, 503) },
|
|
277
|
+
}),
|
|
278
|
+
(c) => {
|
|
279
|
+
const id = (c.req.valid('param') as Record<string, string>)[param]!
|
|
280
|
+
const { by: asked, limit: count, ...filters } = c.req.valid('query') as { by?: SearchSpace; limit: number } & Filters
|
|
281
|
+
const chosen = asked ?? (config.spaces().look ? 'look' : 'meaning')
|
|
282
|
+
const vectors = spacesFor(config, chosen)[chosen]!
|
|
283
|
+
// An id may itself hold a colon (`episode:intro`); a board ref adds a type prefix (`recipe:soup`) to a bare id
|
|
284
|
+
const key = [id, id.replace(/^[a-z][\w-]*:/, '')].map((candidate) => config.keyOf?.(candidate) ?? candidate).find((candidate) => vectorOf(vectors, candidate)) ?? id
|
|
285
|
+
const vector = vectorOf(vectors, key)
|
|
286
|
+
if (!vector) throw new ProblemError('not_found', { detail: `No ${chosen} vector for ${id}: it is not in the catalog, or not embedded yet. Find ids through search.` })
|
|
287
|
+
const keep = config.filters?.keep(filters)
|
|
288
|
+
const rows = nearest(vectors, vector, { limit: count, keep: (other) => other !== key && (keep?.(other) ?? true), groupOf: config.groupOf, perGroup: config.perGroup })
|
|
289
|
+
const origin = originOf(c)
|
|
290
|
+
return c.json(answer(origin, pathWith(c.req.path, c.req.query()), null, chosen, withItems(origin, rows.map((row) => ({ ...row, z: null, space: chosen })))), 200)
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type ToolConfig = Pick<VectorSearchConfig, 'path' | 'similarPath' | 'noun' | 'imageHosts' | 'pictures' | 'offers'> & {
|
|
296
|
+
/** One line naming a result's item, e.g. its title. */
|
|
297
|
+
label: (item: any) => string
|
|
298
|
+
/** The same filters as the routes, as tool arguments. */
|
|
299
|
+
filters?: z.ZodRawShape
|
|
300
|
+
/** Names default to `search_<noun>`, `search_<noun>_by_image`, `similar_<noun>`. */
|
|
301
|
+
names?: Partial<Record<'search' | 'image' | 'similar', string>>
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const summary = (label: ToolConfig['label']) => (json: { query: string | null; by: string; _embedded: { results: Result[]; closest?: Result[] } }) => {
|
|
305
|
+
const line = (result: Result, n: number) => `${n + 1}. ${label(result.item)} · ${result.space} ${result.score}${result.z === null ? '' : ` (z ${result.z})`} · ${(result.item as { _links?: { self?: { href: string } } })._links?.self?.href ?? ''}`.trim()
|
|
306
|
+
const results = json._embedded.results
|
|
307
|
+
if (!results.length) {
|
|
308
|
+
const closest = json._embedded.closest ?? []
|
|
309
|
+
return [`Nothing stands out${json.query ? ` for "${json.query}"` : ''} by ${json.by}. Try other words, by=both, or fewer filters.`, ...(closest.length ? ['Nearest below the bar (not matches):', ...closest.map(line)] : [])].join('\n')
|
|
310
|
+
}
|
|
311
|
+
return results.map(line).join('\n')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The MCP tools for the same three routes, so an agent searches by words, by picture and by likeness through the API
|
|
316
|
+
* with its own credentials.
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* vectorSearchTools({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar', noun: 'recipes', label: (recipe) => `${recipe.title} (${recipe.year})` })
|
|
320
|
+
*/
|
|
321
|
+
export const vectorSearchTools = (config: ToolConfig): McpTool[] => {
|
|
322
|
+
const { path, similarPath, noun } = config
|
|
323
|
+
const filters = config.filters ?? {}
|
|
324
|
+
const offers = config.offers ?? (['look', 'meaning'] as SearchSpace[])
|
|
325
|
+
const by = offers.length > 1 ? z.enum(['both', 'look', 'meaning']).optional().describe('look: appearance; meaning: subject; both (default for words).') : z.enum([offers[0]!] as [SearchSpace]).optional().describe(`Only ${offers[0]} here.`)
|
|
326
|
+
const limit = z.number().int().min(1).max(100).optional().describe('At most this many (default 24).')
|
|
327
|
+
const summarize = summary(config.label)
|
|
328
|
+
const tools: McpTool[] = [
|
|
329
|
+
mcpTool({
|
|
330
|
+
name: config.names?.search ?? `search_${noun}`,
|
|
331
|
+
title: `Search ${noun} by words`,
|
|
332
|
+
description: `${noun} that match a description of what they show or look like, best first. Returns only clear matches: an empty answer means nothing fits.`,
|
|
333
|
+
input: z.object({ q: z.string().min(2).describe('What you are looking for, in words.'), by, limit, ...filters }),
|
|
334
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
335
|
+
call: (args) => ({ path, query: args as never }),
|
|
336
|
+
summarize,
|
|
337
|
+
}),
|
|
338
|
+
...(config.pictures === false ? [] : [mcpTool({
|
|
339
|
+
name: config.names?.image ?? `search_${noun}_by_image`,
|
|
340
|
+
title: `Search ${noun} by picture`,
|
|
341
|
+
description: `${noun} that look like a picture: pass it as a data URL (data:image/png;base64,…), or one of this server's own image links${config.imageHosts?.length ? `, or an https URL on ${config.imageHosts.join(', ')}` : ''}. Other URLs are refused. by=meaning finds ${noun} about what the picture shows.`,
|
|
342
|
+
input: z.object({ image: z.string().describe("A data URL, or one of this server's own image links."), by: z.enum(['look', 'meaning', 'both']).optional().describe('look (default), meaning or both.'), limit, ...filters }),
|
|
343
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
344
|
+
call: ({ image, ...rest }) => ({ method: 'POST', path, query: rest as never, body: { image } }),
|
|
345
|
+
summarize,
|
|
346
|
+
})]),
|
|
347
|
+
]
|
|
348
|
+
if (similarPath) {
|
|
349
|
+
const param = similarPath.match(/\{(\w+)\}/)?.[1] ?? 'id'
|
|
350
|
+
tools.push(
|
|
351
|
+
mcpTool({
|
|
352
|
+
name: config.names?.similar ?? `similar_${noun}`,
|
|
353
|
+
title: `${noun[0]!.toUpperCase()}${noun.slice(1)} like one`,
|
|
354
|
+
description: `${noun} like one you have the id of: by look (same forms and style) or meaning (same subject). Always answers, closest first.`,
|
|
355
|
+
input: z.object({ [param]: z.string().describe(`The item’s ${param}, from a search result or its link (a board ref like \`type:${param}\` works too).`), by: z.enum(offers as [SearchSpace, ...SearchSpace[]]).optional().describe(offers.length > 1 ? 'look or meaning.' : `Only ${offers[0]} here.`), limit, ...filters }),
|
|
356
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
357
|
+
call: (args: Record<string, unknown>) => {
|
|
358
|
+
const { [param]: id, ...rest } = args
|
|
359
|
+
// As given: the route tells an id holding a colon from a board ref's type prefix
|
|
360
|
+
return { path: similarPath.replace(/\{\w+\}/, encodeURIComponent(String(id))), query: rest as never }
|
|
361
|
+
},
|
|
362
|
+
summarize,
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
return tools
|
|
367
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# vectors
|
|
2
|
+
|
|
3
|
+
Embeddings with Gemini Embedding 2 through OpenRouter: what things look like (`look`, from pictures) and what they are
|
|
4
|
+
about (`meaning`, from text), kept in append-only ledgers keyed by a content hash, so a rerun pays only for what
|
|
5
|
+
changed. Plus the maths search needs: `nearest`, `standouts`, `centroid`.
|
|
6
|
+
|
|
7
|
+
Needs: nothing else from the kit. An OpenRouter key per app.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { embedMissing, embedRequest, readLedger, vectorSpace, nearest } from './kit/index.js'
|
|
11
|
+
|
|
12
|
+
const run = await embedMissing({ file: 'data/vectors/meaning.jsonl', items, embed: (inputs) => embedRequest(key, inputs), batchSize: 100, budget: 0.5 })
|
|
13
|
+
const space = vectorSpace(await readLedger('data/vectors/meaning.jsonl'), (key) => store.has(key))
|
|
14
|
+
const similar = nearest(space, vectorOf(space, 'note:1')!, { limit: 12 })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Export | What |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `embedMissing` | embeds only new or changed items, appends each batch as it returns, stops at `budget` (a 402/403 is a `SpendCapError`) |
|
|
20
|
+
| `createEmbedder` | query-time embedding of words or a picture |
|
|
21
|
+
| `readLedger`, `vectorSpace`, `vectorOf`, `centroid` | load a ledger into packed arrays; the latest line per key wins |
|
|
22
|
+
| `nearest`, `standouts` | likeness, and only the rows that stand out from the whole catalog (z-score, with `minScore` floors) |
|
|
23
|
+
| `hashOf` | the content hash; byte-compatible across versions (a test pins it) |
|
|
24
|
+
|
|
25
|
+
## Rules
|
|
26
|
+
|
|
27
|
+
- Builds run off the request path (a job, or a larger machine), start with a pilot (`limit: 200`), always carry a `budget`.
|
|
28
|
+
- Ledgers are data: gitignored, kept offsite (object storage), baked into the image or synced in. A newer ledger left in another
|
|
29
|
+
checkout means live search runs on stale vectors: keep one copy current.
|
|
30
|
+
- Standard: `docs/STANDARD.md` §15.
|