@columenlabs/lumen-ai 0.1.72 → 0.1.73
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 +46 -1
- package/dist/assistant/engine.d.ts +157 -0
- package/dist/assistant/engine.js +1064 -0
- package/dist/assistant/index.d.ts +18 -0
- package/dist/assistant/index.js +18 -0
- package/dist/assistant/models.d.ts +72 -0
- package/dist/assistant/models.js +69 -0
- package/dist/assistant/routes.d.ts +10 -0
- package/dist/assistant/routes.js +319 -0
- package/dist/assistant/state.d.ts +109 -0
- package/dist/assistant/state.js +138 -0
- package/dist/assistant/store.d.ts +78 -0
- package/dist/assistant/store.js +120 -0
- package/dist/assistant/tools.d.ts +49 -0
- package/dist/assistant/tools.js +110 -0
- package/dist/assistant/web.d.ts +45 -0
- package/dist/assistant/web.js +172 -0
- package/package.json +10 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web: `fetch`, a plain GET from the Worker (no browser, 10 s, 2 MB), tried first for APIs, JSON
|
|
3
|
+
* and plain pages; and ScrapeQL's own MCP tools (`search`, `scrape`, `extract`, `ask`) over its API
|
|
4
|
+
* for pages that need a real browser or after a fetch fails. ScrapeQL sits behind Cloudflare Access,
|
|
5
|
+
* so the app reaches it with one Access service token an admin pastes in Settings.
|
|
6
|
+
*/
|
|
7
|
+
import { plainTools, remoteMcpTools } from './tools.js';
|
|
8
|
+
/** Fetches one answer may have in flight at once; more wait their turn. */
|
|
9
|
+
const PARALLEL = 20;
|
|
10
|
+
const TIMEOUT_MS = 10_000;
|
|
11
|
+
/** Bytes of body read; past it the body is cut and `truncated` says so. */
|
|
12
|
+
const MAX_BYTES = 2_000_000;
|
|
13
|
+
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.`;
|
|
14
|
+
/** Why a URL is refused, or null. */
|
|
15
|
+
function refused(url, block) {
|
|
16
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
17
|
+
return 'only http and https';
|
|
18
|
+
const host = url.hostname.toLowerCase();
|
|
19
|
+
if (block.some((b) => host === b || host.endsWith(`.${b}`)))
|
|
20
|
+
return "the app's own hosts";
|
|
21
|
+
if (host === 'localhost' ||
|
|
22
|
+
host.endsWith('.local') ||
|
|
23
|
+
host.endsWith('.internal') ||
|
|
24
|
+
/^[\d.]+$/.test(host) ||
|
|
25
|
+
host.includes(':') ||
|
|
26
|
+
host.startsWith('['))
|
|
27
|
+
return 'a public host name, not an address';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
async function readCapped(res) {
|
|
31
|
+
if (!res.body)
|
|
32
|
+
return { bytes: new Uint8Array(), truncated: false };
|
|
33
|
+
const reader = res.body.getReader();
|
|
34
|
+
const parts = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
let truncated = false;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done)
|
|
40
|
+
break;
|
|
41
|
+
parts.push(value);
|
|
42
|
+
size += value.byteLength;
|
|
43
|
+
if (size > MAX_BYTES) {
|
|
44
|
+
truncated = true;
|
|
45
|
+
await reader.cancel();
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const bytes = new Uint8Array(Math.min(size, MAX_BYTES));
|
|
50
|
+
let at = 0;
|
|
51
|
+
for (const p of parts) {
|
|
52
|
+
const take = p.subarray(0, bytes.length - at);
|
|
53
|
+
bytes.set(take, at);
|
|
54
|
+
at += take.length;
|
|
55
|
+
if (at >= bytes.length)
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
return { bytes, truncated };
|
|
59
|
+
}
|
|
60
|
+
/** One GET, never thrown: what it got, or why not. */
|
|
61
|
+
export async function webFetch(raw, headers = {}, o = {}) {
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ok: false, status: 0, url: raw, error: 'not a full URL (https://…)' };
|
|
68
|
+
}
|
|
69
|
+
const why = refused(url, (o.blockHosts ?? []).map((h) => h.toLowerCase()));
|
|
70
|
+
if (why)
|
|
71
|
+
return { ok: false, status: 0, url: raw, error: `refused: ${why}` };
|
|
72
|
+
const f = o.fetch ?? fetch;
|
|
73
|
+
try {
|
|
74
|
+
const res = await f(url.toString(), {
|
|
75
|
+
headers: { 'user-agent': o.userAgent ?? 'LumenAssistant/1.0', accept: '*/*', ...headers },
|
|
76
|
+
redirect: 'follow',
|
|
77
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
78
|
+
});
|
|
79
|
+
const type = res.headers.get('content-type') ?? '';
|
|
80
|
+
const { bytes, truncated } = await readCapped(res);
|
|
81
|
+
const text = new TextDecoder().decode(bytes);
|
|
82
|
+
let json;
|
|
83
|
+
if (!truncated && (/json/i.test(type) || /^\s*[[{]/.test(text))) {
|
|
84
|
+
try {
|
|
85
|
+
json = JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: res.ok,
|
|
91
|
+
status: res.status,
|
|
92
|
+
url: res.url || url.toString(),
|
|
93
|
+
content_type: type,
|
|
94
|
+
...(json !== undefined ? { json } : { text }),
|
|
95
|
+
truncated,
|
|
96
|
+
...(res.ok ? {} : { error: `HTTP ${res.status}` }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
status: 0,
|
|
104
|
+
url: url.toString(),
|
|
105
|
+
error: timedOut
|
|
106
|
+
? `timed out after ${TIMEOUT_MS / 1000} s`
|
|
107
|
+
: String(err?.message ?? err),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** `fetch` as a tool, at most `PARALLEL` at once per answer. */
|
|
112
|
+
export function webFetchTools(o = {}) {
|
|
113
|
+
return async () => {
|
|
114
|
+
let running = 0;
|
|
115
|
+
const waiting = [];
|
|
116
|
+
const open = await plainTools([
|
|
117
|
+
{
|
|
118
|
+
name: 'fetch',
|
|
119
|
+
title: 'Read a web page',
|
|
120
|
+
description: DESCRIPTION,
|
|
121
|
+
read: true,
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
url: { type: 'string', maxLength: 2000, description: 'A full address, https://…' },
|
|
126
|
+
headers: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
additionalProperties: { type: 'string', maxLength: 500 },
|
|
129
|
+
description: 'Request headers, e.g. {"accept": "application/json"}.',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
required: ['url'],
|
|
133
|
+
},
|
|
134
|
+
async run(args) {
|
|
135
|
+
const a = (args ?? {});
|
|
136
|
+
while (running >= PARALLEL)
|
|
137
|
+
await new Promise((r) => waiting.push(r));
|
|
138
|
+
running++;
|
|
139
|
+
try {
|
|
140
|
+
const headers = a.headers && typeof a.headers === 'object'
|
|
141
|
+
? Object.fromEntries(Object.entries(a.headers).map(([k, v]) => [
|
|
142
|
+
k,
|
|
143
|
+
String(v),
|
|
144
|
+
]))
|
|
145
|
+
: {};
|
|
146
|
+
return await webFetch(String(a.url ?? ''), headers, o);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
running--;
|
|
150
|
+
waiting.shift()?.();
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
])();
|
|
155
|
+
return open;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** ScrapeQL's MCP tools over its API, with an Access service token. Every one of them reads. */
|
|
159
|
+
export function scrapeqlTools(url, token, f) {
|
|
160
|
+
const remote = remoteMcpTools({
|
|
161
|
+
url,
|
|
162
|
+
headers: {
|
|
163
|
+
'CF-Access-Client-Id': token.client_id,
|
|
164
|
+
'CF-Access-Client-Secret': token.client_secret,
|
|
165
|
+
},
|
|
166
|
+
...(f ? { fetch: f } : {}),
|
|
167
|
+
});
|
|
168
|
+
return async () => {
|
|
169
|
+
const open = await remote();
|
|
170
|
+
return { ...open, tools: open.tools.map((t) => ({ ...t, read: true })) };
|
|
171
|
+
};
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Lumen's assistant and agent parts: serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
|
|
3
|
+
"version": "0.1.73",
|
|
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",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"./mcp": {
|
|
15
15
|
"types": "./dist/mcp.d.ts",
|
|
16
16
|
"default": "./dist/mcp.js"
|
|
17
|
+
},
|
|
18
|
+
"./assistant": {
|
|
19
|
+
"types": "./dist/assistant/index.d.ts",
|
|
20
|
+
"default": "./dist/assistant/index.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"files": [
|
|
@@ -36,7 +40,10 @@
|
|
|
36
40
|
"registry": "https://registry.npmjs.org/"
|
|
37
41
|
},
|
|
38
42
|
"dependencies": {
|
|
39
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
43
|
+
"@columenlabs/lumen-ops": "0.1.73",
|
|
44
|
+
"@columenlabs/lumen-ui-types": "0.1.73",
|
|
45
|
+
"zod": "^4.3.6",
|
|
46
|
+
"@anthropic-ai/sdk": "0.133.0"
|
|
40
47
|
},
|
|
41
48
|
"peerDependencies": {
|
|
42
49
|
"@modelcontextprotocol/sdk": "^1.29.0"
|