@columenlabs/lumen-ai 0.1.141 → 0.1.143
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 +22 -1
- package/dist/assistant/engine.js +2 -2
- package/dist/assistant/find.d.ts +47 -6
- package/dist/assistant/find.js +136 -38
- package/dist/assistant/index.d.ts +1 -0
- package/dist/assistant/index.js +1 -0
- package/dist/assistant/meaning.d.ts +87 -0
- package/dist/assistant/meaning.js +115 -0
- package/package.json +3 -3
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
|
|
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. A search by meaning carries `nearest: true` and no `count`: its rows are the nearest few, not every row that fits, so the model never quotes them as a number of matches.
|
|
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.
|
package/dist/assistant/engine.js
CHANGED
|
@@ -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
|
-
|
|
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 (a like search has no count, only the nearest rows: never give a number of matches for it), 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.`);
|
package/dist/assistant/find.d.ts
CHANGED
|
@@ -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,28 @@ export interface FindQuery {
|
|
|
64
86
|
[tagOrRange: string]: unknown;
|
|
65
87
|
}
|
|
66
88
|
export interface FindResult {
|
|
67
|
-
/**
|
|
68
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Rows that match, all of them, before `limit`. Say this number when asked how many. Left out
|
|
91
|
+
* of a search by meaning, whose rows are the nearest few, not every row that fits.
|
|
92
|
+
*/
|
|
93
|
+
count?: number;
|
|
94
|
+
/** Set on a search by meaning: the rows are the nearest in meaning, nearest first, to pick from. */
|
|
95
|
+
nearest?: true;
|
|
96
|
+
/** Rows in this answer (at most `limit`). */
|
|
97
|
+
returned: number;
|
|
69
98
|
rows: Record<string, unknown>[];
|
|
70
|
-
/**
|
|
99
|
+
/** Different rows that share a name: each is its own row, so say both. */
|
|
100
|
+
same_name?: {
|
|
101
|
+
name: string;
|
|
102
|
+
ids: unknown[];
|
|
103
|
+
}[];
|
|
104
|
+
/** Set when a part of the query could not run as asked (no word index yet, meaning down). */
|
|
71
105
|
note?: string;
|
|
72
106
|
}
|
|
73
107
|
/** Checks a definition once, where the app declares it. */
|
|
74
108
|
export declare function defineFind(def: FindDef): FindDef;
|
|
109
|
+
/** A field as SQL over the row aliased `t`: its column, or its own expression. */
|
|
110
|
+
export declare const findRef: (def: FindDef, f: string) => string;
|
|
75
111
|
/**
|
|
76
112
|
* The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
|
|
77
113
|
* triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
|
|
@@ -83,10 +119,15 @@ export declare function findSql(def: FindDef): {
|
|
|
83
119
|
fill: string;
|
|
84
120
|
drop: string[];
|
|
85
121
|
};
|
|
86
|
-
/**
|
|
87
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Runs one find: one query, the rows' asked fields (JSON parsed) and how many match in all. With
|
|
124
|
+
* `like` and a `meaning`, the index's nearest rows come first and the same SQL trims them.
|
|
125
|
+
*/
|
|
126
|
+
export declare function runFind(def: FindDef, sql: SqlRunner, q: FindQuery, meaning?: FindMeaning): Promise<FindResult>;
|
|
88
127
|
/**
|
|
89
128
|
* The find tool for one table, opened for one answer: its tag values are read once (one small query)
|
|
90
129
|
* so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
|
|
91
130
|
*/
|
|
92
|
-
export declare function findTool(def: FindDef, sql: SqlRunner
|
|
131
|
+
export declare function findTool(def: FindDef, sql: SqlRunner, opts?: {
|
|
132
|
+
meaning?: FindMeaning;
|
|
133
|
+
}): Promise<PlainTool>;
|
package/dist/assistant/find.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { ToolError, withDeadline } from './tools.js';
|
|
2
|
-
const RESERVED = new Set([
|
|
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
|
-
/**
|
|
122
|
-
|
|
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 =
|
|
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
|
-
? `${
|
|
191
|
-
:
|
|
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}` : `${
|
|
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 {
|
|
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
|
-
/**
|
|
206
|
-
|
|
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
|
-
|
|
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,65 @@ 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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
298
|
+
else
|
|
299
|
+
row[f] = v;
|
|
300
|
+
}
|
|
301
|
+
return row;
|
|
302
|
+
});
|
|
303
|
+
const same = sameNames(def, out, built.fields);
|
|
304
|
+
const byMeaning = !!near?.length;
|
|
305
|
+
if (byMeaning)
|
|
306
|
+
notes.push('These are the nearest in meaning, nearest first, not every row that fits: pick from them and do not say how many match.');
|
|
307
|
+
return {
|
|
308
|
+
...(byMeaning ? { nearest: true } : { count }),
|
|
309
|
+
returned: out.length,
|
|
310
|
+
rows: out,
|
|
311
|
+
...(same.length ? { same_name: same } : {}),
|
|
312
|
+
...(notes.length ? { note: notes.join(' ') } : {}),
|
|
241
313
|
};
|
|
242
314
|
}
|
|
315
|
+
/** Rows in the answer that share a name, so the model counts and lists each of them. */
|
|
316
|
+
function sameNames(def, rows, fields) {
|
|
317
|
+
const title = def.title ?? textFields(def)[0];
|
|
318
|
+
if (!title || !fields.includes(title))
|
|
319
|
+
return [];
|
|
320
|
+
const by = new Map();
|
|
321
|
+
for (const r of rows) {
|
|
322
|
+
const name = String(r[title] ?? '').trim();
|
|
323
|
+
if (!name)
|
|
324
|
+
continue;
|
|
325
|
+
const k = name.toLowerCase();
|
|
326
|
+
const g = by.get(k) ?? { name, ids: [] };
|
|
327
|
+
g.ids.push(r[def.key]);
|
|
328
|
+
by.set(k, g);
|
|
329
|
+
}
|
|
330
|
+
return [...by.values()].filter((g) => g.ids.length > 1);
|
|
331
|
+
}
|
|
243
332
|
/** The values a tag field holds, to list in the tool's description. */
|
|
244
333
|
async function tagValues(def, sql, field) {
|
|
245
|
-
const c =
|
|
246
|
-
const rows = await sql.all(`SELECT DISTINCT ${c} AS v FROM ${def.table}${def.where ? ` WHERE ${def.where}` : ''} ORDER BY
|
|
334
|
+
const c = findRef(def, field);
|
|
335
|
+
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
336
|
return rows.map((r) => String(r.v ?? '')).filter(Boolean);
|
|
248
337
|
}
|
|
249
338
|
/** The tool's flat JSON Schema, from the fields. */
|
|
250
|
-
function schemaOf(def, tags) {
|
|
339
|
+
function schemaOf(def, tags, meaning) {
|
|
251
340
|
const names = Object.keys(def.fields);
|
|
252
341
|
const text = textFields(def);
|
|
253
342
|
const list = (description, items = { type: 'string' }) => ({
|
|
@@ -256,6 +345,14 @@ function schemaOf(def, tags) {
|
|
|
256
345
|
description,
|
|
257
346
|
});
|
|
258
347
|
const props = {
|
|
348
|
+
...(meaning
|
|
349
|
+
? {
|
|
350
|
+
like: {
|
|
351
|
+
type: 'string',
|
|
352
|
+
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.`,
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
: {}),
|
|
259
356
|
any: list('Rows with at least one of these words or phrases.'),
|
|
260
357
|
all: list('Rows with every one of these words or phrases.'),
|
|
261
358
|
none: list('Leave out rows with any of these words or phrases (an ingredient to avoid).'),
|
|
@@ -300,7 +397,8 @@ function schemaOf(def, tags) {
|
|
|
300
397
|
* The find tool for one table, opened for one answer: its tag values are read once (one small query)
|
|
301
398
|
* so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
|
|
302
399
|
*/
|
|
303
|
-
export async function findTool(def, sql) {
|
|
400
|
+
export async function findTool(def, sql, opts = {}) {
|
|
401
|
+
const meaning = def.meaning ? opts.meaning : undefined;
|
|
304
402
|
const tags = {};
|
|
305
403
|
await Promise.all(Object.entries(def.fields)
|
|
306
404
|
.filter(([, f]) => f.kind === 'tag')
|
|
@@ -312,10 +410,10 @@ export async function findTool(def, sql) {
|
|
|
312
410
|
.join(', ');
|
|
313
411
|
return {
|
|
314
412
|
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
|
|
316
|
-
inputSchema: schemaOf(def, tags),
|
|
413
|
+
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; a like search returns the nearest instead, marked nearest, with no count) and any that share a name (same_name).`,
|
|
414
|
+
inputSchema: schemaOf(def, tags, Boolean(meaning)),
|
|
317
415
|
read: true,
|
|
318
416
|
find: true,
|
|
319
|
-
run: (args) => runFind(def, sql, (args ?? {})),
|
|
417
|
+
run: (args) => runFind(def, sql, (args ?? {}), meaning),
|
|
320
418
|
};
|
|
321
419
|
}
|
|
@@ -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';
|
package/dist/assistant/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.143",
|
|
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.
|
|
45
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
44
|
+
"@columenlabs/lumen-ops": "0.1.143",
|
|
45
|
+
"@columenlabs/lumen-ui-types": "0.1.143",
|
|
46
46
|
"openai": "7.32.0",
|
|
47
47
|
"zod": "^4.3.6"
|
|
48
48
|
},
|