@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/src/index.ts ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createCliRenderer } from "@opentui/core";
4
+ import { loadConfig } from "./config.ts";
5
+ import { App } from "./app.ts";
6
+ import { scanClaudeCodeSessions } from "./import/claude-code-to-md.ts";
7
+ import { scanOpencodeSessions } from "./import/opencode-to-md.ts";
8
+ import { scanMemorizerMemories } from "./import/memorizer-to-md.ts";
9
+ import { importClaudeExport } from "./import/claude-export-to-md.ts";
10
+ import type { SessionEntry } from "./import/types.ts";
11
+ import { SearchIndex, deleteIndex } from "./search/index.ts";
12
+ import { loadTheme } from "./theme.ts";
13
+
14
+ const { version } = require("../package.json");
15
+
16
+ if (process.argv.includes("--version")) {
17
+ console.log(`session-md v${version}`);
18
+ process.exit(0);
19
+ }
20
+
21
+ const command = process.argv[2];
22
+
23
+ if (command === "update") {
24
+ const oldVersion = version;
25
+ console.log(`Current version: v${oldVersion}`);
26
+ console.log("Checking for updates...");
27
+
28
+ try {
29
+ const res = await fetch("https://registry.npmjs.org/@alexzeitler/session-md/latest");
30
+ if (!res.ok) {
31
+ console.error(`Failed to check for updates (HTTP ${res.status})`);
32
+ process.exit(1);
33
+ }
34
+ const pkg = await res.json() as { version: string };
35
+ const latestVersion = pkg.version;
36
+
37
+ if (latestVersion === oldVersion) {
38
+ console.log(`✓ session-md v${oldVersion} is already up to date`);
39
+ process.exit(0);
40
+ }
41
+
42
+ console.log(`New version available: v${latestVersion}`);
43
+ console.log("Installing...");
44
+
45
+ const proc = Bun.spawnSync({
46
+ cmd: ["bun", "install", "-g", "@alexzeitler/session-md@" + latestVersion],
47
+ stdout: "inherit",
48
+ stderr: "inherit",
49
+ });
50
+
51
+ if (proc.exitCode !== 0) {
52
+ console.error("Update failed");
53
+ process.exit(proc.exitCode ?? 1);
54
+ }
55
+
56
+ console.log(`✓ Updated session-md v${oldVersion} → v${latestVersion}`);
57
+ } catch (err) {
58
+ console.error(`Update failed: ${err}`);
59
+ process.exit(1);
60
+ }
61
+ process.exit(0);
62
+ }
63
+
64
+ if (command === "reindex") {
65
+ deleteIndex();
66
+ console.log("✓ Search index deleted, will rebuild on next start.");
67
+ process.exit(0);
68
+ }
69
+
70
+ // --- MCP subcommand ---
71
+ if (command === "mcp") {
72
+ const sub = process.argv[3];
73
+ const args = process.argv.slice(3);
74
+ const hasFlag = (f: string) => args.includes(f);
75
+ const getFlagValue = (f: string) => {
76
+ const idx = args.indexOf(f);
77
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
78
+ };
79
+
80
+ const { resolve } = await import("path");
81
+ const { homedir } = await import("os");
82
+ const { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync } = await import("fs");
83
+ const { spawn: nodeSpawn } = await import("child_process");
84
+ const { fileURLToPath } = await import("url");
85
+
86
+ const cacheDir = process.env.XDG_CACHE_HOME
87
+ ? resolve(process.env.XDG_CACHE_HOME, "session-md")
88
+ : resolve(homedir(), ".cache", "session-md");
89
+ const pidPath = resolve(cacheDir, "mcp.pid");
90
+
91
+ if (sub === "stop") {
92
+ if (!existsSync(pidPath)) {
93
+ console.log("Not running (no PID file).");
94
+ process.exit(0);
95
+ }
96
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim());
97
+ try {
98
+ process.kill(pid, 0);
99
+ process.kill(pid, "SIGTERM");
100
+ unlinkSync(pidPath);
101
+ console.log(`Stopped session-md MCP server (PID ${pid}).`);
102
+ } catch {
103
+ unlinkSync(pidPath);
104
+ console.log("Cleaned up stale PID file (server was not running).");
105
+ }
106
+ process.exit(0);
107
+ }
108
+
109
+ if (hasFlag("--http")) {
110
+ const port = Number(getFlagValue("--port")) || 8282;
111
+
112
+ if (hasFlag("--daemon")) {
113
+ // Guard: check if already running
114
+ if (existsSync(pidPath)) {
115
+ const existingPid = parseInt(readFileSync(pidPath, "utf-8").trim());
116
+ try {
117
+ process.kill(existingPid, 0);
118
+ console.error(`Already running (PID ${existingPid}). Run 'session-md mcp stop' first.`);
119
+ process.exit(1);
120
+ } catch {
121
+ // Stale PID file — continue
122
+ }
123
+ }
124
+
125
+ mkdirSync(cacheDir, { recursive: true });
126
+ const logPath = resolve(cacheDir, "mcp.log");
127
+ const logFd = openSync(logPath, "w");
128
+ const selfPath = fileURLToPath(import.meta.url);
129
+ const child = nodeSpawn(process.execPath, [selfPath, "mcp", "--http", "--port", String(port)], {
130
+ stdio: ["ignore", logFd, logFd],
131
+ detached: true,
132
+ });
133
+ child.unref();
134
+ closeSync(logFd);
135
+
136
+ writeFileSync(pidPath, String(child.pid));
137
+ console.log(`Started on http://localhost:${port}/mcp (PID ${child.pid})`);
138
+ console.log(`Logs: ${logPath}`);
139
+ process.exit(0);
140
+ }
141
+
142
+ // Foreground HTTP mode
143
+ const { startMcpHttpServer } = await import("./mcp/http.ts");
144
+ try {
145
+ await startMcpHttpServer(port);
146
+ } catch (e: any) {
147
+ if (e?.code === "EADDRINUSE") {
148
+ console.error(`Port ${port} already in use. Try a different port with --port.`);
149
+ process.exit(1);
150
+ }
151
+ throw e;
152
+ }
153
+ } else {
154
+ // Default: stdio transport
155
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
156
+ const { createMcpServer, createSessionStore } = await import("./mcp/server.ts");
157
+ const store = await createSessionStore();
158
+ const server = await createMcpServer(store);
159
+ const transport = new StdioServerTransport();
160
+ await server.connect(transport);
161
+ }
162
+
163
+ // Block forever — HTTP server and stdio transport keep the process alive
164
+ // via the event loop; this prevents falling through to the TUI code below.
165
+ await new Promise(() => {});
166
+ }
167
+
168
+ const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
169
+
170
+ function printSpinner(frame: number, msg: string): void {
171
+ process.stdout.write(`\r${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${msg}`);
172
+ }
173
+
174
+ function clearLine(): void {
175
+ process.stdout.write("\r\x1b[K");
176
+ }
177
+
178
+ const config = await loadConfig();
179
+
180
+ // Phase 1: Collect all sessions (CLI, before TUI)
181
+ const allSessions: SessionEntry[] = [];
182
+ let spinnerFrame = 0;
183
+
184
+ printSpinner(spinnerFrame++, "Scanning sources...");
185
+
186
+ if (config.sources.claude_code) {
187
+ try {
188
+ const entries = scanClaudeCodeSessions(config.sources.claude_code);
189
+ allSessions.push(...entries);
190
+ printSpinner(spinnerFrame++, `Scanned claude-code: ${entries.length} sessions`);
191
+ } catch {
192
+ // Silently skip
193
+ }
194
+ }
195
+
196
+ if (config.sources.opencode) {
197
+ try {
198
+ const entries = scanOpencodeSessions(config.sources.opencode);
199
+ allSessions.push(...entries);
200
+ printSpinner(spinnerFrame++, `Scanned opencode: ${entries.length} sessions`);
201
+ } catch {
202
+ // Silently skip
203
+ }
204
+ }
205
+
206
+ // Async sources
207
+ const asyncLoaders: Promise<SessionEntry[]>[] = [];
208
+
209
+ if (config.sources.memorizer_url) {
210
+ asyncLoaders.push(
211
+ scanMemorizerMemories(config.sources.memorizer_url).catch(() => []),
212
+ );
213
+ }
214
+
215
+ if (config.sources.claude_export) {
216
+ asyncLoaders.push(
217
+ importClaudeExport(config.sources.claude_export).catch(() => []),
218
+ );
219
+ }
220
+
221
+ if (asyncLoaders.length > 0) {
222
+ printSpinner(spinnerFrame++, "Loading async sources...");
223
+ const results = await Promise.all(asyncLoaders);
224
+ for (const entries of results) {
225
+ allSessions.push(...entries);
226
+ }
227
+ }
228
+
229
+ allSessions.sort(
230
+ (a, b) =>
231
+ new Date(b.meta.created_at).getTime() -
232
+ new Date(a.meta.created_at).getTime(),
233
+ );
234
+
235
+ printSpinner(spinnerFrame++, `${allSessions.length} sessions total. Indexing...`);
236
+
237
+ // Phase 2: Build/update search index
238
+ const searchIndex = new SearchIndex();
239
+
240
+ const spinnerInterval = setInterval(() => {
241
+ // Keep spinner animation alive during indexing
242
+ spinnerFrame++;
243
+ }, 80);
244
+
245
+ const indexed = searchIndex.indexSessions(allSessions, (current, total) => {
246
+ printSpinner(spinnerFrame++, `Indexing ${current}/${total}...`);
247
+ });
248
+
249
+ // Cleanup stale entries
250
+ const validIds = new Set(allSessions.map((s) => s.meta.id));
251
+ const removed = searchIndex.cleanup(validIds);
252
+
253
+ clearInterval(spinnerInterval);
254
+
255
+ if (indexed > 0 || removed > 0) {
256
+ clearLine();
257
+ console.log(`✓ Indexed ${indexed} new/updated, removed ${removed} stale entries`);
258
+ } else {
259
+ clearLine();
260
+ console.log(`✓ Search index up to date (${allSessions.length} sessions)`);
261
+ }
262
+
263
+ // Phase 3: Start TUI
264
+ let renderer: Awaited<ReturnType<typeof createCliRenderer>> | null = null;
265
+
266
+ try {
267
+ renderer = await createCliRenderer({
268
+ exitOnCtrlC: true,
269
+ });
270
+
271
+ const theme = loadTheme(config.theme);
272
+ const app = new App(renderer, config, searchIndex, theme);
273
+ await app.start();
274
+ app.loadSessions(allSessions);
275
+ } catch (err) {
276
+ if (renderer) {
277
+ renderer.destroy();
278
+ }
279
+ searchIndex.close();
280
+ console.error("Failed to start session-md:", err);
281
+ process.exit(1);
282
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * session-md MCP HTTP Transport
3
+ *
4
+ * Streamable HTTP server with session management, health endpoint,
5
+ * and daemon support. Pattern follows qmd's implementation.
6
+ */
7
+
8
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
9
+ import { randomUUID } from "node:crypto";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
12
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
13
+ import { createMcpServer, createSessionStore, type SessionStore } from "./server.ts";
14
+
15
+ export type HttpServerHandle = {
16
+ httpServer: import("http").Server;
17
+ port: number;
18
+ stop: () => Promise<void>;
19
+ };
20
+
21
+ export async function startMcpHttpServer(
22
+ port: number,
23
+ options?: { quiet?: boolean },
24
+ ): Promise<HttpServerHandle> {
25
+ const store = await createSessionStore();
26
+
27
+ const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
28
+
29
+ async function createSession(): Promise<WebStandardStreamableHTTPServerTransport> {
30
+ const transport = new WebStandardStreamableHTTPServerTransport({
31
+ sessionIdGenerator: () => randomUUID(),
32
+ enableJsonResponse: true,
33
+ onsessioninitialized: (sessionId: string) => {
34
+ sessions.set(sessionId, transport);
35
+ log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
36
+ },
37
+ });
38
+ const server = await createMcpServer(store);
39
+ await server.connect(transport);
40
+
41
+ transport.onclose = () => {
42
+ if (transport.sessionId) {
43
+ sessions.delete(transport.sessionId);
44
+ }
45
+ };
46
+
47
+ return transport;
48
+ }
49
+
50
+ const startTime = Date.now();
51
+ const quiet = options?.quiet ?? false;
52
+
53
+ function ts(): string {
54
+ return new Date().toISOString().slice(11, 23);
55
+ }
56
+
57
+ function describeRequest(body: any): string {
58
+ const method = body?.method ?? "unknown";
59
+ if (method === "tools/call") {
60
+ const tool = body.params?.name ?? "?";
61
+ const args = body.params?.arguments;
62
+ if (args?.query) {
63
+ const q = String(args.query).slice(0, 80);
64
+ return `tools/call ${tool} "${q}"`;
65
+ }
66
+ if (args?.id) return `tools/call ${tool} ${args.id}`;
67
+ return `tools/call ${tool}`;
68
+ }
69
+ return method;
70
+ }
71
+
72
+ function log(msg: string): void {
73
+ if (!quiet) console.error(msg);
74
+ }
75
+
76
+ async function collectBody(req: IncomingMessage): Promise<string> {
77
+ const chunks: Buffer[] = [];
78
+ for await (const chunk of req) chunks.push(chunk as Buffer);
79
+ return Buffer.concat(chunks).toString();
80
+ }
81
+
82
+ const httpServer = createServer(
83
+ async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
84
+ const reqStart = Date.now();
85
+ const pathname = nodeReq.url || "/";
86
+
87
+ try {
88
+ // GET /health
89
+ if (pathname === "/health" && nodeReq.method === "GET") {
90
+ const body = JSON.stringify({
91
+ status: "ok",
92
+ uptime: Math.floor((Date.now() - startTime) / 1000),
93
+ sessions: sessions.size,
94
+ indexed: store.sessions.length,
95
+ });
96
+ nodeRes.writeHead(200, { "Content-Type": "application/json" });
97
+ nodeRes.end(body);
98
+ log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
99
+ return;
100
+ }
101
+
102
+ // POST /mcp — MCP Streamable HTTP
103
+ if (pathname === "/mcp" && nodeReq.method === "POST") {
104
+ const rawBody = await collectBody(nodeReq);
105
+ const body = JSON.parse(rawBody);
106
+ const label = describeRequest(body);
107
+ const url = `http://localhost:${port}${pathname}`;
108
+ const headers: Record<string, string> = {};
109
+ for (const [k, v] of Object.entries(nodeReq.headers)) {
110
+ if (typeof v === "string") headers[k] = v;
111
+ }
112
+
113
+ const sessionId = headers["mcp-session-id"];
114
+ let transport: WebStandardStreamableHTTPServerTransport;
115
+
116
+ if (sessionId) {
117
+ const existing = sessions.get(sessionId);
118
+ if (!existing) {
119
+ nodeRes.writeHead(404, { "Content-Type": "application/json" });
120
+ nodeRes.end(
121
+ JSON.stringify({
122
+ jsonrpc: "2.0",
123
+ error: { code: -32001, message: "Session not found" },
124
+ id: body?.id ?? null,
125
+ }),
126
+ );
127
+ return;
128
+ }
129
+ transport = existing;
130
+ } else if (isInitializeRequest(body)) {
131
+ transport = await createSession();
132
+ } else {
133
+ nodeRes.writeHead(400, { "Content-Type": "application/json" });
134
+ nodeRes.end(
135
+ JSON.stringify({
136
+ jsonrpc: "2.0",
137
+ error: {
138
+ code: -32000,
139
+ message: "Bad Request: Missing session ID",
140
+ },
141
+ id: body?.id ?? null,
142
+ }),
143
+ );
144
+ return;
145
+ }
146
+
147
+ const request = new Request(url, {
148
+ method: "POST",
149
+ headers,
150
+ body: rawBody,
151
+ });
152
+ const response = await transport.handleRequest(request, {
153
+ parsedBody: body,
154
+ });
155
+
156
+ nodeRes.writeHead(
157
+ response.status,
158
+ Object.fromEntries(response.headers),
159
+ );
160
+ nodeRes.end(Buffer.from(await response.arrayBuffer()));
161
+ log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
162
+ return;
163
+ }
164
+
165
+ // GET/DELETE /mcp — session-bound
166
+ if (pathname === "/mcp") {
167
+ const headers: Record<string, string> = {};
168
+ for (const [k, v] of Object.entries(nodeReq.headers)) {
169
+ if (typeof v === "string") headers[k] = v;
170
+ }
171
+
172
+ const sessionId = headers["mcp-session-id"];
173
+ if (!sessionId) {
174
+ nodeRes.writeHead(400, { "Content-Type": "application/json" });
175
+ nodeRes.end(
176
+ JSON.stringify({
177
+ jsonrpc: "2.0",
178
+ error: {
179
+ code: -32000,
180
+ message: "Bad Request: Missing session ID",
181
+ },
182
+ id: null,
183
+ }),
184
+ );
185
+ return;
186
+ }
187
+
188
+ const transport = sessions.get(sessionId);
189
+ if (!transport) {
190
+ nodeRes.writeHead(404, { "Content-Type": "application/json" });
191
+ nodeRes.end(
192
+ JSON.stringify({
193
+ jsonrpc: "2.0",
194
+ error: { code: -32001, message: "Session not found" },
195
+ id: null,
196
+ }),
197
+ );
198
+ return;
199
+ }
200
+
201
+ const url = `http://localhost:${port}${pathname}`;
202
+ const rawBody =
203
+ nodeReq.method !== "GET" && nodeReq.method !== "HEAD"
204
+ ? await collectBody(nodeReq)
205
+ : undefined;
206
+ const request = new Request(url, {
207
+ method: nodeReq.method || "GET",
208
+ headers,
209
+ ...(rawBody ? { body: rawBody } : {}),
210
+ });
211
+ const response = await transport.handleRequest(request);
212
+ nodeRes.writeHead(
213
+ response.status,
214
+ Object.fromEntries(response.headers),
215
+ );
216
+ nodeRes.end(Buffer.from(await response.arrayBuffer()));
217
+ return;
218
+ }
219
+
220
+ nodeRes.writeHead(404);
221
+ nodeRes.end("Not Found");
222
+ } catch (err) {
223
+ console.error("HTTP handler error:", err);
224
+ nodeRes.writeHead(500);
225
+ nodeRes.end("Internal Server Error");
226
+ }
227
+ },
228
+ );
229
+
230
+ await new Promise<void>((resolve, reject) => {
231
+ httpServer.on("error", reject);
232
+ httpServer.listen(port, "localhost", () => resolve());
233
+ });
234
+
235
+ const actualPort = (httpServer.address() as import("net").AddressInfo).port;
236
+
237
+ let stopping = false;
238
+ const stop = async () => {
239
+ if (stopping) return;
240
+ stopping = true;
241
+ for (const transport of sessions.values()) {
242
+ await transport.close();
243
+ }
244
+ sessions.clear();
245
+ httpServer.close();
246
+ store.searchIndex.close();
247
+ };
248
+
249
+ process.on("SIGTERM", async () => {
250
+ console.error("Shutting down (SIGTERM)...");
251
+ await stop();
252
+ process.exit(0);
253
+ });
254
+ process.on("SIGINT", async () => {
255
+ console.error("Shutting down (SIGINT)...");
256
+ await stop();
257
+ process.exit(0);
258
+ });
259
+
260
+ log(
261
+ `session-md MCP server listening on http://localhost:${actualPort}/mcp`,
262
+ );
263
+ return { httpServer, port: actualPort, stop };
264
+ }