@alexzeitler/session-md 0.5.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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * session-md MCP Server
3
+ *
4
+ * Exposes session search, listing, retrieval, and import as MCP tools.
5
+ * Shared by both stdio and HTTP transports.
6
+ */
7
+
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { z } from "zod";
10
+ import { SearchIndex } from "../search/index.ts";
11
+ import { loadSessionMarkdownSync } from "../import/loader.ts";
12
+ import { scanClaudeCodeSessions } from "../import/claude-code-to-md.ts";
13
+ import { scanOpencodeSessions } from "../import/opencode-to-md.ts";
14
+ import { scanMemorizerMemories } from "../import/memorizer-to-md.ts";
15
+ import { importClaudeExport } from "../import/claude-export-to-md.ts";
16
+ import { loadConfig, type Config } from "../config.ts";
17
+ import type { SessionEntry, SourceType } from "../import/types.ts";
18
+
19
+ const { version } = require("../../package.json");
20
+
21
+ export type SessionStore = {
22
+ config: Config;
23
+ searchIndex: SearchIndex;
24
+ sessions: SessionEntry[];
25
+ };
26
+
27
+ /**
28
+ * Scan all configured sources and build/update the search index.
29
+ * Returns the populated SessionStore.
30
+ */
31
+ export async function createSessionStore(): Promise<SessionStore> {
32
+ const config = await loadConfig();
33
+ const sessions: SessionEntry[] = [];
34
+
35
+ if (config.sources.claude_code) {
36
+ try {
37
+ sessions.push(...scanClaudeCodeSessions(config.sources.claude_code));
38
+ } catch {
39
+ // skip
40
+ }
41
+ }
42
+
43
+ if (config.sources.opencode) {
44
+ try {
45
+ sessions.push(...scanOpencodeSessions(config.sources.opencode));
46
+ } catch {
47
+ // skip
48
+ }
49
+ }
50
+
51
+ const asyncLoaders: Promise<SessionEntry[]>[] = [];
52
+
53
+ if (config.sources.memorizer_url) {
54
+ asyncLoaders.push(
55
+ scanMemorizerMemories(config.sources.memorizer_url).catch(() => []),
56
+ );
57
+ }
58
+
59
+ if (config.sources.claude_export) {
60
+ asyncLoaders.push(
61
+ importClaudeExport(config.sources.claude_export).catch(() => []),
62
+ );
63
+ }
64
+
65
+ if (asyncLoaders.length > 0) {
66
+ const results = await Promise.all(asyncLoaders);
67
+ for (const entries of results) {
68
+ sessions.push(...entries);
69
+ }
70
+ }
71
+
72
+ sessions.sort(
73
+ (a, b) =>
74
+ new Date(b.meta.created_at).getTime() -
75
+ new Date(a.meta.created_at).getTime(),
76
+ );
77
+
78
+ const searchIndex = new SearchIndex();
79
+ searchIndex.indexSessions(sessions);
80
+
81
+ const validIds = new Set(sessions.map((s) => s.meta.id));
82
+ searchIndex.cleanup(validIds);
83
+
84
+ return { config, searchIndex, sessions };
85
+ }
86
+
87
+ /**
88
+ * Create an MCP server with all session-md tools registered.
89
+ */
90
+ export async function createMcpServer(store: SessionStore): Promise<McpServer> {
91
+ const server = new McpServer(
92
+ { name: "session-md", version },
93
+ {
94
+ instructions: [
95
+ `session-md provides access to ${store.sessions.length} AI chat sessions`,
96
+ `from Claude Code, Claude.ai exports, OpenCode, and Memorizer.`,
97
+ "",
98
+ "Tools:",
99
+ " - search_sessions: Full-text search across all sessions",
100
+ " - list_sessions: List sessions, optionally filtered by source",
101
+ " - get_session: Retrieve full markdown content of a session by ID",
102
+ " - import_sessions: Re-scan all configured sources and update the index",
103
+ ].join("\n"),
104
+ },
105
+ );
106
+
107
+ // --- search_sessions ---
108
+ server.registerTool(
109
+ "search_sessions",
110
+ {
111
+ title: "Search sessions",
112
+ description:
113
+ "Full-text search across all indexed AI chat sessions. Returns matching sessions with snippets.",
114
+ annotations: { readOnlyHint: true, openWorldHint: false },
115
+ inputSchema: {
116
+ query: z.string().describe("Search query (keywords, AND logic)"),
117
+ source: z
118
+ .enum(["claude-code", "claude-export", "opencode", "memorizer", "all"])
119
+ .optional()
120
+ .describe("Filter by source type (default: all)"),
121
+ limit: z
122
+ .number()
123
+ .optional()
124
+ .describe("Max results to return (default: 20)"),
125
+ },
126
+ },
127
+ async ({ query, source, limit }) => {
128
+ const results = store.searchIndex.search(
129
+ query,
130
+ source ?? "all",
131
+ limit ?? 20,
132
+ );
133
+
134
+ if (results.length === 0) {
135
+ return {
136
+ content: [{ type: "text", text: `No results found for "${query}"` }],
137
+ };
138
+ }
139
+
140
+ const lines = [
141
+ `Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":\n`,
142
+ ];
143
+ for (const r of results) {
144
+ lines.push(`- **${r.title}** [${r.source}] (id: ${r.id})`);
145
+ if (r.project) lines.push(` Project: ${r.project}`);
146
+ if (r.snippet) lines.push(` ${r.snippet}`);
147
+ lines.push("");
148
+ }
149
+
150
+ return { content: [{ type: "text", text: lines.join("\n") }] };
151
+ },
152
+ );
153
+
154
+ // --- list_sessions ---
155
+ server.registerTool(
156
+ "list_sessions",
157
+ {
158
+ title: "List sessions",
159
+ description:
160
+ "List all indexed AI chat sessions, optionally filtered by source. Returns metadata (no content).",
161
+ annotations: { readOnlyHint: true, openWorldHint: false },
162
+ inputSchema: {
163
+ source: z
164
+ .enum(["claude-code", "claude-export", "opencode", "memorizer", "all"])
165
+ .optional()
166
+ .describe("Filter by source type (default: all)"),
167
+ limit: z
168
+ .number()
169
+ .optional()
170
+ .describe("Max results to return (default: 50)"),
171
+ offset: z
172
+ .number()
173
+ .optional()
174
+ .describe("Skip first N results (default: 0)"),
175
+ },
176
+ },
177
+ async ({ source, limit, offset }) => {
178
+ let filtered = store.sessions;
179
+ if (source && source !== "all") {
180
+ filtered = filtered.filter((s) => s.meta.source === source);
181
+ }
182
+
183
+ const start = offset ?? 0;
184
+ const end = start + (limit ?? 50);
185
+ const page = filtered.slice(start, end);
186
+
187
+ const lines = [
188
+ `${filtered.length} sessions total${source && source !== "all" ? ` (source: ${source})` : ""}, showing ${start + 1}-${Math.min(end, filtered.length)}:\n`,
189
+ ];
190
+
191
+ for (const s of page) {
192
+ lines.push(
193
+ `- **${s.meta.title}** [${s.meta.source}] ${s.meta.created_at} (id: ${s.meta.id})`,
194
+ );
195
+ }
196
+
197
+ return { content: [{ type: "text", text: lines.join("\n") }] };
198
+ },
199
+ );
200
+
201
+ // --- get_session ---
202
+ server.registerTool(
203
+ "get_session",
204
+ {
205
+ title: "Get session content",
206
+ description:
207
+ "Retrieve the full markdown content of a session by its ID.",
208
+ annotations: { readOnlyHint: true, openWorldHint: false },
209
+ inputSchema: {
210
+ id: z.string().describe("Session ID (UUID or short ID from search results)"),
211
+ },
212
+ },
213
+ async ({ id }) => {
214
+ const entry = store.sessions.find((s) => s.meta.id === id);
215
+ if (!entry) {
216
+ return {
217
+ content: [{ type: "text", text: `Session not found: ${id}` }],
218
+ isError: true,
219
+ };
220
+ }
221
+
222
+ try {
223
+ const md = loadSessionMarkdownSync(entry);
224
+ return { content: [{ type: "text", text: md }] };
225
+ } catch (err) {
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: `Failed to load session ${id}: ${err}`,
231
+ },
232
+ ],
233
+ isError: true,
234
+ };
235
+ }
236
+ },
237
+ );
238
+
239
+ // --- import_sessions ---
240
+ server.registerTool(
241
+ "import_sessions",
242
+ {
243
+ title: "Import sessions",
244
+ description:
245
+ "Re-scan all configured sources and update the search index. Returns import statistics.",
246
+ annotations: { readOnlyHint: false, openWorldHint: false },
247
+ inputSchema: {},
248
+ },
249
+ async () => {
250
+ const oldCount = store.sessions.length;
251
+
252
+ // Re-scan all sources
253
+ const newSessions: SessionEntry[] = [];
254
+
255
+ if (store.config.sources.claude_code) {
256
+ try {
257
+ newSessions.push(
258
+ ...scanClaudeCodeSessions(store.config.sources.claude_code),
259
+ );
260
+ } catch {
261
+ // skip
262
+ }
263
+ }
264
+
265
+ if (store.config.sources.opencode) {
266
+ try {
267
+ newSessions.push(
268
+ ...scanOpencodeSessions(store.config.sources.opencode),
269
+ );
270
+ } catch {
271
+ // skip
272
+ }
273
+ }
274
+
275
+ const asyncLoaders: Promise<SessionEntry[]>[] = [];
276
+
277
+ if (store.config.sources.memorizer_url) {
278
+ asyncLoaders.push(
279
+ scanMemorizerMemories(store.config.sources.memorizer_url).catch(
280
+ () => [],
281
+ ),
282
+ );
283
+ }
284
+
285
+ if (store.config.sources.claude_export) {
286
+ asyncLoaders.push(
287
+ importClaudeExport(store.config.sources.claude_export).catch(
288
+ () => [],
289
+ ),
290
+ );
291
+ }
292
+
293
+ if (asyncLoaders.length > 0) {
294
+ const results = await Promise.all(asyncLoaders);
295
+ for (const entries of results) {
296
+ newSessions.push(...entries);
297
+ }
298
+ }
299
+
300
+ newSessions.sort(
301
+ (a, b) =>
302
+ new Date(b.meta.created_at).getTime() -
303
+ new Date(a.meta.created_at).getTime(),
304
+ );
305
+
306
+ const indexed = store.searchIndex.indexSessions(newSessions);
307
+ const validIds = new Set(newSessions.map((s) => s.meta.id));
308
+ const removed = store.searchIndex.cleanup(validIds);
309
+
310
+ // Update store in-place
311
+ store.sessions = newSessions;
312
+
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text",
317
+ text: [
318
+ `Import complete.`,
319
+ `Sessions: ${oldCount} -> ${newSessions.length}`,
320
+ `Indexed: ${indexed} new/updated`,
321
+ `Removed: ${removed} stale`,
322
+ ].join("\n"),
323
+ },
324
+ ],
325
+ };
326
+ },
327
+ );
328
+
329
+ return server;
330
+ }
@@ -0,0 +1,235 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { mkdirSync, existsSync, unlinkSync } from "fs";
5
+ import { stripMarkdown } from "./plaintext.ts";
6
+ import type { SessionEntry, SourceType } from "../import/types.ts";
7
+ import { loadSessionMarkdownSync } from "../import/loader.ts";
8
+
9
+ const CONFIG_DIR = join(homedir(), ".config", "session-md");
10
+ const DB_PATH = join(CONFIG_DIR, "search-index.sqlite");
11
+
12
+ export function deleteIndex(): boolean {
13
+ for (const suffix of ["", "-wal", "-shm"]) {
14
+ const p = DB_PATH + suffix;
15
+ if (existsSync(p)) unlinkSync(p);
16
+ }
17
+ return true;
18
+ }
19
+
20
+ export interface SearchResult {
21
+ id: string;
22
+ title: string;
23
+ source: SourceType;
24
+ project: string | null;
25
+ snippet: string;
26
+ }
27
+
28
+ export class SearchIndex {
29
+ private db: Database;
30
+
31
+ constructor() {
32
+ mkdirSync(CONFIG_DIR, { recursive: true });
33
+ this.db = new Database(DB_PATH);
34
+ this.db.exec("PRAGMA journal_mode=WAL");
35
+ this.setupSchema();
36
+ }
37
+
38
+ private setupSchema(): void {
39
+ this.db.exec(`
40
+ CREATE TABLE IF NOT EXISTS sessions (
41
+ id TEXT PRIMARY KEY,
42
+ source TEXT NOT NULL,
43
+ title TEXT,
44
+ project TEXT,
45
+ content TEXT,
46
+ content_hash TEXT NOT NULL
47
+ )
48
+ `);
49
+
50
+ // Check if FTS table exists
51
+ const ftsExists = this.db
52
+ .prepare(
53
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions_fts'",
54
+ )
55
+ .get();
56
+
57
+ if (!ftsExists) {
58
+ this.db.exec(`
59
+ CREATE VIRTUAL TABLE sessions_fts USING fts5(
60
+ title, project, content,
61
+ content='sessions',
62
+ content_rowid='rowid'
63
+ )
64
+ `);
65
+
66
+ this.db.exec(`
67
+ CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
68
+ INSERT INTO sessions_fts(rowid, title, project, content)
69
+ VALUES (new.rowid, new.title, new.project, new.content);
70
+ END
71
+ `);
72
+
73
+ this.db.exec(`
74
+ CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
75
+ INSERT INTO sessions_fts(sessions_fts, rowid, title, project, content)
76
+ VALUES ('delete', old.rowid, old.title, old.project, old.content);
77
+ END
78
+ `);
79
+
80
+ this.db.exec(`
81
+ CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
82
+ INSERT INTO sessions_fts(sessions_fts, rowid, title, project, content)
83
+ VALUES ('delete', old.rowid, old.title, old.project, old.content);
84
+ INSERT INTO sessions_fts(rowid, title, project, content)
85
+ VALUES (new.rowid, new.title, new.project, new.content);
86
+ END
87
+ `);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Index sessions incrementally. Returns count of newly indexed/updated sessions.
93
+ */
94
+ indexSessions(
95
+ sessions: SessionEntry[],
96
+ onProgress?: (current: number, total: number) => void,
97
+ ): number {
98
+ const checkStmt = this.db.prepare(
99
+ "SELECT content_hash FROM sessions WHERE id = ?",
100
+ );
101
+ const upsertStmt = this.db.prepare(`
102
+ INSERT OR REPLACE INTO sessions (id, source, title, project, content, content_hash)
103
+ VALUES (?, ?, ?, ?, ?, ?)
104
+ `);
105
+
106
+ let indexed = 0;
107
+ const toIndex: SessionEntry[] = [];
108
+
109
+ // First pass: determine what needs indexing
110
+ for (const entry of sessions) {
111
+ const existing = checkStmt.get(entry.meta.id) as
112
+ | { content_hash: string }
113
+ | null;
114
+ if (!existing || existing.content_hash !== entry.contentHash) {
115
+ toIndex.push(entry);
116
+ }
117
+ }
118
+
119
+ if (toIndex.length === 0) {
120
+ onProgress?.(sessions.length, sessions.length);
121
+ return 0;
122
+ }
123
+
124
+ // Second pass: index in a transaction
125
+ const indexBatch = this.db.transaction(() => {
126
+ for (const entry of toIndex) {
127
+ try {
128
+ let md: string;
129
+ if (entry.md) {
130
+ md = entry.md;
131
+ } else {
132
+ md = loadSessionMarkdownSync(entry);
133
+ }
134
+
135
+ const plaintext = stripMarkdown(md);
136
+
137
+ upsertStmt.run(
138
+ entry.meta.id,
139
+ entry.meta.source,
140
+ entry.meta.title,
141
+ entry.meta.project ?? null,
142
+ plaintext,
143
+ entry.contentHash,
144
+ );
145
+ indexed++;
146
+ } catch {
147
+ // Skip sessions that fail to load
148
+ }
149
+
150
+ onProgress?.(indexed, toIndex.length);
151
+ }
152
+ });
153
+
154
+ indexBatch();
155
+ return indexed;
156
+ }
157
+
158
+ /**
159
+ * Remove index entries for sessions that no longer exist.
160
+ */
161
+ cleanup(validIds: Set<string>): number {
162
+ const allIds = this.db
163
+ .prepare("SELECT id FROM sessions")
164
+ .all() as { id: string }[];
165
+
166
+ let removed = 0;
167
+ const deleteStmt = this.db.prepare("DELETE FROM sessions WHERE id = ?");
168
+
169
+ const cleanupBatch = this.db.transaction(() => {
170
+ for (const row of allIds) {
171
+ if (!validIds.has(row.id)) {
172
+ deleteStmt.run(row.id);
173
+ removed++;
174
+ }
175
+ }
176
+ });
177
+
178
+ cleanupBatch();
179
+ return removed;
180
+ }
181
+
182
+ /**
183
+ * Full-text search within a specific source (or all sources).
184
+ */
185
+ search(query: string, source?: SourceType | "all", limit = 100): SearchResult[] {
186
+ // Escape FTS5 special characters and build query
187
+ const ftsQuery = query
188
+ .replace(/["(){}[\]:^~!@#$%&*+=|\\<>,./;?]/g, " ")
189
+ .trim()
190
+ .split(/\s+/)
191
+ .filter(Boolean)
192
+ .map((term) => `"${term}"`)
193
+ .join(" AND ");
194
+
195
+ if (!ftsQuery) return [];
196
+
197
+ let sql: string;
198
+ let params: any[];
199
+
200
+ if (source && source !== "all") {
201
+ sql = `
202
+ SELECT s.id, s.title, s.source, s.project,
203
+ snippet(sessions_fts, 2, '»', '«', '…', 32) as snippet
204
+ FROM sessions_fts
205
+ JOIN sessions s ON s.rowid = sessions_fts.rowid
206
+ WHERE sessions_fts MATCH ?
207
+ AND s.source = ?
208
+ ORDER BY rank
209
+ LIMIT ?
210
+ `;
211
+ params = [ftsQuery, source, limit];
212
+ } else {
213
+ sql = `
214
+ SELECT s.id, s.title, s.source, s.project,
215
+ snippet(sessions_fts, 2, '»', '«', '…', 32) as snippet
216
+ FROM sessions_fts
217
+ JOIN sessions s ON s.rowid = sessions_fts.rowid
218
+ WHERE sessions_fts MATCH ?
219
+ ORDER BY rank
220
+ LIMIT ?
221
+ `;
222
+ params = [ftsQuery, limit];
223
+ }
224
+
225
+ try {
226
+ return this.db.prepare(sql).all(...params) as SearchResult[];
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ close(): void {
233
+ this.db.close();
234
+ }
235
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Strips markdown syntax and YAML frontmatter from content,
3
+ * returning plain text suitable for full-text indexing.
4
+ */
5
+ export function stripMarkdown(md: string): string {
6
+ let text = md;
7
+
8
+ // Remove YAML frontmatter
9
+ const fmEnd = text.indexOf("---", text.indexOf("---") + 3);
10
+ if (fmEnd !== -1) {
11
+ text = text.slice(fmEnd + 3).trimStart();
12
+ }
13
+
14
+ // Remove markdown headings (keep text)
15
+ text = text.replace(/^#{1,6}\s+/gm, "");
16
+
17
+ // Remove bold/italic markers
18
+ text = text.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1");
19
+ text = text.replace(/_{1,3}([^_]+)_{1,3}/g, "$1");
20
+
21
+ // Remove inline code backticks
22
+ text = text.replace(/`([^`]+)`/g, "$1");
23
+
24
+ // Remove code block fences (keep content)
25
+ text = text.replace(/^```[\s\S]*?^```/gm, "");
26
+
27
+ // Remove blockquote markers
28
+ text = text.replace(/^>\s?/gm, "");
29
+
30
+ // Remove link syntax [text](url) → text
31
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
32
+
33
+ // Remove image syntax ![alt](url)
34
+ text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1");
35
+
36
+ // Remove horizontal rules
37
+ text = text.replace(/^[-*_]{3,}\s*$/gm, "");
38
+
39
+ // Remove list markers
40
+ text = text.replace(/^[\s]*[-*+]\s+/gm, "");
41
+ text = text.replace(/^[\s]*\d+\.\s+/gm, "");
42
+
43
+ // Collapse multiple newlines
44
+ text = text.replace(/\n{3,}/g, "\n\n");
45
+
46
+ return text.trim();
47
+ }