opencode-rag-plugin 1.19.5 → 1.19.8

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.
@@ -1,8 +1,10 @@
1
1
  import { postJson } from "../embedder/http.js";
2
- import { buildUserMessage, sleep } from "./shared.js";
2
+ import { buildUserMessage, buildBatchUserMessage, parseBatchDescriptions, sleep } from "./shared.js";
3
3
  import pLimit from "p-limit";
4
4
  /** HTTP status codes that are safe to retry on. */
5
5
  const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
6
+ /** Consecutive failed batch attempts after which batching is disabled for the rest of the run. */
7
+ const BATCH_MAX_STREAK = 2;
6
8
  /**
7
9
  * Description provider that works with any OpenAI-compatible chat API (including Ollama).
8
10
  *
@@ -11,6 +13,10 @@ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
11
13
  */
12
14
  export class LlmDescriptionProvider {
13
15
  config;
16
+ /** Consecutive batches that needed individual fallback; disables batching past BATCH_MAX_STREAK. */
17
+ batchFailStreak = 0;
18
+ /** Whether multi-chunk batching is still active (adaptive, per provider instance / index run). */
19
+ adaptiveBatchActive = true;
14
20
  /**
15
21
  * @param config - Configuration for the LLM provider, including base URL, model, API key, proxy, and retry settings.
16
22
  */
@@ -37,28 +43,125 @@ export class LlmDescriptionProvider {
37
43
  async generateBatchDescriptions(chunks, logger, opts) {
38
44
  const log = logger ?? { info: (msg) => process.stderr.write(`${msg}\n`), warn: (msg) => process.stderr.write(`${msg}\n`), debug: (msg) => process.stderr.write(`${msg}\n`) };
39
45
  const concurrency = this.config.batchConcurrency ?? 3;
46
+ // Batching is an EXPERIMENTAL opt-in (description.batchEnabled). Off by
47
+ // default — a batch size of 1 routes every group through the individual
48
+ // request path below, which is exactly the per-chunk behavior.
49
+ const batchEnabled = this.config.batchEnabled === true;
50
+ const batchMaxChunks = batchEnabled ? (this.config.batchMaxChunks ?? 25) : 1;
40
51
  const total = chunks.length;
41
- log.debug(`[describer] Generating descriptions for ${total} chunks via ${this.config.provider}/${this.config.model} (concurrency: ${concurrency})`);
52
+ log.debug(`[describer] Generating descriptions for ${total} chunks via ${this.config.provider}/${this.config.model} (concurrency: ${concurrency}, batch: ${batchEnabled ? batchMaxChunks : "off"})`);
42
53
  const result = new Map();
43
54
  const limit = pLimit(concurrency);
44
55
  let completed = 0;
45
- await Promise.all(chunks.map((chunk) => limit(async () => {
46
- const userMsg = buildUserMessage(chunk, this.config.maxContentChars);
47
- log.debug(`[describer] REQUEST chunk ${chunk.id} (${chunk.metadata.filePath}:${chunk.metadata.startLine}):\n${userMsg}`);
48
- try {
49
- const desc = await this.generateDescription(chunk);
50
- result.set(chunk.id, desc);
51
- log.debug(`[describer] RESPONSE chunk ${chunk.id}: ${desc}`);
52
- }
53
- catch (err) {
54
- log.warn(`[describer] Failed to describe chunk ${chunk.id} (${chunk.metadata.filePath}:${chunk.metadata.startLine}): ${err instanceof Error ? err.message : String(err)}`);
55
- }
56
+ const emitProgress = (chunk) => {
56
57
  completed++;
57
58
  opts?.onProgress?.(chunk, completed, opts.total ?? total);
59
+ };
60
+ // Group chunks so that up to batchMaxChunks share a single LLM request,
61
+ // cutting round trips (one prefill + one generation per group instead of
62
+ // per chunk). Groups with a single chunk use the individual path for
63
+ // consistent error handling.
64
+ const groups = [];
65
+ for (let i = 0; i < chunks.length; i += batchMaxChunks) {
66
+ groups.push(chunks.slice(i, i + batchMaxChunks));
67
+ }
68
+ await Promise.all(groups.map((group) => limit(async () => {
69
+ if (group.length === 1) {
70
+ const chunk = group[0];
71
+ try {
72
+ const desc = await this.generateDescription(chunk);
73
+ result.set(chunk.id, desc);
74
+ }
75
+ catch (err) {
76
+ log.warn(`[describer] Failed to describe chunk ${chunk.id} (${chunk.metadata.filePath}:${chunk.metadata.startLine}): ${err instanceof Error ? err.message : String(err)}`);
77
+ }
78
+ emitProgress(chunk);
79
+ return;
80
+ }
81
+ // Small models are unreliable at structured multi-item output — if a
82
+ // batch response can't be parsed, fall back to individual requests
83
+ // and degrade to per-chunk mode for the rest of the run after
84
+ // BATCH_MAX_STREAK consecutive failures.
85
+ let resolved;
86
+ let batchFailed = false;
87
+ if (this.adaptiveBatchActive) {
88
+ try {
89
+ resolved = await this.batchDescribe(group, log);
90
+ }
91
+ catch (err) {
92
+ log.warn(`[describer] Batch description failed for ${group.length} chunks, falling back to individual: ${err instanceof Error ? err.message : String(err)}`);
93
+ resolved = new Map();
94
+ batchFailed = true;
95
+ }
96
+ }
97
+ else {
98
+ resolved = new Map();
99
+ }
100
+ // Batch labels are ordinals (1..N); map back to chunks by position.
101
+ const missing = [];
102
+ for (let i = 0; i < group.length; i++) {
103
+ const chunk = group[i];
104
+ const desc = resolved.get(String(i + 1));
105
+ if (desc && desc.trim().length > 0) {
106
+ result.set(chunk.id, desc.trim());
107
+ emitProgress(chunk);
108
+ }
109
+ else {
110
+ missing.push(chunk);
111
+ }
112
+ }
113
+ if (batchFailed || missing.length > 0) {
114
+ this.batchFailStreak++;
115
+ if (this.adaptiveBatchActive && this.batchFailStreak >= BATCH_MAX_STREAK) {
116
+ this.adaptiveBatchActive = false;
117
+ log.warn(`[describer] Batch descriptions unreliable (${this.batchFailStreak} consecutive failures), switching to individual requests for the rest of the run`);
118
+ }
119
+ }
120
+ else {
121
+ this.batchFailStreak = 0;
122
+ }
123
+ // Fall back to individual requests for chunks the batch did not cover.
124
+ for (const chunk of missing) {
125
+ try {
126
+ const desc = await this.generateDescription(chunk);
127
+ result.set(chunk.id, desc);
128
+ }
129
+ catch (err) {
130
+ log.warn(`[describer] Failed to describe chunk ${chunk.id} (${chunk.metadata.filePath}:${chunk.metadata.startLine}): ${err instanceof Error ? err.message : String(err)}`);
131
+ }
132
+ emitProgress(chunk);
133
+ }
58
134
  })));
59
135
  log.debug(`[describer] Descriptions generated: ${result.size}/${total}`);
60
136
  return result;
61
137
  }
138
+ /**
139
+ * Describe a group of chunks in a single LLM request and parse the response.
140
+ *
141
+ * Builds one chat request whose user message contains all chunks wrapped in
142
+ * `[CHUNK <n>]` markers and expects a `<n>: <description>` line per chunk.
143
+ * Labels are ordinals and are mapped back to chunks by position in the
144
+ * calling group; labels outside the group's range (hallucinations) are
145
+ * naturally dropped, prompting the caller to fall back to individual
146
+ * requests for the missing chunks.
147
+ *
148
+ * @param group - Chunks to describe in one request (length > 1)
149
+ * @param log - Logger for diagnostic messages
150
+ * @returns Map of ordinal label to description (may be partial or empty)
151
+ * @throws When the LLM request itself fails (caller falls back per chunk)
152
+ */
153
+ async batchDescribe(group, log) {
154
+ const messages = [
155
+ { role: "system", content: this.config.systemPrompt },
156
+ { role: "user", content: buildBatchUserMessage(group, this.config.maxContentChars) },
157
+ ];
158
+ const timeoutMs = this.config.batchTimeoutMs ?? this.config.timeoutMs ?? 120000;
159
+ log.debug(`[describer] BATCH REQUEST ${group.length} chunks (${group[0].metadata.filePath}):\n${messages[1].content}`);
160
+ const content = await this.chatRequest(messages, timeoutMs);
161
+ const parsed = parseBatchDescriptions(content);
162
+ log.debug(`[describer] BATCH RESPONSE parsed ${parsed.size}/${group.length} chunks`);
163
+ return parsed;
164
+ }
62
165
  /**
63
166
  * Sends a chat completion request to the LLM API with retry and exponential backoff.
64
167
  * For Ollama, uses the `/api/chat` endpoint with streaming disabled; otherwise uses the standard `/v1/chat/completions` endpoint.
@@ -75,7 +178,7 @@ export class LlmDescriptionProvider {
75
178
  ? `${baseUrl}/chat`
76
179
  : `${baseUrl}${baseUrl.endsWith("/v1") ? "" : "/v1"}/chat/completions`;
77
180
  const body = isOllama
78
- ? { model: this.config.model, messages, stream: false, think: this.config.think ?? false, options: { num_ctx: this.config.numCtx } }
181
+ ? { model: this.config.model, messages, stream: false, think: this.config.think ?? false, options: { num_ctx: this.config.numCtx }, keep_alive: this.config.keepAlive }
79
182
  : { model: this.config.model, messages };
80
183
  const headers = {};
81
184
  if (this.config.apiKey) {
@@ -15,3 +15,31 @@ import type { Chunk } from "../core/interfaces.js";
15
15
  export declare function buildUserMessage(chunk: Chunk, maxContentChars?: number): string;
16
16
  /** Promise-based delay for use with async/await. */
17
17
  export declare function sleep(ms: number): Promise<void>;
18
+ /**
19
+ * Build a user message describing multiple chunks in a single LLM request.
20
+ *
21
+ * Each chunk is wrapped in `[CHUNK <n>] ... [END CHUNK <n>]` markers using
22
+ * short ordinal labels (1-based position in the group) rather than chunk
23
+ * UUIDs — small models reliably reproduce `n: <description>` lines but often
24
+ * mangle long random ids. The model is asked to reply with exactly one line
25
+ * per chunk in `<n>: <one-line description>` format (see
26
+ * `parseBatchDescriptions`); labels map back to chunks by position.
27
+ *
28
+ * @param chunks - The chunks to describe in one request
29
+ * @param maxContentChars - Optional per-chunk content truncation limit
30
+ * @returns Formatted message string for the batched LLM request
31
+ */
32
+ export declare function buildBatchUserMessage(chunks: Chunk[], maxContentChars?: number): string;
33
+ /**
34
+ * Parse a batched description response into a `label → description` map.
35
+ *
36
+ * Tolerant parser: only lines matching `<label>: <text>` are kept, so
37
+ * preamble, trailing prose, or markdown fences are ignored. Labels are the
38
+ * short ordinals used in `buildBatchUserMessage` (e.g. `1: handles auth`);
39
+ * they are mapped back to chunk IDs by position in the calling group.
40
+ * Duplicate labels keep the first occurrence.
41
+ *
42
+ * @param content - Raw response text from the LLM
43
+ * @returns Map of label to description (may be empty or partial)
44
+ */
45
+ export declare function parseBatchDescriptions(content: string): Map<string, string>;
@@ -31,4 +31,64 @@ export function buildUserMessage(chunk, maxContentChars) {
31
31
  export function sleep(ms) {
32
32
  return new Promise((resolve) => setTimeout(resolve, ms));
33
33
  }
34
+ /**
35
+ * Build a user message describing multiple chunks in a single LLM request.
36
+ *
37
+ * Each chunk is wrapped in `[CHUNK <n>] ... [END CHUNK <n>]` markers using
38
+ * short ordinal labels (1-based position in the group) rather than chunk
39
+ * UUIDs — small models reliably reproduce `n: <description>` lines but often
40
+ * mangle long random ids. The model is asked to reply with exactly one line
41
+ * per chunk in `<n>: <one-line description>` format (see
42
+ * `parseBatchDescriptions`); labels map back to chunks by position.
43
+ *
44
+ * @param chunks - The chunks to describe in one request
45
+ * @param maxContentChars - Optional per-chunk content truncation limit
46
+ * @returns Formatted message string for the batched LLM request
47
+ */
48
+ export function buildBatchUserMessage(chunks, maxContentChars) {
49
+ const parts = [
50
+ "Describe each code chunk below in ONE short sentence (max 20 words) each.",
51
+ "Do not repeat code in your descriptions.",
52
+ "Reply with EXACTLY one line per chunk, in this format:",
53
+ "<number>: <one-line description>",
54
+ "",
55
+ ];
56
+ chunks.forEach((chunk, index) => {
57
+ const label = index + 1;
58
+ parts.push(`[CHUNK ${label}]`);
59
+ parts.push(buildUserMessage(chunk, maxContentChars));
60
+ parts.push(`[END CHUNK ${label}]`);
61
+ parts.push("");
62
+ });
63
+ return parts.join("\n").trim();
64
+ }
65
+ /**
66
+ * Parse a batched description response into a `label → description` map.
67
+ *
68
+ * Tolerant parser: only lines matching `<label>: <text>` are kept, so
69
+ * preamble, trailing prose, or markdown fences are ignored. Labels are the
70
+ * short ordinals used in `buildBatchUserMessage` (e.g. `1: handles auth`);
71
+ * they are mapped back to chunk IDs by position in the calling group.
72
+ * Duplicate labels keep the first occurrence.
73
+ *
74
+ * @param content - Raw response text from the LLM
75
+ * @returns Map of label to description (may be empty or partial)
76
+ */
77
+ export function parseBatchDescriptions(content) {
78
+ const result = new Map();
79
+ const lineRe = /^([A-Za-z0-9_.-]+):\s*(.+)$/;
80
+ for (const rawLine of content.split(/\r?\n/)) {
81
+ const line = rawLine.trim();
82
+ if (!line)
83
+ continue;
84
+ const match = lineRe.exec(line);
85
+ if (match && match[1] && match[2] && match[2].trim().length > 0) {
86
+ const id = match[1];
87
+ if (!result.has(id)) {
88
+ result.set(id, match[2].trim());
89
+ }
90
+ }
91
+ }
92
+ return result;
93
+ }
34
94
  //# sourceMappingURL=shared.js.map
@@ -24,7 +24,7 @@ export function createEmbedder(config) {
24
24
  "Use an embedding-capable provider (ollama, openai, cohere, nvidia, azure, mistral, together, fireworks).");
25
25
  }
26
26
  if (provider === "ollama") {
27
- return new OllamaProvider(baseUrl, model, apiKey, effectiveTimeoutMs, proxy, config.logging.level);
27
+ return new OllamaProvider(baseUrl, model, apiKey, effectiveTimeoutMs, proxy, config.logging.level, config.embedding.keepAlive);
28
28
  }
29
29
  if (provider === "cohere") {
30
30
  if (!apiKey) {
@@ -12,6 +12,7 @@ import type { ProxyConfig } from "../core/config.js";
12
12
  * @param timeoutMs - Request timeout in milliseconds (default 120000)
13
13
  * @param proxy - Optional proxy configuration
14
14
  * @param logLevel - Optional logging level for debug output
15
+ * @param keepAlive - Optional Ollama keep_alive value sent with /api/embed requests (e.g. "-1")
15
16
  */
16
17
  export declare class OllamaProvider implements EmbeddingProvider {
17
18
  readonly name = "ollama";
@@ -21,7 +22,8 @@ export declare class OllamaProvider implements EmbeddingProvider {
21
22
  private readonly timeoutMs;
22
23
  private proxy?;
23
24
  private readonly logLevel?;
24
- constructor(baseUrl: string, model: string, apiKey?: string, timeoutMs?: number, proxy?: ProxyConfig, logLevel?: string);
25
+ private readonly keepAlive?;
26
+ constructor(baseUrl: string, model: string, apiKey?: string, timeoutMs?: number, proxy?: ProxyConfig, logLevel?: string, keepAlive?: string);
25
27
  private getLogFilePath;
26
28
  private debug;
27
29
  embed(texts: string[], _purpose?: "query" | "document"): Promise<number[][]>;
@@ -10,6 +10,7 @@ import { appendDebugLog } from "../core/fileLogger.js";
10
10
  * @param timeoutMs - Request timeout in milliseconds (default 120000)
11
11
  * @param proxy - Optional proxy configuration
12
12
  * @param logLevel - Optional logging level for debug output
13
+ * @param keepAlive - Optional Ollama keep_alive value sent with /api/embed requests (e.g. "-1")
13
14
  */
14
15
  export class OllamaProvider {
15
16
  name = "ollama";
@@ -19,13 +20,15 @@ export class OllamaProvider {
19
20
  timeoutMs;
20
21
  proxy;
21
22
  logLevel;
22
- constructor(baseUrl, model, apiKey, timeoutMs = 120000, proxy, logLevel) {
23
+ keepAlive;
24
+ constructor(baseUrl, model, apiKey, timeoutMs = 120000, proxy, logLevel, keepAlive) {
23
25
  this.baseUrl = baseUrl.replace(/\/+$/, "");
24
26
  this.model = model;
25
27
  this.apiKey = apiKey;
26
28
  this.timeoutMs = timeoutMs;
27
29
  this.proxy = proxy;
28
30
  this.logLevel = logLevel;
31
+ this.keepAlive = keepAlive;
29
32
  }
30
33
  getLogFilePath() {
31
34
  return path.resolve(process.cwd(), ".opencode", "opencode-rag.log");
@@ -44,7 +47,14 @@ export class OllamaProvider {
44
47
  }
45
48
  this.debug(`OllamaProvider requesting ${texts.length} embedding vector${texts.length === 1 ? "" : "s"}`);
46
49
  try {
47
- const response = await postJson(`${this.baseUrl}/embed`, { model: this.model, input: texts.length === 1 ? texts[0] : texts }, headers, this.timeoutMs, this.proxy);
50
+ const body = {
51
+ model: this.model,
52
+ input: texts.length === 1 ? texts[0] : texts,
53
+ };
54
+ if (this.keepAlive) {
55
+ body.keep_alive = this.keepAlive;
56
+ }
57
+ const response = await postJson(`${this.baseUrl}/embed`, body, headers, this.timeoutMs, this.proxy);
48
58
  if (!response.ok) {
49
59
  const body = await response.text();
50
60
  throw new Error(`Ollama embedding failed (${response.status}): ${body}`);