From ac7deb70a822c58774f192f4fa8f5890e146ee27 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Wed, 4 Mar 2026 04:25:54 +0530 Subject: [PATCH 01/29] fix: update version to 1.0.6 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 952a7b6..d84fcb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.5", + "version": "1.0.6", "type": "module", "license": "MIT", "bin": { From 60acdba5d9a2f643a1430f686653e51e033a8f1b Mon Sep 17 00:00:00 2001 From: Daniel Reyes Date: Wed, 4 Mar 2026 14:20:04 -0600 Subject: [PATCH 02/29] Fix: pre-truncate oversized embedding input to prevent Ollama SDK hang The Ollama JS SDK hangs indefinitely (promise never resolves or rejects) when embed input exceeds the model's context window. This means the existing adaptive retry logic in embedSingleAdaptive never fires, since no error is thrown. Pre-truncate input text to 8000 chars before sending to ollama.embed(). This is a conservative limit that works reliably with nomic-embed-text (8192 token context window). The truncation happens at index time so the header and symbol names (most semantically relevant) are preserved while excess file content is trimmed. Reproduces with any project containing large generated files (e.g. GraphQL codegen output, migration snapshots, cloudflare-env.d.ts). --- src/core/embeddings.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 92a0880..3d583d0 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -66,6 +66,7 @@ const DEFAULT_EMBED_BATCH_SIZE = 8; const MIN_EMBED_INPUT_CHARS = 256; const SINGLE_INPUT_SHRINK_FACTOR = 0.75; const MAX_SINGLE_INPUT_RETRIES = 8; +const MAX_EMBED_INPUT_CHARS = 8000; // Conservative limit to avoid Ollama SDK hanging on oversized input const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); @@ -293,7 +294,8 @@ export class SearchIndex { for (let i = 0; i < docs.length; i++) { const doc = docs[i]; - const text = `${doc.header} ${doc.symbols.join(" ")} ${doc.content}`; + const rawText = `${doc.header} ${doc.symbols.join(" ")} ${doc.content}`; + const text = rawText.length > MAX_EMBED_INPUT_CHARS ? rawText.slice(0, MAX_EMBED_INPUT_CHARS) : rawText; const hash = hashContent(text); if (cache[doc.path]?.hash === hash) { From 973e636710724e193469f6dcebf4d938bb496e35 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Thu, 5 Mar 2026 18:01:46 +0530 Subject: [PATCH 03/29] feat: enhance embedding and navigation features with new runtime options and improved file handling --- README.md | 8 ++ src/core/embeddings.ts | 140 +++++++++++++++++++++++--- src/tools/semantic-navigate.ts | 84 +++++++++++++--- src/tools/semantic-search.ts | 68 +++++++++---- test/main/embeddings.test.mjs | 143 +++++++++++++++++++++++++++ test/main/semantic-navigate.test.mjs | 40 ++++++++ test/main/semantic-search.test.mjs | 46 ++++++++- 7 files changed, 476 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 13d7db9..6384767 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,14 @@ Three layers built with TypeScript over stdio using the Model Context Protocol S | `OLLAMA_API_KEY` | - | Ollama Cloud API key | | `OLLAMA_CHAT_MODEL` | `llama3.2` | Chat model for cluster labeling | | `CONTEXTPLUS_EMBED_BATCH_SIZE` | `8` | Embedding batch size per GPU call, clamped to 5-10 | +| `CONTEXTPLUS_EMBED_CHUNK_CHARS` | `2000` | Per-chunk chars before merge, clamped to 256-8000 | +| `CONTEXTPLUS_MAX_EMBED_FILE_SIZE` | `51200` | Skip non-code text files larger than this many bytes | +| `CONTEXTPLUS_EMBED_NUM_GPU` | - | Optional Ollama embed runtime `num_gpu` override | +| `CONTEXTPLUS_EMBED_MAIN_GPU` | - | Optional Ollama embed runtime `main_gpu` override | +| `CONTEXTPLUS_EMBED_NUM_THREAD` | - | Optional Ollama embed runtime `num_thread` override | +| `CONTEXTPLUS_EMBED_NUM_BATCH` | - | Optional Ollama embed runtime `num_batch` override | +| `CONTEXTPLUS_EMBED_NUM_CTX` | - | Optional Ollama embed runtime `num_ctx` override | +| `CONTEXTPLUS_EMBED_LOW_VRAM` | - | Optional Ollama embed runtime `low_vram` override | | `CONTEXTPLUS_EMBED_TRACKER` | `true` | Enable realtime embedding refresh on file changes | | `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | `8` | Max changed files processed per tracker tick, clamped to 5-10 | | `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | `700` | Debounce window before tracker refresh | diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 3d583d0..bc4dce7 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -53,6 +53,15 @@ interface ResolvedSearchQueryOptions { requireSemanticMatch: boolean; } +interface EmbedRuntimeOptions { + num_gpu?: number; + main_gpu?: number; + num_thread?: number; + num_batch?: number; + num_ctx?: number; + low_vram?: boolean; +} + export interface EmbeddingCache { [path: string]: { hash: string; vector: number[] }; } @@ -63,10 +72,12 @@ const CACHE_FILE = "embeddings-cache.json"; const MIN_EMBED_BATCH_SIZE = 5; const MAX_EMBED_BATCH_SIZE = 10; const DEFAULT_EMBED_BATCH_SIZE = 8; -const MIN_EMBED_INPUT_CHARS = 256; +const MIN_EMBED_INPUT_CHARS = 1; const SINGLE_INPUT_SHRINK_FACTOR = 0.75; -const MAX_SINGLE_INPUT_RETRIES = 8; -const MAX_EMBED_INPUT_CHARS = 8000; // Conservative limit to avoid Ollama SDK hanging on oversized input +const MAX_SINGLE_INPUT_RETRIES = 40; +const MIN_EMBED_CHUNK_CHARS = 256; +const DEFAULT_EMBED_CHUNK_CHARS = 2000; +const MAX_EMBED_CHUNK_CHARS = 8000; const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); @@ -76,11 +87,49 @@ function toIntegerOr(value: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function toOptionalInteger(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function toOptionalBoolean(value: string | undefined): boolean | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + return undefined; +} + +function getEmbedRuntimeOptions(): EmbedRuntimeOptions | undefined { + const options: EmbedRuntimeOptions = { + num_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_GPU), + main_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_MAIN_GPU), + num_thread: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_THREAD), + num_batch: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_BATCH), + num_ctx: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_CTX), + low_vram: toOptionalBoolean(process.env.CONTEXTPLUS_EMBED_LOW_VRAM), + }; + + if (Object.values(options).every((value) => value === undefined)) return undefined; + return options; +} + +function buildEmbedRequest(input: string[]): { model: string; input: string[]; options?: EmbedRuntimeOptions } { + const options = getEmbedRuntimeOptions(); + return options ? { model: EMBED_MODEL, input, options } : { model: EMBED_MODEL, input }; +} + export function getEmbeddingBatchSize(): number { const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_BATCH_SIZE, DEFAULT_EMBED_BATCH_SIZE); return Math.min(MAX_EMBED_BATCH_SIZE, Math.max(MIN_EMBED_BATCH_SIZE, requested)); } +export function getEmbedChunkChars(): number { + const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_CHUNK_CHARS, DEFAULT_EMBED_CHUNK_CHARS); + return Math.min(MAX_EMBED_CHUNK_CHARS, Math.max(MIN_EMBED_CHUNK_CHARS, requested)); +} + function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); @@ -104,7 +153,7 @@ async function embedSingleAdaptive(input: string): Promise { for (let attempt = 0; attempt <= MAX_SINGLE_INPUT_RETRIES; attempt++) { try { - const response = await ollama.embed({ model: EMBED_MODEL, input: [candidate] }); + const response = await ollama.embed(buildEmbedRequest([candidate])); if (!response.embeddings[0]) throw new Error("Missing embedding vector in Ollama response"); return response.embeddings[0]; } catch (error) { @@ -120,7 +169,7 @@ async function embedSingleAdaptive(input: string): Promise { async function embedBatchAdaptive(batch: string[]): Promise { try { - const response = await ollama.embed({ model: EMBED_MODEL, input: batch }); + const response = await ollama.embed(buildEmbedRequest(batch)); if (response.embeddings.length !== batch.length) { throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${response.embeddings.length}`); } @@ -137,16 +186,62 @@ async function embedBatchAdaptive(batch: string[]): Promise { } } +function splitEmbeddingInput(input: string): string[] { + const chunkChars = getEmbedChunkChars(); + if (input.length <= chunkChars) return [input]; + const chunks: string[] = []; + for (let start = 0; start < input.length; start += chunkChars) { + chunks.push(input.slice(start, start + chunkChars)); + } + return chunks; +} + +function mergeEmbeddingVectors(vectors: number[][], weights: number[]): number[] { + if (vectors.length === 0) throw new Error("Cannot merge empty embedding vectors"); + if (vectors.length === 1) return vectors[0]; + + const dimension = vectors[0].length; + const merged = new Array(dimension).fill(0); + let totalWeight = 0; + + for (let i = 0; i < vectors.length; i++) { + const vector = vectors[i]; + if (vector.length !== dimension) { + throw new Error(`Embedding dimension mismatch: expected ${dimension}, got ${vector.length}`); + } + const weight = Math.max(1, weights[i] ?? 1); + totalWeight += weight; + for (let d = 0; d < dimension; d++) merged[d] += vector[d] * weight; + } + + if (totalWeight <= 0) return vectors[0]; + for (let d = 0; d < merged.length; d++) merged[d] /= totalWeight; + return merged; +} + export async function fetchEmbedding(input: string | string[]): Promise { const inputs = Array.isArray(input) ? input : [input]; if (inputs.length === 0) return []; + const chunkedInputs = inputs.map(splitEmbeddingInput); + const flattenedInputs = chunkedInputs.flat(); const batchSize = getEmbeddingBatchSize(); - const embeddings: number[][] = []; + const flattenedEmbeddings: number[][] = []; - for (let i = 0; i < inputs.length; i += batchSize) { - const batch = inputs.slice(i, i + batchSize); - embeddings.push(...await embedBatchAdaptive(batch)); + for (let i = 0; i < flattenedInputs.length; i += batchSize) { + const batch = flattenedInputs.slice(i, i + batchSize); + flattenedEmbeddings.push(...await embedBatchAdaptive(batch)); + } + + const embeddings: number[][] = []; + let offset = 0; + for (const chunks of chunkedInputs) { + const vectors = flattenedEmbeddings.slice(offset, offset + chunks.length); + if (vectors.length !== chunks.length) { + throw new Error(`Merged embedding size mismatch: expected ${chunks.length}, got ${vectors.length}`); + } + embeddings.push(mergeEmbeddingVectors(vectors, chunks.map((chunk) => chunk.length))); + offset += chunks.length; } return embeddings; @@ -295,13 +390,12 @@ export class SearchIndex { for (let i = 0; i < docs.length; i++) { const doc = docs[i]; const rawText = `${doc.header} ${doc.symbols.join(" ")} ${doc.content}`; - const text = rawText.length > MAX_EMBED_INPUT_CHARS ? rawText.slice(0, MAX_EMBED_INPUT_CHARS) : rawText; - const hash = hashContent(text); + const hash = hashContent(rawText); if (cache[doc.path]?.hash === hash) { this.vectors[i] = cache[doc.path].vector; } else { - uncached.push({ idx: i, text, hash }); + uncached.push({ idx: i, text: rawText, hash }); } } @@ -309,10 +403,24 @@ export class SearchIndex { const batchSize = getEmbeddingBatchSize(); for (let b = 0; b < uncached.length; b += batchSize) { const batch = uncached.slice(b, b + batchSize); - const embeddings = await fetchEmbedding(batch.map((u) => u.text)); - for (let j = 0; j < batch.length; j++) { - this.vectors[batch[j].idx] = embeddings[j]; - cache[docs[batch[j].idx].path] = { hash: batch[j].hash, vector: embeddings[j] }; + try { + const embeddings = await fetchEmbedding(batch.map((u) => u.text)); + for (let j = 0; j < batch.length; j++) { + this.vectors[batch[j].idx] = embeddings[j]; + cache[docs[batch[j].idx].path] = { hash: batch[j].hash, vector: embeddings[j] }; + } + } catch (error) { + if (!isContextLengthError(error)) throw error; + for (const item of batch) { + try { + const [vector] = await fetchEmbedding(item.text); + this.vectors[item.idx] = vector; + cache[docs[item.idx].path] = { hash: item.hash, vector }; + } catch (itemError) { + if (!isContextLengthError(itemError)) throw itemError; + delete cache[docs[item.idx].path]; + } + } } } await saveCache(rootDir, cache); diff --git a/src/tools/semantic-navigate.ts b/src/tools/semantic-navigate.ts index 0d45b65..cc3dc30 100644 --- a/src/tools/semantic-navigate.ts +++ b/src/tools/semantic-navigate.ts @@ -7,6 +7,7 @@ import { analyzeFile, flattenSymbols, isSupportedFile } from "../core/parser.js" import { fetchEmbedding } from "../core/embeddings.js"; import { readFile } from "fs/promises"; import { spectralCluster, findPathPattern } from "../core/clustering.js"; +import { extname } from "path"; export interface SemanticNavigateOptions { rootDir: string; @@ -31,6 +32,19 @@ interface ClusterNode { const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; const CHAT_MODEL = process.env.OLLAMA_CHAT_MODEL ?? "llama3.2"; const MAX_FILES_PER_LEAF = 20; +const NON_CODE_NAVIGATE_EXTENSIONS = new Set([ + ".json", + ".jsonc", + ".geojson", + ".csv", + ".tsv", + ".ndjson", + ".yaml", + ".yml", + ".toml", + ".lock", + ".env", +]); const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); @@ -38,6 +52,10 @@ async function fetchEmbeddings(inputs: string[]): Promise { return fetchEmbedding(inputs); } +function isNavigableSourceCandidate(filePath: string): boolean { + return isSupportedFile(filePath) && !NON_CODE_NAVIGATE_EXTENSIONS.has(extname(filePath).toLowerCase()); +} + async function chatCompletion(prompt: string): Promise { const response = await ollama.chat({ model: CHAT_MODEL, @@ -47,6 +65,30 @@ async function chatCompletion(prompt: string): Promise { return response.message.content; } +async function embedFilesWithFallback(files: FileInfo[]): Promise<{ files: FileInfo[]; vectors: number[][]; skipped: number }> { + if (files.length === 0) return { files: [], vectors: [], skipped: 0 }; + const texts = files.map((file) => `${file.header} ${file.relativePath} ${file.content}`); + + try { + return { files, vectors: await fetchEmbeddings(texts), skipped: 0 }; + } catch (error) { + const keptFiles: FileInfo[] = []; + const vectors: number[][] = []; + + for (let i = 0; i < files.length; i++) { + try { + const [vector] = await fetchEmbeddings([texts[i]]); + keptFiles.push(files[i]); + vectors.push(vector); + } catch { + } + } + + if (keptFiles.length === 0) throw error; + return { files: keptFiles, vectors, skipped: files.length - keptFiles.length }; + } +} + function extractHeader(content: string): string { const lines = content.split("\n"); const headerLines: string[] = []; @@ -175,7 +217,7 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis const maxDepth = options.maxDepth ?? 3; const entries = await walkDirectory({ rootDir: options.rootDir, depthLimit: 0 }); - const fileEntries = entries.filter((e) => !e.isDirectory && isSupportedFile(e.path)); + const fileEntries = entries.filter((e) => !e.isDirectory && isNavigableSourceCandidate(e.path)); if (fileEntries.length === 0) return "No supported source files found in the project."; @@ -205,36 +247,48 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis if (files.length === 0) return "Could not read any source files."; - const embedTexts = files.map((f) => `${f.header} ${f.relativePath} ${f.content}`); - - let vectors: number[][]; + let embeddableFiles: FileInfo[] = files; + let vectors: number[][] = []; + let skippedForEmbedding = 0; try { - vectors = await fetchEmbeddings(embedTexts); + const embedded = await embedFilesWithFallback(files); + embeddableFiles = embedded.files; + vectors = embedded.vectors; + skippedForEmbedding = embedded.skipped; } catch (err) { return `Ollama not available for embeddings: ${err instanceof Error ? err.message : String(err)}\nMake sure Ollama is running or signed in (ollama signin) with model ${EMBED_MODEL}.`; } - if (files.length <= MAX_FILES_PER_LEAF) { + if (embeddableFiles.length === 0) return "No embeddable source files found in the project."; + + if (embeddableFiles.length <= MAX_FILES_PER_LEAF) { let fileLabels: string[]; try { - const prompt = `For each file below, produce a 3-7 word description. Return ONLY a JSON array of strings.\n\n${files.map((f) => `${f.relativePath}: ${f.header}`).join("\n")}`; + const prompt = `For each file below, produce a 3-7 word description. Return ONLY a JSON array of strings.\n\n${embeddableFiles.map((f) => `${f.relativePath}: ${f.header}`).join("\n")}`; const response = await chatCompletion(prompt); const match = response.match(/\[[\s\S]*\]/); - fileLabels = match ? JSON.parse(match[0]) : files.map((f) => f.header); + fileLabels = match ? JSON.parse(match[0]) : embeddableFiles.map((f) => f.header); } catch { - fileLabels = files.map((f) => f.header); + fileLabels = embeddableFiles.map((f) => f.header); } - const lines = [`Semantic Navigator: ${files.length} files\n`]; - for (let i = 0; i < files.length; i++) { - const symbols = files[i].symbolPreview.length > 0 ? ` | symbols: ${files[i].symbolPreview.join(", ")}` : ""; - lines.push(` ${files[i].relativePath} - ${fileLabels[i] || files[i].header}${symbols}`); + const summary = skippedForEmbedding > 0 + ? `Semantic Navigator: ${embeddableFiles.length} files (${skippedForEmbedding} skipped due embedding limits)\n` + : `Semantic Navigator: ${embeddableFiles.length} files\n`; + const lines = [summary]; + for (let i = 0; i < embeddableFiles.length; i++) { + const symbols = embeddableFiles[i].symbolPreview.length > 0 ? ` | symbols: ${embeddableFiles[i].symbolPreview.join(", ")}` : ""; + lines.push(` ${embeddableFiles[i].relativePath} - ${fileLabels[i] || embeddableFiles[i].header}${symbols}`); } return lines.join("\n"); } - const tree = await buildHierarchy(files, vectors, maxClusters, 0, maxDepth); + const tree = await buildHierarchy(embeddableFiles, vectors, maxClusters, 0, maxDepth); tree.label = "Project"; - return `Semantic Navigator: ${files.length} files organized by meaning\n\n${renderClusterTree(tree)}`; + const summary = skippedForEmbedding > 0 + ? `Semantic Navigator: ${embeddableFiles.length} files organized by meaning (${skippedForEmbedding} skipped due embedding limits)` + : `Semantic Navigator: ${embeddableFiles.length} files organized by meaning`; + + return `${summary}\n\n${renderClusterTree(tree)}`; } diff --git a/src/tools/semantic-search.ts b/src/tools/semantic-search.ts index 2c67176..c511e81 100644 --- a/src/tools/semantic-search.ts +++ b/src/tools/semantic-search.ts @@ -12,7 +12,7 @@ import { type SearchDocument, type SearchQueryOptions, } from "../core/embeddings.js"; -import { readFile } from "fs/promises"; +import { readFile, stat } from "fs/promises"; import { extname, resolve } from "path"; export interface SemanticSearchOptions { @@ -34,13 +34,38 @@ let lastIndexTime = 0; const INDEX_TTL_MS = 60000; const SEARCH_CACHE_FILE = "embeddings-cache.json"; -const TEXT_INDEX_EXTENSIONS = new Set([".md", ".txt", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".lock", ".env"]); +const TEXT_INDEX_EXTENSIONS = new Set([ + ".md", + ".txt", + ".json", + ".jsonc", + ".geojson", + ".csv", + ".tsv", + ".ndjson", + ".yaml", + ".yml", + ".toml", + ".lock", + ".env", +]); const MAX_TEXT_DOC_CHARS = 4000; +const DEFAULT_MAX_EMBED_FILE_SIZE = 50 * 1024; function isTextIndexCandidate(filePath: string): boolean { return TEXT_INDEX_EXTENSIONS.has(extname(filePath).toLowerCase()); } +function toIntegerOr(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function getMaxEmbedFileSize(): number { + return Math.max(1024, toIntegerOr(process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE, DEFAULT_MAX_EMBED_FILE_SIZE)); +} + function extractPlainTextHeader(content: string): string { const lines = content.split("\n"); const headerLines: string[] = []; @@ -67,38 +92,39 @@ async function buildSearchDocumentForFile(rootDir: string, relativePath: string) const normalized = normalizeRelativePath(relativePath); const fullPath = resolve(rootDir, normalized); - if (isSupportedFile(fullPath)) { + if (isTextIndexCandidate(fullPath)) { try { - const analysis = await analyzeFile(fullPath); - const flatSymbols = flattenSymbols(analysis.symbols); + if ((await stat(fullPath)).size > getMaxEmbedFileSize()) return null; + const raw = await readFile(fullPath, "utf-8"); + const content = raw.slice(0, MAX_TEXT_DOC_CHARS); return { path: normalized, - header: analysis.header, - symbols: flatSymbols.map((s) => s.name), - symbolEntries: flatSymbols.map((s) => ({ - name: s.name, - kind: s.kind, - line: s.line, - endLine: s.endLine, - signature: s.signature, - })), - content: flatSymbols.map((s) => s.signature).join(" "), + header: extractPlainTextHeader(content), + symbols: [], + content, }; } catch { return null; } } - if (!isTextIndexCandidate(fullPath)) return null; + if (!isSupportedFile(fullPath)) return null; try { - const raw = await readFile(fullPath, "utf-8"); - const content = raw.slice(0, MAX_TEXT_DOC_CHARS); + const analysis = await analyzeFile(fullPath); + const flatSymbols = flattenSymbols(analysis.symbols); return { path: normalized, - header: extractPlainTextHeader(content), - symbols: [], - content, + header: analysis.header, + symbols: flatSymbols.map((s) => s.name), + symbolEntries: flatSymbols.map((s) => ({ + name: s.name, + kind: s.kind, + line: s.line, + endLine: s.endLine, + signature: s.signature, + })), + content: flatSymbols.map((s) => s.signature).join(" "), }; } catch { return null; diff --git a/test/main/embeddings.test.mjs b/test/main/embeddings.test.mjs index 82c14ad..be22c25 100644 --- a/test/main/embeddings.test.mjs +++ b/test/main/embeddings.test.mjs @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { Ollama } from "ollama"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { SearchIndex, fetchEmbedding, @@ -40,6 +43,49 @@ describe("embeddings", () => { const index = new SearchIndex(); assert.equal(typeof index.getDocumentCount, "function"); }); + + it("re-embeds when content changes beyond first 8000 characters", async () => { + const originalEmbed = Ollama.prototype.embed; + const rootDir = await mkdtemp(join(tmpdir(), "contextplus-embed-")); + let callCount = 0; + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + for (const value of batch) { + if (value.length > 8000) + throw new Error("input length exceeds context length"); + } + callCount += batch.length; + return { embeddings: batch.map(() => [1, 0, 0]) }; + }; + + try { + const index = new SearchIndex(); + const sharedPrefix = "x".repeat(8500); + const firstDoc = [{ + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_one`, + }]; + const secondDoc = [{ + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_two`, + }]; + + await index.index(firstDoc, rootDir); + const firstPassCalls = callCount; + assert.ok(firstPassCalls > 0); + + callCount = 0; + await index.index(secondDoc, rootDir); + assert.ok(callCount > 0); + } finally { + Ollama.prototype.embed = originalEmbed; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); describe("fetchEmbedding", () => { @@ -85,5 +131,102 @@ describe("embeddings", () => { Ollama.prototype.embed = originalEmbed; } }); + + it("splits oversized text into chunks and merges vectors", async () => { + const originalEmbed = Ollama.prototype.embed; + const tailMarker = "__tail_marker__"; + const seenLengths = []; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + for (const value of batch) { + seenLengths.push(value.length); + if (value.length > 8000) + throw new Error("input length exceeds context length"); + } + return { + embeddings: batch.map((value) => [value.includes(tailMarker) ? 10 : 1]), + }; + }; + + try { + const vectors = await fetchEmbedding(`${"a".repeat(9000)}${tailMarker}${"b".repeat(1000)}`); + assert.equal(vectors.length, 1); + assert.ok(vectors[0][0] > 1); + assert.ok(seenLengths.every((length) => length <= 8000)); + assert.ok(seenLengths.length > 1); + } finally { + Ollama.prototype.embed = originalEmbed; + } + }); + + it("keeps shrinking under strict context limits beyond eight retries", async () => { + const originalEmbed = Ollama.prototype.embed; + const seenLengths = []; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + seenLengths.push(...batch.map((value) => value.length)); + if (batch.some((value) => value.length > 20)) { + throw new Error("input length exceeds context length"); + } + return { embeddings: batch.map((value) => [value.length]) }; + }; + + try { + const vectors = await fetchEmbedding("x".repeat(8000)); + assert.equal(vectors.length, 1); + assert.ok(vectors[0][0] <= 20); + assert.ok(seenLengths.length > 9); + } finally { + Ollama.prototype.embed = originalEmbed; + } + }); + + it("forwards configured embed runtime options to Ollama", async () => { + const originalEmbed = Ollama.prototype.embed; + const previousEnv = { + CONTEXTPLUS_EMBED_NUM_GPU: process.env.CONTEXTPLUS_EMBED_NUM_GPU, + CONTEXTPLUS_EMBED_MAIN_GPU: process.env.CONTEXTPLUS_EMBED_MAIN_GPU, + CONTEXTPLUS_EMBED_NUM_THREAD: process.env.CONTEXTPLUS_EMBED_NUM_THREAD, + CONTEXTPLUS_EMBED_NUM_BATCH: process.env.CONTEXTPLUS_EMBED_NUM_BATCH, + CONTEXTPLUS_EMBED_NUM_CTX: process.env.CONTEXTPLUS_EMBED_NUM_CTX, + CONTEXTPLUS_EMBED_LOW_VRAM: process.env.CONTEXTPLUS_EMBED_LOW_VRAM, + }; + const requests = []; + + process.env.CONTEXTPLUS_EMBED_NUM_GPU = "1"; + process.env.CONTEXTPLUS_EMBED_MAIN_GPU = "0"; + process.env.CONTEXTPLUS_EMBED_NUM_THREAD = "6"; + process.env.CONTEXTPLUS_EMBED_NUM_BATCH = "64"; + process.env.CONTEXTPLUS_EMBED_NUM_CTX = "4096"; + process.env.CONTEXTPLUS_EMBED_LOW_VRAM = "true"; + + Ollama.prototype.embed = async function (request) { + requests.push(request); + const batch = Array.isArray(request.input) ? request.input : [request.input]; + return { embeddings: batch.map((value) => [value.length]) }; + }; + + try { + const vectors = await fetchEmbedding("gpu options probe"); + assert.equal(vectors.length, 1); + assert.ok(requests.length > 0); + assert.deepEqual(requests[0].options, { + num_gpu: 1, + main_gpu: 0, + num_thread: 6, + num_batch: 64, + num_ctx: 4096, + low_vram: true, + }); + } finally { + Ollama.prototype.embed = originalEmbed; + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); }); diff --git a/test/main/semantic-navigate.test.mjs b/test/main/semantic-navigate.test.mjs index 822a7ee..596dff6 100644 --- a/test/main/semantic-navigate.test.mjs +++ b/test/main/semantic-navigate.test.mjs @@ -3,6 +3,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Ollama } from "ollama"; describe("semantic-navigate", () => { it("exports semanticNavigate as a function", async () => { @@ -14,4 +18,40 @@ describe("semantic-navigate", () => { const mod = await import("../../build/tools/semantic-navigate.js"); assert.equal(mod.semanticNavigate.length, 1); }); + + it("skips data files and navigates source files", async () => { + const { semanticNavigate } = await import("../../build/tools/semantic-navigate.js"); + const rootDir = await mkdtemp(join(tmpdir(), "contextplus-semantic-navigate-")); + const originalEmbed = Ollama.prototype.embed; + const originalChat = Ollama.prototype.chat; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + return { embeddings: batch.map(() => [1, 0, 0]) }; + }; + Ollama.prototype.chat = async function () { + return { message: { content: JSON.stringify(["Source file"]) } }; + }; + + try { + await writeFile(join(rootDir, "app.ts"), [ + "// Semantic navigate fixture header line one two three four", + "// FEATURE: semantic navigate fixture for source-only clustering output", + "export const meaning = 42;", + "", + ].join("\n")); + await writeFile( + join(rootDir, "data.json"), + JSON.stringify({ rows: Array.from({ length: 10000 }, (_, idx) => ({ id: idx, value: `cell_${idx}` })) }), + ); + + const result = await semanticNavigate({ rootDir, maxDepth: 2, maxClusters: 5 }); + assert.match(result, /app\.ts/); + assert.doesNotMatch(result, /data\.json/); + } finally { + Ollama.prototype.embed = originalEmbed; + Ollama.prototype.chat = originalChat; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/main/semantic-search.test.mjs b/test/main/semantic-search.test.mjs index cf96404..bb2209b 100644 --- a/test/main/semantic-search.test.mjs +++ b/test/main/semantic-search.test.mjs @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { invalidateSearchCache } from "../../build/tools/semantic-search.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Ollama } from "ollama"; +import { invalidateSearchCache, semanticCodeSearch } from "../../build/tools/semantic-search.js"; describe("semantic-search", () => { describe("invalidateSearchCache", () => { @@ -30,5 +34,45 @@ describe("semantic-search", () => { const mod = await import("../../build/tools/semantic-search.js"); assert.equal(mod.semanticCodeSearch.length, 1); }); + + it("skips oversized data files and still indexes source files", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "contextplus-semantic-search-")); + const originalEmbed = Ollama.prototype.embed; + const previousSizeLimit = process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; + + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + return { embeddings: batch.map((value) => [value.includes("greet") ? 1 : 0.25]) }; + }; + + process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = "1024"; + + try { + await writeFile(join(rootDir, "app.ts"), [ + "// Semantic search fixture header line one two three four", + "// FEATURE: semantic search fixture coverage for mixed data projects", + "export function greet(name: string): string {", + " return `hello ${name}`;", + "}", + "", + ].join("\n")); + await writeFile( + join(rootDir, "data.json"), + JSON.stringify({ + rows: Array.from({ length: 50000 }, (_, idx) => ({ id: idx, value: `payload_${idx}` })), + }), + ); + + invalidateSearchCache(); + const result = await semanticCodeSearch({ rootDir, query: "greet", topK: 3 }); + assert.match(result, /app\.ts/); + assert.doesNotMatch(result, /data\.json/); + } finally { + if (previousSizeLimit === undefined) delete process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; + else process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = previousSizeLimit; + Ollama.prototype.embed = originalEmbed; + await rm(rootDir, { recursive: true, force: true }); + } + }); }); }); From 49ef66d9f11c85f273a226c86033bcd4e18cd06a Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Thu, 5 Mar 2026 18:03:06 +0530 Subject: [PATCH 04/29] fix: improve code formatting and readability in tests and documentation --- README.md | 16 +++++----- test/main/embeddings.test.mjs | 40 ++++++++++++++--------- test/main/semantic-navigate.test.mjs | 35 ++++++++++++++------ test/main/semantic-search.test.mjs | 48 ++++++++++++++++++++-------- 4 files changed, 92 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 6384767..7860772 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,14 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ### Memory & RAG -| Tool | Description | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | -| `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | -| `search_memory_graph` | Semantic search with graph traversal — finds direct matches then walks 1st/2nd-degree neighbors. | -| `prune_stale_links` | Remove decayed edges (e^(-λt) below threshold) and orphan nodes with low access counts. | -| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | -| `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | +| Tool | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------- | +| `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | +| `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | +| `search_memory_graph` | Semantic search with graph traversal — finds direct matches then walks 1st/2nd-degree neighbors. | +| `prune_stale_links` | Remove decayed edges (e^(-λt) below threshold) and orphan nodes with low access counts. | +| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | +| `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | ## Setup diff --git a/test/main/embeddings.test.mjs b/test/main/embeddings.test.mjs index be22c25..3aedbd2 100644 --- a/test/main/embeddings.test.mjs +++ b/test/main/embeddings.test.mjs @@ -61,18 +61,22 @@ describe("embeddings", () => { try { const index = new SearchIndex(); const sharedPrefix = "x".repeat(8500); - const firstDoc = [{ - path: "src/long.ts", - header: "header", - symbols: ["alpha"], - content: `${sharedPrefix} tail_one`, - }]; - const secondDoc = [{ - path: "src/long.ts", - header: "header", - symbols: ["alpha"], - content: `${sharedPrefix} tail_two`, - }]; + const firstDoc = [ + { + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_one`, + }, + ]; + const secondDoc = [ + { + path: "src/long.ts", + header: "header", + symbols: ["alpha"], + content: `${sharedPrefix} tail_two`, + }, + ]; await index.index(firstDoc, rootDir); const firstPassCalls = callCount; @@ -145,12 +149,16 @@ describe("embeddings", () => { throw new Error("input length exceeds context length"); } return { - embeddings: batch.map((value) => [value.includes(tailMarker) ? 10 : 1]), + embeddings: batch.map((value) => [ + value.includes(tailMarker) ? 10 : 1, + ]), }; }; try { - const vectors = await fetchEmbedding(`${"a".repeat(9000)}${tailMarker}${"b".repeat(1000)}`); + const vectors = await fetchEmbedding( + `${"a".repeat(9000)}${tailMarker}${"b".repeat(1000)}`, + ); assert.equal(vectors.length, 1); assert.ok(vectors[0][0] > 1); assert.ok(seenLengths.every((length) => length <= 8000)); @@ -204,7 +212,9 @@ describe("embeddings", () => { Ollama.prototype.embed = async function (request) { requests.push(request); - const batch = Array.isArray(request.input) ? request.input : [request.input]; + const batch = Array.isArray(request.input) + ? request.input + : [request.input]; return { embeddings: batch.map((value) => [value.length]) }; }; diff --git a/test/main/semantic-navigate.test.mjs b/test/main/semantic-navigate.test.mjs index 596dff6..3ee9fe9 100644 --- a/test/main/semantic-navigate.test.mjs +++ b/test/main/semantic-navigate.test.mjs @@ -20,8 +20,11 @@ describe("semantic-navigate", () => { }); it("skips data files and navigates source files", async () => { - const { semanticNavigate } = await import("../../build/tools/semantic-navigate.js"); - const rootDir = await mkdtemp(join(tmpdir(), "contextplus-semantic-navigate-")); + const { semanticNavigate } = + await import("../../build/tools/semantic-navigate.js"); + const rootDir = await mkdtemp( + join(tmpdir(), "contextplus-semantic-navigate-"), + ); const originalEmbed = Ollama.prototype.embed; const originalChat = Ollama.prototype.chat; @@ -34,18 +37,30 @@ describe("semantic-navigate", () => { }; try { - await writeFile(join(rootDir, "app.ts"), [ - "// Semantic navigate fixture header line one two three four", - "// FEATURE: semantic navigate fixture for source-only clustering output", - "export const meaning = 42;", - "", - ].join("\n")); + await writeFile( + join(rootDir, "app.ts"), + [ + "// Semantic navigate fixture header line one two three four", + "// FEATURE: semantic navigate fixture for source-only clustering output", + "export const meaning = 42;", + "", + ].join("\n"), + ); await writeFile( join(rootDir, "data.json"), - JSON.stringify({ rows: Array.from({ length: 10000 }, (_, idx) => ({ id: idx, value: `cell_${idx}` })) }), + JSON.stringify({ + rows: Array.from({ length: 10000 }, (_, idx) => ({ + id: idx, + value: `cell_${idx}`, + })), + }), ); - const result = await semanticNavigate({ rootDir, maxDepth: 2, maxClusters: 5 }); + const result = await semanticNavigate({ + rootDir, + maxDepth: 2, + maxClusters: 5, + }); assert.match(result, /app\.ts/); assert.doesNotMatch(result, /data\.json/); } finally { diff --git a/test/main/semantic-search.test.mjs b/test/main/semantic-search.test.mjs index bb2209b..054ddf8 100644 --- a/test/main/semantic-search.test.mjs +++ b/test/main/semantic-search.test.mjs @@ -4,7 +4,10 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Ollama } from "ollama"; -import { invalidateSearchCache, semanticCodeSearch } from "../../build/tools/semantic-search.js"; +import { + invalidateSearchCache, + semanticCodeSearch, +} from "../../build/tools/semantic-search.js"; describe("semantic-search", () => { describe("invalidateSearchCache", () => { @@ -36,39 +39,56 @@ describe("semantic-search", () => { }); it("skips oversized data files and still indexes source files", async () => { - const rootDir = await mkdtemp(join(tmpdir(), "contextplus-semantic-search-")); + const rootDir = await mkdtemp( + join(tmpdir(), "contextplus-semantic-search-"), + ); const originalEmbed = Ollama.prototype.embed; const previousSizeLimit = process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; Ollama.prototype.embed = async function ({ input }) { const batch = Array.isArray(input) ? input : [input]; - return { embeddings: batch.map((value) => [value.includes("greet") ? 1 : 0.25]) }; + return { + embeddings: batch.map((value) => [ + value.includes("greet") ? 1 : 0.25, + ]), + }; }; process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = "1024"; try { - await writeFile(join(rootDir, "app.ts"), [ - "// Semantic search fixture header line one two three four", - "// FEATURE: semantic search fixture coverage for mixed data projects", - "export function greet(name: string): string {", - " return `hello ${name}`;", - "}", - "", - ].join("\n")); + await writeFile( + join(rootDir, "app.ts"), + [ + "// Semantic search fixture header line one two three four", + "// FEATURE: semantic search fixture coverage for mixed data projects", + "export function greet(name: string): string {", + " return `hello ${name}`;", + "}", + "", + ].join("\n"), + ); await writeFile( join(rootDir, "data.json"), JSON.stringify({ - rows: Array.from({ length: 50000 }, (_, idx) => ({ id: idx, value: `payload_${idx}` })), + rows: Array.from({ length: 50000 }, (_, idx) => ({ + id: idx, + value: `payload_${idx}`, + })), }), ); invalidateSearchCache(); - const result = await semanticCodeSearch({ rootDir, query: "greet", topK: 3 }); + const result = await semanticCodeSearch({ + rootDir, + query: "greet", + topK: 3, + }); assert.match(result, /app\.ts/); assert.doesNotMatch(result, /data\.json/); } finally { - if (previousSizeLimit === undefined) delete process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; + if (previousSizeLimit === undefined) + delete process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE; else process.env.CONTEXTPLUS_MAX_EMBED_FILE_SIZE = previousSizeLimit; Ollama.prototype.embed = originalEmbed; await rm(rootDir, { recursive: true, force: true }); From 19542b6f7fc086272f3f9a680951b9ac635794c3 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Fri, 6 Mar 2026 00:03:44 +0530 Subject: [PATCH 05/29] fix: update README.md to specify variable types for configuration options --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7860772..a00a5ad 100644 --- a/README.md +++ b/README.md @@ -146,23 +146,23 @@ Three layers built with TypeScript over stdio using the Model Context Protocol S ## Config -| Variable | Default | Description | -| --------------------------------------- | ------------------ | ------------------------------------------------------------- | -| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | Embedding model | -| `OLLAMA_API_KEY` | - | Ollama Cloud API key | -| `OLLAMA_CHAT_MODEL` | `llama3.2` | Chat model for cluster labeling | -| `CONTEXTPLUS_EMBED_BATCH_SIZE` | `8` | Embedding batch size per GPU call, clamped to 5-10 | -| `CONTEXTPLUS_EMBED_CHUNK_CHARS` | `2000` | Per-chunk chars before merge, clamped to 256-8000 | -| `CONTEXTPLUS_MAX_EMBED_FILE_SIZE` | `51200` | Skip non-code text files larger than this many bytes | -| `CONTEXTPLUS_EMBED_NUM_GPU` | - | Optional Ollama embed runtime `num_gpu` override | -| `CONTEXTPLUS_EMBED_MAIN_GPU` | - | Optional Ollama embed runtime `main_gpu` override | -| `CONTEXTPLUS_EMBED_NUM_THREAD` | - | Optional Ollama embed runtime `num_thread` override | -| `CONTEXTPLUS_EMBED_NUM_BATCH` | - | Optional Ollama embed runtime `num_batch` override | -| `CONTEXTPLUS_EMBED_NUM_CTX` | - | Optional Ollama embed runtime `num_ctx` override | -| `CONTEXTPLUS_EMBED_LOW_VRAM` | - | Optional Ollama embed runtime `low_vram` override | -| `CONTEXTPLUS_EMBED_TRACKER` | `true` | Enable realtime embedding refresh on file changes | -| `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | `8` | Max changed files processed per tracker tick, clamped to 5-10 | -| `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | `700` | Debounce window before tracker refresh | +| Variable | Type | Default | Description | +| --------------------------------------- | ------------------------- | ------------------ | ------------------------------------------------------------- | +| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Embedding model | +| `OLLAMA_API_KEY` | string | - | Ollama Cloud API key | +| `OLLAMA_CHAT_MODEL` | string | `llama3.2` | Chat model for cluster labeling | +| `CONTEXTPLUS_EMBED_BATCH_SIZE` | string (parsed as number) | `8` | Embedding batch size per GPU call, clamped to 5-10 | +| `CONTEXTPLUS_EMBED_CHUNK_CHARS` | string (parsed as number) | `2000` | Per-chunk chars before merge, clamped to 256-8000 | +| `CONTEXTPLUS_MAX_EMBED_FILE_SIZE` | string (parsed as number) | `51200` | Skip non-code text files larger than this many bytes | +| `CONTEXTPLUS_EMBED_NUM_GPU` | string (parsed as number) | - | Optional Ollama embed runtime `num_gpu` override | +| `CONTEXTPLUS_EMBED_MAIN_GPU` | string (parsed as number) | - | Optional Ollama embed runtime `main_gpu` override | +| `CONTEXTPLUS_EMBED_NUM_THREAD` | string (parsed as number) | - | Optional Ollama embed runtime `num_thread` override | +| `CONTEXTPLUS_EMBED_NUM_BATCH` | string (parsed as number) | - | Optional Ollama embed runtime `num_batch` override | +| `CONTEXTPLUS_EMBED_NUM_CTX` | string (parsed as number) | - | Optional Ollama embed runtime `num_ctx` override | +| `CONTEXTPLUS_EMBED_LOW_VRAM` | string (parsed as boolean)| - | Optional Ollama embed runtime `low_vram` override | +| `CONTEXTPLUS_EMBED_TRACKER` | string (parsed as boolean)| `true` | Enable realtime embedding refresh on file changes | +| `CONTEXTPLUS_EMBED_TRACKER_MAX_FILES` | string (parsed as number) | `8` | Max changed files processed per tracker tick, clamped to 5-10 | +| `CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS` | string (parsed as number) | `700` | Debounce window before tracker refresh | ## Test From dec1e74daaa773a6ceabd4855945b977344e51e2 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Fri, 6 Mar 2026 12:54:18 +0530 Subject: [PATCH 06/29] fix: bump version to 1.0.7 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d84fcb3..4dd7617 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.6", + "version": "1.0.7", "type": "module", "license": "MIT", "bin": { From 85ed3b8d91bbce65a6e4019b11d0cb9b88511110 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Tue, 10 Mar 2026 23:09:43 +0530 Subject: [PATCH 07/29] feat: implement embedding tracker controller with lazy and eager modes, enhance process lifecycle management, and add related tests --- package-lock.json | 11 ++- src/core/embedding-tracker.ts | 47 +++++++++ src/core/process-lifecycle.ts | 114 +++++++++++++++++++++ src/index.ts | 142 +++++++++++++++++---------- test/main/embedding-tracker.test.mjs | 71 +++++++++++++- test/main/process-lifecycle.test.mjs | 91 +++++++++++++++++ 6 files changed, 420 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index d814935..d8a05e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "contextual", - "version": "1.0.0", + "name": "contextplus", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "contextual", - "version": "1.0.0", + "name": "contextplus", + "version": "1.0.7", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "ignore": "^7.0.4", @@ -18,7 +19,7 @@ "zod": "^3.25.23" }, "bin": { - "contextual": "build/index.js" + "contextplus": "build/index.js" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", diff --git a/src/core/embedding-tracker.ts b/src/core/embedding-tracker.ts index 49b31ae..b49184f 100644 --- a/src/core/embedding-tracker.ts +++ b/src/core/embedding-tracker.ts @@ -11,6 +11,17 @@ export interface EmbeddingTrackerOptions { maxFilesPerTick?: number; } +export interface EmbeddingTrackerController { + ensureStarted: () => void; + stop: () => void; + isRunning: () => boolean; +} + +export interface EmbeddingTrackerControllerOptions extends EmbeddingTrackerOptions { + mode?: string; + starter?: (options: EmbeddingTrackerOptions) => () => void; +} + const MIN_FILES_PER_TICK = 5; const MAX_FILES_PER_TICK = 10; const DEFAULT_FILES_PER_TICK = 8; @@ -44,6 +55,14 @@ function clampDebounceMs(value: number | undefined): number { return Math.max(100, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); } +export function parseEmbeddingTrackerMode(value: string | undefined): "off" | "lazy" | "eager" { + if (!value) return "lazy"; + const normalized = value.trim().toLowerCase(); + if (["false", "0", "no", "off", "disabled", "none"].includes(normalized)) return "off"; + if (["eager", "startup", "boot"].includes(normalized)) return "eager"; + return "lazy"; +} + export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => void { const pendingFiles = new Set(); const debounceMs = clampDebounceMs(options.debounceMs); @@ -111,3 +130,31 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v watcher = null; }; } + +export function createEmbeddingTrackerController(options: EmbeddingTrackerControllerOptions): EmbeddingTrackerController { + const { mode: rawMode, starter = startEmbeddingTracker, ...trackerOptions } = options; + const mode = parseEmbeddingTrackerMode(rawMode); + + let running = false; + let stopTracker = () => { }; + + const ensureStarted = (): void => { + if (running || mode === "off") return; + stopTracker = starter(trackerOptions); + running = true; + }; + + if (mode === "eager") ensureStarted(); + + return { + ensureStarted, + stop: () => { + if (!running) return; + running = false; + const stop = stopTracker; + stopTracker = () => { }; + stop(); + }, + isRunning: () => running, + }; +} diff --git a/src/core/process-lifecycle.ts b/src/core/process-lifecycle.ts index 92bf090..9197f14 100644 --- a/src/core/process-lifecycle.ts +++ b/src/core/process-lifecycle.ts @@ -6,11 +6,43 @@ interface ErrorWithCode { } const BROKEN_PIPE_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "ECONNRESET"]); +const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000; +const MIN_IDLE_TIMEOUT_MS = 60 * 1000; +const DEFAULT_PARENT_POLL_MS = 5 * 1000; +const MIN_PARENT_POLL_MS = 1 * 1000; export interface CleanupOptions { stopTracker: () => void; closeServer: () => Promise | void; closeTransport: () => Promise | void; + stopMonitors?: () => void; +} + +export interface IdleMonitor { + touch: () => void; + stop: () => void; +} + +export interface IdleMonitorOptions { + timeoutMs: number; + onIdle: () => void; +} + +export interface ParentMonitorOptions { + parentPid: number; + pollIntervalMs?: number; + onParentExit: () => void; + isProcessAlive?: (pid: number) => boolean; +} + +function toIntegerOr(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function unrefHandle(handle: { unref?: () => void } | null): void { + handle?.unref?.(); } export function isBrokenPipeError(error: unknown): boolean { @@ -19,7 +51,89 @@ export function isBrokenPipeError(error: unknown): boolean { return typeof code === "string" && BROKEN_PIPE_CODES.has(code); } +export function getIdleShutdownMs(value: string | undefined): number { + const normalized = value?.trim().toLowerCase(); + if (normalized && ["0", "false", "off", "disabled", "none"].includes(normalized)) return 0; + return Math.max(MIN_IDLE_TIMEOUT_MS, toIntegerOr(value, DEFAULT_IDLE_TIMEOUT_MS)); +} + +export function getParentPollMs(value: string | undefined): number { + return Math.max(MIN_PARENT_POLL_MS, toIntegerOr(value, DEFAULT_PARENT_POLL_MS)); +} + +export function isProcessAlive(pid: number, killCheck: (pid: number, signal: number) => void = process.kill): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + + try { + killCheck(pid, 0); + return true; + } catch (error) { + if (!error || typeof error !== "object") return false; + const { code } = error as ErrorWithCode; + return code !== "ESRCH"; + } +} + +export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor { + if (options.timeoutMs <= 0) { + return { + touch: () => { }, + stop: () => { }, + }; + } + + let timer: NodeJS.Timeout | null = null; + + const schedule = (): void => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + options.onIdle(); + }, options.timeoutMs); + unrefHandle(timer); + }; + + schedule(); + + return { + touch: schedule, + stop: () => { + if (!timer) return; + clearTimeout(timer); + timer = null; + }, + }; +} + +export function startParentMonitor(options: ParentMonitorOptions): () => void { + if (!Number.isFinite(options.parentPid) || options.parentPid <= 1 || options.parentPid === process.pid) { + return () => { }; + } + + const pollIntervalMs = Math.max(MIN_PARENT_POLL_MS, Math.floor(options.pollIntervalMs ?? DEFAULT_PARENT_POLL_MS)); + const isAlive = options.isProcessAlive ?? isProcessAlive; + let stopped = false; + + const stop = (): void => { + if (stopped) return; + stopped = true; + clearInterval(interval); + }; + + const interval = setInterval(() => { + if (stopped) return; + if (process.ppid !== options.parentPid || !isAlive(options.parentPid)) { + stop(); + options.onParentExit(); + } + }, pollIntervalMs); + + unrefHandle(interval); + return stop; +} + export async function runCleanup(options: CleanupOptions): Promise { + options.stopMonitors?.(); options.stopTracker(); await Promise.allSettled([ Promise.resolve(options.closeServer()), diff --git a/src/index.ts b/src/index.ts index a1fbf2c..03d962f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { mkdir, writeFile } from "fs/promises"; import { dirname, resolve } from "path"; import { z } from "zod"; -import { startEmbeddingTracker } from "./core/embedding-tracker.js"; -import { isBrokenPipeError, runCleanup } from "./core/process-lifecycle.js"; +import { createEmbeddingTrackerController } from "./core/embedding-tracker.js"; +import { createIdleMonitor, getIdleShutdownMs, getParentPollMs, isBrokenPipeError, runCleanup, startParentMonitor } from "./core/process-lifecycle.js"; import { getContextTree } from "./tools/context-tree.js"; import { getFileSkeleton } from "./tools/file-skeleton.js"; import { ensureMcpDataDir } from "./core/embeddings.js"; @@ -40,6 +40,20 @@ const ROOT_DIR = passthroughArgs[0] && !SUB_COMMANDS.includes(passthroughArgs[0] const INSTRUCTIONS_SOURCE_URL = "https://contextplus.vercel.app/api/instructions"; const INSTRUCTIONS_RESOURCE_URI = "contextplus://instructions"; +let noteServerActivity = () => { }; +let ensureTrackerRunning = () => { }; + +function withRequestActivity( + handler: (args: TArgs) => Promise, + options?: { useEmbeddingTracker?: boolean }, +): (args: TArgs) => Promise { + return async (args: TArgs): Promise => { + noteServerActivity(); + if (options?.useEmbeddingTracker) ensureTrackerRunning(); + return handler(args); + }; +} + function parseAgentTarget(input?: string): AgentTarget { const normalized = (input ?? "claude").toLowerCase(); if (normalized === "claude" || normalized === "claude-code") return "claude"; @@ -82,7 +96,7 @@ function buildMcpConfig(runner: "npx" | "bunx") { OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", CONTEXTPLUS_EMBED_BATCH_SIZE: "8", - CONTEXTPLUS_EMBED_TRACKER: "true", + CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, }, @@ -107,7 +121,7 @@ function buildOpenCodeConfig(runner: "npx" | "bunx") { OLLAMA_CHAT_MODEL: "gemma2:27b", OLLAMA_API_KEY: "YOUR_OLLAMA_API_KEY", CONTEXTPLUS_EMBED_BATCH_SIZE: "8", - CONTEXTPLUS_EMBED_TRACKER: "true", + CONTEXTPLUS_EMBED_TRACKER: "lazy", }, }, }, @@ -139,7 +153,7 @@ const server = new McpServer({ server.resource( "contextplus_instructions", INSTRUCTIONS_RESOURCE_URI, - async (uri) => { + withRequestActivity(async (uri) => { const response = await fetch(INSTRUCTIONS_SOURCE_URL); return { contents: [{ @@ -148,7 +162,7 @@ server.resource( text: await response.text(), }], }; - }, + }), ); server.tool( @@ -162,7 +176,7 @@ server.tool( include_symbols: z.boolean().optional().describe("Include function/class/enum names in the tree. Defaults to true."), max_tokens: z.number().optional().describe("Maximum tokens for output. Auto-prunes if exceeded. Default: 20000."), }, - async ({ target_path, depth_limit, include_symbols, max_tokens }) => ({ + withRequestActivity(async ({ target_path, depth_limit, include_symbols, max_tokens }) => ({ content: [{ type: "text" as const, text: await getContextTree({ @@ -173,7 +187,7 @@ server.tool( maxTokens: max_tokens, }), }], - }), + })), ); server.tool( @@ -188,7 +202,7 @@ server.tool( semantic_weight: z.number().optional().describe("Weight for semantic similarity score. Default: 0.78."), keyword_weight: z.number().optional().describe("Weight for keyword overlap score. Default: 0.22."), }, - async ({ query, top_k, top_calls_per_identifier, include_kinds, semantic_weight, keyword_weight }) => ({ + withRequestActivity(async ({ query, top_k, top_calls_per_identifier, include_kinds, semantic_weight, keyword_weight }) => ({ content: [{ type: "text" as const, text: await semanticIdentifierSearch({ @@ -201,7 +215,7 @@ server.tool( keywordWeight: keyword_weight, }), }], - }), + }), { useEmbeddingTracker: true }), ); server.tool( @@ -211,12 +225,12 @@ server.tool( { file_path: z.string().describe("Path to the file to inspect (relative to project root)."), }, - async ({ file_path }) => ({ + withRequestActivity(async ({ file_path }) => ({ content: [{ type: "text" as const, text: await getFileSkeleton({ rootDir: ROOT_DIR, filePath: file_path }), }], - }), + })), ); server.tool( @@ -234,7 +248,7 @@ server.tool( require_keyword_match: z.boolean().optional().describe("When true, only return files with keyword overlap."), require_semantic_match: z.boolean().optional().describe("When true, only return files with positive semantic similarity."), }, - async ({ + withRequestActivity(async ({ query, top_k, semantic_weight, @@ -260,7 +274,7 @@ server.tool( requireSemanticMatch: require_semantic_match, }), }], - }), + }), { useEmbeddingTracker: true }), ); server.tool( @@ -271,12 +285,12 @@ server.tool( symbol_name: z.string().describe("The function, class, or variable name to trace across the codebase."), file_context: z.string().optional().describe("The file where the symbol is defined. Excludes the definition line from results."), }, - async ({ symbol_name, file_context }) => ({ + withRequestActivity(async ({ symbol_name, file_context }) => ({ content: [{ type: "text" as const, text: await getBlastRadius({ rootDir: ROOT_DIR, symbolName: symbol_name, fileContext: file_context }), }], - }), + })), ); server.tool( @@ -286,12 +300,12 @@ server.tool( { target_path: z.string().optional().describe("Specific file or folder to lint (relative to root). Omit for full project."), }, - async ({ target_path }) => ({ + withRequestActivity(async ({ target_path }) => ({ content: [{ type: "text" as const, text: await runStaticAnalysis({ rootDir: ROOT_DIR, targetPath: target_path }), }], - }), + })), ); server.tool( @@ -303,7 +317,7 @@ server.tool( file_path: z.string().describe("Where to save the file (relative to project root)."), new_content: z.string().describe("The complete file content to save."), }, - async ({ file_path, new_content }) => { + withRequestActivity(async ({ file_path, new_content }) => { invalidateSearchCache(); invalidateIdentifierSearchCache(); return { @@ -312,7 +326,7 @@ server.tool( text: await proposeCommit({ rootDir: ROOT_DIR, filePath: file_path, newContent: new_content }), }], }; - }, + }), ); server.tool( @@ -320,7 +334,7 @@ server.tool( "List all shadow restore points created by propose_commit. Each point captures the file state before the AI made changes. " + "Use this to find a restore point ID for undoing a bad change.", {}, - async () => { + withRequestActivity(async () => { const points = await listRestorePoints(ROOT_DIR); if (points.length === 0) return { content: [{ type: "text" as const, text: "No restore points found." }] }; @@ -328,7 +342,7 @@ server.tool( `${p.id} | ${new Date(p.timestamp).toISOString()} | ${p.files.join(", ")} | ${p.message}`, ); return { content: [{ type: "text" as const, text: `Restore Points (${points.length}):\n\n${lines.join("\n")}` }] }; - }, + }), ); server.tool( @@ -338,7 +352,7 @@ server.tool( { point_id: z.string().describe("The restore point ID (format: rp-timestamp-hash). Get from list_restore_points."), }, - async ({ point_id }) => { + withRequestActivity(async ({ point_id }) => { const restored = await restorePoint(ROOT_DIR, point_id); invalidateSearchCache(); invalidateIdentifierSearchCache(); @@ -350,7 +364,7 @@ server.tool( : "No files were restored. The backup may be empty.", }], }; - }, + }), ); server.tool( @@ -362,12 +376,12 @@ server.tool( max_depth: z.number().optional().describe("Maximum nesting depth of clusters. Default: 3."), max_clusters: z.number().optional().describe("Maximum sub-clusters per level. Default: 20."), }, - async ({ max_depth, max_clusters }) => ({ + withRequestActivity(async ({ max_depth, max_clusters }) => ({ content: [{ type: "text" as const, text: await semanticNavigate({ rootDir: ROOT_DIR, maxDepth: max_depth, maxClusters: max_clusters }), }], - }), + })), ); server.tool( @@ -380,7 +394,7 @@ server.tool( feature_name: z.string().optional().describe("Feature name to search for. Finds matching hub file automatically."), show_orphans: z.boolean().optional().describe("If true, lists all source files not linked to any feature hub."), }, - async ({ hub_path, feature_name, show_orphans }) => ({ + withRequestActivity(async ({ hub_path, feature_name, show_orphans }) => ({ content: [{ type: "text" as const, text: await getFeatureHub({ @@ -390,7 +404,7 @@ server.tool( showOrphans: show_orphans, }), }], - }), + })), ); server.tool( @@ -403,12 +417,12 @@ server.tool( content: z.string().describe("Detailed content for the node. Used for embedding generation."), metadata: z.record(z.string()).optional().describe("Optional key-value metadata pairs."), }, - async ({ type, label, content, metadata }) => ({ + withRequestActivity(async ({ type, label, content, metadata }) => ({ content: [{ type: "text" as const, text: await toolUpsertMemoryNode({ rootDir: ROOT_DIR, type, label, content, metadata }), }], - }), + })), ); server.tool( @@ -422,12 +436,12 @@ server.tool( weight: z.number().optional().describe("Edge weight 0-1. Higher = stronger relationship. Default: 1.0."), metadata: z.record(z.string()).optional().describe("Optional key-value metadata for the edge."), }, - async ({ source_id, target_id, relation, weight, metadata }) => ({ + withRequestActivity(async ({ source_id, target_id, relation, weight, metadata }) => ({ content: [{ type: "text" as const, text: await toolCreateRelation({ rootDir: ROOT_DIR, sourceId: source_id, targetId: target_id, relation, weight, metadata }), }], - }), + })), ); server.tool( @@ -441,12 +455,12 @@ server.tool( edge_filter: z.array(z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"])).optional() .describe("Only traverse edges of these types. Omit for all types."), }, - async ({ query, max_depth, top_k, edge_filter }) => ({ + withRequestActivity(async ({ query, max_depth, top_k, edge_filter }) => ({ content: [{ type: "text" as const, text: await toolSearchMemoryGraph({ rootDir: ROOT_DIR, query, maxDepth: max_depth, topK: top_k, edgeFilter: edge_filter }), }], - }), + })), ); server.tool( @@ -456,12 +470,12 @@ server.tool( { threshold: z.number().optional().describe("Minimum decayed weight to keep an edge. Default: 0.15. Lower = keep more edges."), }, - async ({ threshold }) => ({ + withRequestActivity(async ({ threshold }) => ({ content: [{ type: "text" as const, text: await toolPruneStaleLinks({ rootDir: ROOT_DIR, threshold }), }], - }), + })), ); server.tool( @@ -478,12 +492,12 @@ server.tool( })).describe("Array of nodes to add. Each needs type, label, and content."), auto_link: z.boolean().optional().describe("Whether to auto-create similarity edges. Default: true."), }, - async ({ items, auto_link }) => ({ + withRequestActivity(async ({ items, auto_link }) => ({ content: [{ type: "text" as const, text: await toolAddInterlinkedContext({ rootDir: ROOT_DIR, items, autoLink: auto_link }), }], - }), + })), ); server.tool( @@ -496,12 +510,12 @@ server.tool( edge_filter: z.array(z.enum(["relates_to", "depends_on", "implements", "references", "similar_to", "contains"])).optional() .describe("Only traverse edges of these types. Omit for all."), }, - async ({ start_node_id, max_depth, edge_filter }) => ({ + withRequestActivity(async ({ start_node_id, max_depth, edge_filter }) => ({ content: [{ type: "text" as const, text: await toolRetrieveWithTraversal({ rootDir: ROOT_DIR, startNodeId: start_node_id, maxDepth: max_depth, edgeFilter: edge_filter }), }], - }), + })), ); async function main() { @@ -521,18 +535,25 @@ async function main() { return; } await ensureMcpDataDir(ROOT_DIR); - const trackerEnabled = (process.env.CONTEXTPLUS_EMBED_TRACKER ?? "true").toLowerCase() !== "false"; - const stopTracker = trackerEnabled - ? startEmbeddingTracker({ - rootDir: ROOT_DIR, - debounceMs: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS ?? "700", 10), - maxFilesPerTick: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_MAX_FILES ?? "8", 10), - }) - : () => { }; + const trackerController = createEmbeddingTrackerController({ + rootDir: ROOT_DIR, + mode: process.env.CONTEXTPLUS_EMBED_TRACKER, + debounceMs: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_DEBOUNCE_MS ?? "700", 10), + maxFilesPerTick: Number.parseInt(process.env.CONTEXTPLUS_EMBED_TRACKER_MAX_FILES ?? "8", 10), + }); const transport = new StdioServerTransport(); await server.connect(transport); let shuttingDown = false; + let stopParentMonitor = () => { }; + const idleMonitor = createIdleMonitor({ + timeoutMs: getIdleShutdownMs(process.env.CONTEXTPLUS_IDLE_TIMEOUT_MS), + onIdle: () => requestShutdown("idle-timeout", 0), + }); + + noteServerActivity = idleMonitor.touch; + ensureTrackerRunning = trackerController.ensureStarted; + const closeServer = async () => { const closable = server as unknown as { close?: () => Promise | void }; if (typeof closable.close === "function") { @@ -549,16 +570,36 @@ async function main() { if (shuttingDown) return; shuttingDown = true; console.error(`Context+ MCP shutdown requested: ${reason}`); - await runCleanup({ stopTracker, closeServer, closeTransport }); + await runCleanup({ + stopTracker: trackerController.stop, + closeServer, + closeTransport, + stopMonitors: () => { + idleMonitor.stop(); + stopParentMonitor(); + }, + }); process.exit(exitCode); }; const requestShutdown = (reason: string, exitCode: number = 0) => { void shutdown(reason, exitCode); }; + stopParentMonitor = startParentMonitor({ + parentPid: process.ppid, + pollIntervalMs: getParentPollMs(process.env.CONTEXTPLUS_PARENT_POLL_MS), + onParentExit: () => requestShutdown("parent-exit", 0), + }); + process.once("SIGINT", () => requestShutdown("SIGINT", 0)); process.once("SIGTERM", () => requestShutdown("SIGTERM", 0)); - process.once("exit", () => stopTracker()); + process.once("SIGHUP", () => requestShutdown("SIGHUP", 0)); + process.once("disconnect", () => requestShutdown("disconnect", 0)); + process.once("exit", () => { + idleMonitor.stop(); + stopParentMonitor(); + trackerController.stop(); + }); process.stdin.once("end", () => requestShutdown("stdin-end", 0)); process.stdin.once("close", () => requestShutdown("stdin-close", 0)); process.stdin.once("error", (error) => { @@ -571,6 +612,7 @@ async function main() { if (isBrokenPipeError(error)) requestShutdown("stderr-error", 0); }); + noteServerActivity(); console.error(`Context+ MCP server running on stdio | root: ${ROOT_DIR}`); } diff --git a/test/main/embedding-tracker.test.mjs b/test/main/embedding-tracker.test.mjs index 352e2fa..2c47dba 100644 --- a/test/main/embedding-tracker.test.mjs +++ b/test/main/embedding-tracker.test.mjs @@ -1,7 +1,14 @@ +// Embedding tracker controller tests cover lazy startup and shutdown modes +// FEATURE: Verifies watcher creation only occurs when explicitly needed + import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { + createEmbeddingTrackerController, + parseEmbeddingTrackerMode, +} from "../../build/core/embedding-tracker.js"; -describe("embedding-tracker", () => { +describe("embedding-tracker controller", () => { it("exports startEmbeddingTracker", async () => { const mod = await import("../../build/core/embedding-tracker.js"); assert.equal(typeof mod.startEmbeddingTracker, "function"); @@ -11,4 +18,66 @@ describe("embedding-tracker", () => { const mod = await import("../../build/core/embedding-tracker.js"); assert.equal(mod.startEmbeddingTracker.length, 1); }); + + it("parses tracker modes with lazy as the safe default", () => { + assert.equal(parseEmbeddingTrackerMode(undefined), "lazy"); + assert.equal(parseEmbeddingTrackerMode("true"), "lazy"); + assert.equal(parseEmbeddingTrackerMode("lazy"), "lazy"); + assert.equal(parseEmbeddingTrackerMode("eager"), "eager"); + assert.equal(parseEmbeddingTrackerMode("off"), "off"); + }); + + it("defers tracker startup in lazy mode", () => { + let starts = 0; + let stops = 0; + const controller = createEmbeddingTrackerController({ + rootDir: ".", + mode: "true", + starter: () => { + starts += 1; + return () => { + stops += 1; + }; + }, + }); + + assert.equal(starts, 0); + assert.equal(controller.isRunning(), false); + controller.ensureStarted(); + controller.ensureStarted(); + assert.equal(starts, 1); + assert.equal(controller.isRunning(), true); + controller.stop(); + assert.equal(stops, 1); + assert.equal(controller.isRunning(), false); + }); + + it("starts immediately in eager mode and never starts when disabled", () => { + let eagerStarts = 0; + const eager = createEmbeddingTrackerController({ + rootDir: ".", + mode: "eager", + starter: () => { + eagerStarts += 1; + return () => {}; + }, + }); + + assert.equal(eagerStarts, 1); + assert.equal(eager.isRunning(), true); + + let disabledStarts = 0; + const disabled = createEmbeddingTrackerController({ + rootDir: ".", + mode: "false", + starter: () => { + disabledStarts += 1; + return () => {}; + }, + }); + + disabled.ensureStarted(); + assert.equal(disabledStarts, 0); + assert.equal(disabled.isRunning(), false); + }); }); diff --git a/test/main/process-lifecycle.test.mjs b/test/main/process-lifecycle.test.mjs index a61b867..60a4d37 100644 --- a/test/main/process-lifecycle.test.mjs +++ b/test/main/process-lifecycle.test.mjs @@ -1,10 +1,18 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + createIdleMonitor, + getIdleShutdownMs, isBrokenPipeError, + isProcessAlive, runCleanup, + startParentMonitor, } from "../../build/core/process-lifecycle.js"; +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe("process-lifecycle", () => { it("detects broken pipe style stream errors", () => { assert.equal(isBrokenPipeError({ code: "EPIPE" }), true); @@ -35,4 +43,87 @@ describe("process-lifecycle", () => { assert.equal(calls.includes("server"), true); assert.equal(calls.includes("transport"), true); }); + + it("stops monitors during cleanup", async () => { + const calls = []; + await runCleanup({ + stopTracker: () => { + calls.push("tracker"); + }, + stopMonitors: () => { + calls.push("monitors"); + }, + closeServer: async () => { + calls.push("server"); + }, + closeTransport: async () => { + calls.push("transport"); + }, + }); + assert.deepEqual(calls, ["monitors", "tracker", "server", "transport"]); + }); + + it("parses idle timeout values with disable support", () => { + assert.equal(getIdleShutdownMs(undefined), 900000); + assert.equal(getIdleShutdownMs("off"), 0); + assert.equal(getIdleShutdownMs("1000"), 60000); + }); + + it("checks process liveness through signal probing", () => { + assert.equal( + isProcessAlive(42, () => {}), + true, + ); + assert.equal( + isProcessAlive(42, () => { + throw { code: "ESRCH" }; + }), + false, + ); + assert.equal( + isProcessAlive(42, () => { + throw { code: "EPERM" }; + }), + true, + ); + }); + + it("fires idle monitor after inactivity", async () => { + let calls = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { + calls += 1; + }, + }); + + await wait(15); + monitor.touch(); + await wait(20); + assert.equal(calls, 0); + await wait(20); + assert.equal(calls, 1); + monitor.stop(); + }); + + it("fires parent monitor when parent disappears", async () => { + let checks = 0; + let calls = 0; + const stop = startParentMonitor({ + parentPid: process.ppid, + pollIntervalMs: 10, + isProcessAlive: () => { + checks += 1; + return false; + }, + onParentExit: () => { + calls += 1; + }, + }); + + await wait(1100); + stop(); + assert.equal(calls, 1); + assert.equal(checks, 1); + }); }); From 37a0fa0eb2d542a4f45d9bf3c4c1f7d04c872c43 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Tue, 10 Mar 2026 23:16:32 +0530 Subject: [PATCH 08/29] feat: Implement Ollama-powered embedding engine with adaptive input handling, caching, and process shutdown hooks. --- src/core/embedding-tracker.ts | 7 +++++-- src/core/embeddings.ts | 18 ++++++++++++++++-- src/core/process-lifecycle.ts | 2 ++ src/index.ts | 3 ++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/core/embedding-tracker.ts b/src/core/embedding-tracker.ts index b49184f..6da91d5 100644 --- a/src/core/embedding-tracker.ts +++ b/src/core/embedding-tracker.ts @@ -25,7 +25,8 @@ export interface EmbeddingTrackerControllerOptions extends EmbeddingTrackerOptio const MIN_FILES_PER_TICK = 5; const MAX_FILES_PER_TICK = 10; const DEFAULT_FILES_PER_TICK = 8; -const DEFAULT_DEBOUNCE_MS = 700; +const DEFAULT_DEBOUNCE_MS = 1500; +const MAX_PENDING_FILES = 50; const IGNORE_PREFIXES = [ ".mcp_data/", @@ -52,7 +53,7 @@ function clampFilesPerTick(value: number | undefined): number { function clampDebounceMs(value: number | undefined): number { if (!Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS; - return Math.max(100, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); + return Math.max(500, Math.floor(value ?? DEFAULT_DEBOUNCE_MS)); } export function parseEmbeddingTrackerMode(value: string | undefined): "off" | "lazy" | "eager" { @@ -78,6 +79,7 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v timer = setTimeout(() => { void flushPending(); }, delay); + timer.unref(); }; const flushPending = async (): Promise => { @@ -111,6 +113,7 @@ export function startEmbeddingTracker(options: EmbeddingTrackerOptions): () => v if (closed || !fileName) return; const relativePath = normalizeRelativePath(String(fileName)); if (!shouldTrack(relativePath)) return; + if (pendingFiles.size >= MAX_PENDING_FILES) return; pendingFiles.add(relativePath); schedule(); }); diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index bc4dce7..7c8e7ca 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -5,6 +5,14 @@ import { Ollama } from "ollama"; import { readFile, writeFile, mkdir } from "fs/promises"; import { join } from "path"; +const EMBED_TIMEOUT_MS = 60_000; +let embedAbortController = new AbortController(); + +export function cancelAllEmbeddings(): void { + embedAbortController.abort(); + embedAbortController = new AbortController(); +} + export interface SearchDocument { path: string; header: string; @@ -120,6 +128,12 @@ function buildEmbedRequest(input: string[]): { model: string; input: string[]; o return options ? { model: EMBED_MODEL, input, options } : { model: EMBED_MODEL, input }; } +async function embedWithTimeout(request: ReturnType): Promise<{ embeddings: number[][] }> { + const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); + const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); + return ollama.embed({ ...request, signal } as Parameters[0]); +} + export function getEmbeddingBatchSize(): number { const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_BATCH_SIZE, DEFAULT_EMBED_BATCH_SIZE); return Math.min(MAX_EMBED_BATCH_SIZE, Math.max(MIN_EMBED_BATCH_SIZE, requested)); @@ -153,7 +167,7 @@ async function embedSingleAdaptive(input: string): Promise { for (let attempt = 0; attempt <= MAX_SINGLE_INPUT_RETRIES; attempt++) { try { - const response = await ollama.embed(buildEmbedRequest([candidate])); + const response = await embedWithTimeout(buildEmbedRequest([candidate])); if (!response.embeddings[0]) throw new Error("Missing embedding vector in Ollama response"); return response.embeddings[0]; } catch (error) { @@ -169,7 +183,7 @@ async function embedSingleAdaptive(input: string): Promise { async function embedBatchAdaptive(batch: string[]): Promise { try { - const response = await ollama.embed(buildEmbedRequest(batch)); + const response = await embedWithTimeout(buildEmbedRequest(batch)); if (response.embeddings.length !== batch.length) { throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${response.embeddings.length}`); } diff --git a/src/core/process-lifecycle.ts b/src/core/process-lifecycle.ts index 9197f14..f9256f8 100644 --- a/src/core/process-lifecycle.ts +++ b/src/core/process-lifecycle.ts @@ -12,6 +12,7 @@ const DEFAULT_PARENT_POLL_MS = 5 * 1000; const MIN_PARENT_POLL_MS = 1 * 1000; export interface CleanupOptions { + cancelEmbeddings?: () => void; stopTracker: () => void; closeServer: () => Promise | void; closeTransport: () => Promise | void; @@ -133,6 +134,7 @@ export function startParentMonitor(options: ParentMonitorOptions): () => void { } export async function runCleanup(options: CleanupOptions): Promise { + options.cancelEmbeddings?.(); options.stopMonitors?.(); options.stopTracker(); await Promise.allSettled([ diff --git a/src/index.ts b/src/index.ts index 03d962f..dc4a514 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { createEmbeddingTrackerController } from "./core/embedding-tracker.js"; import { createIdleMonitor, getIdleShutdownMs, getParentPollMs, isBrokenPipeError, runCleanup, startParentMonitor } from "./core/process-lifecycle.js"; import { getContextTree } from "./tools/context-tree.js"; import { getFileSkeleton } from "./tools/file-skeleton.js"; -import { ensureMcpDataDir } from "./core/embeddings.js"; +import { ensureMcpDataDir, cancelAllEmbeddings } from "./core/embeddings.js"; import { semanticCodeSearch, invalidateSearchCache } from "./tools/semantic-search.js"; import { semanticIdentifierSearch, invalidateIdentifierSearchCache } from "./tools/semantic-identifiers.js"; import { getBlastRadius } from "./tools/blast-radius.js"; @@ -571,6 +571,7 @@ async function main() { shuttingDown = true; console.error(`Context+ MCP shutdown requested: ${reason}`); await runCleanup({ + cancelEmbeddings: cancelAllEmbeddings, stopTracker: trackerController.stop, closeServer, closeTransport, From d2f44d32cf14fbd258bd1f012be6bd626ae20361 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Tue, 10 Mar 2026 23:17:52 +0530 Subject: [PATCH 09/29] fix: bump version to 1.0.8 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4dd7617..f2831a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.7", + "version": "1.0.8", "type": "module", "license": "MIT", "bin": { From f43a8a5f751c5a554a121d7c877ec038cec3df83 Mon Sep 17 00:00:00 2001 From: Overtime Date: Thu, 26 Mar 2026 20:12:38 -0400 Subject: [PATCH 10/29] fix: check transport liveness before idle-timeout shutdown The idle monitor fires after 15 minutes of no tool calls and triggers process.exit(). This kills the MCP server even when the agent connection (stdio transport) is still alive - the agent may just be idle (thinking, waiting for user input, etc). The abrupt exit breaks the SSE stream for the calling agent. The fix adds an optional isTransportAlive callback to createIdleMonitor. When the idle timer fires, it checks whether the transport (stdin) is still readable. If so, the timer reschedules instead of shutting down. When the transport is actually dead, shutdown proceeds normally. In index.ts, the callback checks process.stdin.readable && !destroyed. Backward compatible: when isTransportAlive is not provided, the original behavior (unconditional shutdown) is preserved. Includes 7 new tests: - 5 unit tests for createIdleMonitor with isTransportAlive - 2 spawn-level integration tests proving the bug (without fix) and the fix (with isTransportAlive) at the process level --- src/core/process-lifecycle.ts | 5 + src/index.ts | 1 + test/main/idle-timeout-spawn.test.mjs | 147 ++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 test/main/idle-timeout-spawn.test.mjs diff --git a/src/core/process-lifecycle.ts b/src/core/process-lifecycle.ts index f9256f8..f7949e2 100644 --- a/src/core/process-lifecycle.ts +++ b/src/core/process-lifecycle.ts @@ -27,6 +27,7 @@ export interface IdleMonitor { export interface IdleMonitorOptions { timeoutMs: number; onIdle: () => void; + isTransportAlive?: () => boolean; } export interface ParentMonitorOptions { @@ -89,6 +90,10 @@ export function createIdleMonitor(options: IdleMonitorOptions): IdleMonitor { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; + if (options.isTransportAlive && options.isTransportAlive()) { + schedule(); + return; + } options.onIdle(); }, options.timeoutMs); unrefHandle(timer); diff --git a/src/index.ts b/src/index.ts index dc4a514..e688e3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -549,6 +549,7 @@ async function main() { const idleMonitor = createIdleMonitor({ timeoutMs: getIdleShutdownMs(process.env.CONTEXTPLUS_IDLE_TIMEOUT_MS), onIdle: () => requestShutdown("idle-timeout", 0), + isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed, }); noteServerActivity = idleMonitor.touch; diff --git a/test/main/idle-timeout-spawn.test.mjs b/test/main/idle-timeout-spawn.test.mjs new file mode 100644 index 0000000..3085f7a --- /dev/null +++ b/test/main/idle-timeout-spawn.test.mjs @@ -0,0 +1,147 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + createIdleMonitor, +} from "../../build/core/process-lifecycle.js"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createTestScript(withFix) { + const buildPath = join(PROJECT_ROOT, "build/core/process-lifecycle.js").replace(/\\/g, "/"); + return ` + import { createIdleMonitor } from "file://${buildPath}"; + + const idleMonitor = createIdleMonitor({ + timeoutMs: 200, + onIdle: () => { + process.stderr.write("IDLE_SHUTDOWN\\n"); + process.exit(0); + }, + ${withFix ? 'isTransportAlive: () => process.stdin.readable && !process.stdin.destroyed,' : ''} + }); + + process.stderr.write("STARTED\\n"); + const keepAlive = setInterval(() => {}, 1000); + setTimeout(() => { + idleMonitor.stop(); + clearInterval(keepAlive); + process.stderr.write("SURVIVED\\n"); + process.exit(0); + }, 1500); + `; +} + +function runHarness(withFix) { + return new Promise((resolve) => { + const tmpDir = mkdtempSync(join(tmpdir(), "cp-test-")); + const scriptPath = join(tmpDir, "harness.mjs"); + writeFileSync(scriptPath, createTestScript(withFix)); + + const child = spawn("node", [scriptPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (d) => { stderr += d.toString(); }); + + child.on("exit", (code) => { + resolve({ code, stderr }); + }); + }); +} + +describe("idle-timeout transport-aware fix", () => { + it("does NOT fire onIdle when isTransportAlive returns true", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => true, + }); + await wait(80); + assert.equal(idleFired, 0, "onIdle should not fire when transport is alive"); + monitor.stop(); + }); + + it("fires onIdle when isTransportAlive returns false", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire when transport is dead"); + monitor.stop(); + }); + + it("fires onIdle normally when no isTransportAlive provided (backward compat)", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + }); + await wait(80); + assert.equal(idleFired, 1, "onIdle should fire with no transport check"); + monitor.stop(); + }); + + it("reschedules then fires when transport dies after initial alive check", async () => { + let transportAlive = true; + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 30, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => transportAlive, + }); + await wait(50); + assert.equal(idleFired, 0, "should not fire while transport alive"); + transportAlive = false; + await wait(50); + assert.equal(idleFired, 1, "should fire after transport dies"); + monitor.stop(); + }); + + it("touch resets the idle timer even with transport check", async () => { + let idleFired = 0; + const monitor = createIdleMonitor({ + timeoutMs: 40, + onIdle: () => { idleFired += 1; }, + isTransportAlive: () => false, + }); + await wait(20); + monitor.touch(); + await wait(20); + assert.equal(idleFired, 0, "touch should reset timer"); + await wait(30); + assert.equal(idleFired, 1, "should fire after full timeout post-touch"); + monitor.stop(); + }); + + it("spawn: without isTransportAlive, server exits on idle with stdin open", async () => { + const result = await runHarness(false); + assert.equal(result.code, 0); + assert.ok(result.stderr.includes("IDLE_SHUTDOWN"), + "server idle-shutdown with stdin open (no transport check)"); + assert.ok(!result.stderr.includes("SURVIVED"), + "server died before survival window"); + }); + + it("spawn: with isTransportAlive, server survives idle when stdin is open", async () => { + const result = await runHarness(true); + assert.equal(result.code, 0); + assert.ok(!result.stderr.includes("IDLE_SHUTDOWN"), + "server should NOT idle-shutdown when transport alive"); + assert.ok(result.stderr.includes("SURVIVED"), + "server should survive past idle timeout"); + }); +}); From a0f940476578b1b6040b310f83fde6bf29eac99c Mon Sep 17 00:00:00 2001 From: Cenk Tekin Date: Sat, 28 Mar 2026 13:35:21 +0300 Subject: [PATCH 11/29] feat: add OpenAI-compatible embedding provider support Add multi-provider embedding backend via CONTEXTPLUS_EMBED_PROVIDER env var: - 'ollama' (default): existing Ollama backend, fully backward compatible - 'openai': any OpenAI-compatible API (Gemini, OpenAI, Groq, etc.) New env vars for OpenAI provider: - CONTEXTPLUS_EMBED_PROVIDER=openai - CONTEXTPLUS_OPENAI_API_KEY (or OPENAI_API_KEY) - CONTEXTPLUS_OPENAI_BASE_URL (or OPENAI_BASE_URL) - CONTEXTPLUS_OPENAI_EMBED_MODEL (or OPENAI_EMBED_MODEL) - CONTEXTPLUS_OPENAI_CHAT_MODEL (or OPENAI_CHAT_MODEL) Both embeddings.ts and semantic-navigate.ts patched. Ollama import is now lazy (dynamic import) - no crash if Ollama not installed. --- package-lock.json | 4 +- src/core/embeddings.ts | 89 +++++++++++++++++++++++++--------- src/tools/semantic-navigate.ts | 53 +++++++++++++++++--- 3 files changed, 115 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8a05e1..9e87ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "contextplus", - "version": "1.0.7", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contextplus", - "version": "1.0.7", + "version": "1.0.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 7c8e7ca..493922c 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -1,7 +1,7 @@ -// Ollama-powered vector embedding engine with cosine similarity search +// Multi-provider vector embedding engine with cosine similarity search +// Supports Ollama (local) and OpenAI-compatible APIs (Gemini, OpenAI, etc.) // Indexes file headers and symbols, caches embeddings to disk for speed -import { Ollama } from "ollama"; import { readFile, writeFile, mkdir } from "fs/promises"; import { join } from "path"; @@ -74,7 +74,11 @@ export interface EmbeddingCache { [path: string]: { hash: string; vector: number[] }; } +const EMBED_PROVIDER = (process.env.CONTEXTPLUS_EMBED_PROVIDER ?? "ollama").toLowerCase(); const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; +const OPENAI_EMBED_MODEL = process.env.CONTEXTPLUS_OPENAI_EMBED_MODEL ?? process.env.OPENAI_EMBED_MODEL ?? "text-embedding-3-small"; +const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; +const OPENAI_BASE_URL = process.env.CONTEXTPLUS_OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const CACHE_DIR = ".mcp_data"; const CACHE_FILE = "embeddings-cache.json"; const MIN_EMBED_BATCH_SIZE = 5; @@ -87,7 +91,53 @@ const MIN_EMBED_CHUNK_CHARS = 256; const DEFAULT_EMBED_CHUNK_CHARS = 2000; const MAX_EMBED_CHUNK_CHARS = 8000; -const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); +type OllamaEmbedClient = { embed: (params: Record) => Promise<{ embeddings: number[][] }> }; +let ollamaClient: OllamaEmbedClient | null = null; + +async function getOllamaClient(): Promise { + if (!ollamaClient) { + const { Ollama } = await import("ollama"); + ollamaClient = new Ollama({ host: process.env.OLLAMA_HOST }) as unknown as OllamaEmbedClient; + } + return ollamaClient; +} + +async function callOllamaEmbed(input: string[], signal: AbortSignal): Promise { + const client = await getOllamaClient(); + const options = getEmbedRuntimeOptions(); + const request: Record = { model: EMBED_MODEL, input, signal }; + if (options) request.options = options; + const response = await client.embed(request); + return response.embeddings; +} + +async function callOpenAIEmbed(input: string[], signal: AbortSignal): Promise { + const url = `${OPENAI_BASE_URL.replace(/\/+$/, "")}/embeddings`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ model: OPENAI_EMBED_MODEL, input }), + signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`OpenAI embed API error ${response.status}: ${body}`); + } + + const data = await response.json() as { data: { embedding: number[] }[] }; + return data.data.map((item) => item.embedding); +} + +async function callProviderEmbed(input: string[], signal: AbortSignal): Promise { + if (EMBED_PROVIDER === "openai") { + return callOpenAIEmbed(input, signal); + } + return callOllamaEmbed(input, signal); +} function toIntegerOr(value: string | undefined, fallback: number): number { if (!value) return fallback; @@ -110,6 +160,7 @@ function toOptionalBoolean(value: string | undefined): boolean | undefined { } function getEmbedRuntimeOptions(): EmbedRuntimeOptions | undefined { + if (EMBED_PROVIDER === "openai") return undefined; const options: EmbedRuntimeOptions = { num_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_NUM_GPU), main_gpu: toOptionalInteger(process.env.CONTEXTPLUS_EMBED_MAIN_GPU), @@ -123,17 +174,6 @@ function getEmbedRuntimeOptions(): EmbedRuntimeOptions | undefined { return options; } -function buildEmbedRequest(input: string[]): { model: string; input: string[]; options?: EmbedRuntimeOptions } { - const options = getEmbedRuntimeOptions(); - return options ? { model: EMBED_MODEL, input, options } : { model: EMBED_MODEL, input }; -} - -async function embedWithTimeout(request: ReturnType): Promise<{ embeddings: number[][] }> { - const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); - const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); - return ollama.embed({ ...request, signal } as Parameters[0]); -} - export function getEmbeddingBatchSize(): number { const requested = toIntegerOr(process.env.CONTEXTPLUS_EMBED_BATCH_SIZE, DEFAULT_EMBED_BATCH_SIZE); return Math.min(MAX_EMBED_BATCH_SIZE, Math.max(MIN_EMBED_BATCH_SIZE, requested)); @@ -152,7 +192,8 @@ function getErrorMessage(error: unknown): string { function isContextLengthError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); return message.includes("input length exceeds context length") - || (message.includes("context") && message.includes("exceed")); + || (message.includes("context") && message.includes("exceed")) + || message.includes("maximum context length"); } function shrinkEmbeddingInput(input: string): string { @@ -167,9 +208,11 @@ async function embedSingleAdaptive(input: string): Promise { for (let attempt = 0; attempt <= MAX_SINGLE_INPUT_RETRIES; attempt++) { try { - const response = await embedWithTimeout(buildEmbedRequest([candidate])); - if (!response.embeddings[0]) throw new Error("Missing embedding vector in Ollama response"); - return response.embeddings[0]; + const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); + const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); + const embeddings = await callProviderEmbed([candidate], signal); + if (!embeddings[0]) throw new Error("Missing embedding vector in response"); + return embeddings[0]; } catch (error) { if (!isContextLengthError(error)) throw error; const nextCandidate = shrinkEmbeddingInput(candidate); @@ -183,11 +226,13 @@ async function embedSingleAdaptive(input: string): Promise { async function embedBatchAdaptive(batch: string[]): Promise { try { - const response = await embedWithTimeout(buildEmbedRequest(batch)); - if (response.embeddings.length !== batch.length) { - throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${response.embeddings.length}`); + const timeoutCtrl = AbortSignal.timeout(EMBED_TIMEOUT_MS); + const signal = AbortSignal.any([embedAbortController.signal, timeoutCtrl]); + const embeddings = await callProviderEmbed(batch, signal); + if (embeddings.length !== batch.length) { + throw new Error(`Embedding response size mismatch: expected ${batch.length}, got ${embeddings.length}`); } - return response.embeddings; + return embeddings; } catch (error) { if (!isContextLengthError(error)) throw error; if (batch.length === 1) { diff --git a/src/tools/semantic-navigate.ts b/src/tools/semantic-navigate.ts index cc3dc30..923eded 100644 --- a/src/tools/semantic-navigate.ts +++ b/src/tools/semantic-navigate.ts @@ -1,7 +1,6 @@ -// Semantic project navigator using spectral clustering and Ollama labeling +// Semantic project navigator using spectral clustering and provider-agnostic labeling // Browse codebase by meaning: embeds files, clusters vectors, generates labels -import { Ollama } from "ollama"; import { walkDirectory } from "../core/walker.js"; import { analyzeFile, flattenSymbols, isSupportedFile } from "../core/parser.js"; import { fetchEmbedding } from "../core/embeddings.js"; @@ -29,8 +28,11 @@ interface ClusterNode { children: ClusterNode[]; } -const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; +const EMBED_PROVIDER = (process.env.CONTEXTPLUS_EMBED_PROVIDER ?? "ollama").toLowerCase(); const CHAT_MODEL = process.env.OLLAMA_CHAT_MODEL ?? "llama3.2"; +const OPENAI_CHAT_MODEL = process.env.CONTEXTPLUS_OPENAI_CHAT_MODEL ?? process.env.OPENAI_CHAT_MODEL ?? "gpt-4o-mini"; +const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; +const OPENAI_BASE_URL = process.env.CONTEXTPLUS_OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const MAX_FILES_PER_LEAF = 20; const NON_CODE_NAVIGATE_EXTENSIONS = new Set([ ".json", @@ -46,7 +48,16 @@ const NON_CODE_NAVIGATE_EXTENSIONS = new Set([ ".env", ]); -const ollama = new Ollama({ host: process.env.OLLAMA_HOST }); +type OllamaChatClient = { chat: (params: Record) => Promise<{ message: { content: string } }> }; +let ollamaClient: OllamaChatClient | null = null; + +async function getOllamaClient(): Promise { + if (!ollamaClient) { + const { Ollama } = await import("ollama"); + ollamaClient = new Ollama({ host: process.env.OLLAMA_HOST }) as unknown as OllamaChatClient; + } + return ollamaClient; +} async function fetchEmbeddings(inputs: string[]): Promise { return fetchEmbedding(inputs); @@ -57,7 +68,32 @@ function isNavigableSourceCandidate(filePath: string): boolean { } async function chatCompletion(prompt: string): Promise { - const response = await ollama.chat({ + if (EMBED_PROVIDER === "openai") { + const url = `${OPENAI_BASE_URL.replace(/\/+$/, "")}/chat/completions`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_CHAT_MODEL, + messages: [{ role: "user", content: prompt }], + stream: false, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`OpenAI chat API error ${response.status}: ${body}`); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + return data.choices[0]?.message?.content ?? ""; + } + + const client = await getOllamaClient(); + const response = await client.chat({ model: CHAT_MODEL, messages: [{ role: "user", content: prompt }], stream: false, @@ -123,7 +159,7 @@ async function labelSiblingClusters(clusters: { files: FileInfo[]; pathPattern: const prompt = `You are labeling clusters of code files. For each cluster below, produce EXACTLY one JSON array of objects, each with: - "overarchingTheme": a sentence about the cluster's theme -- "distinguishingFeature": what makes this cluster unique vs siblings +- "distinguishingFeature": what makes this cluster unique vs siblings - "label": EXACTLY 2 words describing the cluster ${clusterDescriptions.join("\n\n")} @@ -256,7 +292,10 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis vectors = embedded.vectors; skippedForEmbedding = embedded.skipped; } catch (err) { - return `Ollama not available for embeddings: ${err instanceof Error ? err.message : String(err)}\nMake sure Ollama is running or signed in (ollama signin) with model ${EMBED_MODEL}.`; + const providerHint = EMBED_PROVIDER === "openai" + ? `Check CONTEXTPLUS_OPENAI_API_KEY and CONTEXTPLUS_OPENAI_BASE_URL.` + : `Make sure Ollama is running with model ${CHAT_MODEL}.`; + return `Embedding provider (${EMBED_PROVIDER}) not available: ${err instanceof Error ? err.message : String(err)}\n${providerHint}`; } if (embeddableFiles.length === 0) return "No embeddable source files found in the project."; From b01c03eee5884d14ed856a90e7e9e4769d20070b Mon Sep 17 00:00:00 2001 From: Cenk Tekin Date: Sat, 28 Mar 2026 13:56:37 +0300 Subject: [PATCH 12/29] fix: address Copilot review feedback 1. Cache key now includes provider+model name to prevent stale vector reuse when switching embedding providers (would cause NaN scores) 2. Error hint in semantic-navigate references embed model instead of chat model for embedding failures --- src/core/embeddings.ts | 3 ++- src/tools/semantic-navigate.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/embeddings.ts b/src/core/embeddings.ts index 493922c..5942bd0 100644 --- a/src/core/embeddings.ts +++ b/src/core/embeddings.ts @@ -80,7 +80,8 @@ const OPENAI_EMBED_MODEL = process.env.CONTEXTPLUS_OPENAI_EMBED_MODEL ?? process const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; const OPENAI_BASE_URL = process.env.CONTEXTPLUS_OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; const CACHE_DIR = ".mcp_data"; -const CACHE_FILE = "embeddings-cache.json"; +const ACTIVE_EMBED_MODEL = EMBED_PROVIDER === "openai" ? OPENAI_EMBED_MODEL : EMBED_MODEL; +const CACHE_FILE = `embeddings-cache-${EMBED_PROVIDER}-${ACTIVE_EMBED_MODEL.replace(/[^a-zA-Z0-9._-]/g, "_")}.json`; const MIN_EMBED_BATCH_SIZE = 5; const MAX_EMBED_BATCH_SIZE = 10; const DEFAULT_EMBED_BATCH_SIZE = 8; diff --git a/src/tools/semantic-navigate.ts b/src/tools/semantic-navigate.ts index 923eded..b99d74e 100644 --- a/src/tools/semantic-navigate.ts +++ b/src/tools/semantic-navigate.ts @@ -29,6 +29,7 @@ interface ClusterNode { } const EMBED_PROVIDER = (process.env.CONTEXTPLUS_EMBED_PROVIDER ?? "ollama").toLowerCase(); +const EMBED_MODEL = process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text"; const CHAT_MODEL = process.env.OLLAMA_CHAT_MODEL ?? "llama3.2"; const OPENAI_CHAT_MODEL = process.env.CONTEXTPLUS_OPENAI_CHAT_MODEL ?? process.env.OPENAI_CHAT_MODEL ?? "gpt-4o-mini"; const OPENAI_API_KEY = process.env.CONTEXTPLUS_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; @@ -294,7 +295,7 @@ export async function semanticNavigate(options: SemanticNavigateOptions): Promis } catch (err) { const providerHint = EMBED_PROVIDER === "openai" ? `Check CONTEXTPLUS_OPENAI_API_KEY and CONTEXTPLUS_OPENAI_BASE_URL.` - : `Make sure Ollama is running with model ${CHAT_MODEL}.`; + : `Make sure Ollama is running (check OLLAMA_HOST) and that the embedding model configured in OLLAMA_EMBED_MODEL is available.`; return `Embedding provider (${EMBED_PROVIDER}) not available: ${err instanceof Error ? err.message : String(err)}\n${providerHint}`; } From f34dded75f28a6d0bd0d68287ec38312eb3ed69f Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Sat, 28 Mar 2026 18:16:41 +0530 Subject: [PATCH 13/29] claude? --- bun.lock | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index c0087c9..4db0574 100644 --- a/bun.lock +++ b/bun.lock @@ -6,27 +6,74 @@ "name": "better-agent-mcp", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", + "ml-matrix": "^6.12.1", + "ollama": "^0.6.3", "simple-git": "^3.27.0", "tree-sitter-wasms": "^0.1.13", - "web-tree-sitter": "^0.26.6", + "web-tree-sitter": "^0.20.8", "zod": "^3.25.23", }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", "@types/node": "^22.15.0", + "tailwindcss": "^4.2.1", "typescript": "^5.8.3", }, }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -43,6 +90,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "claude": ["claude@0.1.2", "", {}, "sha512-Qjrrs+G1pwovbIgGh5R1Ni4Al79AfpbkvfonpHH0yj86cfOq3AoAzNbEeD9TQ980hrog8TM0vh1CNn+7uf/zYA=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -59,12 +108,16 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -101,6 +154,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -119,16 +174,46 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-any-array": ["is-any-array@2.0.1", "", {}, "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -139,14 +224,26 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "ml-array-max": ["ml-array-max@1.2.4", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ=="], + + "ml-array-min": ["ml-array-min@1.2.3", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q=="], + + "ml-array-rescale": ["ml-array-rescale@1.3.7", "", { "dependencies": { "is-any-array": "^2.0.0", "ml-array-max": "^1.2.4", "ml-array-min": "^1.2.3" } }, "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ=="], + + "ml-matrix": ["ml-matrix@6.12.1", "", { "dependencies": { "is-any-array": "^2.0.1", "ml-array-rescale": "^1.3.7" } }, "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -157,8 +254,12 @@ "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], @@ -193,8 +294,14 @@ "simple-git": ["simple-git@3.32.3", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="], @@ -209,7 +316,9 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "web-tree-sitter": ["web-tree-sitter@0.26.6", "", {}, "sha512-fSPR7VBW/fZQdUSp/bXTDLT+i/9dwtbnqgEBMzowrM4U3DzeCwDbY3MKo0584uQxID4m/1xpLflrlT/rLIRPew=="], + "web-tree-sitter": ["web-tree-sitter@0.20.8", "", {}, "sha512-weOVgZ3aAARgdnb220GqYuh7+rZU0Ka9k9yfKtGAzEYMa6GgiCzW9JjQRJyCJakvibQW+dfjJdihjInKuuCAUQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -218,5 +327,17 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/package.json b/package.json index f2831a0..d5edeca 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", "ml-matrix": "^6.12.1", "ollama": "^0.6.3", From f494ffb2d17a3f9c26e6b6604e84e42840febdc5 Mon Sep 17 00:00:00 2001 From: Cenk Tekin Date: Sat, 28 Mar 2026 15:58:00 +0300 Subject: [PATCH 14/29] docs: add OpenAI-compatible embedding provider documentation Covers provider selection, Gemini free tier setup, OpenAI, and other compatible APIs (Groq, vLLM, LiteLLM). Updates Config table with new environment variables and aliases. --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a00a5ad..e683714 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,68 @@ npm install npm run build ``` +## Embedding Providers + +Context+ supports two embedding backends controlled by `CONTEXTPLUS_EMBED_PROVIDER`: + +| Provider | Value | Requires | Best For | +|----------|-------|----------|----------| +| **Ollama** (default) | `ollama` | Local Ollama server | Free, offline, private | +| **OpenAI-compatible** | `openai` | API key | Gemini (free tier), OpenAI, Groq, vLLM | + +### Ollama (Default) + +No extra configuration needed. Just run Ollama with an embedding model: + +```bash +ollama pull nomic-embed-text +ollama serve +``` + +### Google Gemini (Free Tier) + +```json +{ + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_GEMINI_API_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "text-embedding-004" + } +} +``` + +Get a free API key at [Google AI Studio](https://aistudio.google.com/apikey). + +### OpenAI + +```json +{ + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "OPENAI_API_KEY": "sk-...", + "OPENAI_EMBED_MODEL": "text-embedding-3-small" + } +} +``` + +### Other OpenAI-compatible APIs (Groq, vLLM, LiteLLM) + +Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.com/docs/api-reference/embeddings) works: + +```json +{ + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://your-proxy.example.com/v1", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "your-model-name" + } +} +``` + +> **Note:** The `semantic_navigate` tool also uses a chat model for cluster labeling. When using the `openai` provider, set `CONTEXTPLUS_OPENAI_CHAT_MODEL` (default: `gpt-4o-mini`). + ## Architecture Three layers built with TypeScript over stdio using the Model Context Protocol SDK: @@ -146,11 +208,16 @@ Three layers built with TypeScript over stdio using the Model Context Protocol S ## Config -| Variable | Type | Default | Description | -| --------------------------------------- | ------------------------- | ------------------ | ------------------------------------------------------------- | -| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Embedding model | -| `OLLAMA_API_KEY` | string | - | Ollama Cloud API key | -| `OLLAMA_CHAT_MODEL` | string | `llama3.2` | Chat model for cluster labeling | +| Variable | Type | Default | Description | +| --------------------------------------- | ------------------------- | -------------------------------------- | ------------------------------------------------------------- | +| `CONTEXTPLUS_EMBED_PROVIDER` | string | `ollama` | Embedding backend: `ollama` or `openai` | +| `OLLAMA_EMBED_MODEL` | string | `nomic-embed-text` | Ollama embedding model | +| `OLLAMA_API_KEY` | string | - | Ollama Cloud API key | +| `OLLAMA_CHAT_MODEL` | string | `llama3.2` | Ollama chat model for cluster labeling | +| `CONTEXTPLUS_OPENAI_API_KEY` | string | - | API key for OpenAI-compatible provider (alias: `OPENAI_API_KEY`) | +| `CONTEXTPLUS_OPENAI_BASE_URL` | string | `https://api.openai.com/v1` | OpenAI-compatible endpoint URL (alias: `OPENAI_BASE_URL`) | +| `CONTEXTPLUS_OPENAI_EMBED_MODEL` | string | `text-embedding-3-small` | OpenAI-compatible embedding model (alias: `OPENAI_EMBED_MODEL`) | +| `CONTEXTPLUS_OPENAI_CHAT_MODEL` | string | `gpt-4o-mini` | OpenAI-compatible chat model for labeling (alias: `OPENAI_CHAT_MODEL`) | | `CONTEXTPLUS_EMBED_BATCH_SIZE` | string (parsed as number) | `8` | Embedding batch size per GPU call, clamped to 5-10 | | `CONTEXTPLUS_EMBED_CHUNK_CHARS` | string (parsed as number) | `2000` | Per-chunk chars before merge, clamped to 256-8000 | | `CONTEXTPLUS_MAX_EMBED_FILE_SIZE` | string (parsed as number) | `51200` | Skip non-code text files larger than this many bytes | From 78446bce7d4148b469777076310ab1ad31e2c0b9 Mon Sep 17 00:00:00 2001 From: Cenk Tekin Date: Sat, 28 Mar 2026 16:06:25 +0300 Subject: [PATCH 15/29] docs: use full MCP config examples instead of env-only fragments Addresses Copilot review feedback - JSON snippets now show complete mcpServers structure for Claude Code, with a note about reusing the env block in other IDE configs. --- README.md | 50 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e683714..edf8525 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,21 @@ ollama serve ### Google Gemini (Free Tier) +Full Claude Code `.mcp.json` example: + ```json { - "env": { - "CONTEXTPLUS_EMBED_PROVIDER": "openai", - "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_GEMINI_API_KEY", - "CONTEXTPLUS_OPENAI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai", - "CONTEXTPLUS_OPENAI_EMBED_MODEL": "text-embedding-004" + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_GEMINI_API_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta/openai", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "text-embedding-004" + } + } } } ``` @@ -169,10 +177,16 @@ Get a free API key at [Google AI Studio](https://aistudio.google.com/apikey). ```json { - "env": { - "CONTEXTPLUS_EMBED_PROVIDER": "openai", - "OPENAI_API_KEY": "sk-...", - "OPENAI_EMBED_MODEL": "text-embedding-3-small" + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "OPENAI_API_KEY": "sk-...", + "OPENAI_EMBED_MODEL": "text-embedding-3-small" + } + } } } ``` @@ -183,16 +197,24 @@ Any endpoint implementing the [OpenAI Embeddings API](https://platform.openai.co ```json { - "env": { - "CONTEXTPLUS_EMBED_PROVIDER": "openai", - "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_KEY", - "CONTEXTPLUS_OPENAI_BASE_URL": "https://your-proxy.example.com/v1", - "CONTEXTPLUS_OPENAI_EMBED_MODEL": "your-model-name" + "mcpServers": { + "contextplus": { + "command": "npx", + "args": ["-y", "contextplus"], + "env": { + "CONTEXTPLUS_EMBED_PROVIDER": "openai", + "CONTEXTPLUS_OPENAI_API_KEY": "YOUR_KEY", + "CONTEXTPLUS_OPENAI_BASE_URL": "https://your-proxy.example.com/v1", + "CONTEXTPLUS_OPENAI_EMBED_MODEL": "your-model-name" + } + } } } ``` > **Note:** The `semantic_navigate` tool also uses a chat model for cluster labeling. When using the `openai` provider, set `CONTEXTPLUS_OPENAI_CHAT_MODEL` (default: `gpt-4o-mini`). +> +> For VS Code, Cursor, or OpenCode, use the same `env` block inside your IDE's MCP config format (see [Config file locations](#setup) table above). ## Architecture From 7441676c312d5fca08c55bae10d35120ef57aa44 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Sat, 28 Mar 2026 19:42:04 +0530 Subject: [PATCH 16/29] feat: add initial TODO list with tool renaming and new feature proposals --- TODO.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dc0b5c9 --- /dev/null +++ b/TODO.md @@ -0,0 +1,63 @@ +## v1 + +- [ ] rename tools for better meaning + - [ ] rename semantic_navigate to cluster + - [ ] rename get_context_tree to tree + - [ ] rename semantic_identifier_search and semantic_code_search (merged) to search + - [ ] rename get_feature_hub to find_hub and change its functionality to return rankings or relevant hubs based on a search query with options for semantic or keyword search or both + - [ ] add parameter to search for data in hubs by semantic meaning or keyword match or both + - [ ] add parameter optionality so if no parameters are provided, it returns context of all hubs in the project + - [ ] rename get_file_skeleton to skeleton + - [ ] rename get_blast_radius to blast_radius + - [ ] rename run_static_analysis to lint + - [ ] add skill checking - every file has no comments than top 2 lines, and other checks in the instructions file and return a skill score for each file and the project overall with files and lines that need fixing + - [ ] rename propose_commit to checkpoint and change its functionality to create a local undoable commit that agent can create during long worksessions mid work - uses shadow checkpoints or git whichever is better + - [ ] rename list_restore_points to restore_points + - [ ] rename undo_change to restore and change its functionality to restore to a specific commit point + - [ ] rename upsert_memory_node to create_memory + - [ ] rename search_memory_graph to search_memory + - [ ] rename retrieve_with_traversal to explore_memory + - [ ] create delete_memory tool that deletes nodes or relationships in the memory graph + - [ ] prune_stale_links tool should be removed as i want it to be done automatically by the system when any memory tools are called and before graph is accessed + - [ ] add_interlinked_context to bulk_memory +- [ ] merge semantic_identifier_search and semantic_code_search into one tool called search with a parameter for search type (e.g. "identifier" vs "file" or "hybrid" - which uses both regex and semantic search and returns 2 separate lists of results) + - [ ] add options for filtering by semantic meaning or normal search or both + - [ ] use a vector database for storing embeddings and searching instead of doing it in memory for better performance and scalability +- [ ] create a new memory system that uses a graph database and md files and vector database for storing memories + - [ ] add tool for updating memories with new information that updates the embeddings depending on the changes made to the content and the agent should use this instead of directly updating the content in the file + - [ ] update other tools to use the new memory system too, alongside with tools that save nodes and edges automatically and creates embeddings automatically when a new node or edge is created or deleted +- [ ] create a new tool called init that initializes the project by creating a context tree and .contextplus folder + - [ ] use .contextplus/hubs for feature hubs + - [ ] use .contextplus/embeddings for storing file and symbol embeddings + - [ ] use .contextplus/config for configuration files + - [ ] use .contextplus/memories for memory graph data + +--- + +## v2 + +code update: + +- [ ] list overengineered tools and parameters that could be removed for better context +- [ ] remove overengineered tools and parameters +- [ ] remove vibeslop code (if any) +- [ ] remove ollama bugs and spam for embeddings with a smarter embedding generation system that continuously watches for file changes and updates embeddings in the background, only init one time in the project and then its automatically watched + +new features: + +- [ ] ctx+ cli in cli/ folder + - [ ] visualize memory graphs, unto commits, hubs in the cli + - [ ] use charm's tui library - bubble or tea + - [ ] features like `contextplus init` + - [ ] visualize context tree, undo commits, hubs list, and more in the cli + - [ ] create hubs option from the cli for humans +- [ ] acp features (maybe that we can list all sessions and memories from all agents, like opencode, copilot, claude, codex into one generalized list) + - [ ] improved memory search from acp + - [ ] load session memoies from acp into the memory graph + - [ ] cli: see all sessions of all agents in list and add semantic search in cli + - [ ] cli: see all memories of all agents in list and add semantic search in cli + - [ ] use .contextplus/external_memories for storing acp imported memories and sessions +- [ ] faster and cleaner agent protocol access +- [ ] faster tool execution and cleaner outputs and better error handling and reporting with suggestions like "this tool failed, you can do this instead, it will work the same" +- [ ] better treesitter support and tools for using it to understand code structure and semantics better +- [ ] add these features to be visualized in the cli From 6f064c2deedd6f999c2af2e542a3adc9fedc13c9 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Sat, 28 Mar 2026 19:42:18 +0530 Subject: [PATCH 17/29] chore: bump version to 1.0.9 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5edeca..b21386b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "contextplus", - "version": "1.0.8", + "version": "1.0.9", "type": "module", "license": "MIT", "bin": { From 3d101fa7e47c1bac32d4a7b4420b0d182e016ca8 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Sat, 28 Mar 2026 20:14:34 +0530 Subject: [PATCH 18/29] feat: add researchplus tools and features to TODO list --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index dc0b5c9..65105ef 100644 --- a/TODO.md +++ b/TODO.md @@ -61,3 +61,4 @@ new features: - [ ] faster tool execution and cleaner outputs and better error handling and reporting with suggestions like "this tool failed, you can do this instead, it will work the same" - [ ] better treesitter support and tools for using it to understand code structure and semantics better - [ ] add these features to be visualized in the cli +- [ ] add researchplus tools and features From a3c0bcc24826a1ca85d6cb636c168c47c6a9bac9 Mon Sep 17 00:00:00 2001 From: ForLoopCodes Date: Mon, 30 Mar 2026 13:53:47 +0530 Subject: [PATCH 19/29] docs: add instructions for AI agent content modification in TODO list --- TODO.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TODO.md b/TODO.md index 65105ef..b1364bf 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,9 @@ +# TODO List + +## instructions + +ai agents are not allowed to change this file's content without human approval, ai agents can only complete the given tasks and update the task with [x] + ## v1 - [ ] rename tools for better meaning From bd2a656e8fd5d8dd81a10b410a0b7ac60a5fbab8 Mon Sep 17 00:00:00 2001 From: nlang Date: Mon, 30 Mar 2026 21:44:43 +0200 Subject: [PATCH 20/29] fix: serve agent instructions via MCP manifest --- agent-instructions.md | 50 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 +++++++++ 2 files changed, 60 insertions(+) create mode 100644 agent-instructions.md diff --git a/agent-instructions.md b/agent-instructions.md new file mode 100644 index 0000000..fe4cf67 --- /dev/null +++ b/agent-instructions.md @@ -0,0 +1,50 @@ +# Context+ MCP - Agent Workflow + +## Purpose + +Context+ gives you structural awareness of the entire codebase without reading every file. Use these tools to conserve context and maximize accuracy. + +## Workflow + +1. Start every task with `get_context_tree` or `get_file_skeleton` for structural overview +2. Use `semantic_code_search` or `semantic_identifier_search` to find code by meaning +3. Run `get_blast_radius` BEFORE modifying or deleting any symbol +4. Prefer structural tools over full-file reads — only read full files when signatures are insufficient +5. Run `run_static_analysis` after writing code +6. Use `search_memory_graph` at task start for prior context, `upsert_memory_node` after completing work + +## Execution Rules + +- Think less, execute sooner: make the smallest safe change that can be validated quickly +- Batch independent reads/searches in parallel — do not serialize them +- If a command fails, diagnose once, pivot strategy, continue — cap retries to 1-2 +- Keep outputs concise: short status updates, no verbose reasoning + +## Tool Reference + +| Tool | When to Use | +|------|-------------| +| `get_context_tree` | Start of every task. Map files + symbols with line ranges. | +| `get_file_skeleton` | Before full reads. Get signatures + line ranges first. | +| `semantic_code_search` | Find relevant files by concept. | +| `semantic_identifier_search` | Find functions/classes/variables and their call chains. | +| `semantic_navigate` | Browse codebase by meaning, not directory structure. | +| `get_blast_radius` | Before deleting or modifying any symbol. | +| `get_feature_hub` | Browse feature graph hubs. Find orphaned files. | +| `run_static_analysis` | After writing code. Catch errors deterministically. | +| `propose_commit` | Validate and save file changes. | +| `list_restore_points` | See undo history. | +| `undo_change` | Revert a change without touching git. | +| `upsert_memory_node` | Create/update memory nodes (concept, file, symbol, note). | +| `create_relation` | Create typed edges between memory nodes. | +| `search_memory_graph` | Semantic search + graph traversal across neighbors. | +| `prune_stale_links` | Remove decayed edges and orphan nodes. | +| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking. | +| `retrieve_with_traversal` | Walk outward from a node, return scored neighbors. | + +## Anti-Patterns + +1. Reading entire files without checking the skeleton first +2. Deleting functions without checking blast radius +3. Running independent commands sequentially when they can be parallelized +4. Repeating failed commands without changing approach diff --git a/src/index.ts b/src/index.ts index e688e3a..5413421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { readFileSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; import { z } from "zod"; import { createEmbeddingTrackerController } from "./core/embedding-tracker.js"; import { createIdleMonitor, getIdleShutdownMs, getParentPollMs, isBrokenPipeError, runCleanup, startParentMonitor } from "./core/process-lifecycle.js"; @@ -39,6 +41,13 @@ const ROOT_DIR = passthroughArgs[0] && !SUB_COMMANDS.includes(passthroughArgs[0] : process.cwd(); const INSTRUCTIONS_SOURCE_URL = "https://contextplus.vercel.app/api/instructions"; const INSTRUCTIONS_RESOURCE_URI = "contextplus://instructions"; +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +let agentInstructions: string | undefined; +try { + agentInstructions = readFileSync(resolve(PACKAGE_ROOT, "agent-instructions.md"), "utf8"); +} catch { + // agent-instructions.md not found, continuing without manifest instructions +} let noteServerActivity = () => { }; let ensureTrackerRunning = () => { }; @@ -148,6 +157,7 @@ const server = new McpServer({ version: "1.0.0", }, { capabilities: { logging: {} }, + ...(agentInstructions && { instructions: agentInstructions }), }); server.resource( From 7c30ebaedac20b500281e320da400c581096e7ba Mon Sep 17 00:00:00 2001 From: Sebastion Date: Mon, 30 Mar 2026 22:27:31 +0100 Subject: [PATCH 21/29] fix: validate file paths stay within rootDir in shadow restore system (CWE-22) Add assertWithinRoot() guard to createRestorePoint and restorePoint to prevent path traversal via ../ sequences in file paths. Both read and write operations now reject paths that resolve outside the project root. --- src/git/shadow.ts | 27 ++++--- test/main/shadow-traversal.test.mjs | 107 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 test/main/shadow-traversal.test.mjs diff --git a/src/git/shadow.ts b/src/git/shadow.ts index 0b624eb..b44b33e 100644 --- a/src/git/shadow.ts +++ b/src/git/shadow.ts @@ -3,7 +3,7 @@ import { simpleGit, type SimpleGit } from "simple-git"; import { readFile, writeFile, mkdir } from "fs/promises"; -import { join, dirname } from "path"; +import { join, dirname, resolve } from "path"; const SHADOW_BRANCH = "mcp-shadow-history"; const DATA_DIR = ".mcp_data"; @@ -15,6 +15,15 @@ export interface RestorePoint { message: string; } +function assertWithinRoot(rootDir: string, filePath: string): string { + const resolved = resolve(rootDir, filePath); + const normalizedRoot = resolve(rootDir) + "/"; + if (!resolved.startsWith(normalizedRoot) && resolved !== resolve(rootDir)) { + throw new Error(`Path traversal denied: "${filePath}" resolves outside root directory`); + } + return resolved; +} + async function ensureDataDir(rootDir: string): Promise { const dataPath = join(rootDir, DATA_DIR); await mkdir(dataPath, { recursive: true }); @@ -36,13 +45,14 @@ async function saveManifest(rootDir: string, points: RestorePoint[]): Promise { - const dataPath = await ensureDataDir(rootDir); + const normalizedRoot = resolve(rootDir); + const dataPath = await ensureDataDir(normalizedRoot); const id = `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const backupDir = join(dataPath, "backups", id); await mkdir(backupDir, { recursive: true }); for (const file of files) { - const fullPath = join(rootDir, file); + const fullPath = assertWithinRoot(normalizedRoot, file); try { const content = await readFile(fullPath, "utf-8"); const backupPath = join(backupDir, file.replace(/[\\/]/g, "__")); @@ -52,27 +62,28 @@ export async function createRestorePoint(rootDir: string, files: string[], messa } const point: RestorePoint = { id, timestamp: Date.now(), files, message }; - const manifest = await loadManifest(rootDir); + const manifest = await loadManifest(normalizedRoot); manifest.push(point); if (manifest.length > 100) manifest.splice(0, manifest.length - 100); - await saveManifest(rootDir, manifest); + await saveManifest(normalizedRoot, manifest); return point; } export async function restorePoint(rootDir: string, pointId: string): Promise { - const manifest = await loadManifest(rootDir); + const normalizedRoot = resolve(rootDir); + const manifest = await loadManifest(normalizedRoot); const point = manifest.find((p) => p.id === pointId); if (!point) throw new Error(`Restore point ${pointId} not found`); - const backupDir = join(rootDir, DATA_DIR, "backups", pointId); + const backupDir = join(normalizedRoot, DATA_DIR, "backups", pointId); const restoredFiles: string[] = []; for (const file of point.files) { + const targetPath = assertWithinRoot(normalizedRoot, file); const backupPath = join(backupDir, file.replace(/[\\/]/g, "__")); try { const content = await readFile(backupPath, "utf-8"); - const targetPath = join(rootDir, file); await mkdir(dirname(targetPath), { recursive: true }); await writeFile(targetPath, content); restoredFiles.push(file); diff --git a/test/main/shadow-traversal.test.mjs b/test/main/shadow-traversal.test.mjs new file mode 100644 index 0000000..49224b1 --- /dev/null +++ b/test/main/shadow-traversal.test.mjs @@ -0,0 +1,107 @@ +// PoC test for CWE-22: Path traversal in shadow restore system +// FEATURE: Security regression test for path traversal in createRestorePoint / restorePoint + +import { describe, it, after, before } from "node:test"; +import assert from "node:assert/strict"; +import { + createRestorePoint, + restorePoint, +} from "../../build/git/shadow.js"; +import { writeFile, mkdir, rm, readFile, access } from "fs/promises"; +import { join, resolve } from "path"; + +const FIXTURE_DIR = join(process.cwd(), "test", "_shadow_traversal_fixtures"); +const OUTSIDE_DIR = join(process.cwd(), "test", "_shadow_traversal_outside"); + +async function setup() { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(OUTSIDE_DIR, { recursive: true, force: true }); + await mkdir(FIXTURE_DIR, { recursive: true }); + await mkdir(OUTSIDE_DIR, { recursive: true }); +} + +async function cleanup() { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(OUTSIDE_DIR, { recursive: true, force: true }); +} + +describe("shadow path traversal (CWE-22)", async () => { + await setup(); + + describe("createRestorePoint rejects traversal paths", () => { + it("rejects file paths containing ../", async () => { + // Create a sensitive file outside rootDir + const secretPath = join(OUTSIDE_DIR, "secret.txt"); + await writeFile(secretPath, "TOP SECRET DATA"); + + // Attempt to read it via path traversal + // The relative traversal from FIXTURE_DIR to OUTSIDE_DIR: + const traversalPath = "../_shadow_traversal_outside/secret.txt"; + + // Verify the traversal would actually resolve outside rootDir + const resolved = resolve(FIXTURE_DIR, traversalPath); + assert.ok(!resolved.startsWith(FIXTURE_DIR + "/"), + `Traversal path should resolve outside rootDir: ${resolved}`); + + // This should throw or reject the traversal path + await assert.rejects( + () => createRestorePoint(FIXTURE_DIR, [traversalPath], "traversal attempt"), + (err) => { + // Accept any error that indicates the path was rejected + return err instanceof Error && /outside|traversal|invalid|path/i.test(err.message); + }, + "createRestorePoint should reject paths that traverse outside rootDir" + ); + }); + }); + + describe("restorePoint rejects traversal paths in manifest", () => { + it("does not write files outside rootDir during restore", async () => { + // First, create a legitimate restore point + const testFile = "legit.txt"; + await writeFile(join(FIXTURE_DIR, testFile), "legit content"); + const point = await createRestorePoint(FIXTURE_DIR, [testFile], "legit backup"); + + // Now manually tamper with the manifest to inject a traversal path + const manifestPath = join(FIXTURE_DIR, ".mcp_data", "restore-points.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf-8")); + + // Add a traversal file to the existing restore point + const traversalFile = "../_shadow_traversal_outside/pwned.txt"; + const tamperedPoint = { + id: `rp-tampered-${Date.now()}`, + timestamp: Date.now(), + files: [traversalFile], + message: "tampered" + }; + + // Create backup content for the tampered point + const backupDir = join(FIXTURE_DIR, ".mcp_data", "backups", tamperedPoint.id); + await mkdir(backupDir, { recursive: true }); + const backupFileName = traversalFile.replace(/[\\/]/g, "__"); + await writeFile(join(backupDir, backupFileName), "MALICIOUS CONTENT"); + + // Save the tampered manifest + manifest.push(tamperedPoint); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + + // Attempt restore — should reject the traversal path + await assert.rejects( + () => restorePoint(FIXTURE_DIR, tamperedPoint.id), + (err) => { + return err instanceof Error && /outside|traversal|invalid|path/i.test(err.message); + }, + "restorePoint should reject paths that traverse outside rootDir" + ); + + // Verify the file was NOT written outside rootDir + const pwnedPath = join(OUTSIDE_DIR, "pwned.txt"); + await assert.rejects( + () => access(pwnedPath), + "File should not have been written outside rootDir" + ); + }); + }); + + after(cleanup); +}); From 4fe35856dbf52783ec25112ff840d8b3bc772e5a Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 31 Mar 2026 07:22:46 +0100 Subject: [PATCH 22/29] fix: use execFile instead of exec to prevent command injection in static analysis Replace child_process.exec (which spawns a shell) with child_process.execFile (which does not) in the static analysis runner. This prevents shell metacharacters in agent-supplied targetPath from being interpreted, closing a CWE-78 command injection vector. The runCommand function now passes cmd and args as separate parameters to execFileAsync, so arguments are never concatenated into a shell string. --- src/tools/static-analysis.ts | 7 +- test/main/static-analysis-injection.test.mjs | 102 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 test/main/static-analysis-injection.test.mjs diff --git a/src/tools/static-analysis.ts b/src/tools/static-analysis.ts index 48bf883..208e427 100644 --- a/src/tools/static-analysis.ts +++ b/src/tools/static-analysis.ts @@ -1,12 +1,12 @@ // Static analysis runner using native linters and compilers // Delegates dead code detection to deterministic tools, not LLM guessing -import { exec } from "child_process"; +import { execFile } from "child_process"; import { stat } from "fs/promises"; import { resolve, extname } from "path"; import { promisify } from "util"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export interface StaticAnalysisOptions { rootDir: string; @@ -29,9 +29,8 @@ const LINTER_MAP: Record = { }; async function runCommand(cmd: string, args: string[], cwd: string): Promise { - const fullCmd = `${cmd} ${args.join(" ")}`; try { - const { stdout, stderr } = await execAsync(fullCmd, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); + const { stdout, stderr } = await execFileAsync(cmd, args, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); return { tool: cmd, output: (stdout + stderr).trim(), exitCode: 0 }; } catch (err: any) { return { tool: cmd, output: (err.stdout ?? "") + (err.stderr ?? ""), exitCode: err.code ?? 1 }; diff --git a/test/main/static-analysis-injection.test.mjs b/test/main/static-analysis-injection.test.mjs new file mode 100644 index 0000000..3e41a90 --- /dev/null +++ b/test/main/static-analysis-injection.test.mjs @@ -0,0 +1,102 @@ +// Test: CWE-78 command injection via targetPath in static analysis +// Verifies that shell metacharacters in targetPath cannot be used for injection + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { mkdir, writeFile, rm, readFile } from "fs/promises"; +import { resolve, join } from "path"; + +const { runStaticAnalysis } = + await import("../../build/tools/static-analysis.js"); + +const FIXTURE = resolve("test/_injection_fixtures"); +const SENTINEL = join(FIXTURE, "pwned.txt"); + +before(async () => { + await mkdir(FIXTURE, { recursive: true }); + await writeFile( + join(FIXTURE, "safe.py"), + "# Safe python file\n# FEATURE: test\nprint('hello')\n", + ); +}); + +after(async () => { + await rm(FIXTURE, { recursive: true, force: true }); +}); + +describe("CWE-78: command injection via targetPath", () => { + it("should not execute injected commands via $() with .py extension", async () => { + // This payload ends in .py so it matches the Python linter + // The $() will be interpreted by the shell in exec() + const maliciousPath = `$(echo INJECTED > ${SENTINEL}).py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via $() succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via backticks with .py extension", async () => { + const maliciousPath = "`echo INJECTED > " + SENTINEL + "`.py"; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via backticks succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via semicolon ending with .py", async () => { + // Craft: foo; echo INJECTED > sentinel; echo.py + const maliciousPath = `foo; echo INJECTED > ${SENTINEL}; echo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via semicolon succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via pipe ending with .py", async () => { + const maliciousPath = `safe.py | tee ${SENTINEL} | cat foo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via pipe succeeded – sentinel file was created"); + }); +}); From 7b4f6bef7f8d6915ceb688db69c041786387d7c3 Mon Sep 17 00:00:00 2001 From: nlang Date: Thu, 2 Apr 2026 18:47:39 +0200 Subject: [PATCH 23/29] docs: add mandatory tool priority table to agent instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code tends to fall back to native search/read tools (grep, find, cat) instead of using Context+ equivalents. Add an explicit priority table that makes clear Context+ tools must be preferred — they provide semantic understanding, not just string matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- agent-instructions.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/agent-instructions.md b/agent-instructions.md index fe4cf67..3eb22e6 100644 --- a/agent-instructions.md +++ b/agent-instructions.md @@ -2,7 +2,20 @@ ## Purpose -Context+ gives you structural awareness of the entire codebase without reading every file. Use these tools to conserve context and maximize accuracy. +Context+ gives you structural awareness of the entire codebase without reading every file. These tools replace your default search and read operations — use them as your primary interface to the codebase. + +## Tool Priority (Mandatory) + +You MUST use Context+ tools instead of native equivalents. Only fall back to native tools when a Context+ tool cannot fulfill the specific need. + +| Instead of… | MUST use… | Why | +|--------------------------|--------------------------------|---------------------------------------------| +| `grep`, `rg`, `ripgrep` | `semantic_code_search` | Finds by meaning, not just string match | +| `find`, `ls`, `glob` | `get_context_tree` | Returns structure with symbols + line ranges| +| `cat`, `head`, read file | `get_file_skeleton` first | Signatures without wasting context on bodies| +| manual symbol tracing | `get_blast_radius` | Traces all usages across the entire codebase| +| keyword search | `semantic_identifier_search` | Ranked definitions + call chains | +| directory browsing | `semantic_navigate` | Browse by meaning, not file paths | ## Workflow From 3e4965197396170dc1a135c74356c3bc4a962c6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:38:09 +0000 Subject: [PATCH 24/29] docs: add pmll-memory-mcp standalone package info to Memory & RAG sections Agent-Logs-Url: https://github.com/drQedwards/contextplus/sessions/08181583-3753-4fdd-a938-f3c6390b931d Co-authored-by: drQedwards <213266729+drQedwards@users.noreply.github.com> --- INSTRUCTIONS.md | 2 +- README.md | 14 ++++++++++++++ package-lock.json | 12 ++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 8d8a2cf..1577a41 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -26,7 +26,7 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - `static-analysis.ts` - Native linter runner (tsc, eslint, py_compile, cargo check, go vet). - `propose-commit.ts` - Code gatekeeper validating headers, FEATURE tag, no inline comments, nesting, file length. - `feature-hub.ts` - Obsidian-style feature hub navigator with bundled skeleton views. -- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). +- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). Also available standalone via `pmll-memory-mcp` (`npm i pmll-memory-mcp` or `pip install pmll-memory-mcp`) — see [drQedwards/PPM](https://github.com/drQedwards/PPM). The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MUST use `search_memory_graph` at the start of every task to retrieve prior context, and persist learnings with `upsert_memory_node` and `create_relation` after completing work. This prevents redundant exploration and builds cumulative knowledge across sessions. diff --git a/README.md b/README.md index edf8525..80779b7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,20 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ### Memory & RAG +These tools are built into the Context+ MCP server. They are also available as a standalone package via [pmll-memory-mcp](https://github.com/drQedwards/PPM) — a combined Context+ and PMLL integration: + +```bash +npm i pmll-memory-mcp +# or +pip install pmll-memory-mcp +``` + +If both fail, clone the combined repo directly: + +```bash +git clone https://github.com/drQedwards/PPM +``` + | Tool | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------- | | `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | diff --git a/package-lock.json b/package-lock.json index 9e87ce1..29c5609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "contextplus", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contextplus", - "version": "1.0.8", + "version": "1.0.9", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", + "claude": "^0.1.2", "ignore": "^7.0.4", "ml-matrix": "^6.12.1", "ollama": "^0.6.3", @@ -523,6 +524,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/claude": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/claude/-/claude-0.1.2.tgz", + "integrity": "sha512-Qjrrs+G1pwovbIgGh5R1Ni4Al79AfpbkvfonpHH0yj86cfOq3AoAzNbEeD9TQ980hrog8TM0vh1CNn+7uf/zYA==", + "deprecated": "The official Claude Code package is available at @anthropic-ai/claude-code", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "1.0.1", "license": "MIT", From eb853acf1f4bbaa746ae58e932665cc6ef2531e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:42:38 +0000 Subject: [PATCH 25/29] docs: fix Memory & RAG section to correctly describe pmll-memory-mcp as complementary server Agent-Logs-Url: https://github.com/drQedwards/contextplus/sessions/04eb50d7-3867-4f21-adad-7db617efcbae Co-authored-by: drQedwards <213266729+drQedwards@users.noreply.github.com> --- INSTRUCTIONS.md | 2 +- README.md | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 1577a41..7d90280 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -26,7 +26,7 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - `static-analysis.ts` - Native linter runner (tsc, eslint, py_compile, cargo check, go vet). - `propose-commit.ts` - Code gatekeeper validating headers, FEATURE tag, no inline comments, nesting, file length. - `feature-hub.ts` - Obsidian-style feature hub navigator with bundled skeleton views. -- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). Also available standalone via `pmll-memory-mcp` (`npm i pmll-memory-mcp` or `pip install pmll-memory-mcp`) — see [drQedwards/PPM](https://github.com/drQedwards/PPM). +- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). The long-term memory graph architecture is also adapted by the complementary [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) server (`npx pmll-memory-mcp`), which adds short-term KV memory and a solution engine — see [drQedwards/PPM](https://github.com/drQedwards/PPM). The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MUST use `search_memory_graph` at the start of every task to retrieve prior context, and persist learnings with `upsert_memory_node` and `create_relation` after completing work. This prevents redundant exploration and builds cumulative knowledge across sessions. diff --git a/README.md b/README.md index 80779b7..ed2ba64 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,6 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ### Memory & RAG -These tools are built into the Context+ MCP server. They are also available as a standalone package via [pmll-memory-mcp](https://github.com/drQedwards/PPM) — a combined Context+ and PMLL integration: - -```bash -npm i pmll-memory-mcp -# or -pip install pmll-memory-mcp -``` - -If both fail, clone the combined repo directly: - -```bash -git clone https://github.com/drQedwards/PPM -``` - | Tool | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------- | | `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | @@ -64,6 +50,8 @@ git clone https://github.com/drQedwards/PPM | `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | | `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | +> **Complementary server:** [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) (`npx pmll-memory-mcp`) is a separate MCP server by [@drQedwards](https://github.com/drQedwards) that adapts Context+'s long-term memory graph and adds short-term KV context memory, Q-promise deduplication, and a solution engine on top. See [drQedwards/PPM](https://github.com/drQedwards/PPM) for details. + ## Setup ### Quick Start (npx / bunx) From 3f5f81d43bb34190b09662947b5e63acb64850d2 Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" Date: Sat, 4 Apr 2026 06:23:14 -0400 Subject: [PATCH 26/29] Document PMLL short-term KV memory tools and patterns Added PMLL short-term KV memory tools section with usage guidelines and anti-patterns. --- agent-instructions.md | 114 ++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/agent-instructions.md b/agent-instructions.md index 3eb22e6..804b184 100644 --- a/agent-instructions.md +++ b/agent-instructions.md @@ -4,18 +4,42 @@ Context+ gives you structural awareness of the entire codebase without reading every file. These tools replace your default search and read operations — use them as your primary interface to the codebase. +## PMLL Short-Term KV Memory (5 Tools) + +Before every expensive MCP tool invocation, agents MUST use the PMLL short-term KV memory tools to check the cache. This eliminates redundant calls and accelerates task execution. These tools are provided by the [`pmll-memory-mcp`](https://www.npmjs.com/package/pmll-memory-mcp) server (see [mcp/README.md](./README.md)). + +| Tool | Input | Description | +|-----------|----------------------------------------------------|------------------------------------------------------------| +| `init` | `session_id: str`, `silo_size: int = 256` | Initialize a PMLL silo and Q-promise chain for the session | +| `peek` | `session_id: str`, `key: str` | Non-destructive cache lookup + Q-promise check (call before every expensive tool) | +| `set` | `session_id: str`, `key: str`, `value: str` | Store a key-value pair in the silo after a cache miss | +| `resolve` | `session_id: str`, `promise_id: str` | Check or resolve a Q-promise continuation | +| `flush` | `session_id: str` | Clear all silo slots at task completion | + +### The `peek()` Pattern + +Call `peek` before every expensive tool invocation: + +1. **`init`** once at task start to set up the session silo +2. **`peek`** before each expensive call — if hit, use the cached value; if pending, wait on the Q-promise +3. **`set`** after a cache miss to populate the silo for future agents/subtasks +4. **`resolve`** to check or fulfill Q-promise continuations +5. **`flush`** at task end to clear all session slots + +This pattern ensures that Context+ tool results, Playwright page contents, and other expensive outputs are cached and reused across subtasks rather than re-fetched. + ## Tool Priority (Mandatory) You MUST use Context+ tools instead of native equivalents. Only fall back to native tools when a Context+ tool cannot fulfill the specific need. -| Instead of… | MUST use… | Why | -|--------------------------|--------------------------------|---------------------------------------------| -| `grep`, `rg`, `ripgrep` | `semantic_code_search` | Finds by meaning, not just string match | -| `find`, `ls`, `glob` | `get_context_tree` | Returns structure with symbols + line ranges| -| `cat`, `head`, read file | `get_file_skeleton` first | Signatures without wasting context on bodies| -| manual symbol tracing | `get_blast_radius` | Traces all usages across the entire codebase| -| keyword search | `semantic_identifier_search` | Ranked definitions + call chains | -| directory browsing | `semantic_navigate` | Browse by meaning, not file paths | +| Instead of… | MUST use… | Why | +|--------------------------|------------------------------|----------------------------------------------| +| `grep`, `rg`, `ripgrep` | `semantic_code_search` | Finds by meaning, not just string match | +| `find`, `ls`, `glob` | `get_context_tree` | Returns structure with symbols + line ranges | +| `cat`, `head`, read file | `get_file_skeleton` first | Signatures without wasting context on bodies | +| manual symbol tracing | `get_blast_radius` | Traces all usages across the entire codebase | +| keyword search | `semantic_identifier_search` | Ranked definitions + call chains | +| directory browsing | `semantic_navigate` | Browse by meaning, not file paths | ## Workflow @@ -35,25 +59,56 @@ You MUST use Context+ tools instead of native equivalents. Only fall back to nat ## Tool Reference -| Tool | When to Use | -|------|-------------| -| `get_context_tree` | Start of every task. Map files + symbols with line ranges. | -| `get_file_skeleton` | Before full reads. Get signatures + line ranges first. | -| `semantic_code_search` | Find relevant files by concept. | -| `semantic_identifier_search` | Find functions/classes/variables and their call chains. | -| `semantic_navigate` | Browse codebase by meaning, not directory structure. | -| `get_blast_radius` | Before deleting or modifying any symbol. | -| `get_feature_hub` | Browse feature graph hubs. Find orphaned files. | -| `run_static_analysis` | After writing code. Catch errors deterministically. | -| `propose_commit` | Validate and save file changes. | -| `list_restore_points` | See undo history. | -| `undo_change` | Revert a change without touching git. | -| `upsert_memory_node` | Create/update memory nodes (concept, file, symbol, note). | -| `create_relation` | Create typed edges between memory nodes. | -| `search_memory_graph` | Semantic search + graph traversal across neighbors. | -| `prune_stale_links` | Remove decayed edges and orphan nodes. | -| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking. | -| `retrieve_with_traversal` | Walk outward from a node, return scored neighbors. | +### PMLL Short-Term KV Memory + +| Tool | When to Use | +|-----------|------------------------------------------------------------------------------| +| `init` | Once at task start. Set up the PMLL silo and Q-promise chain for the session.| +| `peek` | Before every expensive MCP tool call. Non-destructive cache + Q-promise check.| +| `set` | After a cache miss. Store the result so future agents/subtasks skip the call. | +| `resolve` | When a Q-promise is pending. Check or fulfill the continuation. | +| `flush` | At task end. Clear all silo slots for the session. | + +### GraphQL + +| Tool | When to Use | +|-----------|------------------------------------------------------------------------------| +| `graphql` | Execute GraphQL queries/mutations against the memory store with optional PMLL cache integration. | + +### Context+ Structural Tools + +| Tool | When to Use | +|-----------------------------|--------------------------------------------------------------| +| `get_context_tree` | Start of every task. Map files + symbols with line ranges. | +| `get_file_skeleton` | Before full reads. Get signatures + line ranges first. | +| `semantic_code_search` | Find relevant files by concept. | +| `semantic_identifier_search`| Find functions/classes/variables and their call chains. | +| `semantic_navigate` | Browse codebase by meaning, not directory structure. | +| `get_blast_radius` | Before deleting or modifying any symbol. | +| `get_feature_hub` | Browse feature graph hubs. Find orphaned files. | +| `run_static_analysis` | After writing code. Catch errors deterministically. | +| `propose_commit` | Validate and save file changes. | +| `list_restore_points` | See undo history. | +| `undo_change` | Revert a change without touching git. | + +### Long-Term Memory Graph + +| Tool | When to Use | +|-----------------------------|--------------------------------------------------------------| +| `upsert_memory_node` | Create/update memory nodes (concept, file, symbol, note). | +| `create_relation` | Create typed edges between memory nodes. | +| `search_memory_graph` | Semantic search + graph traversal across neighbors. | +| `prune_stale_links` | Remove decayed edges and orphan nodes. | +| `add_interlinked_context` | Bulk-add nodes with auto-similarity linking. | +| `retrieve_with_traversal` | Walk outward from a node, return scored neighbors. | + +### Solution Engine + +| Tool | When to Use | +|------------------------|-----------------------------------------------------------------------| +| `resolve_context` | Unified context lookup — checks short-term KV first, falls back to long-term semantic graph. | +| `promote_to_long_term` | Promote a frequently-accessed short-term KV entry to the long-term memory graph. | +| `memory_status` | Get a unified view of both short-term (KV cache) and long-term (semantic graph) memory layers. | ## Anti-Patterns @@ -61,3 +116,8 @@ You MUST use Context+ tools instead of native equivalents. Only fall back to nat 2. Deleting functions without checking blast radius 3. Running independent commands sequentially when they can be parallelized 4. Repeating failed commands without changing approach +5. Calling expensive MCP tools without calling `peek` first to check the cache +6. Forgetting to call `init` at task start or `flush` at task end, causing silent cache misses or stale data across sessions +7. Storing frequently-accessed payloads only in short-term KV instead of promoting them to long-term memory with `promote_to_long_term` +8. Calling `search_memory_graph` or `retrieve_with_traversal` directly instead of using `resolve_context`, which checks both memory layers in one call +9. Ignoring Q-promise `pending` status from `peek` and re-issuing the same expensive call instead of waiting with `resolve` From 996a73c5ed5b354e05eef60bfc3821a46df60d15 Mon Sep 17 00:00:00 2001 From: ForLoop Date: Tue, 7 Apr 2026 00:03:49 +0530 Subject: [PATCH 27/29] Update README.md --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index ed2ba64..7b970ae 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ Semantic Intelligence for Large-Scale Engineering. Context+ is an MCP server designed for developers who demand 99% accuracy. By combining RAG, Tree-sitter AST, Spectral Clustering, and Obsidian-style linking, Context+ turns a massive codebase into a searchable, hierarchical feature graph. +also check out: +**AIRENA is now Live.** +Curate a team of AI agents. +Face head-to-head with other orchestrators. +Iterate on your strategy and compete for real money. +- Play Here: https://airena.me +- Read More: https://airena.me/docs + + https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ## Tools From 36456cb054c40c775f82389c6260f8ee5379861f Mon Sep 17 00:00:00 2001 From: ForLoop Date: Tue, 7 Apr 2026 00:05:36 +0530 Subject: [PATCH 28/29] Update README.md --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index 7b970ae..0cd182f 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,7 @@ Semantic Intelligence for Large-Scale Engineering. Context+ is an MCP server designed for developers who demand 99% accuracy. By combining RAG, Tree-sitter AST, Spectral Clustering, and Obsidian-style linking, Context+ turns a massive codebase into a searchable, hierarchical feature graph. -also check out: -**AIRENA is now Live.** -Curate a team of AI agents. -Face head-to-head with other orchestrators. -Iterate on your strategy and compete for real money. -- Play Here: https://airena.me -- Read More: https://airena.me/docs - +**While you're here, check out my other project Airena. Curate a team of AI agents and face head-to-head with other orchestrators. First place on the leaderboard gets a $1600 prize!** https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 From 393875a2d3549fbadd3fc53d72d2bbdce49455b9 Mon Sep 17 00:00:00 2001 From: forloop Date: Sat, 2 May 2026 12:18:20 +0530 Subject: [PATCH 29/29] readme --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 0cd182f..ed2ba64 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Semantic Intelligence for Large-Scale Engineering. Context+ is an MCP server designed for developers who demand 99% accuracy. By combining RAG, Tree-sitter AST, Spectral Clustering, and Obsidian-style linking, Context+ turns a massive codebase into a searchable, hierarchical feature graph. -**While you're here, check out my other project Airena. Curate a team of AI agents and face head-to-head with other orchestrators. First place on the leaderboard gets a $1600 prize!** - https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 ## Tools