memlocal 0.1.0 copy "memlocal: ^0.1.0" to clipboard
memlocal: ^0.1.0 copied to clipboard

Local-first, on-device cognitive memory for AI apps — a Flutter binding to the Rust memlocal engine (CozoDB) with pluggable OpenAI + Jina providers.

example/lib/main.dart

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:memlocal/memlocal.dart';
import 'package:path_provider/path_provider.dart';

const _dimensions = 1536;

/// System prompt for the LLM extraction step: decides what is worth storing,
/// splits into atomic memories, and classifies each into one of the engine's
/// 8 memory types. The model must return ONLY a JSON array.
const _extractionSystemPrompt = '''
You extract durable, atomic memories from a user's message for a long-term memory system.
Return ONLY a JSON array (no prose, no markdown fences). Each element must be:
{"content": "<one atomic fact, written in third person, self-contained>", "type": "<one of: episodic, factual, semantic, procedural, social, spatial, prospective, affective>"}

Rules:
- One fact per element. Preserve proper nouns exactly.
- Only include information worth remembering long-term. Greetings, small talk, acknowledgements, and pure questions contain nothing to remember -> return [].
- Pick the best type: episodic=events/experiences; factual=stable personal facts/preferences; semantic=general knowledge; procedural=how-to/workflows; social=people/relationships; spatial=places/locations; prospective=reminders/future intentions; affective=feelings/emotions.
Return [] when nothing is worth storing.
''';

/// Reads a key from the loaded `.env`, treating empty/whitespace as absent.
String? _envKey(String name) {
  final v = dotenv.maybeGet(name)?.trim();
  return (v == null || v.isEmpty) ? null : v;
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  try {
    await dotenv.load(fileName: '.env');
  } catch (_) {
    // .env missing or unreadable — keys will be absent; the UI will prompt.
  }
  runApp(const MemoryChatApp());
}

class MemoryChatApp extends StatelessWidget {
  const MemoryChatApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'memlocal chat',
        theme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          useMaterial3: true,
        ),
        home: const ChatScreen(),
      );
}

/// The kind of item rendered in the transcript.
enum ChatRole { user, recalled, stored, assistant, system, error }

/// One renderable entry in the chat transcript.
class ChatItem {
  ChatItem.user(this.text)
      : role = ChatRole.user,
        recalled = const [],
        scores = const [],
        rerankedByJina = false,
        stored = const [];
  ChatItem.assistant(this.text)
      : role = ChatRole.assistant,
        recalled = const [],
        scores = const [],
        rerankedByJina = false,
        stored = const [];
  ChatItem.system(this.text)
      : role = ChatRole.system,
        recalled = const [],
        scores = const [],
        rerankedByJina = false,
        stored = const [];
  ChatItem.error(this.text)
      : role = ChatRole.error,
        recalled = const [],
        scores = const [],
        rerankedByJina = false,
        stored = const [];
  ChatItem.recalled(
    this.recalled, {
    required this.scores,
    required this.rerankedByJina,
  })  : role = ChatRole.recalled,
        text = '',
        stored = const [];
  ChatItem.stored(this.stored)
      : role = ChatRole.stored,
        text = '',
        recalled = const [],
        scores = const [],
        rerankedByJina = false;

  final ChatRole role;
  final String text;
  final List<RecalledMemory> recalled;

  /// The score to display per recalled memory (parallel to [recalled]): the
  /// Jina relevance score when [rerankedByJina], otherwise the semantic score.
  final List<double?> scores;

  /// Whether [recalled] was reordered by the Jina reranker (vs. semantic order).
  final bool rerankedByJina;

  /// The (content, type) memories extracted and stored for this turn. Empty
  /// means nothing was worth storing (chit-chat).
  final List<({String content, String type})> stored;
}

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _input = TextEditingController();
  final _scroll = ScrollController();
  final List<ChatItem> _items = [];

  Memlocal? _engine;
  EmbeddingProvider? _embeddingProvider;
  LlmProvider? _llmProvider;
  RerankerProvider? _reranker;

  bool _initializing = true;
  bool _sending = false;
  String? _initError;

  bool get _ready =>
      _engine != null && _embeddingProvider != null && _llmProvider != null;

  @override
  void initState() {
    super.initState();
    _bootstrap();
  }

  @override
  void dispose() {
    _input.dispose();
    _scroll.dispose();
    super.dispose();
  }

  Future<void> _bootstrap() async {
    try {
      final dir = await getApplicationDocumentsDirectory();
      final engine = await Memlocal.open(
        dbPath: '${dir.path}/memlocal_demo.db',
        dimensions: _dimensions,
      );
      // Keys come solely from the `.env` file (see flutter/example/.env).
      final apiKey = _envKey('OPENAI_API_KEY');
      final jinaKey = _envKey('JINA_API_KEY');
      if (!mounted) return;
      setState(() {
        _engine = engine;
        _initializing = false;
        if (apiKey != null) {
          _embeddingProvider = OpenAIEmbeddingProvider(apiKey);
          _llmProvider = OpenAILlmProvider(apiKey);
        }
        // Jina is optional: when absent, retrieval stays semantic-only.
        if (jinaKey != null) {
          _reranker = JinaReranker(jinaKey);
        }
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _initializing = false;
        _initError = '$e';
      });
    }
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!_scroll.hasClients) return;
      _scroll.animateTo(
        _scroll.position.maxScrollExtent,
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOut,
      );
    });
  }

  Future<void> _send() async {
    final text = _input.text.trim();
    if (text.isEmpty || _sending || !_ready) return;

    final engine = _engine!;
    final embeddingProvider = _embeddingProvider!;
    final llmProvider = _llmProvider!;
    final reranker = _reranker;

    setState(() {
      _items.add(ChatItem.user(text));
      _input.clear();
      _sending = true;
    });
    _scrollToBottom();

    try {
      // b. Embed the new message.
      final embedding = await embeddingProvider.embedOne(text);
      // c. Recall PRIOR memories: pull a larger candidate pool BEFORE storing
      //    the current message, then optionally rerank it down to the top 5.
      final pool = await engine.searchSemantic(embedding: embedding, k: 20);

      List<RecalledMemory> recalled;
      List<double?> scores;
      bool rerankedByJina;
      String? rerankNote;

      if (reranker != null && pool.isNotEmpty) {
        try {
          final ranked = await reranker.rerank(
            text,
            pool.map((m) => m.content).toList(),
            topN: 5,
          );
          recalled = ranked.map((r) => pool[r.index]).toList();
          scores = ranked.map<double?>((r) => r.score).toList();
          rerankedByJina = true;
        } catch (e) {
          // Reranking is best-effort: fall back to semantic order, note it,
          // but never abort the turn.
          recalled = pool.take(5).toList();
          scores = recalled.map((m) => m.score).toList();
          rerankedByJina = false;
          rerankNote = '(rerank failed: $e, using semantic order)';
        }
      } else {
        recalled = pool.take(5).toList();
        scores = recalled.map((m) => m.score).toList();
        rerankedByJina = false;
      }

      // d. Store step: run an LLM extraction over the message to decide what's
      //    worth keeping, split it into atomic memories, and classify each into
      //    one of the engine's memory types. Embeddings for stored items are
      //    computed from each extracted memory's own content (the RAW message
      //    embedding above is only the retrieval query, and is unchanged).
      List<({String content, String type})> stored = [];
      String? storeNote;
      try {
        final extracted = await _extractMemories(text);
        for (final m in extracted) {
          final emb = await embeddingProvider.embedOne(m.content);
          await engine.addMemory(content: m.content, kind: m.type, embedding: emb);
        }
        stored = extracted;
      } catch (e) {
        // Extraction failed -> don't lose the message: store it verbatim as factual.
        final emb = await embeddingProvider.embedOne(text);
        await engine.addMemory(content: text, kind: 'factual', embedding: emb);
        stored = [(content: text, type: 'factual')];
        storeNote = 'extraction failed (${e.toString()}); stored raw as factual';
      }

      // e. Build the memory-grounded system prompt + single LLM call.
      final system =
          'You are a helpful assistant with long-term memory of this user. '
          'Relevant memories you have recalled:\n'
          '${recalled.isEmpty ? "(none yet)" : recalled.map((m) => "- ${m.content}").join("\n")}'
          '\nUse them when relevant; if none apply, just answer normally.';
      final reply = await llmProvider.complete(system, text);

      // f. Show recalled context, then what was stored, then the assistant reply.
      if (!mounted) return;
      setState(() {
        _items.add(ChatItem.recalled(
          recalled,
          scores: scores,
          rerankedByJina: rerankedByJina,
        ));
        if (rerankNote != null) _items.add(ChatItem.system(rerankNote));
        _items.add(ChatItem.stored(stored));
        if (storeNote != null) _items.add(ChatItem.system(storeNote));
        _items.add(ChatItem.assistant(reply));
        _sending = false;
      });
      _scrollToBottom();
    } catch (e) {
      // g. Surface the error (incl. OpenAI 4xx body) without crashing.
      if (!mounted) return;
      setState(() {
        _items.add(ChatItem.error('$e'));
        _sending = false;
      });
      _scrollToBottom();
    }
  }

  /// Runs the LLM extraction step over [text]: asks the model what is worth
  /// remembering, split into atomic memories and classified by type. Returns
  /// the extracted (content, type) pairs (possibly empty — e.g. for chit-chat).
  ///
  /// Parses the model output robustly (tolerating ```json fences and prose
  /// around the array). RETHROWS on any parse failure so the caller can fall
  /// back to storing the raw message.
  Future<List<({String content, String type})>> _extractMemories(
    String text,
  ) async {
    final llmProvider = _llmProvider!;
    final raw = await llmProvider.complete(_extractionSystemPrompt, text);

    // Strip whitespace and any markdown code fences the model may have added.
    var cleaned = raw.trim();
    if (cleaned.startsWith('```')) {
      cleaned = cleaned.replaceFirst(RegExp(r'^```(?:json)?'), '');
      if (cleaned.endsWith('```')) {
        cleaned = cleaned.substring(0, cleaned.length - 3);
      }
      cleaned = cleaned.trim();
    }
    // Take the substring from the first '[' to the last ']'.
    final start = cleaned.indexOf('[');
    final end = cleaned.lastIndexOf(']');
    if (start == -1 || end == -1 || end < start) {
      throw FormatException('no JSON array in model output: $raw');
    }
    final decoded = jsonDecode(cleaned.substring(start, end + 1));
    if (decoded is! List) {
      throw const FormatException('extraction output was not a JSON array');
    }

    final out = <({String content, String type})>[];
    for (final element in decoded) {
      if (element is! Map) continue;
      final content = (element['content'] as String?)?.trim() ?? '';
      // Allowed types are the 8 documented ones; pass others through anyway
      // (the Rust side defaults unknown stored-names to semantic).
      final type =
          (element['type'] as String?)?.trim().toLowerCase() ?? 'semantic';
      if (content.isEmpty) continue;
      out.add((content: content, type: type));
    }
    return out;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('memlocal chat'),
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (_initializing) {
      return const Center(child: CircularProgressIndicator());
    }
    if (_initError != null) {
      return Padding(
        padding: const EdgeInsets.all(24),
        child: Center(
          child: Text(
            'Failed to open the memory engine:\n$_initError',
            textAlign: TextAlign.center,
          ),
        ),
      );
    }
    return Column(
      children: [
        if (!_ready) const _ApiKeyBanner(),
        Expanded(child: _buildTranscript()),
        if (_sending) const LinearProgressIndicator(minHeight: 2),
        _buildComposer(),
      ],
    );
  }

  Widget _buildTranscript() {
    if (_items.isEmpty) {
      return Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Text(
            _ready
                ? 'Say hello — every message becomes a memory.'
                : 'Set OPENAI_API_KEY in flutter/example/.env and restart the app.',
            textAlign: TextAlign.center,
            style: TextStyle(color: Theme.of(context).hintColor),
          ),
        ),
      );
    }
    return ListView.builder(
      controller: _scroll,
      padding: const EdgeInsets.symmetric(vertical: 12),
      itemCount: _items.length,
      itemBuilder: (context, i) => _ChatItemView(item: _items[i]),
    );
  }

  Widget _buildComposer() {
    final canSend = _ready && !_sending;
    return SafeArea(
      top: false,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            Expanded(
              child: TextField(
                controller: _input,
                enabled: canSend,
                minLines: 1,
                maxLines: 5,
                textInputAction: TextInputAction.newline,
                keyboardType: TextInputType.multiline,
                decoration: InputDecoration(
                  hintText: _ready
                      ? 'Message'
                      : 'Set OPENAI_API_KEY in .env to start…',
                  border: const OutlineInputBorder(),
                  isDense: true,
                ),
                onSubmitted: (_) => _send(),
              ),
            ),
            const SizedBox(width: 8),
            IconButton.filled(
              onPressed: canSend ? _send : null,
              icon: const Icon(Icons.send),
            ),
          ],
        ),
      ),
    );
  }
}

/// Inline banner shown when no OpenAI API key is configured. Keys come solely
/// from `flutter/example/.env`, so this is informational (not tappable).
class _ApiKeyBanner extends StatelessWidget {
  const _ApiKeyBanner();

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Material(
      color: scheme.secondaryContainer,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        child: Row(
          children: [
            Icon(Icons.key, color: scheme.onSecondaryContainer),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                'Set OPENAI_API_KEY in flutter/example/.env and restart the app.',
                style: TextStyle(color: scheme.onSecondaryContainer),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Renders a single transcript entry based on its [ChatRole].
class _ChatItemView extends StatelessWidget {
  const _ChatItemView({required this.item});

  final ChatItem item;

  @override
  Widget build(BuildContext context) {
    switch (item.role) {
      case ChatRole.user:
        return _Bubble(
          text: item.text,
          alignment: Alignment.centerRight,
          color: Theme.of(context).colorScheme.primary,
          textColor: Theme.of(context).colorScheme.onPrimary,
        );
      case ChatRole.assistant:
        return _Bubble(
          text: item.text,
          alignment: Alignment.centerLeft,
          color: Theme.of(context).colorScheme.surfaceContainerHighest,
          textColor: Theme.of(context).colorScheme.onSurface,
        );
      case ChatRole.system:
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
          child: Text(
            item.text,
            textAlign: TextAlign.center,
            style: TextStyle(
              color: Theme.of(context).hintColor,
              fontStyle: FontStyle.italic,
              fontSize: 12,
            ),
          ),
        );
      case ChatRole.error:
        return _Bubble(
          text: item.text,
          alignment: Alignment.centerLeft,
          color: Theme.of(context).colorScheme.errorContainer,
          textColor: Theme.of(context).colorScheme.onErrorContainer,
        );
      case ChatRole.recalled:
        return _RecalledSection(
          memories: item.recalled,
          scores: item.scores,
          rerankedByJina: item.rerankedByJina,
        );
      case ChatRole.stored:
        return _StoredSection(stored: item.stored);
    }
  }
}

/// A left/right aligned chat bubble.
class _Bubble extends StatelessWidget {
  const _Bubble({
    required this.text,
    required this.alignment,
    required this.color,
    required this.textColor,
  });

  final String text;
  final Alignment alignment;
  final Color color;
  final Color textColor;

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: alignment,
      child: Container(
        constraints: BoxConstraints(
          maxWidth: MediaQuery.of(context).size.width * 0.78,
        ),
        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
        decoration: BoxDecoration(
          color: color,
          borderRadius: BorderRadius.circular(16),
        ),
        child: SelectableText(text, style: TextStyle(color: textColor)),
      ),
    );
  }
}

/// The "🧠 recalled" section listing retrieved memories (shown above the reply).
class _RecalledSection extends StatelessWidget {
  const _RecalledSection({
    required this.memories,
    required this.scores,
    required this.rerankedByJina,
  });

  final List<RecalledMemory> memories;

  /// Score to show per memory (parallel to [memories]): Jina relevance when
  /// [rerankedByJina], otherwise the semantic score.
  final List<double?> scores;

  /// Whether [memories] were reordered by the Jina reranker.
  final bool rerankedByJina;

  @override
  Widget build(BuildContext context) {
    final hintStyle = TextStyle(
      color: Theme.of(context).hintColor,
      fontStyle: FontStyle.italic,
      fontSize: 12,
    );

    if (memories.isEmpty) {
      return Align(
        alignment: Alignment.centerLeft,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(16, 6, 16, 2),
          child: Text('🧠 no relevant memories yet', style: hintStyle),
        ),
      );
    }

    return Align(
      alignment: Alignment.centerLeft,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 2),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 6),
              child: Text(
                '🧠 recalled ${memories.length} '
                '${rerankedByJina ? "(reranked by Jina)" : "(semantic)"}',
                style: TextStyle(
                  color: Theme.of(context).hintColor,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: [
                for (var i = 0; i < memories.length; i++)
                  Chip(
                    visualDensity: VisualDensity.compact,
                    materialTapTargetSize:
                        MaterialTapTargetSize.shrinkWrap,
                    label: Text(
                      _label(memories[i], i < scores.length ? scores[i] : null),
                      style: const TextStyle(fontSize: 12),
                    ),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _label(RecalledMemory m, double? score) => score != null
      ? '[${m.kind}] ${m.content}  (${score.toStringAsFixed(2)})'
      : '[${m.kind}] ${m.content}';
}

/// The "💾 stored" section showing what the extraction step decided to persist
/// for this turn — one chip per atomic memory as `[<type>] <content>`. When the
/// list is empty, the message was deemed chit-chat and nothing was stored.
///
/// Rendered distinctly from the 🧠 recalled section (tinted chips) so the
/// classification and selectivity are visible at a glance.
class _StoredSection extends StatelessWidget {
  const _StoredSection({required this.stored});

  final List<({String content, String type})> stored;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    if (stored.isEmpty) {
      return Align(
        alignment: Alignment.centerLeft,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
          child: Text(
            '💾 nothing worth storing',
            style: TextStyle(
              color: Theme.of(context).hintColor,
              fontStyle: FontStyle.italic,
              fontSize: 12,
            ),
          ),
        ),
      );
    }

    return Align(
      alignment: Alignment.centerLeft,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 2, 16, 6),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 6),
              child: Text(
                '💾 stored ${stored.length}',
                style: TextStyle(
                  color: Theme.of(context).hintColor,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: [
                for (final m in stored)
                  Chip(
                    visualDensity: VisualDensity.compact,
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                    backgroundColor: scheme.tertiaryContainer,
                    side: BorderSide(color: scheme.tertiary.withValues(alpha: 0.4)),
                    label: Text(
                      '[${m.type}] ${m.content}',
                      style: TextStyle(
                        fontSize: 12,
                        color: scheme.onTertiaryContainer,
                      ),
                    ),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
79
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Local-first, on-device cognitive memory for AI apps — a Flutter binding to the Rust memlocal engine (CozoDB) with pluggable OpenAI + Jina providers.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#ai #memory #llm #embeddings #database

License

Apache-2.0 (license)

Dependencies

flutter, flutter_rust_bridge, http, plugin_platform_interface

More

Packages that depend on memlocal

Packages that implement memlocal