specpi 0.19.0 → 0.20.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.
@@ -7,7 +7,7 @@ import { DelegationError } from "./errors.mjs";
7
7
  import { createTimeoutStore } from "./settings.mjs";
8
8
 
9
9
  const stateKey = Symbol.for("specpi.delegation.native.v1");
10
- const revision = 10;
10
+ const revision = 11;
11
11
 
12
12
  export async function withPiCompatibility(sdk, loadCompatibility) {
13
13
  if (typeof sdk.clampThinkingLevel === "function") {
@@ -5,6 +5,14 @@ const MAX_CONTEXT_BYTES = 256 * 1024;
5
5
  const MAX_RUN_CONTEXT_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).contextBytes;
6
6
  const MAX_RETAINED_RESPONSE_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).retainedResponseBytes;
7
7
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
8
+ // Public client identification from https://pi.dev/api/models/providers/github-copilot.
9
+ // Keep this exception exact: other headers may contain credentials or routing overrides.
10
+ const COPILOT_CATALOG_HEADERS = Object.freeze({
11
+ "User-Agent": "GitHubCopilotChat/0.35.0",
12
+ "Editor-Version": "vscode/1.107.0",
13
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
14
+ "Copilot-Integration-Id": "vscode-chat",
15
+ });
8
16
  const MODEL_FIELDS = [
9
17
  "id",
10
18
  "provider",
@@ -62,7 +70,41 @@ function closed(value, keys) {
62
70
 
63
71
  function descriptor(model) {
64
72
  // Only public model behavior fields; never serialize a provider, headers, or credentials.
65
- return JSON.parse(bounded(Object.fromEntries(MODEL_FIELDS.map((key) => [key, model[key]])), 16 * 1024));
73
+ return JSON.parse(
74
+ bounded(
75
+ {
76
+ ...Object.fromEntries(MODEL_FIELDS.map((key) => [key, model[key]])),
77
+ hasModelHeaders: model.headers !== undefined,
78
+ },
79
+ 16 * 1024,
80
+ ),
81
+ );
82
+ }
83
+
84
+ function supportedModelHeaders(model) {
85
+ const headers = model.headers;
86
+ if (headers === undefined) {
87
+ return true;
88
+ }
89
+
90
+ if (model.provider !== "github-copilot" || !headers || typeof headers !== "object") {
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ // Check names before touching values; never evaluate header getters or
96
+ // inspect arbitrary authentication headers. Retain no parent header data.
97
+ const keys = Reflect.ownKeys(headers);
98
+
99
+ return (
100
+ Object.getPrototypeOf(headers) === Object.prototype &&
101
+ keys.length === Object.keys(COPILOT_CATALOG_HEADERS).length &&
102
+ keys.every((key) => Object.hasOwn(COPILOT_CATALOG_HEADERS, key)) &&
103
+ keys.every((key) => Object.getOwnPropertyDescriptor(headers, key)?.value === COPILOT_CATALOG_HEADERS[key])
104
+ );
105
+ } catch {
106
+ return false;
107
+ }
66
108
  }
67
109
 
68
110
  function sameValue(left, right) {
@@ -314,7 +356,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
314
356
  !streaming &&
315
357
  (registry.getProviderAuthStatus(model.provider)?.source === "runtime" ||
316
358
  registry.getRegisteredProviderIds().includes(model.provider) ||
317
- model.headers !== undefined)
359
+ !supportedModelHeaders(model))
318
360
  ) {
319
361
  throw new DelegationError("Delegation cannot inherit this parent runtime provider override");
320
362
  }
@@ -351,6 +393,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
351
393
  runtime.getError() ||
352
394
  !runtime.getProvider(model.provider) ||
353
395
  !childModel ||
396
+ !supportedModelHeaders(childModel) ||
354
397
  !sameValue(descriptor(childModel), selected)
355
398
  ) {
356
399
  throw new DelegationError(
@@ -457,6 +457,26 @@ export function createSnapshot(root, paths, options = {}) {
457
457
 
458
458
  return Object.freeze({
459
459
  sources,
460
+ // Parent-only parser seam. Worker brokers still expose only list/read/search.
461
+ // Read through the same bindings and require the originally captured digest.
462
+ async withSourceBytes(id, consume) {
463
+ ensureOpen();
464
+ const record = byId.get(id);
465
+ if (!record || typeof consume !== "function") {
466
+ fail("byte reader rejected");
467
+ }
468
+
469
+ const bytes = readBoundFile(canonicalRoot, record.relative, record.original, MAX_BYTES);
470
+ try {
471
+ if (hash(bytes) !== record.digest) {
472
+ fail("source changed");
473
+ }
474
+
475
+ return await consume(bytes);
476
+ } finally {
477
+ bytes.fill(0);
478
+ }
479
+ },
460
480
  read(id, startLine = 1, maxLines = 200) {
461
481
  ensureOpen();
462
482
  const record = byId.get(id);
@@ -0,0 +1,7 @@
1
+ export declare function integrationsFile(agentDir: string): string;
2
+ export declare function serializeIntegrations(value: unknown, file: string): string;
3
+ export declare function readIntegrations(agentDir: string): {
4
+ schema: 1;
5
+ structuralSearch: { enabled: boolean };
6
+ [key: string]: unknown;
7
+ };
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const MAX_INTEGRATIONS_BYTES = 16384;
5
+
6
+ export function serializeIntegrations(value, file) {
7
+ let text = `${JSON.stringify(value, null, 2)}\n`;
8
+ if (Buffer.byteLength(text) > MAX_INTEGRATIONS_BYTES) {
9
+ text = JSON.stringify(value);
10
+ }
11
+
12
+ if (Buffer.byteLength(text) > MAX_INTEGRATIONS_BYTES) {
13
+ throw new Error(`Tool integration configuration exceeds ${MAX_INTEGRATIONS_BYTES} bytes: ${file}`);
14
+ }
15
+
16
+ return text;
17
+ }
18
+
19
+ export function integrationsFile(agentDir) {
20
+ return path.join(agentDir, "specpi", "tool-integrations.json");
21
+ }
22
+
23
+ // Content corruption is repairable by an explicit selection; link and file-shape failures are not.
24
+ function corrupt(file, message) {
25
+ const error = new Error(`${message}: ${file}`);
26
+ error.corruptIntegrations = true;
27
+
28
+ return error;
29
+ }
30
+
31
+ export function readIntegrations(agentDir) {
32
+ const file = integrationsFile(agentDir);
33
+ for (const target of [agentDir, path.dirname(file), file]) {
34
+ try {
35
+ if (fs.lstatSync(target).isSymbolicLink()) {
36
+ throw new Error(`Tool integration configuration must not be a link: ${file}`);
37
+ }
38
+ } catch (error) {
39
+ if (error.code === "ENOENT") {
40
+ return { schema: 1, structuralSearch: { enabled: false } };
41
+ }
42
+
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ const stat = fs.statSync(file);
48
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_INTEGRATIONS_BYTES) {
49
+ throw new Error(`Tool integration configuration is not a bounded regular file: ${file}`);
50
+ }
51
+
52
+ let value;
53
+ try {
54
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
55
+ } catch {
56
+ throw corrupt(file, "Tool integration configuration could not be read as JSON");
57
+ }
58
+
59
+ if (!value || value.schema !== 1 || typeof value.structuralSearch?.enabled !== "boolean") {
60
+ throw corrupt(file, "Invalid tool integration configuration");
61
+ }
62
+
63
+ return value;
64
+ }
@@ -0,0 +1,18 @@
1
+ export declare const VERSION: string;
2
+ export declare const LANGUAGES: Readonly<Record<string, { parser: string; extensions: string[] }>>;
3
+ export type SearchInput = {
4
+ language: string;
5
+ pattern: string;
6
+ paths: string[];
7
+ maxResults?: number;
8
+ timeoutMs?: number;
9
+ };
10
+ export declare class SearchError extends Error {
11
+ status: string;
12
+ constructor(status: string, message: string);
13
+ }
14
+ export declare function normalizeSearch(input: unknown): Required<SearchInput>;
15
+ export declare function structuralSearch(
16
+ input: SearchInput,
17
+ options: { cwd: string; runtimeDir: string; signal?: AbortSignal; admit?: () => Promise<void> },
18
+ ): Promise<unknown>;
@@ -0,0 +1,383 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import { createSnapshot } from "../delegation/snapshot.mjs";
7
+
8
+ export const VERSION = "0.45.3";
9
+ export const LANGUAGES = Object.freeze({
10
+ javascript: { parser: "javascript", extensions: [".js", ".mjs", ".cjs"] },
11
+ jsx: { parser: "jsx", extensions: [".jsx"] },
12
+ typescript: { parser: "typescript", extensions: [".ts", ".mts", ".cts"] },
13
+ tsx: { parser: "tsx", extensions: [".tsx"] },
14
+ python: { parser: "python", extensions: [".py"] },
15
+ });
16
+ export const MAX_RESULT_BYTES = 24 * 1024;
17
+ export class SearchError extends Error {
18
+ constructor(status, message) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
23
+ function reject(message) {
24
+ throw new SearchError("denied", message);
25
+ }
26
+
27
+ export function normalizeSearch(input) {
28
+ if (
29
+ !input ||
30
+ typeof input !== "object" ||
31
+ Array.isArray(input) ||
32
+ Object.keys(input).some((key) => !["language", "pattern", "paths", "maxResults", "timeoutMs"].includes(key))
33
+ ) {
34
+ reject("Invalid structural search input.");
35
+ }
36
+
37
+ if (!Object.hasOwn(LANGUAGES, input.language)) {
38
+ throw new SearchError("unsupported_language", "Choose a supported structural search language.");
39
+ }
40
+
41
+ if (
42
+ typeof input.pattern !== "string" ||
43
+ !input.pattern.trim() ||
44
+ Buffer.byteLength(input.pattern) > 4096 ||
45
+ input.pattern.includes("\0")
46
+ ) {
47
+ throw new SearchError("invalid_pattern", "Provide a nonempty pattern up to 4096 bytes.");
48
+ }
49
+
50
+ if (
51
+ !Array.isArray(input.paths) ||
52
+ !input.paths.length ||
53
+ input.paths.length > 200 ||
54
+ input.paths.some(
55
+ (p) =>
56
+ typeof p !== "string" || !LANGUAGES[input.language].extensions.includes(path.extname(p).toLowerCase()),
57
+ ) ||
58
+ Buffer.byteLength(JSON.stringify(input.paths)) > 6000
59
+ ) {
60
+ reject("Select 1–200 explicit relative files matching the language, with at most 6000 bytes of paths.");
61
+ }
62
+
63
+ const maxResults = input.maxResults ?? 50;
64
+ const timeoutMs = input.timeoutMs ?? 10000;
65
+ if (
66
+ !Number.isInteger(maxResults) ||
67
+ maxResults < 1 ||
68
+ maxResults > 100 ||
69
+ !Number.isInteger(timeoutMs) ||
70
+ timeoutMs < 1000 ||
71
+ timeoutMs > 30000
72
+ ) {
73
+ reject("Structural search limits are invalid.");
74
+ }
75
+
76
+ return { language: input.language, pattern: input.pattern, paths: [...input.paths], maxResults, timeoutMs };
77
+ }
78
+
79
+ export function resolveBinary(runtimeDir) {
80
+ const platforms = {
81
+ "win32-x64": "win32-x64-msvc",
82
+ "darwin-arm64": "darwin-arm64",
83
+ "darwin-x64": "darwin-x64",
84
+ "linux-x64": "linux-x64-gnu",
85
+ };
86
+ const platform = platforms[`${process.platform}-${process.arch}`];
87
+ if (!platform || (process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime)) {
88
+ throw new SearchError("unavailable", "Structural search is unavailable on this platform.");
89
+ }
90
+
91
+ const directory = path.join(runtimeDir, "node_modules", "@ast-grep", `cli-${platform}`);
92
+ try {
93
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"));
94
+ const executable = path.join(directory, process.platform === "win32" ? "ast-grep.exe" : "ast-grep");
95
+ if (manifest.version !== VERSION || !fs.statSync(executable).isFile()) {
96
+ throw new Error("Wrong binary");
97
+ }
98
+
99
+ return executable;
100
+ } catch {
101
+ throw new SearchError(
102
+ "unavailable",
103
+ "Structural search runtime unavailable. Run specpi update --structural-search=on without acquisition skip flags.",
104
+ );
105
+ }
106
+ }
107
+
108
+ export function minimalEnvironment() {
109
+ const env = {};
110
+ // Native parser only: no loader overrides, HOME/config discovery, provider keys or shell.
111
+ for (const key of ["SystemRoot", "WINDIR", "TEMP", "TMP", "LANG", "LC_ALL"]) {
112
+ if (process.env[key]) {
113
+ env[key] = process.env[key];
114
+ }
115
+ }
116
+
117
+ return env;
118
+ }
119
+
120
+ export function parseSource(executable, directory, input, bytes, signal, quota = { remaining: 2 * 1024 * 1024 }) {
121
+ return new Promise((resolve, rejectPromise) => {
122
+ if (signal?.aborted) {
123
+ rejectPromise(new SearchError("cancelled", "Structural search cancelled."));
124
+
125
+ return;
126
+ }
127
+
128
+ const args = [
129
+ "run",
130
+ "--config",
131
+ path.join(directory, "sgconfig.yml"),
132
+ "--lang",
133
+ LANGUAGES[input.language].parser,
134
+ `--pattern=${input.pattern}`,
135
+ "--stdin",
136
+ "--json=compact",
137
+ "--threads",
138
+ "1",
139
+ "--color",
140
+ "never",
141
+ ];
142
+ const child = spawn(executable, args, {
143
+ cwd: directory,
144
+ env: minimalEnvironment(),
145
+ shell: false,
146
+ windowsHide: true,
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ });
149
+ const output = [];
150
+ quota.stderrRemaining ??= 8192;
151
+ let failure;
152
+ let killTimer;
153
+ let finished = false;
154
+ const fail = (error) => {
155
+ failure ??= error;
156
+ try {
157
+ child.kill("SIGKILL");
158
+ } catch {
159
+ // The close deadline below reports unconfirmed cleanup and prevents reuse.
160
+ }
161
+
162
+ killTimer ??= setTimeout(() => {
163
+ if (!finished) {
164
+ finished = true;
165
+ rejectPromise(
166
+ new SearchError(
167
+ "cleanup_failed",
168
+ "Parser cleanup could not be confirmed; reload after terminating the owned parser.",
169
+ ),
170
+ );
171
+ }
172
+ }, 1000);
173
+ };
174
+
175
+ const abort = () => fail(new SearchError("cancelled", "Structural search cancelled."));
176
+ signal?.addEventListener("abort", abort, { once: true });
177
+ child.stdout.on("data", (chunk) => {
178
+ quota.remaining -= chunk.length;
179
+ if (quota.remaining < 0) {
180
+ fail(new SearchError("partial", "Parser output limit reached; narrow the selection or pattern."));
181
+ } else {
182
+ output.push(chunk);
183
+ }
184
+ });
185
+ child.stderr.on("data", (chunk) => {
186
+ quota.stderrRemaining -= chunk.length;
187
+ if (quota.stderrRemaining < 0) {
188
+ fail(new SearchError("partial", "Parser diagnostic limit reached."));
189
+ }
190
+ });
191
+ child.stdin.on("error", () => {});
192
+ child.on("error", () => {
193
+ failure ??= new SearchError("unavailable", "Structural parser could not start.");
194
+ });
195
+ child.on("close", (code) => {
196
+ clearTimeout(killTimer);
197
+ signal?.removeEventListener("abort", abort);
198
+ if (finished) {
199
+ return;
200
+ }
201
+
202
+ finished = true;
203
+ if (failure) {
204
+ rejectPromise(failure);
205
+
206
+ return;
207
+ }
208
+
209
+ if (code !== 0 && code !== 1) {
210
+ rejectPromise(new SearchError("invalid_pattern", "Parser rejected this pattern or input."));
211
+
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const raw = Buffer.concat(output).toString("utf8").trim();
217
+ const matches = raw ? JSON.parse(raw) : [];
218
+ if (!Array.isArray(matches)) {
219
+ throw new Error("Malformed output");
220
+ }
221
+
222
+ resolve(matches);
223
+ } catch {
224
+ rejectPromise(new SearchError("unavailable", "Parser returned invalid structured output."));
225
+ }
226
+ });
227
+ child.stdin.end(bytes);
228
+ if (signal?.aborted) {
229
+ abort();
230
+ }
231
+ });
232
+ }
233
+
234
+ function clip(text, maximum) {
235
+ let value = String(text)
236
+ .slice(0, maximum)
237
+ .toWellFormed()
238
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
239
+ while (Buffer.byteLength(value) > maximum) {
240
+ value = value.slice(0, -1).toWellFormed();
241
+ }
242
+
243
+ return value;
244
+ }
245
+
246
+ export async function structuralSearch(
247
+ input,
248
+ { cwd, runtimeDir, signal, admit = async () => {}, parser = parseSource } = {},
249
+ ) {
250
+ const spec = normalizeSearch(input);
251
+ await admit();
252
+ if (signal?.aborted) {
253
+ throw new SearchError("cancelled", "Structural search cancelled.");
254
+ }
255
+
256
+ const executable = resolveBinary(runtimeDir);
257
+ const snapshot = createSnapshot(cwd, spec.paths);
258
+ let directory;
259
+ let preserveDirectory = false;
260
+ const result = {
261
+ status: "complete",
262
+ engineVersion: VERSION,
263
+ language: spec.language,
264
+ captureId: crypto.randomUUID(),
265
+ searchedFiles: 0,
266
+ requestedFiles: spec.paths.length,
267
+ sources: snapshot.sources.map(({ id, path: file, digest }) => ({ id, path: file, digest })),
268
+ matches: [],
269
+ truncated: false,
270
+ columnEncoding: "one-based UTF-8 byte columns; end exclusive",
271
+ };
272
+ try {
273
+ if (
274
+ snapshot.sources.some((source) => source.bytes > 1024 * 1024) ||
275
+ Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES / 2
276
+ ) {
277
+ reject("Source or selection metadata exceeds the search quota; narrow the selection.");
278
+ }
279
+
280
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), "specpi-structural-"));
281
+ fs.writeFileSync(path.join(directory, "sgconfig.yml"), "ruleDirs: []\n", { mode: 0o600 });
282
+ const quota = { remaining: 2 * 1024 * 1024 };
283
+ for (const source of snapshot.sources) {
284
+ await admit();
285
+ const matches = await snapshot.withSourceBytes(source.id, async (bytes) => {
286
+ const raw = await parser(executable, directory, spec, bytes, signal, quota);
287
+ const lines = [0];
288
+ for (let offset = 0; offset < bytes.length; offset += 1) {
289
+ if (bytes[offset] === 10) {
290
+ lines.push(offset + 1);
291
+ }
292
+ }
293
+
294
+ const position = (offset) => {
295
+ let low = 0;
296
+ let high = lines.length;
297
+ while (low + 1 < high) {
298
+ const middle = Math.floor((low + high) / 2);
299
+ if (lines[middle] <= offset) {
300
+ low = middle;
301
+ } else {
302
+ high = middle;
303
+ }
304
+ }
305
+
306
+ return { line: low, column: offset - lines[low] };
307
+ };
308
+
309
+ // CLI display columns count characters. Use original byte offsets instead.
310
+ return raw.slice(0, spec.maxResults + 1).map((match) => {
311
+ const start = match.range?.byteOffset?.start;
312
+ const end = match.range?.byteOffset?.end;
313
+ if (
314
+ !Number.isSafeInteger(start) ||
315
+ !Number.isSafeInteger(end) ||
316
+ start < 0 ||
317
+ end < start ||
318
+ end > bytes.length ||
319
+ bytes.subarray(start, end).toString("utf8") !== match.text
320
+ ) {
321
+ throw new SearchError("unavailable", "Parser returned an invalid source range.");
322
+ }
323
+
324
+ return { text: match.text, range: { start: position(start), end: position(end) } };
325
+ });
326
+ });
327
+ result.searchedFiles += 1;
328
+ for (const match of matches) {
329
+ const start = match.range?.start;
330
+ const end = match.range?.end;
331
+ if (
332
+ ![start?.line, start?.column, end?.line, end?.column].every(
333
+ (v) => Number.isSafeInteger(v) && v >= 0,
334
+ ) ||
335
+ typeof match.text !== "string"
336
+ ) {
337
+ throw new SearchError("unavailable", "Parser returned an invalid source range.");
338
+ }
339
+
340
+ const entry = {
341
+ sourceId: source.id,
342
+ path: source.path,
343
+ start: { line: start.line + 1, byteColumn: start.column + 1 },
344
+ end: { line: end.line + 1, byteColumn: end.column + 1 },
345
+ snippet: clip(match.text, 512),
346
+ snippetTruncated: Buffer.byteLength(match.text) > 512,
347
+ };
348
+ result.matches.push(entry);
349
+ if (
350
+ result.matches.length > spec.maxResults ||
351
+ Buffer.byteLength(JSON.stringify(result)) > MAX_RESULT_BYTES - 512
352
+ ) {
353
+ result.matches.pop();
354
+ result.truncated = true;
355
+ result.status = "partial";
356
+
357
+ return result;
358
+ }
359
+ }
360
+ }
361
+
362
+ return result;
363
+ } catch (error) {
364
+ preserveDirectory = error instanceof SearchError && error.status === "cleanup_failed";
365
+ if (error instanceof SearchError && error.status === "partial") {
366
+ return { ...result, status: "partial", truncated: true, reason: error.message };
367
+ }
368
+
369
+ throw error;
370
+ } finally {
371
+ snapshot.destroy();
372
+ if (directory && !preserveDirectory) {
373
+ try {
374
+ fs.rmSync(directory, { recursive: true, force: true });
375
+ } catch {
376
+ throw new SearchError(
377
+ "cleanup_failed",
378
+ "Structural scratch cleanup failed; resolve it before reloading.",
379
+ );
380
+ }
381
+ }
382
+ }
383
+ }