@fugood/buttress-server 2.25.1-beta.1 → 2.25.1-beta.3

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/README.md CHANGED
@@ -33,6 +33,11 @@ variant packages plus onnxruntime, sharp, and oxc-transform prebuilds — into a
33
33
  both). Override detection with
34
34
  `--ggml-variant=default|cuda|vulkan|snapdragon|all`.
35
35
 
36
+ Installs and upgrades are transactional: the executable, the loose assets and
37
+ every native package are downloaded into a staging directory and validated
38
+ before any live path is replaced, and a failure part-way (or during the
39
+ replacement itself) leaves the previously installed version runnable.
40
+
36
41
  Build the distribution locally from this package:
37
42
 
38
43
  ```bash
@@ -278,6 +283,8 @@ Loads a GGUF LLM. Runtime keys above can be overridden per-generator under `[gen
278
283
  | `n_ctx` | number | Context window. Auto-capped at the model's training context. |
279
284
  | `n_gpu_layers` | number\|`"auto"` | Layers offloaded to GPU (default `"auto"`) |
280
285
  | `n_batch` | number | Prompt batch size (default `512`) |
286
+ | `embedding` | boolean | Enable llama.cpp embedding mode for a dedicated embedding model (default `false`) |
287
+ | `pooling_type` | string | Optional embedding pooling override such as `mean` |
281
288
  | `n_ubatch`, `n_threads`, `n_parallel`, `n_cpu_moe` | number | Same semantics as the `[runtime]` defaults |
282
289
  | `flash_attn_type`, `cache_type_k`, `cache_type_v`, `kv_unified`, `swa_full`, `ctx_shift`, `state_cache_budget_mb`, `state_cache_max_checkpoints`, `use_mmap`, `use_mlock`, `no_extra_bufts`, `cpu_mask`, `cpu_strict`, `devices` | various | Per-model overrides for the `[runtime]` defaults |
283
290
 
@@ -572,7 +579,7 @@ dir = "./functions" # relative paths resolve against this config file
572
579
 
573
580
  On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and a commented `_example.ts` when the directory holds no functions yet.
574
581
 
575
- Ready-to-copy examples — a no-prerequisite starter, LLM summarization, ffmpeg + STT transcription, TTS with a downloadable result, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
582
+ Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
576
583
 
577
584
  ### Writing a function
578
585
 
@@ -605,6 +612,9 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
605
612
  | -------------- | ------------ |
606
613
  | `spawn(cmd, args?, opts?)` | Run a child process; resolves `{ code, signal, stdout, stderr, truncated }`. A non-zero exit resolves — check `code`. Every child is killed when the call ends. |
607
614
  | `buttress.completion({ model?, messages, max_tokens?, onToken?, … })` | Run a chat completion on this server's LLM generator. `messages` go through the model's chat template; thinking is off unless you pass `enable_thinking: true` (which fills `reasoning_content`). Other keys reach the backend verbatim. |
615
+ | `buttress.embedding({ model?, text, embd_normalize? })` | Embed text with a configured `ggml-llm` generator whose model has `embedding = true`; returns `{ embedding: number[] }`. |
616
+ | `buttress.tokenize({ model?, text, params? })` | Tokenize with a configured GGML or MLX LLM → `{ tokens: number[], … }`. |
617
+ | `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
608
618
  | `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
609
619
  | `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
610
620
  | `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
@@ -614,7 +624,7 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
614
624
  | `log`, `fetch`, `env`, `config`, `dir` | Prefixed logging, host `fetch`, `process.env`, the `[functions.config]` table, the functions directory. |
615
625
  | `libs` | `_`/`lodash`, `moment`, `math`/`mathjs`, `voca`, `chroma`, `json5`, `qs`, `bytes`, `ms`, `nanoid`, `md5`. |
616
626
 
617
- Functions may `import` Node builtins (`node:fs/promises`, …) and sibling files inside the functions directory. Package imports are not supported.
627
+ Functions may `import` Node builtins (`node:fs/promises`, …), sibling files inside the functions directory, and the two provided database packages `sqlite3` and `sqlite-vec`. Other package imports are rejected. `sqlite-vec` supports macOS and Linux on x64/arm64, plus Windows x64; its upstream package does not ship a Windows arm64 extension, so the standalone Windows arm64 build does not provide either SQLite import.
618
628
 
619
629
  Edits are picked up on the next call — the server re-transpiles when a file in the function's module graph changes, so no restart is needed. A file that fails to load is logged and skipped; the rest keep working.
620
630
 
@@ -11,6 +11,7 @@ needed. On first start the server scaffolds `buttress-functions.d.ts` and a
11
11
  |------|-------|-------|
12
12
  | `host-info.ts` | The simplest possible function: a node builtin + `context.libs` | nothing |
13
13
  | `summarize-text.ts` | Calling this server's own LLM (`context.buttress.completion`) | an LLM `[[generators]]` entry |
14
+ | `simple-rag.ts` | Token chunking, embeddings, `sqlite3` + `sqlite-vec` retrieval, then completion | a chat LLM + a GGML embedding `[[generators]]` entry (below) |
14
15
  | `transcribe-media.ts` | `context.spawn` (ffmpeg), the scratch dir, SSE progress, STT | `ffmpeg` on PATH + an STT `[[generators]]` entry |
15
16
  | `text-to-speech.ts` | TTS (`context.buttress.synthesize`) + downloadable output (`context.fileUrl`) | an `onnx-tts` `[[generators]]` entry |
16
17
  | `_auth.ts` | Custom auth: keep workspace tokens working, add static API keys | see the file header |
@@ -18,6 +19,38 @@ needed. On first start the server scaffolds `buttress-functions.d.ts` and a
18
19
  `_auth.ts` is not a function: copying it changes how every `/functions` endpoint
19
20
  authenticates callers. Read its header before copying it.
20
21
 
22
+ `simple-rag.ts` needs a dedicated embedding model. Keep your chat generator as
23
+ the first LLM (or pass its `repo_id` as `chatModel`) and add, for example:
24
+
25
+ ```toml
26
+ [[generators]]
27
+ type = "ggml-llm"
28
+ [generators.model]
29
+ repo_id = "nomic-ai/nomic-embed-text-v1.5-GGUF"
30
+ filename = "nomic-embed-text-v1.5.Q8_0.gguf"
31
+ embedding = true
32
+ pooling_type = "mean"
33
+ n_ctx = 2048
34
+ ```
35
+
36
+ Then call it with `question`, `documents`, and
37
+ `embeddingModel: "nomic-ai/nomic-embed-text-v1.5-GGUF"`. SQLite defaults to an
38
+ in-memory index:
39
+
40
+ ```json
41
+ { "databasePath": ":memory:" }
42
+ ```
43
+
44
+ For a file-backed database, pass an absolute path or one relative to the
45
+ configured functions directory (missing parent directories are created):
46
+
47
+ ```json
48
+ { "databasePath": "./data/simple-rag.sqlite" }
49
+ ```
50
+
51
+ The sample rebuilds `chunks` on each call so both modes behave identically; a
52
+ file retains the latest index after the database closes.
53
+
21
54
  Quick test after copying (on a bound server pass a workspace token, or run
22
55
  unbound with `allow_unauthenticated = true`):
23
56
 
@@ -0,0 +1,231 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import sqlite3 from 'sqlite3'
5
+ import * as sqliteVec from 'sqlite-vec'
6
+
7
+ type Input = {
8
+ question: string
9
+ documents: string[]
10
+ embeddingModel: string
11
+ chatModel?: string
12
+ databasePath?: string
13
+ chunkTokens?: number
14
+ topK?: number
15
+ }
16
+
17
+ type Database = InstanceType<typeof sqlite3.Database>
18
+ type Match = { text: string; distance: number }
19
+
20
+ export const meta: ButtressFunctionMeta = {
21
+ description: 'Answer a question from supplied documents with sqlite-vec retrieval',
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {
25
+ question: { type: 'string', description: 'Question to answer' },
26
+ documents: {
27
+ type: 'array',
28
+ items: { type: 'string' },
29
+ description: 'Source texts to chunk and search',
30
+ },
31
+ embeddingModel: {
32
+ type: 'string',
33
+ description: 'repo_id of a configured GGML generator with embedding = true',
34
+ },
35
+ chatModel: {
36
+ type: 'string',
37
+ description: 'Optional repo_id of the generator that writes the answer',
38
+ },
39
+ databasePath: {
40
+ type: 'string',
41
+ default: ':memory:',
42
+ description:
43
+ 'Use :memory: for an ephemeral index or a SQLite file path; relative paths resolve from the functions directory',
44
+ },
45
+ chunkTokens: { type: 'integer', default: 256, minimum: 32, maximum: 1024 },
46
+ topK: { type: 'integer', default: 3, minimum: 1, maximum: 10 },
47
+ },
48
+ required: ['question', 'documents', 'embeddingModel'],
49
+ },
50
+ timeout: '10m',
51
+ }
52
+
53
+ const resolveDatabasePath = async (
54
+ requestedPath: string | undefined,
55
+ functionsDirectory: string,
56
+ ): Promise<string> => {
57
+ const databasePath = requestedPath?.trim() || ':memory:'
58
+ if (databasePath === ':memory:') return databasePath
59
+
60
+ const resolvedPath = path.isAbsolute(databasePath)
61
+ ? databasePath
62
+ : path.resolve(functionsDirectory, databasePath)
63
+ await mkdir(path.dirname(resolvedPath), { recursive: true })
64
+ return resolvedPath
65
+ }
66
+
67
+ const openDatabase = (databasePath: string): Promise<Database> =>
68
+ new Promise((resolve, reject) => {
69
+ const database = new sqlite3.Database(databasePath, (error) => {
70
+ if (error) reject(error)
71
+ else resolve(database)
72
+ })
73
+ })
74
+
75
+ const loadExtension = (database: Database, extension: string): Promise<void> =>
76
+ new Promise((resolve, reject) => {
77
+ database.loadExtension(extension, (error) => {
78
+ if (error) reject(error)
79
+ else resolve()
80
+ })
81
+ })
82
+
83
+ const run = (database: Database, sql: string, ...params: unknown[]): Promise<void> =>
84
+ new Promise((resolve, reject) => {
85
+ database.run(sql, ...params, (error: Error | null) => {
86
+ if (error) reject(error)
87
+ else resolve()
88
+ })
89
+ })
90
+
91
+ const all = <T>(database: Database, sql: string, ...params: unknown[]): Promise<T[]> =>
92
+ new Promise((resolve, reject) => {
93
+ database.all<T>(sql, ...params, (error: Error | null, rows: T[]) => {
94
+ if (error) reject(error)
95
+ else resolve(rows)
96
+ })
97
+ })
98
+
99
+ const close = (database: Database): Promise<void> =>
100
+ new Promise((resolve, reject) => {
101
+ database.close((error) => {
102
+ if (error) reject(error)
103
+ else resolve()
104
+ })
105
+ })
106
+
107
+ const vectorBlob = (values: number[]): Buffer => {
108
+ const vector = Float32Array.from(values)
109
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength)
110
+ }
111
+
112
+ const clampInteger = (value: unknown, fallback: number, min: number, max: number): number => {
113
+ const parsed = Number(value)
114
+ return Number.isFinite(parsed) ? Math.min(max, Math.max(min, Math.floor(parsed))) : fallback
115
+ }
116
+
117
+ export default async function (
118
+ {
119
+ question,
120
+ documents,
121
+ embeddingModel,
122
+ chatModel,
123
+ databasePath,
124
+ chunkTokens = 256,
125
+ topK = 3,
126
+ }: Input,
127
+ context: ButtressFunctionContext,
128
+ ) {
129
+ if (!question?.trim()) throw new Error('question is required')
130
+ if (!Array.isArray(documents) || documents.length === 0) {
131
+ throw new Error('documents must contain at least one source text')
132
+ }
133
+
134
+ const chunkSize = clampInteger(chunkTokens, 256, 32, 1024)
135
+ const limit = clampInteger(topK, 3, 1, 10)
136
+ const chunks: string[] = []
137
+
138
+ context.emit('progress', { stage: 'chunking', documents: documents.length })
139
+ for (const document of documents) {
140
+ const { tokens } = await context.buttress.tokenize({
141
+ model: embeddingModel,
142
+ text: String(document),
143
+ })
144
+ for (let offset = 0; offset < tokens.length; offset += chunkSize) {
145
+ const text = await context.buttress.detokenize({
146
+ model: embeddingModel,
147
+ tokens: tokens.slice(offset, offset + chunkSize),
148
+ })
149
+ if (text.trim()) chunks.push(text)
150
+ }
151
+ }
152
+ if (chunks.length === 0) throw new Error('documents produced no searchable text')
153
+
154
+ context.emit('progress', { stage: 'embedding', chunks: chunks.length })
155
+ const embedded: { text: string; embedding: number[] }[] = []
156
+ for (const text of chunks) {
157
+ const { embedding } = await context.buttress.embedding({ model: embeddingModel, text })
158
+ if (embedding.length === 0) throw new Error('embedding model returned an empty vector')
159
+ if (embedded.length > 0 && embedding.length !== embedded[0].embedding.length) {
160
+ throw new Error('embedding model returned vectors with inconsistent dimensions')
161
+ }
162
+ embedded.push({ text, embedding })
163
+ }
164
+
165
+ const { embedding: questionEmbedding } = await context.buttress.embedding({
166
+ model: embeddingModel,
167
+ text: question,
168
+ })
169
+ const dimensions = embedded[0].embedding.length
170
+ if (questionEmbedding.length !== dimensions) {
171
+ throw new Error('question embedding dimension does not match the document vectors')
172
+ }
173
+
174
+ const resolvedDatabasePath = await resolveDatabasePath(databasePath, context.dir)
175
+ const database = await openDatabase(resolvedDatabasePath)
176
+ let matches: Match[]
177
+ try {
178
+ // sqlite-vec's convenience `load()` is synchronous, while node-sqlite3's
179
+ // loadExtension is callback-based. Awaiting the path explicitly avoids a
180
+ // race with the first vec0 statement.
181
+ await loadExtension(database, sqliteVec.getLoadablePath())
182
+ // Rebuild on every call so memory and file-backed usage have identical,
183
+ // deterministic behavior. The file retains the latest index after close.
184
+ await run(database, 'DROP TABLE IF EXISTS chunks')
185
+ await run(
186
+ database,
187
+ `CREATE VIRTUAL TABLE chunks USING vec0(embedding float[${dimensions}], text TEXT)`,
188
+ )
189
+ for (const chunk of embedded) {
190
+ await run(
191
+ database,
192
+ 'INSERT INTO chunks(embedding, text) VALUES (?, ?)',
193
+ vectorBlob(chunk.embedding),
194
+ chunk.text,
195
+ )
196
+ }
197
+ matches = await all<Match>(
198
+ database,
199
+ `SELECT text, distance
200
+ FROM chunks
201
+ WHERE embedding MATCH ?
202
+ ORDER BY distance
203
+ LIMIT ${limit}`,
204
+ vectorBlob(questionEmbedding),
205
+ )
206
+ } finally {
207
+ await close(database)
208
+ }
209
+
210
+ context.emit('progress', { stage: 'answering', matches: matches.length })
211
+ const sources = matches.map((match, index) => `[${index + 1}] ${match.text}`).join('\n\n')
212
+ const answer = await context.buttress.completion({
213
+ model: chatModel,
214
+ messages: [
215
+ {
216
+ role: 'system',
217
+ content:
218
+ 'Answer only from the supplied sources. If they do not contain the answer, say so.',
219
+ },
220
+ { role: 'user', content: `Sources:\n${sources}\n\nQuestion: ${question}` },
221
+ ],
222
+ max_tokens: 512,
223
+ })
224
+
225
+ return {
226
+ answer: answer.content,
227
+ matches,
228
+ chunks: chunks.length,
229
+ usage: answer.usage,
230
+ }
231
+ }
@@ -12,6 +12,8 @@
12
12
  * and `JSON.stringify` work, but `instanceof` against host constructors does
13
13
  * not. Never `instanceof`-check a function's return value.
14
14
  */
15
+ /** The deliberately small package surface local functions may import. */
16
+ export declare const SUPPORTED_PACKAGE_IMPORTS: readonly ['sqlite3', 'sqlite-vec'];
15
17
  export declare class FunctionImportError extends Error {
16
18
  constructor(message: string);
17
19
  }
@@ -23,6 +25,9 @@ export declare const createSandboxGlobals: (extra?: Record<string, any>) => Reco
23
25
  export type ResolvedSpecifier = {
24
26
  kind: 'builtin';
25
27
  id: string;
28
+ } | {
29
+ kind: 'package';
30
+ id: string;
26
31
  } | {
27
32
  kind: 'file';
28
33
  path: string;
@@ -9,6 +9,6 @@
9
9
  export declare const TYPES_FILE_NAME = "buttress-functions.d.ts";
10
10
  export declare const TSCONFIG_FILE_NAME = "tsconfig.json";
11
11
  export declare const EXAMPLE_FILE_NAME = "_example.ts";
12
- export declare const TYPES_TEMPLATE = "// Generated by @fugood/buttress-server \u2014 do not edit.\n// Rewritten on every server start to match the running version.\n//\n// Local functions are EXPERIMENTAL: this contract may change between\n// releases \u2014 after a server upgrade, re-read this file for the current shape.\n\n/** Result of `context.spawn(...)`. */\ntype ButtressSpawnResult = {\n /** Exit code, or null when the process was killed by a signal. */\n code: number | null\n signal: NodeJS.Signals | null\n stdout: string | Buffer\n stderr: string | Buffer\n /** True when output hit `maxBuffer` and capture stopped early. */\n truncated: boolean\n}\n\ntype ButtressSpawnOptions = {\n cwd?: string\n /** Merged over the server's own environment. */\n env?: Record<string, string | undefined>\n /** Written to stdin, which is then closed. */\n input?: string | Uint8Array\n /** 'utf8' (default) yields strings; 'buffer' yields Buffers. */\n encoding?: 'utf8' | 'buffer'\n /** Per-stream capture cap in bytes (default 8MB). */\n maxBuffer?: number\n onStdout?: (chunk: Buffer) => void\n onStderr?: (chunk: Buffer) => void\n}\n\ntype ButtressCompletionResult = {\n content: string\n reasoning_content?: string\n tool_calls?: any[]\n interrupted?: boolean\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }\n}\n\ntype ButtressFunctionContext = {\n /**\n * Run a child process. Resolves once it exits \u2014 a non-zero `code` is a\n * normal resolution, so check it yourself. Rejects only when the process\n * cannot be started. Every process a call spawns is killed when the call\n * ends or times out.\n */\n spawn: (\n command: string,\n args?: string[],\n options?: ButtressSpawnOptions,\n ) => Promise<ButtressSpawnResult>\n\n buttress: {\n /**\n * Run a chat completion on this server's LLM generator. `messages` are\n * rendered with the model's own chat template, and thinking is off unless\n * you pass `enable_thinking: true` (which fills `reasoning_content`).\n */\n completion: (options: {\n /** Configured `[[generators]]` model; defaults to the first one. */\n model?: string\n messages?: { role: string; content: any }[]\n prompt?: string\n /** Cap on generated tokens. Unbounded when omitted. */\n max_tokens?: number\n temperature?: number\n /** Emit the model's reasoning into `reasoning_content` (default false). */\n enable_thinking?: boolean\n /** Called for every streamed token event. */\n onToken?: (event: { token?: string; content?: string; [key: string]: any }) => void\n /** Any other backend sampling option (`top_p`, `stop`, `tools`, \u2026). */\n [param: string]: any\n }) => Promise<ButtressCompletionResult>\n\n /** Transcribe audio with this server's STT generator. */\n transcribe: (options: {\n /** Must match a configured STT model (`repo_id` or `repo_id:filename`). */\n model?: string\n filePath?: string\n audioData?: Uint8Array | Buffer\n options?: Record<string, any>\n }) => Promise<any>\n\n /**\n * Synthesize speech with this server's TTS generator (onnx-tts). The WAV\n * is written into `tempDir`; return `fileUrl(path)` to let callers\n * download it.\n */\n synthesize: (options: {\n /** Must match a configured TTS model when given. */\n model?: string\n text: string\n /** Backend options; `options.speaker` picks a registered voice. */\n options?: Record<string, any>\n }) => Promise<{ path: string; sampling_rate: number; channels: number }>\n }\n\n fetch: typeof fetch\n /** Server-side log, prefixed with the function name. */\n log: (...args: unknown[]) => void\n /** Emit a progress event. Delivered only to SSE callers; a no-op otherwise. */\n emit: (event: string, data?: unknown) => void\n /** Aborted when the call times out or the caller disconnects. */\n signal: AbortSignal\n env: Record<string, string | undefined>\n /** The `[functions.config]` table from the server config. */\n config: Record<string, any>\n /** Per-call scratch directory, created on first access. */\n tempDir: string\n /**\n * Download URL path (`/functions/files/...`) for a file inside `tempDir`;\n * relative input resolves against it. Callers fetch the URL with the same\n * auth as any function call. Scratch dirs are swept after ~24h.\n */\n fileUrl: (target: string) => string\n /** Absolute path of this functions directory. */\n dir: string\n /** Helper libraries: _, lodash, moment, math, mathjs, voca, chroma, json5, qs, bytes, ms, nanoid, md5. */\n libs: Record<string, any>\n}\n\ntype ButtressFunctionMeta = {\n /** Shown to MCP clients in `tools/list`. */\n description?: string\n /** JSON Schema for the input object, passed to MCP verbatim. */\n parameters?: Record<string, any>\n /** Deadline for this function: ms, or a duration string like \"10m\". */\n timeout?: number | string\n}\n\n// --- Custom auth ------------------------------------------------------------\n// Drop an `_auth.ts` into this directory to gate the /functions endpoints\n// with your own logic. It exports the same shape as a function file:\n//\n// export const meta: ButtressAuthMeta = { mode: 'both' }\n// export default async function (\n// request: ButtressAuthRequest,\n// context: ButtressAuthContext,\n// ): Promise<ButtressAuthResult> { ... }\n//\n// While an `_auth` file exists but fails to load, every call is rejected.\n\ntype ButtressAuthMeta = {\n /**\n * 'both' (default): runs after the built-in workspace auth passes, as an\n * extra gate. 'override': replaces workspace auth \u2014 this function is the\n * only authority (a presented workspace token is still verified into\n * `request.workspaceAuth` so you can choose to honor it).\n */\n mode?: 'override' | 'both'\n}\n\ntype ButtressAuthRequest = {\n method: string\n path: string\n /** Function name for `GET`/`POST /functions/<name>`; undefined for list and MCP. */\n name?: string\n headers: Record<string, string | undefined>\n query: Record<string, string | undefined>\n /** Raw bearer token (Authorization header or `?token=`), if any. */\n token: string | null\n workspaceAuth: {\n /** Whether this server is bound to a workspace. */\n bound: boolean\n /** Whether the caller presented a valid workspace access token. */\n authenticated: boolean\n identity: {\n workspaceId: string\n subjectType: 'ws' | 'dev'\n subjectId: string\n jti?: string\n exp: number\n } | null\n }\n}\n\ntype ButtressAuthContext = {\n /** Server-side log, prefixed with \"_auth\". */\n log: (...args: unknown[]) => void\n fetch: typeof fetch\n env: Record<string, string | undefined>\n /** The `[functions.config]` table, same as `context.config` in functions. */\n config: Record<string, any>\n /** Absolute path of this functions directory. */\n dir: string\n /** Same helper libraries functions get. */\n libs: Record<string, any>\n}\n\n/** Only `true` (or `{ ok: true }`) allows the request; anything else denies. */\ntype ButtressAuthResult =\n | boolean\n | {\n ok: boolean\n /** Response status for a denial, 400-499 (default 403). */\n status?: number\n /** Message returned to the caller on denial. */\n error?: string\n }\n";
12
+ export declare const TYPES_TEMPLATE = "// Generated by @fugood/buttress-server \u2014 do not edit.\n// Rewritten on every server start to match the running version.\n//\n// Local functions are EXPERIMENTAL: this contract may change between\n// releases \u2014 after a server upgrade, re-read this file for the current shape.\n\n/** Result of `context.spawn(...)`. */\ntype ButtressSpawnResult = {\n /** Exit code, or null when the process was killed by a signal. */\n code: number | null\n signal: NodeJS.Signals | null\n stdout: string | Buffer\n stderr: string | Buffer\n /** True when output hit `maxBuffer` and capture stopped early. */\n truncated: boolean\n}\n\ntype ButtressSpawnOptions = {\n cwd?: string\n /** Merged over the server's own environment. */\n env?: Record<string, string | undefined>\n /** Written to stdin, which is then closed. */\n input?: string | Uint8Array\n /** 'utf8' (default) yields strings; 'buffer' yields Buffers. */\n encoding?: 'utf8' | 'buffer'\n /** Per-stream capture cap in bytes (default 8MB). */\n maxBuffer?: number\n onStdout?: (chunk: Buffer) => void\n onStderr?: (chunk: Buffer) => void\n}\n\ntype ButtressCompletionResult = {\n content: string\n reasoning_content?: string\n tool_calls?: any[]\n interrupted?: boolean\n usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }\n}\n\ntype ButtressEmbeddingResult = {\n /** Plain numbers, ready for JSON or conversion into a sqlite-vec float32 BLOB. */\n embedding: number[]\n}\n\ntype ButtressFunctionContext = {\n /**\n * Run a child process. Resolves once it exits \u2014 a non-zero `code` is a\n * normal resolution, so check it yourself. Rejects only when the process\n * cannot be started. Every process a call spawns is killed when the call\n * ends or times out.\n */\n spawn: (\n command: string,\n args?: string[],\n options?: ButtressSpawnOptions,\n ) => Promise<ButtressSpawnResult>\n\n buttress: {\n /**\n * Run a chat completion on this server's LLM generator. `messages` are\n * rendered with the model's own chat template, and thinking is off unless\n * you pass `enable_thinking: true` (which fills `reasoning_content`).\n */\n completion: (options: {\n /** Configured `[[generators]]` model; defaults to the first one. */\n model?: string\n messages?: { role: string; content: any }[]\n prompt?: string\n /** Cap on generated tokens. Unbounded when omitted. */\n max_tokens?: number\n temperature?: number\n /** Emit the model's reasoning into `reasoning_content` (default false). */\n enable_thinking?: boolean\n /** Called for every streamed token event. */\n onToken?: (event: { token?: string; content?: string; [key: string]: any }) => void\n /** Any other backend sampling option (`top_p`, `stop`, `tools`, \u2026). */\n [param: string]: any\n }) => Promise<ButtressCompletionResult>\n\n /**\n * Embed text with a GGML generator whose `[generators.model]` table sets\n * `embedding = true`. The vector is returned as plain numbers.\n */\n embedding: (options: {\n model?: string\n text: string\n /** llama.cpp normalization mode; 2 (L2) is the native default. */\n embd_normalize?: number\n }) => Promise<ButtressEmbeddingResult>\n\n /** Tokenize text with a configured GGML or MLX generator. */\n tokenize: (options: {\n model?: string\n text: string\n params?: Record<string, any>\n }) => Promise<Record<string, any> & { tokens: number[] }>\n\n /** Turn model token ids back into text. */\n detokenize: (options: { model?: string; tokens: number[] }) => Promise<string>\n\n /** Transcribe audio with this server's STT generator. */\n transcribe: (options: {\n /** Must match a configured STT model (`repo_id` or `repo_id:filename`). */\n model?: string\n filePath?: string\n audioData?: Uint8Array | Buffer\n options?: Record<string, any>\n }) => Promise<any>\n\n /**\n * Synthesize speech with this server's TTS generator (onnx-tts). The WAV\n * is written into `tempDir`; return `fileUrl(path)` to let callers\n * download it.\n */\n synthesize: (options: {\n /** Must match a configured TTS model when given. */\n model?: string\n text: string\n /** Backend options; `options.speaker` picks a registered voice. */\n options?: Record<string, any>\n }) => Promise<{ path: string; sampling_rate: number; channels: number }>\n }\n\n fetch: typeof fetch\n /** Server-side log, prefixed with the function name. */\n log: (...args: unknown[]) => void\n /** Emit a progress event. Delivered only to SSE callers; a no-op otherwise. */\n emit: (event: string, data?: unknown) => void\n /** Aborted when the call times out or the caller disconnects. */\n signal: AbortSignal\n env: Record<string, string | undefined>\n /** The `[functions.config]` table from the server config. */\n config: Record<string, any>\n /** Per-call scratch directory, created on first access. */\n tempDir: string\n /**\n * Download URL path (`/functions/files/...`) for a file inside `tempDir`;\n * relative input resolves against it. Callers fetch the URL with the same\n * auth as any function call. Scratch dirs are swept after ~24h.\n */\n fileUrl: (target: string) => string\n /** Absolute path of this functions directory. */\n dir: string\n /** Helper libraries: _, lodash, moment, math, mathjs, voca, chroma, json5, qs, bytes, ms, nanoid, md5. */\n libs: Record<string, any>\n}\n\ntype ButtressFunctionMeta = {\n /** Shown to MCP clients in `tools/list`. */\n description?: string\n /** JSON Schema for the input object, passed to MCP verbatim. */\n parameters?: Record<string, any>\n /** Deadline for this function: ms, or a duration string like \"10m\". */\n timeout?: number | string\n}\n\n// --- Custom auth ------------------------------------------------------------\n// Drop an `_auth.ts` into this directory to gate the /functions endpoints\n// with your own logic. It exports the same shape as a function file:\n//\n// export const meta: ButtressAuthMeta = { mode: 'both' }\n// export default async function (\n// request: ButtressAuthRequest,\n// context: ButtressAuthContext,\n// ): Promise<ButtressAuthResult> { ... }\n//\n// While an `_auth` file exists but fails to load, every call is rejected.\n\ntype ButtressAuthMeta = {\n /**\n * 'both' (default): runs after the built-in workspace auth passes, as an\n * extra gate. 'override': replaces workspace auth \u2014 this function is the\n * only authority (a presented workspace token is still verified into\n * `request.workspaceAuth` so you can choose to honor it).\n */\n mode?: 'override' | 'both'\n}\n\ntype ButtressAuthRequest = {\n method: string\n path: string\n /** Function name for `GET`/`POST /functions/<name>`; undefined for list and MCP. */\n name?: string\n headers: Record<string, string | undefined>\n query: Record<string, string | undefined>\n /** Raw bearer token (Authorization header or `?token=`), if any. */\n token: string | null\n workspaceAuth: {\n /** Whether this server is bound to a workspace. */\n bound: boolean\n /** Whether the caller presented a valid workspace access token. */\n authenticated: boolean\n identity: {\n workspaceId: string\n subjectType: 'ws' | 'dev'\n subjectId: string\n jti?: string\n exp: number\n } | null\n }\n}\n\ntype ButtressAuthContext = {\n /** Server-side log, prefixed with \"_auth\". */\n log: (...args: unknown[]) => void\n fetch: typeof fetch\n env: Record<string, string | undefined>\n /** The `[functions.config]` table, same as `context.config` in functions. */\n config: Record<string, any>\n /** Absolute path of this functions directory. */\n dir: string\n /** Same helper libraries functions get. */\n libs: Record<string, any>\n}\n\n/** Only `true` (or `{ ok: true }`) allows the request; anything else denies. */\ntype ButtressAuthResult =\n | boolean\n | {\n ok: boolean\n /** Response status for a denial, 400-499 (default 403). */\n status?: number\n /** Message returned to the caller on denial. */\n error?: string\n }\n\n// The only non-builtin packages the local-function loader accepts. These\n// declarations keep editor support working when this directory is outside the\n// server package's own node_modules resolution tree.\ndeclare module 'sqlite3' {\n type SqliteCallback = (error: Error | null) => void\n\n class Database {\n constructor(filename: string, callback?: SqliteCallback)\n run(sql: string, ...params: any[]): this\n all<T = Record<string, any>>(\n sql: string,\n ...params: [...any[], (error: Error | null, rows: T[]) => void]\n ): this\n exec(sql: string, callback?: SqliteCallback): this\n loadExtension(filename: string, callback?: SqliteCallback): this\n close(callback?: SqliteCallback): void\n }\n\n const sqlite3: { Database: typeof Database }\n export { Database }\n export default sqlite3\n}\n\ndeclare module 'sqlite-vec' {\n export function getLoadablePath(): string\n export function load(database: { loadExtension(path: string): unknown }): void\n}\n";
13
13
  export declare const TSCONFIG_TEMPLATE = "{\n // Editor support for Buttress local functions.\n // Install @types/node here for typings on \"node:*\" imports.\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"Bundler\",\n \"lib\": [\"ES2023\"],\n \"strict\": true,\n \"noEmit\": true,\n \"allowJs\": true,\n \"skipLibCheck\": true\n },\n \"include\": [\"**/*.ts\", \"**/*.js\"]\n}\n";
14
14
  export declare const EXAMPLE_TEMPLATE = "// Example Buttress local function.\n//\n// Rename (or copy) this file to expose it: files starting with \"_\" are\n// ignored. The tool name is the file name \u2014 \"video-duration.ts\" becomes the\n// tool \"video-duration\", callable over MCP, at POST /functions/video-duration\n// with a JSON body, and at GET /functions/video-duration?path=... with the\n// input in the query string.\n\nexport const meta: ButtressFunctionMeta = {\n description: 'Report the duration of a video file using ffprobe',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Absolute path to a video file' },\n },\n required: ['path'],\n },\n timeout: '2m',\n}\n\nexport default async function (\n { path }: { path: string },\n context: ButtressFunctionContext,\n): Promise<{ seconds: number }> {\n const { code, stdout, stderr } = await context.spawn('ffprobe', [\n '-v',\n 'error',\n '-show_entries',\n 'format=duration',\n '-of',\n 'default=noprint_wrappers=1:nokey=1',\n path,\n ])\n\n if (code !== 0) throw new Error(`ffprobe failed (${code}): ${stderr}`)\n\n return { seconds: Number(String(stdout).trim()) }\n}\n";
@@ -60,6 +60,28 @@ export type CompletionResult = {
60
60
  total_tokens: number;
61
61
  };
62
62
  };
63
+ export type EmbeddingOptions = {
64
+ model?: string;
65
+ text: string;
66
+ /** llama.cpp normalization mode; 2 (L2) is the native default. */
67
+ embd_normalize?: number;
68
+ };
69
+ export type EmbeddingResult = {
70
+ /** Plain numbers (not a cross-realm Float32Array), ready for JSON or sqlite-vec. */
71
+ embedding: number[];
72
+ };
73
+ export type TokenizeOptions = {
74
+ model?: string;
75
+ text: string;
76
+ params?: Record<string, any>;
77
+ };
78
+ export type TokenizeResult = Record<string, any> & {
79
+ tokens: number[];
80
+ };
81
+ export type DetokenizeOptions = {
82
+ model?: string;
83
+ tokens: number[];
84
+ };
63
85
  export type TranscribeOptions = {
64
86
  model?: string;
65
87
  /** Absolute (or functions-dir relative) path to an audio file. */
@@ -85,6 +107,10 @@ export type FunctionContext = {
85
107
  spawn: (command: string, args?: string[], options?: SpawnOptions) => Promise<SpawnResult>;
86
108
  buttress: {
87
109
  completion: (options: CompletionOptions) => Promise<CompletionResult>;
110
+ /** GGML only; the selected generator must set `model.embedding = true`. */
111
+ embedding: (options: EmbeddingOptions) => Promise<EmbeddingResult>;
112
+ tokenize: (options: TokenizeOptions) => Promise<TokenizeResult>;
113
+ detokenize: (options: DetokenizeOptions) => Promise<string>;
88
114
  transcribe: (options: TranscribeOptions) => Promise<any>;
89
115
  synthesize: (options: SynthesizeOptions) => Promise<SynthesizeResult>;
90
116
  };