diff --git a/README.md b/README.md index bf6986e..9469801 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,7 @@ Artifacts are chunked and embedded into Qdrant using the same hybrid dense + BM2 | `MAX_FILE_SIZE_MB` | `5` | Maximum file size in MB. Files larger than this are skipped during indexing. Increase for repos with large generated or data files you want indexed. | | `SEARCH_DEFAULT_LIMIT` | `10` | Default number of results returned by `codebase_search` (1-50). Each result is a ranked code chunk with file path, line range, and content. Higher values give broader coverage but produce more output. Can still be overridden per-query via the `limit` tool parameter. | | `SEARCH_MIN_SCORE` | `0.10` | Minimum RRF (Reciprocal Rank Fusion) score threshold (0-1). Results below this score are filtered out. Helps remove low-relevance noise from search results. Set to `0` to disable filtering (returns all results up to `limit`). Can be overridden per-query via the `minScore` tool parameter. Works together with `limit`: results are first filtered by score, then capped at `limit`. | +| `SOCRATICODE_PROJECT_ID` | *(none)* | Override the auto-generated project ID. When set, all paths resolve to the same Qdrant collections, allowing multiple directories (e.g. git worktrees of the same repo) to share a single index. Must match `[a-zA-Z0-9_-]+`. | | `SOCRATICODE_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` | | `SOCRATICODE_LOG_FILE` | *(none)* | Absolute path to a log file. When set, all log entries are appended to this file (a session separator is written on each server start). Useful for debugging when the MCP host doesn't surface log notifications. | diff --git a/src/config.ts b/src/config.ts index 1a71e18..7252438 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,8 +6,22 @@ import path from "node:path"; /** * Generate a stable project ID from an absolute folder path. * Uses a short SHA-256 prefix so collection names stay Qdrant-friendly. + * + * When `SOCRATICODE_PROJECT_ID` is set, that value is used directly instead + * of hashing the path. This lets multiple directory trees (e.g. git + * worktrees) share a single Qdrant index. The value must contain only + * characters valid in a Qdrant collection name (`[a-zA-Z0-9_-]`). */ export function projectIdFromPath(folderPath: string): string { + const explicit = process.env.SOCRATICODE_PROJECT_ID?.trim(); + if (explicit) { + if (!/^[a-zA-Z0-9_-]+$/.test(explicit)) { + throw new Error( + `SOCRATICODE_PROJECT_ID must match [a-zA-Z0-9_-]+ but got: "${explicit}"`, + ); + } + return explicit; + } const normalized = path.resolve(folderPath); return createHash("sha256").update(normalized).digest("hex").slice(0, 12); } diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 9408a8e..c75fbfe 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -1,9 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { collectionName, contextCollectionName, graphCollectionName, projectIdFromPath } from "../../src/config.js"; describe("config", () => { + // Clean up env override between tests + const originalEnv = process.env.SOCRATICODE_PROJECT_ID; + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.SOCRATICODE_PROJECT_ID; + } else { + process.env.SOCRATICODE_PROJECT_ID = originalEnv; + } + }); + describe("projectIdFromPath", () => { it("returns a 12-character hex string", () => { const id = projectIdFromPath("/some/project/path"); @@ -42,6 +52,37 @@ describe("config", () => { // path.resolve normalizes trailing slash, so they should match expect(id1).toBe(id2); }); + + it("uses SOCRATICODE_PROJECT_ID when set", () => { + process.env.SOCRATICODE_PROJECT_ID = "my-shared-project"; + const id = projectIdFromPath("/some/project/path"); + expect(id).toBe("my-shared-project"); + }); + + it("ignores path differences when SOCRATICODE_PROJECT_ID is set", () => { + process.env.SOCRATICODE_PROJECT_ID = "shared"; + const id1 = projectIdFromPath("/worktree/a"); + const id2 = projectIdFromPath("/worktree/b"); + expect(id1).toBe(id2); + }); + + it("trims whitespace from SOCRATICODE_PROJECT_ID", () => { + process.env.SOCRATICODE_PROJECT_ID = " my-project "; + expect(projectIdFromPath("/any/path")).toBe("my-project"); + }); + + it("throws on invalid SOCRATICODE_PROJECT_ID characters", () => { + process.env.SOCRATICODE_PROJECT_ID = "invalid/name"; + expect(() => projectIdFromPath("/any/path")).toThrow( + /SOCRATICODE_PROJECT_ID must match/, + ); + }); + + it("falls back to hash when SOCRATICODE_PROJECT_ID is empty", () => { + process.env.SOCRATICODE_PROJECT_ID = " "; + const id = projectIdFromPath("/some/project/path"); + expect(id).toMatch(/^[0-9a-f]{12}$/); + }); }); describe("collectionName", () => {