anvilsign in

collin/strudel-claude

pre-demo / node-tui / sessions.ts
1// Sessions: a pattern and the conversation that produced it, kept together.
2//
3// This mirrors the web app's model (src/sessions.js), where a session owns both
4// the editor contents and the chat history. Keeping them together is the point:
5// a pattern without the conversation that shaped it loses the "why", and asking
6// Claude to change something it can't see the history of produces worse answers.
7//
8// Storage is one JSON file per session in a plain directory, rather than the
9// browser's single localStorage blob. A directory is greppable, diffable, and
10// survives this program — and one file per session means an unparseable session
11// costs you that session, not the whole library.
12//
13// `.str` files in the same directory are read as code-only sessions. That's the
14// plain-text import/export path: exactly the text you'd type into the editor,
15// with nothing else around it, so a pattern can move between this, strudel.cc,
16// and a gist without ceremony.
17import fs from 'node:fs/promises';
18import path from 'node:path';
19import type { Turn } from './claude.ts';
20
21const JSON_EXT = '.json';
22const PLAIN_EXT = '.str';
23
24/** Where sessions live. Override with STRUDEL_SESSIONS. */
25export const SESSIONS_DIR = process.env['STRUDEL_SESSIONS']
26 ?? path.join(process.cwd(), 'sessions');
27
28/** What the list needs to show a session without reading its body. */
29export interface SessionMeta {
30 name: string;
31 file: string;
32 updatedAt: number;
33 /** False for a `.str` file, which carries no conversation. */
34 hasHistory: boolean;
35}
36
37export interface Session extends SessionMeta {
38 code: string;
39 history: Turn[];
40}
41
42/** Filenames are user input; keep them to something a shell won't fight. */
43export function slugify(name: string): string {
44 const clean = name
45 .trim()
46 .toLowerCase()
47 .replace(/[^a-z0-9._-]+/g, '-')
48 .replace(/^[-.]+|[-.]+$/g, '')
49 .slice(0, 64);
50 return clean || 'untitled';
51}
52
53/**
54 * A name derived from the first thing the user asked for.
55 *
56 * Sessions accumulate whether or not you name them, and "untitled-4" tells you
57 * nothing later. The opening request is usually a good description of what the
58 * session became.
59 */
60export function nameFrom(history: readonly Turn[]): string | null {
61 const first = history.find((t) => t.role === 'user')?.content.trim();
62 if (!first) return null;
63 return first.length > 32 ? `${first.slice(0, 32)}…` : first;
64}
65
66export async function list(): Promise<SessionMeta[]> {
67 let entries;
68 try {
69 entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true });
70 } catch {
71 return []; // no library yet — the first save creates it
72 }
73
74 const found = new Map<string, SessionMeta>();
75 for (const entry of entries) {
76 if (!entry.isFile()) continue;
77 const isJson = entry.name.endsWith(JSON_EXT);
78 const isPlain = entry.name.endsWith(PLAIN_EXT);
79 if (!isJson && !isPlain) continue;
80
81 const name = entry.name.slice(0, -(isJson ? JSON_EXT : PLAIN_EXT).length);
82 // A session saved from here shadows a plain export of the same name — the
83 // JSON is the one with the conversation in it.
84 if (isPlain && found.has(name)) continue;
85
86 const file = path.join(SESSIONS_DIR, entry.name);
87 const stat = await fs.stat(file);
88 found.set(name, { name, file, updatedAt: stat.mtimeMs, hasHistory: isJson });
89 }
90
91 // Most recently touched first: the session you're working on is the one you
92 // want to get back to.
93 return [...found.values()].sort((a, b) => b.updatedAt - a.updatedAt);
94}
95
96export async function load(meta: SessionMeta): Promise<Session> {
97 const raw = await fs.readFile(meta.file, 'utf8');
98
99 if (!meta.file.endsWith(JSON_EXT)) {
100 return { ...meta, code: raw, history: [] };
101 }
102
103 const parsed = JSON.parse(raw) as { code?: unknown; history?: unknown };
104 return {
105 ...meta,
106 code: typeof parsed.code === 'string' ? parsed.code : '',
107 // Anything malformed in the history is dropped rather than thrown over:
108 // a corrupt transcript should cost you the transcript, not the pattern.
109 history: Array.isArray(parsed.history)
110 ? (parsed.history.filter(
111 (t): t is Turn =>
112 typeof t === 'object' && t !== null
113 && (t as Turn).role !== undefined && typeof (t as Turn).content === 'string',
114 ))
115 : [],
116 };
117}
118
119export async function save(
120 name: string,
121 code: string,
122 history: readonly Turn[],
123): Promise<SessionMeta> {
124 await fs.mkdir(SESSIONS_DIR, { recursive: true });
125 const slug = slugify(name);
126 const file = path.join(SESSIONS_DIR, `${slug}${JSON_EXT}`);
127 const body = JSON.stringify({ name: slug, code, history }, null, 2);
128 await fs.writeFile(file, `${body}\n`, 'utf8');
129 const stat = await fs.stat(file);
130 return { name: slug, file, updatedAt: stat.mtimeMs, hasHistory: true };
131}
132
133/** Write just the pattern, as plain text — the export half of the `.str` path. */
134export async function exportPlain(name: string, code: string): Promise<string> {
135 await fs.mkdir(SESSIONS_DIR, { recursive: true });
136 const file = path.join(SESSIONS_DIR, `${slugify(name)}${PLAIN_EXT}`);
137 await fs.writeFile(file, code.endsWith('\n') ? code : `${code}\n`, 'utf8');
138 return file;
139}