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