feat: add SOCRATICODE_PROJECT_ID env var for shared indexes across directories

When working with git worktrees (or any setup where the same codebase lives
in multiple directories), each path currently gets its own Qdrant collection.
This means the same codebase is indexed multiple times.

This change adds a SOCRATICODE_PROJECT_ID environment variable that, when set,
overrides the path-based project ID generation. All directories sharing the
same SOCRATICODE_PROJECT_ID will use the same Qdrant collections (codebase,
codegraph, context), eliminating redundant indexing.

The value must match [a-zA-Z0-9_-]+ to remain Qdrant-friendly. An error is
thrown at startup if the value contains invalid characters.
This commit is contained in:
Csaba Tuncsik
2026-03-15 10:53:59 +01:00
parent 72c7ce05f8
commit fadfd8a80e
3 changed files with 57 additions and 1 deletions
+1
View File
@@ -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. |
+14
View File
@@ -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);
}
+42 -1
View File
@@ -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", () => {