From 922934442fd4856af7ca2ecaa6531fd265ee05ca Mon Sep 17 00:00:00 2001 From: dh Date: Sun, 8 Feb 2026 22:31:04 +0100 Subject: [PATCH 1/3] Update pnpm-lock.yaml to include new dependencies for mindcache-server and enhance README with local API example. Modify tsconfig.json to support new package paths for mindcache and mindcache-server. --- README.md | 1 + examples/mindcache_server_local/README.md | 47 ++ examples/mindcache_server_local/client/app.js | 161 +++++ .../mindcache_server_local/client/index.html | 174 +++++ .../data/team-a/memory.md | 36 + .../data/team-b/memory.md | 27 + .../mindcache_server_local/package-lock.json | 557 ++++++++++++++++ examples/mindcache_server_local/package.json | 15 + examples/mindcache_server_local/src/client.ts | 36 + .../src/clientServer.ts | 60 ++ examples/mindcache_server_local/src/config.ts | 15 + examples/mindcache_server_local/src/dev.ts | 47 ++ examples/mindcache_server_local/src/seed.ts | 111 ++++ examples/mindcache_server_local/src/server.ts | 32 + .../src/serverRuntime.ts | 287 ++++++++ examples/mindcache_server_local/tsconfig.json | 19 + packages/README.md | 2 +- packages/mindcache-server/README.md | 113 ++++ packages/mindcache-server/package.json | 63 ++ .../src/MindCacheApiServer.ts | 325 ++++++++++ .../src/MindCacheServerCore.ts | 613 ++++++++++++++++++ packages/mindcache-server/src/cli.ts | 164 +++++ packages/mindcache-server/src/fileUtils.ts | 107 +++ packages/mindcache-server/src/index.ts | 16 + packages/mindcache-server/src/types.ts | 96 +++ .../tests/MindCacheServerCore.test.ts | 220 +++++++ packages/mindcache-server/tsconfig.json | 26 + packages/mindcache-server/tsup.config.ts | 14 + packages/mindcache-server/vitest.config.ts | 15 + pnpm-lock.yaml | 28 + tsconfig.json | 8 +- 31 files changed, 3433 insertions(+), 2 deletions(-) create mode 100644 examples/mindcache_server_local/README.md create mode 100644 examples/mindcache_server_local/client/app.js create mode 100644 examples/mindcache_server_local/client/index.html create mode 100644 examples/mindcache_server_local/data/team-a/memory.md create mode 100644 examples/mindcache_server_local/data/team-b/memory.md create mode 100644 examples/mindcache_server_local/package-lock.json create mode 100644 examples/mindcache_server_local/package.json create mode 100644 examples/mindcache_server_local/src/client.ts create mode 100644 examples/mindcache_server_local/src/clientServer.ts create mode 100644 examples/mindcache_server_local/src/config.ts create mode 100644 examples/mindcache_server_local/src/dev.ts create mode 100644 examples/mindcache_server_local/src/seed.ts create mode 100644 examples/mindcache_server_local/src/server.ts create mode 100644 examples/mindcache_server_local/src/serverRuntime.ts create mode 100644 examples/mindcache_server_local/tsconfig.json create mode 100644 packages/mindcache-server/README.md create mode 100644 packages/mindcache-server/package.json create mode 100644 packages/mindcache-server/src/MindCacheApiServer.ts create mode 100644 packages/mindcache-server/src/MindCacheServerCore.ts create mode 100644 packages/mindcache-server/src/cli.ts create mode 100644 packages/mindcache-server/src/fileUtils.ts create mode 100644 packages/mindcache-server/src/index.ts create mode 100644 packages/mindcache-server/src/types.ts create mode 100644 packages/mindcache-server/tests/MindCacheServerCore.test.ts create mode 100644 packages/mindcache-server/tsconfig.json create mode 100644 packages/mindcache-server/tsup.config.ts create mode 100644 packages/mindcache-server/vitest.config.ts diff --git a/README.md b/README.md index b1fafac..714e565 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,7 @@ export class MyDurableObject { See the [examples directory](./examples) for complete implementations: - **[Contact Extractor](./examples/contact_extractor)** - AI-powered contact extraction using custom types +- **[MindCache Server Local](./examples/mindcache_server_local)** - local API + browser client for tag-based key discovery - Form management with AI assistant - Image processing workflows - Multi-step workflows with memory persistence diff --git a/examples/mindcache_server_local/README.md b/examples/mindcache_server_local/README.md new file mode 100644 index 0000000..9255ea8 --- /dev/null +++ b/examples/mindcache_server_local/README.md @@ -0,0 +1,47 @@ +# MindCache Server Local Example + +This example creates sample MindCache files, starts the local `mindcache-server` API, and starts a browser client that: + +- lists all tags +- lets you select tags +- shows keys that match selected tags (`all` or `any` mode) + +## Run + +```bash +cd examples/mindcache_server_local +npm install +npm run dev +``` + +Then open: + +- Client UI: [http://127.0.0.1:4173](http://127.0.0.1:4173) +- API: [http://127.0.0.1:4040](http://127.0.0.1:4040) + +## Separate commands + +```bash +# create sample files in ./data +npm run seed + +# start only API server +npm run server + +# start only client UI server +npm run client +``` + +## What gets created + +Sample files are created under: + +- `examples/mindcache_server_local/data/team-a/memory.md` +- `examples/mindcache_server_local/data/team-b/memory.md` + +The API loads these files and indexes keys as: + +- `team-a/memory.md::customer_profile` +- `team-b/memory.md::customer_profile` + +This demonstrates collision-safe keys based on relative file path. diff --git a/examples/mindcache_server_local/client/app.js b/examples/mindcache_server_local/client/app.js new file mode 100644 index 0000000..c4bfcf8 --- /dev/null +++ b/examples/mindcache_server_local/client/app.js @@ -0,0 +1,161 @@ +const tagsEl = document.getElementById('tags'); +const entriesEl = document.getElementById('entries'); +const statusEl = document.getElementById('status'); +const summaryEl = document.getElementById('summary'); +const apiBaseInput = document.getElementById('apiBase'); +const matchModeSelect = document.getElementById('matchMode'); +const refreshBtn = document.getElementById('refreshBtn'); +const clearBtn = document.getElementById('clearBtn'); + +const state = { + allTags: [], + selectedTags: new Set(), + entries: [] +}; + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function apiBase() { + return apiBaseInput.value.trim().replace(/\/$/, ''); +} + +function renderTags() { + if (state.allTags.length === 0) { + tagsEl.innerHTML = 'No tags found.'; + return; + } + + tagsEl.innerHTML = state.allTags + .map((tag) => { + const active = state.selectedTags.has(tag); + const className = active ? 'tag active' : 'tag'; + return ``; + }) + .join(''); +} + +function renderEntries() { + const selected = Array.from(state.selectedTags).sort(); + const mode = matchModeSelect.value; + + if (state.entries.length === 0) { + const filterText = selected.length > 0 + ? `No keys matched [${selected.join(', ')}] (${mode}).` + : 'No keys found.'; + summaryEl.textContent = filterText; + entriesEl.innerHTML = ''; + return; + } + + const label = selected.length > 0 + ? `Showing ${state.entries.length} key(s) for tags [${selected.join(', ')}], match=${mode}.` + : `Showing all ${state.entries.length} key(s).`; + summaryEl.textContent = label; + + entriesEl.innerHTML = state.entries + .map((entry) => { + const tags = (entry.attributes?.contentTags || []).map((tag) => `${escapeHtml(tag)}`).join(' '); + return ` +
+
${escapeHtml(entry.key)}
+
raw key: ${escapeHtml(entry.rawKey)} | file: ${escapeHtml(entry.fileId)}
+
${tags || 'no tags'}
+
+ `; + }) + .join(''); +} + +async function fetchTags() { + const response = await fetch(`${apiBase()}/v1/tags`); + if (!response.ok) { + throw new Error(`Failed fetching tags: ${response.status}`); + } + + const body = await response.json(); + state.allTags = Array.isArray(body.tags) ? body.tags : []; + + for (const tag of Array.from(state.selectedTags)) { + if (!state.allTags.includes(tag)) { + state.selectedTags.delete(tag); + } + } +} + +async function fetchEntries() { + const selectedTags = Array.from(state.selectedTags).sort(); + const params = new URLSearchParams(); + if (selectedTags.length > 0) { + params.set('tags', selectedTags.join(',')); + } + params.set('match', matchModeSelect.value); + + const response = await fetch(`${apiBase()}/v1/entries?${params.toString()}`); + if (!response.ok) { + throw new Error(`Failed fetching entries: ${response.status}`); + } + + const body = await response.json(); + state.entries = Array.isArray(body.entries) ? body.entries : []; +} + +async function refresh() { + statusEl.textContent = 'Loading...'; + + try { + await fetchTags(); + await fetchEntries(); + renderTags(); + renderEntries(); + statusEl.textContent = `Loaded ${state.allTags.length} tags.`; + } catch (error) { + statusEl.textContent = error instanceof Error ? error.message : String(error); + } +} + +tagsEl.addEventListener('click', async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const tag = target.dataset.tag; + if (!tag) { + return; + } + + if (state.selectedTags.has(tag)) { + state.selectedTags.delete(tag); + } else { + state.selectedTags.add(tag); + } + + renderTags(); + await refresh(); +}); + +refreshBtn.addEventListener('click', () => { + void refresh(); +}); + +clearBtn.addEventListener('click', () => { + state.selectedTags.clear(); + void refresh(); +}); + +matchModeSelect.addEventListener('change', () => { + void refresh(); +}); + +apiBaseInput.addEventListener('change', () => { + void refresh(); +}); + +void refresh(); diff --git a/examples/mindcache_server_local/client/index.html b/examples/mindcache_server_local/client/index.html new file mode 100644 index 0000000..59b3471 --- /dev/null +++ b/examples/mindcache_server_local/client/index.html @@ -0,0 +1,174 @@ + + + + + + MindCache Server Local Client + + + +
+

MindCache Server Tag Explorer

+

Select one or more tags to see matching keys from the local API.

+ +
+
+ + + + + + +
+ +
Loading tags...
+
+
+ +
+

Matching Keys

+
No data yet.
+
+
+
+ + + + diff --git a/examples/mindcache_server_local/data/team-a/memory.md b/examples/mindcache_server_local/data/team-a/memory.md new file mode 100644 index 0000000..76f3eb9 --- /dev/null +++ b/examples/mindcache_server_local/data/team-a/memory.md @@ -0,0 +1,36 @@ +# Team A Memory + +Sample MindCache data for Team A + +Export Date: 2026-02-08 + +--- + +## Keys & Values + +### customer_profile +- **System Tags**: `SystemPrompt, LLMRead` +- **Z-Index**: `10` +- **Tags**: `customer`, `acme`, `sales` +- **Value**: +``` +Acme Corp: enterprise customer, strong expansion in Q2. +``` + +### open_risks +- **System Tags**: `SystemPrompt, LLMRead` +- **Z-Index**: `20` +- **Tags**: `customer`, `risk`, `acme` +- **Value**: +``` +Legal review pending for renewal contract. +``` + +### internal_note +- **System Tags**: `LLMRead` +- **Z-Index**: `30` +- **Tags**: `team`, `ops` +- **Value**: +``` +Schedule stakeholder sync every Friday. +``` diff --git a/examples/mindcache_server_local/data/team-b/memory.md b/examples/mindcache_server_local/data/team-b/memory.md new file mode 100644 index 0000000..683c10e --- /dev/null +++ b/examples/mindcache_server_local/data/team-b/memory.md @@ -0,0 +1,27 @@ +# Team B Memory + +Sample MindCache data for Team B + +Export Date: 2026-02-08 + +--- + +## Keys & Values + +### customer_profile +- **System Tags**: `SystemPrompt, LLMRead` +- **Z-Index**: `10` +- **Tags**: `customer`, `beta`, `pilot` +- **Value**: +``` +Beta Labs: SMB, interested in pilot feature flags. +``` + +### feature_requests +- **System Tags**: `SystemPrompt, LLMRead` +- **Z-Index**: `20` +- **Tags**: `product`, `beta`, `requests` +- **Value**: +``` +Needs audit logs and custom retention policy. +``` diff --git a/examples/mindcache_server_local/package-lock.json b/examples/mindcache_server_local/package-lock.json new file mode 100644 index 0000000..27013a6 --- /dev/null +++ b/examples/mindcache_server_local/package-lock.json @@ -0,0 +1,557 @@ +{ + "name": "mindcache-server-local-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mindcache-server-local-example", + "version": "0.1.0", + "devDependencies": { + "tsx": "^4.20.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/examples/mindcache_server_local/package.json b/examples/mindcache_server_local/package.json new file mode 100644 index 0000000..e4dfacf --- /dev/null +++ b/examples/mindcache_server_local/package.json @@ -0,0 +1,15 @@ +{ + "name": "mindcache-server-local-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "seed": "tsx src/seed.ts", + "server": "tsx src/server.ts", + "client": "tsx src/client.ts", + "dev": "tsx src/dev.ts" + }, + "devDependencies": { + "tsx": "^4.20.3" + } +} diff --git a/examples/mindcache_server_local/src/client.ts b/examples/mindcache_server_local/src/client.ts new file mode 100644 index 0000000..6c16bad --- /dev/null +++ b/examples/mindcache_server_local/src/client.ts @@ -0,0 +1,36 @@ +import { CLIENT_HOST, CLIENT_PORT } from './config.ts'; +import { startClientServer } from './clientServer.ts'; + +async function main(): Promise { + const server = await startClientServer(CLIENT_HOST, CLIENT_PORT); + + // eslint-disable-next-line no-console + console.log(`[example] Client running at http://${CLIENT_HOST}:${CLIENT_PORT}`); + + const shutdown = async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + + process.on('SIGTERM', () => { + void shutdown(); + }); +} + +void main().catch((error) => { + // eslint-disable-next-line no-console + console.error(`[example] Client failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/examples/mindcache_server_local/src/clientServer.ts b/examples/mindcache_server_local/src/clientServer.ts new file mode 100644 index 0000000..b729cd2 --- /dev/null +++ b/examples/mindcache_server_local/src/clientServer.ts @@ -0,0 +1,60 @@ +import { createServer, type Server } from 'node:http'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { CLIENT_DIR } from './config.ts'; + +const MIME_BY_EXTENSION: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8' +}; + +function contentTypeFor(filePath: string): string { + return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +function resolveClientFile(requestPath: string): string | null { + const normalizedPath = requestPath === '/' ? '/index.html' : requestPath; + const absolutePath = path.resolve(CLIENT_DIR, `.${normalizedPath}`); + + if (!absolutePath.startsWith(CLIENT_DIR)) { + return null; + } + + return absolutePath; +} + +export async function startClientServer(host: string, port: number): Promise { + const server = createServer(async (req, res) => { + const requestPath = req.url?.split('?')[0] || '/'; + const filePath = resolveClientFile(requestPath); + + if (!filePath) { + res.statusCode = 403; + res.end('Forbidden'); + return; + } + + try { + const content = await fs.readFile(filePath); + res.setHeader('Content-Type', contentTypeFor(filePath)); + res.statusCode = 200; + res.end(content); + } catch { + res.statusCode = 404; + res.end('Not Found'); + } + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + resolve(); + }); + }); + + return server; +} diff --git a/examples/mindcache_server_local/src/config.ts b/examples/mindcache_server_local/src/config.ts new file mode 100644 index 0000000..4244307 --- /dev/null +++ b/examples/mindcache_server_local/src/config.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const API_HOST = process.env.MINDCACHE_API_HOST || '127.0.0.1'; +export const API_PORT = Number(process.env.MINDCACHE_API_PORT || 4040); +export const CLIENT_HOST = process.env.MINDCACHE_CLIENT_HOST || '127.0.0.1'; +export const CLIENT_PORT = Number(process.env.MINDCACHE_CLIENT_PORT || 4173); + +const currentFile = fileURLToPath(import.meta.url); +const srcDir = path.dirname(currentFile); +export const EXAMPLE_DIR = path.resolve(srcDir, '..'); +export const DATA_DIR = path.join(EXAMPLE_DIR, 'data'); +export const CLIENT_DIR = path.join(EXAMPLE_DIR, 'client'); + +export const API_BASE_URL = `http://${API_HOST}:${API_PORT}`; diff --git a/examples/mindcache_server_local/src/dev.ts b/examples/mindcache_server_local/src/dev.ts new file mode 100644 index 0000000..9f8a003 --- /dev/null +++ b/examples/mindcache_server_local/src/dev.ts @@ -0,0 +1,47 @@ +import { API_BASE_URL, CLIENT_HOST, CLIENT_PORT, DATA_DIR } from './config.ts'; +import { startClientServer } from './clientServer.ts'; +import { seedExampleFiles } from './seed.ts'; +import { startMindCacheRuntime } from './serverRuntime.ts'; + +async function main(): Promise { + await seedExampleFiles(); + + const runtime = await startMindCacheRuntime(); + const clientServer = await startClientServer(CLIENT_HOST, CLIENT_PORT); + + // eslint-disable-next-line no-console + console.log(`[example] Seeded files in ${DATA_DIR}`); + // eslint-disable-next-line no-console + console.log(`[example] API: ${API_BASE_URL}`); + // eslint-disable-next-line no-console + console.log(`[example] Client: http://${CLIENT_HOST}:${CLIENT_PORT}`); + + const shutdown = async () => { + await new Promise((resolve, reject) => { + clientServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + await runtime.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + + process.on('SIGTERM', () => { + void shutdown(); + }); +} + +void main().catch((error) => { + // eslint-disable-next-line no-console + console.error(`[example] Dev launcher failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/examples/mindcache_server_local/src/seed.ts b/examples/mindcache_server_local/src/seed.ts new file mode 100644 index 0000000..8c79c96 --- /dev/null +++ b/examples/mindcache_server_local/src/seed.ts @@ -0,0 +1,111 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { MindCache, type KeyAttributes } from '../../../packages/mindcache/dist/server.mjs'; + +import { DATA_DIR } from './config.ts'; + +interface Entry { + key: string; + value: unknown; + attributes: Partial; +} + +function createMarkdown(entries: Entry[], options: { name: string; description: string }): string { + const cache = new MindCache({ accessLevel: 'admin' }); + + for (const entry of entries) { + cache.set_value(entry.key, entry.value, entry.attributes); + } + + return cache.toMarkdown(options); +} + +async function writeMarkdownFile(relativePath: string, content: string): Promise { + const absolutePath = path.join(DATA_DIR, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, 'utf8'); +} + +export async function seedExampleFiles(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); + + const teamA = createMarkdown( + [ + { + key: 'customer_profile', + value: 'Acme Corp: enterprise customer, strong expansion in Q2.', + attributes: { + contentTags: ['customer', 'acme', 'sales'], + systemTags: ['SystemPrompt', 'LLMRead'], + zIndex: 10 + } + }, + { + key: 'open_risks', + value: 'Legal review pending for renewal contract.', + attributes: { + contentTags: ['customer', 'risk', 'acme'], + systemTags: ['SystemPrompt', 'LLMRead'], + zIndex: 20 + } + }, + { + key: 'internal_note', + value: 'Schedule stakeholder sync every Friday.', + attributes: { + contentTags: ['team', 'ops'], + systemTags: ['LLMRead'], + zIndex: 30 + } + } + ], + { + name: 'Team A Memory', + description: 'Sample MindCache data for Team A' + } + ); + + const teamB = createMarkdown( + [ + { + key: 'customer_profile', + value: 'Beta Labs: SMB, interested in pilot feature flags.', + attributes: { + contentTags: ['customer', 'beta', 'pilot'], + systemTags: ['SystemPrompt', 'LLMRead'], + zIndex: 10 + } + }, + { + key: 'feature_requests', + value: 'Needs audit logs and custom retention policy.', + attributes: { + contentTags: ['product', 'beta', 'requests'], + systemTags: ['SystemPrompt', 'LLMRead'], + zIndex: 20 + } + } + ], + { + name: 'Team B Memory', + description: 'Sample MindCache data for Team B' + } + ); + + await writeMarkdownFile('team-a/memory.md', teamA); + await writeMarkdownFile('team-b/memory.md', teamB); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + seedExampleFiles() + .then(() => { + // eslint-disable-next-line no-console + console.log(`[example] Seeded MindCache files in ${DATA_DIR}`); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(`[example] Failed seeding files: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/examples/mindcache_server_local/src/server.ts b/examples/mindcache_server_local/src/server.ts new file mode 100644 index 0000000..77f4558 --- /dev/null +++ b/examples/mindcache_server_local/src/server.ts @@ -0,0 +1,32 @@ +import { API_BASE_URL, DATA_DIR } from './config.ts'; +import { seedExampleFiles } from './seed.ts'; +import { startMindCacheRuntime } from './serverRuntime.ts'; + +async function main(): Promise { + await seedExampleFiles(); + const runtime = await startMindCacheRuntime(); + + // eslint-disable-next-line no-console + console.log(`[example] MindCache API running at ${API_BASE_URL}`); + // eslint-disable-next-line no-console + console.log(`[example] Watching data folder: ${DATA_DIR}`); + + const shutdown = async () => { + await runtime.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + + process.on('SIGTERM', () => { + void shutdown(); + }); +} + +void main().catch((error) => { + // eslint-disable-next-line no-console + console.error(`[example] Server failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/examples/mindcache_server_local/src/serverRuntime.ts b/examples/mindcache_server_local/src/serverRuntime.ts new file mode 100644 index 0000000..97bf7ad --- /dev/null +++ b/examples/mindcache_server_local/src/serverRuntime.ts @@ -0,0 +1,287 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { MindCache } from '../../../packages/mindcache/dist/server.mjs'; + +import { API_HOST, API_PORT, DATA_DIR } from './config.ts'; + +type MatchMode = 'all' | 'any'; + +interface IndexedEntry { + key: string; + rawKey: string; + fileId: string; + value: unknown; + attributes: { + type: string; + contentType?: string; + contentTags: string[]; + systemTags: string[]; + zIndex: number; + customType?: string; + }; +} + +interface RuntimeHandle { + stop: () => Promise; +} + +const SUPPORTED_EXTENSIONS = new Set(['.md', '.markdown', '.mindcache', '.json']); + +function canonicalFileId(rootDir: string, absolutePath: string): string { + const relative = path.relative(rootDir, absolutePath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Path outside root: ${absolutePath}`); + } + return relative.split(path.sep).join('/'); +} + +function parseTags(raw: string | null): string[] { + if (!raw) { + return []; + } + + return raw + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); +} + +function matchTags(entryTags: string[], filterTags: string[], mode: MatchMode): boolean { + if (filterTags.length === 0) { + return true; + } + + if (mode === 'any') { + return filterTags.some(tag => entryTags.includes(tag)); + } + + return filterTags.every(tag => entryTags.includes(tag)); +} + +async function listMindCacheFiles(rootDir: string): Promise> { + const files: Array<{ fileId: string; absolutePath: string }> = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + let dirEntries; + try { + dirEntries = await fs.readdir(current, { withFileTypes: true, encoding: 'utf8' }); + } catch { + continue; + } + + for (const entry of dirEntries) { + if (entry.isDirectory()) { + if (!entry.name.startsWith('.')) { + stack.push(path.join(current, entry.name)); + } + continue; + } + + if (!entry.isFile()) { + continue; + } + + const absolutePath = path.join(current, entry.name); + const extension = path.extname(entry.name).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(extension)) { + continue; + } + + files.push({ + fileId: canonicalFileId(rootDir, absolutePath), + absolutePath + }); + } + } + + return files; +} + +function parseFileContent(filePath: string, content: string): Record { + const extension = path.extname(filePath).toLowerCase(); + const cache = new MindCache({ accessLevel: 'admin' }); + + if (extension === '.json') { + const parsed = JSON.parse(content); + cache.deserialize(parsed); + } else { + cache.fromMarkdown(content, false); + } + + return cache.serialize() as Record; +} + +async function loadIndex(rootDir: string): Promise<{ tags: string[]; entries: IndexedEntry[] }> { + const files = await listMindCacheFiles(rootDir); + const entries: IndexedEntry[] = []; + const tags = new Set(); + + for (const file of files) { + let content: string; + try { + content = await fs.readFile(file.absolutePath, 'utf8'); + } catch { + continue; + } + + let parsed: Record; + try { + parsed = parseFileContent(file.absolutePath, content); + } catch { + continue; + } + + for (const [rawKey, entry] of Object.entries(parsed)) { + if (rawKey.startsWith('$')) { + continue; + } + + const contentTags = Array.from(new Set((entry.attributes?.contentTags || []) as string[])); + for (const tag of contentTags) { + tags.add(tag); + } + + entries.push({ + key: `${file.fileId}::${rawKey}`, + rawKey, + fileId: file.fileId, + value: entry.value, + attributes: { + type: entry.attributes?.type || 'text', + contentType: entry.attributes?.contentType, + contentTags, + systemTags: Array.from(new Set((entry.attributes?.systemTags || []) as string[])), + zIndex: entry.attributes?.zIndex ?? 0, + customType: entry.attributes?.customType + } + }); + } + } + + entries.sort((a, b) => a.key.localeCompare(b.key)); + + return { + tags: Array.from(tags).sort((a, b) => a.localeCompare(b)), + entries + }; +} + +function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) + }); + res.end(body); +} + +function withCors(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS'); +} + +async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + withCors(res); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (!req.url) { + sendJson(res, 400, { error: 'Missing URL' }); + return; + } + + const url = new URL(req.url, 'http://localhost'); + + if (url.pathname === '/' && req.method === 'GET') { + sendJson(res, 200, { + name: 'mindcache-server-local-example', + status: 'ok', + endpoints: [ + 'GET /health', + 'GET /v1/tags', + 'GET /v1/entries?tags=tag1,tag2&match=all|any' + ] + }); + return; + } + + if (url.pathname === '/health' && req.method === 'GET') { + const index = await loadIndex(DATA_DIR); + sendJson(res, 200, { + status: 'ok', + rootDir: DATA_DIR, + tags: index.tags.length, + keys: index.entries.length + }); + return; + } + + if (url.pathname === '/v1/tags' && req.method === 'GET') { + const index = await loadIndex(DATA_DIR); + sendJson(res, 200, { tags: index.tags }); + return; + } + + if (url.pathname === '/v1/entries' && req.method === 'GET') { + const filterTags = parseTags(url.searchParams.get('tags')); + const mode = url.searchParams.get('match') === 'any' ? 'any' : 'all'; + + const index = await loadIndex(DATA_DIR); + const entries = index.entries.filter(entry => matchTags(entry.attributes.contentTags, filterTags, mode)); + + sendJson(res, 200, { + tags: filterTags, + match: mode, + count: entries.length, + entries + }); + return; + } + + sendJson(res, 404, { error: 'Not found' }); +} + +export async function startMindCacheRuntime(): Promise { + const server = createServer((req, res) => { + void handleRequest(req, res).catch(error => { + sendJson(res, 500, { + error: error instanceof Error ? error.message : String(error) + }); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(API_PORT, API_HOST, () => { + server.off('error', reject); + resolve(); + }); + }); + + return { + stop: async () => { + await new Promise((resolve, reject) => { + server.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }; +} diff --git a/examples/mindcache_server_local/tsconfig.json b/examples/mindcache_server_local/tsconfig.json new file mode 100644 index 0000000..a95b11e --- /dev/null +++ b/examples/mindcache_server_local/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "types": ["node"], + "baseUrl": "../..", + "paths": { + "mindcache": ["./packages/mindcache/src/index.ts"], + "mindcache/server": ["./packages/mindcache/src/server.ts"] + } + }, + "include": ["src/**/*"] +} diff --git a/packages/README.md b/packages/README.md index 4907e23..52e156e 100644 --- a/packages/README.md +++ b/packages/README.md @@ -98,6 +98,7 @@ Create `.env.local`: | Package | Port | Description | |---------|------|-------------| | `@mindcache/server` | 8787 | Cloudflare Workers + Durable Objects API | +| `@mindcache/mindcache-server` | 4040 | Local filesystem-backed MindCache API server | | `@mindcache/web` | 3000 | Next.js dashboard | | `@mindcache/shared` | - | Shared types and utilities | | `mindcache` | - | Client SDK | @@ -129,4 +130,3 @@ cd packages/web pnpm dev # Next.js dev server pnpm build # Production build ``` - diff --git a/packages/mindcache-server/README.md b/packages/mindcache-server/README.md new file mode 100644 index 0000000..f78047c --- /dev/null +++ b/packages/mindcache-server/README.md @@ -0,0 +1,113 @@ +# @mindcache/mindcache-server + +Local API server that indexes many MindCache files from a filesystem directory and exposes query endpoints optimized for tag-based context window retrieval. + +## What It Does + +- Watches a root directory of MindCache files (`.md`, `.markdown`, `.mindcache`, `.json` by default). +- Builds one in-memory aggregate index. +- Uses canonical keys: `relative/path/to/file.ext::rawKey`. +- Applies per-file key-level diffs on updates (only changed keys are rewritten). +- Serves HTTP endpoints for tags, entries, and context windows. + +## Install + +```bash +npm install @mindcache/mindcache-server +``` + +## Run + +```bash +npx mindcache-server --root ./data --port 4040 +``` + +## CLI Options + +- `--root `: root folder containing MindCache files (required) +- `--host `: HTTP host (default `127.0.0.1`) +- `--port `: HTTP port (default `4040`) +- `--extensions `: comma-separated extensions (default `.md,.markdown,.mindcache,.json`) +- `--poll-interval `: fallback polling interval (default `5000`, `0` disables) +- `--debounce `: watch debounce delay (default `200`) +- `--auth-token `: require `Authorization: Bearer ` for all endpoints except `/health` +- `--no-watch`: disable filesystem watchers + +## API + +### Health + +```http +GET /health +``` + +### List tags + +```http +GET /v1/tags +``` + +### Get tags for one key + +```http +GET /v1/key-tags?key=team/a/memory.md::customer_profile +``` + +### Find keys by tags + +```http +GET /v1/keys?tags=project,customer&match=all +``` + +- `match=all` means key must contain all tags. +- `match=any` means key can contain any tag. + +### Get entries + +```http +GET /v1/entries?tags=project-a&match=all +``` + +### Get context window + +```http +GET /v1/context-window?tags=project-a,customer&match=all +``` + +or + +```http +POST /v1/context-window +Content-Type: application/json + +{ + "tags": ["project-a", "customer"], + "match": "all" +} +``` + +### Force reconcile + +```http +POST /v1/reconcile +``` + +## Programmatic Usage + +```ts +import { MindCacheApiServer, MindCacheServerCore } from '@mindcache/mindcache-server'; + +const core = new MindCacheServerCore({ + rootDir: '/data/mindcache-files', + watch: true +}); + +const api = new MindCacheApiServer({ + core, + host: '127.0.0.1', + port: 4040 +}); + +await core.start(); +await api.start(); +``` diff --git a/packages/mindcache-server/package.json b/packages/mindcache-server/package.json new file mode 100644 index 0000000..eaabb54 --- /dev/null +++ b/packages/mindcache-server/package.json @@ -0,0 +1,63 @@ +{ + "name": "@mindcache/mindcache-server", + "version": "0.1.0", + "description": "Local filesystem-backed MindCache API server", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "bin": { + "mindcache-server": "./dist/cli.js" + }, + "files": [ + "dist/**/*", + "README.md" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint src/**/*.ts tests/**/*.ts", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "keywords": [ + "mindcache", + "server", + "local", + "filesystem", + "api" + ], + "author": "MindCache Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dh7/mindcache.git", + "directory": "packages/mindcache-server" + }, + "dependencies": { + "mindcache": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mindcache-server/src/MindCacheApiServer.ts b/packages/mindcache-server/src/MindCacheApiServer.ts new file mode 100644 index 0000000..a55519b --- /dev/null +++ b/packages/mindcache-server/src/MindCacheApiServer.ts @@ -0,0 +1,325 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { URL } from 'node:url'; + +import type { + ContextWindowQuery, + Logger, + MindCacheApiServerOptions, + MindCacheServerCoreApi, + TagMatchMode +} from './types'; + +const defaultLogger: Logger = { + info: (message: string) => { + // eslint-disable-next-line no-console + console.log(message); + }, + warn: (message: string) => { + // eslint-disable-next-line no-console + console.warn(message); + }, + error: (message: string) => { + // eslint-disable-next-line no-console + console.error(message); + } +}; + +interface ParsedRequest { + pathname: string; + query: URLSearchParams; +} + +export class MindCacheApiServer { + private readonly core: MindCacheServerCoreApi; + private readonly host: string; + private readonly port: number; + private readonly authToken?: string; + private readonly logger: Logger; + private server: Server | null = null; + + constructor(options: MindCacheApiServerOptions) { + this.core = options.core; + this.host = options.host || '127.0.0.1'; + this.port = options.port || 4040; + this.authToken = options.authToken; + this.logger = options.logger || defaultLogger; + } + + async start(): Promise { + if (this.server) { + return; + } + + this.server = createServer((req, res) => { + void this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + if (!this.server) { + reject(new Error('Server not initialized')); + return; + } + + this.server.once('error', reject); + this.server.listen(this.port, this.host, () => { + this.server?.off('error', reject); + resolve(); + }); + }); + + this.logger.info(`[mindcache-server] API listening on http://${this.host}:${this.port}`); + } + + async stop(): Promise { + if (!this.server) { + return; + } + + const server = this.server; + this.server = null; + + await new Promise((resolve, reject) => { + server.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + this.applyCorsHeaders(res); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + const parsed = this.parseRequest(req); + if (!parsed) { + this.sendJson(res, 400, { error: 'Invalid URL' }); + return; + } + + if (parsed.pathname !== '/health' && parsed.pathname !== '/' && !this.isAuthorized(req)) { + this.sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + try { + if (req.method === 'GET' && parsed.pathname === '/') { + this.sendJson(res, 200, { + name: 'mindcache-server', + status: 'ok', + endpoints: [ + 'GET /health', + 'GET /v1/stats', + 'GET /v1/tags', + 'GET /v1/key-tags?key=', + 'GET /v1/keys?tags=tag1,tag2&match=all|any', + 'GET /v1/entries?tags=tag1,tag2&match=all|any', + 'GET /v1/context-window?tags=tag1,tag2&match=all|any', + 'POST /v1/context-window', + 'POST /v1/reconcile' + ] + }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/health') { + this.sendJson(res, 200, { + status: 'ok', + ...this.core.getStats() + }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/stats') { + this.sendJson(res, 200, this.core.getStats()); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/tags') { + this.sendJson(res, 200, { tags: this.core.getAllTags() }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/key-tags') { + const key = parsed.query.get('key') || ''; + if (!key) { + this.sendJson(res, 400, { error: 'Missing required query parameter: key' }); + return; + } + this.sendJson(res, 200, { + key, + tags: this.core.getTagsForKey(key) + }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/keys') { + const query = this.queryFromSearchParams(parsed.query); + const keys = this.core.findKeysByTags(query.tags || [], query.match); + this.sendJson(res, 200, { + tags: query.tags || [], + match: query.match, + count: keys.length, + keys + }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/entries') { + const query = this.queryFromSearchParams(parsed.query); + const entries = this.core.listEntries(query); + this.sendJson(res, 200, { + tags: query.tags || [], + match: query.match, + count: entries.length, + entries + }); + return; + } + + if (req.method === 'GET' && parsed.pathname === '/v1/context-window') { + const query = this.queryFromSearchParams(parsed.query); + const result = this.core.getContextWindow(query); + this.sendJson(res, 200, result); + return; + } + + if (req.method === 'POST' && parsed.pathname === '/v1/context-window') { + const body = await this.readBody(req); + const query = this.queryFromBody(body); + const result = this.core.getContextWindow(query); + this.sendJson(res, 200, result); + return; + } + + if (req.method === 'POST' && parsed.pathname === '/v1/reconcile') { + await this.core.reconcile('api'); + this.sendJson(res, 200, { + ok: true, + ...this.core.getStats() + }); + return; + } + + this.sendJson(res, 404, { error: 'Not found' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isBadRequest = message === 'Invalid JSON body'; + this.logger.error(`[mindcache-server] Request error: ${error instanceof Error ? error.message : String(error)}`); + this.sendJson(res, isBadRequest ? 400 : 500, { + error: isBadRequest ? message : (error instanceof Error ? error.message : 'Internal server error') + }); + } + } + + private parseRequest(req: IncomingMessage): ParsedRequest | null { + if (!req.url) { + return null; + } + + try { + const url = new URL(req.url, 'http://localhost'); + return { + pathname: url.pathname, + query: url.searchParams + }; + } catch { + return null; + } + } + + private queryFromSearchParams(params: URLSearchParams): ContextWindowQuery { + const tags = this.parseTags(params.get('tags')); + const match = this.parseMatch(params.get('match')); + return { tags, match }; + } + + private queryFromBody(body: unknown): ContextWindowQuery { + const object = body && typeof body === 'object' ? body as Record : {}; + const tagsValue = object.tags; + const tags = Array.isArray(tagsValue) + ? tagsValue.filter(tag => typeof tag === 'string') as string[] + : []; + + const match = this.parseMatch(typeof object.match === 'string' ? object.match : null); + + return { tags, match }; + } + + private parseTags(raw: string | null): string[] { + if (!raw) { + return []; + } + + return raw + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); + } + + private parseMatch(raw: string | null): TagMatchMode { + if (raw === 'any') { + return 'any'; + } + return 'all'; + } + + private async readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + + if (chunks.length === 0) { + return {}; + } + + const rawBody = Buffer.concat(chunks).toString('utf8'); + if (!rawBody.trim()) { + return {}; + } + + try { + return JSON.parse(rawBody); + } catch { + throw new Error('Invalid JSON body'); + } + } + + private isAuthorized(req: IncomingMessage): boolean { + if (!this.authToken) { + return true; + } + + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.slice('Bearer '.length).trim(); + return token === this.authToken; + } + + private sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { + const body = JSON.stringify(payload, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) + }); + res.end(body); + } + + private applyCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + } +} diff --git a/packages/mindcache-server/src/MindCacheServerCore.ts b/packages/mindcache-server/src/MindCacheServerCore.ts new file mode 100644 index 0000000..3b17ecd --- /dev/null +++ b/packages/mindcache-server/src/MindCacheServerCore.ts @@ -0,0 +1,613 @@ +import { promises as fs, watch as fsWatch, type FSWatcher } from 'node:fs'; +import path from 'node:path'; + +import { MindCache } from 'mindcache/server'; + +import { + normalizeExtensions, + parseMindCacheFile, + readFileText, + toCanonicalKey, + toFileId +} from './fileUtils'; +import type { + ContextWindowQuery, + ContextWindowResult, + EntrySnapshot, + FileMetadata, + FileSnapshot, + IndexedEntry, + Logger, + MindCacheServerCoreApi, + MindCacheServerCoreOptions, + MindCacheServerStats, + TagMatchMode +} from './types'; + +const DEFAULT_EXTENSIONS = ['.md', '.markdown', '.mindcache', '.json']; +const DEFAULT_POLL_INTERVAL_MS = 5000; +const DEFAULT_DEBOUNCE_MS = 200; + +const defaultLogger: Logger = { + info: (message: string) => { + // eslint-disable-next-line no-console + console.log(message); + }, + warn: (message: string) => { + // eslint-disable-next-line no-console + console.warn(message); + }, + error: (message: string) => { + // eslint-disable-next-line no-console + console.error(message); + }, + debug: (message: string) => { + // eslint-disable-next-line no-console + console.debug(message); + } +}; + +export class MindCacheServerCore implements MindCacheServerCoreApi { + private readonly rootDir: string; + private readonly includeExtensions: Set; + private readonly watchEnabled: boolean; + private readonly pollIntervalMs: number; + private readonly debounceMs: number; + private readonly logger: Logger; + + private readonly aggregate = new MindCache({ accessLevel: 'admin' }); + + private fileSnapshots = new Map(); + private fileMetadata = new Map(); + private tagIndex = new Map>(); + private rawKeyOwners = new Map>(); + private canonicalKeySources = new Map(); + + private watcher: FSWatcher | null = null; + private directoryWatchers = new Map(); + private usingRecursiveWatcher = false; + private reconcileTimer: ReturnType | null = null; + private pollTimer: ReturnType | null = null; + + private started = false; + private reconcileInFlight = false; + private reconcileQueued = false; + private lastReconcileAt: Date | null = null; + + constructor(options: MindCacheServerCoreOptions) { + if (!options.rootDir) { + throw new Error('rootDir is required'); + } + + this.rootDir = path.resolve(options.rootDir); + this.includeExtensions = normalizeExtensions(options.includeExtensions || DEFAULT_EXTENSIONS); + this.watchEnabled = options.watch ?? true; + this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + this.logger = options.logger || defaultLogger; + } + + async start(): Promise { + if (this.started) { + return; + } + + await fs.mkdir(this.rootDir, { recursive: true }); + await this.reconcile('startup'); + + if (this.watchEnabled) { + await this.startWatchers(); + } + + if (this.pollIntervalMs > 0) { + this.pollTimer = setInterval(() => { + this.scheduleReconcile('poll'); + }, this.pollIntervalMs); + this.pollTimer.unref(); + } + + this.started = true; + } + + async stop(): Promise { + if (this.reconcileTimer) { + clearTimeout(this.reconcileTimer); + this.reconcileTimer = null; + } + + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + + for (const watcher of this.directoryWatchers.values()) { + watcher.close(); + } + this.directoryWatchers.clear(); + + this.started = false; + } + + get mindcache(): MindCache { + return this.aggregate; + } + + get rootPath(): string { + return this.rootDir; + } + + getAllTags(): string[] { + return Array.from(this.tagIndex.keys()).sort((a, b) => a.localeCompare(b)); + } + + getTagsForKey(canonicalKey: string): string[] { + return this.aggregate.getTags(canonicalKey).sort((a, b) => a.localeCompare(b)); + } + + getContextWindow(query: ContextWindowQuery = {}): ContextWindowResult { + const tags = this.normalizeTags(query.tags || []); + const match = query.match || 'all'; + const keys = this.findKeysByTags(tags, match); + const scopedCache = new MindCache({ accessLevel: 'admin' }); + + for (const key of keys) { + const attributes = this.aggregate.get_attributes(key); + if (!attributes) { + continue; + } + const value = this.aggregate.get_value(key); + scopedCache.set_value(key, value, attributes); + } + + const visibleKeys = keys.filter(key => { + const attributes = this.aggregate.get_attributes(key); + return Boolean( + attributes?.systemTags.includes('SystemPrompt') || + attributes?.systemTags.includes('LLMRead') + ); + }); + + return { + tags, + match, + keys, + visibleKeys, + contextWindow: scopedCache.get_system_prompt() + }; + } + + findKeysByTags(tags: string[], match: TagMatchMode = 'all'): string[] { + const normalizedTags = this.normalizeTags(tags); + + if (normalizedTags.length === 0) { + return this.aggregate.keys().sort((a, b) => a.localeCompare(b)); + } + + if (match === 'any') { + const union = new Set(); + for (const tag of normalizedTags) { + const keys = this.tagIndex.get(tag); + if (!keys) { + continue; + } + for (const key of keys) { + union.add(key); + } + } + return Array.from(union).sort((a, b) => a.localeCompare(b)); + } + + const sets: Set[] = []; + for (const tag of normalizedTags) { + const keys = this.tagIndex.get(tag); + if (!keys) { + return []; + } + sets.push(keys); + } + + sets.sort((a, b) => a.size - b.size); + const [first, ...rest] = sets; + const intersection = new Set(first); + + for (const set of rest) { + for (const key of intersection) { + if (!set.has(key)) { + intersection.delete(key); + } + } + if (intersection.size === 0) { + return []; + } + } + + return Array.from(intersection).sort((a, b) => a.localeCompare(b)); + } + + listEntries(query: ContextWindowQuery = {}): IndexedEntry[] { + const tags = this.normalizeTags(query.tags || []); + const match = query.match || 'all'; + const keys = this.findKeysByTags(tags, match); + const entries: IndexedEntry[] = []; + + for (const key of keys) { + const entry = this.getEntry(key); + if (entry) { + entries.push(entry); + } + } + + return entries; + } + + getEntry(canonicalKey: string): IndexedEntry | undefined { + const source = this.canonicalKeySources.get(canonicalKey); + if (!source) { + return undefined; + } + + const attributes = this.aggregate.get_attributes(canonicalKey); + if (!attributes) { + return undefined; + } + + return { + key: canonicalKey, + rawKey: source.rawKey, + fileId: source.fileId, + value: this.aggregate.get_value(canonicalKey), + attributes + }; + } + + getStats(): MindCacheServerStats { + return { + rootDir: this.rootDir, + files: this.fileSnapshots.size, + keys: this.aggregate.size(), + tags: this.tagIndex.size, + watchEnabled: this.watchEnabled, + started: this.started, + lastReconcileAt: this.lastReconcileAt ? this.lastReconcileAt.toISOString() : null + }; + } + + async reconcile(reason = 'manual'): Promise { + if (this.reconcileInFlight) { + this.reconcileQueued = true; + return; + } + + this.reconcileInFlight = true; + + try { + const discoveredFiles = await this.scanMindCacheFiles(); + const nextMetadata = new Map(); + + for (const [fileId, metadata] of discoveredFiles) { + const previousMetadata = this.fileMetadata.get(fileId); + const changed = !previousMetadata || + previousMetadata.mtimeMs !== metadata.mtimeMs || + previousMetadata.size !== metadata.size || + previousMetadata.absolutePath !== metadata.absolutePath; + + if (!changed) { + if (previousMetadata) { + nextMetadata.set(fileId, previousMetadata); + } + continue; + } + + const loaded = await this.reloadFile(metadata); + if (loaded) { + nextMetadata.set(fileId, metadata); + } else if (previousMetadata) { + nextMetadata.set(fileId, previousMetadata); + } + } + + for (const fileId of this.fileMetadata.keys()) { + if (!discoveredFiles.has(fileId)) { + this.removeFile(fileId); + } + } + + this.fileMetadata = nextMetadata; + this.lastReconcileAt = new Date(); + this.logger.debug?.(`[mindcache-server] Reconcile complete (${reason}). files=${this.fileSnapshots.size} keys=${this.aggregate.size()}`); + + if (this.watchEnabled && !this.usingRecursiveWatcher) { + await this.syncDirectoryWatchers(); + } + } catch (error) { + this.logger.error(`[mindcache-server] Reconcile failed (${reason}): ${error instanceof Error ? error.message : String(error)}`); + throw error; + } finally { + this.reconcileInFlight = false; + if (this.reconcileQueued) { + this.reconcileQueued = false; + void this.reconcile('queued'); + } + } + } + + private normalizeTags(tags: string[]): string[] { + const unique = new Set(); + for (const tag of tags) { + const cleaned = tag.trim(); + if (cleaned) { + unique.add(cleaned); + } + } + return Array.from(unique).sort((a, b) => a.localeCompare(b)); + } + + private async reloadFile(metadata: FileMetadata): Promise { + try { + const content = await readFileText(metadata.absolutePath); + const nextEntries = parseMindCacheFile(content, metadata.absolutePath); + const previousSnapshot = this.fileSnapshots.get(metadata.fileId); + const previousEntries = previousSnapshot?.entries || new Map(); + + this.applyFileDiff(metadata.fileId, previousEntries, nextEntries); + + this.fileSnapshots.set(metadata.fileId, { + fileId: metadata.fileId, + absolutePath: metadata.absolutePath, + mtimeMs: metadata.mtimeMs, + size: metadata.size, + entries: nextEntries + }); + + return true; + } catch (error) { + this.logger.warn(`[mindcache-server] Failed to load ${metadata.absolutePath}: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + private removeFile(fileId: string): void { + const snapshot = this.fileSnapshots.get(fileId); + if (!snapshot) { + this.fileMetadata.delete(fileId); + return; + } + + for (const entry of snapshot.entries.values()) { + this.removeEntry(fileId, entry); + } + + this.fileSnapshots.delete(fileId); + this.fileMetadata.delete(fileId); + } + + private applyFileDiff(fileId: string, previous: Map, next: Map): void { + for (const [rawKey, oldEntry] of previous) { + if (!next.has(rawKey)) { + this.removeEntry(fileId, oldEntry); + } + } + + for (const [rawKey, newEntry] of next) { + const oldEntry = previous.get(rawKey); + if (!oldEntry || oldEntry.hash !== newEntry.hash) { + this.upsertEntry(fileId, newEntry, oldEntry); + } + } + } + + private upsertEntry(fileId: string, entry: EntrySnapshot, previous?: EntrySnapshot): void { + const canonicalKey = toCanonicalKey(fileId, entry.rawKey); + + this.aggregate.set_value(canonicalKey, entry.value, entry.attributes); + this.canonicalKeySources.set(canonicalKey, { fileId, rawKey: entry.rawKey }); + + this.updateTagIndex( + canonicalKey, + previous?.attributes.contentTags || [], + entry.attributes.contentTags || [] + ); + + if (!this.rawKeyOwners.has(entry.rawKey)) { + this.rawKeyOwners.set(entry.rawKey, new Set()); + } + this.rawKeyOwners.get(entry.rawKey)?.add(canonicalKey); + } + + private removeEntry(fileId: string, entry: EntrySnapshot): void { + const canonicalKey = toCanonicalKey(fileId, entry.rawKey); + + this.aggregate.delete(canonicalKey); + this.canonicalKeySources.delete(canonicalKey); + this.updateTagIndex(canonicalKey, entry.attributes.contentTags || [], []); + + const owners = this.rawKeyOwners.get(entry.rawKey); + if (owners) { + owners.delete(canonicalKey); + if (owners.size === 0) { + this.rawKeyOwners.delete(entry.rawKey); + } + } + } + + private updateTagIndex(canonicalKey: string, oldTags: string[], newTags: string[]): void { + const oldSet = new Set(oldTags); + const newSet = new Set(newTags); + + for (const tag of oldSet) { + if (newSet.has(tag)) { + continue; + } + const keys = this.tagIndex.get(tag); + if (!keys) { + continue; + } + keys.delete(canonicalKey); + if (keys.size === 0) { + this.tagIndex.delete(tag); + } + } + + for (const tag of newSet) { + if (!this.tagIndex.has(tag)) { + this.tagIndex.set(tag, new Set()); + } + this.tagIndex.get(tag)?.add(canonicalKey); + } + } + + private async scanMindCacheFiles(): Promise> { + const results = new Map(); + const stack: string[] = [this.rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) { + continue; + } + + let dirEntries; + try { + dirEntries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + this.logger.warn(`[mindcache-server] Failed to read directory ${currentDir}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + + for (const entry of dirEntries) { + if (entry.name.startsWith('.') && entry.isDirectory()) { + continue; + } + + const absolutePath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + + if (!entry.isFile() || !this.isSupportedFile(absolutePath)) { + continue; + } + + let stats; + try { + stats = await fs.stat(absolutePath); + } catch { + continue; + } + + let fileId: string; + try { + fileId = toFileId(this.rootDir, absolutePath); + } catch { + continue; + } + + results.set(fileId, { + fileId, + absolutePath, + mtimeMs: stats.mtimeMs, + size: stats.size + }); + } + } + + return results; + } + + private isSupportedFile(absolutePath: string): boolean { + const extension = path.extname(absolutePath).toLowerCase(); + return this.includeExtensions.has(extension); + } + + private async startWatchers(): Promise { + try { + this.watcher = fsWatch(this.rootDir, { recursive: true }, () => { + this.scheduleReconcile('watch'); + }); + this.usingRecursiveWatcher = true; + this.logger.debug?.('[mindcache-server] Started recursive watcher'); + } catch { + this.usingRecursiveWatcher = false; + await this.syncDirectoryWatchers(); + this.logger.debug?.('[mindcache-server] Recursive watch unavailable, using per-directory watchers'); + } + } + + private async syncDirectoryWatchers(): Promise { + const directories = await this.scanDirectories(); + + for (const directory of directories) { + if (this.directoryWatchers.has(directory)) { + continue; + } + + try { + const watcher = fsWatch(directory, () => { + this.scheduleReconcile('watch'); + }); + this.directoryWatchers.set(directory, watcher); + } catch (error) { + this.logger.warn(`[mindcache-server] Failed to watch directory ${directory}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + for (const [directory, watcher] of this.directoryWatchers) { + if (directories.has(directory)) { + continue; + } + watcher.close(); + this.directoryWatchers.delete(directory); + } + } + + private async scanDirectories(): Promise> { + const directories = new Set(); + const stack: string[] = [this.rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) { + continue; + } + + directories.add(currentDir); + + let dirEntries; + try { + dirEntries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of dirEntries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith('.')) { + continue; + } + stack.push(path.join(currentDir, entry.name)); + } + } + + return directories; + } + + private scheduleReconcile(reason: string): void { + if (this.reconcileTimer) { + clearTimeout(this.reconcileTimer); + } + + this.reconcileTimer = setTimeout(() => { + this.reconcileTimer = null; + void this.reconcile(reason); + }, this.debounceMs); + } +} diff --git a/packages/mindcache-server/src/cli.ts b/packages/mindcache-server/src/cli.ts new file mode 100644 index 0000000..e7aca88 --- /dev/null +++ b/packages/mindcache-server/src/cli.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import path from 'node:path'; + +import { MindCacheApiServer } from './MindCacheApiServer'; +import { MindCacheServerCore } from './MindCacheServerCore'; + +interface ParsedArgs { + rootDir: string; + host: string; + port: number; + watch: boolean; + pollIntervalMs: number; + debounceMs: number; + extensions: string[]; + authToken?: string; +} + +function printHelp(): void { + // eslint-disable-next-line no-console + console.log(`mindcache-server + +Usage: + mindcache-server --root [options] + +Options: + --root Root folder containing MindCache files (required) + --host HTTP host (default: 127.0.0.1) + --port HTTP port (default: 4040) + --extensions Comma-separated file extensions (default: .md,.markdown,.mindcache,.json) + --poll-interval Polling interval fallback in milliseconds (default: 5000) + --debounce Debounce for watch events in milliseconds (default: 200) + --auth-token Require bearer token for all endpoints except /health + --no-watch Disable filesystem watchers + --help Show this help + +Examples: + mindcache-server --root ./data + mindcache-server --root /data/mindcache --port 5050 --auth-token secret +`); +} + +function parseArgs(argv: string[]): ParsedArgs { + const args: ParsedArgs = { + rootDir: '', + host: '127.0.0.1', + port: 4040, + watch: true, + pollIntervalMs: 5000, + debounceMs: 200, + extensions: ['.md', '.markdown', '.mindcache', '.json'] + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + + if (arg === '--no-watch') { + args.watch = false; + continue; + } + + const value = argv[i + 1]; + + switch (arg) { + case '--root': + args.rootDir = value || ''; + i++; + break; + case '--host': + args.host = value || args.host; + i++; + break; + case '--port': + args.port = Number(value) || args.port; + i++; + break; + case '--poll-interval': + args.pollIntervalMs = Number(value) || args.pollIntervalMs; + i++; + break; + case '--debounce': + args.debounceMs = Number(value) || args.debounceMs; + i++; + break; + case '--extensions': + args.extensions = (value || '') + .split(',') + .map(extension => extension.trim()) + .filter(Boolean); + i++; + break; + case '--auth-token': + args.authToken = value; + i++; + break; + default: + break; + } + } + + if (!args.rootDir) { + throw new Error('--root is required'); + } + + if (!Number.isFinite(args.port) || args.port <= 0 || args.port > 65535) { + throw new Error('--port must be between 1 and 65535'); + } + + if (!Number.isFinite(args.pollIntervalMs) || args.pollIntervalMs < 0) { + throw new Error('--poll-interval must be >= 0'); + } + + if (!Number.isFinite(args.debounceMs) || args.debounceMs < 0) { + throw new Error('--debounce must be >= 0'); + } + + return args; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + const core = new MindCacheServerCore({ + rootDir: path.resolve(args.rootDir), + includeExtensions: args.extensions, + watch: args.watch, + pollIntervalMs: args.pollIntervalMs, + debounceMs: args.debounceMs + }); + + const api = new MindCacheApiServer({ + core, + host: args.host, + port: args.port, + authToken: args.authToken + }); + + await core.start(); + await api.start(); + + const shutdown = async () => { + await api.stop(); + await core.stop(); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + + process.on('SIGTERM', () => { + void shutdown(); + }); +} + +void main().catch(error => { + // eslint-disable-next-line no-console + console.error(`[mindcache-server] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/packages/mindcache-server/src/fileUtils.ts b/packages/mindcache-server/src/fileUtils.ts new file mode 100644 index 0000000..b27f2f3 --- /dev/null +++ b/packages/mindcache-server/src/fileUtils.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { MindCache, DEFAULT_KEY_ATTRIBUTES, type KeyAttributes, type STM } from 'mindcache/server'; + +import type { EntrySnapshot } from './types'; + +const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mindcache']); + +export function normalizeExtensions(extensions: string[]): Set { + return new Set( + extensions.map(ext => ext.trim().toLowerCase()).filter(Boolean).map(ext => ext.startsWith('.') ? ext : `.${ext}`) + ); +} + +export function normalizeFileId(fileId: string): string { + return fileId.split(path.sep).join('/'); +} + +export function toFileId(rootDir: string, absolutePath: string): string { + const relative = path.relative(rootDir, absolutePath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`File is outside rootDir: ${absolutePath}`); + } + return normalizeFileId(relative); +} + +export function toCanonicalKey(fileId: string, rawKey: string): string { + return `${fileId}::${rawKey}`; +} + +export async function readFileText(absolutePath: string): Promise { + return fs.readFile(absolutePath, 'utf8'); +} + +function isMarkdownFile(absolutePath: string): boolean { + return MARKDOWN_EXTENSIONS.has(path.extname(absolutePath).toLowerCase()); +} + +function normalizeAttributes(attributes: Partial | undefined): KeyAttributes { + const merged = { + ...DEFAULT_KEY_ATTRIBUTES, + ...attributes + }; + + return { + ...merged, + contentTags: Array.from(new Set(merged.contentTags || [])), + systemTags: Array.from(new Set(merged.systemTags || [])), + zIndex: merged.zIndex ?? 0 + }; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map(item => stableStringify(item)).join(',')}]`; + } + + const objectValue = value as Record; + const keys = Object.keys(objectValue).sort(); + const pairs = keys.map(key => `${JSON.stringify(key)}:${stableStringify(objectValue[key])}`); + return `{${pairs.join(',')}}`; +} + +function hashEntry(value: unknown, attributes: KeyAttributes): string { + const serialized = stableStringify({ value, attributes }); + return createHash('sha256').update(serialized).digest('hex'); +} + +export function parseMindCacheFile(content: string, absolutePath: string): Map { + const extension = path.extname(absolutePath).toLowerCase(); + const cache = new MindCache({ accessLevel: 'admin' }); + + if (isMarkdownFile(absolutePath)) { + cache.fromMarkdown(content, false); + } else if (extension === '.json') { + const parsed = JSON.parse(content) as STM; + cache.deserialize(parsed); + } else { + throw new Error(`Unsupported file extension: ${extension}`); + } + + const serialized = cache.serialize(); + const entries = new Map(); + + for (const [rawKey, entry] of Object.entries(serialized)) { + if (rawKey.startsWith('$')) { + continue; + } + + const attributes = normalizeAttributes(entry.attributes); + const hash = hashEntry(entry.value, attributes); + + entries.set(rawKey, { + rawKey, + value: entry.value, + attributes, + hash + }); + } + + return entries; +} diff --git a/packages/mindcache-server/src/index.ts b/packages/mindcache-server/src/index.ts new file mode 100644 index 0000000..f064080 --- /dev/null +++ b/packages/mindcache-server/src/index.ts @@ -0,0 +1,16 @@ +export { MindCacheServerCore } from './MindCacheServerCore'; +export { MindCacheApiServer } from './MindCacheApiServer'; + +export type { + ContextWindowQuery, + ContextWindowResult, + EntrySnapshot, + FileSnapshot, + IndexedEntry, + Logger, + MindCacheApiServerOptions, + MindCacheServerCoreApi, + MindCacheServerCoreOptions, + MindCacheServerStats, + TagMatchMode +} from './types'; diff --git a/packages/mindcache-server/src/types.ts b/packages/mindcache-server/src/types.ts new file mode 100644 index 0000000..617a7d0 --- /dev/null +++ b/packages/mindcache-server/src/types.ts @@ -0,0 +1,96 @@ +import type { KeyAttributes } from 'mindcache/server'; + +export type TagMatchMode = 'all' | 'any'; + +export interface Logger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug?(message: string): void; +} + +export interface MindCacheServerCoreOptions { + /** Root folder that contains MindCache files */ + rootDir: string; + /** File extensions to include (with leading dot) */ + includeExtensions?: string[]; + /** Enable filesystem watch mode */ + watch?: boolean; + /** Fallback polling interval in milliseconds (0 disables polling) */ + pollIntervalMs?: number; + /** Debounce delay for filesystem events in milliseconds */ + debounceMs?: number; + /** Logger used by the server */ + logger?: Logger; +} + +export interface MindCacheApiServerOptions { + core: MindCacheServerCoreApi; + host?: string; + port?: number; + authToken?: string; + logger?: Logger; +} + +export interface EntrySnapshot { + rawKey: string; + value: unknown; + attributes: KeyAttributes; + hash: string; +} + +export interface FileSnapshot { + fileId: string; + absolutePath: string; + mtimeMs: number; + size: number; + entries: Map; +} + +export interface IndexedEntry { + key: string; + rawKey: string; + fileId: string; + value: unknown; + attributes: KeyAttributes; +} + +export interface FileMetadata { + fileId: string; + absolutePath: string; + mtimeMs: number; + size: number; +} + +export interface ContextWindowQuery { + tags?: string[]; + match?: TagMatchMode; +} + +export interface ContextWindowResult { + tags: string[]; + match: TagMatchMode; + keys: string[]; + visibleKeys: string[]; + contextWindow: string; +} + +export interface MindCacheServerStats { + rootDir: string; + files: number; + keys: number; + tags: number; + watchEnabled: boolean; + started: boolean; + lastReconcileAt: string | null; +} + +export interface MindCacheServerCoreApi { + getStats(): MindCacheServerStats; + getAllTags(): string[]; + getTagsForKey(canonicalKey: string): string[]; + findKeysByTags(tags: string[], match?: TagMatchMode): string[]; + listEntries(query?: ContextWindowQuery): IndexedEntry[]; + getContextWindow(query?: ContextWindowQuery): ContextWindowResult; + reconcile(reason?: string): Promise; +} diff --git a/packages/mindcache-server/tests/MindCacheServerCore.test.ts b/packages/mindcache-server/tests/MindCacheServerCore.test.ts new file mode 100644 index 0000000..76d089e --- /dev/null +++ b/packages/mindcache-server/tests/MindCacheServerCore.test.ts @@ -0,0 +1,220 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; +import { MindCache, type KeyAttributes } from 'mindcache/server'; + +import { MindCacheServerCore } from '../src/MindCacheServerCore'; + +interface EntryDefinition { + key: string; + value: unknown; + attributes?: Partial; +} + +async function writeMarkdownFile(filePath: string, entries: EntryDefinition[]): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const cache = new MindCache({ accessLevel: 'admin' }); + for (const entry of entries) { + cache.set_value(entry.key, entry.value, entry.attributes); + } + + await fs.writeFile(filePath, cache.toMarkdown(), 'utf8'); +} + +async function createTempRoot(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'mindcache-server-test-')); +} + +describe('MindCacheServerCore', () => { + test('applies key-level diffs when one key changes in a file', async () => { + const rootDir = await createTempRoot(); + const filePath = path.join(rootDir, 'team', 'memory.md'); + + await writeMarkdownFile(filePath, [ + { + key: 'profile', + value: 'alice-v1', + attributes: { + contentTags: ['user', 'profile'], + systemTags: ['SystemPrompt', 'LLMRead'] + } + }, + { + key: 'notes', + value: 'note-v1', + attributes: { + contentTags: ['team'], + systemTags: ['SystemPrompt', 'LLMRead'] + } + } + ]); + + const core = new MindCacheServerCore({ + rootDir, + watch: false, + pollIntervalMs: 0 + }); + + try { + await core.start(); + + await writeMarkdownFile(filePath, [ + { + key: 'profile', + value: 'alice-v2', + attributes: { + contentTags: ['user', 'profile', 'vip'], + systemTags: ['SystemPrompt', 'LLMRead'] + } + }, + { + key: 'notes', + value: 'note-v1', + attributes: { + contentTags: ['team'], + systemTags: ['SystemPrompt', 'LLMRead'] + } + } + ]); + + await core.reconcile('test-update'); + + const profileKey = 'team/memory.md::profile'; + const notesKey = 'team/memory.md::notes'; + + expect(core.getEntry(profileKey)?.value).toBe('alice-v2'); + expect(core.getEntry(notesKey)?.value).toBe('note-v1'); + expect(core.getTagsForKey(profileKey)).toEqual(['profile', 'user', 'vip']); + expect(core.findKeysByTags(['vip'])).toEqual([profileKey]); + } finally { + await core.stop(); + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + + test('uses relative path prefixes to avoid key collisions', async () => { + const rootDir = await createTempRoot(); + + await writeMarkdownFile(path.join(rootDir, 'project-a', 'cache.md'), [ + { + key: 'note', + value: 'a', + attributes: { + contentTags: ['shared'], + systemTags: ['SystemPrompt'] + } + } + ]); + + await writeMarkdownFile(path.join(rootDir, 'project-b', 'cache.md'), [ + { + key: 'note', + value: 'b', + attributes: { + contentTags: ['shared'], + systemTags: ['SystemPrompt'] + } + } + ]); + + const core = new MindCacheServerCore({ + rootDir, + watch: false, + pollIntervalMs: 0 + }); + + try { + await core.start(); + + const keys = core.findKeysByTags(['shared']); + expect(keys).toEqual([ + 'project-a/cache.md::note', + 'project-b/cache.md::note' + ]); + } finally { + await core.stop(); + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + + test('removes keys when source file is deleted', async () => { + const rootDir = await createTempRoot(); + const filePath = path.join(rootDir, 'delete-me.md'); + + await writeMarkdownFile(filePath, [ + { + key: 'tmp', + value: 'exists', + attributes: { + contentTags: ['tmp'], + systemTags: ['SystemPrompt'] + } + } + ]); + + const core = new MindCacheServerCore({ + rootDir, + watch: false, + pollIntervalMs: 0 + }); + + try { + await core.start(); + expect(core.findKeysByTags(['tmp'])).toEqual(['delete-me.md::tmp']); + + await fs.rm(filePath, { force: true }); + await core.reconcile('test-delete'); + + expect(core.findKeysByTags(['tmp'])).toEqual([]); + expect(core.getStats().keys).toBe(0); + } finally { + await core.stop(); + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); + + test('context window query respects tag filtering and visibility', async () => { + const rootDir = await createTempRoot(); + + await writeMarkdownFile(path.join(rootDir, 'ctx.md'), [ + { + key: 'visible', + value: 'visible-value', + attributes: { + contentTags: ['ctx'], + systemTags: ['SystemPrompt'] + } + }, + { + key: 'hidden', + value: 'hidden-value', + attributes: { + contentTags: ['ctx'], + systemTags: [] + } + } + ]); + + const core = new MindCacheServerCore({ + rootDir, + watch: false, + pollIntervalMs: 0 + }); + + try { + await core.start(); + + const context = core.getContextWindow({ tags: ['ctx'] }); + expect(context.keys).toEqual(['ctx.md::hidden', 'ctx.md::visible']); + expect(context.visibleKeys).toEqual(['ctx.md::visible']); + expect(context.contextWindow).toContain('ctx.md::visible'); + expect(context.contextWindow).not.toContain('ctx.md::hidden'); + } finally { + await core.stop(); + await fs.rm(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/mindcache-server/tsconfig.json b/packages/mindcache-server/tsconfig.json new file mode 100644 index 0000000..fd0b45e --- /dev/null +++ b/packages/mindcache-server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "noEmit": true, + "types": ["node"], + "baseUrl": "../..", + "paths": { + "mindcache": ["./packages/mindcache/src/index.ts"], + "mindcache/server": ["./packages/mindcache/src/server.ts"] + }, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mindcache-server/tsup.config.ts b/packages/mindcache-server/tsup.config.ts new file mode 100644 index 0000000..079d95d --- /dev/null +++ b/packages/mindcache-server/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + cli: 'src/cli.ts' + }, + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true +}); diff --git a/packages/mindcache-server/vitest.config.ts b/packages/mindcache-server/vitest.config.ts new file mode 100644 index 0000000..a1e47f8 --- /dev/null +++ b/packages/mindcache-server/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + 'mindcache/server': path.resolve(__dirname, '../mindcache/src/server.ts'), + mindcache: path.resolve(__dirname, '../mindcache/src/index.ts') + } + }, + test: { + environment: 'node', + include: ['tests/**/*.test.ts'] + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d62927..b5cb02d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,34 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@20.19.25) + packages/mindcache-server: + dependencies: + mindcache: + specifier: workspace:* + version: link:../mindcache + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.25 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: ^8.57.0 + version: 8.57.1 + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@20.19.25) + packages/server: dependencies: '@ai-sdk/openai': diff --git a/tsconfig.json b/tsconfig.json index 6e9661d..c6b916d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ "paths": { "mindcache": [ "./packages/mindcache/src/index.ts" + ], + "mindcache/server": [ + "./packages/mindcache/src/server.ts" ] } }, @@ -29,6 +32,9 @@ { "path": "./packages/mindcache" }, + { + "path": "./packages/mindcache-server" + }, { "path": "./packages/server" }, @@ -39,4 +45,4 @@ "path": "./packages/web" } ] -} \ No newline at end of file +} From b2d4b59fe8a2c48e22d076451096ccae2b0211c6 Mon Sep 17 00:00:00 2001 From: dh Date: Sun, 8 Feb 2026 22:37:15 +0100 Subject: [PATCH 2/3] Update build process and TypeScript configuration for mindcache-server - Modified the build script in package.json to include TypeScript compilation alongside tsup. - Changed tsconfig.json to emit declaration files only, improving build efficiency. - Disabled declaration file generation in tsup.config.ts to streamline the build process. --- packages/mindcache-server/package.json | 2 +- packages/mindcache-server/tsconfig.json | 2 +- packages/mindcache-server/tsup.config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mindcache-server/package.json b/packages/mindcache-server/package.json index eaabb54..550654e 100644 --- a/packages/mindcache-server/package.json +++ b/packages/mindcache-server/package.json @@ -20,7 +20,7 @@ "README.md" ], "scripts": { - "build": "tsup", + "build": "tsup && tsc", "dev": "tsup --watch", "typecheck": "tsc --noEmit", "lint": "eslint src/**/*.ts tests/**/*.ts", diff --git a/packages/mindcache-server/tsconfig.json b/packages/mindcache-server/tsconfig.json index fd0b45e..8727c56 100644 --- a/packages/mindcache-server/tsconfig.json +++ b/packages/mindcache-server/tsconfig.json @@ -11,7 +11,7 @@ "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, - "noEmit": true, + "emitDeclarationOnly": true, "types": ["node"], "baseUrl": "../..", "paths": { diff --git a/packages/mindcache-server/tsup.config.ts b/packages/mindcache-server/tsup.config.ts index 079d95d..e46e0f4 100644 --- a/packages/mindcache-server/tsup.config.ts +++ b/packages/mindcache-server/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ cli: 'src/cli.ts' }, format: ['cjs', 'esm'], - dts: true, + dts: false, splitting: false, sourcemap: true, clean: true, From 769472315b8bf2ddbce74477e7ec878e9c314dc8 Mon Sep 17 00:00:00 2001 From: dh Date: Sun, 8 Feb 2026 22:51:30 +0100 Subject: [PATCH 3/3] Refactor TypeScript build configuration for mindcache-server - Updated the build script in package.json to remove TypeScript compilation, relying solely on tsup. - Changed tsconfig.json to use ES2022 module format and bundler resolution, enhancing compatibility. - Adjusted tsup.config.ts to enable declaration file generation, improving type support in the build process. --- packages/mindcache-server/package.json | 2 +- packages/mindcache-server/tsconfig.json | 11 +++-------- packages/mindcache-server/tsup.config.ts | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/mindcache-server/package.json b/packages/mindcache-server/package.json index 550654e..eaabb54 100644 --- a/packages/mindcache-server/package.json +++ b/packages/mindcache-server/package.json @@ -20,7 +20,7 @@ "README.md" ], "scripts": { - "build": "tsup && tsc", + "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", "lint": "eslint src/**/*.ts tests/**/*.ts", diff --git a/packages/mindcache-server/tsconfig.json b/packages/mindcache-server/tsconfig.json index 8727c56..ad186e1 100644 --- a/packages/mindcache-server/tsconfig.json +++ b/packages/mindcache-server/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", - "moduleResolution": "node", + "module": "ES2022", + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -11,13 +11,8 @@ "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, - "emitDeclarationOnly": true, + "noEmit": true, "types": ["node"], - "baseUrl": "../..", - "paths": { - "mindcache": ["./packages/mindcache/src/index.ts"], - "mindcache/server": ["./packages/mindcache/src/server.ts"] - }, "rootDir": "./src", "outDir": "./dist" }, diff --git a/packages/mindcache-server/tsup.config.ts b/packages/mindcache-server/tsup.config.ts index e46e0f4..079d95d 100644 --- a/packages/mindcache-server/tsup.config.ts +++ b/packages/mindcache-server/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ cli: 'src/cli.ts' }, format: ['cjs', 'esm'], - dts: false, + dts: true, splitting: false, sourcemap: true, clean: true,