Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,57 @@ Use Google's Gemini embedding API. Requires an [API key](https://aistudio.google

> Defaults: `EMBEDDING_MODEL=gemini-embedding-001`, `EMBEDDING_DIMENSIONS=3072`.

### Git Worktrees (shared index across directories)

If you use [git worktrees](https://git-scm.com/docs/git-worktree) — or any workflow where the same repository lives in multiple directories — each path would normally get its own Qdrant index. This means redundant embedding and storage for what is essentially the same codebase.

Set `SOCRATICODE_PROJECT_ID` to share a single index across all directories of the same project.

#### MCP hosts with git worktree detection (e.g. Claude Code)

Some MCP hosts (like [Claude Code](https://claude.ai/claude-code)) resolve the project root by following git worktree links. Since worktrees point back to the main repository's `.git` directory, the host automatically maps all worktrees to the same project config. This means you only need to configure the MCP server **once** for the main checkout — all worktrees inherit it automatically.

For Claude Code, add the server with local scope from your main checkout:

```bash
cd /path/to/main-checkout
claude mcp add -e SOCRATICODE_PROJECT_ID=my-project --scope local socraticode -- npx -y socraticode
```

All worktrees created from this repo will automatically connect to socraticode with the shared project ID. No per-worktree setup needed.

> **Note:** This only works for git worktrees. Separate `git clone`s of the same repo have independent `.git` directories and won't share the config.

#### Other MCP hosts (per-project `.mcp.json`)

For MCP hosts that don't resolve git worktree paths, add a `.mcp.json` at the root of each worktree (and your main checkout):

```json
{
"mcpServers": {
"socraticode": {
"command": "npx",
"args": ["-y", "socraticode"],
"env": {
"SOCRATICODE_PROJECT_ID": "my-project"
}
}
}
}
```

Add `.mcp.json` to your `.gitignore` if you don't want it tracked.

#### How it works

With this config, agents running in `/repo/main`, `/repo/worktree-feat-a`, and `/repo/worktree-fix-b` all share the same `codebase_my-project`, `codegraph_my-project`, and `context_my-project` Qdrant collections.

**How it works in practice:**

- The semantic index reflects whichever worktree last triggered a file change — but since branches typically differ by only a handful of files, the index is 99%+ accurate for all worktrees
- Your AI agent reads actual file contents from its own worktree; the shared index is only used for discovery and navigation
- When changes merge back to main, the file watcher re-indexes the changed files and the index converges

### Available tools

Once connected, 21 tools are available to your AI assistant:
Expand Down Expand Up @@ -578,6 +629,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. |

Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
43 changes: 42 additions & 1 deletion tests/unit/config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading