@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,70 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ t,
6
+ fg,
7
+ bold,
8
+ } from "@opentui/core";
9
+ import type { Theme } from "../theme.ts";
10
+
11
+ export type FocusArea = "sidebar" | "main";
12
+
13
+ export class StatusBar {
14
+ readonly container: BoxRenderable;
15
+ private statusText: TextRenderable;
16
+ private msgText: TextRenderable;
17
+
18
+ constructor(private ctx: CliRenderer, private theme: Theme) {
19
+ this.container = new BoxRenderable(ctx, {
20
+ id: "status-bar",
21
+ height: 3,
22
+ width: "100%" as any,
23
+ borderStyle: "rounded",
24
+ borderColor: this.theme.border_inactive,
25
+ flexDirection: "row",
26
+ justifyContent: "space-between",
27
+ });
28
+
29
+ this.statusText = new TextRenderable(ctx, {
30
+ id: "status-text",
31
+ content: "",
32
+ paddingLeft: 1,
33
+ });
34
+
35
+ this.msgText = new TextRenderable(ctx, {
36
+ id: "msg-text",
37
+ content: "",
38
+ paddingRight: 1,
39
+ });
40
+
41
+ this.container.add(this.statusText);
42
+ this.container.add(this.msgText);
43
+ }
44
+
45
+ update(selectedCount: number, totalCount: number, focus: FocusArea): void {
46
+ const selPrefix =
47
+ selectedCount > 0 ? `${selectedCount} selected | ` : "";
48
+
49
+ const hint =
50
+ focus === "sidebar"
51
+ ? "j/k navigate gg/G top/end SPACE select c copy / filter g grep q quit"
52
+ : "j/k scroll gg/G top/end Esc back q quit";
53
+
54
+ this.statusText.content = t`${fg(this.theme.success)(selPrefix)}${fg(this.theme.muted)(`${totalCount} sessions`)} | ${fg(this.theme.muted)(hint)}`;
55
+ }
56
+
57
+ showError(msg: string): void {
58
+ this.msgText.content = t`${fg(this.theme.error)(msg)}`;
59
+ setTimeout(() => {
60
+ this.msgText.content = "";
61
+ }, 5000);
62
+ }
63
+
64
+ showInfo(msg: string): void {
65
+ this.msgText.content = t`${fg(this.theme.success)(msg)}`;
66
+ setTimeout(() => {
67
+ this.msgText.content = "";
68
+ }, 3000);
69
+ }
70
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ BoxRenderable,
3
+ SelectRenderable,
4
+ SelectRenderableEvents,
5
+ TextRenderable,
6
+ InputRenderable,
7
+ InputRenderableEvents,
8
+ type SelectOption,
9
+ type CliRenderer,
10
+ t,
11
+ fg,
12
+ bold,
13
+ } from "@opentui/core";
14
+ import type { Theme } from "../theme.ts";
15
+
16
+ export class TargetPicker {
17
+ readonly container: BoxRenderable;
18
+ readonly select: SelectRenderable;
19
+ private headerText: TextRenderable;
20
+ private nameInput: InputRenderable;
21
+ private pathInput: InputRenderable;
22
+ private inputContainer: BoxRenderable;
23
+ private enteringNew = false;
24
+ private inputStep: "path" | "name" = "path";
25
+
26
+ onTargetSelected: ((targetPath: string) => void) | null = null;
27
+ onNewTarget: ((name: string, path: string) => void) | null = null;
28
+ onCancel: (() => void) | null = null;
29
+
30
+ constructor(private ctx: CliRenderer, private theme?: Theme) {
31
+ this.container = new BoxRenderable(ctx, {
32
+ id: "target-picker",
33
+ flexDirection: "column",
34
+ flexGrow: 1,
35
+ padding: 1,
36
+ gap: 1,
37
+ });
38
+
39
+ this.headerText = new TextRenderable(ctx, {
40
+ id: "target-header",
41
+ content: "",
42
+ });
43
+
44
+ this.select = new SelectRenderable(ctx, {
45
+ id: "target-select",
46
+ flexGrow: 1,
47
+ options: [],
48
+ showDescription: true,
49
+ wrapSelection: true,
50
+ selectedBackgroundColor: this.theme?.selection_bg ?? "#264f78",
51
+ selectedTextColor: this.theme?.selection_fg ?? "#ffffff",
52
+ selectedDescriptionColor: this.theme?.selection_desc ?? "#a0c4e8",
53
+ });
54
+
55
+ this.inputContainer = new BoxRenderable(ctx, {
56
+ id: "target-input-container",
57
+ flexDirection: "column",
58
+ gap: 1,
59
+ padding: 1,
60
+ });
61
+
62
+ const pathLabel = new TextRenderable(ctx, {
63
+ id: "target-path-label",
64
+ content: t`${bold("Path:")}`,
65
+ });
66
+
67
+ this.pathInput = new InputRenderable(ctx, {
68
+ id: "target-path-input",
69
+ width: 60,
70
+ placeholder: "~/path/to/folder",
71
+ });
72
+
73
+ const nameLabel = new TextRenderable(ctx, {
74
+ id: "target-name-label",
75
+ content: t`${bold("Name (optional):")}`,
76
+ });
77
+
78
+ this.nameInput = new InputRenderable(ctx, {
79
+ id: "target-name-input",
80
+ width: 30,
81
+ placeholder: "short name",
82
+ });
83
+
84
+ this.inputContainer.add(pathLabel);
85
+ this.inputContainer.add(this.pathInput);
86
+ this.inputContainer.add(nameLabel);
87
+ this.inputContainer.add(this.nameInput);
88
+
89
+ this.container.add(this.headerText);
90
+ this.container.add(this.select);
91
+
92
+ this.select.on(
93
+ SelectRenderableEvents.ITEM_SELECTED,
94
+ (_index: number, option: SelectOption) => {
95
+ if (option.value === "__new__") {
96
+ this.showNewTargetInput();
97
+ } else if (this.onTargetSelected) {
98
+ this.onTargetSelected(option.value);
99
+ }
100
+ },
101
+ );
102
+
103
+ this.pathInput.on(InputRenderableEvents.ENTER, () => {
104
+ this.nameInput.focus();
105
+ });
106
+
107
+ this.nameInput.on(InputRenderableEvents.ENTER, () => {
108
+ const path = this.pathInput.value.trim();
109
+ if (!path) return;
110
+ const name = this.nameInput.value.trim();
111
+ if (this.onNewTarget) {
112
+ this.onNewTarget(name, path);
113
+ }
114
+ });
115
+ }
116
+
117
+ show(targets: Record<string, string>, fileCount: number): void {
118
+ this.enteringNew = false;
119
+ this.headerText.content = t`${bold(`Copy ${fileCount} file(s) to...`)}`;
120
+
121
+ const options: SelectOption[] = Object.entries(targets).map(
122
+ ([name, path]) => ({
123
+ name,
124
+ description: path,
125
+ value: path,
126
+ }),
127
+ );
128
+ options.push({
129
+ name: "[+ new folder...]",
130
+ description: "",
131
+ value: "__new__",
132
+ });
133
+
134
+ this.select.options = options;
135
+ }
136
+
137
+ private showNewTargetInput(): void {
138
+ this.enteringNew = true;
139
+ this.container.remove(this.select.id);
140
+ this.container.add(this.inputContainer);
141
+ this.pathInput.value = "";
142
+ this.nameInput.value = "";
143
+ this.pathInput.focus();
144
+ }
145
+
146
+ hideNewTargetInput(): void {
147
+ if (this.enteringNew) {
148
+ this.enteringNew = false;
149
+ this.container.remove(this.inputContainer.id);
150
+ this.container.add(this.select);
151
+ this.select.focus();
152
+ }
153
+ }
154
+
155
+ isEnteringNew(): boolean {
156
+ return this.enteringNew;
157
+ }
158
+
159
+ focus(): void {
160
+ if (this.enteringNew) {
161
+ this.pathInput.focus();
162
+ } else {
163
+ this.select.focus();
164
+ }
165
+ }
166
+
167
+ reset(): void {
168
+ this.enteringNew = false;
169
+ this.pathInput.value = "";
170
+ this.nameInput.value = "";
171
+ this.headerText.content = "";
172
+ this.select.options = [];
173
+ }
174
+ }
package/src/config.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { parse, stringify } from "smol-toml";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { existsSync, mkdirSync } from "fs";
5
+
6
+ const CONFIG_DIR = join(homedir(), ".config", "session-md");
7
+ const CONFIG_PATH = join(CONFIG_DIR, "config.toml");
8
+
9
+ export interface Config {
10
+ default_target: string;
11
+ targets: Record<string, string>;
12
+ sources: {
13
+ claude_code?: string;
14
+ opencode?: string;
15
+ claude_export?: string;
16
+ memorizer_url?: string;
17
+ memorizer_output?: string;
18
+ };
19
+ theme?: Record<string, string>;
20
+ }
21
+
22
+ const DEFAULT_CONFIG: Config = {
23
+ default_target: join(homedir(), "notes", "claude-chats"),
24
+ targets: {
25
+ vault: join(homedir(), "notes", "claude-chats"),
26
+ },
27
+ sources: {
28
+ claude_code: join(homedir(), ".claude", "projects"),
29
+ opencode: join(homedir(), ".local", "share", "opencode", "storage"),
30
+ },
31
+ };
32
+
33
+ function expandTilde(p: string): string {
34
+ if (p.startsWith("~/") || p === "~") {
35
+ return join(homedir(), p.slice(1));
36
+ }
37
+ return p;
38
+ }
39
+
40
+ function expandPaths(config: Config): Config {
41
+ config.default_target = expandTilde(config.default_target);
42
+
43
+ for (const [key, val] of Object.entries(config.targets)) {
44
+ config.targets[key] = expandTilde(val);
45
+ }
46
+
47
+ const sources = config.sources;
48
+ if (sources.claude_code) sources.claude_code = expandTilde(sources.claude_code);
49
+ if (sources.opencode) sources.opencode = expandTilde(sources.opencode);
50
+ if (sources.claude_export) sources.claude_export = expandTilde(sources.claude_export);
51
+ if (sources.memorizer_output)
52
+ sources.memorizer_output = expandTilde(sources.memorizer_output);
53
+
54
+ return config;
55
+ }
56
+
57
+ export async function loadConfig(): Promise<Config> {
58
+ if (!existsSync(CONFIG_PATH)) {
59
+ mkdirSync(CONFIG_DIR, { recursive: true });
60
+ await Bun.write(CONFIG_PATH, stringify(DEFAULT_CONFIG as any));
61
+ return expandPaths({ ...DEFAULT_CONFIG });
62
+ }
63
+
64
+ const raw = await Bun.file(CONFIG_PATH).text();
65
+ const parsed = parse(raw) as unknown as Config;
66
+
67
+ const config: Config = {
68
+ default_target: parsed.default_target ?? DEFAULT_CONFIG.default_target,
69
+ targets: parsed.targets ?? DEFAULT_CONFIG.targets,
70
+ sources: { ...DEFAULT_CONFIG.sources, ...parsed.sources },
71
+ theme: (parsed as any).theme ?? undefined,
72
+ };
73
+
74
+ return expandPaths(config);
75
+ }
76
+
77
+ export async function addTarget(name: string, path: string): Promise<void> {
78
+ const raw = await Bun.file(CONFIG_PATH).text();
79
+ const parsed = parse(raw) as any;
80
+
81
+ if (!parsed.targets) parsed.targets = {};
82
+ parsed.targets[name] = path;
83
+
84
+ await Bun.write(CONFIG_PATH, stringify(parsed));
85
+ }
@@ -0,0 +1,23 @@
1
+ import { join, dirname } from "path";
2
+ import { mkdirSync, existsSync } from "fs";
3
+ import type { SessionEntry } from "./import/types.ts";
4
+ import { loadSessionMarkdownSync } from "./import/loader.ts";
5
+
6
+ export async function copySessionsToTarget(
7
+ sessions: SessionEntry[],
8
+ targetPath: string,
9
+ ): Promise<void> {
10
+ mkdirSync(targetPath, { recursive: true });
11
+
12
+ for (const session of sessions) {
13
+ const md = loadSessionMarkdownSync(session);
14
+ const dest = join(targetPath, session.filename);
15
+ const destDir = dirname(dest);
16
+
17
+ if (!existsSync(destDir)) {
18
+ mkdirSync(destDir, { recursive: true });
19
+ }
20
+
21
+ await Bun.write(dest, md);
22
+ }
23
+ }
@@ -0,0 +1,184 @@
1
+ import { join, basename, dirname } from "path";
2
+ import { readdirSync, existsSync, statSync, readFileSync } from "fs";
3
+ import {
4
+ type SessionEntry,
5
+ type SessionMeta,
6
+ buildFrontmatter,
7
+ formatDate,
8
+ truncateTitle,
9
+ } from "./types.ts";
10
+
11
+ interface ContentBlock {
12
+ type: string;
13
+ text?: string;
14
+ thinking?: string;
15
+ name?: string;
16
+ input?: Record<string, unknown>;
17
+ tool_use_id?: string;
18
+ content?: ContentBlock[];
19
+ }
20
+
21
+ function extractTextFromContent(
22
+ content: string | ContentBlock[],
23
+ ): string {
24
+ if (typeof content === "string") return content;
25
+ if (!Array.isArray(content)) return "";
26
+
27
+ const parts: string[] = [];
28
+ for (const block of content) {
29
+ if (block.type === "text" && block.text) {
30
+ parts.push(block.text);
31
+ } else if (block.type === "tool_use" && block.name) {
32
+ const inputStr = block.input
33
+ ? JSON.stringify(block.input, null, 2)
34
+ : "";
35
+ parts.push(`> **Tool**: ${block.name}\n> \`\`\`\n> ${inputStr}\n> \`\`\``);
36
+ }
37
+ }
38
+ return parts.join("\n\n");
39
+ }
40
+
41
+ /**
42
+ * Fast scan: only reads first few lines to extract title, no full parse.
43
+ */
44
+ export function scanClaudeCodeSessions(
45
+ sourcePath: string,
46
+ ): SessionEntry[] {
47
+ if (!existsSync(sourcePath)) return [];
48
+
49
+ const entries: SessionEntry[] = [];
50
+
51
+ const projectDirs = readdirSync(sourcePath, { withFileTypes: true })
52
+ .filter((d) => d.isDirectory());
53
+
54
+ for (const projDir of projectDirs) {
55
+ const projPath = join(sourcePath, projDir.name);
56
+ let jsonlFiles: string[];
57
+ try {
58
+ jsonlFiles = readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
59
+ } catch {
60
+ continue;
61
+ }
62
+
63
+ for (const file of jsonlFiles) {
64
+ const filePath = join(projPath, file);
65
+ const sessionId = basename(file, ".jsonl");
66
+
67
+ try {
68
+ const stat = statSync(filePath);
69
+ if (stat.size === 0) continue;
70
+
71
+ // Quick title extraction: read first ~4KB, find first user message
72
+ const fd = require("fs").openSync(filePath, "r");
73
+ const buf = Buffer.alloc(Math.min(stat.size, 4096));
74
+ require("fs").readSync(fd, buf, 0, buf.length, 0);
75
+ require("fs").closeSync(fd);
76
+
77
+ let title = `Session ${sessionId.slice(0, 8)}`;
78
+ const chunk = buf.toString("utf-8");
79
+ const lines = chunk.split("\n");
80
+ for (const line of lines) {
81
+ if (!line.trim()) continue;
82
+ try {
83
+ const parsed = JSON.parse(line);
84
+ if (parsed.type === "user" && parsed.message?.content) {
85
+ const text =
86
+ typeof parsed.message.content === "string"
87
+ ? parsed.message.content
88
+ : "";
89
+ if (text) {
90
+ title = truncateTitle(text.split("\n")[0] ?? text);
91
+ break;
92
+ }
93
+ }
94
+ } catch {
95
+ // Incomplete JSON line at buffer boundary — skip
96
+ }
97
+ }
98
+
99
+ const date = formatDate(new Date(stat.mtime));
100
+ const shortId = sessionId.slice(0, 8);
101
+
102
+ entries.push({
103
+ filename: `${date}-${shortId}.md`,
104
+ sourcePath: filePath,
105
+ contentHash: String(stat.size),
106
+ meta: {
107
+ title,
108
+ id: sessionId,
109
+ source: "claude-code",
110
+ project: projDir.name,
111
+ created_at: stat.mtime.toISOString(),
112
+ updated_at: stat.mtime.toISOString(),
113
+ },
114
+ });
115
+ } catch {
116
+ // Skip corrupt files
117
+ }
118
+ }
119
+ }
120
+
121
+ return entries;
122
+ }
123
+
124
+ /**
125
+ * Full parse: reads entire JSONL file and generates markdown.
126
+ */
127
+ export function claudeCodeSessionToMd(jsonlPath: string): string {
128
+ const text = readFileSync(jsonlPath, "utf-8");
129
+ const lines = text.split("\n").filter((l: string) => l.trim());
130
+
131
+ let title = "";
132
+ let firstTimestamp = "";
133
+ let lastTimestamp = "";
134
+ let sessionId = "";
135
+ const messages: string[] = [];
136
+
137
+ for (const line of lines) {
138
+ let parsed: any;
139
+ try {
140
+ parsed = JSON.parse(line);
141
+ } catch {
142
+ continue;
143
+ }
144
+
145
+ if (!sessionId && parsed.sessionId) sessionId = parsed.sessionId;
146
+ if (!firstTimestamp && parsed.timestamp) firstTimestamp = parsed.timestamp;
147
+ if (parsed.timestamp) lastTimestamp = parsed.timestamp;
148
+
149
+ if (parsed.type === "user" && parsed.message) {
150
+ const text = extractTextFromContent(parsed.message.content);
151
+ if (!text) continue;
152
+
153
+ if (Array.isArray(parsed.message.content)) {
154
+ const hasToolResult = parsed.message.content.some(
155
+ (b: ContentBlock) => b.type === "tool_result",
156
+ );
157
+ if (hasToolResult) continue;
158
+ }
159
+
160
+ if (!title) title = truncateTitle(text.split("\n")[0] ?? text);
161
+ messages.push(`## Human\n\n${text}`);
162
+ } else if (parsed.type === "assistant" && parsed.message) {
163
+ const text = extractTextFromContent(parsed.message.content);
164
+ if (text) {
165
+ messages.push(`## Claude\n\n${text}`);
166
+ }
167
+ }
168
+ }
169
+
170
+ if (!title) title = `Session ${sessionId.slice(0, 8)}`;
171
+
172
+ const projectDir = basename(dirname(jsonlPath));
173
+ const meta: SessionMeta = {
174
+ title,
175
+ id: sessionId,
176
+ source: "claude-code",
177
+ project: projectDir,
178
+ created_at: firstTimestamp || new Date().toISOString(),
179
+ updated_at: lastTimestamp || new Date().toISOString(),
180
+ };
181
+
182
+ const frontmatter = buildFrontmatter(meta);
183
+ return `${frontmatter}\n\n# ${title}\n\n${messages.join("\n\n")}`;
184
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, statSync } from "fs";
2
+ import {
3
+ type SessionEntry,
4
+ type SessionMeta,
5
+ buildFrontmatter,
6
+ formatDate,
7
+ slugify,
8
+ truncateTitle,
9
+ } from "./types.ts";
10
+
11
+ interface ClaudeConversation {
12
+ uuid: string;
13
+ name: string;
14
+ created_at: string;
15
+ updated_at: string;
16
+ chat_messages: ChatMessage[];
17
+ }
18
+
19
+ interface ChatMessage {
20
+ uuid: string;
21
+ sender: string;
22
+ text: string;
23
+ created_at: string;
24
+ content?: ContentBlock[];
25
+ }
26
+
27
+ interface ContentBlock {
28
+ type: string;
29
+ text?: string;
30
+ }
31
+
32
+ function extractText(msg: ChatMessage): string {
33
+ if (msg.text) return msg.text;
34
+ if (msg.content) {
35
+ return msg.content
36
+ .filter((b) => b.type === "text" && b.text)
37
+ .map((b) => b.text!)
38
+ .join("\n\n");
39
+ }
40
+ return "";
41
+ }
42
+
43
+ function conversationToMd(conv: ClaudeConversation): string {
44
+ const title = conv.name || truncateTitle(conv.chat_messages[0]?.text ?? "Untitled");
45
+
46
+ const meta: SessionMeta = {
47
+ title,
48
+ id: conv.uuid,
49
+ source: "claude-export",
50
+ created_at: conv.created_at,
51
+ updated_at: conv.updated_at,
52
+ };
53
+
54
+ const messages: string[] = [];
55
+ for (const msg of conv.chat_messages) {
56
+ const text = extractText(msg);
57
+ if (!text) continue;
58
+
59
+ const role = msg.sender === "human" ? "Human" : "Claude";
60
+ messages.push(`## ${role}\n\n${text}`);
61
+ }
62
+
63
+ const frontmatter = buildFrontmatter(meta);
64
+ return `${frontmatter}\n\n# ${title}\n\n${messages.join("\n\n")}`;
65
+ }
66
+
67
+ /**
68
+ * Import Claude.ai export (ZIP or conversations.json).
69
+ * Returns SessionEntry[] with pre-generated markdown.
70
+ */
71
+ export async function importClaudeExport(
72
+ filePath: string,
73
+ ): Promise<SessionEntry[]> {
74
+ if (!existsSync(filePath)) return [];
75
+
76
+ let conversations: ClaudeConversation[];
77
+
78
+ if (filePath.endsWith(".zip")) {
79
+ const proc = Bun.spawnSync({
80
+ cmd: ["unzip", "-p", filePath, "conversations.json"],
81
+ stdout: "pipe",
82
+ });
83
+
84
+ if (proc.exitCode !== 0) {
85
+ throw new Error("Failed to extract conversations.json from ZIP");
86
+ }
87
+
88
+ conversations = JSON.parse(new TextDecoder().decode(proc.stdout));
89
+ } else {
90
+ const raw = await Bun.file(filePath).text();
91
+ conversations = JSON.parse(raw);
92
+ }
93
+
94
+ if (!Array.isArray(conversations)) return [];
95
+
96
+ const fileHash = String(statSync(filePath).size);
97
+ const entries: SessionEntry[] = [];
98
+ for (const conv of conversations) {
99
+ if (!conv.chat_messages || conv.chat_messages.length === 0) continue;
100
+
101
+ const md = conversationToMd(conv);
102
+ const date = formatDate(new Date(conv.created_at));
103
+ const slug = slugify(conv.name || "untitled");
104
+
105
+ entries.push({
106
+ filename: `${date}-${slug}.md`,
107
+ sourcePath: filePath,
108
+ contentHash: fileHash,
109
+ md,
110
+ meta: {
111
+ title: conv.name || "Untitled",
112
+ id: conv.uuid,
113
+ source: "claude-export",
114
+ created_at: conv.created_at,
115
+ updated_at: conv.updated_at,
116
+ },
117
+ });
118
+ }
119
+
120
+ return entries;
121
+ }
122
+