Skip to main content

09 → Session persistence

Overview

What you'll build

You’re persisting conversations to disk so the CLI can resume where it left off. Each session stores metadata (directory, title, timestamps) and a chronological list of messages under ~/.cliq/storage/.

Why it matters

  • Lets users close the CLI and resume later without losing context.
  • Enables analytics like “what files were edited?” or “what tools were used?”
  • Provides history for UI features (future Step 10 tools, dashboards, etc.).

Big picture

Steps 01–08 focused on live interaction. This step closes the loop by making conversations durable. Later, Step 11 will address performance considerations (batching writes, concurrency).

Core concepts (Effect-TS)

Session vs. Message Namespace

Sessions live under session/<sessionId>.json; messages live under message/<sessionId>/<messageId>.json. Keeping them separate allows quick listing of sessions without loading every message.

Stable Identifiers

generateID() produces a unique message/session ID; hashProject(directory) ensures we group sessions by project path.

Serialization Helpers

serialize/deserialize convert between TypeScript objects and JSON strings. They keep validation centralized and avoid duplicating JSON handling.


Implementation

Implementation

Step 1: Define the SessionStore layer

src/persistence/SessionStore.ts
import { Effect, Layer, Option } from 'effect'

export const layer = Layer.effect(
SessionStore,
Effect.gen(function* () {
const store = yield* FileKeyValueStore

const createSession = (directory: string, title: string) =>
Effect.all([timestamp(), generateID()]).pipe(
Effect.map(([now, id]) => ({
id,
projectID: hashProject(directory),
directory,
title,
version: '0.1.0',
time: { created: now, updated: now },
})),
Effect.tap((session) =>
serialize(session).pipe(Effect.flatMap((json) => store.set('session', session.id, json))),
),
)

const saveMessage = (message: StoredMessage) =>
serialize(message).pipe(
Effect.flatMap((json) => store.set(messageNamespace(message.sessionID), message.id, json)),
)

const listMessages = (sessionID: string) =>
Effect.gen(function* () {
const keys = yield* store.listKeys(messageNamespace(sessionID))
const messages = yield* Effect.forEach(keys, (key) =>
Effect.gen(function* () {
const maybeMessage = yield* store.get(messageNamespace(sessionID), key)
const json = Option.getOrThrow(maybeMessage)
return yield* deserialize<StoredMessage>(json)
}),
)

return messages.sort((a, b) => a.time.created - b.time.created)
})

return {
createSession,
saveMessage,
listMessages,
} as const
}),
)

Step 2: Create a session on startup

src/chat/ChatProgram.ts
const session = yield * sessionStore.createSession(process.cwd(), 'Cliq Session')
yield * displayWelcome(session.id, config, toolNames)
yield * chatLoop(session.id)

Use the project directory (process.cwd()) as part of the session identity, so each repo gets its own history.

Step 3: Persist messages as they stream

src/chat/MessageService.ts
const userMessage = createUserMessage(input, sessionId)
yield * sessionStore.saveMessage(userMessage)

const assistantText = yield * streamTextToConsole(stream.textStream)

if (assistantText.trim().length > 0) {
const assistantMessage = createAssistantMessage(assistantText.trim(), sessionId)
yield * sessionStore.saveMessage(assistantMessage)
}

Wrap calls in Effect.catchAll so persistence errors don’t crash the chat loop—log and continue.


Resetting Sessions

Need a clean slate? Delete the storage directory:

rm -rf ~/.cliq/storage/session ~/.cliq/storage/message

Run the CLI again and a new session will be created automatically.


Testing & Validation
  1. Start the CLI, ask a question, exit, and restart—history should reload.
  2. Inspect ~/.cliq/storage/session/*.json to confirm metadata matches the project path.
  3. Delete the storage directory and confirm the CLI creates a new session without errors.
  4. Simulate a read failure (e.g., corrupt a message JSON) and ensure Option.getOrThrow surfaces a clear error.

Common Issues
ProblemLikely CauseFix
Option is undefinedImport missing in snippetAdd import { Option } from "effect"; to SessionStore.ts
Old sessions keep loadingStorage not clearedRemove ~/.cliq/storage/session and message directories
Messages out of orderNot sorting after loadEnsure listMessages sorts by time.created
Session files grow too largeNever archiving old historyRotate or prune messages after N entries

Connections

Builds on:

Sets up:

Related code:

  • src/persistence/SessionStore.ts
  • src/chat/ChatProgram.ts
  • src/chat/MessageService.ts
  • src/persistence/keys.ts