Repository navigation
Expand file tree
/
Copy pathserver.ts
More file actions
3271 lines (2896 loc) · 182 KB
/
Copy pathserver.ts
File metadata and controls
3271 lines (2896 loc) · 182 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import express from "express";
import path from "path";
import fs from "fs";
import os from "os";
import net from "net";
import crypto from "crypto";
import dotenv from "dotenv";
import { createServer as createViteServer } from "vite";
import { GoogleGenAI, Type } from "@google/genai";
// Load environment variables. override:true makes .env authoritative over
// inherited shell/machine env vars — without it, a stale system-level key
// (e.g. an exhausted OPENAI_API_KEY set in Windows) silently beats a fresh
// one in .env, because dotenv's default is to NOT overwrite existing vars.
dotenv.config({ override: true });
// --- Run persistence ---------------------------------------------------------
// Finished research must never depend on a browser tab surviving: a synthesis
// run persists its INPUTS (all specialist reports) the moment the request
// arrives and its OUTPUT the moment generation completes, under
// runs/<timestamp>-<topic-slug>/. Best-effort by design — a disk failure logs
// a warning and the pipeline continues.
const RUNS_DIR = path.join(process.cwd(), "runs");
// Generated agent portraits (gitignored). See /api/research/agent-portrait.
const PORTRAITS_DIR = path.join(process.cwd(), "portraits");
const PORTRAIT_MODEL = process.env.PORTRAIT_MODEL || "gemini-3.1-flash-image";
const PORTRAIT_ACCENTS: Record<string, string> = {
cyan: "cyan", emerald: "emerald green", rose: "rose pink", amber: "amber", purple: "violet", indigo: "indigo", blue: "electric blue", fuchsia: "magenta",
};
const slugify = (s: string) =>
String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "run";
const persistRunFile = (runDir: string, name: string, data: string) => {
try {
fs.mkdirSync(runDir, { recursive: true });
fs.writeFileSync(path.join(runDir, name), data, "utf8");
} catch (e: any) {
console.warn(`Run persistence failed (${name}): ${e?.message || e}`);
}
};
// Validate Gemini API Key
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.warn("WARNING: GEMINI_API_KEY environment variable is not set. Gemini API calls will fail.");
}
// Initialize Gemini Client
const ai = new GoogleGenAI({
apiKey: apiKey,
httpOptions: {
headers: {
"User-Agent": "aistudio-build",
},
},
});
const generateWithRetry = async (params: any, retries = 4, baseDelayMs = 15000): Promise<any> => {
for (let i = 0; i < retries; i++) {
try {
return await ai.models.generateContent(params);
} catch (error: any) {
const isRateLimitOrOverload =
error?.status === 429 ||
error?.status === 503 ||
error?.status === 'RESOURCE_EXHAUSTED' ||
error?.message?.includes('429') ||
error?.message?.includes('503') ||
error?.message?.includes('quota');
if (i < retries - 1 && isRateLimitOrOverload) {
// Look for a retry delay in the error if provided, otherwise fallback to exponential backoff with random jitter
let delay = baseDelayMs * Math.pow(1.5, i) + Math.floor(Math.random() * 4000);
if (error?.message) {
const match = error.message.match(/Please retry in ([\d.]+)s/);
if (match && match[1]) {
delay = (parseFloat(match[1]) * 1000) + 1500; // Add 1.5s buffer
}
}
console.warn(`[API] Rate limit/Overload encountered. Retrying in ${delay}ms... (Attempt ${i + 1} of ${retries})`);
await new Promise(res => setTimeout(res, delay));
} else {
throw error;
}
}
}
};
const generateStreamWithRetry = async (params: any, retries = 4, baseDelayMs = 15000): Promise<any> => {
for (let i = 0; i < retries; i++) {
try {
const responseStream = await ai.models.generateContentStream(params);
// Wait for the first chunk to ensure the stream actually starts and we catch early rate limit errors
const iterator = responseStream[Symbol.asyncIterator]();
const firstChunk = await iterator.next();
// If we get here, it succeeded. We create a generator to yield the first chunk then the rest
async function* wrappedStream() {
if (!firstChunk.done) {
yield firstChunk.value;
yield* iterator;
}
}
return wrappedStream();
} catch (error: any) {
const isRateLimitOrOverload =
error?.status === 429 ||
error?.status === 503 ||
error?.status === 'RESOURCE_EXHAUSTED' ||
error?.message?.includes('429') ||
error?.message?.includes('503') ||
error?.message?.includes('quota');
if (i < retries - 1 && isRateLimitOrOverload) {
let delay = baseDelayMs * Math.pow(1.5, i) + Math.floor(Math.random() * 4000);
if (error?.message) {
const match = error.message.match(/Please retry in ([\d.]+)s/);
if (match && match[1]) {
delay = (parseFloat(match[1]) * 1000) + 1500; // Add 1.5s buffer
}
}
console.warn(`[API Stream] Rate limit/Overload encountered. Retrying in ${delay}ms... (Attempt ${i + 1} of ${retries})`);
await new Promise(res => setTimeout(res, delay));
} else {
throw error;
}
}
}
};
// -------------------------------------------------------------
// Universal Multi-Provider Routing & LLM Execution Helpers
// -------------------------------------------------------------
// Parses the FIRST balanced JSON object/array starting at the first opening
// char — rescues responses where the model emits valid JSON followed by
// trailing prose or a second JSON blob.
function parseFirstBalanced(text: string, openChar: "{" | "[", closeChar: "}" | "]"): any | null {
const start = text.indexOf(openChar);
if (start === -1) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === openChar) depth++;
else if (ch === closeChar) {
depth--;
if (depth === 0) {
try {
return JSON.parse(text.slice(start, i + 1));
} catch (e) {
return null;
}
}
}
}
return null;
}
function extractJSON(text: string): any {
let cleanText = text.replace(/```json/gi, "").replace(/```/g, "").trim();
try {
return JSON.parse(cleanText);
} catch (e) {
const firstBrace = cleanText.indexOf("{");
const firstBracket = cleanText.indexOf("[");
let startIdx = -1;
let endIdx = -1;
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
startIdx = firstBrace;
endIdx = cleanText.lastIndexOf("}");
} else if (firstBracket !== -1) {
startIdx = firstBracket;
endIdx = cleanText.lastIndexOf("]");
}
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
const JSONStr = cleanText.substring(startIdx, endIdx + 1);
try {
return JSON.parse(JSONStr);
} catch (innerError: any) {
const balanced = parseFirstBalanced(cleanText, "{", "}") ?? parseFirstBalanced(cleanText, "[", "]");
if (balanced !== null) return balanced;
throw new Error(`Candidate JSON located but parsing failed: ${innerError.message}. Content: ${JSONStr.slice(0, 600)}`);
}
}
throw new Error(`Failed to locate any valid JSON array or block in model response: ${text.slice(0, 600)}`);
}
}
// .env keys are the durable option — browser-stored keys are per-origin
// (host:port) and vanish whenever the port changes.
function envKeyFor(provider: string): string {
const envKeys: Record<string, string> = {
gemini: process.env.GEMINI_API_KEY || "",
openrouter: process.env.OPENROUTER_API_KEY || "",
openai: process.env.OPENAI_API_KEY || "",
anthropic: process.env.ANTHROPIC_API_KEY || "",
venice: process.env.VENICE_API_KEY || "",
};
return envKeys[provider] || "";
}
// The three pipeline roles plus "forge": the model that writes reusable
// personas for the Agent Library. Forge falls back to the orchestrator
// mapping when the client has not set one.
type TaskRole = "orchestrator" | "agent" | "synthesis" | "forge";
function getModelAndKey(taskRole: TaskRole, settings: any) {
let provider = "gemini";
let model = "gemini-3.5-flash";
let apiKey = "";
let baseUrl = "";
const mappingKey: TaskRole = taskRole === "forge" && !(settings?.modelMapping?.forge?.model) ? "orchestrator" : taskRole;
if (settings && settings.modelMapping && settings.modelMapping[mappingKey]) {
const mapping = settings.modelMapping[mappingKey];
provider = mapping.provider || "gemini";
model = mapping.model || "gemini-3.5-flash";
if (settings.providers && settings.providers[provider]) {
const prov = settings.providers[provider];
apiKey = prov.apiKey || "";
baseUrl = prov.baseUrl || "";
}
}
if (!apiKey) {
apiKey = envKeyFor(provider);
}
if (!baseUrl) {
if (provider === "lmstudio") baseUrl = process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1";
else if (provider === "ollama") baseUrl = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
}
return { provider, model, apiKey, baseUrl };
}
// Non-streaming JSON calls MUST time out: a congested provider (e.g. a
// brand-new model being hammered on OpenRouter) can otherwise hold the
// connection open indefinitely, hanging the whole pipeline at "assembling"
// with no error and no retry. 180s accommodates slow reasoning models.
const JSON_CALL_TIMEOUT_MS = 180000;
async function callOpenAICompatible(url: string, apiKey: string, body: any): Promise<any> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JSON_CALL_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { "Authorization": `Bearer ${apiKey}` } : {})
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upstream API status ${response.status}: ${errorText || response.statusText}`);
}
return await response.json();
} catch (err: any) {
if (err?.name === "AbortError") {
throw new Error(`Upstream request timed out after ${JSON_CALL_TIMEOUT_MS / 1000}s (${url}) — the provider may be overloaded; retry or switch models.`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function callAnthropic(apiKey: string, body: any): Promise<any> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), JSON_CALL_TIMEOUT_MS);
try {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01"
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Anthropic API status ${response.status}: ${errorText || response.statusText}`);
}
return await response.json();
} catch (err: any) {
if (err?.name === "AbortError") {
throw new Error(`Anthropic request timed out after ${JSON_CALL_TIMEOUT_MS / 1000}s — the provider may be overloaded; retry or switch models.`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
// -------------------------------------------------------------
// Live Web Grounding
// -------------------------------------------------------------
// Gemini, Anthropic, OpenRouter, and Venice can run real web search natively,
// so hasSearch is wired into their request bodies. Every other provider
// (LM Studio, Ollama, plain OpenAI chat) has no live internet access — for
// those the server runs SearXNG queries itself and injects the results into
// the prompt, otherwise the model silently answers from stale training data
// while claiming to have "searched".
const SEARXNG_BASE_URL = (process.env.SEARXNG_BASE_URL || "http://localhost:8888").replace(/\/$/, "");
// Optional API fallbacks for when SearXNG is unreachable or returns nothing.
// Leave the env vars unset to disable an engine.
const BRAVE_SEARCH_API_KEY = process.env.BRAVE_SEARCH_API_KEY || "";
const TAVILY_API_KEY = process.env.TAVILY_API_KEY || "";
const NATIVE_SEARCH_PROVIDERS = new Set(["gemini", "anthropic", "openrouter", "venice"]);
// Providers whose native search is genuinely agentic (the model iterates its
// own real queries). Everyone else gets the server-side injected block —
// including OpenRouter/Venice, whose web plugins auto-derive a single query
// from the prompt and miss niche exact-phrase targets.
const AGENTIC_SEARCH_PROVIDERS = new Set(["gemini", "anthropic"]);
interface SearchHit {
title: string;
url: string;
snippet: string;
publishedDate?: string;
fromNews?: boolean;
}
async function searxngSearch(query: string, maxResults = 10, category?: string): Promise<SearchHit[]> {
const catParam = category ? `&categories=${encodeURIComponent(category)}` : "";
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json${catParam}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`SearXNG responded with status ${response.status}`);
const data = await response.json() as any;
return (data.results || []).slice(0, maxResults).map((r: any) => ({
title: r.title || "(untitled)",
url: r.url || "",
snippet: (r.content || "").slice(0, 500),
publishedDate: typeof r.publishedDate === "string" ? r.publishedDate.slice(0, 10) : undefined,
fromNews: category === "news",
}));
} finally {
clearTimeout(timer);
}
}
// Mechanical query variants: exact quoted phrase and de-glued identifiers
// (DUDE44BRAVO -> DUDE 44 BRAVO). These guarantee a niche exact-phrase target
// still gets hunted even when the LLM query planner fails or under-delivers.
function buildQueryVariants(topic: string): string[] {
const variants = new Set<string>();
const t = topic.trim().slice(0, 120);
if (!t) return [];
variants.add(t);
const spaced = t.replace(/([A-Za-z])(\d)/g, "$1 $2").replace(/(\d)([A-Za-z])/g, "$1 $2");
if (spaced !== t) variants.add(spaced);
if (t.length <= 60 && !t.includes('"')) variants.add(`"${t}"`);
if (spaced !== t && spaced.length <= 60) variants.add(`"${spaced}"`);
return [...variants];
}
const QUERY_STOPWORDS = new Set(["the", "and", "for", "with", "that", "this", "from", "what", "who", "how", "are", "was", "were", "latest", "news", "about", "into", "over", "under", "when", "where", "which", "does", "did", "has", "have", "its", "their", "your", "our", "not", "all", "any", "site", "org", "com"]);
// Rank hits by overlap with the queries' distinctive terms so junk from noisy
// engines sinks instead of crowding real coverage out of the context block.
function rankHits(hits: SearchHit[], queries: string[]): SearchHit[] {
const terms = new Set<string>();
for (const q of queries) {
for (const w of q.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)) {
if (w.length >= 2 && !QUERY_STOPWORDS.has(w)) terms.add(w);
}
}
const scored = hits.map((h) => {
const text = `${h.title} ${h.snippet}`.toLowerCase();
let score = 0;
for (const term of terms) if (text.includes(term)) score++;
if (h.fromNews) score += 1;
return { h, score };
});
scored.sort((a, b) => b.score - a.score);
// When enough hits genuinely match, drop the zero-overlap junk entirely.
const matching = scored.filter((s) => s.score >= 2);
return (matching.length >= 5 ? matching : scored).map((s) => s.h);
}
async function braveSearch(query: string, maxResults = 8): Promise<SearchHit[]> {
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${maxResults}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(url, {
headers: { "Accept": "application/json", "X-Subscription-Token": BRAVE_SEARCH_API_KEY },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Brave responded with status ${response.status}`);
const data = await response.json() as any;
return (data.web?.results || []).slice(0, maxResults).map((r: any) => ({
title: r.title || "(untitled)",
url: r.url || "",
snippet: (r.description || "").slice(0, 500),
}));
} finally {
clearTimeout(timer);
}
}
async function tavilySearch(query: string, maxResults = 8): Promise<SearchHit[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: TAVILY_API_KEY, query, max_results: maxResults }),
signal: controller.signal,
});
if (!response.ok) throw new Error(`Tavily responded with status ${response.status}`);
const data = await response.json() as any;
return (data.results || []).slice(0, maxResults).map((r: any) => ({
title: r.title || "(untitled)",
url: r.url || "",
snippet: (r.content || "").slice(0, 500),
}));
} finally {
clearTimeout(timer);
}
}
// Crude but dependency-free HTML→text extraction for full-article reading.
// Good enough to hand a model the body text of a news article or blog post.
function htmlToText(html: string): string {
let s = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<(nav|header|footer|aside|form)[\s\S]*?<\/\1>/gi, " ");
s = s.replace(/<(br|p|div|li|h[1-6]|tr|section|article)[^>]*>/gi, "\n");
s = s.replace(/<[^>]+>/g, " ");
s = s
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/�?39;/g, "'")
.replace(/&#\d+;/g, " ");
return s.replace(/[ \t]+/g, " ").replace(/\s*\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim();
}
async function fetchPageExtract(url: string, maxChars = 6000): Promise<string | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SwarmIntel/1.0 research assistant",
"Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
},
});
if (!response.ok) return null;
const ctype = response.headers.get("content-type") || "";
if (!ctype.includes("text/html") && !ctype.includes("text/plain") && !ctype.includes("application/xhtml")) return null;
const raw = (await response.text()).slice(0, 600_000);
const text = ctype.includes("text/plain") ? raw : htmlToText(raw);
// Paywalls and JS shells extract to almost nothing — not worth injecting.
if (text.length < 300) return null;
return text.slice(0, maxChars);
} catch (e) {
return null;
} finally {
clearTimeout(timer);
}
}
// How many top-ranked pages get fetched and text-extracted per grounding run.
// Generous by design — local models with 1M+ contexts thrive on more.
const FULL_PAGE_EXTRACT_COUNT = 10;
// Runs all queries in parallel against an engine chain — SearXNG first (free,
// aggregates many engines; queried in BOTH the general and news categories,
// because the general category's engine soup buries current events that the
// news category surfaces cleanly), then any configured API fallbacks. The
// first engine that returns hits wins; `engine` reports which one grounded
// the run. Hits are relevance-ranked, the top few pages are fetched in full
// and text-extracted so agents read complete articles instead of snippets,
// and the block states exactly which queries were executed so agents can
// report methodology honestly.
async function gatherLiveContext(
queries: string[],
refine?: (digest: string, alreadyRun: string[]) => Promise<string[]>,
onStage?: (s: StageInfo) => void,
// The news-category doubling was a junk-filter fix for SearXNG's general
// category. Under a fringe-first evidence policy it is OFF: the server must
// not inject mainstream press on every wave while the prompt asks for
// archives and practitioner lanes. Search mix is code, not a sentence.
newsLane: boolean = true
): Promise<{ block: string; hitCount: number; engine: string; pages: number; waves: number; urls: string[]; extractedUrls: string[]; queries: string[] }> {
const uniqueQueries = [...new Set(queries.map((q) => q.trim()).filter(Boolean))].slice(0, 10);
const engines: { name: string; enabled: boolean; sequentialDelayMs?: number; jobsFor: (qs: string[]) => { query: string; run: () => Promise<SearchHit[]> }[] }[] = [
{
name: "SearXNG",
enabled: true,
jobsFor: (qs) => qs.flatMap((q) => newsLane
? [
{ query: q, run: () => searxngSearch(q, 10) },
{ query: q, run: () => searxngSearch(q, 10, "news") },
]
: [{ query: q, run: () => searxngSearch(q, 10) }]),
},
{
// Brave's free tier allows one request per second; firing a wave in
// parallel returns 429 for everything after the first.
name: "Brave",
enabled: !!BRAVE_SEARCH_API_KEY,
sequentialDelayMs: 1100,
jobsFor: (qs) => qs.map((q) => ({ query: q, run: () => braveSearch(q) })),
},
{
name: "Tavily",
enabled: !!TAVILY_API_KEY,
jobsFor: (qs) => qs.map((q) => ({ query: q, run: () => tavilySearch(q) })),
},
];
for (const engine of engines) {
if (!engine.enabled) continue;
const seen = new Set<string>();
const hits: SearchHit[] = [];
const perQuery = new Map<string, number>();
// Queries for which at least one job actually completed. A query that
// only failed (engine down, rate-limited) must be reported to the model
// as NOT RUN — never as "0 results", which reads as evidence of absence.
const succeeded = new Set<string>();
const runWave = async (waveQueries: string[]) => {
const jobs = engine.jobsFor(waveQueries);
let settled: PromiseSettledResult<SearchHit[]>[];
if (engine.sequentialDelayMs) {
settled = [];
for (let i = 0; i < jobs.length; i++) {
if (i > 0) await new Promise((r) => setTimeout(r, engine.sequentialDelayMs));
settled.push(...(await Promise.allSettled([jobs[i].run()])));
}
} else {
settled = await Promise.allSettled(jobs.map((j) => j.run()));
}
settled.forEach((result, idx) => {
const job = jobs[idx];
if (result.status !== "fulfilled") {
console.warn(`[Grounding] ${engine.name} query failed ("${job.query.slice(0, 60)}"): ${result.reason?.message || result.reason}`);
return;
}
succeeded.add(job.query);
perQuery.set(job.query, (perQuery.get(job.query) || 0) + result.value.length);
for (const hit of result.value) {
if (!hit.url || seen.has(hit.url)) continue;
seen.add(hit.url);
hits.push(hit);
}
});
};
// Wave 1 — landscape survey.
onStage?.({ stage: "searching", wave: 1 });
await runWave(uniqueQueries);
// Wave 2 — iterative deepening: queries written FROM what wave 1 actually
// returned (chase discovered entities, hunt counter-evidence, fill gaps).
let allQueries = [...uniqueQueries];
let waves = 1;
if (refine && hits.length > 0) {
try {
const digest = rankHits(hits, uniqueQueries)
.slice(0, 15)
.map((h) => `- ${h.title} :: ${h.snippet.slice(0, 160)}`)
.join("\n");
const followups = (await refine(digest, uniqueQueries))
.map((q) => q.trim())
.filter((q) => q && !allQueries.some((prev) => prev.toLowerCase() === q.toLowerCase()))
.slice(0, 6);
if (followups.length > 0) {
console.log(`[Grounding] Wave 2 (${engine.name}): ${followups.map((q) => `"${q}"`).join(" | ")}`);
onStage?.({ stage: "searching", wave: 2, hits: hits.length });
await runWave(followups);
allQueries = [...allQueries, ...followups];
waves = 2;
}
} catch (err: any) {
console.warn(`[Grounding] Wave 2 refinement failed: ${err?.message || err}`);
}
}
if (hits.length > 0) {
const ranked = rankHits(hits, allQueries).slice(0, 40);
const today = new Date().toISOString().slice(0, 10);
const queryLines = allQueries
.map((q, i) => succeeded.has(q)
? `- [wave ${i < uniqueQueries.length ? 1 : 2}] "${q}" (${perQuery.get(q) || 0} raw hits)`
: `- [wave ${i < uniqueQueries.length ? 1 : 2}] "${q}" — SEARCH FAILED (engine error): this query did NOT run. Treat it as unsearched; it is NOT evidence that nothing exists.`)
.join("\n");
const lines = ranked.map(
(h, i) => `[${i + 1}] ${h.title}${h.publishedDate ? ` (published ${h.publishedDate})` : ""}\n URL: ${h.url}\n ${h.snippet}`
);
// Full-article reading: fetch the top-ranked pages and hand the agent
// real body text, not just search snippets.
const toFetch = ranked.slice(0, FULL_PAGE_EXTRACT_COUNT);
onStage?.({ stage: "reading", pages: toFetch.length, hits: ranked.length });
const extractResults = await Promise.allSettled(toFetch.map((h) => fetchPageExtract(h.url)));
const extracts: string[] = [];
const extractedUrls: string[] = [];
extractResults.forEach((result, idx) => {
if (result.status === "fulfilled" && result.value) {
extractedUrls.push(toFetch[idx].url);
extracts.push(`=== FULL TEXT of [${idx + 1}] ${toFetch[idx].title}\n URL: ${toFetch[idx].url}\n${result.value}`);
}
});
const extractsSection = extracts.length > 0
? `\n\nFULL SOURCE EXTRACTS (complete body text fetched from the top-ranked sources — quote and verify against THESE, not just the snippets):\n${extracts.join("\n\n")}`
: "";
const block = `LIVE WEB SEARCH RESULTS (retrieved ${today} UTC via ${engine.name} — current, real-world data)
Searched in ${waves === 2 ? "TWO waves: a landscape survey, then a deepening wave whose queries were written from what the first wave actually returned" : "a single landscape wave"}.
Queries the research system executed on your behalf (these are the ONLY searches run for you):
${queryLines}
Results (deduplicated, relevance-ranked):
${lines.join("\n")}${extractsSection}`;
return { block, hitCount: ranked.length, engine: engine.name, pages: extracts.length, waves, urls: ranked.map((h) => h.url), extractedUrls, queries: allQueries };
}
console.warn(`[Grounding] ${engine.name} returned no hits${engine.name === "SearXNG" ? ` (${SEARXNG_BASE_URL})` : ""} — trying next engine.`);
}
return { block: "", hitCount: 0, engine: "none", pages: 0, waves: 0, urls: [], extractedUrls: [], queries: [] };
}
// Normalize a URL for "already in hand" comparisons between the injected
// SearXNG set and what a provider's native search tool brings back.
const normUrl = (u: string): string =>
String(u || "").trim().replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/[#?].*$/, "").replace(/\/+$/, "").toLowerCase();
// Iterative deepening (k-deep-research "progressive refinement"): wave 2's
// queries are written FROM wave 1's actual results — chasing the specific
// entities that surfaced, hunting counter-evidence, and filling visible gaps.
async function refineQueriesFromResults(
settings: any,
digest: string,
alreadyRun: string[]
): Promise<string[]> {
const schema = {
type: Type.OBJECT,
properties: {
queries: { type: Type.ARRAY, description: "4-6 follow-up search queries", items: { type: Type.STRING } },
},
required: ["queries"],
};
const prompt = `A first wave of web searches has returned. Your job is to write the SECOND wave.
QUERIES ALREADY RUN (do not repeat these):
${alreadyRun.map((q) => `- ${q}`).join("\n")}
WHAT WAVE 1 RETURNED (titles :: snippets):
${digest}
Write 4-6 NEW search queries that deepen this investigation. Mandatory coverage:
- CHASE SPECIFICS: name the concrete entities that actually appeared above — people, organizations, documents, products, place names, dates, case numbers — and search for them directly.
- HUNT COUNTER-EVIDENCE: at least one query aimed at critique, rebuttal, debunking, or the dissenting view of the dominant claim above.
- FOLLOW THE INCENTIVE: at least one query about who funds, benefits from, or is harmed by the dominant narrative above.
- FILL THE GAP: target what wave 1 conspicuously did NOT answer.
Each query is a tight search-engine string of 2-8 words, not a sentence or question. Return only JSON: {"queries": ["...", "..."]}.`;
const result = await generateUnifiedJSON(
"orchestrator",
settings,
prompt,
"You are a research query strategist running the deepening pass of an investigation. Output only the JSON object.",
schema
);
return Array.isArray(result?.queries)
? result.queries.filter((q: any) => typeof q === "string" && q.trim()).map((q: string) => q.trim().slice(0, 140))
: [];
}
export interface GroundingInfo {
mode: "native" | "injected" | "none";
detail: string;
}
// Real pipeline stage telemetry, streamed to the client as SSE `stage`
// events so the UI shows what an agent is actually doing instead of a
// simulated progress bar.
export interface StageInfo {
stage: "planning" | "searching" | "reading" | "reasoning" | "writing";
wave?: number;
pages?: number;
hits?: number;
}
// -------------------------------------------------------------
// Follow-Up Runs
// -------------------------------------------------------------
// A follow-up session carries condensed context from its parent run so the
// orchestrator targets the gaps and agents build on established findings
// instead of re-deriving them.
function formatPriorContextBlock(priorContext: any): string {
if (!priorContext || typeof priorContext !== "object") return "";
const synthesis = String(priorContext.synthesis || "").slice(0, 24000);
const chatExcerpt = String(priorContext.chatExcerpt || "").slice(0, 8000);
const directive = String(priorContext.directive || "").trim();
const parentTopic = String(priorContext.parentTopic || "").trim();
if (!synthesis && !directive) return "";
// Fringe case files carry their unworked leads forward so follow-up swarms
// work the case instead of restarting it.
const openLeads = Array.isArray(priorContext.leads)
? priorContext.leads.filter((l: any) => l && l.status === "open" && typeof l.text === "string")
: [];
const leadsBlock = openLeads.length
? `\nOPEN LEADS ON FILE (unworked threads from the prior investigation — prioritize these):\n${openLeads.map((l: any) => `- [${l.id}] ${l.text}`).join("\n")}\n`
: "";
return `PRIOR INVESTIGATION CONTEXT (this is a FOLLOW-UP run — an earlier swarm already researched the topic below):
ORIGINAL TOPIC: "${parentTopic}"
PRIOR SYNTHESIZED FINDINGS (condensed — treat as established ground):
${synthesis || "(no synthesis on file)"}
${chatExcerpt ? `\nINTERROGATION EXCHANGES THAT MOTIVATED THIS FOLLOW-UP:\n${chatExcerpt}\n` : ""}${leadsBlock}
FOLLOW-UP DIRECTIVE FROM THE USER: "${directive}"`;
}
// -------------------------------------------------------------
// Directive Conditioner
// -------------------------------------------------------------
// The best runs this app has produced used a full research DIRECTIVE as the
// topic (primary question, epistemic categories, coverage, failure modes,
// numbered deliverables, research behaviour, success condition) followed by
// the user's own context. Plain one-line topics underperform with the same
// prompts. This step writes that directive for any input. The model writes
// ONLY the directive; the server appends the user's input verbatim, so
// nothing the user wrote can be shortened or lost.
// -------------------------------------------------------------
// Evidence Policy — the source rubric, separate from Fringe Mode
// -------------------------------------------------------------
// Fringe Mode is a verdict shape (Evidence Docket, open leads, provenance
// tags). Evidence policy is the source religion: which lanes are the spine
// and how a source earns credibility. They were welded together once and the
// institutional rubric outvoted the fringe costume. The only credibility axis
// anywhere in the pipeline is now CHAIN OF CUSTODY.
type EvidencePolicy = "mainstream-first" | "parity" | "fringe-first";
const normalizePolicy = (v: any, fringe: boolean): EvidencePolicy =>
v === "mainstream-first" || v === "parity" || v === "fringe-first" ? v : fringe ? "fringe-first" : "mainstream-first";
const POLICY_LABEL: Record<EvidencePolicy, string> = {
"mainstream-first": "MAINSTREAM-FIRST",
parity: "PARITY",
"fringe-first": "FRINGE-FIRST",
};
const CUSTODY_SCALE = `CHAIN-OF-CUSTODY SCORING (the only credibility axis, 0-10): 10 = you read the primary artifact itself (the filing, the scan, the original post, the dataset, the recording) at a resolvable location; 7-9 = a faithful copy or transcript whose original is identified and could be opened; 4-6 = a secondary account that names its primary source; 1-3 = a claim with no artifact behind it. Who published it is context you may note, never the score. Never label a source unreliable from memory; score what you actually traced. A traced artifact that CONTRADICTS a claim is a finding — record it as such.`;
function EVIDENCE_POLICY_BLOCK(policy: EvidencePolicy): string {
const spine =
policy === "fringe-first"
? `SPINE: archival and FOIA reading rooms, primary documents (patents, filings, court records, contracts, datasets), practitioner and researcher communities (forums, Discords, mailing lists, Substacks, podcasts, out-of-print books and scans), and original-era press are the backbone of this investigation. Mainstream summaries are context and are NOT required for a finding to stand. A finding supported by a traced community artifact outranks an untraced institutional summary. "The source is a forum" is never a defect; "the claim has no artifact" is.`
: policy === "parity"
? `SPINE: institutional, press, archival, and practitioner sources carry EQUAL standing. A claim is strong when independent lanes converge on it and weak when one lane echoes itself — regardless of which lane. Do not discount a practitioner artifact for being a practitioner artifact, and do not promote a press summary for being press.`
: `SPINE: institutional records and established press are the backbone; archival and practitioner material corroborates and extends them. Even here, credibility is chain of custody, not masthead: a press summary with no traceable primary scores below a scanned filing.`;
return `EVIDENCE POLICY — ${POLICY_LABEL[policy]} (set by the user for this run).
The user owns the evidence policy. Apply it. Do not substitute your own judgment about which sources deserve weight.
${spine}
${CUSTODY_SCALE}
Stamp every report section that scores sources with "scored under ${POLICY_LABEL[policy]}".`;
}
function OPERATOR_DIRECTIVES_BLOCK(settings: any): string {
const text = String(settings?.operatorDirectives || "").trim();
if (!text) return "";
return `OPERATOR DIRECTIVES (standing instructions from the user — they outrank every default rule that follows):
${text.slice(0, 4000)}
`;
}
interface ConditionerConfig {
depth: "recon" | "standard" | "deep";
fringe: boolean;
redTeam: boolean;
agentCount: number | "auto";
policy: EvidencePolicy;
operatorDirectives?: string;
}
const CONDITIONER_MAX_INPUT_WORDS = 12000;
const CONDITIONER_MIN_OUTPUT_WORDS = 300;
// A 2,000-word directive from a reasoning model can take several minutes;
// the generic JSON-call timeout (180 s) is too short for it.
const CONDITIONER_TIMEOUT_MS = 480000;
const CONDITIONER_SYSTEM = "You are the Directive Conditioner for a multi-agent research swarm. You turn a research request into a complete, machine-fitted research directive. You structure; you never research, never answer the question, and never restate the user's context back to them.";
const CONTEXT_FENCE_HEADER = "# AUTHORITATIVE CONTEXT (verbatim user input — treat as the project baseline; do not summarize it back)";
function assembleConditionedTopic(directive: string, rawTopic: string): string {
return `${directive.trim()}\n\n---\n\n${CONTEXT_FENCE_HEADER}\n\n${rawTopic.trim()}`;
}
function buildConditionerPrompt(rawTopic: string, cfg: ConditionerConfig): string {
const deliverableCount = cfg.depth === "recon" ? 3 : cfg.depth === "deep" ? 7 : 5;
const wordTarget = cfg.depth === "recon" ? "900-1,400" : cfg.depth === "deep" ? "1,800-2,600" : "1,300-2,000";
const agentFloors = cfg.depth === "recon" ? "800-1,200" : cfg.depth === "deep" ? "5,000-8,000" : "2,500-4,000";
const synthFloor = cfg.depth === "recon" ? "1,500-2,500" : cfg.depth === "deep" ? "8,000-12,000" : "4,000-6,000";
const epistemic = cfg.fringe
? `Use the case-file provenance tags this pipeline already enforces: [primary text], [community lore], [witness testimony], [documented anomaly], [official record], [verified]. Add [design choice] and [open question] if the request is about building or deciding something.`
: `Define four to six labelled categories every claim must be tagged with. Default set: ESTABLISHED FACT (sourced, uncontested), REPORTED CLAIM (sourced, contested or single-source), INFERENCE (the agent's reasoning from facts), DESIGN CHOICE (a recommendation, not a finding), OPEN QUESTION (could not be settled this run). Rename or extend them to fit the request's domain.`;
const verdictLine = cfg.fringe
? "This is a CASE FILE run: the synthesis opens with the state of the evidence and may legitimately conclude insufficient-to-conclude. The success condition must not demand a verdict."
: "The synthesis opens with the direct answer to the primary question (the pick, the ranking, the verdict, the design). The success condition must say what that answer looks like.";
return `Today's date is ${new Date().toDateString()}.
${OPERATOR_DIRECTIVES_BLOCK({ operatorDirectives: cfg.operatorDirectives })}${EVIDENCE_POLICY_BLOCK(cfg.policy)}
THE PIPELINE THIS DIRECTIVE WILL DRIVE (write for this machine, not for a chatbot):
- An orchestrator reads the directive, writes a need analysis, and sprouts ${cfg.agentCount === "auto" ? "3-9" : cfg.agentCount} specialist agents from it. Each agent runs SEQUENTIALLY and writes a ${agentFloors}-word report (depth: ${cfg.depth.toUpperCase()}).
- Every agent is web-grounded: the server runs SearXNG searches planned from the directive and the agent's assignment, fetches full page text from the top-ranked hits, and injects both into the agent's prompt; deep runs add a second search wave written from the first wave's results. Agents can quote and cite only what those searches return (plus native search on Gemini/Anthropic). They have no code execution, no file access, no databases.
- ${cfg.redTeam ? "A Red Team pass (VEX) cross-examines each report WITHOUT web access before synthesis." : "No Red Team pass this run."}
- A single synthesis model folds every report into one ${synthFloor}-word document with a fixed structure (executive summary, tracks and methodology, thematic insights, conflict/consensus/uncertainty, recommendations, conclusion, Source Ledger). ${verdictLine}
- Deliverables you specify must be things that structure can hold: tables, matrices, ranked lists, labelled sections, pipelines. Nothing interactive, nothing that needs tools the agents lack.
THE RESEARCH REQUEST (verbatim; the server will append this under an AUTHORITATIVE CONTEXT fence after your directive, so do NOT copy, summarize, or paraphrase it back):
<<<REQUEST
${rawTopic.trim()}
REQUEST>>>
YOUR JOB: write the DIRECTIVE that sits above that context. ${wordTarget} words of Markdown. Required sections, in this order, with these exact top-level headers:
# <TITLE> — RESEARCH DIRECTIVE
Two to six sentences of framing: what this run must produce, what it must NOT do (no broad histories, no restating context, no re-deriving what the request already establishes), and what the request already treats as settled.
# PRIMARY QUESTION
One question in a blockquote, then two to four sentences on the goal: what a useful answer is for, and what it is not for.
# CRITICAL DESIGN PRINCIPLE
The one constraint that most changes how agents should think about this request (for example: who the real decision-maker is, what must never be inherited, what the answer must be optimized for). If the request implies none, write one that sharpens the primary question. One short header line plus one paragraph.
# REQUIRED EPISTEMIC SEPARATION
${epistemic} One line per category: the label and the rule for using it.
# COVERAGE
The components, entities, functions, angles or candidates that MUST be addressed, as a bulleted list derived from the request. You may add obvious adjacent items; mark each added one "(added)". Agents are forbidden from skipping any listed item.
Named programs, people, organizations or documents in this list are SEEDS, not the territory: each is a trailhead the agent starts from, never a boundary it stops at. Phrase named items as "starting from X" and end the section with a mandatory bullet: "BEYOND THE SEEDS: every agent must report what the seeds LED TO that is not on this list — the successor entity, the co-author, the next contract, the name that appears in the same document — and treat an unlisted find as more valuable than a confirmed listed one." A run that only verifies the seeds has failed this section.
# KNOWN FAILURE MODES
At least ${cfg.depth === "recon" ? 3 : 5} traps specific to THIS topic (not generic research advice), each as a short bold name and one line on how an agent would detect it.
# REQUIRED DELIVERABLES
Exactly ${deliverableCount} numbered deliverables. For each: a short name, the shape (table with named columns / ranked list / matrix / labelled section / pipeline), and one line on what it must contain. These become the backbone of the synthesis; make them concrete enough that a missing one is obvious.
# RESEARCH BEHAVIOR
When agents should use live web search and when they should rely on the authoritative context; which kinds of sources are primary for this request; what "already known" means here so agents do not re-research it; and the rule that absence from a limited search is never evidence of absence.
# SUCCESS CONDITION
What a passing synthesis looks like, in the requester's own terms: three to six bullet points that could be checked against the final document.
RULES:
- THE EVIDENCE POLICY ABOVE GOVERNS EVERY SECTION YOU WRITE. The epistemic categories, failure modes, research behaviour and success condition must all score sources by chain of custody, never by who published them. One failure mode is FORBIDDEN: "the source is an anonymous forum / community site / blog" is NOT slippage and must not appear as a failure signal. Slippage is a claim with no artifact behind it. Provenance tags describe chain of custody, not permission to use: a [community lore] or [witness testimony] item with a recoverable original is evidence and may be a building block. Never write that any tag "never forms a building block" or similar.
- NO QUOTAS ON FINDINGS. Never set a numeric floor on what the research must find ("at least 10 individuals", "no fewer than 5 patents", "minimum 3 labs"). Quotas on evidence invite fabrication. Floors apply only to STRUCTURE: every deliverable present, every claim tagged, every gap registered as an open question. A deliverable's content line says what it must contain, never how many.
- Use only the epistemic tags you defined in REQUIRED EPISTEMIC SEPARATION; do not introduce new ones elsewhere in the directive.
- Write in English throughout.
- IMPROVE, DO NOT REPLACE. If the request already contains any of these sections or their equivalents (a stated primary question, deliverables, constraints, failure modes, a success condition), carry that material into the matching section VERBATIM and fill only what is missing. A request that is already a full directive should come out nearly unchanged plus the sections it lacked.
- Never research, never answer the question, never invent facts about the subject. Your knowledge of the domain may shape COVERAGE and KNOWN FAILURE MODES; it may not supply findings.
- Never copy or summarize the request text into the directive except where the IMPROVE rule requires carrying a section forward.
- Write in direct imperative prose. No preamble, no closing remarks, no commentary about this task. Output the directive only, starting with the title header.`;
}
async function conditionDirective(rawTopic: string, settings: any, cfg: ConditionerConfig): Promise<string | null> {
const inputWords = rawTopic.trim().split(/\s+/).filter(Boolean).length;
if (inputWords > CONDITIONER_MAX_INPUT_WORDS) {
console.warn(`[Conditioner] skipped: input is ${inputWords} words (ceiling ${CONDITIONER_MAX_INPUT_WORDS}).`);
return null;
}
const prompt = buildConditionerPrompt(rawTopic, cfg);
let acc = "";
try {
await Promise.race([
runUniversalStream("orchestrator", settings, prompt, CONDITIONER_SYSTEM, false, (t) => { acc += t; }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`conditioner timed out after ${CONDITIONER_TIMEOUT_MS / 1000}s`)), CONDITIONER_TIMEOUT_MS)),
]);
} catch (err: any) {
console.warn(`[Conditioner] failed, using raw topic: ${err?.message || err}`);
return null;
}
const directive = acc.trim();
const outWords = directive.split(/\s+/).filter(Boolean).length;
const hasPrimary = /^#\s*PRIMARY QUESTION/m.test(directive);
if (outWords < CONDITIONER_MIN_OUTPUT_WORDS || !hasPrimary) {
console.warn(`[Conditioner] output rejected (${outWords} words, primary-question header ${hasPrimary ? "present" : "missing"}); using raw topic.`);
return null;
}
console.log(`[Conditioner] expanded ${inputWords} → ${outWords} directive words.`);
return directive;
}
// -------------------------------------------------------------
// Fringe Mode — case-file investigation prompt blocks
// -------------------------------------------------------------
// The toggle flips the pipeline from verdict-oriented reporting to evidence
// accumulation: investigation-native personas, non-mainstream sourcing,
// provenance tagging, and an Evidence Docket synthesis that may legitimately
// conclude "insufficient to conclude".
const FRINGE_ORCHESTRATOR_HINT = `
FRINGE MODE — CASE FILE INVESTIGATION: This topic sits on the edges of mainstream coverage (fringe, esoteric, anomalous, heterodox, or frontier territory). Frame your mission analysis as OPENING A CASE FILE: what is actually claimed or reported, what evidence could exist, where that evidence would live, and what would move the case forward. Sprout investigation-native specialists fitted to this exact territory — examples of the species: an archives/FOIA hound for declassified and official records, an insider-practitioner fluent in the community's own literature, a historian of the subject's lineage, an anomaly cataloguer who inventories documented incidents, a lore cartographer mapping claims to their original sources, a frontier-lab watcher for research edges. Derive the team from the topic; do not force these examples. Do NOT field a mainstream-consensus gatekeeper persona ("the debunker") — in a case file, rigor lives in provenance, not dismissal.
ANCHOR REQUIREMENT (critical): each specialist's investigative assignment must NAME 2-4 concrete anchors to run down — specific facilities, programs, products, texts, traditions, researchers, or incidents — drawn from YOUR OWN knowledge of this territory's canon, including canonical examples the research request itself never mentions. An abstract mandate hides concrete trailheads; a fringe veteran already knows where they are. Abstract assignments ("analyze the pattern of...") with no named anchors are unacceptable.`;
const FRINGE_AGENT_RULES = `FRINGE INVESTIGATION RULES (mandatory — you are working a CASE FILE, not writing a verdict):
- CANON FIRST. Before anything else, lay out what the fringe/esoteric canon already holds on this territory FROM YOUR OWN KNOWLEDGE: the relevant traditions and their key terms, the texts and researchers, the recurring symbols and codename lineages, and the famous prior cases — then use live search to verify, date, and extend that map. A fringe veteran starts from the canon in their head, not from a blank search box. Name names; abstract pattern-talk without named anchors is a failed investigation.
- CONNECT ACROSS DOMAINS. The method of this territory is lineage-tracing: the same name, number, or symbol recurring across eras and institutions (a defense facility, an ancient tradition, a consumer product) IS the evidence trail. When you meet a loaded name, actively sweep the other domains it might appear in.
- Investigations accumulate. Collect and catalog evidence; do NOT close the case because early evidence is thin. A detective does not find the first clue and declare the crime never happened.
- Document the territory ON ITS OWN TERMS: map the claims, incidents, lineages, key figures, and internal logic faithfully. Do not pad the report with reflexive "however, experts dismiss this" hedging — skepticism belongs in provenance, not editorializing.
- PROVENANCE-TAG every major claim with one of: [primary text], [community lore], [witness testimony], [documented anomaly], [official record], [verified]. Let the tags do the epistemics.
- HUNT NON-MAINSTREAM SOURCES: archives and special collections (archive.org and national archives), declassified/FOIA reading rooms, court records, patent filings, out-of-print books and scans, preprints, niche journals, practitioner forums and communities, original-era newspapers. Mainstream summaries are a starting point, never the destination.
- End your report with an '## Open Leads' section listing 3-6 SPECIFIC, followable threads (a named archive to pull, a person to trace, a document to locate, a claim to cross-check) — never vague "more research needed" filler.`;
const FRINGE_SYNTHESIS_STRUCTURE = (topic: string) => `# ${topic}: Evidence Docket — Case File Synthesis
## 1. Case Status
- Open with exactly one status: EVIDENCE ACCUMULATING, THREADS CONVERGING, ACCOUNTS CONTESTED, or CASE COLD — followed by a short justification.
- "Insufficient to conclude" is a valid, respectable finding. Do NOT force a verdict the evidence cannot carry.
## 2. Case Overview
- What is claimed or reported, the scope of this investigation, and the specialist angles fielded.
## 3. Evidence Catalog
- The material gathered, organized by theme. PRESERVE the specialists' provenance tags ([primary text], [witness testimony], [official record], etc.) and citations.
## 4. Converging Threads
- Where independent lines of evidence point the same direction — the strongest patterns in the file.
## 5. Contradictions & Contested Ground
- Where accounts conflict, where evidence undercuts claims, and what remains genuinely unresolved.
## 6. Open Leads
- Consolidate and deduplicate the specialists' open leads into a prioritized list.
- Format each as: "LEAD: <the specific followable thread> — WHY: <what it could resolve>".
## 7. Case Notes
- Closing observations: the overall quality of the file, collection gaps, and what the next commission should target.`;
// -------------------------------------------------------------
// Sentinel Mode — delta sweep prompt blocks
// -------------------------------------------------------------
// A delta sweep (priorContext.delta) is a follow-up run whose sole mission is
// CHANGE DETECTION: agents hunt what moved since the prior findings instead
// of extending them, and the synthesis becomes a Delta Briefing. Manual
// trigger only — there is deliberately no scheduling/cron anywhere.
const DELTA_ORCHESTRATOR_DIRECTIVE = `
DELTA SWEEP — SENTINEL RE-CHECK: This run is a standing-watch sweep over a completed investigation, NOT a fresh survey. Sprout agents whose assignments are explicitly about CHANGE DETECTION since the prior run: new developments, new evidence, corrections, retractions, reversals, and shifts in the landscape the prior findings describe. Slice the prior synthesis into watch areas and assign each agent a slice — every assignment should read as "what has changed about X since the prior run", never "investigate X". Do NOT field agents to re-survey ground the prior synthesis already covers.`;
const DELTA_AGENT_RULES = `DELTA RULES (mandatory — this is a sentinel sweep, not a fresh investigation):
- Report ONLY what is NEW, CHANGED, or CORRECTED since the prior findings above. Do not restate stable prior material except as a one-line anchor for a change ("was X → now Y").
- For every area of your assignment where nothing has moved, say so explicitly: "No change detected — <area>". A verified absence of change is a finding; silence is not.
- Tag each finding as one of: NEW (previously unreported), CHANGED (prior finding needs updating), CORRECTED (prior finding was wrong), or CONFIRMED (prior finding independently re-verified).
- Date-stamp every change and cite the source establishing it happened after the prior run.`;
const DELTA_SYNTHESIS_STRUCTURE = (topic: string) => `# ${topic}: Delta Briefing
## 1. Sweep Summary
- One-paragraph verdict: how much has moved since the prior run — a lot, a little, or nothing — and the single most consequential change (or the absence of one).
- State the sweep window explicitly (prior run → today).
## 2. What's New