@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,86 @@
1
+ import { join } from "path";
2
+ import type { SessionEntry } from "./types.ts";
3
+
4
+ const cache = new Map<string, string>();
5
+ let worker: Worker | null = null;
6
+ let pendingCallbacks = new Map<
7
+ string,
8
+ { resolve: (md: string) => void; reject: (err: Error) => void }
9
+ >();
10
+
11
+ function getWorker(): Worker {
12
+ if (!worker) {
13
+ worker = new Worker(join(import.meta.dir, "parse-worker.ts"));
14
+ worker.onmessage = (event: MessageEvent) => {
15
+ const { id, md, error } = event.data;
16
+ const cb = pendingCallbacks.get(id);
17
+ if (!cb) return;
18
+ pendingCallbacks.delete(id);
19
+
20
+ if (error) {
21
+ cb.reject(new Error(error));
22
+ } else {
23
+ cache.set(id, md);
24
+ cb.resolve(md);
25
+ }
26
+ };
27
+ }
28
+ return worker;
29
+ }
30
+
31
+ /**
32
+ * Loads markdown for a session entry asynchronously.
33
+ * Cached results return immediately. Heavy parsing runs in a worker thread.
34
+ */
35
+ export function loadSessionMarkdownAsync(
36
+ entry: SessionEntry,
37
+ ): Promise<string> {
38
+ // Pre-generated (memorizer, claude-export)
39
+ if (entry.md) return Promise.resolve(entry.md);
40
+
41
+ // Cached
42
+ const cached = cache.get(entry.meta.id);
43
+ if (cached) return Promise.resolve(cached);
44
+
45
+ // Cancel any pending load for the same id
46
+ const existing = pendingCallbacks.get(entry.meta.id);
47
+ if (existing) {
48
+ existing.reject(new Error("cancelled"));
49
+ pendingCallbacks.delete(entry.meta.id);
50
+ }
51
+
52
+ return new Promise((resolve, reject) => {
53
+ pendingCallbacks.set(entry.meta.id, { resolve, reject });
54
+ getWorker().postMessage({
55
+ id: entry.meta.id,
56
+ source: entry.meta.source,
57
+ sourcePath: entry.sourcePath,
58
+ sessionId: entry.meta.id,
59
+ });
60
+ });
61
+ }
62
+
63
+ /** Synchronous cache-only check (for copy-to-target) */
64
+ export function loadSessionMarkdownSync(entry: SessionEntry): string {
65
+ if (entry.md) return entry.md;
66
+ const cached = cache.get(entry.meta.id);
67
+ if (cached) return cached;
68
+
69
+ // Fallback: import synchronously (for copy operations)
70
+ if (entry.meta.source === "claude-code") {
71
+ const { claudeCodeSessionToMd } = require("./claude-code-to-md.ts");
72
+ const md = claudeCodeSessionToMd(entry.sourcePath);
73
+ cache.set(entry.meta.id, md);
74
+ return md;
75
+ }
76
+ if (entry.meta.source === "opencode") {
77
+ const { dirname } = require("path");
78
+ const { opencodeSessionToMd } = require("./opencode-to-md.ts");
79
+ const storagePath = dirname(dirname(dirname(entry.sourcePath)));
80
+ const md = opencodeSessionToMd(storagePath, entry.meta.id);
81
+ cache.set(entry.meta.id, md);
82
+ return md;
83
+ }
84
+
85
+ return `# ${entry.meta.title}\n\n*Content not available*`;
86
+ }
@@ -0,0 +1,117 @@
1
+ import { join } from "path";
2
+ import {
3
+ type SessionEntry,
4
+ slugify,
5
+ } from "./types.ts";
6
+
7
+ interface Workspace {
8
+ id: string;
9
+ name: string;
10
+ }
11
+
12
+ interface Project {
13
+ id: string;
14
+ name: string;
15
+ workspaceName: string;
16
+ }
17
+
18
+ interface Memory {
19
+ id: string;
20
+ title: string;
21
+ text: string;
22
+ type: string;
23
+ tags: string[];
24
+ confidence: number;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+
29
+ async function fetchJson<T>(url: string): Promise<T> {
30
+ const res = await fetch(url);
31
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
32
+ return res.json() as Promise<T>;
33
+ }
34
+
35
+ async function fetchAllMemories(
36
+ baseUrl: string,
37
+ projectId: string,
38
+ ): Promise<Memory[]> {
39
+ const all: Memory[] = [];
40
+ let page = 1;
41
+ const pageSize = 100;
42
+
43
+ while (true) {
44
+ const url = `${baseUrl}/api/memory?projectId=${projectId}&page=${page}&pageSize=${pageSize}`;
45
+ const res = await fetchJson<{ memories: Memory[] }>(url);
46
+ all.push(...res.memories);
47
+ if (res.memories.length < pageSize) break;
48
+ page++;
49
+ }
50
+
51
+ return all;
52
+ }
53
+
54
+ /**
55
+ * Fetches all memories from Memorizer API.
56
+ * Returns SessionEntry[] with pre-generated markdown (md field set).
57
+ */
58
+ export async function scanMemorizerMemories(
59
+ baseUrl: string,
60
+ ): Promise<SessionEntry[]> {
61
+ const entries: SessionEntry[] = [];
62
+
63
+ const { workspaces } = await fetchJson<{ workspaces: Workspace[] }>(
64
+ `${baseUrl}/api/workspace`,
65
+ );
66
+
67
+ for (const ws of workspaces) {
68
+ const { projects } = await fetchJson<{ projects: Project[] }>(
69
+ `${baseUrl}/api/project?workspaceId=${ws.id}`,
70
+ );
71
+
72
+ for (const proj of projects) {
73
+ const memories = await fetchAllMemories(baseUrl, proj.id);
74
+
75
+ for (const mem of memories) {
76
+ const tags = mem.tags.length > 0 ? `[${mem.tags.join(", ")}]` : "[]";
77
+
78
+ const frontmatter = [
79
+ "---",
80
+ `title: "${mem.title.replace(/"/g, '\\"')}"`,
81
+ `id: ${mem.id}`,
82
+ `source: memorizer`,
83
+ `workspace: "${ws.name.replace(/"/g, '\\"')}"`,
84
+ `project: "${proj.name.replace(/"/g, '\\"')}"`,
85
+ `type: ${mem.type}`,
86
+ `tags: ${tags}`,
87
+ `confidence: ${mem.confidence}`,
88
+ `created_at: ${mem.createdAt}`,
89
+ `updated_at: ${mem.updatedAt}`,
90
+ "---",
91
+ ].join("\n");
92
+
93
+ const md = `${frontmatter}\n\n# ${mem.title}\n\n${mem.text}`;
94
+ const wsSlug = slugify(ws.name);
95
+ const projSlug = slugify(proj.name);
96
+ const titleSlug = slugify(mem.title);
97
+
98
+ entries.push({
99
+ filename: join(wsSlug, projSlug, `${titleSlug}.md`),
100
+ sourcePath: `${baseUrl}/api/memory/${mem.id}`,
101
+ contentHash: mem.updatedAt,
102
+ md,
103
+ meta: {
104
+ title: mem.title,
105
+ id: mem.id,
106
+ source: "memorizer",
107
+ project: `${ws.name}/${proj.name}`,
108
+ created_at: mem.createdAt,
109
+ updated_at: mem.updatedAt,
110
+ },
111
+ });
112
+ }
113
+ }
114
+ }
115
+
116
+ return entries;
117
+ }
@@ -0,0 +1,176 @@
1
+ import { join } from "path";
2
+ import { readdirSync, existsSync, readFileSync, statSync } from "fs";
3
+ import {
4
+ type SessionEntry,
5
+ type SessionMeta,
6
+ buildFrontmatter,
7
+ formatDate,
8
+ truncateTitle,
9
+ } from "./types.ts";
10
+
11
+ interface OcSession {
12
+ id: string;
13
+ version: string;
14
+ title: string;
15
+ time: { created: number; updated: number };
16
+ }
17
+
18
+ interface OcMessage {
19
+ id: string;
20
+ sessionID: string;
21
+ role: string;
22
+ time: { created: number; completed?: number };
23
+ parentID?: string;
24
+ modelID?: string;
25
+ }
26
+
27
+ interface OcPart {
28
+ id: string;
29
+ sessionID: string;
30
+ messageID: string;
31
+ type: string;
32
+ text?: string;
33
+ tool?: string;
34
+ state?: {
35
+ status: string;
36
+ input?: Record<string, unknown>;
37
+ output?: string;
38
+ title?: string;
39
+ };
40
+ }
41
+
42
+ function readJson<T>(path: string): T | null {
43
+ try {
44
+ return JSON.parse(readFileSync(path, "utf-8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fast scan: only reads session metadata files, no message/part parsing.
52
+ */
53
+ export function scanOpencodeSessions(
54
+ storagePath: string,
55
+ ): SessionEntry[] {
56
+ if (!existsSync(storagePath)) return [];
57
+
58
+ const sessionDir = join(storagePath, "session");
59
+ if (!existsSync(sessionDir)) return [];
60
+
61
+ const entries: SessionEntry[] = [];
62
+
63
+ for (const projHash of readdirSync(sessionDir)) {
64
+ const projDir = join(sessionDir, projHash);
65
+ let files: string[];
66
+ try {
67
+ files = readdirSync(projDir).filter((f) => f.endsWith(".json"));
68
+ } catch {
69
+ continue;
70
+ }
71
+
72
+ for (const file of files) {
73
+ const ses = readJson<OcSession>(join(projDir, file));
74
+ if (!ses) continue;
75
+
76
+ const filePath = join(projDir, file);
77
+ const date = formatDate(new Date(ses.time.created));
78
+ const shortId = ses.id.replace(/^ses_/, "").slice(0, 8);
79
+ const fileStat = statSync(filePath);
80
+
81
+ entries.push({
82
+ filename: `${date}-${shortId}.md`,
83
+ sourcePath: filePath,
84
+ contentHash: String(fileStat.size),
85
+ meta: {
86
+ title: ses.title || `Session ${shortId}`,
87
+ id: ses.id,
88
+ source: "opencode",
89
+ created_at: new Date(ses.time.created).toISOString(),
90
+ updated_at: new Date(ses.time.updated).toISOString(),
91
+ },
92
+ });
93
+ }
94
+ }
95
+
96
+ return entries;
97
+ }
98
+
99
+ /**
100
+ * Full parse: reads session + all messages + parts and generates markdown.
101
+ */
102
+ export function opencodeSessionToMd(
103
+ storagePath: string,
104
+ sessionId: string,
105
+ ): string {
106
+ const sessionDir = join(storagePath, "session");
107
+ if (!existsSync(sessionDir)) return "";
108
+
109
+ let session: OcSession | null = null;
110
+ for (const projHash of readdirSync(sessionDir)) {
111
+ const sesPath = join(sessionDir, projHash, `${sessionId}.json`);
112
+ if (existsSync(sesPath)) {
113
+ session = readJson<OcSession>(sesPath);
114
+ break;
115
+ }
116
+ }
117
+ if (!session) return "";
118
+
119
+ const msgDir = join(storagePath, "message", sessionId);
120
+ if (!existsSync(msgDir)) return "";
121
+
122
+ const msgFiles = readdirSync(msgDir).filter((f) => f.endsWith(".json")).sort();
123
+ const messages: OcMessage[] = [];
124
+ for (const f of msgFiles) {
125
+ const msg = readJson<OcMessage>(join(msgDir, f));
126
+ if (msg) messages.push(msg);
127
+ }
128
+
129
+ messages.sort((a, b) => a.time.created - b.time.created);
130
+
131
+ const mdSections: string[] = [];
132
+ for (const msg of messages) {
133
+ const partDir = join(storagePath, "part", msg.id);
134
+ if (!existsSync(partDir)) continue;
135
+
136
+ const partFiles = readdirSync(partDir).filter((f) => f.endsWith(".json")).sort();
137
+ const parts: OcPart[] = [];
138
+ for (const f of partFiles) {
139
+ const part = readJson<OcPart>(join(partDir, f));
140
+ if (part) parts.push(part);
141
+ }
142
+
143
+ const textParts: string[] = [];
144
+ for (const part of parts) {
145
+ if (part.type === "text" && part.text) {
146
+ textParts.push(part.text);
147
+ } else if (part.type === "tool" && part.tool && part.state) {
148
+ const inputStr = part.state.input
149
+ ? JSON.stringify(part.state.input, null, 2)
150
+ : "";
151
+ textParts.push(
152
+ `> **Tool**: ${part.tool}\n> \`\`\`\n> ${inputStr}\n> \`\`\``,
153
+ );
154
+ }
155
+ }
156
+
157
+ if (textParts.length === 0) continue;
158
+ const role = msg.role === "user" ? "Human" : "Claude";
159
+ mdSections.push(`## ${role}\n\n${textParts.join("\n\n")}`);
160
+ }
161
+
162
+ const title = session.title || `Session ${sessionId.slice(0, 8)}`;
163
+ const created = new Date(session.time.created);
164
+ const updated = new Date(session.time.updated);
165
+
166
+ const meta: SessionMeta = {
167
+ title: truncateTitle(title),
168
+ id: sessionId,
169
+ source: "opencode",
170
+ created_at: created.toISOString(),
171
+ updated_at: updated.toISOString(),
172
+ };
173
+
174
+ const frontmatter = buildFrontmatter(meta);
175
+ return `${frontmatter}\n\n# ${meta.title}\n\n${mdSections.join("\n\n")}`;
176
+ }
@@ -0,0 +1,28 @@
1
+ /// Worker thread: parses session files off the main thread.
2
+
3
+ import { claudeCodeSessionToMd } from "./claude-code-to-md.ts";
4
+ import { opencodeSessionToMd } from "./opencode-to-md.ts";
5
+ import { dirname } from "path";
6
+
7
+ declare var self: Worker;
8
+
9
+ self.onmessage = (event: MessageEvent) => {
10
+ const { id, source, sourcePath, sessionId } = event.data;
11
+
12
+ try {
13
+ let md: string;
14
+
15
+ if (source === "claude-code") {
16
+ md = claudeCodeSessionToMd(sourcePath);
17
+ } else if (source === "opencode") {
18
+ const storagePath = dirname(dirname(dirname(sourcePath)));
19
+ md = opencodeSessionToMd(storagePath, sessionId);
20
+ } else {
21
+ md = `# Untitled\n\n*Content not available*`;
22
+ }
23
+
24
+ self.postMessage({ id, md, error: null });
25
+ } catch (err) {
26
+ self.postMessage({ id, md: null, error: String(err) });
27
+ }
28
+ };
@@ -0,0 +1,56 @@
1
+ export type SourceType = "claude-code" | "claude-export" | "opencode" | "memorizer";
2
+
3
+ export interface SessionEntry {
4
+ /** Display filename (e.g. 2026-03-13-abc12345.md) */
5
+ filename: string;
6
+ /** Session metadata (collected without reading file content) */
7
+ meta: SessionMeta;
8
+ /** Absolute path to the original source file (for lazy loading) */
9
+ sourcePath: string;
10
+ /** Pre-generated markdown (only set for memorizer/claude-export, null for lazy sources) */
11
+ md?: string;
12
+ /** Hash for search index invalidation (file size, updated_at, etc.) */
13
+ contentHash: string;
14
+ }
15
+
16
+ export interface SessionMeta {
17
+ title: string;
18
+ id: string;
19
+ source: SourceType;
20
+ project?: string;
21
+ created_at: string;
22
+ updated_at: string;
23
+ }
24
+
25
+ export function buildFrontmatter(meta: SessionMeta): string {
26
+ const lines = [
27
+ "---",
28
+ `title: "${meta.title.replace(/"/g, '\\"')}"`,
29
+ `id: ${meta.id}`,
30
+ `source: ${meta.source}`,
31
+ ];
32
+ if (meta.project) {
33
+ lines.push(`project: "${meta.project.replace(/"/g, '\\"')}"`);
34
+ }
35
+ lines.push(`created_at: ${meta.created_at}`);
36
+ lines.push(`updated_at: ${meta.updated_at}`);
37
+ lines.push("---");
38
+ return lines.join("\n");
39
+ }
40
+
41
+ export function slugify(text: string): string {
42
+ return text
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-|-$/g, "")
46
+ .slice(0, 80);
47
+ }
48
+
49
+ export function formatDate(date: Date): string {
50
+ return date.toISOString().split("T")[0]!;
51
+ }
52
+
53
+ export function truncateTitle(text: string, maxLen = 80): string {
54
+ if (text.length <= maxLen) return text;
55
+ return text.slice(0, maxLen).trimEnd() + "…";
56
+ }