@lincoln504/pi-research 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +1287 -540
  3. package/dist/thread-worker.mjs +30 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +81 -1
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +50 -10
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cli.ts +149 -21
  21. package/src/config.ts +56 -15
  22. package/src/constants.ts +31 -0
  23. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  24. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  25. package/src/core/pi-version.ts +136 -0
  26. package/src/core/service-registry.ts +117 -8
  27. package/src/index.ts +11 -18
  28. package/src/infrastructure/browser/browser-client.ts +16 -4
  29. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  30. package/src/infrastructure/browser/browser-server.ts +17 -5
  31. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  32. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  33. package/src/infrastructure/browser/client-agent.ts +4 -3
  34. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  35. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  36. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  37. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  38. package/src/infrastructure/browser/thread-worker.mjs +30 -11
  39. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  40. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  41. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  42. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  43. package/src/infrastructure/file-lock-service.ts +223 -26
  44. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  45. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  46. package/src/infrastructure/state/state-manager-service.ts +1 -1
  47. package/src/infrastructure/state/state-manager.ts +19 -20
  48. package/src/infrastructure/types/state-types.ts +9 -0
  49. package/src/knowledge/embedder-init.ts +1 -1
  50. package/src/knowledge/embedder.ts +52 -3
  51. package/src/knowledge/index.ts +1 -1
  52. package/src/knowledge/store.ts +35 -9
  53. package/src/knowledge/webgpu-viability.ts +9 -4
  54. package/src/knowledge/writer-queue.ts +88 -46
  55. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  56. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  57. package/src/orchestration/research-orchestration-service.ts +2 -4
  58. package/src/orchestration/research-synthesis-service.ts +33 -11
  59. package/src/security/cisa-kev.ts +10 -3
  60. package/src/security/github-advisories.ts +18 -7
  61. package/src/security/nvd-client.ts +17 -6
  62. package/src/security/osv-client.ts +14 -4
  63. package/src/skill-install/skill-installer.ts +135 -9
  64. package/src/stackexchange/rest-client.ts +6 -2
  65. package/src/tools/research-knowledge-search.ts +15 -0
  66. package/src/tools/search.ts +6 -1
  67. package/src/utils/atomic-replace.ts +101 -0
  68. package/src/utils/citation-utils.ts +3 -1
  69. package/src/utils/http-body.ts +176 -0
  70. package/src/web-research/browser-search.ts +50 -4
  71. package/src/web-research/search.ts +20 -5
  72. package/src/web-research/types.ts +17 -4
  73. package/src/web-research/web-scraper.ts +7 -50
  74. package/src/youtube/transcript-client.ts +5 -1
@@ -15,14 +15,24 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const { execSync } = require('child_process');
18
+ const { spawnSync } = require('child_process');
19
19
  const path = require('path');
20
20
 
21
21
  const ROOT = path.join(__dirname, '..');
22
22
 
23
- try {
24
- execSync('node scripts/build.cjs all', { stdio: 'inherit', cwd: ROOT });
25
- } catch (err) {
26
- console.error('[prepare] Build failed:', err.message);
23
+ // spawnSync with an argv array, not execSync with a quoted string: execSync goes
24
+ // through /bin/sh -c, where $(…), backticks and quotes stay live INSIDE double
25
+ // quotes — so a Node binary path under a directory named with shell metacharacters
26
+ // would execute them during npm ci / a git install. Same fix setup.cjs and
27
+ // cleanup.cjs already carry. process.execPath (not a bare 'node') for the same
28
+ // reason as ensure-native-deps.cjs: a PATH lookup can resolve a different
29
+ // interpreter (nvm-windows, Volta, a portable Node) than the one running npm.
30
+ const r = spawnSync(process.execPath, ['scripts/build.cjs', 'all'], { stdio: 'inherit', cwd: ROOT });
31
+ if (r.error) {
32
+ console.error('[prepare] Build failed:', r.error.message);
27
33
  process.exit(1);
28
34
  }
35
+ if (r.status !== 0) {
36
+ console.error(`[prepare] Build failed with exit code ${r.status}`);
37
+ process.exit(r.status ?? 1);
38
+ }
package/scripts/setup.cjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * PLAYWRIGHT_INSTALL_DEPS=true - also install Linux system deps (or pass --system-deps)
12
12
  */
13
13
 
14
- const { execSync } = require('child_process');
14
+ const { execSync, spawnSync } = require('child_process');
15
15
  const { existsSync, readdirSync, statSync } = require('fs');
16
16
  const { homedir } = require('os');
17
17
  const path = require('path');
@@ -95,18 +95,38 @@ if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
95
95
  if (!alreadyInstalled) {
96
96
  try {
97
97
  const bin = resolveCamoufoxBin();
98
- const cmd = bin ? `"${bin}" fetch` : 'npx camoufox-js fetch';
98
+ // spawnSync with an ARGV ARRAY, never a shell string. `bin` is a filesystem
99
+ // path derived from the install directory, and execSync runs through
100
+ // `/bin/sh -c` where `$(…)`, backticks and quotes stay live INSIDE double
101
+ // quotes — so a package installed under a directory whose name contains
102
+ // shell metacharacters would execute them at install time. Windows still
103
+ // needs a shell to run the `.cmd` shim, and there the path is quoted; the
104
+ // runtime twin in ensure-browser.ts uses the same shape.
99
105
  // Bound the ~100MB download so a stalled/interrupted network fails fast
100
106
  // instead of hanging `npm install` forever (the catch below exits 0, and the
101
107
  // browser is re-fetchable manually). 15 min is ample even on slow links.
102
- execSync(cmd, { stdio: 'inherit', env, timeout: 15 * 60 * 1000 });
108
+ const spawnOpts = { stdio: 'inherit', env, timeout: 15 * 60 * 1000 };
109
+ const res = bin
110
+ ? (isWindows
111
+ ? spawnSync(`"${bin}"`, ['fetch'], { ...spawnOpts, shell: true })
112
+ : spawnSync(bin, ['fetch'], spawnOpts))
113
+ : spawnSync('npx', ['camoufox-js', 'fetch'], { ...spawnOpts, shell: isWindows });
114
+ if (res.error) throw res.error;
115
+ if (res.status !== 0) throw new Error(`camoufox fetch exited with code ${res.status}`);
103
116
  browsersInstalled = true;
104
117
  } catch (error) {
105
118
  const msg = error instanceof Error ? error.message : String(error);
106
119
  console.error('ERROR: Camoufox browser install failed — pi-research will not work.');
107
120
  console.error('Run manually to fix: npx camoufox-js fetch');
108
121
  console.error(`Reason: ${msg}`);
109
- process.exit(0);
122
+ // Exit 0 by default: the browser is fetched lazily on first use, so a restrictive
123
+ // network must not fail a user's install. But that also meant nothing here could
124
+ // ever turn CI red — a broken Windows launcher yielded a green `npm ci`. CI sets
125
+ // PI_RESEARCH_STRICT_SETUP=1 so the platform-specific spawn paths above are
126
+ // actually enforced somewhere.
127
+ const strict = process.env.PI_RESEARCH_STRICT_SETUP === '1' ||
128
+ process.env.PI_RESEARCH_STRICT_SETUP === 'true';
129
+ process.exit(strict ? 1 : 0);
110
130
  }
111
131
  } else {
112
132
  browsersInstalled = true;
@@ -52,7 +52,13 @@ node "<skill_dir>/scripts/run.mjs" status # verify detection
52
52
  Recommended. From the pi extension, run `/research-config` and choose
53
53
  Install in External Agents. It detects Claude, Codex, and OpenClaw and
54
54
  symlinks this skill into each one that is installed. Remove from External Agents
55
- removes those symlinks (cleanup also runs automatically on `npm uninstall`).
55
+ removes those symlinks.
56
+
57
+ Uninstalling the npm package does **not** remove them: npm 7 and newer do not run a
58
+ package's own `preuninstall` script (verified against npm 11). Use Remove from
59
+ External Agents first, or run `node scripts/cleanup.cjs` from the package directory,
60
+ otherwise the symlinks, `~/.pi/research/state` and the `~/.cache/pi-research` model
61
+ cache are all left behind.
56
62
 
57
63
  Cursor is not auto-installed — it has no global skills directory and only reads
58
64
  project-level `.cursor/skills/`. Symlink it per-project instead (see the table).
@@ -82,7 +88,8 @@ node "<skill_dir>/scripts/run.mjs" status
82
88
  ### Files
83
89
 
84
90
  - `SKILL.md` — the skill definition + system prompt (what agents read).
85
- - `scripts/run.mjs` — the launcher (compiled from `run.ts`).
91
+ - `scripts/run.mjs` — the launcher (built from `run.ts`, which is a repo-only source
92
+ file and is not shipped in the npm package).
86
93
  - `references/configuration.md` — model/key setup and the full variable reference.
87
94
 
88
95
  ### License
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.1.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.2.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -55,8 +55,10 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
55
55
  - **Cite the report's sources.** Carry its URLs/`[n]` references into your answer;
56
56
  attribute each non-trivial claim. Never present researched facts as unsourced;
57
57
  never invent or substitute sources.
58
- - **Surface the saved file.** If stderr/stdout ends with `Research report saved to: <path>`,
59
- give the user that exact path. If absent, no file was written — say nothing about one.
58
+ - **Surface the saved file.** Look for a saved-report line: stdout's report footer ends
59
+ with `Research report saved to: <path>`, and stderr carries `[pi-research] report saved
60
+ to: <path>`. Give the user that exact path. If neither appears, no file was written —
61
+ say nothing about one.
60
62
 
61
63
  ## Exit codes
62
64
 
@@ -66,6 +68,7 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
66
68
  | `78` on `knowledge` | Two cases — **read stderr to tell them apart.** | Says the store is **disabled**: silent; proceed to `research`. Mentions **credentials/model/API key**: this is the same hard config failure as `78` on `research` — **stop and relay full stderr**; don't silently continue, `research` will fail the same way. |
67
69
  | `78` on `research` | Engine missing or no model/key | **Stop. Relay full stderr** (it shows where to configure). Don't retry until fixed. |
68
70
  | `75` | Machine at capacity — other research runs are already using every slot | **Nothing is broken.** Wait for an in-flight run to finish, then retry this query once. Don't reconfigure anything and don't retry in a tight loop. |
71
+ | `130` (and `129`/`131`/`143`) | Cancelled — the run was interrupted. These are the POSIX `128 + signal` codes: `130` Ctrl-C/SIGINT, `143` SIGTERM, `129` SIGHUP, `131` SIGQUIT. Treat **any exit ≥ 128 as a cancellation.** | **Nothing is broken and nothing failed.** Say the research was cancelled. **Do NOT re-run it** unless the user asks; they stopped it deliberately. |
69
72
  | `70` | Runtime error | Relay stderr; suggest one retry for transient failures (network, rate limit). |
70
73
  | `64` | Bad arguments | Fix args; don't surface unless the query itself is malformed. |
71
74
 
@@ -62,8 +62,12 @@ outranks both (the command says so when that happens).
62
62
 
63
63
  ### Exit codes
64
64
 
65
- `0` success · `64` bad arguments · `78` not configured (engine missing or no
66
- model/key; message lists the fix) · `70` runtime error (network/provider/internal).
65
+ `0` success · `64` bad arguments · `70` runtime error (network/provider/internal) ·
66
+ `75` machine at capacity — every concurrent-run slot is taken; wait and retry once ·
67
+ `78` not configured (engine missing or no model/key; message lists the fix).
68
+
69
+ A run stopped by a signal exits `128 + N`: `130` Ctrl-C (SIGINT), `143` SIGTERM,
70
+ `129` SIGHUP, `131` SIGQUIT. See SKILL.md for the full table.
67
71
 
68
72
  ### Troubleshooting
69
73
 
@@ -3,13 +3,18 @@
3
3
  // skills/pi-research/scripts/run.ts
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { homedir } from "node:os";
6
+ import { homedir, constants as osConstants } from "node:os";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { createRequire } from "node:module";
9
9
  import { fileURLToPath } from "node:url";
10
10
  var PKG = "@lincoln504/pi-research";
11
11
  var REQUIRED_DEP = "@earendil-works/pi-coding-agent";
12
- var EXIT = { OK: 0, USAGE: 64, CONFIG: 78, SOFTWARE: 70 };
12
+ var EXIT = { OK: 0, USAGE: 64, CONFIG: 78, SOFTWARE: 70, CANCELLED: 130 };
13
+ var CANCELLING_SIGNALS = /* @__PURE__ */ new Set(["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]);
14
+ function exitCodeForSignal(sig) {
15
+ const signum = osConstants.signals[sig];
16
+ return typeof signum === "number" ? 128 + signum : EXIT.CANCELLED;
17
+ }
13
18
  var argv = process.argv.slice(2);
14
19
  var subcommand = argv[0];
15
20
  if (!subcommand || subcommand === "-h" || subcommand === "--help" || subcommand === "help") {
@@ -199,6 +204,17 @@ Set PI_RESEARCH_BIN to the engine's node CLI (\u2026\\dist\\cli.mjs) or PI_RESEA
199
204
  ...useShell ? { shell: true } : {}
200
205
  }
201
206
  );
207
+ for (const sig of CANCELLING_SIGNALS) {
208
+ try {
209
+ process.on(sig, () => {
210
+ try {
211
+ child.kill(sig);
212
+ } catch {
213
+ }
214
+ });
215
+ } catch {
216
+ }
217
+ }
202
218
  child.on("error", (err) => {
203
219
  process.stderr.write(`
204
220
  Error: failed to launch pi-research (${engine2.label}): ${err.message}
@@ -207,6 +223,12 @@ Error: failed to launch pi-research (${engine2.label}): ${err.message}
207
223
  });
208
224
  child.on("exit", (code, signal) => {
209
225
  if (signal) {
226
+ if (CANCELLING_SIGNALS.has(signal)) {
227
+ process.stderr.write(`
228
+ pi-research cancelled (${signal}).
229
+ `);
230
+ process.exit(exitCodeForSignal(signal));
231
+ }
210
232
  process.stderr.write(`
211
233
  Error: pi-research killed by ${signal}
212
234
  `);
package/src/cli.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as fs from 'node:fs';
25
+ import * as os from 'node:os';
25
26
  import * as path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
@@ -84,8 +85,47 @@ export const EXIT = {
84
85
  * re-running a broken command.
85
86
  */
86
87
  TEMPFAIL: 75,
88
+ /**
89
+ * The run was CANCELLED via a programmatic abort (the caller's AbortSignal),
90
+ * with no OS signal involved. 130 — 128+SIGINT — is the conventional
91
+ * "interrupted" code and the natural default when there is no signal number to
92
+ * derive one from.
93
+ *
94
+ * A cancellation caused by an actual SIGNAL does NOT use this constant: see
95
+ * exitCodeForSignal below, which reports the true 128+N.
96
+ *
97
+ * Distinct from SOFTWARE for the same reason TEMPFAIL is: a deliberate stop is
98
+ * not a fault. Reported as SOFTWARE (70), a cancel matched the skill contract's
99
+ * "runtime error — suggest one retry for transient failures", so an agent
100
+ * relaying the result would offer to re-run the very research the user had just
101
+ * interrupted, and a --json consumer could not tell a cancel from a crash.
102
+ */
103
+ CANCELLED: 130,
87
104
  } as const;
88
105
 
106
+ /**
107
+ * Exit code for a run stopped by `sig`, following the POSIX 128+N convention that
108
+ * a shell uses when reporting a signal-terminated child (SIGINT → 130,
109
+ * SIGTERM → 143, SIGHUP → 129, SIGQUIT → 131).
110
+ *
111
+ * Why derive it per-signal instead of returning a single "cancelled" code: this
112
+ * process INSTALLS handlers for those signals, so it is never actually killed by
113
+ * them — it catches them and exits deliberately. If it exited a flat 130, the code
114
+ * a wrapper observes would depend on whether our handler won the race against a
115
+ * force-kill: handler wins → 130, handler loses → the shell's 143. Deriving 128+N
116
+ * makes the handler transparent, so the same user action reports the same number
117
+ * either way.
118
+ *
119
+ * Every value lands in 129..143, safely inside the 0-255 range a POSIX wait status
120
+ * preserves — nothing here can be truncated to a false 0. Windows does not
121
+ * implement the 128+N convention at all (its exit codes are 32-bit and carry no
122
+ * signal semantics), so there these are simply our documented contract values.
123
+ */
124
+ export function exitCodeForSignal(sig: NodeJS.Signals): number {
125
+ const signum = (os.constants.signals as Record<string, number | undefined>)[sig];
126
+ return typeof signum === 'number' ? 128 + signum : EXIT.CANCELLED;
127
+ }
128
+
89
129
  // ---------------------------------------------------------------------------
90
130
  // Output helpers
91
131
  // ---------------------------------------------------------------------------
@@ -190,20 +230,33 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
190
230
  // iteration and the "don't clobber the real environment" guard would then
191
231
  // block the overlay's value — cli.env could never override config.env.
192
232
  const merged: Record<string, string> = {};
233
+ // Keys whose winning value came from an EXPLICIT --config file (not the
234
+ // default base, not the cli.env overlay). Project-scoped keys are only
235
+ // bridged for these — see below.
236
+ const fromExplicitBase = new Set<string>();
193
237
  for (const filePath of [basePath, cliOverlayPath]) {
194
238
  try {
195
239
  if (!fs.existsSync(filePath)) continue;
196
- Object.assign(merged, parseDotEnv(fs.readFileSync(filePath, 'utf-8')));
240
+ const parsed = parseDotEnv(fs.readFileSync(filePath, 'utf-8'));
241
+ Object.assign(merged, parsed);
242
+ for (const k of Object.keys(parsed)) {
243
+ if (explicitConfigPath !== undefined && filePath === basePath) fromExplicitBase.add(k);
244
+ else fromExplicitBase.delete(k);
245
+ }
197
246
  loaded = true;
198
247
  } catch {
199
248
  // ignore unreadable file; fall through to the next
200
249
  }
201
250
  }
202
251
  for (const [k, v] of Object.entries(merged)) {
203
- // Skip per-directory (project-scoped) keys: promoting them into process.env would make
204
- // config.env out-rank the per-directory registry, defeating a per-cwd override. They are
205
- // still applied as a lower-precedence file layer by getConfig()/loadEnvFiles().
206
- if (isProjectScopedKey(k)) continue;
252
+ // Skip per-directory (project-scoped) keys from the AMBIENT files: promoting them into
253
+ // process.env would make config.env out-rank the per-directory registry, defeating a
254
+ // per-cwd override. They are still applied as a lower-precedence file layer by
255
+ // getConfig()/loadEnvFiles(). An EXPLICIT --config file is different: getConfig never
256
+ // reads that path, so skipping its keys silently dropped them (a depth or
257
+ // knowledge-mode set in `--config ci.env` simply did not apply) — and a file the user
258
+ // named on this exact invocation outranking the per-directory registry is the intent.
259
+ if (isProjectScopedKey(k) && !fromExplicitBase.has(k)) continue;
207
260
  if (process.env[k] === undefined && (k.startsWith('PI_RESEARCH_') || k === 'STACKEXCHANGE_API_KEY' || k === 'GITHUB_TOKEN' || k === 'NVD_API_KEY')) {
208
261
  process.env[k] = v;
209
262
  }
@@ -863,9 +916,25 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
863
916
  // machine as a setup problem would send the user to reconfigure a working install.
864
917
  const isCapacity = err instanceof Error && err.name === 'ResearchRunCapacityError';
865
918
 
919
+ // Cancellation. Primary signal is the latch set by the SIGINT/SIGTERM handler —
920
+ // an exact fact, unlike the message, which varies by whichever subsystem noticed
921
+ // the abort first ('Aborted' from the search layer, 'Research aborted' /
922
+ // 'Research cancelled' from the orchestrator). The anchored message signatures
923
+ // only cover a programmatic abort where no signal arrived. Deliberately NOT
924
+ // matched: a bare `err.name === 'AbortError'` — internal timeout aborts
925
+ // (AbortSignal-driven retry/scrape deadlines) throw that name too, and an
926
+ // escaped one would report "cancelled — do not re-run" for a run nobody
927
+ // stopped. In the CLI every real cancellation sets the latch or carries these
928
+ // exact signatures. Checked before everything below: an abort can unwind
929
+ // through a provider call and surface wording that the config/rate-limit
930
+ // heuristics would otherwise claim.
931
+ const isCancellation =
932
+ cancellationRequested ||
933
+ (err instanceof Error && /^(aborted|research (aborted|cancelled))$/i.test(msg));
934
+
866
935
  // Auth / model / config-shaped errors → CONFIG exit + config block.
867
936
  const isConfigError =
868
- !isTaggedResearchStop && !isCapacity && (
937
+ !isTaggedResearchStop && !isCapacity && !isCancellation && (
869
938
  lower.includes('no llm model available') ||
870
939
  lower.includes('not found in pi') ||
871
940
  lower.includes('no api key') ||
@@ -879,20 +948,27 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
879
948
  // "api key" in its body (e.g. "rate limit exceeded for this api key"), and
880
949
  // classifying that as a setup problem sends the user to reconfigure a
881
950
  // perfectly working key instead of waiting out the limit.
882
- const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
883
- const exitCode = isCapacity
884
- ? EXIT.TEMPFAIL
885
- : isConfigError && !isRateLimit
886
- ? EXIT.CONFIG
887
- : EXIT.SOFTWARE;
951
+ const isRateLimit = !isCancellation && (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests'));
952
+ const exitCode = isCancellation
953
+ ? (cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED)
954
+ : isCapacity
955
+ ? EXIT.TEMPFAIL
956
+ : isConfigError && !isRateLimit
957
+ ? EXIT.CONFIG
958
+ : EXIT.SOFTWARE;
888
959
 
889
960
  // --json: emit a structured error on stdout so a machine consumer never has to
890
961
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
891
962
  if (json) {
892
963
  const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
893
964
  // Machine-readable "this will succeed later" flag, so a consumer can back off
894
- // and retry without string-matching the human message.
895
- if (isCapacity || isRateLimit) payload['retryable'] = true;
965
+ // and retry without string-matching the human message. Gated on cancellation:
966
+ // a Ctrl-C landing in the same window a queue-wait expires must never emit
967
+ // `cancelled: true` alongside `retryable: true` — a consumer honouring the
968
+ // latter would re-run the research the user just stopped.
969
+ if ((isCapacity || isRateLimit) && !isCancellation) payload['retryable'] = true;
970
+ // A cancel is emphatically NOT retryable-by-default: the user asked to stop.
971
+ if (isCancellation) payload['cancelled'] = true;
896
972
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
897
973
  payload['stack'] = err.stack;
898
974
  }
@@ -900,6 +976,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
900
976
  return exitCode;
901
977
  }
902
978
 
979
+ // Cancellation is a completed intention, not a failure — never a stack trace,
980
+ // and never the word "failed".
981
+ if (isCancellation) {
982
+ toStderr(`\npi-research ${what} cancelled.\n`);
983
+ return exitCode;
984
+ }
985
+
903
986
  // Capacity exhaustion is a healthy-machine "busy" state — report it as such and
904
987
  // never with a stack trace, which would read as a crash for a routine condition.
905
988
  if (isCapacity) {
@@ -1024,12 +1107,14 @@ CONFIGURE
1024
1107
  Run \`${BINARY_NAME} status\` to see exactly what is detected.
1025
1108
 
1026
1109
  EXIT CODES
1027
- 0 success
1028
- 64 bad arguments
1029
- 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
1030
- 78 not configured — message prints the exact locations to fix
1031
- (on \`knowledge\`, also returned when the store is deliberately disabled)
1032
- 70 runtime error (network, provider, internal)
1110
+ 0 success
1111
+ 64 bad arguments
1112
+ 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
1113
+ 78 not configured — message prints the exact locations to fix
1114
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
1115
+ 130 cancelled — Ctrl-C; nothing failed, do not auto-retry
1116
+ (other stop signals report POSIX 128+N: 143 SIGTERM, 129 SIGHUP, 131 SIGQUIT)
1117
+ 70 runtime error (network, provider, internal)
1033
1118
  `;
1034
1119
  }
1035
1120
 
@@ -1269,6 +1354,32 @@ const flushAndExit = (code: number): void => {
1269
1354
  // outlives that one run.
1270
1355
  let activeResearchAbortController: AbortController | null = null;
1271
1356
 
1357
+ /**
1358
+ * Set once SIGINT/SIGTERM has been handled. Read by reportError so a cancellation
1359
+ * is classified by the fact that we were signalled, not by pattern-matching the
1360
+ * message an aborted subsystem happened to throw.
1361
+ */
1362
+ let cancellationRequested = false;
1363
+
1364
+ /**
1365
+ * WHICH signal fired — so a cancellation that unwinds through reportError
1366
+ * reports the same 128+N the signal path itself would (SIGTERM → 143, not a
1367
+ * flat 130). reportError races onSignal's own flushAndExit; latching the name
1368
+ * makes both racers agree on the number, and keeps the `exitCode` field in
1369
+ * `--json` from contradicting the actual exit status.
1370
+ */
1371
+ let cancellationSignal: NodeJS.Signals | null = null;
1372
+
1373
+ /** Test seams for the module-level cancellation latch (set only by onSignal). */
1374
+ export function _resetCancellationLatchForTests(): void {
1375
+ cancellationRequested = false;
1376
+ cancellationSignal = null;
1377
+ }
1378
+ export function _markCancellationForTests(sig?: NodeJS.Signals): void {
1379
+ cancellationRequested = true;
1380
+ cancellationSignal = sig ?? null;
1381
+ }
1382
+
1272
1383
  async function main(argv: string[]): Promise<number> {
1273
1384
  // Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
1274
1385
  // before the process exits. safeShutdown() is idempotent — safe to call
@@ -1277,6 +1388,8 @@ async function main(argv: string[]): Promise<number> {
1277
1388
  const onSignal = (sig: string) => {
1278
1389
  if (_signalCleanupDone) return;
1279
1390
  _signalCleanupDone = true;
1391
+ cancellationRequested = true;
1392
+ cancellationSignal = sig as NodeJS.Signals;
1280
1393
  toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
1281
1394
  // Abort the in-flight research run FIRST (synchronously ahead of the shutdown
1282
1395
  // teardown below) so the orchestrator's existing signal-aware cancellation path
@@ -1286,10 +1399,25 @@ async function main(argv: string[]): Promise<number> {
1286
1399
  // Fire-and-forget: we can't await here, but safeShutdown swallows errors
1287
1400
  // and the finally blocks in each command handler also call it. Drain via
1288
1401
  // flushAndExit (not a bare process.exit) so buffered stdout isn't truncated.
1289
- void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
1402
+ // 128+N, not a flat CANCELLED: see exitCodeForSignal. Because we handle these
1403
+ // signals rather than dying from them, a fixed code would make the observed
1404
+ // exit status depend on whether this handler beat a force-kill.
1405
+ void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig as NodeJS.Signals)));
1290
1406
  };
1291
1407
  process.once('SIGINT', () => onSignal('SIGINT'));
1292
1408
  process.once('SIGTERM', () => onSignal('SIGTERM'));
1409
+ // SIGHUP (terminal closed) and, on Windows, SIGBREAK (Ctrl+Break). Without these
1410
+ // the run dies without cancelling: no abort of the in-flight research, no browser
1411
+ // or embedding-server teardown, and no 128+N exit code. On Windows it matters
1412
+ // most — SIGTERM is never delivered there (process.kill maps to TerminateProcess),
1413
+ // so Ctrl-C and Ctrl+Break are the only signals that reach this handler at all.
1414
+ // The SDK already registers both (see _addSignalHandlers); the CLI did not, which
1415
+ // left the SDK's handler to tear the container down underneath a live orchestrator
1416
+ // — exactly the race the CLI installs its own handlers to avoid.
1417
+ process.once('SIGHUP', () => onSignal('SIGHUP'));
1418
+ if (process.platform === 'win32') {
1419
+ process.once('SIGBREAK', () => onSignal('SIGBREAK'));
1420
+ }
1293
1421
 
1294
1422
  let parsed: ParsedArgs;
1295
1423
  try {
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ import { getConfigDirName } from './utils/host-config.ts';
14
14
  import { Type, type Static } from 'typebox';
15
15
  import { Value } from 'typebox/value';
16
16
  import { normalizeWorkspacePath } from './utils/text-utils.ts';
17
+ import { replaceFileSync } from './utils/atomic-replace.ts';
17
18
 
18
19
  /**
19
20
  * Validates configuration schema using TypeBox.
@@ -501,7 +502,7 @@ export function describeKnowledgeStoreMode(
501
502
  let registryHasKey = false;
502
503
  try {
503
504
  const registry = loadProjectSettingsRegistry();
504
- registryHasKey = registry[normalizeWorkspacePath(cwd)]?.[KEY] !== undefined;
505
+ registryHasKey = findRegistryEntry(registry, normalizeWorkspacePath(cwd))?.[KEY] !== undefined;
505
506
  } catch { /* unreadable registry → not the origin */ }
506
507
  if (registryHasKey) origin = 'this directory (project settings)';
507
508
  else if (iface && fileHasKey(getInterfaceEnvFilePath(iface))) origin = `${iface}.env overlay`;
@@ -546,12 +547,48 @@ export function parseDotEnv(content: string): Record<string, string> {
546
547
  * Load environment variables from global and local files.
547
548
  * Order: Global File < Interface overlay < Legacy .env < Centralized Registry (WINS)
548
549
  */
550
+ /**
551
+ * Look up a directory's entry in the project-settings registry, tolerating case.
552
+ *
553
+ * On Windows and on macOS's default (case-insensitive) filesystem, `C:\\Users\\Bob\\Proj`
554
+ * and `c:\\users\\bob\\proj` are the SAME directory but different object keys, so a shell
555
+ * that reports a different casing than the one the settings were saved under made those
556
+ * settings silently vanish. normalizeWorkspacePath only resolves and strips a trailing
557
+ * separator; it does not fold case, and it must not start doing so — the key it produces
558
+ * is persisted, so folding it would orphan every entry already on disk.
559
+ *
560
+ * Resolving at READ time instead fixes the lookup without touching stored data: exact
561
+ * match first (the only path taken on Linux, and the fast path everywhere), then a
562
+ * case-insensitive scan as a fallback.
563
+ */
564
+ function findRegistryKey(
565
+ registry: Record<string, Record<string, string>>,
566
+ normalizedCwd: string,
567
+ ): string | undefined {
568
+ if (registry[normalizedCwd] !== undefined) return normalizedCwd;
569
+ // Only case-insensitive filesystems can produce this mismatch; skip the scan elsewhere.
570
+ if (process.platform !== 'win32' && process.platform !== 'darwin') return undefined;
571
+ const lowered = normalizedCwd.toLowerCase();
572
+ for (const key of Object.keys(registry)) {
573
+ if (key.toLowerCase() === lowered) return key;
574
+ }
575
+ return undefined;
576
+ }
577
+
578
+ function findRegistryEntry(
579
+ registry: Record<string, Record<string, string>>,
580
+ normalizedCwd: string,
581
+ ): Record<string, string> | undefined {
582
+ const key = findRegistryKey(registry, normalizedCwd);
583
+ return key === undefined ? undefined : registry[key];
584
+ }
585
+
549
586
  function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, string> {
550
587
  const merged: Record<string, string> = {};
551
588
  const globalPath = getGlobalEnvFilePath();
552
589
  const registry = loadProjectSettingsRegistry();
553
590
  const normalizedCwd = normalizeWorkspacePath(cwd);
554
- const homeRegistry = registry[normalizeWorkspacePath(os.homedir())];
591
+ const homeRegistry = findRegistryEntry(registry, normalizeWorkspacePath(os.homedir()));
555
592
 
556
593
  // 1. ONE-TIME MIGRATION: Populate config.env from HOME registry if missing
557
594
  try {
@@ -613,15 +650,17 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
613
650
  Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
614
651
  );
615
652
  const existingLocal = Object.fromEntries(
616
- Object.entries(registry[normalizedCwd] ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
653
+ Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
617
654
  );
618
655
  if (Object.keys(legacyLocal).length > 0 && JSON.stringify(existingLocal) !== JSON.stringify(legacyLocal)) {
619
656
  logger.info(`[config] Migrating legacy .pi-research.env settings from ${cwd} to central registry...`);
620
657
  // Persist under the registry lock, reading the latest on-disk state so a concurrent
621
658
  // write isn't lost; also reflect the merge in the in-memory `registry` used just below.
622
- registry[normalizedCwd] = { ...registry[normalizedCwd], ...legacyLocal };
659
+ const mergeKey = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
660
+ registry[mergeKey] = { ...registry[mergeKey], ...legacyLocal };
623
661
  updateProjectSettingsRegistry((disk) => {
624
- disk[normalizedCwd] = { ...disk[normalizedCwd], ...legacyLocal };
662
+ const diskKey = findRegistryKey(disk, normalizedCwd) ?? normalizedCwd;
663
+ disk[diskKey] = { ...disk[diskKey], ...legacyLocal };
625
664
  });
626
665
  }
627
666
  } catch (err) {
@@ -633,8 +672,9 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
633
672
  // LOCAL_SCOPE_KEYS are applied (and may override), never user-scoped keys — those belong to
634
673
  // config.env. Any non-local key present here is stale pollution (e.g. a pre-fix legacy
635
674
  // migration) and is ignored rather than allowed to override the user's global settings.
636
- if (registry[normalizedCwd]) {
637
- for (const [key, val] of Object.entries(registry[normalizedCwd])) {
675
+ const cwdEntry = findRegistryEntry(registry, normalizedCwd);
676
+ if (cwdEntry) {
677
+ for (const [key, val] of Object.entries(cwdEntry)) {
638
678
  if (LOCAL_SCOPE_KEYS.has(key)) {
639
679
  // Don't interpolate values in warnings — a config key could carry a secret and
640
680
  // redactSecrets won't catch short/non-standard tokens. Key + winner is enough.
@@ -763,7 +803,11 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
763
803
  // Read-merge-write under one lock so a concurrent local write to another directory
764
804
  // isn't lost (the merge onto the existing entry happens against the latest on-disk state).
765
805
  updateProjectSettingsRegistry((registry) => {
766
- registry[normalizedCwd] = { ...(registry[normalizedCwd] ?? {}), ...newValues };
806
+ // Merge into the entry that already represents THIS directory, even if it was
807
+ // written with different casing on a case-insensitive filesystem. Writing a
808
+ // second key would leave the earlier settings stranded and unreachable.
809
+ const key = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
810
+ registry[key] = { ...(registry[key] ?? {}), ...newValues };
767
811
  });
768
812
 
769
813
  logger.debug(`[config] Saved project settings for ${normalizedCwd} to central registry.`);
@@ -870,15 +914,12 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
870
914
  const tmpPath = `${p}.tmp.${process.pid}.${Date.now()}`;
871
915
  fs.writeFileSync(tmpPath, outLines.join('\n'), { encoding: 'utf-8', mode: 0o600 });
872
916
  try {
873
- fs.renameSync(tmpPath, p);
874
- } catch (renameErr) {
875
- if (process.platform === 'win32') {
876
- fs.copyFileSync(tmpPath, p);
917
+ if (replaceFileSync(tmpPath, p) === 'copied') {
877
918
  try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
878
- } else {
879
- try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
880
- throw renameErr;
881
919
  }
920
+ } catch (renameErr) {
921
+ try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
922
+ throw renameErr;
882
923
  }
883
924
  // Tighten regardless of how the file came to exist (fresh rename, Windows copy over
884
925
  // an existing file, or a pre-hardening 0644 file that the rename replaced-in-place).
package/src/constants.ts CHANGED
@@ -177,3 +177,34 @@ export const RESEARCHER_LAUNCH_DELAY_MS = 1500;
177
177
  export const MAX_QUERIES_PER_RESEARCHER_LEVEL_1 = 10;
178
178
  export const MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
179
179
  export const MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
180
+
181
+ /**
182
+ * Tools excluded from every researcher unless the caller says otherwise.
183
+ *
184
+ * `grep` is developer-only: during WEB research a high-capability model otherwise
185
+ * spends turns searching the local filesystem instead of the internet.
186
+ */
187
+ export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
188
+
189
+ /**
190
+ * Resolve the effective researcher exclusion list.
191
+ *
192
+ * An EMPTY array is "no preference", not "exclude nothing". The two front-ends
193
+ * express no-preference differently — the pi extension always builds an array
194
+ * (`[...new Set([...])]`, often empty) while the CLI omits the key entirely — so a
195
+ * plain truthiness test made the same user input mean opposite things: grep was
196
+ * available in the extension and excluded on the CLI, regardless of depth.
197
+ *
198
+ * `configDisabled` (PI_RESEARCH_DISABLED_TOOLS) is strictly ADDITIVE. Folding it in
199
+ * as a replacement meant disabling any unrelated tool silently ENABLED grep, so a
200
+ * subtract-only setting could grant a capability.
201
+ */
202
+ export function resolveExcludedTools(
203
+ callerExclusions: readonly string[] | undefined,
204
+ configDisabled: readonly string[] = [],
205
+ ): string[] {
206
+ const base = callerExclusions && callerExclusions.length > 0
207
+ ? callerExclusions
208
+ : DEFAULT_EXCLUDED_TOOLS;
209
+ return [...new Set([...base, ...configDisabled])];
210
+ }
@@ -120,7 +120,12 @@ export interface IKnowledgeStore extends IService {
120
120
  /** Extended operations */
121
121
  addDocuments(docs: StoreDocument[]): Promise<void>;
122
122
  deleteByUrl(url: string): Promise<void>;
123
- deleteByUrlAndType(url: string, ingestionType: string): Promise<void>;
123
+ /**
124
+ * @param olderThan When set, only rows with `timestamp < olderThan` are removed —
125
+ * lets a refresh insert its replacement first and prune the superseded rows
126
+ * afterwards, so a failed insert can never leave the URL with nothing.
127
+ */
128
+ deleteByUrlAndType(url: string, ingestionType: string, olderThan?: number): Promise<void>;
124
129
  isStoreClosed?(): boolean;
125
130
 
126
131
  /**