@columenlabs/lumen-ai 0.1.250 → 0.1.252
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 +8 -1
- package/dist/assistant/state.js +15 -1
- 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, planDocName, waitText, withProvider, } from './state.js';
|
|
19
|
+
import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, ownPlanCalls, 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';
|
|
@@ -408,6 +409,7 @@ export class Assistant {
|
|
|
408
409
|
kind: 'chat',
|
|
409
410
|
title: message.split('\n')[0] || 'Chat',
|
|
410
411
|
own_usd: ownKeyUsd(paying, onPayer),
|
|
412
|
+
own_plan_calls: ownPlanCalls(paying, onPayer),
|
|
411
413
|
}, end.parts)
|
|
412
414
|
.catch(() => undefined);
|
|
413
415
|
const total = end.parts.reduce((a, m) => ({
|
|
@@ -824,7 +826,22 @@ export class Assistant {
|
|
|
824
826
|
for (const source of Array.isArray(s) ? s : [s])
|
|
825
827
|
sources.push({ group: g.id, access, source, ...(g.writes ? { writes: true } : {}) });
|
|
826
828
|
}
|
|
827
|
-
|
|
829
|
+
const lookup = this.o.web?.lookup;
|
|
830
|
+
const blockHosts = this.o.web?.fetch ? this.o.web.fetch.blockHosts : undefined;
|
|
831
|
+
if ((p.web ?? true) && lookup) {
|
|
832
|
+
const sq = this.o.web?.scrapeql;
|
|
833
|
+
const token = sq && !sq.fetch ? await this.state.scrapeql() : null;
|
|
834
|
+
if (sq && (sq.fetch || token))
|
|
835
|
+
sources.push({
|
|
836
|
+
group: 'web',
|
|
837
|
+
access: 'read',
|
|
838
|
+
source: webLookupTools(scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch), {
|
|
839
|
+
...(blockHosts ? { blockHosts } : {}),
|
|
840
|
+
...(typeof lookup === 'object' ? lookup : {}),
|
|
841
|
+
}),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
else if (p.web ?? true) {
|
|
828
845
|
if (this.o.web?.fetch !== false)
|
|
829
846
|
sources.push({
|
|
830
847
|
group: 'web',
|
|
@@ -869,7 +886,9 @@ export class Assistant {
|
|
|
869
886
|
lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
|
|
870
887
|
if (failed)
|
|
871
888
|
lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
|
|
872
|
-
if (fns.web)
|
|
889
|
+
if (fns.web && this.o.web?.lookup)
|
|
890
|
+
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.");
|
|
891
|
+
else if (fns.web)
|
|
873
892
|
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.');
|
|
874
893
|
if (main) {
|
|
875
894
|
const reach = this.above(main.tier);
|
|
@@ -1076,6 +1095,7 @@ export class Assistant {
|
|
|
1076
1095
|
task: o.task,
|
|
1077
1096
|
title: o.run?.title ?? task?.label ?? o.task,
|
|
1078
1097
|
own_usd: ownKeyUsd(paying, [part]),
|
|
1098
|
+
own_plan_calls: ownPlanCalls(paying, [part]),
|
|
1079
1099
|
}, [part])
|
|
1080
1100
|
.catch(() => undefined);
|
|
1081
1101
|
}
|
|
@@ -1164,6 +1184,7 @@ export class Assistant {
|
|
|
1164
1184
|
task: o.task,
|
|
1165
1185
|
title: o.run?.title ?? task?.label ?? o.task,
|
|
1166
1186
|
own_usd: ownKeyUsd(paying, [part]),
|
|
1187
|
+
own_plan_calls: ownPlanCalls(paying, [part]),
|
|
1167
1188
|
}, [part])
|
|
1168
1189
|
.catch(() => undefined);
|
|
1169
1190
|
}
|
|
@@ -111,6 +111,11 @@ export declare function ownKeyUsd(paying: {
|
|
|
111
111
|
payer: AssistantPayer;
|
|
112
112
|
plans: Paying['plans'];
|
|
113
113
|
}, parts: UsagePart[]): number;
|
|
114
|
+
/** Calls in `parts` a plan sign-in of the person's answered (their own payer, a plan provider). */
|
|
115
|
+
export declare function ownPlanCalls(paying: {
|
|
116
|
+
payer: AssistantPayer;
|
|
117
|
+
plans: Paying['plans'];
|
|
118
|
+
}, parts: UsagePart[]): number;
|
|
114
119
|
/**
|
|
115
120
|
* What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
|
|
116
121
|
* nothing per call), on a provider they can still pay with. A row from before ways were recorded
|
|
@@ -276,7 +281,8 @@ export declare class State {
|
|
|
276
281
|
/**
|
|
277
282
|
* Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
|
|
278
283
|
* chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
|
|
279
|
-
* `own_usd` is what of this answer the person's own key or plan paid, added to the entry's
|
|
284
|
+
* `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).
|
|
280
286
|
*/
|
|
281
287
|
addUsage(id: string, e: {
|
|
282
288
|
id: string;
|
|
@@ -284,6 +290,7 @@ export declare class State {
|
|
|
284
290
|
task?: string;
|
|
285
291
|
title: string;
|
|
286
292
|
own_usd?: number;
|
|
293
|
+
own_plan_calls?: number;
|
|
287
294
|
}, parts: UsagePart[]): Promise<void>;
|
|
288
295
|
/** One month's history (this month by default) beside its spend, and the months that have one. */
|
|
289
296
|
usage(id: string, month?: string): Promise<UsageHistory>;
|
package/dist/assistant/state.js
CHANGED
|
@@ -32,6 +32,17 @@ export function ownKeyUsd(paying, parts) {
|
|
|
32
32
|
})
|
|
33
33
|
.reduce((a, p) => a + p.usd, 0);
|
|
34
34
|
}
|
|
35
|
+
/** Calls in `parts` a plan sign-in of the person's answered (their own payer, a plan provider). */
|
|
36
|
+
export function ownPlanCalls(paying, parts) {
|
|
37
|
+
if (paying.payer !== 'own')
|
|
38
|
+
return 0;
|
|
39
|
+
return parts
|
|
40
|
+
.filter((p) => {
|
|
41
|
+
const prov = providerOfPart(p);
|
|
42
|
+
return !!(prov && paying.plans[prov]);
|
|
43
|
+
})
|
|
44
|
+
.reduce((a, p) => a + p.calls, 0);
|
|
45
|
+
}
|
|
35
46
|
/**
|
|
36
47
|
* What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
|
|
37
48
|
* nothing per call), on a provider they can still pay with. A row from before ways were recorded
|
|
@@ -352,7 +363,8 @@ export class State {
|
|
|
352
363
|
/**
|
|
353
364
|
* Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
|
|
354
365
|
* chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
|
|
355
|
-
* `own_usd` is what of this answer the person's own key or plan paid, added to the entry's
|
|
366
|
+
* `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).
|
|
356
368
|
*/
|
|
357
369
|
async addUsage(id, e, parts) {
|
|
358
370
|
if (!parts.some((p) => p.calls))
|
|
@@ -365,6 +377,7 @@ export class State {
|
|
|
365
377
|
const merged = mergeParts(prev?.parts ?? [], parts);
|
|
366
378
|
const sum = (k) => merged.reduce((a, p) => a + p[k], 0);
|
|
367
379
|
const own = (prev?.own_usd ?? 0) + (e.own_usd ?? 0);
|
|
380
|
+
const plan = (prev?.own_plan_calls ?? 0) + (e.own_plan_calls ?? 0);
|
|
368
381
|
entries.unshift({
|
|
369
382
|
id: e.id,
|
|
370
383
|
kind: e.kind,
|
|
@@ -378,6 +391,7 @@ export class State {
|
|
|
378
391
|
out: sum('out'),
|
|
379
392
|
usd: sum('usd'),
|
|
380
393
|
...(own > 0 ? { own_usd: own } : {}),
|
|
394
|
+
...(plan > 0 ? { own_plan_calls: plan } : {}),
|
|
381
395
|
parts: merged,
|
|
382
396
|
});
|
|
383
397
|
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.252",
|
|
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.252",
|
|
60
|
+
"@columenlabs/lumen-ui-types": "0.1.252",
|
|
57
61
|
"agents": "0.32.0",
|
|
58
62
|
"ai": "7.0.162",
|
|
59
63
|
"just-bash": "3.6.0",
|