@columenlabs/lumen-ai 0.1.139 → 0.1.141

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
@@ -156,3 +156,35 @@ createAssistant({
156
156
  ```
157
157
 
158
158
  An app with its own assistant loop builds the same block with `bearings({ timeZone, lastAt, app, extras })` and puts it before the message.
159
+
160
+ ### Find: one question, one query
161
+
162
+ The standard way to search an app's records (Columen's canonical setup, 2026-10-28): every app that shows the assistant a collection gives it a find tool, never a list plus one-record reads. One call turns the question into one query that runs where the rows live (D1 or a Durable Object's SQLite). The model gets back only the matching rows and the fields it asked for, and makes the final call itself.
163
+
164
+ - **Filters** (tags, number and date ranges) are exact SQL.
165
+ - **Words** are matched by an FTS5 index (`porter unicode61`, so "egg" finds "eggs"): `any`, `all`, `none` (leave out: "no milk"), and `in` to say which fields to look in.
166
+ - **Meaning** comes from Vectorize: candidates for a loose ask, trimmed by the same filters and words. It is the next step on this part; until it lands, the model does the fuzzy part over what words and filters return.
167
+
168
+ ```ts
169
+ import { d1Sql, defineFind, findSql, findTool } from '@columenlabs/lumen-ai/assistant';
170
+
171
+ export const notesFind = defineFind({
172
+ name: 'notes', label: 'note', table: 'notes', key: 'id',
173
+ where: 'deleted = 0', // the app's own SQL, always applied
174
+ order: 'updated_at DESC', // with no words to rank by
175
+ fields: {
176
+ title: { kind: 'text' },
177
+ body: { kind: 'text', show: false }, // searchable, returned only when asked
178
+ tags: { kind: 'text', json: true }, // a JSON column, returned parsed
179
+ folder: { kind: 'tag' }, // exact filter; its values are listed to the model
180
+ updated_at: { kind: 'date' }, // updated_at_from, updated_at_to
181
+ },
182
+ });
183
+
184
+ // In a group's tool source, per answer (a Durable Object passes doSql(ctx.storage.sql)):
185
+ plainTools([await findTool(notesFind, d1Sql(env.DB))]);
186
+ ```
187
+
188
+ - `findTool` makes `<name>_find` with a flat input schema (every provider takes it) and reads each tag field's values once to list them. The assistant tells the model to put a whole question in one find call.
189
+ - `runFind(def, sql, query)` is the same query without the tool: bound parameters only, never model-written SQL. It gives up after 3 s.
190
+ - `findSql(def)` gives the index's SQL. `create` is an external-content FTS5 table plus triggers that keep it in step on every insert, update and delete, whoever writes. `fill` indexes the rows already there, and `drop` takes it all out. Put `create` and `fill` in a migration (D1) or a budgeted background step (a Durable Object), never on a request. Until the index exists, words fall back to a plain text match and the result says so, so code can ship before its migration.
@@ -214,6 +214,8 @@ export declare class Assistant {
214
214
  /** Opens the tools the person allows: each group's, then the web's. */
215
215
  private openTools;
216
216
  private toolsFor;
217
+ /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
218
+ private batchTool;
217
219
  /** One answer as server-sent events. Abort `signal` to stop it. */
218
220
  turnStream(caller: Caller, req: AssistantMessage, signal?: AbortSignal): ReadableStream<Uint8Array>;
219
221
  turn(caller: Caller, req: AssistantMessage, emit: Emit, signal?: AbortSignal): Promise<void>;
@@ -229,6 +231,8 @@ export declare class Assistant {
229
231
  private handOff;
230
232
  /** One tool call: a draft card for a write, else the call itself, timed. */
231
233
  private runTool;
234
+ /** A batch of reads: each runs as its own call (the same read twice runs once), in parallel, shown as one step. */
235
+ private runBatch;
232
236
  /** Model calls and tool rounds until the model answers; returns its words. */
233
237
  private loop;
234
238
  /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
@@ -45,6 +45,11 @@ const COMPACT_AT = 150_000;
45
45
  /** A tool result handed to the model is cut past this (about 4,000 tokens). */
46
46
  const RESULT_CHARS = 16_000;
47
47
  const MEMO_CHARS = 8000;
48
+ /** The built-in tool that runs several reads at once, as one step. */
49
+ const BATCH = 'batch';
50
+ const BATCH_MAX = 25;
51
+ /** What one batch returns at most, all its results together. */
52
+ const BATCH_CHARS = 48_000;
48
53
  const ADVICE_MAX = 2;
49
54
  const ADVICE_TOKENS = 2048;
50
55
  const ADVISOR_BETA = 'advisor-tool-2026-03-01';
@@ -631,14 +636,49 @@ export class Assistant {
631
636
  };
632
637
  }
633
638
  toolsFor(fns) {
634
- return [...fns.entries()].map(([name, f]) => ({
635
- name,
636
- description: `${f.draft ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. " : f.write ? 'WRITES AT ONCE: this call makes the change now; the person can undo it. ' : ''}${f.def.title ? `${f.def.title}. ` : ''}${f.def.description ?? ''}`.slice(0, 4000),
639
+ const batch = this.batchTool(fns);
640
+ return [
641
+ ...[...fns.entries()].map(([name, f]) => ({
642
+ name,
643
+ description: `${f.draft ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. " : f.write ? 'WRITES AT ONCE: this call makes the change now; the person can undo it. ' : ''}${f.def.title ? `${f.def.title}. ` : ''}${f.def.description ?? ''}`.slice(0, 4000),
644
+ input_schema: {
645
+ type: 'object',
646
+ ...withoutMeta(f.def.inputSchema),
647
+ },
648
+ })),
649
+ ...(batch ? [batch] : []),
650
+ ];
651
+ }
652
+ /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
653
+ batchTool(fns) {
654
+ if (fns.has(BATCH))
655
+ return null;
656
+ const reads = [...fns.entries()].filter(([, f]) => !f.draft && !f.write).map(([n]) => n);
657
+ if (!reads.length)
658
+ return null;
659
+ return {
660
+ name: BATCH,
661
+ description: `Runs up to ${BATCH_MAX} read calls at once and returns each result, numbered in order. Use it to read several records (one tool with several inputs) or several tools that do not depend on each other, in one step instead of one call at a time. Reads only.`,
637
662
  input_schema: {
638
663
  type: 'object',
639
- ...withoutMeta(f.def.inputSchema),
664
+ properties: {
665
+ calls: {
666
+ type: 'array',
667
+ minItems: 1,
668
+ maxItems: BATCH_MAX,
669
+ items: {
670
+ type: 'object',
671
+ properties: {
672
+ tool: { type: 'string', enum: reads },
673
+ input: { type: 'object', description: "The tool's own input." },
674
+ },
675
+ required: ['tool', 'input'],
676
+ },
677
+ },
678
+ },
679
+ required: ['calls'],
640
680
  },
641
- }));
681
+ };
642
682
  }
643
683
  // ---------------------------------------------------------------- the turn
644
684
  /** One answer as server-sent events. Abort `signal` to stop it. */
@@ -918,6 +958,16 @@ export class Assistant {
918
958
  const lines = [
919
959
  `You are ${this.name}. Each message starts with <bearings>: the time now in the person's zone and where they are. Take today, now and how long ago from it; never guess the date. It is for you only: never quote it or start an answer with it.`,
920
960
  'Answer in short plain sentences. Use the tools for facts; never guess a number a tool can give you. Call several tools at once when they do not depend on each other.',
961
+ ...([...fns.values()].some((f) => f.def.find)
962
+ ? [
963
+ "Questions about an app's records go through its find tool (the tool named <app>_find): put every condition of the question in one call (filters, words to include or leave out, which fields to look in), ask only for the fields the answer needs, then judge the rest yourself from what it returns. Never list or open records one by one to filter them. Rows a find already returned in this chat you use again without asking for them again.",
964
+ ]
965
+ : []),
966
+ ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
967
+ ? [
968
+ `To read several records, or one tool with several inputs, put the reads in one ${BATCH} call: one step for all of them, never one call at a time. Results you already have from earlier in the chat you use, without reading them again.`,
969
+ ]
970
+ : []),
921
971
  'A tool marked DRAFT ONLY makes a card the person saves or dismisses; it changes nothing by itself. Say what the card will do; never claim it is done. The next message tells you how each card ended.',
922
972
  'A tool marked WRITES AT ONCE makes its change when you call it; the person can undo it from the chat. Change only what they asked for, and say what you changed.',
923
973
  ];
@@ -933,7 +983,7 @@ export class Assistant {
933
983
  const reach = this.above(main.tier);
934
984
  if (reach.length) {
935
985
  const own = policy === 'any' ? reach : policy === 'next' ? reach.slice(0, 1) : [];
936
- lines.push(`Tiers: you run on ${main.tier.label}. The escalate tool hands one task to a higher tier (${reach.map((t) => t.label).join(' or ')}) with the same tools; it works in its own section of the chat and returns a memo. It costs more than answering yourself.`, `Call it when the person names a tier or says "go deeper"${own.length ? `, and on your own (${own.map((t) => t.label).join(' or ')}) when the question needs judgement across many results (why "judgement"), after two of your tool calls failed (why "stuck"), or when a fix of a check still fails (why "check_failed")` : ''}. Lookups you answer yourself.`, 'Its task must carry every fact from this chat it needs: it does not see the chat. Then answer from its memo in your own short words.');
986
+ lines.push(`Tiers: you run on ${main.tier.label}. The escalate tool hands one task to a higher tier (${reach.map((t) => t.label).join(' or ')}) with the same tools; it works in its own section of the chat and returns a memo. It costs more than answering yourself.`, `Call it when the person names a tier or says "go deeper"${own.length ? `, and on your own (${own.map((t) => t.label).join(' or ')}) when the question needs judgement a lookup cannot settle (why "judgement"), after two of your tool calls failed (why "stuck"), or when a fix of a check still fails (why "check_failed")` : ''}. Lookups you answer yourself: reading or checking many records against a plain rule (an ingredient, a date, a number, a name) is a lookup, done with a find call or ${BATCH}, never escalated.`, 'Its task must carry every fact from this chat it needs: it does not see the chat. Then answer from its memo in your own short words.');
937
987
  }
938
988
  if (advisor)
939
989
  lines.push(`Advisor: the advisor tool (no input) asks ${advisor.session.tier.label} to read this chat and send you a plan; it runs no tools. Call it before a question that needs judgement or after a tool failed, then do the work yourself. At most ${ADVICE_MAX} times an answer. Ask the advisor before you escalate.`);
@@ -1086,13 +1136,17 @@ export class Assistant {
1086
1136
  : reply(`Memo from ${item.label}:\n${memo.slice(0, MEMO_CHARS)}`);
1087
1137
  }
1088
1138
  /** One tool call: a draft card for a write, else the call itself, timed. */
1089
- async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal) {
1139
+ async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal,
1140
+ /** Part of a batch: runs and is remembered, but draws no step of its own. */
1141
+ quiet = false) {
1090
1142
  const reply = (content, is_error = false) => ({
1091
1143
  type: 'tool_result',
1092
1144
  tool_use_id: use.id,
1093
1145
  content,
1094
1146
  is_error,
1095
1147
  });
1148
+ if (use.name === BATCH && !fns.has(BATCH) && !quiet)
1149
+ return this.runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal);
1096
1150
  const f = fns.get(use.name);
1097
1151
  if (!f)
1098
1152
  return reply(`No tool ${use.name}.`, true);
@@ -1123,7 +1177,8 @@ export class Assistant {
1123
1177
  settle = done;
1124
1178
  }));
1125
1179
  const id = newId('s');
1126
- emit('step_start', { id, tool: use.name, title });
1180
+ if (!quiet)
1181
+ emit('step_start', { id, tool: use.name, title });
1127
1182
  const t0 = performance.now();
1128
1183
  let out;
1129
1184
  let ok = true;
@@ -1160,9 +1215,11 @@ export class Assistant {
1160
1215
  ? { write: { undo: !!this.groupById(f.group)?.undo, status: 'done' } }
1161
1216
  : {}),
1162
1217
  };
1163
- sink.push(item);
1164
- emit('step', item);
1165
- counts.steps += 1;
1218
+ if (!quiet) {
1219
+ sink.push(item);
1220
+ emit('step', item);
1221
+ counts.steps += 1;
1222
+ }
1166
1223
  // A change made at once can make an earlier read stale: reads after it run again.
1167
1224
  if (f.write)
1168
1225
  counts.seen.clear();
@@ -1187,6 +1244,68 @@ export class Assistant {
1187
1244
  }
1188
1245
  return reply(cut, !ok);
1189
1246
  }
1247
+ /** A batch of reads: each runs as its own call (the same read twice runs once), in parallel, shown as one step. */
1248
+ async runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal) {
1249
+ const reply = (content, is_error = false) => ({
1250
+ type: 'tool_result',
1251
+ tool_use_id: use.id,
1252
+ content,
1253
+ is_error,
1254
+ });
1255
+ const raw = use.input?.calls;
1256
+ const calls = (Array.isArray(raw) ? raw : []).map((c) => {
1257
+ const o = (c && typeof c === 'object' ? c : {});
1258
+ return {
1259
+ tool: typeof o.tool === 'string' ? o.tool : '',
1260
+ input: o.input && typeof o.input === 'object' ? o.input : {},
1261
+ };
1262
+ });
1263
+ if (!calls.length)
1264
+ return reply(`Give {calls: [{tool, input}]}: 1 to ${BATCH_MAX} reads.`, true);
1265
+ if (calls.length > BATCH_MAX)
1266
+ return reply(`At most ${BATCH_MAX} calls a batch: split them into batches.`, true);
1267
+ const titles = [...new Set(calls.map((c) => fns.get(c.tool)?.def.title ?? c.tool))];
1268
+ const title = titles.length === 1 ? `${titles[0]} ×${calls.length}` : `${calls.length} reads`;
1269
+ const id = newId('s');
1270
+ emit('step_start', { id, tool: BATCH, title });
1271
+ const t0 = performance.now();
1272
+ const done = await Promise.all(calls.map(async (c, i) => {
1273
+ const f = fns.get(c.tool);
1274
+ if (!f)
1275
+ return { ...c, ok: false, text: `No tool ${c.tool}.` };
1276
+ if (f.draft || f.write)
1277
+ return { ...c, ok: false, text: 'Only reads go in a batch: call this tool on its own.' };
1278
+ const r = await this.runTool({
1279
+ type: 'tool_use',
1280
+ id: `${use.id}_${i}`,
1281
+ name: c.tool,
1282
+ input: c.input,
1283
+ }, fns, sink, emit, runs, counts, session, handoff, signal, true);
1284
+ return { ...c, ok: !r.is_error, text: typeof r.content === 'string' ? r.content : '' };
1285
+ }));
1286
+ const failed = done.filter((d) => !d.ok).length;
1287
+ const item = {
1288
+ kind: 'step',
1289
+ id,
1290
+ tool: BATCH,
1291
+ title,
1292
+ ok: failed < done.length,
1293
+ ms: Math.round(performance.now() - t0),
1294
+ ...(failed ? { error: `${failed} of ${done.length} failed` } : {}),
1295
+ };
1296
+ sink.push(item);
1297
+ emit('step', item);
1298
+ counts.steps += 1;
1299
+ let left = BATCH_CHARS;
1300
+ const parts = done.map((d, i) => {
1301
+ const fits = d.text.length <= left;
1302
+ left -= fits ? d.text.length : 0;
1303
+ return `[${i + 1}] ${d.tool} ${stableJson(d.input)}${d.ok ? '' : ' (failed)'}\n${fits
1304
+ ? d.text
1305
+ : '(left out: the batch passed its size; read this one again in a smaller batch)'}`;
1306
+ });
1307
+ return reply(parts.join('\n\n'), failed === done.length);
1308
+ }
1190
1309
  /** Model calls and tool rounds until the model answers; returns its words. */
1191
1310
  loop(o) {
1192
1311
  const { provider } = o.session.model;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Find tools: one call turns a question into one query that runs where the rows live (D1 or a
3
+ * Durable Object's SQLite), so the assistant never lists or opens records one by one to filter them.
4
+ *
5
+ * An app declares its table's fields once (`defineFind`). The kit gives the tool (`findTool`: a flat
6
+ * input of tag filters, words to include or leave out, which fields to look in and which to return),
7
+ * the query (`runFind`: bound parameters only, never model-written SQL) and the SQL that sets up the
8
+ * word index (`findSql`: an FTS5 table kept in step with the table by triggers, whoever writes).
9
+ *
10
+ * const recipesFind = defineFind({ name: 'recipes', label: 'recipe', table: 'recipes', key: 'id',
11
+ * where: 'is_deleted = 0', fields: { title: { kind: 'text' }, category: { kind: 'tag' } } });
12
+ * const tool = await findTool(recipesFind, d1Sql(env.RECIPES_DB));
13
+ *
14
+ * Words are matched by the FTS5 index (stems: "egg" finds "eggs"). Until that index exists, words
15
+ * fall back to a plain text match, so a build that lands before its index still answers.
16
+ */
17
+ import type { SqlRunner } from './store.js';
18
+ import { type PlainTool } from './tools.js';
19
+ /** text: searchable words. tag: an exact value to filter on (listed to the model). number, date: ranges. */
20
+ export type FindKind = 'text' | 'tag' | 'number' | 'date';
21
+ export interface FindField {
22
+ kind: FindKind;
23
+ /** The table's column, when it is named differently. */
24
+ column?: string;
25
+ /** Returned unless the call names its fields. False keeps it searchable but out of the default. */
26
+ show?: boolean;
27
+ /** The column holds JSON (a list of ingredients): returned parsed. */
28
+ json?: boolean;
29
+ /** A few words for the model. */
30
+ about?: string;
31
+ }
32
+ export interface FindDef {
33
+ /** The tool is `<name>_find`. */
34
+ name: string;
35
+ /** What one row is, for the model: "recipe". */
36
+ label: string;
37
+ /** One line on what the rows are. */
38
+ about?: string;
39
+ table: string;
40
+ /** The table's integer primary key (the FTS rowid). */
41
+ key: string;
42
+ /** Always applied: rows the person never sees (deleted ones). The app's own SQL, never the model's. */
43
+ where?: string;
44
+ fields: Record<string, FindField>;
45
+ /** The FTS5 table: `<table>_fts` by default. */
46
+ fts?: string;
47
+ /** The order with no words to rank by: `category, title`. Column names. */
48
+ order?: string;
49
+ /** Rows a call returns by default (25) and at most (200). */
50
+ limit?: number;
51
+ max?: number;
52
+ }
53
+ /** What the model sends: flat, so every provider takes the schema. Tag fields are their own keys. */
54
+ export interface FindQuery {
55
+ any?: string[];
56
+ all?: string[];
57
+ none?: string[];
58
+ in?: string[];
59
+ ids?: number[];
60
+ fields?: string[];
61
+ sort?: string;
62
+ desc?: boolean;
63
+ limit?: number;
64
+ [tagOrRange: string]: unknown;
65
+ }
66
+ export interface FindResult {
67
+ /** Rows that match, all of them, before `limit`. */
68
+ count: number;
69
+ rows: Record<string, unknown>[];
70
+ /** Set when words were matched without the word index (it is not set up yet). */
71
+ note?: string;
72
+ }
73
+ /** Checks a definition once, where the app declares it. */
74
+ export declare function defineFind(def: FindDef): FindDef;
75
+ /**
76
+ * The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
77
+ * triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
78
+ * that indexes the rows already there. Each `create` statement is safe to run again. Run `fill` once,
79
+ * in a migration or a budgeted background step, never on a request.
80
+ */
81
+ export declare function findSql(def: FindDef): {
82
+ create: string[];
83
+ fill: string;
84
+ drop: string[];
85
+ };
86
+ /** Runs one find: one query, the rows' asked fields (JSON parsed) and how many match in all. */
87
+ export declare function runFind(def: FindDef, sql: SqlRunner, q: FindQuery): Promise<FindResult>;
88
+ /**
89
+ * The find tool for one table, opened for one answer: its tag values are read once (one small query)
90
+ * so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
91
+ */
92
+ export declare function findTool(def: FindDef, sql: SqlRunner): Promise<PlainTool>;
@@ -0,0 +1,321 @@
1
+ import { ToolError, withDeadline } from './tools.js';
2
+ const RESERVED = new Set(['any', 'all', 'none', 'in', 'ids', 'fields', 'sort', 'desc', 'limit']);
3
+ const FIND_MS = 3_000;
4
+ const TAG_VALUES = 60;
5
+ const WORDS = 20;
6
+ /** Checks a definition once, where the app declares it. */
7
+ export function defineFind(def) {
8
+ const ident = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ for (const n of [def.name, def.table, def.key, def.fts ?? 'x'])
10
+ if (!ident.test(n))
11
+ throw new Error(`find ${def.name}: "${n}" is not a plain name`);
12
+ for (const [n, f] of Object.entries(def.fields)) {
13
+ if (!ident.test(n) || !ident.test(f.column ?? n))
14
+ throw new Error(`find ${def.name}: field "${n}" is not a plain name`);
15
+ if (RESERVED.has(n))
16
+ throw new Error(`find ${def.name}: field "${n}" is a reserved word`);
17
+ }
18
+ if (!Object.values(def.fields).some((f) => f.kind === 'text'))
19
+ throw new Error(`find ${def.name}: give at least one text field`);
20
+ return def;
21
+ }
22
+ const col = (def, f) => def.fields[f]?.column ?? f;
23
+ const ftsName = (def) => def.fts ?? `${def.table}_fts`;
24
+ const textFields = (def) => Object.entries(def.fields)
25
+ .filter(([, f]) => f.kind === 'text')
26
+ .map(([n]) => n);
27
+ /**
28
+ * The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
29
+ * triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
30
+ * that indexes the rows already there. Each `create` statement is safe to run again. Run `fill` once,
31
+ * in a migration or a budgeted background step, never on a request.
32
+ */
33
+ export function findSql(def) {
34
+ const fts = ftsName(def);
35
+ const cols = textFields(def).map((f) => col(def, f));
36
+ const list = cols.join(', ');
37
+ const nu = cols.map((c) => `new.${c}`).join(', ');
38
+ const ol = cols.map((c) => `old.${c}`).join(', ');
39
+ return {
40
+ create: [
41
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5(${list}, content='${def.table}', content_rowid='${def.key}', tokenize='porter unicode61')`,
42
+ `CREATE TRIGGER IF NOT EXISTS ${fts}_ai AFTER INSERT ON ${def.table} BEGIN INSERT INTO ${fts}(rowid, ${list}) VALUES (new.${def.key}, ${nu}); END`,
43
+ `CREATE TRIGGER IF NOT EXISTS ${fts}_ad AFTER DELETE ON ${def.table} BEGIN INSERT INTO ${fts}(${fts}, rowid, ${list}) VALUES ('delete', old.${def.key}, ${ol}); END`,
44
+ `CREATE TRIGGER IF NOT EXISTS ${fts}_au AFTER UPDATE ON ${def.table} BEGIN INSERT INTO ${fts}(${fts}, rowid, ${list}) VALUES ('delete', old.${def.key}, ${ol}); INSERT INTO ${fts}(rowid, ${list}) VALUES (new.${def.key}, ${nu}); END`,
45
+ ],
46
+ fill: `INSERT INTO ${fts}(${fts}) VALUES ('rebuild')`,
47
+ drop: [
48
+ `DROP TRIGGER IF EXISTS ${fts}_ai`,
49
+ `DROP TRIGGER IF EXISTS ${fts}_ad`,
50
+ `DROP TRIGGER IF EXISTS ${fts}_au`,
51
+ `DROP TABLE IF EXISTS ${fts}`,
52
+ ],
53
+ };
54
+ }
55
+ /** Words as the model sent them (a list, or one string as a list of one): trimmed, deduped, at most `WORDS`. */
56
+ function words(v) {
57
+ const list = Array.isArray(v) ? v : typeof v === 'string' ? [v] : [];
58
+ const out = new Set();
59
+ for (const w of list) {
60
+ if (typeof w !== 'string')
61
+ continue;
62
+ const t = w.replace(/\s+/g, ' ').trim().slice(0, 80);
63
+ if (t)
64
+ out.add(t);
65
+ }
66
+ return [...out].slice(0, WORDS);
67
+ }
68
+ /** One FTS5 term: a quoted phrase, so the model's words are never read as FTS syntax. */
69
+ const phrase = (w) => `"${w.replace(/"/g, '""')}"`;
70
+ function strings(v) {
71
+ const a = Array.isArray(v) ? v : v === undefined || v === null ? [] : [v];
72
+ return a.filter((x) => typeof x === 'string' || typeof x === 'number').map((x) => String(x));
73
+ }
74
+ /** The query, checked: what is not a field of this table is an error the model can fix. */
75
+ function checked(def, q) {
76
+ const names = Object.keys(def.fields);
77
+ const unknown = (list, what) => {
78
+ const bad = list.filter((f) => !def.fields[f]);
79
+ if (bad.length)
80
+ throw new ToolError(`${what}: no field ${bad.join(', ')}. Fields: ${names.join(', ')}.`);
81
+ };
82
+ const inFields = strings(q.in);
83
+ unknown(inFields, 'in');
84
+ const notText = inFields.filter((f) => def.fields[f]?.kind !== 'text');
85
+ if (notText.length)
86
+ throw new ToolError(`in: ${notText.join(', ')} are not text fields.`);
87
+ const fields = strings(q.fields);
88
+ unknown(fields, 'fields');
89
+ if (q.sort !== undefined)
90
+ unknown(strings(q.sort), 'sort');
91
+ for (const k of Object.keys(q)) {
92
+ if (RESERVED.has(k))
93
+ continue;
94
+ const base = k.replace(/_(min|max|from|to)$/, '');
95
+ if (!def.fields[base] || def.fields[base]?.kind === 'text')
96
+ throw new ToolError(`${k}: not a filter. Filters: ${filterKeys(def).join(', ')}.`);
97
+ }
98
+ const max = def.max ?? 200;
99
+ const limit = Math.max(1, Math.min(max, Math.trunc(Number(q.limit ?? def.limit ?? 25)) || 25));
100
+ return {
101
+ any: words(q.any),
102
+ all: words(q.all),
103
+ none: words(q.none),
104
+ in: inFields.length ? inFields : textFields(def),
105
+ ids: (Array.isArray(q.ids) ? q.ids : []).map(Number).filter(Number.isInteger).slice(0, max),
106
+ fields: fields.length ? fields : names.filter((n) => def.fields[n]?.show !== false),
107
+ sort: typeof q.sort === 'string' ? q.sort : undefined,
108
+ desc: q.desc === true,
109
+ limit,
110
+ };
111
+ }
112
+ function filterKeys(def) {
113
+ return Object.entries(def.fields).flatMap(([n, f]) => f.kind === 'tag'
114
+ ? [n]
115
+ : f.kind === 'number'
116
+ ? [`${n}_min`, `${n}_max`]
117
+ : f.kind === 'date'
118
+ ? [`${n}_from`, `${n}_to`]
119
+ : []);
120
+ }
121
+ /** One find as SQL and its bound values. `fts` false matches words with LIKE (no index yet). */
122
+ function compile(def, q, fts) {
123
+ const c = checked(def, q);
124
+ const t = 't';
125
+ const where = [];
126
+ const args = [];
127
+ if (def.where)
128
+ where.push(`(${def.where})`);
129
+ for (const [n, f] of Object.entries(def.fields)) {
130
+ const column = `${t}.${col(def, n)}`;
131
+ if (f.kind === 'tag') {
132
+ const vals = strings(q[n]).slice(0, 50);
133
+ if (vals.length) {
134
+ where.push(`${column} COLLATE NOCASE IN (${vals.map(() => '?').join(', ')})`);
135
+ args.push(...vals);
136
+ }
137
+ }
138
+ else if (f.kind === 'number' || f.kind === 'date') {
139
+ const [lo, hi] = f.kind === 'number' ? ['_min', '_max'] : ['_from', '_to'];
140
+ for (const [suffix, op] of [
141
+ [lo, '>='],
142
+ [hi, '<='],
143
+ ]) {
144
+ const v = q[`${n}${suffix}`];
145
+ if (v === undefined || v === null || v === '')
146
+ continue;
147
+ where.push(`${column} ${op} ?`);
148
+ args.push(f.kind === 'number' ? Number(v) : String(v));
149
+ }
150
+ }
151
+ }
152
+ if (c.ids.length) {
153
+ where.push(`${t}.${def.key} IN (${c.ids.map(() => '?').join(', ')})`);
154
+ args.push(...c.ids);
155
+ }
156
+ const fname = ftsName(def);
157
+ const cols = c.in.map((f) => col(def, f));
158
+ const scope = `{${cols.join(' ')}} : `;
159
+ const positive = [
160
+ ...(c.any.length ? [`(${c.any.map(phrase).join(' OR ')})`] : []),
161
+ ...c.all.map(phrase),
162
+ ];
163
+ let join = '';
164
+ let rank = '';
165
+ if (fts) {
166
+ if (positive.length) {
167
+ join = ` JOIN ${fname} ON ${fname}.rowid = ${t}.${def.key}`;
168
+ where.push(`${fname} MATCH ?`);
169
+ args.push(`${scope}(${positive.join(' AND ')})`);
170
+ rank = `${fname}.rank`;
171
+ }
172
+ if (c.none.length) {
173
+ where.push(`${t}.${def.key} NOT IN (SELECT rowid FROM ${fname} WHERE ${fname} MATCH ?)`);
174
+ args.push(`${scope}(${c.none.map(phrase).join(' OR ')})`);
175
+ }
176
+ }
177
+ else {
178
+ const like = (w) => {
179
+ args.push(...cols.map(() => `%${w}%`));
180
+ return `(${cols.map((x) => `${t}.${x} LIKE ?`).join(' OR ')})`;
181
+ };
182
+ if (c.any.length)
183
+ where.push(`(${c.any.map(like).join(' OR ')})`);
184
+ for (const w of c.all)
185
+ where.push(like(w));
186
+ for (const w of c.none)
187
+ where.push(`NOT ${like(w)}`);
188
+ }
189
+ const order = c.sort
190
+ ? `${t}.${col(def, c.sort)} ${c.desc ? 'DESC' : 'ASC'}`
191
+ : rank ||
192
+ (def.order
193
+ ? def.order
194
+ .split(',')
195
+ .map((o) => `${t}.${o.trim()}`)
196
+ .join(', ')
197
+ : `${t}.${def.key}`);
198
+ const select = [...new Set([def.key, ...c.fields])]
199
+ .map((f) => (f === def.key ? `${t}.${def.key} AS ${def.key}` : `${t}.${col(def, f)} AS ${f}`))
200
+ .join(', ');
201
+ const sql = `SELECT ${select}, COUNT(*) OVER () AS _count FROM ${def.table} ${t}${join}${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY ${order} LIMIT ${c.limit}`;
202
+ return { sql, args, fields: c.fields, words: c.any.length + c.all.length + c.none.length > 0 };
203
+ }
204
+ const missingIndex = (e) => e instanceof Error && /no such table|no such module|fts5/i.test(e.message);
205
+ /** Runs one find: one query, the rows' asked fields (JSON parsed) and how many match in all. */
206
+ export async function runFind(def, sql, q) {
207
+ let built = compile(def, q, true);
208
+ let note;
209
+ let rows;
210
+ try {
211
+ rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
212
+ }
213
+ catch (e) {
214
+ if (!built.words || !missingIndex(e))
215
+ throw e;
216
+ built = compile(def, q, false);
217
+ note = 'Words were matched as plain text: the word index is not set up yet.';
218
+ rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
219
+ }
220
+ const count = Number(rows[0]?._count ?? 0);
221
+ return {
222
+ count,
223
+ rows: rows.map((r) => {
224
+ const out = { [def.key]: r[def.key] };
225
+ for (const f of built.fields) {
226
+ const v = r[f];
227
+ if (def.fields[f]?.json && typeof v === 'string') {
228
+ try {
229
+ out[f] = JSON.parse(v);
230
+ }
231
+ catch {
232
+ out[f] = v;
233
+ }
234
+ }
235
+ else
236
+ out[f] = v;
237
+ }
238
+ return out;
239
+ }),
240
+ ...(note ? { note } : {}),
241
+ };
242
+ }
243
+ /** The values a tag field holds, to list in the tool's description. */
244
+ async function tagValues(def, sql, field) {
245
+ const c = col(def, field);
246
+ const rows = await sql.all(`SELECT DISTINCT ${c} AS v FROM ${def.table}${def.where ? ` WHERE ${def.where}` : ''} ORDER BY ${c} LIMIT ${TAG_VALUES + 1}`);
247
+ return rows.map((r) => String(r.v ?? '')).filter(Boolean);
248
+ }
249
+ /** The tool's flat JSON Schema, from the fields. */
250
+ function schemaOf(def, tags) {
251
+ const names = Object.keys(def.fields);
252
+ const text = textFields(def);
253
+ const list = (description, items = { type: 'string' }) => ({
254
+ type: 'array',
255
+ items,
256
+ description,
257
+ });
258
+ const props = {
259
+ any: list('Rows with at least one of these words or phrases.'),
260
+ all: list('Rows with every one of these words or phrases.'),
261
+ none: list('Leave out rows with any of these words or phrases (an ingredient to avoid).'),
262
+ in: list('Which fields the words must be in; all text fields when left out.', {
263
+ type: 'string',
264
+ enum: text,
265
+ }),
266
+ ids: list(`Only these ${def.label}s, by ${def.key}.`, { type: 'integer' }),
267
+ fields: list(`What to return for each ${def.label}; ${names.filter((n) => def.fields[n]?.show !== false).join(', ')} when left out. Ask only for what the answer needs.`, { type: 'string', enum: names }),
268
+ sort: {
269
+ type: 'string',
270
+ enum: names,
271
+ description: 'Order by this field; by best word match, else the default order, when left out.',
272
+ },
273
+ desc: { type: 'boolean', description: 'Highest first.' },
274
+ limit: {
275
+ type: 'integer',
276
+ minimum: 1,
277
+ maximum: def.max ?? 200,
278
+ description: `Rows to return (${def.limit ?? 25} by default). The result says how many match in all.`,
279
+ },
280
+ };
281
+ for (const [n, f] of Object.entries(def.fields)) {
282
+ if (f.kind === 'tag') {
283
+ const vals = tags[n] ?? [];
284
+ props[n] = list(`Only these ${n} values${vals.length && vals.length <= TAG_VALUES ? `: ${vals.join(', ')}` : ''}.`, vals.length && vals.length <= TAG_VALUES
285
+ ? { type: 'string', enum: vals }
286
+ : { type: 'string' });
287
+ }
288
+ else if (f.kind === 'number') {
289
+ props[`${n}_min`] = { type: 'number', description: `${n} at least this.` };
290
+ props[`${n}_max`] = { type: 'number', description: `${n} at most this.` };
291
+ }
292
+ else if (f.kind === 'date') {
293
+ props[`${n}_from`] = { type: 'string', description: `${n} on or after this (YYYY-MM-DD).` };
294
+ props[`${n}_to`] = { type: 'string', description: `${n} on or before this (YYYY-MM-DD).` };
295
+ }
296
+ }
297
+ return { type: 'object', properties: props, additionalProperties: false };
298
+ }
299
+ /**
300
+ * The find tool for one table, opened for one answer: its tag values are read once (one small query)
301
+ * so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
302
+ */
303
+ export async function findTool(def, sql) {
304
+ const tags = {};
305
+ await Promise.all(Object.entries(def.fields)
306
+ .filter(([, f]) => f.kind === 'tag')
307
+ .map(async ([n]) => {
308
+ tags[n] = await withDeadline(`${def.name} values`, FIND_MS, tagValues(def, sql, n)).catch(() => []);
309
+ }));
310
+ const fields = Object.entries(def.fields)
311
+ .map(([n, f]) => `${n} (${f.kind}${f.about ? `: ${f.about}` : ''})`)
312
+ .join(', ');
313
+ return {
314
+ name: `${def.name}_find`,
315
+ description: `Finds ${def.label}s in one query${def.about ? ` (${def.about})` : ''}: put every condition of the question in one call (filters, words to include or leave out, which fields to look in) and ask only for the fields the answer needs. Fields: ${fields}. Returns the matching ${def.label}s and how many match in all.`,
316
+ inputSchema: schemaOf(def, tags),
317
+ read: true,
318
+ find: true,
319
+ run: (args) => runFind(def, sql, (args ?? {})),
320
+ };
321
+ }
@@ -15,6 +15,7 @@
15
15
  export * from './bearings.js';
16
16
  export * from './chatgpt.js';
17
17
  export * from './engine.js';
18
+ export * from './find.js';
18
19
  export * from './models.js';
19
20
  export * from './routes.js';
20
21
  export { DEFAULT_CAP, type KeyDoc, keyDocName, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, State, type TokenDoc, waitText, } from './state.js';
@@ -15,6 +15,7 @@
15
15
  export * from './bearings.js';
16
16
  export * from './chatgpt.js';
17
17
  export * from './engine.js';
18
+ export * from './find.js';
18
19
  export * from './models.js';
19
20
  export * from './routes.js';
20
21
  export { DEFAULT_CAP, keyDocName, monthIn, State, waitText, } from './state.js';
@@ -7,6 +7,8 @@ export interface ToolDef {
7
7
  /** JSON Schema of its input (an object). */
8
8
  inputSchema: Record<string, unknown>;
9
9
  read: boolean;
10
+ /** A find tool (`findTool`): one query per question, so the assistant puts every condition in it. */
11
+ find?: boolean;
10
12
  }
11
13
  /** Tools opened for one answer: what they are, how to call one, and how to let them go. */
12
14
  export interface OpenTools {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.139",
3
+ "version": "0.1.141",
4
4
  "description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@anthropic-ai/sdk": "0.133.0",
44
- "@columenlabs/lumen-ops": "0.1.139",
45
- "@columenlabs/lumen-ui-types": "0.1.139",
44
+ "@columenlabs/lumen-ops": "0.1.141",
45
+ "@columenlabs/lumen-ui-types": "0.1.141",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },