diff --git a/docs/styles.css b/docs/styles.css
index 4fd257e..2f7cee2 100644
--- a/docs/styles.css
+++ b/docs/styles.css
@@ -438,6 +438,20 @@ section {
text-align: center;
}
+/* Local-First Section */
+.local-first-section {
+ background: linear-gradient(135deg, #0c1220 0%, #1a2744 50%, #0d3b66 100%);
+ padding: var(--space-3xl) 0;
+}
+
+.local-first-section .section-header h2 {
+ color: white;
+}
+
+.local-first-section .section-header p {
+ color: rgba(255, 255, 255, 0.7);
+}
+
.btn-cloud {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
text-decoration: none;
diff --git a/examples/local_first_chat/.gitignore b/examples/local_first_chat/.gitignore
new file mode 100644
index 0000000..3e982a6
--- /dev/null
+++ b/examples/local_first_chat/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+.env
+.env.local
+package-lock.json
diff --git a/examples/local_first_chat/README.md b/examples/local_first_chat/README.md
new file mode 100644
index 0000000..0b2f0e9
--- /dev/null
+++ b/examples/local_first_chat/README.md
@@ -0,0 +1,70 @@
+# Local-First Chat
+
+A minimal example of a **100% client-side** AI chat app using MindCache.
+
+## Features
+
+- **No server required** - AI calls go directly from browser to OpenAI
+- **API key in localStorage** - Never sent to any server
+- **Data in IndexedDB** - Persists locally, works offline
+- **Real-time streaming** - See responses as they generate
+- **MindCache integration** - AI can read/write to your local data
+
+## Quick Start
+
+```bash
+# Install dependencies
+npm install
+
+# Run development server
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) and enter your OpenAI API key when prompted.
+
+## How It Works
+
+```tsx
+// layout.tsx - Configure the provider (just 3 lines of config!)
+
+ {children}
+
+
+// page.tsx - Use the chat component
+
+```
+
+That's it! ~15 lines of code for a full AI chat app.
+
+## Architecture
+
+```
+Browser
+├── MindCache (IndexedDB) ← Local data persistence
+├── AI SDK (streamText) ← Streaming AI responses
+└── OpenAI API ← Direct API calls (no proxy)
+```
+
+## Adding MindCache Data
+
+The AI can read and write to MindCache. Set up some initial data:
+
+```tsx
+const { mindcache } = useMindCacheContext();
+
+// Add data the AI can see and modify
+mindcache.set_value('user_name', 'Alice', {
+ systemTags: ['SystemPrompt', 'LLMWrite']
+});
+```
+
+Now ask the AI: "What's my name?" or "Change my name to Bob"
diff --git a/examples/local_first_chat/env.example b/examples/local_first_chat/env.example
new file mode 100644
index 0000000..52bd74d
--- /dev/null
+++ b/examples/local_first_chat/env.example
@@ -0,0 +1,3 @@
+# No server-side env needed!
+# API key is stored in browser localStorage
+# Just run: pnpm dev
diff --git a/examples/local_first_chat/next-env.d.ts b/examples/local_first_chat/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/examples/local_first_chat/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/local_first_chat/next.config.js b/examples/local_first_chat/next.config.js
new file mode 100644
index 0000000..7fe6e97
--- /dev/null
+++ b/examples/local_first_chat/next.config.js
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ transpilePackages: ['mindcache']
+};
+
+module.exports = nextConfig;
diff --git a/examples/local_first_chat/package.json b/examples/local_first_chat/package.json
new file mode 100644
index 0000000..93db82e
--- /dev/null
+++ b/examples/local_first_chat/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "local-first-chat",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "mindcache": "file:../../packages/mindcache",
+ "next": "^15.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "typescript": "^5"
+ }
+}
diff --git a/examples/local_first_chat/src/app/globals.css b/examples/local_first_chat/src/app/globals.css
new file mode 100644
index 0000000..6fad0db
--- /dev/null
+++ b/examples/local_first_chat/src/app/globals.css
@@ -0,0 +1,18 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+ background: #000;
+ color: #fff;
+}
+
+/* Blinking cursor animation for streaming */
+@keyframes blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
diff --git a/examples/local_first_chat/src/app/layout.tsx b/examples/local_first_chat/src/app/layout.tsx
new file mode 100644
index 0000000..cf8743b
--- /dev/null
+++ b/examples/local_first_chat/src/app/layout.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import { MindCacheProvider } from 'mindcache';
+import './globals.css';
+
+/**
+ * Local-first MindCache app layout
+ *
+ * - API key stored in browser localStorage (never sent to server)
+ * - AI runs directly in browser → OpenAI
+ * - Data persisted in IndexedDB
+ * - No server required!
+ */
+export default function RootLayout({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/local_first_chat/src/app/page.tsx b/examples/local_first_chat/src/app/page.tsx
new file mode 100644
index 0000000..3f7858e
--- /dev/null
+++ b/examples/local_first_chat/src/app/page.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { MindCacheChat, useMindCacheContext } from 'mindcache';
+
+/**
+ * Simple local-first chat page
+ *
+ * That's it! The MindCacheChat component handles:
+ * - API key input (if not set)
+ * - Real-time streaming
+ * - MindCache tool integration
+ * - Mobile-friendly UI
+ */
+export default function Home() {
+ return (
+
+
+
+
+ );
+}
+
+function Header() {
+ const { mindcache, isLoaded } = useMindCacheContext();
+
+ return (
+
+
+ Local-First Chat
+
+
+ {isLoaded ? `${Object.keys(mindcache?.getAll() || {}).length} keys in MindCache` : 'Loading...'}
+
+
+ );
+}
diff --git a/examples/local_first_chat/tsconfig.json b/examples/local_first_chat/tsconfig.json
new file mode 100644
index 0000000..fba2bf3
--- /dev/null
+++ b/examples/local_first_chat/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/local_first_mindcache/.gitignore b/examples/local_first_mindcache/.gitignore
new file mode 100644
index 0000000..3e982a6
--- /dev/null
+++ b/examples/local_first_mindcache/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.next
+.env
+.env.local
+package-lock.json
diff --git a/examples/local_first_mindcache/README.md b/examples/local_first_mindcache/README.md
new file mode 100644
index 0000000..86305b5
--- /dev/null
+++ b/examples/local_first_mindcache/README.md
@@ -0,0 +1,97 @@
+# Local-First MindCache
+
+A complete MindCache demo with AI chat and key-value editor - all running 100% client-side.
+
+## Features
+
+- **AI Chat** - Talk to an AI that can read and write your MindCache data
+- **MindCache Editor** - View and edit all your stored data
+- **Real-time Sync** - Changes from AI immediately appear in the editor
+- **Persistent Storage** - All data saved to IndexedDB
+- **No Server Required** - Everything runs in the browser
+
+## Quick Start
+
+```bash
+npm install
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) and enter your OpenAI API key when prompted.
+
+## How It Works
+
+### Layout (layout.tsx)
+```tsx
+
+ {children}
+
+```
+
+### Page (page.tsx)
+```tsx
+const { mindcache, isLoaded } = useMindCacheContext();
+
+return (
+ <>
+
+
+ >
+);
+```
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Browser │
+├─────────────────────┬───────────────────────────────┤
+│ MindCacheChat │ STMEditor │
+│ ┌───────────────┐ │ ┌───────────────────────┐ │
+│ │ useClientChat │ │ │ useMindCacheContext │ │
+│ └───────┬───────┘ │ └───────────┬───────────┘ │
+│ │ │ │ │
+│ ▼ │ ▼ │
+│ ┌───────────────────────────────────────────┐ │
+│ │ MindCacheProvider │ │
+│ │ ┌─────────────┐ ┌─────────────────┐ │ │
+│ │ │ MindCache │◄───│ AI SDK │ │ │
+│ │ │ (IndexedDB)│ │ (streamText) │ │ │
+│ │ └─────────────┘ └────────┬────────┘ │ │
+│ └──────────────────────────────│────────────┘ │
+│ │ │
+└──────────────────────────────────│──────────────────┘
+ ▼
+ OpenAI API
+```
+
+## Key Differences from nextjs_client_demo
+
+| Feature | nextjs_client_demo | local_first_mindcache |
+|---------|-------------------|----------------------|
+| Chat | Custom ChatInterface + API route | `
` |
+| AI Execution | Server-side API | Client-side `streamText` |
+| Config | Manual modelProvider | `provider: 'openai'` |
+| Lines of Code | ~1500+ | ~300 |
+| Dependencies | Many | Just `mindcache` |
+
+## Files
+
+```
+src/
+├── app/
+│ ├── layout.tsx # MindCacheProvider setup
+│ ├── page.tsx # Main split-panel layout
+│ └── globals.css # Minimal styling
+└── components/
+ ├── STMEditor.tsx # Key-value editor
+ └── STMToolbar.tsx # Add/Export/Import/Clear
+```
diff --git a/examples/local_first_mindcache/next-env.d.ts b/examples/local_first_mindcache/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/examples/local_first_mindcache/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/local_first_mindcache/next.config.js b/examples/local_first_mindcache/next.config.js
new file mode 100644
index 0000000..7fe6e97
--- /dev/null
+++ b/examples/local_first_mindcache/next.config.js
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ transpilePackages: ['mindcache']
+};
+
+module.exports = nextConfig;
diff --git a/examples/local_first_mindcache/package.json b/examples/local_first_mindcache/package.json
new file mode 100644
index 0000000..5c5dc54
--- /dev/null
+++ b/examples/local_first_mindcache/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "local-first-mindcache",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "mindcache": "file:../../packages/mindcache",
+ "next": "^15.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "typescript": "^5"
+ }
+}
diff --git a/examples/local_first_mindcache/src/app/globals.css b/examples/local_first_mindcache/src/app/globals.css
new file mode 100644
index 0000000..26b83c0
--- /dev/null
+++ b/examples/local_first_mindcache/src/app/globals.css
@@ -0,0 +1,32 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+ background: #000;
+ color: #22c55e;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #111;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #333;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #444;
+}
diff --git a/examples/local_first_mindcache/src/app/layout.tsx b/examples/local_first_mindcache/src/app/layout.tsx
new file mode 100644
index 0000000..08d4f66
--- /dev/null
+++ b/examples/local_first_mindcache/src/app/layout.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { MindCacheProvider } from 'mindcache';
+import './globals.css';
+
+export default function RootLayout({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/local_first_mindcache/src/app/page.tsx b/examples/local_first_mindcache/src/app/page.tsx
new file mode 100644
index 0000000..9dedde5
--- /dev/null
+++ b/examples/local_first_mindcache/src/app/page.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import { useState, useCallback, useEffect } from 'react';
+import { MindCacheChat, useMindCacheContext } from 'mindcache';
+import STMEditor from '@/components/STMEditor';
+import STMToolbar from '@/components/STMToolbar';
+
+export default function Home() {
+ const { mindcache, isLoaded, hasApiKey } = useMindCacheContext();
+ const [leftWidth, setLeftWidth] = useState(60);
+ const [isResizing, setIsResizing] = useState(false);
+ const [stmVersion, setStmVersion] = useState(0);
+ const [chatKey, setChatKey] = useState(0);
+
+ // Initialize default keys
+ useEffect(() => {
+ if (!isLoaded || !mindcache) return;
+
+ const currentKeys = Object.keys(mindcache.getAll());
+ const userKeys = currentKeys.filter(key => !key.startsWith('$'));
+
+ if (userKeys.length === 0) {
+ mindcache.set_value('name', 'Anonymous User', {
+ systemTags: ['SystemPrompt', 'LLMWrite']
+ });
+ mindcache.set_value('preferences', 'No preferences set', {
+ systemTags: ['SystemPrompt', 'LLMWrite']
+ });
+ mindcache.set_value('notes', 'No notes yet', {
+ systemTags: ['SystemPrompt', 'LLMWrite']
+ });
+ }
+ }, [isLoaded, mindcache]);
+
+ const handleSTMChange = useCallback(() => {
+ setStmVersion(v => v + 1);
+ }, []);
+
+ const handleFullRefresh = useCallback(() => {
+ setStmVersion(v => v + 1);
+ setChatKey(k => k + 1);
+ }, []);
+
+ // Resizing handlers
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ setIsResizing(true);
+ e.preventDefault();
+ }, []);
+
+ useEffect(() => {
+ if (!isResizing) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const newLeftWidth = (e.clientX / window.innerWidth) * 100;
+ setLeftWidth(Math.min(Math.max(newLeftWidth, 30), 70));
+ };
+
+ const handleMouseUp = () => setIsResizing(false);
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ };
+ }, [isResizing]);
+
+ if (!isLoaded) {
+ return (
+
+
Loading MindCache...
+
●●●
+
+ );
+ }
+
+ return (
+
+ {/* Left: Chat */}
+
+
+ 💬 Chat
+
+ {hasApiKey ? '🟢 API Key Set' : '🔴 No API Key'}
+
+
+
+
+
+ {/* Resizer */}
+
(e.currentTarget.style.background = 'rgba(34, 197, 94, 0.3)')}
+ onMouseLeave={(e) => !isResizing && (e.currentTarget.style.background = 'transparent')}
+ />
+
+ {/* Right: STM Editor */}
+
+
+ 🧠 MindCache Editor
+
+ {Object.keys(mindcache?.getAll() || {}).filter(k => !k.startsWith('$')).length} keys
+
+
+
+
+
+
+ );
+}
diff --git a/examples/local_first_mindcache/src/components/STMEditor.tsx b/examples/local_first_mindcache/src/components/STMEditor.tsx
new file mode 100644
index 0000000..11f0079
--- /dev/null
+++ b/examples/local_first_mindcache/src/components/STMEditor.tsx
@@ -0,0 +1,350 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useMindCacheContext, type SystemTag } from 'mindcache';
+
+interface STMEditorProps {
+ onSTMChange?: () => void;
+ stmVersion?: number;
+}
+
+export default function STMEditor({ onSTMChange, stmVersion }: STMEditorProps) {
+ const { mindcache } = useMindCacheContext();
+ const [stmState, setSTMState] = useState
>({});
+ const [editingKey, setEditingKey] = useState(null);
+ const [editingValue, setEditingValue] = useState('');
+ const [editingProps, setEditingProps] = useState(null);
+ const [propsForm, setPropsForm] = useState({
+ keyName: '',
+ systemTags: [] as SystemTag[]
+ });
+
+ // Sync state with mindcache
+ useEffect(() => {
+ if (!mindcache) return;
+ setSTMState(mindcache.getAll());
+ }, [mindcache, stmVersion]);
+
+ // Subscribe to changes
+ useEffect(() => {
+ if (!mindcache) return;
+
+ const updateState = () => {
+ setSTMState(mindcache.getAll());
+ onSTMChange?.();
+ };
+
+ mindcache.subscribeToAll(updateState);
+ return () => mindcache.unsubscribeFromAll(updateState);
+ }, [mindcache, onSTMChange]);
+
+ const startEditing = (key: string, value: unknown) => {
+ setEditingKey(key);
+ setEditingValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value || ''));
+ };
+
+ const saveEdit = () => {
+ if (!mindcache || !editingKey) return;
+
+ let parsedValue;
+ try {
+ parsedValue = JSON.parse(editingValue);
+ } catch {
+ parsedValue = editingValue;
+ }
+
+ mindcache.set_value(editingKey, parsedValue);
+ setEditingKey(null);
+ setEditingValue('');
+ };
+
+ const deleteKey = (key: string) => {
+ if (!mindcache) return;
+ mindcache.delete(key);
+ };
+
+ const startEditingProps = (key: string) => {
+ if (!mindcache) return;
+
+ const attrs = mindcache.get_attributes(key);
+ setPropsForm({
+ keyName: key,
+ systemTags: attrs?.systemTags || []
+ });
+ setEditingProps(key);
+ };
+
+ const saveProps = () => {
+ if (!mindcache || !editingProps) return;
+
+ const oldKey = editingProps;
+ const newKey = propsForm.keyName.trim();
+
+ if (newKey !== oldKey) {
+ if (mindcache.has(newKey) || newKey.startsWith('$')) {
+ alert(`Key "${newKey}" already exists or is a system key`);
+ return;
+ }
+ const value = mindcache.get_value(oldKey);
+ mindcache.set_value(newKey, value, { systemTags: propsForm.systemTags });
+ mindcache.delete(oldKey);
+ } else {
+ mindcache.set_attributes(oldKey, { systemTags: propsForm.systemTags });
+ }
+
+ setEditingProps(null);
+ };
+
+ const toggleSystemTag = (tag: SystemTag) => {
+ const has = propsForm.systemTags.includes(tag);
+ setPropsForm({
+ ...propsForm,
+ systemTags: has
+ ? propsForm.systemTags.filter(t => t !== tag)
+ : [...propsForm.systemTags, tag]
+ });
+ };
+
+ const entries = Object.entries(stmState).filter(([key]) => !key.startsWith('$'));
+
+ return (
+
+ {entries.length === 0 ? (
+
+ No data yet. Add a key or chat with the AI to create memories.
+
+ ) : (
+
+ {entries.map(([key, value]) => {
+ const attrs = mindcache?.get_attributes(key);
+ const indicators = [];
+ const sys = attrs?.systemTags || [];
+ if (sys.includes('LLMWrite')) indicators.push('W');
+ if (sys.includes('SystemPrompt')) indicators.push('S');
+
+ return (
+
+ {/* Header */}
+
+
+ {key}
+ {indicators.length > 0 && (
+
+ [{indicators.join('')}]
+
+ )}
+
+
+
+
+
+
+
+ {/* Value */}
+ {editingKey === key ? (
+
+ ) : (
+
startEditing(key, value)}
+ style={{
+ padding: '8px',
+ background: '#111',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ color: value ? '#22c55e' : '#666',
+ fontSize: '13px',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word'
+ }}
+ >
+ {value ? String(value) : '(empty - click to edit)'}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* Properties Modal */}
+ {editingProps && (
+
+
+
Key Properties
+
+ {/* Key Name */}
+
+
+ setPropsForm({ ...propsForm, keyName: e.target.value })}
+ style={{
+ width: '100%',
+ padding: '8px',
+ background: '#111',
+ border: '1px solid #333',
+ borderRadius: '4px',
+ color: '#22c55e',
+ fontFamily: 'inherit',
+ outline: 'none'
+ }}
+ />
+
+
+ {/* System Tags */}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/examples/local_first_mindcache/src/components/STMToolbar.tsx b/examples/local_first_mindcache/src/components/STMToolbar.tsx
new file mode 100644
index 0000000..91f3d4b
--- /dev/null
+++ b/examples/local_first_mindcache/src/components/STMToolbar.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useState, useRef } from 'react';
+import { useMindCacheContext } from 'mindcache';
+
+interface STMToolbarProps {
+ onRefresh: () => void;
+}
+
+export default function STMToolbar({ onRefresh }: STMToolbarProps) {
+ const { mindcache } = useMindCacheContext();
+ const [showAddKey, setShowAddKey] = useState(false);
+ const [newKeyName, setNewKeyName] = useState('');
+ const fileInputRef = useRef(null);
+
+ const handleAddKey = () => {
+ if (!mindcache || !newKeyName.trim()) return;
+
+ mindcache.set_value(newKeyName.trim(), '', {
+ systemTags: ['SystemPrompt', 'LLMWrite']
+ });
+ setNewKeyName('');
+ setShowAddKey(false);
+ onRefresh();
+ };
+
+ const handleExport = () => {
+ if (!mindcache) return;
+
+ const markdown = mindcache.toMarkdown();
+ const blob = new Blob([markdown], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `mindcache-${new Date().toISOString().split('T')[0]}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImport = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file || !mindcache) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const content = event.target?.result as string;
+ mindcache.fromMarkdown(content, { merge: true });
+ onRefresh();
+ };
+ reader.readAsText(file);
+ e.target.value = '';
+ };
+
+ const handleClear = () => {
+ if (!mindcache) return;
+ if (confirm('Clear all MindCache data? This cannot be undone.')) {
+ const keys = Object.keys(mindcache.getAll());
+ keys.forEach(key => {
+ if (!key.startsWith('$')) {
+ mindcache.delete(key);
+ }
+ });
+ onRefresh();
+ }
+ };
+
+ const buttonStyle: React.CSSProperties = {
+ padding: '6px 12px',
+ background: 'transparent',
+ border: '1px solid #333',
+ borderRadius: '4px',
+ color: '#22c55e',
+ cursor: 'pointer',
+ fontSize: '12px',
+ fontFamily: 'inherit'
+ };
+
+ return (
+
+ {showAddKey ? (
+
+ setNewKeyName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleAddKey();
+ if (e.key === 'Escape') setShowAddKey(false);
+ }}
+ placeholder="Key name..."
+ autoFocus
+ style={{
+ padding: '6px 12px',
+ background: '#111',
+ border: '1px solid #22c55e',
+ borderRadius: '4px',
+ color: '#22c55e',
+ fontSize: '12px',
+ fontFamily: 'inherit',
+ outline: 'none'
+ }}
+ />
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/examples/local_first_mindcache/tsconfig.json b/examples/local_first_mindcache/tsconfig.json
new file mode 100644
index 0000000..fba2bf3
--- /dev/null
+++ b/examples/local_first_mindcache/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/gitstore/README.md b/packages/gitstore/README.md
index ed611bb..67f183a 100644
--- a/packages/gitstore/README.md
+++ b/packages/gitstore/README.md
@@ -2,6 +2,24 @@
Git repository abstraction for MindCache - list files, read/write with automatic commits.
+## 30-Second Start
+
+```typescript
+import { GitStore } from '@mindcache/gitstore';
+
+const store = new GitStore({
+ owner: 'your-username',
+ repo: 'your-repo',
+ tokenProvider: async () => 'ghp_your_token'
+});
+
+// Read
+const content = await store.readFile('notes.md');
+
+// Write (creates commit)
+await store.writeFile('notes.md', '# Updated!', { message: 'Update notes' });
+```
+
## Installation
```bash
diff --git a/packages/mindcache/package.json b/packages/mindcache/package.json
index f0279fb..355d6ca 100644
--- a/packages/mindcache/package.json
+++ b/packages/mindcache/package.json
@@ -72,6 +72,8 @@
"vitest": "^2.1.9"
},
"dependencies": {
+ "@ai-sdk/openai": "^2.0.0",
+ "ai": "^5.0.0",
"fast-diff": "^1.3.0",
"lib0": "^0.2.115",
"y-indexeddb": "^9.0.12",
@@ -80,13 +82,7 @@
"zod": "^3.23.0"
},
"peerDependencies": {
- "ai": ">=3.0.0",
- "react": "^19.0.0"
- },
- "peerDependenciesMeta": {
- "ai": {
- "optional": true
- }
+ "react": "^18.0.0 || ^19.0.0"
},
"engines": {
"node": ">=18.0.0"
diff --git a/packages/mindcache/src/index.ts b/packages/mindcache/src/index.ts
index 5d29961..407725c 100644
--- a/packages/mindcache/src/index.ts
+++ b/packages/mindcache/src/index.ts
@@ -40,3 +40,33 @@ export type { IndexedDBConfig } from './local';
// React exports
export { useMindCache } from './react';
export type { UseMindCacheResult } from './react';
+
+// Local-first React components and hooks
+export {
+ MindCacheProvider,
+ useMindCacheContext,
+ MindCacheChat,
+ useClientChat,
+ useLocalFirstSync
+} from './react';
+
+export type {
+ // Provider types
+ MindCacheProviderConfig,
+ MindCacheContextValue,
+ LocalFirstSyncConfig,
+ AIConfig,
+ // Chat types
+ MindCacheChatProps,
+ ChatTheme,
+ UseClientChatOptions,
+ UseClientChatReturn,
+ ChatMessage,
+ ChatStatus,
+ // Sync types
+ UseLocalFirstSyncOptions,
+ UseLocalFirstSyncReturn,
+ GitStoreSyncConfig,
+ ServerSyncConfig,
+ SyncStatus
+} from './react';
diff --git a/packages/mindcache/src/react/MindCacheChat.tsx b/packages/mindcache/src/react/MindCacheChat.tsx
new file mode 100644
index 0000000..6433424
--- /dev/null
+++ b/packages/mindcache/src/react/MindCacheChat.tsx
@@ -0,0 +1,553 @@
+'use client';
+
+import React, { useState, useRef, useEffect, type FormEvent, type KeyboardEvent } from 'react';
+import { useClientChat, type ChatMessage, type UseClientChatOptions } from './useClientChat';
+import { useMindCacheContext } from './MindCacheContext';
+
+/**
+ * Chat theme configuration
+ */
+export interface ChatTheme {
+ /** Container background */
+ background?: string;
+ /** User message background */
+ userBubble?: string;
+ /** Assistant message background */
+ assistantBubble?: string;
+ /** Text color */
+ textColor?: string;
+ /** Secondary text color */
+ secondaryTextColor?: string;
+ /** Border color */
+ borderColor?: string;
+ /** Primary/accent color */
+ primaryColor?: string;
+ /** Font family */
+ fontFamily?: string;
+}
+
+/**
+ * Default dark theme
+ */
+const defaultTheme: ChatTheme = {
+ background: '#000',
+ userBubble: '#1a1a2e',
+ assistantBubble: '#0d0d0d',
+ textColor: '#22c55e',
+ secondaryTextColor: '#6b7280',
+ borderColor: '#333',
+ primaryColor: '#22c55e',
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
+};
+
+/**
+ * MindCacheChat props
+ */
+export interface MindCacheChatProps extends Omit {
+ /** Custom theme */
+ theme?: ChatTheme;
+ /** Placeholder text for input */
+ placeholder?: string;
+ /** Welcome message (shown when no messages) */
+ welcomeMessage?: string;
+ /** Show API key input if not configured */
+ showApiKeyInput?: boolean;
+ /** Custom class name for container */
+ className?: string;
+ /** Custom styles for container */
+ style?: React.CSSProperties;
+ /** Render custom message component */
+ renderMessage?: (message: ChatMessage) => React.ReactNode;
+ /** Header component */
+ header?: React.ReactNode;
+ /** Footer component (below input) */
+ footer?: React.ReactNode;
+}
+
+/**
+ * Default message renderer
+ */
+function DefaultMessage({
+ message,
+ theme
+}: {
+ message: ChatMessage;
+ theme: ChatTheme;
+}) {
+ const isUser = message.role === 'user';
+
+ return (
+
+
+
+ {isUser ? 'You' : 'Assistant'}
+
+
+ {message.content}
+
+
+
+ );
+}
+
+/**
+ * API Key input component
+ */
+function ApiKeyInput({
+ theme,
+ onSubmit
+}: {
+ theme: ChatTheme;
+ onSubmit: (key: string) => void;
+}) {
+ const [key, setKey] = useState('');
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (key.trim()) {
+ onSubmit(key.trim());
+ }
+ };
+
+ return (
+
+
+ Enter your API key to start chatting
+
+
+
+ Your key is stored locally and never sent to our servers.
+
+
+ );
+}
+
+/**
+ * MindCacheChat - Ready-to-use chat component for local-first AI
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export function MindCacheChat({
+ theme: customTheme,
+ placeholder = 'Type a message...',
+ welcomeMessage = 'Hello! I\'m ready to help you.',
+ showApiKeyInput = true,
+ className,
+ style,
+ renderMessage,
+ header,
+ footer,
+ initialMessages,
+ ...chatOptions
+}: MindCacheChatProps) {
+ const context = useMindCacheContext();
+ const theme = { ...defaultTheme, ...customTheme };
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+ const [inputValue, setInputValue] = useState('');
+
+ // Initialize with welcome message if no initial messages
+ const defaultInitialMessages: ChatMessage[] = welcomeMessage ? [
+ {
+ id: 'welcome',
+ role: 'assistant',
+ content: welcomeMessage,
+ createdAt: new Date()
+ }
+ ] : [];
+
+ const {
+ messages,
+ sendMessage,
+ isLoading,
+ error,
+ streamingContent,
+ stop
+ } = useClientChat({
+ ...chatOptions,
+ initialMessages: initialMessages || defaultInitialMessages,
+ mindcache: context.mindcache || undefined
+ });
+
+ // Auto-scroll to bottom
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ // Handle submit
+ const handleSubmit = async (e?: FormEvent) => {
+ e?.preventDefault();
+ if (!inputValue.trim() || isLoading) {
+ return;
+ }
+
+ const message = inputValue.trim();
+ setInputValue('');
+ await sendMessage(message);
+ };
+
+ // Handle keyboard
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ // Show API key input if needed
+ if (showApiKeyInput && !context.hasApiKey && context.aiConfig.keyStorage !== 'memory') {
+ return (
+
+ {header}
+
context.setApiKey(key)}
+ />
+ {footer}
+
+ );
+ }
+
+ // Loading state
+ if (!context.isLoaded) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ return (
+
+ {header}
+
+ {/* Messages area */}
+
+ {messages.map((message) => (
+ renderMessage ? (
+
+ {renderMessage(message)}
+
+ ) : (
+
+ )
+ ))}
+
+ {/* Show streaming content in real-time */}
+ {streamingContent && (
+
+
+
+ Assistant
+
+
+ {streamingContent}
+ ▊
+
+
+
+ )}
+
+ {/* Show loading indicator only when not streaming yet */}
+ {isLoading && !streamingContent && (
+
+ )}
+
+ {error && (
+
+ Error: {error.message}
+
+ )}
+
+
+
+
+ {/* Input area */}
+
+
+ {footer}
+
+ );
+}
diff --git a/packages/mindcache/src/react/MindCacheContext.tsx b/packages/mindcache/src/react/MindCacheContext.tsx
new file mode 100644
index 0000000..7c00d2a
--- /dev/null
+++ b/packages/mindcache/src/react/MindCacheContext.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import React, { createContext, useContext, useEffect, useState, useRef, type ReactNode } from 'react';
+import { createOpenAI } from '@ai-sdk/openai';
+import { MindCache, type MindCacheOptions } from '../core/MindCache';
+
+/** Supported AI providers */
+export type AIProvider = 'openai' | 'anthropic' | 'custom';
+
+/**
+ * Create a model from provider config
+ */
+function createModel(provider: AIProvider, model: string, apiKey: string) {
+ switch (provider) {
+ case 'openai': {
+ const openai = createOpenAI({ apiKey });
+ return openai(model);
+ }
+ case 'anthropic':
+ throw new Error('Anthropic provider not yet implemented. Use modelProvider for custom providers.');
+ default:
+ throw new Error(`Unknown provider: ${provider}. Use modelProvider for custom providers.`);
+ }
+}
+
+/**
+ * Configuration for local-first sync
+ */
+export interface LocalFirstSyncConfig {
+ /** Optional server URL for real-time sync when online */
+ serverUrl?: string;
+ /** GitStore configuration for GitHub backup */
+ gitstore?: {
+ owner: string;
+ repo: string;
+ path?: string;
+ /** Token provider function or direct token */
+ token: string | (() => Promise);
+ };
+ /** Auto-sync interval in ms (default: 30000 = 30s) */
+ autoSyncInterval?: number;
+ /** Debounce delay for saves in ms (default: 2000) */
+ saveDebounceMs?: number;
+}
+
+/**
+ * AI configuration for client-side chat
+ */
+export interface AIConfig {
+ /**
+ * AI provider: 'openai' | 'anthropic' | 'custom'
+ * If using 'custom', you must provide modelProvider
+ * @default 'openai'
+ */
+ provider?: AIProvider;
+ /**
+ * Model name (e.g., 'gpt-4o', 'gpt-4o-mini', 'claude-3-5-sonnet')
+ * @default 'gpt-4o'
+ */
+ model?: string;
+ /** API key - stored in localStorage if keyStorage is 'localStorage' */
+ apiKey?: string;
+ /** Where to store the API key: 'localStorage' | 'memory' | 'prompt' */
+ keyStorage?: 'localStorage' | 'memory' | 'prompt';
+ /** localStorage key for API key (default: 'ai_api_key') */
+ storageKey?: string;
+ /**
+ * Custom model provider function (advanced usage)
+ * Use this for providers not built-in or custom configurations
+ * @example
+ * ```ts
+ * import { createOpenAI } from '@ai-sdk/openai';
+ * modelProvider: (apiKey) => createOpenAI({ apiKey, baseURL: '...' })('gpt-4o')
+ * ```
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ modelProvider?: (apiKey: string) => any;
+}
+
+/**
+ * MindCache Provider configuration
+ */
+export interface MindCacheProviderConfig {
+ /** MindCache options (IndexedDB config, etc.) */
+ mindcache?: MindCacheOptions;
+ /** Local-first sync configuration */
+ sync?: LocalFirstSyncConfig;
+ /** AI configuration for client-side chat */
+ ai?: AIConfig;
+ /** Children components */
+ children: ReactNode;
+}
+
+/**
+ * MindCache context value
+ */
+export interface MindCacheContextValue {
+ /** The MindCache instance */
+ mindcache: MindCache | null;
+ /** Whether MindCache is loaded and ready */
+ isLoaded: boolean;
+ /** Any error during initialization */
+ error: Error | null;
+ /** AI configuration */
+ aiConfig: AIConfig;
+ /** Sync configuration */
+ syncConfig: LocalFirstSyncConfig | undefined;
+ /** Get the API key (from storage or prompt) */
+ getApiKey: () => string | null;
+ /** Set the API key */
+ setApiKey: (key: string) => void;
+ /** Whether API key is configured */
+ hasApiKey: boolean;
+ /** Get the AI model (uses API key from storage) */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getModel: () => any;
+ /** Trigger a manual sync to GitStore */
+ syncToGitStore: () => Promise;
+ /** Last sync timestamp */
+ lastSyncAt: Date | null;
+ /** Whether currently syncing */
+ isSyncing: boolean;
+}
+
+const MindCacheContext = createContext(null);
+
+/**
+ * Hook to access MindCache context
+ */
+export function useMindCacheContext(): MindCacheContextValue {
+ const context = useContext(MindCacheContext);
+ if (!context) {
+ throw new Error('useMindCacheContext must be used within a MindCacheProvider');
+ }
+ return context;
+}
+
+/**
+ * MindCacheProvider - Context provider for local-first MindCache apps
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export function MindCacheProvider({
+ mindcache: mcOptions,
+ sync: syncConfig,
+ ai: aiConfig = {},
+ children
+}: MindCacheProviderConfig) {
+ const [mindcache, setMindcache] = useState(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasApiKey, setHasApiKey] = useState(false);
+ const [lastSyncAt, setLastSyncAt] = useState(null);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const initRef = useRef(false);
+
+ // Default AI config
+ const resolvedAiConfig: AIConfig = {
+ provider: 'openai',
+ model: 'gpt-4o',
+ keyStorage: 'localStorage',
+ storageKey: 'ai_api_key',
+ ...aiConfig
+ };
+
+ // Initialize MindCache
+ useEffect(() => {
+ if (initRef.current) {
+ return;
+ }
+ initRef.current = true;
+
+ const init = async () => {
+ try {
+ // Default to IndexedDB if no options provided
+ const options: MindCacheOptions = mcOptions || {
+ indexedDB: {
+ dbName: 'mindcache_local_first',
+ storeName: 'mindcache_store',
+ debounceMs: 1000
+ }
+ };
+
+ const mc = new MindCache(options);
+ await mc.waitForSync();
+ setMindcache(mc);
+ setIsLoaded(true);
+
+ // Check for existing API key
+ if (resolvedAiConfig.keyStorage === 'localStorage' && typeof window !== 'undefined') {
+ const stored = localStorage.getItem(resolvedAiConfig.storageKey || 'openai_api_key');
+ setHasApiKey(!!stored);
+ } else if (resolvedAiConfig.apiKey) {
+ setHasApiKey(true);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ setIsLoaded(true);
+ }
+ };
+
+ init();
+
+ return () => {
+ if (mindcache) {
+ mindcache.disconnect();
+ }
+ };
+ }, []);
+
+ // Get API key from configured storage
+ const getApiKey = (): string | null => {
+ if (resolvedAiConfig.apiKey) {
+ return resolvedAiConfig.apiKey;
+ }
+ if (resolvedAiConfig.keyStorage === 'localStorage' && typeof window !== 'undefined') {
+ return localStorage.getItem(resolvedAiConfig.storageKey || 'openai_api_key');
+ }
+ return null;
+ };
+
+ // Set API key
+ const setApiKey = (key: string) => {
+ if (resolvedAiConfig.keyStorage === 'localStorage' && typeof window !== 'undefined') {
+ localStorage.setItem(resolvedAiConfig.storageKey || 'openai_api_key', key);
+ setHasApiKey(true);
+ }
+ };
+
+ // Get AI model
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const getModel = (): any => {
+ const apiKey = getApiKey();
+ if (!apiKey) {
+ throw new Error('API key not configured. Call setApiKey() first or configure ai.apiKey.');
+ }
+
+ // Use custom modelProvider if provided
+ if (resolvedAiConfig.modelProvider) {
+ return resolvedAiConfig.modelProvider(apiKey);
+ }
+
+ // Use built-in provider
+ const provider = resolvedAiConfig.provider || 'openai';
+ const model = resolvedAiConfig.model || 'gpt-4o';
+ return createModel(provider, model, apiKey);
+ };
+
+ // Sync to GitStore
+ const syncToGitStore = async () => {
+ if (!mindcache || !syncConfig?.gitstore) {
+ return;
+ }
+
+ setIsSyncing(true);
+ try {
+ // Dynamic import to avoid bundling gitstore if not used
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let gitStoreModule: any;
+ try {
+ gitStoreModule = await (Function('return import("@mindcache/gitstore")')() as Promise);
+ } catch {
+ throw new Error('@mindcache/gitstore is not installed. Run: npm install @mindcache/gitstore');
+ }
+
+ const { GitStore, MindCacheSync } = gitStoreModule;
+
+ const token = typeof syncConfig.gitstore.token === 'function'
+ ? await syncConfig.gitstore.token()
+ : syncConfig.gitstore.token;
+
+ const gitStore = new GitStore({
+ owner: syncConfig.gitstore.owner,
+ repo: syncConfig.gitstore.repo,
+ tokenProvider: async () => token
+ });
+
+ const sync = new MindCacheSync(gitStore, mindcache, {
+ filePath: syncConfig.gitstore.path || 'mindcache.md'
+ });
+
+ await sync.save({ message: 'Auto-sync from MindCache' });
+ setLastSyncAt(new Date());
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('[MindCacheProvider] Sync error:', err);
+ throw err;
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const value: MindCacheContextValue = {
+ mindcache,
+ isLoaded,
+ error,
+ aiConfig: resolvedAiConfig,
+ syncConfig,
+ getApiKey,
+ setApiKey,
+ hasApiKey,
+ getModel,
+ syncToGitStore,
+ lastSyncAt,
+ isSyncing
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/mindcache/src/react/README.md b/packages/mindcache/src/react/README.md
new file mode 100644
index 0000000..60ff9db
--- /dev/null
+++ b/packages/mindcache/src/react/README.md
@@ -0,0 +1,167 @@
+# MindCache React Components
+
+Local-first AI chat components with zero server required.
+
+## Quick Start
+
+```tsx
+import { MindCacheProvider, MindCacheChat } from 'mindcache';
+
+function App() {
+ return (
+
+
+
+ );
+}
+```
+
+That's it! The chat component handles:
+- API key input (prompts user if not set)
+- Real-time streaming responses
+- MindCache tool integration
+- Mobile-friendly UI
+
+## Components
+
+### ``
+
+Wraps your app and provides MindCache + AI configuration.
+
+```tsx
+
+ {children}
+
+```
+
+### ``
+
+Pre-built chat UI component.
+
+```tsx
+
+```
+
+## Hooks
+
+### `useMindCacheContext()`
+
+Access MindCache and AI configuration.
+
+```tsx
+const {
+ mindcache, // MindCache instance
+ isLoaded, // Ready to use?
+ hasApiKey, // API key configured?
+ setApiKey, // Set API key
+ getModel, // Get AI model instance
+ syncToGitStore // Manual sync to GitHub
+} = useMindCacheContext();
+```
+
+### `useClientChat()`
+
+Build custom chat UI.
+
+```tsx
+const {
+ messages,
+ sendMessage,
+ isLoading,
+ error,
+ streamingContent,
+ stop
+} = useClientChat({
+ systemPrompt: 'You are a helpful assistant.',
+ maxToolCalls: 5,
+ onFinish: (message) => console.log('Done:', message),
+ onMindCacheChange: () => console.log('MindCache updated')
+});
+```
+
+### `useLocalFirstSync()`
+
+Sync MindCache to GitHub.
+
+```tsx
+const {
+ status, // 'idle' | 'loading' | 'saving' | 'error'
+ lastSyncAt,
+ hasLocalChanges,
+ save, // Manual save
+ load, // Manual load
+ sync // Load then save
+} = useLocalFirstSync({
+ mindcache,
+ gitstore: {
+ owner: 'me',
+ repo: 'data',
+ token: async () => getToken()
+ },
+ autoSyncInterval: 30000,
+ saveDebounceMs: 5000
+});
+```
+
+## Custom AI Provider
+
+For providers not built-in, use `modelProvider`:
+
+```tsx
+import { createAnthropic } from '@ai-sdk/anthropic';
+
+ {
+ const anthropic = createAnthropic({ apiKey });
+ return anthropic('claude-3-5-sonnet-20241022');
+ }
+ }}
+>
+```
+
+## Architecture
+
+```
+Browser
+├── MindCache (IndexedDB) ← Local persistence
+├── AI SDK (streamText) ← Streaming responses
+└── OpenAI/Anthropic API ← Direct API calls
+```
+
+No server required. API keys stored in browser localStorage.
diff --git a/packages/mindcache/src/react/index.ts b/packages/mindcache/src/react/index.ts
index 364dd31..e4bc86f 100644
--- a/packages/mindcache/src/react/index.ts
+++ b/packages/mindcache/src/react/index.ts
@@ -1,4 +1,42 @@
+// Core hooks
export { useMindCache } from './useMindCache';
export type { UseMindCacheResult } from './useMindCache';
export { useMindCacheDocument } from './useMindCacheDocument';
export type { UseMindCacheDocumentResult } from './useMindCacheDocument';
+
+// Local-first components and hooks
+export { MindCacheProvider, useMindCacheContext } from './MindCacheContext';
+export type {
+ MindCacheProviderConfig,
+ MindCacheContextValue,
+ LocalFirstSyncConfig,
+ AIConfig,
+ AIProvider
+} from './MindCacheContext';
+
+export { MindCacheChat } from './MindCacheChat';
+export type {
+ MindCacheChatProps,
+ ChatTheme
+} from './MindCacheChat';
+
+export { useClientChat } from './useClientChat';
+export type {
+ UseClientChatOptions,
+ UseClientChatReturn,
+ ChatMessage,
+ ChatStatus,
+ MessagePart,
+ TextPart,
+ ToolCallPart,
+ ToolResultPart
+} from './useClientChat';
+
+export { useLocalFirstSync } from './useLocalFirstSync';
+export type {
+ UseLocalFirstSyncOptions,
+ UseLocalFirstSyncReturn,
+ GitStoreSyncConfig,
+ ServerSyncConfig,
+ SyncStatus
+} from './useLocalFirstSync';
diff --git a/packages/mindcache/src/react/useClientChat.ts b/packages/mindcache/src/react/useClientChat.ts
new file mode 100644
index 0000000..259d5f0
--- /dev/null
+++ b/packages/mindcache/src/react/useClientChat.ts
@@ -0,0 +1,332 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { streamText, stepCountIs } from 'ai';
+import type { MindCache } from '../core/MindCache';
+import { useMindCacheContext } from './MindCacheContext';
+
+/**
+ * Message part types (compatible with AI SDK UIMessage)
+ */
+export interface TextPart {
+ type: 'text';
+ text: string;
+}
+
+export interface ToolCallPart {
+ type: 'tool-call';
+ toolCallId: string;
+ toolName: string;
+ args: Record;
+}
+
+export interface ToolResultPart {
+ type: 'tool-result';
+ toolCallId: string;
+ toolName: string;
+ result: unknown;
+}
+
+export type MessagePart = TextPart | ToolCallPart | ToolResultPart;
+
+/**
+ * Chat message structure (compatible with AI SDK UIMessage)
+ */
+export interface ChatMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ parts?: MessagePart[];
+ createdAt: Date;
+}
+
+/**
+ * Chat status
+ */
+export type ChatStatus = 'idle' | 'loading' | 'streaming' | 'error';
+
+/**
+ * useClientChat options
+ */
+export interface UseClientChatOptions {
+ /** MindCache instance (uses context if not provided) */
+ mindcache?: MindCache;
+ /** Initial messages */
+ initialMessages?: ChatMessage[];
+ /** Custom system prompt (overrides MindCache system prompt) */
+ systemPrompt?: string;
+ /** Callback when AI modifies MindCache */
+ onMindCacheChange?: () => void;
+ /** Callback when message is complete */
+ onFinish?: (message: ChatMessage) => void;
+ /** Callback on error */
+ onError?: (error: Error) => void;
+ /** Max tool call iterations (default: 5) */
+ maxToolCalls?: number;
+}
+
+/**
+ * useClientChat return value
+ */
+export interface UseClientChatReturn {
+ /** All messages in the conversation */
+ messages: ChatMessage[];
+ /** Current status */
+ status: ChatStatus;
+ /** Current error if any */
+ error: Error | null;
+ /** Send a message */
+ sendMessage: (content: string) => Promise;
+ /** Clear all messages */
+ clearMessages: () => void;
+ /** Whether currently loading/streaming */
+ isLoading: boolean;
+ /** Add a message programmatically */
+ addMessage: (message: Omit) => void;
+ /** Stop the current generation */
+ stop: () => void;
+ /** Current streaming text (updates in real-time) */
+ streamingContent: string;
+}
+
+/**
+ * Generate unique ID
+ */
+function generateId(): string {
+ return Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
+}
+
+/**
+ * useClientChat - Client-side AI chat hook with real-time streaming
+ *
+ * Runs AI entirely in the browser using the Vercel AI SDK.
+ * Automatically integrates with MindCache for context and tool execution.
+ * Shows text streaming in real-time as it's generated.
+ *
+ * @example
+ * ```tsx
+ * function Chat() {
+ * const { messages, sendMessage, isLoading, streamingContent } = useClientChat();
+ *
+ * return (
+ *
+ * {messages.map(m =>
{m.content}
)}
+ * {streamingContent &&
{streamingContent}
}
+ *
sendMessage(e.target.value)} />
+ *
+ * );
+ * }
+ * ```
+ */
+export function useClientChat(options: UseClientChatOptions = {}): UseClientChatReturn {
+ const context = useMindCacheContext();
+ const mc = options.mindcache || context.mindcache;
+
+ const [messages, setMessages] = useState(options.initialMessages || []);
+ const [status, setStatus] = useState('idle');
+ const [error, setError] = useState(null);
+ const [streamingContent, setStreamingContent] = useState('');
+ const abortControllerRef = useRef(null);
+
+ const {
+ systemPrompt,
+ onMindCacheChange,
+ onFinish,
+ onError,
+ maxToolCalls = 5
+ } = options;
+
+ // Get API key from context
+ const apiKey = context.getApiKey();
+
+ // Add message helper
+ const addMessage = useCallback((msg: Omit) => {
+ const newMessage: ChatMessage = {
+ ...msg,
+ id: generateId(),
+ createdAt: new Date()
+ };
+ setMessages(prev => [...prev, newMessage]);
+ return newMessage;
+ }, []);
+
+ // Clear messages
+ const clearMessages = useCallback(() => {
+ setMessages([]);
+ setError(null);
+ setStreamingContent('');
+ }, []);
+
+ // Stop generation
+ const stop = useCallback(() => {
+ abortControllerRef.current?.abort();
+ setStatus('idle');
+ }, []);
+
+ // Send message
+ const sendMessage = useCallback(async (content: string) => {
+ if (!mc) {
+ const err = new Error('MindCache not initialized');
+ setError(err);
+ onError?.(err);
+ return;
+ }
+
+ if (!apiKey) {
+ const err = new Error('API key not configured. Please set your API key.');
+ setError(err);
+ onError?.(err);
+ return;
+ }
+
+ // Cancel any ongoing request
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+
+ // Add user message
+ const userMessage = addMessage({ role: 'user', content });
+ setStatus('loading');
+ setError(null);
+ setStreamingContent('');
+
+ try {
+ // Get model from context (handles provider config automatically)
+ const model = context.getModel();
+
+ // Get system prompt and tools from MindCache
+ const finalSystemPrompt = systemPrompt || mc.get_system_prompt();
+ const tools = mc.create_vercel_ai_tools();
+
+ // Build messages for API
+ const apiMessages = messages.concat(userMessage).map(m => ({
+ role: m.role as 'user' | 'assistant' | 'system',
+ content: m.content
+ }));
+
+ setStatus('streaming');
+
+ // Accumulated parts for the final message
+ const parts: MessagePart[] = [];
+ let accumulatedText = '';
+
+ // Stream the response with real-time updates
+ const result = await streamText({
+ model,
+ system: finalSystemPrompt,
+ messages: apiMessages,
+ tools,
+ stopWhen: stepCountIs(maxToolCalls),
+ abortSignal: abortControllerRef.current.signal,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onStepFinish: async (step: any) => {
+ // Handle tool calls
+ if (step.toolCalls && step.toolCalls.length > 0) {
+ for (const toolCall of step.toolCalls) {
+ const toolName = toolCall.toolName;
+ const args = (toolCall.args || toolCall.input || {}) as Record;
+
+ // Add tool call part
+ parts.push({
+ type: 'tool-call',
+ toolCallId: toolCall.toolCallId,
+ toolName,
+ args
+ });
+
+ // Execute tool on MindCache
+ if (typeof toolName === 'string' && (toolName.startsWith('write_') || toolName === 'create_key')) {
+ const value = args.value as string;
+ const result = mc.executeToolCall(toolName, value);
+
+ // Add tool result part
+ parts.push({
+ type: 'tool-result',
+ toolCallId: toolCall.toolCallId,
+ toolName,
+ result
+ });
+
+ onMindCacheChange?.();
+ }
+ }
+ }
+
+ // Add text from this step if any
+ if (step.text) {
+ accumulatedText += step.text;
+ }
+ }
+ });
+
+ // Stream text chunks to UI in real-time
+ for await (const chunk of result.textStream) {
+ accumulatedText += chunk;
+ setStreamingContent(accumulatedText);
+ }
+
+ // Build final message with parts
+ if (accumulatedText) {
+ parts.unshift({ type: 'text', text: accumulatedText });
+ }
+
+ // Add assistant message with all parts
+ const assistantMessage: ChatMessage = {
+ id: generateId(),
+ role: 'assistant',
+ content: accumulatedText,
+ parts: parts.length > 0 ? parts : undefined,
+ createdAt: new Date()
+ };
+
+ setMessages(prev => [...prev, assistantMessage]);
+ setStreamingContent('');
+ setStatus('idle');
+ onFinish?.(assistantMessage);
+
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') {
+ // If we have partial content, save it
+ if (streamingContent) {
+ const partialMessage: ChatMessage = {
+ id: generateId(),
+ role: 'assistant',
+ content: streamingContent + ' [stopped]',
+ createdAt: new Date()
+ };
+ setMessages(prev => [...prev, partialMessage]);
+ }
+ setStreamingContent('');
+ setStatus('idle');
+ return;
+ }
+
+ const error = err instanceof Error ? err : new Error(String(err));
+ setError(error);
+ setStatus('error');
+ setStreamingContent('');
+ onError?.(error);
+ }
+ }, [
+ mc, apiKey, context, messages, systemPrompt,
+ maxToolCalls, addMessage, onMindCacheChange, onFinish, onError, streamingContent
+ ]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, []);
+
+ return {
+ messages,
+ status,
+ error,
+ sendMessage,
+ clearMessages,
+ isLoading: status === 'loading' || status === 'streaming',
+ addMessage,
+ stop,
+ streamingContent
+ };
+}
diff --git a/packages/mindcache/src/react/useLocalFirstSync.ts b/packages/mindcache/src/react/useLocalFirstSync.ts
new file mode 100644
index 0000000..edc9e46
--- /dev/null
+++ b/packages/mindcache/src/react/useLocalFirstSync.ts
@@ -0,0 +1,341 @@
+'use client';
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { MindCache } from '../core/MindCache';
+
+/**
+ * GitStore sync configuration
+ */
+export interface GitStoreSyncConfig {
+ /** GitHub repository owner */
+ owner: string;
+ /** GitHub repository name */
+ repo: string;
+ /** File path in repo (default: 'mindcache.md') */
+ path?: string;
+ /** GitHub token or token provider */
+ token: string | (() => Promise);
+ /** Branch to sync to (default: 'main') */
+ branch?: string;
+}
+
+/**
+ * Server sync configuration
+ */
+export interface ServerSyncConfig {
+ /** Server URL for sync endpoint */
+ url: string;
+ /** Optional auth token */
+ authToken?: string;
+}
+
+/**
+ * useLocalFirstSync options
+ */
+export interface UseLocalFirstSyncOptions {
+ /** MindCache instance to sync */
+ mindcache: MindCache | null;
+ /** GitStore configuration */
+ gitstore?: GitStoreSyncConfig;
+ /** Optional server sync configuration */
+ server?: ServerSyncConfig;
+ /** Auto-sync interval in ms (0 = disabled, default: 0) */
+ autoSyncInterval?: number;
+ /** Debounce delay for auto-save in ms (default: 5000) */
+ saveDebounceMs?: number;
+ /** Load from remote on mount (default: true) */
+ loadOnMount?: boolean;
+ /** Merge remote data with local (default: true) */
+ mergeOnLoad?: boolean;
+}
+
+/**
+ * Sync status
+ */
+export type SyncStatus = 'idle' | 'loading' | 'saving' | 'syncing' | 'error';
+
+/**
+ * useLocalFirstSync return value
+ */
+export interface UseLocalFirstSyncReturn {
+ /** Current sync status */
+ status: SyncStatus;
+ /** Last error */
+ error: Error | null;
+ /** Last successful sync timestamp */
+ lastSyncAt: Date | null;
+ /** Whether there are unsaved local changes */
+ hasLocalChanges: boolean;
+ /** Load data from remote (GitStore or server) */
+ load: () => Promise;
+ /** Save current state to remote */
+ save: (message?: string) => Promise;
+ /** Full sync: load then save if changes */
+ sync: () => Promise;
+ /** Mark local changes as saved (for manual tracking) */
+ markSaved: () => void;
+}
+
+/**
+ * useLocalFirstSync - Hook for local-first sync with GitStore
+ *
+ * Provides automatic syncing between local MindCache and GitHub via GitStore.
+ * Data is always available locally via IndexedDB, with async GitHub backup.
+ *
+ * @example
+ * ```tsx
+ * const { status, save, load, hasLocalChanges } = useLocalFirstSync({
+ * mindcache,
+ * gitstore: {
+ * owner: 'myuser',
+ * repo: 'my-data',
+ * token: process.env.GITHUB_TOKEN,
+ * },
+ * autoSyncInterval: 60000, // Sync every minute
+ * });
+ * ```
+ */
+export function useLocalFirstSync(options: UseLocalFirstSyncOptions): UseLocalFirstSyncReturn {
+ const {
+ mindcache,
+ gitstore,
+ server,
+ autoSyncInterval = 0,
+ saveDebounceMs = 5000,
+ loadOnMount = true,
+ mergeOnLoad = true
+ } = options;
+
+ const [status, setStatus] = useState('idle');
+ const [error, setError] = useState(null);
+ const [lastSyncAt, setLastSyncAt] = useState(null);
+ const [hasLocalChanges, setHasLocalChanges] = useState(false);
+
+ const saveTimeoutRef = useRef | null>(null);
+ const syncIntervalRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+
+ // Get token from provider or direct value
+ const getToken = useCallback(async (): Promise => {
+ if (!gitstore) {
+ throw new Error('GitStore not configured');
+ }
+ return typeof gitstore.token === 'function'
+ ? await gitstore.token()
+ : gitstore.token;
+ }, [gitstore]);
+
+ // Load from remote
+ const load = useCallback(async () => {
+ if (!mindcache) {
+ return;
+ }
+
+ setStatus('loading');
+ setError(null);
+
+ try {
+ if (gitstore) {
+ // Dynamic import (using Function to avoid TypeScript module resolution)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let gitStoreModule: any;
+ try {
+ gitStoreModule = await (Function('return import("@mindcache/gitstore")')() as Promise);
+ } catch {
+ throw new Error('@mindcache/gitstore is not installed. Run: npm install @mindcache/gitstore');
+ }
+
+ const { GitStore, MindCacheSync } = gitStoreModule;
+ const token = await getToken();
+
+ const store = new GitStore({
+ owner: gitstore.owner,
+ repo: gitstore.repo,
+ branch: gitstore.branch,
+ tokenProvider: async () => token
+ });
+
+ const sync = new MindCacheSync(store, mindcache, {
+ filePath: gitstore.path || 'mindcache.md'
+ });
+
+ await sync.load({ merge: mergeOnLoad });
+ } else if (server) {
+ // Server sync
+ const response = await fetch(server.url, {
+ headers: server.authToken
+ ? { Authorization: `Bearer ${server.authToken}` }
+ : {}
+ });
+
+ if (response.ok) {
+ const markdown = await response.text();
+ mindcache.fromMarkdown(markdown, mergeOnLoad);
+ }
+ }
+
+ if (mountedRef.current) {
+ setLastSyncAt(new Date());
+ setStatus('idle');
+ }
+ } catch (err) {
+ if (mountedRef.current) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ setError(error);
+ setStatus('error');
+ }
+ throw err;
+ }
+ }, [mindcache, gitstore, server, getToken, mergeOnLoad]);
+
+ // Save to remote
+ const save = useCallback(async (message?: string) => {
+ if (!mindcache) {
+ return;
+ }
+
+ setStatus('saving');
+ setError(null);
+
+ try {
+ if (gitstore) {
+ // Dynamic import (using Function to avoid TypeScript module resolution)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let gitStoreModule: any;
+ try {
+ gitStoreModule = await (Function('return import("@mindcache/gitstore")')() as Promise);
+ } catch {
+ throw new Error('@mindcache/gitstore is not installed. Run: npm install @mindcache/gitstore');
+ }
+
+ const { GitStore, MindCacheSync } = gitStoreModule;
+ const token = await getToken();
+
+ const store = new GitStore({
+ owner: gitstore.owner,
+ repo: gitstore.repo,
+ branch: gitstore.branch,
+ tokenProvider: async () => token
+ });
+
+ const sync = new MindCacheSync(store, mindcache, {
+ filePath: gitstore.path || 'mindcache.md'
+ });
+
+ await sync.save({ message: message || 'MindCache sync' });
+ } else if (server) {
+ await fetch(server.url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/plain',
+ ...(server.authToken ? { Authorization: `Bearer ${server.authToken}` } : {})
+ },
+ body: mindcache.toMarkdown()
+ });
+ }
+
+ if (mountedRef.current) {
+ setLastSyncAt(new Date());
+ setHasLocalChanges(false);
+ setStatus('idle');
+ }
+ } catch (err) {
+ if (mountedRef.current) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ setError(error);
+ setStatus('error');
+ }
+ throw err;
+ }
+ }, [mindcache, gitstore, server, getToken]);
+
+ // Full sync
+ const sync = useCallback(async () => {
+ setStatus('syncing');
+ try {
+ await load();
+ if (hasLocalChanges) {
+ await save();
+ }
+ } catch (err) {
+ // Error already handled in load/save
+ }
+ }, [load, save, hasLocalChanges]);
+
+ // Mark as saved
+ const markSaved = useCallback(() => {
+ setHasLocalChanges(false);
+ }, []);
+
+ // Subscribe to MindCache changes for auto-save
+ useEffect(() => {
+ if (!mindcache || !gitstore || saveDebounceMs <= 0) {
+ return;
+ }
+
+ const unsubscribe = mindcache.subscribeToAll(() => {
+ setHasLocalChanges(true);
+
+ // Debounced auto-save
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ saveTimeoutRef.current = setTimeout(() => {
+ // eslint-disable-next-line no-console
+ save('Auto-save').catch(console.error);
+ }, saveDebounceMs);
+ });
+
+ return () => {
+ unsubscribe();
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ };
+ }, [mindcache, gitstore, saveDebounceMs, save]);
+
+ // Auto-sync interval
+ useEffect(() => {
+ if (!mindcache || autoSyncInterval <= 0) {
+ return;
+ }
+
+ syncIntervalRef.current = setInterval(() => {
+ // eslint-disable-next-line no-console
+ sync().catch(console.error);
+ }, autoSyncInterval);
+
+ return () => {
+ if (syncIntervalRef.current) {
+ clearInterval(syncIntervalRef.current);
+ }
+ };
+ }, [mindcache, autoSyncInterval, sync]);
+
+ // Load on mount
+ useEffect(() => {
+ if (loadOnMount && mindcache && (gitstore || server)) {
+ // eslint-disable-next-line no-console
+ load().catch(console.error);
+ }
+ }, [mindcache, gitstore, server, loadOnMount]);
+
+ // Cleanup
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ return {
+ status,
+ error,
+ lastSyncAt,
+ hasLocalChanges,
+ load,
+ save,
+ sync,
+ markSaved
+ };
+}
diff --git a/packages/mindcache/tsconfig.json b/packages/mindcache/tsconfig.json
index 86b2a96..9463e15 100644
--- a/packages/mindcache/tsconfig.json
+++ b/packages/mindcache/tsconfig.json
@@ -4,6 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
+ "jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ec6d40..1ea7eb0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,9 +65,12 @@ importers:
packages/mindcache:
dependencies:
+ '@ai-sdk/openai':
+ specifier: ^2.0.0
+ version: 2.0.80(zod@3.25.76)
ai:
- specifier: '>=3.0.0'
- version: 5.0.104(zod@3.25.76)
+ specifier: ^5.0.0
+ version: 5.0.113(zod@3.25.76)
fast-diff:
specifier: ^1.3.0
version: 1.3.0
@@ -75,7 +78,7 @@ importers:
specifier: ^0.2.115
version: 0.2.115
react:
- specifier: ^19.0.0
+ specifier: ^18.0.0 || ^19.0.0
version: 19.2.0
y-indexeddb:
specifier: ^9.0.12