@columenlabs/lumen-ai 0.1.140 → 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 +53 -0
- package/dist/assistant/engine.js +6 -1
- package/dist/assistant/find.d.ts +128 -0
- package/dist/assistant/find.js +416 -0
- package/dist/assistant/index.d.ts +2 -0
- package/dist/assistant/index.js +2 -0
- package/dist/assistant/meaning.d.ts +87 -0
- package/dist/assistant/meaning.js +115 -0
- package/dist/assistant/tools.d.ts +2 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -156,3 +156,56 @@ 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, 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.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { d1Sql, defineFind, findSql, findTool } from '@columenlabs/lumen-ai/assistant';
|
|
172
|
+
|
|
173
|
+
export const notesFind = defineFind({
|
|
174
|
+
name: 'notes', label: 'note', table: 'notes', key: 'id',
|
|
175
|
+
where: 'deleted = 0', // the app's own SQL, always applied
|
|
176
|
+
order: 'updated_at DESC', // with no words to rank by
|
|
177
|
+
fields: {
|
|
178
|
+
title: { kind: 'text' },
|
|
179
|
+
body: { kind: 'text', show: false }, // searchable, returned only when asked
|
|
180
|
+
tags: { kind: 'text', json: true }, // a JSON column, returned parsed
|
|
181
|
+
folder: { kind: 'tag' }, // exact filter; its values are listed to the model
|
|
182
|
+
updated_at: { kind: 'date' }, // updated_at_from, updated_at_to
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// In a group's tool source, per answer (a Durable Object passes doSql(ctx.storage.sql)):
|
|
187
|
+
plainTools([await findTool(notesFind, d1Sql(env.DB))]);
|
|
188
|
+
```
|
|
189
|
+
|
|
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.
|
|
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.
|
|
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
|
@@ -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. 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
|
+
]
|
|
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. 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.');
|
|
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,128 @@
|
|
|
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
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
import type { FindMeaning } from './meaning.js';
|
|
23
|
+
import type { SqlRunner } from './store.js';
|
|
24
|
+
import { type PlainTool } from './tools.js';
|
|
25
|
+
/** text: searchable words. tag: an exact value to filter on (listed to the model). number, date: ranges. */
|
|
26
|
+
export type FindKind = 'text' | 'tag' | 'number' | 'date';
|
|
27
|
+
export interface FindField {
|
|
28
|
+
kind: FindKind;
|
|
29
|
+
/** The table's column, when it is named differently. */
|
|
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;
|
|
36
|
+
/** Returned unless the call names its fields. False keeps it searchable but out of the default. */
|
|
37
|
+
show?: boolean;
|
|
38
|
+
/** The column holds JSON (a list of ingredients): returned parsed. */
|
|
39
|
+
json?: boolean;
|
|
40
|
+
/** A few words for the model. */
|
|
41
|
+
about?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface FindDef {
|
|
44
|
+
/** The tool is `<name>_find`. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** What one row is, for the model: "recipe". */
|
|
47
|
+
label: string;
|
|
48
|
+
/** One line on what the rows are. */
|
|
49
|
+
about?: string;
|
|
50
|
+
table: string;
|
|
51
|
+
/** The table's integer primary key (the FTS rowid). */
|
|
52
|
+
key: string;
|
|
53
|
+
/** Always applied: rows the person never sees (deleted ones). The app's own SQL, never the model's. */
|
|
54
|
+
where?: string;
|
|
55
|
+
fields: Record<string, FindField>;
|
|
56
|
+
/** The FTS5 table: `<table>_fts` by default. */
|
|
57
|
+
fts?: string;
|
|
58
|
+
/** The order with no words to rank by: `category, title`. Column names. */
|
|
59
|
+
order?: string;
|
|
60
|
+
/** Rows a call returns by default (25) and at most (200). */
|
|
61
|
+
limit?: number;
|
|
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
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** What the model sends: flat, so every provider takes the schema. Tag fields are their own keys. */
|
|
75
|
+
export interface FindQuery {
|
|
76
|
+
like?: string;
|
|
77
|
+
any?: string[];
|
|
78
|
+
all?: string[];
|
|
79
|
+
none?: string[];
|
|
80
|
+
in?: string[];
|
|
81
|
+
ids?: number[];
|
|
82
|
+
fields?: string[];
|
|
83
|
+
sort?: string;
|
|
84
|
+
desc?: boolean;
|
|
85
|
+
limit?: number;
|
|
86
|
+
[tagOrRange: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
export interface FindResult {
|
|
89
|
+
/** Rows that match, all of them, before `limit`. Say this number when asked how many. */
|
|
90
|
+
count: number;
|
|
91
|
+
/** Rows in this answer (at most `limit`). */
|
|
92
|
+
returned: number;
|
|
93
|
+
rows: Record<string, unknown>[];
|
|
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). */
|
|
100
|
+
note?: string;
|
|
101
|
+
}
|
|
102
|
+
/** Checks a definition once, where the app declares it. */
|
|
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;
|
|
106
|
+
/**
|
|
107
|
+
* The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
|
|
108
|
+
* triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
|
|
109
|
+
* that indexes the rows already there. Each `create` statement is safe to run again. Run `fill` once,
|
|
110
|
+
* in a migration or a budgeted background step, never on a request.
|
|
111
|
+
*/
|
|
112
|
+
export declare function findSql(def: FindDef): {
|
|
113
|
+
create: string[];
|
|
114
|
+
fill: string;
|
|
115
|
+
drop: string[];
|
|
116
|
+
};
|
|
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>;
|
|
122
|
+
/**
|
|
123
|
+
* The find tool for one table, opened for one answer: its tag values are read once (one small query)
|
|
124
|
+
* so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
|
|
125
|
+
*/
|
|
126
|
+
export declare function findTool(def: FindDef, sql: SqlRunner, opts?: {
|
|
127
|
+
meaning?: FindMeaning;
|
|
128
|
+
}): Promise<PlainTool>;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { ToolError, withDeadline } from './tools.js';
|
|
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
|
+
]);
|
|
14
|
+
const FIND_MS = 3_000;
|
|
15
|
+
const TAG_VALUES = 60;
|
|
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;
|
|
20
|
+
/** Checks a definition once, where the app declares it. */
|
|
21
|
+
export function defineFind(def) {
|
|
22
|
+
const ident = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
23
|
+
for (const n of [def.name, def.table, def.key, def.fts ?? 'x'])
|
|
24
|
+
if (!ident.test(n))
|
|
25
|
+
throw new Error(`find ${def.name}: "${n}" is not a plain name`);
|
|
26
|
+
for (const [n, f] of Object.entries(def.fields)) {
|
|
27
|
+
if (!ident.test(n) || !ident.test(f.column ?? n))
|
|
28
|
+
throw new Error(`find ${def.name}: field "${n}" is not a plain name`);
|
|
29
|
+
if (RESERVED.has(n))
|
|
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`);
|
|
33
|
+
}
|
|
34
|
+
if (!Object.values(def.fields).some((f) => f.kind === 'text'))
|
|
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
|
+
}
|
|
45
|
+
return def;
|
|
46
|
+
}
|
|
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)}`;
|
|
50
|
+
const ftsName = (def) => def.fts ?? `${def.table}_fts`;
|
|
51
|
+
const textFields = (def) => Object.entries(def.fields)
|
|
52
|
+
.filter(([, f]) => f.kind === 'text')
|
|
53
|
+
.map(([n]) => n);
|
|
54
|
+
/**
|
|
55
|
+
* The SQL that sets up the word index: an external-content FTS5 table over the text columns, the
|
|
56
|
+
* triggers that keep it in step on every insert, update and delete, and (`fill`) the one statement
|
|
57
|
+
* that indexes the rows already there. Each `create` statement is safe to run again. Run `fill` once,
|
|
58
|
+
* in a migration or a budgeted background step, never on a request.
|
|
59
|
+
*/
|
|
60
|
+
export function findSql(def) {
|
|
61
|
+
const fts = ftsName(def);
|
|
62
|
+
const cols = textFields(def).map((f) => col(def, f));
|
|
63
|
+
const list = cols.join(', ');
|
|
64
|
+
const nu = cols.map((c) => `new.${c}`).join(', ');
|
|
65
|
+
const ol = cols.map((c) => `old.${c}`).join(', ');
|
|
66
|
+
return {
|
|
67
|
+
create: [
|
|
68
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${fts} USING fts5(${list}, content='${def.table}', content_rowid='${def.key}', tokenize='porter unicode61')`,
|
|
69
|
+
`CREATE TRIGGER IF NOT EXISTS ${fts}_ai AFTER INSERT ON ${def.table} BEGIN INSERT INTO ${fts}(rowid, ${list}) VALUES (new.${def.key}, ${nu}); END`,
|
|
70
|
+
`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`,
|
|
71
|
+
`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`,
|
|
72
|
+
],
|
|
73
|
+
fill: `INSERT INTO ${fts}(${fts}) VALUES ('rebuild')`,
|
|
74
|
+
drop: [
|
|
75
|
+
`DROP TRIGGER IF EXISTS ${fts}_ai`,
|
|
76
|
+
`DROP TRIGGER IF EXISTS ${fts}_ad`,
|
|
77
|
+
`DROP TRIGGER IF EXISTS ${fts}_au`,
|
|
78
|
+
`DROP TABLE IF EXISTS ${fts}`,
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Words as the model sent them (a list, or one string as a list of one): trimmed, deduped, at most `WORDS`. */
|
|
83
|
+
function words(v) {
|
|
84
|
+
const list = Array.isArray(v) ? v : typeof v === 'string' ? [v] : [];
|
|
85
|
+
const out = new Set();
|
|
86
|
+
for (const w of list) {
|
|
87
|
+
if (typeof w !== 'string')
|
|
88
|
+
continue;
|
|
89
|
+
const t = w.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
90
|
+
if (t)
|
|
91
|
+
out.add(t);
|
|
92
|
+
}
|
|
93
|
+
return [...out].slice(0, WORDS);
|
|
94
|
+
}
|
|
95
|
+
/** One FTS5 term: a quoted phrase, so the model's words are never read as FTS syntax. */
|
|
96
|
+
const phrase = (w) => `"${w.replace(/"/g, '""')}"`;
|
|
97
|
+
function strings(v) {
|
|
98
|
+
const a = Array.isArray(v) ? v : v === undefined || v === null ? [] : [v];
|
|
99
|
+
return a.filter((x) => typeof x === 'string' || typeof x === 'number').map((x) => String(x));
|
|
100
|
+
}
|
|
101
|
+
/** The query, checked: what is not a field of this table is an error the model can fix. */
|
|
102
|
+
function checked(def, q) {
|
|
103
|
+
const names = Object.keys(def.fields);
|
|
104
|
+
const unknown = (list, what) => {
|
|
105
|
+
const bad = list.filter((f) => !def.fields[f]);
|
|
106
|
+
if (bad.length)
|
|
107
|
+
throw new ToolError(`${what}: no field ${bad.join(', ')}. Fields: ${names.join(', ')}.`);
|
|
108
|
+
};
|
|
109
|
+
const inFields = strings(q.in);
|
|
110
|
+
unknown(inFields, 'in');
|
|
111
|
+
const notText = inFields.filter((f) => def.fields[f]?.kind !== 'text');
|
|
112
|
+
if (notText.length)
|
|
113
|
+
throw new ToolError(`in: ${notText.join(', ')} are not text fields.`);
|
|
114
|
+
const fields = strings(q.fields);
|
|
115
|
+
unknown(fields, 'fields');
|
|
116
|
+
if (q.sort !== undefined)
|
|
117
|
+
unknown(strings(q.sort), 'sort');
|
|
118
|
+
for (const k of Object.keys(q)) {
|
|
119
|
+
if (RESERVED.has(k))
|
|
120
|
+
continue;
|
|
121
|
+
const base = k.replace(/_(min|max|from|to)$/, '');
|
|
122
|
+
if (!def.fields[base] || def.fields[base]?.kind === 'text')
|
|
123
|
+
throw new ToolError(`${k}: not a filter. Filters: ${filterKeys(def).join(', ')}.`);
|
|
124
|
+
}
|
|
125
|
+
const max = def.max ?? 200;
|
|
126
|
+
const limit = Math.max(1, Math.min(max, Math.trunc(Number(q.limit ?? def.limit ?? 25)) || 25));
|
|
127
|
+
return {
|
|
128
|
+
like: typeof q.like === 'string' ? q.like.replace(/\s+/g, ' ').trim().slice(0, 300) : '',
|
|
129
|
+
any: words(q.any),
|
|
130
|
+
all: words(q.all),
|
|
131
|
+
none: words(q.none),
|
|
132
|
+
in: inFields.length ? inFields : textFields(def),
|
|
133
|
+
ids: (Array.isArray(q.ids) ? q.ids : []).map(Number).filter(Number.isInteger).slice(0, max),
|
|
134
|
+
fields: fields.length ? fields : names.filter((n) => def.fields[n]?.show !== false),
|
|
135
|
+
sort: typeof q.sort === 'string' ? q.sort : undefined,
|
|
136
|
+
desc: q.desc === true,
|
|
137
|
+
limit,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function filterKeys(def) {
|
|
141
|
+
return Object.entries(def.fields).flatMap(([n, f]) => f.kind === 'tag'
|
|
142
|
+
? [n]
|
|
143
|
+
: f.kind === 'number'
|
|
144
|
+
? [`${n}_min`, `${n}_max`]
|
|
145
|
+
: f.kind === 'date'
|
|
146
|
+
? [`${n}_from`, `${n}_to`]
|
|
147
|
+
: []);
|
|
148
|
+
}
|
|
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) {
|
|
154
|
+
const c = checked(def, q);
|
|
155
|
+
const t = 't';
|
|
156
|
+
const where = [];
|
|
157
|
+
const args = [];
|
|
158
|
+
if (def.where)
|
|
159
|
+
where.push(`(${def.where})`);
|
|
160
|
+
for (const [n, f] of Object.entries(def.fields)) {
|
|
161
|
+
const column = findRef(def, n);
|
|
162
|
+
if (f.kind === 'tag') {
|
|
163
|
+
const vals = strings(q[n]).slice(0, 50);
|
|
164
|
+
if (vals.length) {
|
|
165
|
+
where.push(`${column} COLLATE NOCASE IN (${vals.map(() => '?').join(', ')})`);
|
|
166
|
+
args.push(...vals);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (f.kind === 'number' || f.kind === 'date') {
|
|
170
|
+
const [lo, hi] = f.kind === 'number' ? ['_min', '_max'] : ['_from', '_to'];
|
|
171
|
+
for (const [suffix, op] of [
|
|
172
|
+
[lo, '>='],
|
|
173
|
+
[hi, '<='],
|
|
174
|
+
]) {
|
|
175
|
+
const v = q[`${n}${suffix}`];
|
|
176
|
+
if (v === undefined || v === null || v === '')
|
|
177
|
+
continue;
|
|
178
|
+
where.push(`${column} ${op} ?`);
|
|
179
|
+
args.push(f.kind === 'number' ? Number(v) : String(v));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (c.ids.length) {
|
|
184
|
+
where.push(`${t}.${def.key} IN (${c.ids.map(() => '?').join(', ')})`);
|
|
185
|
+
args.push(...c.ids);
|
|
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
|
+
}
|
|
194
|
+
const fname = ftsName(def);
|
|
195
|
+
const cols = c.in.map((f) => col(def, f));
|
|
196
|
+
const scope = `{${cols.join(' ')}} : `;
|
|
197
|
+
const positive = [
|
|
198
|
+
...(c.any.length ? [`(${c.any.map(phrase).join(' OR ')})`] : []),
|
|
199
|
+
...c.all.map(phrase),
|
|
200
|
+
];
|
|
201
|
+
let join = '';
|
|
202
|
+
let rank = '';
|
|
203
|
+
if (fts) {
|
|
204
|
+
if (positive.length) {
|
|
205
|
+
join = ` JOIN ${fname} ON ${fname}.rowid = ${t}.${def.key}`;
|
|
206
|
+
where.push(`${fname} MATCH ?`);
|
|
207
|
+
args.push(`${scope}(${positive.join(' AND ')})`);
|
|
208
|
+
rank = `${fname}.rank`;
|
|
209
|
+
}
|
|
210
|
+
if (c.none.length) {
|
|
211
|
+
where.push(`${t}.${def.key} NOT IN (SELECT rowid FROM ${fname} WHERE ${fname} MATCH ?)`);
|
|
212
|
+
args.push(`${scope}(${c.none.map(phrase).join(' OR ')})`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const like = (w) => {
|
|
217
|
+
args.push(...cols.map(() => `%${w}%`));
|
|
218
|
+
return `(${cols.map((x) => `${t}.${x} LIKE ?`).join(' OR ')})`;
|
|
219
|
+
};
|
|
220
|
+
if (c.any.length)
|
|
221
|
+
where.push(`(${c.any.map(like).join(' OR ')})`);
|
|
222
|
+
for (const w of c.all)
|
|
223
|
+
where.push(like(w));
|
|
224
|
+
for (const w of c.none)
|
|
225
|
+
where.push(`NOT ${like(w)}`);
|
|
226
|
+
}
|
|
227
|
+
const order = c.sort
|
|
228
|
+
? `${findRef(def, c.sort)} ${c.desc ? 'DESC' : 'ASC'}`
|
|
229
|
+
: nearOrder ||
|
|
230
|
+
rank ||
|
|
231
|
+
(def.order
|
|
232
|
+
? def.order
|
|
233
|
+
.split(',')
|
|
234
|
+
.map((o) => `${t}.${o.trim()}`)
|
|
235
|
+
.join(', ')
|
|
236
|
+
: `${t}.${def.key}`);
|
|
237
|
+
const select = [...new Set([def.key, ...c.fields])]
|
|
238
|
+
.map((f) => (f === def.key ? `${t}.${def.key} AS ${def.key}` : `${findRef(def, f)} AS ${f}`))
|
|
239
|
+
.join(', ');
|
|
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}`;
|
|
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
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const missingIndex = (e) => e instanceof Error && /no such table|no such module|fts5/i.test(e.message);
|
|
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) {
|
|
256
|
+
let built = compile(def, q, true);
|
|
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
|
+
}
|
|
274
|
+
let rows;
|
|
275
|
+
try {
|
|
276
|
+
rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
if (!built.words || !missingIndex(e))
|
|
280
|
+
throw e;
|
|
281
|
+
built = compile(def, q, false, near);
|
|
282
|
+
notes.push('Words were matched as plain text: the word index is not set up yet.');
|
|
283
|
+
rows = await withDeadline(`${def.name} find`, FIND_MS, sql.all(built.sql, ...built.args));
|
|
284
|
+
}
|
|
285
|
+
const count = Number(rows[0]?._count ?? 0);
|
|
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;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
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(' ') } : {}),
|
|
310
|
+
};
|
|
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
|
+
}
|
|
329
|
+
/** The values a tag field holds, to list in the tool's description. */
|
|
330
|
+
async function tagValues(def, sql, field) {
|
|
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}`);
|
|
333
|
+
return rows.map((r) => String(r.v ?? '')).filter(Boolean);
|
|
334
|
+
}
|
|
335
|
+
/** The tool's flat JSON Schema, from the fields. */
|
|
336
|
+
function schemaOf(def, tags, meaning) {
|
|
337
|
+
const names = Object.keys(def.fields);
|
|
338
|
+
const text = textFields(def);
|
|
339
|
+
const list = (description, items = { type: 'string' }) => ({
|
|
340
|
+
type: 'array',
|
|
341
|
+
items,
|
|
342
|
+
description,
|
|
343
|
+
});
|
|
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
|
+
: {}),
|
|
353
|
+
any: list('Rows with at least one of these words or phrases.'),
|
|
354
|
+
all: list('Rows with every one of these words or phrases.'),
|
|
355
|
+
none: list('Leave out rows with any of these words or phrases (an ingredient to avoid).'),
|
|
356
|
+
in: list('Which fields the words must be in; all text fields when left out.', {
|
|
357
|
+
type: 'string',
|
|
358
|
+
enum: text,
|
|
359
|
+
}),
|
|
360
|
+
ids: list(`Only these ${def.label}s, by ${def.key}.`, { type: 'integer' }),
|
|
361
|
+
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 }),
|
|
362
|
+
sort: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
enum: names,
|
|
365
|
+
description: 'Order by this field; by best word match, else the default order, when left out.',
|
|
366
|
+
},
|
|
367
|
+
desc: { type: 'boolean', description: 'Highest first.' },
|
|
368
|
+
limit: {
|
|
369
|
+
type: 'integer',
|
|
370
|
+
minimum: 1,
|
|
371
|
+
maximum: def.max ?? 200,
|
|
372
|
+
description: `Rows to return (${def.limit ?? 25} by default). The result says how many match in all.`,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
for (const [n, f] of Object.entries(def.fields)) {
|
|
376
|
+
if (f.kind === 'tag') {
|
|
377
|
+
const vals = tags[n] ?? [];
|
|
378
|
+
props[n] = list(`Only these ${n} values${vals.length && vals.length <= TAG_VALUES ? `: ${vals.join(', ')}` : ''}.`, vals.length && vals.length <= TAG_VALUES
|
|
379
|
+
? { type: 'string', enum: vals }
|
|
380
|
+
: { type: 'string' });
|
|
381
|
+
}
|
|
382
|
+
else if (f.kind === 'number') {
|
|
383
|
+
props[`${n}_min`] = { type: 'number', description: `${n} at least this.` };
|
|
384
|
+
props[`${n}_max`] = { type: 'number', description: `${n} at most this.` };
|
|
385
|
+
}
|
|
386
|
+
else if (f.kind === 'date') {
|
|
387
|
+
props[`${n}_from`] = { type: 'string', description: `${n} on or after this (YYYY-MM-DD).` };
|
|
388
|
+
props[`${n}_to`] = { type: 'string', description: `${n} on or before this (YYYY-MM-DD).` };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { type: 'object', properties: props, additionalProperties: false };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The find tool for one table, opened for one answer: its tag values are read once (one small query)
|
|
395
|
+
* so the model picks real ones. A read; `find` marks it so the assistant puts a whole question in it.
|
|
396
|
+
*/
|
|
397
|
+
export async function findTool(def, sql, opts = {}) {
|
|
398
|
+
const meaning = def.meaning ? opts.meaning : undefined;
|
|
399
|
+
const tags = {};
|
|
400
|
+
await Promise.all(Object.entries(def.fields)
|
|
401
|
+
.filter(([, f]) => f.kind === 'tag')
|
|
402
|
+
.map(async ([n]) => {
|
|
403
|
+
tags[n] = await withDeadline(`${def.name} values`, FIND_MS, tagValues(def, sql, n)).catch(() => []);
|
|
404
|
+
}));
|
|
405
|
+
const fields = Object.entries(def.fields)
|
|
406
|
+
.map(([n, f]) => `${n} (${f.kind}${f.about ? `: ${f.about}` : ''})`)
|
|
407
|
+
.join(', ');
|
|
408
|
+
return {
|
|
409
|
+
name: `${def.name}_find`,
|
|
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)),
|
|
412
|
+
read: true,
|
|
413
|
+
find: true,
|
|
414
|
+
run: (args) => runFind(def, sql, (args ?? {}), meaning),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
export * from './bearings.js';
|
|
16
16
|
export * from './chatgpt.js';
|
|
17
17
|
export * from './engine.js';
|
|
18
|
+
export * from './find.js';
|
|
19
|
+
export * from './meaning.js';
|
|
18
20
|
export * from './models.js';
|
|
19
21
|
export * from './routes.js';
|
|
20
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
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
export * from './bearings.js';
|
|
16
16
|
export * from './chatgpt.js';
|
|
17
17
|
export * from './engine.js';
|
|
18
|
+
export * from './find.js';
|
|
19
|
+
export * from './meaning.js';
|
|
18
20
|
export * from './models.js';
|
|
19
21
|
export * from './routes.js';
|
|
20
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
|
+
}
|
|
@@ -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.
|
|
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.
|
|
45
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
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
|
},
|