maqam 0.1.0
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/LICENSE +21 -0
- package/README.md +176 -0
- package/app/app.js +113 -0
- package/app/assets/maqam-brand-board.png +0 -0
- package/app/assets/maqam-logo.svg +17 -0
- package/app/assets/maqam-readme-hero.png +0 -0
- package/app/assets/maqam-system-map.svg +114 -0
- package/app/index.html +113 -0
- package/app/styles.css +397 -0
- package/bin/ajnas-crawl.js +119 -0
- package/bin/maqam.js +22 -0
- package/package.json +74 -0
- package/src/framework/errors.js +35 -0
- package/src/framework/evidence-ledger.js +72 -0
- package/src/framework/policy.js +119 -0
- package/src/framework/research-workflow.js +80 -0
- package/src/framework/runtime.js +101 -0
- package/src/framework/skill-registry.js +52 -0
- package/src/framework/tool-gateway.js +65 -0
- package/src/index.js +351 -0
- package/src/maqam/server.js +189 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
import robotsParser from "robots-parser";
|
|
3
|
+
import TurndownService from "turndown";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_USER_AGENT = "Maqam/0.1 (+https://github.com/AjnasNB/maqam)";
|
|
6
|
+
const DEFAULT_MAX_BYTES = 3 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
|
|
10
|
+
function toUrl(value, base) {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(value, base).toString();
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeUrl(value) {
|
|
19
|
+
const url = new URL(value);
|
|
20
|
+
url.hash = "";
|
|
21
|
+
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
|
|
22
|
+
url.port = "";
|
|
23
|
+
}
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isHttpUrl(value) {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameOrigin(a, b) {
|
|
37
|
+
return new URL(a).origin === new URL(b).origin;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchText(url, options) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(url, {
|
|
45
|
+
headers: {
|
|
46
|
+
"user-agent": options.userAgent,
|
|
47
|
+
accept: options.accept || "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
|
|
48
|
+
},
|
|
49
|
+
redirect: "follow",
|
|
50
|
+
signal: controller.signal
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const contentType = response.headers.get("content-type") || "";
|
|
54
|
+
const length = Number(response.headers.get("content-length") || 0);
|
|
55
|
+
if (length > options.maxBytes) {
|
|
56
|
+
throw new Error(`Response too large: ${length} bytes`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reader = response.body?.getReader();
|
|
60
|
+
if (!reader) {
|
|
61
|
+
return { response, text: await response.text(), contentType };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let received = 0;
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
received += value.byteLength;
|
|
70
|
+
if (received > options.maxBytes) {
|
|
71
|
+
throw new Error(`Response exceeded maxBytes: ${options.maxBytes}`);
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value);
|
|
74
|
+
}
|
|
75
|
+
const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
76
|
+
return { response, text: buffer.toString("utf8"), contentType };
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseRobotsSitemaps(robotsText) {
|
|
83
|
+
return robotsText
|
|
84
|
+
.split(/\r?\n/)
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter((line) => /^sitemap:/i.test(line))
|
|
87
|
+
.map((line) => line.replace(/^sitemap:\s*/i, "").trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadRobots(origin, options) {
|
|
92
|
+
const robotsUrl = new URL("/robots.txt", origin).toString();
|
|
93
|
+
try {
|
|
94
|
+
const { response, text } = await fetchText(robotsUrl, {
|
|
95
|
+
...options,
|
|
96
|
+
accept: "text/plain,*/*;q=0.5",
|
|
97
|
+
maxBytes: Math.min(options.maxBytes, 512 * 1024)
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return {
|
|
101
|
+
parser: robotsParser(robotsUrl, ""),
|
|
102
|
+
sitemaps: []
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
parser: robotsParser(robotsUrl, text),
|
|
107
|
+
sitemaps: parseRobotsSitemaps(text)
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
parser: robotsParser(robotsUrl, ""),
|
|
112
|
+
sitemaps: []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function discoverSitemapUrls(sitemapUrl, options) {
|
|
118
|
+
const discovered = [];
|
|
119
|
+
try {
|
|
120
|
+
const { response, text, contentType } = await fetchText(sitemapUrl, {
|
|
121
|
+
...options,
|
|
122
|
+
accept: "application/xml,text/xml,*/*;q=0.5"
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) return discovered;
|
|
125
|
+
if (!contentType.includes("xml") && !text.trim().startsWith("<")) return discovered;
|
|
126
|
+
|
|
127
|
+
const $ = cheerio.load(text, { xmlMode: true });
|
|
128
|
+
$("url > loc").each((_, el) => {
|
|
129
|
+
const value = $(el).text().trim();
|
|
130
|
+
if (isHttpUrl(value)) discovered.push(normalizeUrl(value));
|
|
131
|
+
});
|
|
132
|
+
$("sitemap > loc").each((_, el) => {
|
|
133
|
+
const value = $(el).text().trim();
|
|
134
|
+
if (isHttpUrl(value)) discovered.push(normalizeUrl(value));
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
return discovered;
|
|
138
|
+
}
|
|
139
|
+
return discovered;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractLinks($, baseUrl) {
|
|
143
|
+
const links = [];
|
|
144
|
+
$("a[href]").each((_, el) => {
|
|
145
|
+
const href = $(el).attr("href");
|
|
146
|
+
const resolved = toUrl(href, baseUrl);
|
|
147
|
+
if (resolved && isHttpUrl(resolved)) {
|
|
148
|
+
links.push(normalizeUrl(resolved));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return [...new Set(links)];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cleanForExtraction($) {
|
|
155
|
+
$("script, style, noscript, template, svg, canvas, iframe").remove();
|
|
156
|
+
$("[hidden], [aria-hidden='true']").remove();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function extractPage(html, url) {
|
|
160
|
+
const $ = cheerio.load(html);
|
|
161
|
+
cleanForExtraction($);
|
|
162
|
+
|
|
163
|
+
const title = ($("title").first().text() || $("h1").first().text() || "").trim().replace(/\s+/g, " ");
|
|
164
|
+
const description = ($("meta[name='description']").attr("content") || "").trim();
|
|
165
|
+
const h1 = $("h1").first().text().trim().replace(/\s+/g, " ");
|
|
166
|
+
const canonical = toUrl($("link[rel='canonical']").attr("href") || url, url);
|
|
167
|
+
const links = extractLinks($, url);
|
|
168
|
+
|
|
169
|
+
const main = $("main").first();
|
|
170
|
+
const contentRoot = main.length ? main : $("body");
|
|
171
|
+
const htmlFragment = contentRoot.html() || "";
|
|
172
|
+
const text = contentRoot.text().replace(/\s+/g, " ").trim();
|
|
173
|
+
|
|
174
|
+
const turndown = new TurndownService({
|
|
175
|
+
headingStyle: "atx",
|
|
176
|
+
codeBlockStyle: "fenced",
|
|
177
|
+
bulletListMarker: "-"
|
|
178
|
+
});
|
|
179
|
+
const markdown = turndown.turndown(htmlFragment).replace(/\n{3,}/g, "\n\n").trim();
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
url,
|
|
183
|
+
canonical,
|
|
184
|
+
title,
|
|
185
|
+
description,
|
|
186
|
+
h1,
|
|
187
|
+
text,
|
|
188
|
+
markdown,
|
|
189
|
+
links,
|
|
190
|
+
fetchedAt: new Date().toISOString()
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
class CrawlQueue {
|
|
195
|
+
constructor() {
|
|
196
|
+
this.items = [];
|
|
197
|
+
this.offset = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
push(url) {
|
|
201
|
+
this.items.push(url);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
shift() {
|
|
205
|
+
if (this.offset >= this.items.length) return null;
|
|
206
|
+
const value = this.items[this.offset];
|
|
207
|
+
this.offset += 1;
|
|
208
|
+
if (this.offset > 1000 && this.offset * 2 > this.items.length) {
|
|
209
|
+
this.items = this.items.slice(this.offset);
|
|
210
|
+
this.offset = 0;
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get length() {
|
|
216
|
+
return this.items.length - this.offset;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function crawl(input = {}) {
|
|
221
|
+
const seeds = (input.seeds || input.urls || [])
|
|
222
|
+
.map((seed) => (isHttpUrl(seed) ? normalizeUrl(seed) : null))
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
|
|
225
|
+
if (!seeds.length) {
|
|
226
|
+
throw new Error("At least one http(s) seed URL is required.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const options = {
|
|
230
|
+
maxPages: input.maxPages ?? 50,
|
|
231
|
+
concurrency: input.concurrency ?? 4,
|
|
232
|
+
sameOrigin: input.sameOrigin ?? true,
|
|
233
|
+
includeSitemaps: input.includeSitemaps ?? false,
|
|
234
|
+
obeyRobots: input.obeyRobots ?? true,
|
|
235
|
+
userAgent: input.userAgent || DEFAULT_USER_AGENT,
|
|
236
|
+
delayMs: input.delayMs ?? 250,
|
|
237
|
+
timeoutMs: input.timeoutMs ?? 15_000,
|
|
238
|
+
maxBytes: input.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
239
|
+
onPage: input.onPage || null,
|
|
240
|
+
onError: input.onError || null
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const queue = new CrawlQueue();
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
const enqueued = new Set();
|
|
246
|
+
const results = [];
|
|
247
|
+
const robotsByOrigin = new Map();
|
|
248
|
+
const lastFetchByOrigin = new Map();
|
|
249
|
+
const seedOrigins = new Set(seeds.map((seed) => new URL(seed).origin));
|
|
250
|
+
|
|
251
|
+
const enqueue = (url) => {
|
|
252
|
+
if (!url || enqueued.has(url) || seen.has(url)) return;
|
|
253
|
+
if (options.sameOrigin && ![...seedOrigins].some((origin) => sameOrigin(url, origin))) return;
|
|
254
|
+
enqueued.add(url);
|
|
255
|
+
queue.push(url);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
for (const seed of seeds) enqueue(seed);
|
|
259
|
+
|
|
260
|
+
async function getRobots(url) {
|
|
261
|
+
const origin = new URL(url).origin;
|
|
262
|
+
if (!robotsByOrigin.has(origin)) {
|
|
263
|
+
robotsByOrigin.set(origin, await loadRobots(origin, options));
|
|
264
|
+
}
|
|
265
|
+
return robotsByOrigin.get(origin);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (options.includeSitemaps) {
|
|
269
|
+
for (const seed of seeds) {
|
|
270
|
+
const robots = await getRobots(seed);
|
|
271
|
+
const sitemapUrls = robots.sitemaps.length
|
|
272
|
+
? robots.sitemaps
|
|
273
|
+
: [new URL("/sitemap.xml", new URL(seed).origin).toString()];
|
|
274
|
+
for (const sitemapUrl of sitemapUrls) {
|
|
275
|
+
const urls = await discoverSitemapUrls(sitemapUrl, options);
|
|
276
|
+
for (const url of urls) enqueue(url);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function waitForOrigin(url) {
|
|
282
|
+
const origin = new URL(url).origin;
|
|
283
|
+
const last = lastFetchByOrigin.get(origin) || 0;
|
|
284
|
+
const waitMs = Math.max(0, last + options.delayMs - Date.now());
|
|
285
|
+
if (waitMs) await sleep(waitMs);
|
|
286
|
+
lastFetchByOrigin.set(origin, Date.now());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function worker() {
|
|
290
|
+
while (results.length < options.maxPages) {
|
|
291
|
+
const url = queue.shift();
|
|
292
|
+
if (!url) return;
|
|
293
|
+
if (seen.has(url)) continue;
|
|
294
|
+
seen.add(url);
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
if (options.obeyRobots) {
|
|
298
|
+
const robots = await getRobots(url);
|
|
299
|
+
if (robots.parser && !robots.parser.isAllowed(url, options.userAgent)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await waitForOrigin(url);
|
|
305
|
+
const { response, text, contentType } = await fetchText(url, options);
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
throw new Error(`HTTP ${response.status}`);
|
|
308
|
+
}
|
|
309
|
+
if (!/html|xml|text\//i.test(contentType)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const page = extractPage(text, response.url || url);
|
|
314
|
+
page.status = response.status;
|
|
315
|
+
page.contentType = contentType;
|
|
316
|
+
results.push(page);
|
|
317
|
+
if (options.onPage) await options.onPage(page);
|
|
318
|
+
|
|
319
|
+
for (const link of page.links) {
|
|
320
|
+
if (results.length + queue.length >= options.maxPages * 6) break;
|
|
321
|
+
enqueue(link);
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const failure = { url, error: error.message || String(error) };
|
|
325
|
+
if (options.onError) await options.onError(failure);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const workerCount = Math.max(1, Math.min(options.concurrency, options.maxPages));
|
|
331
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
332
|
+
return results.slice(0, options.maxPages);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export { discoverSitemapUrls, normalizeUrl };
|
|
336
|
+
export { AjnasFrameworkError, ApprovalRequiredError, PolicyDeniedError, toErrorRecord } from "./framework/errors.js";
|
|
337
|
+
export { PolicyEngine } from "./framework/policy.js";
|
|
338
|
+
export { EvidenceLedger } from "./framework/evidence-ledger.js";
|
|
339
|
+
export { ToolGateway } from "./framework/tool-gateway.js";
|
|
340
|
+
export { SkillRegistry } from "./framework/skill-registry.js";
|
|
341
|
+
export { AgentRuntime } from "./framework/runtime.js";
|
|
342
|
+
export { createResearchWorkflow } from "./framework/research-workflow.js";
|
|
343
|
+
|
|
344
|
+
export function createCrawlerTool(defaultOptions = {}) {
|
|
345
|
+
return async function crawlerTool(input = {}) {
|
|
346
|
+
return crawl({
|
|
347
|
+
...defaultOptions,
|
|
348
|
+
...input
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { extname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
AgentRuntime,
|
|
7
|
+
EvidenceLedger,
|
|
8
|
+
PolicyEngine,
|
|
9
|
+
ToolGateway,
|
|
10
|
+
createCrawlerTool,
|
|
11
|
+
createResearchWorkflow
|
|
12
|
+
} from "../index.js";
|
|
13
|
+
|
|
14
|
+
const PRODUCT = {
|
|
15
|
+
name: "Maqam",
|
|
16
|
+
tagline: "Compose governed agents",
|
|
17
|
+
description: "Enterprise agent framework console for policy-bound research, evidence capture, and auditable workflow runs."
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PUBLIC_DIR = fileURLToPath(new URL("../../app/", import.meta.url));
|
|
21
|
+
|
|
22
|
+
const CONTENT_TYPES = {
|
|
23
|
+
".html": "text/html; charset=utf-8",
|
|
24
|
+
".css": "text/css; charset=utf-8",
|
|
25
|
+
".js": "text/javascript; charset=utf-8",
|
|
26
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".json": "application/json; charset=utf-8"
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function sendJson(response, statusCode, payload) {
|
|
32
|
+
response.writeHead(statusCode, { "content-type": CONTENT_TYPES[".json"] });
|
|
33
|
+
response.end(JSON.stringify(payload, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function httpError(statusCode, message) {
|
|
37
|
+
const error = new Error(message);
|
|
38
|
+
error.statusCode = statusCode;
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readJsonBody(request) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
for await (const chunk of request) {
|
|
46
|
+
size += chunk.byteLength;
|
|
47
|
+
if (size > 1024 * 1024) throw httpError(413, "Request body is too large.");
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
}
|
|
50
|
+
if (!chunks.length) return {};
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
throw httpError(400, "Request body must be valid JSON.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSeeds(seeds) {
|
|
59
|
+
if (!Array.isArray(seeds)) throw httpError(400, "`seeds` must be an array of URLs.");
|
|
60
|
+
const normalized = seeds.map((seed) => {
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(seed);
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
64
|
+
url.hash = "";
|
|
65
|
+
return url.toString();
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}).filter(Boolean);
|
|
70
|
+
if (!normalized.length) throw httpError(400, "At least one http(s) seed URL is required.");
|
|
71
|
+
return [...new Set(normalized)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clampMaxPages(value) {
|
|
75
|
+
const maxPages = Number(value || 5);
|
|
76
|
+
if (!Number.isFinite(maxPages)) return 5;
|
|
77
|
+
return Math.max(1, Math.min(25, Math.floor(maxPages)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function deriveOrigins(seeds) {
|
|
81
|
+
return [...new Set(seeds.map((seed) => new URL(seed).origin))];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function runResearch(body, crawlerTool) {
|
|
85
|
+
const seeds = normalizeSeeds(body.seeds || []);
|
|
86
|
+
const maxPages = clampMaxPages(body.maxPages);
|
|
87
|
+
const allowedOrigins = Array.isArray(body.allowedOrigins) && body.allowedOrigins.length
|
|
88
|
+
? body.allowedOrigins
|
|
89
|
+
: deriveOrigins(seeds);
|
|
90
|
+
|
|
91
|
+
const evidenceLedger = new EvidenceLedger();
|
|
92
|
+
const policyEngine = new PolicyEngine({
|
|
93
|
+
allowedTools: ["crawler"],
|
|
94
|
+
allowedOrigins,
|
|
95
|
+
maxToolCalls: 40
|
|
96
|
+
});
|
|
97
|
+
const toolGateway = new ToolGateway({ policyEngine, evidenceLedger });
|
|
98
|
+
toolGateway.registerTool("crawler", crawlerTool || createCrawlerTool({
|
|
99
|
+
concurrency: 2,
|
|
100
|
+
delayMs: 250,
|
|
101
|
+
timeoutMs: 12_000
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
const runtime = new AgentRuntime({ policyEngine, evidenceLedger, toolGateway });
|
|
105
|
+
const run = await runtime.runWorkflow(
|
|
106
|
+
createResearchWorkflow({ seeds, maxPages, sameOrigin: body.sameOrigin ?? true }),
|
|
107
|
+
{
|
|
108
|
+
objective: body.objective || "Run a governed public research workflow.",
|
|
109
|
+
allowedTools: ["crawler"],
|
|
110
|
+
allowedOrigins,
|
|
111
|
+
budget: { maxToolCalls: 40, maxRuntimeMs: 600_000 }
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
product: PRODUCT,
|
|
117
|
+
run,
|
|
118
|
+
toolTrace: toolGateway.trace,
|
|
119
|
+
generatedAt: new Date().toISOString()
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function serveStatic(request, response, publicDir) {
|
|
124
|
+
const url = new URL(request.url, "http://localhost");
|
|
125
|
+
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
126
|
+
const root = resolve(publicDir);
|
|
127
|
+
const filePath = resolve(root, `.${decodeURIComponent(pathname)}`);
|
|
128
|
+
|
|
129
|
+
if (!filePath.startsWith(root)) {
|
|
130
|
+
sendJson(response, 403, { error: "Forbidden" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const file = await readFile(filePath);
|
|
136
|
+
response.writeHead(200, {
|
|
137
|
+
"content-type": CONTENT_TYPES[extname(filePath)] || "application/octet-stream"
|
|
138
|
+
});
|
|
139
|
+
response.end(file);
|
|
140
|
+
} catch {
|
|
141
|
+
sendJson(response, 404, { error: "Not found" });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createMaqamServer(options = {}) {
|
|
146
|
+
const publicDir = options.publicDir || DEFAULT_PUBLIC_DIR;
|
|
147
|
+
const crawlerTool = options.crawlerTool || null;
|
|
148
|
+
|
|
149
|
+
return createServer(async (request, response) => {
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(request.url, "http://localhost");
|
|
152
|
+
|
|
153
|
+
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
154
|
+
sendJson(response, 200, { product: PRODUCT, status: "ok" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (request.method === "POST" && url.pathname === "/api/runs/research") {
|
|
159
|
+
const body = await readJsonBody(request);
|
|
160
|
+
sendJson(response, 200, await runResearch(body, crawlerTool));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
165
|
+
await serveStatic(request, response, publicDir);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
sendJson(response, 405, { error: "Method not allowed" });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
sendJson(response, error.statusCode || 500, {
|
|
172
|
+
error: error.message || "Unexpected server error"
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function startMaqamServer(options = {}) {
|
|
179
|
+
const port = Number(options.port || process.env.PORT || 8787);
|
|
180
|
+
const host = options.host || process.env.HOST || "127.0.0.1";
|
|
181
|
+
const server = createMaqamServer(options);
|
|
182
|
+
server.listen(port, host, () => {
|
|
183
|
+
const address = `http://${host}:${port}`;
|
|
184
|
+
process.stdout.write(`Maqam console running at ${address}\n`);
|
|
185
|
+
});
|
|
186
|
+
return server;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { PRODUCT as MAQAM_PRODUCT };
|