socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
327 lines • 17.9 kB
TypeScript
import { QdrantClient } from "@qdrant/js-client-rest";
import type { ArtifactIndexState, CodeGraph, FileChunk, SearchResult } from "../types.js";
import type { GraphInputRecord } from "./graph-inputs.js";
import { type EffectiveIndexProfile } from "./index-profile.js";
/**
* Render a Qdrant client error including the reason the server actually gave.
*
* `@qdrant/js-client-rest` builds an `ApiError` whose `message` is only the HTTP
* status text — a 400 reads as the bare, useless "Bad Request" — while the
* server's explanation ("JSON payload (N bytes) is larger than allowed …") sits
* in `err.data.status.error`. Logging `err.message` alone therefore throws away
* the one part a user can act on, so every symbol-graph failure looked
* identical. Appends that reason when present, and looks through a `cause`
* chain so an error already wrapped by {@link wrapQdrantError} still resolves.
*
* Without a server reason, appends the innermost cause's message and `code`
* instead, so a transport failure reads as
* `fetch failed: other side closed [UND_ERR_SOCKET]` rather than `fetch failed`.
*/
export declare function describeQdrantError(err: unknown): string;
export declare function getClient(): QdrantClient;
/**
* Ensure a code collection is ready, sharing one attempt between concurrent
* callers in this process. Failed attempts are removed so a later call retries.
*/
export declare function ensureCollection(name: string): Promise<void>;
/** True when an error means "someone else already created it" — safe to ignore. */
export declare function isAlreadyExistsError(err: unknown): boolean;
/** Create a payload index on a collection (best effort — never throws) */
export declare function ensurePayloadIndex(collName: string, fieldName: string): Promise<void>;
/** Delete a collection */
export declare function deleteCollection(name: string): Promise<void>;
export type ProjectPathState = "present-on-this-host" | "absent-on-this-host" | "unknown/inaccessible";
/** One metadata point, projected to the fields that decide whether a preview is still current. */
export interface ProjectMetadataRecord {
pointId: string | number;
collectionName: string;
projectPath: string | null;
indexingStatus: string | null;
lastIndexedAt: string | null;
lastBuiltAt: string | null;
builtByVersion: string | null;
/** Absent in legacy records. Refused or malformed ownership remains report-only. */
localIndexOwnership?: unknown;
}
export interface ProjectReclamationEntry {
identity: string;
projectPath: string | null;
canonicalPath: string | null;
pathState: ProjectPathState;
resourceCollections: string[];
metadataRecords: ProjectMetadataRecord[];
/** Some record says an indexer has this identity mid-write. */
inProgress: boolean;
possibleSuperseded: boolean;
requiresManualInspection: boolean;
/** Why a person has to look before this identity can be deleted; empty when nothing holds it back. */
manualInspectionReasons: string[];
confirmationToken: string;
}
/** A metadata point the inventory could not attribute to an identity, kept for a person to look at. */
export interface UnrecognisedMetadataEntry {
pointId: string | number;
collectionName: string | null;
projectPath: string | null;
reason: string;
}
/** A collection under the configured prefix whose name fits two identities and nothing settles which. */
export interface UnattributedCollection {
name: string;
reason: string;
/** Every identity the name fits; each is held back from deletion while the name stays unsettled. */
candidateIdentities: string[];
}
export interface ProjectReclamationInventory {
entries: ProjectReclamationEntry[];
unrecognisedMetadata: UnrecognisedMetadataEntry[];
unattributedCollections: UnattributedCollection[];
}
export interface ProjectReclamationOutcome {
resource: string;
kind: "collection" | "metadata";
/** `skipped`: the caller withdrew consent before this write; nothing was attempted. */
outcome: "deleted" | "failed" | "skipped";
error?: string;
}
/**
* Read the stored identities without deriving an identity from the local path.
* The result is an inventory: an absent path is local observation, never proof
* that a shared-store index may be deleted.
*/
export declare function getProjectReclamationInventory(): Promise<ProjectReclamationInventory>;
/** Delete exactly the inventoried resources, one outcome each; a resource already gone counts as deleted. */
export declare function removeProjectReclamationEntry(entry: ProjectReclamationEntry,
/** Asked before every write; once it answers false, nothing more is attempted. */
mayContinue?: () => boolean | Promise<boolean>,
/** Automatic reclamation retains its proof until collection deletion is verified. Manual prune is unchanged. */
preserveMetadataUntilCollectionsGone?: boolean): Promise<ProjectReclamationOutcome[]>;
/** List all codebase, codegraph, and context artifact entries.
* Codebase and context entries are actual collections; codegraph entries come from metadata.
*
* The collection-name filters honour `QDRANT_COLLECTION_PREFIX` so when
* sharing a Qdrant server with other applications or other SocratiCode
* instances, only this instance's collections are listed. */
export declare function listCodebaseCollections(): Promise<string[]>;
/** Upsert chunks into a collection */
export declare function upsertChunks(collectionName: string, chunks: FileChunk[], contentHash: string, profile: EffectiveIndexProfile): Promise<void>;
/** Upsert pre-embedded points into a collection (no embedding generation).
* bm25Text is forwarded to Qdrant's server-side BM25 inference (truncated if too long).
*
* A failing batch is retried point by point to isolate the bad point(s). If any
* point still fails, this throws: a partial write must fail the whole indexing
* operation.
*
* Callers must not treat a partial write as success. A re-indexed file has its
* previous chunks deleted before this call, so recording it as indexed after a
* partial failure strands it at zero chunks with a content hash that suppresses
* every future re-index. Throwing leaves stored hashes and earlier checkpoints
* untouched, so the next index or update retries the file naturally. */
export declare function upsertPreEmbeddedChunks(collectionName: string, points: Array<{
id: string;
vector: number[];
bm25Text: string;
payload: Record<string, unknown>;
}>): Promise<void>;
/** Delete a file's chunks, optionally waiting for application before reporting freshness. */
export declare function deleteFileChunks(collectionName: string, relativePath: string, waitForCompletion?: boolean): Promise<void>;
/** Hybrid search: combines dense semantic search with BM25 lexical search via RRF fusion.
* Dense vector is generated client-side; BM25 inference runs server-side in Qdrant (requires v1.15.2+). */
export declare function searchChunks(collectionName: string, query: string, limit?: number, fileFilter?: string, languageFilter?: string): Promise<SearchResult[]>;
export declare function loadEffectiveIndexProfileForCollection(collectionName: string): Promise<EffectiveIndexProfile>;
/** Merge results from multiple collection queries using client-side Reciprocal Rank Fusion.
* Deduplicates by `label::relativePath` so that files with the same relative path
* in different projects are kept as separate hits. Within a single project,
* the first (higher-priority) occurrence wins on conflict.
* Exported for unit testing. */
export declare function mergeMultiCollectionResults(collectionResults: Array<{
label: string;
results: SearchResult[];
}>, limit: number): SearchResult[];
/** Search across multiple collections in parallel with client-side RRF fusion and deduplication.
* Each collection's results are queried independently, then merged using Reciprocal Rank Fusion.
* When the same relativePath appears in multiple collections, the result from the
* earlier (higher-priority) collection wins.
*
* @param collections - Array of { name, label } where label identifies the source project
* in results. Order defines priority for deduplication (first wins).
* @param query - Natural language search query.
* @param limit - Maximum total results to return after merge.
* @param fileFilter - Optional relativePath filter applied to every collection.
* @param languageFilter - Optional language filter applied to every collection.
*/
export declare function searchMultipleCollections(collections: Array<{
name: string;
label: string;
}>, query: string, limit?: number, fileFilter?: string, languageFilter?: string): Promise<SearchResult[]>;
/** Hybrid search with arbitrary payload filters.
* Used by context artifacts to filter by artifactName. */
export declare function searchChunksWithFilter(collectionName: string, query: string, limit: number, filters: Array<{
key: string;
value: string;
}>): Promise<SearchResult[]>;
/**
* Every `relativePath` that currently has at least one point in the collection.
*
* Exists so a caller can tell "this file is unchanged, skip it" apart from "this
* file's chunks are gone". The stored hash map cannot make that distinction on
* its own: it records what was hashed, not what survived.
*
* Pages through with `scroll` rather than `facet` deliberately — facet takes a
* limit and offers no cursor, so it cannot enumerate a repository's worth of
* paths reliably. Payload is narrowed to the one field and vectors are excluded,
* so the cost is one small round trip per `pageSize` points.
*/
export declare function listIndexedFilePaths(collName: string, pageSize?: number): Promise<Set<string>>;
/** Get collection info.
* Returns the collection info if it exists, null if the collection does not exist,
* or throws an error if the request fails for any other reason (network, timeout, etc.).
* This distinction is critical: callers must NOT treat transient errors as "collection missing". */
export interface CollectionInfo {
pointsCount: number;
status: string;
/** Stored dense-vector width, when Qdrant exposes a supported vector config. */
denseVectorSize?: number;
}
/**
* Read a collection's point count, status and dense vector size.
*
* Returns `null` only when the collection does not exist; every other failure
* propagates.
*
* A read of a collection this process is still initializing waits for that
* initialization first: Qdrant can answer a read made during the create with a
* 500 rather than 404 or the new collection. If the initialization fails, that
* failure propagates. Reads with no local initialization in flight are
* unaffected.
*/
export declare function getCollectionInfo(name: string): Promise<CollectionInfo | null>;
/** Reset the metadata collection readiness cache (for testing only) */
export declare function resetMetadataCollectionCache(): void;
/** Indexing status persisted in Qdrant metadata */
export type IndexingStatus = "in-progress" | "completed";
export interface ProjectMetadata {
projectPath: string;
lastIndexedAt: string;
filesTotal: number;
filesIndexed: number;
indexingStatus: IndexingStatus;
effectiveProfile: EffectiveIndexProfile | null;
}
/** Save project metadata and file hashes to Qdrant */
export declare function saveProjectMetadata(collName: string, projectPath: string, filesTotal: number, filesIndexed: number, fileHashes: Map<string, string>, indexingStatus: IndexingStatus, effectiveProfile: EffectiveIndexProfile): Promise<void>;
/** Load only the stored code-index profile. Missing profile means legacy metadata. */
export declare function loadProjectEffectiveProfile(collName: string): Promise<EffectiveIndexProfile | null>;
/** Load file hashes for a project from Qdrant.
* Returns the hash map if found, null if the metadata point doesn't exist,
* or throws on transient/unexpected errors so callers can distinguish
* "no metadata" from "Qdrant unreachable". */
export declare function loadProjectHashes(collName: string): Promise<Map<string, string> | null>;
/** Get project metadata (for list display).
* Returns null if metadata doesn't exist or on any error (logged as warning). */
export declare function getProjectMetadata(collName: string): Promise<ProjectMetadata | null>;
/**
* Persisted indexing status, without the best-effort swallowing.
*
* `getProjectMetadata()` is display-oriented: it catches every read error and
* answers `null`, which is right for showing a status line and wrong for
* deciding one. A caller that asks "was the last run interrupted?" and takes
* `null` for "no" will, on a transient metadata read failure, skip the recovery
* that failure should have triggered — and then persist the still-damaged index
* as `completed`, which is worse than not having run at all.
*
* Returns `null` only when there is genuinely no metadata for the collection.
* Transport, parsing and validation failures propagate, so the caller aborts
* rather than guessing.
*/
export declare function loadIndexingStatus(collName: string): Promise<IndexingStatus | null>;
/** Delete project metadata.
* Errors are logged but not propagated (best-effort deletion). */
export declare function deleteProjectMetadata(collName: string): Promise<void>;
/** Save a code graph to Qdrant as a single metadata point */
export declare function saveGraphData(graphCollName: string, projectPath: string, graph: CodeGraph, graphInputs: GraphInputRecord): Promise<void>;
/** Load a code graph from Qdrant.
* Returns null if no graph exists or on any error (logged as warning). */
export declare function loadGraphData(graphCollName: string): Promise<CodeGraph | null>;
/** Get graph metadata (for list/status display).
* Returns null if no graph exists or on any error (logged as warning). */
export declare function getGraphMetadata(graphCollName: string): Promise<{
projectPath: string;
lastBuiltAt: string;
nodeCount: number;
edgeCount: number;
/** Absent on graphs persisted before this field was recorded. */
importCount?: number;
/** SocratiCode version that built this graph. Absent on graphs persisted
* before this field was recorded. */
builtByVersion?: string;
} | null>;
/**
* What is stored about the graph's inputs, and whether anything is stored at
* all — three outcomes the caller must not confuse.
*/
export type GraphInputsLoad =
/** No metadata point: this project has no persisted graph. */
{
status: "absent";
}
/** A graph is there. `value` is its record, or undefined on one built before records existed. */
| {
status: "stored";
value: unknown;
}
/** The read itself failed. Nothing is known, least of all that there is no record. */
| {
status: "failed";
error: string;
};
/**
* The record of what the graph was built from, without the graph.
*
* Projected to that one field deliberately. The same point carries
* `graphData`, the whole serialized graph — megabytes on a large repository —
* and every incremental update asks this question. Pulling the graph across to
* answer it would cost more than the rebuild the answer is meant to avoid —
* the same amplification that made `listCodebaseCollections` project its
* scroll rather than read whole payloads.
*
* A missing point and a failed read are reported apart, and neither is
* reported as "no record". A graph with no record must rebuild once and write
* one however quiet the update was; a graph that could not be read tells us
* nothing and must be treated as the most conservative of the three; and only
* a genuinely absent point means there is no graph here to refresh.
*/
export declare function loadGraphInputs(graphCollName: string): Promise<GraphInputsLoad>;
/** Delete graph data from metadata.
* Errors are logged but not propagated (best-effort deletion). */
export declare function deleteGraphData(graphCollName: string): Promise<void>;
/** Save context artifact metadata to Qdrant */
export declare function saveContextMetadata(contextCollName: string, projectPath: string, artifacts: ArtifactIndexState[], effectiveProfile: EffectiveIndexProfile): Promise<void>;
export interface ContextIndexMetadata {
artifacts: ArtifactIndexState[];
effectiveProfile: EffectiveIndexProfile | null;
}
/**
* Load context states and profile for mutation paths. Missing metadata returns
* null; transport, JSON, and profile validation failures propagate.
*/
export declare function loadContextIndexMetadata(contextCollName: string): Promise<ContextIndexMetadata | null>;
/** Load only the stored context-index profile. Missing profile means legacy metadata. */
export declare function loadContextEffectiveProfile(contextCollName: string): Promise<EffectiveIndexProfile | null>;
/** Load context artifact metadata from Qdrant.
* Returns null if no metadata exists or on any error (logged as warning). */
export declare function loadContextMetadata(contextCollName: string): Promise<ArtifactIndexState[] | null>;
/** Get context collection metadata (for list/status display).
* Returns null if no metadata exists or on any error (logged as warning). */
export declare function getContextMetadata(contextCollName: string): Promise<{
projectPath: string;
lastIndexedAt: string;
artifactCount: number;
effectiveProfile: EffectiveIndexProfile | null;
} | null>;
/** Delete context artifact metadata.
* Errors are logged but not propagated (best-effort deletion). */
export declare function deleteContextMetadata(contextCollName: string): Promise<void>;
/** Delete all chunks for a specific artifact within a collection */
export declare function deleteArtifactChunks(collectionName: string, artifactName: string): Promise<void>;
//# sourceMappingURL=qdrant.d.ts.map