Skip to main content

Overview

The ConversationStore is the core abstraction in ff-ai for managing conversation persistence. It provides a provider-agnostic interface that can be implemented with any storage backend.

Interface

The ConversationStore is defined as an Effect Context.Tag with two methods:
import { ConversationStore } from 'ff-ai';
import type { ConversationMessage } from 'ff-ai';

// The ConversationStore is an Effect Context.Tag with two methods:
// - getMessages: retrieves messages from a thread
// - saveMessages: persists messages to a thread
// 
// Both methods require a ThreadIdentifier with resourceId and threadId

Methods

getMessages

Retrieve messages from a conversation thread.
params
object
required
Parameters for retrieving messages
return
Effect<ConversationMessage[], StoreError>
An Effect that resolves to an array of messages, ordered chronologically (oldest first)
Window Size Behavior The windowSize parameter controls conversation context:
  • Counts only user messages (not assistant or tool messages)
  • Returns all messages from the Nth most recent user message onward
  • Default is 10 user messages
  • Set to 0 to retrieve no messages
  • The implementation handles cases where fewer messages exist than the window size
Example:
import { ConversationStore } from 'ff-ai';
import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const store = yield* ConversationStore;

  // Get last 5 user messages and all associated messages
  const messages = yield* store.getMessages({
    resourceId: 'user-123',
    threadId: 'thread-456',
    windowSize: 5
  });

  console.log(`Retrieved ${messages.length} messages`);
  return messages;
});

saveMessages

Persist messages to a conversation thread.
params
object
required
Parameters for saving messages
return
Effect<void, StoreError>
An Effect that resolves when messages are saved successfully
Example:
import { ConversationStore, ConversationMessage } from 'ff-ai';
import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const store = yield* ConversationStore;

  const messages = [
    ConversationMessage.fromModelMessage({
      role: 'user',
      content: 'Hello!'
    }),
    ConversationMessage.fromModelMessage({
      role: 'assistant',
      content: 'Hi there! How can I help you today?'
    })
  ];

  yield* store.saveMessages({
    resourceId: 'user-123',
    threadId: 'thread-456',
    messages
  });
});

Thread Identifier

Both methods accept a ThreadIdentifier which consists of:
export type ThreadIdentifier = {
  resourceId: ResourceId;  // string
  threadId: ThreadId;      // string
};
This two-level identification allows you to:
  • Organize conversations by resource (user, project, workspace)
  • Have multiple conversation threads per resource
  • Easily query all conversations for a resource

Error Handling

Both methods return an Effect that may fail with StoreError:
import { Data } from 'effect';

export class StoreError extends Data.TaggedError('ff-ai/StoreError')<{
  message: string;
  cause?: unknown;
}> {
  constructor(message: string, opts?: { cause?: unknown }) {
    super({ message, ...opts });
  }
}
Handle errors using Effect operators:
const program = Effect.gen(function* () {
  const store = yield* ConversationStore;

  const messages = yield* store.getMessages({
    resourceId: 'user-123',
    threadId: 'thread-456'
  }).pipe(
    Effect.catchTag('StoreError', (error) => {
      console.error('Failed to get messages:', error.message);
      return Effect.succeed([]);  // Return empty array on error
    })
  );

  return messages;
});

Implementing a Custom Provider

You can implement a custom storage provider by creating a Layer that provides the ConversationStore service:
import { ConversationStore } from 'ff-ai';
import { Data, Effect, Layer } from 'effect';

// Define StoreError inline (not exported from ff-ai)
class StoreError extends Data.TaggedError('StoreError')<{
  message: string;
  cause?: unknown;
}> {}

export const MyCustomStoreLayer = Layer.succeed(
  ConversationStore,
  {
    getMessages: Effect.fn(function* (params) {
      // Your implementation here
      // Must return Effect<ConversationMessage[], StoreError>

      try {
        const messages = yield* Effect.tryPromise({
          try: () => myDatabase.getMessages(params),
          catch: (error) => new StoreError(
            'Failed to get messages',
            { cause: error }
          )
        });
        return messages;
      } catch (error) {
        return yield* Effect.fail(
          new StoreError('Failed to get messages', { cause: error })
        );
      }
    }),

    saveMessages: Effect.fn(function* (params) {
      // Your implementation here
      // Must return Effect<void, StoreError>

      yield* Effect.tryPromise({
        try: () => myDatabase.saveMessages(params),
        catch: (error) => new StoreError(
          'Failed to save messages',
          { cause: error }
        )
      });
    })
  }
);

Built-in Providers

Best Practices

Larger window sizes provide more context but increase token costs and latency. Start with the default of 10 user messages and adjust based on your use case.
Always handle StoreError in your application logic. Consider fallbacks like returning empty arrays or cached data.
Establish a naming convention for resourceId and threadId early. For example: user-{uuid} and thread-{uuid}.
For very long conversations, the window size mechanism provides automatic pagination based on user messages.

Next Steps