opencode-rag-plugin 1.19.4 → 1.19.5

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 (72) hide show
  1. package/dist/chunker/base.js +19 -5
  2. package/dist/chunker/factory.js +27 -9
  3. package/dist/chunker/grammar.d.ts +18 -1
  4. package/dist/chunker/grammar.js +48 -10
  5. package/dist/chunker/pdf.js +30 -14
  6. package/dist/cli/commands/init-helpers.js +15 -2
  7. package/dist/cli/commands/init.js +31 -21
  8. package/dist/cli/commands/quirk.js +9 -3
  9. package/dist/cli/commands/setup.js +5 -2
  10. package/dist/cli/commands/status.js +14 -5
  11. package/dist/cli/commands/ui.js +23 -9
  12. package/dist/cli/commands/update.js +4 -5
  13. package/dist/cli/format.d.ts +5 -2
  14. package/dist/cli/format.js +14 -5
  15. package/dist/content/image.js +33 -11
  16. package/dist/content/reader.js +74 -13
  17. package/dist/core/bootstrap.js +10 -3
  18. package/dist/core/config.js +31 -0
  19. package/dist/core/desc-cache.d.ts +8 -2
  20. package/dist/core/desc-cache.js +10 -3
  21. package/dist/core/doc-progress.js +5 -2
  22. package/dist/core/provider-defaults.d.ts +2 -0
  23. package/dist/core/provider-defaults.js +19 -4
  24. package/dist/core/runtime-overrides.d.ts +0 -6
  25. package/dist/core/version-check.d.ts +5 -0
  26. package/dist/core/version-check.js +8 -2
  27. package/dist/describer/anthropic.d.ts +2 -2
  28. package/dist/describer/anthropic.js +19 -5
  29. package/dist/describer/describer.js +15 -2
  30. package/dist/describer/gemini.js +25 -10
  31. package/dist/embedder/factory.d.ts +5 -3
  32. package/dist/embedder/factory.js +41 -8
  33. package/dist/embedder/health.js +19 -19
  34. package/dist/embedder/http.d.ts +14 -1
  35. package/dist/embedder/http.js +60 -6
  36. package/dist/eval/session-logger.js +7 -0
  37. package/dist/eval/storage.js +8 -0
  38. package/dist/indexer/git-diff.d.ts +1 -1
  39. package/dist/indexer/git-diff.js +5 -1
  40. package/dist/indexer/pipeline.js +421 -344
  41. package/dist/indexer/stats.d.ts +2 -0
  42. package/dist/indexer/stats.js +1 -0
  43. package/dist/indexer/watch.js +8 -1
  44. package/dist/indexer/worker.js +21 -0
  45. package/dist/mcp/cli.js +4 -0
  46. package/dist/mcp/handlers.js +16 -5
  47. package/dist/mcp/server.js +3 -0
  48. package/dist/opencode/create-read-tool.js +14 -3
  49. package/dist/opencode/tool-args.js +23 -1
  50. package/dist/plugin.js +66 -152
  51. package/dist/quirks/auto-capture.js +5 -0
  52. package/dist/quirks/quirk-store.d.ts +1 -1
  53. package/dist/quirks/quirk-store.js +56 -17
  54. package/dist/retriever/context-optimizer.js +18 -4
  55. package/dist/retriever/keyword-index.d.ts +2 -0
  56. package/dist/retriever/keyword-index.js +38 -4
  57. package/dist/retriever/retriever.js +6 -1
  58. package/dist/tui.js +41 -4
  59. package/dist/vectorstore/lancedb.d.ts +25 -1
  60. package/dist/vectorstore/lancedb.js +157 -11
  61. package/dist/vectorstore/memory.js +5 -1
  62. package/dist/watcher.js +30 -4
  63. package/dist/web/api.d.ts +5 -1
  64. package/dist/web/api.js +195 -69
  65. package/dist/web/server.d.ts +2 -0
  66. package/dist/web/server.js +66 -28
  67. package/dist/web/static.d.ts +5 -2
  68. package/dist/web/static.js +9 -5
  69. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  70. package/dist/web/ui/index.html +1 -1
  71. package/package.json +1 -1
  72. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -1,4 +1,4 @@
1
- import { isOpenAiCompatible } from "../core/provider-defaults.js";
1
+ import { isOpenAiCompatible, supportsEmbedding } from "../core/provider-defaults.js";
2
2
  import { OllamaProvider } from "./ollama.js";
3
3
  import { OpenAIProvider } from "./openai.js";
4
4
  import { CohereProvider } from "./cohere.js";
@@ -17,6 +17,12 @@ import pLimit from "p-limit";
17
17
  export function createEmbedder(config) {
18
18
  const { provider, baseUrl, model, apiKey, proxy, timeoutMs } = config.embedding;
19
19
  const effectiveTimeoutMs = timeoutMs ?? 120000;
20
+ // Fail fast for chat-only providers (groq, deepseek, anthropic, google)
21
+ // — proceeding would surface a cryptic failure at index time.
22
+ if (!supportsEmbedding(provider)) {
23
+ throw new Error(`Provider "${provider}" does not support embeddings. ` +
24
+ "Use an embedding-capable provider (ollama, openai, cohere, nvidia, azure, mistral, together, fireworks).");
25
+ }
20
26
  if (provider === "ollama") {
21
27
  return new OllamaProvider(baseUrl, model, apiKey, effectiveTimeoutMs, proxy, config.logging.level);
22
28
  }
@@ -34,6 +40,15 @@ export function createEmbedder(config) {
34
40
  }
35
41
  throw new Error(`Unknown embedding provider: ${provider}`);
36
42
  }
43
+ /**
44
+ * HTTP statuses that indicate a permanent failure — retrying cannot help
45
+ * (auth errors, bad requests, missing resources). Providers raise these as
46
+ * Error objects whose messages contain the status code.
47
+ */
48
+ const PERMANENT_STATUS_RE = /\(?(400|401|403|404|422)\)?/;
49
+ function isPermanentError(err) {
50
+ return PERMANENT_STATUS_RE.test(err instanceof Error ? err.message : String(err));
51
+ }
37
52
  /**
38
53
  * Embed a list of texts in batches with optional concurrency control and per-batch retry.
39
54
  *
@@ -41,9 +56,11 @@ export function createEmbedder(config) {
41
56
  * (or concurrently when `concurrency > 1`). When concurrency is limited, uses
42
57
  * `p-limit` to cap the number of in-flight requests.
43
58
  *
44
- * Each batch is retried up to `retryMax` times with exponential backoff. If all
45
- * retries are exhausted, the batch is skipped and empty arrays are returned for
46
- * those texts so the caller can still process successfully embedded batches.
59
+ * Each batch is retried up to `retryMax` times with exponential backoff (only
60
+ * for transient failures auth/validation errors are not retried). If all
61
+ * retries are exhausted or the provider returns a mismatched embedding count,
62
+ * the batch is skipped and empty arrays are returned for those texts so the
63
+ * caller can still process successfully embedded batches.
47
64
  *
48
65
  * @param embedder - The embedding provider to use
49
66
  * @param texts - Array of text strings to embed
@@ -67,13 +84,27 @@ export async function embedBatch(embedder, texts, batchSize = 10, purpose, concu
67
84
  async function embedWithRetry(batchTexts) {
68
85
  for (let attempt = 0; attempt <= retryMax; attempt++) {
69
86
  try {
70
- return await embedder.embed(batchTexts, purpose);
87
+ const embeddings = await embedder.embed(batchTexts, purpose);
88
+ // Validate the response shape: a count mismatch would silently attach
89
+ // vectors to the wrong chunks downstream; a dimension mismatch would
90
+ // poison the store. Treat both as a retryable batch failure.
91
+ if (embeddings.length !== batchTexts.length) {
92
+ throw new Error(`Embedding provider returned ${embeddings.length} vectors for ${batchTexts.length} texts`);
93
+ }
94
+ const dims = new Set(embeddings.map((v) => v.length));
95
+ if (dims.size > 1 || dims.has(0)) {
96
+ throw new Error(`Embedding provider returned inconsistent vector dimensions: ${[...dims].join(", ")}`);
97
+ }
98
+ return embeddings;
71
99
  }
72
100
  catch (err) {
73
- if (attempt < retryMax) {
74
- const delay = retryBaseDelayMs * Math.pow(2, attempt);
101
+ if (attempt < retryMax && !isPermanentError(err)) {
102
+ const delay = retryBaseDelayMs * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4);
75
103
  await new Promise(resolve => setTimeout(resolve, delay));
76
104
  }
105
+ else if (attempt >= retryMax || isPermanentError(err)) {
106
+ return null;
107
+ }
77
108
  }
78
109
  }
79
110
  return null;
@@ -99,7 +130,9 @@ export async function embedBatch(embedder, texts, batchSize = 10, purpose, concu
99
130
  const batchResults = await Promise.all(batches.map((batch) => limit(async () => {
100
131
  const embeddings = await embedWithRetry(batch.texts);
101
132
  const flatResult = embeddings ?? batch.texts.map(() => []);
102
- completedCount += embeddings?.length ?? 0;
133
+ // Count processed TEXTS (not returned vectors) so failed batches
134
+ // still advance progress towards the total.
135
+ completedCount += batch.texts.length;
103
136
  onProgress?.(completedCount, texts.length);
104
137
  return { index: batch.index, embeddings: flatResult };
105
138
  })));
@@ -1,4 +1,4 @@
1
- import { postJson } from "./http.js";
1
+ import { fetchWithProxy, postJson } from "./http.js";
2
2
  /**
3
3
  * Check connectivity and model availability for all configured providers.
4
4
  * Returns one result per configured model (embedding + description + image_description if enabled).
@@ -42,10 +42,10 @@ async function checkDescriptionModel(config, _timeoutMs) {
42
42
  return checkOllamaChat(baseUrl, model, descTimeout, desc.proxy);
43
43
  }
44
44
  if (provider === "anthropic") {
45
- return checkAnthropicChat(baseUrl, model, apiKey, descTimeout);
45
+ return checkAnthropicChat(baseUrl, model, apiKey, descTimeout, desc.proxy);
46
46
  }
47
47
  if (provider === "google") {
48
- return checkGoogleChat(baseUrl, model, apiKey, descTimeout);
48
+ return checkGoogleChat(baseUrl, model, apiKey, descTimeout, desc.proxy);
49
49
  }
50
50
  // OpenAI-compatible chat endpoint
51
51
  return checkOpenAiChat(baseUrl, model, apiKey, descTimeout, desc.proxy);
@@ -62,10 +62,10 @@ async function checkImageDescriptionModel(config, _timeoutMs) {
62
62
  return checkOllamaChat(baseUrl, model, imgTimeout, img.proxy, "image_description");
63
63
  }
64
64
  if (provider === "anthropic") {
65
- return checkAnthropicChat(baseUrl, model, apiKey, imgTimeout, "image_description");
65
+ return checkAnthropicChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
66
66
  }
67
67
  if (provider === "google") {
68
- return checkGoogleChat(baseUrl, model, apiKey, imgTimeout, "image_description");
68
+ return checkGoogleChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
69
69
  }
70
70
  // OpenAI-compatible chat endpoint
71
71
  return checkOpenAiChat(baseUrl, model, apiKey, imgTimeout, img.proxy, "image_description");
@@ -125,16 +125,16 @@ async function checkOllamaChat(baseUrl, model, timeoutMs, proxy, type = "descrip
125
125
  * Check OpenAI-compatible embedding endpoint via the /models endpoint
126
126
  * to validate the API key without consuming embedding tokens.
127
127
  */
128
- async function checkOpenAiEmbed(baseUrl, model, apiKey, timeoutMs, _proxy) {
128
+ async function checkOpenAiEmbed(baseUrl, model, apiKey, timeoutMs, proxy) {
129
129
  if (!apiKey) {
130
130
  return { provider: "openai", model, type: "embedding", status: "error", error: "No API key configured" };
131
131
  }
132
132
  const url = `${baseUrl.replace(/\/+$/, "")}/models`;
133
133
  try {
134
- const response = await fetch(url, {
134
+ const response = await fetchWithProxy(url, {
135
135
  headers: { Authorization: `Bearer ${apiKey}` },
136
136
  signal: AbortSignal.timeout(Math.min(timeoutMs ?? 15000, 15000)),
137
- });
137
+ }, proxy);
138
138
  if (response.ok) {
139
139
  return { provider: "openai", model, type: "embedding", status: "ok" };
140
140
  }
@@ -150,16 +150,16 @@ async function checkOpenAiEmbed(baseUrl, model, apiKey, timeoutMs, _proxy) {
150
150
  }
151
151
  }
152
152
  /** Check OpenAI-compatible chat endpoint via the /models endpoint. */
153
- async function checkOpenAiChat(baseUrl, model, apiKey, timeoutMs, _proxy, type = "description") {
153
+ async function checkOpenAiChat(baseUrl, model, apiKey, timeoutMs, proxy, type = "description") {
154
154
  if (!apiKey) {
155
155
  return { provider: "openai", model, type, status: "error", error: "No API key configured" };
156
156
  }
157
157
  const url = `${baseUrl.replace(/\/+$/, "")}/models`;
158
158
  try {
159
- const response = await fetch(url, {
159
+ const response = await fetchWithProxy(url, {
160
160
  headers: { Authorization: `Bearer ${apiKey}` },
161
161
  signal: AbortSignal.timeout(Math.min(timeoutMs ?? 15000, 15000)),
162
- });
162
+ }, proxy);
163
163
  if (response.ok) {
164
164
  return { provider: "openai", model, type, status: "ok" };
165
165
  }
@@ -196,13 +196,13 @@ async function checkCohereEmbed(baseUrl, model, apiKey, timeoutMs, proxy) {
196
196
  }
197
197
  // ── Anthropic check ────────────────────────────────────────────
198
198
  /** Check Anthropic chat endpoint by sending a minimal message. */
199
- async function checkAnthropicChat(baseUrl, model, apiKey, timeoutMs, type = "description") {
199
+ async function checkAnthropicChat(baseUrl, model, apiKey, timeoutMs, proxy, type = "description") {
200
200
  if (!apiKey) {
201
201
  return { provider: "anthropic", model, type, status: "error", error: "No API key configured" };
202
202
  }
203
203
  const url = `${baseUrl.replace(/\/+$/, "")}/messages`;
204
204
  try {
205
- const response = await fetch(url, {
205
+ const response = await fetchWithProxy(url, {
206
206
  method: "POST",
207
207
  headers: {
208
208
  "Content-Type": "application/json",
@@ -215,7 +215,7 @@ async function checkAnthropicChat(baseUrl, model, apiKey, timeoutMs, type = "des
215
215
  messages: [{ role: "user", content: "hi" }],
216
216
  }),
217
217
  signal: AbortSignal.timeout(Math.min(timeoutMs ?? 15000, 15000)),
218
- });
218
+ }, proxy);
219
219
  if (response.ok) {
220
220
  return { provider: "anthropic", model, type, status: "ok" };
221
221
  }
@@ -232,20 +232,20 @@ async function checkAnthropicChat(baseUrl, model, apiKey, timeoutMs, type = "des
232
232
  }
233
233
  // ── Google Gemini check ────────────────────────────────────────
234
234
  /** Check Google Gemini chat endpoint by sending a minimal generateContent request. */
235
- async function checkGoogleChat(baseUrl, model, apiKey, timeoutMs, type = "description") {
235
+ async function checkGoogleChat(baseUrl, model, apiKey, timeoutMs, proxy, type = "description") {
236
236
  if (!apiKey) {
237
237
  return { provider: "google", model, type, status: "error", error: "No API key configured" };
238
238
  }
239
- const url = `${baseUrl.replace(/\/+$/, "")}/models/${model}:generateContent?key=${apiKey}`;
239
+ const url = `${baseUrl.replace(/\/+$/, "")}/models/${model}:generateContent`;
240
240
  try {
241
- const response = await fetch(url, {
241
+ const response = await fetchWithProxy(url, {
242
242
  method: "POST",
243
- headers: { "Content-Type": "application/json" },
243
+ headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
244
244
  body: JSON.stringify({
245
245
  contents: [{ parts: [{ text: "hi" }] }],
246
246
  }),
247
247
  signal: AbortSignal.timeout(Math.min(timeoutMs ?? 15000, 15000)),
248
- });
248
+ }, proxy);
249
249
  if (response.ok) {
250
250
  return { provider: "google", model, type, status: "ok" };
251
251
  }
@@ -12,6 +12,19 @@ export interface HttpResponseLike {
12
12
  /** Destroy all pooled TCP/TLS sockets and clear the connection pool. */
13
13
  declare function destroyAllPooledConnections(): void;
14
14
  export { destroyAllPooledConnections };
15
+ /**
16
+ * Fetch a URL through the configured proxy, applying the proxy to
17
+ * HTTP(S)_PROXY env vars for the duration of the request (serialized).
18
+ *
19
+ * Used by health checks and other one-off requests that do not go
20
+ * through {@link postJson}.
21
+ */
22
+ export declare function fetchWithProxy(urlString: string, init: {
23
+ method?: string;
24
+ headers?: Record<string, string>;
25
+ body?: string;
26
+ signal?: AbortSignal;
27
+ }, proxy?: ProxyConfig): Promise<Response>;
15
28
  /**
16
29
  * Check whether a hostname refers to the local machine.
17
30
  *
@@ -43,7 +56,7 @@ export declare function matchesNoProxy(hostname: string, noProxy?: string): bool
43
56
  * @param redirectCount - Internal redirect counter (starts at 0)
44
57
  * @returns A promise resolving to an HttpResponseLike
45
58
  */
46
- export declare function directRequest(url: URL, body: unknown, headers: Record<string, string>, timeoutMs: number, redirectCount?: number): Promise<HttpResponseLike>;
59
+ export declare function directRequest(url: URL, body: unknown, headers: Record<string, string>, timeoutMs: number, redirectCount?: number, signal?: AbortSignal): Promise<HttpResponseLike>;
47
60
  /**
48
61
  * POST JSON to a URL, optionally routing through a proxy.
49
62
  *
@@ -67,6 +67,48 @@ function destroyAllPooledConnections() {
67
67
  connectionPool.clear();
68
68
  }
69
69
  export { destroyAllPooledConnections };
70
+ /**
71
+ * Fetch a URL through the configured proxy, applying the proxy to
72
+ * HTTP(S)_PROXY env vars for the duration of the request (serialized).
73
+ *
74
+ * Used by health checks and other one-off requests that do not go
75
+ * through {@link postJson}.
76
+ */
77
+ export async function fetchWithProxy(urlString, init, proxy) {
78
+ const authHeader = buildProxyAuthHeader(proxy);
79
+ const envOverride = applyProxyEnv(proxy);
80
+ const [savedHttpProxy, savedHttpsProxy] = [process.env.HTTP_PROXY, process.env.HTTPS_PROXY];
81
+ return proxyRequestQueue.then(async () => {
82
+ try {
83
+ if (envOverride) {
84
+ process.env.HTTP_PROXY = envOverride.httpProxy;
85
+ process.env.HTTPS_PROXY = envOverride.httpsProxy;
86
+ }
87
+ const headers = { ...init.headers };
88
+ if (authHeader) {
89
+ headers["Proxy-Authorization"] = authHeader;
90
+ }
91
+ return await fetch(urlString, {
92
+ method: init.method ?? "GET",
93
+ headers,
94
+ body: init.body,
95
+ signal: init.signal,
96
+ });
97
+ }
98
+ finally {
99
+ if (envOverride) {
100
+ if (savedHttpProxy === undefined)
101
+ delete process.env.HTTP_PROXY;
102
+ else
103
+ process.env.HTTP_PROXY = savedHttpProxy;
104
+ if (savedHttpsProxy === undefined)
105
+ delete process.env.HTTPS_PROXY;
106
+ else
107
+ process.env.HTTPS_PROXY = savedHttpsProxy;
108
+ }
109
+ }
110
+ });
111
+ }
70
112
  /**
71
113
  * Check whether a hostname refers to the local machine.
72
114
  *
@@ -150,8 +192,8 @@ function applyProxyEnv(proxy) {
150
192
  * @param redirectCount - Internal redirect counter (starts at 0)
151
193
  * @returns A promise resolving to an HttpResponseLike
152
194
  */
153
- export function directRequest(url, body, headers, timeoutMs, redirectCount = 0) {
154
- return sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount);
195
+ export function directRequest(url, body, headers, timeoutMs, redirectCount = 0, signal) {
196
+ return sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount, signal);
155
197
  }
156
198
  const CRLF = Buffer.from("\r\n");
157
199
  /** Build the full raw HTTP/1.1 request buffer (headers + JSON body). */
@@ -241,7 +283,7 @@ function parseResponse(buffer) {
241
283
  return { status, headers, body };
242
284
  }
243
285
  /** Raw HTTP/1.1 POST over TCP/TLS with connection pooling, redirect following, and timeout. */
244
- async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount) {
286
+ async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount, signal) {
245
287
  const requestBuffer = buildRequestPayload(body, headers, url);
246
288
  const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
247
289
  const isHttps = url.protocol === "https:";
@@ -259,6 +301,7 @@ async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount)
259
301
  return;
260
302
  settled = true;
261
303
  clearTimeout(timeout);
304
+ signal?.removeEventListener("abort", onAbort);
262
305
  fn();
263
306
  };
264
307
  const timeout = setTimeout(() => {
@@ -267,6 +310,16 @@ async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount)
267
310
  reject(new Error(`Request timed out after ${timeoutMs}ms`));
268
311
  });
269
312
  }, timeoutMs);
313
+ // Wire the caller's AbortSignal through to the raw socket (F2.1)
314
+ const onAbort = () => {
315
+ if (settled)
316
+ return;
317
+ settle(() => {
318
+ socket.destroy();
319
+ reject(new DOMException("The operation was aborted.", "AbortError"));
320
+ });
321
+ };
322
+ signal?.addEventListener("abort", onAbort, { once: true });
270
323
  const releaseOrDestroy = () => {
271
324
  if (responseConnectionClose) {
272
325
  socket.destroy();
@@ -369,7 +422,8 @@ async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount)
369
422
  typeof location === "string" &&
370
423
  location.length > 0) {
371
424
  if (redirectCount >= 5) {
372
- const text = `Redirect limit exceeded for ${url.toString()}`;
425
+ // Strip the query string it may contain API keys (e.g. `?key=...`)
426
+ const text = `Redirect limit exceeded for ${url.origin}${url.pathname}`;
373
427
  return {
374
428
  ok: false,
375
429
  status: response.status,
@@ -389,7 +443,7 @@ async function sendRawHttpRequest(url, body, headers, timeoutMs, redirectCount)
389
443
  const safeHeaders = sameOrigin
390
444
  ? headers
391
445
  : Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "authorization"));
392
- return sendRawHttpRequest(redirectUrl, body, safeHeaders, timeoutMs, redirectCount + 1);
446
+ return sendRawHttpRequest(redirectUrl, body, safeHeaders, timeoutMs, redirectCount + 1, signal);
393
447
  }
394
448
  const text = response.body.toString("utf8");
395
449
  return {
@@ -421,7 +475,7 @@ export async function postJson(urlString, body, headers, timeoutMs, proxy, signa
421
475
  const url = new URL(urlString);
422
476
  const bypassProxy = isLocalhost(url.hostname) || matchesNoProxy(url.hostname, proxy?.noProxy);
423
477
  if (bypassProxy || !proxy?.url) {
424
- return directRequest(url, body, headers, timeoutMs);
478
+ return directRequest(url, body, headers, timeoutMs, 0, signal);
425
479
  }
426
480
  return postJsonViaFetch(urlString, body, headers, timeoutMs, proxy, signal);
427
481
  }
@@ -15,6 +15,13 @@ export function createSessionLogger(storePath) {
15
15
  const info = props.info;
16
16
  if (!info || info.role !== "assistant")
17
17
  return;
18
+ // message.updated fires repeatedly DURING streaming — only record
19
+ // completed messages (time.completed/finish/error set), otherwise
20
+ // every token chunk triggers a synchronous disk write on the
21
+ // event hot path and the log fills with redundant entries.
22
+ const isFinal = !!info.time?.completed || !!info.finish || !!info.error;
23
+ if (!isFinal)
24
+ return;
18
25
  const ev = {
19
26
  ts: Date.now(),
20
27
  event: "message",
@@ -19,6 +19,10 @@ export function validateSessionID(sessionID) {
19
19
  /** Append a single event to a session's JSONL log file. Creates the directory and file if needed. */
20
20
  export function appendSessionEvent(storePath, event) {
21
21
  try {
22
+ // Guard against path traversal / malformed IDs — never write outside the
23
+ // eval directory.
24
+ if (!validateSessionID(event.sessionID))
25
+ return;
22
26
  const dir = getEvalDir(storePath);
23
27
  mkdirSync(dir, { recursive: true });
24
28
  const filePath = getSessionPath(storePath, event.sessionID);
@@ -31,6 +35,8 @@ export function appendSessionEvent(storePath, event) {
31
35
  /** Read all events for a session from its JSONL log file. Returns an empty array if the file does not exist or cannot be read. */
32
36
  export function readSessionEvents(storePath, sessionID) {
33
37
  try {
38
+ if (!validateSessionID(sessionID))
39
+ return [];
34
40
  const filePath = getSessionPath(storePath, sessionID);
35
41
  const content = readFileSync(filePath, "utf8");
36
42
  const lines = content.split("\n").filter((l) => l.trim().length > 0);
@@ -58,6 +64,8 @@ export function listSessionIDs(storePath) {
58
64
  /** Delete a session's JSONL log file from disk. Silently succeeds if the file does not exist. */
59
65
  export function deleteSession(storePath, sessionID) {
60
66
  try {
67
+ if (!validateSessionID(sessionID))
68
+ return;
61
69
  const filePath = getSessionPath(storePath, sessionID);
62
70
  if (existsSync(filePath)) {
63
71
  unlinkSync(filePath);
@@ -23,7 +23,7 @@ export declare function getCurrentCommit(cwd: string): string | null;
23
23
  *
24
24
  * @param cwd - A path inside the repository.
25
25
  * @param fromCommit - The commit SHA to diff against.
26
- * @returns A diff result, or `null` if the git command fails.
26
+ * @returns A diff result, or `null` if the git command fails or the commit is invalid.
27
27
  */
28
28
  export declare function getChangedFilesSince(cwd: string, fromCommit: string): GitDiffResult | null;
29
29
  /**
@@ -45,9 +45,13 @@ export function getCurrentCommit(cwd) {
45
45
  *
46
46
  * @param cwd - A path inside the repository.
47
47
  * @param fromCommit - The commit SHA to diff against.
48
- * @returns A diff result, or `null` if the git command fails.
48
+ * @returns A diff result, or `null` if the git command fails or the commit is invalid.
49
49
  */
50
50
  export function getChangedFilesSince(cwd, fromCommit) {
51
+ // The commit value originates from a manifest file — validate it strictly
52
+ // so a crafted manifest can never inject git options via argv.
53
+ if (!/^[0-9a-f]{7,40}$/i.test(fromCommit))
54
+ return null;
51
55
  try {
52
56
  const changedRaw = execFileSync("git", ["diff", "--name-only", "--diff-filter=ACMRT", fromCommit, "HEAD"], {
53
57
  cwd, encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"],