@24klynx/session 0.1.0 → 0.1.4

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/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Message, Session, SessionId } from "@lynx/core";
1
+ import { Message, Session, SessionId } from "@24klynx/core";
2
2
  import Database from "better-sqlite3";
3
3
 
4
4
  //#region src/recovery.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { SessionError, SessionNotFoundError, StorageCorruptedError, StorageError, asSessionId } from "@lynx/core";
1
+ import { SessionError, SessionNotFoundError, StorageCorruptedError, StorageError, asSessionId } from "@24klynx/core";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { copyFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
@@ -1 +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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"}
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 \"@24klynx/core\";\nimport { StorageError, StorageCorruptedError } from \"@24klynx/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 \"@24klynx/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 \"@24klynx/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 \"@24klynx/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 \"@24klynx/core\";\nimport { asSessionId, SessionError, SessionNotFoundError } from \"@24klynx/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 CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@24klynx/session",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Session management — CRUD, draft, crash recovery, search",
5
+ "files": [
6
+ "dist"
7
+ ],
5
8
  "type": "module",
6
9
  "main": "./dist/index.mjs",
7
10
  "types": "./dist/index.d.mts",
@@ -11,19 +14,16 @@
11
14
  "types": "./dist/index.d.mts"
12
15
  }
13
16
  },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
14
20
  "dependencies": {
15
21
  "better-sqlite3": "^12.10.0",
16
- "@24klynx/core": "0.1.0"
22
+ "@24klynx/core": "0.1.4"
17
23
  },
18
24
  "devDependencies": {
19
25
  "@types/better-sqlite3": "^7.6.13"
20
26
  },
21
- "files": [
22
- "dist"
23
- ],
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
27
  "scripts": {
28
28
  "build": "tsdown --config-loader tsx",
29
29
  "test": "vitest run --passWithNoTests",