diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b05d83c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Reminders +* examples use npm not pnpm +* before merging, check the CI pipeline, lint, build, etc... then npm login and npm publish mindcache if relevant. + diff --git a/docs/index.html b/docs/index.html index a201ab1..95251f2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -27,6 +27,7 @@ + +
+
+
+

Local-First with GitStore

+

100% client-side apps with GitHub as your database

+
+
+
+
💻
+

No Server Required

+

Run AI entirely in the browser. API keys stay in localStorage, never touch a server.

+
+
+
📂
+

GitHub as Database

+

Sync MindCache to any GitHub repo. Version history, branching, and collaboration built-in.

+
+
+
📱
+

Cross-Platform

+

Web, Mac, Android — all apps sync through GitHub. Works offline, syncs when online.

+
+
+
⚛️
+

React Components

+

Drop-in <MindCacheChat> component. Full AI chat in ~15 lines of code.

+
+
+
+
// Local-first AI chat in 15 lines
+import { MindCacheProvider, MindCacheChat } from 'mindcache';
+
+function App() {
+  return (
+    <MindCacheProvider
+      ai={{ provider: 'openai', model: 'gpt-4o', keyStorage: 'localStorage' }}
+    >
+      <MindCacheChat welcomeMessage="Hello! I run in your browser." />
+    </MindCacheProvider>
+  );
+}
+
+ +
+
+
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 ? ( +
+