@duckmind/dm-windows-x64 0.60.6 → 0.60.8
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/dm.exe +0 -0
- package/extensions/.dm-extensions.json +70 -123
- package/extensions/dm-9router-ext/src/index.js +410 -5
- package/extensions/dm-caveman/extensions/caveman.js +283 -12
- package/extensions/dm-cliproxy/index.js +182 -2
- package/extensions/dm-cliproxy/scripts/check-config-migration.js +66 -8
- package/extensions/dm-cliproxy/src/apply.js +228 -1
- package/extensions/dm-cliproxy/src/cache.js +42 -1
- package/extensions/dm-cliproxy/src/commands.js +50 -2
- package/extensions/dm-cliproxy/src/compat.js +81 -1
- package/extensions/dm-cliproxy/src/config.js +192 -2
- package/extensions/dm-cliproxy/src/conflicts.js +46 -1
- package/extensions/dm-cliproxy/src/fetch-models.js +190 -1
- package/extensions/dm-cliproxy/src/fetch-usage.js +41 -1
- package/extensions/dm-cliproxy/src/log.js +23 -1
- package/extensions/dm-cliproxy/src/status-quota.js +77 -1
- package/extensions/dm-cliproxy/src/ui-frame.js +50 -1
- package/extensions/dm-cliproxy/src/ui-hub/hub.js +199 -2
- package/extensions/dm-cliproxy/src/ui-hub/index.js +26 -2
- package/extensions/dm-cliproxy/src/ui-hub/shell.js +46 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-diagnostics.js +69 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-models.js +379 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-usage.js +106 -1
- package/extensions/dm-cliproxy/src/ui-picker/catalog.js +41 -1
- package/extensions/dm-cliproxy/src/ui-picker/mutate.js +115 -1
- package/extensions/dm-cliproxy/src/ui-picker/prompt-confirm.js +35 -1
- package/extensions/dm-cliproxy/src/ui-picker/prompt-name.js +62 -1
- package/extensions/dm-cliproxy/src/ui-picker/providers.js +45 -1
- package/extensions/dm-cliproxy/src/ui-picker/render-text.js +34 -1
- package/extensions/dm-cliproxy/src/ui-picker/rows.js +57 -1
- package/extensions/dm-cliproxy/src/ui-setup.js +260 -2
- package/extensions/dm-cliproxy/src/ui-usage.js +151 -1
- package/extensions/dm-cliproxy/src/usage-shared-cache.js +97 -1
- package/extensions/dm-context/src/context.js +144 -1
- package/extensions/dm-context/src/index.js +339 -7
- package/extensions/dm-context/src/utils.js +9 -1
- package/extensions/dm-cua/bin/browser-cua.mjs +73 -8
- package/extensions/dm-cua/index.js +75 -6
- package/extensions/dm-cua/src/browser-cua-lib.mjs +490 -6
- package/extensions/dm-cua/src/browser-install.mjs +331 -2
- package/extensions/dm-fff/src/index.js +688 -12
- package/extensions/dm-fff/src/query.js +60 -1
- package/extensions/dm-goal/src/goal.js +894 -23
- package/extensions/dm-image2/index.js +103 -9
- package/extensions/dm-image2/src/image-lib.mjs +1275 -8
- package/extensions/dm-subagents/install.mjs +62 -8
- package/extensions/dm-subagents/src/agents/agent-management.js +1190 -36
- package/extensions/dm-subagents/src/agents/agent-memory.js +216 -6
- package/extensions/dm-subagents/src/agents/agent-scope.js +5 -1
- package/extensions/dm-subagents/src/agents/agent-selection.js +20 -1
- package/extensions/dm-subagents/src/agents/agent-serializer.js +120 -5
- package/extensions/dm-subagents/src/agents/agents.js +1139 -11
- package/extensions/dm-subagents/src/agents/chain-serializer.js +299 -11
- package/extensions/dm-subagents/src/agents/frontmatter.js +65 -6
- package/extensions/dm-subagents/src/agents/identity.js +29 -1
- package/extensions/dm-subagents/src/agents/proactive-skills.js +141 -1
- package/extensions/dm-subagents/src/agents/skills.js +614 -6
- package/extensions/dm-subagents/src/extension/config.js +35 -2
- package/extensions/dm-subagents/src/extension/control-notices.js +69 -4
- package/extensions/dm-subagents/src/extension/doctor.js +172 -15
- package/extensions/dm-subagents/src/extension/fanout-child.js +158 -231
- package/extensions/dm-subagents/src/extension/index.js +521 -359
- package/extensions/dm-subagents/src/extension/rpc.js +266 -7
- package/extensions/dm-subagents/src/extension/schemas.js +275 -1
- package/extensions/dm-subagents/src/extension/tool-description.js +111 -6
- package/extensions/dm-subagents/src/intercom/intercom-bridge.js +126 -4
- package/extensions/dm-subagents/src/intercom/native-supervisor-channel.js +452 -5
- package/extensions/dm-subagents/src/intercom/result-intercom.js +319 -3
- package/extensions/dm-subagents/src/profiles/profiles.js +458 -3
- package/extensions/dm-subagents/src/runs/background/async-execution.js +834 -40
- package/extensions/dm-subagents/src/runs/background/async-job-tracker.js +435 -14
- package/extensions/dm-subagents/src/runs/background/async-resume.js +334 -8
- package/extensions/dm-subagents/src/runs/background/async-status.js +313 -12
- package/extensions/dm-subagents/src/runs/background/chain-append.js +245 -2
- package/extensions/dm-subagents/src/runs/background/chain-root-attachment.js +136 -1
- package/extensions/dm-subagents/src/runs/background/completion-batcher.js +94 -1
- package/extensions/dm-subagents/src/runs/background/completion-dedupe.js +54 -1
- package/extensions/dm-subagents/src/runs/background/control-channel.js +190 -1
- package/extensions/dm-subagents/src/runs/background/fleet-view.js +483 -17
- package/extensions/dm-subagents/src/runs/background/notify.js +129 -3
- package/extensions/dm-subagents/src/runs/background/parallel-groups.js +34 -1
- package/extensions/dm-subagents/src/runs/background/result-watcher.js +236 -6
- package/extensions/dm-subagents/src/runs/background/run-id-resolver.js +76 -4
- package/extensions/dm-subagents/src/runs/background/run-status.js +427 -23
- package/extensions/dm-subagents/src/runs/background/scheduled-runs.js +487 -4
- package/extensions/dm-subagents/src/runs/background/stale-run-reconciler.js +306 -9
- package/extensions/dm-subagents/src/runs/background/subagent-runner.js +2849 -73
- package/extensions/dm-subagents/src/runs/background/top-level-async.js +5 -1
- package/extensions/dm-subagents/src/runs/background/wait.js +206 -11
- package/extensions/dm-subagents/src/runs/foreground/chain-clarify.js +1013 -12
- package/extensions/dm-subagents/src/runs/foreground/chain-execution.js +980 -101
- package/extensions/dm-subagents/src/runs/foreground/execution.js +1165 -45
- package/extensions/dm-subagents/src/runs/foreground/subagent-executor.js +3157 -222
- package/extensions/dm-subagents/src/runs/shared/acceptance.js +835 -3
- package/extensions/dm-subagents/src/runs/shared/chain-outputs.js +104 -1
- package/extensions/dm-subagents/src/runs/shared/completion-guard.js +116 -3
- package/extensions/dm-subagents/src/runs/shared/dm-args.js +208 -1
- package/extensions/dm-subagents/src/runs/shared/dm-spawn.js +90 -1
- package/extensions/dm-subagents/src/runs/shared/dynamic-fanout.js +282 -1
- package/extensions/dm-subagents/src/runs/shared/long-running-guard.js +148 -1
- package/extensions/dm-subagents/src/runs/shared/mcp-direct-tool-allowlist.js +305 -1
- package/extensions/dm-subagents/src/runs/shared/model-fallback.js +194 -1
- package/extensions/dm-subagents/src/runs/shared/model-scope.js +65 -1
- package/extensions/dm-subagents/src/runs/shared/nested-events.js +851 -8
- package/extensions/dm-subagents/src/runs/shared/nested-path.js +41 -1
- package/extensions/dm-subagents/src/runs/shared/nested-render.js +105 -1
- package/extensions/dm-subagents/src/runs/shared/parallel-utils.js +81 -4
- package/extensions/dm-subagents/src/runs/shared/run-history.js +51 -4
- package/extensions/dm-subagents/src/runs/shared/single-output.js +149 -8
- package/extensions/dm-subagents/src/runs/shared/structured-output.js +58 -1
- package/extensions/dm-subagents/src/runs/shared/subagent-control.js +166 -5
- package/extensions/dm-subagents/src/runs/shared/subagent-prompt-runtime.js +329 -13
- package/extensions/dm-subagents/src/runs/shared/tool-budget.js +73 -1
- package/extensions/dm-subagents/src/runs/shared/turn-budget.js +47 -4
- package/extensions/dm-subagents/src/runs/shared/workflow-graph.js +196 -1
- package/extensions/dm-subagents/src/runs/shared/worktree.js +435 -3
- package/extensions/dm-subagents/src/shared/artifacts.js +92 -2
- package/extensions/dm-subagents/src/shared/atomic-json.js +55 -1
- package/extensions/dm-subagents/src/shared/child-transcript.js +167 -5
- package/extensions/dm-subagents/src/shared/file-coalescer.js +25 -1
- package/extensions/dm-subagents/src/shared/fork-context.js +147 -4
- package/extensions/dm-subagents/src/shared/formatters.js +98 -7
- package/extensions/dm-subagents/src/shared/jsonl-writer.js +56 -2
- package/extensions/dm-subagents/src/shared/model-info.js +62 -1
- package/extensions/dm-subagents/src/shared/post-exit-stdio-guard.js +68 -1
- package/extensions/dm-subagents/src/shared/session-identity.js +6 -1
- package/extensions/dm-subagents/src/shared/session-tokens.js +39 -2
- package/extensions/dm-subagents/src/shared/settings.js +198 -11
- package/extensions/dm-subagents/src/shared/status-format.js +53 -1
- package/extensions/dm-subagents/src/shared/types.js +184 -6
- package/extensions/dm-subagents/src/shared/utils.js +462 -2
- package/extensions/dm-subagents/src/slash/prompt-template-bridge.js +288 -1
- package/extensions/dm-subagents/src/slash/prompt-workflows.js +297 -7
- package/extensions/dm-subagents/src/slash/slash-bridge.js +118 -1
- package/extensions/dm-subagents/src/slash/slash-commands.js +1287 -31
- package/extensions/dm-subagents/src/slash/slash-live-state.js +240 -4
- package/extensions/dm-subagents/src/tui/render-helpers.js +64 -1
- package/extensions/dm-subagents/src/tui/render.js +1542 -4
- package/extensions/dm-usage/index.js +1294 -9
- package/extensions/greedysearch-dm/bin/cdp-greedy.mjs +40 -9
- package/extensions/greedysearch-dm/bin/cdp-headless.mjs +5 -2
- package/extensions/greedysearch-dm/bin/cdp-visible.mjs +5 -2
- package/extensions/greedysearch-dm/bin/cdp.mjs +896 -30
- package/extensions/greedysearch-dm/bin/gschrome.mjs +30 -2
- package/extensions/greedysearch-dm/bin/kill-visible.mjs +7 -2
- package/extensions/greedysearch-dm/bin/launch-visible.mjs +13 -2
- package/extensions/greedysearch-dm/bin/launch.mjs +282 -10
- package/extensions/greedysearch-dm/bin/mcp.mjs +386 -361
- package/extensions/greedysearch-dm/bin/search.mjs +620 -540
- package/extensions/greedysearch-dm/bin/visible.mjs +22 -2
- package/extensions/greedysearch-dm/extractors/bing-copilot.mjs +329 -579
- package/extensions/greedysearch-dm/extractors/chatgpt.mjs +301 -583
- package/extensions/greedysearch-dm/extractors/common.mjs +408 -32
- package/extensions/greedysearch-dm/extractors/consensus.mjs +376 -365
- package/extensions/greedysearch-dm/extractors/consent.mjs +303 -14
- package/extensions/greedysearch-dm/extractors/gemini.mjs +228 -592
- package/extensions/greedysearch-dm/extractors/google-ai.mjs +78 -499
- package/extensions/greedysearch-dm/extractors/logically.mjs +270 -347
- package/extensions/greedysearch-dm/extractors/perplexity.mjs +243 -581
- package/extensions/greedysearch-dm/extractors/selectors.mjs +32 -1
- package/extensions/greedysearch-dm/extractors/semantic-scholar.mjs +130 -317
- package/extensions/greedysearch-dm/index.js +123 -23
- package/extensions/greedysearch-dm/src/fetcher.mjs +576 -2
- package/extensions/greedysearch-dm/src/formatters/results.js +95 -10
- package/extensions/greedysearch-dm/src/formatters/sources.js +57 -1
- package/extensions/greedysearch-dm/src/formatters/synthesis.js +49 -1
- package/extensions/greedysearch-dm/src/github.mjs +222 -7
- package/extensions/greedysearch-dm/src/reddit.mjs +145 -14
- package/extensions/greedysearch-dm/src/search/browser-lifecycle.mjs +340 -9
- package/extensions/greedysearch-dm/src/search/challenge-detect.mjs +112 -4
- package/extensions/greedysearch-dm/src/search/chrome.mjs +486 -285
- package/extensions/greedysearch-dm/src/search/constants.mjs +109 -8
- package/extensions/greedysearch-dm/src/search/defaults.mjs +10 -1
- package/extensions/greedysearch-dm/src/search/engines.mjs +79 -9
- package/extensions/greedysearch-dm/src/search/fetch-source.mjs +441 -349
- package/extensions/greedysearch-dm/src/search/file-sources.mjs +29 -7
- package/extensions/greedysearch-dm/src/search/minimize.mjs +86 -1
- package/extensions/greedysearch-dm/src/search/output.mjs +51 -5
- package/extensions/greedysearch-dm/src/search/paths.mjs +48 -1
- package/extensions/greedysearch-dm/src/search/pdf.mjs +63 -2
- package/extensions/greedysearch-dm/src/search/port-pid.mjs +69 -1
- package/extensions/greedysearch-dm/src/search/progress.mjs +109 -2
- package/extensions/greedysearch-dm/src/search/query.mjs +21 -1
- package/extensions/greedysearch-dm/src/search/recovery.mjs +49 -1
- package/extensions/greedysearch-dm/src/search/research.mjs +2227 -458
- package/extensions/greedysearch-dm/src/search/scale-aware.mjs +61 -11
- package/extensions/greedysearch-dm/src/search/simple-research.mjs +396 -805
- package/extensions/greedysearch-dm/src/search/sources.mjs +412 -1
- package/extensions/greedysearch-dm/src/search/synthesis-runner.mjs +127 -12
- package/extensions/greedysearch-dm/src/search/synthesis.mjs +202 -12
- package/extensions/greedysearch-dm/src/tools/greedy-search-handler.js +209 -23
- package/extensions/greedysearch-dm/src/tools/shared.js +226 -10
- package/extensions/greedysearch-dm/src/utils/content.mjs +35 -4
- package/extensions/greedysearch-dm/src/utils/helpers.js +22 -1
- package/extensions/greedysearch-dm/src/utils/node-runtime.mjs +10 -1
- package/extensions/greedysearch-dm/src/utils/system-cmds.mjs +61 -1
- package/package.json +1 -1
|
@@ -1,486 +1,2255 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { nodeRuntimeCommand } from "../utils/node-runtime.mjs";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
buildSourceRegistry,
|
|
8
|
+
classifySourceType,
|
|
9
|
+
computeCompositeScore,
|
|
10
|
+
mergeFetchDataIntoSources,
|
|
11
|
+
normalizeUrl,
|
|
12
|
+
trimText
|
|
13
|
+
} from "./sources.mjs";
|
|
14
|
+
import { parseStructuredJson } from "./synthesis.mjs";
|
|
15
|
+
import { ALL_ENGINES, RESEARCH_ENGINES } from "./constants.mjs";
|
|
16
|
+
import { runGeminiPrompt } from "./synthesis-runner.mjs";
|
|
17
|
+
import { classifyResearchComplexity } from "./scale-aware.mjs";
|
|
18
|
+
import { runSimpleResearchMode } from "./simple-research.mjs";
|
|
19
|
+
import { createProgressTracker } from "./progress.mjs";
|
|
20
|
+
const __dir = fileURLToPath(new URL(".", import.meta.url)).replace(/^\/([A-Z]:)/, "$1");
|
|
21
|
+
const SEARCH_BIN = join(__dir, "..", "..", "bin", "search.mjs");
|
|
22
|
+
const DEFAULT_RESEARCH_BUNDLE_ROOT = join(process.cwd(), ".pi", "greedysearch-research");
|
|
23
|
+
const MAX_PROMPT_CHARS = 28000;
|
|
24
|
+
function slugifyResearchName(value) {
|
|
25
|
+
const slug = String(value || "research").toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "").slice(0, 60);
|
|
26
|
+
return slug || "research";
|
|
27
|
+
}
|
|
28
|
+
function uniqueStrings(items, limit = 1 / 0) {
|
|
29
|
+
const seen = new Set;
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const item of items || []) {
|
|
32
|
+
const clean = trimText(String(item || ""), 1000);
|
|
33
|
+
if (!clean || seen.has(clean))
|
|
34
|
+
continue;
|
|
35
|
+
seen.add(clean);
|
|
36
|
+
out.push(clean);
|
|
37
|
+
if (out.length >= limit)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
async function fetchMultipleResearchSources(...args) {
|
|
43
|
+
const { fetchMultipleSources } = await import("./fetch-source.mjs");
|
|
44
|
+
return fetchMultipleSources(...args);
|
|
45
|
+
}
|
|
46
|
+
async function writeResearchSourcesToFiles(...args) {
|
|
47
|
+
const { writeSourcesToFiles } = await import("./file-sources.mjs");
|
|
48
|
+
return writeSourcesToFiles(...args);
|
|
49
|
+
}
|
|
50
|
+
export function clampResearchOptions({
|
|
51
|
+
breadth = 3,
|
|
52
|
+
iterations = 2,
|
|
53
|
+
maxSources
|
|
54
|
+
}) {
|
|
55
|
+
const safeBreadth = clampInt(breadth, 1, 5, 3);
|
|
56
|
+
const safeIterations = clampInt(iterations, 1, 3, 2);
|
|
57
|
+
const safeMaxSources = clampInt(maxSources ?? Math.max(5, safeBreadth * safeIterations * 2), 3, 12, 8);
|
|
58
|
+
return {
|
|
59
|
+
breadth: safeBreadth,
|
|
60
|
+
iterations: safeIterations,
|
|
61
|
+
maxSources: safeMaxSources
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function clampInt(value, min, max, fallback) {
|
|
65
|
+
const n = Number.parseInt(String(value ?? ""), 10);
|
|
66
|
+
if (!Number.isFinite(n))
|
|
67
|
+
return fallback;
|
|
68
|
+
return Math.min(max, Math.max(min, n));
|
|
69
|
+
}
|
|
70
|
+
export function normalizeResearchQueries(plan, originalQuery, breadth, { expand = true, includeOriginal = true, exclude = [] } = {}) {
|
|
71
|
+
const rawQueries = Array.isArray(plan?.queries) ? plan.queries : [];
|
|
72
|
+
const queries = [];
|
|
73
|
+
const excluded = new Set([...exclude].map((item) => sanitizeResearchQuery(item).toLowerCase()));
|
|
74
|
+
for (const item of rawQueries) {
|
|
75
|
+
const query = typeof item === "string" ? item : item?.query;
|
|
76
|
+
const researchGoal = typeof item === "string" ? "" : item?.researchGoal || "";
|
|
77
|
+
addResearchQuery(queries, query, researchGoal, { exclude: excluded });
|
|
78
|
+
}
|
|
79
|
+
if (includeOriginal) {
|
|
80
|
+
addResearchQuery(queries, originalQuery, "Original user query", {
|
|
81
|
+
prepend: true,
|
|
82
|
+
exclude: excluded
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (expand) {
|
|
86
|
+
const expansionQueries = [
|
|
87
|
+
{
|
|
88
|
+
query: `${originalQuery} official docs GitHub`,
|
|
89
|
+
researchGoal: "Find primary project docs, repository details, and maintainer claims."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
query: `${originalQuery} benchmarks limitations compatibility`,
|
|
93
|
+
researchGoal: "Validate performance claims and uncover unsupported APIs or caveats."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
query: `${originalQuery} alternatives comparison production use cases`,
|
|
97
|
+
researchGoal: "Compare against conventional headless browsers and identify when to choose it."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
query: `${originalQuery} anti bot detection Cloudflare screenshots visual rendering`,
|
|
101
|
+
researchGoal: "Check automation risks, rendering gaps, screenshots, and bot-detection behavior."
|
|
102
|
+
}
|
|
103
|
+
];
|
|
104
|
+
for (const item of expansionQueries) {
|
|
105
|
+
if (queries.length >= breadth)
|
|
106
|
+
break;
|
|
107
|
+
addResearchQuery(queries, item.query, item.researchGoal, {
|
|
108
|
+
exclude: excluded
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return queries.slice(0, breadth);
|
|
113
|
+
}
|
|
114
|
+
function addResearchQuery(queries, query, researchGoal = "", { prepend = false, exclude = new Set } = {}) {
|
|
115
|
+
if (!query || typeof query !== "string")
|
|
116
|
+
return;
|
|
117
|
+
const clean = sanitizeResearchQuery(query);
|
|
118
|
+
if (!clean || exclude.has(clean.toLowerCase()) || queries.some((q) => q.query.toLowerCase() === clean.toLowerCase())) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const item = { query: clean, researchGoal: trimText(researchGoal, 320) };
|
|
122
|
+
if (prepend)
|
|
123
|
+
queries.unshift(item);
|
|
124
|
+
else
|
|
125
|
+
queries.push(item);
|
|
126
|
+
}
|
|
127
|
+
function sanitizeResearchQuery(query) {
|
|
128
|
+
return collapseWhitespace(stripMarkdownLinks(String(query)));
|
|
129
|
+
}
|
|
130
|
+
function stripMarkdownLinks(value) {
|
|
131
|
+
let output = "";
|
|
132
|
+
let index = 0;
|
|
133
|
+
while (index < value.length) {
|
|
134
|
+
const openLabel = value.indexOf("[", index);
|
|
135
|
+
if (openLabel === -1) {
|
|
136
|
+
output += value.slice(index);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const closeLabel = value.indexOf("]", openLabel + 1);
|
|
140
|
+
if (closeLabel === -1 || value[closeLabel + 1] !== "(" || closeLabel === openLabel + 1) {
|
|
141
|
+
output += value.slice(index, openLabel + 1);
|
|
142
|
+
index = openLabel + 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const closeUrl = value.indexOf(")", closeLabel + 2);
|
|
146
|
+
if (closeUrl === -1) {
|
|
147
|
+
output += value.slice(index, openLabel + 1);
|
|
148
|
+
index = openLabel + 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const url = value.slice(closeLabel + 2, closeUrl).trimStart();
|
|
152
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
153
|
+
output += value.slice(index, openLabel + 1);
|
|
154
|
+
index = openLabel + 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
output += value.slice(index, openLabel);
|
|
158
|
+
output += value.slice(openLabel + 1, closeLabel);
|
|
159
|
+
index = closeUrl + 1;
|
|
160
|
+
}
|
|
161
|
+
return output;
|
|
162
|
+
}
|
|
163
|
+
function collapseWhitespace(value) {
|
|
164
|
+
let output = "";
|
|
165
|
+
let previousWasWhitespace = false;
|
|
166
|
+
for (const char of value) {
|
|
167
|
+
if (char === " " || char === "\t" || char === `
|
|
168
|
+
` || char === "\r") {
|
|
169
|
+
if (!previousWasWhitespace)
|
|
170
|
+
output += " ";
|
|
171
|
+
previousWasWhitespace = true;
|
|
172
|
+
} else {
|
|
173
|
+
output += char;
|
|
174
|
+
previousWasWhitespace = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return output.trim();
|
|
178
|
+
}
|
|
179
|
+
export function tokenSet(value) {
|
|
180
|
+
return new Set(String(value).toLowerCase().normalize("NFD").replaceAll(/[\u0300-\u036f]/g, "").split(/[^\w]+/).filter((t) => t.length > 1));
|
|
181
|
+
}
|
|
182
|
+
export function jaccardSimilarity(a, b) {
|
|
183
|
+
const tokensA = tokenSet(a);
|
|
184
|
+
const tokensB = tokenSet(b);
|
|
185
|
+
const unionSize = new Set([...tokensA, ...tokensB]).size;
|
|
186
|
+
if (unionSize === 0)
|
|
187
|
+
return 1;
|
|
188
|
+
let intersection = 0;
|
|
189
|
+
for (const t of tokensA) {
|
|
190
|
+
if (tokensB.has(t))
|
|
191
|
+
intersection++;
|
|
192
|
+
}
|
|
193
|
+
return intersection / unionSize;
|
|
194
|
+
}
|
|
195
|
+
export function isDuplicateQuery(query, usedQueries, { threshold = 0.75, roundIndex = 0, originalQuery = null } = {}) {
|
|
196
|
+
const normalized = sanitizeResearchQuery(query).toLowerCase();
|
|
197
|
+
if (usedQueries.has(normalized))
|
|
198
|
+
return true;
|
|
199
|
+
if (originalQuery && roundIndex > 0 && normalized === sanitizeResearchQuery(originalQuery).toLowerCase()) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
for (const used of usedQueries) {
|
|
203
|
+
if (jaccardSimilarity(normalized, used) >= threshold) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
function buildQualityEvaluationPrompt(originalQuery, rounds, allLearnings, allGaps) {
|
|
210
|
+
const roundSummaries = rounds.map((round) => ({
|
|
211
|
+
queries: round.queries?.map((q) => q.query || "") || [],
|
|
212
|
+
learnings: round.learnings || [],
|
|
213
|
+
gaps: round.gaps || []
|
|
214
|
+
}));
|
|
215
|
+
return [
|
|
216
|
+
"You are evaluating the quality of an iterative research run.",
|
|
217
|
+
"Assess coverage across: official sources, limitations/risks, benchmarks/performance, production usage, and counter-evidence.",
|
|
218
|
+
"Score each dimension 0-10. Overall score 0-10.",
|
|
219
|
+
"Identify remaining knowledge gaps.",
|
|
220
|
+
"Propose targeted next actions (search queries or direct URL fetches) that would most improve the research.",
|
|
221
|
+
"Decide whether to continue or stop.",
|
|
222
|
+
"terminationReason must be one of: quality_threshold | max_rounds | no_novel_actions | insufficient_evidence.",
|
|
223
|
+
"",
|
|
224
|
+
`Original research question: ${originalQuery}`,
|
|
225
|
+
`Rounds completed: ${JSON.stringify(roundSummaries, null, 2)}`,
|
|
226
|
+
`Accumulated learnings: ${JSON.stringify(allLearnings.slice(0, 12), null, 2)}`,
|
|
227
|
+
`Known gaps: ${JSON.stringify(allGaps.slice(0, 8), null, 2)}`,
|
|
228
|
+
"",
|
|
229
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
230
|
+
"BEGIN_JSON",
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
score: 7.5,
|
|
233
|
+
coverage: {
|
|
234
|
+
officialSources: 8,
|
|
235
|
+
limitations: 5,
|
|
236
|
+
benchmarks: 7,
|
|
237
|
+
productionUseCases: 6,
|
|
238
|
+
counterEvidence: 4
|
|
239
|
+
},
|
|
240
|
+
knowledgeGaps: ["specific gap or missing evidence"],
|
|
241
|
+
shouldContinue: true,
|
|
242
|
+
terminationReason: "quality_threshold",
|
|
243
|
+
nextActions: [
|
|
244
|
+
{ type: "search", query: "targeted search query" },
|
|
245
|
+
{ type: "fetchUrl", url: "https://example.com/primary-doc" }
|
|
246
|
+
]
|
|
247
|
+
}, null, 2),
|
|
248
|
+
"END_JSON"
|
|
249
|
+
].join(`
|
|
250
|
+
`);
|
|
251
|
+
}
|
|
252
|
+
export function buildFallbackQueriesFromGaps(gaps, originalQuery, usedQueries, nextBreadth, roundIndex) {
|
|
253
|
+
const fallbacks = [];
|
|
254
|
+
const angles = [
|
|
255
|
+
{
|
|
256
|
+
template: (gap) => `${gap} official documentation`,
|
|
257
|
+
label: "official docs"
|
|
105
258
|
},
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
get: () => {
|
|
110
|
-
__greedyMimeTypes = __greedyMimeTypes || __makeMimeTypes();
|
|
111
|
-
return __greedyMimeTypes;
|
|
259
|
+
{
|
|
260
|
+
template: (gap) => `${gap} GitHub issues discussions`,
|
|
261
|
+
label: "community signals"
|
|
112
262
|
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
263
|
+
{
|
|
264
|
+
template: (gap) => `${gap} benchmarks performance comparison`,
|
|
265
|
+
label: "benchmarks"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
template: (gap) => `${gap} limitations risks caveats`,
|
|
269
|
+
label: "limitations"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
template: (gap) => `${gap} production deployment experience`,
|
|
273
|
+
label: "production usage"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
template: (gap) => `${originalQuery} ${gap} counter evidence`,
|
|
277
|
+
label: "counter-evidence"
|
|
278
|
+
}
|
|
279
|
+
];
|
|
280
|
+
for (let i = 0;i < gaps.length && fallbacks.length < nextBreadth; i++) {
|
|
281
|
+
const gap = gaps[i];
|
|
282
|
+
const angle = angles[i % angles.length];
|
|
283
|
+
const candidate = angle.template(gap);
|
|
284
|
+
if (!isDuplicateQuery(candidate, usedQueries, { roundIndex })) {
|
|
285
|
+
fallbacks.push({
|
|
286
|
+
query: candidate,
|
|
287
|
+
researchGoal: `Gap-driven: ${gap} (${angle.label})`
|
|
288
|
+
});
|
|
289
|
+
}
|
|
132
290
|
}
|
|
133
|
-
|
|
291
|
+
return fallbacks;
|
|
292
|
+
}
|
|
293
|
+
async function evaluateResearchQuality(originalQuery, rounds, allLearnings, allGaps, qualityHistory) {
|
|
134
294
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
295
|
+
const rawEvaluation = await runGeminiPrompt(buildQualityEvaluationPrompt(originalQuery, rounds, allLearnings, allGaps), { timeoutMs: 120000 });
|
|
296
|
+
const evaluation = parseGeminiJson(rawEvaluation, {});
|
|
297
|
+
const score = typeof evaluation.score === "number" ? Math.min(10, Math.max(0, evaluation.score)) : qualityHistory.length > 0 ? qualityHistory[qualityHistory.length - 1] : 5;
|
|
298
|
+
const gaps = Array.isArray(evaluation.knowledgeGaps) ? evaluation.knowledgeGaps.map((g) => String(g)).filter(Boolean).slice(0, 6) : [];
|
|
299
|
+
const nextActions = Array.isArray(evaluation.nextActions) ? evaluation.nextActions.slice(0, 5) : [];
|
|
300
|
+
const shouldContinue = typeof evaluation.shouldContinue === "boolean" ? evaluation.shouldContinue : score < 8;
|
|
301
|
+
const terminationReason = evaluation.terminationReason || null;
|
|
302
|
+
return {
|
|
303
|
+
score,
|
|
304
|
+
coverage: evaluation.coverage || {},
|
|
305
|
+
knowledgeGaps: gaps,
|
|
306
|
+
shouldContinue,
|
|
307
|
+
nextActions,
|
|
308
|
+
terminationReason: terminationReason || (score >= 8.5 ? "quality_threshold" : null),
|
|
309
|
+
evaluationError: ""
|
|
310
|
+
};
|
|
311
|
+
} catch (error) {
|
|
312
|
+
process.stderr.write(`[greedysearch] Quality evaluation failed: ${error.message}
|
|
313
|
+
`);
|
|
314
|
+
return {
|
|
315
|
+
score: qualityHistory.length > 0 ? qualityHistory[qualityHistory.length - 1] : 5,
|
|
316
|
+
coverage: {},
|
|
317
|
+
knowledgeGaps: [],
|
|
318
|
+
shouldContinue: true,
|
|
319
|
+
nextActions: [],
|
|
320
|
+
terminationReason: null,
|
|
321
|
+
evaluationError: error.message
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function summarizeEngineAnswers(result) {
|
|
326
|
+
const summaries = {};
|
|
327
|
+
for (const engine of Object.keys(result || {}).filter((key) => !key.startsWith("_"))) {
|
|
328
|
+
const value = result?.[engine];
|
|
329
|
+
if (!value)
|
|
330
|
+
continue;
|
|
331
|
+
summaries[engine] = value.error ? { status: "error", error: String(value.error) } : {
|
|
332
|
+
status: "ok",
|
|
333
|
+
answer: trimText(value.answer || "", 1400),
|
|
334
|
+
sources: Array.isArray(value.sources) ? value.sources.slice(0, 5).map((s) => ({
|
|
335
|
+
title: trimText(s.title || "", 160),
|
|
336
|
+
url: s.url || ""
|
|
337
|
+
})) : []
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return summaries;
|
|
341
|
+
}
|
|
342
|
+
function buildResearchActionPrompt(query, breadth, learnings = [], gaps = [], usedUrls = []) {
|
|
343
|
+
const gapSection = gaps.length > 0 ? `
|
|
344
|
+
Known knowledge gaps to target:
|
|
345
|
+
${gaps.map((g) => `- ${g}`).join(`
|
|
346
|
+
`)}` : "";
|
|
347
|
+
const usedUrlSection = usedUrls.length > 0 ? `
|
|
348
|
+
Already fetched URLs (do not re-fetch):
|
|
349
|
+
${usedUrls.map((u) => `- ${u}`).join(`
|
|
350
|
+
`)}` : "";
|
|
351
|
+
return [
|
|
352
|
+
"You are planning web research actions for a multi-engine search agent.",
|
|
353
|
+
"You can plan two types of actions:",
|
|
354
|
+
' - "search": run a multi-engine SERP search query',
|
|
355
|
+
' - "fetchUrl": directly fetch a specific URL (docs page, GitHub repo, specification, etc.)',
|
|
356
|
+
'Prefer "fetchUrl" when a specific primary source URL is known or obvious.',
|
|
357
|
+
'Use "search" for broad discovery or when specific URLs are unknown.',
|
|
358
|
+
`Return at most ${breadth} actions.`,
|
|
359
|
+
"Avoid near-duplicate search queries and already-fetched URLs.",
|
|
360
|
+
"",
|
|
361
|
+
`User topic: ${query}`,
|
|
362
|
+
learnings.length ? `
|
|
363
|
+
Prior learnings to build on:
|
|
364
|
+
${learnings.map((l) => `- ${l}`).join(`
|
|
365
|
+
`)}` : "",
|
|
366
|
+
gapSection,
|
|
367
|
+
usedUrlSection,
|
|
368
|
+
"",
|
|
369
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
370
|
+
"BEGIN_JSON",
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
actions: [
|
|
373
|
+
{
|
|
374
|
+
type: "search",
|
|
375
|
+
query: "specific search query",
|
|
376
|
+
researchGoal: "what this action should clarify"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
type: "fetchUrl",
|
|
380
|
+
url: "https://example.com/docs/relevant-page",
|
|
381
|
+
researchGoal: "extract specific information from this page"
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
}, null, 2),
|
|
385
|
+
"END_JSON"
|
|
386
|
+
].join(`
|
|
387
|
+
`);
|
|
388
|
+
}
|
|
389
|
+
export function validateAction(action) {
|
|
390
|
+
if (!action || typeof action !== "object")
|
|
391
|
+
return null;
|
|
392
|
+
const type = action.type;
|
|
393
|
+
const researchGoal = trimText(action.researchGoal || "", 320);
|
|
394
|
+
if (type === "search") {
|
|
395
|
+
if (action.query == null)
|
|
396
|
+
return null;
|
|
397
|
+
const query = sanitizeResearchQuery(action.query);
|
|
398
|
+
return query ? { type: "search", query, researchGoal } : null;
|
|
399
|
+
}
|
|
400
|
+
if (type === "fetchUrl") {
|
|
401
|
+
if (action.url == null)
|
|
402
|
+
return null;
|
|
403
|
+
const url = normalizeUrl(action.url);
|
|
404
|
+
return url ? { type: "fetchUrl", url, researchGoal } : null;
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
async function executeResearchAction(action, { locale = null, short = true, usedQueries, usedUrls, maxChars = 8000 } = {}) {
|
|
409
|
+
if (action.type === "search") {
|
|
410
|
+
const normalizedQuery = sanitizeResearchQuery(action.query).toLowerCase();
|
|
411
|
+
usedQueries.add(normalizedQuery);
|
|
412
|
+
try {
|
|
413
|
+
const result = await runFastAllSearch(action.query, { locale, short });
|
|
414
|
+
const sources = buildSourceRegistry(result, action.query);
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
action,
|
|
418
|
+
result,
|
|
419
|
+
sources
|
|
420
|
+
};
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
action,
|
|
425
|
+
error: error.message,
|
|
426
|
+
sources: []
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (action.type === "fetchUrl") {
|
|
431
|
+
const normalizedUrl = normalizeUrl(action.url);
|
|
432
|
+
if (usedUrls.has(normalizedUrl)) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
action,
|
|
436
|
+
error: `URL already fetched: ${normalizedUrl}`,
|
|
437
|
+
sources: []
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const fetchResult = await fetchSingleResearchSource(normalizedUrl, maxChars);
|
|
442
|
+
usedUrls.add(normalizedUrl);
|
|
443
|
+
const domain = getDomainFromUrl(normalizedUrl);
|
|
444
|
+
const source = {
|
|
445
|
+
id: "",
|
|
446
|
+
canonicalUrl: fetchResult.finalUrl || normalizedUrl,
|
|
447
|
+
displayUrl: fetchResult.url || normalizedUrl,
|
|
448
|
+
domain,
|
|
449
|
+
title: fetchResult.title || normalizedUrl,
|
|
450
|
+
engines: ["fetch"],
|
|
451
|
+
engineCount: 1,
|
|
452
|
+
perEngine: {},
|
|
453
|
+
sourceType: classifySourceType(domain, fetchResult.title || "", fetchResult.finalUrl || normalizedUrl),
|
|
454
|
+
isOfficial: false,
|
|
455
|
+
smartScore: 0,
|
|
456
|
+
fetch: {
|
|
457
|
+
attempted: true,
|
|
458
|
+
ok: !fetchResult.error && (fetchResult.contentChars || 0) > 100,
|
|
459
|
+
status: fetchResult.status || null,
|
|
460
|
+
finalUrl: fetchResult.finalUrl || normalizedUrl,
|
|
461
|
+
content: fetchResult.content || "",
|
|
462
|
+
contentChars: fetchResult.contentChars || 0,
|
|
463
|
+
snippet: fetchResult.snippet || "",
|
|
464
|
+
error: fetchResult.error || ""
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
action,
|
|
470
|
+
result: null,
|
|
471
|
+
sources: [source],
|
|
472
|
+
fetchResult: {
|
|
473
|
+
id: source.id,
|
|
474
|
+
url: normalizedUrl,
|
|
475
|
+
finalUrl: fetchResult.finalUrl || normalizedUrl,
|
|
476
|
+
title: fetchResult.title || "",
|
|
477
|
+
content: fetchResult.content || "",
|
|
478
|
+
contentChars: fetchResult.contentChars || 0,
|
|
479
|
+
snippet: fetchResult.snippet || "",
|
|
480
|
+
status: fetchResult.status || null,
|
|
481
|
+
error: fetchResult.error || "",
|
|
482
|
+
source: fetchResult.source || "http",
|
|
483
|
+
duration: fetchResult.duration || 0
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
action,
|
|
490
|
+
error: error.message,
|
|
491
|
+
sources: []
|
|
492
|
+
};
|
|
137
493
|
}
|
|
138
|
-
}
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
action,
|
|
498
|
+
error: `Unknown action type: ${action.type}`,
|
|
499
|
+
sources: []
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
async function fetchSingleResearchSource(url, maxChars) {
|
|
503
|
+
const { fetchSourceContent } = await import("./fetch-source.mjs");
|
|
504
|
+
return await fetchSourceContent(url, maxChars);
|
|
505
|
+
}
|
|
506
|
+
function getDomainFromUrl(rawUrl) {
|
|
139
507
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
508
|
+
const domain = new URL(rawUrl).hostname.toLowerCase();
|
|
509
|
+
return domain.replace(/^www\./, "");
|
|
510
|
+
} catch {
|
|
511
|
+
return "";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function normalizeGitHubFetchActions(actions, usedUrls) {
|
|
515
|
+
const normalized = [];
|
|
516
|
+
const { parseGitHubUrl } = await import("../github.mjs");
|
|
517
|
+
for (const action of actions) {
|
|
518
|
+
if (action.type !== "fetchUrl") {
|
|
519
|
+
normalized.push(action);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const parsed = parseGitHubUrl(action.url);
|
|
523
|
+
if (!parsed || parsed.type !== "root") {
|
|
524
|
+
normalized.push(action);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const { owner, repo } = parsed;
|
|
528
|
+
const base = `https://github.com/${owner}/${repo}`;
|
|
529
|
+
if (usedUrls.has(base)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const targets = [
|
|
533
|
+
base
|
|
534
|
+
];
|
|
535
|
+
const candidatePaths = [
|
|
536
|
+
`${base}/blob/main/CONTRIBUTING.md`,
|
|
537
|
+
`${base}/blob/master/CONTRIBUTING.md`,
|
|
538
|
+
`${base}/blob/main/CHANGELOG.md`,
|
|
539
|
+
`${base}/blob/master/CHANGELOG.md`,
|
|
540
|
+
`${base}/blob/main/docs/README.md`
|
|
541
|
+
];
|
|
542
|
+
for (const candidate of candidatePaths) {
|
|
543
|
+
if (targets.length >= 3)
|
|
544
|
+
break;
|
|
545
|
+
if (!usedUrls.has(candidate)) {
|
|
546
|
+
targets.push(candidate);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const url of targets) {
|
|
550
|
+
normalized.push({
|
|
551
|
+
type: "fetchUrl",
|
|
552
|
+
url,
|
|
553
|
+
researchGoal: action.researchGoal || `Fetch GitHub content for ${owner}/${repo}`
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return normalized;
|
|
558
|
+
}
|
|
559
|
+
export function parseActionPlan(rawJson, breadth) {
|
|
560
|
+
const parsed = parseStructuredJson(rawJson?.answer || "") || {};
|
|
561
|
+
const rawActions = Array.isArray(parsed?.actions) ? parsed.actions : [];
|
|
562
|
+
const actions = [];
|
|
563
|
+
for (const item of rawActions) {
|
|
564
|
+
const action = validateAction(item);
|
|
565
|
+
if (action && actions.length < breadth) {
|
|
566
|
+
actions.push(action);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return actions;
|
|
570
|
+
}
|
|
571
|
+
export function queriesToActions(queries) {
|
|
572
|
+
return (queries || []).map((q) => ({
|
|
573
|
+
type: "search",
|
|
574
|
+
query: typeof q === "string" ? q : q.query,
|
|
575
|
+
researchGoal: typeof q === "string" ? "" : q.researchGoal || ""
|
|
576
|
+
})).filter((a) => a.query);
|
|
577
|
+
}
|
|
578
|
+
function sourceKey(source) {
|
|
579
|
+
return normalizeUrl(source?.finalUrl || source?.canonicalUrl || source?.url || "") || source?.id || "";
|
|
580
|
+
}
|
|
581
|
+
function normalizeEvidenceExtractions(payload, fetchedSources) {
|
|
582
|
+
const raw = Array.isArray(payload?.extractions) ? payload.extractions : [];
|
|
583
|
+
const byUrl = new Map;
|
|
584
|
+
const byId = new Map;
|
|
585
|
+
for (const source of fetchedSources || []) {
|
|
586
|
+
if (source?.id)
|
|
587
|
+
byId.set(String(source.id), source);
|
|
588
|
+
const key = sourceKey(source);
|
|
589
|
+
if (key)
|
|
590
|
+
byUrl.set(key, source);
|
|
591
|
+
}
|
|
592
|
+
return raw.map((item) => {
|
|
593
|
+
const source = byId.get(String(item?.sourceId || "")) || byUrl.get(normalizeUrl(item?.url || "") || "");
|
|
594
|
+
const sourceId = String(item?.sourceId || source?.id || "");
|
|
595
|
+
const url = normalizeUrl(item?.url || source?.finalUrl || source?.url || "");
|
|
596
|
+
const answers = Array.isArray(item?.answers) ? item.answers.map((answer) => ({
|
|
597
|
+
id: String(answer?.id || ""),
|
|
598
|
+
evidence: trimText(answer?.evidence || "", 500),
|
|
599
|
+
sourceIds: [sourceId].filter(Boolean)
|
|
600
|
+
})).filter((answer) => answer.id) : [];
|
|
601
|
+
return {
|
|
602
|
+
sourceId,
|
|
603
|
+
url,
|
|
604
|
+
title: source?.title || item?.title || "",
|
|
605
|
+
rational: trimText(item?.rational || "", 700),
|
|
606
|
+
evidence: trimText(item?.evidence || "", 1600),
|
|
607
|
+
summary: trimText(item?.summary || "", 700),
|
|
608
|
+
answers,
|
|
609
|
+
newQuestions: uniqueStrings(item?.newQuestions || [], 6)
|
|
154
610
|
};
|
|
611
|
+
}).filter((item) => item.sourceId || item.url || item.summary || item.evidence);
|
|
612
|
+
}
|
|
613
|
+
function buildEvidenceExtractionPrompt(originalQuery, questions, fetchedSources, alreadyExtracted = new Set) {
|
|
614
|
+
const openQuestions = (questions || []).filter((q) => q.status !== "closed").slice(0, 12).map((q) => ({ id: q.id, question: q.question }));
|
|
615
|
+
const sourceSnippets = (fetchedSources || []).filter((source) => source?.content || source?.snippet).filter((source) => !alreadyExtracted.has(sourceKey(source))).slice(0, 6).map((source, index) => ({
|
|
616
|
+
id: source.id || `F${index + 1}`,
|
|
617
|
+
title: source.title || "",
|
|
618
|
+
url: source.finalUrl || source.url || source.canonicalUrl || "",
|
|
619
|
+
content: trimText(source.content || source.snippet || "", 5000)
|
|
620
|
+
}));
|
|
621
|
+
return [
|
|
622
|
+
"You are doing goal-based evidence extraction for an iterative research run.",
|
|
623
|
+
"For each source, extract only information that helps answer the open questions.",
|
|
624
|
+
"Use original wording/details where useful. Do not invent answers; leave questions open if evidence is insufficient.",
|
|
625
|
+
"If a source answers one or more tracked questions, identify those question IDs explicitly.",
|
|
626
|
+
"Also propose genuinely new sub-questions discovered from the evidence.",
|
|
627
|
+
"",
|
|
628
|
+
`Original research question: ${originalQuery}`,
|
|
629
|
+
`Open question ledger: ${JSON.stringify(openQuestions, null, 2)}`,
|
|
630
|
+
`Fetched sources: ${JSON.stringify(sourceSnippets, null, 2)}`,
|
|
631
|
+
"",
|
|
632
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
633
|
+
"BEGIN_JSON",
|
|
634
|
+
JSON.stringify({
|
|
635
|
+
extractions: [
|
|
636
|
+
{
|
|
637
|
+
sourceId: "S1",
|
|
638
|
+
url: "https://example.com/source",
|
|
639
|
+
rational: "why this source matters for the goal",
|
|
640
|
+
evidence: "specific quoted/paraphrased evidence with numbers, dates, caveats",
|
|
641
|
+
summary: "concise contribution to the research question",
|
|
642
|
+
answers: [
|
|
643
|
+
{
|
|
644
|
+
id: "Q1",
|
|
645
|
+
evidence: "brief evidence that closes the question"
|
|
646
|
+
}
|
|
647
|
+
],
|
|
648
|
+
newQuestions: ["new sub-question raised by this source"]
|
|
649
|
+
}
|
|
650
|
+
]
|
|
651
|
+
}, null, 2),
|
|
652
|
+
"END_JSON"
|
|
653
|
+
].join(`
|
|
654
|
+
`);
|
|
655
|
+
}
|
|
656
|
+
export async function extractEvidenceFromSources({
|
|
657
|
+
query,
|
|
658
|
+
questions,
|
|
659
|
+
fetchedSources,
|
|
660
|
+
extractedSourceKeys
|
|
661
|
+
}) {
|
|
662
|
+
const pending = (fetchedSources || []).filter((source) => (source?.content || source?.snippet) && !extractedSourceKeys.has(sourceKey(source)));
|
|
663
|
+
if (pending.length === 0)
|
|
664
|
+
return { evidence: [], error: "" };
|
|
665
|
+
try {
|
|
666
|
+
const raw = await runGeminiPrompt(buildEvidenceExtractionPrompt(query, questions, pending, extractedSourceKeys), { timeoutMs: 120000 });
|
|
667
|
+
const parsed = parseGeminiJson(raw, { extractions: [] });
|
|
668
|
+
const evidence = normalizeEvidenceExtractions(parsed, pending);
|
|
669
|
+
for (const source of pending) {
|
|
670
|
+
const key = sourceKey(source);
|
|
671
|
+
if (key)
|
|
672
|
+
extractedSourceKeys.add(key);
|
|
673
|
+
}
|
|
674
|
+
return { evidence, error: "" };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
return { evidence: [], error: error.message || String(error) };
|
|
155
677
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
678
|
+
}
|
|
679
|
+
export function buildEvidenceAndLearningPrompt(originalQuery, questions, roundQueries, searchSummaries, pendingSources, fetchedSources, evidenceItems = []) {
|
|
680
|
+
const openQuestions = (questions || []).filter((q) => q.status !== "closed").slice(0, 12).map((q) => ({ id: q.id, question: q.question }));
|
|
681
|
+
const extractionSources = (pendingSources || []).filter((source) => source?.content || source?.snippet);
|
|
682
|
+
const learningSources = (fetchedSources || []).filter((source) => source?.content || source?.snippet);
|
|
683
|
+
const assemblePrompt = ({
|
|
684
|
+
extractionCount,
|
|
685
|
+
extractionLimit,
|
|
686
|
+
learningCount,
|
|
687
|
+
learningLimit
|
|
688
|
+
}) => {
|
|
689
|
+
const extractionSourceSnippets = extractionSources.slice(0, extractionCount).map((source, index) => ({
|
|
690
|
+
id: source.id || `F${index + 1}`,
|
|
691
|
+
title: source.title || "",
|
|
692
|
+
url: source.finalUrl || source.url || source.canonicalUrl || "",
|
|
693
|
+
content: trimText(source.content || source.snippet || "", extractionLimit)
|
|
694
|
+
}));
|
|
695
|
+
const learningSourceSnippets = learningSources.slice(0, learningCount).map((source, index) => ({
|
|
696
|
+
id: `F${index + 1}`,
|
|
697
|
+
title: source.title || "",
|
|
698
|
+
url: source.finalUrl || source.url || "",
|
|
699
|
+
snippet: trimText(source.content || source.snippet || "", learningLimit)
|
|
700
|
+
}));
|
|
701
|
+
return [
|
|
702
|
+
"You are doing two combined research tasks for one round of an iterative research run. Perform BOTH tasks and return a single combined JSON object.",
|
|
703
|
+
"",
|
|
704
|
+
"TASK A — Goal-based evidence extraction:",
|
|
705
|
+
"For each source under 'Sources for evidence extraction', extract only information that helps answer the open questions.",
|
|
706
|
+
"Use original wording/details where useful. Do not invent answers; leave questions open if evidence is insufficient.",
|
|
707
|
+
"If a source answers one or more tracked questions, identify those question IDs explicitly.",
|
|
708
|
+
"Also propose genuinely new sub-questions discovered from the evidence.",
|
|
709
|
+
"",
|
|
710
|
+
"TASK B — Compact research-state learning extraction:",
|
|
711
|
+
"Using the round queries, question ledger, extracted source evidence, engine summaries, and fetched source snippets below, create dense, non-overlapping learnings with exact names, numbers, dates, limitations, and caveats where available.",
|
|
712
|
+
"Also propose follow-up search queries that would most improve confidence or fill gaps.",
|
|
713
|
+
"",
|
|
714
|
+
`Original research question: ${originalQuery}`,
|
|
715
|
+
`Open question ledger: ${JSON.stringify(openQuestions)}`,
|
|
716
|
+
`Round queries: ${JSON.stringify(roundQueries)}`,
|
|
717
|
+
`Question ledger: ${JSON.stringify(questions)}`,
|
|
718
|
+
`Extracted source evidence so far: ${JSON.stringify(evidenceItems.slice(-12))}`,
|
|
719
|
+
`Engine summaries: ${JSON.stringify(searchSummaries)}`,
|
|
720
|
+
`Sources for evidence extraction (Task A): ${JSON.stringify(extractionSourceSnippets)}`,
|
|
721
|
+
`Fetched source snippets (Task B context): ${JSON.stringify(learningSourceSnippets)}`,
|
|
722
|
+
"",
|
|
723
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers, combining both tasks into one object:",
|
|
724
|
+
"BEGIN_JSON",
|
|
725
|
+
JSON.stringify({
|
|
726
|
+
extractions: [
|
|
727
|
+
{
|
|
728
|
+
sourceId: "S1",
|
|
729
|
+
url: "https://example.com/source",
|
|
730
|
+
rational: "why this source matters for the goal",
|
|
731
|
+
evidence: "specific quoted/paraphrased evidence with numbers, dates, caveats",
|
|
732
|
+
summary: "concise contribution to the research question",
|
|
733
|
+
answers: [
|
|
734
|
+
{
|
|
735
|
+
id: "Q1",
|
|
736
|
+
evidence: "brief evidence that closes the question"
|
|
737
|
+
}
|
|
738
|
+
],
|
|
739
|
+
newQuestions: ["new sub-question raised by this source"]
|
|
740
|
+
}
|
|
741
|
+
],
|
|
742
|
+
learnings: ["concise, information-dense learning"],
|
|
743
|
+
answeredQuestions: [
|
|
744
|
+
{
|
|
745
|
+
id: "Q1",
|
|
746
|
+
evidence: "brief evidence that closes this question",
|
|
747
|
+
sourceIds: ["S1"]
|
|
748
|
+
}
|
|
749
|
+
],
|
|
750
|
+
newQuestions: ["new sub-question discovered from the evidence"],
|
|
751
|
+
followUpQueries: ["specific next search query"],
|
|
752
|
+
gaps: ["important uncertainty or missing evidence"]
|
|
753
|
+
}, null, 2),
|
|
754
|
+
"END_JSON"
|
|
755
|
+
].join(`
|
|
756
|
+
`);
|
|
757
|
+
};
|
|
758
|
+
let extractionCount = Math.min(4, extractionSources.length);
|
|
759
|
+
let extractionLimit = 3000;
|
|
760
|
+
let learningCount = Math.min(6, learningSources.length);
|
|
761
|
+
let learningLimit = 2000;
|
|
762
|
+
let prompt = assemblePrompt({
|
|
763
|
+
extractionCount,
|
|
764
|
+
extractionLimit,
|
|
765
|
+
learningCount,
|
|
766
|
+
learningLimit
|
|
767
|
+
});
|
|
768
|
+
let trimmedToFit = false;
|
|
769
|
+
const minimumSourceChars = 600;
|
|
770
|
+
while (prompt.length > MAX_PROMPT_CHARS) {
|
|
771
|
+
if (extractionLimit > minimumSourceChars || learningLimit > minimumSourceChars) {
|
|
772
|
+
extractionLimit = Math.max(minimumSourceChars, Math.floor(extractionLimit / 2));
|
|
773
|
+
learningLimit = Math.max(minimumSourceChars, Math.floor(learningLimit / 2));
|
|
774
|
+
} else if (learningCount > 1) {
|
|
775
|
+
learningCount -= 1;
|
|
776
|
+
} else if (extractionCount > 1) {
|
|
777
|
+
extractionCount -= 1;
|
|
778
|
+
} else if (extractionLimit > 1 || learningLimit > 1) {
|
|
779
|
+
extractionLimit = Math.max(1, Math.floor(extractionLimit / 2));
|
|
780
|
+
learningLimit = Math.max(1, Math.floor(learningLimit / 2));
|
|
781
|
+
} else {
|
|
782
|
+
throw new Error(`[greedysearch] evidence/learning prompt exceeds Gemini input cap after source trimming: ${prompt.length} chars`);
|
|
783
|
+
}
|
|
784
|
+
trimmedToFit = true;
|
|
785
|
+
prompt = assemblePrompt({
|
|
786
|
+
extractionCount,
|
|
787
|
+
extractionLimit,
|
|
788
|
+
learningCount,
|
|
789
|
+
learningLimit
|
|
164
790
|
});
|
|
165
791
|
}
|
|
792
|
+
if (trimmedToFit) {
|
|
793
|
+
console.error(`[greedysearch] evidence/learning prompt trimmed to fit Gemini input cap: ${prompt.length} chars`);
|
|
794
|
+
}
|
|
795
|
+
return prompt;
|
|
796
|
+
}
|
|
797
|
+
export async function extractEvidenceAndLearnings({
|
|
798
|
+
query,
|
|
799
|
+
questions,
|
|
800
|
+
fetchedSources,
|
|
801
|
+
extractedSourceKeys,
|
|
802
|
+
roundQueries,
|
|
803
|
+
searchSummaries,
|
|
804
|
+
evidenceItems = []
|
|
805
|
+
}) {
|
|
806
|
+
const pending = (fetchedSources || []).filter((source) => (source?.content || source?.snippet) && !extractedSourceKeys.has(sourceKey(source)));
|
|
807
|
+
let evidence = [];
|
|
808
|
+
let evidenceError = "";
|
|
809
|
+
let learningPayload = { learnings: [], followUpQueries: [], gaps: [] };
|
|
810
|
+
let learningError = "";
|
|
166
811
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
812
|
+
const raw = await runGeminiPrompt(buildEvidenceAndLearningPrompt(query, questions, roundQueries, searchSummaries, pending, fetchedSources, evidenceItems), { timeoutMs: 180000 });
|
|
813
|
+
const parsed = parseGeminiJson(raw, {});
|
|
814
|
+
evidence = normalizeEvidenceExtractions(parsed, pending);
|
|
815
|
+
for (const source of pending) {
|
|
816
|
+
const key = sourceKey(source);
|
|
817
|
+
if (key)
|
|
818
|
+
extractedSourceKeys.add(key);
|
|
819
|
+
}
|
|
820
|
+
learningPayload = { ...learningPayload, ...parsed };
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const message = error.message || String(error);
|
|
823
|
+
evidenceError = message;
|
|
824
|
+
learningError = message;
|
|
825
|
+
}
|
|
826
|
+
return { evidence, evidenceError, learningPayload, learningError };
|
|
827
|
+
}
|
|
828
|
+
export function buildFinalReportPrompt(originalQuery, rounds, sources, questions = [], evidenceItems = []) {
|
|
829
|
+
const learnings = rounds.flatMap((round) => round.learnings || []);
|
|
830
|
+
const gaps = rounds.flatMap((round) => round.gaps || []);
|
|
831
|
+
const sourceRegistry = sources.slice(0, 12).map((source) => ({
|
|
832
|
+
id: source.id,
|
|
833
|
+
title: source.title,
|
|
834
|
+
domain: source.domain,
|
|
835
|
+
url: source.canonicalUrl,
|
|
836
|
+
type: source.sourceType,
|
|
837
|
+
engines: source.engines,
|
|
838
|
+
fetch: source.fetch?.attempted ? {
|
|
839
|
+
ok: source.fetch.ok,
|
|
840
|
+
snippet: trimText(source.fetch.snippet || "", 1200),
|
|
841
|
+
publishedTime: source.fetch.publishedTime || ""
|
|
842
|
+
} : undefined
|
|
843
|
+
}));
|
|
844
|
+
return [
|
|
845
|
+
"You are writing the final research report for an iterative deep-research run.",
|
|
846
|
+
"Produce a thorough markdown report organized into clear sections.",
|
|
847
|
+
"",
|
|
848
|
+
"Use the learnings and source registry below. Every substantive claim MUST be backed by an [S1] citation.",
|
|
849
|
+
'Where engines disagree, surface the conflicting claims explicitly in the "differences" array.',
|
|
850
|
+
'Include a "Key Claims" structure that maps each distinct claim to its supporting source IDs.',
|
|
851
|
+
"",
|
|
852
|
+
"Report structure:",
|
|
853
|
+
"1. ## Summary — A 2-4 sentence executive summary of findings",
|
|
854
|
+
"2. ## Key Findings — The main findings, organized by theme or question, each with inline citations",
|
|
855
|
+
"3. ## Areas of Disagreement — Where engines or sources conflict (if any)",
|
|
856
|
+
"4. ## Limitations & Caveats — Important qualifiers, gaps, or uncertainties",
|
|
857
|
+
"",
|
|
858
|
+
`Original research question: ${originalQuery}`,
|
|
859
|
+
`Learnings: ${JSON.stringify(learnings, null, 2)}`,
|
|
860
|
+
`Known gaps/caveats: ${JSON.stringify(gaps, null, 2)}`,
|
|
861
|
+
`Question ledger: ${JSON.stringify(questions, null, 2)}`,
|
|
862
|
+
`Goal-based extracted evidence: ${JSON.stringify(evidenceItems.slice(-20), null, 2)}`,
|
|
863
|
+
`Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
|
|
864
|
+
"",
|
|
865
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
866
|
+
"BEGIN_JSON",
|
|
867
|
+
JSON.stringify({
|
|
868
|
+
answer: "markdown report with sections and inline [S1] citations",
|
|
869
|
+
agreement: {
|
|
870
|
+
level: "high|medium|low|mixed|conflicting",
|
|
871
|
+
summary: "one-sentence confidence summary"
|
|
872
|
+
},
|
|
873
|
+
differences: ["notable disagreement or conflict between sources"],
|
|
874
|
+
caveats: ["important caveat or qualification"],
|
|
875
|
+
claims: [
|
|
876
|
+
{
|
|
877
|
+
claim: "specific factual statement from the research",
|
|
878
|
+
support: "strong|moderate|weak|conflicting",
|
|
879
|
+
sourceIds: ["S1", "S2"]
|
|
880
|
+
}
|
|
881
|
+
],
|
|
882
|
+
recommendedSources: ["S1", "S2"]
|
|
883
|
+
}, null, 2),
|
|
884
|
+
"END_JSON"
|
|
885
|
+
].join(`
|
|
886
|
+
`);
|
|
887
|
+
}
|
|
888
|
+
export function buildSynthesisFromEvidencePrompt(originalQuery, sources = [], questions = [], evidenceItems = []) {
|
|
889
|
+
const sourceRegistry = sources.slice(0, 12).map((source) => ({
|
|
890
|
+
id: source.id,
|
|
891
|
+
title: source.title,
|
|
892
|
+
domain: source.domain,
|
|
893
|
+
url: source.canonicalUrl,
|
|
894
|
+
type: source.sourceType,
|
|
895
|
+
engines: source.engines
|
|
896
|
+
}));
|
|
897
|
+
const evidenceSlice = evidenceItems.slice(-20);
|
|
898
|
+
const answerableQuestionIds = new Set;
|
|
899
|
+
for (const item of evidenceSlice) {
|
|
900
|
+
for (const ans of item.answers || []) {
|
|
901
|
+
if (ans?.id)
|
|
902
|
+
answerableQuestionIds.add(ans.id);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const openQuestionSummary = (questions || []).filter((q) => q.status !== "closed").map((q) => ({ id: q.id, question: q.question }));
|
|
906
|
+
return [
|
|
907
|
+
"You are writing the final research report from goal-based extracted evidence.",
|
|
908
|
+
"Per-round learnings were not produced, but the per-source evidence extraction step succeeded.",
|
|
909
|
+
"Synthesize a thorough markdown report using ONLY the evidence below. Every substantive claim MUST be backed by an [S1] citation.",
|
|
910
|
+
"",
|
|
911
|
+
"Report structure:",
|
|
912
|
+
"1. ## Summary — A 2-4 sentence executive summary of findings",
|
|
913
|
+
"2. ## Key Findings — The main findings, organized by theme or question, each with inline citations",
|
|
914
|
+
"3. ## Limitations & Caveats — Important qualifiers, gaps, or uncertainties",
|
|
915
|
+
"",
|
|
916
|
+
`Original research question: ${originalQuery}`,
|
|
917
|
+
`Per-source extracted evidence: ${JSON.stringify(evidenceSlice, null, 2)}`,
|
|
918
|
+
`Source registry: ${JSON.stringify(sourceRegistry, null, 2)}`,
|
|
919
|
+
`Questions already answered by the evidence: ${JSON.stringify(Array.from(answerableQuestionIds))}`,
|
|
920
|
+
`Questions still open after this evidence: ${JSON.stringify(openQuestionSummary)}`,
|
|
921
|
+
"",
|
|
922
|
+
"Respond ONLY with JSON wrapped in BEGIN_JSON / END_JSON markers:",
|
|
923
|
+
"BEGIN_JSON",
|
|
924
|
+
JSON.stringify({
|
|
925
|
+
answer: "markdown report with sections and inline [S1] citations",
|
|
926
|
+
agreement: {
|
|
927
|
+
level: "high|medium|low|mixed|conflicting",
|
|
928
|
+
summary: "one-sentence confidence summary"
|
|
929
|
+
},
|
|
930
|
+
differences: ["notable disagreement or conflict between sources"],
|
|
931
|
+
caveats: ["important caveat or qualification"],
|
|
932
|
+
claims: [
|
|
933
|
+
{
|
|
934
|
+
claim: "specific factual statement supported by the evidence",
|
|
935
|
+
support: "strong|moderate|weak|conflicting",
|
|
936
|
+
sourceIds: ["S1", "S2"]
|
|
937
|
+
}
|
|
938
|
+
],
|
|
939
|
+
recommendedSources: ["S1", "S2"]
|
|
940
|
+
}, null, 2),
|
|
941
|
+
"END_JSON"
|
|
942
|
+
].join(`
|
|
943
|
+
`);
|
|
944
|
+
}
|
|
945
|
+
async function runFastAllSearch(query, { locale = null, short = true } = {}) {
|
|
946
|
+
const args = [SEARCH_BIN, "all", "--inline", "--stdin", "--fast"];
|
|
947
|
+
if (!short)
|
|
948
|
+
args.push("--full");
|
|
949
|
+
if (locale)
|
|
950
|
+
args.push("--locale", locale);
|
|
951
|
+
return new Promise((resolve, reject) => {
|
|
952
|
+
const proc = spawn(nodeRuntimeCommand(), args, {
|
|
953
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
954
|
+
env: { ...process.env, GREEDY_SEARCH_RESEARCH_CHILD: "1" }
|
|
172
955
|
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
956
|
+
proc.stdin.write(query);
|
|
957
|
+
proc.stdin.end();
|
|
958
|
+
let out = "";
|
|
959
|
+
let err = "";
|
|
960
|
+
let stderrBuffer = "";
|
|
961
|
+
proc.stdout.on("data", (d) => out += d);
|
|
962
|
+
proc.stderr.on("data", (d) => {
|
|
963
|
+
err += d;
|
|
964
|
+
stderrBuffer += d.toString();
|
|
965
|
+
const lines = stderrBuffer.split(`
|
|
966
|
+
`);
|
|
967
|
+
stderrBuffer = lines.pop() || "";
|
|
968
|
+
for (const line of lines) {
|
|
969
|
+
if (shouldForwardChildStderr(line)) {
|
|
970
|
+
process.stderr.write(`${line}
|
|
971
|
+
`);
|
|
972
|
+
}
|
|
183
973
|
}
|
|
184
|
-
return result;
|
|
185
974
|
});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
var origStroke = CanvasRenderingContext2D.prototype.strokeText;
|
|
203
|
-
CanvasRenderingContext2D.prototype.strokeText = __markNative(function strokeText() {
|
|
204
|
-
this.globalAlpha = 0.9995;
|
|
205
|
-
return origStroke.apply(this, arguments);
|
|
975
|
+
const t = setTimeout(() => {
|
|
976
|
+
proc.kill();
|
|
977
|
+
reject(new Error(`research child search timed out for: ${query}`));
|
|
978
|
+
}, 140000);
|
|
979
|
+
proc.on("close", (code) => {
|
|
980
|
+
clearTimeout(t);
|
|
981
|
+
if (code !== 0) {
|
|
982
|
+
reject(new Error(err.trim() || `search child exited with code ${code}`));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
resolve(JSON.parse(out.trim()));
|
|
987
|
+
} catch {
|
|
988
|
+
reject(new Error(`Invalid JSON from research child: ${out.slice(0, 200)}`));
|
|
989
|
+
}
|
|
206
990
|
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
function dedupeSources(sourceLists) {
|
|
994
|
+
const seen = new Map;
|
|
995
|
+
for (const source of sourceLists.flat()) {
|
|
996
|
+
const canonicalUrl = normalizeUrl(source.canonicalUrl || source.url);
|
|
997
|
+
if (!canonicalUrl)
|
|
998
|
+
continue;
|
|
999
|
+
const existing = seen.get(canonicalUrl);
|
|
1000
|
+
if (!existing) {
|
|
1001
|
+
seen.set(canonicalUrl, { ...source, canonicalUrl });
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
existing.engines = [
|
|
1005
|
+
...new Set([...existing.engines || [], ...source.engines || []])
|
|
1006
|
+
];
|
|
1007
|
+
existing.engineCount = existing.engines.length;
|
|
1008
|
+
existing.smartScore = Math.max(existing.smartScore || 0, source.smartScore || 0);
|
|
1009
|
+
}
|
|
1010
|
+
return Array.from(seen.values()).sort((a, b) => {
|
|
1011
|
+
const diff = computeCompositeScore(b) - computeCompositeScore(a);
|
|
1012
|
+
if (diff !== 0)
|
|
1013
|
+
return diff;
|
|
1014
|
+
return (a.domain || "").localeCompare(b.domain || "");
|
|
1015
|
+
}).slice(0, 12).map((source, index) => ({ ...source, id: `S${index + 1}` }));
|
|
1016
|
+
}
|
|
1017
|
+
const _enginePattern = ALL_ENGINES.join("|");
|
|
1018
|
+
const _engineRegex = new RegExp(`^\\[(${_enginePattern})\\]`);
|
|
1019
|
+
function shouldForwardChildStderr(line) {
|
|
1020
|
+
return /^PROGRESS:/.test(line) || /^\[greedysearch\]/.test(line) || _engineRegex.test(line) || /^GreedySearch Chrome/.test(line) || /^Launching GreedySearch Chrome/.test(line) || /^Headless mode/.test(line) || /^Ready\.?$/.test(line);
|
|
1021
|
+
}
|
|
1022
|
+
function parseGeminiJson(raw, fallback = {}) {
|
|
1023
|
+
return parseStructuredJson(raw?.answer || "") || fallback;
|
|
1024
|
+
}
|
|
1025
|
+
export function auditCitations(answer, sources) {
|
|
1026
|
+
if (!answer || !Array.isArray(sources)) {
|
|
1027
|
+
return {
|
|
1028
|
+
cited: [],
|
|
1029
|
+
missing: [],
|
|
1030
|
+
unfetched: [],
|
|
1031
|
+
ok: true
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const idPattern = /\b[SF](\d+)\b/g;
|
|
1035
|
+
const citedIds = new Set;
|
|
1036
|
+
let match;
|
|
1037
|
+
while ((match = idPattern.exec(answer)) !== null) {
|
|
1038
|
+
citedIds.add(`S${match[1]}`);
|
|
1039
|
+
citedIds.add(`F${match[1]}`);
|
|
1040
|
+
}
|
|
1041
|
+
const sourceMap = new Map;
|
|
1042
|
+
for (const source of sources) {
|
|
1043
|
+
const id = source?.id;
|
|
1044
|
+
if (id) {
|
|
1045
|
+
sourceMap.set(id, source);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const cited = Array.from(citedIds);
|
|
1049
|
+
const missing = [];
|
|
1050
|
+
const unfetched = [];
|
|
1051
|
+
for (const id of cited) {
|
|
1052
|
+
const source = sourceMap.get(id);
|
|
1053
|
+
if (!source) {
|
|
1054
|
+
const indexMatch = id.match(/^(S|F)(\d+)$/);
|
|
1055
|
+
if (indexMatch) {
|
|
1056
|
+
const idx = parseInt(indexMatch[2], 10) - 1;
|
|
1057
|
+
if (idx >= 0 && idx < sources.length) {
|
|
1058
|
+
const matched = sources[idx];
|
|
1059
|
+
if (matched) {
|
|
1060
|
+
const fetchOk = matched.fetch?.ok || matched.content && matched.content.length > 100 || matched.contentChars && matched.contentChars > 100;
|
|
1061
|
+
if (!fetchOk) {
|
|
1062
|
+
unfetched.push(id);
|
|
222
1063
|
}
|
|
223
|
-
|
|
1064
|
+
continue;
|
|
224
1065
|
}
|
|
225
1066
|
}
|
|
226
1067
|
}
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// Headless Chrome's AudioContext produces slightly different output.
|
|
233
|
-
// Subtle noise breaks audio-based fingerprinting.
|
|
234
|
-
try {
|
|
235
|
-
var __audioSeed = ((Date.now() & 0x1F) | 1);
|
|
236
|
-
var origGetChannelData = AudioBuffer.prototype.getChannelData;
|
|
237
|
-
AudioBuffer.prototype.getChannelData = __markNative(function getChannelData(channel) {
|
|
238
|
-
var data = origGetChannelData.call(this, channel);
|
|
239
|
-
for (var __i = 0; __i < data.length; __i += 64) {
|
|
240
|
-
data[__i] *= 0.99999;
|
|
1068
|
+
missing.push(id);
|
|
1069
|
+
} else {
|
|
1070
|
+
const fetchOk = source.fetch?.ok || source.content && source.content.length > 100 || source.contentChars && source.contentChars > 100;
|
|
1071
|
+
if (!fetchOk) {
|
|
1072
|
+
unfetched.push(id);
|
|
241
1073
|
}
|
|
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
|
-
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
cited,
|
|
1078
|
+
missing,
|
|
1079
|
+
unfetched,
|
|
1080
|
+
ok: missing.length === 0
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
export async function checkCitationUrls(sources, { timeoutMs = 6000, concurrency = 4 } = {}) {
|
|
1084
|
+
const safeConcurrency = Math.max(1, Math.floor(concurrency || 1));
|
|
1085
|
+
const citedSources = (sources || []).filter((s) => s?.id && (s?.canonicalUrl || s?.finalUrl || s?.url));
|
|
1086
|
+
if (citedSources.length === 0) {
|
|
1087
|
+
return { reachable: [], dead: [], skipped: [], ok: true };
|
|
1088
|
+
}
|
|
1089
|
+
const reachable = [];
|
|
1090
|
+
const dead = [];
|
|
1091
|
+
const skipped = [];
|
|
1092
|
+
const results = new Array(citedSources.length);
|
|
1093
|
+
let nextIndex = 0;
|
|
1094
|
+
async function worker() {
|
|
1095
|
+
while (true) {
|
|
1096
|
+
const i = nextIndex++;
|
|
1097
|
+
if (i >= citedSources.length)
|
|
1098
|
+
return;
|
|
1099
|
+
const source = citedSources[i];
|
|
1100
|
+
try {
|
|
1101
|
+
const url = source.fetch?.finalUrl || source.canonicalUrl || source.finalUrl || source.url;
|
|
1102
|
+
if (!url) {
|
|
1103
|
+
results[i] = { id: source.id, url: "", status: "skipped" };
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
const parsed = new URL(url);
|
|
1108
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1109
|
+
results[i] = { id: source.id, url, status: "skipped" };
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
} catch {
|
|
1113
|
+
results[i] = { id: source.id, url, status: "skipped" };
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
const controller = new AbortController;
|
|
1118
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1119
|
+
try {
|
|
1120
|
+
const response = await fetch(url, {
|
|
1121
|
+
method: "HEAD",
|
|
1122
|
+
redirect: "follow",
|
|
1123
|
+
signal: controller.signal,
|
|
1124
|
+
headers: {
|
|
1125
|
+
"User-Agent": "Mozilla/5.0 (compatible; GreedySearch/2.0; +https://github.com/apmantza/greedysearch-dm)"
|
|
1126
|
+
}
|
|
292
1127
|
});
|
|
293
|
-
|
|
294
|
-
|
|
1128
|
+
clearTimeout(timer);
|
|
1129
|
+
const ok = response.status >= 200 && response.status < 400;
|
|
1130
|
+
const botProtectedOrHeadlessHost = [401, 403, 405, 429].includes(response.status);
|
|
1131
|
+
let status = "dead";
|
|
1132
|
+
if (ok)
|
|
1133
|
+
status = "reachable";
|
|
1134
|
+
else if (botProtectedOrHeadlessHost)
|
|
1135
|
+
status = "skipped";
|
|
1136
|
+
results[i] = {
|
|
1137
|
+
id: source.id,
|
|
1138
|
+
url,
|
|
1139
|
+
status,
|
|
1140
|
+
httpStatus: response.status,
|
|
1141
|
+
reason: botProtectedOrHeadlessHost ? "bot-protected-or-head-disallowed" : undefined
|
|
1142
|
+
};
|
|
1143
|
+
} catch (fetchError) {
|
|
1144
|
+
clearTimeout(timer);
|
|
1145
|
+
results[i] = {
|
|
1146
|
+
id: source.id,
|
|
1147
|
+
url,
|
|
1148
|
+
status: "dead",
|
|
1149
|
+
error: fetchError.name === "AbortError" ? "timeout" : fetchError.message
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
results[i] = {
|
|
1154
|
+
id: source.id,
|
|
1155
|
+
url,
|
|
1156
|
+
status: "dead",
|
|
1157
|
+
error: error.message
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
results[i] = {
|
|
1162
|
+
id: "?",
|
|
1163
|
+
url: "",
|
|
1164
|
+
status: "dead",
|
|
1165
|
+
error: error?.message || "unknown"
|
|
295
1166
|
};
|
|
296
|
-
}
|
|
297
|
-
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const workerCount = Math.min(citedSources.length, safeConcurrency);
|
|
1171
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
1172
|
+
for (const value of results) {
|
|
1173
|
+
if (value.status === "reachable")
|
|
1174
|
+
reachable.push(value);
|
|
1175
|
+
else if (value.status === "dead")
|
|
1176
|
+
dead.push(value);
|
|
1177
|
+
else
|
|
1178
|
+
skipped.push(value);
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
reachable,
|
|
1182
|
+
dead,
|
|
1183
|
+
skipped,
|
|
1184
|
+
ok: dead.length === 0
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
export async function runCitationUrlCheck(combinedSources, citationAudit = null) {
|
|
1188
|
+
process.stderr.write(`PROGRESS:research:check-urls
|
|
1189
|
+
`);
|
|
1190
|
+
try {
|
|
1191
|
+
const citedIds = new Set(citationAudit?.cited || []);
|
|
1192
|
+
const sourcesToCheck = citedIds.size ? (combinedSources || []).filter((source) => citedIds.has(source?.id)) : combinedSources;
|
|
1193
|
+
const citationUrls = await checkCitationUrls(sourcesToCheck, {
|
|
1194
|
+
timeoutMs: 6000,
|
|
1195
|
+
concurrency: 4
|
|
1196
|
+
});
|
|
1197
|
+
if (!citationUrls.ok) {
|
|
1198
|
+
process.stderr.write(`[greedysearch] ${citationUrls.dead.length} dead citation URL(s) detected
|
|
1199
|
+
`);
|
|
1200
|
+
}
|
|
1201
|
+
return citationUrls;
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
process.stderr.write(`[greedysearch] URL reachability check failed: ${error.message}
|
|
1204
|
+
`);
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
export function computeResearchFloor({
|
|
1209
|
+
sources = [],
|
|
1210
|
+
fetchedSources = [],
|
|
1211
|
+
synthesis = {},
|
|
1212
|
+
citationAudit = null,
|
|
1213
|
+
gaps = [],
|
|
1214
|
+
questions = [],
|
|
1215
|
+
rounds = [],
|
|
1216
|
+
qualityScore = 0,
|
|
1217
|
+
qualityThreshold = 8.5,
|
|
1218
|
+
maxSources = 8,
|
|
1219
|
+
requireCitations = true,
|
|
1220
|
+
requireQuestions = true
|
|
1221
|
+
} = {}) {
|
|
1222
|
+
const fetchedOk = fetchedSources.filter((source) => source?.fetch?.ok || (source?.contentChars || 0) > 100 || String(source?.content || "").length > 100);
|
|
1223
|
+
const primarySources = sources.filter((source) => ["official-docs", "repo", "maintainer-blog", "academic"].includes(String(source?.sourceType || "")));
|
|
1224
|
+
const claims = Array.isArray(synthesis?.claims) ? synthesis.claims : [];
|
|
1225
|
+
const citedCount = citationAudit ? citationAudit.cited?.length || 0 : 0;
|
|
1226
|
+
const questionStats = questionProgress(questions);
|
|
1227
|
+
const requiredQuestions = (questions || []).filter((q) => !q.createdRound || q.reason === "Original research question");
|
|
1228
|
+
const requiredQuestionStats = questionProgress(requiredQuestions);
|
|
1229
|
+
const roundCount = (rounds || []).length;
|
|
1230
|
+
const baseMin = Math.min(4, Math.max(2, Number(maxSources) || 8));
|
|
1231
|
+
const minFetched = roundCount <= 1 ? Math.min(2, baseMin) : baseMin;
|
|
1232
|
+
const checks = {
|
|
1233
|
+
roundsRun: rounds.length >= 1,
|
|
1234
|
+
fetchedSources: fetchedOk.length >= minFetched,
|
|
1235
|
+
primarySources: primarySources.length >= 1,
|
|
1236
|
+
qualityScore: qualityScore >= Math.min(qualityThreshold, 8) || requireCitations && claims.length > 0 && citedCount > 0,
|
|
1237
|
+
claimsExtracted: !requireCitations || claims.length > 0,
|
|
1238
|
+
citationsPresent: !requireCitations || citedCount > 0,
|
|
1239
|
+
citationsValid: !requireCitations || citationAudit?.ok === true,
|
|
1240
|
+
unfetchedCitations: !requireCitations || (citationAudit?.unfetched || []).length === 0,
|
|
1241
|
+
requiredQuestionsClosed: !requireQuestions || requiredQuestionStats.open === 0
|
|
1242
|
+
};
|
|
1243
|
+
return {
|
|
1244
|
+
floorMet: Object.values(checks).every(Boolean),
|
|
1245
|
+
checks,
|
|
1246
|
+
metrics: {
|
|
1247
|
+
fetchedOk: fetchedOk.length,
|
|
1248
|
+
primarySources: primarySources.length,
|
|
1249
|
+
claims: claims.length,
|
|
1250
|
+
cited: citedCount,
|
|
1251
|
+
gaps: gaps.length,
|
|
1252
|
+
openQuestions: questionStats.open,
|
|
1253
|
+
closedQuestions: questionStats.closed,
|
|
1254
|
+
totalQuestions: questionStats.total,
|
|
1255
|
+
openRequiredQuestions: requiredQuestionStats.open,
|
|
1256
|
+
closedRequiredQuestions: requiredQuestionStats.closed,
|
|
1257
|
+
totalRequiredQuestions: requiredQuestionStats.total,
|
|
1258
|
+
qualityScore,
|
|
1259
|
+
minFetched
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function annotateFetchedSourcesWithIds(fetchedSources, sources) {
|
|
1264
|
+
const byUrl = new Map;
|
|
1265
|
+
for (const source of sources || []) {
|
|
1266
|
+
const key = normalizeUrl(source?.canonicalUrl || source?.finalUrl || source?.url);
|
|
1267
|
+
if (key && source?.id)
|
|
1268
|
+
byUrl.set(key, source.id);
|
|
1269
|
+
}
|
|
1270
|
+
return (fetchedSources || []).map((source, index) => {
|
|
1271
|
+
const key = normalizeUrl(source?.finalUrl || source?.canonicalUrl || source?.url);
|
|
1272
|
+
return {
|
|
1273
|
+
...source,
|
|
1274
|
+
id: source?.id || byUrl.get(key) || `F${index + 1}`
|
|
1275
|
+
};
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
export function createQuestionLedger(query) {
|
|
1279
|
+
return [
|
|
1280
|
+
{
|
|
1281
|
+
id: "Q1",
|
|
1282
|
+
question: trimText(sanitizeResearchQuery(query), 320),
|
|
1283
|
+
status: "open",
|
|
1284
|
+
reason: "Original research question",
|
|
1285
|
+
evidence: [],
|
|
1286
|
+
sourceIds: []
|
|
1287
|
+
}
|
|
1288
|
+
];
|
|
1289
|
+
}
|
|
1290
|
+
function nextQuestionId(questions) {
|
|
1291
|
+
let max = 0;
|
|
1292
|
+
for (const q of questions || []) {
|
|
1293
|
+
const n = Number.parseInt(String(q.id || "").replace(/^Q/i, ""), 10);
|
|
1294
|
+
if (Number.isFinite(n))
|
|
1295
|
+
max = Math.max(max, n);
|
|
1296
|
+
}
|
|
1297
|
+
return `Q${max + 1}`;
|
|
1298
|
+
}
|
|
1299
|
+
function findSimilarQuestion(questions, question) {
|
|
1300
|
+
const normalized = sanitizeResearchQuery(question).toLowerCase();
|
|
1301
|
+
return (questions || []).find((q) => q.question?.toLowerCase() === normalized || jaccardSimilarity(q.question || "", normalized) >= 0.82);
|
|
1302
|
+
}
|
|
1303
|
+
function addQuestion(questions, question, { reason = "", round = null } = {}) {
|
|
1304
|
+
const clean = trimText(sanitizeResearchQuery(question), 320);
|
|
1305
|
+
if (!clean)
|
|
1306
|
+
return null;
|
|
1307
|
+
const existing = findSimilarQuestion(questions, clean);
|
|
1308
|
+
if (existing)
|
|
1309
|
+
return existing;
|
|
1310
|
+
const item = {
|
|
1311
|
+
id: nextQuestionId(questions),
|
|
1312
|
+
question: clean,
|
|
1313
|
+
status: "open",
|
|
1314
|
+
reason: trimText(reason, 240),
|
|
1315
|
+
createdRound: round,
|
|
1316
|
+
evidence: [],
|
|
1317
|
+
sourceIds: []
|
|
1318
|
+
};
|
|
1319
|
+
questions.push(item);
|
|
1320
|
+
return item;
|
|
1321
|
+
}
|
|
1322
|
+
function closeQuestion(questions, idOrQuestion, { evidence = "", sourceIds = [], round = null } = {}) {
|
|
1323
|
+
const target = questions.find((q) => q.id === idOrQuestion) || findSimilarQuestion(questions, idOrQuestion);
|
|
1324
|
+
if (!target)
|
|
1325
|
+
return null;
|
|
1326
|
+
target.status = "closed";
|
|
1327
|
+
target.closedRound = target.closedRound || round;
|
|
1328
|
+
if (evidence)
|
|
1329
|
+
target.evidence = uniqueStrings([...target.evidence || [], evidence], 4);
|
|
1330
|
+
if (Array.isArray(sourceIds)) {
|
|
1331
|
+
target.sourceIds = uniqueStrings([...target.sourceIds || [], ...sourceIds], 8);
|
|
1332
|
+
}
|
|
1333
|
+
return target;
|
|
1334
|
+
}
|
|
1335
|
+
function questionProgress(questions) {
|
|
1336
|
+
const total = questions.length;
|
|
1337
|
+
const closed = questions.filter((q) => q.status === "closed").length;
|
|
1338
|
+
return { total, closed, open: Math.max(0, total - closed) };
|
|
1339
|
+
}
|
|
1340
|
+
export function updateQuestionLedger(questions, { roundNumber, actions = [], learningPayload = {} } = {}) {
|
|
1341
|
+
for (const run of actions) {
|
|
1342
|
+
const action = run?.action || run;
|
|
1343
|
+
const goal = action?.researchGoal && action.researchGoal !== "Original user query" ? action.researchGoal : action?.query || action?.url || "";
|
|
1344
|
+
if (goal) {
|
|
1345
|
+
addQuestion(questions, goal, {
|
|
1346
|
+
reason: "Planned research action",
|
|
1347
|
+
round: roundNumber
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const MAX_OPEN_FOLLOWUPS = 5;
|
|
1352
|
+
const followupOpen = questions.filter((q) => q.status === "open" && q.reason === "Discovered gap/follow-up");
|
|
1353
|
+
if (followupOpen.length > MAX_OPEN_FOLLOWUPS) {
|
|
1354
|
+
const overflow = followupOpen.sort((a, b) => (a.createdRound || 0) - (b.createdRound || 0)).slice(0, followupOpen.length - MAX_OPEN_FOLLOWUPS);
|
|
1355
|
+
for (const q of overflow) {
|
|
1356
|
+
q.status = "resolved";
|
|
1357
|
+
q.closedRound = roundNumber;
|
|
1358
|
+
q.evidence = uniqueStrings([...q.evidence || [], "Auto-resolved to cap open-question ledger"], 4);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const answered = Array.isArray(learningPayload.answeredQuestions) ? learningPayload.answeredQuestions : [];
|
|
1362
|
+
for (const item of answered) {
|
|
1363
|
+
if (typeof item === "string") {
|
|
1364
|
+
closeQuestion(questions, item, { round: roundNumber });
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const id = item?.id || item?.question;
|
|
1368
|
+
if (!id && item?.question) {
|
|
1369
|
+
const added = addQuestion(questions, item.question, {
|
|
1370
|
+
reason: "Answered during learning extraction",
|
|
1371
|
+
round: roundNumber
|
|
1372
|
+
});
|
|
1373
|
+
if (added)
|
|
1374
|
+
closeQuestion(questions, added.id, { round: roundNumber });
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
closeQuestion(questions, id, {
|
|
1378
|
+
evidence: item?.evidence || item?.answer || "",
|
|
1379
|
+
sourceIds: Array.isArray(item?.sourceIds) ? item.sourceIds : [],
|
|
1380
|
+
round: roundNumber
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
const newQuestions = Array.isArray(learningPayload.newQuestions) ? learningPayload.newQuestions : [];
|
|
1384
|
+
for (const question of newQuestions) {
|
|
1385
|
+
addQuestion(questions, question, {
|
|
1386
|
+
reason: "Discovered gap/follow-up",
|
|
1387
|
+
round: roundNumber
|
|
298
1388
|
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
1389
|
+
}
|
|
1390
|
+
return questions;
|
|
1391
|
+
}
|
|
1392
|
+
function pickAcademicFetchTargets(combinedSources, usedUrls) {
|
|
1393
|
+
if (!Array.isArray(combinedSources) || combinedSources.length === 0)
|
|
1394
|
+
return [];
|
|
1395
|
+
const ACADEMIC_HOSTS = ["arxiv.org", "semanticscholar.org", "doi.org"];
|
|
1396
|
+
const seen = new Set;
|
|
1397
|
+
const targets = [];
|
|
1398
|
+
for (const source of combinedSources) {
|
|
1399
|
+
const url = source?.canonicalUrl || source?.finalUrl || source?.url || "";
|
|
1400
|
+
if (!url)
|
|
1401
|
+
continue;
|
|
1402
|
+
let domain = "";
|
|
1403
|
+
try {
|
|
1404
|
+
domain = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
1405
|
+
} catch {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (!ACADEMIC_HOSTS.some((h) => domain === h || domain.endsWith(`.${h}`))) {
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (usedUrls.has(url) || seen.has(url))
|
|
1412
|
+
continue;
|
|
1413
|
+
seen.add(url);
|
|
1414
|
+
const htmlUrl = url.includes("/pdf/") ? url.replace(/\/pdf\//, "/html/").replace(/\.pdf$/i, "") : url;
|
|
1415
|
+
targets.push({
|
|
1416
|
+
url: htmlUrl,
|
|
1417
|
+
label: source?.title || source?.id || domain
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
return targets.slice(0, 2);
|
|
1421
|
+
}
|
|
1422
|
+
export function reconcileQuestionsFromSynthesis(questions, synthesis, citationAudit) {
|
|
1423
|
+
if (!synthesis?.answer || citationAudit?.ok !== true)
|
|
1424
|
+
return questions;
|
|
1425
|
+
const claims = Array.isArray(synthesis.claims) ? synthesis.claims : [];
|
|
1426
|
+
const citedIds = Array.isArray(citationAudit.cited) ? citationAudit.cited : [];
|
|
1427
|
+
if (claims.length === 0 || citedIds.length === 0)
|
|
1428
|
+
return questions;
|
|
1429
|
+
for (const question of questions) {
|
|
1430
|
+
if (question.status === "closed")
|
|
1431
|
+
continue;
|
|
1432
|
+
let bestClaim = null;
|
|
1433
|
+
let bestScore = 0;
|
|
1434
|
+
for (const claim of claims) {
|
|
1435
|
+
const score = jaccardSimilarity(question.question || "", claim.claim || "");
|
|
1436
|
+
if (score > bestScore) {
|
|
1437
|
+
bestScore = score;
|
|
1438
|
+
bestClaim = claim;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (question.id === "Q1" || bestScore >= 0.18) {
|
|
1442
|
+
closeQuestion(questions, question.id, {
|
|
1443
|
+
evidence: bestClaim?.claim || "Answered in final cited synthesis",
|
|
1444
|
+
sourceIds: Array.isArray(bestClaim?.sourceIds) ? bestClaim.sourceIds : citedIds.slice(0, 4)
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return questions;
|
|
1449
|
+
}
|
|
1450
|
+
function renderQuestionStatus(questions) {
|
|
1451
|
+
if (!questions.length)
|
|
1452
|
+
return "No tracked questions.";
|
|
1453
|
+
return questions.map((q) => {
|
|
1454
|
+
const ids = q.sourceIds?.length ? ` (${q.sourceIds.join(", ")})` : "";
|
|
1455
|
+
return `- [${q.status === "closed" ? "x" : " "}] ${q.id}: ${q.question}${ids}`;
|
|
1456
|
+
}).join(`
|
|
1457
|
+
`);
|
|
1458
|
+
}
|
|
1459
|
+
function markdownList(items, fallback = "None recorded.") {
|
|
1460
|
+
const unique = uniqueStrings(items);
|
|
1461
|
+
return unique.length ? unique.map((item) => `- ${item}`).join(`
|
|
1462
|
+
`) : fallback;
|
|
1463
|
+
}
|
|
1464
|
+
export function writeProvenanceSidecar(dir, {
|
|
1465
|
+
query,
|
|
1466
|
+
rounds,
|
|
1467
|
+
sources,
|
|
1468
|
+
fetchedSources,
|
|
1469
|
+
citationAudit,
|
|
1470
|
+
citationUrls,
|
|
1471
|
+
floor,
|
|
1472
|
+
manifest
|
|
1473
|
+
}) {
|
|
1474
|
+
const fetchedOk = (fetchedSources || []).filter((s) => s?.contentChars > 100 || s?.fetch?.ok);
|
|
1475
|
+
const primarySources = (sources || []).filter((s) => ["official-docs", "repo", "maintainer-blog", "academic"].includes(String(s?.sourceType || "")));
|
|
1476
|
+
const citedIds = new Set(citationAudit?.cited || []);
|
|
1477
|
+
const citedSources = (sources || []).filter((s) => citedIds.has(s?.id));
|
|
1478
|
+
const lines = [
|
|
1479
|
+
`# Provenance: ${query}`,
|
|
1480
|
+
"",
|
|
1481
|
+
`- **Date:** ${manifest?.startedAt || new Date().toISOString()}`,
|
|
1482
|
+
`- **Duration:** ${manifest?.durationMs ? `${(manifest.durationMs / 1000).toFixed(1)}s` : "unknown"}`,
|
|
1483
|
+
`- **Mode:** ${manifest?.terminationReason === "simple_single_pass" ? "simple (single-pass)" : "iterative"}`,
|
|
1484
|
+
`- **Rounds:** ${manifest?.rounds || rounds?.length || 1}`,
|
|
1485
|
+
"",
|
|
1486
|
+
"## Sources",
|
|
1487
|
+
"",
|
|
1488
|
+
`- **Consulted:** ${sources?.length || 0}`,
|
|
1489
|
+
`- **Fetched successfully:** ${fetchedOk.length}`,
|
|
1490
|
+
`- **Primary sources:** ${primarySources.length}`,
|
|
1491
|
+
`- **Cited in report:** ${citedSources.length}`,
|
|
1492
|
+
""
|
|
1493
|
+
];
|
|
1494
|
+
if (citedSources.length > 0) {
|
|
1495
|
+
lines.push("### Cited sources", "");
|
|
1496
|
+
for (const source of citedSources) {
|
|
1497
|
+
const url = source.canonicalUrl || source.finalUrl || source.url || "";
|
|
1498
|
+
const fetched = source.fetch?.ok ? "✓" : "✗";
|
|
1499
|
+
lines.push(`- **${source.id}:** [${source.title || url}](${url}) (${source.sourceType || "unknown"}, fetched: ${fetched})`);
|
|
1500
|
+
}
|
|
1501
|
+
lines.push("");
|
|
1502
|
+
}
|
|
1503
|
+
if (citationUrls && (citationUrls.reachable.length > 0 || citationUrls.dead.length > 0)) {
|
|
1504
|
+
lines.push("## URL reachability", "");
|
|
1505
|
+
if (citationUrls.dead.length > 0) {
|
|
1506
|
+
lines.push("");
|
|
1507
|
+
lines.push("**Dead links:**");
|
|
1508
|
+
for (const d of citationUrls.dead) {
|
|
1509
|
+
lines.push(`- ${d.id}: ${d.url} (${d.httpStatus || d.error || "unknown"})`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (citationUrls.reachable.length > 0) {
|
|
1513
|
+
lines.push("");
|
|
1514
|
+
lines.push(`**Reachable:** ${citationUrls.reachable.length}/${citationUrls.reachable.length + citationUrls.dead.length}`);
|
|
1515
|
+
}
|
|
1516
|
+
lines.push("");
|
|
1517
|
+
}
|
|
1518
|
+
const verificationStatus = !citationAudit ? "NOT CHECKED" : citationAudit.ok && (citationUrls?.ok ?? true) ? "PASS" : citationAudit.ok === false ? "FAIL (missing citations)" : "FAIL (dead links)";
|
|
1519
|
+
lines.push("## Verification", "", `- **Citations:** ${citationAudit?.ok ? "PASS" : `FAIL — missing: ${(citationAudit?.missing || []).join(", ")}`}`, `- **URL reachability:** ${citationUrls ? citationUrls.ok ? "PASS" : `FAIL — ${citationUrls.dead.length} dead` : "SKIPPED"}`, `- **Floor:** ${floor?.floorMet ? "PASS" : "PARTIAL"}`, `- **Overall:** ${verificationStatus}`, "");
|
|
1520
|
+
if (floor?.checks) {
|
|
1521
|
+
lines.push("## Floor checks", "");
|
|
1522
|
+
for (const [name, ok] of Object.entries(floor.checks)) {
|
|
1523
|
+
lines.push(`- [${ok ? "x" : " "}] ${name}`);
|
|
1524
|
+
}
|
|
1525
|
+
lines.push("");
|
|
1526
|
+
}
|
|
1527
|
+
writeFileSync(join(dir, "provenance.md"), lines.join(`
|
|
1528
|
+
`), "utf8");
|
|
1529
|
+
}
|
|
1530
|
+
export async function writeResearchBundle({
|
|
1531
|
+
query,
|
|
1532
|
+
rounds,
|
|
1533
|
+
sources,
|
|
1534
|
+
fetchedSources,
|
|
1535
|
+
evidenceItems = [],
|
|
1536
|
+
synthesis,
|
|
1537
|
+
citationAudit,
|
|
1538
|
+
floor,
|
|
1539
|
+
manifest,
|
|
1540
|
+
allGaps = [],
|
|
1541
|
+
questions = [],
|
|
1542
|
+
citationUrls = null,
|
|
1543
|
+
outDir = null
|
|
1544
|
+
}) {
|
|
1545
|
+
const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 19);
|
|
1546
|
+
const dir = outDir || join(DEFAULT_RESEARCH_BUNDLE_ROOT, `${stamp}_${slugifyResearchName(query)}`);
|
|
1547
|
+
const reportsDir = join(dir, "reports");
|
|
1548
|
+
const sourcesDir = join(dir, "sources");
|
|
1549
|
+
const dataDir = join(dir, "data");
|
|
1550
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
1551
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
1552
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1553
|
+
const sourceFiles = await writeResearchSourcesToFiles(fetchedSources, sourcesDir);
|
|
1554
|
+
const gaps = uniqueStrings([
|
|
1555
|
+
...allGaps,
|
|
1556
|
+
...rounds.flatMap((round) => round.gaps || [])
|
|
1557
|
+
]);
|
|
1558
|
+
writeFileSync(join(dir, "STATUS.md"), [
|
|
1559
|
+
floor.floorMet ? "STATUS: DONE" : "STATUS: PARTIAL",
|
|
1560
|
+
"",
|
|
1561
|
+
`Query: ${query}`,
|
|
1562
|
+
`Stop reason: ${manifest.terminationReason || "max_rounds"}`,
|
|
1563
|
+
"",
|
|
1564
|
+
"## Deterministic floor checks",
|
|
1565
|
+
...Object.entries(floor.checks).map(([name, ok]) => `- [${ok ? "x" : " "}] ${name}`),
|
|
1566
|
+
"",
|
|
1567
|
+
"## Questions",
|
|
1568
|
+
renderQuestionStatus(questions),
|
|
1569
|
+
"",
|
|
1570
|
+
"## Open gaps",
|
|
1571
|
+
markdownList(gaps),
|
|
1572
|
+
""
|
|
1573
|
+
].join(`
|
|
1574
|
+
`), "utf8");
|
|
1575
|
+
writeFileSync(join(dir, "OUTLINE.md"), [
|
|
1576
|
+
"# Research bundle outline",
|
|
1577
|
+
"",
|
|
1578
|
+
"- `reports/SUMMARY.md` — final cited report",
|
|
1579
|
+
"- `reports/CLAIMS.md` — extracted claims with support/source IDs",
|
|
1580
|
+
"- `reports/EVIDENCE.md` — goal-based source evidence",
|
|
1581
|
+
"- `reports/GAPS.md` — remaining caveats and uncertainties",
|
|
1582
|
+
"- `provenance.md` — human-readable run metadata and verification",
|
|
1583
|
+
"- `sources/` — fetched source markdown files",
|
|
1584
|
+
"- `data/manifest.json` — machine-readable run metadata",
|
|
1585
|
+
"- `data/rounds.json` — per-round actions/learnings/gaps",
|
|
1586
|
+
"- `data/sources.json` — ranked source registry",
|
|
1587
|
+
"- `data/questions.json` — open/closed question ledger",
|
|
1588
|
+
""
|
|
1589
|
+
].join(`
|
|
1590
|
+
`), "utf8");
|
|
1591
|
+
writeFileSync(join(reportsDir, "SUMMARY.md"), String(synthesis.answer || ""), "utf8");
|
|
1592
|
+
writeFileSync(join(reportsDir, "CLAIMS.md"), [
|
|
1593
|
+
"# Key claims",
|
|
1594
|
+
"",
|
|
1595
|
+
...Array.isArray(synthesis.claims) && synthesis.claims.length ? synthesis.claims.map((claim) => {
|
|
1596
|
+
const ids = Array.isArray(claim.sourceIds) ? claim.sourceIds.join(", ") : "";
|
|
1597
|
+
return `- ${claim.claim || ""} (${claim.support || "support unknown"}${ids ? `; ${ids}` : ""})`;
|
|
1598
|
+
}) : ["No structured claims were extracted."],
|
|
1599
|
+
""
|
|
1600
|
+
].join(`
|
|
1601
|
+
`), "utf8");
|
|
1602
|
+
writeFileSync(join(reportsDir, "EVIDENCE.md"), [
|
|
1603
|
+
"# Extracted evidence",
|
|
1604
|
+
"",
|
|
1605
|
+
...evidenceItems.length ? evidenceItems.map((item) => [
|
|
1606
|
+
`## ${item.sourceId || item.url || "Source"}`,
|
|
1607
|
+
item.url ? `<${item.url}>` : "",
|
|
1608
|
+
item.rational ? `**Rational:** ${item.rational}` : "",
|
|
1609
|
+
item.evidence ? `**Evidence:** ${item.evidence}` : "",
|
|
1610
|
+
item.summary ? `**Summary:** ${item.summary}` : "",
|
|
1611
|
+
""
|
|
1612
|
+
].filter(Boolean).join(`
|
|
1613
|
+
`)) : ["No goal-based evidence was extracted."],
|
|
1614
|
+
""
|
|
1615
|
+
].join(`
|
|
1616
|
+
`), "utf8");
|
|
1617
|
+
writeFileSync(join(reportsDir, "GAPS.md"), [
|
|
1618
|
+
"# Gaps and caveats",
|
|
1619
|
+
"",
|
|
1620
|
+
"## Caveats",
|
|
1621
|
+
markdownList(synthesis.caveats || []),
|
|
1622
|
+
"",
|
|
1623
|
+
"## Research gaps",
|
|
1624
|
+
markdownList(gaps),
|
|
1625
|
+
""
|
|
1626
|
+
].join(`
|
|
1627
|
+
`), "utf8");
|
|
1628
|
+
writeFileSync(join(dataDir, "manifest.json"), JSON.stringify({ ...manifest, floor, citationAudit }, null, 2), "utf8");
|
|
1629
|
+
writeFileSync(join(dataDir, "rounds.json"), JSON.stringify(rounds, null, 2), "utf8");
|
|
1630
|
+
writeFileSync(join(dataDir, "sources.json"), JSON.stringify(sources, null, 2), "utf8");
|
|
1631
|
+
writeFileSync(join(dataDir, "questions.json"), JSON.stringify(questions, null, 2), "utf8");
|
|
1632
|
+
writeFileSync(join(dataDir, "evidence.json"), JSON.stringify(evidenceItems, null, 2), "utf8");
|
|
1633
|
+
writeFileSync(join(sourcesDir, "index.md"), [
|
|
1634
|
+
"# Source index",
|
|
1635
|
+
"",
|
|
1636
|
+
...sourceFiles.map((source) => {
|
|
1637
|
+
const label = source.title || source.url;
|
|
1638
|
+
const url = source.finalUrl || source.url;
|
|
1639
|
+
const path = source.contentPath ? ` — ${source.contentPath}` : "";
|
|
1640
|
+
return `- ${source.id || "?"}: [${label}](${url})${path}`;
|
|
1641
|
+
}),
|
|
1642
|
+
""
|
|
1643
|
+
].join(`
|
|
1644
|
+
`), "utf8");
|
|
306
1645
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
1646
|
+
writeProvenanceSidecar(dir, {
|
|
1647
|
+
query,
|
|
1648
|
+
rounds,
|
|
1649
|
+
sources,
|
|
1650
|
+
fetchedSources,
|
|
1651
|
+
citationAudit,
|
|
1652
|
+
citationUrls,
|
|
1653
|
+
floor,
|
|
1654
|
+
manifest
|
|
1655
|
+
});
|
|
1656
|
+
} catch (sidecarError) {
|
|
1657
|
+
process.stderr.write(`[greedysearch] Provenance sidecar write failed (non-critical): ${sidecarError.message}
|
|
1658
|
+
`);
|
|
1659
|
+
}
|
|
1660
|
+
return {
|
|
1661
|
+
dir,
|
|
1662
|
+
statusPath: join(dir, "STATUS.md"),
|
|
1663
|
+
summaryPath: join(reportsDir, "SUMMARY.md"),
|
|
1664
|
+
manifestPath: join(dataDir, "manifest.json"),
|
|
1665
|
+
provenancePath: join(dir, "provenance.md"),
|
|
1666
|
+
sourceCount: sourceFiles.length,
|
|
1667
|
+
sourceFiles
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
export async function runResearchMode({
|
|
1671
|
+
query,
|
|
1672
|
+
breadth,
|
|
1673
|
+
iterations,
|
|
1674
|
+
maxSources,
|
|
1675
|
+
locale = null,
|
|
1676
|
+
short = false,
|
|
1677
|
+
qualityThreshold = 8.5,
|
|
1678
|
+
writeBundle = process.env.GREEDY_RESEARCH_BUNDLE !== "0",
|
|
1679
|
+
researchOutDir = null
|
|
1680
|
+
} = {}) {
|
|
1681
|
+
const options = clampResearchOptions({ breadth, iterations, maxSources });
|
|
1682
|
+
const userSpecifiedBreadth = breadth !== undefined && breadth !== null;
|
|
1683
|
+
const userSpecifiedIterations = iterations !== undefined && iterations !== null;
|
|
1684
|
+
const atDefaults = !userSpecifiedBreadth && !userSpecifiedIterations;
|
|
1685
|
+
if (atDefaults) {
|
|
1686
|
+
try {
|
|
1687
|
+
const classification = await classifyResearchComplexity(query);
|
|
1688
|
+
process.stderr.write(`[greedysearch] Complexity: ${classification.complexity} (${classification.reasoning})
|
|
1689
|
+
`);
|
|
1690
|
+
if (classification.complexity === "simple") {
|
|
1691
|
+
process.stderr.write(`[greedysearch] Simple query detected — using fast single-pass path
|
|
1692
|
+
`);
|
|
1693
|
+
return runSimpleResearchMode({
|
|
1694
|
+
query,
|
|
1695
|
+
locale,
|
|
1696
|
+
maxSources: Math.min(maxSources ?? 5, 5),
|
|
1697
|
+
qualityThreshold,
|
|
1698
|
+
writeBundle,
|
|
1699
|
+
researchOutDir
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
if (!userSpecifiedBreadth) {
|
|
1703
|
+
options.breadth = classification.suggestedBreadth;
|
|
1704
|
+
}
|
|
1705
|
+
if (!userSpecifiedIterations) {
|
|
1706
|
+
options.iterations = classification.suggestedIterations;
|
|
1707
|
+
}
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
process.stderr.write(`[greedysearch] Scale classification failed, using defaults: ${error.message}
|
|
1710
|
+
`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const rounds = [];
|
|
1714
|
+
let allLearnings = [];
|
|
1715
|
+
let allGaps = [];
|
|
1716
|
+
const questions = createQuestionLedger(query);
|
|
1717
|
+
let activeActions = null;
|
|
1718
|
+
let combinedSources = [];
|
|
1719
|
+
let fetchedSources = [];
|
|
1720
|
+
let evidenceItems = [];
|
|
1721
|
+
const extractedSourceKeys = new Set;
|
|
1722
|
+
const usedQueries = new Set;
|
|
1723
|
+
const usedUrls = new Set;
|
|
1724
|
+
const qualityHistory = [];
|
|
1725
|
+
let terminationReason = "max_rounds";
|
|
1726
|
+
const startedAt = new Date().toISOString();
|
|
1727
|
+
const startMs = Date.now();
|
|
1728
|
+
let totalActionsRun = 0;
|
|
1729
|
+
let totalSearches = 0;
|
|
1730
|
+
let totalFetches = 0;
|
|
1731
|
+
const engineFailures = [];
|
|
1732
|
+
const progressTracker = createProgressTracker({
|
|
1733
|
+
totalActions: options.iterations * options.breadth,
|
|
1734
|
+
totalRounds: options.iterations,
|
|
1735
|
+
totalFetches: options.iterations,
|
|
1736
|
+
silent: process.env.GREEDY_RESEARCH_QUIET === "1"
|
|
1737
|
+
});
|
|
1738
|
+
progressTracker.startRound(1);
|
|
1739
|
+
process.stderr.write(`[greedysearch] Research mode: breadth ${options.breadth}, iterations ${options.iterations}, qualityThreshold ${qualityThreshold}, engines ${RESEARCH_ENGINES.join(",")}, synthesizer gemini
|
|
1740
|
+
`);
|
|
1741
|
+
for (let roundIndex = 0;roundIndex < options.iterations; roundIndex++) {
|
|
1742
|
+
const roundNumber = roundIndex + 1;
|
|
1743
|
+
const roundBreadth = Math.max(1, Math.ceil(options.breadth / 2 ** roundIndex));
|
|
1744
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:planning
|
|
1745
|
+
`);
|
|
1746
|
+
if (!activeActions) {
|
|
1747
|
+
try {
|
|
1748
|
+
const rawPlan = await runGeminiPrompt(buildResearchActionPrompt(query, roundBreadth, allLearnings, allGaps, [...usedUrls]), { timeoutMs: 120000 });
|
|
1749
|
+
let planActions = parseActionPlan(rawPlan, roundBreadth);
|
|
1750
|
+
if (roundIndex === 0) {
|
|
1751
|
+
planActions.unshift({
|
|
1752
|
+
type: "search",
|
|
1753
|
+
query,
|
|
1754
|
+
researchGoal: "Original user query"
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
planActions = await normalizeGitHubFetchActions(planActions, usedUrls);
|
|
1758
|
+
activeActions = planActions;
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
process.stderr.write(`[greedysearch] Action planning failed, using fallback queries: ${error.message}
|
|
1761
|
+
`);
|
|
1762
|
+
const fallbackQueries = normalizeResearchQueries(null, query, roundBreadth, {
|
|
1763
|
+
includeOriginal: roundIndex === 0,
|
|
1764
|
+
exclude: usedQueries
|
|
1765
|
+
});
|
|
1766
|
+
activeActions = queriesToActions(fallbackQueries);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const noveltyFiltered = (activeActions || []).filter((action) => {
|
|
1770
|
+
if (action.type === "search") {
|
|
1771
|
+
const pass = !isDuplicateQuery(action.query, usedQueries, {
|
|
1772
|
+
roundIndex,
|
|
1773
|
+
originalQuery: query
|
|
1774
|
+
});
|
|
1775
|
+
if (!pass) {
|
|
1776
|
+
process.stderr.write(`[greedysearch] Novelty gate rejected search: ${action.query}
|
|
1777
|
+
`);
|
|
1778
|
+
}
|
|
1779
|
+
return pass;
|
|
1780
|
+
}
|
|
1781
|
+
if (action.type === "fetchUrl") {
|
|
1782
|
+
const pass = !usedUrls.has(action.url);
|
|
1783
|
+
if (!pass) {
|
|
1784
|
+
process.stderr.write(`[greedysearch] Novelty gate rejected fetch: ${action.url}
|
|
1785
|
+
`);
|
|
1786
|
+
}
|
|
1787
|
+
return pass;
|
|
1788
|
+
}
|
|
1789
|
+
return false;
|
|
1790
|
+
});
|
|
1791
|
+
const roundActions = noveltyFiltered.slice(0, roundBreadth);
|
|
1792
|
+
const academicTargets = pickAcademicFetchTargets(combinedSources, usedUrls);
|
|
1793
|
+
const hasFetch = roundActions.some((a) => a.type === "fetchUrl");
|
|
1794
|
+
if (!hasFetch && academicTargets.length > 0) {
|
|
1795
|
+
const injectTarget = academicTargets[0];
|
|
1796
|
+
roundActions.push({
|
|
1797
|
+
type: "fetchUrl",
|
|
1798
|
+
url: injectTarget.url,
|
|
1799
|
+
researchGoal: `Direct fetch of known academic source: ${injectTarget.label || injectTarget.url}`
|
|
1800
|
+
});
|
|
1801
|
+
process.stderr.write(`[greedysearch] Forced fetchUrl for academic source: ${injectTarget.url}
|
|
1802
|
+
`);
|
|
1803
|
+
}
|
|
1804
|
+
const actionResults = new Array(roundActions.length);
|
|
1805
|
+
const actionWorkerCount = Math.min(3, roundActions.length);
|
|
1806
|
+
let nextActionIndex = 0;
|
|
1807
|
+
async function actionWorker() {
|
|
1808
|
+
while (true) {
|
|
1809
|
+
const i = nextActionIndex++;
|
|
1810
|
+
if (i >= roundActions.length)
|
|
1811
|
+
return;
|
|
1812
|
+
const action = roundActions[i];
|
|
1813
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:action-${i + 1}/${roundActions.length}
|
|
1814
|
+
`);
|
|
1815
|
+
process.stderr.write(`[greedysearch] Action ${i + 1}/${roundActions.length} [${action.type}]: ${(action.query || action.url).slice(0, 80)}
|
|
1816
|
+
`);
|
|
1817
|
+
progressTracker.startAction(action.type, (action.query || action.url || "").slice(0, 60));
|
|
1818
|
+
const run = await executeResearchAction(action, {
|
|
1819
|
+
locale,
|
|
1820
|
+
short,
|
|
1821
|
+
usedQueries,
|
|
1822
|
+
usedUrls,
|
|
1823
|
+
maxChars: 8000
|
|
1824
|
+
});
|
|
1825
|
+
progressTracker.endAction();
|
|
1826
|
+
actionResults[i] = run;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
await Promise.all(Array.from({ length: actionWorkerCount }, () => actionWorker()));
|
|
1830
|
+
const actionRuns = [];
|
|
1831
|
+
for (let i = 0;i < roundActions.length; i++) {
|
|
1832
|
+
const action = roundActions[i];
|
|
1833
|
+
const run = actionResults[i];
|
|
1834
|
+
actionRuns.push(run);
|
|
1835
|
+
totalActionsRun++;
|
|
1836
|
+
if (action.type === "search")
|
|
1837
|
+
totalSearches++;
|
|
1838
|
+
if (action.type === "fetchUrl") {
|
|
1839
|
+
totalFetches++;
|
|
1840
|
+
progressTracker.endFetch(run.ok);
|
|
1841
|
+
}
|
|
1842
|
+
if (!run.ok) {
|
|
1843
|
+
engineFailures.push({
|
|
1844
|
+
round: roundNumber,
|
|
1845
|
+
type: action.type,
|
|
1846
|
+
target: action.query || action.url,
|
|
1847
|
+
error: run.error
|
|
1848
|
+
});
|
|
1849
|
+
process.stderr.write(`[greedysearch] Action failed: ${run.error}
|
|
1850
|
+
`);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
const searchActionRuns = actionRuns.filter((r) => r.action.type === "search");
|
|
1854
|
+
const fetchActionRuns = actionRuns.filter((r) => r.action.type === "fetchUrl");
|
|
1855
|
+
updateQuestionLedger(questions, { roundNumber, actions: actionRuns });
|
|
1856
|
+
combinedSources = dedupeSources([
|
|
1857
|
+
combinedSources,
|
|
1858
|
+
searchActionRuns.flatMap((run) => run.sources || []),
|
|
1859
|
+
fetchActionRuns.flatMap((run) => run.sources || [])
|
|
1860
|
+
]);
|
|
1861
|
+
for (const fetchRun of fetchActionRuns) {
|
|
1862
|
+
if (fetchRun.fetchResult) {
|
|
1863
|
+
fetchedSources.push(fetchRun.fetchResult);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
fetchedSources = dedupeFetchedSources(fetchedSources);
|
|
1867
|
+
const remainingFetchBudget = Math.max(0, options.maxSources - fetchedSources.filter((source) => source?.content || source?.contentChars > 100).length);
|
|
1868
|
+
if (remainingFetchBudget > 0 && combinedSources.length > 0) {
|
|
1869
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:fetching
|
|
1870
|
+
`);
|
|
1871
|
+
const fetchedUrlKeys = new Set;
|
|
1872
|
+
const safeNormalizeFetchUrl = (value) => {
|
|
1873
|
+
try {
|
|
1874
|
+
return normalizeUrl(value || "");
|
|
1875
|
+
} catch {
|
|
1876
|
+
return "";
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
for (const source of fetchedSources) {
|
|
1880
|
+
for (const value of [
|
|
1881
|
+
source?.url,
|
|
1882
|
+
source?.finalUrl,
|
|
1883
|
+
source?.canonicalUrl
|
|
1884
|
+
]) {
|
|
1885
|
+
const key = safeNormalizeFetchUrl(value);
|
|
1886
|
+
if (key)
|
|
1887
|
+
fetchedUrlKeys.add(key);
|
|
1888
|
+
}
|
|
313
1889
|
}
|
|
314
|
-
|
|
1890
|
+
const fetchCandidates = combinedSources.filter((source) => {
|
|
1891
|
+
const keys = [source?.canonicalUrl, source?.finalUrl, source?.url].map((value) => safeNormalizeFetchUrl(value)).filter(Boolean);
|
|
1892
|
+
return keys.length > 0 && keys.every((key) => !fetchedUrlKeys.has(key));
|
|
1893
|
+
});
|
|
1894
|
+
const fetched = await fetchMultipleResearchSources(fetchCandidates, Math.min(remainingFetchBudget, fetchCandidates.length), 8000, Math.min(3, remainingFetchBudget || 1));
|
|
1895
|
+
fetchedSources = dedupeFetchedSources([...fetchedSources, ...fetched]);
|
|
1896
|
+
combinedSources = mergeFetchDataIntoSources(combinedSources, fetchedSources);
|
|
1897
|
+
}
|
|
1898
|
+
fetchedSources = annotateFetchedSourcesWithIds(fetchedSources, combinedSources);
|
|
1899
|
+
const roundQueries = actionRuns.map((run) => ({
|
|
1900
|
+
query: run.action.query || run.action.url || "",
|
|
1901
|
+
researchGoal: run.action.researchGoal || ""
|
|
1902
|
+
}));
|
|
1903
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:evidence
|
|
1904
|
+
`);
|
|
1905
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:learning
|
|
1906
|
+
`);
|
|
1907
|
+
const combinedExtraction = await extractEvidenceAndLearnings({
|
|
1908
|
+
query,
|
|
1909
|
+
questions,
|
|
1910
|
+
fetchedSources,
|
|
1911
|
+
extractedSourceKeys,
|
|
1912
|
+
roundQueries,
|
|
1913
|
+
searchSummaries: searchActionRuns.map((run) => ({
|
|
1914
|
+
query: run.action.query,
|
|
1915
|
+
researchGoal: run.action.researchGoal,
|
|
1916
|
+
error: run.error || "",
|
|
1917
|
+
engines: summarizeEngineAnswers(run.result)
|
|
1918
|
+
})),
|
|
1919
|
+
evidenceItems
|
|
1920
|
+
});
|
|
1921
|
+
const evidenceRun = {
|
|
1922
|
+
evidence: combinedExtraction.evidence,
|
|
1923
|
+
error: combinedExtraction.evidenceError
|
|
315
1924
|
};
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1925
|
+
if (evidenceRun.error) {
|
|
1926
|
+
process.stderr.write(`[greedysearch] Evidence extraction failed: ${evidenceRun.error}
|
|
1927
|
+
`);
|
|
1928
|
+
}
|
|
1929
|
+
evidenceItems = [...evidenceItems, ...evidenceRun.evidence];
|
|
1930
|
+
for (const evidence of evidenceRun.evidence) {
|
|
1931
|
+
updateQuestionLedger(questions, {
|
|
1932
|
+
roundNumber,
|
|
1933
|
+
learningPayload: {
|
|
1934
|
+
answeredQuestions: evidence.answers || [],
|
|
1935
|
+
newQuestions: evidence.newQuestions || []
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
const learningPayload = combinedExtraction.learningPayload;
|
|
1940
|
+
const learningError = combinedExtraction.learningError;
|
|
1941
|
+
if (learningError) {
|
|
1942
|
+
process.stderr.write(`[greedysearch] Learning extraction failed: ${learningError}
|
|
1943
|
+
`);
|
|
1944
|
+
}
|
|
1945
|
+
const learnings = Array.isArray(learningPayload.learnings) ? learningPayload.learnings.map((l) => String(l)).filter(Boolean).slice(0, 8) : [];
|
|
1946
|
+
const gaps = Array.isArray(learningPayload.gaps) ? learningPayload.gaps.map((g) => String(g)).filter(Boolean).slice(0, 6) : [];
|
|
1947
|
+
allLearnings = uniqueStrings([...allLearnings, ...learnings]);
|
|
1948
|
+
allGaps = uniqueStrings([...allGaps, ...gaps]);
|
|
1949
|
+
updateQuestionLedger(questions, {
|
|
1950
|
+
roundNumber,
|
|
1951
|
+
actions: [],
|
|
1952
|
+
learningPayload,
|
|
1953
|
+
gaps
|
|
1954
|
+
});
|
|
1955
|
+
rounds.push({
|
|
1956
|
+
round: roundNumber,
|
|
1957
|
+
actions: actionRuns.map((run) => ({
|
|
1958
|
+
type: run.action.type,
|
|
1959
|
+
query: run.action.query || "",
|
|
1960
|
+
url: run.action.url || "",
|
|
1961
|
+
researchGoal: run.action.researchGoal || "",
|
|
1962
|
+
error: run.error || "",
|
|
1963
|
+
sourceCount: run.sources?.length || 0
|
|
1964
|
+
})),
|
|
1965
|
+
learnings,
|
|
1966
|
+
gaps,
|
|
1967
|
+
evidence: evidenceRun.evidence,
|
|
1968
|
+
evidenceError: evidenceRun.error,
|
|
1969
|
+
learningError
|
|
1970
|
+
});
|
|
1971
|
+
process.stderr.write(`PROGRESS:research:round-${roundNumber}:evaluating
|
|
1972
|
+
`);
|
|
1973
|
+
progressTracker.endRound();
|
|
1974
|
+
if (roundNumber < options.iterations) {
|
|
1975
|
+
progressTracker.startRound(roundNumber + 1);
|
|
1976
|
+
}
|
|
1977
|
+
const isFinalRound = roundNumber === options.iterations;
|
|
1978
|
+
const evaluation = isFinalRound ? {
|
|
1979
|
+
score: qualityHistory.length > 0 ? qualityHistory[qualityHistory.length - 1] : 5,
|
|
1980
|
+
coverage: {},
|
|
1981
|
+
knowledgeGaps: [],
|
|
1982
|
+
shouldContinue: false,
|
|
1983
|
+
nextActions: [],
|
|
1984
|
+
terminationReason: null,
|
|
1985
|
+
evaluationError: ""
|
|
1986
|
+
} : await evaluateResearchQuality(query, rounds, allLearnings, allGaps, qualityHistory);
|
|
1987
|
+
qualityHistory.push(evaluation.score);
|
|
1988
|
+
allGaps = uniqueStrings([...allGaps, ...evaluation.knowledgeGaps || []]);
|
|
1989
|
+
updateQuestionLedger(questions, {
|
|
1990
|
+
roundNumber,
|
|
1991
|
+
gaps: evaluation.knowledgeGaps || []
|
|
1992
|
+
});
|
|
1993
|
+
const preliminaryFloor = computeResearchFloor({
|
|
1994
|
+
sources: combinedSources,
|
|
1995
|
+
fetchedSources,
|
|
1996
|
+
gaps: allGaps,
|
|
1997
|
+
questions,
|
|
1998
|
+
rounds,
|
|
1999
|
+
qualityScore: evaluation.score,
|
|
2000
|
+
qualityThreshold,
|
|
2001
|
+
maxSources: options.maxSources,
|
|
2002
|
+
requireCitations: false,
|
|
2003
|
+
requireQuestions: false
|
|
2004
|
+
});
|
|
2005
|
+
process.stderr.write(`[greedysearch] Quality score round ${roundNumber}: ${evaluation.score.toFixed(1)} (shouldContinue: ${evaluation.shouldContinue}, floor: ${preliminaryFloor.floorMet})
|
|
2006
|
+
`);
|
|
2007
|
+
if (evaluation.score >= qualityThreshold && preliminaryFloor.floorMet && (!evaluation.shouldContinue || evaluation.terminationReason === "quality_threshold")) {
|
|
2008
|
+
terminationReason = evaluation.terminationReason || "quality_threshold";
|
|
2009
|
+
process.stderr.write(`[greedysearch] Research floor reached (score: ${evaluation.score.toFixed(1)}). Terminating early.
|
|
2010
|
+
`);
|
|
2011
|
+
break;
|
|
2012
|
+
}
|
|
2013
|
+
const nextBreadth = Math.max(1, Math.ceil(roundBreadth / 2));
|
|
2014
|
+
const followUpActions = (learningPayload.followUpQueries || []).map((q) => ({
|
|
2015
|
+
type: "search",
|
|
2016
|
+
query: sanitizeResearchQuery(String(q)),
|
|
2017
|
+
researchGoal: "Follow-up from learning extraction"
|
|
2018
|
+
})).filter((a) => a.query && a.query.toLowerCase() !== query.toLowerCase()).slice(0, nextBreadth);
|
|
2019
|
+
let nextActiveActions = followUpActions;
|
|
2020
|
+
if (nextActiveActions.length < nextBreadth && evaluation.nextActions.length > 0) {
|
|
2021
|
+
const evaluatorActions = evaluation.nextActions.map((a) => validateAction(a)).filter(Boolean);
|
|
2022
|
+
const merged = [...nextActiveActions, ...evaluatorActions];
|
|
2023
|
+
nextActiveActions = merged.slice(0, nextBreadth);
|
|
2024
|
+
}
|
|
2025
|
+
if (nextActiveActions.length < nextBreadth && allGaps.length > 0) {
|
|
2026
|
+
const fallbacks = buildFallbackQueriesFromGaps(allGaps, query, usedQueries, nextBreadth - nextActiveActions.length, roundIndex + 1);
|
|
2027
|
+
const fallbackActions = fallbacks.map((f) => ({
|
|
2028
|
+
type: "search",
|
|
2029
|
+
query: f.query,
|
|
2030
|
+
researchGoal: f.researchGoal
|
|
2031
|
+
}));
|
|
2032
|
+
nextActiveActions = [...nextActiveActions, ...fallbackActions].slice(0, nextBreadth);
|
|
2033
|
+
if (fallbacks.length > 0) {
|
|
2034
|
+
process.stderr.write(`[greedysearch] Generated ${fallbacks.length} gap-driven fallback actions.
|
|
2035
|
+
`);
|
|
331
2036
|
}
|
|
332
|
-
|
|
2037
|
+
}
|
|
2038
|
+
activeActions = nextActiveActions.length >= nextBreadth ? nextActiveActions : null;
|
|
2039
|
+
}
|
|
2040
|
+
process.stderr.write(`PROGRESS:research:final-report
|
|
2041
|
+
`);
|
|
2042
|
+
let synthesis = {
|
|
2043
|
+
answer: allLearnings.length ? allLearnings.map((learning) => `- ${learning}`).join(`
|
|
2044
|
+
`) : "Research completed, but no structured learnings were extracted.",
|
|
2045
|
+
agreement: { level: "mixed", summary: "Research synthesis fallback." },
|
|
2046
|
+
differences: [],
|
|
2047
|
+
caveats: [],
|
|
2048
|
+
claims: [],
|
|
2049
|
+
recommendedSources: combinedSources.slice(0, 4).map((source) => source.id),
|
|
2050
|
+
synthesized: false
|
|
2051
|
+
};
|
|
2052
|
+
try {
|
|
2053
|
+
const rawReport = await runGeminiPrompt(buildFinalReportPrompt(query, rounds, combinedSources, questions, evidenceItems), { timeoutMs: 180000 });
|
|
2054
|
+
const parsed = parseGeminiJson(rawReport, {});
|
|
2055
|
+
const hasClaims = Array.isArray(parsed?.claims) && parsed.claims.length > 0;
|
|
2056
|
+
synthesis = {
|
|
2057
|
+
...synthesis,
|
|
2058
|
+
...parsed,
|
|
2059
|
+
rawAnswer: rawReport.answer || "",
|
|
2060
|
+
geminiSources: rawReport.sources || [],
|
|
2061
|
+
synthesized: hasClaims
|
|
333
2062
|
};
|
|
334
|
-
} catch(
|
|
335
|
-
}
|
|
336
|
-
`
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
[greedysearch]
|
|
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
|
-
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
process.stderr.write(`[greedysearch] Final report failed: ${error.message}
|
|
2065
|
+
`);
|
|
2066
|
+
synthesis.error = error.message;
|
|
2067
|
+
}
|
|
2068
|
+
const hasStructuredSynthesis = synthesis.synthesized === true && Array.isArray(synthesis.claims) && synthesis.claims.length > 0;
|
|
2069
|
+
if (!hasStructuredSynthesis && evidenceItems.length > 0) {
|
|
2070
|
+
process.stderr.write(`[greedysearch] Falling back to evidence-based synthesis (no per-round learnings).
|
|
2071
|
+
`);
|
|
2072
|
+
try {
|
|
2073
|
+
const evidencePrompt = buildSynthesisFromEvidencePrompt(query, combinedSources, questions, evidenceItems);
|
|
2074
|
+
const rawEvidenceReport = await runGeminiPrompt(evidencePrompt, {
|
|
2075
|
+
timeoutMs: 180000
|
|
2076
|
+
});
|
|
2077
|
+
const parsedEvidence = parseGeminiJson(rawEvidenceReport, {});
|
|
2078
|
+
synthesis = {
|
|
2079
|
+
...synthesis,
|
|
2080
|
+
...parsedEvidence,
|
|
2081
|
+
rawAnswer: rawEvidenceReport.answer || synthesis.answer || "",
|
|
2082
|
+
geminiSources: rawEvidenceReport.sources || synthesis.geminiSources || [],
|
|
2083
|
+
synthesized: true,
|
|
2084
|
+
synthesisMode: "evidence_fallback"
|
|
2085
|
+
};
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
process.stderr.write(`[greedysearch] Evidence-based synthesis failed: ${error.message}
|
|
2088
|
+
`);
|
|
2089
|
+
synthesis.evidenceFallbackError = error.message;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
const finishedAt = new Date().toISOString();
|
|
2093
|
+
const durationMs = Date.now() - startMs;
|
|
2094
|
+
const qualityScore = qualityHistory.at(-1) || 0;
|
|
2095
|
+
fetchedSources = annotateFetchedSourcesWithIds(fetchedSources, combinedSources);
|
|
2096
|
+
process.stderr.write(`PROGRESS:research:audit-citations
|
|
2097
|
+
`);
|
|
2098
|
+
const citationAudit = auditCitations(synthesis.answer || "", combinedSources);
|
|
2099
|
+
const citationUrls = await runCitationUrlCheck(combinedSources, citationAudit);
|
|
2100
|
+
reconcileQuestionsFromSynthesis(questions, synthesis, citationAudit);
|
|
2101
|
+
const floor = computeResearchFloor({
|
|
2102
|
+
sources: combinedSources,
|
|
2103
|
+
fetchedSources,
|
|
2104
|
+
synthesis,
|
|
2105
|
+
citationAudit,
|
|
2106
|
+
gaps: allGaps,
|
|
2107
|
+
questions,
|
|
2108
|
+
rounds,
|
|
2109
|
+
qualityScore,
|
|
2110
|
+
qualityThreshold,
|
|
2111
|
+
maxSources: options.maxSources
|
|
2112
|
+
});
|
|
2113
|
+
if (floor.floorMet && terminationReason === "max_rounds") {
|
|
2114
|
+
terminationReason = "done_floor_met";
|
|
2115
|
+
} else if (!floor.floorMet && terminationReason === "quality_threshold") {
|
|
2116
|
+
terminationReason = "max_rounds_floor_unmet";
|
|
2117
|
+
}
|
|
2118
|
+
const manifest = {
|
|
2119
|
+
startedAt,
|
|
2120
|
+
finishedAt,
|
|
2121
|
+
durationMs,
|
|
2122
|
+
engines: RESEARCH_ENGINES,
|
|
2123
|
+
synthesizer: "gemini",
|
|
2124
|
+
rounds: rounds.length,
|
|
2125
|
+
actionsRun: totalActionsRun,
|
|
2126
|
+
searches: totalSearches,
|
|
2127
|
+
fetches: totalFetches,
|
|
2128
|
+
sourcesFetched: fetchedSources.filter((s) => s?.contentChars > 100).length,
|
|
2129
|
+
engineFailures,
|
|
2130
|
+
terminationReason,
|
|
2131
|
+
floorMet: floor.floorMet
|
|
2132
|
+
};
|
|
2133
|
+
let bundle = null;
|
|
2134
|
+
let fetchedFiles;
|
|
2135
|
+
if (writeBundle) {
|
|
2136
|
+
process.stderr.write(`PROGRESS:research:bundle
|
|
2137
|
+
`);
|
|
2138
|
+
try {
|
|
2139
|
+
bundle = await writeResearchBundle({
|
|
2140
|
+
query,
|
|
2141
|
+
rounds,
|
|
2142
|
+
sources: combinedSources,
|
|
2143
|
+
fetchedSources,
|
|
2144
|
+
evidenceItems,
|
|
2145
|
+
synthesis,
|
|
2146
|
+
citationAudit,
|
|
2147
|
+
citationUrls,
|
|
2148
|
+
floor,
|
|
2149
|
+
manifest,
|
|
2150
|
+
allGaps,
|
|
2151
|
+
questions,
|
|
2152
|
+
outDir: researchOutDir
|
|
2153
|
+
});
|
|
2154
|
+
fetchedFiles = bundle.sourceFiles;
|
|
2155
|
+
delete bundle.sourceFiles;
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
bundle = { error: error.message || String(error) };
|
|
2158
|
+
fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
|
|
2159
|
+
}
|
|
2160
|
+
} else {
|
|
2161
|
+
fetchedFiles = await writeResearchSourcesToFiles(fetchedSources);
|
|
2162
|
+
}
|
|
2163
|
+
process.stderr.write(`PROGRESS:research:done
|
|
2164
|
+
`);
|
|
2165
|
+
progressTracker.finish();
|
|
2166
|
+
return {
|
|
2167
|
+
query,
|
|
2168
|
+
_research: {
|
|
2169
|
+
mode: "iterative",
|
|
2170
|
+
breadth: options.breadth,
|
|
2171
|
+
iterations: options.iterations,
|
|
2172
|
+
maxSources: options.maxSources,
|
|
2173
|
+
rounds,
|
|
2174
|
+
learnings: allLearnings,
|
|
2175
|
+
gaps: allGaps,
|
|
2176
|
+
evidence: evidenceItems,
|
|
2177
|
+
questions,
|
|
2178
|
+
questionProgress: questionProgress(questions),
|
|
2179
|
+
qualityHistory,
|
|
2180
|
+
terminationReason,
|
|
2181
|
+
qualityThreshold,
|
|
2182
|
+
floor,
|
|
2183
|
+
bundle,
|
|
2184
|
+
manifest
|
|
2185
|
+
},
|
|
2186
|
+
_citationAudit: citationAudit,
|
|
2187
|
+
_citationUrls: citationUrls,
|
|
2188
|
+
_sources: combinedSources,
|
|
2189
|
+
_fetchedSources: fetchedFiles,
|
|
2190
|
+
_synthesis: synthesis,
|
|
2191
|
+
_confidence: {
|
|
2192
|
+
sourcesCount: combinedSources.length,
|
|
2193
|
+
fetchedSourceSuccessRate: fetchedSources.length > 0 ? Number((fetchedSources.filter((source) => source.contentChars > 100).length / fetchedSources.length).toFixed(2)) : 0,
|
|
2194
|
+
agreementLevel: synthesis.agreement?.level || "mixed",
|
|
2195
|
+
floorMet: floor.floorMet
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
function dedupeFetchedSources(sources) {
|
|
2200
|
+
const byUrl = new Map;
|
|
2201
|
+
for (const source of sources) {
|
|
2202
|
+
const key = source?.id || normalizeUrl(source?.finalUrl || source?.url || "");
|
|
2203
|
+
if (!key)
|
|
2204
|
+
continue;
|
|
2205
|
+
const existing = byUrl.get(key);
|
|
2206
|
+
if (!existing || (source.contentChars || 0) > (existing.contentChars || 0)) {
|
|
2207
|
+
byUrl.set(key, source);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
const tokenInfo = new Map;
|
|
2211
|
+
function getTokenInfo(source) {
|
|
2212
|
+
let info = tokenInfo.get(source);
|
|
2213
|
+
if (!info) {
|
|
2214
|
+
const content = String(source.content || source.snippet || "");
|
|
2215
|
+
info = {
|
|
2216
|
+
length: content.length,
|
|
2217
|
+
tokens: tokenSet(content.slice(0, 4000))
|
|
2218
|
+
};
|
|
2219
|
+
tokenInfo.set(source, info);
|
|
2220
|
+
}
|
|
2221
|
+
return info;
|
|
2222
|
+
}
|
|
2223
|
+
function jaccardSetSimilarity(a, b) {
|
|
2224
|
+
let intersection = 0;
|
|
2225
|
+
const smaller = a.size <= b.size ? a : b;
|
|
2226
|
+
const larger = a.size <= b.size ? b : a;
|
|
2227
|
+
for (const t of smaller) {
|
|
2228
|
+
if (larger.has(t))
|
|
2229
|
+
intersection++;
|
|
2230
|
+
}
|
|
2231
|
+
const union = a.size + b.size - intersection;
|
|
2232
|
+
if (union === 0)
|
|
2233
|
+
return 1;
|
|
2234
|
+
return intersection / union;
|
|
2235
|
+
}
|
|
2236
|
+
const out = [];
|
|
2237
|
+
for (const source of byUrl.values()) {
|
|
2238
|
+
const sourceInfo = getTokenInfo(source);
|
|
2239
|
+
const duplicateIndex = out.findIndex((existing) => {
|
|
2240
|
+
const existingInfo = tokenInfo.get(existing);
|
|
2241
|
+
if (sourceInfo.length < 400 || existingInfo.length < 400) {
|
|
2242
|
+
return false;
|
|
2243
|
+
}
|
|
2244
|
+
return jaccardSetSimilarity(sourceInfo.tokens, existingInfo.tokens) >= 0.9;
|
|
2245
|
+
});
|
|
2246
|
+
if (duplicateIndex === -1) {
|
|
2247
|
+
out.push(source);
|
|
2248
|
+
continue;
|
|
2249
|
+
}
|
|
2250
|
+
if ((source.contentChars || 0) > (out[duplicateIndex].contentChars || 0)) {
|
|
2251
|
+
out[duplicateIndex] = source;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
return out;
|
|
2255
|
+
}
|