Agent-first ARCHITECTURE.md for
@pleaseai/csp(binary:csp) — a TypeScript / Bun port of MinishLab/semble.Status: this document describes the target architecture for the port. The current tree (
src/index.ts,src/cli.ts) is scaffolding; modules marked (planned) below have not been ported yet. The shape is committed: the README is the public contract and this document is the internal contract that backs it.
Purpose: Give AI coding agents instant, token-efficient access to any codebase through natural-language or symbol queries, returning ranked code snippets in milliseconds on CPU — without API keys, GPU, or external services.
Primary users:
- AI coding agents (Claude Code, Cursor, Codex, OpenCode, Copilot CLI, Gemini CLI, Zed, VS Code, Kiro, Windsurf) — via the MCP server transport.
- Developers writing agent harnesses or scripts — via the CLI (
csp search,csp index,csp find-related). - Developers embedding code search into their own TypeScript code — via the library API (
CspIndex.fromPath,.search,.findRelated).
Core workflow (the search happy path):
- Resolve input: caller (MCP tool / CLI / library) supplies a local directory or a git URL plus a query string.
- Index (cached): walk files honouring
.gitignore/.cspignore, chunk each file with tree-sitter (line-fallback when the language has no parser), build a BM25 index over identifier-aware tokens and a dense Model2Vec embedding matrix in parallel. - Score: run BM25 and dense retrieval against the query, over-fetch candidates (
top_k * 5), normalize each list with Reciprocal Rank Fusion (k = 60), and blend with adaptivealpha(0.3for symbol queries,0.5for natural-language queries). - Rerank: apply multi-chunk file boost, query-type boost (definition / stem / embedded symbol), then path-penalised top-k rerank with file-saturation decay.
- Return the top-k
SearchResultrecords; the MCP / CLI layer formats them and writes savings telemetry to~/.csp/savings.jsonl.
Key constraints:
- CPU only: no GPU, no remote inference, no transformer forward pass at query time. Model2Vec is a vocab → embedding lookup + pooled aggregation.
- End-to-end under a second on typical repos: index in well under 1s, queries in single-digit ms.
- No network at query time: after one-off model download, indexing and searching are fully offline.
- API surface stability: every exported name, CLI flag, and MCP tool listed in
README.md/README.ko.mdis load-bearing; renames touch both READMEs in the same change.
Dependencies flow downward only. Lower layers must not import upper layers. The MCP server and CLI are siblings on the Interface layer; neither imports the other.
┌─────────────────────────────────────────────────────────────────┐
│ Interface Layer │
│ │
│ src/cli.ts src/mcp/ │
│ (csp subcommands) (MCP server: search, find_related tools) │
├─────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ │
│ src/index.ts CspIndex orchestration │
│ (public API barrel) (fromPath / fromGit / save / load) │
├─────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ │
│ src/types.ts Chunk, SearchResult, ContentType │
│ src/search.ts Hybrid RRF + alpha blend │
│ src/ranking/ weighting, boosting, penalties │
│ src/tokens.ts identifier-aware tokenizer (BM25 input) │
│ src/chunking/ tree-sitter chunking + line fallback │
├─────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ │
│ src/indexing/ file walker, BM25 build, dense embed │
│ src/cache.ts disk persistence (hash-keyed) │
│ src/stats.ts ~/.csp/savings.jsonl │
│ src/utils.ts git URL detection, chunk resolution │
└─────────────────────────────────────────────────────────────────┘
Invariant — domain has no I/O: src/search.ts, src/ranking/*, src/tokens.ts, and src/chunking/* are pure: they take in-memory inputs (Chunk[], embedding matrix, BM25 index handle, query string) and return scored results. They do not read files, spawn processes, or call the network. This makes them unit-testable with fixtures and keeps the ranking pipeline auditable against the upstream Python source.
Invariant — interfaces do not bypass application: src/cli.ts and src/mcp/ only call into CspIndex. They never import src/search.ts or src/ranking/* directly. Telemetry (stats.ts) is invoked by CspIndex, not by the interface layer.
For understanding the public API and where everything starts:
README.md/README.ko.md— The public contract: MCP configs, CLI commands, library snippets, stats path. Read this before reading code; the README defines the names and shapes that the rest of the codebase exists to fulfil.src/index.ts— The library barrel. Re-exportsCspIndex,Chunk,SearchResult,ContentType, andversion. The shortest path from "what does the package expose?" to the answer.
For understanding a search call end-to-end:
src/cli.ts→csp searchsubcommand — Shows argument parsing, content-type resolution, path/URL dispatch, and the call intoCspIndex.fromPath(...).search(...).src/indexing/index.ts→CspIndex(planned) — Orchestrator: holdsmodel,bm25Index,semanticIndex,chunks, builds the file/language mapping, and runssearch()+findRelated(). This is the seam between "I have a repo" and "I have ranked results".src/search.ts(planned) — The hybrid scoring pipeline: dense retrieval + BM25 → RRF normalize → alpha-weighted blend → multi-chunk file boost → query boost → path-penalised top-k rerank. Mirrorssrc/semble/search.pyin the upstream.
For understanding indexing:
src/indexing/create.ts(planned) — Walks files, chunks with tree-sitter (or line fallback), embeds, and builds BM25. Thebm25_index, semantic_index, chunks = create_index_from_path(...)tuple insrc/semble/index/create.pyis the model.src/chunking/core.ts(planned) — The 1500-char tree-sitter chunker with recursion-depth guards and_MIN_CHUNK_SIZEfloor.
For understanding ranking decisions (the heart of why csp beats grep):
src/ranking/weighting.ts(planned) — Adaptivealpharesolution (is_symbol_query()→ 0.3 / 0.5).src/ranking/boosting.ts(planned) — Definition boost, identifier-stem boost, embedded-symbol boost, file-coherence boost.src/ranking/penalties.ts(planned) — Path penalties (test files, barrels, compat/legacy, examples,.d.ts) + file-saturation decay during top-k selection.
| Module | Purpose | Key Files | Depends On | Depended By |
|---|---|---|---|---|
src/index.ts |
Public library barrel: re-exports the documented API surface. | index.ts |
types, indexing |
(consumers via import '@pleaseai/csp') |
src/cli.ts |
csp binary entrypoint. Subcommands: search, index, find-related, mcp, init, savings. |
cli.ts |
indexing, mcp (planned), stats |
(end users via bin: csp) |
src/mcp/ (planned) |
MCP server exposing search and find_related tools over stdio. |
mcp/server.ts |
indexing, @modelcontextprotocol/sdk |
cli.ts (csp mcp subcommand) |
src/types.ts (planned) |
Public types: Chunk, SearchResult, ContentType, IndexStats, CallType. |
types.ts |
— | every other module |
src/tokens.ts (planned) |
Identifier-aware tokenizer for BM25: camel/Pascal/snake splitting + original compound. | tokens.ts |
— | indexing, search, ranking/boosting |
src/chunking/ (planned) |
Tree-sitter AST chunking (1500-char target) with line fallback when no parser. | chunking/core.ts, chunking/chunk-source.ts |
web-tree-sitter, language wasm modules |
indexing/create.ts |
src/indexing/ (planned) |
CspIndex orchestration: file walker, language detection, BM25 build, dense embed, persistence. |
indexing/index.ts, indexing/create.ts, indexing/files.ts, indexing/file-walker.ts, indexing/dense.ts, indexing/sparse.ts |
chunking, tokens, ignore, embedding backend |
cli.ts, mcp/ |
src/search.ts (planned) |
Hybrid RRF + alpha-weighted blend of dense and BM25 results. | search.ts |
types, ranking, tokens |
indexing/index.ts |
src/ranking/ (planned) |
Code-aware reranking: weighting, query/file boosting, path penalties. | ranking/weighting.ts, ranking/boosting.ts, ranking/penalties.ts |
types, tokens |
search.ts |
src/cache.ts (planned) |
On-disk index cache (hash-keyed by repo state); invalidation on file change. | cache.ts |
indexing (for serialisation), node:fs |
cli.ts, mcp/ |
src/stats.ts (planned) |
Token-savings telemetry — append-only ~/.csp/savings.jsonl; csp savings reader. |
stats.ts |
node:fs |
indexing/index.ts (write), cli.ts (read) |
src/utils.ts (planned) |
isGitUrl, resolveChunk(file, line), formatResults. |
utils.ts |
types |
cli.ts, mcp/ |
src/agents/ (planned) |
Sub-agent prompts shipped to csp init (per-harness markdown). |
agents/claude.md, agents/cursor.md, … |
— | cli.ts (csp init) |
Hybrid scoring uses RRF, not raw-score blending. Both the dense and BM25 result lists are converted to 1 / (k + rank) scores with k = 60 before the alpha blend. This is what makes alpha independent of raw-score magnitudes across queries. Do NOT replace this with a min-max normalization or a raw-score weighted sum — the alpha defaults (0.3 / 0.5) are tuned against RRF.
Public field names are camelCase, not snake_case. The upstream Python uses chunk.file_path, start_line, end_line. The TypeScript port exposes chunk.filePath, startLine, endLine. The READMEs document these explicitly. Do NOT introduce snake_case at the public boundary "for parity"; only port-internal helpers may match upstream names verbatim.
Adaptive alpha is the default; explicit alpha is opt-in. resolveAlpha(query, alpha) returns the user-supplied alpha when provided, else 0.3 for symbol-like queries and 0.5 for NL queries. Do NOT remove the auto-detection — the upstream benchmarks assume it.
Path penalties only apply when BM25 is contributing. In rerankTopK, the penalisePaths argument must be alpha < 1.0. Pure-semantic queries (alpha = 1.0) skip path penalties because they cannot signal file-type intent. This mirrors the upstream behavior.
File-saturation decay caps results per file at 1 before damping. _FILE_SATURATION_THRESHOLD = 1, _FILE_SATURATION_DECAY = 0.5. The second chunk from the same file is multiplied by 0.5, the third by 0.25, and so on. Do NOT raise the threshold to "show more from the best file" — file coherence is already boosted earlier in the pipeline.
MCP tool descriptions tell the agent when to call, not how the algorithm works. Reference: src/semble/mcp.py. Long descriptions waste agent context.
Algorithmic ports must read the original Python source, not memory. Use ask src github:MinishLab/semble@main and read the relevant src/semble/*.py before writing TypeScript. When porting a non-trivial function, leave a // Port of src/semble/<path>::<name> comment so reviewers can diff against the source of truth.
Native tree-sitter is allowed via @kreuzberg/tree-sitter-language-pack. This NAPI package ships pre-compiled binaries for macOS / Linux / Windows and gives parity with upstream semble's tree-sitter-language-pack Python bindings (305 languages out of the box, no WASM loader overhead). The decision is captured in ADR 0001. Other native add-ons remain discouraged — anything beyond tree-sitter needs its own ADR justifying the loss of portability under Bun and across container images that lack a C toolchain.
Bilingual README must stay in sync. Any public-API change (CLI flag, library symbol, MCP tool, config option, stats path) updates both README.md and README.ko.md in the same commit. The CLAUDE.md captures this as load-bearing.
No Common Development Tasks, Tips, Support filler in docs. Per .please/docs/knowledge/product-guidelines.md: anything non-obvious goes into .please/docs/knowledge/gotchas.md, not into README chatter.
Error handling:
- User-input errors (path missing, invalid content type, malformed query) surface as concrete messages — e.g.
Path does not exist: ./foo— at the interface layer (CLI / MCP). The interface layer translates exceptions into exit codes (CLI) or JSON-RPC error responses (MCP). - Library callers see typed exceptions:
Errorsubclasses with descriptive names (PathNotFoundError,GitCloneError,InvalidIndexError). The Python upstream usesFileNotFoundError,NotADirectoryError,RuntimeError; the TS port may collapse these into a smaller set ofcsp-specific errors but the messages stay informative. - Domain layer never swallows errors: ranking and search must propagate, not "return empty results" silently. Empty inputs (
!chunks.length || !query.trim()) return[]explicitly at the top ofCspIndex.search().
Logging:
- The library logs nothing by default —
console.logandprocess.stderr.writeare reserved for the interface layer. - The CLI prints human-friendly output to stdout and progress/diagnostics to stderr. Stdout stays machine-parseable when piped; reserve TTY formatting for
process.stdout.isTTY. - The MCP server uses stderr only for diagnostics (per MCP spec — stdout is the JSON-RPC channel).
- No structured logger /
pino/winstondependency. If a project debug mode is needed later, a tinydebug-style namespace vianode:util.debuglog('csp:*')is the path; it stays optional and zero-cost when disabled.
Testing:
- Runner:
bun:testexclusively. No Jest, no Vitest.bun test path/to/file.test.tsruns a single file;bun test --watchfor TDD loops. - Layout: co-located
*.test.tsnext to sources (src/tokens.test.tsnext tosrc/tokens.ts). End-to-end / integration tests undertests/with fixture repos intests/fixtures/. - Domain tests are pure: build a
Chunk[]array in-memory, run the pipeline, assert on the ordered output. No filesystem, no model loading. - Indexing tests use small fixture repos (
tests/fixtures/sample-ts-project/). One repo per content-type matrix entry (code / docs / config). - MCP integration tests spawn the server via
Bun.spawn, write JSON-RPC frames to stdin, assert responses on stdout. - Coverage target: >80% for new code per
.please/docs/knowledge/workflow.md.bun test --coverage.
Configuration:
- No runtime config file. Behavior is controlled by CLI flags, MCP server args (
--content code docs), and library options. This matches semble and keepscspdeployable without filesystem state beyond~/.csp/savings.jsonl. - Environment variables:
SEMBLE_CLONE_TIMEOUT(60s default forgit clone) ports toCSP_CLONE_TIMEOUT. Add new env vars sparingly and document them next to where they are read, not in a central config module. - Model cache lives where the embedding backend places it (HuggingFace cache for
@huggingface/transformers). This is intentional — sharing cache across tools (csp, other transformers-based projects) is a feature, not leakage. - Stats file (
~/.csp/savings.jsonl) is append-only, JSON-Lines, one record per search/find_related call. The reader (csp savings) tolerates missing or partial files.
Well-tested (will be):
src/tokens.tsandsrc/ranking/*are pure functions with small, deterministic outputs — the easiest places to land high coverage and the highest-leverage places to have high coverage because they drive ranking decisions.src/types.tsis type-only; covered transitively by every test that touchesChunk/SearchResult.
Fragile (handle with care):
src/chunking/core.ts: tree-sitter integration straddles WASM lifecycle, language-pack download, and AST recursion. Mirror the upstream's_RECURSION_DEPTH = 500and_MIN_CHUNK_SIZE = 50guards exactly; deviations have cascade effects on ranking input.src/indexing/file-walker.ts: gitignore semantics (pathspec.GitIgnoreSpec.from_lines(..., backend="simple")upstream) have edge cases around negation patterns and directory vs file matching. Port the_is_ignored"negation + extension suffix → bypass extension filter" rule verbatim and unit-test it against the same fixtures the upstream uses.src/search.tscandidate over-fetch:topK * 5is load-bearing for rerank-after-blend to have room to move. Changing this multiplier without re-running benchmarks risks recall regressions that won't show up in unit tests.
Technical debt (current):
- The current
src/index.tsandsrc/cli.tsare placeholders that satisfy thetsdownbuild but do not implement anything. They will be replaced as modules land. - No CI yet —
.github/workflows/needs to land before the first port PR. - No CHANGELOG — the README references "0.x: public API may change between minor versions; each minor release notes breaking changes in CHANGELOG" but the file does not exist yet.
- The bilingual README sync is a manual discipline; a lint or test that diffs section anchors between
README.mdandREADME.ko.mdwould close the gap.
Last updated: 2026-05-28 — initial ARCHITECTURE.md alongside scaffolding.
Related project context:
README.md/README.ko.md— Public contract (MCP / CLI / library)CLAUDE.md— Project context for AI agents working on this repo.please/docs/knowledge/product.md— Vision, target users, goals.please/docs/knowledge/tech-stack.md— Technology choices with rationale.please/docs/knowledge/workflow.md— TDD, quality gates, dev commands.please/docs/decisions/— ADRs (none yet; document divergence-from-semble decisions here as they arise)