@24klynx/session 0.1.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,154 @@
1
+ import { Message, Session, SessionId } from "@lynx/core";
2
+ import Database from "better-sqlite3";
3
+
4
+ //#region src/recovery.d.ts
5
+ interface RecoveryResult {
6
+ /** The session that was interrupted. */
7
+ session: Session;
8
+ /** Messages up to the last complete turn boundary. */
9
+ safeMessages: Message[];
10
+ /** The last complete turn index. */
11
+ lastTurnIndex: number;
12
+ /** Whether the session was interrupted mid‑turn. */
13
+ wasInterrupted: boolean;
14
+ }
15
+ /**
16
+ * Detect whether a session was interrupted and calculate the safe
17
+ * recovery point (last complete turn boundary).
18
+ *
19
+ * Returns `undefined` if no interruption was detected.
20
+ */
21
+ declare function detectTurnInterruption(db: Database.Database, sessionId: SessionId): RecoveryResult | undefined;
22
+ /**
23
+ * Mark a session as crashed so it can be recovered next startup.
24
+ */
25
+ declare function markSessionCrashed(session: Session): Session;
26
+ /**
27
+ * Clear the crash marker after successful recovery.
28
+ */
29
+ declare function clearCrashMarker(session: Session): Session;
30
+ //#endregion
31
+ //#region src/manager.d.ts
32
+ interface SessionManager {
33
+ create(label: string, workspace: string, parentSessionId?: SessionId): Session;
34
+ load(id: SessionId): Session;
35
+ save(session: Session, messages: Message[]): void;
36
+ delete(id: SessionId): void;
37
+ fork(id: SessionId, newLabel?: string): Session;
38
+ rename(id: SessionId, newLabel: string): Session;
39
+ list(limit?: number): Session[];
40
+ search(query: string, limit?: number): Session[];
41
+ saveDraft(sessionId: SessionId, messages: Message[]): void;
42
+ onDraftDirty(sessionId: SessionId, messages: Message[]): void;
43
+ flushDraft(sessionId: SessionId, messages: Message[]): void;
44
+ discardDraft(sessionId: SessionId): void;
45
+ restoreDraft(sessionId: SessionId): Message[] | undefined;
46
+ getLastSession(): Session | undefined;
47
+ prune(retentionDays: number): number;
48
+ checkpoint(session: Session, messages: Message[]): void;
49
+ recover(id: SessionId): RecoveryResult | undefined;
50
+ markCrashed(session: Session): Session;
51
+ clearCrashed(session: Session): Session;
52
+ destroy(): void;
53
+ }
54
+ /**
55
+ * Create a SessionManager backed by the given database.
56
+ *
57
+ * Every method returns plain objects — no internal mutable state
58
+ * (except the draft manager's timers).
59
+ */
60
+ declare function createSessionManager(db: Database.Database): SessionManager;
61
+ //#endregion
62
+ //#region src/draft.d.ts
63
+ interface DraftManager {
64
+ /** Record a message change and schedule a flush. */
65
+ onDirty(sessionId: SessionId, messages: Message[]): void;
66
+ /** Flush immediately and cancel pending timer. */
67
+ flushNow(sessionId: SessionId, messages: Message[]): void;
68
+ /** Cancel the pending flush and remove the draft from disk. */
69
+ discard(sessionId: SessionId): void;
70
+ /** Restore the last saved draft (if any). */
71
+ restore(sessionId: SessionId): Message[] | undefined;
72
+ /** Cancel all pending timers (call on shutdown). */
73
+ destroy(): void;
74
+ }
75
+ /**
76
+ * Create a draft auto‑save manager.
77
+ *
78
+ * One instance per Database — the manager holds timer handles
79
+ * so it must be destroyed on shutdown.
80
+ */
81
+ declare function createDraftManager(db: Database.Database): DraftManager;
82
+ //#endregion
83
+ //#region src/picker.d.ts
84
+ interface ScoredSession {
85
+ session: Session;
86
+ /** Composite score — higher = better match. */
87
+ score: number;
88
+ /** Which dimension contributed most. */
89
+ breakdown: {
90
+ recency: number;
91
+ relevance: number;
92
+ density: number;
93
+ };
94
+ }
95
+ interface PickerOptions {
96
+ /** Maximum number of results. */
97
+ limit?: number;
98
+ }
99
+ /**
100
+ * Score and rank sessions for a user query.
101
+ *
102
+ * An empty query returns sessions ranked by recency only.
103
+ *
104
+ * ```ts
105
+ * const results = pickSessions(sessions, "my-project");
106
+ * for (const r of results) {
107
+ * console.log(`${r.session.label}: ${r.score}`);
108
+ * }
109
+ * ```
110
+ */
111
+ declare function pickSessions(sessions: Session[], query: string, opts?: PickerOptions): ScoredSession[];
112
+ //#endregion
113
+ //#region src/store.d.ts
114
+ /**
115
+ * Persist a session to SQLite.
116
+ * Message bodies are NOT stored here — they live in JSON files
117
+ * and are loaded on demand.
118
+ */
119
+ declare function saveSession(db: Database.Database, session: Session, messageCount: number): void;
120
+ /** Load session metadata (without messages) from SQLite. */
121
+ declare function loadSession(db: Database.Database, id: SessionId): Session | undefined;
122
+ /** Delete a session and its draft. */
123
+ declare function deleteSession(db: Database.Database, id: SessionId): void;
124
+ /** Update mutable session fields. */
125
+ declare function updateSession(db: Database.Database, id: SessionId, patch: {
126
+ label?: string;
127
+ workspace?: string;
128
+ messageCount?: number;
129
+ metadata?: Record<string, unknown>;
130
+ }): void;
131
+ /** List session metadata ordered by updatedAt desc. */
132
+ declare function listSessions(db: Database.Database, limit?: number): Session[];
133
+ /**
134
+ * Get the most recently updated session.
135
+ * Returns `undefined` when no sessions exist (first launch).
136
+ */
137
+ declare function getLastSession(db: Database.Database): Session | undefined;
138
+ /**
139
+ * Delete sessions whose `updated_at` is older than the cutoff timestamp.
140
+ * Returns the number of deleted rows.
141
+ *
142
+ * Used by SessionManager.prune() to clean up stale sessions
143
+ * and by CLI maintenance commands.
144
+ */
145
+ declare function pruneSessions(db: Database.Database, cutoffTimestamp: number): number;
146
+ /** Save a draft for a session. */
147
+ declare function saveDraft(db: Database.Database, sessionId: SessionId, messages: Message[]): void;
148
+ /** Load a saved draft. Returns undefined if none exists. */
149
+ declare function loadDraft(db: Database.Database, sessionId: SessionId): Message[] | undefined;
150
+ /** Delete a draft (e.g. after successful save). */
151
+ declare function deleteDraft(db: Database.Database, sessionId: SessionId): void;
152
+ //#endregion
153
+ export { type DraftManager, type PickerOptions, type RecoveryResult, type ScoredSession, type SessionManager, clearCrashMarker, createDraftManager, createSessionManager, deleteDraft, deleteSession, detectTurnInterruption, getLastSession, listSessions, loadDraft, loadSession, markSessionCrashed, pickSessions, pruneSessions, saveDraft, saveSession, updateSession };
154
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/recovery.ts","../src/manager.ts","../src/draft.ts","../src/picker.ts","../src/store.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,503 @@
1
+ import { SessionError, SessionNotFoundError, StorageCorruptedError, StorageError, asSessionId } from "@lynx/core";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { copyFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ //#region src/store.ts
6
+ let _cache;
7
+ /** Clear the cached prepared statements (call when the database is closed). */
8
+ function clearStatementCache() {
9
+ _cache = void 0;
10
+ }
11
+ function getCache(db) {
12
+ if (!_cache) _cache = {
13
+ insertSession: db.prepare(`
14
+ INSERT OR REPLACE INTO sessions (id, label, workspace, parent_id, forked_from_message_count, message_count, metadata, created_at, updated_at)
15
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
16
+ `),
17
+ updateSession: db.prepare(`
18
+ UPDATE sessions SET label = ?, workspace = ?, message_count = ?, metadata = ?, updated_at = ?
19
+ WHERE id = ?
20
+ `),
21
+ deleteSession: db.prepare("DELETE FROM sessions WHERE id = ?"),
22
+ loadSession: db.prepare("SELECT * FROM sessions WHERE id = ?"),
23
+ listSessions: db.prepare(`
24
+ SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT ?
25
+ `),
26
+ getLastSession: db.prepare(`
27
+ SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT 1
28
+ `),
29
+ pruneSessions: db.prepare(`
30
+ DELETE FROM sessions WHERE updated_at < ?
31
+ `),
32
+ insertDraft: db.prepare(`
33
+ INSERT OR REPLACE INTO drafts (session_id, messages_json, updated_at)
34
+ VALUES (?, ?, ?)
35
+ `),
36
+ loadDraft: db.prepare("SELECT messages_json FROM drafts WHERE session_id = ?"),
37
+ deleteDraft: db.prepare("DELETE FROM drafts WHERE session_id = ?")
38
+ };
39
+ return _cache;
40
+ }
41
+ function rowToSession(row) {
42
+ return {
43
+ id: row.id,
44
+ label: row.label,
45
+ workspace: row.workspace,
46
+ messages: [],
47
+ parentSessionId: row.parent_id ?? void 0,
48
+ forkedFromMessageCount: row.forked_from_message_count ?? void 0,
49
+ createdAt: row.created_at,
50
+ updatedAt: row.updated_at,
51
+ metadata: JSON.parse(row.metadata)
52
+ };
53
+ }
54
+ /**
55
+ * Persist a session to SQLite.
56
+ * Message bodies are NOT stored here — they live in JSON files
57
+ * and are loaded on demand.
58
+ */
59
+ function saveSession(db, session, messageCount) {
60
+ getCache(db).insertSession.run(session.id, session.label, session.workspace, session.parentSessionId ?? null, session.forkedFromMessageCount ?? null, messageCount, JSON.stringify(session.metadata), session.createdAt, session.updatedAt);
61
+ }
62
+ /** Load session metadata (without messages) from SQLite. */
63
+ function loadSession(db, id) {
64
+ const row = getCache(db).loadSession.get(id);
65
+ if (!row) return void 0;
66
+ return rowToSession(row);
67
+ }
68
+ /** Delete a session and its draft. */
69
+ function deleteSession(db, id) {
70
+ if (getCache(db).deleteSession.run(id).changes === 0) throw new StorageError(`Session not found: ${id}`, {
71
+ category: "storage",
72
+ recoverable: false,
73
+ retryable: false,
74
+ userVisible: true
75
+ });
76
+ }
77
+ /** Update mutable session fields. */
78
+ function updateSession(db, id, patch) {
79
+ const existing = loadSession(db, id);
80
+ if (!existing) throw new StorageError(`Session not found: ${id}`, {
81
+ category: "storage",
82
+ recoverable: false,
83
+ retryable: false
84
+ });
85
+ getCache(db).updateSession.run(patch.label ?? existing.label, patch.workspace ?? existing.workspace, patch.messageCount ?? 0, JSON.stringify(patch.metadata ?? existing.metadata), Date.now(), id);
86
+ }
87
+ /** List session metadata ordered by updatedAt desc. */
88
+ function listSessions(db, limit = 50) {
89
+ return getCache(db).listSessions.all(limit).map(rowToSession);
90
+ }
91
+ /**
92
+ * Get the most recently updated session.
93
+ * Returns `undefined` when no sessions exist (first launch).
94
+ */
95
+ function getLastSession(db) {
96
+ const row = getCache(db).getLastSession.get();
97
+ return row ? rowToSession(row) : void 0;
98
+ }
99
+ /**
100
+ * Delete sessions whose `updated_at` is older than the cutoff timestamp.
101
+ * Returns the number of deleted rows.
102
+ *
103
+ * Used by SessionManager.prune() to clean up stale sessions
104
+ * and by CLI maintenance commands.
105
+ */
106
+ function pruneSessions(db, cutoffTimestamp) {
107
+ return getCache(db).pruneSessions.run(cutoffTimestamp).changes;
108
+ }
109
+ /** Save a draft for a session. */
110
+ function saveDraft(db, sessionId, messages) {
111
+ getCache(db).insertDraft.run(sessionId, JSON.stringify(messages), Date.now());
112
+ }
113
+ /** Load a saved draft. Returns undefined if none exists. */
114
+ function loadDraft(db, sessionId) {
115
+ const row = getCache(db).loadDraft.get(sessionId);
116
+ if (!row) return void 0;
117
+ try {
118
+ return JSON.parse(row.messages_json);
119
+ } catch {
120
+ throw new StorageCorruptedError(`Draft JSON is corrupted for session ${sessionId}`);
121
+ }
122
+ }
123
+ /** Delete a draft (e.g. after successful save). */
124
+ function deleteDraft(db, sessionId) {
125
+ getCache(db).deleteDraft.run(sessionId);
126
+ }
127
+ //#endregion
128
+ //#region src/draft.ts
129
+ /** How long to wait after the last message before flushing to disk. */
130
+ const DEBOUNCE_MS = 2e3;
131
+ /**
132
+ * Create a draft auto‑save manager.
133
+ *
134
+ * One instance per Database — the manager holds timer handles
135
+ * so it must be destroyed on shutdown.
136
+ */
137
+ function createDraftManager(db) {
138
+ const timers = /* @__PURE__ */ new Map();
139
+ return {
140
+ onDirty(sessionId, messages) {
141
+ const existing = timers.get(sessionId);
142
+ if (existing) clearTimeout(existing);
143
+ timers.set(sessionId, setTimeout(() => {
144
+ saveDraft(db, sessionId, messages);
145
+ timers.delete(sessionId);
146
+ }, DEBOUNCE_MS));
147
+ },
148
+ flushNow(sessionId, messages) {
149
+ const existing = timers.get(sessionId);
150
+ if (existing) {
151
+ clearTimeout(existing);
152
+ timers.delete(sessionId);
153
+ }
154
+ saveDraft(db, sessionId, messages);
155
+ },
156
+ discard(sessionId) {
157
+ const existing = timers.get(sessionId);
158
+ if (existing) {
159
+ clearTimeout(existing);
160
+ timers.delete(sessionId);
161
+ }
162
+ try {
163
+ deleteDraft(db, sessionId);
164
+ } catch {}
165
+ },
166
+ restore(sessionId) {
167
+ return loadDraft(db, sessionId);
168
+ },
169
+ destroy() {
170
+ for (const timer of timers.values()) clearTimeout(timer);
171
+ timers.clear();
172
+ }
173
+ };
174
+ }
175
+ //#endregion
176
+ //#region src/recovery.ts
177
+ /**
178
+ * Detect whether a session was interrupted and calculate the safe
179
+ * recovery point (last complete turn boundary).
180
+ *
181
+ * Returns `undefined` if no interruption was detected.
182
+ */
183
+ function detectTurnInterruption(db, sessionId) {
184
+ const session = loadSession(db, sessionId);
185
+ if (!session) return void 0;
186
+ if (!session.metadata.crashed) return void 0;
187
+ const draft = loadDraft(db, sessionId);
188
+ if (!draft || draft.length === 0) return void 0;
189
+ let lastTurnIndex = 0;
190
+ let lastCompleteIndex = 0;
191
+ for (let i = draft.length - 1; i >= 0; i--) {
192
+ const msg = draft[i];
193
+ if (msg.role === "assistant") {
194
+ lastTurnIndex = msg.turnIndex;
195
+ lastCompleteIndex = i + 1;
196
+ break;
197
+ }
198
+ }
199
+ const safeMessages = draft.slice(0, lastCompleteIndex);
200
+ return {
201
+ session: {
202
+ ...session,
203
+ metadata: {
204
+ ...session.metadata,
205
+ crashed: false
206
+ }
207
+ },
208
+ safeMessages,
209
+ lastTurnIndex,
210
+ wasInterrupted: safeMessages.length < draft.length
211
+ };
212
+ }
213
+ /**
214
+ * Mark a session as crashed so it can be recovered next startup.
215
+ */
216
+ function markSessionCrashed(session) {
217
+ return {
218
+ ...session,
219
+ metadata: {
220
+ ...session.metadata,
221
+ crashed: true
222
+ },
223
+ updatedAt: Date.now()
224
+ };
225
+ }
226
+ /**
227
+ * Clear the crash marker after successful recovery.
228
+ */
229
+ function clearCrashMarker(session) {
230
+ const { crashed: _crashed, ...rest } = session.metadata;
231
+ return {
232
+ ...session,
233
+ metadata: rest,
234
+ updatedAt: Date.now()
235
+ };
236
+ }
237
+ //#endregion
238
+ //#region src/picker.ts
239
+ const NOW_SCORE = 100;
240
+ const DAY_MS = 864e5;
241
+ const DECAY_FACTOR = .5;
242
+ /**
243
+ * Score and rank sessions for a user query.
244
+ *
245
+ * An empty query returns sessions ranked by recency only.
246
+ *
247
+ * ```ts
248
+ * const results = pickSessions(sessions, "my-project");
249
+ * for (const r of results) {
250
+ * console.log(`${r.session.label}: ${r.score}`);
251
+ * }
252
+ * ```
253
+ */
254
+ function pickSessions(sessions, query, opts = {}) {
255
+ const q = query.toLowerCase().trim();
256
+ const limit = opts.limit ?? 20;
257
+ const scored = sessions.map((session) => {
258
+ const recency = scoreRecency(session.updatedAt);
259
+ const relevance = q ? scoreRelevance(session.label, q) : 0;
260
+ const density = scoreMessageCount(session.messages.length);
261
+ return {
262
+ session,
263
+ score: .4 * recency + .4 * relevance + .2 * density,
264
+ breakdown: {
265
+ recency,
266
+ relevance,
267
+ density
268
+ }
269
+ };
270
+ });
271
+ scored.sort((a, b) => b.score - a.score);
272
+ return scored.slice(0, limit);
273
+ }
274
+ /**
275
+ * Recency score: linear decay over the last 7 days.
276
+ * Sessions older than 7 days get a floor of 1.
277
+ */
278
+ function scoreRecency(updatedAt) {
279
+ const ageDays = (Date.now() - updatedAt) / DAY_MS;
280
+ if (ageDays <= 0) return NOW_SCORE;
281
+ if (ageDays >= 7) return 1;
282
+ return NOW_SCORE * Math.pow(DECAY_FACTOR, ageDays);
283
+ }
284
+ /**
285
+ * Relevance score: quality of substring match.
286
+ *
287
+ * exact match: 100
288
+ * starts with query: 80
289
+ * contains substring: 60
290
+ * no match: 0
291
+ */
292
+ function scoreRelevance(label, query) {
293
+ const lower = label.toLowerCase();
294
+ if (lower === query) return 100;
295
+ if (lower.startsWith(query)) return 80;
296
+ if (lower.includes(query)) return 60;
297
+ return 0;
298
+ }
299
+ /**
300
+ * Message count density: log‑scale so huge sessions don't dominate.
301
+ */
302
+ function scoreMessageCount(count) {
303
+ if (count === 0) return 0;
304
+ return Math.min(100, Math.log2(count + 1) * 15);
305
+ }
306
+ //#endregion
307
+ //#region src/json-store.ts
308
+ /**
309
+ * JSON file read/write with atomic replacement.
310
+ *
311
+ * Session messages can be large — storing them as JSON files keeps
312
+ * SQLite rows small (only metadata) while letting the OS page cache
313
+ * handle the heavy blobs efficiently.
314
+ *
315
+ * Atomic writes (write temp → rename) guarantee that a crash during
316
+ * a write never leaves a corrupted or half‑written session file.
317
+ */
318
+ /** EBUSY retry count for Windows rename contention. */
319
+ const RENAME_RETRIES = 5;
320
+ /** Base delay between EBUSY retries (ms). */
321
+ const RENAME_RETRY_DELAY_MS = 20;
322
+ function atomicRename(tmpPath, targetPath) {
323
+ for (let attempt = 0; attempt < RENAME_RETRIES; attempt++) try {
324
+ renameSync(tmpPath, targetPath);
325
+ return;
326
+ } catch (err) {
327
+ const code = err.code;
328
+ if (code === "EBUSY" && attempt < RENAME_RETRIES - 1) {
329
+ const delay = RENAME_RETRY_DELAY_MS * (attempt + 1);
330
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
331
+ continue;
332
+ }
333
+ if ((code === "EPERM" || code === "EXDEV") && attempt === 0) {
334
+ copyFileSync(tmpPath, targetPath);
335
+ try {
336
+ unlinkSync(tmpPath);
337
+ } catch {}
338
+ return;
339
+ }
340
+ throw err;
341
+ }
342
+ }
343
+ /**
344
+ * Atomically write `data` to `filePath`.
345
+ *
346
+ * Implementation: write to `<filePath>.<random>.tmp` → fsync →
347
+ * rename over the target with EBUSY retry and EPERM/EXDEV copy fallback.
348
+ * If the process crashes between write and rename the `.tmp` file is
349
+ * orphaned but the original is intact.
350
+ */
351
+ function writeJson(filePath, data) {
352
+ mkdirSync(dirname(filePath), {
353
+ mode: 448,
354
+ recursive: true
355
+ });
356
+ const tmpPath = `${filePath}.${Math.random().toString(36).slice(2, 8)}.tmp`;
357
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), {
358
+ encoding: "utf-8",
359
+ mode: 384
360
+ });
361
+ try {
362
+ atomicRename(tmpPath, filePath);
363
+ } finally {
364
+ try {
365
+ unlinkSync(tmpPath);
366
+ } catch {}
367
+ }
368
+ }
369
+ //#endregion
370
+ //#region src/manager.ts
371
+ /**
372
+ * Create a SessionManager backed by the given database.
373
+ *
374
+ * Every method returns plain objects — no internal mutable state
375
+ * (except the draft manager's timers).
376
+ */
377
+ function createSessionManager(db) {
378
+ const drafts = createDraftManager(db);
379
+ function nextId() {
380
+ return asSessionId(crypto.randomUUID());
381
+ }
382
+ const manager = {
383
+ create(label, workspace, parentSessionId) {
384
+ const id = nextId();
385
+ const now = Date.now();
386
+ const session = {
387
+ id,
388
+ label,
389
+ workspace,
390
+ messages: [],
391
+ parentSessionId,
392
+ createdAt: now,
393
+ updatedAt: now,
394
+ metadata: {}
395
+ };
396
+ saveSession(db, session, 0);
397
+ return session;
398
+ },
399
+ load(id) {
400
+ const session = loadSession(db, id);
401
+ if (!session) throw new SessionNotFoundError(id);
402
+ return session;
403
+ },
404
+ save(session, messages) {
405
+ if (!session.id) throw new SessionError("Cannot save session without id", { category: "session" });
406
+ saveSession(db, {
407
+ ...session,
408
+ updatedAt: Date.now()
409
+ }, messages.length);
410
+ writeJson(join(join(homedir(), ".lynx", "sessions"), `${session.id}.json`), {
411
+ sessionId: session.id,
412
+ label: session.label,
413
+ workspace: session.workspace,
414
+ messages,
415
+ savedAt: Date.now()
416
+ });
417
+ },
418
+ delete(id) {
419
+ deleteSession(db, id);
420
+ drafts.discard(id);
421
+ },
422
+ fork(id, newLabel) {
423
+ const original = manager.load(id);
424
+ const label = newLabel ?? `${original.label} (fork)`;
425
+ const forked = manager.create(label, original.workspace, id);
426
+ forked.metadata = {
427
+ ...original.metadata,
428
+ forkedFromMessageCount: original.messages.length
429
+ };
430
+ return forked;
431
+ },
432
+ rename(id, newLabel) {
433
+ const session = manager.load(id);
434
+ const renamed = {
435
+ ...session,
436
+ label: newLabel,
437
+ updatedAt: Date.now()
438
+ };
439
+ saveSession(db, renamed, session.messages.length);
440
+ return renamed;
441
+ },
442
+ list(limit) {
443
+ return listSessions(db, limit);
444
+ },
445
+ search(query, limit) {
446
+ const all = listSessions(db);
447
+ const lower = query.toLowerCase();
448
+ return pickSessions(all.filter((s) => s.label.toLowerCase().includes(lower) || s.workspace.toLowerCase().includes(lower)), query, { limit: limit ?? 20 }).map((s) => s.session);
449
+ },
450
+ getLastSession() {
451
+ return getLastSession(db);
452
+ },
453
+ prune(retentionDays) {
454
+ return pruneSessions(db, Date.now() - retentionDays * 24 * 60 * 60 * 1e3);
455
+ },
456
+ checkpoint(session, messages) {
457
+ writeJson(join(join(homedir(), ".lynx", "sessions"), `${session.id}.json`), {
458
+ sessionId: session.id,
459
+ label: session.label,
460
+ workspace: session.workspace,
461
+ messages,
462
+ savedAt: Date.now()
463
+ });
464
+ },
465
+ saveDraft(sessionId, messages) {
466
+ saveDraft(db, sessionId, messages);
467
+ },
468
+ onDraftDirty(sessionId, messages) {
469
+ drafts.onDirty(sessionId, messages);
470
+ },
471
+ flushDraft(sessionId, messages) {
472
+ drafts.flushNow(sessionId, messages);
473
+ },
474
+ discardDraft(sessionId) {
475
+ drafts.discard(sessionId);
476
+ },
477
+ restoreDraft(sessionId) {
478
+ return drafts.restore(sessionId);
479
+ },
480
+ recover(id) {
481
+ return detectTurnInterruption(db, id);
482
+ },
483
+ markCrashed(session) {
484
+ const marked = markSessionCrashed(session);
485
+ saveSession(db, marked, 0);
486
+ return marked;
487
+ },
488
+ clearCrashed(session) {
489
+ const cleared = clearCrashMarker(session);
490
+ saveSession(db, cleared, 0);
491
+ return cleared;
492
+ },
493
+ destroy() {
494
+ drafts.destroy();
495
+ clearStatementCache();
496
+ }
497
+ };
498
+ return manager;
499
+ }
500
+ //#endregion
501
+ export { clearCrashMarker, createDraftManager, createSessionManager, deleteDraft, deleteSession, detectTurnInterruption, getLastSession, listSessions, loadDraft, loadSession, markSessionCrashed, pickSessions, pruneSessions, saveDraft, saveSession, updateSession };
502
+
503
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["getLastFromStore","pruneFromStore"],"sources":["../src/store.ts","../src/draft.ts","../src/recovery.ts","../src/picker.ts","../src/json-store.ts","../src/manager.ts"],"sourcesContent":["/**\n * SQLite persistence layer for sessions and drafts.\n *\n * Uses better-sqlite3 via the shared database opened by lynx-core.\n * All writes go through prepared statements for performance and\n * SQL injection prevention.\n */\n\nimport type Database from \"better-sqlite3\";\nimport type { Session, SessionId, Message } from \"@lynx/core\";\nimport { StorageError, StorageCorruptedError } from \"@lynx/core\";\n\n// ── Prepared statement cache ───────────────────────\n\ninterface StatementCache {\n insertSession: Database.Statement;\n updateSession: Database.Statement;\n deleteSession: Database.Statement;\n loadSession: Database.Statement;\n listSessions: Database.Statement;\n getLastSession: Database.Statement;\n pruneSessions: Database.Statement;\n insertDraft: Database.Statement;\n loadDraft: Database.Statement;\n deleteDraft: Database.Statement;\n}\n\nlet _cache: StatementCache | undefined;\n\n/** Clear the cached prepared statements (call when the database is closed). */\nexport function clearStatementCache(): void {\n _cache = undefined;\n}\n\nfunction getCache(db: Database.Database): StatementCache {\n if (!_cache) {\n _cache = {\n insertSession: db.prepare(`\n INSERT OR REPLACE INTO sessions (id, label, workspace, parent_id, forked_from_message_count, message_count, metadata, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n `),\n updateSession: db.prepare(`\n UPDATE sessions SET label = ?, workspace = ?, message_count = ?, metadata = ?, updated_at = ?\n WHERE id = ?\n `),\n deleteSession: db.prepare(\"DELETE FROM sessions WHERE id = ?\"),\n loadSession: db.prepare(\"SELECT * FROM sessions WHERE id = ?\"),\n listSessions: db.prepare(`\n SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT ?\n `),\n getLastSession: db.prepare(`\n SELECT * FROM sessions ORDER BY updated_at DESC, id DESC LIMIT 1\n `),\n pruneSessions: db.prepare(`\n DELETE FROM sessions WHERE updated_at < ?\n `),\n insertDraft: db.prepare(`\n INSERT OR REPLACE INTO drafts (session_id, messages_json, updated_at)\n VALUES (?, ?, ?)\n `),\n loadDraft: db.prepare(\"SELECT messages_json FROM drafts WHERE session_id = ?\"),\n deleteDraft: db.prepare(\"DELETE FROM drafts WHERE session_id = ?\"),\n };\n }\n return _cache;\n}\n\n// ── Row type ───────────────────────────────────────\n\ninterface SessionRow {\n id: string;\n label: string;\n workspace: string;\n parent_id: string | null;\n forked_from_message_count: number | null;\n message_count: number;\n metadata: string; // JSON\n created_at: number;\n updated_at: number;\n}\n\nfunction rowToSession(row: SessionRow): Session {\n return {\n id: row.id as SessionId,\n label: row.label,\n workspace: row.workspace,\n messages: [], // loaded separately via JSON files\n parentSessionId: (row.parent_id as SessionId) ?? undefined,\n forkedFromMessageCount: row.forked_from_message_count ?? undefined,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n metadata: JSON.parse(row.metadata),\n };\n}\n\n// ── Public API ─────────────────────────────────────\n\n/**\n * Persist a session to SQLite.\n * Message bodies are NOT stored here — they live in JSON files\n * and are loaded on demand.\n */\nexport function saveSession(db: Database.Database, session: Session, messageCount: number): void {\n const cache = getCache(db);\n cache.insertSession.run(\n session.id,\n session.label,\n session.workspace,\n session.parentSessionId ?? null,\n session.forkedFromMessageCount ?? null,\n messageCount,\n JSON.stringify(session.metadata),\n session.createdAt,\n session.updatedAt,\n );\n}\n\n/** Load session metadata (without messages) from SQLite. */\nexport function loadSession(db: Database.Database, id: SessionId): Session | undefined {\n const cache = getCache(db);\n const row = cache.loadSession.get(id) as SessionRow | undefined;\n if (!row) return undefined;\n return rowToSession(row);\n}\n\n/** Delete a session and its draft. */\nexport function deleteSession(db: Database.Database, id: SessionId): void {\n const cache = getCache(db);\n const info = cache.deleteSession.run(id);\n if (info.changes === 0) {\n throw new StorageError(`Session not found: ${id}`, {\n category: \"storage\",\n recoverable: false,\n retryable: false,\n userVisible: true,\n });\n }\n}\n\n/** Update mutable session fields. */\nexport function updateSession(\n db: Database.Database,\n id: SessionId,\n patch: {\n label?: string;\n workspace?: string;\n messageCount?: number;\n metadata?: Record<string, unknown>;\n },\n): void {\n const existing = loadSession(db, id);\n if (!existing)\n throw new StorageError(`Session not found: ${id}`, {\n category: \"storage\",\n recoverable: false,\n retryable: false,\n });\n\n const cache = getCache(db);\n cache.updateSession.run(\n patch.label ?? existing.label,\n patch.workspace ?? existing.workspace,\n patch.messageCount ?? 0,\n JSON.stringify(patch.metadata ?? existing.metadata),\n Date.now(),\n id,\n );\n}\n\n/** List session metadata ordered by updatedAt desc. */\nexport function listSessions(db: Database.Database, limit = 50): Session[] {\n const cache = getCache(db);\n const rows = cache.listSessions.all(limit) as SessionRow[];\n return rows.map(rowToSession);\n}\n\n/**\n * Get the most recently updated session.\n * Returns `undefined` when no sessions exist (first launch).\n */\nexport function getLastSession(db: Database.Database): Session | undefined {\n const cache = getCache(db);\n const row = cache.getLastSession.get() as SessionRow | undefined;\n return row ? rowToSession(row) : undefined;\n}\n\n/**\n * Delete sessions whose `updated_at` is older than the cutoff timestamp.\n * Returns the number of deleted rows.\n *\n * Used by SessionManager.prune() to clean up stale sessions\n * and by CLI maintenance commands.\n */\nexport function pruneSessions(db: Database.Database, cutoffTimestamp: number): number {\n const cache = getCache(db);\n const info = cache.pruneSessions.run(cutoffTimestamp);\n return info.changes;\n}\n\n// ── Drafts ─────────────────────────────────────────\n\n/** Save a draft for a session. */\nexport function saveDraft(db: Database.Database, sessionId: SessionId, messages: Message[]): void {\n const cache = getCache(db);\n cache.insertDraft.run(sessionId, JSON.stringify(messages), Date.now());\n}\n\n/** Load a saved draft. Returns undefined if none exists. */\nexport function loadDraft(db: Database.Database, sessionId: SessionId): Message[] | undefined {\n const cache = getCache(db);\n const row = cache.loadDraft.get(sessionId) as { messages_json: string } | undefined;\n if (!row) return undefined;\n try {\n return JSON.parse(row.messages_json) as Message[];\n } catch {\n throw new StorageCorruptedError(`Draft JSON is corrupted for session ${sessionId}`);\n }\n}\n\n/** Delete a draft (e.g. after successful save). */\nexport function deleteDraft(db: Database.Database, sessionId: SessionId): void {\n const cache = getCache(db);\n cache.deleteDraft.run(sessionId);\n}\n","/**\n * Draft auto‑save with debounce.\n *\n * Every 2 seconds after the last message, the in‑memory draft is\n * flushed to SQLite so that a crash or SIGTERM doesn't lose the\n * current conversation state.\n */\n\nimport type Database from \"better-sqlite3\";\nimport type { SessionId, Message } from \"@lynx/core\";\nimport { saveDraft, deleteDraft, loadDraft } from \"./store.js\";\n\n// ── Constants ──────────────────────────────────────\n\n/** How long to wait after the last message before flushing to disk. */\nconst DEBOUNCE_MS = 2_000;\n\n// ── Types ──────────────────────────────────────────\n\nexport interface DraftManager {\n /** Record a message change and schedule a flush. */\n onDirty(sessionId: SessionId, messages: Message[]): void;\n /** Flush immediately and cancel pending timer. */\n flushNow(sessionId: SessionId, messages: Message[]): void;\n /** Cancel the pending flush and remove the draft from disk. */\n discard(sessionId: SessionId): void;\n /** Restore the last saved draft (if any). */\n restore(sessionId: SessionId): Message[] | undefined;\n /** Cancel all pending timers (call on shutdown). */\n destroy(): void;\n}\n\n// ── Public API ─────────────────────────────────────\n\n/**\n * Create a draft auto‑save manager.\n *\n * One instance per Database — the manager holds timer handles\n * so it must be destroyed on shutdown.\n */\nexport function createDraftManager(db: Database.Database): DraftManager {\n const timers = new Map<SessionId, ReturnType<typeof setTimeout>>();\n\n const manager: DraftManager = {\n onDirty(sessionId: SessionId, messages: Message[]): void {\n const existing = timers.get(sessionId);\n if (existing) clearTimeout(existing);\n\n timers.set(\n sessionId,\n setTimeout(() => {\n saveDraft(db, sessionId, messages);\n timers.delete(sessionId);\n }, DEBOUNCE_MS),\n );\n },\n\n flushNow(sessionId: SessionId, messages: Message[]): void {\n const existing = timers.get(sessionId);\n if (existing) {\n clearTimeout(existing);\n timers.delete(sessionId);\n }\n saveDraft(db, sessionId, messages);\n },\n\n discard(sessionId: SessionId): void {\n const existing = timers.get(sessionId);\n if (existing) {\n clearTimeout(existing);\n timers.delete(sessionId);\n }\n try {\n deleteDraft(db, sessionId);\n } catch {\n // draft might not exist — that's fine\n }\n },\n\n restore(sessionId: SessionId): Message[] | undefined {\n return loadDraft(db, sessionId);\n },\n\n destroy(): void {\n for (const timer of timers.values()) {\n clearTimeout(timer);\n }\n timers.clear();\n },\n };\n\n return manager;\n}\n","/**\n * Crash recovery — detect interrupted sessions and restore to the last\n * known‑good checkpoint.\n *\n * When Lynx crashes mid‑turn (SIGKILL, power loss, etc.), the next startup\n * detects the interruption and offers to resume from the last turn boundary.\n */\n\nimport type Database from \"better-sqlite3\";\nimport type { Session, SessionId, Message } from \"@lynx/core\";\nimport { loadSession } from \"./store.js\";\nimport { loadDraft } from \"./store.js\";\n\n// ── Types ──────────────────────────────────────────\n\nexport interface RecoveryResult {\n /** The session that was interrupted. */\n session: Session;\n /** Messages up to the last complete turn boundary. */\n safeMessages: Message[];\n /** The last complete turn index. */\n lastTurnIndex: number;\n /** Whether the session was interrupted mid‑turn. */\n wasInterrupted: boolean;\n}\n\n// ── Public API ─────────────────────────────────────\n\n/**\n * Detect whether a session was interrupted and calculate the safe\n * recovery point (last complete turn boundary).\n *\n * Returns `undefined` if no interruption was detected.\n */\nexport function detectTurnInterruption(\n db: Database.Database,\n sessionId: SessionId,\n): RecoveryResult | undefined {\n const session = loadSession(db, sessionId);\n if (!session) return undefined;\n\n // Check if the session was marked as crashed\n if (!session.metadata.crashed) return undefined;\n\n // Load the draft — it contains the in‑progress messages\n const draft = loadDraft(db, sessionId);\n if (!draft || draft.length === 0) return undefined;\n\n // Find the last complete turn boundary.\n // A turn is complete when we have an assistant message (the turn)\n // followed by an optional tool_use/tool_result pair and then\n // the response is fully received.\n //\n // Strategy: walk backwards through draft messages and find the\n // last assistant message with turnIndex = N, then take all messages\n // up to and including that turn.\n let lastTurnIndex = 0;\n let lastCompleteIndex = 0;\n\n for (let i = draft.length - 1; i >= 0; i--) {\n const msg = draft[i];\n if (msg.role === \"assistant\") {\n lastTurnIndex = msg.turnIndex;\n // The turn is complete if the assistant message has content\n // and there's no incomplete tool_use without a matching tool_result\n lastCompleteIndex = i + 1; // include this message\n break;\n }\n }\n\n const safeMessages = draft.slice(0, lastCompleteIndex);\n\n return {\n session: { ...session, metadata: { ...session.metadata, crashed: false } },\n safeMessages,\n lastTurnIndex,\n wasInterrupted: safeMessages.length < draft.length,\n };\n}\n\n/**\n * Mark a session as crashed so it can be recovered next startup.\n */\nexport function markSessionCrashed(session: Session): Session {\n return {\n ...session,\n metadata: { ...session.metadata, crashed: true },\n updatedAt: Date.now(),\n };\n}\n\n/**\n * Clear the crash marker after successful recovery.\n */\nexport function clearCrashMarker(session: Session): Session {\n const { crashed: _crashed, ...rest } = session.metadata;\n return {\n ...session,\n metadata: rest,\n updatedAt: Date.now(),\n };\n}\n","/**\n * SessionPicker logic — fuzzy search with 3‑dimensional ranking.\n *\n * Used by the TUI session picker modal. The scoring algorithm:\n * 1. recency — newer sessions score higher\n * 2. relevance — substring match quality (exact > prefix > contains)\n * 3. messageCount — more messages = deeper conversation\n */\n\nimport type { Session } from \"@lynx/core\";\n\n// ── Types ──────────────────────────────────────────\n\nexport interface ScoredSession {\n session: Session;\n /** Composite score — higher = better match. */\n score: number;\n /** Which dimension contributed most. */\n breakdown: {\n recency: number;\n relevance: number;\n density: number;\n };\n}\n\nexport interface PickerOptions {\n /** Maximum number of results. */\n limit?: number;\n}\n\n// ── Constants ──────────────────────────────────────\n\nconst NOW_SCORE = 100;\nconst DAY_MS = 86_400_000;\nconst DECAY_FACTOR = 0.5;\n\n// ── Public API ─────────────────────────────────────\n\n/**\n * Score and rank sessions for a user query.\n *\n * An empty query returns sessions ranked by recency only.\n *\n * ```ts\n * const results = pickSessions(sessions, \"my-project\");\n * for (const r of results) {\n * console.log(`${r.session.label}: ${r.score}`);\n * }\n * ```\n */\nexport function pickSessions(\n sessions: Session[],\n query: string,\n opts: PickerOptions = {},\n): ScoredSession[] {\n const q = query.toLowerCase().trim();\n const limit = opts.limit ?? 20;\n\n const scored = sessions.map((session) => {\n const recency = scoreRecency(session.updatedAt);\n const relevance = q ? scoreRelevance(session.label, q) : 0;\n const density = scoreMessageCount(session.messages.length);\n\n const score = 0.4 * recency + 0.4 * relevance + 0.2 * density;\n\n return {\n session,\n score,\n breakdown: { recency, relevance, density },\n };\n });\n\n // Sort descending by score\n scored.sort((a, b) => b.score - a.score);\n return scored.slice(0, limit);\n}\n\n// ── Scoring functions ──────────────────────────────\n\n/**\n * Recency score: linear decay over the last 7 days.\n * Sessions older than 7 days get a floor of 1.\n */\nfunction scoreRecency(updatedAt: number): number {\n const ageMs = Date.now() - updatedAt;\n const ageDays = ageMs / DAY_MS;\n\n if (ageDays <= 0) return NOW_SCORE;\n if (ageDays >= 7) return 1;\n\n return NOW_SCORE * Math.pow(DECAY_FACTOR, ageDays);\n}\n\n/**\n * Relevance score: quality of substring match.\n *\n * exact match: 100\n * starts with query: 80\n * contains substring: 60\n * no match: 0\n */\nfunction scoreRelevance(label: string, query: string): number {\n const lower = label.toLowerCase();\n\n if (lower === query) return 100;\n if (lower.startsWith(query)) return 80;\n if (lower.includes(query)) return 60;\n return 0;\n}\n\n/**\n * Message count density: log‑scale so huge sessions don't dominate.\n */\nfunction scoreMessageCount(count: number): number {\n if (count === 0) return 0;\n return Math.min(100, Math.log2(count + 1) * 15);\n}\n","/**\n * JSON file read/write with atomic replacement.\n *\n * Session messages can be large — storing them as JSON files keeps\n * SQLite rows small (only metadata) while letting the OS page cache\n * handle the heavy blobs efficiently.\n *\n * Atomic writes (write temp → rename) guarantee that a crash during\n * a write never leaves a corrupted or half‑written session file.\n */\n\nimport {\n readFileSync,\n writeFileSync,\n renameSync,\n existsSync,\n unlinkSync,\n copyFileSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { mkdirSync } from \"node:fs\";\n\n// ── Constants ────────────────────────────────────────\n\n/** EBUSY retry count for Windows rename contention. */\nconst RENAME_RETRIES = 5;\n/** Base delay between EBUSY retries (ms). */\nconst RENAME_RETRY_DELAY_MS = 20;\n\n// ── Helpers ──────────────────────────────────────────\n\nfunction atomicRename(tmpPath: string, targetPath: string): void {\n for (let attempt = 0; attempt < RENAME_RETRIES; attempt++) {\n try {\n renameSync(tmpPath, targetPath);\n return;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n // EBUSY: Windows virus scanner or indexer has the file open\n if (code === \"EBUSY\" && attempt < RENAME_RETRIES - 1) {\n const delay = RENAME_RETRY_DELAY_MS * (attempt + 1);\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);\n continue;\n }\n // EPERM / EXDEV: cross‑device rename — fall back to copy\n if ((code === \"EPERM\" || code === \"EXDEV\") && attempt === 0) {\n copyFileSync(tmpPath, targetPath);\n try {\n unlinkSync(tmpPath);\n } catch {\n // Best‑effort temp cleanup\n }\n return;\n }\n throw err;\n }\n }\n}\n\n// ── Public API ───────────────────────────────────────\n\n/**\n * Read and parse a JSON file.\n * Returns `undefined` when the file does not exist so callers can\n * distinguish \"empty\" from \"never written\".\n */\nexport function readJson<T = unknown>(filePath: string): T | undefined {\n if (!existsSync(filePath)) {\n return undefined;\n }\n const raw = readFileSync(filePath, \"utf-8\");\n return JSON.parse(raw) as T;\n}\n\n/**\n * Read a JSON file that must exist.\n * Throws if the file is missing — use when the file is required.\n */\nexport function readJsonRequired<T = unknown>(filePath: string): T {\n const data = readJson<T>(filePath);\n if (data === undefined) {\n throw new Error(`Required JSON file not found: ${filePath}`);\n }\n return data;\n}\n\n/**\n * Read a JSON file, returning `fallback` if the file does not exist.\n */\nexport function readJsonOr<T>(filePath: string, fallback: T): T {\n return readJson<T>(filePath) ?? fallback;\n}\n\n/**\n * Atomically write `data` to `filePath`.\n *\n * Implementation: write to `<filePath>.<random>.tmp` → fsync →\n * rename over the target with EBUSY retry and EPERM/EXDEV copy fallback.\n * If the process crashes between write and rename the `.tmp` file is\n * orphaned but the original is intact.\n */\nexport function writeJson<T = unknown>(filePath: string, data: T): void {\n const dir = dirname(filePath);\n mkdirSync(dir, { mode: 0o700, recursive: true });\n\n const tmpPath = `${filePath}.${Math.random().toString(36).slice(2, 8)}.tmp`;\n const json = JSON.stringify(data, null, 2);\n\n writeFileSync(tmpPath, json, { encoding: \"utf-8\", mode: 0o600 });\n\n try {\n atomicRename(tmpPath, filePath);\n } finally {\n // Clean up orphaned temp file (no‑op if rename succeeded)\n try {\n unlinkSync(tmpPath);\n } catch {\n // Already gone — that's fine\n }\n }\n}\n\n/**\n * Read‑modify‑write a JSON file with atomic replacement.\n *\n * `fn` receives the current data (or `undefined` if the file is new)\n * and must return the new data to write.\n */\nexport function updateJson<T>(filePath: string, fn: (current: T | undefined) => T): T {\n const current = readJson<T>(filePath);\n const next = fn(current);\n writeJson(filePath, next);\n return next;\n}\n\n/**\n * Delete a JSON file if it exists.\n * No‑op when the file is already absent.\n */\nexport function removeJson(filePath: string): void {\n if (existsSync(filePath)) {\n unlinkSync(filePath);\n }\n}\n\n/**\n * Check whether a JSON file exists at the given path.\n * Convenience wrapper so callers don't need to import `node:fs`.\n */\nexport function jsonExists(filePath: string): boolean {\n return existsSync(filePath);\n}\n","/**\n * SessionManager — single‑file monolithic session lifecycle manager.\n *\n * Responsibilities:\n * CRUD — create, load, save, delete, fork, rename\n * List — 3‑dimensional sort (updatedAt + relevance + messageCount)\n * Search — substring match on session labels\n * Checkpoint — persist in‑memory messages to JSON + SQLite\n * Recovery — detect crash, restore to last turn boundary\n *\n * One instance per database. All methods are synchronous or async\n * with minimal overhead for the hot path (list/search).\n */\n\nimport type Database from \"better-sqlite3\";\nimport type { Session, SessionId, Message } from \"@lynx/core\";\nimport { asSessionId, SessionError, SessionNotFoundError } from \"@lynx/core\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nimport {\n saveSession,\n loadSession,\n deleteSession as deleteFromStore,\n listSessions,\n getLastSession as getLastFromStore,\n pruneSessions as pruneFromStore,\n saveDraft,\n clearStatementCache,\n} from \"./store.js\";\nimport { createDraftManager } from \"./draft.js\";\nimport { detectTurnInterruption, markSessionCrashed, clearCrashMarker } from \"./recovery.js\";\nimport type { RecoveryResult } from \"./recovery.js\";\nimport { pickSessions } from \"./picker.js\";\nimport { writeJson } from \"./json-store.js\";\n\n// ── Types ──────────────────────────────────────────\n\nexport interface SessionManager {\n // CRUD\n create(label: string, workspace: string, parentSessionId?: SessionId): Session;\n load(id: SessionId): Session;\n save(session: Session, messages: Message[]): void;\n delete(id: SessionId): void;\n fork(id: SessionId, newLabel?: string): Session;\n rename(id: SessionId, newLabel: string): Session;\n\n // Query\n list(limit?: number): Session[];\n search(query: string, limit?: number): Session[];\n\n // Draft\n saveDraft(sessionId: SessionId, messages: Message[]): void;\n onDraftDirty(sessionId: SessionId, messages: Message[]): void;\n flushDraft(sessionId: SessionId, messages: Message[]): void;\n discardDraft(sessionId: SessionId): void;\n restoreDraft(sessionId: SessionId): Message[] | undefined;\n\n // Query helpers\n getLastSession(): Session | undefined;\n prune(retentionDays: number): number;\n\n // Checkpoint\n checkpoint(session: Session, messages: Message[]): void;\n\n // Recovery\n recover(id: SessionId): RecoveryResult | undefined;\n markCrashed(session: Session): Session;\n clearCrashed(session: Session): Session;\n\n // Lifecycle\n destroy(): void;\n}\n\n// ── Public API ─────────────────────────────────────\n\n/**\n * Create a SessionManager backed by the given database.\n *\n * Every method returns plain objects — no internal mutable state\n * (except the draft manager's timers).\n */\nexport function createSessionManager(db: Database.Database): SessionManager {\n const drafts = createDraftManager(db);\n\n function nextId(): SessionId {\n return asSessionId(crypto.randomUUID());\n }\n\n const manager: SessionManager = {\n // ── CRUD ───────────────────────────────────────\n\n create(label: string, workspace: string, parentSessionId?: SessionId): Session {\n const id = nextId();\n const now = Date.now();\n const session: Session = {\n id,\n label,\n workspace,\n messages: [],\n parentSessionId,\n createdAt: now,\n updatedAt: now,\n metadata: {},\n };\n saveSession(db, session, 0);\n return session;\n },\n\n load(id: SessionId): Session {\n const session = loadSession(db, id);\n if (!session) throw new SessionNotFoundError(id);\n return session;\n },\n\n save(session: Session, messages: Message[]): void {\n if (!session.id)\n throw new SessionError(\"Cannot save session without id\", { category: \"session\" });\n const updated = { ...session, updatedAt: Date.now() };\n saveSession(db, updated, messages.length);\n // Persist messages to JSON on disk\n const dir = join(homedir(), \".lynx\", \"sessions\");\n const path = join(dir, `${session.id}.json`);\n writeJson(path, {\n sessionId: session.id,\n label: session.label,\n workspace: session.workspace,\n messages,\n savedAt: Date.now(),\n });\n },\n\n delete(id: SessionId): void {\n deleteFromStore(db, id);\n drafts.discard(id);\n },\n\n fork(id: SessionId, newLabel?: string): Session {\n const original = manager.load(id);\n const label = newLabel ?? `${original.label} (fork)`;\n const forked = manager.create(label, original.workspace, id);\n forked.metadata = {\n ...original.metadata,\n forkedFromMessageCount: original.messages.length,\n };\n return forked;\n },\n\n rename(id: SessionId, newLabel: string): Session {\n const session = manager.load(id);\n const renamed = { ...session, label: newLabel, updatedAt: Date.now() };\n saveSession(db, renamed, session.messages.length);\n return renamed;\n },\n\n // ── Query ──────────────────────────────────────\n\n list(limit?: number): Session[] {\n return listSessions(db, limit);\n },\n\n search(query: string, limit?: number): Session[] {\n const all = listSessions(db);\n const lower = query.toLowerCase();\n const matched = all.filter(\n (s) => s.label.toLowerCase().includes(lower) || s.workspace.toLowerCase().includes(lower),\n );\n // Apply 3‑dimensional scoring (recency + relevance + message count)\n const scored = pickSessions(matched, query, { limit: limit ?? 20 });\n return scored.map((s) => s.session);\n },\n\n // ── Query helpers ──────────────────────────────\n\n getLastSession(): Session | undefined {\n return getLastFromStore(db);\n },\n\n prune(retentionDays: number): number {\n const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;\n return pruneFromStore(db, cutoff);\n },\n\n // ── Checkpoint ──────────────────────────────────\n\n checkpoint(session: Session, messages: Message[]): void {\n const dir = join(homedir(), \".lynx\", \"sessions\");\n const path = join(dir, `${session.id}.json`);\n writeJson(path, {\n sessionId: session.id,\n label: session.label,\n workspace: session.workspace,\n messages,\n savedAt: Date.now(),\n });\n },\n\n // ── Draft ──────────────────────────────────────\n\n saveDraft(sessionId: SessionId, messages: Message[]): void {\n saveDraft(db, sessionId, messages);\n },\n\n onDraftDirty(sessionId: SessionId, messages: Message[]): void {\n drafts.onDirty(sessionId, messages);\n },\n\n flushDraft(sessionId: SessionId, messages: Message[]): void {\n drafts.flushNow(sessionId, messages);\n },\n\n discardDraft(sessionId: SessionId): void {\n drafts.discard(sessionId);\n },\n\n restoreDraft(sessionId: SessionId): Message[] | undefined {\n return drafts.restore(sessionId);\n },\n\n // ── Recovery ───────────────────────────────────\n\n recover(id: SessionId): RecoveryResult | undefined {\n return detectTurnInterruption(db, id);\n },\n\n markCrashed(session: Session): Session {\n const marked = markSessionCrashed(session);\n saveSession(db, marked, 0);\n return marked;\n },\n\n clearCrashed(session: Session): Session {\n const cleared = clearCrashMarker(session);\n saveSession(db, cleared, 0);\n return cleared;\n },\n\n // ── Lifecycle ──────────────────────────────────\n\n destroy(): void {\n drafts.destroy();\n clearStatementCache();\n },\n };\n\n return manager;\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@24klynx/session",
3
+ "version": "0.1.0",
4
+ "description": "Session management — CRUD, draft, crash recovery, search",
5
+ "type": "module",
6
+ "main": "./dist/index.mjs",
7
+ "types": "./dist/index.d.mts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "types": "./dist/index.d.mts"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "better-sqlite3": "^12.10.0",
16
+ "@24klynx/core": "0.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/better-sqlite3": "^7.6.13"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown --config-loader tsx",
29
+ "test": "vitest run --passWithNoTests",
30
+ "typecheck": "tsgo --noEmit"
31
+ }
32
+ }