@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.
@@ -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
- engineMatch[1],
113
- engineMatch[2] as "done" | "error" | "needs-human",
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
- "synthesis",
123
- synthMatch[1] as "done" | "error" | "needs-human",
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
- // Synthesis status is shown only when the caller explicitly requested
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
- const { stripQuotes } = await import("./src/tools/shared.ts");
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
- "Clipboard interceptor returned empty text",
175
+ "Cloudflare Turnstile challenge detected in closed shadow DOM",
171
176
  true,
172
- "new: clipboard error (headless Cloudflare block)",
177
+ "new: CF closed-shadow-dom block triggers visible retry",
173
178
  ],
174
179
  [
175
- "[bing] Clipboard empty, retrying in 2s...",
180
+ "Copilot verification required please solve it manually in the browser window",
176
181
  true,
177
- "new: clipboard empty retry message",
182
+ "new: per-engine 'verification required' triggers visible retry",
178
183
  ],
179
184
  [
180
- "Cloudflare challenge detected — content blocked in headless",
185
+ "Network timeout after 30000ms",
181
186
  true,
182
- "new: Cloudflare detection triggers visible retry",
187
+ "new: timeout triggers visible retry",
183
188
  ],
184
189
  [
185
- "Network timeout after 30000ms",
190
+ "Perplexity input not found — page may be blocked or in unexpected state",
186
191
  true,
187
- "new: timeout triggers visible retry",
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: "Clipboard interceptor returned empty text" },
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
- "Clipboard interceptor returned empty text",
241
+ "Perplexity input not found — page may be blocked or in unexpected state",
237
242
  true,
238
- "new: clipboard also triggers for perplexity",
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
  // ─────────────────────────────────────────────────────────────────────────────