@apmantza/greedysearch-pi 2.0.0 → 2.1.2
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/CHANGELOG.md +54 -3
- package/README.md +2 -2
- package/bin/search.mjs +121 -13
- package/extractors/bing-copilot.mjs +6 -14
- package/extractors/chatgpt.mjs +130 -13
- package/extractors/common.mjs +58 -1
- package/extractors/consent.mjs +182 -18
- package/extractors/gemini.mjs +51 -36
- package/extractors/google-ai.mjs +129 -128
- package/extractors/logically.mjs +68 -6
- package/extractors/perplexity.mjs +547 -217
- package/package.json +2 -2
- package/skills/greedy-search/skill.md +20 -18
- package/src/fetcher.mjs +15 -0
- package/src/formatters/results.ts +24 -2
- package/src/search/challenge-detect.mjs +205 -0
- package/src/search/constants.mjs +5 -0
- package/src/search/progress.mjs +145 -0
- package/src/search/recovery.mjs +25 -3
- package/src/search/research.mjs +366 -7
- package/src/search/scale-aware.mjs +93 -0
- package/src/search/simple-research.mjs +520 -0
- package/src/tools/greedy-search-handler.ts +8 -10
- package/src/tools/shared.ts +145 -20
- package/test.mjs +160 -12
package/src/tools/shared.ts
CHANGED
|
@@ -52,6 +52,19 @@ export function errorResult(prefix: string, e: unknown): ToolResult {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Progress update for a single engine finishing/failing */
|
|
56
|
+
type EngineProgress = {
|
|
57
|
+
type: "engine";
|
|
58
|
+
engine: string;
|
|
59
|
+
status: "done" | "error" | "needs-human";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Free-form progress text (e.g. research bar + ETA) */
|
|
63
|
+
type TextProgress = {
|
|
64
|
+
type: "text";
|
|
65
|
+
text: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Spawn search.mjs and collect JSON results, with progress streaming via stderr.
|
|
57
70
|
* Shared by GreedySearch tool handlers.
|
|
@@ -62,10 +75,7 @@ export function runSearch(
|
|
|
62
75
|
flags: string[],
|
|
63
76
|
searchBin: string,
|
|
64
77
|
signal?: AbortSignal,
|
|
65
|
-
onProgress?: (
|
|
66
|
-
engine: string,
|
|
67
|
-
status: "done" | "error" | "needs-human",
|
|
68
|
-
) => void,
|
|
78
|
+
onProgress?: (update: EngineProgress | TextProgress) => void,
|
|
69
79
|
options: { headless?: boolean } = {},
|
|
70
80
|
): Promise<Record<string, unknown>> {
|
|
71
81
|
return new Promise((resolve, reject) => {
|
|
@@ -108,20 +118,48 @@ export function runSearch(
|
|
|
108
118
|
// Engine progress: any known engine
|
|
109
119
|
const engineMatch = line.match(ENGINE_PROGRESS_RE);
|
|
110
120
|
if (engineMatch && onProgress) {
|
|
111
|
-
onProgress(
|
|
112
|
-
|
|
113
|
-
engineMatch[
|
|
114
|
-
|
|
121
|
+
onProgress({
|
|
122
|
+
type: "engine",
|
|
123
|
+
engine: engineMatch[1],
|
|
124
|
+
status: engineMatch[2] as "done" | "error" | "needs-human",
|
|
125
|
+
});
|
|
115
126
|
}
|
|
116
127
|
// Synthesis progress: skipped (manual verification) or done/error
|
|
117
128
|
const synthMatch = line.match(
|
|
118
129
|
/^PROGRESS:synthesis:(done|error|skipped)$/,
|
|
119
130
|
);
|
|
120
131
|
if (synthMatch && onProgress) {
|
|
121
|
-
onProgress(
|
|
122
|
-
"
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
onProgress({
|
|
133
|
+
type: "engine",
|
|
134
|
+
engine: "synthesis",
|
|
135
|
+
status: synthMatch[1] as "done" | "error" | "needs-human",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Research progress markers (planning/fetching/synthesizing)
|
|
139
|
+
const researchMatch = line.match(/^PROGRESS:research:(.+)$/);
|
|
140
|
+
if (researchMatch && onProgress) {
|
|
141
|
+
onProgress({
|
|
142
|
+
type: "text",
|
|
143
|
+
text: researchMatch[1],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Progress bar + ETA lines from createProgressTracker
|
|
147
|
+
const barMatch = line.match(/^\[greedysearch\] (\[.+?\] .+)$/);
|
|
148
|
+
if (barMatch && onProgress) {
|
|
149
|
+
onProgress({
|
|
150
|
+
type: "text",
|
|
151
|
+
text: barMatch[1],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Single-engine stage lines: "[perplexity] stage: nav (+563ms)"
|
|
155
|
+
const stageMatch = line.match(
|
|
156
|
+
/^\[(perplexity|google|chatgpt|bing|gemini|semantic-scholar|logically)\] stage: (.+) \(\+\d+ms\)$/,
|
|
157
|
+
);
|
|
158
|
+
if (stageMatch && onProgress) {
|
|
159
|
+
onProgress({
|
|
160
|
+
type: "text",
|
|
161
|
+
text: `${stageMatch[1]}: ${stageMatch[2]}`,
|
|
162
|
+
});
|
|
125
163
|
}
|
|
126
164
|
}
|
|
127
165
|
});
|
|
@@ -132,6 +170,15 @@ export function runSearch(
|
|
|
132
170
|
if (code !== 0) {
|
|
133
171
|
reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
|
|
134
172
|
} else {
|
|
173
|
+
// For single-engine calls, signal completion so the progress
|
|
174
|
+
// tracker can mark the engine as done.
|
|
175
|
+
if (onProgress && engine !== "all") {
|
|
176
|
+
onProgress({
|
|
177
|
+
type: "engine",
|
|
178
|
+
engine,
|
|
179
|
+
status: "done" as const,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
135
182
|
try {
|
|
136
183
|
resolve(JSON.parse(out.trim()));
|
|
137
184
|
} catch {
|
|
@@ -144,20 +191,72 @@ export function runSearch(
|
|
|
144
191
|
});
|
|
145
192
|
}
|
|
146
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Render a Unicode progress bar.
|
|
196
|
+
* Example: [████████████░░░░] for 75%
|
|
197
|
+
*/
|
|
198
|
+
function renderBar(done: number, total: number): string {
|
|
199
|
+
if (total <= 0) return "";
|
|
200
|
+
const width = 16;
|
|
201
|
+
const filled = Math.round((done / total) * width);
|
|
202
|
+
return (
|
|
203
|
+
"[" +
|
|
204
|
+
"█".repeat(Math.min(filled, width)) +
|
|
205
|
+
"░".repeat(Math.max(0, width - filled)) +
|
|
206
|
+
"]"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format milliseconds as a short human duration.
|
|
212
|
+
* e.g. "—" / "45s" / "1m 30s"
|
|
213
|
+
*/
|
|
214
|
+
function fmtDuration(ms: number): string {
|
|
215
|
+
if (ms < 1000) return "—";
|
|
216
|
+
const s = Math.round(ms / 1000);
|
|
217
|
+
if (s < 60) return `${s}s`;
|
|
218
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
219
|
+
}
|
|
220
|
+
|
|
147
221
|
/**
|
|
148
222
|
* Build a progress callback that tracks completed engines.
|
|
149
223
|
* Returns an onProgress function suitable for runSearch.
|
|
224
|
+
*
|
|
225
|
+
* For multi-engine calls (research or not) this shows a bar + ETA
|
|
226
|
+
* line that tracks fraction of engines completed. The bar always
|
|
227
|
+
* appears; the research-path bar from `createProgressTracker` takes
|
|
228
|
+
* priority when present.
|
|
150
229
|
*/
|
|
151
230
|
export function makeProgressTracker(
|
|
152
231
|
engines: readonly string[],
|
|
153
232
|
onUpdate: ((update: ProgressUpdate) => void) | undefined,
|
|
154
233
|
suffix: "Searching" | "Researching",
|
|
155
234
|
showSynthesis: boolean,
|
|
235
|
+
query?: string,
|
|
156
236
|
) {
|
|
237
|
+
const startedAt = Date.now();
|
|
157
238
|
const completed = new Map<string, "done" | "error" | "needs-human">();
|
|
239
|
+
let latestBarText = "";
|
|
240
|
+
|
|
241
|
+
function render() {
|
|
242
|
+
const lines: string[] = [];
|
|
243
|
+
lines.push(`**${suffix}...** ${query || ""}`.trim());
|
|
244
|
+
|
|
245
|
+
const done = completed.size;
|
|
246
|
+
|
|
247
|
+
// Multi-engine bar + ETA (unless research supplies its own bar)
|
|
248
|
+
if (engines.length > 1 && done > 0 && !latestBarText) {
|
|
249
|
+
const elapsed = Date.now() - startedAt;
|
|
250
|
+
const frac = Math.min(1, done / engines.length);
|
|
251
|
+
const etaMs = frac > 0.01 ? Math.round(elapsed / frac - elapsed) : null;
|
|
252
|
+
const bar = renderBar(done, engines.length);
|
|
253
|
+
const eta = etaMs != null && etaMs > 0 ? fmtDuration(etaMs) : "—";
|
|
254
|
+
lines.push(`${bar} ${done}/${engines.length} engines (ETA ${eta})`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Research mode bar from createProgressTracker has priority
|
|
258
|
+
if (latestBarText) lines.push(latestBarText);
|
|
158
259
|
|
|
159
|
-
return (eng: string, status: "done" | "error" | "needs-human") => {
|
|
160
|
-
completed.set(eng, status);
|
|
161
260
|
const parts: string[] = [];
|
|
162
261
|
for (const e of engines) {
|
|
163
262
|
const s = completed.get(e);
|
|
@@ -167,21 +266,47 @@ export function makeProgressTracker(
|
|
|
167
266
|
parts.push(`🔓 ${e} needs manual verification`);
|
|
168
267
|
else parts.push(`⏳ ${e}`);
|
|
169
268
|
}
|
|
170
|
-
|
|
171
|
-
// Gemini synthesis for a multi-engine search.
|
|
172
|
-
if (showSynthesis && completed.size >= engines.length) {
|
|
269
|
+
if (showSynthesis && done >= engines.length) {
|
|
173
270
|
const synStatus = completed.get("synthesis");
|
|
174
271
|
if (synStatus === "done") parts.push("✅ synthesized");
|
|
175
272
|
else if (synStatus === "error") parts.push("❌ synthesis failed");
|
|
176
273
|
else if (synStatus === "needs-human") parts.push("⏭️ synthesis skipped");
|
|
177
274
|
else parts.push("🔄 synthesizing");
|
|
178
275
|
}
|
|
276
|
+
if (parts.length > 0) {
|
|
277
|
+
// Engine status line: 5 engines with emoji+separator runs ~110
|
|
278
|
+
// chars (visible width 116+ because emoji take 2 cols each),
|
|
279
|
+
// which is over the 112-char terminal width. The TUI's
|
|
280
|
+
// Text.render can't wrap a single line and crashes with
|
|
281
|
+
// "Rendered line N exceeds terminal width (W > W-4)"
|
|
282
|
+
// if a single rendered line is wider than the terminal.
|
|
283
|
+
// Truncate at 90 chars (well under 100 visible-width to leave
|
|
284
|
+
// padding-room for variable emoji widths).
|
|
285
|
+
const statusLine = parts.join(" · ");
|
|
286
|
+
lines.push(
|
|
287
|
+
statusLine.length > 90
|
|
288
|
+
? statusLine.slice(0, 88) + "…"
|
|
289
|
+
: statusLine,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
179
292
|
|
|
180
293
|
onUpdate?.({
|
|
181
|
-
content: [
|
|
182
|
-
{ type: "text", text: `**${suffix}...** ${parts.join(" · ")}` },
|
|
183
|
-
],
|
|
294
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
184
295
|
details: { _progress: true },
|
|
185
296
|
} satisfies ProgressUpdate);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return (update: EngineProgress | TextProgress) => {
|
|
300
|
+
if (update.type === "text") {
|
|
301
|
+
if (update.text.startsWith("[")) {
|
|
302
|
+
latestBarText = update.text;
|
|
303
|
+
}
|
|
304
|
+
render();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { engine, status } = update;
|
|
309
|
+
completed.set(engine, status);
|
|
310
|
+
render();
|
|
186
311
|
};
|
|
187
312
|
}
|
package/test.mjs
CHANGED
|
@@ -98,7 +98,12 @@ if (["", "all", "unit", "quick", "smoke", "synth"].includes(mode)) {
|
|
|
98
98
|
section("🧪 Unit Tests");
|
|
99
99
|
|
|
100
100
|
subsection("stripQuotes — param double-escaping workaround (issue #2)");
|
|
101
|
-
|
|
101
|
+
// Inlined from src/tools/shared.ts — importing the .ts file from
|
|
102
|
+
// test.mjs works at the project root (Node strips types) but fails
|
|
103
|
+
// when test.mjs runs from the installed tarball in node_modules
|
|
104
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). Keep in sync with
|
|
105
|
+
// src/tools/shared.ts.
|
|
106
|
+
const stripQuotes = (val) => String(val ?? "").replace(/^"|"$/g, "");
|
|
102
107
|
|
|
103
108
|
const stripCases = [
|
|
104
109
|
// [input, expected, label]
|
|
@@ -167,24 +172,24 @@ if (["", "all", "unit", "quick", "smoke", "synth"].includes(mode)) {
|
|
|
167
172
|
["VERIFICATION REQUIRED", true, 'legacy pattern: "VERIFICATION REQUIRED"'],
|
|
168
173
|
["verification failed", true, 'extended: "verification" in sentence'],
|
|
169
174
|
[
|
|
170
|
-
"
|
|
175
|
+
"Cloudflare Turnstile challenge detected in closed shadow DOM",
|
|
171
176
|
true,
|
|
172
|
-
"new:
|
|
177
|
+
"new: CF closed-shadow-dom block triggers visible retry",
|
|
173
178
|
],
|
|
174
179
|
[
|
|
175
|
-
"
|
|
180
|
+
"Copilot verification required — please solve it manually in the browser window",
|
|
176
181
|
true,
|
|
177
|
-
"new:
|
|
182
|
+
"new: per-engine 'verification required' triggers visible retry",
|
|
178
183
|
],
|
|
179
184
|
[
|
|
180
|
-
"
|
|
185
|
+
"Network timeout after 30000ms",
|
|
181
186
|
true,
|
|
182
|
-
"new:
|
|
187
|
+
"new: timeout triggers visible retry",
|
|
183
188
|
],
|
|
184
189
|
[
|
|
185
|
-
"
|
|
190
|
+
"Perplexity input not found — page may be blocked or in unexpected state",
|
|
186
191
|
true,
|
|
187
|
-
"new:
|
|
192
|
+
"new: 'input not found' triggers visible retry",
|
|
188
193
|
],
|
|
189
194
|
["", false, "empty string"],
|
|
190
195
|
];
|
|
@@ -218,7 +223,7 @@ if (["", "all", "unit", "quick", "smoke", "synth"].includes(mode)) {
|
|
|
218
223
|
}
|
|
219
224
|
|
|
220
225
|
const retryEngines = findHeadlessBlockedEngines({
|
|
221
|
-
perplexity: { error: "
|
|
226
|
+
perplexity: { error: "Perplexity input not found — page may be blocked or in unexpected state" },
|
|
222
227
|
bing: { error: "Copilot verification required" },
|
|
223
228
|
google: { error: "Google verification required" },
|
|
224
229
|
});
|
|
@@ -233,11 +238,16 @@ if (["", "all", "unit", "quick", "smoke", "synth"].includes(mode)) {
|
|
|
233
238
|
const pplxTestCases = [
|
|
234
239
|
["ask-input selector not found", true, 'legacy: "ask-input"'],
|
|
235
240
|
[
|
|
236
|
-
"
|
|
241
|
+
"Perplexity input not found — page may be blocked or in unexpected state",
|
|
237
242
|
true,
|
|
238
|
-
"new:
|
|
243
|
+
"new: 'input not found' triggers for perplexity",
|
|
239
244
|
],
|
|
240
245
|
["Perplexity timeout", true, "timeout triggers visible retry"],
|
|
246
|
+
[
|
|
247
|
+
"Clipboard interceptor returned empty text",
|
|
248
|
+
false,
|
|
249
|
+
"new: 'clipboard' substring no longer triggers (was too broad — fired on routine DOM-fallback failures)",
|
|
250
|
+
],
|
|
241
251
|
];
|
|
242
252
|
for (const [error, expected, label] of pplxTestCases) {
|
|
243
253
|
const matched = isHeadlessBlockedError(error);
|
|
@@ -976,6 +986,115 @@ END_JSON`,
|
|
|
976
986
|
failMsg("citation audit: S2 should be flagged as unfetched");
|
|
977
987
|
}
|
|
978
988
|
|
|
989
|
+
subsection("Citation URL Reachability — checkCitationUrls");
|
|
990
|
+
const { checkCitationUrls, runCitationUrlCheck } = await import(
|
|
991
|
+
"./src/search/research.mjs"
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
// Empty sources → ok
|
|
995
|
+
const emptyResult = await checkCitationUrls([]);
|
|
996
|
+
if (emptyResult.ok && emptyResult.reachable.length === 0) {
|
|
997
|
+
passMsg("checkCitationUrls: empty sources returns ok");
|
|
998
|
+
} else {
|
|
999
|
+
failMsg(
|
|
1000
|
+
`checkCitationUrls: empty sources unexpected: ${JSON.stringify(emptyResult)}`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Non-HTTP URLs are skipped
|
|
1005
|
+
const nonHttpResult = await checkCitationUrls([
|
|
1006
|
+
{ id: "S1", url: "ftp://example.com/file" },
|
|
1007
|
+
{ id: "S2", url: "not-a-url" },
|
|
1008
|
+
]);
|
|
1009
|
+
if (
|
|
1010
|
+
nonHttpResult.ok &&
|
|
1011
|
+
nonHttpResult.skipped.length === 2 &&
|
|
1012
|
+
nonHttpResult.reachable.length === 0
|
|
1013
|
+
) {
|
|
1014
|
+
passMsg("checkCitationUrls: non-HTTP URLs are skipped");
|
|
1015
|
+
} else {
|
|
1016
|
+
failMsg(
|
|
1017
|
+
`checkCitationUrls: non-HTTP unexpected: ${JSON.stringify(nonHttpResult)}`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Concurrency guard: concurrency=0 should not infinite loop
|
|
1022
|
+
// Skip in CI — makes a real HEAD request to example.com which may be
|
|
1023
|
+
// blocked in sandboxed CI environments
|
|
1024
|
+
if (!process.env.CI) {
|
|
1025
|
+
const concurrencyResult = await checkCitationUrls(
|
|
1026
|
+
[{ id: "S1", url: "https://example.com" }],
|
|
1027
|
+
{ concurrency: 0, timeoutMs: 2000 },
|
|
1028
|
+
);
|
|
1029
|
+
if (concurrencyResult.ok || concurrencyResult.dead.length > 0) {
|
|
1030
|
+
passMsg("checkCitationUrls: concurrency=0 does not infinite loop");
|
|
1031
|
+
} else {
|
|
1032
|
+
failMsg(
|
|
1033
|
+
`checkCitationUrls: concurrency=0 unexpected: ${JSON.stringify(concurrencyResult)}`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// runCitationUrlCheck returns null on error (non-throwing)
|
|
1039
|
+
const runResult = await runCitationUrlCheck([]);
|
|
1040
|
+
if (runResult && runResult.ok) {
|
|
1041
|
+
passMsg("runCitationUrlCheck: empty sources returns ok");
|
|
1042
|
+
} else {
|
|
1043
|
+
failMsg(
|
|
1044
|
+
`runCitationUrlCheck: empty sources unexpected: ${JSON.stringify(runResult)}`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
subsection("Provenance Sidecar — writeProvenanceSidecar");
|
|
1049
|
+
const { writeProvenanceSidecar } = await import("./src/search/research.mjs");
|
|
1050
|
+
const { existsSync, rmSync } = await import("node:fs");
|
|
1051
|
+
const { tmpdir } = await import("node:os");
|
|
1052
|
+
|
|
1053
|
+
const testProvenanceDir = join(
|
|
1054
|
+
tmpdir(),
|
|
1055
|
+
`greedysearch-test-provenance-${Date.now()}`,
|
|
1056
|
+
);
|
|
1057
|
+
mkdirSync(testProvenanceDir, { recursive: true });
|
|
1058
|
+
|
|
1059
|
+
try {
|
|
1060
|
+
writeProvenanceSidecar(testProvenanceDir, {
|
|
1061
|
+
query: "test query",
|
|
1062
|
+
rounds: [{ round: 1, actions: [], learnings: [], gaps: [] }],
|
|
1063
|
+
sources: [{ id: "S1", title: "Test Source" }],
|
|
1064
|
+
fetchedSources: [{ id: "S1", contentChars: 500 }],
|
|
1065
|
+
citationAudit: { ok: true, cited: ["S1"], missing: [], unfetched: [] },
|
|
1066
|
+
citationUrls: { reachable: [], dead: [], skipped: [], ok: true },
|
|
1067
|
+
floor: { floorMet: true, checks: { citationsPresent: true } },
|
|
1068
|
+
manifest: {
|
|
1069
|
+
startedAt: "2026-01-01",
|
|
1070
|
+
finishedAt: "2026-01-01",
|
|
1071
|
+
durationMs: 1000,
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const provenancePath = join(testProvenanceDir, "provenance.md");
|
|
1076
|
+
if (existsSync(provenancePath)) {
|
|
1077
|
+
const content = readFileSync(provenancePath, "utf8");
|
|
1078
|
+
if (content.includes("test query") && content.includes("S1")) {
|
|
1079
|
+
passMsg(
|
|
1080
|
+
"writeProvenanceSidecar: writes provenance.md with query and sources",
|
|
1081
|
+
);
|
|
1082
|
+
} else {
|
|
1083
|
+
failMsg(
|
|
1084
|
+
"writeProvenanceSidecar: provenance.md missing expected content",
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
failMsg("writeProvenanceSidecar: provenance.md not created");
|
|
1089
|
+
}
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
failMsg(`writeProvenanceSidecar: threw error: ${e.message}`);
|
|
1092
|
+
} finally {
|
|
1093
|
+
try {
|
|
1094
|
+
rmSync(testProvenanceDir, { recursive: true, force: true });
|
|
1095
|
+
} catch {}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
979
1098
|
subsection("Research Floor and Question Ledger");
|
|
980
1099
|
const { computeResearchFloor, createQuestionLedger, updateQuestionLedger } =
|
|
981
1100
|
await import("./src/search/research.mjs");
|
|
@@ -1060,6 +1179,35 @@ trailing note`);
|
|
|
1060
1179
|
`structured JSON: failed to repair ${JSON.stringify(parsedLooseJson)}`,
|
|
1061
1180
|
);
|
|
1062
1181
|
}
|
|
1182
|
+
|
|
1183
|
+
subsection("Progress tracker — bar rendering and ETA");
|
|
1184
|
+
const { createProgressTracker } = await import("./src/search/progress.mjs");
|
|
1185
|
+
const silentTracker = createProgressTracker({
|
|
1186
|
+
totalActions: 4,
|
|
1187
|
+
silent: true,
|
|
1188
|
+
});
|
|
1189
|
+
silentTracker.startAction("search", "test");
|
|
1190
|
+
silentTracker.endAction();
|
|
1191
|
+
silentTracker.startAction("fetch", "https://example.com");
|
|
1192
|
+
silentTracker.endAction();
|
|
1193
|
+
if (silentTracker.getElapsedMs() >= 0) {
|
|
1194
|
+
passMsg("progress: tracker records action timing");
|
|
1195
|
+
} else {
|
|
1196
|
+
failMsg("progress: tracker elapsed time invalid");
|
|
1197
|
+
}
|
|
1198
|
+
// Test bar formatting indirectly via duration
|
|
1199
|
+
const tracker2 = createProgressTracker({
|
|
1200
|
+
totalActions: 2,
|
|
1201
|
+
totalRounds: 1,
|
|
1202
|
+
silent: true,
|
|
1203
|
+
});
|
|
1204
|
+
tracker2.startAction("search", "q1");
|
|
1205
|
+
tracker2.endAction();
|
|
1206
|
+
if (tracker2.getElapsedMs() >= 0) {
|
|
1207
|
+
passMsg("progress: round tracking works");
|
|
1208
|
+
} else {
|
|
1209
|
+
failMsg("progress: round tracking broken");
|
|
1210
|
+
}
|
|
1063
1211
|
}
|
|
1064
1212
|
|
|
1065
1213
|
// ─────────────────────────────────────────────────────────────────────────────
|