opencode-rag-plugin 1.19.4 → 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.
Files changed (87) 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/image.js +5 -0
  6. package/dist/chunker/pdf.js +30 -14
  7. package/dist/cli/commands/backend-detect.d.ts +61 -0
  8. package/dist/cli/commands/backend-detect.js +119 -0
  9. package/dist/cli/commands/index-command.js +11 -0
  10. package/dist/cli/commands/init-helpers.d.ts +4 -1
  11. package/dist/cli/commands/init-helpers.js +21 -4
  12. package/dist/cli/commands/init.js +61 -24
  13. package/dist/cli/commands/quirk.js +9 -3
  14. package/dist/cli/commands/setup.js +12 -3
  15. package/dist/cli/commands/status.js +14 -5
  16. package/dist/cli/commands/ui.js +23 -9
  17. package/dist/cli/commands/update.js +4 -5
  18. package/dist/cli/format.d.ts +5 -2
  19. package/dist/cli/format.js +14 -5
  20. package/dist/content/image.js +33 -11
  21. package/dist/content/reader.js +74 -13
  22. package/dist/core/bootstrap.js +10 -3
  23. package/dist/core/config.d.ts +27 -1
  24. package/dist/core/config.js +41 -2
  25. package/dist/core/desc-cache.d.ts +8 -2
  26. package/dist/core/desc-cache.js +10 -3
  27. package/dist/core/doc-progress.js +5 -2
  28. package/dist/core/interfaces.d.ts +32 -4
  29. package/dist/core/manifest.js +1 -1
  30. package/dist/core/provider-defaults.d.ts +2 -0
  31. package/dist/core/provider-defaults.js +19 -4
  32. package/dist/core/runtime-overrides.d.ts +0 -6
  33. package/dist/core/version-check.d.ts +5 -0
  34. package/dist/core/version-check.js +8 -2
  35. package/dist/describer/anthropic.d.ts +2 -2
  36. package/dist/describer/anthropic.js +19 -5
  37. package/dist/describer/describer.d.ts +20 -0
  38. package/dist/describer/describer.js +132 -16
  39. package/dist/describer/gemini.js +25 -10
  40. package/dist/describer/shared.d.ts +28 -0
  41. package/dist/describer/shared.js +60 -0
  42. package/dist/embedder/factory.d.ts +5 -3
  43. package/dist/embedder/factory.js +42 -9
  44. package/dist/embedder/health.js +19 -19
  45. package/dist/embedder/http.d.ts +14 -1
  46. package/dist/embedder/http.js +60 -6
  47. package/dist/embedder/ollama.d.ts +3 -1
  48. package/dist/embedder/ollama.js +12 -2
  49. package/dist/eval/session-logger.js +7 -0
  50. package/dist/eval/storage.js +8 -0
  51. package/dist/indexer/git-diff.d.ts +1 -1
  52. package/dist/indexer/git-diff.js +5 -1
  53. package/dist/indexer/pipeline.js +511 -346
  54. package/dist/indexer/stats.d.ts +2 -0
  55. package/dist/indexer/stats.js +1 -0
  56. package/dist/indexer/watch.js +8 -1
  57. package/dist/indexer/worker.js +21 -0
  58. package/dist/mcp/cli.js +4 -0
  59. package/dist/mcp/handlers.js +16 -5
  60. package/dist/mcp/server.js +3 -0
  61. package/dist/opencode/create-read-tool.js +14 -3
  62. package/dist/opencode/tool-args.js +23 -1
  63. package/dist/plugin.js +66 -152
  64. package/dist/quirks/auto-capture.js +5 -0
  65. package/dist/quirks/quirk-store.d.ts +1 -1
  66. package/dist/quirks/quirk-store.js +56 -17
  67. package/dist/retriever/context-optimizer.js +18 -4
  68. package/dist/retriever/keyword-index.d.ts +2 -0
  69. package/dist/retriever/keyword-index.js +38 -4
  70. package/dist/retriever/retriever.js +6 -1
  71. package/dist/tui.js +41 -4
  72. package/dist/vectorstore/lancedb.d.ts +104 -8
  73. package/dist/vectorstore/lancedb.js +345 -71
  74. package/dist/vectorstore/memory.d.ts +8 -3
  75. package/dist/vectorstore/memory.js +27 -4
  76. package/dist/watcher.d.ts +8 -0
  77. package/dist/watcher.js +237 -85
  78. package/dist/web/api.d.ts +5 -1
  79. package/dist/web/api.js +195 -69
  80. package/dist/web/server.d.ts +2 -0
  81. package/dist/web/server.js +66 -28
  82. package/dist/web/static.d.ts +5 -2
  83. package/dist/web/static.js +9 -5
  84. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  85. package/dist/web/ui/index.html +1 -1
  86. package/package.json +1 -1
  87. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
package/dist/watcher.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import chokidar from "chokidar";
7
7
  import path from "node:path";
8
- import { writeFileSync, unlinkSync, existsSync } from "node:fs";
8
+ import { writeFileSync, unlinkSync, existsSync, readFileSync, openSync, closeSync } from "node:fs";
9
9
  import { appendDebugLog } from "./core/fileLogger.js";
10
10
  import { isCorruptionError } from "./vectorstore/lancedb.js";
11
11
  import { createWatchPassScheduler, createWatchIgnore, runIndexPass, } from "./indexer.js";
@@ -18,6 +18,81 @@ function writeWatcherStatus(storePath, status) {
18
18
  // silently ignore write errors
19
19
  }
20
20
  }
21
+ // ── Cross-process watcher claim lock ────────────────────────────────────────
22
+ // `index.lock` only serializes individual index passes — it does NOT stop N
23
+ // processes (N OpenCode sessions, or a session + `opencode-rag index --watch`)
24
+ // from each spawning their own chokidar watcher for the same workspace. Every
25
+ // extra watcher fires its own fire-and-forget initial pass (all but one skip
26
+ // the pass lock and then retry every 30s) and burns file-watcher resources.
27
+ // The claim lock below guarantees at most ONE active watcher per workspace:
28
+ // the first process to claim it runs the watcher; the others stay dormant and
29
+ // periodically re-check so they take over if the owning process exits.
30
+ const WATCHER_LOCK_FILE = "watcher.lock";
31
+ /** How often a dormant indexer re-checks whether the watcher lock is free. */
32
+ const WATCHER_RECHECK_MS = 60_000;
33
+ function isPidAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function readWatcherLock(storePath) {
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path.join(storePath, WATCHER_LOCK_FILE), "utf-8"));
45
+ return typeof parsed.pid === "number" ? parsed : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Atomically claim the watcher lock for a workspace. Returns true only if
53
+ * this process is now the active watcher. A stale lock (dead PID) or an
54
+ * unreadable/corrupt lock file is cleared and re-claimed.
55
+ */
56
+ export function tryAcquireWatcherLock(storePath) {
57
+ const lockPath = path.join(storePath, WATCHER_LOCK_FILE);
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ const existing = readWatcherLock(storePath);
60
+ if (!existing || !isPidAlive(existing.pid)) {
61
+ // Stale, corrupt, or missing lock — clear it and claim atomically
62
+ // (O_EXCL create so two racing processes cannot both win).
63
+ try {
64
+ unlinkSync(lockPath);
65
+ }
66
+ catch { /* may not exist */ }
67
+ try {
68
+ const fd = openSync(lockPath, "wx");
69
+ try {
70
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), "utf-8");
71
+ }
72
+ finally {
73
+ closeSync(fd);
74
+ }
75
+ return true;
76
+ }
77
+ catch {
78
+ // Someone else claimed it between our unlink and create — retry once.
79
+ continue;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+ return false;
85
+ }
86
+ /** Release the watcher lock — only if this process owns it. */
87
+ export function releaseWatcherLock(storePath) {
88
+ const lock = readWatcherLock(storePath);
89
+ if (lock?.pid === process.pid) {
90
+ try {
91
+ unlinkSync(path.join(storePath, WATCHER_LOCK_FILE));
92
+ }
93
+ catch { /* ignore */ }
94
+ }
95
+ }
21
96
  /**
22
97
  * Create a background file watcher that automatically re-indexes the
23
98
  * workspace when files change. Uses chokidar for file system events and
@@ -29,98 +104,134 @@ function writeWatcherStatus(storePath, status) {
29
104
  */
30
105
  export function createBackgroundIndexer(options) {
31
106
  const { cwd, storePath, config, store, embedder, logFilePath, logLevel, keywordIndex, descriptionProvider, dimension } = options;
32
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
33
- const ac = new AbortController();
34
- const updateStatus = (partial) => {
35
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
36
- };
37
- const runPass = async (filterPaths) => {
38
- updateStatus({ running: true, lastRunAt: Date.now() });
39
- try {
40
- await runIndexPass({
41
- cwd,
42
- storePath,
43
- config,
44
- store,
45
- embedder,
46
- keywordIndex,
47
- descriptionProvider,
48
- dimension,
49
- filterPaths,
50
- abortSignal: ac.signal,
51
- logger: {
52
- info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
53
- warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
54
- debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
55
- },
56
- });
57
- updateStatus({ running: false, lastRunAt: Date.now() });
58
- }
59
- catch (err) {
60
- appendDebugLog(logFilePath, {
61
- scope: "autoIndex",
62
- message: "Watch reindex pass failed",
63
- error: err,
64
- }, logLevel);
65
- if (isCorruptionError(err)) {
107
+ const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
108
+ let active = false;
109
+ let recheckTimer;
110
+ let stopActive;
111
+ /** Start the chokidar watcher, debounced scheduler, and initial pass. */
112
+ const startActive = () => {
113
+ if (active)
114
+ return;
115
+ active = true;
116
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
117
+ const ac = new AbortController();
118
+ const updateStatus = (partial) => {
119
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
120
+ };
121
+ const runPass = async (filterPaths) => {
122
+ updateStatus({ running: true, lastRunAt: Date.now() });
123
+ try {
124
+ const stats = await runIndexPass({
125
+ cwd,
126
+ storePath,
127
+ config,
128
+ store,
129
+ embedder,
130
+ keywordIndex,
131
+ descriptionProvider,
132
+ dimension,
133
+ filterPaths,
134
+ abortSignal: ac.signal,
135
+ logger: {
136
+ info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
137
+ warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
138
+ debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
139
+ },
140
+ });
141
+ // A lock-skipped pass did NO work — retry shortly so the workspace
142
+ // does not stay unindexed until the next file event.
143
+ if (stats.skipped) {
144
+ appendDebugLog(logFilePath, {
145
+ scope: "autoIndex",
146
+ message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
147
+ }, logLevel);
148
+ if (!ac.signal.aborted) {
149
+ setTimeout(() => {
150
+ if (!ac.signal.aborted)
151
+ scheduler.notifyChange(filterPaths);
152
+ }, 30_000).unref();
153
+ }
154
+ }
155
+ updateStatus({ running: false, lastRunAt: Date.now() });
156
+ }
157
+ catch (err) {
66
158
  appendDebugLog(logFilePath, {
67
159
  scope: "autoIndex",
68
- message: "Corruption detected run 'opencode-rag index --force' to rebuild manually",
160
+ message: "Watch reindex pass failed",
161
+ error: err,
69
162
  }, logLevel);
163
+ if (isCorruptionError(err)) {
164
+ appendDebugLog(logFilePath, {
165
+ scope: "autoIndex",
166
+ message: "Corruption detected — run 'opencode-rag index --force' to rebuild manually",
167
+ }, logLevel);
168
+ }
169
+ updateStatus({ running: false, lastRunAt: Date.now() });
70
170
  }
71
- updateStatus({ running: false, lastRunAt: Date.now() });
72
- }
73
- };
74
- // Fire-and-forget initial index pass
75
- runPass().catch((err) => {
76
- appendDebugLog(logFilePath, {
77
- scope: "autoIndex",
78
- message: "Initial index pass failed",
79
- error: err,
80
- }, logLevel);
81
- });
82
- const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
83
- const scheduler = createWatchPassScheduler(runPass, (error) => {
84
- const message = error.message || String(error);
85
- appendDebugLog(logFilePath, {
86
- scope: "autoIndex",
87
- message: `Watch reindex failed: ${message}`,
88
- error,
89
- }, logLevel);
90
- }, autoIndexCfg.debounceMs);
91
- const watcher = chokidar.watch(cwd, {
92
- ignored: createWatchIgnore(cwd, config, storePath),
93
- ignoreInitial: true,
94
- persistent: true,
95
- });
96
- const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
97
- watcher.on("add", handleChange);
98
- watcher.on("change", handleChange);
99
- watcher.on("unlink", handleChange);
100
- watcher.on("unlinkDir", handleChange);
101
- watcher.on("addDir", handleChange);
102
- watcher.on("error", (error) => {
103
- appendDebugLog(logFilePath, {
104
- scope: "autoIndex",
105
- message: `Watcher error: ${error.message}`,
106
- error,
107
- }, logLevel);
108
- });
109
- // Periodic timer: only needed for git backend (chokidar gets real FS events)
110
- const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
111
- const periodicTimer = watcherBackend === "git"
112
- ? setInterval(() => {
113
- scheduler.notifyChange();
114
- }, autoIndexCfg.intervalMs)
115
- : undefined;
116
- return {
117
- async close() {
171
+ };
172
+ // Fire-and-forget initial index pass
173
+ runPass().catch((err) => {
174
+ appendDebugLog(logFilePath, {
175
+ scope: "autoIndex",
176
+ message: "Initial index pass failed",
177
+ error: err,
178
+ }, logLevel);
179
+ });
180
+ const scheduler = createWatchPassScheduler(runPass, (error) => {
181
+ const message = error.message || String(error);
182
+ appendDebugLog(logFilePath, {
183
+ scope: "autoIndex",
184
+ message: `Watch reindex failed: ${message}`,
185
+ error,
186
+ }, logLevel);
187
+ }, autoIndexCfg.debounceMs);
188
+ const watcher = chokidar.watch(cwd, {
189
+ ignored: createWatchIgnore(cwd, config, storePath),
190
+ ignoreInitial: true,
191
+ persistent: true,
192
+ });
193
+ const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
194
+ watcher.on("add", handleChange);
195
+ watcher.on("change", handleChange);
196
+ watcher.on("unlink", handleChange);
197
+ watcher.on("unlinkDir", handleChange);
198
+ watcher.on("addDir", handleChange);
199
+ watcher.on("error", (error) => {
200
+ appendDebugLog(logFilePath, {
201
+ scope: "autoIndex",
202
+ message: `Watcher error: ${error.message}`,
203
+ error,
204
+ }, logLevel);
205
+ });
206
+ // Periodic timer: only needed for git backend (chokidar gets real FS events).
207
+ // Note: with git mode BOTH backends run — the scheduler coalesces redundant
208
+ // passes into one, so this only adds a safety net for missed events.
209
+ const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
210
+ const periodicTimer = watcherBackend === "git"
211
+ ? setInterval(() => {
212
+ scheduler.notifyChange();
213
+ }, autoIndexCfg.intervalMs)
214
+ : undefined;
215
+ // Never keep the process alive just for the periodic scan
216
+ periodicTimer?.unref();
217
+ stopActive = async () => {
218
+ if (!active)
219
+ return;
220
+ active = false;
118
221
  if (periodicTimer)
119
222
  clearInterval(periodicTimer);
120
223
  ac.abort();
121
224
  scheduler.close();
122
- await scheduler.waitForIdle();
123
- await watcher.close();
225
+ // chokidar close() can hang on some Windows setups — guard it like
226
+ // the CLI does.
227
+ await Promise.race([
228
+ scheduler.waitForIdle(),
229
+ new Promise((resolve) => setTimeout(resolve, 5000).unref()),
230
+ ]);
231
+ await Promise.race([
232
+ watcher.close(),
233
+ new Promise((resolve) => setTimeout(resolve, 5000).unref()),
234
+ ]);
124
235
  const statusPath = path.join(storePath, "watcher-status.json");
125
236
  if (existsSync(statusPath)) {
126
237
  try {
@@ -128,10 +239,51 @@ export function createBackgroundIndexer(options) {
128
239
  }
129
240
  catch { /* ignore */ }
130
241
  }
242
+ releaseWatcherLock(storePath);
131
243
  appendDebugLog(logFilePath, {
132
244
  scope: "autoIndex",
133
245
  message: "Background indexer shut down",
134
246
  });
247
+ };
248
+ };
249
+ if (tryAcquireWatcherLock(storePath)) {
250
+ startActive();
251
+ }
252
+ else {
253
+ // Another process already runs the watcher for this workspace (a second
254
+ // OpenCode session, `opencode-rag index --watch`, …). Stay dormant and
255
+ // re-check periodically so this process takes over once the owner exits.
256
+ const owner = readWatcherLock(storePath);
257
+ appendDebugLog(logFilePath, {
258
+ scope: "autoIndex",
259
+ message: owner?.pid
260
+ ? `Watcher already running for this workspace (PID ${owner.pid}) — skipping duplicate watcher`
261
+ : "Watcher already running for this workspace — skipping duplicate watcher",
262
+ }, logLevel);
263
+ recheckTimer = setInterval(() => {
264
+ if (!active && tryAcquireWatcherLock(storePath)) {
265
+ appendDebugLog(logFilePath, {
266
+ scope: "autoIndex",
267
+ message: "Previous watcher released this workspace — taking over",
268
+ }, logLevel);
269
+ if (recheckTimer) {
270
+ clearInterval(recheckTimer);
271
+ recheckTimer = undefined;
272
+ }
273
+ startActive();
274
+ }
275
+ }, WATCHER_RECHECK_MS);
276
+ recheckTimer.unref();
277
+ }
278
+ return {
279
+ async close() {
280
+ if (recheckTimer) {
281
+ clearInterval(recheckTimer);
282
+ recheckTimer = undefined;
283
+ }
284
+ if (stopActive) {
285
+ await stopActive();
286
+ }
135
287
  },
136
288
  };
137
289
  }
package/dist/web/api.d.ts CHANGED
@@ -28,7 +28,7 @@ interface ApiResponse {
28
28
  * @param cfg - Active RAG configuration (used by quirk endpoints).
29
29
  * @returns An async handler that returns `true` when a route matched or `false` otherwise.
30
30
  */
31
- export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig, getEmbedder?: () => Promise<EmbeddingProvider>): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
31
+ export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig, getEmbedder?: () => Promise<EmbeddingProvider>, token?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
32
32
  /**
33
33
  * Perform token-usage analysis for a single evaluation session.
34
34
  *
@@ -52,4 +52,8 @@ export declare function handleEvalTokenCompare(storePath: string, params: URLSea
52
52
  * `avgReadsPerQueryWithRAG`, `queryCount`.
53
53
  */
54
54
  export declare function handleEvalProjectSavings(body: unknown): ApiResponse;
55
+ /** Thrown when the request body exceeds {@link MAX_BODY_BYTES}; mapped to a 413 response. */
56
+ export declare class BodyTooLargeError extends Error {
57
+ constructor();
58
+ }
55
59
  export {};