@lincoln504/pi-research 1.1.0 → 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.
- package/README.md +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host (pi) version compatibility.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the extension entry point so the policy is one testable unit
|
|
5
|
+
* rather than inline arithmetic, because the range this package declares cannot
|
|
6
|
+
* itself enforce compatibility:
|
|
7
|
+
*
|
|
8
|
+
* - As a pi EXTENSION, the host supplies `@earendil-works/*`. Our package.json
|
|
9
|
+
* range never constrains it, so an in-process check is the only enforcement.
|
|
10
|
+
* - As a standalone CLI/SDK, the range is `>=0.80.8 <1` and published tarballs
|
|
11
|
+
* carry no lockfile, so every fresh install resolves the newest 0.x at that
|
|
12
|
+
* instant. pi is pre-1.0: a minor bump may break anything under semver, and
|
|
13
|
+
* already has (0.83.0 extended the ResourceLoader contract).
|
|
14
|
+
*
|
|
15
|
+
* Hence two thresholds, not one. Below the FLOOR we throw — those APIs are gone
|
|
16
|
+
* and nothing will work. Above the TESTED ceiling we warn once and continue: a
|
|
17
|
+
* newer pi usually works, refusing it would strand users on every pi release, but
|
|
18
|
+
* silently pretending it is verified is how "works on my machine" ships.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface SemverParts {
|
|
22
|
+
major: number;
|
|
23
|
+
minor: number;
|
|
24
|
+
patch: number;
|
|
25
|
+
/**
|
|
26
|
+
* True when the version carried a pre-release tag (`-rc.1`). Semver orders a
|
|
27
|
+
* pre-release BELOW its release, so `0.80.8-rc.1` predates the `0.80.8` floor
|
|
28
|
+
* and may lack the APIs the floor exists for. Only the floor comparison uses
|
|
29
|
+
* this; the tested-ceiling check compares lines and ignores it.
|
|
30
|
+
*/
|
|
31
|
+
prerelease?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a semver-ish version string. Tolerates a `v` prefix and pre-release /
|
|
36
|
+
* build metadata suffixes (`0.84.0-rc.1`, `0.84.0+abc`), which pi has used.
|
|
37
|
+
* Returns null when the numeric core cannot be read, so callers degrade to an
|
|
38
|
+
* explicit "cannot determine" rather than to a silently wrong comparison.
|
|
39
|
+
*/
|
|
40
|
+
export function parsePiVersion(version: string): SemverParts | null {
|
|
41
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(-)?/.exec(String(version ?? '').trim());
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
const major = Number(m[1]);
|
|
44
|
+
const minor = Number(m[2]);
|
|
45
|
+
const patch = Number(m[3]);
|
|
46
|
+
if ([major, minor, patch].some((n) => !Number.isFinite(n))) return null;
|
|
47
|
+
// The flag is emitted only when set, so a plain release parses to the exact
|
|
48
|
+
// {major, minor, patch} shape existing comparisons and tests expect.
|
|
49
|
+
return m[4] === '-' ? { major, minor, patch, prerelease: true } : { major, minor, patch };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Ordering comparison: negative if a < b, 0 if equal, positive if a > b. */
|
|
53
|
+
export function compareVersions(a: SemverParts, b: SemverParts): number {
|
|
54
|
+
return (a.major - b.major) || (a.minor - b.minor) || (a.patch - b.patch);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Oldest host this package works against.
|
|
59
|
+
*
|
|
60
|
+
* 0.80.8 introduced `ModelRuntime` and removed `AuthStorage` /
|
|
61
|
+
* `ModelRegistry.create()`. `buildModelRegistry` calls `ModelRuntime.create()`
|
|
62
|
+
* unconditionally, and `createAgentSession()` is invoked without the removed
|
|
63
|
+
* `modelRegistry` option, relying on the 0.80.8+ host to build its own.
|
|
64
|
+
*/
|
|
65
|
+
export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 80, patch: 8 };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Newest host line this release was actually exercised against (CI + a real run).
|
|
69
|
+
* Bump this — deliberately — when a new pi is verified, not automatically.
|
|
70
|
+
* Compared on MAJOR.MINOR only: a patch bump within a tested line is not a new
|
|
71
|
+
* surface, and warning on it would be noise.
|
|
72
|
+
*/
|
|
73
|
+
export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 83, patch: 0 };
|
|
74
|
+
|
|
75
|
+
export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
|
|
76
|
+
|
|
77
|
+
export interface PiCompatibility {
|
|
78
|
+
level: PiCompatibilityLevel;
|
|
79
|
+
/** True when the host must be rejected outright. */
|
|
80
|
+
fatal: boolean;
|
|
81
|
+
message: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classify a host version against the supported window.
|
|
86
|
+
*
|
|
87
|
+
* Pure: no logging, no throwing. The caller decides what to do with each level,
|
|
88
|
+
* which is what makes the policy testable without stubbing a logger.
|
|
89
|
+
*/
|
|
90
|
+
export function checkPiCompatibility(
|
|
91
|
+
version: string,
|
|
92
|
+
opts: { min?: SemverParts; testedMax?: SemverParts } = {},
|
|
93
|
+
): PiCompatibility {
|
|
94
|
+
const min = opts.min ?? PI_MIN_VERSION;
|
|
95
|
+
const testedMax = opts.testedMax ?? PI_TESTED_MAX_VERSION;
|
|
96
|
+
|
|
97
|
+
const parsed = parsePiVersion(version);
|
|
98
|
+
if (!parsed) {
|
|
99
|
+
return {
|
|
100
|
+
level: 'unparseable',
|
|
101
|
+
fatal: true,
|
|
102
|
+
message:
|
|
103
|
+
`[pi-research] Cannot parse pi-coding-agent version "${version}". ` +
|
|
104
|
+
`Please ensure pi-coding-agent is installed correctly.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A pre-release OF the floor version orders below the floor (0.80.8-rc.1 <
|
|
109
|
+
// 0.80.8) and may predate the very APIs the floor guards.
|
|
110
|
+
if (compareVersions(parsed, min) < 0 || (compareVersions(parsed, min) === 0 && parsed.prerelease === true)) {
|
|
111
|
+
return {
|
|
112
|
+
level: 'too-old',
|
|
113
|
+
fatal: true,
|
|
114
|
+
message:
|
|
115
|
+
`[pi-research] pi-coding-agent v${version} is too old. ` +
|
|
116
|
+
`Requires v${min.major}.${min.minor}.${min.patch}+. Please update pi-coding-agent.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Compare on major.minor only — a patch release inside a tested line is fine.
|
|
121
|
+
const line: SemverParts = { major: parsed.major, minor: parsed.minor, patch: 0 };
|
|
122
|
+
const testedLine: SemverParts = { major: testedMax.major, minor: testedMax.minor, patch: 0 };
|
|
123
|
+
if (compareVersions(line, testedLine) > 0) {
|
|
124
|
+
return {
|
|
125
|
+
level: 'untested',
|
|
126
|
+
fatal: false,
|
|
127
|
+
message:
|
|
128
|
+
`[pi-research] pi-coding-agent v${version} is newer than the last version this ` +
|
|
129
|
+
`release of pi-research was tested against (v${testedMax.major}.${testedMax.minor}.x). ` +
|
|
130
|
+
`Continuing — it will most likely work — but if you hit anything odd, that mismatch ` +
|
|
131
|
+
`is the first thing to suspect. Updating pi-research usually resolves it.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { level: 'ok', fatal: false, message: null };
|
|
136
|
+
}
|
|
@@ -17,6 +17,13 @@ import { logger } from '../logger.ts';
|
|
|
17
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How long teardown waits for an initialization that is still in flight. Long
|
|
22
|
+
* enough to cover a normal service coming up, short enough that a service blocked
|
|
23
|
+
* on something slow (a model download) cannot hold shutdown open indefinitely.
|
|
24
|
+
*/
|
|
25
|
+
const DISPOSE_INFLIGHT_INIT_TIMEOUT_MS = 5_000;
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* Service lifecycle stages
|
|
22
29
|
*/
|
|
@@ -209,15 +216,52 @@ export class ServiceContainer {
|
|
|
209
216
|
throw new Error(`Service '${name}' is not registered`);
|
|
210
217
|
}
|
|
211
218
|
|
|
212
|
-
// Return existing instance if already initialized
|
|
219
|
+
// Return existing instance if already initialized.
|
|
213
220
|
if (registration.instance) {
|
|
221
|
+
// `_initializeService` publishes `registration.instance` BEFORE awaiting the
|
|
222
|
+
// service's own initialize(), deliberately: a service whose initialize()
|
|
223
|
+
// resolves a peer that resolves it back would otherwise deadlock, and the
|
|
224
|
+
// partial instance is what breaks that cycle. But the same early publish
|
|
225
|
+
// hands a HALF-BUILT service to unrelated concurrent callers — e.g.
|
|
226
|
+
// StateManagerService only assigns its inner manager after nine awaited
|
|
227
|
+
// getService() calls, so every method on it throws "not initialized" until
|
|
228
|
+
// then, and the embedding factory resolves it and immediately calls
|
|
229
|
+
// updateState(). Worse, the ctx branch below would re-run initialize() on an
|
|
230
|
+
// instance that is still running it, constructing a second StateManager and
|
|
231
|
+
// discarding the first.
|
|
232
|
+
//
|
|
233
|
+
// So: if it is still initializing, wait for that to finish. The cycle case is
|
|
234
|
+
// exactly the case where `caller` is set (we are inside some service's
|
|
235
|
+
// initialize()), and there we must NOT wait — that is the deadlock the early
|
|
236
|
+
// publish exists to avoid. Outside an initialization there is no cycle to
|
|
237
|
+
// break and waiting is always correct.
|
|
238
|
+
if (registration.initializationPromise && caller === undefined) {
|
|
239
|
+
await registration.initializationPromise.catch(() => { /* surfaced below */ });
|
|
240
|
+
if (!registration.instance) {
|
|
241
|
+
// Initialization failed and cleaned up after itself — fall through and
|
|
242
|
+
// retry from scratch rather than returning a torn-down instance.
|
|
243
|
+
return this.get<T>(name, ctx);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
214
246
|
// Re-initialize if ctx is provided and service supports it
|
|
215
247
|
if (ctx && registration.instance.initialize) {
|
|
216
248
|
// Wrap in initializationContext so nested get() calls are tracked
|
|
217
|
-
|
|
249
|
+
const reinit = initializationContext.run(name, async () => {
|
|
218
250
|
await registration.instance!.initialize!(ctx);
|
|
219
251
|
return registration.instance as T;
|
|
220
252
|
});
|
|
253
|
+
// Recorded like a first init: a re-init is just as in-flight. Without
|
|
254
|
+
// this, disposeAll's settle-wait cannot see it (it would dispose the
|
|
255
|
+
// instance under a running initialize()), and concurrent callers
|
|
256
|
+
// received the instance mid-re-initialization.
|
|
257
|
+
registration.initializationPromise = reinit as Promise<IService>;
|
|
258
|
+
const clearIfCurrent = () => {
|
|
259
|
+
if (registration.initializationPromise === (reinit as Promise<IService>)) {
|
|
260
|
+
registration.initializationPromise = null;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
reinit.then(clearIfCurrent, clearIfCurrent);
|
|
264
|
+
return reinit;
|
|
221
265
|
}
|
|
222
266
|
return registration.instance as T;
|
|
223
267
|
}
|
|
@@ -243,7 +287,21 @@ export class ServiceContainer {
|
|
|
243
287
|
}
|
|
244
288
|
|
|
245
289
|
/**
|
|
246
|
-
* Get a service instance synchronously (returns null if not
|
|
290
|
+
* Get a service instance synchronously (returns null if not registered or not
|
|
291
|
+
* yet constructed).
|
|
292
|
+
*
|
|
293
|
+
* Deliberately returns instances in NON-settled lifecycles too. This is a
|
|
294
|
+
* lifecycle *inspector* as much as an accessor: the health check resolves the
|
|
295
|
+
* knowledge store through it precisely so it can report `initializing` /
|
|
296
|
+
* `disabled` / `disposed (not running)` WITHOUT forcing initialization (forcing
|
|
297
|
+
* it was a past cause of 45 s CI stalls). Filtering those states out here would
|
|
298
|
+
* make every one of them indistinguishable from "absent".
|
|
299
|
+
*
|
|
300
|
+
* The trade-off: because `_initializeService` publishes the instance before
|
|
301
|
+
* awaiting its `initialize()` (to break dependency cycles), a caller that
|
|
302
|
+
* immediately *uses* the result can catch a half-built service. Callers that
|
|
303
|
+
* need a ready service must use the async `get()`, which waits — see the note
|
|
304
|
+
* there. Callers that only inspect `lifecycle` are safe by construction.
|
|
247
305
|
*/
|
|
248
306
|
tryGet<T extends IService>(name: string): T | null {
|
|
249
307
|
if (this.isDisposing) {
|
|
@@ -286,6 +344,15 @@ export class ServiceContainer {
|
|
|
286
344
|
throw new Error(`Service '${name}' is not registered`);
|
|
287
345
|
}
|
|
288
346
|
|
|
347
|
+
// Settle an in-flight initialization first. Disposing the early-published
|
|
348
|
+
// instance while its initialize() is still running hands the awaiting get()
|
|
349
|
+
// caller a service that was disposed under it — observed as store-closed
|
|
350
|
+
// errors when /research-config cleared the knowledge store during a
|
|
351
|
+
// concurrent first search.
|
|
352
|
+
if (registration.initializationPromise) {
|
|
353
|
+
await registration.initializationPromise.catch(() => { /* failed init cleaned up after itself */ });
|
|
354
|
+
}
|
|
355
|
+
|
|
289
356
|
if (registration.instance && registration.instance.dispose) {
|
|
290
357
|
await registration.instance.dispose().catch((err: unknown) => {
|
|
291
358
|
logger.warn(`[ServiceContainer] Error disposing service '${name}':`, err);
|
|
@@ -309,6 +376,11 @@ export class ServiceContainer {
|
|
|
309
376
|
throw new Error(`Service '${name}' is not registered`);
|
|
310
377
|
}
|
|
311
378
|
|
|
379
|
+
// Settle an in-flight initialization before swapping — see clear().
|
|
380
|
+
if (registration.initializationPromise) {
|
|
381
|
+
await registration.initializationPromise.catch(() => { /* failed init cleaned up after itself */ });
|
|
382
|
+
}
|
|
383
|
+
|
|
312
384
|
// Dispose old instance if present
|
|
313
385
|
if (registration.instance && registration.instance.dispose) {
|
|
314
386
|
await registration.instance.dispose().catch((err: unknown) => {
|
|
@@ -347,7 +419,41 @@ export class ServiceContainer {
|
|
|
347
419
|
}
|
|
348
420
|
|
|
349
421
|
try {
|
|
350
|
-
|
|
422
|
+
// Settle any initialization still in flight BEFORE snapshotting what to tear
|
|
423
|
+
// down. `activeServices` is "instances that are non-null right now", so a
|
|
424
|
+
// get() whose factory has not resolved yet is invisible to it — and nothing
|
|
425
|
+
// else awaits it. Such a service installs itself into the registry AFTER
|
|
426
|
+
// teardown reports completion and is never disposed: for BrowserTaskScheduler
|
|
427
|
+
// that is a listening HTTP server plus a cluster pool and Camoufox children
|
|
428
|
+
// outliving shutdown; for KnowledgeStoreService an ONNX session and LanceDB
|
|
429
|
+
// handles. (SchedulerService.dispose hand-rolls this same guard for itself,
|
|
430
|
+
// which does not help when the leaked service IS SchedulerService.)
|
|
431
|
+
//
|
|
432
|
+
// Bounded: a service blocked on something slow (a model download) must not
|
|
433
|
+
// hold teardown open indefinitely. On timeout we proceed — the snapshot below
|
|
434
|
+
// still catches anything that landed in the meantime.
|
|
435
|
+
const inFlight = Array.from(this.services.values())
|
|
436
|
+
.map((r) => r.initializationPromise)
|
|
437
|
+
.filter((p): p is Promise<IService> => p != null);
|
|
438
|
+
if (inFlight.length > 0) {
|
|
439
|
+
if (this.defaultOptions.enableLogging) {
|
|
440
|
+
logger.debug(`[ServiceContainer] Waiting for ${inFlight.length} in-flight initialization(s) before teardown`);
|
|
441
|
+
}
|
|
442
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
443
|
+
await Promise.race([
|
|
444
|
+
Promise.allSettled(inFlight),
|
|
445
|
+
new Promise<void>((resolve) => {
|
|
446
|
+
settleTimer = setTimeout(() => {
|
|
447
|
+
logger.warn('[ServiceContainer] Timed out waiting for in-flight service initialization; proceeding with teardown');
|
|
448
|
+
resolve();
|
|
449
|
+
}, DISPOSE_INFLIGHT_INIT_TIMEOUT_MS);
|
|
450
|
+
settleTimer.unref?.();
|
|
451
|
+
}),
|
|
452
|
+
]);
|
|
453
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const activeServices = Array.from(this.services.keys()).filter(name =>
|
|
351
457
|
this.services.get(name)?.instance !== null
|
|
352
458
|
);
|
|
353
459
|
|
|
@@ -443,14 +549,17 @@ export class ServiceContainer {
|
|
|
443
549
|
return null;
|
|
444
550
|
}
|
|
445
551
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
552
|
+
// Promise first: the instance is published BEFORE its initialize() completes
|
|
553
|
+
// (the early publish that lets dependency cycles resolve), so instance-first
|
|
554
|
+
// ordering reported a mid-initialization service as INITIALIZED.
|
|
450
555
|
if (registration.initializationPromise) {
|
|
451
556
|
return ServiceLifecycle.INITIALIZING;
|
|
452
557
|
}
|
|
453
558
|
|
|
559
|
+
if (registration.instance) {
|
|
560
|
+
return ServiceLifecycle.INITIALIZED;
|
|
561
|
+
}
|
|
562
|
+
|
|
454
563
|
return ServiceLifecycle.UNINITIALIZED;
|
|
455
564
|
}
|
|
456
565
|
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ResearchResultDetails } from './types/index.ts';
|
|
|
11
11
|
import { createResearchTool, createHealthTool } from './tool.ts';
|
|
12
12
|
import { createResearchKnowledgeSearchTool } from './tools/research-knowledge-search.ts';
|
|
13
13
|
import { logger } from './logger.ts';
|
|
14
|
+
import { checkPiCompatibility } from './core/pi-version.ts';
|
|
14
15
|
import { randomUUID } from 'node:crypto';
|
|
15
16
|
import { shutdownManager } from './utils/shutdown-manager.ts';
|
|
16
17
|
import { healthRegistry } from './healthcheck/index.ts';
|
|
@@ -98,25 +99,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
98
99
|
// 0.80.8+ host auto-building its ModelRuntime. This in-host check is the real
|
|
99
100
|
// enforcement point: when running as an extension the host's version is NOT
|
|
100
101
|
// constrained by npm's resolution of our own package.json range.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
102
|
+
// Two thresholds, not one — see src/core/pi-version.ts. Below the floor the APIs
|
|
103
|
+
// we call do not exist, so we refuse. Above the last TESTED line we warn and
|
|
104
|
+
// continue: pi is pre-1.0, a minor bump can break anything under semver (0.83.0
|
|
105
|
+
// already extended the ResourceLoader contract), and our own dependency range
|
|
106
|
+
// cannot constrain the host at all when running as an extension.
|
|
107
|
+
const compat = checkPiCompatibility(PI_VERSION);
|
|
108
|
+
if (compat.fatal) {
|
|
109
|
+
throw new Error(compat.message ?? `[pi-research] Unsupported pi-coding-agent version "${PI_VERSION}".`);
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|| (major === minMajor && minor < minMinor)
|
|
114
|
-
|| (major === minMajor && minor === minMinor && patch < minPatch);
|
|
115
|
-
if (tooOld) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
`[pi-research] pi-coding-agent v${PI_VERSION} is too old. ` +
|
|
118
|
-
`Requires v${minMajor}.${minMinor}.${minPatch}+. Please update pi-coding-agent.`,
|
|
119
|
-
);
|
|
111
|
+
if (compat.message) {
|
|
112
|
+
logger.warn(compat.message);
|
|
120
113
|
}
|
|
121
114
|
|
|
122
115
|
// Re-register the beforeExit safety net (deactivate() strips event listeners during reload).
|
|
@@ -11,6 +11,7 @@ import type { SearchResult } from '../../web-research/types.ts';
|
|
|
11
11
|
import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
12
12
|
import { logger } from '../../logger.ts';
|
|
13
13
|
import { errorTracker } from '../../utils/error-tracker.ts';
|
|
14
|
+
import { Utf8Body } from '../../utils/http-body.ts';
|
|
14
15
|
import { getClientAgent } from './client-agent.ts';
|
|
15
16
|
import type { NodeError } from '../../types/index.ts';
|
|
16
17
|
|
|
@@ -21,6 +22,13 @@ import type { NodeError } from '../../types/index.ts';
|
|
|
21
22
|
export class BrowserClient implements IScheduler {
|
|
22
23
|
private readonly authSecret: string;
|
|
23
24
|
|
|
25
|
+
/** The leader port this client was built for — lets the herd-guard cooldown
|
|
26
|
+
* verify the cached handle still targets the REGISTERED leader, not a
|
|
27
|
+
* predecessor that died inside the cooldown window. */
|
|
28
|
+
get targetPort(): number {
|
|
29
|
+
return this.port;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
constructor(private readonly port: number, authSecret?: string) {
|
|
25
33
|
this.authSecret = authSecret ?? process.env['PI_BROWSER_AUTH_SECRET'] ?? '';
|
|
26
34
|
logger.log(`[BrowserClient] Connecting to global scheduler at http://127.0.0.1:${port}`);
|
|
@@ -93,8 +101,11 @@ export class BrowserClient implements IScheduler {
|
|
|
93
101
|
// when headers arrive, but the body is still streaming — clearing
|
|
94
102
|
// now would let a stalled body hang forever and make an external
|
|
95
103
|
// abort a no-op. Keep both armed until 'end' or 'error'.
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
// This response carries scraped page content, so per-chunk decoding
|
|
105
|
+
// would corrupt every non-ASCII page that exceeds one chunk. Collect
|
|
106
|
+
// bytes and decode once. See Utf8Body.
|
|
107
|
+
const collected = new Utf8Body();
|
|
108
|
+
res.on('data', (chunk: Buffer) => collected.push(chunk));
|
|
98
109
|
res.on('end', () => {
|
|
99
110
|
if (resolved) return;
|
|
100
111
|
resolved = true;
|
|
@@ -102,7 +113,7 @@ export class BrowserClient implements IScheduler {
|
|
|
102
113
|
abortCleanup?.();
|
|
103
114
|
const duration = Date.now() - start;
|
|
104
115
|
try {
|
|
105
|
-
const parsed = JSON.parse(
|
|
116
|
+
const parsed = JSON.parse(collected.toString());
|
|
106
117
|
if (res.statusCode === 503 && parsed?.error === 'draining') {
|
|
107
118
|
// Planned leadership handover, not a fault. Distinct message so
|
|
108
119
|
// isServerDrainingError can classify it: retry paths treat it as
|
|
@@ -135,7 +146,8 @@ export class BrowserClient implements IScheduler {
|
|
|
135
146
|
}
|
|
136
147
|
} catch (_e) {
|
|
137
148
|
// FIX (#23): Truncate response body in error to prevent data leakage
|
|
138
|
-
const
|
|
149
|
+
const raw = collected.toString();
|
|
150
|
+
const preview = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
|
|
139
151
|
const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
|
|
140
152
|
errorTracker.trackError(error, {
|
|
141
153
|
component: 'browser-manager',
|
|
@@ -109,7 +109,18 @@ export function isPoolShutdownError(error: unknown): boolean {
|
|
|
109
109
|
return typeof err.message === 'string' && (
|
|
110
110
|
err.message.includes('Worker pool is shutting down') ||
|
|
111
111
|
err.message.includes('Cannot execute a task on destroying pool') ||
|
|
112
|
-
err.message.includes('destroying pool')
|
|
112
|
+
err.message.includes('destroying pool') ||
|
|
113
|
+
// worker-pool-manager throws this when auto-recovery is still swapping the
|
|
114
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
115
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
116
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
117
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
118
|
+
err.message.includes('Worker pool is being reset') ||
|
|
119
|
+
// poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
|
|
120
|
+
// while it is in progress). A queued closure holding a pool captured before
|
|
121
|
+
// an auto-recovery destroy dispatches into exactly this window; it is the
|
|
122
|
+
// same transient swap the "being reset" string covers, not a hard fault.
|
|
123
|
+
err.message.includes('Cannot execute a task on not started pool')
|
|
113
124
|
);
|
|
114
125
|
}
|
|
115
126
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import * as crypto from 'node:crypto';
|
|
3
3
|
import { logger } from '../../logger.ts';
|
|
4
|
+
import { Utf8Body } from '../../utils/http-body.ts';
|
|
4
5
|
import type { SearchResult } from '../../web-research/types.ts';
|
|
5
6
|
import { isCloudflareBlockError, isPoolShutdownError } from './browser-error-utils.ts';
|
|
6
7
|
|
|
@@ -92,6 +93,12 @@ export class BrowserServer {
|
|
|
92
93
|
const expected = Buffer.from(getOrCreateAuthSecret(), 'utf8');
|
|
93
94
|
const actual = Buffer.from(authHeader ?? '', 'utf8');
|
|
94
95
|
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
|
|
96
|
+
// Drain the request before answering (mirrors the embedding
|
|
97
|
+
// server's 403 path): without it a still-arriving body leaves
|
|
98
|
+
// the request half-read and Node destroys the socket after the
|
|
99
|
+
// response flush, so the client can see ECONNRESET instead of
|
|
100
|
+
// the status.
|
|
101
|
+
req.resume();
|
|
95
102
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
96
103
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
97
104
|
return;
|
|
@@ -103,6 +110,7 @@ export class BrowserServer {
|
|
|
103
110
|
// stops the client reusing this socket for a retry that would
|
|
104
111
|
// only 503 again.
|
|
105
112
|
if (this.draining) {
|
|
113
|
+
req.resume(); // drain before answering — see the 403 path
|
|
106
114
|
res.writeHead(503, {
|
|
107
115
|
'Content-Type': 'application/json',
|
|
108
116
|
'Connection': 'close',
|
|
@@ -112,18 +120,22 @@ export class BrowserServer {
|
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
if (req.method !== 'POST') {
|
|
123
|
+
req.resume(); // drain before answering — see the 403 path
|
|
115
124
|
res.writeHead(405);
|
|
116
125
|
res.end('Method Not Allowed');
|
|
117
126
|
return;
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
|
|
129
|
+
// Decoded once at 'end' rather than per chunk: `body += chunk`
|
|
130
|
+
// mangles any multi-byte character that straddles a chunk boundary.
|
|
131
|
+
// See Utf8Body.
|
|
132
|
+
const collected = new Utf8Body();
|
|
121
133
|
let oversized = false;
|
|
122
134
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB limit
|
|
123
135
|
|
|
124
|
-
req.on('data', chunk => {
|
|
125
|
-
|
|
126
|
-
if (
|
|
136
|
+
req.on('data', (chunk: Buffer) => {
|
|
137
|
+
collected.push(chunk);
|
|
138
|
+
if (collected.byteLength > MAX_BODY_SIZE) {
|
|
127
139
|
oversized = true;
|
|
128
140
|
req.destroy(new Error('Payload too large'));
|
|
129
141
|
}
|
|
@@ -144,7 +156,7 @@ export class BrowserServer {
|
|
|
144
156
|
|
|
145
157
|
req.on('end', async () => {
|
|
146
158
|
try {
|
|
147
|
-
const data = JSON.parse(
|
|
159
|
+
const data = JSON.parse(collected.toString());
|
|
148
160
|
let result: any;
|
|
149
161
|
|
|
150
162
|
switch (req.url) {
|