@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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
|
-
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
13
|
+
import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
15
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
16
|
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
@@ -33,86 +33,6 @@ const NO_SOURCES_NOTE =
|
|
|
33
33
|
const UNVERIFIED_LINKS_NOTE =
|
|
34
34
|
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
38
|
-
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
39
|
-
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
40
|
-
* instead of being silently deleted as a "dangling citation".
|
|
41
|
-
*/
|
|
42
|
-
const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
46
|
-
* map, delete the ones known to dangle, leave everything else untouched.
|
|
47
|
-
*
|
|
48
|
-
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
49
|
-
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
50
|
-
* silently flattens nested list levels into one and turns 4-space indented code
|
|
51
|
-
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
52
|
-
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
53
|
-
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
54
|
-
*
|
|
55
|
-
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
56
|
-
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
57
|
-
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
58
|
-
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
59
|
-
* spans are detected by an odd backtick count earlier on the same line.
|
|
60
|
-
*
|
|
61
|
-
* @param remap local marker number → verified global id
|
|
62
|
-
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
63
|
-
*/
|
|
64
|
-
function rewriteCitationMarkers(
|
|
65
|
-
text: string,
|
|
66
|
-
remap: Map<number, number>,
|
|
67
|
-
isDangling: (n: number) => boolean,
|
|
68
|
-
): string {
|
|
69
|
-
const inFence = fencedCodeMask(text);
|
|
70
|
-
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
71
|
-
const n = parseInt(digits, 10);
|
|
72
|
-
const mapped = remap.get(n);
|
|
73
|
-
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
74
|
-
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
75
|
-
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
76
|
-
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
77
|
-
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
78
|
-
// otherwise drop the pad too so no double space is left behind.
|
|
79
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
80
|
-
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
86
|
-
*
|
|
87
|
-
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
88
|
-
* rewriting stays linear in the body length.
|
|
89
|
-
*/
|
|
90
|
-
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
91
|
-
const ranges: Array<[number, number]> = [];
|
|
92
|
-
let open = -1;
|
|
93
|
-
let pos = 0;
|
|
94
|
-
for (const line of text.split('\n')) {
|
|
95
|
-
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
96
|
-
if (open < 0) open = pos;
|
|
97
|
-
else {
|
|
98
|
-
ranges.push([open, pos + line.length]);
|
|
99
|
-
open = -1;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
103
|
-
}
|
|
104
|
-
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
105
|
-
if (open >= 0) ranges.push([open, text.length]);
|
|
106
|
-
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
110
|
-
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
111
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
112
|
-
let ticks = 0;
|
|
113
|
-
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
114
|
-
return ticks % 2 === 1;
|
|
115
|
-
}
|
|
116
36
|
|
|
117
37
|
/**
|
|
118
38
|
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
@@ -172,9 +92,12 @@ export class ResearchSynthesisService implements IService {
|
|
|
172
92
|
this.sessions.set(sessionId, reports);
|
|
173
93
|
return reports;
|
|
174
94
|
}
|
|
175
|
-
// Evict the least-recently-used session when at capacity.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
95
|
+
// Evict the least-recently-used session when at capacity. Every STORE touches
|
|
96
|
+
// its session to the end (above); reads deliberately do not (peekSessionReports
|
|
97
|
+
// exists so a stray read cannot create-and-evict). A run in its read-only
|
|
98
|
+
// synthesis phase therefore stops refreshing its recency — tolerable only
|
|
99
|
+
// because reaching MAX_SESSIONS at all needs ~200 uncleaned sessions in one
|
|
100
|
+
// process (run cap 3, sessions cleared after each run), i.e. a leaking host.
|
|
178
101
|
if (this.sessions.size >= ResearchSynthesisService.MAX_SESSIONS) {
|
|
179
102
|
const oldestKey = this.sessions.keys().next().value;
|
|
180
103
|
if (oldestKey !== undefined) {
|
|
@@ -187,6 +110,25 @@ export class ResearchSynthesisService implements IService {
|
|
|
187
110
|
return reports;
|
|
188
111
|
}
|
|
189
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Read a session's reports WITHOUT creating an entry for an unknown id.
|
|
115
|
+
*
|
|
116
|
+
* The read-only accessors below must not go through `getSessionReports`: that
|
|
117
|
+
* is create-on-read, so a query for an id we have never seen inserts an empty
|
|
118
|
+
* map — and at MAX_SESSIONS that insert evicts the current head. The LRU touch
|
|
119
|
+
* protects sessions that are read or written, but an in-flight run whose
|
|
120
|
+
* researchers are all still executing (reports stored early, nothing touched
|
|
121
|
+
* since) is exactly the stalest key, so a stray read for an unknown session
|
|
122
|
+
* could evict a live run's reports and silently shrink its final synthesis.
|
|
123
|
+
*
|
|
124
|
+
* Returns a shared empty map for unknown ids — callers here only read.
|
|
125
|
+
*/
|
|
126
|
+
private peekSessionReports(sessionId: string): ReadonlyMap<string, string> {
|
|
127
|
+
return this.sessions.get(sessionId) ?? ResearchSynthesisService.EMPTY_REPORTS;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static readonly EMPTY_REPORTS: ReadonlyMap<string, string> = new Map<string, string>();
|
|
131
|
+
|
|
190
132
|
/**
|
|
191
133
|
* Store a researcher report
|
|
192
134
|
* @param sessionId - Session identifier
|
|
@@ -201,14 +143,14 @@ export class ResearchSynthesisService implements IService {
|
|
|
201
143
|
* Get a report by ID
|
|
202
144
|
*/
|
|
203
145
|
getReport(sessionId: string, id: string): string | undefined {
|
|
204
|
-
return this.
|
|
146
|
+
return this.peekSessionReports(sessionId).get(id);
|
|
205
147
|
}
|
|
206
148
|
|
|
207
149
|
/**
|
|
208
150
|
* Get all reports
|
|
209
151
|
*/
|
|
210
152
|
getAllReports(sessionId: string): Map<string, string> {
|
|
211
|
-
return new Map(this.
|
|
153
|
+
return new Map(this.peekSessionReports(sessionId));
|
|
212
154
|
}
|
|
213
155
|
|
|
214
156
|
/**
|
|
@@ -217,7 +159,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
217
159
|
getReportsForRound(sessionId: string, round: number): Map<string, string> {
|
|
218
160
|
const roundReports = new Map<string, string>();
|
|
219
161
|
const prefix = `${round}.`;
|
|
220
|
-
for (const [key, report] of this.
|
|
162
|
+
for (const [key, report] of this.peekSessionReports(sessionId).entries()) {
|
|
221
163
|
if (key.startsWith(prefix)) {
|
|
222
164
|
roundReports.set(key, report);
|
|
223
165
|
}
|
|
@@ -229,14 +171,14 @@ export class ResearchSynthesisService implements IService {
|
|
|
229
171
|
* Get total number of reports
|
|
230
172
|
*/
|
|
231
173
|
getReportCount(sessionId: string): number {
|
|
232
|
-
return this.
|
|
174
|
+
return this.peekSessionReports(sessionId).size;
|
|
233
175
|
}
|
|
234
176
|
|
|
235
177
|
/**
|
|
236
178
|
* Check if there are any reports
|
|
237
179
|
*/
|
|
238
180
|
hasReports(sessionId: string): boolean {
|
|
239
|
-
return this.
|
|
181
|
+
return this.peekSessionReports(sessionId).size > 0;
|
|
240
182
|
}
|
|
241
183
|
|
|
242
184
|
/**
|
|
@@ -257,7 +199,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
257
199
|
* @returns Fallback synthesis string
|
|
258
200
|
*/
|
|
259
201
|
buildFallbackSynthesis(sessionId: string, currentRound: number = 0): string {
|
|
260
|
-
const reports = this.
|
|
202
|
+
const reports = this.peekSessionReports(sessionId);
|
|
261
203
|
const reportCount = reports.size;
|
|
262
204
|
const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : '';
|
|
263
205
|
let synthesis = `Research Findings${roundInfo}\n\n`;
|
|
@@ -291,7 +233,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
291
233
|
* @returns Synthesis with guaranteed and verified CITED LINKS section
|
|
292
234
|
*/
|
|
293
235
|
ensureCitedLinks(sessionId: string, synthesis: string): string {
|
|
294
|
-
const reports = this.
|
|
236
|
+
const reports = this.peekSessionReports(sessionId);
|
|
295
237
|
|
|
296
238
|
// Primary source: citations the researchers wrote into their reports. These are
|
|
297
239
|
// the richest (they carry descriptions and source tags), so they win when present.
|
|
@@ -454,7 +396,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
454
396
|
extractAllCitations(sessionId: string): Array<{ url: string; description: string; source?: string }> {
|
|
455
397
|
const seen = new Set<string>();
|
|
456
398
|
const allCitations: Array<{ url: string; description: string; source?: string }> = [];
|
|
457
|
-
const reports = this.
|
|
399
|
+
const reports = this.peekSessionReports(sessionId);
|
|
458
400
|
|
|
459
401
|
for (const report of reports.values()) {
|
|
460
402
|
const citations = parseCitations(report);
|
package/src/security/cisa-kev.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Vulnerability, CisaKevResult } from './types.ts';
|
|
|
10
10
|
import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
11
11
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
12
12
|
import { metrics } from '../utils/metrics.ts';
|
|
13
|
+
import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
|
|
13
14
|
|
|
14
15
|
const CISA_KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
15
16
|
|
|
@@ -183,10 +184,16 @@ export async function searchCisaKev(
|
|
|
183
184
|
|
|
184
185
|
let data: unknown;
|
|
185
186
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
// The KEV feed is the whole catalogue in one document — legitimately tens of
|
|
188
|
+
// megabytes and growing — so it reads against the bulk cap, not the default.
|
|
189
|
+
data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof BodyTooLargeError) {
|
|
192
|
+
metrics.increment('cisa_kev_errors_total', 1, { error_type: 'body_too_large' });
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
188
195
|
metrics.increment('cisa_kev_errors_total', 1, { error_type: 'malformed_json' });
|
|
189
|
-
throw new Error('CISA KEV feed returned a malformed JSON response.');
|
|
196
|
+
throw new Error('CISA KEV feed returned a malformed JSON response.', { cause: err });
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
// CISA KEV format is an array of vulnerability objects or nested in a response object
|
|
@@ -17,6 +17,7 @@ import { normalizeEcosystem } from './ecosystem.ts';
|
|
|
17
17
|
import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
18
18
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
19
19
|
import { metrics } from '../utils/metrics.ts';
|
|
20
|
+
import { readJsonCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
20
21
|
import { logger } from '../logger.ts';
|
|
21
22
|
import {
|
|
22
23
|
isGitHubAdvisoryRaw,
|
|
@@ -153,10 +154,16 @@ export async function searchGitHubAdvisories(
|
|
|
153
154
|
|
|
154
155
|
let data: unknown;
|
|
155
156
|
try {
|
|
156
|
-
data = await response
|
|
157
|
-
} catch {
|
|
157
|
+
data = await readJsonCapped(response);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// Size failures keep their own identity rather than being reported as a
|
|
160
|
+
// syntax error, which would point diagnosis at the wrong thing entirely.
|
|
161
|
+
if (err instanceof BodyTooLargeError) {
|
|
162
|
+
metrics.increment('github_search_errors_total', 1, { error_type: 'body_too_large' });
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
158
165
|
metrics.increment('github_search_errors_total', 1, { error_type: 'malformed_json' });
|
|
159
|
-
throw new Error('GitHub API returned a malformed JSON response (repo advisories).');
|
|
166
|
+
throw new Error('GitHub API returned a malformed JSON response (repo advisories).', { cause: err });
|
|
160
167
|
}
|
|
161
168
|
let repoAdvisories: readonly GitHubAdvisoryRaw[] = [];
|
|
162
169
|
|
|
@@ -244,10 +251,14 @@ export async function searchGitHubAdvisories(
|
|
|
244
251
|
// A 404 on a direct GHSA/CVE lookup means "no such advisory", not failure.
|
|
245
252
|
let data: unknown;
|
|
246
253
|
try {
|
|
247
|
-
data = await response
|
|
248
|
-
} catch {
|
|
254
|
+
data = await readJsonCapped(response);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (err instanceof BodyTooLargeError) {
|
|
257
|
+
metrics.increment('github_search_errors_total', 1, { error_type: 'body_too_large', endpoint: endpointType });
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
249
260
|
metrics.increment('github_search_errors_total', 1, { error_type: 'malformed_json', endpoint: endpointType });
|
|
250
|
-
throw new Error(`GitHub API returned a malformed JSON response for term "${term}"
|
|
261
|
+
throw new Error(`GitHub API returned a malformed JSON response for term "${term}".`, { cause: err });
|
|
251
262
|
}
|
|
252
263
|
let items: readonly GitHubAdvisoryRaw[] = [];
|
|
253
264
|
|
|
@@ -259,7 +270,7 @@ export async function searchGitHubAdvisories(
|
|
|
259
270
|
items = data.items.filter(isGitHubAdvisoryRaw);
|
|
260
271
|
}
|
|
261
272
|
|
|
262
|
-
metrics.increment(items.length > 0 ? 'github_cache_hits_total' : 'github_cache_misses_total', 1, {
|
|
273
|
+
metrics.increment(items.length > 0 ? 'github_cache_hits_total' : 'github_cache_misses_total', 1, { endpoint: endpointType });
|
|
263
274
|
|
|
264
275
|
return items.map(mapGitHubAdvisory);
|
|
265
276
|
};
|
|
@@ -24,6 +24,7 @@ import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-
|
|
|
24
24
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
25
25
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
26
26
|
import { metrics } from '../utils/metrics.ts';
|
|
27
|
+
import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
|
|
27
28
|
import {
|
|
28
29
|
isWeaknessDescription,
|
|
29
30
|
isWeakness,
|
|
@@ -396,11 +397,17 @@ async function fetchPaginated(
|
|
|
396
397
|
const response = await fetchWithRetry(url, signal);
|
|
397
398
|
let data: unknown;
|
|
398
399
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
400
|
+
// A page can legitimately carry up to MAX_RESULTS_PER_PAGE (2000) full CVE
|
|
401
|
+
// records, so pagination reads against the bulk cap rather than the default.
|
|
402
|
+
data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
|
|
403
|
+
} catch (err) {
|
|
401
404
|
// A 200 with a truncated/HTML body (proxy or CDN error page) would otherwise
|
|
402
|
-
// throw and discard this whole term. Stop paginating this term instead
|
|
403
|
-
|
|
405
|
+
// throw and discard this whole term. Stop paginating this term instead —
|
|
406
|
+
// the pages already gathered are kept. An over-cap body degrades the same
|
|
407
|
+
// way but is counted separately so the cause stays diagnosable.
|
|
408
|
+
metrics.increment('nvd_search_errors_total', 1, {
|
|
409
|
+
error_type: err instanceof BodyTooLargeError ? 'body_too_large' : 'malformed_json',
|
|
410
|
+
});
|
|
404
411
|
break;
|
|
405
412
|
}
|
|
406
413
|
|
|
@@ -412,10 +419,14 @@ async function fetchPaginated(
|
|
|
412
419
|
const admitted = rawEntries.filter(admitEntry).map((entry) => parseNVDEntry(entry, options));
|
|
413
420
|
|
|
414
421
|
if (rawEntries.length === 0) {
|
|
415
|
-
metrics
|
|
422
|
+
// No `term` label: the metrics registry keys counters by label set and is only
|
|
423
|
+
// cleared by an explicit clearSession(), so labelling by the raw search term made
|
|
424
|
+
// every distinct security query add a permanent entry for the life of a pi host
|
|
425
|
+
// session — unbounded growth driven straight by user input.
|
|
426
|
+
metrics.increment('nvd_cache_misses_total', 1);
|
|
416
427
|
break;
|
|
417
428
|
}
|
|
418
|
-
metrics.increment('nvd_cache_hits_total', 1
|
|
429
|
+
metrics.increment('nvd_cache_hits_total', 1);
|
|
419
430
|
|
|
420
431
|
allVulnerabilities.push(...admitted);
|
|
421
432
|
|
|
@@ -14,6 +14,7 @@ import { logger } from '../logger.ts';
|
|
|
14
14
|
import { OSV_TIMEOUT_MS, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS } from '../constants.ts';
|
|
15
15
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
16
16
|
import { metrics } from '../utils/metrics.ts';
|
|
17
|
+
import { readJsonCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
17
18
|
import {
|
|
18
19
|
isOsvVulnerability,
|
|
19
20
|
isOsvQueryResponse,
|
|
@@ -137,10 +138,17 @@ export async function searchOSV(
|
|
|
137
138
|
|
|
138
139
|
let data: unknown;
|
|
139
140
|
try {
|
|
140
|
-
data = await response
|
|
141
|
-
} catch {
|
|
141
|
+
data = await readJsonCapped(response);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// An over-cap body is a size failure, not a syntax one. Rethrowing it
|
|
144
|
+
// unwrapped keeps the diagnosis honest instead of reporting a 40MB reply
|
|
145
|
+
// as malformed JSON.
|
|
146
|
+
if (err instanceof BodyTooLargeError) {
|
|
147
|
+
metrics.increment('osv_search_errors_total', 1, { error_type: 'body_too_large' });
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
142
150
|
metrics.increment('osv_search_errors_total', 1, { error_type: 'malformed_json' });
|
|
143
|
-
throw new Error(`OSV returned a malformed JSON response for "${term}"
|
|
151
|
+
throw new Error(`OSV returned a malformed JSON response for "${term}".`, { cause: err });
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
let items: OsvVulnerability[];
|
|
@@ -154,7 +162,9 @@ export async function searchOSV(
|
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
const endpoint = isIdLookup ? 'vulns_by_id' : 'query';
|
|
157
|
-
|
|
165
|
+
// `endpoint` only — see the nvd-client note: a raw-term label grows the counter
|
|
166
|
+
// registry without bound across a session.
|
|
167
|
+
metrics.increment(items.length > 0 ? 'osv_cache_hits_total' : 'osv_cache_misses_total', 1, { endpoint });
|
|
158
168
|
|
|
159
169
|
const vulns: Vulnerability[] = [];
|
|
160
170
|
for (const item of items) {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* only written on explicit opt-in (callers must pass them deliberately).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import * as crypto from 'node:crypto';
|
|
21
22
|
import * as fs from 'node:fs';
|
|
22
23
|
import * as os from 'node:os';
|
|
23
24
|
import * as path from 'node:path';
|
|
@@ -57,6 +58,24 @@ export const HARNESSES: readonly HarnessDef[] = [
|
|
|
57
58
|
const SKILL_NAME = 'pi-research';
|
|
58
59
|
const PACKAGE_NAME = '@lincoln504/pi-research';
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* This package's version, used to stamp manifest entries so a stale COPY can be
|
|
63
|
+
* detected and refreshed. Read from the SKILL.md we are about to install rather
|
|
64
|
+
* than from package.json: that file is the copied artifact, its metadata line is
|
|
65
|
+
* kept in sync by the `version` npm script, and it is present in every layout
|
|
66
|
+
* (repo, published tarball, installed copy). Returns null if unreadable, which
|
|
67
|
+
* simply means "cannot tell" — the entry is then always treated as stale, which
|
|
68
|
+
* refreshes rather than skips.
|
|
69
|
+
*/
|
|
70
|
+
function readSkillVersion(sourceDir: string): string | null {
|
|
71
|
+
try {
|
|
72
|
+
const md = fs.readFileSync(path.join(sourceDir, 'SKILL.md'), 'utf-8');
|
|
73
|
+
return /"version"\s*:\s*"([^"]+)"/.exec(md)?.[1] ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
/**
|
|
61
80
|
* The coding agents the in-app installer (the /research-config TUI) targets:
|
|
62
81
|
* Claude, Codex, and OpenClaw. Each loads skills from a home-directory
|
|
@@ -81,6 +100,22 @@ export interface ManifestEntry {
|
|
|
81
100
|
/** Absolute path of the skill source this was linked/copied from. */
|
|
82
101
|
source: string;
|
|
83
102
|
createdAt: string;
|
|
103
|
+
/**
|
|
104
|
+
* Package version that produced this entry.
|
|
105
|
+
*
|
|
106
|
+
* Only load-bearing for `copy` entries: a symlink re-reads the package on every
|
|
107
|
+
* use and so is always current, but a COPY is a point-in-time snapshot that
|
|
108
|
+
* nothing ever refreshed — reconcile skipped non-symlinks entirely, and the
|
|
109
|
+
* entry recorded no version, so drift was undetectable by construction. Copies
|
|
110
|
+
* are not rare: they happen on Windows without symlink privilege, cross-device,
|
|
111
|
+
* with `--copy`, and via the documented OpenClaw install route. Those users kept
|
|
112
|
+
* the SKILL.md their engine shipped with *forever* — including its exit-code
|
|
113
|
+
* contract — while the engine underneath them changed.
|
|
114
|
+
*
|
|
115
|
+
* Optional so manifests written before this field are still valid; an entry with
|
|
116
|
+
* no version simply reads as "not current" and is refreshed once.
|
|
117
|
+
*/
|
|
118
|
+
version?: string;
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
interface Manifest {
|
|
@@ -293,6 +328,7 @@ export function installSkill(toolIds: string[], opts: InstallOptions = {}): Inst
|
|
|
293
328
|
const manifest = readManifest(opts);
|
|
294
329
|
const results: InstallResult[] = [];
|
|
295
330
|
const now = new Date().toISOString();
|
|
331
|
+
const skillVersion = readSkillVersion(source);
|
|
296
332
|
|
|
297
333
|
for (const id of toolIds) {
|
|
298
334
|
const def = HARNESSES.find(h => h.id === id);
|
|
@@ -315,16 +351,22 @@ export function installSkill(toolIds: string[], opts: InstallOptions = {}): Inst
|
|
|
315
351
|
if (lst.isSymbolicLink()) {
|
|
316
352
|
const dest = path.resolve(path.dirname(absSkillPath), fs.readlinkSync(absSkillPath));
|
|
317
353
|
if (dest !== path.resolve(source)) {
|
|
354
|
+
// dryRun means "plan only, touch nothing" — the re-point is a real
|
|
355
|
+
// unlink+symlink and must honour it like the fresh-install path does.
|
|
356
|
+
if (opts.dryRun) {
|
|
357
|
+
results.push({ tool: id, path: absSkillPath, type: 'symlink', status: 'planned', message: 'would re-point stale symlink to current source' });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
318
360
|
fs.unlinkSync(absSkillPath);
|
|
319
361
|
fs.symlinkSync(source, absSkillPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
320
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'symlink', source, createdAt: now });
|
|
362
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'symlink', source, createdAt: now, ...(skillVersion ? { version: skillVersion } : {}) });
|
|
321
363
|
results.push({ tool: id, path: absSkillPath, type: 'symlink', status: 'installed', message: 're-pointed stale symlink to current source' });
|
|
322
364
|
continue;
|
|
323
365
|
}
|
|
324
366
|
}
|
|
325
367
|
} catch { /* fall through to already-installed below */ }
|
|
326
368
|
results.push({ tool: id, path: absSkillPath, type, status: 'already-installed' });
|
|
327
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? 'symlink' : 'copy', source, createdAt: now });
|
|
369
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? 'symlink' : 'copy', source, createdAt: now, ...(skillVersion ? { version: skillVersion } : {}) });
|
|
328
370
|
continue;
|
|
329
371
|
}
|
|
330
372
|
if (fs.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
|
|
@@ -344,14 +386,14 @@ export function installSkill(toolIds: string[], opts: InstallOptions = {}): Inst
|
|
|
344
386
|
} catch (err: any) {
|
|
345
387
|
// Windows without privilege, or cross-device: fall back to copy.
|
|
346
388
|
copyDir(source, absSkillPath);
|
|
347
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'copy', source, createdAt: now });
|
|
389
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'copy', source, createdAt: now, ...(skillVersion ? { version: skillVersion } : {}) });
|
|
348
390
|
results.push({ tool: id, path: absSkillPath, type: 'copy', status: 'installed', message: `symlink unavailable (${err?.code ?? err?.message}); copied instead` });
|
|
349
391
|
continue;
|
|
350
392
|
}
|
|
351
393
|
} else {
|
|
352
394
|
copyDir(source, absSkillPath);
|
|
353
395
|
}
|
|
354
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now });
|
|
396
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now, ...(skillVersion ? { version: skillVersion } : {}) });
|
|
355
397
|
results.push({ tool: id, path: absSkillPath, type, status: 'installed' });
|
|
356
398
|
} catch (err: any) {
|
|
357
399
|
results.push({ tool: id, path: absSkillPath, type, status: 'error', message: err?.message ?? String(err) });
|
|
@@ -412,7 +454,7 @@ export function uninstallSkill(toolIds?: string[], opts: InstallOptions = {}): U
|
|
|
412
454
|
}
|
|
413
455
|
try {
|
|
414
456
|
if (isSymlinkPresent(skillPath)) fs.unlinkSync(skillPath);
|
|
415
|
-
else fs.rmSync(skillPath, { recursive: true, force: true });
|
|
457
|
+
else fs.rmSync(skillPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
416
458
|
results.push({ tool, path: skillPath, status: 'removed' });
|
|
417
459
|
} catch (err: any) {
|
|
418
460
|
results.push({ tool, path: skillPath, status: 'error', message: err?.message ?? String(err) });
|
|
@@ -441,6 +483,12 @@ export interface ReconcileResult {
|
|
|
441
483
|
pruned: string[];
|
|
442
484
|
/** Stale/dangling owned symlinks re-pointed to the current package source. */
|
|
443
485
|
repointed: string[];
|
|
486
|
+
/**
|
|
487
|
+
* Owned COPIES refreshed because the package version moved on. A symlink tracks
|
|
488
|
+
* the package automatically; a copy does not, so without this an upgrade left
|
|
489
|
+
* copy-installed users on the previous SKILL.md indefinitely.
|
|
490
|
+
*/
|
|
491
|
+
refreshed: string[];
|
|
444
492
|
}
|
|
445
493
|
|
|
446
494
|
/**
|
|
@@ -460,17 +508,77 @@ export interface ReconcileResult {
|
|
|
460
508
|
* Foreign (non-owned) paths and self-contained copies are never touched.
|
|
461
509
|
*/
|
|
462
510
|
export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResult {
|
|
463
|
-
const result: ReconcileResult = { pruned: [], repointed: [] };
|
|
511
|
+
const result: ReconcileResult = { pruned: [], repointed: [], refreshed: [] };
|
|
464
512
|
const manifest = readManifest(opts);
|
|
465
513
|
if (manifest.entries.length === 0) return result;
|
|
466
514
|
|
|
467
515
|
let source: string | null;
|
|
468
516
|
try { source = resolveSkillSourceDir(opts.skillSourceDir); } catch { source = null; }
|
|
469
517
|
|
|
518
|
+
const currentVersion = source !== null ? readSkillVersion(source) : null;
|
|
519
|
+
|
|
470
520
|
const keep: ManifestEntry[] = [];
|
|
471
521
|
for (const e of manifest.entries) {
|
|
472
|
-
//
|
|
473
|
-
|
|
522
|
+
// A COPY has no target to dangle, but it is a point-in-time snapshot: unlike a
|
|
523
|
+
// symlink it does not track the package, so an engine upgrade leaves it serving
|
|
524
|
+
// the OLD SKILL.md — including its exit-code contract — indefinitely. Refresh it
|
|
525
|
+
// when the stamped version differs from what we now ship. An entry written
|
|
526
|
+
// before versions were stamped has none, so it reads as stale and is refreshed
|
|
527
|
+
// once. Ownership is re-checked first: a foreign directory at this path is
|
|
528
|
+
// never overwritten.
|
|
529
|
+
if (e.type !== 'symlink') {
|
|
530
|
+
const stale = currentVersion !== null && e.version !== currentVersion;
|
|
531
|
+
if (stale && source !== null && fs.existsSync(e.path) && isOwnedCopy(e.path)) {
|
|
532
|
+
// Build-then-swap, never rm-then-copy. The old shape (rmSync, then
|
|
533
|
+
// copyDir) destroyed the install permanently when the copy failed
|
|
534
|
+
// (ENOSPC, a locked file): the retry guard requires the path to exist,
|
|
535
|
+
// so a vanished install was never retried either. The staging names are
|
|
536
|
+
// dot-prefixed so a crash mid-swap cannot leave a directory a skill
|
|
537
|
+
// scanner would pick up as a duplicate skill.
|
|
538
|
+
const dir = path.dirname(e.path);
|
|
539
|
+
const base = path.basename(e.path);
|
|
540
|
+
// Sweep leftovers from a previous crashed swap before starting a new one.
|
|
541
|
+
try {
|
|
542
|
+
for (const name of fs.readdirSync(dir)) {
|
|
543
|
+
if (name.startsWith(`.${base}.new-`) || name.startsWith(`.${base}.old-`)) {
|
|
544
|
+
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch { /* hygiene only */ }
|
|
548
|
+
const suffix = crypto.randomBytes(4).toString('hex');
|
|
549
|
+
const fresh = path.join(dir, `.${base}.new-${suffix}`);
|
|
550
|
+
const retired = path.join(dir, `.${base}.old-${suffix}`);
|
|
551
|
+
try {
|
|
552
|
+
copyDir(source, fresh);
|
|
553
|
+
} catch {
|
|
554
|
+
try { fs.rmSync(fresh, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
555
|
+
keep.push(e); // source unreadable / disk full — install intact, retry next startup
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
fs.renameSync(e.path, retired);
|
|
560
|
+
} catch {
|
|
561
|
+
try { fs.rmSync(fresh, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
562
|
+
keep.push(e); // could not move the old copy aside (locked on Windows) — intact
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
fs.renameSync(fresh, e.path);
|
|
567
|
+
} catch {
|
|
568
|
+
// Put the old copy back — an old skill is strictly better than none.
|
|
569
|
+
try { fs.renameSync(retired, e.path); } catch { /* skills dir itself broken */ }
|
|
570
|
+
try { fs.rmSync(fresh, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
571
|
+
keep.push(e);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
try { fs.rmSync(retired, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } catch { /* leaked dot-dir; swept next reconcile */ }
|
|
575
|
+
keep.push({ ...e, source, version: currentVersion });
|
|
576
|
+
result.refreshed.push(e.path);
|
|
577
|
+
} else {
|
|
578
|
+
keep.push(e);
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
474
582
|
|
|
475
583
|
// Link the user deleted by hand — nothing on disk; drop the stale entry.
|
|
476
584
|
if (!isSymlinkPresent(e.path)) { result.pruned.push(e.path); continue; }
|
|
@@ -514,6 +622,24 @@ export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResu
|
|
|
514
622
|
// Helpers
|
|
515
623
|
// ---------------------------------------------------------------------------
|
|
516
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Recursive copy at the JS level — deliberately NOT fs.cpSync. cpSync's native
|
|
627
|
+
* implementation (std::filesystem) ABORTS the whole process on an unreadable
|
|
628
|
+
* directory instead of throwing a catchable error (observed on Node 25:
|
|
629
|
+
* "terminate called after throwing an instance of
|
|
630
|
+
* 'std::filesystem::__cxx11::filesystem_error'"). This runs inside
|
|
631
|
+
* reconcileSkillInstalls at every startup, where a copy failure must be caught
|
|
632
|
+
* and rolled back — never take the host down. statSync/copyFileSync follow
|
|
633
|
+
* symlinks, matching the dereference behaviour cpSync was given.
|
|
634
|
+
*/
|
|
517
635
|
function copyDir(src: string, dest: string): void {
|
|
518
|
-
fs.
|
|
636
|
+
const st = fs.statSync(src);
|
|
637
|
+
if (st.isDirectory()) {
|
|
638
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
639
|
+
for (const name of fs.readdirSync(src)) {
|
|
640
|
+
copyDir(path.join(src, name), path.join(dest, name));
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
fs.copyFileSync(src, dest);
|
|
644
|
+
}
|
|
519
645
|
}
|
|
@@ -7,6 +7,7 @@ import type { StackExchangeWrapper } from './types.ts';
|
|
|
7
7
|
import { logger } from '../logger.ts';
|
|
8
8
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
9
9
|
import { metrics } from '../utils/metrics.ts';
|
|
10
|
+
import { readJsonCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
10
11
|
|
|
11
12
|
const API_BASE = 'https://api.stackexchange.com/2.3';
|
|
12
13
|
|
|
@@ -99,7 +100,7 @@ export class StackExchangeClient {
|
|
|
99
100
|
|
|
100
101
|
// NB: the timeout is cleared in `finally`, not here. Clearing it now —
|
|
101
102
|
// when only the headers have arrived — would leave the body read
|
|
102
|
-
// (
|
|
103
|
+
// (readJsonCapped below) with no timeout, so a stalled body could hang
|
|
103
104
|
// for the whole process lifetime.
|
|
104
105
|
|
|
105
106
|
// The SE API returns a structured JSON wrapper (with error_id) even for
|
|
@@ -109,8 +110,11 @@ export class StackExchangeClient {
|
|
|
109
110
|
// "Unexpected token <" SyntaxError that hides the real cause.
|
|
110
111
|
let data: StackExchangeWrapper<T>;
|
|
111
112
|
try {
|
|
112
|
-
data = await
|
|
113
|
+
data = await readJsonCapped<StackExchangeWrapper<T>>(response);
|
|
113
114
|
} catch (parseErr) {
|
|
115
|
+
// An over-cap body is a size failure, not a malformed-JSON one: rethrow
|
|
116
|
+
// it unwrapped so it can't be relabelled as an HTTP-status error below.
|
|
117
|
+
if (parseErr instanceof BodyTooLargeError) throw parseErr;
|
|
114
118
|
if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
|
|
115
119
|
throw parseErr;
|
|
116
120
|
}
|