opencode-rag-plugin 1.18.0 → 1.18.2

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 (40) hide show
  1. package/ReadMe.md +44 -1
  2. package/dist/cli/commands/init-helpers.d.ts +5 -1
  3. package/dist/cli/commands/init-helpers.js +8 -19
  4. package/dist/cli/commands/init.js +42 -8
  5. package/dist/cli/commands/query.js +1 -1
  6. package/dist/cli/commands/quirk.js +2 -2
  7. package/dist/cli/commands/setup.js +15 -6
  8. package/dist/cli/commands/ui.js +1 -1
  9. package/dist/core/config.d.ts +16 -0
  10. package/dist/core/config.js +22 -1
  11. package/dist/core/interfaces.d.ts +9 -0
  12. package/dist/core/runtime-overrides.d.ts +11 -1
  13. package/dist/core/runtime-overrides.js +21 -0
  14. package/dist/describer/anthropic.d.ts +4 -0
  15. package/dist/describer/anthropic.js +9 -2
  16. package/dist/describer/describer.d.ts +4 -0
  17. package/dist/describer/describer.js +8 -0
  18. package/dist/describer/gemini.d.ts +3 -0
  19. package/dist/describer/gemini.js +8 -2
  20. package/dist/indexer/pipeline.js +40 -0
  21. package/dist/opencode/system-guidance.d.ts +34 -0
  22. package/dist/opencode/system-guidance.js +127 -0
  23. package/dist/plugin.js +172 -70
  24. package/dist/quirks/auto-capture.d.ts +14 -0
  25. package/dist/quirks/auto-capture.js +112 -0
  26. package/dist/quirks/prompts.d.ts +1 -0
  27. package/dist/quirks/prompts.js +13 -0
  28. package/dist/quirks/quirk-store.d.ts +4 -0
  29. package/dist/quirks/quirk-store.js +6 -6
  30. package/dist/tui.js +134 -1
  31. package/dist/vectorstore/lancedb.d.ts +10 -1
  32. package/dist/vectorstore/lancedb.js +28 -2
  33. package/dist/vectorstore/memory.d.ts +2 -0
  34. package/dist/vectorstore/memory.js +3 -0
  35. package/dist/web/api.d.ts +3 -1
  36. package/dist/web/api.js +50 -1
  37. package/dist/web/server.d.ts +2 -1
  38. package/dist/web/server.js +3 -2
  39. package/dist/web/ui/index.html +163 -0
  40. package/package.json +1 -1
@@ -0,0 +1,112 @@
1
+ import { addQuirk, recallQuirks, lexicalSimilarity } from "./quirk-store.js";
2
+ import { isQuirkAllowed } from "./monitor.js";
3
+ import { MEMORY_CAPTURE_SYSTEM_PROMPT } from "./prompts.js";
4
+ const ERROR_SIGNAL_RE = /(error|failure|fail|failed|err(?:or)?\s*:|stack\s*trace|exit\s+code\s*[1-9]|npm\s+ERR|build\s+failed|test\s+fail|cannot\s+find\s+module|command\s+failed|unresolved|conflict|peer.dep|not\s+found|enoent|eaccess|econnrefused)/i;
5
+ function detectErrorSignal(text) {
6
+ return ERROR_SIGNAL_RE.test(text);
7
+ }
8
+ function buildExchangeText(exchanges) {
9
+ const lines = [];
10
+ for (const ex of exchanges) {
11
+ lines.push("=== User ===");
12
+ lines.push(ex.userReq);
13
+ if (ex.toolResults.length > 0) {
14
+ for (const tr of ex.toolResults) {
15
+ lines.push(`--- Tool: ${tr.tool} ---`);
16
+ lines.push(tr.output.slice(0, 500));
17
+ }
18
+ }
19
+ lines.push("=== Assistant ===");
20
+ lines.push(ex.assistantText);
21
+ }
22
+ return lines.join("\n");
23
+ }
24
+ function parseExtractionOutput(text) {
25
+ const results = [];
26
+ for (const line of text.split("\n")) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed === "NOTHING")
29
+ continue;
30
+ const pipeIdx = trimmed.indexOf("|");
31
+ if (pipeIdx <= 0)
32
+ continue;
33
+ const quirkType = trimmed.slice(0, pipeIdx).trim();
34
+ const content = trimmed.slice(pipeIdx + 1).trim();
35
+ if (!quirkType || !content || content.length > 200)
36
+ continue;
37
+ results.push({ quirkType, content });
38
+ }
39
+ return results;
40
+ }
41
+ async function extractQuirksFromText(provider, exchangeText) {
42
+ const raw = await provider.generateText(MEMORY_CAPTURE_SYSTEM_PROMPT, exchangeText);
43
+ return parseExtractionOutput(raw);
44
+ }
45
+ async function dedupCandidates(deps, exchangeHead, candidates, threshold) {
46
+ const existing = await recallQuirks(deps, exchangeHead, { topK: 5 });
47
+ if (existing.length === 0)
48
+ return candidates;
49
+ const existingContent = existing.map((r) => r.chunk.content);
50
+ return candidates.filter((c) => {
51
+ for (const ec of existingContent) {
52
+ if (lexicalSimilarity(c.content, ec) >= threshold) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ });
58
+ }
59
+ export async function autoCaptureQuirks(deps, descriptionProvider, exchanges, cfg, opts) {
60
+ const memoryCfg = cfg.memory;
61
+ if (!memoryCfg)
62
+ return;
63
+ const maxPerTurn = memoryCfg.autoCaptureMaxPerTurn ?? 2;
64
+ const dedupThreshold = memoryCfg.autoCaptureDedupThreshold ?? 0.85;
65
+ const captureAll = opts?.captureAll ?? false;
66
+ if (!captureAll) {
67
+ const combined = exchanges.map((e) => e.userReq + "\n" + e.assistantText + "\n" + e.toolResults.map((t) => t.output).join("\n")).join("\n");
68
+ if (!detectErrorSignal(combined))
69
+ return;
70
+ }
71
+ const exchangeText = buildExchangeText(exchanges);
72
+ if (!exchangeText.trim())
73
+ return;
74
+ let candidates;
75
+ try {
76
+ candidates = await extractQuirksFromText(descriptionProvider, exchangeText);
77
+ }
78
+ catch {
79
+ return;
80
+ }
81
+ if (candidates.length === 0)
82
+ return;
83
+ const exchangeHead = exchanges.map((e) => e.userReq).join(" ");
84
+ let deduped;
85
+ try {
86
+ deduped = await dedupCandidates(deps, exchangeHead, candidates, dedupThreshold);
87
+ }
88
+ catch {
89
+ deduped = candidates;
90
+ }
91
+ let added = 0;
92
+ for (const c of deduped) {
93
+ if (added >= maxPerTurn)
94
+ break;
95
+ const allowed = isQuirkAllowed(c.content);
96
+ if (!allowed.ok)
97
+ continue;
98
+ try {
99
+ await addQuirk(deps, {
100
+ content: c.content,
101
+ quirkType: c.quirkType,
102
+ tags: ["auto-captured"],
103
+ confidence: 0.7,
104
+ });
105
+ added++;
106
+ }
107
+ catch {
108
+ // skip individual failures
109
+ }
110
+ }
111
+ }
112
+ //# sourceMappingURL=auto-capture.js.map
@@ -0,0 +1 @@
1
+ export declare const MEMORY_CAPTURE_SYSTEM_PROMPT: string;
@@ -0,0 +1,13 @@
1
+ export const MEMORY_CAPTURE_SYSTEM_PROMPT = "You are a quirk-extraction assistant. Given a transcript excerpt of an AI agent resolving a " +
2
+ "coding problem, extract concise experiential quirks that are worth remembering.\n\n" +
3
+ "Rules:\n" +
4
+ "- Output exactly one line per quirk in the format: TYPE|content\n" +
5
+ "- TYPE must be one of: gotcha, preference, decision, environment-constraint\n" +
6
+ "- Keep content brief and to the point — one short sentence capturing the essential fact\n" +
7
+ "- If no quirks are present, output exactly: NOTHING\n" +
8
+ "- Do not include explanations, numbering, or markdown\n\n" +
9
+ "Examples:\n" +
10
+ "gotcha|npm install needs --legacy-peer-deps due to LanceDB peer dep conflicts\n" +
11
+ "preference|Use tsx over ts-node for running TypeScript scripts\n" +
12
+ "NOTHING";
13
+ //# sourceMappingURL=prompts.js.map
@@ -20,6 +20,10 @@ export declare function recallQuirks(deps: QuirkStoreDeps, query: string, option
20
20
  topK?: number;
21
21
  quirkType?: string;
22
22
  tags?: string[];
23
+ /** Override recallMinScore from config. Used by auto-inject for a lower threshold. */
24
+ minScore?: number;
23
25
  }): Promise<SearchResult[]>;
24
26
  /** Lint quirks for low confidence, staleness, duplicates, and orphan source refs. */
25
27
  export declare function lintQuirks(deps: QuirkStoreDeps): Promise<string[]>;
28
+ /** Simple Jaccard-based lexical similarity (word overlap). */
29
+ export declare function lexicalSimilarity(a: string, b: string): number;
@@ -146,13 +146,13 @@ export async function recallQuirks(deps, query, options) {
146
146
  if (options?.quirkType) {
147
147
  filter.languages = [options.quirkType];
148
148
  }
149
- const recallMinScore = deps.cfg.memory?.recallMinScore ?? 0.72;
149
+ const recallMinScore = options?.minScore ?? deps.cfg.memory?.recallMinScore ?? 0.72;
150
150
  const raw = await retrieve(query, deps.embedder, deps.store, {
151
151
  topK: topK * 3,
152
152
  minScore: recallMinScore,
153
153
  keywordIndex: deps.keywordIndex,
154
154
  keywordWeight: deps.cfg.retrieval.hybridSearch?.keywordWeight,
155
- hybridEnabled: false,
155
+ hybridEnabled: true,
156
156
  queryPrefix: deps.cfg.embedding.queryPrefix,
157
157
  filter,
158
158
  });
@@ -185,13 +185,13 @@ export async function lintQuirks(deps) {
185
185
  const cfg = deps.cfg.memory;
186
186
  for (const q of quirks) {
187
187
  if (q.confidence < (cfg?.minConfidence ?? 0.5)) {
188
- issues.push(`Low confidence (${q.confidence}): "${q.content.slice(0, 60)}..." [${q.id}]`);
188
+ issues.push(`Low confidence (${q.confidence}): "${q.content}" [${q.id}]`);
189
189
  }
190
190
  if (cfg?.decay?.enabled && q.lastObserved) {
191
191
  const ageDays = (Date.now() - new Date(q.lastObserved).getTime()) / 86_400_000;
192
192
  const halfLife = cfg.decay.halfLifeDays;
193
193
  if (halfLife > 0 && ageDays > halfLife * 2) {
194
- issues.push(`Stale (${Math.round(ageDays)}d old): "${q.content.slice(0, 60)}..." [${q.id}]`);
194
+ issues.push(`Stale (${Math.round(ageDays)}d old): "${q.content}" [${q.id}]`);
195
195
  }
196
196
  }
197
197
  }
@@ -200,14 +200,14 @@ export async function lintQuirks(deps) {
200
200
  for (let j = i + 1; j < quirks.length; j++) {
201
201
  const sim = lexicalSimilarity(quirks[i].content, quirks[j].content);
202
202
  if (sim > 0.85) {
203
- issues.push(`Near-duplicate (${(sim * 100).toFixed(0)}% similar): "${quirks[i].content.slice(0, 50)}..." ↔ "${quirks[j].content.slice(0, 50)}..."`);
203
+ issues.push(`Near-duplicate (${(sim * 100).toFixed(0)}% similar): "${quirks[i].content}" ↔ "${quirks[j].content}"`);
204
204
  }
205
205
  }
206
206
  }
207
207
  return issues;
208
208
  }
209
209
  /** Simple Jaccard-based lexical similarity (word overlap). */
210
- function lexicalSimilarity(a, b) {
210
+ export function lexicalSimilarity(a, b) {
211
211
  const wordsA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
212
212
  const wordsB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
213
213
  const intersection = new Set([...wordsA].filter((w) => wordsB.has(w)));
package/dist/tui.js CHANGED
@@ -345,10 +345,16 @@ function buildSettingCategories(cfg, ro, providers) {
345
345
  const docModeRo = (ro.documentationMode ?? {});
346
346
  const wikiModeCfg = (cfg.wikiMode ?? {});
347
347
  const wikiModeRo = (ro.wikiMode ?? {});
348
+ const memoryCfg = (cfg.memory ?? {});
349
+ const memoryRo = (ro.memory ?? {});
348
350
  const embeddingCfg = (cfg.embedding ?? {});
349
351
  const embeddingRo = (ro.embedding ?? {});
350
352
  const tuiCfg = (cfg.tui ?? {});
351
353
  const tuiRo = (ro.tui ?? {});
354
+ const indexingCfg = (cfg.indexing ?? {});
355
+ const indexingRo = (ro.indexing ?? {});
356
+ const chunkingCfg = (cfg.chunking ?? {});
357
+ const chunkingRo = (ro.chunking ?? {});
352
358
  const modelOptions = providers ? buildModelOptions(providers) : undefined;
353
359
  function displayModel(roProvider, roModel, cfgProvider, cfgModel, defaultProvider, defaultModel) {
354
360
  const p = roProvider ?? cfgProvider ?? defaultProvider;
@@ -416,6 +422,31 @@ function buildSettingCategories(cfg, ro, providers) {
416
422
  },
417
423
  ],
418
424
  },
425
+ {
426
+ id: "chunking",
427
+ label: "Chunking",
428
+ description: "Configure how files are split into chunks",
429
+ entries: [
430
+ {
431
+ path: ["indexing", "chunkOverlap"],
432
+ label: "Chunk overlap (lines)",
433
+ type: "number",
434
+ currentValue: indexingRo.chunkOverlap ?? indexingCfg.chunkOverlap ?? 0,
435
+ },
436
+ {
437
+ path: ["indexing", "maxSvgSizeBytes"],
438
+ label: "Max SVG size (bytes)",
439
+ type: "number",
440
+ currentValue: indexingRo.maxSvgSizeBytes ?? indexingCfg.maxSvgSizeBytes ?? 1_048_576,
441
+ },
442
+ {
443
+ path: ["chunking", "nodeTypes"],
444
+ label: "Node types (JSON)",
445
+ type: "json",
446
+ currentValue: chunkingRo.nodeTypes ?? chunkingCfg.nodeTypes ?? {},
447
+ },
448
+ ],
449
+ },
419
450
  {
420
451
  id: "embedding",
421
452
  label: "Embedding",
@@ -488,6 +519,67 @@ function buildSettingCategories(cfg, ro, providers) {
488
519
  },
489
520
  ],
490
521
  },
522
+ {
523
+ id: "memory",
524
+ label: "Quirk Memory",
525
+ description: "Configure experiential memory — gotchas, preferences, decisions recalled across sessions",
526
+ entries: [
527
+ {
528
+ path: ["memory", "enabled"],
529
+ label: "Quirk memory enabled",
530
+ type: "boolean",
531
+ currentValue: memoryRo.enabled ?? memoryCfg.enabled ?? true,
532
+ },
533
+ {
534
+ path: ["memory", "autoInject"],
535
+ label: "Auto-inject quirks",
536
+ type: "boolean",
537
+ currentValue: memoryRo.autoInject ?? memoryCfg.autoInject ?? false,
538
+ },
539
+ {
540
+ path: ["memory", "recallMinScore"],
541
+ label: "User prompt min score",
542
+ type: "number",
543
+ currentValue: memoryRo.recallMinScore ?? memoryCfg.recallMinScore ?? 0.72,
544
+ },
545
+ {
546
+ path: ["memory", "autoInjectMinScore"],
547
+ label: "System prompt min score",
548
+ type: "number",
549
+ currentValue: memoryRo.autoInjectMinScore ?? memoryCfg.autoInjectMinScore ?? 0.6,
550
+ },
551
+ {
552
+ path: ["memory", "autoInjectLatencyBudgetMs"],
553
+ label: "Latency budget (ms)",
554
+ type: "number",
555
+ currentValue: memoryRo.autoInjectLatencyBudgetMs ?? memoryCfg.autoInjectLatencyBudgetMs ?? 2000,
556
+ },
557
+ {
558
+ path: ["memory", "minConfidence"],
559
+ label: "Min confidence",
560
+ type: "number",
561
+ currentValue: memoryRo.minConfidence ?? memoryCfg.minConfidence ?? 0.5,
562
+ },
563
+ {
564
+ path: ["memory", "passiveCapture"],
565
+ label: "Passive capture",
566
+ type: "boolean",
567
+ currentValue: memoryRo.passiveCapture ?? memoryCfg.passiveCapture ?? false,
568
+ },
569
+ {
570
+ path: ["memory", "promptEnforcement"],
571
+ label: "Prompt enforcement",
572
+ type: "boolean",
573
+ currentValue: memoryRo.promptEnforcement ?? memoryCfg.promptEnforcement ?? true,
574
+ },
575
+ {
576
+ path: ["memory", "sessionEndExtraction"],
577
+ label: "Session-end extraction",
578
+ type: "boolean",
579
+ currentValue: memoryRo.sessionEndExtraction ?? memoryCfg.sessionEndExtraction ?? true,
580
+ },
581
+ ],
582
+ },
491
583
  {
492
584
  id: "keybindings",
493
585
  label: "Keybindings",
@@ -564,7 +656,7 @@ async function openSettingsDialog(api) {
564
656
  function showSettingMenu(cat) {
565
657
  const options = [
566
658
  ...cat.entries.map((s) => ({
567
- title: `${s.label}: ${s.type === "boolean" ? (s.currentValue ? "Yes" : "No") : String(s.currentValue)}`,
659
+ title: `${s.label}: ${s.type === "boolean" ? (s.currentValue ? "Yes" : "No") : s.type === "json" ? JSON.stringify(s.currentValue) : String(s.currentValue)}`,
568
660
  value: s.path.join("."),
569
661
  description: s.options ? "Select to open model picker" : (s.type === "boolean" ? "Select to toggle" : "Select to edit"),
570
662
  })),
@@ -594,6 +686,9 @@ async function openSettingsDialog(api) {
594
686
  title: "Settings",
595
687
  message: `${entry.label}: ${newVal ? "Yes" : "No"}`,
596
688
  });
689
+ if (entry.path[0] === "indexing" || entry.path[0] === "chunking") {
690
+ api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
691
+ }
597
692
  entry.currentValue = newVal;
598
693
  showSettingMenu(cat);
599
694
  }
@@ -612,6 +707,9 @@ async function openSettingsDialog(api) {
612
707
  title: "Settings",
613
708
  message: `${entry.label}: ${num}`,
614
709
  });
710
+ if (entry.path[0] === "indexing" || entry.path[0] === "chunking") {
711
+ api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
712
+ }
615
713
  entry.currentValue = num;
616
714
  }
617
715
  showSettingMenu(cat);
@@ -621,6 +719,41 @@ async function openSettingsDialog(api) {
621
719
  },
622
720
  }));
623
721
  }
722
+ else if (entry.type === "json") {
723
+ api.ui.dialog.replace(() => api.ui.DialogPrompt({
724
+ title: `Edit ${entry.label}`,
725
+ placeholder: "JSON",
726
+ value: JSON.stringify(entry.currentValue, null, 2),
727
+ onConfirm: (input) => {
728
+ try {
729
+ const parsed = JSON.parse(input);
730
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
731
+ api.ui.toast({ variant: "error", title: "Settings", message: "Value must be a JSON object" });
732
+ showSettingMenu(cat);
733
+ return;
734
+ }
735
+ saveRuntimeOverride(storePath, entry.path, parsed);
736
+ saveConfigValue(configPath, entry.path, parsed);
737
+ api.ui.toast({
738
+ variant: "success",
739
+ title: "Settings",
740
+ message: `${entry.label}: updated`,
741
+ });
742
+ if (entry.path[0] === "indexing" || entry.path[0] === "chunking") {
743
+ api.ui.toast({ variant: "warning", title: "Settings", message: "Chunking changed. Re-index required." });
744
+ }
745
+ entry.currentValue = parsed;
746
+ }
747
+ catch {
748
+ api.ui.toast({ variant: "error", title: "Settings", message: "Invalid JSON" });
749
+ }
750
+ showSettingMenu(cat);
751
+ },
752
+ onCancel: () => {
753
+ showSettingMenu(cat);
754
+ },
755
+ }));
756
+ }
624
757
  else {
625
758
  api.ui.dialog.replace(() => api.ui.DialogPrompt({
626
759
  title: `Edit ${entry.label}`,
@@ -100,9 +100,18 @@ export declare class LanceDbStore implements VectorStore {
100
100
  private searchInternal;
101
101
  /**
102
102
  * Return the total number of chunks stored in the table.
103
- * @returns The chunk count, or 0 if the table does not exist.
103
+ * Guarded by a 30s timeout — returns 0 if the store is unresponsive
104
+ * (e.g. corrupted version-manifest accumulation) so the pipeline can
105
+ * proceed with a fresh index instead of hanging forever.
106
+ * @returns The chunk count, or 0 if the table does not exist or times out.
104
107
  */
105
108
  count(): Promise<number>;
109
+ /**
110
+ * Compact fragments and prune old version manifests to prevent the
111
+ * version-manifest accumulation that causes countRows() to hang.
112
+ * Should be called at the end of a successful index pass.
113
+ */
114
+ optimize(): Promise<void>;
106
115
  /**
107
116
  * Return all unique file paths currently stored in the index.
108
117
  * @returns An array of normalized file paths.
@@ -513,7 +513,10 @@ export class LanceDbStore {
513
513
  }
514
514
  /**
515
515
  * Return the total number of chunks stored in the table.
516
- * @returns The chunk count, or 0 if the table does not exist.
516
+ * Guarded by a 30s timeout — returns 0 if the store is unresponsive
517
+ * (e.g. corrupted version-manifest accumulation) so the pipeline can
518
+ * proceed with a fresh index instead of hanging forever.
519
+ * @returns The chunk count, or 0 if the table does not exist or times out.
517
520
  */
518
521
  async count() {
519
522
  try {
@@ -522,12 +525,35 @@ export class LanceDbStore {
522
525
  if (!tableNames.includes(TABLE_NAME))
523
526
  return 0;
524
527
  const table = await this.getTable();
525
- return await table.countRows();
528
+ const COUNT_TIMEOUT_MS = 30_000;
529
+ const result = await Promise.race([
530
+ table.countRows(),
531
+ new Promise((resolve) => setTimeout(() => resolve(null), COUNT_TIMEOUT_MS)),
532
+ ]);
533
+ if (result === null) {
534
+ console.warn(`[lancedb] count() timed out after ${COUNT_TIMEOUT_MS / 1000}s — treating store as empty.`);
535
+ return 0;
536
+ }
537
+ return result;
526
538
  }
527
539
  catch {
528
540
  return 0;
529
541
  }
530
542
  }
543
+ /**
544
+ * Compact fragments and prune old version manifests to prevent the
545
+ * version-manifest accumulation that causes countRows() to hang.
546
+ * Should be called at the end of a successful index pass.
547
+ */
548
+ async optimize() {
549
+ try {
550
+ const table = await this.getTable();
551
+ await table.optimize({ cleanupOlderThan: new Date(), deleteUnverified: false });
552
+ }
553
+ catch {
554
+ // Optimize is best-effort — must not break indexing.
555
+ }
556
+ }
531
557
  /**
532
558
  * Return all unique file paths currently stored in the index.
533
559
  * @returns An array of normalized file paths.
@@ -21,4 +21,6 @@ export declare class InMemoryVectorStore implements VectorStore {
21
21
  getChunksByFilePath(filePath: string): Promise<Chunk[]>;
22
22
  /** Release any held resources. No-op for the in-memory store. */
23
23
  close(): Promise<void>;
24
+ /** No-op for the in-memory store. */
25
+ optimize(): Promise<void>;
24
26
  }
@@ -73,6 +73,9 @@ export class InMemoryVectorStore {
73
73
  /** Release any held resources. No-op for the in-memory store. */
74
74
  async close() {
75
75
  }
76
+ /** No-op for the in-memory store. */
77
+ async optimize() {
78
+ }
76
79
  }
77
80
  /**
78
81
  * Compute the cosine similarity between two equal-length vectors.
package/dist/web/api.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { LanceDbStore } from "../vectorstore/lancedb.js";
6
6
  import { KeywordIndex } from "../retriever/keyword-index.js";
7
+ import type { RagConfig } from "../core/config.js";
7
8
  /** Internal shape for a JSON API response: an HTTP status code and a serialisable body. */
8
9
  interface ApiResponse {
9
10
  status: number;
@@ -23,9 +24,10 @@ interface ApiResponse {
23
24
  * @param keywordIndex - The keyword-index instance for text search.
24
25
  * @param storePath - Filesystem path to the store directory (used by eval endpoints).
25
26
  * @param cwd - Optional workspace root for resolving file paths.
27
+ * @param cfg - Active RAG configuration (used by quirk endpoints).
26
28
  * @returns An async handler that returns `true` when a route matched or `false` otherwise.
27
29
  */
28
- export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
30
+ export declare function createApiHandler(store: LanceDbStore, keywordIndex: KeywordIndex, storePath: string, cwd?: string, cfg?: RagConfig): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
29
31
  /**
30
32
  * Perform token-usage analysis for a single evaluation session.
31
33
  *
package/dist/web/api.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { extname, resolve as resolvePathModule } from "node:path";
3
3
  import { listSessions, getSession, deleteSession, compareSessions, validateSessionID } from "../eval/storage.js";
4
4
  import { analyzeTokenUsage, compareTokenAnalyses, projectTokenSavings } from "../eval/token-analysis.js";
5
+ import { listQuirks, lintQuirks, removeQuirk } from "../quirks/quirk-store.js";
5
6
  const FILE_MIME_TYPES = {
6
7
  ".png": "image/png",
7
8
  ".jpg": "image/jpeg",
@@ -11,6 +12,13 @@ const FILE_MIME_TYPES = {
11
12
  ".bmp": "image/bmp",
12
13
  ".svg": "image/svg+xml",
13
14
  };
15
+ /** No-op embedder used by quirk endpoints that never need to embed text in the read-only UI context. */
16
+ const stubEmbedder = {
17
+ name: "stub",
18
+ embed: async () => {
19
+ throw new Error("Embedder is not available in the Web UI context");
20
+ },
21
+ };
14
22
  /** Split a raw URL into its pathname and parsed query-string parameters. */
15
23
  function parseQuery(url) {
16
24
  const [path, queryString] = url.split("?");
@@ -43,13 +51,22 @@ function sendJson(res, response) {
43
51
  * @param keywordIndex - The keyword-index instance for text search.
44
52
  * @param storePath - Filesystem path to the store directory (used by eval endpoints).
45
53
  * @param cwd - Optional workspace root for resolving file paths.
54
+ * @param cfg - Active RAG configuration (used by quirk endpoints).
46
55
  * @returns An async handler that returns `true` when a route matched or `false` otherwise.
47
56
  */
48
- export function createApiHandler(store, keywordIndex, storePath, cwd) {
57
+ export function createApiHandler(store, keywordIndex, storePath, cwd, cfg) {
49
58
  return async (req, res) => {
50
59
  const url = req.url ?? "/";
51
60
  const method = req.method ?? "GET";
52
61
  const { path, params } = parseQuery(url);
62
+ // Quirk store dependencies (embedder is a no-op stub for the read-only UI context).
63
+ const quirkDeps = {
64
+ embedder: stubEmbedder,
65
+ store,
66
+ keywordIndex,
67
+ cfg: cfg ?? {},
68
+ storePath,
69
+ };
53
70
  // CORS preflight
54
71
  if (method === "OPTIONS") {
55
72
  res.writeHead(204, {
@@ -143,6 +160,22 @@ export function createApiHandler(store, keywordIndex, storePath, cwd) {
143
160
  const body = await readBody(req);
144
161
  response = handleEvalProjectSavings(body);
145
162
  }
163
+ // Quirk memory endpoints
164
+ else if (path === "/api/quirks" && method === "GET") {
165
+ response = await handleQuirks(quirkDeps);
166
+ }
167
+ else if (path === "/api/quirks/lint" && method === "GET") {
168
+ response = await handleQuirkLint(quirkDeps);
169
+ }
170
+ else if (path.startsWith("/api/quirks/") && method === "DELETE") {
171
+ const id = path.slice("/api/quirks/".length);
172
+ if (!id) {
173
+ response = { status: 400, body: { error: "Missing quirk ID" } };
174
+ }
175
+ else {
176
+ response = await handleQuirkDelete(quirkDeps, id);
177
+ }
178
+ }
146
179
  else {
147
180
  return false;
148
181
  }
@@ -181,6 +214,22 @@ async function handleFiles(store) {
181
214
  const files = await store.listFiles();
182
215
  return { status: 200, body: files };
183
216
  }
217
+ // ── Quirk Memory API ─────────────────────────────────────────────────
218
+ /** Respond with all stored quirks, sorted by last-observed time (most recent first). */
219
+ async function handleQuirks(deps) {
220
+ const quirks = await listQuirks(deps);
221
+ return { status: 200, body: { quirks } };
222
+ }
223
+ /** Respond with the health-check issues for the quirk store (confidence, staleness, duplicates). */
224
+ async function handleQuirkLint(deps) {
225
+ const issues = await lintQuirks(deps);
226
+ return { status: 200, body: { issues } };
227
+ }
228
+ /** Delete a single quirk by its ID from the store, index, and audit log. */
229
+ async function handleQuirkDelete(deps, id) {
230
+ await removeQuirk(deps, id);
231
+ return { status: 200, body: { deleted: true, id } };
232
+ }
184
233
  /**
185
234
  * Respond with a paginated, optionally filtered list of chunks.
186
235
  *
@@ -15,6 +15,7 @@ export interface WebUiServer {
15
15
  * @param port - TCP port to listen on.
16
16
  * @param cwd - Optional workspace root used to resolve file paths for the file API.
17
17
  * @param vectorDimension - Embedding vector dimension (default 384).
18
+ * @param cfg - Active RAG configuration (used by quirk endpoints).
18
19
  * @returns A {@link WebUiServer} handle for the running server.
19
20
  */
20
- export declare function startWebUi(storePath: string, port: number, cwd?: string, vectorDimension?: number): Promise<WebUiServer>;
21
+ export declare function startWebUi(storePath: string, port: number, cwd?: string, vectorDimension?: number, cfg?: import("../core/config.js").RagConfig): Promise<WebUiServer>;
@@ -53,13 +53,14 @@ function serveUiAsset(res, filePath) {
53
53
  * @param port - TCP port to listen on.
54
54
  * @param cwd - Optional workspace root used to resolve file paths for the file API.
55
55
  * @param vectorDimension - Embedding vector dimension (default 384).
56
+ * @param cfg - Active RAG configuration (used by quirk endpoints).
56
57
  * @returns A {@link WebUiServer} handle for the running server.
57
58
  */
58
- export async function startWebUi(storePath, port, cwd, vectorDimension = 384) {
59
+ export async function startWebUi(storePath, port, cwd, vectorDimension = 384, cfg) {
59
60
  const store = new LanceDbStore(storePath, vectorDimension);
60
61
  const keywordIndex = await KeywordIndex.load(storePath);
61
62
  const html = getStaticHtml();
62
- const apiHandler = createApiHandler(store, keywordIndex, storePath, cwd);
63
+ const apiHandler = createApiHandler(store, keywordIndex, storePath, cwd, cfg);
63
64
  const server = createServer(async (req, res) => {
64
65
  const url = req.url ?? "/";
65
66
  if (url === "/" || url === "/index.html") {