@columenlabs/lumen-ai 0.1.251 → 0.1.253
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 +1 -0
- package/dist/assistant/engine.d.ts +7 -0
- package/dist/assistant/engine.js +24 -3
- package/dist/assistant/state.d.ts +21 -3
- package/dist/assistant/state.js +36 -3
- package/dist/assistant/web.d.ts +2 -0
- package/dist/assistant/web.js +26 -1
- package/dist/subagents/index.d.ts +5 -0
- package/dist/subagents/index.js +5 -0
- package/dist/subagents/web.d.ts +39 -0
- package/dist/subagents/web.js +133 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
|
|
|
111
111
|
- **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
|
|
112
112
|
- **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. A group with `readOnly: true` (read tools only) is offered Off or Read alone. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })`; the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
|
|
113
113
|
- **Web.** `fetch` is a plain GET from the Worker (10 s, 2 MB, never the app's own hosts or private addresses). ScrapeQL's own tools join over its MCP API once an admin pastes a Cloudflare Access service token (`PUT /web/scrapeql`). A Worker on ScrapeQL's own Cloudflare account passes its service binding instead, `scrapeql: { url: 'https://scrapeql/mcp', fetch: env.SCRAPEQL.fetch.bind(env.SCRAPEQL) }`: no token is stored, the tools are always on and Settings shows the link with a Test. Each web step carries `web` (`StepWeb`: a search's `query` and `results`, or the page it `read`), so the panel folds a search's results under its line and numbers each page read under the answer as Sources chips; the model marks facts with those numbers ([1]).
|
|
114
|
+
- **Web lookup (a subagent).** `web: { scrapeql, lookup: true }` swaps fetch and ScrapeQL's own tools for one tool, `web__lookup` (`webLookupTools` from `@columenlabs/lumen-ai/subagents`): given a question (and `urls` when the pages are known), it searches with ScrapeQL, has ScrapeQL's `ask` read the best pages (3 by default, `pages` up to 5) and answer in a few sentences each (`answerTokens`, default 300), and returns `{question, sources: [{url, title, answer}], failed}`: about a thousand tokens, never whole pages, so the assistant's context stays small. No model runs in the kit for it; ScrapeQL picks its own cheapest model. Its step carries `web.lookup` and `web.reads`, which the panel folds under a Looked up line and numbers as Sources chips. `fetch.blockHosts` still applies.
|
|
114
115
|
- **Hand off and advice.** The `escalate` tool hands one task to a higher tier (the person sets next, any or only when asked); Anthropic's advisor tool lets a higher tier send a plan, at most two an answer. A hand-off gives up after `HAND_OFF_MS` (4 minutes) and the turn goes on without it.
|
|
115
116
|
- **Chats** run on the agent (`./agent`, `LumenAgent`, below): the app routes `POST /chat` and each chat's reads, cards and Undo to its own agent, and `assistantRoutes` answers `POST /chat` with 410 `moved`. Chats saved by the old turn loop stay readable (`GET /chats`, `GET|DELETE /chats/:id`) until `assistant.purge()` drops them 30 days after their last message.
|
|
116
117
|
- **Screen moves**: while the person's Settings ▸ Screen is Move (the default), the panel sends its page map (`AssistantMessage.screen`: routes, nav pages, Settings sections, cards on screen, rows to open) and `LumenAgent` gives the model a `screen` tool (`./agent` `screen.ts`). Each call's moves (go, settings, chart, open, point) are checked against the map, kept in the chat and streamed as a `screen` event; the panel runs them through `AssistantScreenProvider` (mounted by `LumenShell`) with an Undo chip, and their outcomes go back with the next message (`outcomes`). Nothing is saved by a move.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantEffort, AssistantMessage, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantStatus, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
import type { AgentTurnEnd, AgentTurnPlan } from '../agent/agent.js';
|
|
3
|
+
import { type WebLookupOptions } from '../subagents/web.js';
|
|
3
4
|
import { type BearingsExtra, type BearingsNow } from './bearings.js';
|
|
4
5
|
import { type ModelDef, type TaskDef, type TierDef } from './models.js';
|
|
5
6
|
import { type OwnWays, State } from './state.js';
|
|
@@ -174,6 +175,12 @@ export interface AssistantOptions {
|
|
|
174
175
|
url: string;
|
|
175
176
|
fetch?: typeof fetch;
|
|
176
177
|
};
|
|
178
|
+
/**
|
|
179
|
+
* One `web__lookup` tool in place of fetch and ScrapeQL's own tools: it searches, has ScrapeQL
|
|
180
|
+
* read the best pages and answer the question, and returns a short answer per page, so whole
|
|
181
|
+
* pages never reach the model. Needs `scrapeql`; without it the web is off.
|
|
182
|
+
*/
|
|
183
|
+
lookup?: boolean | WebLookupOptions;
|
|
177
184
|
};
|
|
178
185
|
store: AssistantStore;
|
|
179
186
|
sealer?: Sealer;
|
package/dist/assistant/engine.js
CHANGED
|
@@ -12,10 +12,11 @@ import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
|
|
|
12
12
|
import { generateText, jsonSchema, stepCountIs, tool } from 'ai';
|
|
13
13
|
import OpenAI, { toFile } from 'openai';
|
|
14
14
|
import { aiModel, turnSettings } from '../agent/models.js';
|
|
15
|
+
import { webLookupTools } from '../subagents/web.js';
|
|
15
16
|
import { bearings } from './bearings.js';
|
|
16
17
|
import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
|
|
17
18
|
import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, XAI_BASE_URL, effortParams, } from './models.js';
|
|
18
|
-
import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, ownPlanCalls, planDocName, waitText, withProvider, } from './state.js';
|
|
19
|
+
import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, ownPlanCalls, ownPlanUsd, planDocName, waitText, withProvider, } from './state.js';
|
|
19
20
|
import { Docs } from './store.js';
|
|
20
21
|
import { toolName, withDeadline } from './tools.js';
|
|
21
22
|
import { scrapeqlTools, webFetchTools } from './web.js';
|
|
@@ -409,6 +410,7 @@ export class Assistant {
|
|
|
409
410
|
title: message.split('\n')[0] || 'Chat',
|
|
410
411
|
own_usd: ownKeyUsd(paying, onPayer),
|
|
411
412
|
own_plan_calls: ownPlanCalls(paying, onPayer),
|
|
413
|
+
own_plan_usd: ownPlanUsd(paying, onPayer),
|
|
412
414
|
}, end.parts)
|
|
413
415
|
.catch(() => undefined);
|
|
414
416
|
const total = end.parts.reduce((a, m) => ({
|
|
@@ -825,7 +827,22 @@ export class Assistant {
|
|
|
825
827
|
for (const source of Array.isArray(s) ? s : [s])
|
|
826
828
|
sources.push({ group: g.id, access, source, ...(g.writes ? { writes: true } : {}) });
|
|
827
829
|
}
|
|
828
|
-
|
|
830
|
+
const lookup = this.o.web?.lookup;
|
|
831
|
+
const blockHosts = this.o.web?.fetch ? this.o.web.fetch.blockHosts : undefined;
|
|
832
|
+
if ((p.web ?? true) && lookup) {
|
|
833
|
+
const sq = this.o.web?.scrapeql;
|
|
834
|
+
const token = sq && !sq.fetch ? await this.state.scrapeql() : null;
|
|
835
|
+
if (sq && (sq.fetch || token))
|
|
836
|
+
sources.push({
|
|
837
|
+
group: 'web',
|
|
838
|
+
access: 'read',
|
|
839
|
+
source: webLookupTools(scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch), {
|
|
840
|
+
...(blockHosts ? { blockHosts } : {}),
|
|
841
|
+
...(typeof lookup === 'object' ? lookup : {}),
|
|
842
|
+
}),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
else if (p.web ?? true) {
|
|
829
846
|
if (this.o.web?.fetch !== false)
|
|
830
847
|
sources.push({
|
|
831
848
|
group: 'web',
|
|
@@ -870,7 +887,9 @@ export class Assistant {
|
|
|
870
887
|
lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
|
|
871
888
|
if (failed)
|
|
872
889
|
lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
|
|
873
|
-
if (fns.web)
|
|
890
|
+
if (fns.web && this.o.web?.lookup)
|
|
891
|
+
lines.push("Web: web__lookup answers one question from the web: give the full question (and urls when you already know the pages); it returns a short answer from each page it read. Look up again with a sharper question rather than asking for whole pages. The person sees each page a lookup read numbered under your answer, in the order returned (the first lookup's sources first, a later lookup's after them): mark a fact with its number, like [1], and never cite a page that is not among the sources.");
|
|
892
|
+
else if (fns.web)
|
|
874
893
|
lines.push('Web: use web__fetch first for APIs and plain pages; the browser tools only for pages that need JavaScript or after a fetch fails. To find pages, search, then read the one to three that answer the question. The person sees each page you read numbered under your answer, in the order you read them (1 for the first page read in this answer): mark a fact you took from a page with its number, like [1], and never cite a page you did not read.');
|
|
875
894
|
if (main) {
|
|
876
895
|
const reach = this.above(main.tier);
|
|
@@ -1078,6 +1097,7 @@ export class Assistant {
|
|
|
1078
1097
|
title: o.run?.title ?? task?.label ?? o.task,
|
|
1079
1098
|
own_usd: ownKeyUsd(paying, [part]),
|
|
1080
1099
|
own_plan_calls: ownPlanCalls(paying, [part]),
|
|
1100
|
+
own_plan_usd: ownPlanUsd(paying, [part]),
|
|
1081
1101
|
}, [part])
|
|
1082
1102
|
.catch(() => undefined);
|
|
1083
1103
|
}
|
|
@@ -1167,6 +1187,7 @@ export class Assistant {
|
|
|
1167
1187
|
title: o.run?.title ?? task?.label ?? o.task,
|
|
1168
1188
|
own_usd: ownKeyUsd(paying, [part]),
|
|
1169
1189
|
own_plan_calls: ownPlanCalls(paying, [part]),
|
|
1190
|
+
own_plan_usd: ownPlanUsd(paying, [part]),
|
|
1170
1191
|
}, [part])
|
|
1171
1192
|
.catch(() => undefined);
|
|
1172
1193
|
}
|
|
@@ -104,8 +104,19 @@ export type SpendRow = UsagePart & {
|
|
|
104
104
|
};
|
|
105
105
|
/** The provider a part ran on: by its model id, else by the model's name at the end of its label. */
|
|
106
106
|
export declare function providerOfPart(p: UsagePart): AssistantProvider | undefined;
|
|
107
|
-
/**
|
|
108
|
-
|
|
107
|
+
/**
|
|
108
|
+
* What a part's tokens cost at its model's API rates: the estimate shown for a plan sign-in's calls,
|
|
109
|
+
* which cost the person nothing. Cache writes count as plain input. Undefined for an unknown model.
|
|
110
|
+
*/
|
|
111
|
+
export declare function listUsd(p: UsagePart): number | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Spend rows with the provider each ran on, so a page can tell a removed key's spend apart, and
|
|
114
|
+
* a $0 row (a plan's calls) with what it would cost at API rates (`list_usd`).
|
|
115
|
+
*/
|
|
116
|
+
export declare function withProvider<R extends UsagePart>(rows: R[]): (R & {
|
|
117
|
+
provider?: "anthropic" | "openai" | "xai" | undefined;
|
|
118
|
+
list_usd?: number | undefined;
|
|
119
|
+
})[];
|
|
109
120
|
/** USD of `parts` the person's own API keys paid: a provider signed in with a plan costs $0. */
|
|
110
121
|
export declare function ownKeyUsd(paying: {
|
|
111
122
|
payer: AssistantPayer;
|
|
@@ -116,6 +127,11 @@ export declare function ownPlanCalls(paying: {
|
|
|
116
127
|
payer: AssistantPayer;
|
|
117
128
|
plans: Paying['plans'];
|
|
118
129
|
}, parts: UsagePart[]): number;
|
|
130
|
+
/** Those plan calls at the models' API rates: an estimate of what the plan saved the person. */
|
|
131
|
+
export declare function ownPlanUsd(paying: {
|
|
132
|
+
payer: AssistantPayer;
|
|
133
|
+
plans: Paying['plans'];
|
|
134
|
+
}, parts: UsagePart[]): number;
|
|
119
135
|
/**
|
|
120
136
|
* What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
|
|
121
137
|
* nothing per call), on a provider they can still pay with. A row from before ways were recorded
|
|
@@ -282,7 +298,8 @@ export declare class State {
|
|
|
282
298
|
* Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
|
|
283
299
|
* chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
|
|
284
300
|
* `own_usd` is what of this answer the person's own key or plan paid, added to the entry's;
|
|
285
|
-
* `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced)
|
|
301
|
+
* `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced),
|
|
302
|
+
* `own_plan_usd` those calls at API rates (an estimate).
|
|
286
303
|
*/
|
|
287
304
|
addUsage(id: string, e: {
|
|
288
305
|
id: string;
|
|
@@ -291,6 +308,7 @@ export declare class State {
|
|
|
291
308
|
title: string;
|
|
292
309
|
own_usd?: number;
|
|
293
310
|
own_plan_calls?: number;
|
|
311
|
+
own_plan_usd?: number;
|
|
294
312
|
}, parts: UsagePart[]): Promise<void>;
|
|
295
313
|
/** One month's history (this month by default) beside its spend, and the months that have one. */
|
|
296
314
|
usage(id: string, month?: string): Promise<UsageHistory>;
|
package/dist/assistant/state.js
CHANGED
|
@@ -14,11 +14,30 @@ export function providerOfPart(p) {
|
|
|
14
14
|
KNOWN_MODELS.find((x) => p.label === x.label || p.label.endsWith(` · ${x.label}`));
|
|
15
15
|
return m?.provider;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* What a part's tokens cost at its model's API rates: the estimate shown for a plan sign-in's calls,
|
|
19
|
+
* which cost the person nothing. Cache writes count as plain input. Undefined for an unknown model.
|
|
20
|
+
*/
|
|
21
|
+
export function listUsd(p) {
|
|
22
|
+
const m = KNOWN_MODELS.find((x) => x.id === p.model) ??
|
|
23
|
+
KNOWN_MODELS.find((x) => p.label === x.label || p.label.endsWith(` · ${x.label}`));
|
|
24
|
+
if (!m)
|
|
25
|
+
return undefined;
|
|
26
|
+
return (Math.max(0, p.in - p.read) * m.in + p.read * m.read + p.out * m.out) / 1e6;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Spend rows with the provider each ran on, so a page can tell a removed key's spend apart, and
|
|
30
|
+
* a $0 row (a plan's calls) with what it would cost at API rates (`list_usd`).
|
|
31
|
+
*/
|
|
18
32
|
export function withProvider(rows) {
|
|
19
33
|
return rows.map((r) => {
|
|
20
34
|
const provider = providerOfPart(r);
|
|
21
|
-
|
|
35
|
+
const list = r.calls && r.usd < 0.0005 ? listUsd(r) : undefined;
|
|
36
|
+
return {
|
|
37
|
+
...r,
|
|
38
|
+
...(provider ? { provider } : {}),
|
|
39
|
+
...(list !== undefined ? { list_usd: list } : {}),
|
|
40
|
+
};
|
|
22
41
|
});
|
|
23
42
|
}
|
|
24
43
|
/** USD of `parts` the person's own API keys paid: a provider signed in with a plan costs $0. */
|
|
@@ -43,6 +62,17 @@ export function ownPlanCalls(paying, parts) {
|
|
|
43
62
|
})
|
|
44
63
|
.reduce((a, p) => a + p.calls, 0);
|
|
45
64
|
}
|
|
65
|
+
/** Those plan calls at the models' API rates: an estimate of what the plan saved the person. */
|
|
66
|
+
export function ownPlanUsd(paying, parts) {
|
|
67
|
+
if (paying.payer !== 'own')
|
|
68
|
+
return 0;
|
|
69
|
+
return parts
|
|
70
|
+
.filter((p) => {
|
|
71
|
+
const prov = providerOfPart(p);
|
|
72
|
+
return !!(prov && paying.plans[prov]);
|
|
73
|
+
})
|
|
74
|
+
.reduce((a, p) => a + (listUsd(p) ?? 0), 0);
|
|
75
|
+
}
|
|
46
76
|
/**
|
|
47
77
|
* What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
|
|
48
78
|
* nothing per call), on a provider they can still pay with. A row from before ways were recorded
|
|
@@ -364,7 +394,8 @@ export class State {
|
|
|
364
394
|
* Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
|
|
365
395
|
* chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
|
|
366
396
|
* `own_usd` is what of this answer the person's own key or plan paid, added to the entry's;
|
|
367
|
-
* `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced)
|
|
397
|
+
* `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced),
|
|
398
|
+
* `own_plan_usd` those calls at API rates (an estimate).
|
|
368
399
|
*/
|
|
369
400
|
async addUsage(id, e, parts) {
|
|
370
401
|
if (!parts.some((p) => p.calls))
|
|
@@ -378,6 +409,7 @@ export class State {
|
|
|
378
409
|
const sum = (k) => merged.reduce((a, p) => a + p[k], 0);
|
|
379
410
|
const own = (prev?.own_usd ?? 0) + (e.own_usd ?? 0);
|
|
380
411
|
const plan = (prev?.own_plan_calls ?? 0) + (e.own_plan_calls ?? 0);
|
|
412
|
+
const planUsd = (prev?.own_plan_usd ?? 0) + (e.own_plan_usd ?? 0);
|
|
381
413
|
entries.unshift({
|
|
382
414
|
id: e.id,
|
|
383
415
|
kind: e.kind,
|
|
@@ -392,6 +424,7 @@ export class State {
|
|
|
392
424
|
usd: sum('usd'),
|
|
393
425
|
...(own > 0 ? { own_usd: own } : {}),
|
|
394
426
|
...(plan > 0 ? { own_plan_calls: plan } : {}),
|
|
427
|
+
...(planUsd > 0 ? { own_plan_usd: planUsd } : {}),
|
|
395
428
|
parts: merged,
|
|
396
429
|
});
|
|
397
430
|
return { entries: entries.slice(0, USAGE_KEPT) };
|
package/dist/assistant/web.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface WebFetchOptions {
|
|
|
12
12
|
userAgent?: string;
|
|
13
13
|
fetch?: typeof fetch;
|
|
14
14
|
}
|
|
15
|
+
/** Why a URL is refused, or null. */
|
|
16
|
+
export declare function refused(url: URL, block: string[]): string | null;
|
|
15
17
|
/** One GET, never thrown: what it got, or why not. */
|
|
16
18
|
export declare function webFetch(raw: string, headers?: Record<string, string>, o?: WebFetchOptions): Promise<{
|
|
17
19
|
ok: boolean;
|
package/dist/assistant/web.js
CHANGED
|
@@ -6,7 +6,7 @@ const TIMEOUT_MS = 10_000;
|
|
|
6
6
|
const MAX_BYTES = 2_000_000;
|
|
7
7
|
const DESCRIPTION = `Plain HTTP GET of one URL from the server, no browser: fast and cheap, so use it first for APIs, JSON, CSV, RSS and plain HTML. Returns {ok, status, url (after redirects), content_type, json (when the body is JSON) or text, truncated}; a failed fetch (refused, timed out, not 2xx) returns ok false with status and error. Bodies over ${MAX_BYTES / 1_000_000} MB are cut; ${TIMEOUT_MS / 1000} s per fetch. Use the browser tools (scrape, extract, ask) only for pages built by JavaScript, bot walls, or after fetch fails.`;
|
|
8
8
|
/** Why a URL is refused, or null. */
|
|
9
|
-
function refused(url, block) {
|
|
9
|
+
export function refused(url, block) {
|
|
10
10
|
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
11
11
|
return 'only http and https';
|
|
12
12
|
const host = url.hostname.toLowerCase();
|
|
@@ -212,6 +212,31 @@ export function webStep(name, input, output, failed) {
|
|
|
212
212
|
}
|
|
213
213
|
return { ...(query ? { query } : {}), results };
|
|
214
214
|
}
|
|
215
|
+
if (tool === 'lookup') {
|
|
216
|
+
if (failed)
|
|
217
|
+
return undefined;
|
|
218
|
+
let o = output;
|
|
219
|
+
if (typeof o === 'string') {
|
|
220
|
+
try {
|
|
221
|
+
o = JSON.parse(o);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
o = {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const r = (o && typeof o === 'object' ? o : {});
|
|
228
|
+
const reads = [];
|
|
229
|
+
for (const x of Array.isArray(r.sources) ? r.sources : []) {
|
|
230
|
+
const u = (x ?? {});
|
|
231
|
+
if (typeof u.url !== 'string' || !/^https?:\/\//.test(u.url))
|
|
232
|
+
continue;
|
|
233
|
+
if (reads.some((p) => p.url === u.url))
|
|
234
|
+
continue;
|
|
235
|
+
reads.push(typeof u.title === 'string' && u.title ? { url: u.url, title: u.title } : { url: u.url });
|
|
236
|
+
}
|
|
237
|
+
const q = typeof args.question === 'string' ? args.question : r.question;
|
|
238
|
+
return { lookup: true, ...(typeof q === 'string' ? { query: q.slice(0, 300) } : {}), reads };
|
|
239
|
+
}
|
|
215
240
|
if (failed) {
|
|
216
241
|
const url = typeof args.url === 'string' ? args.url : '';
|
|
217
242
|
return /^https?:\/\//.test(url) ? { tried: { url } } : undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web lookup: one tool that answers a question from the web without putting whole pages in the
|
|
3
|
+
* assistant's context. It searches (ScrapeQL `search`, no model), then has ScrapeQL `ask` read each
|
|
4
|
+
* of the best pages and answer the question in a few sentences (ScrapeQL runs that on its cheapest
|
|
5
|
+
* model). The assistant gets back a short answer per page and its address, about a thousand tokens
|
|
6
|
+
* in all, instead of the pages themselves.
|
|
7
|
+
*/
|
|
8
|
+
import { type ToolSource } from '../assistant/tools.js';
|
|
9
|
+
export interface WebLookupOptions {
|
|
10
|
+
/** Pages read for one question; default 3, at most 5. */
|
|
11
|
+
pages?: number;
|
|
12
|
+
/** Tokens each page's answer may use; default 300 (ScrapeQL's floor is 200). */
|
|
13
|
+
answerTokens?: number;
|
|
14
|
+
/** Hosts never read (the app's own, behind sign-in), each with its subdomains. */
|
|
15
|
+
blockHosts?: string[];
|
|
16
|
+
}
|
|
17
|
+
/** One page a lookup read: its address, its title when the search gave one, and its answer. */
|
|
18
|
+
export interface LookupSource {
|
|
19
|
+
url: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
answer: string;
|
|
22
|
+
}
|
|
23
|
+
/** What `web__lookup` returns. */
|
|
24
|
+
export interface LookupResult {
|
|
25
|
+
question: string;
|
|
26
|
+
/** The search words, when it searched. */
|
|
27
|
+
query?: string;
|
|
28
|
+
sources: LookupSource[];
|
|
29
|
+
/** Pages it could not read, and why. */
|
|
30
|
+
failed?: {
|
|
31
|
+
url: string;
|
|
32
|
+
error: string;
|
|
33
|
+
}[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The `lookup` tool (in the `web` group, `web__lookup`) over ScrapeQL's tools (`scrapeqlTools`).
|
|
37
|
+
* ScrapeQL is opened on the first lookup of an answer and closed with it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function webLookupTools(scrapeql: ToolSource, o?: WebLookupOptions): ToolSource;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web lookup: one tool that answers a question from the web without putting whole pages in the
|
|
3
|
+
* assistant's context. It searches (ScrapeQL `search`, no model), then has ScrapeQL `ask` read each
|
|
4
|
+
* of the best pages and answer the question in a few sentences (ScrapeQL runs that on its cheapest
|
|
5
|
+
* model). The assistant gets back a short answer per page and its address, about a thousand tokens
|
|
6
|
+
* in all, instead of the pages themselves.
|
|
7
|
+
*/
|
|
8
|
+
import { ToolError, plainTools } from '../assistant/tools.js';
|
|
9
|
+
import { refused } from '../assistant/web.js';
|
|
10
|
+
const LINK = /\[([^\]\n]{0,300})\]\((https?:\/\/[^)\s]{1,2000})\)/g;
|
|
11
|
+
/** ScrapeQL notes after an answer (`\n\n---\n_…_`): for an agent, not for this one. */
|
|
12
|
+
const FOOTER = /\n\n---\n_[\s\S]*$/;
|
|
13
|
+
const ANSWER_MAX = 2000;
|
|
14
|
+
const DESCRIPTION = 'Answer a question from the web. It searches, reads the best pages and returns a short answer from each with its address: about a thousand tokens, never whole pages. Ask one specific question (a part number, a standard, a size); pass urls to read pages you already know instead of searching. Returns {question, sources: [{url, title, answer}], failed}.';
|
|
15
|
+
const clamp = (n, lo, hi, d) => Math.min(hi, Math.max(lo, Math.round(n ?? d)));
|
|
16
|
+
/** Text of a tool's answer: a string, or JSON of anything else. */
|
|
17
|
+
const textOf = (v) => (typeof v === 'string' ? v : JSON.stringify(v ?? ''));
|
|
18
|
+
/** True when `raw` is an http(s) address this lookup may read. */
|
|
19
|
+
function readable(raw, block) {
|
|
20
|
+
try {
|
|
21
|
+
return refused(new URL(raw), block) === null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The `lookup` tool (in the `web` group, `web__lookup`) over ScrapeQL's tools (`scrapeqlTools`).
|
|
29
|
+
* ScrapeQL is opened on the first lookup of an answer and closed with it.
|
|
30
|
+
*/
|
|
31
|
+
export function webLookupTools(scrapeql, o = {}) {
|
|
32
|
+
const pages = clamp(o.pages, 1, 5, 3);
|
|
33
|
+
const answerTokens = clamp(o.answerTokens, 200, 2000, 300);
|
|
34
|
+
const block = (o.blockHosts ?? []).map((h) => h.toLowerCase());
|
|
35
|
+
return async () => {
|
|
36
|
+
let open;
|
|
37
|
+
const sq = () => {
|
|
38
|
+
open ??= scrapeql();
|
|
39
|
+
return open;
|
|
40
|
+
};
|
|
41
|
+
const tools = await plainTools([
|
|
42
|
+
{
|
|
43
|
+
name: 'lookup',
|
|
44
|
+
title: 'Look it up on the web',
|
|
45
|
+
description: DESCRIPTION,
|
|
46
|
+
read: true,
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
question: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
maxLength: 500,
|
|
53
|
+
description: 'The question each page should answer, in full.',
|
|
54
|
+
},
|
|
55
|
+
search: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
maxLength: 300,
|
|
58
|
+
description: 'Search words, when they differ from the question.',
|
|
59
|
+
},
|
|
60
|
+
urls: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string', maxLength: 2000 },
|
|
63
|
+
maxItems: pages,
|
|
64
|
+
description: 'Pages to read instead of searching.',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['question'],
|
|
68
|
+
},
|
|
69
|
+
async run(args) {
|
|
70
|
+
const a = (args ?? {});
|
|
71
|
+
const question = String(a.question ?? '')
|
|
72
|
+
.trim()
|
|
73
|
+
.slice(0, 500);
|
|
74
|
+
if (!question)
|
|
75
|
+
throw new ToolError('Give the question to look up.');
|
|
76
|
+
const given = Array.isArray(a.urls) ? a.urls.map(String) : [];
|
|
77
|
+
const t = await sq();
|
|
78
|
+
let query;
|
|
79
|
+
let picks;
|
|
80
|
+
if (given.length) {
|
|
81
|
+
picks = given.filter((u) => readable(u, block)).map((url) => ({ url }));
|
|
82
|
+
if (!picks.length)
|
|
83
|
+
throw new ToolError('None of those addresses can be read.');
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
query =
|
|
87
|
+
String(a.search ?? '')
|
|
88
|
+
.trim()
|
|
89
|
+
.slice(0, 300) || question;
|
|
90
|
+
const found = textOf(await t.call('search', { query, limit: pages + 3 }));
|
|
91
|
+
picks = [];
|
|
92
|
+
for (const m of found.matchAll(LINK)) {
|
|
93
|
+
const url = m[2];
|
|
94
|
+
if (!readable(url, block) || picks.some((p) => p.url === url))
|
|
95
|
+
continue;
|
|
96
|
+
const title = m[1].trim();
|
|
97
|
+
picks.push(title && title !== url ? { url, title } : { url });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
picks = picks.slice(0, pages);
|
|
101
|
+
const out = { question, ...(query ? { query } : {}), sources: [] };
|
|
102
|
+
if (!picks.length)
|
|
103
|
+
return out;
|
|
104
|
+
const read = await Promise.all(picks.map(async (p) => {
|
|
105
|
+
try {
|
|
106
|
+
const r = textOf(await t.call('ask', { url: p.url, question, budgetTokens: answerTokens }));
|
|
107
|
+
return { ...p, answer: r.replace(FOOTER, '').trim().slice(0, ANSWER_MAX) };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return { url: p.url, error: String(err?.message ?? err).slice(0, 200) };
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
const failed = [];
|
|
114
|
+
for (const r of read)
|
|
115
|
+
if ('answer' in r) {
|
|
116
|
+
if (r.answer)
|
|
117
|
+
out.sources.push(r);
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
failed.push(r);
|
|
121
|
+
return failed.length ? { ...out, failed } : out;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
])();
|
|
125
|
+
return {
|
|
126
|
+
...tools,
|
|
127
|
+
async close() {
|
|
128
|
+
if (open)
|
|
129
|
+
await (await open.catch(() => null))?.close();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.253",
|
|
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",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"./workspaces": {
|
|
27
27
|
"types": "./dist/workspaces/index.d.ts",
|
|
28
28
|
"default": "./dist/workspaces/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./subagents": {
|
|
31
|
+
"types": "./dist/subagents/index.d.ts",
|
|
32
|
+
"default": "./dist/subagents/index.js"
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
"files": [
|
|
@@ -52,8 +56,8 @@
|
|
|
52
56
|
"@ai-sdk/openai": "4.0.108",
|
|
53
57
|
"@anthropic-ai/sdk": "0.133.0",
|
|
54
58
|
"@cloudflare/think": "0.23.0",
|
|
55
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
56
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
59
|
+
"@columenlabs/lumen-ops": "0.1.253",
|
|
60
|
+
"@columenlabs/lumen-ui-types": "0.1.253",
|
|
57
61
|
"agents": "0.32.0",
|
|
58
62
|
"ai": "7.0.162",
|
|
59
63
|
"just-bash": "3.6.0",
|