socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
413 lines • 25.1 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import path from "node:path";
import { collectionName, projectIdFromPath, resolveLinkedCollections } from "../config.js";
import { getWatcherMode, SEARCH_DEFAULT_LIMIT, SEARCH_MIN_SCORE, SOCRATICODE_VERSION } from "../constants.js";
import { getGraphStatus, isGraphBuilderStale } from "../services/code-graph.js";
import { getArtifactStatusSummary } from "../services/context-artifacts.js";
import { ensureQdrantReady } from "../services/docker.js";
import { withGitRefreshNotice } from "../services/git-refresh.js";
import { indexProfileDifferences, requestedIndexProfile, resolveEffectiveIndexProfile, } from "../services/index-profile.js";
import { getIndexingProgress, getLastCompleted, isIndexingInProgress } from "../services/indexer.js";
import { getLockHolderPid } from "../services/lock.js";
import { getCollectionInfo, getProjectMetadata, loadProjectEffectiveProfile, searchChunks, searchMultipleCollections, } from "../services/qdrant.js";
import { ensureWatcherStarted, isWatchedByAnyProcess, isWatching } from "../services/watcher.js";
/** Format an IndexingProgress into display lines (elapsed, progress, batches, graph). */
function formatProgressLines(progress) {
const elapsed = ((Date.now() - progress.startedAt) / 1000).toFixed(0);
const pct = progress.filesTotal > 0
? ` (${Math.round((progress.filesProcessed / progress.filesTotal) * 100)}%)`
: "";
const progressLine = (progress.chunksTotal && progress.chunksTotal > 0)
? ` Progress: ${progress.chunksProcessed ?? 0}/${progress.chunksTotal} chunks embedded (${Math.round(((progress.chunksProcessed ?? 0) / progress.chunksTotal) * 100)}%)`
: ` Progress: ${progress.filesProcessed}/${progress.filesTotal} files${pct}`;
const batchLine = (progress.batchesTotal && progress.batchesTotal > 1)
? ` Batches: ${progress.batchesProcessed ?? 0}/${progress.batchesTotal} completed (${progress.filesProcessed}/${progress.filesTotal} files)`
: undefined;
const graphLine = progress.phase === "building code graph"
? "Code graph: building now..."
: "Code graph: pending — will be auto-built after indexing completes";
return { elapsed, pct, progressLine, batchLine, graphLine };
}
/** Append the current watcher policy and activity state to a tool response. */
async function appendWatcherState(lines, resolvedPath, watcherMode, context) {
const watchedHere = isWatching(resolvedPath);
const watchedAnywhere = watchedHere || await isWatchedByAnyProcess(resolvedPath);
const watcherLines = [];
if (watcherMode === "git") {
watcherLines.push("File watcher: disabled (SOCRATICODE_WATCHER=git; refresh is triggered by ref/HEAD changes)");
if (watchedAnywhere)
watcherLines.push("Warning: an active file watcher in another configuration may still update this shared index.");
}
else if (context === "search") {
if (watcherMode === "off") {
if (watchedHere) {
watcherLines.push("⚠ WARNING: File watching is disabled in this process, but this process still has an active watcher.");
watcherLines.push(" Restart the MCP server to apply the changed environment setting.");
}
else if (watchedAnywhere) {
watcherLines.push("⚠ WARNING: File watching is disabled in this process, but another active watcher can still update the shared index.");
watcherLines.push(" Set SOCRATICODE_WATCHER=off for every MCP process that uses this checkout.");
}
else {
watcherLines.push("ℹ INDEX SNAPSHOT: File watching is disabled by SOCRATICODE_WATCHER=off.");
watcherLines.push(" Results reflect the last explicit codebase_index or codebase_update.");
}
}
else if (watcherMode === "manual") {
if (!watchedAnywhere) {
watcherLines.push("ℹ INDEX SNAPSHOT: Automatic file watching is disabled by SOCRATICODE_WATCHER=manual.");
watcherLines.push(" Run codebase_update to refresh, or start the watcher explicitly with codebase_watch.");
}
else if (!watchedHere) {
watcherLines.push("ℹ NOTE: Another MCP process is watching this project, so the shared index may still update automatically.");
}
}
else if (!watchedAnywhere) {
watcherLines.push("⚠ WARNING: File watcher is not yet active for this project. Results may be stale.");
watcherLines.push(" The watcher is being started automatically. Run codebase_update to force an immediate catch-up.");
}
}
else if (watcherMode === "off") {
watcherLines.push("File watcher: disabled (SOCRATICODE_WATCHER=off)");
if (watchedHere) {
watcherLines.push(" Warning: this process still has an active watcher. Restart the MCP server to apply the changed environment setting.");
}
else if (watchedAnywhere) {
watcherLines.push(" Warning: another MCP process is watching this project. Set SOCRATICODE_WATCHER=off for every process that uses this checkout.");
}
}
else if (watchedHere) {
watcherLines.push(watcherMode === "manual"
? "File watcher: active (started explicitly; SOCRATICODE_WATCHER=manual)"
: "File watcher: active (auto-updating on changes)");
}
else if (watchedAnywhere) {
watcherLines.push(watcherMode === "manual"
? "File watcher: active (watched by another process; automatic startup disabled here)"
: "File watcher: active (watched by another process)");
}
else {
watcherLines.push(watcherMode === "manual"
? "File watcher: inactive (SOCRATICODE_WATCHER=manual; start explicitly or run codebase_update to refresh)"
: "File watcher: inactive");
}
if (watcherLines.length > 0) {
lines.push("", ...watcherLines);
}
}
/** Explain an empty filtered search by the filter, the index state, or both. */
async function filteredNoResultsLines(query, resolvedPath, collection, fileFilter, languageFilter, includeLinked) {
const indexing = isIndexingInProgress(resolvedPath);
if (!includeLinked && !indexing && ((await getCollectionInfo(collection))?.pointsCount ?? 0) === 0) {
return [
`No results found for "${query}" in project ${resolvedPath}.`,
"Make sure the project has been indexed first using codebase_index.",
];
}
const filters = [];
const hints = [];
if (fileFilter) {
filters.push(`fileFilter "${fileFilter}"`);
hints.push("fileFilter takes one file path exactly as a result header shows it (e.g. src/index.ts); directories, globs, lists and bare names of files below the root match nothing. Omit fileFilter to search everything.");
}
if (languageFilter) {
filters.push(`languageFilter "${languageFilter}"`);
hints.push("languageFilter takes a language label exactly as a result header shows it in brackets (e.g. typescript, python, go), not an alias such as ts or py.");
}
if (indexing) {
hints.push("Indexing is in progress, so matching files may not be indexed yet; codebase_status shows progress.");
}
if (includeLinked) {
return [`No results found for "${query}" in project ${resolvedPath} or its linked projects with ${filters.join(" and ")}.`, ...hints];
}
const miss = filters.length > 1
? `${filters.join(" and ")} together matched no indexed chunk`
: fileFilter
? `${filters[0]} matched no indexed file`
: `${filters[0]} matched no indexed chunk`;
return [`No results found for "${query}" in project ${resolvedPath}: ${miss}.`, ...hints];
}
/** Run a query or status request with the selected mode's Git freshness notice. */
export async function handleQueryTool(name, args) {
const projectPath = args.projectPath || process.cwd();
return withGitRefreshNotice(projectPath, () => dispatchQueryTool(name, args));
}
/** Dispatch search and status requests using the existing project identity and watcher policy. */
async function dispatchQueryTool(name, args) {
const projectPath = args.projectPath || process.cwd();
const resolvedPath = path.resolve(projectPath);
const projectId = projectIdFromPath(resolvedPath);
const collection = collectionName(projectId);
const watcherMode = getWatcherMode();
// Auto-start watcher on any query/status interaction (fire-and-forget)
ensureWatcherStarted(resolvedPath);
switch (name) {
case "codebase_search": {
await ensureQdrantReady();
const query = args.query;
const limit = args.limit || SEARCH_DEFAULT_LIMIT;
const fileFilter = args.fileFilter;
const languageFilter = args.languageFilter;
const includeLinked = args.includeLinked;
let allResults;
if (includeLinked) {
const collections = resolveLinkedCollections(resolvedPath);
allResults = await searchMultipleCollections(collections, query, limit, fileFilter, languageFilter);
}
else {
allResults = await searchChunks(collection, query, limit, fileFilter, languageFilter);
}
// Apply minimum score threshold
const minScore = args.minScore ?? SEARCH_MIN_SCORE;
const results = minScore > 0
? allResults.filter((r) => r.score >= minScore)
: allResults;
const filteredCount = allResults.length - results.length;
if (results.length === 0) {
const lines = [];
if (filteredCount > 0) {
lines.push(`No results above score threshold ${minScore.toFixed(2)} for "${query}" in project ${resolvedPath}.`, `${filteredCount} result${filteredCount === 1 ? " was" : "s were"} below the threshold. Try a broader query or lower the minScore parameter.`);
}
else if (fileFilter || languageFilter) {
lines.push(...await filteredNoResultsLines(query, resolvedPath, collection, fileFilter, languageFilter, includeLinked));
}
else {
lines.push(`No results found for "${query}" in project ${resolvedPath}.`, "Make sure the project has been indexed first using codebase_index.");
}
await appendWatcherState(lines, resolvedPath, watcherMode, "search");
return lines.join("\n");
}
const lines = [`Search results for "${query}" (${results.length} matches):\n`];
if (isIndexingInProgress(resolvedPath)) {
const progress = getIndexingProgress(resolvedPath);
if (progress?.type === "full-index") {
const pct = progress.filesTotal > 0
? `${Math.round((progress.filesProcessed / progress.filesTotal) * 100)}%`
: "unknown";
lines.push(`⚠ INCOMPLETE INDEX: A full index is currently in progress (${pct} done).`);
lines.push(" These results are from the portion indexed so far and may be significantly incomplete.");
lines.push(" Call codebase_status to check progress. Wait for indexing to complete for full results.\n");
}
else {
lines.push("⚠ NOTE: An incremental index update is in progress. Results may be slightly stale.\n");
}
}
await appendWatcherState(lines, resolvedPath, watcherMode, "search");
for (const r of results) {
const projectTag = r.project ? ` [${r.project}]` : "";
lines.push(`--- ${r.relativePath} (lines ${r.startLine}-${r.endLine}) [${r.language}]${projectTag} score: ${r.score.toFixed(4)} ---`);
lines.push(r.content);
lines.push("");
}
if (filteredCount > 0) {
lines.push(`(${filteredCount} additional result${filteredCount === 1 ? "" : "s"} below score threshold ${minScore.toFixed(2)} omitted)`);
}
return lines.join("\n");
}
case "codebase_status": {
try {
await ensureQdrantReady();
}
catch {
// Even if Qdrant is down, check if indexing is in progress (infra might be starting)
if (isIndexingInProgress(resolvedPath)) {
const progress = getIndexingProgress(resolvedPath);
if (progress) {
const { elapsed, progressLine, batchLine, graphLine } = formatProgressLines(progress);
const lines = [
`Project: ${resolvedPath}`,
"",
`\u26a0 ${progress.type === "full-index" ? "Full index" : "Incremental update"} in progress`,
` Phase: ${progress.phase}`,
progressLine,
...(batchLine ? [batchLine] : []),
` Elapsed: ${elapsed}s`,
"",
graphLine,
"",
"Qdrant is starting up. Keep calling codebase_status to check progress.",
];
await appendWatcherState(lines, resolvedPath, watcherMode, "status");
return lines.join("\n");
}
}
const lines = ["Qdrant is not available. Run codebase_index first to set up infrastructure."];
await appendWatcherState(lines, resolvedPath, watcherMode, "status");
return lines.join("\n");
}
const info = await getCollectionInfo(collection);
// Check for in-progress indexing even if no collection exists yet
if (!info) {
if (isIndexingInProgress(resolvedPath)) {
const progress = getIndexingProgress(resolvedPath);
if (progress) {
const { elapsed, progressLine, batchLine, graphLine } = formatProgressLines(progress);
const lines = [
`Project: ${resolvedPath}`,
"",
`\u26a0 ${progress.type === "full-index" ? "Full index" : "Incremental update"} in progress`,
` Phase: ${progress.phase}`,
progressLine,
...(batchLine ? [batchLine] : []),
` Elapsed: ${elapsed}s`,
"",
graphLine,
"",
"Index is being created. Keep calling codebase_status to check progress.",
];
await appendWatcherState(lines, resolvedPath, watcherMode, "status");
return lines.join("\n");
}
}
const lines = [
`No index found for project: ${resolvedPath}`,
"Run codebase_index to create one.",
];
await appendWatcherState(lines, resolvedPath, watcherMode, "status");
return lines.join("\n");
}
const metadata = await getProjectMetadata(collection);
const storedProfile = await loadProjectEffectiveProfile(collection);
const statusLines = [
`Project: ${resolvedPath}`,
`Collection: ${collection}`,
`Status: ${info.status}`,
`Indexed chunks: ${info.pointsCount}`,
];
const effectiveProfile = resolveEffectiveIndexProfile("code", storedProfile, info.pointsCount > 0, info.denseVectorSize);
const requestedProfile = requestedIndexProfile("code");
const profileDifferences = indexProfileDifferences(effectiveProfile, requestedProfile);
if (profileDifferences.length > 0) {
statusLines.push(`Index profile: ${profileDifferences.length} requested change${profileDifferences.length === 1 ? "" : "s"} inactive for this existing index: ${profileDifferences.join(", ")}`);
// Say what this does and does not mean. The index remains operational
// with its stored representation: every write continues to match the
// collection's profile, so a changed file is re-chunked the way that
// profile says, not the way the settings now ask for. That is why
// re-running codebase_index adopts nothing, and why editing every file
// would not either. Only a collection created fresh adopts the pending
// changes, which for indexFormatVersion is also what recovers content
// an older index truncated away.
statusLines.push("Index profile: this index remains operational with its stored representation. Re-running codebase_index reproduces that representation and adopts none of the pending changes. To apply them and, for chunk-format changes, improve content coverage, run codebase_remove and then codebase_index; that is optional.");
}
if (effectiveProfile.legacyUnverifiedFields.length > 0) {
statusLines.push(`Index profile: legacy-unverified fields: ${effectiveProfile.legacyUnverifiedFields.join(", ")}`);
}
// Detect persisted incomplete index (previous run was interrupted)
if (metadata?.indexingStatus === "in-progress" && !isIndexingInProgress(resolvedPath)) {
// Check if another process is actively indexing (cross-process lock)
const orphanPid = await getLockHolderPid(resolvedPath, "index");
if (orphanPid !== null) {
statusLines.push("");
statusLines.push(`⚠ ANOTHER PROCESS (PID ${orphanPid}) IS ACTIVELY INDEXING this project.`);
statusLines.push(` Files indexed so far: ${metadata.filesIndexed} of ${metadata.filesTotal} discovered`);
statusLines.push(` Chunks stored: ${info.pointsCount} (partial)`);
statusLines.push("");
statusLines.push(" This is likely an automatic resume of a previous indexing interruption.");
statusLines.push(" You can use codebase_stop to terminate it (and restart it directly to watch progress if you want), or wait for it to finish.");
}
else {
statusLines.push("");
statusLines.push("⚠ INDEX IS INCOMPLETE — a previous indexing run was interrupted before finishing.");
statusLines.push(` Files indexed: ${metadata.filesIndexed} of ${metadata.filesTotal} discovered`);
statusLines.push(` Chunks stored: ${info.pointsCount} (partial)`);
statusLines.push("");
statusLines.push(" Run codebase_index to resume and complete the index.");
}
}
// Show in-progress indexing
if (isIndexingInProgress(resolvedPath)) {
const progress = getIndexingProgress(resolvedPath);
statusLines.push("");
if (progress) {
const { elapsed, progressLine, batchLine } = formatProgressLines(progress);
statusLines.push(`⚠ ${progress.type === "full-index" ? "Full index" : "Incremental update"} in progress`);
statusLines.push(` Phase: ${progress.phase}`);
statusLines.push(progressLine);
if (batchLine) {
statusLines.push(batchLine);
}
statusLines.push(` Elapsed: ${elapsed}s`);
if (progress.filesTotal > 0 && progress.filesProcessed < progress.filesTotal) {
statusLines.push("");
statusLines.push("Keep calling codebase_status to check progress until it reaches 100%.");
}
}
}
else {
// Show last completed operation
const completed = getLastCompleted(resolvedPath);
if (completed) {
statusLines.push("");
const ago = ((Date.now() - completed.completedAt) / 1000).toFixed(0);
const duration = (completed.durationMs / 1000).toFixed(1);
if (completed.error) {
statusLines.push(`Last operation: ${completed.type === "full-index" ? "Full index" : "Incremental update"} — FAILED`);
statusLines.push(` Error: ${completed.error}`);
statusLines.push(` ${ago}s ago (ran for ${duration}s)`);
}
else {
statusLines.push(`Last operation: ${completed.type === "full-index" ? "Full index" : "Incremental update"} — completed`);
statusLines.push(` Files: ${completed.filesProcessed}, Chunks: ${completed.chunksCreated}`);
statusLines.push(` ${ago}s ago (took ${duration}s)`);
}
}
}
await appendWatcherState(statusLines, resolvedPath, watcherMode, "status");
// Graph status
try {
const graphInfo = await getGraphStatus(resolvedPath);
statusLines.push("");
if (graphInfo) {
statusLines.push(`Code graph: ${graphInfo.nodeCount} files, ${graphInfo.edgeCount} edges`);
const graphAgo = ((Date.now() - new Date(graphInfo.lastBuiltAt).getTime()) / 1000).toFixed(0);
statusLines.push(` Last built: ${graphAgo}s ago${graphInfo.cached ? " (cached in memory)" : ""}`);
// The stored graph is served unchanged across upgrades, so this
// reports the build that produced it, not the one answering (issue
// #120). One line per case, matching the density of the rest of this
// status; codebase_graph_status carries the full explanation.
if (!graphInfo.builtByVersion) {
// A graph persisted before the stamp existed. Unknown is not the
// same as current: this is exactly the state that made a stale
// artifact read as a resolver bug, so it gets said out loud.
statusLines.push(` Built by an unrecorded version — run codebase_graph_build to confirm this graph reflects v${SOCRATICODE_VERSION}'s resolvers.`);
}
else if (isGraphBuilderStale(graphInfo.builtByVersion, SOCRATICODE_VERSION)) {
statusLines.push(` Built by v${graphInfo.builtByVersion}, this server is v${SOCRATICODE_VERSION} — run codebase_graph_build to pick up newer resolvers.`);
}
}
else if (isIndexingInProgress(resolvedPath)) {
const progress = getIndexingProgress(resolvedPath);
if (progress?.phase === "building code graph") {
statusLines.push("Code graph: building now...");
}
else {
statusLines.push("Code graph: pending — will be auto-built after indexing completes");
}
}
else {
statusLines.push("Code graph: not built");
statusLines.push(" Run codebase_graph_build to build it, or codebase_index to re-index (graph is built automatically).");
}
}
catch {
// Graph status check failed — non-critical
}
// Context artifacts status
try {
const artifactSummary = await getArtifactStatusSummary(resolvedPath);
if (artifactSummary) {
statusLines.push("");
statusLines.push(...artifactSummary.lines);
}
}
catch {
// Artifact status check failed — non-critical
}
return statusLines.join("\n");
}
default:
return `Unknown tool: ${name}`;
}
}
//# sourceMappingURL=query-tools.js.map