@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 +263 -0
- package/index.js +49 -25
- package/package.json +2 -2
- package/src/bundler/bundleWriter.js +57 -60
- package/src/graph/cwd.js +8 -0
- package/src/graph/db.js +8 -4
- package/src/tools/bundle.js +1 -2
- package/src/tools/checkpoint.js +40 -0
- package/src/tools/maintain.js +89 -67
- package/src/tools/resume.js +91 -0
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 }
|
|
6
|
-
import { maintain }
|
|
7
|
-
import { search }
|
|
8
|
-
import { describe }
|
|
9
|
-
import { codeGraph }
|
|
10
|
-
import { security }
|
|
11
|
-
import { bundle }
|
|
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
|
|
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
|
|
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
|
-
|
|
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':
|
|
131
|
-
case 'cairn_search':
|
|
132
|
-
case 'cairn_describe':
|
|
133
|
-
case 'cairn_code_graph':
|
|
134
|
-
case 'cairn_security':
|
|
135
|
-
case 'cairn_bundle':
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
const bundleDir = path.join(getCairnDir(), 'bundles');
|
|
22
|
+
const root = process.cwd();
|
|
24
23
|
|
|
25
|
-
const outPath = path.join(
|
|
26
|
-
const mtimePath = path.join(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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');
|
package/src/graph/cwd.js
ADDED
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
|
-
|
|
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
|
|
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(
|
|
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);
|
package/src/tools/bundle.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/tools/maintain.js
CHANGED
|
@@ -18,86 +18,108 @@ function inferFileType(filePath) {
|
|
|
18
18
|
return 'source';
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
78
|
-
for (const
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
}
|