@columenlabs/lumen-ai 0.1.141 → 0.1.142

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
@@ -163,7 +163,9 @@ The standard way to search an app's records (Columen's canonical setup, 2026-10-
163
163
 
164
164
  - **Filters** (tags, number and date ranges) are exact SQL.
165
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.
166
+ - **Meaning** comes from Vectorize, opt in per app: `like` ("something light for a hot day") asks the index for the nearest rows, and the same filters and words trim them.
167
+ - **Worked-out fields**: a number, date or tag field may be the app's own SQL over the row (`sql: 'json_array_length(t.steps)'`) instead of a column.
168
+ - **Counts**: the result carries `count` (all matches), `returned` and `same_name` (different rows that share a name), so the model says the right number.
167
169
 
168
170
  ```ts
169
171
  import { d1Sql, defineFind, findSql, findTool } from '@columenlabs/lumen-ai/assistant';
@@ -188,3 +190,22 @@ plainTools([await findTool(notesFind, d1Sql(env.DB))]);
188
190
  - `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
191
  - `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
192
  - `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.
193
+
194
+ Meaning, for the apps whose people ask loosely (a recipe book, a career log; not for exact records like card benefits):
195
+
196
+ ```ts
197
+ import { meaningStep, vectorMeaning, workersEmbed } from '@columenlabs/lumen-ai/assistant';
198
+
199
+ // On the definition: what a row means, and the column every write moves.
200
+ meaning: { fields: ['title', 'body', 'tags'], changed: 'updated_at' },
201
+
202
+ const embed = workersEmbed(env.AI); // bge-base-en-v1.5: a 768-dimension cosine index
203
+ await findTool(notesFind, d1Sql(env.DB), { meaning: vectorMeaning({ embed, index: env.NOTES_INDEX }) });
204
+
205
+ // Off the request path (waitUntil after a write and when the tool opens, an alarm, a queue):
206
+ ctx.waitUntil(meaningStep({ def: notesFind, sql: d1Sql(env.DB), embed, index: env.NOTES_INDEX, mark }));
207
+ ```
208
+
209
+ - `meaningStep` embeds only the rows whose `changed` column moved past its bookmark (`mark`: get and put, kept by the app), at most 50 a step in one embedding call and one upsert, and takes rows the find hides (its `where`) out of the index. It resumes where it stopped and runs safely twice at once; `done` is false while rows are left.
210
+ - A person's own rows get a `namespace` each (`vectorMeaning` and `meaningStep` take one), so one index serves one app.
211
+ - When the index cannot answer in 3 s, `like` is left out, the rest of the find runs and the result says so.
@@ -960,7 +960,7 @@ export class Assistant {
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
961
  ...([...fns.values()].some((f) => f.def.find)
962
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.",
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. A loose ask (a mood, an occasion, "something light", "good for a party") goes in its like field when the tool has one; then pick from what it returns yourself. How many match is the result\'s count: say that number, and name each row in same_name, which are different records that share a name. Rows a find already returned you use again only when they answer the whole new question; a new condition (another ingredient, a kind of dish, how quick) is a new find call.',
964
964
  ]
965
965
  : []),
966
966
  ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
@@ -983,7 +983,7 @@ export class Assistant {
983
983
  const reach = this.above(main.tier);
984
984
  if (reach.length) {
985
985
  const own = policy === 'any' ? reach : policy === 'next' ? reach.slice(0, 1) : [];
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.');
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. Suggesting a few of an app's records for a loose ask (a dish for a hot day, something for a party) is a lookup too: one find call, then your own pick, 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.');
987
987
  }
988
988
  if (advisor)
989
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.`);
@@ -13,7 +13,13 @@
13
13
  *
14
14
  * Words are matched by the FTS5 index (stems: "egg" finds "eggs"). Until that index exists, words
15
15
  * fall back to a plain text match, so a build that lands before its index still answers.
16
+ *
17
+ * Meaning (opt in, `meaning` on the definition plus a `FindMeaning` given to `findTool`): `like`
18
+ * asks a vector index for the rows nearest a loose ask ("something light for a hot day") and the
19
+ * same SQL trims them, so filters and words still hold. `meaning.ts` gives the Vectorize index and
20
+ * the budgeted background step that keeps it in step.
16
21
  */
22
+ import type { FindMeaning } from './meaning.js';
17
23
  import type { SqlRunner } from './store.js';
18
24
  import { type PlainTool } from './tools.js';
19
25
  /** text: searchable words. tag: an exact value to filter on (listed to the model). number, date: ranges. */
@@ -22,6 +28,11 @@ export interface FindField {
22
28
  kind: FindKind;
23
29
  /** The table's column, when it is named differently. */
24
30
  column?: string;
31
+ /**
32
+ * Not a column but the app's own SQL over the row, its columns as `t.<column>`
33
+ * (`json_array_length(t.steps)`): a number, date or tag worked out per row. Never text.
34
+ */
35
+ sql?: string;
25
36
  /** Returned unless the call names its fields. False keeps it searchable but out of the default. */
26
37
  show?: boolean;
27
38
  /** The column holds JSON (a list of ingredients): returned parsed. */
@@ -49,9 +60,20 @@ export interface FindDef {
49
60
  /** Rows a call returns by default (25) and at most (200). */
50
61
  limit?: number;
51
62
  max?: number;
63
+ /** The field that names a row (the first text field by default): rows that share one are flagged. */
64
+ title?: string;
65
+ /**
66
+ * Search by meaning: which fields make up what a row means (embedded in the background) and
67
+ * the column that changes on every write, so the index step picks up only what changed.
68
+ */
69
+ meaning?: {
70
+ fields: string[];
71
+ changed: string;
72
+ };
52
73
  }
53
74
  /** What the model sends: flat, so every provider takes the schema. Tag fields are their own keys. */
54
75
  export interface FindQuery {
76
+ like?: string;
55
77
  any?: string[];
56
78
  all?: string[];
57
79
  none?: string[];
@@ -64,14 +86,23 @@ export interface FindQuery {
64
86
  [tagOrRange: string]: unknown;
65
87
  }
66
88
  export interface FindResult {
67
- /** Rows that match, all of them, before `limit`. */
89
+ /** Rows that match, all of them, before `limit`. Say this number when asked how many. */
68
90
  count: number;
91
+ /** Rows in this answer (at most `limit`). */
92
+ returned: number;
69
93
  rows: Record<string, unknown>[];
70
- /** Set when words were matched without the word index (it is not set up yet). */
94
+ /** Different rows that share a name: each is its own row, so say both. */
95
+ same_name?: {
96
+ name: string;
97
+ ids: unknown[];
98
+ }[];
99
+ /** Set when a part of the query could not run as asked (no word index yet, meaning down). */
71
100
  note?: string;
72
101
  }
73
102
  /** Checks a definition once, where the app declares it. */
74
103
  export declare function defineFind(def: FindDef): FindDef;
104
+ /** A field as SQL over the row aliased `t`: its column, or its own expression. */
105
+ export declare const findRef: (def: FindDef, f: string) => string;
75
106
  /**
76
107
  * The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
77
108
  * triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
@@ -83,10 +114,15 @@ export declare function findSql(def: FindDef): {
83
114
  fill: string;
84
115
  drop: string[];
85
116
  };
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>;
117
+ /**
118
+ * Runs one find: one query, the rows' asked fields (JSON parsed) and how many match in all. With
119
+ * `like` and a `meaning`, the index's nearest rows come first and the same SQL trims them.
120
+ */
121
+ export declare function runFind(def: FindDef, sql: SqlRunner, q: FindQuery, meaning?: FindMeaning): Promise<FindResult>;
88
122
  /**
89
123
  * The find tool for one table, opened for one answer: its tag values are read once (one small query)
90
124
  * so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
91
125
  */
92
- export declare function findTool(def: FindDef, sql: SqlRunner): Promise<PlainTool>;
126
+ export declare function findTool(def: FindDef, sql: SqlRunner, opts?: {
127
+ meaning?: FindMeaning;
128
+ }): Promise<PlainTool>;
@@ -1,8 +1,22 @@
1
1
  import { ToolError, withDeadline } from './tools.js';
2
- const RESERVED = new Set(['any', 'all', 'none', 'in', 'ids', 'fields', 'sort', 'desc', 'limit']);
2
+ const RESERVED = new Set([
3
+ 'like',
4
+ 'any',
5
+ 'all',
6
+ 'none',
7
+ 'in',
8
+ 'ids',
9
+ 'fields',
10
+ 'sort',
11
+ 'desc',
12
+ 'limit',
13
+ ]);
3
14
  const FIND_MS = 3_000;
4
15
  const TAG_VALUES = 60;
5
16
  const WORDS = 20;
17
+ /** Rows nearest in meaning that `like` asks the index for, before the SQL trims them. */
18
+ const NEAR_MIN = 20;
19
+ const NEAR_MAX = 50;
6
20
  /** Checks a definition once, where the app declares it. */
7
21
  export function defineFind(def) {
8
22
  const ident = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -14,12 +28,25 @@ export function defineFind(def) {
14
28
  throw new Error(`find ${def.name}: field "${n}" is not a plain name`);
15
29
  if (RESERVED.has(n))
16
30
  throw new Error(`find ${def.name}: field "${n}" is a reserved word`);
31
+ if (f.sql && f.kind === 'text')
32
+ throw new Error(`find ${def.name}: field "${n}" is text, which needs a column, not sql`);
17
33
  }
18
34
  if (!Object.values(def.fields).some((f) => f.kind === 'text'))
19
35
  throw new Error(`find ${def.name}: give at least one text field`);
36
+ if (def.title && def.fields[def.title]?.kind !== 'text')
37
+ throw new Error(`find ${def.name}: title "${def.title}" is not a text field`);
38
+ if (def.meaning) {
39
+ if (!ident.test(def.meaning.changed))
40
+ throw new Error(`find ${def.name}: meaning.changed "${def.meaning.changed}" is not a plain name`);
41
+ const bad = def.meaning.fields.filter((f) => !def.fields[f]);
42
+ if (bad.length || !def.meaning.fields.length)
43
+ throw new Error(`find ${def.name}: meaning.fields ${bad.join(', ') || '(none)'} are not fields`);
44
+ }
20
45
  return def;
21
46
  }
22
47
  const col = (def, f) => def.fields[f]?.column ?? f;
48
+ /** A field as SQL over the row aliased `t`: its column, or its own expression. */
49
+ export const findRef = (def, f) => def.fields[f]?.sql ?? `t.${col(def, f)}`;
23
50
  const ftsName = (def) => def.fts ?? `${def.table}_fts`;
24
51
  const textFields = (def) => Object.entries(def.fields)
25
52
  .filter(([, f]) => f.kind === 'text')
@@ -98,6 +125,7 @@ function checked(def, q) {
98
125
  const max = def.max ?? 200;
99
126
  const limit = Math.max(1, Math.min(max, Math.trunc(Number(q.limit ?? def.limit ?? 25)) || 25));
100
127
  return {
128
+ like: typeof q.like === 'string' ? q.like.replace(/\s+/g, ' ').trim().slice(0, 300) : '',
101
129
  any: words(q.any),
102
130
  all: words(q.all),
103
131
  none: words(q.none),
@@ -118,8 +146,11 @@ function filterKeys(def) {
118
146
  ? [`${n}_from`, `${n}_to`]
119
147
  : []);
120
148
  }
121
- /** One find as SQL and its bound values. `fts` false matches words with LIKE (no index yet). */
122
- function compile(def, q, fts) {
149
+ /**
150
+ * One find as SQL and its bound values. `fts` false matches words with LIKE (no index yet). `near`
151
+ * is the rows nearest in meaning, nearest first: only they can match, in that order.
152
+ */
153
+ function compile(def, q, fts, near) {
123
154
  const c = checked(def, q);
124
155
  const t = 't';
125
156
  const where = [];
@@ -127,7 +158,7 @@ function compile(def, q, fts) {
127
158
  if (def.where)
128
159
  where.push(`(${def.where})`);
129
160
  for (const [n, f] of Object.entries(def.fields)) {
130
- const column = `${t}.${col(def, n)}`;
161
+ const column = findRef(def, n);
131
162
  if (f.kind === 'tag') {
132
163
  const vals = strings(q[n]).slice(0, 50);
133
164
  if (vals.length) {
@@ -153,6 +184,13 @@ function compile(def, q, fts) {
153
184
  where.push(`${t}.${def.key} IN (${c.ids.map(() => '?').join(', ')})`);
154
185
  args.push(...c.ids);
155
186
  }
187
+ let nearOrder = '';
188
+ if (near?.length) {
189
+ where.push(`${t}.${def.key} IN (${near.map(() => '?').join(', ')})`);
190
+ args.push(...near);
191
+ // Literal integers, not bound: the ORDER BY comes after the WHERE's bound values.
192
+ nearOrder = `CASE ${t}.${def.key} ${near.map((id, i) => `WHEN ${Math.trunc(id)} THEN ${i}`).join(' ')} END`;
193
+ }
156
194
  const fname = ftsName(def);
157
195
  const cols = c.in.map((f) => col(def, f));
158
196
  const scope = `{${cols.join(' ')}} : `;
@@ -187,8 +225,9 @@ function compile(def, q, fts) {
187
225
  where.push(`NOT ${like(w)}`);
188
226
  }
189
227
  const order = c.sort
190
- ? `${t}.${col(def, c.sort)} ${c.desc ? 'DESC' : 'ASC'}`
191
- : rank ||
228
+ ? `${findRef(def, c.sort)} ${c.desc ? 'DESC' : 'ASC'}`
229
+ : nearOrder ||
230
+ rank ||
192
231
  (def.order
193
232
  ? def.order
194
233
  .split(',')
@@ -196,16 +235,42 @@ function compile(def, q, fts) {
196
235
  .join(', ')
197
236
  : `${t}.${def.key}`);
198
237
  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}`))
238
+ .map((f) => (f === def.key ? `${t}.${def.key} AS ${def.key}` : `${findRef(def, f)} AS ${f}`))
200
239
  .join(', ');
201
240
  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 };
241
+ return {
242
+ sql,
243
+ args,
244
+ fields: c.fields,
245
+ like: c.like,
246
+ limit: c.limit,
247
+ words: c.any.length + c.all.length + c.none.length > 0,
248
+ };
203
249
  }
204
250
  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) {
251
+ /**
252
+ * Runs one find: one query, the rows' asked fields (JSON parsed) and how many match in all. With
253
+ * `like` and a `meaning`, the index's nearest rows come first and the same SQL trims them.
254
+ */
255
+ export async function runFind(def, sql, q, meaning) {
207
256
  let built = compile(def, q, true);
208
- let note;
257
+ const notes = [];
258
+ let near;
259
+ if (built.like) {
260
+ if (!meaning || !def.meaning)
261
+ throw new ToolError('like: this find has no search by meaning; use words instead.');
262
+ const k = Math.min(NEAR_MAX, Math.max(NEAR_MIN, built.limit * 2));
263
+ try {
264
+ near = await withDeadline(`${def.name} meaning`, FIND_MS, meaning.near(built.like, k));
265
+ }
266
+ catch {
267
+ near = undefined;
268
+ }
269
+ if (near?.length)
270
+ built = compile(def, q, true, near);
271
+ else
272
+ notes.push('Search by meaning is not available now: like was left out, the rest applied.');
273
+ }
209
274
  let rows;
210
275
  try {
211
276
  rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
@@ -213,41 +278,62 @@ export async function runFind(def, sql, q) {
213
278
  catch (e) {
214
279
  if (!built.words || !missingIndex(e))
215
280
  throw e;
216
- built = compile(def, q, false);
217
- note = 'Words were matched as plain text: the word index is not set up yet.';
281
+ built = compile(def, q, false, near);
282
+ notes.push('Words were matched as plain text: the word index is not set up yet.');
218
283
  rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
219
284
  }
220
285
  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
- }
286
+ const out = rows.map((r) => {
287
+ const row = { [def.key]: r[def.key] };
288
+ for (const f of built.fields) {
289
+ const v = r[f];
290
+ if (def.fields[f]?.json && typeof v === 'string') {
291
+ try {
292
+ row[f] = JSON.parse(v);
293
+ }
294
+ catch {
295
+ row[f] = v;
234
296
  }
235
- else
236
- out[f] = v;
237
297
  }
238
- return out;
239
- }),
240
- ...(note ? { note } : {}),
298
+ else
299
+ row[f] = v;
300
+ }
301
+ return row;
302
+ });
303
+ const same = sameNames(def, out, built.fields);
304
+ return {
305
+ count,
306
+ returned: out.length,
307
+ rows: out,
308
+ ...(same.length ? { same_name: same } : {}),
309
+ ...(notes.length ? { note: notes.join(' ') } : {}),
241
310
  };
242
311
  }
312
+ /** Rows in the answer that share a name, so the model counts and lists each of them. */
313
+ function sameNames(def, rows, fields) {
314
+ const title = def.title ?? textFields(def)[0];
315
+ if (!title || !fields.includes(title))
316
+ return [];
317
+ const by = new Map();
318
+ for (const r of rows) {
319
+ const name = String(r[title] ?? '').trim();
320
+ if (!name)
321
+ continue;
322
+ const k = name.toLowerCase();
323
+ const g = by.get(k) ?? { name, ids: [] };
324
+ g.ids.push(r[def.key]);
325
+ by.set(k, g);
326
+ }
327
+ return [...by.values()].filter((g) => g.ids.length > 1);
328
+ }
243
329
  /** The values a tag field holds, to list in the tool's description. */
244
330
  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}`);
331
+ const c = findRef(def, field);
332
+ const rows = await sql.all(`SELECT DISTINCT ${c} AS v FROM ${def.table} t${def.where ? ` WHERE ${def.where}` : ''} ORDER BY v LIMIT ${TAG_VALUES + 1}`);
247
333
  return rows.map((r) => String(r.v ?? '')).filter(Boolean);
248
334
  }
249
335
  /** The tool's flat JSON Schema, from the fields. */
250
- function schemaOf(def, tags) {
336
+ function schemaOf(def, tags, meaning) {
251
337
  const names = Object.keys(def.fields);
252
338
  const text = textFields(def);
253
339
  const list = (description, items = { type: 'string' }) => ({
@@ -256,6 +342,14 @@ function schemaOf(def, tags) {
256
342
  description,
257
343
  });
258
344
  const props = {
345
+ ...(meaning
346
+ ? {
347
+ like: {
348
+ type: 'string',
349
+ description: `A loose ask in plain words ("something light for a hot day", "good for a party"): the ${def.label}s nearest in meaning come first, and every other condition still applies. Use it for a mood, an occasion or a kind of thing, not for one exact word.`,
350
+ },
351
+ }
352
+ : {}),
259
353
  any: list('Rows with at least one of these words or phrases.'),
260
354
  all: list('Rows with every one of these words or phrases.'),
261
355
  none: list('Leave out rows with any of these words or phrases (an ingredient to avoid).'),
@@ -300,7 +394,8 @@ function schemaOf(def, tags) {
300
394
  * The find tool for one table, opened for one answer: its tag values are read once (one small query)
301
395
  * so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
302
396
  */
303
- export async function findTool(def, sql) {
397
+ export async function findTool(def, sql, opts = {}) {
398
+ const meaning = def.meaning ? opts.meaning : undefined;
304
399
  const tags = {};
305
400
  await Promise.all(Object.entries(def.fields)
306
401
  .filter(([, f]) => f.kind === 'tag')
@@ -312,10 +407,10 @@ export async function findTool(def, sql) {
312
407
  .join(', ');
313
408
  return {
314
409
  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),
410
+ 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${meaning ? ', and like for a loose ask' : ''}) and ask only for the fields the answer needs. Fields: ${fields}. Returns the matching ${def.label}s, how many match in all (count) and any that share a name (same_name).`,
411
+ inputSchema: schemaOf(def, tags, Boolean(meaning)),
317
412
  read: true,
318
413
  find: true,
319
- run: (args) => runFind(def, sql, (args ?? {})),
414
+ run: (args) => runFind(def, sql, (args ?? {}), meaning),
320
415
  };
321
416
  }
@@ -16,6 +16,7 @@ export * from './bearings.js';
16
16
  export * from './chatgpt.js';
17
17
  export * from './engine.js';
18
18
  export * from './find.js';
19
+ export * from './meaning.js';
19
20
  export * from './models.js';
20
21
  export * from './routes.js';
21
22
  export { DEFAULT_CAP, type KeyDoc, keyDocName, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, State, type TokenDoc, waitText, } from './state.js';
@@ -16,6 +16,7 @@ export * from './bearings.js';
16
16
  export * from './chatgpt.js';
17
17
  export * from './engine.js';
18
18
  export * from './find.js';
19
+ export * from './meaning.js';
19
20
  export * from './models.js';
20
21
  export * from './routes.js';
21
22
  export { DEFAULT_CAP, keyDocName, monthIn, State, waitText, } from './state.js';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Search by meaning for a find tool: a vector index of what each row means, asked for the rows
3
+ * nearest a loose ask, which the find's own SQL then trims (filters, words, rows the person never
4
+ * sees). Opt in per app: `meaning` on the definition, then
5
+ *
6
+ * const meaning = vectorMeaning({ embed: workersEmbed(env.AI), index: env.NOTES_INDEX });
7
+ * await findTool(notesFind, d1Sql(env.DB), { meaning });
8
+ *
9
+ * The index is kept in step by `meaningStep`, a small budgeted background step (`waitUntil`, an
10
+ * alarm or a queue) that embeds only the rows whose `changed` column moved past its bookmark, so
11
+ * it never runs on the request path and resumes wherever it stopped, whoever wrote the rows.
12
+ */
13
+ import { type FindDef } from './find.js';
14
+ import type { SqlRunner } from './store.js';
15
+ /** The rows nearest in meaning to a loose ask, by key, nearest first. */
16
+ export interface FindMeaning {
17
+ near(text: string, k: number): Promise<number[]>;
18
+ }
19
+ /** Texts to vectors, in order. */
20
+ export type Embed = (texts: string[], as: 'query' | 'passage') => Promise<number[][]>;
21
+ /** What a vector index needs (Cloudflare Vectorize has it). */
22
+ export interface VectorIndex {
23
+ query(vector: number[], options: {
24
+ topK: number;
25
+ namespace?: string;
26
+ }): Promise<{
27
+ matches: {
28
+ id: string;
29
+ score: number;
30
+ }[];
31
+ }>;
32
+ upsert(vectors: {
33
+ id: string;
34
+ values: number[];
35
+ namespace?: string;
36
+ }[]): Promise<unknown>;
37
+ deleteByIds(ids: string[]): Promise<unknown>;
38
+ }
39
+ /** Workers AI's text embeddings (`AI.run`). */
40
+ export interface EmbedAi {
41
+ run(model: string, input: {
42
+ text: string[];
43
+ }): Promise<unknown>;
44
+ }
45
+ export declare const EMBED_MODEL = "@cf/baai/bge-base-en-v1.5";
46
+ /** Embeddings from Workers AI: bge-base-en-v1.5 (768 dimensions, cosine) unless `model` says. */
47
+ export declare function workersEmbed(ai: EmbedAi, model?: string): Embed;
48
+ /** Search by meaning over a vector index: one embedding, one query. */
49
+ export declare function vectorMeaning(o: {
50
+ embed: Embed;
51
+ index: VectorIndex;
52
+ /** One per person when rows are a person's own. */
53
+ namespace?: string;
54
+ }): FindMeaning;
55
+ /** Where the step stopped: the last row's `changed` value and key. */
56
+ export interface MeaningMark {
57
+ at: string | number;
58
+ key: number;
59
+ }
60
+ export interface MeaningStepOptions {
61
+ def: FindDef;
62
+ sql: SqlRunner;
63
+ embed: Embed;
64
+ index: VectorIndex;
65
+ namespace?: string;
66
+ /** The bookmark, kept by the app (a settings row, its object's storage). */
67
+ mark: {
68
+ get(): Promise<MeaningMark | null>;
69
+ put(m: MeaningMark): Promise<void>;
70
+ };
71
+ /** Rows one step reads at most (50). */
72
+ rows?: number;
73
+ }
74
+ /** What a row means, as text: its meaning fields with their names, JSON lists flattened. */
75
+ export declare function meaningText(def: FindDef, row: Record<string, unknown>): string;
76
+ /**
77
+ * One budgeted step of keeping the index in step: the next rows (at most `rows`) whose `changed`
78
+ * column moved past the bookmark, embedded in one call and written in one upsert; rows the find
79
+ * never shows (its `where`) leave the index. Safe to run twice at once (writes are idempotent).
80
+ * `done` is false while rows are left: run it again (another `waitUntil`, the next alarm).
81
+ * A row deleted outright keeps its vector, which the find's SQL never matches.
82
+ */
83
+ export declare function meaningStep(o: MeaningStepOptions): Promise<{
84
+ indexed: number;
85
+ removed: number;
86
+ done: boolean;
87
+ }>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Search by meaning for a find tool: a vector index of what each row means, asked for the rows
3
+ * nearest a loose ask, which the find's own SQL then trims (filters, words, rows the person never
4
+ * sees). Opt in per app: `meaning` on the definition, then
5
+ *
6
+ * const meaning = vectorMeaning({ embed: workersEmbed(env.AI), index: env.NOTES_INDEX });
7
+ * await findTool(notesFind, d1Sql(env.DB), { meaning });
8
+ *
9
+ * The index is kept in step by `meaningStep`, a small budgeted background step (`waitUntil`, an
10
+ * alarm or a queue) that embeds only the rows whose `changed` column moved past its bookmark, so
11
+ * it never runs on the request path and resumes wherever it stopped, whoever wrote the rows.
12
+ */
13
+ import { findRef } from './find.js';
14
+ import { withDeadline } from './tools.js';
15
+ export const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5';
16
+ /** bge models are asymmetric: a query (not a passage) needs this, or scores bunch together. */
17
+ const BGE_QUERY = 'Represent this sentence for searching relevant passages: ';
18
+ /** Texts one embedding call takes. */
19
+ const EMBED_BATCH = 100;
20
+ /** Embeddings from Workers AI: bge-base-en-v1.5 (768 dimensions, cosine) unless `model` says. */
21
+ export function workersEmbed(ai, model = EMBED_MODEL) {
22
+ const prefix = /\/bge-/.test(model) ? BGE_QUERY : '';
23
+ return async (texts, as) => {
24
+ const out = [];
25
+ for (let i = 0; i < texts.length; i += EMBED_BATCH) {
26
+ const batch = texts.slice(i, i + EMBED_BATCH).map((t) => (as === 'query' ? prefix + t : t));
27
+ const res = (await ai.run(model, { text: batch }));
28
+ if (!res.data || res.data.length !== batch.length)
29
+ throw new Error('embedding failed');
30
+ out.push(...res.data);
31
+ }
32
+ return out;
33
+ };
34
+ }
35
+ /**
36
+ * A vector id: the row's key, after the namespace when there is one, so ids stay unique across
37
+ * people in one index (a Durable Object app gives each person a namespace).
38
+ */
39
+ const vectorId = (key, namespace) => namespace ? `${namespace}:${key}` : `${key}`;
40
+ const keyOf = (id) => Number(id.slice(id.lastIndexOf(':') + 1));
41
+ /** Search by meaning over a vector index: one embedding, one query. */
42
+ export function vectorMeaning(o) {
43
+ return {
44
+ async near(text, k) {
45
+ const [vector] = await o.embed([text], 'query');
46
+ if (!vector)
47
+ return [];
48
+ const res = await o.index.query(vector, {
49
+ topK: k,
50
+ ...(o.namespace ? { namespace: o.namespace } : {}),
51
+ });
52
+ return res.matches.map((m) => keyOf(m.id)).filter(Number.isInteger);
53
+ },
54
+ };
55
+ }
56
+ /** What a row means, as text: its meaning fields with their names, JSON lists flattened. */
57
+ export function meaningText(def, row) {
58
+ const parts = [];
59
+ for (const f of def.meaning?.fields ?? []) {
60
+ let v = row[f];
61
+ if (def.fields[f]?.json && typeof v === 'string') {
62
+ try {
63
+ v = JSON.parse(v);
64
+ }
65
+ catch { }
66
+ }
67
+ const flat = (x) => Array.isArray(x)
68
+ ? x.map(flat).filter(Boolean).join('; ')
69
+ : x && typeof x === 'object'
70
+ ? Object.values(x).map(flat).filter(Boolean).join(': ')
71
+ : x === null || x === undefined
72
+ ? ''
73
+ : String(x);
74
+ const text = flat(v).replace(/\s+/g, ' ').trim();
75
+ if (text)
76
+ parts.push(`${f}: ${text}`);
77
+ }
78
+ return parts.join('\n').slice(0, 2_000);
79
+ }
80
+ const STEP_MS = 20_000;
81
+ /**
82
+ * One budgeted step of keeping the index in step: the next rows (at most `rows`) whose `changed`
83
+ * column moved past the bookmark, embedded in one call and written in one upsert; rows the find
84
+ * never shows (its `where`) leave the index. Safe to run twice at once (writes are idempotent).
85
+ * `done` is false while rows are left: run it again (another `waitUntil`, the next alarm).
86
+ * A row deleted outright keeps its vector, which the find's SQL never matches.
87
+ */
88
+ export async function meaningStep(o) {
89
+ const { def } = o;
90
+ if (!def.meaning)
91
+ return { indexed: 0, removed: 0, done: true };
92
+ const n = Math.max(1, Math.min(EMBED_BATCH, o.rows ?? 50));
93
+ const changed = `t.${def.meaning.changed}`;
94
+ const mark = await o.mark.get();
95
+ const cols = [...new Set(def.meaning.fields)].map((f) => `${findRef(def, f)} AS ${f}`).join(', ');
96
+ const live = def.where ? `CASE WHEN (${def.where}) THEN 1 ELSE 0 END` : '1';
97
+ const rows = await withDeadline(`${def.name} meaning`, STEP_MS, o.sql.all(`SELECT t.${def.key} AS _key, ${changed} AS _at, ${live} AS _live, ${cols} FROM ${def.table} t${mark ? ` WHERE (${changed} > ? OR (${changed} = ? AND t.${def.key} > ?))` : ''} ORDER BY ${changed}, t.${def.key} LIMIT ${n}`, ...(mark ? [mark.at, mark.at, mark.key] : [])));
98
+ if (!rows.length)
99
+ return { indexed: 0, removed: 0, done: true };
100
+ const keep = rows.filter((r) => Number(r._live) === 1);
101
+ const gone = rows.filter((r) => Number(r._live) !== 1);
102
+ if (keep.length) {
103
+ const vectors = await withDeadline(`${def.name} embed`, STEP_MS, o.embed(keep.map((r) => meaningText(def, r)), 'passage'));
104
+ await o.index.upsert(keep.map((r, i) => ({
105
+ id: vectorId(Number(r._key), o.namespace),
106
+ values: vectors[i] ?? [],
107
+ ...(o.namespace ? { namespace: o.namespace } : {}),
108
+ })));
109
+ }
110
+ if (gone.length)
111
+ await o.index.deleteByIds(gone.map((r) => vectorId(Number(r._key), o.namespace)));
112
+ const last = rows[rows.length - 1];
113
+ await o.mark.put({ at: last._at, key: Number(last._key) });
114
+ return { indexed: keep.length, removed: gone.length, done: rows.length < n };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.141",
3
+ "version": "0.1.142",
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.141",
45
- "@columenlabs/lumen-ui-types": "0.1.141",
44
+ "@columenlabs/lumen-ops": "0.1.142",
45
+ "@columenlabs/lumen-ui-types": "0.1.142",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },