@columenlabs/lumen-ai 0.1.140 → 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.
@@ -958,6 +958,11 @@ export class Assistant {
958
958
  const lines = [
959
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.`,
960
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
+ : []),
961
966
  ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
962
967
  ? [
963
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.`,
@@ -978,7 +983,7 @@ export class Assistant {
978
983
  const reach = this.above(main.tier);
979
984
  if (reach.length) {
980
985
  const own = policy === 'any' ? reach : policy === 'next' ? reach.slice(0, 1) : [];
981
- 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 ${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.');
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.');
982
987
  }
983
988
  if (advisor)
984
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.`);
@@ -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.140",
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.140",
45
- "@columenlabs/lumen-ui-types": "0.1.140",
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
  },