@tangle-network/agent-knowledge 1.9.0 → 1.10.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/README.md +6 -0
- package/dist/index.d.ts +1380 -1080
- package/dist/index.js +764 -390
- package/dist/index.js.map +1 -1
- package/dist/memory/index.d.ts +1 -1
- package/dist/{types-BEDGlXB-.d.ts → types-C17sAROL.d.ts} +1 -1
- package/dist/viz/index.d.ts +1 -1
- package/docs/results/adaptive.md +120 -0
- package/docs/results/claim-grounding.md +97 -0
- package/docs/results/cost-quality.md +27 -0
- package/docs/supervisor-profiles.md +295 -0
- package/docs/two-agent-research-ab.md +428 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -77,6 +77,495 @@ import {
|
|
|
77
77
|
researcherProfile
|
|
78
78
|
} from "./chunk-MU5CEOGE.js";
|
|
79
79
|
|
|
80
|
+
// src/web-research-worker.ts
|
|
81
|
+
var DEFAULT_MODEL = "glm-5.2";
|
|
82
|
+
var DEFAULT_BASE_URL = "https://router.tangle.tools/v1";
|
|
83
|
+
var MIN_MAX_TOKENS = 1200;
|
|
84
|
+
var RouterError = class extends Error {
|
|
85
|
+
constructor(status, message) {
|
|
86
|
+
super(`router ${status}: ${message}`);
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.name = "RouterError";
|
|
89
|
+
}
|
|
90
|
+
status;
|
|
91
|
+
};
|
|
92
|
+
function createTangleRouterClient(options = {}) {
|
|
93
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
94
|
+
const apiKey = options.apiKey ?? process.env.TANGLE_API_KEY;
|
|
95
|
+
if (!apiKey) {
|
|
96
|
+
throw new RouterError(401, "no TANGLE_API_KEY (pass apiKey or set the env var)");
|
|
97
|
+
}
|
|
98
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
99
|
+
const headers = {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
Authorization: `Bearer ${apiKey}`
|
|
102
|
+
};
|
|
103
|
+
const price = { prompt: 0.95 / 1e6, completion: 3 / 1e6 };
|
|
104
|
+
const acc = {
|
|
105
|
+
chatCalls: 0,
|
|
106
|
+
searchCalls: 0,
|
|
107
|
+
promptTokens: 0,
|
|
108
|
+
completionTokens: 0,
|
|
109
|
+
usd: 0,
|
|
110
|
+
wallMs: 0
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
async search(query, opts) {
|
|
114
|
+
const t0 = Date.now();
|
|
115
|
+
const res = await fetch(`${baseUrl}/search`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers,
|
|
118
|
+
signal: options.signal,
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
query,
|
|
121
|
+
...options.searchProvider ? { provider: options.searchProvider } : {},
|
|
122
|
+
...opts?.maxResults != null ? { maxResults: opts.maxResults } : {}
|
|
123
|
+
})
|
|
124
|
+
});
|
|
125
|
+
acc.searchCalls += 1;
|
|
126
|
+
acc.wallMs += Date.now() - t0;
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new RouterError(res.status, await res.text().catch(() => res.statusText));
|
|
129
|
+
}
|
|
130
|
+
const body = await res.json();
|
|
131
|
+
return (body.data ?? []).filter((hit) => typeof hit?.url === "string" && hit.url.length > 0).map((hit) => ({ title: hit.title ?? hit.url, url: hit.url, snippet: hit.snippet }));
|
|
132
|
+
},
|
|
133
|
+
async chat(messages, maxTokens) {
|
|
134
|
+
const max_tokens = Math.max(MIN_MAX_TOKENS, maxTokens ?? MIN_MAX_TOKENS);
|
|
135
|
+
const t0 = Date.now();
|
|
136
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
signal: options.signal,
|
|
140
|
+
body: JSON.stringify({ model, messages, max_tokens, temperature: 0.2, stream: false })
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new RouterError(res.status, await res.text().catch(() => res.statusText));
|
|
144
|
+
}
|
|
145
|
+
const body = await res.json();
|
|
146
|
+
const promptTokens = body.usage?.prompt_tokens ?? 0;
|
|
147
|
+
const completionTokens = body.usage?.completion_tokens ?? 0;
|
|
148
|
+
acc.chatCalls += 1;
|
|
149
|
+
acc.promptTokens += promptTokens;
|
|
150
|
+
acc.completionTokens += completionTokens;
|
|
151
|
+
acc.usd += promptTokens * price.prompt + completionTokens * price.completion;
|
|
152
|
+
acc.wallMs += Date.now() - t0;
|
|
153
|
+
return body.choices?.[0]?.message?.content ?? "";
|
|
154
|
+
},
|
|
155
|
+
usage() {
|
|
156
|
+
return { ...acc };
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function resolveRouter(opts) {
|
|
161
|
+
return opts.router ?? createTangleRouterClient(opts.router_options);
|
|
162
|
+
}
|
|
163
|
+
function createWebResearchWorker(options = {}) {
|
|
164
|
+
const queriesPerGap = Math.max(1, options.queriesPerGap ?? 2);
|
|
165
|
+
const resultsPerQuery = Math.max(1, options.resultsPerQuery ?? 3);
|
|
166
|
+
const maxSourcesPerRound = Math.max(1, options.maxSourcesPerRound ?? 6);
|
|
167
|
+
const minTextChars = Math.max(1, options.minTextChars ?? 200);
|
|
168
|
+
const maxTextChars = Math.max(minTextChars, options.maxTextChars ?? 4e3);
|
|
169
|
+
return async (ctx) => {
|
|
170
|
+
const router = resolveRouter(options);
|
|
171
|
+
const targetGaps = ctx.gaps.filter((gap) => gap.blocking);
|
|
172
|
+
const gaps = targetGaps.length > 0 ? targetGaps : ctx.gaps;
|
|
173
|
+
if (gaps.length === 0) {
|
|
174
|
+
return { sources: [], notes: "no open gaps to research" };
|
|
175
|
+
}
|
|
176
|
+
const queries = await formSearchQueries(router, ctx, gaps, queriesPerGap);
|
|
177
|
+
const proposals = [];
|
|
178
|
+
const seenUris = /* @__PURE__ */ new Set();
|
|
179
|
+
for (const query of queries) {
|
|
180
|
+
if (proposals.length >= maxSourcesPerRound) break;
|
|
181
|
+
if (ctx.signal?.aborted) break;
|
|
182
|
+
let hits;
|
|
183
|
+
try {
|
|
184
|
+
hits = await router.search(query, { maxResults: resultsPerQuery });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.name === "AbortError") break;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (const hit of hits) {
|
|
190
|
+
if (proposals.length >= maxSourcesPerRound) break;
|
|
191
|
+
if (seenUris.has(hit.url)) continue;
|
|
192
|
+
const fetched = await politeFetch(hit.url, {
|
|
193
|
+
signal: ctx.signal,
|
|
194
|
+
cacheDir: options.cacheDir
|
|
195
|
+
});
|
|
196
|
+
if (!fetched.verifiable) continue;
|
|
197
|
+
const text = htmlToText(fetched.body).slice(0, maxTextChars);
|
|
198
|
+
if (text.length < minTextChars) continue;
|
|
199
|
+
seenUris.add(hit.url);
|
|
200
|
+
proposals.push({
|
|
201
|
+
uri: hit.url,
|
|
202
|
+
title: hit.title || hit.url,
|
|
203
|
+
text,
|
|
204
|
+
// We just fetched + verified this page, so stamp `lastVerifiedAt` with
|
|
205
|
+
// fetch time. Do NOT set `validUntil`: a live page has no inherent
|
|
206
|
+
// future expiry, and the readiness freshness check treats any
|
|
207
|
+
// `validUntil <= now` as EXPIRED (score 0). The page's `Last-Modified`
|
|
208
|
+
// is a PAST date, so writing it here would mark every real source stale
|
|
209
|
+
// and zero out coverage. Record it as provenance metadata instead.
|
|
210
|
+
lastVerifiedAt: fetched.fetchedAt,
|
|
211
|
+
metadata: {
|
|
212
|
+
discoveredVia: query,
|
|
213
|
+
snippet: hit.snippet ?? "",
|
|
214
|
+
goal: ctx.goal,
|
|
215
|
+
sourceUpdatedAt: fetched.sourceUpdatedAt
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
sources: proposals,
|
|
222
|
+
buildPages: buildCitingPages(proposals),
|
|
223
|
+
notes: `web-research worker: ${queries.length} queries \u2192 ${proposals.length} fetched sources`
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function formSearchQueries(router, ctx, gaps, queriesPerGap) {
|
|
228
|
+
const gapLines = gaps.map((gap, i) => `${i + 1}. ${gap.description} (readiness query: "${gap.query}")`).join("\n");
|
|
229
|
+
const want = gaps.length * queriesPerGap;
|
|
230
|
+
const system = "You are a research librarian. Turn knowledge gaps into precise web search queries that will surface authoritative primary sources (papers, docs, standards, official pages). Return ONLY a JSON array of query strings, no prose.";
|
|
231
|
+
const user = [
|
|
232
|
+
`Research goal: ${ctx.goal}`,
|
|
233
|
+
ctx.steer ? `Steer from the coordinator:
|
|
234
|
+
${ctx.steer}` : "",
|
|
235
|
+
`Open knowledge gaps:
|
|
236
|
+
${gapLines}`,
|
|
237
|
+
`Return up to ${want} search query strings as a JSON array (e.g. ["query one","query two"]).`
|
|
238
|
+
].filter(Boolean).join("\n\n");
|
|
239
|
+
let raw = "";
|
|
240
|
+
try {
|
|
241
|
+
raw = await router.chat(
|
|
242
|
+
[
|
|
243
|
+
{ role: "system", content: system },
|
|
244
|
+
{ role: "user", content: user }
|
|
245
|
+
],
|
|
246
|
+
MIN_MAX_TOKENS
|
|
247
|
+
);
|
|
248
|
+
} catch {
|
|
249
|
+
raw = "";
|
|
250
|
+
}
|
|
251
|
+
const parsed = parseQueryList(raw);
|
|
252
|
+
const fromLlm = parsed.slice(0, want);
|
|
253
|
+
if (fromLlm.length > 0) return dedupeStrings(fromLlm);
|
|
254
|
+
return dedupeStrings(gaps.map((gap) => gap.query || gap.description));
|
|
255
|
+
}
|
|
256
|
+
function parseQueryList(raw) {
|
|
257
|
+
const text = raw.trim();
|
|
258
|
+
if (!text) return [];
|
|
259
|
+
const candidates = [];
|
|
260
|
+
const arrayMatch = text.match(/\[[\s\S]*\]/);
|
|
261
|
+
if (arrayMatch) {
|
|
262
|
+
try {
|
|
263
|
+
const arr = JSON.parse(arrayMatch[0]);
|
|
264
|
+
for (const item of arr)
|
|
265
|
+
if (typeof item === "string" && item.trim()) candidates.push(item.trim());
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (candidates.length === 0) {
|
|
270
|
+
for (const line of text.split("\n")) {
|
|
271
|
+
const cleaned = line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/^["']|["']$/g, "").trim();
|
|
272
|
+
if (cleaned && cleaned.length > 2 && !cleaned.startsWith("{")) candidates.push(cleaned);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return candidates;
|
|
276
|
+
}
|
|
277
|
+
function dedupeStrings(values) {
|
|
278
|
+
const seen = /* @__PURE__ */ new Set();
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const value of values) {
|
|
281
|
+
const key = value.toLowerCase().trim();
|
|
282
|
+
if (key && !seen.has(key)) {
|
|
283
|
+
seen.add(key);
|
|
284
|
+
out.push(value.trim());
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
function buildCitingPages(proposals) {
|
|
290
|
+
return (acceptedSources) => {
|
|
291
|
+
if (acceptedSources.length === 0) return void 0;
|
|
292
|
+
const blocks = acceptedSources.map((record) => {
|
|
293
|
+
const proposal = proposals.find((p) => p.uri === record.metadata?.originalUri);
|
|
294
|
+
const uri = proposal?.uri ?? record.id;
|
|
295
|
+
const slug = uri.replace(/^https?:\/\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 120) || record.id;
|
|
296
|
+
const title = proposal?.title ?? record.id;
|
|
297
|
+
const body = proposal?.text ?? "";
|
|
298
|
+
return [
|
|
299
|
+
`---FILE: knowledge/${slug}.md---`,
|
|
300
|
+
"---",
|
|
301
|
+
`title: ${escapeYaml(title)}`,
|
|
302
|
+
`sources: ["${record.id}"]`,
|
|
303
|
+
`source_url: ${uri}`,
|
|
304
|
+
"---",
|
|
305
|
+
`# ${title}`,
|
|
306
|
+
"",
|
|
307
|
+
body,
|
|
308
|
+
"",
|
|
309
|
+
`Source: ${uri}`,
|
|
310
|
+
"---END FILE---"
|
|
311
|
+
].join("\n");
|
|
312
|
+
});
|
|
313
|
+
return blocks.join("\n");
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function escapeYaml(value) {
|
|
317
|
+
return value.replace(/[\r\n]+/g, " ").replace(/"/g, "'").trim();
|
|
318
|
+
}
|
|
319
|
+
function createVerifyingResearchDriver(options = {}) {
|
|
320
|
+
const acceptOnParseFailure = options.acceptOnParseFailure ?? false;
|
|
321
|
+
return {
|
|
322
|
+
async verifySource(source, ctx) {
|
|
323
|
+
const router = resolveRouter(options);
|
|
324
|
+
const gapLines = ctx.gaps.map((gap) => `- ${gap.description} (query: "${gap.query}")`).join("\n");
|
|
325
|
+
const acceptedTitles = ctx.acceptedThisRound.map((accepted) => `- ${accepted.title ?? accepted.uri}`).join("\n");
|
|
326
|
+
const excerpt = source.text.slice(0, 1500);
|
|
327
|
+
const system = 'You verify whether a fetched web source belongs in a curated knowledge base. Accept a source ONLY if it is genuinely on-topic for the research goal and helps close one of the open gaps, AND it is not a near-duplicate of an already-accepted source. Reject spam, listicles, off-topic pages, marketing, and near-duplicates. Respond with ONLY a JSON object: {"accept": true|false, "reason": "<short reason>"}.';
|
|
328
|
+
const user = [
|
|
329
|
+
`Research goal: ${ctx.goal}`,
|
|
330
|
+
`Open gaps:
|
|
331
|
+
${gapLines || "(none specified)"}`,
|
|
332
|
+
acceptedTitles ? `Already accepted this round:
|
|
333
|
+
${acceptedTitles}` : "Nothing accepted yet this round.",
|
|
334
|
+
`Candidate source:
|
|
335
|
+
URL: ${source.uri}
|
|
336
|
+
Title: ${source.title ?? "(none)"}
|
|
337
|
+
Excerpt:
|
|
338
|
+
${excerpt}`,
|
|
339
|
+
'Verdict as JSON {"accept": boolean, "reason": string}:'
|
|
340
|
+
].join("\n\n");
|
|
341
|
+
let raw = "";
|
|
342
|
+
try {
|
|
343
|
+
raw = await router.chat(
|
|
344
|
+
[
|
|
345
|
+
{ role: "system", content: system },
|
|
346
|
+
{ role: "user", content: user }
|
|
347
|
+
],
|
|
348
|
+
MIN_MAX_TOKENS
|
|
349
|
+
);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error.name === "AbortError") throw error;
|
|
352
|
+
return acceptOnParseFailure ? { accept: true } : { accept: false, reason: `verifier unavailable: ${error.message}` };
|
|
353
|
+
}
|
|
354
|
+
const verdict = parseVerdict(raw);
|
|
355
|
+
if (verdict) return verdict;
|
|
356
|
+
return acceptOnParseFailure ? { accept: true } : { accept: false, reason: "verifier returned an unparseable verdict" };
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function parseVerdict(raw) {
|
|
361
|
+
const text = raw.trim();
|
|
362
|
+
if (!text) return null;
|
|
363
|
+
const objMatch = text.match(/\{[\s\S]*\}/);
|
|
364
|
+
if (!objMatch) return null;
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(objMatch[0]);
|
|
367
|
+
if (typeof parsed.accept !== "boolean") return null;
|
|
368
|
+
if (parsed.accept) return { accept: true };
|
|
369
|
+
return {
|
|
370
|
+
accept: false,
|
|
371
|
+
reason: typeof parsed.reason === "string" && parsed.reason.trim() ? parsed.reason.trim() : "rejected by verifier"
|
|
372
|
+
};
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/adaptive-driver.ts
|
|
379
|
+
function canonicalizeUrl(uri) {
|
|
380
|
+
const trimmed = uri.trim();
|
|
381
|
+
try {
|
|
382
|
+
const url = new URL(trimmed);
|
|
383
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
384
|
+
const kept = [];
|
|
385
|
+
for (const [key, value] of url.searchParams) {
|
|
386
|
+
const lower = key.toLowerCase();
|
|
387
|
+
if (lower.startsWith("utm_")) continue;
|
|
388
|
+
if (trackingParams.has(lower)) continue;
|
|
389
|
+
kept.push([key, value]);
|
|
390
|
+
}
|
|
391
|
+
kept.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
392
|
+
const query = kept.map(([k, v]) => `${k}=${v}`).join("&");
|
|
393
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
394
|
+
return `${host}${path}${query ? `?${query}` : ""}`;
|
|
395
|
+
} catch {
|
|
396
|
+
return trimmed.toLowerCase();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
var trackingParams = /* @__PURE__ */ new Set([
|
|
400
|
+
"ref",
|
|
401
|
+
"ref_src",
|
|
402
|
+
"source",
|
|
403
|
+
"fbclid",
|
|
404
|
+
"gclid",
|
|
405
|
+
"mc_cid",
|
|
406
|
+
"mc_eid",
|
|
407
|
+
"igshid",
|
|
408
|
+
"spm",
|
|
409
|
+
"_hsenc",
|
|
410
|
+
"_hsmi"
|
|
411
|
+
]);
|
|
412
|
+
function contentKey(text) {
|
|
413
|
+
const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
414
|
+
return sha256(normalized);
|
|
415
|
+
}
|
|
416
|
+
var defaultAuthoritativeHosts = [
|
|
417
|
+
"arxiv.org",
|
|
418
|
+
"aclanthology.org",
|
|
419
|
+
"openreview.net",
|
|
420
|
+
"dl.acm.org",
|
|
421
|
+
"ieeexplore.ieee.org",
|
|
422
|
+
"nature.com",
|
|
423
|
+
"science.org",
|
|
424
|
+
"pubmed.ncbi.nlm.nih.gov",
|
|
425
|
+
"ncbi.nlm.nih.gov",
|
|
426
|
+
".edu",
|
|
427
|
+
".gov",
|
|
428
|
+
"docs.python.org",
|
|
429
|
+
"pytorch.org",
|
|
430
|
+
"tensorflow.org",
|
|
431
|
+
"huggingface.co",
|
|
432
|
+
"github.com",
|
|
433
|
+
"developer.mozilla.org",
|
|
434
|
+
"wikipedia.org",
|
|
435
|
+
"w3.org",
|
|
436
|
+
"ietf.org",
|
|
437
|
+
"rfc-editor.org"
|
|
438
|
+
];
|
|
439
|
+
var defaultSpamPatterns = [
|
|
440
|
+
/\bbuy\b.*\b(cheap|now|deal|sale|discount)\b/i,
|
|
441
|
+
/\b\d+\s+(things|ways|reasons|tips|tricks|secrets|hacks)\b.*\b(you|that|will)\b/i,
|
|
442
|
+
/\bshock(ing|ed)?\b/i,
|
|
443
|
+
/\bclickbait\b/i,
|
|
444
|
+
/!!!|\$\$\$/,
|
|
445
|
+
/\b(coupon|promo code|affiliate|sponsored)\b/i,
|
|
446
|
+
/\bbest .* (of \d{4}|in \d{4})\b/i
|
|
447
|
+
];
|
|
448
|
+
function triageSource(source, options) {
|
|
449
|
+
const titleAndSnippet = `${source.title ?? ""} ${typeof source.metadata?.snippet === "string" ? source.metadata.snippet : ""}`.trim();
|
|
450
|
+
const bodyLen = source.text.trim().length;
|
|
451
|
+
for (const pattern of options.spamPatterns) {
|
|
452
|
+
if (pattern.test(titleAndSnippet)) {
|
|
453
|
+
return { triage: "drop", reason: `spam/listicle title (${pattern.source})` };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (bodyLen < options.minBodyChars) {
|
|
457
|
+
return { triage: "drop", reason: `thin body (${bodyLen} < ${options.minBodyChars} chars)` };
|
|
458
|
+
}
|
|
459
|
+
const host = hostOf(source.uri);
|
|
460
|
+
const authoritative = host.length > 0 && options.authoritativeHosts.some((suffix) => hostMatches(host, suffix));
|
|
461
|
+
if (authoritative && bodyLen >= options.substantialBodyChars) {
|
|
462
|
+
return { triage: "keep", reason: `authoritative host ${host} + substantial body (${bodyLen})` };
|
|
463
|
+
}
|
|
464
|
+
return { triage: "ambiguous", reason: `unknown host ${host || "(none)"}, body ${bodyLen} chars` };
|
|
465
|
+
}
|
|
466
|
+
function hostOf(uri) {
|
|
467
|
+
try {
|
|
468
|
+
return new URL(uri.trim()).hostname.toLowerCase().replace(/^www\./, "");
|
|
469
|
+
} catch {
|
|
470
|
+
return "";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function hostMatches(host, suffix) {
|
|
474
|
+
if (suffix.startsWith(".")) return host.endsWith(suffix) || host === suffix.slice(1);
|
|
475
|
+
return host === suffix || host.endsWith(`.${suffix}`);
|
|
476
|
+
}
|
|
477
|
+
function createAdaptiveResearchDriver(options = {}) {
|
|
478
|
+
const authoritativeHosts = options.authoritativeHosts ?? defaultAuthoritativeHosts;
|
|
479
|
+
const spamPatterns = options.spamPatterns ?? defaultSpamPatterns;
|
|
480
|
+
const minBodyChars = Math.max(1, options.minBodyChars ?? 400);
|
|
481
|
+
const substantialBodyChars = Math.max(minBodyChars, options.substantialBodyChars ?? 600);
|
|
482
|
+
const relevance = createVerifyingResearchDriver({
|
|
483
|
+
router: options.router,
|
|
484
|
+
router_options: options.router_options,
|
|
485
|
+
acceptOnParseFailure: options.verifying?.acceptOnParseFailure
|
|
486
|
+
});
|
|
487
|
+
const acceptedUrlKeys = /* @__PURE__ */ new Set();
|
|
488
|
+
const acceptedContentKeys = /* @__PURE__ */ new Set();
|
|
489
|
+
const stats = {
|
|
490
|
+
total: 0,
|
|
491
|
+
dedupRejected: 0,
|
|
492
|
+
heuristicKept: 0,
|
|
493
|
+
heuristicDropped: 0,
|
|
494
|
+
llmCalls: 0,
|
|
495
|
+
llmAccepted: 0,
|
|
496
|
+
decisions: []
|
|
497
|
+
};
|
|
498
|
+
function record(decision) {
|
|
499
|
+
stats.decisions.push(decision);
|
|
500
|
+
options.onDecision?.(decision);
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
async verifySource(source, ctx) {
|
|
504
|
+
stats.total += 1;
|
|
505
|
+
const urlKey = canonicalizeUrl(source.uri);
|
|
506
|
+
const cKey = contentKey(source.text);
|
|
507
|
+
const roundUrlKeys = new Set(
|
|
508
|
+
ctx.acceptedThisRound.map((accepted) => canonicalizeUrl(accepted.uri))
|
|
509
|
+
);
|
|
510
|
+
const roundContentKeys = new Set(
|
|
511
|
+
ctx.acceptedThisRound.map((accepted) => contentKey(accepted.text))
|
|
512
|
+
);
|
|
513
|
+
const indexUrlKeys = new Set(
|
|
514
|
+
ctx.index.sources.flatMap(
|
|
515
|
+
(indexed) => typeof indexed.metadata?.originalUri === "string" ? [canonicalizeUrl(indexed.metadata.originalUri)] : []
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
const dupUrl = acceptedUrlKeys.has(urlKey) || roundUrlKeys.has(urlKey) || indexUrlKeys.has(urlKey);
|
|
519
|
+
const dupContent = acceptedContentKeys.has(cKey) || roundContentKeys.has(cKey);
|
|
520
|
+
if (dupUrl || dupContent) {
|
|
521
|
+
stats.dedupRejected += 1;
|
|
522
|
+
const reason2 = dupUrl ? "duplicate-url" : "duplicate-content";
|
|
523
|
+
record({ uri: source.uri, stage: "dedup", accepted: false, reason: reason2 });
|
|
524
|
+
return { accept: false, reason: `dedup: ${reason2}` };
|
|
525
|
+
}
|
|
526
|
+
const { triage, reason } = triageSource(source, {
|
|
527
|
+
authoritativeHosts,
|
|
528
|
+
spamPatterns,
|
|
529
|
+
minBodyChars,
|
|
530
|
+
substantialBodyChars
|
|
531
|
+
});
|
|
532
|
+
if (triage === "keep") {
|
|
533
|
+
stats.heuristicKept += 1;
|
|
534
|
+
acceptedUrlKeys.add(urlKey);
|
|
535
|
+
acceptedContentKeys.add(cKey);
|
|
536
|
+
record({ uri: source.uri, stage: "heuristic", accepted: true, triage, reason });
|
|
537
|
+
return { accept: true };
|
|
538
|
+
}
|
|
539
|
+
if (triage === "drop") {
|
|
540
|
+
stats.heuristicDropped += 1;
|
|
541
|
+
record({ uri: source.uri, stage: "heuristic", accepted: false, triage, reason });
|
|
542
|
+
return { accept: false, reason: `heuristic drop: ${reason}` };
|
|
543
|
+
}
|
|
544
|
+
stats.llmCalls += 1;
|
|
545
|
+
const verdict = await relevance.verifySource(source, ctx);
|
|
546
|
+
if (verdict.accept) {
|
|
547
|
+
stats.llmAccepted += 1;
|
|
548
|
+
acceptedUrlKeys.add(urlKey);
|
|
549
|
+
acceptedContentKeys.add(cKey);
|
|
550
|
+
}
|
|
551
|
+
record({
|
|
552
|
+
uri: source.uri,
|
|
553
|
+
stage: "llm",
|
|
554
|
+
accepted: verdict.accept,
|
|
555
|
+
triage,
|
|
556
|
+
reason: verdict.accept ? "llm accepted" : verdict.reason
|
|
557
|
+
});
|
|
558
|
+
return verdict;
|
|
559
|
+
},
|
|
560
|
+
stats() {
|
|
561
|
+
return {
|
|
562
|
+
...stats,
|
|
563
|
+
decisions: [...stats.decisions]
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
80
569
|
// src/changes.ts
|
|
81
570
|
function detectChanges(prev, next, options = {}) {
|
|
82
571
|
const skipUnverifiable = options.skipUnverifiable ?? true;
|
|
@@ -286,8 +775,158 @@ ${chunk.text}`;
|
|
|
286
775
|
} else {
|
|
287
776
|
out.push({ ...chunk });
|
|
288
777
|
}
|
|
289
|
-
}
|
|
290
|
-
return out;
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/claim-grounding.ts
|
|
783
|
+
var citedClaimKey = "citedClaim";
|
|
784
|
+
function citedClaimOf(source) {
|
|
785
|
+
const claim = source.metadata?.[citedClaimKey];
|
|
786
|
+
return typeof claim === "string" && claim.trim() ? claim.trim() : void 0;
|
|
787
|
+
}
|
|
788
|
+
function withCitedClaim(source, claim) {
|
|
789
|
+
return { ...source, metadata: { ...source.metadata, [citedClaimKey]: claim } };
|
|
790
|
+
}
|
|
791
|
+
var stopwords = /* @__PURE__ */ new Set([
|
|
792
|
+
"the",
|
|
793
|
+
"a",
|
|
794
|
+
"an",
|
|
795
|
+
"and",
|
|
796
|
+
"or",
|
|
797
|
+
"but",
|
|
798
|
+
"of",
|
|
799
|
+
"to",
|
|
800
|
+
"in",
|
|
801
|
+
"on",
|
|
802
|
+
"for",
|
|
803
|
+
"with",
|
|
804
|
+
"as",
|
|
805
|
+
"by",
|
|
806
|
+
"at",
|
|
807
|
+
"from",
|
|
808
|
+
"that",
|
|
809
|
+
"this",
|
|
810
|
+
"these",
|
|
811
|
+
"those",
|
|
812
|
+
"it",
|
|
813
|
+
"its",
|
|
814
|
+
"is",
|
|
815
|
+
"are",
|
|
816
|
+
"was",
|
|
817
|
+
"were",
|
|
818
|
+
"be",
|
|
819
|
+
"been",
|
|
820
|
+
"being",
|
|
821
|
+
"has",
|
|
822
|
+
"have",
|
|
823
|
+
"had",
|
|
824
|
+
"can",
|
|
825
|
+
"will",
|
|
826
|
+
"would",
|
|
827
|
+
"should",
|
|
828
|
+
"may",
|
|
829
|
+
"might",
|
|
830
|
+
"not",
|
|
831
|
+
"no",
|
|
832
|
+
"than",
|
|
833
|
+
"then",
|
|
834
|
+
"over",
|
|
835
|
+
"under",
|
|
836
|
+
"about",
|
|
837
|
+
"into",
|
|
838
|
+
"their",
|
|
839
|
+
"they",
|
|
840
|
+
"them"
|
|
841
|
+
]);
|
|
842
|
+
function normalize(text) {
|
|
843
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
844
|
+
}
|
|
845
|
+
function contentWords(claim, minWordLength) {
|
|
846
|
+
const words = normalize(claim).split(" ").filter((word) => word.length >= minWordLength && !stopwords.has(word));
|
|
847
|
+
return [...new Set(words)];
|
|
848
|
+
}
|
|
849
|
+
function groundClaimInText(claim, pageText, options = {}) {
|
|
850
|
+
const minOverlap = options.minOverlap ?? 0.7;
|
|
851
|
+
const minWordLength = Math.max(1, options.minWordLength ?? 3);
|
|
852
|
+
const claimTrimmed = claim.trim();
|
|
853
|
+
if (!claimTrimmed) return { grounded: false, mode: "empty-claim", overlap: 0, missingWords: [] };
|
|
854
|
+
if (!pageText.trim()) return { grounded: false, mode: "empty-text", overlap: 0, missingWords: [] };
|
|
855
|
+
const haystackLower = pageText.toLowerCase();
|
|
856
|
+
if (haystackLower.includes(claimTrimmed.toLowerCase())) {
|
|
857
|
+
return { grounded: true, mode: "verbatim", overlap: 1, missingWords: [] };
|
|
858
|
+
}
|
|
859
|
+
const haystackNorm = normalize(pageText);
|
|
860
|
+
const claimNorm = normalize(claimTrimmed);
|
|
861
|
+
if (claimNorm && haystackNorm.includes(claimNorm)) {
|
|
862
|
+
return { grounded: true, mode: "normalized", overlap: 1, missingWords: [] };
|
|
863
|
+
}
|
|
864
|
+
const words = contentWords(claimTrimmed, minWordLength);
|
|
865
|
+
if (words.length === 0) {
|
|
866
|
+
return { grounded: false, mode: "absent", overlap: 0, missingWords: [] };
|
|
867
|
+
}
|
|
868
|
+
const present = words.filter(
|
|
869
|
+
(word) => new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(haystackNorm)
|
|
870
|
+
);
|
|
871
|
+
const missingWords = words.filter((word) => !present.includes(word));
|
|
872
|
+
const overlap = present.length / words.length;
|
|
873
|
+
const grounded = overlap >= minOverlap;
|
|
874
|
+
return { grounded, mode: grounded ? "overlap" : "absent", overlap, missingWords };
|
|
875
|
+
}
|
|
876
|
+
function createClaimGroundingVerifier(options = {}) {
|
|
877
|
+
const onMissingClaim = options.onMissingClaim ?? "reject";
|
|
878
|
+
return async function verifySource(source, ctx) {
|
|
879
|
+
const claim = citedClaimOf(source);
|
|
880
|
+
if (!claim) {
|
|
881
|
+
if (onMissingClaim === "reject") {
|
|
882
|
+
return {
|
|
883
|
+
accept: false,
|
|
884
|
+
reason: "no cited claim: claim-grounding mode requires every source to declare its claim"
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return options.relevanceVerifier ? options.relevanceVerifier(source, ctx) : { accept: true };
|
|
888
|
+
}
|
|
889
|
+
const grounding = groundClaimInText(claim, source.text, options);
|
|
890
|
+
if (!grounding.grounded) {
|
|
891
|
+
const detail = grounding.mode === "empty-text" ? "fetched page has no text" : `claim not found in the fetched page (overlap ${(grounding.overlap * 100).toFixed(0)}%${grounding.missingWords.length ? `, missing: ${grounding.missingWords.slice(0, 6).join(", ")}` : ""})`;
|
|
892
|
+
return {
|
|
893
|
+
accept: false,
|
|
894
|
+
reason: `misattributed citation: ${detail}`
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
if (options.relevanceVerifier) return options.relevanceVerifier(source, ctx);
|
|
898
|
+
return { accept: true };
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function createClaimDecorator(options = {}) {
|
|
902
|
+
const maxTokens = options.maxTokens ?? 1200;
|
|
903
|
+
return async function decorate(source, goal) {
|
|
904
|
+
const router = options.router ?? createTangleRouterClient(options.router_options);
|
|
905
|
+
const excerpt = source.text.slice(0, 1500);
|
|
906
|
+
const system = "You extract the single most important factual claim a researcher would cite a page for. State ONE concrete, checkable sentence using the page's own key terms and numbers. Do NOT invent facts not in the excerpt. Respond with ONLY the claim sentence, no prose.";
|
|
907
|
+
const user = [
|
|
908
|
+
`Research goal: ${goal}`,
|
|
909
|
+
`Page title: ${source.title ?? "(none)"}`,
|
|
910
|
+
`Page excerpt:
|
|
911
|
+
${excerpt}`,
|
|
912
|
+
"The single claim this page should be cited for:"
|
|
913
|
+
].join("\n\n");
|
|
914
|
+
let raw = "";
|
|
915
|
+
try {
|
|
916
|
+
raw = await router.chat(
|
|
917
|
+
[
|
|
918
|
+
{ role: "system", content: system },
|
|
919
|
+
{ role: "user", content: user }
|
|
920
|
+
],
|
|
921
|
+
maxTokens
|
|
922
|
+
);
|
|
923
|
+
} catch {
|
|
924
|
+
return source;
|
|
925
|
+
}
|
|
926
|
+
const claim = raw.trim().split("\n")[0]?.trim();
|
|
927
|
+
if (!claim) return source;
|
|
928
|
+
return withCitedClaim(source, claim);
|
|
929
|
+
};
|
|
291
930
|
}
|
|
292
931
|
|
|
293
932
|
// src/discovery.ts
|
|
@@ -1145,408 +1784,133 @@ async function runTwoAgentResearchLoop(options) {
|
|
|
1145
1784
|
signal: options.signal
|
|
1146
1785
|
});
|
|
1147
1786
|
driverNotes = driverContribution.notes;
|
|
1148
|
-
driverSources = await registerSources(options, driverContribution.sources ?? []);
|
|
1149
|
-
writtenPages.push(...await applyPages(options.root, driverContribution, driverSources));
|
|
1150
|
-
index = await buildKnowledgeIndex(options.root);
|
|
1151
|
-
readiness = readinessFor(options, index);
|
|
1152
|
-
}
|
|
1153
|
-
ready = isReady(readiness?.report);
|
|
1154
|
-
const remainingGaps = gapsFromReadiness(readiness);
|
|
1155
|
-
steer = ready ? void 0 : foldGaps(options.driver, remainingGaps);
|
|
1156
|
-
const step = {
|
|
1157
|
-
round: round2,
|
|
1158
|
-
gaps,
|
|
1159
|
-
acceptedWorkerSources,
|
|
1160
|
-
rejectedWorkerSources,
|
|
1161
|
-
driverSources,
|
|
1162
|
-
writtenPages,
|
|
1163
|
-
readiness,
|
|
1164
|
-
ready,
|
|
1165
|
-
event: createKnowledgeEvent({
|
|
1166
|
-
type: "research.iteration",
|
|
1167
|
-
actor: options.actor,
|
|
1168
|
-
target: options.root,
|
|
1169
|
-
metadata: {
|
|
1170
|
-
goal: options.goal,
|
|
1171
|
-
round: round2,
|
|
1172
|
-
ready,
|
|
1173
|
-
acceptedWorkerSourceCount: acceptedWorkerSources.length,
|
|
1174
|
-
rejectedWorkerSourceCount: rejectedWorkerSources.length,
|
|
1175
|
-
driverSourceCount: driverSources.length,
|
|
1176
|
-
writtenPageCount: writtenPages.length,
|
|
1177
|
-
remainingGapCount: remainingGaps.length
|
|
1178
|
-
}
|
|
1179
|
-
}),
|
|
1180
|
-
notes: { worker: workerContribution.notes, driver: driverNotes }
|
|
1181
|
-
};
|
|
1182
|
-
steps.push(step);
|
|
1183
|
-
await options.onRound?.(step);
|
|
1184
|
-
}
|
|
1185
|
-
return {
|
|
1186
|
-
root: options.root,
|
|
1187
|
-
goal: options.goal,
|
|
1188
|
-
rounds: steps.length,
|
|
1189
|
-
ready,
|
|
1190
|
-
index,
|
|
1191
|
-
readiness,
|
|
1192
|
-
steps
|
|
1193
|
-
};
|
|
1194
|
-
}
|
|
1195
|
-
function isReady(report) {
|
|
1196
|
-
if (!report) return false;
|
|
1197
|
-
return report.blockingMissingRequirements.length === 0;
|
|
1198
|
-
}
|
|
1199
|
-
function gapsFromReadiness(readiness) {
|
|
1200
|
-
if (!readiness) return [];
|
|
1201
|
-
const blocking = readiness.report.blockingMissingRequirements.map(
|
|
1202
|
-
(requirement) => gapFor(requirement, readiness, true)
|
|
1203
|
-
);
|
|
1204
|
-
const nonBlocking = readiness.report.nonBlockingGaps.map(
|
|
1205
|
-
(requirement) => gapFor(requirement, readiness, false)
|
|
1206
|
-
);
|
|
1207
|
-
return [...blocking, ...nonBlocking];
|
|
1208
|
-
}
|
|
1209
|
-
function gapFor(requirement, readiness, blocking) {
|
|
1210
|
-
const spec = readiness.requirements.find((entry) => entry.id === requirement.id);
|
|
1211
|
-
const query = typeof spec?.metadata?.query === "string" ? spec.metadata.query : requirement.description;
|
|
1212
|
-
return { id: requirement.id, description: requirement.description, query, blocking };
|
|
1213
|
-
}
|
|
1214
|
-
function foldGaps(driver, gaps) {
|
|
1215
|
-
if (gaps.length === 0) return void 0;
|
|
1216
|
-
if (driver.foldGaps) return driver.foldGaps(gaps);
|
|
1217
|
-
return [
|
|
1218
|
-
"The knowledge base is still missing the following. Prioritise these next round:",
|
|
1219
|
-
...gaps.map(
|
|
1220
|
-
(gap) => `- (${gap.blocking ? "blocking" : "soft"}) ${gap.description} [${gap.id}]`
|
|
1221
|
-
)
|
|
1222
|
-
].join("\n");
|
|
1223
|
-
}
|
|
1224
|
-
function isDuplicate(source, existingUris, accepted) {
|
|
1225
|
-
return existingUris.has(source.uri) || accepted.some((candidate) => candidate.uri === source.uri);
|
|
1226
|
-
}
|
|
1227
|
-
async function registerSources(options, sources) {
|
|
1228
|
-
const records = [];
|
|
1229
|
-
for (const source of sources) {
|
|
1230
|
-
records.push(await addSourceText(options.root, source, options.sourceOptions));
|
|
1231
|
-
}
|
|
1232
|
-
return records;
|
|
1233
|
-
}
|
|
1234
|
-
async function applyPages(root, contribution, acceptedSources) {
|
|
1235
|
-
if (acceptedSources.length === 0) return [];
|
|
1236
|
-
const parts = [];
|
|
1237
|
-
if (contribution.proposalText) parts.push(contribution.proposalText);
|
|
1238
|
-
const built = contribution.buildPages?.(acceptedSources);
|
|
1239
|
-
if (built) parts.push(built);
|
|
1240
|
-
if (parts.length === 0) return [];
|
|
1241
|
-
const applied = await applyKnowledgeWriteBlocks(root, parts.join("\n"));
|
|
1242
|
-
return applied.written;
|
|
1243
|
-
}
|
|
1244
|
-
function requireReadiness(readiness, options) {
|
|
1245
|
-
if (readiness) return readiness;
|
|
1246
|
-
return buildEvalKnowledgeBundle({
|
|
1247
|
-
...options.readiness ?? {},
|
|
1248
|
-
taskId: options.readinessTaskId ?? options.goal,
|
|
1249
|
-
index: emptyIndex(options.root),
|
|
1250
|
-
specs: []
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1253
|
-
function emptyIndex(root) {
|
|
1254
|
-
return {
|
|
1255
|
-
root,
|
|
1256
|
-
generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1257
|
-
sources: [],
|
|
1258
|
-
pages: [],
|
|
1259
|
-
graph: { nodes: [], edges: [] }
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
function sourceMatchesGaps(source, index, gaps) {
|
|
1263
|
-
const haystack = `${source.title ?? ""}
|
|
1264
|
-
${source.text}`.toLowerCase();
|
|
1265
|
-
const hits = [];
|
|
1266
|
-
for (const gap of gaps) {
|
|
1267
|
-
for (const token of gap.query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1268
|
-
if (haystack.includes(token)) {
|
|
1269
|
-
hits.push(...searchKnowledge(index, gap.query, 1));
|
|
1270
|
-
break;
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
return hits;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
// src/web-research-worker.ts
|
|
1278
|
-
var DEFAULT_MODEL = "glm-5.2";
|
|
1279
|
-
var DEFAULT_BASE_URL = "https://router.tangle.tools/v1";
|
|
1280
|
-
var MIN_MAX_TOKENS = 1200;
|
|
1281
|
-
var RouterError = class extends Error {
|
|
1282
|
-
constructor(status, message) {
|
|
1283
|
-
super(`router ${status}: ${message}`);
|
|
1284
|
-
this.status = status;
|
|
1285
|
-
this.name = "RouterError";
|
|
1286
|
-
}
|
|
1287
|
-
status;
|
|
1288
|
-
};
|
|
1289
|
-
function createTangleRouterClient(options = {}) {
|
|
1290
|
-
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
1291
|
-
const apiKey = options.apiKey ?? process.env.TANGLE_API_KEY;
|
|
1292
|
-
if (!apiKey) {
|
|
1293
|
-
throw new RouterError(401, "no TANGLE_API_KEY (pass apiKey or set the env var)");
|
|
1294
|
-
}
|
|
1295
|
-
const model = options.model ?? DEFAULT_MODEL;
|
|
1296
|
-
const headers = {
|
|
1297
|
-
"Content-Type": "application/json",
|
|
1298
|
-
Authorization: `Bearer ${apiKey}`
|
|
1299
|
-
};
|
|
1300
|
-
return {
|
|
1301
|
-
async search(query, opts) {
|
|
1302
|
-
const res = await fetch(`${baseUrl}/search`, {
|
|
1303
|
-
method: "POST",
|
|
1304
|
-
headers,
|
|
1305
|
-
signal: options.signal,
|
|
1306
|
-
body: JSON.stringify({
|
|
1307
|
-
query,
|
|
1308
|
-
...options.searchProvider ? { provider: options.searchProvider } : {},
|
|
1309
|
-
...opts?.maxResults != null ? { maxResults: opts.maxResults } : {}
|
|
1310
|
-
})
|
|
1311
|
-
});
|
|
1312
|
-
if (!res.ok) {
|
|
1313
|
-
throw new RouterError(res.status, await res.text().catch(() => res.statusText));
|
|
1314
|
-
}
|
|
1315
|
-
const body = await res.json();
|
|
1316
|
-
return (body.data ?? []).filter((hit) => typeof hit?.url === "string" && hit.url.length > 0).map((hit) => ({ title: hit.title ?? hit.url, url: hit.url, snippet: hit.snippet }));
|
|
1317
|
-
},
|
|
1318
|
-
async chat(messages, maxTokens) {
|
|
1319
|
-
const max_tokens = Math.max(MIN_MAX_TOKENS, maxTokens ?? MIN_MAX_TOKENS);
|
|
1320
|
-
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
1321
|
-
method: "POST",
|
|
1322
|
-
headers,
|
|
1323
|
-
signal: options.signal,
|
|
1324
|
-
body: JSON.stringify({ model, messages, max_tokens, temperature: 0.2, stream: false })
|
|
1325
|
-
});
|
|
1326
|
-
if (!res.ok) {
|
|
1327
|
-
throw new RouterError(res.status, await res.text().catch(() => res.statusText));
|
|
1328
|
-
}
|
|
1329
|
-
const body = await res.json();
|
|
1330
|
-
return body.choices?.[0]?.message?.content ?? "";
|
|
1331
|
-
}
|
|
1332
|
-
};
|
|
1333
|
-
}
|
|
1334
|
-
function resolveRouter(opts) {
|
|
1335
|
-
return opts.router ?? createTangleRouterClient(opts.router_options);
|
|
1336
|
-
}
|
|
1337
|
-
function createWebResearchWorker(options = {}) {
|
|
1338
|
-
const queriesPerGap = Math.max(1, options.queriesPerGap ?? 2);
|
|
1339
|
-
const resultsPerQuery = Math.max(1, options.resultsPerQuery ?? 3);
|
|
1340
|
-
const maxSourcesPerRound = Math.max(1, options.maxSourcesPerRound ?? 6);
|
|
1341
|
-
const minTextChars = Math.max(1, options.minTextChars ?? 200);
|
|
1342
|
-
const maxTextChars = Math.max(minTextChars, options.maxTextChars ?? 4e3);
|
|
1343
|
-
return async (ctx) => {
|
|
1344
|
-
const router = resolveRouter(options);
|
|
1345
|
-
const targetGaps = ctx.gaps.filter((gap) => gap.blocking);
|
|
1346
|
-
const gaps = targetGaps.length > 0 ? targetGaps : ctx.gaps;
|
|
1347
|
-
if (gaps.length === 0) {
|
|
1348
|
-
return { sources: [], notes: "no open gaps to research" };
|
|
1349
|
-
}
|
|
1350
|
-
const queries = await formSearchQueries(router, ctx, gaps, queriesPerGap);
|
|
1351
|
-
const proposals = [];
|
|
1352
|
-
const seenUris = /* @__PURE__ */ new Set();
|
|
1353
|
-
for (const query of queries) {
|
|
1354
|
-
if (proposals.length >= maxSourcesPerRound) break;
|
|
1355
|
-
if (ctx.signal?.aborted) break;
|
|
1356
|
-
let hits;
|
|
1357
|
-
try {
|
|
1358
|
-
hits = await router.search(query, { maxResults: resultsPerQuery });
|
|
1359
|
-
} catch (error) {
|
|
1360
|
-
if (error.name === "AbortError") break;
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
for (const hit of hits) {
|
|
1364
|
-
if (proposals.length >= maxSourcesPerRound) break;
|
|
1365
|
-
if (seenUris.has(hit.url)) continue;
|
|
1366
|
-
const fetched = await politeFetch(hit.url, {
|
|
1367
|
-
signal: ctx.signal,
|
|
1368
|
-
cacheDir: options.cacheDir
|
|
1369
|
-
});
|
|
1370
|
-
if (!fetched.verifiable) continue;
|
|
1371
|
-
const text = htmlToText(fetched.body).slice(0, maxTextChars);
|
|
1372
|
-
if (text.length < minTextChars) continue;
|
|
1373
|
-
seenUris.add(hit.url);
|
|
1374
|
-
proposals.push({
|
|
1375
|
-
uri: hit.url,
|
|
1376
|
-
title: hit.title || hit.url,
|
|
1377
|
-
text,
|
|
1378
|
-
// We just fetched + verified this page, so stamp `lastVerifiedAt` with
|
|
1379
|
-
// fetch time. Do NOT set `validUntil`: a live page has no inherent
|
|
1380
|
-
// future expiry, and the readiness freshness check treats any
|
|
1381
|
-
// `validUntil <= now` as EXPIRED (score 0). The page's `Last-Modified`
|
|
1382
|
-
// is a PAST date, so writing it here would mark every real source stale
|
|
1383
|
-
// and zero out coverage. Record it as provenance metadata instead.
|
|
1384
|
-
lastVerifiedAt: fetched.fetchedAt,
|
|
1385
|
-
metadata: {
|
|
1386
|
-
discoveredVia: query,
|
|
1387
|
-
snippet: hit.snippet ?? "",
|
|
1388
|
-
goal: ctx.goal,
|
|
1389
|
-
sourceUpdatedAt: fetched.sourceUpdatedAt
|
|
1390
|
-
}
|
|
1391
|
-
});
|
|
1392
|
-
}
|
|
1787
|
+
driverSources = await registerSources(options, driverContribution.sources ?? []);
|
|
1788
|
+
writtenPages.push(...await applyPages(options.root, driverContribution, driverSources));
|
|
1789
|
+
index = await buildKnowledgeIndex(options.root);
|
|
1790
|
+
readiness = readinessFor(options, index);
|
|
1393
1791
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1792
|
+
ready = isReady(readiness?.report);
|
|
1793
|
+
const remainingGaps = gapsFromReadiness(readiness);
|
|
1794
|
+
steer = ready ? void 0 : foldGaps(options.driver, remainingGaps);
|
|
1795
|
+
const step = {
|
|
1796
|
+
round: round2,
|
|
1797
|
+
gaps,
|
|
1798
|
+
acceptedWorkerSources,
|
|
1799
|
+
rejectedWorkerSources,
|
|
1800
|
+
driverSources,
|
|
1801
|
+
writtenPages,
|
|
1802
|
+
readiness,
|
|
1803
|
+
ready,
|
|
1804
|
+
event: createKnowledgeEvent({
|
|
1805
|
+
type: "research.iteration",
|
|
1806
|
+
actor: options.actor,
|
|
1807
|
+
target: options.root,
|
|
1808
|
+
metadata: {
|
|
1809
|
+
goal: options.goal,
|
|
1810
|
+
round: round2,
|
|
1811
|
+
ready,
|
|
1812
|
+
acceptedWorkerSourceCount: acceptedWorkerSources.length,
|
|
1813
|
+
rejectedWorkerSourceCount: rejectedWorkerSources.length,
|
|
1814
|
+
driverSourceCount: driverSources.length,
|
|
1815
|
+
writtenPageCount: writtenPages.length,
|
|
1816
|
+
remainingGapCount: remainingGaps.length
|
|
1817
|
+
}
|
|
1818
|
+
}),
|
|
1819
|
+
notes: { worker: workerContribution.notes, driver: driverNotes }
|
|
1398
1820
|
};
|
|
1821
|
+
steps.push(step);
|
|
1822
|
+
await options.onRound?.(step);
|
|
1823
|
+
}
|
|
1824
|
+
return {
|
|
1825
|
+
root: options.root,
|
|
1826
|
+
goal: options.goal,
|
|
1827
|
+
rounds: steps.length,
|
|
1828
|
+
ready,
|
|
1829
|
+
index,
|
|
1830
|
+
readiness,
|
|
1831
|
+
steps
|
|
1399
1832
|
};
|
|
1400
1833
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
const system = "You are a research librarian. Turn knowledge gaps into precise web search queries that will surface authoritative primary sources (papers, docs, standards, official pages). Return ONLY a JSON array of query strings, no prose.";
|
|
1405
|
-
const user = [
|
|
1406
|
-
`Research goal: ${ctx.goal}`,
|
|
1407
|
-
ctx.steer ? `Steer from the coordinator:
|
|
1408
|
-
${ctx.steer}` : "",
|
|
1409
|
-
`Open knowledge gaps:
|
|
1410
|
-
${gapLines}`,
|
|
1411
|
-
`Return up to ${want} search query strings as a JSON array (e.g. ["query one","query two"]).`
|
|
1412
|
-
].filter(Boolean).join("\n\n");
|
|
1413
|
-
let raw = "";
|
|
1414
|
-
try {
|
|
1415
|
-
raw = await router.chat(
|
|
1416
|
-
[
|
|
1417
|
-
{ role: "system", content: system },
|
|
1418
|
-
{ role: "user", content: user }
|
|
1419
|
-
],
|
|
1420
|
-
MIN_MAX_TOKENS
|
|
1421
|
-
);
|
|
1422
|
-
} catch {
|
|
1423
|
-
raw = "";
|
|
1424
|
-
}
|
|
1425
|
-
const parsed = parseQueryList(raw);
|
|
1426
|
-
const fromLlm = parsed.slice(0, want);
|
|
1427
|
-
if (fromLlm.length > 0) return dedupeStrings(fromLlm);
|
|
1428
|
-
return dedupeStrings(gaps.map((gap) => gap.query || gap.description));
|
|
1834
|
+
function isReady(report) {
|
|
1835
|
+
if (!report) return false;
|
|
1836
|
+
return report.blockingMissingRequirements.length === 0;
|
|
1429
1837
|
}
|
|
1430
|
-
function
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
if (typeof item === "string" && item.trim()) candidates.push(item.trim());
|
|
1440
|
-
} catch {
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
if (candidates.length === 0) {
|
|
1444
|
-
for (const line of text.split("\n")) {
|
|
1445
|
-
const cleaned = line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/^["']|["']$/g, "").trim();
|
|
1446
|
-
if (cleaned && cleaned.length > 2 && !cleaned.startsWith("{")) candidates.push(cleaned);
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
return candidates;
|
|
1838
|
+
function gapsFromReadiness(readiness) {
|
|
1839
|
+
if (!readiness) return [];
|
|
1840
|
+
const blocking = readiness.report.blockingMissingRequirements.map(
|
|
1841
|
+
(requirement) => gapFor(requirement, readiness, true)
|
|
1842
|
+
);
|
|
1843
|
+
const nonBlocking = readiness.report.nonBlockingGaps.map(
|
|
1844
|
+
(requirement) => gapFor(requirement, readiness, false)
|
|
1845
|
+
);
|
|
1846
|
+
return [...blocking, ...nonBlocking];
|
|
1450
1847
|
}
|
|
1451
|
-
function
|
|
1452
|
-
const
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1848
|
+
function gapFor(requirement, readiness, blocking) {
|
|
1849
|
+
const spec = readiness.requirements.find((entry) => entry.id === requirement.id);
|
|
1850
|
+
const query = typeof spec?.metadata?.query === "string" ? spec.metadata.query : requirement.description;
|
|
1851
|
+
return { id: requirement.id, description: requirement.description, query, blocking };
|
|
1852
|
+
}
|
|
1853
|
+
function foldGaps(driver, gaps) {
|
|
1854
|
+
if (gaps.length === 0) return void 0;
|
|
1855
|
+
if (driver.foldGaps) return driver.foldGaps(gaps);
|
|
1856
|
+
return [
|
|
1857
|
+
"The knowledge base is still missing the following. Prioritise these next round:",
|
|
1858
|
+
...gaps.map(
|
|
1859
|
+
(gap) => `- (${gap.blocking ? "blocking" : "soft"}) ${gap.description} [${gap.id}]`
|
|
1860
|
+
)
|
|
1861
|
+
].join("\n");
|
|
1862
|
+
}
|
|
1863
|
+
function isDuplicate(source, existingUris, accepted) {
|
|
1864
|
+
return existingUris.has(source.uri) || accepted.some((candidate) => candidate.uri === source.uri);
|
|
1865
|
+
}
|
|
1866
|
+
async function registerSources(options, sources) {
|
|
1867
|
+
const records = [];
|
|
1868
|
+
for (const source of sources) {
|
|
1869
|
+
records.push(await addSourceText(options.root, source, options.sourceOptions));
|
|
1460
1870
|
}
|
|
1461
|
-
return
|
|
1871
|
+
return records;
|
|
1462
1872
|
}
|
|
1463
|
-
function
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
return [
|
|
1473
|
-
`---FILE: knowledge/${slug}.md---`,
|
|
1474
|
-
"---",
|
|
1475
|
-
`title: ${escapeYaml(title)}`,
|
|
1476
|
-
`sources: ["${record.id}"]`,
|
|
1477
|
-
`source_url: ${uri}`,
|
|
1478
|
-
"---",
|
|
1479
|
-
`# ${title}`,
|
|
1480
|
-
"",
|
|
1481
|
-
body,
|
|
1482
|
-
"",
|
|
1483
|
-
`Source: ${uri}`,
|
|
1484
|
-
"---END FILE---"
|
|
1485
|
-
].join("\n");
|
|
1486
|
-
});
|
|
1487
|
-
return blocks.join("\n");
|
|
1488
|
-
};
|
|
1873
|
+
async function applyPages(root, contribution, acceptedSources) {
|
|
1874
|
+
if (acceptedSources.length === 0) return [];
|
|
1875
|
+
const parts = [];
|
|
1876
|
+
if (contribution.proposalText) parts.push(contribution.proposalText);
|
|
1877
|
+
const built = contribution.buildPages?.(acceptedSources);
|
|
1878
|
+
if (built) parts.push(built);
|
|
1879
|
+
if (parts.length === 0) return [];
|
|
1880
|
+
const applied = await applyKnowledgeWriteBlocks(root, parts.join("\n"));
|
|
1881
|
+
return applied.written;
|
|
1489
1882
|
}
|
|
1490
|
-
function
|
|
1491
|
-
|
|
1883
|
+
function requireReadiness(readiness, options) {
|
|
1884
|
+
if (readiness) return readiness;
|
|
1885
|
+
return buildEvalKnowledgeBundle({
|
|
1886
|
+
...options.readiness ?? {},
|
|
1887
|
+
taskId: options.readinessTaskId ?? options.goal,
|
|
1888
|
+
index: emptyIndex(options.root),
|
|
1889
|
+
specs: []
|
|
1890
|
+
});
|
|
1492
1891
|
}
|
|
1493
|
-
function
|
|
1494
|
-
const acceptOnParseFailure = options.acceptOnParseFailure ?? false;
|
|
1892
|
+
function emptyIndex(root) {
|
|
1495
1893
|
return {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const system = 'You verify whether a fetched web source belongs in a curated knowledge base. Accept a source ONLY if it is genuinely on-topic for the research goal and helps close one of the open gaps, AND it is not a near-duplicate of an already-accepted source. Reject spam, listicles, off-topic pages, marketing, and near-duplicates. Respond with ONLY a JSON object: {"accept": true|false, "reason": "<short reason>"}.';
|
|
1502
|
-
const user = [
|
|
1503
|
-
`Research goal: ${ctx.goal}`,
|
|
1504
|
-
`Open gaps:
|
|
1505
|
-
${gapLines || "(none specified)"}`,
|
|
1506
|
-
acceptedTitles ? `Already accepted this round:
|
|
1507
|
-
${acceptedTitles}` : "Nothing accepted yet this round.",
|
|
1508
|
-
`Candidate source:
|
|
1509
|
-
URL: ${source.uri}
|
|
1510
|
-
Title: ${source.title ?? "(none)"}
|
|
1511
|
-
Excerpt:
|
|
1512
|
-
${excerpt}`,
|
|
1513
|
-
'Verdict as JSON {"accept": boolean, "reason": string}:'
|
|
1514
|
-
].join("\n\n");
|
|
1515
|
-
let raw = "";
|
|
1516
|
-
try {
|
|
1517
|
-
raw = await router.chat(
|
|
1518
|
-
[
|
|
1519
|
-
{ role: "system", content: system },
|
|
1520
|
-
{ role: "user", content: user }
|
|
1521
|
-
],
|
|
1522
|
-
MIN_MAX_TOKENS
|
|
1523
|
-
);
|
|
1524
|
-
} catch (error) {
|
|
1525
|
-
if (error.name === "AbortError") throw error;
|
|
1526
|
-
return acceptOnParseFailure ? { accept: true } : { accept: false, reason: `verifier unavailable: ${error.message}` };
|
|
1527
|
-
}
|
|
1528
|
-
const verdict = parseVerdict(raw);
|
|
1529
|
-
if (verdict) return verdict;
|
|
1530
|
-
return acceptOnParseFailure ? { accept: true } : { accept: false, reason: "verifier returned an unparseable verdict" };
|
|
1531
|
-
}
|
|
1894
|
+
root,
|
|
1895
|
+
generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
1896
|
+
sources: [],
|
|
1897
|
+
pages: [],
|
|
1898
|
+
graph: { nodes: [], edges: [] }
|
|
1532
1899
|
};
|
|
1533
1900
|
}
|
|
1534
|
-
function
|
|
1535
|
-
const
|
|
1536
|
-
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
reason: typeof parsed.reason === "string" && parsed.reason.trim() ? parsed.reason.trim() : "rejected by verifier"
|
|
1546
|
-
};
|
|
1547
|
-
} catch {
|
|
1548
|
-
return null;
|
|
1901
|
+
function sourceMatchesGaps(source, index, gaps) {
|
|
1902
|
+
const haystack = `${source.title ?? ""}
|
|
1903
|
+
${source.text}`.toLowerCase();
|
|
1904
|
+
const hits = [];
|
|
1905
|
+
for (const gap of gaps) {
|
|
1906
|
+
for (const token of gap.query.toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1907
|
+
if (haystack.includes(token)) {
|
|
1908
|
+
hits.push(...searchKnowledge(index, gap.query, 1));
|
|
1909
|
+
break;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1549
1912
|
}
|
|
1913
|
+
return hits;
|
|
1550
1914
|
}
|
|
1551
1915
|
export {
|
|
1552
1916
|
AgentMemoryHitSchema,
|
|
@@ -1581,7 +1945,14 @@ export {
|
|
|
1581
1945
|
buildEvalKnowledgeBundle,
|
|
1582
1946
|
buildKnowledgeGraph,
|
|
1583
1947
|
buildKnowledgeIndex,
|
|
1948
|
+
canonicalizeUrl,
|
|
1584
1949
|
chunkMarkdown,
|
|
1950
|
+
citedClaimKey,
|
|
1951
|
+
citedClaimOf,
|
|
1952
|
+
contentKey,
|
|
1953
|
+
createAdaptiveResearchDriver,
|
|
1954
|
+
createClaimDecorator,
|
|
1955
|
+
createClaimGroundingVerifier,
|
|
1585
1956
|
createCornellLiiSource,
|
|
1586
1957
|
createD1FreshnessStoreStub,
|
|
1587
1958
|
createFileSystemFreshnessStore,
|
|
@@ -1602,6 +1973,7 @@ export {
|
|
|
1602
1973
|
extractWikilinks,
|
|
1603
1974
|
firstMatch,
|
|
1604
1975
|
formatFrontmatter,
|
|
1976
|
+
groundClaimInText,
|
|
1605
1977
|
htmlToText,
|
|
1606
1978
|
initKnowledgeBase,
|
|
1607
1979
|
innerHtmlById,
|
|
@@ -1638,7 +2010,9 @@ export {
|
|
|
1638
2010
|
stripFrontmatter,
|
|
1639
2011
|
textSourceAdapter,
|
|
1640
2012
|
tokenizeQuery,
|
|
2013
|
+
triageSource,
|
|
1641
2014
|
validateKnowledgeIndex,
|
|
2015
|
+
withCitedClaim,
|
|
1642
2016
|
writeJson,
|
|
1643
2017
|
writeKnowledgeIndex,
|
|
1644
2018
|
writeSourceRegistry
|