@bennys001/claude-code-memory 0.9.7
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 +198 -0
- package/dist/index.js +1242 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
// package.json
|
|
4
|
+
var package_default = {
|
|
5
|
+
name: "@bennys001/claude-code-memory",
|
|
6
|
+
publishConfig: { access: "public" },
|
|
7
|
+
version: "0.9.7",
|
|
8
|
+
description: "MCP server that gives Claude Code persistent memory via an Obsidian knowledge vault",
|
|
9
|
+
module: "dist/index.js",
|
|
10
|
+
main: "dist/index.js",
|
|
11
|
+
type: "module",
|
|
12
|
+
repository: {
|
|
13
|
+
type: "git",
|
|
14
|
+
url: "https://github.com/Ben-Spn/claude-code-memory.git"
|
|
15
|
+
},
|
|
16
|
+
bugs: {
|
|
17
|
+
url: "https://github.com/Ben-Spn/claude-code-memory/issues"
|
|
18
|
+
},
|
|
19
|
+
homepage: "https://github.com/Ben-Spn/claude-code-memory?tab=readme-ov-file#claude-code-memory-ccm",
|
|
20
|
+
scripts: {
|
|
21
|
+
prepare: "husky",
|
|
22
|
+
build: "bunup",
|
|
23
|
+
prepublishOnly: "bun run build"
|
|
24
|
+
},
|
|
25
|
+
bin: {
|
|
26
|
+
ccm: "dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
files: [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
devDependencies: {
|
|
32
|
+
"@types/bun": "1.3.9",
|
|
33
|
+
"@types/dompurify": "3.2.0",
|
|
34
|
+
"@types/mozilla-readability": "0.2.1",
|
|
35
|
+
"@types/turndown": "5.0.6",
|
|
36
|
+
bunup: "0.16.31",
|
|
37
|
+
husky: "9.1.7"
|
|
38
|
+
},
|
|
39
|
+
peerDependencies: {
|
|
40
|
+
typescript: "5.9.3"
|
|
41
|
+
},
|
|
42
|
+
dependencies: {
|
|
43
|
+
"@modelcontextprotocol/sdk": "1.27.1",
|
|
44
|
+
"@mozilla/readability": "0.6.0",
|
|
45
|
+
dompurify: "3.3.1",
|
|
46
|
+
globby: "16.1.1",
|
|
47
|
+
"gray-matter": "4.0.3",
|
|
48
|
+
linkedom: "0.18.12",
|
|
49
|
+
"smol-toml": "1.6.0",
|
|
50
|
+
tiktoken: "1.0.22",
|
|
51
|
+
turndown: "7.2.2",
|
|
52
|
+
zod: "4.3.6"
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/index.ts
|
|
57
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
58
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
59
|
+
import { homedir as homedir4 } from "os";
|
|
60
|
+
import { join as join7 } from "path";
|
|
61
|
+
|
|
62
|
+
// src/vault/loader.ts
|
|
63
|
+
import { globby } from "globby";
|
|
64
|
+
|
|
65
|
+
// src/vault/parser.ts
|
|
66
|
+
import matter from "gray-matter";
|
|
67
|
+
import { get_encoding } from "tiktoken";
|
|
68
|
+
|
|
69
|
+
// src/vault/types.ts
|
|
70
|
+
import { z } from "zod";
|
|
71
|
+
var NoteType = z.enum(["gotcha", "decision", "pattern", "reference"]);
|
|
72
|
+
var NOTE_TYPE_ICONS = {
|
|
73
|
+
gotcha: "\uD83D\uDD34",
|
|
74
|
+
decision: "\uD83D\uDFE4",
|
|
75
|
+
pattern: "\uD83D\uDD35",
|
|
76
|
+
reference: "\uD83D\uDFE2"
|
|
77
|
+
};
|
|
78
|
+
var NOTE_TYPE_PRIORITY = {
|
|
79
|
+
gotcha: 0,
|
|
80
|
+
decision: 1,
|
|
81
|
+
pattern: 2,
|
|
82
|
+
reference: 3
|
|
83
|
+
};
|
|
84
|
+
var NoteFrontmatter = z.object({
|
|
85
|
+
type: NoteType,
|
|
86
|
+
projects: z.array(z.string()).default([]),
|
|
87
|
+
tags: z.array(z.string()).default([]),
|
|
88
|
+
created: z.coerce.date(),
|
|
89
|
+
updated: z.coerce.date(),
|
|
90
|
+
title: z.string().optional()
|
|
91
|
+
});
|
|
92
|
+
var ProjectConfigSchema = z.object({
|
|
93
|
+
project: z.object({
|
|
94
|
+
name: z.string()
|
|
95
|
+
}).optional(),
|
|
96
|
+
filter: z.object({
|
|
97
|
+
tags: z.array(z.string()).optional(),
|
|
98
|
+
types: z.array(NoteType).optional(),
|
|
99
|
+
exclude: z.array(z.string()).optional()
|
|
100
|
+
}).optional()
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// src/vault/parser.ts
|
|
104
|
+
import { relative, basename } from "path";
|
|
105
|
+
var encoder = get_encoding("cl100k_base");
|
|
106
|
+
function countTokens(text) {
|
|
107
|
+
if (!text)
|
|
108
|
+
return 0;
|
|
109
|
+
return encoder.encode(text).length;
|
|
110
|
+
}
|
|
111
|
+
function extractTitle(frontmatter, content, filePath) {
|
|
112
|
+
if (frontmatter.title)
|
|
113
|
+
return frontmatter.title;
|
|
114
|
+
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
115
|
+
if (headingMatch)
|
|
116
|
+
return headingMatch[1].trim();
|
|
117
|
+
return basename(filePath, ".md");
|
|
118
|
+
}
|
|
119
|
+
async function parseNote(filePath, vaultPath) {
|
|
120
|
+
const raw = await Bun.file(filePath).text();
|
|
121
|
+
const { data, content } = matter(raw);
|
|
122
|
+
const result = NoteFrontmatter.safeParse(data);
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
console.error(`Skipping ${relative(vaultPath, filePath)}: invalid frontmatter`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const body = content.trim();
|
|
128
|
+
return {
|
|
129
|
+
frontmatter: result.data,
|
|
130
|
+
filePath,
|
|
131
|
+
relativePath: relative(vaultPath, filePath),
|
|
132
|
+
title: extractTitle(data, content, filePath),
|
|
133
|
+
body,
|
|
134
|
+
tokenCount: countTokens(body)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/vault/loader.ts
|
|
139
|
+
async function discoverFiles(vaultPath) {
|
|
140
|
+
return globby("**/*.md", {
|
|
141
|
+
cwd: vaultPath,
|
|
142
|
+
absolute: true,
|
|
143
|
+
ignore: ["**/node_modules/**", "**/.*/**"]
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function loadVault(vaultPath) {
|
|
147
|
+
const files = await discoverFiles(vaultPath);
|
|
148
|
+
const results = await Promise.all(files.map((f) => parseNote(f, vaultPath)));
|
|
149
|
+
return results.filter((entry) => entry !== null).sort((a, b) => NOTE_TYPE_PRIORITY[a.frontmatter.type] - NOTE_TYPE_PRIORITY[b.frontmatter.type]);
|
|
150
|
+
}
|
|
151
|
+
function matchesGlob(path, pattern) {
|
|
152
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
153
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
154
|
+
}
|
|
155
|
+
function filterEntries(entries, config) {
|
|
156
|
+
if (!config?.filter)
|
|
157
|
+
return entries;
|
|
158
|
+
const { tags, types, exclude } = config.filter;
|
|
159
|
+
return entries.filter((entry) => {
|
|
160
|
+
if (types?.length && !types.includes(entry.frontmatter.type))
|
|
161
|
+
return false;
|
|
162
|
+
if (tags?.length && !entry.frontmatter.tags.some((t) => tags.includes(t)))
|
|
163
|
+
return false;
|
|
164
|
+
if (exclude?.length && exclude.some((pattern) => matchesGlob(entry.relativePath, pattern)))
|
|
165
|
+
return false;
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/tools/index-tool.ts
|
|
171
|
+
import { z as z2 } from "zod";
|
|
172
|
+
|
|
173
|
+
// src/index-engine/index.ts
|
|
174
|
+
function formatTokenCount(count) {
|
|
175
|
+
return `~${Math.round(count / 10) * 10}`;
|
|
176
|
+
}
|
|
177
|
+
function toIndexEntry(entry) {
|
|
178
|
+
return {
|
|
179
|
+
icon: NOTE_TYPE_ICONS[entry.frontmatter.type],
|
|
180
|
+
title: entry.title,
|
|
181
|
+
relativePath: entry.relativePath,
|
|
182
|
+
tokenDisplay: formatTokenCount(entry.tokenCount)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function buildIndexTable(entries) {
|
|
186
|
+
if (entries.length === 0) {
|
|
187
|
+
return "No knowledge entries match the current filters.";
|
|
188
|
+
}
|
|
189
|
+
const headers = ["T", "Title", "Path", "~Tok"];
|
|
190
|
+
const rows = entries.map((e) => {
|
|
191
|
+
const idx = toIndexEntry(e);
|
|
192
|
+
return [idx.icon, idx.title, idx.relativePath, idx.tokenDisplay];
|
|
193
|
+
});
|
|
194
|
+
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
195
|
+
const padRow = (cells) => "| " + cells.map((c, i) => c.padEnd(colWidths[i])).join(" | ") + " |";
|
|
196
|
+
const separator = "|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|";
|
|
197
|
+
return [padRow(headers), separator, ...rows.map(padRow)].join(`
|
|
198
|
+
`);
|
|
199
|
+
}
|
|
200
|
+
function formatKnowledgeSection(entries) {
|
|
201
|
+
const table = buildIndexTable(entries);
|
|
202
|
+
return [
|
|
203
|
+
"## Knowledge Index",
|
|
204
|
+
"Use MCP tool `read` with the note path to fetch full details on demand.",
|
|
205
|
+
"\uD83D\uDD34 = gotcha \uD83D\uDFE4 = decision \uD83D\uDD35 = pattern \uD83D\uDFE2 = reference",
|
|
206
|
+
"",
|
|
207
|
+
table
|
|
208
|
+
].join(`
|
|
209
|
+
`);
|
|
210
|
+
}
|
|
211
|
+
function injectKnowledgeSection(existingContent, entries) {
|
|
212
|
+
const newSection = formatKnowledgeSection(entries);
|
|
213
|
+
const sectionStart = existingContent.indexOf("## Knowledge Index");
|
|
214
|
+
if (sectionStart === -1) {
|
|
215
|
+
return newSection + `
|
|
216
|
+
|
|
217
|
+
` + existingContent.trimStart();
|
|
218
|
+
}
|
|
219
|
+
const beforeSection = existingContent.substring(0, sectionStart);
|
|
220
|
+
const afterSectionStart = existingContent.indexOf(`
|
|
221
|
+
## `, sectionStart + "## Knowledge Index".length);
|
|
222
|
+
const rest = afterSectionStart === -1 ? beforeSection.trimEnd() : (beforeSection + existingContent.substring(afterSectionStart + 1)).trimStart();
|
|
223
|
+
return rest ? newSection + `
|
|
224
|
+
|
|
225
|
+
` + rest : newSection + `
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/tools/index-tool.ts
|
|
230
|
+
function executeIndex(args, entries) {
|
|
231
|
+
let filtered = entries;
|
|
232
|
+
if (args.project) {
|
|
233
|
+
filtered = filtered.filter((e) => e.frontmatter.projects.includes(args.project));
|
|
234
|
+
}
|
|
235
|
+
return buildIndexTable(filtered);
|
|
236
|
+
}
|
|
237
|
+
function registerIndexTool(server, entries) {
|
|
238
|
+
server.registerTool("index", {
|
|
239
|
+
description: "Return the compressed knowledge index table for the current project or vault",
|
|
240
|
+
inputSchema: z2.object({
|
|
241
|
+
project: z2.string().optional().describe("Filter to notes tagged with this project name")
|
|
242
|
+
})
|
|
243
|
+
}, async (args) => {
|
|
244
|
+
const result = executeIndex(args, entries);
|
|
245
|
+
return { content: [{ type: "text", text: result }] };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/tools/read-tool.ts
|
|
250
|
+
import { resolve, relative as relative2 } from "path";
|
|
251
|
+
import { z as z3 } from "zod";
|
|
252
|
+
async function executeRead(notePath, vaultPath) {
|
|
253
|
+
if (notePath.startsWith("/")) {
|
|
254
|
+
return { ok: false, error: "Absolute paths not allowed. Use paths relative to vault root." };
|
|
255
|
+
}
|
|
256
|
+
const resolved = resolve(vaultPath, notePath);
|
|
257
|
+
const rel = relative2(vaultPath, resolved);
|
|
258
|
+
if (rel.startsWith("..")) {
|
|
259
|
+
return { ok: false, error: "Path resolves outside vault. Use paths relative to vault root." };
|
|
260
|
+
}
|
|
261
|
+
const file = Bun.file(resolved);
|
|
262
|
+
if (!await file.exists()) {
|
|
263
|
+
return { ok: false, error: `Note not found: ${notePath}. Run the 'index' tool to see available notes.` };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, content: await file.text() };
|
|
266
|
+
}
|
|
267
|
+
function registerReadTool(server, vaultPath) {
|
|
268
|
+
server.registerTool("read", {
|
|
269
|
+
description: "[Deprecated \u2014 prefer 'research' tool which returns full note content in one call] Fetch full content of a vault note by its relative path",
|
|
270
|
+
inputSchema: z3.object({
|
|
271
|
+
path: z3.string().describe("Relative path within vault (e.g. gotchas/bevy-system-ordering.md)")
|
|
272
|
+
})
|
|
273
|
+
}, async ({ path }) => {
|
|
274
|
+
const result = await executeRead(path, vaultPath);
|
|
275
|
+
if (result.ok) {
|
|
276
|
+
return { content: [{ type: "text", text: result.content }] };
|
|
277
|
+
}
|
|
278
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/tools/search-tool.ts
|
|
283
|
+
import { z as z4 } from "zod";
|
|
284
|
+
function scoreEntry(entry, keywords) {
|
|
285
|
+
let score = 0;
|
|
286
|
+
const titleLower = entry.title.toLowerCase();
|
|
287
|
+
const tagsLower = entry.frontmatter.tags.map((t) => t.toLowerCase());
|
|
288
|
+
const bodyLower = entry.body.toLowerCase();
|
|
289
|
+
for (const kw of keywords) {
|
|
290
|
+
const kwLower = kw.toLowerCase();
|
|
291
|
+
if (titleLower.includes(kwLower))
|
|
292
|
+
score += 10;
|
|
293
|
+
if (tagsLower.some((t) => t.includes(kwLower)))
|
|
294
|
+
score += 5;
|
|
295
|
+
if (bodyLower.includes(kwLower))
|
|
296
|
+
score += 1;
|
|
297
|
+
}
|
|
298
|
+
return score;
|
|
299
|
+
}
|
|
300
|
+
function executeSearch(args, entries) {
|
|
301
|
+
let filtered = entries.slice();
|
|
302
|
+
if (args.types?.length) {
|
|
303
|
+
filtered = filtered.filter((e) => args.types.includes(e.frontmatter.type));
|
|
304
|
+
}
|
|
305
|
+
if (args.tags?.length) {
|
|
306
|
+
filtered = filtered.filter((e) => e.frontmatter.tags.some((t) => args.tags.includes(t)));
|
|
307
|
+
}
|
|
308
|
+
const keywords = args.query.split(/\s+/).filter(Boolean);
|
|
309
|
+
if (keywords.length === 0)
|
|
310
|
+
return [];
|
|
311
|
+
const scored = filtered.map((entry) => ({
|
|
312
|
+
icon: NOTE_TYPE_ICONS[entry.frontmatter.type],
|
|
313
|
+
title: entry.title,
|
|
314
|
+
type: entry.frontmatter.type,
|
|
315
|
+
relativePath: entry.relativePath,
|
|
316
|
+
tokenDisplay: formatTokenCount(entry.tokenCount),
|
|
317
|
+
score: scoreEntry(entry, keywords)
|
|
318
|
+
})).filter((r) => r.score > 0).sort((a, b) => b.score - a.score);
|
|
319
|
+
const limit = args.limit ?? 10;
|
|
320
|
+
return scored.slice(0, limit);
|
|
321
|
+
}
|
|
322
|
+
function registerSearchTool(server, entries) {
|
|
323
|
+
server.registerTool("search", {
|
|
324
|
+
description: "[Deprecated \u2014 prefer 'research' tool which returns full note content in one call] Keyword search across vault notes. Returns index entries only.",
|
|
325
|
+
inputSchema: z4.object({
|
|
326
|
+
query: z4.string().describe("Space-separated keywords to search for"),
|
|
327
|
+
types: z4.array(NoteType).optional().describe("Filter to these note types"),
|
|
328
|
+
tags: z4.array(z4.string()).optional().describe("Filter to notes with these tags"),
|
|
329
|
+
limit: z4.number().optional().describe("Max results (default 10)")
|
|
330
|
+
})
|
|
331
|
+
}, async (args) => {
|
|
332
|
+
const results = executeSearch(args, entries);
|
|
333
|
+
if (results.length === 0) {
|
|
334
|
+
return { content: [{ type: "text", text: "No notes match that query." }] };
|
|
335
|
+
}
|
|
336
|
+
const table = [
|
|
337
|
+
"| T | Title | Path | ~Tok | Score |",
|
|
338
|
+
"|---|-------|------|------|-------|",
|
|
339
|
+
...results.map((r) => `| ${r.icon} | ${r.title} | ${r.relativePath} | ${r.tokenDisplay} | ${r.score} |`)
|
|
340
|
+
].join(`
|
|
341
|
+
`);
|
|
342
|
+
return { content: [{ type: "text", text: table }] };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/tools/sync-tool.ts
|
|
347
|
+
import { z as z5 } from "zod";
|
|
348
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
349
|
+
import { homedir } from "os";
|
|
350
|
+
|
|
351
|
+
// src/vault/config.ts
|
|
352
|
+
import { parse, stringify } from "smol-toml";
|
|
353
|
+
import { join as join2, basename as basename2, extname } from "path";
|
|
354
|
+
var {Glob } = globalThis.Bun;
|
|
355
|
+
var EXT_TO_TAGS = {
|
|
356
|
+
rs: ["rust"],
|
|
357
|
+
ts: ["typescript"],
|
|
358
|
+
tsx: ["typescript", "react"],
|
|
359
|
+
js: ["javascript"],
|
|
360
|
+
jsx: ["javascript", "react"],
|
|
361
|
+
vue: ["vue"],
|
|
362
|
+
py: ["python"],
|
|
363
|
+
go: ["go"],
|
|
364
|
+
rb: ["ruby"],
|
|
365
|
+
java: ["java"],
|
|
366
|
+
kt: ["kotlin"],
|
|
367
|
+
scala: ["scala"],
|
|
368
|
+
cs: ["csharp"],
|
|
369
|
+
cpp: ["cpp"],
|
|
370
|
+
cc: ["cpp"],
|
|
371
|
+
cxx: ["cpp"],
|
|
372
|
+
c: ["c"],
|
|
373
|
+
h: ["c"],
|
|
374
|
+
swift: ["swift"],
|
|
375
|
+
dart: ["dart", "flutter"],
|
|
376
|
+
ex: ["elixir"],
|
|
377
|
+
exs: ["elixir"],
|
|
378
|
+
zig: ["zig"],
|
|
379
|
+
hs: ["haskell"],
|
|
380
|
+
php: ["php"],
|
|
381
|
+
lua: ["lua"]
|
|
382
|
+
};
|
|
383
|
+
var IGNORE_DIRS = ["node_modules", ".*", "target", "dist", "build"];
|
|
384
|
+
async function loadProjectConfig(dir) {
|
|
385
|
+
const configPath = join2(dir, ".context.toml");
|
|
386
|
+
const file = Bun.file(configPath);
|
|
387
|
+
if (!await file.exists())
|
|
388
|
+
return null;
|
|
389
|
+
try {
|
|
390
|
+
const raw = await file.text();
|
|
391
|
+
const parsed = parse(raw);
|
|
392
|
+
const result = ProjectConfigSchema.safeParse(parsed);
|
|
393
|
+
if (!result.success) {
|
|
394
|
+
console.error(`Warning: Invalid .context.toml in ${dir}`);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return result.data;
|
|
398
|
+
} catch {
|
|
399
|
+
console.error(`Warning: Failed to parse .context.toml in ${dir}`);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function collectVaultTags(entries) {
|
|
404
|
+
const tags = new Set;
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
for (const tag of entry.frontmatter.tags) {
|
|
407
|
+
tags.add(tag);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return tags;
|
|
411
|
+
}
|
|
412
|
+
async function detectTagsFromExtensions(dir, vaultTags) {
|
|
413
|
+
const glob = new Glob(`**/*.*`);
|
|
414
|
+
const extensions = new Set;
|
|
415
|
+
for await (const path of glob.scan({ cwd: dir, dot: false, followSymlinks: false })) {
|
|
416
|
+
const skip = IGNORE_DIRS.some((d) => d.startsWith(".") ? path.startsWith(".") : path.startsWith(d + "/"));
|
|
417
|
+
if (skip)
|
|
418
|
+
continue;
|
|
419
|
+
const ext = extname(path).slice(1).toLowerCase();
|
|
420
|
+
if (ext)
|
|
421
|
+
extensions.add(ext);
|
|
422
|
+
}
|
|
423
|
+
const candidates = new Set;
|
|
424
|
+
for (const ext of extensions) {
|
|
425
|
+
const tags = EXT_TO_TAGS[ext];
|
|
426
|
+
if (tags) {
|
|
427
|
+
for (const tag of tags)
|
|
428
|
+
candidates.add(tag);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return [...candidates].filter((t) => vaultTags.has(t)).sort();
|
|
432
|
+
}
|
|
433
|
+
async function createDefaultConfig(dir, allEntries) {
|
|
434
|
+
const config = { project: { name: basename2(dir) } };
|
|
435
|
+
if (allEntries) {
|
|
436
|
+
const vaultTags = collectVaultTags(allEntries);
|
|
437
|
+
const inferred = await detectTagsFromExtensions(dir, vaultTags);
|
|
438
|
+
if (inferred.length > 0) {
|
|
439
|
+
config.filter = { tags: inferred };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const configPath = join2(dir, ".context.toml");
|
|
443
|
+
await Bun.write(configPath, stringify(config) + `
|
|
444
|
+
`);
|
|
445
|
+
return config;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/tools/sync-tool.ts
|
|
449
|
+
var TECH_TAGS = new Set([
|
|
450
|
+
"bash",
|
|
451
|
+
"c",
|
|
452
|
+
"clojure",
|
|
453
|
+
"cpp",
|
|
454
|
+
"csharp",
|
|
455
|
+
"css",
|
|
456
|
+
"dart",
|
|
457
|
+
"elixir",
|
|
458
|
+
"erlang",
|
|
459
|
+
"fsharp",
|
|
460
|
+
"go",
|
|
461
|
+
"haskell",
|
|
462
|
+
"html",
|
|
463
|
+
"java",
|
|
464
|
+
"javascript",
|
|
465
|
+
"kotlin",
|
|
466
|
+
"lua",
|
|
467
|
+
"ocaml",
|
|
468
|
+
"perl",
|
|
469
|
+
"php",
|
|
470
|
+
"python",
|
|
471
|
+
"ruby",
|
|
472
|
+
"rust",
|
|
473
|
+
"scala",
|
|
474
|
+
"sql",
|
|
475
|
+
"swift",
|
|
476
|
+
"typescript",
|
|
477
|
+
"zig",
|
|
478
|
+
"angular",
|
|
479
|
+
"astro",
|
|
480
|
+
"bevy",
|
|
481
|
+
"django",
|
|
482
|
+
"docker",
|
|
483
|
+
"electron",
|
|
484
|
+
"express",
|
|
485
|
+
"fastapi",
|
|
486
|
+
"fastify",
|
|
487
|
+
"flask",
|
|
488
|
+
"flutter",
|
|
489
|
+
"gatsby",
|
|
490
|
+
"gin",
|
|
491
|
+
"godot",
|
|
492
|
+
"htmx",
|
|
493
|
+
"kubernetes",
|
|
494
|
+
"laravel",
|
|
495
|
+
"nestjs",
|
|
496
|
+
"nextjs",
|
|
497
|
+
"nuxt",
|
|
498
|
+
"rails",
|
|
499
|
+
"react",
|
|
500
|
+
"react-native",
|
|
501
|
+
"remix",
|
|
502
|
+
"solid",
|
|
503
|
+
"spring",
|
|
504
|
+
"svelte",
|
|
505
|
+
"tailwind",
|
|
506
|
+
"tauri",
|
|
507
|
+
"unity",
|
|
508
|
+
"unreal",
|
|
509
|
+
"vue",
|
|
510
|
+
"bun",
|
|
511
|
+
"deno",
|
|
512
|
+
"node",
|
|
513
|
+
"nodejs"
|
|
514
|
+
]);
|
|
515
|
+
function hasTechTag(entry) {
|
|
516
|
+
return entry.frontmatter.tags.some((t) => TECH_TAGS.has(t));
|
|
517
|
+
}
|
|
518
|
+
function extractTableEntries(content) {
|
|
519
|
+
const sectionStart = content.indexOf("## Knowledge Index");
|
|
520
|
+
if (sectionStart === -1)
|
|
521
|
+
return null;
|
|
522
|
+
let sectionEnd = content.indexOf(`
|
|
523
|
+
## `, sectionStart + "## Knowledge Index".length);
|
|
524
|
+
const section = sectionEnd === -1 ? content.substring(sectionStart) : content.substring(sectionStart, sectionEnd);
|
|
525
|
+
const tableLines = section.split(`
|
|
526
|
+
`).filter((line) => line.startsWith("|"));
|
|
527
|
+
if (tableLines.length < 2)
|
|
528
|
+
return null;
|
|
529
|
+
return tableLines.slice(2).map((row) => row.split("|").slice(1, -1).map((c) => c.trim()).join("|"));
|
|
530
|
+
}
|
|
531
|
+
function buildEntryFingerprint(entries) {
|
|
532
|
+
return entries.map((e) => {
|
|
533
|
+
const idx = toIndexEntry(e);
|
|
534
|
+
return [idx.icon, idx.title, idx.relativePath, idx.tokenDisplay].join("|");
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function syncToFile(claudeMdPath, filtered) {
|
|
538
|
+
const totalTokens = filtered.reduce((sum, e) => sum + e.tokenCount, 0);
|
|
539
|
+
const file = Bun.file(claudeMdPath);
|
|
540
|
+
const existing = await file.exists() ? await file.text() : "";
|
|
541
|
+
if (existing) {
|
|
542
|
+
const sectionAtTop = existing.trimStart().startsWith("## Knowledge Index");
|
|
543
|
+
const existingEntries = extractTableEntries(existing);
|
|
544
|
+
if (sectionAtTop && existingEntries !== null && existingEntries.join(`
|
|
545
|
+
`) === buildEntryFingerprint(filtered).join(`
|
|
546
|
+
`)) {
|
|
547
|
+
return { entryCount: filtered.length, totalTokens, changed: false };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const updated = existing ? injectKnowledgeSection(existing, filtered) : formatKnowledgeSection(filtered) + `
|
|
551
|
+
`;
|
|
552
|
+
await Bun.write(claudeMdPath, updated);
|
|
553
|
+
return { entryCount: filtered.length, totalTokens, changed: true };
|
|
554
|
+
}
|
|
555
|
+
async function executeSync(targetDir, allEntries, vaultPath, options = {}) {
|
|
556
|
+
let config = await loadProjectConfig(targetDir);
|
|
557
|
+
let autoCreated = false;
|
|
558
|
+
const globalClaudeDir = options.globalClaudeDir ?? resolve2(homedir(), ".claude");
|
|
559
|
+
const isGlobalDir = resolve2(targetDir) === resolve2(globalClaudeDir);
|
|
560
|
+
if (!config && !isGlobalDir) {
|
|
561
|
+
config = await createDefaultConfig(targetDir, allEntries);
|
|
562
|
+
autoCreated = true;
|
|
563
|
+
}
|
|
564
|
+
const filterConfig = config?.filter ? { ...config, filter: { ...config.filter, tags: undefined } } : config;
|
|
565
|
+
let filtered = filterEntries(allEntries, filterConfig);
|
|
566
|
+
const projectName = config?.project?.name;
|
|
567
|
+
const filterTags = config?.filter?.tags;
|
|
568
|
+
if (projectName) {
|
|
569
|
+
filtered = filtered.filter((e) => e.frontmatter.projects.includes(projectName) || e.frontmatter.projects.length === 0 && filterTags?.length !== undefined && filterTags.length > 0 && e.frontmatter.tags.some((t) => filterTags.includes(t)));
|
|
570
|
+
} else {
|
|
571
|
+
filtered = filtered.filter((e) => e.frontmatter.projects.length === 0 && !hasTechTag(e));
|
|
572
|
+
}
|
|
573
|
+
const { entryCount, totalTokens, changed } = await syncToFile(join3(targetDir, "CLAUDE.md"), filtered);
|
|
574
|
+
let summary = changed ? `Synced ${entryCount} entries to CLAUDE.md (${formatTokenCount(totalTokens)} total index tokens)` : `CLAUDE.md already up to date (${entryCount} entries, ${formatTokenCount(totalTokens)} total index tokens)`;
|
|
575
|
+
if (autoCreated) {
|
|
576
|
+
const allTags = [...new Set(allEntries.flatMap((e) => e.frontmatter.tags))].sort();
|
|
577
|
+
const inferredTags = config?.filter?.tags;
|
|
578
|
+
if (inferredTags?.length) {
|
|
579
|
+
summary += `
|
|
580
|
+
Auto-created .context.toml with inferred tags: [${inferredTags.join(", ")}]`;
|
|
581
|
+
} else {
|
|
582
|
+
summary += `
|
|
583
|
+
Auto-created .context.toml (no tags inferred from file extensions)`;
|
|
584
|
+
}
|
|
585
|
+
if (allTags.length > 0) {
|
|
586
|
+
summary += `
|
|
587
|
+
Available vault tags: [${allTags.join(", ")}] \u2014 edit .context.toml filter.tags to refine`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!isGlobalDir) {
|
|
591
|
+
const globalEntries = allEntries.filter((e) => e.frontmatter.projects.length === 0 && !hasTechTag(e));
|
|
592
|
+
const globalResult = await syncToFile(join3(globalClaudeDir, "CLAUDE.md"), globalEntries);
|
|
593
|
+
summary += globalResult.changed ? `
|
|
594
|
+
Synced ${globalResult.entryCount} global entries to ~/.claude/CLAUDE.md (${formatTokenCount(globalResult.totalTokens)} total index tokens)` : `
|
|
595
|
+
~/.claude/CLAUDE.md already up to date (${globalResult.entryCount} entries, ${formatTokenCount(globalResult.totalTokens)} total index tokens)`;
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
entryCount,
|
|
599
|
+
totalTokens,
|
|
600
|
+
summary
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function registerSyncTool(server, entries, vaultPath) {
|
|
604
|
+
server.registerTool("sync", {
|
|
605
|
+
description: "Generate or update the Knowledge Index section in a project's CLAUDE.md",
|
|
606
|
+
inputSchema: z5.object({
|
|
607
|
+
targetDir: z5.string().optional().describe("Project directory (defaults to server CWD)")
|
|
608
|
+
})
|
|
609
|
+
}, async ({ targetDir }) => {
|
|
610
|
+
const dir = targetDir ?? process.cwd();
|
|
611
|
+
const result = await executeSync(dir, entries, vaultPath);
|
|
612
|
+
return { content: [{ type: "text", text: result.summary }] };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/tools/write-tool.ts
|
|
617
|
+
import { resolve as resolve3, relative as relative3, dirname } from "path";
|
|
618
|
+
import { mkdir } from "fs/promises";
|
|
619
|
+
import { z as z6 } from "zod";
|
|
620
|
+
|
|
621
|
+
// src/utils.ts
|
|
622
|
+
function formatDate(date) {
|
|
623
|
+
return date.toISOString().slice(0, 10);
|
|
624
|
+
}
|
|
625
|
+
var C = {
|
|
626
|
+
reset: "\x1B[0m",
|
|
627
|
+
bold: "\x1B[1m",
|
|
628
|
+
dim: "\x1B[2m",
|
|
629
|
+
green: "\x1B[32m",
|
|
630
|
+
yellow: "\x1B[33m",
|
|
631
|
+
red: "\x1B[31m",
|
|
632
|
+
cyan: "\x1B[36m"
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// src/tools/write-tool.ts
|
|
636
|
+
function buildFrontmatter(args) {
|
|
637
|
+
const lines = ["---", `type: ${args.type}`];
|
|
638
|
+
if (args.projects?.length) {
|
|
639
|
+
lines.push("projects:");
|
|
640
|
+
for (const p of args.projects)
|
|
641
|
+
lines.push(` - ${p}`);
|
|
642
|
+
}
|
|
643
|
+
if (args.tags?.length) {
|
|
644
|
+
lines.push("tags:");
|
|
645
|
+
for (const t of args.tags)
|
|
646
|
+
lines.push(` - ${t}`);
|
|
647
|
+
}
|
|
648
|
+
lines.push(`created: ${args.date}`, `updated: ${args.date}`, "---");
|
|
649
|
+
return lines.join(`
|
|
650
|
+
`);
|
|
651
|
+
}
|
|
652
|
+
async function executeWrite(args, entries, vaultPath) {
|
|
653
|
+
if (args.path.startsWith("/")) {
|
|
654
|
+
return { ok: false, error: "Absolute paths not allowed. Use paths relative to vault root." };
|
|
655
|
+
}
|
|
656
|
+
const resolved = resolve3(vaultPath, args.path);
|
|
657
|
+
const rel = relative3(vaultPath, resolved);
|
|
658
|
+
if (rel.startsWith("..")) {
|
|
659
|
+
return { ok: false, error: "Path resolves outside vault. Use paths relative to vault root." };
|
|
660
|
+
}
|
|
661
|
+
if (await Bun.file(resolved).exists()) {
|
|
662
|
+
return { ok: false, error: `File already exists: ${args.path}. Use a different path.` };
|
|
663
|
+
}
|
|
664
|
+
const today = formatDate(new Date);
|
|
665
|
+
const frontmatter = buildFrontmatter({
|
|
666
|
+
type: args.type,
|
|
667
|
+
tags: args.tags,
|
|
668
|
+
projects: args.projects,
|
|
669
|
+
date: today
|
|
670
|
+
});
|
|
671
|
+
const content = `${frontmatter}
|
|
672
|
+
|
|
673
|
+
# ${args.title}
|
|
674
|
+
|
|
675
|
+
${args.body}
|
|
676
|
+
`;
|
|
677
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
678
|
+
await Bun.write(resolved, content);
|
|
679
|
+
const entry = await parseNote(resolved, vaultPath);
|
|
680
|
+
if (entry) {
|
|
681
|
+
entries.push(entry);
|
|
682
|
+
entries.sort((a, b) => NOTE_TYPE_PRIORITY[a.frontmatter.type] - NOTE_TYPE_PRIORITY[b.frontmatter.type]);
|
|
683
|
+
}
|
|
684
|
+
return { ok: true, path: rel };
|
|
685
|
+
}
|
|
686
|
+
function registerWriteTool(server, entries, vaultPath) {
|
|
687
|
+
server.registerTool("write", {
|
|
688
|
+
description: "Create a new note in the vault with structured frontmatter. " + "Rejects writes to existing paths. " + "To write a web page to the vault, first use the fetch-page tool.",
|
|
689
|
+
inputSchema: z6.object({
|
|
690
|
+
path: z6.string().describe("Relative path within vault (e.g. gotchas/my-new-note.md)"),
|
|
691
|
+
type: NoteType.describe("Note type"),
|
|
692
|
+
title: z6.string().describe("Note title (becomes the H1 heading)"),
|
|
693
|
+
body: z6.string().describe("Markdown body content (after the H1)"),
|
|
694
|
+
tags: z6.array(z6.string()).optional().describe("Searchable tags"),
|
|
695
|
+
projects: z6.array(z6.string()).optional().describe("Project names this note relates to")
|
|
696
|
+
})
|
|
697
|
+
}, async (args) => {
|
|
698
|
+
const result = await executeWrite(args, entries, vaultPath);
|
|
699
|
+
if (result.ok) {
|
|
700
|
+
return { content: [{ type: "text", text: `Created note: ${result.path}` }] };
|
|
701
|
+
}
|
|
702
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/tools/fetch-page-tool.ts
|
|
707
|
+
import { join as join4 } from "path";
|
|
708
|
+
import { mkdtemp, writeFile } from "fs/promises";
|
|
709
|
+
import { tmpdir } from "os";
|
|
710
|
+
import { z as z7 } from "zod";
|
|
711
|
+
|
|
712
|
+
// src/web/fetcher.ts
|
|
713
|
+
import { Readability } from "@mozilla/readability";
|
|
714
|
+
import { parseHTML } from "linkedom";
|
|
715
|
+
import DOMPurify from "dompurify";
|
|
716
|
+
import TurndownService from "turndown";
|
|
717
|
+
function convertHTMLToMarkdown(html, url) {
|
|
718
|
+
const window = parseHTML(html);
|
|
719
|
+
if (!window.document?.documentElement) {
|
|
720
|
+
throw new Error(`Could not extract readable content from ${url}`);
|
|
721
|
+
}
|
|
722
|
+
const purify = DOMPurify(window);
|
|
723
|
+
const cleanHTML = purify.sanitize(window.document.documentElement.outerHTML, {
|
|
724
|
+
WHOLE_DOCUMENT: true
|
|
725
|
+
});
|
|
726
|
+
const cleanWindow = parseHTML(cleanHTML);
|
|
727
|
+
const reader = new Readability(cleanWindow.document, {
|
|
728
|
+
charThreshold: 0
|
|
729
|
+
});
|
|
730
|
+
const article = reader.parse();
|
|
731
|
+
if (!article?.content) {
|
|
732
|
+
throw new Error(`Could not extract readable content from ${url}`);
|
|
733
|
+
}
|
|
734
|
+
const turndown = new TurndownService({
|
|
735
|
+
headingStyle: "atx",
|
|
736
|
+
codeBlockStyle: "fenced"
|
|
737
|
+
});
|
|
738
|
+
const markdown = turndown.turndown(article.content);
|
|
739
|
+
return {
|
|
740
|
+
title: article.title ?? "",
|
|
741
|
+
markdown,
|
|
742
|
+
siteName: article.siteName ?? null,
|
|
743
|
+
excerpt: article.excerpt ?? null
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
async function fetchPageAsMarkdown(url) {
|
|
747
|
+
const response = await fetch(url, {
|
|
748
|
+
headers: {
|
|
749
|
+
"User-Agent": "claude-code-memory/1.0 (vault note fetcher)"
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
if (!response.ok) {
|
|
753
|
+
throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
|
|
754
|
+
}
|
|
755
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
756
|
+
if (!contentType.includes("text/html")) {
|
|
757
|
+
throw new Error(`Expected text/html but got ${contentType}`);
|
|
758
|
+
}
|
|
759
|
+
const html = await response.text();
|
|
760
|
+
return convertHTMLToMarkdown(html, url);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/tools/fetch-page-tool.ts
|
|
764
|
+
async function executeFetchPage(url, fetcher = fetchPageAsMarkdown) {
|
|
765
|
+
let page;
|
|
766
|
+
try {
|
|
767
|
+
page = await fetcher(url);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
770
|
+
return { ok: false, error: `Failed to fetch URL: ${msg}` };
|
|
771
|
+
}
|
|
772
|
+
const tempDir = await mkdtemp(join4(tmpdir(), "ccm-fetch-"));
|
|
773
|
+
const tempPath = join4(tempDir, "page.md");
|
|
774
|
+
await writeFile(tempPath, page.markdown);
|
|
775
|
+
return {
|
|
776
|
+
ok: true,
|
|
777
|
+
tempPath,
|
|
778
|
+
title: page.title,
|
|
779
|
+
excerpt: page.excerpt,
|
|
780
|
+
siteName: page.siteName
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function registerFetchPageTool(server) {
|
|
784
|
+
server.registerTool("fetch-page", {
|
|
785
|
+
description: "Fetch a web page and convert it to markdown. " + "Returns a temp file path containing the raw markdown. " + "After calling this tool, read the temp file, clean up the markdown, " + "pick appropriate title/tags/type, then call the write tool to save to the vault.",
|
|
786
|
+
inputSchema: z7.object({
|
|
787
|
+
url: z7.url().describe("URL of the web page to fetch")
|
|
788
|
+
})
|
|
789
|
+
}, async (args) => {
|
|
790
|
+
const result = await executeFetchPage(args.url);
|
|
791
|
+
if (result.ok) {
|
|
792
|
+
const parts = [
|
|
793
|
+
`Temp file: ${result.tempPath}`,
|
|
794
|
+
`Title: ${result.title}`
|
|
795
|
+
];
|
|
796
|
+
if (result.excerpt)
|
|
797
|
+
parts.push(`Excerpt: ${result.excerpt}`);
|
|
798
|
+
if (result.siteName)
|
|
799
|
+
parts.push(`Site: ${result.siteName}`);
|
|
800
|
+
return { content: [{ type: "text", text: parts.join(`
|
|
801
|
+
`) }] };
|
|
802
|
+
}
|
|
803
|
+
return { content: [{ type: "text", text: result.error }], isError: true };
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/tools/research-tool.ts
|
|
808
|
+
import { z as z8 } from "zod";
|
|
809
|
+
function buildResearchOutput(result) {
|
|
810
|
+
const lines = [];
|
|
811
|
+
const countLabel = result.truncated ? `${result.notes.length} notes (truncated to ~${result.maxTokenBudget} tokens)` : `${result.notes.length} notes`;
|
|
812
|
+
lines.push(`## Research Results (${countLabel})`, "", result.table);
|
|
813
|
+
for (const note of result.notes) {
|
|
814
|
+
lines.push("", "---", `### ${note.relativePath}`, "", note.content);
|
|
815
|
+
}
|
|
816
|
+
return lines.join(`
|
|
817
|
+
`);
|
|
818
|
+
}
|
|
819
|
+
async function executeResearch(args, entries, vaultPath) {
|
|
820
|
+
const searchResults = executeSearch({ query: args.query, types: args.types, tags: args.tags, limit: args.limit }, entries);
|
|
821
|
+
if (searchResults.length === 0) {
|
|
822
|
+
return { table: "", notes: [], totalTokens: 0, truncated: false };
|
|
823
|
+
}
|
|
824
|
+
const table = [
|
|
825
|
+
"| T | Title | Path | ~Tok | Score |",
|
|
826
|
+
"|---|-------|------|------|-------|",
|
|
827
|
+
...searchResults.map((r) => `| ${r.icon} | ${r.title} | ${r.relativePath} | ${r.tokenDisplay} | ${r.score} |`)
|
|
828
|
+
].join(`
|
|
829
|
+
`);
|
|
830
|
+
const notes = [];
|
|
831
|
+
let totalTokens = 0;
|
|
832
|
+
let truncated = false;
|
|
833
|
+
const maxTokens = args.maxTokens ?? Infinity;
|
|
834
|
+
for (const result of searchResults) {
|
|
835
|
+
const entry = entries.find((e) => e.relativePath === result.relativePath);
|
|
836
|
+
if (!entry)
|
|
837
|
+
continue;
|
|
838
|
+
if (totalTokens + entry.tokenCount > maxTokens && notes.length > 0) {
|
|
839
|
+
truncated = true;
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const readResult = await executeRead(result.relativePath, vaultPath);
|
|
843
|
+
if (!readResult.ok)
|
|
844
|
+
continue;
|
|
845
|
+
notes.push({ relativePath: result.relativePath, content: readResult.content });
|
|
846
|
+
totalTokens += entry.tokenCount;
|
|
847
|
+
}
|
|
848
|
+
return { table, notes, totalTokens, truncated, maxTokenBudget: args.maxTokens };
|
|
849
|
+
}
|
|
850
|
+
function registerResearchTool(server, entries, vaultPath) {
|
|
851
|
+
server.registerTool("research", {
|
|
852
|
+
description: "Batched search+read: finds matching notes and returns their full content in a single call. " + "Use this instead of search \u2192 read chains to reduce round-trips.",
|
|
853
|
+
inputSchema: z8.object({
|
|
854
|
+
query: z8.string().describe("Space-separated keywords to search for"),
|
|
855
|
+
types: z8.array(NoteType).optional().describe("Filter to these note types"),
|
|
856
|
+
tags: z8.array(z8.string()).optional().describe("Filter to notes with these tags"),
|
|
857
|
+
limit: z8.number().optional().describe("Max results (default 10)"),
|
|
858
|
+
maxTokens: z8.number().optional().describe("Token budget \u2014 stops including note bodies once exceeded")
|
|
859
|
+
})
|
|
860
|
+
}, async (args) => {
|
|
861
|
+
const result = await executeResearch(args, entries, vaultPath);
|
|
862
|
+
if (result.notes.length === 0) {
|
|
863
|
+
return { content: [{ type: "text", text: "No notes match that query." }] };
|
|
864
|
+
}
|
|
865
|
+
return { content: [{ type: "text", text: buildResearchOutput(result) }] };
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/cli/init.ts
|
|
870
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
871
|
+
import { join as join6 } from "path";
|
|
872
|
+
import { homedir as homedir3 } from "os";
|
|
873
|
+
|
|
874
|
+
// src/cli/seed.ts
|
|
875
|
+
var OFM_NOTE = (date) => `---
|
|
876
|
+
type: pattern
|
|
877
|
+
tags:
|
|
878
|
+
- obsidian
|
|
879
|
+
- markdown
|
|
880
|
+
- formatting
|
|
881
|
+
created: ${date}
|
|
882
|
+
updated: ${date}
|
|
883
|
+
---
|
|
884
|
+
|
|
885
|
+
# Obsidian Flavored Markdown Conventions
|
|
886
|
+
|
|
887
|
+
Follow these conventions when writing vault notes to ensure compatibility with Obsidian.
|
|
888
|
+
|
|
889
|
+
## Links
|
|
890
|
+
|
|
891
|
+
Use wikilinks, not markdown links:
|
|
892
|
+
|
|
893
|
+
\`\`\`markdown
|
|
894
|
+
[[Note Name]]
|
|
895
|
+
[[Note Name|Display Text]]
|
|
896
|
+
[[Note Name#Heading]]
|
|
897
|
+
[[Note Name#^block-id]]
|
|
898
|
+
\`\`\`
|
|
899
|
+
|
|
900
|
+
## Embeds
|
|
901
|
+
|
|
902
|
+
\`\`\`markdown
|
|
903
|
+
![[Note Name]]
|
|
904
|
+
![[Note Name#Heading]]
|
|
905
|
+
![[image.png|300]]
|
|
906
|
+
\`\`\`
|
|
907
|
+
|
|
908
|
+
## Callouts
|
|
909
|
+
|
|
910
|
+
Use callouts for important information:
|
|
911
|
+
|
|
912
|
+
\`\`\`markdown
|
|
913
|
+
> [!note]
|
|
914
|
+
> General information.
|
|
915
|
+
|
|
916
|
+
> [!warning] Watch Out
|
|
917
|
+
> Something to be careful about.
|
|
918
|
+
|
|
919
|
+
> [!tip]- Collapsed by default
|
|
920
|
+
> Hidden until expanded.
|
|
921
|
+
\`\`\`
|
|
922
|
+
|
|
923
|
+
Common types: \`note\`, \`tip\`, \`warning\`, \`danger\`, \`info\`, \`example\`, \`quote\`, \`bug\`, \`todo\`
|
|
924
|
+
|
|
925
|
+
## Highlights
|
|
926
|
+
|
|
927
|
+
Use \`==double equals==\` for emphasis instead of bold when marking key terms.
|
|
928
|
+
|
|
929
|
+
## Tags
|
|
930
|
+
|
|
931
|
+
Use \`#tag\` or \`#nested/tag\` inline. In frontmatter, use a YAML list under \`tags:\`.
|
|
932
|
+
|
|
933
|
+
## Block References
|
|
934
|
+
|
|
935
|
+
Add \`^block-id\` at the end of a paragraph to make it linkable:
|
|
936
|
+
|
|
937
|
+
\`\`\`markdown
|
|
938
|
+
This paragraph can be referenced elsewhere. ^my-block
|
|
939
|
+
|
|
940
|
+
Then link to it: [[Note Name#^my-block]]
|
|
941
|
+
\`\`\`
|
|
942
|
+
|
|
943
|
+
## Comments
|
|
944
|
+
|
|
945
|
+
Use \`%%\` for content hidden in reading view:
|
|
946
|
+
|
|
947
|
+
\`\`\`markdown
|
|
948
|
+
Visible text %%hidden comment%% more visible text.
|
|
949
|
+
\`\`\`
|
|
950
|
+
|
|
951
|
+
## Properties
|
|
952
|
+
|
|
953
|
+
Frontmatter uses YAML. Common fields:
|
|
954
|
+
|
|
955
|
+
\`\`\`yaml
|
|
956
|
+
---
|
|
957
|
+
title: Note Title
|
|
958
|
+
tags:
|
|
959
|
+
- tag1
|
|
960
|
+
- tag2
|
|
961
|
+
aliases:
|
|
962
|
+
- Alternative Name
|
|
963
|
+
---
|
|
964
|
+
\`\`\`
|
|
965
|
+
`;
|
|
966
|
+
var NOTE_TEMPLATE = `---
|
|
967
|
+
type:
|
|
968
|
+
tags:
|
|
969
|
+
-
|
|
970
|
+
projects:
|
|
971
|
+
-
|
|
972
|
+
created:
|
|
973
|
+
updated:
|
|
974
|
+
---
|
|
975
|
+
|
|
976
|
+
# Title
|
|
977
|
+
|
|
978
|
+
Body content here.
|
|
979
|
+
`;
|
|
980
|
+
function buildSeedNotes(dateStr) {
|
|
981
|
+
return [
|
|
982
|
+
{ relativePath: "patterns/obsidian-flavored-markdown.md", content: OFM_NOTE(dateStr) },
|
|
983
|
+
{ relativePath: "_templates/note.md", content: NOTE_TEMPLATE }
|
|
984
|
+
];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/cli/obsidian.ts
|
|
988
|
+
import { readFile, writeFile as writeFile2, copyFile } from "fs/promises";
|
|
989
|
+
import { join as join5 } from "path";
|
|
990
|
+
import { randomBytes } from "crypto";
|
|
991
|
+
import { homedir as homedir2 } from "os";
|
|
992
|
+
var DEFAULT_CONFIG_PATH = join5(homedir2(), ".config/obsidian/obsidian.json");
|
|
993
|
+
function generateVaultId() {
|
|
994
|
+
return randomBytes(8).toString("hex");
|
|
995
|
+
}
|
|
996
|
+
async function loadObsidianConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
997
|
+
try {
|
|
998
|
+
const raw = await readFile(configPath, "utf-8");
|
|
999
|
+
return JSON.parse(raw);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function findVaultByPath(config, vaultPath) {
|
|
1005
|
+
for (const [id, vault] of Object.entries(config.vaults)) {
|
|
1006
|
+
if (vault.path === vaultPath)
|
|
1007
|
+
return id;
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
async function registerVaultWithObsidian(vaultPath, configPath = DEFAULT_CONFIG_PATH) {
|
|
1012
|
+
const config = await loadObsidianConfig(configPath);
|
|
1013
|
+
if (!config) {
|
|
1014
|
+
return { skipped: true, reason: "Obsidian not installed (config not found)" };
|
|
1015
|
+
}
|
|
1016
|
+
const existingId = findVaultByPath(config, vaultPath);
|
|
1017
|
+
if (existingId) {
|
|
1018
|
+
return { skipped: true, reason: "Vault already registered", vaultId: existingId };
|
|
1019
|
+
}
|
|
1020
|
+
await copyFile(configPath, `${configPath}.backup`);
|
|
1021
|
+
const vaultId = generateVaultId();
|
|
1022
|
+
config.vaults[vaultId] = {
|
|
1023
|
+
path: vaultPath,
|
|
1024
|
+
ts: Date.now(),
|
|
1025
|
+
open: true
|
|
1026
|
+
};
|
|
1027
|
+
await writeFile2(configPath, JSON.stringify(config, null, 2));
|
|
1028
|
+
return { registered: true, vaultId };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// src/cli/claude-code.ts
|
|
1032
|
+
import { spawn } from "child_process";
|
|
1033
|
+
import { resolve as resolvePath } from "path";
|
|
1034
|
+
function buildServerCommand() {
|
|
1035
|
+
const runtime = resolvePath(process.argv[0]);
|
|
1036
|
+
const script = resolvePath(process.argv[1]);
|
|
1037
|
+
return [runtime, script, "--stdio"];
|
|
1038
|
+
}
|
|
1039
|
+
function buildRegisterArgs(serverCmd) {
|
|
1040
|
+
return [
|
|
1041
|
+
"mcp",
|
|
1042
|
+
"add",
|
|
1043
|
+
"--transport",
|
|
1044
|
+
"stdio",
|
|
1045
|
+
"--scope",
|
|
1046
|
+
"user",
|
|
1047
|
+
"ccm",
|
|
1048
|
+
"--",
|
|
1049
|
+
...serverCmd
|
|
1050
|
+
];
|
|
1051
|
+
}
|
|
1052
|
+
function formatManualCommand(serverCmd) {
|
|
1053
|
+
return `claude mcp add --transport stdio --scope user ccm -- ${serverCmd.join(" ")}`;
|
|
1054
|
+
}
|
|
1055
|
+
function registerMcpServer() {
|
|
1056
|
+
const serverCmd = buildServerCommand();
|
|
1057
|
+
const registerArgs = buildRegisterArgs(serverCmd);
|
|
1058
|
+
const manualCommand = formatManualCommand(serverCmd);
|
|
1059
|
+
return new Promise((resolve4) => {
|
|
1060
|
+
let stdout = "";
|
|
1061
|
+
let stderr = "";
|
|
1062
|
+
const proc = spawn("claude", registerArgs, { stdio: "pipe" });
|
|
1063
|
+
proc.stdout.on("data", (data) => {
|
|
1064
|
+
stdout += data.toString();
|
|
1065
|
+
});
|
|
1066
|
+
proc.stderr.on("data", (data) => {
|
|
1067
|
+
stderr += data.toString();
|
|
1068
|
+
});
|
|
1069
|
+
proc.on("error", (err) => {
|
|
1070
|
+
if (err.code === "ENOENT") {
|
|
1071
|
+
resolve4({
|
|
1072
|
+
success: false,
|
|
1073
|
+
error: "Claude CLI not found in PATH",
|
|
1074
|
+
manualCommand
|
|
1075
|
+
});
|
|
1076
|
+
} else {
|
|
1077
|
+
resolve4({
|
|
1078
|
+
success: false,
|
|
1079
|
+
error: err.message,
|
|
1080
|
+
manualCommand
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
proc.on("close", (code) => {
|
|
1085
|
+
if (code === 0) {
|
|
1086
|
+
resolve4({ success: true, output: (stdout || stderr).trim() });
|
|
1087
|
+
} else {
|
|
1088
|
+
resolve4({
|
|
1089
|
+
success: false,
|
|
1090
|
+
error: (stderr || stdout).trim() || `Process exited with code ${code}`,
|
|
1091
|
+
manualCommand
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/cli/init.ts
|
|
1099
|
+
var VAULT_PATH = join6(homedir3(), ".ccm", "knowledge-base");
|
|
1100
|
+
var SUBDIRS = ["gotchas", "decisions", "patterns", "references", "_templates"];
|
|
1101
|
+
async function executeInit() {
|
|
1102
|
+
const steps = [];
|
|
1103
|
+
await mkdir2(VAULT_PATH, { recursive: true });
|
|
1104
|
+
steps.push({ name: "vault directory", status: "created", detail: VAULT_PATH });
|
|
1105
|
+
for (const sub of SUBDIRS) {
|
|
1106
|
+
await mkdir2(join6(VAULT_PATH, sub), { recursive: true });
|
|
1107
|
+
}
|
|
1108
|
+
steps.push({ name: "subdirectories", status: "created", detail: SUBDIRS.join(", ") });
|
|
1109
|
+
const dotObsidian = join6(VAULT_PATH, ".obsidian");
|
|
1110
|
+
await mkdir2(dotObsidian, { recursive: true });
|
|
1111
|
+
const dateStr = formatDate(new Date);
|
|
1112
|
+
const seedNotes = buildSeedNotes(dateStr);
|
|
1113
|
+
for (const note of seedNotes) {
|
|
1114
|
+
const fullPath = join6(VAULT_PATH, note.relativePath);
|
|
1115
|
+
const file = Bun.file(fullPath);
|
|
1116
|
+
if (await file.exists()) {
|
|
1117
|
+
steps.push({ name: note.relativePath, status: "skipped", detail: "already exists" });
|
|
1118
|
+
} else {
|
|
1119
|
+
await mkdir2(join6(VAULT_PATH, note.relativePath, ".."), { recursive: true });
|
|
1120
|
+
await Bun.write(fullPath, note.content);
|
|
1121
|
+
steps.push({ name: note.relativePath, status: "created", detail: "" });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const obsidianResult = await registerVaultWithObsidian(VAULT_PATH);
|
|
1125
|
+
if ("registered" in obsidianResult) {
|
|
1126
|
+
steps.push({
|
|
1127
|
+
name: "Obsidian registration",
|
|
1128
|
+
status: "created",
|
|
1129
|
+
detail: `vault ID: ${obsidianResult.vaultId}`
|
|
1130
|
+
});
|
|
1131
|
+
} else {
|
|
1132
|
+
steps.push({
|
|
1133
|
+
name: "Obsidian registration",
|
|
1134
|
+
status: "skipped",
|
|
1135
|
+
detail: obsidianResult.reason
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
const mcpResult = await registerMcpServer();
|
|
1139
|
+
if (mcpResult.success) {
|
|
1140
|
+
steps.push({ name: "Claude Code MCP", status: "created", detail: mcpResult.output });
|
|
1141
|
+
} else {
|
|
1142
|
+
steps.push({
|
|
1143
|
+
name: "Claude Code MCP",
|
|
1144
|
+
status: "failed",
|
|
1145
|
+
detail: `${mcpResult.error}
|
|
1146
|
+
Run manually: ${mcpResult.manualCommand}`
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
return { vaultPath: VAULT_PATH, steps };
|
|
1150
|
+
}
|
|
1151
|
+
function formatStep(step) {
|
|
1152
|
+
const icons = {
|
|
1153
|
+
created: `${C.green}+${C.reset}`,
|
|
1154
|
+
skipped: `${C.yellow}-${C.reset}`,
|
|
1155
|
+
failed: `${C.red}!${C.reset}`
|
|
1156
|
+
};
|
|
1157
|
+
const detail = step.detail ? ` ${C.dim}(${step.detail})${C.reset}` : "";
|
|
1158
|
+
return ` ${icons[step.status]} ${step.name}${detail}`;
|
|
1159
|
+
}
|
|
1160
|
+
function formatInitSummary(result) {
|
|
1161
|
+
const lines = [
|
|
1162
|
+
`${C.bold}ccm init${C.reset}`,
|
|
1163
|
+
`${C.dim}vault:${C.reset} ${result.vaultPath}`,
|
|
1164
|
+
"",
|
|
1165
|
+
...result.steps.map(formatStep)
|
|
1166
|
+
];
|
|
1167
|
+
const created = result.steps.filter((s) => s.status === "created").length;
|
|
1168
|
+
const skipped = result.steps.filter((s) => s.status === "skipped").length;
|
|
1169
|
+
const failed = result.steps.filter((s) => s.status === "failed").length;
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
if (failed > 0) {
|
|
1172
|
+
lines.push(`${C.red}${created} created, ${skipped} skipped, ${failed} failed${C.reset}`);
|
|
1173
|
+
} else {
|
|
1174
|
+
lines.push(`${C.green}${created} created${C.reset}, ${skipped} skipped`);
|
|
1175
|
+
}
|
|
1176
|
+
return lines.join(`
|
|
1177
|
+
`);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/index.ts
|
|
1181
|
+
var VAULT_PATH2 = join7(homedir4(), ".ccm", "knowledge-base");
|
|
1182
|
+
function parseCliArgs() {
|
|
1183
|
+
const args = process.argv.slice(2);
|
|
1184
|
+
if (args.includes("--init"))
|
|
1185
|
+
return "init";
|
|
1186
|
+
if (args.includes("--stdio"))
|
|
1187
|
+
return "serve";
|
|
1188
|
+
return "help";
|
|
1189
|
+
}
|
|
1190
|
+
function printHelp() {
|
|
1191
|
+
console.log(`${C.bold}ccm${C.reset} ${C.dim}\u2014 persistent memory for Claude Code${C.reset}
|
|
1192
|
+
|
|
1193
|
+
${C.bold}Usage:${C.reset}
|
|
1194
|
+
${C.cyan}ccm --init${C.reset} Scaffold vault and register MCP server
|
|
1195
|
+
|
|
1196
|
+
${C.dim}Vault:${C.reset} ~/.ccm/knowledge-base/
|
|
1197
|
+
${C.dim}Docs:${C.reset} https://github.com/bennys001/claude-code-memory`);
|
|
1198
|
+
}
|
|
1199
|
+
async function runInit() {
|
|
1200
|
+
const result = await executeInit();
|
|
1201
|
+
console.log(formatInitSummary(result));
|
|
1202
|
+
const failed = result.steps.filter((s) => s.status === "failed").length;
|
|
1203
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1204
|
+
}
|
|
1205
|
+
async function runServer() {
|
|
1206
|
+
const entries = await loadVault(VAULT_PATH2);
|
|
1207
|
+
console.error(`Loaded ${entries.length} notes from ${VAULT_PATH2}`);
|
|
1208
|
+
const server = new McpServer({
|
|
1209
|
+
name: "ccm",
|
|
1210
|
+
version: package_default.version
|
|
1211
|
+
});
|
|
1212
|
+
registerIndexTool(server, entries);
|
|
1213
|
+
registerReadTool(server, VAULT_PATH2);
|
|
1214
|
+
registerSearchTool(server, entries);
|
|
1215
|
+
registerSyncTool(server, entries, VAULT_PATH2);
|
|
1216
|
+
registerWriteTool(server, entries, VAULT_PATH2);
|
|
1217
|
+
registerFetchPageTool(server);
|
|
1218
|
+
registerResearchTool(server, entries, VAULT_PATH2);
|
|
1219
|
+
const transport = new StdioServerTransport;
|
|
1220
|
+
await server.connect(transport);
|
|
1221
|
+
const shutdown = async () => {
|
|
1222
|
+
await server.close();
|
|
1223
|
+
process.exit(0);
|
|
1224
|
+
};
|
|
1225
|
+
process.on("SIGINT", shutdown);
|
|
1226
|
+
process.on("SIGTERM", shutdown);
|
|
1227
|
+
}
|
|
1228
|
+
var cli = parseCliArgs();
|
|
1229
|
+
if (cli === "help") {
|
|
1230
|
+
printHelp();
|
|
1231
|
+
process.exit(0);
|
|
1232
|
+
} else if (cli === "init") {
|
|
1233
|
+
runInit().catch((err) => {
|
|
1234
|
+
console.error("Fatal:", err);
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
});
|
|
1237
|
+
} else {
|
|
1238
|
+
runServer().catch((err) => {
|
|
1239
|
+
console.error("Fatal:", err);
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
});
|
|
1242
|
+
}
|