@maxgfr/codeindex 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. package/src/workspaces.ts +748 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,512 @@
1
+ // MCP (Model Context Protocol) server over stdio — hand-rolled JSON-RPC 2.0 so
2
+ // the engine stays zero-dependency. Newline-delimited JSON messages, protocol
3
+ // 2024-11-05 (compatible with later revisions' initialize handshake). Exposes
4
+ // the engine's read-only indexing capabilities as MCP tools; every tool takes a
5
+ // `repo` path and returns JSON text content.
6
+ //
7
+ // Register in an MCP client as: node scripts/engine.mjs mcp
8
+ import { createInterface } from "node:readline";
9
+ import { ENGINE_VERSION } from "./types.js";
10
+ import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
11
+ import { buildIndexArtifacts } from "./pipeline.js";
12
+ import { renderGraphJson } from "./render/graph-json.js";
13
+ import { scanRepo } from "./scan.js";
14
+ import { buildCallerIndex } from "./callers.js";
15
+ import { detectWorkspaces } from "./workspaces.js";
16
+ import { gitChurn } from "./git.js";
17
+ import { grepRepo } from "./grep.js";
18
+ import { changeCoupling, rankHotspots } from "./coupling.js";
19
+ import { renderRepoMap } from "./repomap.js";
20
+ import { findDeadCode } from "./deadcode.js";
21
+ import { symbolComplexity, riskHotspots } from "./complexity.js";
22
+ import { renderMermaid } from "./viz.js";
23
+ import { symbolsOverview, findSymbol, findReferences } from "./query.js";
24
+ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit.js";
25
+ import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
26
+ import { searchIndex } from "./bm25.js";
27
+ import { checkRules, parseRules } from "./rules.js";
28
+
29
+ interface RpcRequest {
30
+ jsonrpc: "2.0";
31
+ id?: number | string | null;
32
+ method: string;
33
+ params?: Record<string, unknown>;
34
+ }
35
+
36
+ const repoProp = { repo: { type: "string", description: "Absolute path to the repository root" } };
37
+ const scopeProps = {
38
+ scope: { type: "string", description: "Restrict to one directory (repo-relative)" },
39
+ include: { type: "array", items: { type: "string" }, description: "Include globs" },
40
+ exclude: { type: "array", items: { type: "string" }, description: "Exclude globs" },
41
+ };
42
+
43
+ const TOOLS = [
44
+ {
45
+ name: "scan_summary",
46
+ description:
47
+ "Deterministically scan a repository: file count, per-language file histogram, HEAD commit, and whether the walk was capped. Fast first look at any codebase.",
48
+ inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
49
+ },
50
+ {
51
+ name: "graph",
52
+ description:
53
+ "Build the full typed cross-file link-graph (import/call/use/doc-link/mention edges, module grouping, PageRank centrality, Louvain communities, tests-map). Returns graph.json. Large on big repos — prefer scan_summary/symbols/callers for targeted questions.",
54
+ inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
55
+ },
56
+ {
57
+ name: "symbols",
58
+ description:
59
+ "Where is a symbol defined and which files reference it? Returns the definition sites (file, line, kind, exported) and referencing files. Omit `name` for the full symbol index.",
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: { ...repoProp, name: { type: "string", description: "Symbol name to look up" } },
63
+ required: ["repo"],
64
+ },
65
+ },
66
+ {
67
+ name: "callers",
68
+ description:
69
+ "Who calls a function? Per-symbol caller index: each defined symbol with the exact (file, line) call sites that bind to it. Omit `name` for the full index.",
70
+ inputSchema: {
71
+ type: "object",
72
+ properties: { ...repoProp, name: { type: "string", description: "Symbol name to look up" } },
73
+ required: ["repo"],
74
+ },
75
+ },
76
+ {
77
+ name: "workspaces",
78
+ description:
79
+ "Detect monorepo packages (npm/pnpm/yarn/lerna/nx/cargo/go.work/maven) with the workspace dependency graph, one cycle if present, and a topological build order.",
80
+ inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
81
+ },
82
+ {
83
+ name: "churn",
84
+ description: "Per-file git commit counts (whole history, or since a ref) — the churn half of hotspot analysis.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: { ...repoProp, since: { type: "string", description: "Only count commits after this ref" } },
88
+ required: ["repo"],
89
+ },
90
+ },
91
+ {
92
+ name: "symbols_overview",
93
+ description:
94
+ "All symbols declared in ONE file (name, kind, line span, exported, parent), in declaration order — the fastest way to understand a file without reading it.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: { ...repoProp, file: { type: "string", description: "Repo-relative file path" } },
98
+ required: ["repo", "file"],
99
+ },
100
+ },
101
+ {
102
+ name: "find_symbol",
103
+ description:
104
+ "Find symbol declarations by name or name path ('Class/method' matches a method inside Class). Options: substring matching, includeBody to return the declaration's source. Exact-name matches rank first.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ ...repoProp,
109
+ namePath: { type: "string", description: "Symbol name or Parent/child path" },
110
+ substring: { type: "boolean" },
111
+ includeBody: { type: "boolean" },
112
+ },
113
+ required: ["repo", "namePath"],
114
+ },
115
+ },
116
+ {
117
+ name: "find_references",
118
+ description:
119
+ "Who references a symbol? Three labeled tiers: defs (declarations), callSites (line-precise, import-corroborated call bindings), referencingFiles (file-level identifier/doc mentions — may include homonyms). Confidence decreases across tiers; the labels let you decide what to trust.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: { ...repoProp, name: { type: "string", description: "Symbol name" } },
123
+ required: ["repo", "name"],
124
+ },
125
+ },
126
+ {
127
+ name: "repo_map",
128
+ description:
129
+ "Token-budgeted map of the repository: the highest-PageRank files with their key exported signatures, deterministically rendered to fit `budgetTokens` (default 1024). The densest single read to understand an unfamiliar codebase.",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: { ...repoProp, budgetTokens: { type: "number", description: "Approximate token budget (default 1024)" } },
133
+ required: ["repo"],
134
+ },
135
+ },
136
+ {
137
+ name: "hotspots",
138
+ description:
139
+ "Where does work concentrate? Files ranked by git churn × size (commits × log2 lines). High-scoring files are where changes and defects cluster.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: { ...repoProp, since: { type: "string", description: "Only count commits after this ref" } },
143
+ required: ["repo"],
144
+ },
145
+ },
146
+ {
147
+ name: "coupling",
148
+ description:
149
+ "Change coupling: pairs of files that repeatedly change in the same commits — hidden dependencies no import shows. strength 1.0 = every change to one touched the other.",
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: { ...repoProp, since: { type: "string", description: "Only mine commits after this ref" } },
153
+ required: ["repo"],
154
+ },
155
+ },
156
+ {
157
+ name: "replace_symbol_body",
158
+ description:
159
+ "WRITE: replace a symbol's whole declaration with `body` (verbatim, supply full indentation). The symbol is resolved by name path ('Class/method'); ambiguity errors list the candidates — qualify with `file`. Line spans come from the AST index.",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ ...repoProp,
164
+ namePath: { type: "string" },
165
+ body: { type: "string" },
166
+ file: { type: "string", description: "Disambiguate: repo-relative file containing the symbol" },
167
+ },
168
+ required: ["repo", "namePath", "body"],
169
+ },
170
+ },
171
+ {
172
+ name: "insert_after_symbol",
173
+ description:
174
+ "WRITE: insert `body` after a symbol's declaration (blank-line separation preserved for definition-like kinds). Resolved like replace_symbol_body.",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: { ...repoProp, namePath: { type: "string" }, body: { type: "string" }, file: { type: "string" } },
178
+ required: ["repo", "namePath", "body"],
179
+ },
180
+ },
181
+ {
182
+ name: "insert_before_symbol",
183
+ description:
184
+ "WRITE: insert `body` before a symbol's declaration (blank-line separation preserved). Resolved like replace_symbol_body.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: { ...repoProp, namePath: { type: "string" }, body: { type: "string" }, file: { type: "string" } },
188
+ required: ["repo", "namePath", "body"],
189
+ },
190
+ },
191
+ {
192
+ name: "write_memory",
193
+ description:
194
+ "Persist a named markdown note under <repo>/.codeindex/memories/ (names may use topic/name form). Write small, focused notes: project map, build commands, conventions.",
195
+ inputSchema: {
196
+ type: "object",
197
+ properties: { ...repoProp, name: { type: "string" }, content: { type: "string" } },
198
+ required: ["repo", "name", "content"],
199
+ },
200
+ },
201
+ {
202
+ name: "read_memory",
203
+ description: "Read one persisted memory by name.",
204
+ inputSchema: {
205
+ type: "object",
206
+ properties: { ...repoProp, name: { type: "string" } },
207
+ required: ["repo", "name"],
208
+ },
209
+ },
210
+ {
211
+ name: "list_memories",
212
+ description: "List persisted memory names — load this first, then read individual memories on relevance.",
213
+ inputSchema: { type: "object", properties: { ...repoProp }, required: ["repo"] },
214
+ },
215
+ {
216
+ name: "delete_memory",
217
+ description: "Delete one persisted memory by name.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: { ...repoProp, name: { type: "string" } },
221
+ required: ["repo", "name"],
222
+ },
223
+ },
224
+ {
225
+ name: "dead_code",
226
+ description:
227
+ "Dead-code candidates in two labeled tiers: 'unreferenced' (no call site binds AND nothing references the name) and 'uncalled' (referenced somewhere — re-export, type position — but never called). Exported symbols only; test files and entrypoint-looking files excluded as roots.",
228
+ inputSchema: { type: "object", properties: { ...repoProp, ...scopeProps }, required: ["repo"] },
229
+ },
230
+ {
231
+ name: "complexity",
232
+ description:
233
+ "Cyclomatic-complexity estimates (branch-token counting over AST line spans), most-complex first. Pass `file` for one file's symbols, omit for the repo-wide top. Combine with hotspots: the `risk` field of this tool's sibling ranks complexity × churn.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: { ...repoProp, file: { type: "string" }, risk: { type: "boolean", description: "Return complexity × git-churn risk ranking instead" } },
237
+ required: ["repo"],
238
+ },
239
+ },
240
+ {
241
+ name: "mermaid",
242
+ description:
243
+ "Mermaid diagram of the module graph (renders inline in Claude/GitHub — no graph database). Optionally scoped to one module's neighborhood.",
244
+ inputSchema: {
245
+ type: "object",
246
+ properties: { ...repoProp, module: { type: "string", description: "Module slug to focus on" } },
247
+ required: ["repo"],
248
+ },
249
+ },
250
+ {
251
+ name: "grep",
252
+ description:
253
+ "Search file contents (ripgrep when available, deterministic JS fallback otherwise). Returns sorted (file, line, text) hits.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ ...repoProp,
258
+ pattern: { type: "string", description: "Regular expression to search for" },
259
+ globs: { type: "array", items: { type: "string" }, description: "Restrict to matching paths" },
260
+ ignoreCase: { type: "boolean" },
261
+ maxHits: { type: "number" },
262
+ },
263
+ required: ["repo", "pattern"],
264
+ },
265
+ },
266
+ {
267
+ name: "search",
268
+ description:
269
+ 'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols.',
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ ...repoProp,
274
+ ...scopeProps,
275
+ query: { type: "string", description: "Natural-language or identifier query" },
276
+ limit: { type: "number", description: "Max results (default 20)" },
277
+ },
278
+ required: ["repo", "query"],
279
+ },
280
+ },
281
+ {
282
+ name: "check_rules",
283
+ description:
284
+ 'Validate dependency-cruiser-style architecture rules against the link-graph. Rules (inline JSON array): forbidden edges {name, from, to, kind?, severity?, comment?} with glob paths, plus builtins {name, builtin: "cycles"|"orphans"} (module-level import cycles; edge-less code files). Returns deterministic violations with severity error|warn — a CI gate.',
285
+ inputSchema: {
286
+ type: "object",
287
+ properties: {
288
+ ...repoProp,
289
+ ...scopeProps,
290
+ rules: { type: "array", description: "Rules array (inline JSON — see description)" },
291
+ },
292
+ required: ["repo", "rules"],
293
+ },
294
+ },
295
+ ] as const;
296
+
297
+ function str(v: unknown): string | undefined {
298
+ return typeof v === "string" && v ? v : undefined;
299
+ }
300
+ function strArray(v: unknown): string[] | undefined {
301
+ return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
302
+ }
303
+
304
+ function callTool(name: string, args: Record<string, unknown>): string {
305
+ const repo = str(args.repo);
306
+ if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
307
+ const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
308
+
309
+ if (name === "scan_summary") {
310
+ const scan = scanRepo(repo, scanOpts);
311
+ return JSON.stringify(
312
+ { engineVersion: ENGINE_VERSION, commit: scan.commit, fileCount: scan.files.length, languages: scan.languages, capped: scan.capped },
313
+ null,
314
+ 2,
315
+ );
316
+ }
317
+ if (name === "graph") {
318
+ return renderGraphJson(buildIndexArtifacts(repo, scanOpts).graph);
319
+ }
320
+ if (name === "symbols") {
321
+ const { symbols } = buildIndexArtifacts(repo, scanOpts);
322
+ const lookup = str(args.name);
323
+ if (lookup) {
324
+ return JSON.stringify({ name: lookup, defs: symbols.defs[lookup] ?? [], refs: symbols.refs[lookup] ?? [] }, null, 2);
325
+ }
326
+ return JSON.stringify(symbols, null, 2);
327
+ }
328
+ if (name === "callers") {
329
+ const index = buildCallerIndex(scanRepo(repo, scanOpts));
330
+ const lookup = str(args.name);
331
+ if (lookup) {
332
+ const entry = index.get(lookup);
333
+ return JSON.stringify(entry ?? { error: `no tracked callers for "${lookup}"` }, null, 2);
334
+ }
335
+ const obj: Record<string, unknown> = {};
336
+ for (const [k, v] of index) obj[k] = v;
337
+ return JSON.stringify(obj, null, 2);
338
+ }
339
+ if (name === "workspaces") {
340
+ const info = detectWorkspaces(repo);
341
+ return JSON.stringify({ packages: info.packages, cycle: info.cycle ?? null, topoOrder: info.topoOrder }, null, 2);
342
+ }
343
+ if (name === "churn") {
344
+ const { churn, ok } = gitChurn(repo, { since: str(args.since) });
345
+ const sorted: Record<string, number> = {};
346
+ for (const k of [...churn.keys()].sort()) sorted[k] = churn.get(k)!;
347
+ return JSON.stringify({ ok, churn: sorted }, null, 2);
348
+ }
349
+ if (name === "symbols_overview") {
350
+ const file = str(args.file);
351
+ if (!file) throw new Error("`file` is required");
352
+ return JSON.stringify(symbolsOverview(scanRepo(repo, scanOpts), file), null, 2);
353
+ }
354
+ if (name === "find_symbol") {
355
+ const namePath = str(args.namePath);
356
+ if (!namePath) throw new Error("`namePath` is required");
357
+ const matches = findSymbol(scanRepo(repo, scanOpts), namePath, {
358
+ substring: args.substring === true,
359
+ includeBody: args.includeBody === true,
360
+ });
361
+ return JSON.stringify(matches, null, 2);
362
+ }
363
+ if (name === "find_references") {
364
+ const symName = str(args.name);
365
+ if (!symName) throw new Error("`name` is required");
366
+ return JSON.stringify(findReferences(scanRepo(repo, scanOpts), symName), null, 2);
367
+ }
368
+ if (name === "replace_symbol_body" || name === "insert_after_symbol" || name === "insert_before_symbol") {
369
+ const namePath = str(args.namePath);
370
+ const body = typeof args.body === "string" ? args.body : undefined;
371
+ if (!namePath || body === undefined) throw new Error("`namePath` and `body` are required");
372
+ const scan = scanRepo(repo, scanOpts);
373
+ const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
374
+ return JSON.stringify(fn(scan, namePath, body, str(args.file)), null, 2);
375
+ }
376
+ if (name === "write_memory") {
377
+ const memName = str(args.name);
378
+ const content = typeof args.content === "string" ? args.content : undefined;
379
+ if (!memName || content === undefined) throw new Error("`name` and `content` are required");
380
+ return JSON.stringify({ written: writeMemory(repo, memName, content) }, null, 2);
381
+ }
382
+ if (name === "read_memory") {
383
+ const memName = str(args.name);
384
+ if (!memName) throw new Error("`name` is required");
385
+ const content = readMemory(repo, memName);
386
+ if (content === undefined) throw new Error(`no memory named "${memName}" — see list_memories`);
387
+ return content;
388
+ }
389
+ if (name === "list_memories") {
390
+ return JSON.stringify(listMemories(repo), null, 2);
391
+ }
392
+ if (name === "delete_memory") {
393
+ const memName = str(args.name);
394
+ if (!memName) throw new Error("`name` is required");
395
+ return JSON.stringify({ deleted: deleteMemory(repo, memName) }, null, 2);
396
+ }
397
+ if (name === "dead_code") {
398
+ return JSON.stringify(findDeadCode(scanRepo(repo, scanOpts)), null, 2);
399
+ }
400
+ if (name === "complexity") {
401
+ const scan = scanRepo(repo, scanOpts);
402
+ if (args.risk === true) {
403
+ const { churn, ok } = gitChurn(repo);
404
+ return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2);
405
+ }
406
+ return JSON.stringify(symbolComplexity(scan, str(args.file)), null, 2);
407
+ }
408
+ if (name === "mermaid") {
409
+ const { graph } = buildIndexArtifacts(repo, scanOpts);
410
+ return renderMermaid(graph, { module: str(args.module) });
411
+ }
412
+ if (name === "repo_map") {
413
+ const { scan, graph } = buildIndexArtifacts(repo, scanOpts);
414
+ return renderRepoMap(scan, graph, { budgetTokens: typeof args.budgetTokens === "number" ? args.budgetTokens : undefined });
415
+ }
416
+ if (name === "hotspots") {
417
+ const scan = scanRepo(repo, scanOpts);
418
+ const { churn, ok } = gitChurn(repo, { since: str(args.since) });
419
+ return JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2);
420
+ }
421
+ if (name === "coupling") {
422
+ const { ok, couplings } = changeCoupling(repo, { since: str(args.since) });
423
+ return JSON.stringify({ ok, couplings }, null, 2);
424
+ }
425
+ if (name === "grep") {
426
+ const pattern = str(args.pattern);
427
+ if (!pattern) throw new Error("`pattern` is required");
428
+ const hits = grepRepo(repo, pattern, {
429
+ globs: strArray(args.globs),
430
+ ignoreCase: args.ignoreCase === true,
431
+ maxHits: typeof args.maxHits === "number" ? args.maxHits : undefined,
432
+ });
433
+ return JSON.stringify(hits, null, 2);
434
+ }
435
+ if (name === "search") {
436
+ const query = str(args.query);
437
+ if (!query) throw new Error("`query` is required");
438
+ const results = searchIndex(scanRepo(repo, scanOpts), query, {
439
+ limit: typeof args.limit === "number" ? args.limit : undefined,
440
+ });
441
+ return JSON.stringify(results, null, 2);
442
+ }
443
+ if (name === "check_rules") {
444
+ const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
445
+ const { graph } = buildIndexArtifacts(repo, scanOpts);
446
+ return JSON.stringify(checkRules(graph, rules), null, 2);
447
+ }
448
+ throw new Error(`unknown tool: ${name}`);
449
+ }
450
+
451
+ export async function runMcpServer(): Promise<void> {
452
+ await ensureGrammars(allGrammarKeys()); // AST tier when the sidecar is present
453
+
454
+ const send = (msg: Record<string, unknown>): void => {
455
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
456
+ };
457
+
458
+ const rl = createInterface({ input: process.stdin, terminal: false });
459
+ for await (const line of rl) {
460
+ const trimmed = line.trim();
461
+ if (!trimmed) continue;
462
+ let parsed: unknown;
463
+ try {
464
+ parsed = JSON.parse(trimmed);
465
+ } catch {
466
+ send({ id: null, error: { code: -32700, message: "parse error" } });
467
+ continue;
468
+ }
469
+ // JSON-RPC 2.0 batch: answer each member (a batching client would
470
+ // otherwise hang forever on a silently dropped array).
471
+ const requests = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
472
+ for (const req of requests) handle(req);
473
+ }
474
+
475
+ function handle(req: RpcRequest): void {
476
+ if (req.id === undefined || req.id === null) return; // notification — no response
477
+
478
+ try {
479
+ if (req.method === "initialize") {
480
+ send({
481
+ id: req.id,
482
+ result: {
483
+ protocolVersion: "2024-11-05",
484
+ capabilities: { tools: {} },
485
+ serverInfo: { name: "codeindex", version: ENGINE_VERSION },
486
+ },
487
+ });
488
+ } else if (req.method === "ping") {
489
+ send({ id: req.id, result: {} });
490
+ } else if (req.method === "tools/list") {
491
+ send({ id: req.id, result: { tools: TOOLS } });
492
+ } else if (req.method === "tools/call") {
493
+ const params = req.params ?? {};
494
+ const name = str(params.name) ?? "";
495
+ const args = (params.arguments ?? {}) as Record<string, unknown>;
496
+ try {
497
+ const text = callTool(name, args);
498
+ send({ id: req.id, result: { content: [{ type: "text", text }] } });
499
+ } catch (e) {
500
+ send({
501
+ id: req.id,
502
+ result: { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true },
503
+ });
504
+ }
505
+ } else {
506
+ send({ id: req.id, error: { code: -32601, message: `method not found: ${req.method}` } });
507
+ }
508
+ } catch (e) {
509
+ send({ id: req.id, error: { code: -32603, message: e instanceof Error ? e.message : String(e) } });
510
+ }
511
+ }
512
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Project memories (Serena-parity): named markdown notes an agent persists
2
+ // across sessions — project map, build commands, conventions. Stored under
3
+ // <repo>/.codeindex/memories/<name>.md; names may contain `/` for topic
4
+ // subdirectories. Plain files, no daemon; the value is the discipline (small
5
+ // named notes read on relevance) rather than any machinery.
6
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const MEMORY_DIR = [".codeindex", "memories"];
10
+
11
+ // Reject anything that could escape the memories directory: names become
12
+ // filesystem paths.
13
+ function sanitize(name: string): string {
14
+ const clean = name.replace(/^mem:/, "").replace(/\.md$/, "");
15
+ if (!clean) throw new Error("memory name is empty");
16
+ const segments = clean.split("/");
17
+ for (const seg of segments) {
18
+ if (!seg || seg === "." || seg === ".." || seg.includes("\\")) {
19
+ throw new Error(`invalid memory name: "${name}"`);
20
+ }
21
+ if (!/^[\w][\w.-]*$/.test(seg)) throw new Error(`invalid memory name segment: "${seg}"`);
22
+ }
23
+ return clean;
24
+ }
25
+
26
+ function memoryPath(repo: string, name: string): string {
27
+ return join(repo, ...MEMORY_DIR, `${sanitize(name)}.md`);
28
+ }
29
+
30
+ export function writeMemory(repo: string, name: string, content: string): string {
31
+ const path = memoryPath(repo, name);
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, content.endsWith("\n") ? content : content + "\n");
34
+ return sanitize(name);
35
+ }
36
+
37
+ export function readMemory(repo: string, name: string): string | undefined {
38
+ try {
39
+ return readFileSync(memoryPath(repo, name), "utf8");
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ export function deleteMemory(repo: string, name: string): boolean {
46
+ const path = memoryPath(repo, name);
47
+ try {
48
+ statSync(path);
49
+ } catch {
50
+ return false;
51
+ }
52
+ rmSync(path);
53
+ return true;
54
+ }
55
+
56
+ // Sorted list of memory names (topic/name form) — agents load the LIST first
57
+ // and read individual memories on relevance.
58
+ export function listMemories(repo: string): string[] {
59
+ const root = join(repo, ...MEMORY_DIR);
60
+ const out: string[] = [];
61
+ const walk = (dir: string, prefix: string): void => {
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(dir, { withFileTypes: true });
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const e of entries) {
69
+ if (e.isDirectory()) walk(join(dir, e.name), prefix ? `${prefix}/${e.name}` : e.name);
70
+ else if (e.name.endsWith(".md")) out.push(prefix ? `${prefix}/${e.name.slice(0, -3)}` : e.name.slice(0, -3));
71
+ }
72
+ };
73
+ walk(root, "");
74
+ return out.sort();
75
+ }