@misterhuydo/cairn-mcp 1.0.0 → 1.1.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.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # Cairn MCP
2
+
3
+ > Persistent polyglot knowledge graph for Claude Code. Index once, query forever.
4
+
5
+ Claude is stateless — every session starts at zero. On a multi-repo codebase with Java backends, TypeScript frontends, and Vue components, that means 50% of every session is archaeology: finding where things live before any real work can begin.
6
+
7
+ **Cairn fixes this.** It indexes your project into a local SQLite knowledge graph (stored in `.cairn/`) and exposes 8 MCP tools that give Claude instant, persistent memory across sessions.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g @misterhuydo/cairn-mcp
15
+ ```
16
+
17
+ **Requirements:** Node.js >= 22.15.0
18
+
19
+ ---
20
+
21
+ ## Setup
22
+
23
+ Add to `~/.claude/config.json`:
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "cairn": {
29
+ "command": "cairn-mcp"
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Restart Claude Code. Done.
36
+
37
+ ---
38
+
39
+ ## How it works
40
+
41
+ Cairn works like git — it looks for `.cairn/` in your current working directory. Run Claude Code from your project root and Cairn automatically stores the index at `.cairn/index.db` and bundles at `.cairn/bundles/`.
42
+
43
+ No root paths needed. No global config. Just `cd` to your project and go.
44
+
45
+ ---
46
+
47
+ ## Tools
48
+
49
+ ### `cairn_maintain` — Index your project
50
+ Run once at the start of each session. The index persists in `.cairn/index.db`.
51
+
52
+ ```
53
+ cairn_maintain()
54
+ cairn_maintain({ languages: ["typescript", "vue"] }) // limit to specific languages
55
+ ```
56
+
57
+ ```json
58
+ {
59
+ "repos_indexed": 1,
60
+ "files_by_language": { "java": 412, "typescript": 287, "vue": 94 },
61
+ "symbols_total": 6841,
62
+ "security_findings": 47,
63
+ "duration_ms": 4200
64
+ }
65
+ ```
66
+
67
+ ---
68
+
69
+ ### `cairn_search` — Find anything across all languages
70
+ ```
71
+ cairn_search({ query: "user authentication", language: "typescript" })
72
+ ```
73
+
74
+ ```json
75
+ [
76
+ { "lang": "typescript", "kind": "function", "fqn": "src/composables/useAuth.ts::useAuth" },
77
+ { "lang": "vue", "kind": "component", "fqn": "src/components/LoginForm.vue::LoginForm" },
78
+ { "lang": "java", "kind": "class", "fqn": "com.example.auth.UserAuthenticator" }
79
+ ]
80
+ ```
81
+
82
+ ---
83
+
84
+ ### `cairn_bundle` — Minified source snapshot for Claude to read
85
+ Strips comments and empty lines, writes a compressed `### File:` bundle. Use after `cairn_search` to give Claude readable source without blowing the context window.
86
+
87
+ ```
88
+ cairn_bundle({ filter_paths: ["src/components/checkout"], bundle_name: "checkout" })
89
+ ```
90
+
91
+ ```json
92
+ {
93
+ "bundle_path": "/your/project/.cairn/bundles/checkout.txt",
94
+ "original_kb": 284,
95
+ "compressed_kb": 91,
96
+ "reduction_pct": 68
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ### `cairn_describe` — Summarize a module
103
+ ```
104
+ cairn_describe({ path: "src/components/checkout" })
105
+ ```
106
+
107
+ ```json
108
+ {
109
+ "languages": ["vue", "typescript"],
110
+ "symbols": {
111
+ "component": ["CheckoutForm", "OrderSummary", "PaymentStep"],
112
+ "function": ["useCheckout", "usePayment"]
113
+ },
114
+ "imports_from": ["src/store/cart.ts", "src/api/orders.ts"],
115
+ "imported_by": ["src/views/CartView.vue"],
116
+ "external_deps": ["stripe", "@stripe/stripe-js"]
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ### `cairn_code_graph` — Dependency health
123
+ ```
124
+ cairn_code_graph({ mode: "instability" })
125
+ ```
126
+
127
+ ```json
128
+ {
129
+ "modules": [
130
+ { "name": "src/views", "instability": 1.0, "status": "safe_to_refactor" },
131
+ { "name": "src/composables", "instability": 0.4, "status": "review_before_change" },
132
+ { "name": "src/utils", "instability": 0.1, "status": "load_bearing" }
133
+ ]
134
+ }
135
+ ```
136
+
137
+ Modes: `instability` · `health` · `cycles`
138
+
139
+ ---
140
+
141
+ ### `cairn_security` — Vulnerability scan
142
+ ```
143
+ cairn_security({ severity: "HIGH" })
144
+ ```
145
+
146
+ ```json
147
+ {
148
+ "total_findings": 12,
149
+ "by_language": { "typescript": 7, "java": 5 },
150
+ "findings": [
151
+ { "severity": "HIGH", "cwe": "CWE-79", "file": "src/components/UserProfile.vue", "line": 84, "rule": "XSS via innerHTML" },
152
+ { "severity": "HIGH", "cwe": "CWE-611", "file": "backend/src/.../XmlParser.java", "line": 47, "rule": "XXE" }
153
+ ]
154
+ }
155
+ ```
156
+
157
+ Covers: XSS · XXE · SQL injection · command injection · weak crypto · hardcoded secrets · open redirect · unsafe deserialization
158
+
159
+ ---
160
+
161
+ ### `cairn_checkpoint` — Save session state
162
+ Tell Cairn what you were working on so the next session can pick up where you left off.
163
+
164
+ ```
165
+ cairn_checkpoint({
166
+ message: "Added multi-currency to CheckoutForm, still need PaymentStep",
167
+ active_files: ["src/components/checkout/CheckoutForm.vue"],
168
+ notes: ["CurrencyService expects ISO 4217 codes, not symbols"]
169
+ })
170
+ ```
171
+
172
+ ```json
173
+ {
174
+ "saved": true,
175
+ "checkpoint_at": "2026-02-25T14:30:00Z",
176
+ "active_files_tracked": 1,
177
+ "notes_saved": 1
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ### `cairn_resume` — Restore session + smart re-index
184
+ Call instead of `cairn_maintain` when resuming work. Detects which files changed and only re-indexes those.
185
+
186
+ ```
187
+ cairn_resume()
188
+ ```
189
+
190
+ ```json
191
+ {
192
+ "last_checkpoint": "2026-02-25T14:30:00Z",
193
+ "message": "Added multi-currency to CheckoutForm, still need PaymentStep",
194
+ "index_status": "incremental",
195
+ "files_reindexed": 2,
196
+ "files_unchanged": 845,
197
+ "changed_since_checkpoint": [
198
+ { "file": "src/components/checkout/PaymentStep.vue", "change": "modified" }
199
+ ],
200
+ "active_files": ["src/components/checkout/CheckoutForm.vue"],
201
+ "notes": ["CurrencyService expects ISO 4217 codes, not symbols"],
202
+ "resume_summary": "Last session you were adding multi-currency support..."
203
+ }
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Supported Languages
209
+
210
+ | Language | What Cairn extracts |
211
+ |---|---|
212
+ | Java | packages, classes, interfaces, enums, records, methods |
213
+ | TypeScript / JavaScript | classes, interfaces, functions, types, enums |
214
+ | Vue | components, composables, script block symbols |
215
+ | Python | classes, functions, decorators |
216
+ | SQL | tables, views, stored procedures |
217
+ | XML / HTML | bean ids, component names |
218
+ | Config (YAML, properties, .env) | top-level keys |
219
+ | Markdown | headings |
220
+ | Build files (pom.xml, package.json, build.gradle) | dependencies |
221
+
222
+ ---
223
+
224
+ ## Typical session
225
+
226
+ **Fresh start:**
227
+ ```
228
+ 1. cairn_maintain → index project (persists between sessions)
229
+ 2. cairn_search → find the symbols you need
230
+ 3. cairn_bundle → get a readable snapshot of relevant files
231
+ 4. cairn_describe → understand a module before modifying it
232
+ 5. cairn_security → check for issues before a PR
233
+ 6. cairn_checkpoint → save what you were working on
234
+ ```
235
+
236
+ **Resuming work:**
237
+ ```
238
+ 1. cairn_resume → restore session + incremental re-index
239
+ 2. cairn_search → continue where you left off
240
+ ```
241
+
242
+ ---
243
+
244
+ ## Custom DB path
245
+
246
+ By default the index is stored at `.cairn/index.db` in your project directory. Override with an env variable:
247
+
248
+ ```json
249
+ {
250
+ "mcpServers": {
251
+ "cairn": {
252
+ "command": "cairn-mcp",
253
+ "env": { "CAIRN_DB_PATH": "/your/custom/path/index.db" }
254
+ }
255
+ }
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## License
262
+
263
+ MIT
package/index.js CHANGED
@@ -2,13 +2,15 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
 
5
- import { openDB } from './src/graph/db.js';
6
- import { maintain } from './src/tools/maintain.js';
7
- import { search } from './src/tools/search.js';
8
- import { describe } from './src/tools/describe.js';
9
- import { codeGraph } from './src/tools/codeGraph.js';
10
- import { security } from './src/tools/security.js';
11
- import { bundle } from './src/tools/bundle.js';
5
+ import { openDB } from './src/graph/db.js';
6
+ import { maintain } from './src/tools/maintain.js';
7
+ import { search } from './src/tools/search.js';
8
+ import { describe } from './src/tools/describe.js';
9
+ import { codeGraph } from './src/tools/codeGraph.js';
10
+ import { security } from './src/tools/security.js';
11
+ import { bundle } from './src/tools/bundle.js';
12
+ import { checkpoint } from './src/tools/checkpoint.js';
13
+ import { resume } from './src/tools/resume.js';
12
14
 
13
15
  const db = openDB();
14
16
 
@@ -21,20 +23,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
21
23
  tools: [
22
24
  {
23
25
  name: 'cairn_maintain',
24
- description: 'Index all repos into Cairn\'s polyglot knowledge graph. Supports Java, TypeScript, JavaScript, Vue, Python, SQL, config, and Markdown. Run at session start or after major changes.',
26
+ description: 'Index the current project into Cairn\'s polyglot knowledge graph. Supports Java, TypeScript, JavaScript, Vue, Python, SQL, config, and Markdown. Indexes from the current working directory. Run at session start or after major changes.',
25
27
  inputSchema: {
26
28
  type: 'object',
27
29
  properties: {
28
- roots: {
29
- type: 'array', items: { type: 'string' },
30
- description: 'Absolute paths to repo roots to index',
31
- },
32
30
  languages: {
33
31
  type: 'array', items: { type: 'string' },
34
32
  description: 'Optional: limit to specific languages e.g. ["java","typescript"]',
35
33
  },
36
34
  },
37
- required: ['roots'],
38
35
  },
39
36
  },
40
37
  {
@@ -95,13 +92,9 @@ Typical workflow: cairn_search → find files → cairn_bundle those paths → C
95
92
  inputSchema: {
96
93
  type: 'object',
97
94
  properties: {
98
- roots: {
99
- type: 'array', items: { type: 'string' },
100
- description: 'Repo roots to bundle',
101
- },
102
95
  bundle_name: {
103
96
  type: 'string', default: 'default',
104
- description: 'Name for the bundle file (saved to ~/.cairn/bundles/<name>.txt)',
97
+ description: 'Name for the bundle file (saved to .cairn/bundles/<name>.txt)',
105
98
  },
106
99
  filter_paths: {
107
100
  type: 'array', items: { type: 'string' },
@@ -118,7 +111,36 @@ Typical workflow: cairn_search → find files → cairn_bundle those paths → C
118
111
  only_changed: { type: 'boolean', default: false, description: 'Incremental: only re-bundle files changed since last run' },
119
112
  max_size_kb: { type: 'number', default: 800, description: 'Safety cap in KB to avoid context window overflow' },
120
113
  },
121
- required: ['roots'],
114
+ },
115
+ },
116
+ {
117
+ name: 'cairn_checkpoint',
118
+ description: 'Save current session state to .cairn/session.json so it can be restored next session with cairn_resume.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ message: {
123
+ type: 'string',
124
+ description: 'What you were working on — will be shown on resume',
125
+ },
126
+ active_files: {
127
+ type: 'array', items: { type: 'string' },
128
+ description: 'Optional: absolute paths to files actively being worked on',
129
+ },
130
+ notes: {
131
+ type: 'array', items: { type: 'string' },
132
+ description: 'Optional: things Claude should remember next session',
133
+ },
134
+ },
135
+ required: ['message'],
136
+ },
137
+ },
138
+ {
139
+ name: 'cairn_resume',
140
+ description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Call at the start of a session instead of cairn_maintain when resuming work.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {},
122
144
  },
123
145
  },
124
146
  ],
@@ -127,12 +149,14 @@ Typical workflow: cairn_search → find files → cairn_bundle those paths → C
127
149
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
128
150
  const { name, arguments: args } = request.params;
129
151
  switch (name) {
130
- case 'cairn_maintain': return await maintain(db, args);
131
- case 'cairn_search': return search(db, args);
132
- case 'cairn_describe': return describe(db, args);
133
- case 'cairn_code_graph': return codeGraph(db, args);
134
- case 'cairn_security': return security(db, args);
135
- case 'cairn_bundle': return await bundle(db, args);
152
+ case 'cairn_maintain': return await maintain(db, args);
153
+ case 'cairn_search': return search(db, args);
154
+ case 'cairn_describe': return describe(db, args);
155
+ case 'cairn_code_graph': return codeGraph(db, args);
156
+ case 'cairn_security': return security(db, args);
157
+ case 'cairn_bundle': return await bundle(db, args);
158
+ case 'cairn_checkpoint': return checkpoint(db, args);
159
+ case 'cairn_resume': return await resume(db);
136
160
  default: throw new Error(`Unknown tool: ${name}`);
137
161
  }
138
162
  });
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Persistent polyglot knowledge graph MCP server for Claude Code. Index once, query forever — across Java, TypeScript, Vue, Python, SQL and more.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "cairn-mcp": "./bin/cairn-mcp.js"
8
+ "cairn-mcp": "bin/cairn-mcp.js"
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node --experimental-sqlite index.js"
@@ -1,13 +1,11 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import { minifyContent } from './minifier.js';
5
4
  import { minifyVue } from './vueMinifier.js';
6
5
  import { walkRepo, LANGUAGE_MAP } from '../indexer/fileWalker.js';
6
+ import { getCairnDir } from '../graph/cwd.js';
7
7
 
8
- const BUNDLE_DIR = path.join(os.homedir(), '.cairn', 'bundles');
9
-
10
- export async function writeBundle(roots, options = {}) {
8
+ export async function writeBundle(options = {}) {
11
9
  const {
12
10
  bundleName = 'default',
13
11
  noComments = true,
@@ -20,10 +18,11 @@ export async function writeBundle(roots, options = {}) {
20
18
  maxSizeKB = 800,
21
19
  } = options;
22
20
 
23
- fs.mkdirSync(BUNDLE_DIR, { recursive: true });
21
+ const bundleDir = path.join(getCairnDir(), 'bundles');
22
+ const root = process.cwd();
24
23
 
25
- const outPath = path.join(BUNDLE_DIR, `${bundleName}.txt`);
26
- const mtimePath = path.join(BUNDLE_DIR, `${bundleName}.mtime.json`);
24
+ const outPath = path.join(bundleDir, `${bundleName}.txt`);
25
+ const mtimePath = path.join(bundleDir, `${bundleName}.mtime.json`);
27
26
  const mtimeCache = onlyChanged && fs.existsSync(mtimePath)
28
27
  ? JSON.parse(fs.readFileSync(mtimePath, 'utf8')) : {};
29
28
 
@@ -37,60 +36,58 @@ export async function writeBundle(roots, options = {}) {
37
36
  const langFilter = filterLang
38
37
  ? (Array.isArray(filterLang) ? filterLang : [filterLang]) : null;
39
38
 
40
- for (const root of roots) {
41
- const files = await walkRepo(root);
42
-
43
- for (const filePath of files) {
44
- const ext = path.extname(filePath).toLowerCase();
45
- const language = LANGUAGE_MAP[ext];
46
- if (!language) continue;
47
- if (langFilter && !langFilter.includes(language)) continue;
48
-
49
- // Path filter — match against relative path from repo root
50
- if (filterPaths) {
51
- const rel = path.relative(root, filePath).replace(/\\/g, '/');
52
- if (!filterPaths.some(p => rel.startsWith(p))) continue;
53
- }
54
-
55
- // Incremental: skip files unchanged since last bundle
56
- const mtime = fs.statSync(filePath).mtimeMs;
57
- newMtimes[filePath] = mtime;
58
- if (onlyChanged && mtimeCache[filePath] === mtime) {
59
- stats.skipped_unchanged++;
60
- continue;
61
- }
62
-
63
- // Size cap check current accumulated size before adding more
64
- const currentKB = Buffer.byteLength(lines.join('\n'), 'utf8') / 1024;
65
- if (currentKB > maxSizeKB) {
66
- stats.skipped_size++;
67
- continue;
68
- }
69
-
70
- let raw;
71
- try {
72
- raw = fs.readFileSync(filePath, 'utf8');
73
- } catch {
74
- continue;
75
- }
76
- stats.original_bytes += Buffer.byteLength(raw, 'utf8');
77
-
78
- // Minify
79
- const minified = language === 'vue'
80
- ? minifyVue(raw, { noStyle, noComments, noEmptyLines, aggressive })
81
- : minifyContent(raw, language, { noComments, noEmptyLines, aggressive });
82
-
83
- if (!minified.trim()) continue;
84
-
85
- const rel = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
86
- lines.push(`### File: ${rel}`);
87
- lines.push(minified.trim());
88
- lines.push('');
89
-
90
- stats.compressed_bytes += Buffer.byteLength(minified, 'utf8');
91
- stats.processed++;
92
- stats.by_language[language] = (stats.by_language[language] || 0) + 1;
39
+ const files = await walkRepo(root);
40
+
41
+ for (const filePath of files) {
42
+ const ext = path.extname(filePath).toLowerCase();
43
+ const language = LANGUAGE_MAP[ext];
44
+ if (!language) continue;
45
+ if (langFilter && !langFilter.includes(language)) continue;
46
+
47
+ // Path filter — match against relative path from cwd
48
+ if (filterPaths) {
49
+ const rel = path.relative(root, filePath).replace(/\\/g, '/');
50
+ if (!filterPaths.some(p => rel.startsWith(p))) continue;
51
+ }
52
+
53
+ // Incremental: skip files unchanged since last bundle
54
+ const mtime = fs.statSync(filePath).mtimeMs;
55
+ newMtimes[filePath] = mtime;
56
+ if (onlyChanged && mtimeCache[filePath] === mtime) {
57
+ stats.skipped_unchanged++;
58
+ continue;
59
+ }
60
+
61
+ // Size cap — check current accumulated size before adding more
62
+ const currentKB = Buffer.byteLength(lines.join('\n'), 'utf8') / 1024;
63
+ if (currentKB > maxSizeKB) {
64
+ stats.skipped_size++;
65
+ continue;
93
66
  }
67
+
68
+ let raw;
69
+ try {
70
+ raw = fs.readFileSync(filePath, 'utf8');
71
+ } catch {
72
+ continue;
73
+ }
74
+ stats.original_bytes += Buffer.byteLength(raw, 'utf8');
75
+
76
+ // Minify
77
+ const minified = language === 'vue'
78
+ ? minifyVue(raw, { noStyle, noComments, noEmptyLines, aggressive })
79
+ : minifyContent(raw, language, { noComments, noEmptyLines, aggressive });
80
+
81
+ if (!minified.trim()) continue;
82
+
83
+ const rel = path.relative(root, filePath).replace(/\\/g, '/');
84
+ lines.push(`### File: ${rel}`);
85
+ lines.push(minified.trim());
86
+ lines.push('');
87
+
88
+ stats.compressed_bytes += Buffer.byteLength(minified, 'utf8');
89
+ stats.processed++;
90
+ stats.by_language[language] = (stats.by_language[language] || 0) + 1;
94
91
  }
95
92
 
96
93
  const output = lines.join('\n');
@@ -0,0 +1,8 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export function getCairnDir() {
5
+ const cairnDir = path.join(process.cwd(), '.cairn');
6
+ fs.mkdirSync(path.join(cairnDir, 'bundles'), { recursive: true });
7
+ return cairnDir;
8
+ }
package/src/graph/db.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import fs from 'fs';
4
+ import { getCairnDir } from './cwd.js';
5
5
 
6
- const DB_PATH = process.env.CAIRN_DB_PATH || path.join(os.homedir(), '.cairn', 'index.db');
6
+ function getDbPath() {
7
+ if (process.env.CAIRN_DB_PATH) return process.env.CAIRN_DB_PATH;
8
+ return path.join(getCairnDir(), 'index.db');
9
+ }
7
10
 
8
11
  const SCHEMA = `
9
12
  CREATE TABLE IF NOT EXISTS files (
@@ -58,10 +61,11 @@ const SCHEMA = `
58
61
  `;
59
62
 
60
63
  export function openDB() {
61
- const dir = path.dirname(DB_PATH);
64
+ const dbPath = getDbPath();
65
+ const dir = path.dirname(dbPath);
62
66
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
67
 
64
- const db = new DatabaseSync(DB_PATH);
68
+ const db = new DatabaseSync(dbPath);
65
69
  db.exec('PRAGMA journal_mode = WAL');
66
70
  db.exec('PRAGMA synchronous = NORMAL');
67
71
  db.exec(SCHEMA);
@@ -2,7 +2,6 @@ import { writeBundle } from '../bundler/bundleWriter.js';
2
2
 
3
3
  export async function bundle(_db, args) {
4
4
  const {
5
- roots,
6
5
  bundle_name = 'default',
7
6
  filter_paths = null,
8
7
  filter_language = null,
@@ -14,7 +13,7 @@ export async function bundle(_db, args) {
14
13
  max_size_kb = 800,
15
14
  } = args;
16
15
 
17
- const result = await writeBundle(roots, {
16
+ const result = await writeBundle({
18
17
  bundleName: bundle_name,
19
18
  noComments: no_comments,
20
19
  noEmptyLines: no_empty_lines,
@@ -0,0 +1,40 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getCairnDir } from '../graph/cwd.js';
4
+
5
+ export function checkpoint(_db, { message, active_files = [], notes = [] }) {
6
+ const cairnDir = getCairnDir();
7
+ const sessionPath = path.join(cairnDir, 'session.json');
8
+
9
+ // Snapshot mtimes of active files
10
+ const mtimeSnapshot = {};
11
+ for (const filePath of active_files) {
12
+ try {
13
+ mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs;
14
+ } catch {
15
+ // File may not exist yet
16
+ }
17
+ }
18
+
19
+ const session = {
20
+ message,
21
+ checkpoint_at: new Date().toISOString(),
22
+ active_files,
23
+ notes,
24
+ mtime_snapshot: mtimeSnapshot,
25
+ };
26
+
27
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
28
+
29
+ return {
30
+ content: [{
31
+ type: 'text',
32
+ text: JSON.stringify({
33
+ saved: true,
34
+ checkpoint_at: session.checkpoint_at,
35
+ active_files_tracked: active_files.length,
36
+ notes_saved: notes.length,
37
+ }, null, 2),
38
+ }],
39
+ };
40
+ }
@@ -18,86 +18,108 @@ function inferFileType(filePath) {
18
18
  return 'source';
19
19
  }
20
20
 
21
- export async function maintain(db, { roots, languages }) {
22
- const startTime = Date.now();
23
- const stats = {
24
- repos_indexed: 0,
25
- files_by_language: {},
26
- symbols_total: 0,
27
- dependencies_mapped: 0,
28
- security_findings: 0,
29
- };
21
+ async function indexFile(db, filePath, repoName, languages, stats) {
22
+ try {
23
+ const content = await fs.readFile(filePath, 'utf-8');
24
+ const result = await parseFile(filePath, content, repoName);
25
+ if (!result) return;
26
+
27
+ const { language } = result;
28
+ if (languages && !languages.includes(language)) return;
29
+
30
+ stats.files_by_language[language] = (stats.files_by_language[language] || 0) + 1;
31
+
32
+ const fileId = upsertFile(db, {
33
+ repo: repoName,
34
+ path: filePath,
35
+ language,
36
+ file_type: inferFileType(filePath),
37
+ });
30
38
 
31
- for (const root of roots) {
32
- const repoName = path.basename(root);
33
- stats.repos_indexed++;
34
-
35
- // Index source files
36
- const files = await walkRepo(root);
37
- for (const filePath of files) {
38
- try {
39
- const content = await fs.readFile(filePath, 'utf-8');
40
- const result = await parseFile(filePath, content, repoName);
41
- if (!result) continue;
42
-
43
- const { language } = result;
44
- if (languages && !languages.includes(language)) continue;
45
-
46
- stats.files_by_language[language] = (stats.files_by_language[language] || 0) + 1;
47
-
48
- const fileId = upsertFile(db, {
49
- repo: repoName,
50
- path: filePath,
51
- language,
52
- file_type: inferFileType(filePath),
53
- });
54
-
55
- clearFileData(db, fileId);
56
-
57
- for (const sym of (result.symbols || [])) {
58
- upsertSymbol(db, { ...sym, file_id: fileId, language });
59
- stats.symbols_total++;
60
- }
61
-
62
- for (const imp of (result.imports || [])) {
63
- insertDependency(db, { from_file_id: fileId, to_fqn: imp, dep_type: 'imports' });
64
- stats.dependencies_mapped++;
65
- }
66
-
67
- const findings = scanFile(filePath, content, language);
68
- for (const f of findings) {
69
- insertSecurityFinding(db, { file_id: fileId, ...f });
70
- stats.security_findings++;
71
- }
72
- } catch {
73
- // Skip unreadable/unparseable files
74
- }
39
+ clearFileData(db, fileId);
40
+
41
+ for (const sym of (result.symbols || [])) {
42
+ upsertSymbol(db, { ...sym, file_id: fileId, language });
43
+ stats.symbols_total++;
44
+ }
45
+
46
+ for (const imp of (result.imports || [])) {
47
+ insertDependency(db, { from_file_id: fileId, to_fqn: imp, dep_type: 'imports' });
48
+ stats.dependencies_mapped++;
75
49
  }
76
50
 
77
- // Index build files
78
- for (const buildFile of BUILD_FILES) {
79
- const buildPath = path.join(root, buildFile);
80
- try {
81
- const content = await fs.readFile(buildPath, 'utf-8');
82
- let deps = [];
83
- if (buildFile === 'pom.xml') deps = parseMaven(buildPath, content, repoName);
84
- else if (buildFile === 'package.json') deps = parseNpm(buildPath, content, repoName);
85
- else if (buildFile === 'build.gradle') deps = parseGradle(buildPath, content, repoName);
86
- for (const dep of deps) insertBuildDep(db, { repo: repoName, ...dep });
87
- } catch {
88
- // Build file not present or unreadable
89
- }
51
+ const findings = scanFile(filePath, content, language);
52
+ for (const f of findings) {
53
+ insertSecurityFinding(db, { file_id: fileId, ...f });
54
+ stats.security_findings++;
90
55
  }
56
+ } catch {
57
+ // Skip unreadable/unparseable files
91
58
  }
59
+ }
92
60
 
93
- // Rebuild FTS5 index
61
+ function rebuildFts(db) {
94
62
  db.exec(`
95
63
  DELETE FROM fts_index;
96
64
  INSERT INTO fts_index(fqn, name, description, path, repo, language, kind)
97
65
  SELECT s.fqn, s.name, COALESCE(s.description,''), f.path, f.repo, s.language, s.kind
98
66
  FROM symbols s JOIN files f ON s.file_id = f.id;
99
67
  `);
68
+ }
69
+
70
+ export async function maintain(db, { languages } = {}) {
71
+ const startTime = Date.now();
72
+ const root = process.cwd();
73
+ const repoName = path.basename(root);
74
+ const stats = {
75
+ repos_indexed: 1,
76
+ files_by_language: {},
77
+ symbols_total: 0,
78
+ dependencies_mapped: 0,
79
+ security_findings: 0,
80
+ };
81
+
82
+ const files = await walkRepo(root);
83
+ for (const filePath of files) {
84
+ await indexFile(db, filePath, repoName, languages, stats);
85
+ }
86
+
87
+ // Index build files
88
+ for (const buildFile of BUILD_FILES) {
89
+ const buildPath = path.join(root, buildFile);
90
+ try {
91
+ const content = await fs.readFile(buildPath, 'utf-8');
92
+ let deps = [];
93
+ if (buildFile === 'pom.xml') deps = parseMaven(buildPath, content, repoName);
94
+ else if (buildFile === 'package.json') deps = parseNpm(buildPath, content, repoName);
95
+ else if (buildFile === 'build.gradle') deps = parseGradle(buildPath, content, repoName);
96
+ for (const dep of deps) insertBuildDep(db, { repo: repoName, ...dep });
97
+ } catch {
98
+ // Build file not present or unreadable
99
+ }
100
+ }
101
+
102
+ rebuildFts(db);
100
103
 
101
104
  stats.duration_ms = Date.now() - startTime;
102
105
  return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
103
106
  }
107
+
108
+ // Incremental re-index: only process a specific list of changed file paths
109
+ export async function incrementalMaintain(db, changedFiles) {
110
+ const root = process.cwd();
111
+ const repoName = path.basename(root);
112
+ const stats = {
113
+ files_by_language: {},
114
+ symbols_total: 0,
115
+ dependencies_mapped: 0,
116
+ security_findings: 0,
117
+ };
118
+
119
+ for (const filePath of changedFiles) {
120
+ await indexFile(db, filePath, repoName, null, stats);
121
+ }
122
+
123
+ rebuildFts(db);
124
+ return stats;
125
+ }
@@ -0,0 +1,91 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getCairnDir } from '../graph/cwd.js';
4
+ import { walkRepo } from '../indexer/fileWalker.js';
5
+ import { incrementalMaintain } from './maintain.js';
6
+
7
+ export async function resume(db) {
8
+ const cairnDir = getCairnDir();
9
+ const sessionPath = path.join(cairnDir, 'session.json');
10
+
11
+ if (!fs.existsSync(sessionPath)) {
12
+ return {
13
+ content: [{
14
+ type: 'text',
15
+ text: JSON.stringify({
16
+ error: 'No checkpoint found. Run cairn_maintain to index the project, then cairn_checkpoint to save session state.',
17
+ }, null, 2),
18
+ }],
19
+ };
20
+ }
21
+
22
+ const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
23
+ const snapshot = session.mtime_snapshot || {};
24
+
25
+ // Detect changed files by comparing current mtimes to snapshot
26
+ const changed = [];
27
+ for (const [filePath, savedMtime] of Object.entries(snapshot)) {
28
+ try {
29
+ const currentMtime = fs.statSync(filePath).mtimeMs;
30
+ if (currentMtime !== savedMtime) {
31
+ changed.push({ file: filePath, change: 'modified' });
32
+ }
33
+ } catch {
34
+ changed.push({ file: filePath, change: 'deleted' });
35
+ }
36
+ }
37
+
38
+ // Detect new files: walk cwd and find files not in the snapshot
39
+ try {
40
+ const allFiles = await walkRepo(process.cwd());
41
+ for (const filePath of allFiles) {
42
+ if (!(filePath in snapshot)) {
43
+ changed.push({ file: filePath, change: 'new' });
44
+ }
45
+ }
46
+ } catch {
47
+ // Walk may fail on some systems; proceed without new-file detection
48
+ }
49
+
50
+ // Incremental re-index only changed/new files
51
+ const filesToReindex = changed
52
+ .filter(c => c.change !== 'deleted')
53
+ .map(c => c.file);
54
+
55
+ let reindexStats = { files_reindexed: 0, files_unchanged: 0 };
56
+ if (filesToReindex.length > 0) {
57
+ const stats = await incrementalMaintain(db, filesToReindex);
58
+ reindexStats.files_reindexed = filesToReindex.length;
59
+ }
60
+
61
+ // Count unchanged files from snapshot
62
+ reindexStats.files_unchanged = Object.keys(snapshot).length - changed.filter(c => c.change !== 'new').length;
63
+
64
+ // Build human-readable resume summary
65
+ const changedSummary = changed.length > 0
66
+ ? changed.slice(0, 10).map(c => `${c.change}: ${path.relative(process.cwd(), c.file).replace(/\\/g, '/')}`).join(', ')
67
+ : 'none';
68
+
69
+ const resumeSummary = [
70
+ `Last session: ${session.message}`,
71
+ changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
72
+ session.notes?.length > 0 ? `Notes: ${session.notes.join(' | ')}` : '',
73
+ ].filter(Boolean).join(' ');
74
+
75
+ return {
76
+ content: [{
77
+ type: 'text',
78
+ text: JSON.stringify({
79
+ last_checkpoint: session.checkpoint_at,
80
+ message: session.message,
81
+ index_status: filesToReindex.length > 0 ? 'incremental' : 'up_to_date',
82
+ files_reindexed: reindexStats.files_reindexed,
83
+ files_unchanged: Math.max(0, reindexStats.files_unchanged),
84
+ changed_since_checkpoint: changed.slice(0, 20),
85
+ active_files: session.active_files || [],
86
+ notes: session.notes || [],
87
+ resume_summary: resumeSummary,
88
+ }, null, 2),
89
+ }],
90
+ };
91
+ }