opencode-rag-plugin 1.19.8 → 1.20.1

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 CHANGED
@@ -53,9 +53,9 @@ opencode-rag query "authentication middleware"
53
53
 
54
54
  ## Web UI
55
55
 
56
- A browser-based dashboard for exploring the indexed vector database - browse and inspect chunks and evaluate the OpenCode sessions in terms of retrieved chunks, consumed tokens and more.
56
+ A browser-based dashboard for exploring the indexed vector database - browse, visualize and inspect chunks and evaluate the OpenCode sessions in terms of retrieved chunks, consumed tokens and more.
57
57
 
58
- ![OpenCodeRAG Web UI](doc/assets/webui-dashboard.png)
58
+ ![OpenCodeRAG Web UI](doc/assets/webui-3d.png)
59
59
 
60
60
  Launch with `opencode-rag ui`. See [Web UI documentation](doc/webui.md) for details.
61
61
 
@@ -18,7 +18,7 @@ export declare function getMimeType(ext: string): string;
18
18
  * OpenAI, Anthropic, or Google Gemini).
19
19
  */
20
20
  export interface ImageVisionProvider {
21
- describeImage(imageBase64: string, mimeType: string, prompt: string, abort?: AbortSignal): Promise<string>;
21
+ describeImage(imageBase64: string, mimeType: string, prompt: string, systemPrompt?: string, abort?: AbortSignal): Promise<string>;
22
22
  }
23
23
  /**
24
24
  * Factory function that creates the appropriate {@link ImageVisionProvider}
@@ -1,3 +1,4 @@
1
+ import { normalizeKeepAlive } from "../core/ollama.js";
1
2
  import { postJson } from "../embedder/http.js";
2
3
  import { uuid } from "./uuid.js";
3
4
  const MAX_CHUNK_CHARS = 4000;
@@ -53,22 +54,21 @@ class OllamaImageVisionProvider {
53
54
  this.keepAlive = config.keepAlive;
54
55
  this.proxy = config.proxy;
55
56
  }
56
- async describeImage(imageBase64, _mimeType, prompt, abort) {
57
+ async describeImage(imageBase64, _mimeType, prompt, systemPrompt, abort) {
58
+ const messages = [];
59
+ if (systemPrompt && systemPrompt.trim().length > 0) {
60
+ messages.push({ role: "system", content: systemPrompt });
61
+ }
62
+ messages.push({ role: "user", content: prompt, images: [imageBase64] });
57
63
  const body = {
58
64
  model: this.model,
59
- messages: [
60
- {
61
- role: "user",
62
- content: prompt,
63
- images: [imageBase64],
64
- },
65
- ],
65
+ messages,
66
66
  stream: false,
67
67
  think: this.think,
68
68
  options: { num_ctx: this.numCtx },
69
69
  };
70
70
  if (this.keepAlive) {
71
- body.keep_alive = this.keepAlive;
71
+ body.keep_alive = normalizeKeepAlive(this.keepAlive);
72
72
  }
73
73
  let lastError;
74
74
  for (let attempt = 0; attempt <= VISION_RETRY_MAX; attempt++) {
@@ -114,22 +114,25 @@ class OpenAIImageVisionProvider {
114
114
  this.timeoutMs = config.timeoutMs;
115
115
  this.proxy = config.proxy;
116
116
  }
117
- async describeImage(imageBase64, mimeType, prompt, abort) {
117
+ async describeImage(imageBase64, mimeType, prompt, systemPrompt, abort) {
118
118
  const url = `${this.baseUrl}${this.baseUrl.endsWith("/v1") ? "" : "/v1"}/chat/completions`;
119
- const body = {
120
- model: this.model,
121
- messages: [
119
+ const messages = [];
120
+ if (systemPrompt && systemPrompt.trim().length > 0) {
121
+ messages.push({ role: "system", content: systemPrompt });
122
+ }
123
+ messages.push({
124
+ role: "user",
125
+ content: [
126
+ { type: "text", text: prompt },
122
127
  {
123
- role: "user",
124
- content: [
125
- { type: "text", text: prompt },
126
- {
127
- type: "image_url",
128
- image_url: { url: `data:${mimeType};base64,${imageBase64}` },
129
- },
130
- ],
128
+ type: "image_url",
129
+ image_url: { url: `data:${mimeType};base64,${imageBase64}` },
131
130
  },
132
131
  ],
132
+ });
133
+ const body = {
134
+ model: this.model,
135
+ messages,
133
136
  max_tokens: 2048,
134
137
  };
135
138
  const headers = {
@@ -182,7 +185,7 @@ class AnthropicImageVisionProvider {
182
185
  this.timeoutMs = config.timeoutMs;
183
186
  this.proxy = config.proxy;
184
187
  }
185
- async describeImage(imageBase64, mimeType, prompt, abort) {
188
+ async describeImage(imageBase64, mimeType, prompt, systemPrompt, abort) {
186
189
  const body = {
187
190
  model: this.model,
188
191
  max_tokens: 2048,
@@ -199,6 +202,9 @@ class AnthropicImageVisionProvider {
199
202
  },
200
203
  ],
201
204
  };
205
+ if (systemPrompt && systemPrompt.trim().length > 0) {
206
+ body.system = systemPrompt;
207
+ }
202
208
  const headers = {
203
209
  "x-api-key": this.apiKey,
204
210
  "anthropic-version": "2023-06-01",
@@ -248,7 +254,7 @@ class GeminiImageVisionProvider {
248
254
  this.timeoutMs = config.timeoutMs;
249
255
  this.proxy = config.proxy;
250
256
  }
251
- async describeImage(imageBase64, mimeType, prompt, abort) {
257
+ async describeImage(imageBase64, mimeType, prompt, systemPrompt, abort) {
252
258
  const body = {
253
259
  contents: [
254
260
  {
@@ -265,6 +271,9 @@ class GeminiImageVisionProvider {
265
271
  },
266
272
  ],
267
273
  };
274
+ if (systemPrompt && systemPrompt.trim().length > 0) {
275
+ body.system_instruction = { parts: [{ text: systemPrompt }] };
276
+ }
268
277
  const headers = {
269
278
  "Content-Type": "application/json",
270
279
  };
@@ -22,6 +22,7 @@ export function registerDescribeImageCommand(program) {
22
22
  .description("Describe an image file using a vision model")
23
23
  .argument("<filePath>", "path to image file")
24
24
  .option("-c, --config <path>", "path to config file")
25
+ .option("-s, --system-prompt <text>", "optional system prompt to steer the description toward specific features")
25
26
  .action(async (filePath, options) => {
26
27
  try {
27
28
  const cwd = process.cwd();
@@ -56,7 +57,7 @@ export function registerDescribeImageCommand(program) {
56
57
  const sized = maxDimension > 0 ? await resizeImage(buffer, resolvedPath, maxDimension) : buffer;
57
58
  const b64 = sized.toString("base64");
58
59
  const provider = createImageVisionProvider(imageDescriptionConfig);
59
- const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt);
60
+ const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt, options.systemPrompt);
60
61
  logCliInfo(logFilePath, "describe-image", `\n${c.desc(description)}\n`);
61
62
  await cleanupContext(ctx);
62
63
  }
@@ -165,7 +165,7 @@ export function generateSkillFile() {
165
165
  "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
166
166
  "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
167
167
  "4. User asks a code question → `search_semantic` to gather context before answering",
168
- "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
168
+ "5. User asks about an image or visual asset → `describe_image(filePath)` (optionally pass `systemPrompt` to focus on specific features) to retrieve its generated description, then optionally `search_semantic` for related code",
169
169
  "6. You encounter an error or need a known pitfall → `recall_quirks(query)`",
170
170
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it",
171
171
  "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
@@ -188,7 +188,7 @@ export function generateSkillFile() {
188
188
  "1. **Skeleton first** — call `get_file_skeleton(filePath)` to see structure",
189
189
  "2. **Find usages** — call `find_usages(symbolName)` before modifying any symbol",
190
190
  "3. **Search** — call `search_semantic(query)` to find relevant code",
191
- "4. **Describe images** — call `describe_image(filePath)` when context involves an image file",
191
+ "4. **Describe images** — call `describe_image(filePath)` when context involves an image file (pass `systemPrompt` to focus on specific features)",
192
192
  "5. **Read** — use the `read` tool on specific line ranges identified above",
193
193
  "6. **Edit** — now you have full context to make safe changes",
194
194
  "",
@@ -205,7 +205,7 @@ export function generateSkillFile() {
205
205
  "- `search_semantic`: `query` (req), `pathHints?`, `languageHints?`, `topK?`",
206
206
  "- `get_file_skeleton`: `filePath` (req)",
207
207
  "- `find_usages`: `symbolName` (req), `pathHint?`, `topK?`",
208
- "- `describe_image`: `filePath` (req)",
208
+ "- `describe_image`: `filePath` (req), `systemPrompt?`",
209
209
  "- `recall_quirks`: `query` (req), `topK?`, `quirkType?`, `tags?`",
210
210
  "- `add_quirk`: `content` (req), `quirkType?`, `tags?`, `sourceRef?`",
211
211
  "- `update_quirk`: `id` (req) + at least one of `content?`, `quirkType?`, `tags?`, `confidence?`, `sourceRef?`",
@@ -216,6 +216,7 @@ export function generateSkillFile() {
216
216
  "- Use `pathHints` to narrow searches to specific directories",
217
217
  "- Use `languageHints` to filter by file type",
218
218
  "- `find_usages` is essential before refactoring — it shows every reference",
219
+ "- Pass `systemPrompt` to `describe_image` when you need specific details (e.g. `\"focus on the chart's axes and values\"`)",
219
220
  "- If no results appear, the workspace may not be indexed yet — run `opencode-rag index`",
220
221
  "- Image descriptions are generated at index time using the configured vision provider; ensure `imageDescription` is configured in `opencode-rag.json` if your project includes images",
221
222
  "",
@@ -20,6 +20,8 @@ export interface CliOptions {
20
20
  explain?: boolean;
21
21
  /** Skip confirmation prompts for destructive operations. */
22
22
  yes?: boolean;
23
+ /** Optional system prompt to steer an image description toward specific features. */
24
+ systemPrompt?: string;
23
25
  }
24
26
  /** Options for the `init` command. */
25
27
  export interface InitOptions {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @fileoverview Shared Ollama request-body helpers.
3
+ */
4
+ /**
5
+ * Ollama's `keep_alive` field accepts either a duration string with a unit
6
+ * (e.g. "30m", "24h") or a bare integer (e.g. -1 for keep-in-memory forever,
7
+ * 0 to unload immediately). A bare integer passed as a string (e.g. "-1")
8
+ * fails server-side with `time: missing unit in duration "-1"`.
9
+ *
10
+ * Returns the numeric form for bare integers and passes other strings through
11
+ * unchanged.
12
+ */
13
+ export declare function normalizeKeepAlive(keepAlive?: string): string | number | undefined;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @fileoverview Shared Ollama request-body helpers.
3
+ */
4
+ /**
5
+ * Ollama's `keep_alive` field accepts either a duration string with a unit
6
+ * (e.g. "30m", "24h") or a bare integer (e.g. -1 for keep-in-memory forever,
7
+ * 0 to unload immediately). A bare integer passed as a string (e.g. "-1")
8
+ * fails server-side with `time: missing unit in duration "-1"`.
9
+ *
10
+ * Returns the numeric form for bare integers and passes other strings through
11
+ * unchanged.
12
+ */
13
+ export function normalizeKeepAlive(keepAlive) {
14
+ if (keepAlive === undefined || keepAlive === "") {
15
+ return undefined;
16
+ }
17
+ if (/^-?\d+$/.test(keepAlive)) {
18
+ return Number(keepAlive);
19
+ }
20
+ return keepAlive;
21
+ }
22
+ //# sourceMappingURL=ollama.js.map
@@ -1,3 +1,4 @@
1
+ import { normalizeKeepAlive } from "../core/ollama.js";
1
2
  import { postJson } from "../embedder/http.js";
2
3
  import { buildUserMessage, buildBatchUserMessage, parseBatchDescriptions, sleep } from "./shared.js";
3
4
  import pLimit from "p-limit";
@@ -178,7 +179,7 @@ export class LlmDescriptionProvider {
178
179
  ? `${baseUrl}/chat`
179
180
  : `${baseUrl}${baseUrl.endsWith("/v1") ? "" : "/v1"}/chat/completions`;
180
181
  const body = isOllama
181
- ? { model: this.config.model, messages, stream: false, think: this.config.think ?? false, options: { num_ctx: this.config.numCtx }, keep_alive: this.config.keepAlive }
182
+ ? { model: this.config.model, messages, stream: false, think: this.config.think ?? false, options: { num_ctx: this.config.numCtx }, keep_alive: normalizeKeepAlive(this.config.keepAlive) }
182
183
  : { model: this.config.model, messages };
183
184
  const headers = {};
184
185
  if (this.config.apiKey) {
@@ -1,3 +1,4 @@
1
+ import { normalizeKeepAlive } from "../core/ollama.js";
1
2
  import { postJson } from "./http.js";
2
3
  import path from "node:path";
3
4
  import { appendDebugLog } from "../core/fileLogger.js";
@@ -51,8 +52,9 @@ export class OllamaProvider {
51
52
  model: this.model,
52
53
  input: texts.length === 1 ? texts[0] : texts,
53
54
  };
54
- if (this.keepAlive) {
55
- body.keep_alive = this.keepAlive;
55
+ const keepAlive = normalizeKeepAlive(this.keepAlive);
56
+ if (keepAlive !== undefined) {
57
+ body.keep_alive = keepAlive;
56
58
  }
57
59
  const response = await postJson(`${this.baseUrl}/embed`, body, headers, this.timeoutMs, this.proxy);
58
60
  if (!response.ok) {
@@ -85,6 +85,8 @@ export interface FindUsagesResult {
85
85
  export interface DescribeImageParams {
86
86
  /** Path to the image file. */
87
87
  filePath: string;
88
+ /** Optional system prompt to steer the description toward specific features. */
89
+ systemPrompt?: string;
88
90
  }
89
91
  /** Result of an image description operation. */
90
92
  export interface DescribeImageResult {
@@ -247,7 +247,7 @@ export async function handleDescribeImage(params, cfg, worktree, visionProvider)
247
247
  const sized = maxDimension > 0 ? await resizeImage(buffer, resolvedPath, maxDimension) : buffer;
248
248
  const b64 = sized.toString("base64");
249
249
  const provider = visionProvider ?? (await import("../chunker/image.js")).createImageVisionProvider(imageDescriptionConfig);
250
- const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt);
250
+ const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt, params.systemPrompt);
251
251
  const formatted = [
252
252
  `**Image description** — ${params.filePath}`,
253
253
  "",
@@ -73,8 +73,9 @@ export async function createMcpServer(options) {
73
73
  };
74
74
  }
75
75
  });
76
- server.tool("describe_image", "Describe an image file using a vision model. Reads the file from disk, sends it to the configured vision provider (Ollama, OpenAI, Anthropic, or Google Gemini), and returns a text description of the image contents.", {
76
+ server.tool("describe_image", "Describe an image file using a vision model. Reads the file from disk, sends it to the configured vision provider (Ollama, OpenAI, Anthropic, or Google Gemini), and returns a text description of the image contents. Optionally accepts a systemPrompt to steer the description toward specific features.", {
77
77
  filePath: z.string().min(1, "An image file path is required."),
78
+ systemPrompt: z.string().optional(),
78
79
  }, async (args) => {
79
80
  try {
80
81
  const result = await handleDescribeImage(args, ctx.config, cwd);
@@ -17,7 +17,7 @@ export const MANDATORY_GUIDANCE_LINES = [
17
17
  "- `search_semantic(query)`: retrieve relevant code chunks. Call BEFORE planning, editing, or answering. Accepts `pathHints` and `languageHints`.",
18
18
  "- `get_file_skeleton(filePath)`: structural overview of a file. Call BEFORE reading any file.",
19
19
  "- `find_usages(symbolName)`: find all references. Call BEFORE editing any function, class, or variable.",
20
- "- `describe_image(filePath)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image.",
20
+ "- `describe_image(filePath, systemPrompt?)`: describe an image file using a vision model. Call when user refers to a screenshot, diagram, or image. Optional `systemPrompt` steers the description toward specific features.",
21
21
  "- `recall_quirks(query)`: query experiential quirk memory (gotchas, preferences, decisions). Call when you hit an error or need to recall known pitfalls.",
22
22
  "- `add_quirk(content)`: store a new experiential memory. Call when you discover a non-obvious fact, gotcha, or coding convention.",
23
23
  "- `update_quirk(id, ...)`: fix an outdated or wrong quirk (content, type, tags, confidence, source ref). The ID is shown in `recall_quirks` output.",
@@ -28,7 +28,7 @@ export const MANDATORY_GUIDANCE_LINES = [
28
28
  "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
29
29
  "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
30
30
  "4. User asks a code question → `search_semantic` to gather context before answering",
31
- "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
31
+ "5. User asks about an image or visual asset → `describe_image(filePath)` (optionally pass `systemPrompt` to focus on specific features) to retrieve its generated description, then optionally `search_semantic` for related code",
32
32
  "6. You encounter an error or need to recall a known pitfall → `recall_quirks(query)`",
33
33
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it for future sessions",
34
34
  "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
@@ -92,7 +92,7 @@ export function buildAgentsMdDirective(opts) {
92
92
  "- **Search first** — `search_semantic(query)` instead of grep/glob",
93
93
  "- **Skeleton before read** — `get_file_skeleton(filePath)` then read specific lines",
94
94
  "- **Usages before edit** — `find_usages(symbolName)` before modifying any symbol",
95
- "- **Images via describe** — `describe_image(filePath)` — never read raw bytes",
95
+ "- **Images via describe** — `describe_image(filePath, systemPrompt?)` — never read raw bytes",
96
96
  "- **Recall quirks** — `recall_quirks(query)` when you hit a known pitfall",
97
97
  "- **Add quirks** — `add_quirk(content)` when you discover a non-obvious fact",
98
98
  "- **Fix quirks** — `update_quirk(id, ...)` / `delete_quirk(id)` when a stored quirk is outdated or wrong",
@@ -104,7 +104,7 @@ export function buildAgentsMdDirective(opts) {
104
104
  "2. User mentions a file path → `get_file_skeleton(filePath)` THEN `read` on specific lines",
105
105
  "3. User mentions a function/class/variable to edit → `find_usages(symbolName)` THEN `search_semantic` THEN `edit`",
106
106
  "4. User asks a code question → `search_semantic` to gather context before answering",
107
- "5. User asks about an image or visual asset → `describe_image(filePath)` to retrieve its generated description, then optionally `search_semantic` for related code",
107
+ "5. User asks about an image or visual asset → `describe_image(filePath)` (optionally pass `systemPrompt` to focus on specific features) to retrieve its generated description, then optionally `search_semantic` for related code",
108
108
  "6. You encounter an error or need to recall a known pitfall → `recall_quirks(query)`",
109
109
  "7. You discover a non-obvious fact or workaround → `add_quirk(content)` to persist it for future sessions",
110
110
  "8. A recalled quirk is outdated or wrong → `update_quirk(id, ...)` to fix it, or `delete_quirk(id)` if it no longer applies",
@@ -256,9 +256,12 @@ export function createDescribeImageTool(options) {
256
256
  description: "Describe an image file using a vision model. " +
257
257
  "Reads the file from disk, sends it to the configured vision provider (Ollama, OpenAI, Anthropic, or Google Gemini), " +
258
258
  "and returns a natural language description of what the image shows. " +
259
+ "Optionally accepts a `systemPrompt` to steer the description toward specific features or details you care about " +
260
+ "(e.g. colors, layout, accessibility, text content, specific UI elements). " +
259
261
  "Use when the user refers to a screenshot, diagram, mockup, or any image in the workspace.",
260
262
  args: {
261
263
  filePath: tool.schema.string().min(1, "An image file path is required."),
264
+ systemPrompt: tool.schema.string().optional(),
262
265
  },
263
266
  async execute(args) {
264
267
  try {
@@ -295,7 +298,7 @@ export function createDescribeImageTool(options) {
295
298
  const sized = maxDimension > 0 ? await resizeImage(buffer, resolvedPath, maxDimension) : buffer;
296
299
  const b64 = sized.toString("base64");
297
300
  const provider = visionProvider ?? createImageVisionProvider(imageDescriptionConfig);
298
- const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt);
301
+ const description = await provider.describeImage(b64, mimeType, imageDescriptionConfig.prompt, args.systemPrompt);
299
302
  return {
300
303
  title: `Image description — ${args.filePath}`,
301
304
  output: `**${args.filePath}**\n\n${description}\n\n_Generated with ${imageDescriptionConfig.provider}/${imageDescriptionConfig.model}_`,
package/dist/web/api.js CHANGED
@@ -602,17 +602,18 @@ function redactKeys(obj) {
602
602
  }
603
603
  }
604
604
  /**
605
- * Project chunk embeddings to 2D via PCA for the Embedding Space Explorer.
606
- * Capped at 5000 chunks and memoized per (storePath, maxChunks) so the
605
+ * Project chunk embeddings to 2D/3D via PCA for the Embedding Space Explorer.
606
+ * Capped at 5000 chunks and memoized per (maxChunks, dims) so the
607
607
  * O(n·dim²) computation does not run on every visit.
608
608
  */
609
609
  let projectionCache = null;
610
610
  async function handleEmbeddingProjection(store, params) {
611
611
  const rawMaxChunks = parseInt(params.get("maxChunks") ?? "5000", 10);
612
612
  const maxChunks = Number.isFinite(rawMaxChunks) ? Math.min(5000, Math.max(1, rawMaxChunks)) : 5000;
613
+ const dims = parseInt(params.get("dims") ?? "2", 10) === 3 ? 3 : 2;
613
614
  try {
614
615
  // Invalidated after a reindex pass completes (see handleReindex)
615
- const cacheKey = `${maxChunks}`;
616
+ const cacheKey = `${maxChunks}:${dims}`;
616
617
  if (projectionCache && projectionCache.key === cacheKey) {
617
618
  return { status: 200, body: projectionCache.body };
618
619
  }
@@ -622,23 +623,31 @@ async function handleEmbeddingProjection(store, params) {
622
623
  return { status: 200, body: projectionCache.body };
623
624
  }
624
625
  if (chunks.length === 1) {
625
- const body = { points: [{ id: chunks[0].id, x: 0.5, y: 0.5, filePath: chunks[0].filePath, startLine: chunks[0].startLine, endLine: chunks[0].endLine, language: chunks[0].language, description: chunks[0].description }], totalChunks: 1, displayedChunks: 1 };
626
+ const point = { id: chunks[0].id, x: 0.5, y: 0.5, filePath: chunks[0].filePath, startLine: chunks[0].startLine, endLine: chunks[0].endLine, language: chunks[0].language, description: chunks[0].description };
627
+ if (dims === 3)
628
+ point.z = 0.5;
629
+ const body = { points: [point], totalChunks: 1, displayedChunks: 1 };
626
630
  projectionCache = { key: cacheKey, body };
627
631
  return { status: 200, body };
628
632
  }
629
633
  const { computePCA } = await import("./pca.js");
630
634
  const vectors = chunks.map(c => c.embedding);
631
- const projected = computePCA(vectors);
632
- const points = chunks.map((c, i) => ({
633
- id: c.id,
634
- x: projected[i].x,
635
- y: projected[i].y,
636
- filePath: c.filePath,
637
- startLine: c.startLine,
638
- endLine: c.endLine,
639
- language: c.language,
640
- description: c.description,
641
- }));
635
+ const projected = computePCA(vectors, dims);
636
+ const points = chunks.map((c, i) => {
637
+ const point = {
638
+ id: c.id,
639
+ x: projected[i].x,
640
+ y: projected[i].y,
641
+ filePath: c.filePath,
642
+ startLine: c.startLine,
643
+ endLine: c.endLine,
644
+ language: c.language,
645
+ description: c.description,
646
+ };
647
+ if (dims === 3)
648
+ point.z = projected[i].z;
649
+ return point;
650
+ });
642
651
  const body = { points, totalChunks: chunks.length, displayedChunks: points.length };
643
652
  projectionCache = { key: cacheKey, body };
644
653
  return { status: 200, body };
package/dist/web/pca.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Self-contained, zero-dependency PCA implementation for 2D embedding projection.
2
+ * Self-contained, zero-dependency PCA implementation for embedding projection.
3
+ * Supports projecting to 2 or 3 dimensions (top-K eigenvectors via power
4
+ * iteration + deflation).
3
5
  */
4
- export declare function computePCA(vectors: number[][]): {
6
+ export declare function computePCA(vectors: number[][], dims?: 2 | 3): {
5
7
  x: number;
6
8
  y: number;
9
+ z?: number;
7
10
  }[];
package/dist/web/pca.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Self-contained, zero-dependency PCA implementation for 2D embedding projection.
2
+ * Self-contained, zero-dependency PCA implementation for embedding projection.
3
+ * Supports projecting to 2 or 3 dimensions (top-K eigenvectors via power
4
+ * iteration + deflation).
3
5
  */
4
- export function computePCA(vectors) {
6
+ export function computePCA(vectors, dims = 2) {
5
7
  const n = vectors.length;
6
8
  if (n === 0)
7
9
  return [];
8
10
  const dim = vectors[0].length;
9
11
  if (n === 1)
10
- return [{ x: 0.5, y: 0.5 }];
12
+ return dims === 3 ? [{ x: 0.5, y: 0.5, z: 0.5 }] : [{ x: 0.5, y: 0.5 }];
11
13
  // 1. Compute column means
12
14
  const means = new Array(dim).fill(0);
13
15
  for (let i = 0; i < n; i++) {
@@ -19,7 +21,9 @@ export function computePCA(vectors) {
19
21
  means[j] /= n;
20
22
  // 2. Center data
21
23
  const centered = vectors.map(v => v.map((val, j) => val - means[j]));
22
- // 3. Compute covariance matrix (dim x dim), upper triangle
24
+ // 3. Compute covariance matrix (dim x dim); fill the upper triangle then
25
+ // mirror it so the matrix is symmetric (power iteration needs a symmetric
26
+ // operator to find the true principal axes).
23
27
  const cov = Array.from({ length: dim }, () => new Array(dim).fill(0));
24
28
  for (let i = 0; i < n; i++) {
25
29
  for (let j = 0; j < dim; j++) {
@@ -31,23 +35,42 @@ export function computePCA(vectors) {
31
35
  for (let j = 0; j < dim; j++) {
32
36
  for (let k = j; k < dim; k++) {
33
37
  cov[j][k] /= n - 1;
38
+ cov[k][j] = cov[j][k];
34
39
  }
35
40
  }
36
- // 4. Power iteration to find top-2 eigenvectors
37
- const pc1 = powerIteration(cov, dim, 50);
38
- // Deflate: subtract PC1's contribution to find PC2
39
- const deflated = cov.map((row, i) => {
40
- const pc1DotRow = pc1.reduce((sum, v, idx) => sum + v * cov[i][idx], 0);
41
- const pc1NormSq = pc1.reduce((sum, v) => sum + v * v, 0);
42
- return row.map((val, j) => val - (pc1DotRow / pc1NormSq) * pc1[j]);
41
+ // 4. Find the top-K eigenvectors: power iteration, then deflate the
42
+ // covariance by each discovered eigenvector before finding the next.
43
+ // Once the remaining matrix is numerically ~zero (degenerate / low-rank
44
+ // input), the rest of the PCs are zero vectors — this keeps PC2/PC3 from
45
+ // picking up deflation noise and avoids NaN from a 0/0 deflation.
46
+ const pcs = [];
47
+ let deflated = cov;
48
+ const threshold = maxAbs(cov) * 1e-12;
49
+ for (let pc = 0; pc < dims; pc++) {
50
+ if (maxAbs(deflated) <= threshold) {
51
+ pcs.push(new Array(dim).fill(0));
52
+ continue;
53
+ }
54
+ const eigen = powerIteration(deflated, dim, 50);
55
+ pcs.push(eigen);
56
+ deflated = deflate(deflated, eigen);
57
+ }
58
+ // 5. Project centered data onto the PCs
59
+ const projected = centered.map(v => {
60
+ const point = { x: 0, y: 0, z: 0 };
61
+ for (let pc = 0; pc < dims; pc++) {
62
+ const s = v.reduce((sum, val, j) => sum + val * pcs[pc][j], 0);
63
+ if (pc === 0)
64
+ point.x = s;
65
+ else if (pc === 1)
66
+ point.y = s;
67
+ else
68
+ point.z = s;
69
+ }
70
+ return point;
43
71
  });
44
- const pc2 = powerIteration(deflated, dim, 50);
45
- // 5. Project centered data onto PCs
46
- const projected = centered.map(v => ({
47
- x: v.reduce((sum, val, j) => sum + val * pc1[j], 0),
48
- y: v.reduce((sum, val, j) => sum + val * pc2[j], 0),
49
- }));
50
- // 6. Normalize to [0, 1]
72
+ // 6. Normalize to [0, 1]. 2D keeps per-axis normalization (unchanged);
73
+ // 3D uses the max extent across all axes so the cube stays proportional.
51
74
  const xs = projected.map(p => p.x);
52
75
  const ys = projected.map(p => p.y);
53
76
  const minX = Math.min(...xs);
@@ -56,11 +79,43 @@ export function computePCA(vectors) {
56
79
  const maxY = Math.max(...ys);
57
80
  const rangeX = maxX - minX || 1;
58
81
  const rangeY = maxY - minY || 1;
82
+ if (dims === 2) {
83
+ return projected.map(p => ({
84
+ x: (p.x - minX) / rangeX,
85
+ y: (p.y - minY) / rangeY,
86
+ }));
87
+ }
88
+ const zs = projected.map(p => p.z);
89
+ const minZ = Math.min(...zs);
90
+ const maxZ = Math.max(...zs);
91
+ const maxRange = Math.max(rangeX, rangeY, maxZ - minZ || 1);
59
92
  return projected.map(p => ({
60
- x: (p.x - minX) / rangeX,
61
- y: (p.y - minY) / rangeY,
93
+ x: (p.x - minX) / maxRange,
94
+ y: (p.y - minY) / maxRange,
95
+ z: (p.z - minZ) / maxRange,
62
96
  }));
63
97
  }
98
+ /** Subtract the outer-product contribution of a principal component from a symmetric matrix. */
99
+ function deflate(matrix, pc) {
100
+ const pcNormSq = pc.reduce((sum, v) => sum + v * v, 0);
101
+ return matrix.map((row, i) => {
102
+ const pcDotRow = pc.reduce((sum, v, idx) => sum + v * matrix[i][idx], 0);
103
+ const scale = pcNormSq > 1e-12 ? pcDotRow / pcNormSq : 0;
104
+ return row.map((val, j) => val - scale * pc[j]);
105
+ });
106
+ }
107
+ /** Largest absolute entry of a matrix. */
108
+ function maxAbs(matrix) {
109
+ let m = 0;
110
+ for (const row of matrix) {
111
+ for (const val of row) {
112
+ const abs = Math.abs(val);
113
+ if (abs > m)
114
+ m = abs;
115
+ }
116
+ }
117
+ return m;
118
+ }
64
119
  /** Power iteration to find the dominant eigenvector of a symmetric matrix. */
65
120
  function powerIteration(matrix, dim, maxIter) {
66
121
  let v = new Array(dim).fill(0).map(() => Math.random() * 2 - 1);