opencode-rag-plugin 1.18.1 → 1.19.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 +7 -4
- package/dist/cli/commands/query.js +1 -1
- package/dist/cli/commands/quirk.js +2 -2
- package/dist/content/reader.d.ts +2 -1
- package/dist/content/reader.js +9 -15
- package/dist/core/config.d.ts +34 -2
- package/dist/core/config.js +28 -0
- package/dist/core/exclude.d.ts +4 -0
- package/dist/core/exclude.js +45 -0
- package/dist/core/interfaces.d.ts +6 -0
- package/dist/core/runtime-overrides.d.ts +4 -1
- package/dist/core/runtime-overrides.js +6 -0
- package/dist/indexer/pipeline.js +21 -0
- package/dist/indexer/watch.js +4 -3
- package/dist/indexer.d.ts +1 -0
- package/dist/indexer.js +1 -0
- package/dist/plugin.js +113 -36
- package/dist/quirks/prompts.js +1 -1
- package/dist/quirks/quirk-store.d.ts +17 -0
- package/dist/quirks/quirk-store.js +32 -5
- package/dist/tui.js +134 -1
- package/dist/vectorstore/lancedb.d.ts +33 -0
- package/dist/vectorstore/lancedb.js +169 -80
- package/dist/web/api.d.ts +2 -1
- package/dist/web/api.js +219 -3
- package/dist/web/pca.d.ts +7 -0
- package/dist/web/pca.js +87 -0
- package/dist/web/server.js +23 -8
- package/dist/web/static.d.ts +7 -2
- package/dist/web/static.js +31 -7
- package/dist/web/ui/assets/index-CJBvt6e0.js +3 -0
- package/dist/web/ui/assets/index-CKdp79Tw.css +1 -0
- package/dist/web/ui/assets/vendor-Dy7HKFCY.js +1 -0
- package/dist/web/ui/index.html +9 -1632
- package/package.json +10 -4
- package/dist/web/ui/app.css +0 -1
- package/dist/web/ui/github-dark.css +0 -118
- package/dist/web/ui/highlight.min.js +0 -1213
package/ReadMe.md
CHANGED
|
@@ -128,7 +128,7 @@ See [Plugin documentation](doc/plugin.md#6-wiki-mode--slash-command-wiki) for th
|
|
|
128
128
|
|
|
129
129
|
## Quirk Memory
|
|
130
130
|
|
|
131
|
-
OpenCodeRAG gives your agent **persistent, cross-session memory** of non-obvious facts — gotchas, preferences, decisions, and environment constraints — that it can recall and extend over time. Quirks are embedded and stored in the same vector store as your code, then
|
|
131
|
+
OpenCodeRAG gives your agent **persistent, cross-session memory** of non-obvious facts — gotchas, preferences, decisions, and environment constraints — that it can recall and extend over time. Quirks are embedded and stored in the same vector store as your code, then **automatically injected into the agent's context on every user message** when their relevance score exceeds the configured threshold. Injection uses two thresholds: the stricter `recallMinScore` (0.72) for the user message context, and the more permissive `autoInjectMinScore` (0.45) for the system prompt. This lets the agent avoid repeating mistakes and accumulate project knowledge across sessions without manual recall.
|
|
132
132
|
|
|
133
133
|
**Enabled by default.** Turn it on/off in `opencode-rag.json`:
|
|
134
134
|
|
|
@@ -138,8 +138,11 @@ OpenCodeRAG gives your agent **persistent, cross-session memory** of non-obvious
|
|
|
138
138
|
"enabled": true,
|
|
139
139
|
"autoInject": true,
|
|
140
140
|
"minConfidence": 0.5,
|
|
141
|
-
"recallMinScore": 0.
|
|
142
|
-
"
|
|
141
|
+
"recallMinScore": 0.8,
|
|
142
|
+
"autoInjectMinScore": 0.6,
|
|
143
|
+
"autoInjectTopK": 2,
|
|
144
|
+
"autoInjectLatencyBudgetMs": 2000,
|
|
145
|
+
"decay": { "enabled": false, "halfLifeDays": 30 }
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
```
|
|
@@ -163,7 +166,7 @@ opencode-rag quirk test "npm needs --legacy-peer-deps"
|
|
|
163
166
|
# 99% confidence
|
|
164
167
|
```
|
|
165
168
|
|
|
166
|
-
When `memory.autoInject` is `true`, relevant quirks are
|
|
169
|
+
When `memory.autoInject` is `true`, the plugin checks for relevant quirks on every user message using the combined agent-response + user-query as the search query. Quirks are only injected when their relevance score exceeds the threshold — `recallMinScore` (default 0.72) for the user message, `autoInjectMinScore` (default 0.45) for the system prompt. A latency budget (`autoInjectLatencyBudgetMs`, default 2000ms) prevents slow embedders from blocking message processing. To avoid polluting the context window, each quirk is injected **at most once per session** — once recalled, it is filtered out from all subsequent auto-injections. Every `add_quirk` is vetted by an immutable trust monitor that rejects destructive patterns (e.g. `rm -rf`, `force push`, `bypass security`). See [Plugin documentation](doc/plugin.md#9-quirk-memory-experiential-memory) and [CLI Reference: `quirk`](doc/cli.md#quirk).
|
|
167
170
|
|
|
168
171
|
## MCP Server (Optional)
|
|
169
172
|
|
|
@@ -74,7 +74,7 @@ export function registerQueryCommand(program) {
|
|
|
74
74
|
logCliInfo(logFilePath, "query", ` ${c.label(" Matched:")} ${c.lang(r.explanation.matchedTerms.join(", "))}`);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
logCliInfo(logFilePath, "query", ` ${pc.dim(r.chunk.content.
|
|
77
|
+
logCliInfo(logFilePath, "query", ` ${pc.dim(r.chunk.content.replace(/\n/g, "\n "))}`);
|
|
78
78
|
}
|
|
79
79
|
await cleanupContext(ctx);
|
|
80
80
|
}
|
|
@@ -51,7 +51,7 @@ export function registerQuirkCommand(program) {
|
|
|
51
51
|
const badge = q.quirkType ? `[${q.quirkType}] ` : "";
|
|
52
52
|
const tags = q.tags.length > 0 ? ` (${q.tags.join(", ")})` : "";
|
|
53
53
|
const date = q.lastObserved ? new Date(q.lastObserved).toLocaleDateString() : "";
|
|
54
|
-
logCliInfo(ctx.logFilePath, "quirk list", ` ${c.value(badge)}${c.file(q.content
|
|
54
|
+
logCliInfo(ctx.logFilePath, "quirk list", ` ${c.value(badge)}${c.file(q.content)}${c.dim(tags)} ${c.dim(date)}`);
|
|
55
55
|
}
|
|
56
56
|
await cleanupContext(ctx);
|
|
57
57
|
}
|
|
@@ -119,7 +119,7 @@ export function registerQuirkCommand(program) {
|
|
|
119
119
|
const badge = r.chunk.metadata.quirkType ? `[${r.chunk.metadata.quirkType}] ` : "";
|
|
120
120
|
const tags = r.chunk.metadata.tags?.length ? ` (${r.chunk.metadata.tags.join(", ")})` : "";
|
|
121
121
|
const confidence = r.chunk.metadata.confidence ? `${(r.chunk.metadata.confidence * 100).toFixed(0)}% confidence` : "";
|
|
122
|
-
logCliInfo(ctx.logFilePath, "quirk test", ` ${c.value(badge)}${c.file(r.chunk.content
|
|
122
|
+
logCliInfo(ctx.logFilePath, "quirk test", ` ${c.value(badge)}${c.file(r.chunk.content)}${c.dim(tags)}`);
|
|
123
123
|
logCliInfo(ctx.logFilePath, "quirk test", ` ${c.dim(confidence)}`);
|
|
124
124
|
logCliInfo(ctx.logFilePath, "quirk test", "");
|
|
125
125
|
}
|
package/dist/content/reader.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { RagConfig } from "../core/config.js";
|
|
5
5
|
import { type FileManifest } from "../core/manifest.js";
|
|
6
|
+
import { type ExcludeMatcher } from "../core/exclude.js";
|
|
6
7
|
import { DescriptionCache } from "../core/desc-cache.js";
|
|
7
8
|
import { type ImageVisionProvider } from "../chunker/image.js";
|
|
8
9
|
/** Metadata and extracted content for a single workspace file discovered during scanning. */
|
|
@@ -27,7 +28,7 @@ interface Logger {
|
|
|
27
28
|
* Recursively walk a directory tree and collect paths matching the given extension set,
|
|
28
29
|
* respecting exclusion lists and configurable limits for max directories and results.
|
|
29
30
|
*/
|
|
30
|
-
export declare function walkFiles(dir: string, extensions: Set<string>, excludeDirs:
|
|
31
|
+
export declare function walkFiles(dir: string, extensions: Set<string>, excludeDirs: ExcludeMatcher, excludeFiles?: ExcludeMatcher, rootDir?: string, logger?: Logger, dirCount?: {
|
|
31
32
|
value: number;
|
|
32
33
|
}, maxDirs?: number, maxResults?: number): Promise<string[]>;
|
|
33
34
|
/**
|
package/dist/content/reader.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from "node:fs/promises";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import pLimit from "p-limit";
|
|
7
7
|
import { computeFileHash, computeDescriptionConfigHash, normalizeFilePath } from "../core/manifest.js";
|
|
8
|
+
import { createExcludeMatcher } from "../core/exclude.js";
|
|
8
9
|
import { DescriptionCache } from "../core/desc-cache.js";
|
|
9
10
|
import { createImageVisionProvider, } from "../chunker/image.js";
|
|
10
11
|
import * as pdfExtractor from "./pdf.js";
|
|
@@ -16,15 +17,13 @@ import * as imageExtractor from "./image.js";
|
|
|
16
17
|
* Recursively walk a directory tree and collect paths matching the given extension set,
|
|
17
18
|
* respecting exclusion lists and configurable limits for max directories and results.
|
|
18
19
|
*/
|
|
19
|
-
export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, logger, dirCount, maxDirs = 10_000, maxResults = 100_000) {
|
|
20
|
+
export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, rootDir = dir, logger, dirCount, maxDirs = 10_000, maxResults = 100_000) {
|
|
20
21
|
const results = [];
|
|
21
22
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
22
23
|
for (const entry of entries) {
|
|
23
24
|
const fullPath = path.join(dir, entry.name);
|
|
24
25
|
if (entry.isDirectory()) {
|
|
25
|
-
if (excludeDirs.
|
|
26
|
-
continue;
|
|
27
|
-
if (entry.name.startsWith("."))
|
|
26
|
+
if (excludeDirs.excluded(path.relative(rootDir, fullPath)))
|
|
28
27
|
continue;
|
|
29
28
|
if (dirCount) {
|
|
30
29
|
dirCount.value++;
|
|
@@ -40,7 +39,7 @@ export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, logg
|
|
|
40
39
|
logger?.warn(`Exceeded ${maxResults} matching files — truncating walk at ${fullPath}`);
|
|
41
40
|
return results;
|
|
42
41
|
}
|
|
43
|
-
results.push(...(await walkFiles(fullPath, extensions, excludeDirs, excludeFiles, logger, dirCount, maxDirs, maxResults)));
|
|
42
|
+
results.push(...(await walkFiles(fullPath, extensions, excludeDirs, excludeFiles, rootDir, logger, dirCount, maxDirs, maxResults)));
|
|
44
43
|
}
|
|
45
44
|
else if (entry.isFile()) {
|
|
46
45
|
if (results.length >= maxResults) {
|
|
@@ -49,7 +48,7 @@ export async function walkFiles(dir, extensions, excludeDirs, excludeFiles, logg
|
|
|
49
48
|
}
|
|
50
49
|
const ext = path.extname(entry.name).toLowerCase();
|
|
51
50
|
const basename = entry.name.toLowerCase();
|
|
52
|
-
if ((extensions.has(ext) || extensions.has(basename)) && !excludeFiles?.
|
|
51
|
+
if ((extensions.has(ext) || extensions.has(basename)) && !excludeFiles?.excluded(path.relative(rootDir, fullPath))) {
|
|
53
52
|
results.push(fullPath);
|
|
54
53
|
}
|
|
55
54
|
}
|
|
@@ -103,10 +102,10 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
|
|
|
103
102
|
imagePrompt = imageCfg.prompt;
|
|
104
103
|
imageResizeMaxDimension = imageCfg.resizeMaxDimension;
|
|
105
104
|
}
|
|
105
|
+
const excludeDirMatcher = createExcludeMatcher(config.indexing.excludeDirs);
|
|
106
|
+
const excludeFileMatcher = createExcludeMatcher(config.indexing.excludeFiles ?? []);
|
|
106
107
|
let files;
|
|
107
108
|
if (filterPaths && filterPaths.length > 0) {
|
|
108
|
-
const excludeDirs = new Set(config.indexing.excludeDirs);
|
|
109
|
-
const excludeFiles = new Set(config.indexing.excludeFiles?.map((f) => f.toLowerCase()) ?? []);
|
|
110
109
|
files = filterPaths
|
|
111
110
|
.map((p) => path.resolve(cwd, p))
|
|
112
111
|
.filter((fp) => {
|
|
@@ -114,22 +113,17 @@ export async function scanWorkspaceFiles(cwd, config, logger, manifest, filterPa
|
|
|
114
113
|
const basename = path.basename(fp).toLowerCase();
|
|
115
114
|
if (!extensions.has(ext) && !extensions.has(basename))
|
|
116
115
|
return false;
|
|
117
|
-
if (excludeFiles.has(basename))
|
|
118
|
-
return false;
|
|
119
116
|
const rel = path.relative(cwd, fp);
|
|
120
117
|
if (rel.startsWith(".."))
|
|
121
118
|
return false;
|
|
122
|
-
|
|
123
|
-
if (parts.some((part) => excludeDirs.has(part)))
|
|
124
|
-
return false;
|
|
125
|
-
return true;
|
|
119
|
+
return !excludeDirMatcher.excluded(rel) && !excludeFileMatcher.excluded(rel);
|
|
126
120
|
});
|
|
127
121
|
}
|
|
128
122
|
else {
|
|
129
123
|
logger?.info("Walking directory tree...");
|
|
130
124
|
const walkStart = Date.now();
|
|
131
125
|
const dirCount = { value: 0 };
|
|
132
|
-
files = await walkFiles(cwd, extensions,
|
|
126
|
+
files = await walkFiles(cwd, extensions, excludeDirMatcher, excludeFileMatcher, cwd, logger, dirCount);
|
|
133
127
|
const walkSec = ((Date.now() - walkStart) / 1000).toFixed(1);
|
|
134
128
|
logger?.info(`Found ${files.length} matching files in ${walkSec}s (${dirCount.value} dirs traversed)`);
|
|
135
129
|
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -131,6 +131,19 @@ export interface MemoryConfig {
|
|
|
131
131
|
minConfidence: number;
|
|
132
132
|
/** Minimum query-relevance score (0-1) for a quirk to be recalled. */
|
|
133
133
|
recallMinScore: number;
|
|
134
|
+
/** Minimum relevance score for auto-injected quirks (lower than recallMinScore for manual calls). */
|
|
135
|
+
autoInjectMinScore: number;
|
|
136
|
+
/** Maximum latency budget (ms) for auto-inject quirk recall. If exceeded, injection is skipped. */
|
|
137
|
+
autoInjectLatencyBudgetMs: number;
|
|
138
|
+
/** Max quirks to auto-inject per turn (default 2). */
|
|
139
|
+
autoInjectTopK: number;
|
|
140
|
+
/**
|
|
141
|
+
* Minimum number of shared word tokens (≥3 chars) between a candidate quirk's
|
|
142
|
+
* content and the user's *current* message for the quirk to be auto-injected
|
|
143
|
+
* (default 1). Acts as a relevance gate against meta-quirks that match only
|
|
144
|
+
* the prior assistant text in the combined recall query. Set to `0` to disable.
|
|
145
|
+
*/
|
|
146
|
+
autoInjectMinTokenOverlap: number;
|
|
134
147
|
/** Automatically extract quirks from each completed agent turn. */
|
|
135
148
|
passiveCapture: boolean;
|
|
136
149
|
/** Upgrade the system-prompt nudge into a mandatory trigger. */
|
|
@@ -212,9 +225,28 @@ export interface RagConfig {
|
|
|
212
225
|
indexing: {
|
|
213
226
|
/** File extensions to include in indexing. */
|
|
214
227
|
includeExtensions: string[];
|
|
215
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Directory/file name patterns to exclude.
|
|
230
|
+
*
|
|
231
|
+
* Semantics (same for `excludeFiles`):
|
|
232
|
+
* - Plain name (no path separator, no glob chars `* ? [ { (`):
|
|
233
|
+
* matches that **basename** at **any depth** (case-insensitive).
|
|
234
|
+
* - Basename glob (glob chars, no separator):
|
|
235
|
+
* matches the segment at any depth via glob.
|
|
236
|
+
* - Path pattern (contains a separator or `**`):
|
|
237
|
+
* **anchored to the workspace root** — matched as a glob against each
|
|
238
|
+
* ancestor prefix (so it excludes the matched directory and all contents).
|
|
239
|
+
*
|
|
240
|
+
* Supports standard wildcards: `*`, `?`, `[...]`, `{a,b}`, `(pattern)`.
|
|
241
|
+
* Use `/` as path separator (converted automatically). Matching is
|
|
242
|
+
* case-insensitive and dot-inclusive.
|
|
243
|
+
*/
|
|
216
244
|
excludeDirs: string[];
|
|
217
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* File-name patterns to exclude (same semantics as `excludeDirs`).
|
|
247
|
+
* Plain names match any file with that basename at any depth; path
|
|
248
|
+
* patterns are anchored to the workspace root.
|
|
249
|
+
*/
|
|
218
250
|
excludeFiles?: string[];
|
|
219
251
|
/** Number of overlapping lines between adjacent chunks. */
|
|
220
252
|
chunkOverlap: number;
|
package/dist/core/config.js
CHANGED
|
@@ -95,6 +95,17 @@ export const DEFAULT_CONFIG = {
|
|
|
95
95
|
".commandcode",
|
|
96
96
|
".agents",
|
|
97
97
|
"graphify-out",
|
|
98
|
+
".vscode",
|
|
99
|
+
".vs",
|
|
100
|
+
".idea",
|
|
101
|
+
".next",
|
|
102
|
+
".nuxt",
|
|
103
|
+
".turbo",
|
|
104
|
+
".angular",
|
|
105
|
+
".svelte-kit",
|
|
106
|
+
".gradle",
|
|
107
|
+
".dart_tool",
|
|
108
|
+
".cache",
|
|
98
109
|
],
|
|
99
110
|
excludeFiles: [
|
|
100
111
|
"package-lock.json",
|
|
@@ -258,6 +269,10 @@ export const DEFAULT_CONFIG = {
|
|
|
258
269
|
autoInject: false,
|
|
259
270
|
minConfidence: 0.5,
|
|
260
271
|
recallMinScore: 0.72,
|
|
272
|
+
autoInjectMinScore: 0.6,
|
|
273
|
+
autoInjectLatencyBudgetMs: 2000,
|
|
274
|
+
autoInjectTopK: 2,
|
|
275
|
+
autoInjectMinTokenOverlap: 1,
|
|
261
276
|
passiveCapture: false,
|
|
262
277
|
promptEnforcement: true,
|
|
263
278
|
sessionEndExtraction: true,
|
|
@@ -350,6 +365,19 @@ export function validateConfig(config) {
|
|
|
350
365
|
if (r < 0 || r > 1)
|
|
351
366
|
warnings.push("memory.recallMinScore must be between 0 and 1");
|
|
352
367
|
}
|
|
368
|
+
if (config.memory?.autoInjectMinScore != null) {
|
|
369
|
+
const r = config.memory.autoInjectMinScore;
|
|
370
|
+
if (r < 0 || r > 1)
|
|
371
|
+
warnings.push("memory.autoInjectMinScore must be between 0 and 1");
|
|
372
|
+
}
|
|
373
|
+
if (config.memory?.autoInjectLatencyBudgetMs != null) {
|
|
374
|
+
const r = config.memory.autoInjectLatencyBudgetMs;
|
|
375
|
+
if (r < 0)
|
|
376
|
+
warnings.push("memory.autoInjectLatencyBudgetMs must be >= 0");
|
|
377
|
+
}
|
|
378
|
+
if (config.memory?.autoInjectTopK != null && config.memory.autoInjectTopK < 1) {
|
|
379
|
+
warnings.push("memory.autoInjectTopK must be >= 1");
|
|
380
|
+
}
|
|
353
381
|
if (config.openCode.maxContextChunks <= 0) {
|
|
354
382
|
warnings.push("openCode.maxContextChunks must be > 0");
|
|
355
383
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Minimatch } from "minimatch";
|
|
2
|
+
const GLOB_MAGIC = /[*?{}()\[\]]/;
|
|
3
|
+
export function createExcludeMatcher(patterns) {
|
|
4
|
+
const plainBasenames = new Set();
|
|
5
|
+
const basenameGlobMatchers = [];
|
|
6
|
+
const pathMatchers = [];
|
|
7
|
+
for (const pattern of patterns) {
|
|
8
|
+
const pat = pattern.trim().replace(/\\/g, "/");
|
|
9
|
+
if (!pat)
|
|
10
|
+
continue;
|
|
11
|
+
const hasSep = pat.includes("/");
|
|
12
|
+
const isGlob = GLOB_MAGIC.test(pat);
|
|
13
|
+
if (!hasSep && !isGlob) {
|
|
14
|
+
plainBasenames.add(pat.toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
else if (!hasSep && isGlob) {
|
|
17
|
+
basenameGlobMatchers.push(new Minimatch(pat, { nocase: true, dot: true }));
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
pathMatchers.push(new Minimatch(pat, { nocase: true, dot: true }));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function excluded(relPath) {
|
|
24
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
25
|
+
const segments = normalized.split("/");
|
|
26
|
+
let prefix = "";
|
|
27
|
+
for (const seg of segments) {
|
|
28
|
+
const segLower = seg.toLowerCase();
|
|
29
|
+
prefix = prefix ? `${prefix}/${seg}` : seg;
|
|
30
|
+
if (plainBasenames.has(segLower))
|
|
31
|
+
return true;
|
|
32
|
+
for (const mm of basenameGlobMatchers) {
|
|
33
|
+
if (mm.match(seg))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
for (const mm of pathMatchers) {
|
|
37
|
+
if (mm.match(prefix))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return { excluded };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=exclude.js.map
|
|
@@ -185,6 +185,12 @@ export interface VectorStore {
|
|
|
185
185
|
reopen?(newPath?: string): Promise<void>;
|
|
186
186
|
/** Compact fragments and prune old versions to prevent version-manifest accumulation. */
|
|
187
187
|
optimize?(): Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* Verify that the store's data is actually readable.
|
|
190
|
+
* Returns false if data integrity is compromised (e.g., data files missing from disk).
|
|
191
|
+
* Used by the index pipeline to detect silent corruption before scanning.
|
|
192
|
+
*/
|
|
193
|
+
checkIntegrity?(): Promise<boolean>;
|
|
188
194
|
}
|
|
189
195
|
/** Filter criteria for narrowing search results by file path, language, or kind. */
|
|
190
196
|
export interface MetadataFilter {
|
|
@@ -48,6 +48,9 @@ export interface RuntimeOverrides {
|
|
|
48
48
|
sessionEndExtraction?: boolean;
|
|
49
49
|
autoCaptureMaxPerTurn?: number;
|
|
50
50
|
autoCaptureDedupThreshold?: number;
|
|
51
|
+
autoInjectMinScore?: number;
|
|
52
|
+
autoInjectLatencyBudgetMs?: number;
|
|
53
|
+
autoInjectTopK?: number;
|
|
51
54
|
};
|
|
52
55
|
tui?: {
|
|
53
56
|
fileListKeybinding?: string;
|
|
@@ -57,6 +60,6 @@ export interface RuntimeOverrides {
|
|
|
57
60
|
/** Load runtime overrides from the store directory. Returns empty object if none exist. */
|
|
58
61
|
export declare function loadRuntimeOverrides(storePath: string): RuntimeOverrides;
|
|
59
62
|
/** Save a single runtime override value at a dotted path. Creates intermediate objects as needed. */
|
|
60
|
-
export declare function saveRuntimeOverride(storePath: string, path: string[], value: boolean | number | string): void;
|
|
63
|
+
export declare function saveRuntimeOverride(storePath: string, path: string[], value: boolean | number | string | Record<string, unknown>): void;
|
|
61
64
|
/** Deep-merge runtime overrides into a config object. Returns a new object without mutating the original. */
|
|
62
65
|
export declare function applyRuntimeOverrides(cfg: RagConfig, overrides: RuntimeOverrides): RagConfig;
|
|
@@ -136,6 +136,12 @@ export function applyRuntimeOverrides(cfg, overrides) {
|
|
|
136
136
|
m.autoCaptureMaxPerTurn = overrides.memory.autoCaptureMaxPerTurn;
|
|
137
137
|
if (overrides.memory.autoCaptureDedupThreshold !== undefined)
|
|
138
138
|
m.autoCaptureDedupThreshold = overrides.memory.autoCaptureDedupThreshold;
|
|
139
|
+
if (overrides.memory.autoInjectMinScore !== undefined)
|
|
140
|
+
m.autoInjectMinScore = overrides.memory.autoInjectMinScore;
|
|
141
|
+
if (overrides.memory.autoInjectLatencyBudgetMs !== undefined)
|
|
142
|
+
m.autoInjectLatencyBudgetMs = overrides.memory.autoInjectLatencyBudgetMs;
|
|
143
|
+
if (overrides.memory.autoInjectTopK !== undefined)
|
|
144
|
+
m.autoInjectTopK = overrides.memory.autoInjectTopK;
|
|
139
145
|
}
|
|
140
146
|
if (overrides.tui) {
|
|
141
147
|
merged.tui = {
|
package/dist/indexer/pipeline.js
CHANGED
|
@@ -173,6 +173,27 @@ async function runIndexPassInner(options, logger) {
|
|
|
173
173
|
const storeCountStart = Date.now();
|
|
174
174
|
const existingCount = await options.store.count();
|
|
175
175
|
logger.info(`Store has ${existingCount} existing chunks (${((Date.now() - storeCountStart) / 1000).toFixed(1)}s)`);
|
|
176
|
+
// Direct data-integrity check: try to actually read a row from the store.
|
|
177
|
+
// LanceDB's countRows() may return a value from the version manifest
|
|
178
|
+
// even when the underlying data files are missing from disk, so we need
|
|
179
|
+
// an explicit probe. If this fails, treat the store as corrupt.
|
|
180
|
+
if (!options.force && manifestStatus === "ok" && Object.keys(manifest.files).length > 0) {
|
|
181
|
+
try {
|
|
182
|
+
const isIntact = await options.store.checkIntegrity?.();
|
|
183
|
+
if (isIntact === false) {
|
|
184
|
+
logger.warn("Vector store data integrity check failed — data files are missing " +
|
|
185
|
+
`(${existingCount} chunks in manifest but store can't be read). Re-indexing all files.`);
|
|
186
|
+
for (const key of Object.keys(manifest.files)) {
|
|
187
|
+
delete manifest.files[key];
|
|
188
|
+
}
|
|
189
|
+
manifest.lastIndexedAt = undefined;
|
|
190
|
+
manifestStatus = "missing";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// checkIntegrity not available (older VectorStore impl) — skip
|
|
195
|
+
}
|
|
196
|
+
}
|
|
176
197
|
// Detect data loss: if the store has far fewer chunks than the manifest expects,
|
|
177
198
|
// treat it as a corrupt store (e.g. schema migration dropped the old table).
|
|
178
199
|
if (!options.force && manifestStatus === "ok" && existingCount > 0) {
|
package/dist/indexer/watch.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { manifestPathFor } from "../core/manifest.js";
|
|
6
|
+
import { createExcludeMatcher } from "../core/exclude.js";
|
|
6
7
|
/**
|
|
7
8
|
* Create a scheduler that debounces calls to a re-index pass. While a pass is
|
|
8
9
|
* running, subsequent notifications queue a single rerun. Useful for watching
|
|
@@ -117,7 +118,8 @@ export function createWatchPassScheduler(runPass, onError, debounceMs = 300) {
|
|
|
117
118
|
*/
|
|
118
119
|
export function createWatchIgnore(cwd, config, storePath) {
|
|
119
120
|
const manifestPath = manifestPathFor(storePath);
|
|
120
|
-
const
|
|
121
|
+
const dirMatcher = createExcludeMatcher(config.indexing.excludeDirs);
|
|
122
|
+
const fileMatcher = createExcludeMatcher(config.indexing.excludeFiles ?? []);
|
|
121
123
|
return (watchedPath) => {
|
|
122
124
|
const resolved = path.resolve(watchedPath);
|
|
123
125
|
if (resolved.startsWith(storePath))
|
|
@@ -127,8 +129,7 @@ export function createWatchIgnore(cwd, config, storePath) {
|
|
|
127
129
|
const relative = path.relative(cwd, resolved);
|
|
128
130
|
if (!relative || relative.startsWith(".."))
|
|
129
131
|
return false;
|
|
130
|
-
|
|
131
|
-
return segments.some((segment) => excludeDirs.has(segment));
|
|
132
|
+
return dirMatcher.excluded(relative) || fileMatcher.excluded(relative);
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
//# sourceMappingURL=watch.js.map
|
package/dist/indexer.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type { IndexRunStats, IndexStatusSummary } from "./indexer/stats.js";
|
|
|
6
6
|
export { createIndexStats } from "./indexer/stats.js";
|
|
7
7
|
export type { WorkspaceFile } from "./content/reader.js";
|
|
8
8
|
export { scanWorkspaceFiles, walkFiles } from "./content/reader.js";
|
|
9
|
+
export { createExcludeMatcher, type ExcludeMatcher } from "./core/exclude.js";
|
|
9
10
|
export type { RunIndexPassOptions, WatchPassScheduler } from "./indexer/pipeline.js";
|
|
10
11
|
export { runIndexPass, getIndexStatusSummary, createWatchPassScheduler, createWatchIgnore, type Logger, } from "./indexer/pipeline.js";
|
|
11
12
|
import type { RagConfig } from "./core/config.js";
|
package/dist/indexer.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { createIndexStats } from "./indexer/stats.js";
|
|
6
6
|
export { scanWorkspaceFiles, walkFiles } from "./content/reader.js";
|
|
7
|
+
export { createExcludeMatcher } from "./core/exclude.js";
|
|
7
8
|
export { runIndexPass, getIndexStatusSummary, createWatchPassScheduler, createWatchIgnore, } from "./indexer/pipeline.js";
|
|
8
9
|
import { scanWorkspaceFiles } from "./content/reader.js";
|
|
9
10
|
/**
|
package/dist/plugin.js
CHANGED
|
@@ -25,7 +25,7 @@ import { countTokens } from "./eval/token-counter.js";
|
|
|
25
25
|
import { checkForUpdate, getCurrentVersion, installLatestUpdate } from "./core/version-check.js";
|
|
26
26
|
import { loadAutoUpdateState, saveAutoUpdateState, shouldAttemptInstall } from "./core/auto-update-state.js";
|
|
27
27
|
import { destroyAllPooledConnections } from "./embedder/http.js";
|
|
28
|
-
import { listQuirks, lintQuirks, recallQuirks } from "./quirks/quirk-store.js";
|
|
28
|
+
import { listQuirks, lintQuirks, recallQuirks, sharedWords } from "./quirks/quirk-store.js";
|
|
29
29
|
import { autoCaptureQuirks } from "./quirks/auto-capture.js";
|
|
30
30
|
import { buildSystemGuidanceLines } from "./opencode/system-guidance.js";
|
|
31
31
|
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
@@ -95,6 +95,15 @@ function boundedSet(map, key, value, maxSize) {
|
|
|
95
95
|
}
|
|
96
96
|
map.set(key, value);
|
|
97
97
|
}
|
|
98
|
+
/** Get or create a Set for a session key in the given Map, bounded by maxSize. */
|
|
99
|
+
function getOrCreateSessionSet(map, key, maxSize) {
|
|
100
|
+
let set = map.get(key);
|
|
101
|
+
if (!set) {
|
|
102
|
+
set = new Set();
|
|
103
|
+
boundedSet(map, key, set, maxSize);
|
|
104
|
+
}
|
|
105
|
+
return set;
|
|
106
|
+
}
|
|
98
107
|
/** Name of the semantic search tool as exposed to the LLM. */
|
|
99
108
|
const CONTEXT_TOOL_NAME = "search_semantic";
|
|
100
109
|
/** Marker string injected into context output to identify RAG-sourced content. */
|
|
@@ -440,6 +449,8 @@ export function createRagHooks(options) {
|
|
|
440
449
|
const sessionPrevUserReq = new Map();
|
|
441
450
|
const sessionToolResults = new Map();
|
|
442
451
|
const sessionTranscript = new Map();
|
|
452
|
+
// Track quirk IDs already injected per session to avoid duplicate injection
|
|
453
|
+
const sessionInjectedQuirks = new Map();
|
|
443
454
|
// Evaluation session logger — captures OpenCode events for analysis
|
|
444
455
|
const sessionLogger = createSessionLogger(options.storePath);
|
|
445
456
|
appendDebugLog(options.logFilePath, {
|
|
@@ -767,6 +778,49 @@ export function createRagHooks(options) {
|
|
|
767
778
|
if (wikiMode?.enabled && wikiMode.systemPrompt) {
|
|
768
779
|
output.system.unshift(wikiMode.systemPrompt);
|
|
769
780
|
}
|
|
781
|
+
// Inject relevant quirks into system prompt when memory.autoInject is enabled
|
|
782
|
+
try {
|
|
783
|
+
const sessionId = _input?.sessionID;
|
|
784
|
+
if (sessionId) {
|
|
785
|
+
const memCfg = getEffectiveCfg().memory;
|
|
786
|
+
if (memCfg?.enabled && memCfg?.autoInject) {
|
|
787
|
+
const assistantText = sessionAssistantText.get(sessionId) ?? "";
|
|
788
|
+
const userReq = sessionPrevUserReq.get(sessionId) ?? "";
|
|
789
|
+
const query = [assistantText, userReq].filter((t) => t.length > 0).join("\n");
|
|
790
|
+
if (query.length > 0) {
|
|
791
|
+
const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
|
|
792
|
+
const budgetMs = memCfg.autoInjectLatencyBudgetMs ?? 2000;
|
|
793
|
+
let quirkResults = await Promise.race([
|
|
794
|
+
recallQuirks(quirkDeps, query, { topK: memCfg.autoInjectTopK ?? 2, minScore: memCfg.autoInjectMinScore }),
|
|
795
|
+
new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
|
|
796
|
+
]);
|
|
797
|
+
// Dedup: skip quirks already injected into this session
|
|
798
|
+
const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, sessionId, MAX_SESSION_MAP_SIZE);
|
|
799
|
+
quirkResults = quirkResults.filter((qr) => !injectedSet.has(qr.chunk.id));
|
|
800
|
+
// Lexical gate: drop quirks that match only the prior assistant text (no token
|
|
801
|
+
// overlap with the user's actual request) — prevents meta-quirks (quirks about
|
|
802
|
+
// quirks) from being injected into unrelated tasks. Disabled when autoInjectMinTokenOverlap=0.
|
|
803
|
+
const minTokenOverlap = memCfg.autoInjectMinTokenOverlap ?? 1;
|
|
804
|
+
if (minTokenOverlap > 0 && userReq.length > 0) {
|
|
805
|
+
quirkResults = quirkResults.filter((qr) => sharedWords(qr.chunk.content, userReq) >= minTokenOverlap);
|
|
806
|
+
}
|
|
807
|
+
if (quirkResults.length > 0) {
|
|
808
|
+
const lines = ["", "⚠ **Relevant quirks for this task:**", ""];
|
|
809
|
+
for (const qr of quirkResults) {
|
|
810
|
+
const m = qr.chunk.metadata;
|
|
811
|
+
const badge = m.quirkType ? `[${m.quirkType}] ` : "";
|
|
812
|
+
lines.push(`- ${badge}${qr.chunk.content}`);
|
|
813
|
+
injectedSet.add(qr.chunk.id);
|
|
814
|
+
}
|
|
815
|
+
output.system.unshift(lines.join("\n"));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
// Non-critical — must never throw
|
|
823
|
+
}
|
|
770
824
|
},
|
|
771
825
|
async "chat.message"(input, output) {
|
|
772
826
|
try {
|
|
@@ -996,7 +1050,7 @@ export function createRagHooks(options) {
|
|
|
996
1050
|
for (const q of quirks) {
|
|
997
1051
|
const badge = q.quirkType ? `[\`${q.quirkType}\`] ` : "";
|
|
998
1052
|
const tags = q.tags.length > 0 ? ` _(${q.tags.join(", ")})_` : "";
|
|
999
|
-
lines.push(`- ${badge}${q.content
|
|
1053
|
+
lines.push(`- ${badge}${q.content}${tags} `);
|
|
1000
1054
|
}
|
|
1001
1055
|
}
|
|
1002
1056
|
const memCfg = getEffectiveCfg().memory;
|
|
@@ -1021,6 +1075,63 @@ export function createRagHooks(options) {
|
|
|
1021
1075
|
}
|
|
1022
1076
|
return;
|
|
1023
1077
|
}
|
|
1078
|
+
// Always-on quirk injection when memory.autoInject is enabled
|
|
1079
|
+
// (runs on every non-slash message, not just hotkey injection)
|
|
1080
|
+
const quirkMemoryCfg = getEffectiveCfg().memory;
|
|
1081
|
+
if (quirkMemoryCfg?.enabled && quirkMemoryCfg?.autoInject) {
|
|
1082
|
+
try {
|
|
1083
|
+
const assistantText = sessionAssistantText.get(input.sessionID) ?? "";
|
|
1084
|
+
const quirkQuery = [assistantText, text].filter((t) => t.length > 0).join("\n");
|
|
1085
|
+
if (quirkQuery.length > 0) {
|
|
1086
|
+
const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: getEffectiveCfg(), storePath: options.storePath };
|
|
1087
|
+
const budgetMs = quirkMemoryCfg.autoInjectLatencyBudgetMs ?? 2000;
|
|
1088
|
+
// Use the stricter recallMinScore for user-prompt injection
|
|
1089
|
+
// (system prompt injection uses the lower autoInjectMinScore)
|
|
1090
|
+
const minScore = quirkMemoryCfg.recallMinScore ?? 0.72;
|
|
1091
|
+
let quirkResults = await Promise.race([
|
|
1092
|
+
recallQuirks(quirkDeps, quirkQuery, { topK: quirkMemoryCfg.autoInjectTopK ?? 2, minScore }),
|
|
1093
|
+
new Promise((resolve) => setTimeout(() => resolve([]), budgetMs)),
|
|
1094
|
+
]);
|
|
1095
|
+
// Dedup: skip quirks already injected into this session
|
|
1096
|
+
const injectedSet = getOrCreateSessionSet(sessionInjectedQuirks, input.sessionID, MAX_SESSION_MAP_SIZE);
|
|
1097
|
+
quirkResults = quirkResults.filter((qr) => !injectedSet.has(qr.chunk.id));
|
|
1098
|
+
// Lexical gate: drop quirks that match only the prior assistant text (no token
|
|
1099
|
+
// overlap with the user's current message) — prevents meta-quirks (quirks about
|
|
1100
|
+
// quirks) from being injected into unrelated tasks. Disabled when autoInjectMinTokenOverlap=0.
|
|
1101
|
+
const minTokenOverlap = quirkMemoryCfg.autoInjectMinTokenOverlap ?? 1;
|
|
1102
|
+
if (minTokenOverlap > 0 && text.length > 0) {
|
|
1103
|
+
quirkResults = quirkResults.filter((qr) => sharedWords(qr.chunk.content, text) >= minTokenOverlap);
|
|
1104
|
+
}
|
|
1105
|
+
if (quirkResults.length > 0) {
|
|
1106
|
+
const quirkLines = ["", "---", "⚠ **Quirk memory**", ""];
|
|
1107
|
+
for (const qr of quirkResults) {
|
|
1108
|
+
const m = qr.chunk.metadata;
|
|
1109
|
+
const badge = m.quirkType ? `[\`${m.quirkType}\`] ` : "";
|
|
1110
|
+
quirkLines.push(`- ${badge}${qr.chunk.content}`);
|
|
1111
|
+
injectedSet.add(qr.chunk.id);
|
|
1112
|
+
}
|
|
1113
|
+
const quirkBlock = quirkLines.join("\n");
|
|
1114
|
+
const parts = output?.parts ?? output?.message?.parts;
|
|
1115
|
+
if (Array.isArray(parts) && parts.length > 0) {
|
|
1116
|
+
const first = parts[0];
|
|
1117
|
+
if (typeof first.text === "string") {
|
|
1118
|
+
parts[0] = { ...first, text: first.text + "\n\n" + quirkBlock };
|
|
1119
|
+
}
|
|
1120
|
+
const msgParts = output?.message?.parts;
|
|
1121
|
+
if (Array.isArray(msgParts) && msgParts !== parts) {
|
|
1122
|
+
const mfirst = msgParts[0];
|
|
1123
|
+
if (typeof mfirst.text === "string") {
|
|
1124
|
+
msgParts[0] = { ...mfirst, text: mfirst.text + "\n\n" + quirkBlock };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
// Non-critical — must never throw
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1024
1135
|
// Handle hotkey-triggered RAG injection (Ctrl+Enter / Ctrl+Alt+Enter)
|
|
1025
1136
|
const pendingInjection = consumePendingRagInjection(options.storePath);
|
|
1026
1137
|
if (pendingInjection) {
|
|
@@ -1077,40 +1188,6 @@ export function createRagHooks(options) {
|
|
|
1077
1188
|
});
|
|
1078
1189
|
}
|
|
1079
1190
|
}
|
|
1080
|
-
// Auto-inject quirks when memory.autoInject is enabled
|
|
1081
|
-
const memoryCfg = effectiveCfg.memory;
|
|
1082
|
-
if (memoryCfg?.autoInject) {
|
|
1083
|
-
try {
|
|
1084
|
-
const quirkDeps = { embedder, store, keywordIndex: keywordIndex, cfg: effectiveCfg, storePath: options.storePath };
|
|
1085
|
-
const quirkResults = await recallQuirks(quirkDeps, searchQuery, { topK: 3 });
|
|
1086
|
-
if (quirkResults.length > 0) {
|
|
1087
|
-
const quirkLines = ["", "---", "⚠ **Quirk memory**", ""];
|
|
1088
|
-
for (const qr of quirkResults) {
|
|
1089
|
-
const m = qr.chunk.metadata;
|
|
1090
|
-
const badge = m.quirkType ? `[\`${m.quirkType}\`] ` : "";
|
|
1091
|
-
quirkLines.push(`- ${badge}${qr.chunk.content.slice(0, 200)}`);
|
|
1092
|
-
}
|
|
1093
|
-
const quirkBlock = quirkLines.join("\n");
|
|
1094
|
-
const parts = output?.parts ?? output?.message?.parts;
|
|
1095
|
-
if (Array.isArray(parts) && parts.length > 0) {
|
|
1096
|
-
const first = parts[0];
|
|
1097
|
-
if (typeof first.text === "string") {
|
|
1098
|
-
parts[0] = { ...first, text: first.text + "\n\n" + quirkBlock };
|
|
1099
|
-
}
|
|
1100
|
-
const msgParts = output?.message?.parts;
|
|
1101
|
-
if (Array.isArray(msgParts) && msgParts !== parts) {
|
|
1102
|
-
const mfirst = msgParts[0];
|
|
1103
|
-
if (typeof mfirst.text === "string") {
|
|
1104
|
-
msgParts[0] = { ...mfirst, text: mfirst.text + "\n\n" + quirkBlock };
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
catch {
|
|
1111
|
-
// Non-critical — must never throw
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
1191
|
}
|
|
1115
1192
|
appendDebugLog(options.logFilePath, {
|
|
1116
1193
|
scope: "chat.message",
|
package/dist/quirks/prompts.js
CHANGED
|
@@ -3,7 +3,7 @@ export const MEMORY_CAPTURE_SYSTEM_PROMPT = "You are a quirk-extraction assistan
|
|
|
3
3
|
"Rules:\n" +
|
|
4
4
|
"- Output exactly one line per quirk in the format: TYPE|content\n" +
|
|
5
5
|
"- TYPE must be one of: gotcha, preference, decision, environment-constraint\n" +
|
|
6
|
-
"- content
|
|
6
|
+
"- Keep content brief and to the point — one short sentence capturing the essential fact\n" +
|
|
7
7
|
"- If no quirks are present, output exactly: NOTHING\n" +
|
|
8
8
|
"- Do not include explanations, numbering, or markdown\n\n" +
|
|
9
9
|
"Examples:\n" +
|