@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.
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/package.json +20 -0
- package/session-md +49 -0
- package/src/app.ts +603 -0
- package/src/components/ConversationList.ts +243 -0
- package/src/components/MessageView.ts +241 -0
- package/src/components/SearchResultsView.ts +146 -0
- package/src/components/SourcePicker.ts +87 -0
- package/src/components/StatusBar.ts +70 -0
- package/src/components/TargetPicker.ts +174 -0
- package/src/config.ts +85 -0
- package/src/file-ops.ts +23 -0
- package/src/import/claude-code-to-md.ts +184 -0
- package/src/import/claude-export-to-md.ts +122 -0
- package/src/import/loader.ts +86 -0
- package/src/import/memorizer-to-md.ts +117 -0
- package/src/import/opencode-to-md.ts +176 -0
- package/src/import/parse-worker.ts +28 -0
- package/src/import/types.ts +56 -0
- package/src/index.ts +282 -0
- package/src/mcp/http.ts +264 -0
- package/src/mcp/server.ts +330 -0
- package/src/search/index.ts +235 -0
- package/src/search/plaintext.ts +47 -0
- package/src/theme.ts +111 -0
|
@@ -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 
|
|
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
|
+
}
|