diff --git a/.mcp.json b/.mcp.json index 46558f39..426fa096 100644 --- a/.mcp.json +++ b/.mcp.json @@ -11,6 +11,8 @@ "PERSONAL_SUPERPOWERS_DIR", "XDG_CONFIG_HOME", "EPISODIC_MEMORY_DB_PATH", + "EPISODIC_MEMORY_MODEL_CACHE_DIR", + "EPISODIC_MEMORY_OFFLINE", "CONVERSATION_SEARCH_EXCLUDE_PROJECTS" ] } diff --git a/README.md b/README.md index ee952317..44afb619 100644 --- a/README.md +++ b/README.md @@ -301,12 +301,34 @@ Codex summarization requires `codex-cli 0.130.0` or newer. If Codex app-server s | Component | Uses custom config? | |-----------|---------------------| | Summarization | Yes (up to 10 calls/sync) | -| Embeddings | No (local Transformers.js) | +| Embeddings | Optional cache and offline settings (local Transformers.js) | | Search | No (local SQLite) | | MCP tools | No | Summaries are display-only: they decorate search results and are never embedded or searched, so `EPISODIC_MEMORY_SKIP_SUMMARIES=1` costs you that line of context and nothing else. +### Embedding model cache and offline mode + +Embedding computation is local, but a fresh installation may download its model +on first use. Model files are cached under the durable Episodic Memory +configuration directory in `models/`, outside npm-owned `node_modules`, so an +upgrade or dependency reinstall does not discard them. Set +`EPISODIC_MEMORY_MODEL_CACHE_DIR` to an absolute path to use a different or +pre-seeded cache. + +Set `EPISODIC_MEMORY_OFFLINE=1` to forbid remote model downloads. In that mode, +seed the model cache before indexing or semantic search; a missing model fails +with an explicit cache/offline error rather than silently fetching it. Other +values retain the normal first-install download behavior unless the embedding +host has already disabled remote loading. + +These settings use the native Transformers.js process-global configuration. +The durable cache directory, disabled browser cache, and explicit offline +restriction apply to all loaders sharing that module instance, not only this +model. Episodic Memory never re-enables host-disabled local or remote loading. +An embedding host must coordinate the shared cache and permissions; per-model +or concurrent configuration isolation is not provided. + ## Commands ### `episodic-memory sync` @@ -411,7 +433,7 @@ open output.html 1. **Sync** - Copies conversation files from Claude Code and Codex transcript directories to archive; exports opencode sessions from SQLite into generated JSONL transcripts 2. **Parse** - Extracts user-agent exchanges from Claude Code JSONL, Codex rollout JSONL, or opencode transcript JSONL -3. **Embed** - Generates vector embeddings using Transformers.js (local, offline) +3. **Embed** - Generates vectors locally with Transformers.js after the model is available 4. **Index** - Stores in SQLite with sqlite-vec for fast similarity search 5. **Search** - Semantic search using vector similarity or exact text matching @@ -519,6 +541,12 @@ npm test npm run build ``` +`npm test` prepares the embedding model once in the ignored +`tmp/test-model-cache` directory, then runs test workers without remote model +downloads. A fresh test run may download the public model; setting +`EPISODIC_MEMORY_OFFLINE=1` requires a pre-seeded test cache instead. This does +not change the production model cache or summarization routes. + ## License MIT diff --git a/dist/embeddings.js b/dist/embeddings.js index efc41dc4..2da4fdde 100644 --- a/dist/embeddings.js +++ b/dist/embeddings.js @@ -1,3 +1,4 @@ +import { getModelCacheDir } from './paths.js'; /** * Embedding model configuration. * @@ -65,6 +66,8 @@ export class EmbeddingsUnavailableError extends Error { export async function initEmbeddings() { if (embeddingPipeline) return; + const modelCacheDir = getModelCacheDir(); + const offline = process.env.EPISODIC_MEMORY_OFFLINE === '1'; // Load @huggingface/transformers lazily. Its module graph eagerly requires // `sharp`, so a static top-level import would crash *every* consumer of this // file at import time on hosts where sharp's native binding can't load — @@ -73,9 +76,11 @@ export async function initEmbeddings() { let pipeline; try { const transformers = await import('@huggingface/transformers'); - // Disable progress callbacks / remote cache to prevent stdout pollution in - // MCP context, where stdout is reserved for JSON-RPC communication. - transformers.env.allowLocalModels = true; + // Native settings apply to the shared Transformers.js module instance. + // Keep the durable cache and only restrict existing loading permissions. + transformers.env.cacheDir = modelCacheDir; + if (offline) + transformers.env.allowRemoteModels = false; transformers.env.useBrowserCache = false; pipeline = transformers.pipeline; } @@ -97,7 +102,15 @@ export async function initEmbeddings() { interOpNumThreads: 1, }; } - embeddingPipeline = await pipeline('feature-extraction', MODEL_ID, options); + try { + embeddingPipeline = await pipeline('feature-extraction', MODEL_ID, options); + } + catch (error) { + const message = offline + ? 'Embedding model unavailable in the local cache while EPISODIC_MEMORY_OFFLINE=1; pre-seed the model cache or disable offline mode.' + : 'Embedding model unavailable; check the model cache and remote model access.'; + throw new EmbeddingsUnavailableError(message, { cause: error }); + } console.error('Embedding model loaded'); } export async function generateEmbedding(text) { diff --git a/dist/mcp-server.js b/dist/mcp-server.js index e3a9adac..4c729c3c 100644 --- a/dist/mcp-server.js +++ b/dist/mcp-server.js @@ -29454,6 +29454,16 @@ function getSuperpowersDir() { } return ensureDir(dir); } +function getModelCacheDir() { + const override = process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR; + if (override) { + if (!path.isAbsolute(override)) { + throw new Error("EPISODIC_MEMORY_MODEL_CACHE_DIR must be an absolute path"); + } + return ensureDir(override); + } + return ensureDir(path.join(getSuperpowersDir(), "models")); +} function getIndexDir() { return ensureDir(path.join(getSuperpowersDir(), "conversation-index")); } @@ -29652,10 +29662,13 @@ var EmbeddingsUnavailableError = class extends Error { }; async function initEmbeddings() { if (embeddingPipeline) return; + const modelCacheDir = getModelCacheDir(); + const offline = process.env.EPISODIC_MEMORY_OFFLINE === "1"; let pipeline; try { const transformers = await import("@huggingface/transformers"); - transformers.env.allowLocalModels = true; + transformers.env.cacheDir = modelCacheDir; + if (offline) transformers.env.allowRemoteModels = false; transformers.env.useBrowserCache = false; pipeline = transformers.pipeline; } catch (error61) { @@ -29677,7 +29690,12 @@ async function initEmbeddings() { interOpNumThreads: 1 }; } - embeddingPipeline = await pipeline("feature-extraction", MODEL_ID, options); + try { + embeddingPipeline = await pipeline("feature-extraction", MODEL_ID, options); + } catch (error61) { + const message = offline ? "Embedding model unavailable in the local cache while EPISODIC_MEMORY_OFFLINE=1; pre-seed the model cache or disable offline mode." : "Embedding model unavailable; check the model cache and remote model access."; + throw new EmbeddingsUnavailableError(message, { cause: error61 }); + } console.error("Embedding model loaded"); } async function generateEmbedding(text) { diff --git a/dist/paths.d.ts b/dist/paths.d.ts index 6cee3813..2e83417f 100644 --- a/dist/paths.d.ts +++ b/dist/paths.d.ts @@ -82,6 +82,12 @@ export declare function getSuperpowersDir(): string; * Get conversation archive directory */ export declare function getArchiveDir(): string; +/** + * Keep downloaded embedding models outside npm-owned node_modules so plugin + * upgrades do not discard the cache. An explicit override supports shared or + * pre-seeded caches on hosts without model-network access. + */ +export declare function getModelCacheDir(): string; /** * Get conversation index directory */ diff --git a/dist/paths.js b/dist/paths.js index e80076b1..d4d1df09 100644 --- a/dist/paths.js +++ b/dist/paths.js @@ -190,6 +190,21 @@ export function getArchiveDir() { } return ensureDir(path.join(getSuperpowersDir(), 'conversation-archive')); } +/** + * Keep downloaded embedding models outside npm-owned node_modules so plugin + * upgrades do not discard the cache. An explicit override supports shared or + * pre-seeded caches on hosts without model-network access. + */ +export function getModelCacheDir() { + const override = process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR; + if (override) { + if (!path.isAbsolute(override)) { + throw new Error('EPISODIC_MEMORY_MODEL_CACHE_DIR must be an absolute path'); + } + return ensureDir(override); + } + return ensureDir(path.join(getSuperpowersDir(), 'models')); +} /** * Get conversation index directory */ diff --git a/dist/sync-cli.js b/dist/sync-cli.js index 26172c70..e444fd81 100644 --- a/dist/sync-cli.js +++ b/dist/sync-cli.js @@ -203,7 +203,7 @@ async function syncAll() { totals.summarized += result.summarized; totals.errors.push(...result.errors); } - console.log(`\n✅ Sync complete!`); + console.log(totals.errors.length > 0 ? '\n⚠️ Sync finished with errors.' : '\n✅ Sync complete!'); console.log(` Copied: ${totals.copied}`); console.log(` Skipped: ${totals.skipped}`); console.log(` Indexed: ${totals.indexed}`); diff --git a/package.json b/package.json index 58e050c9..1bf489e5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "build": "tsc && npm run bundle", "bundle": "esbuild src/mcp-server.ts --bundle --platform=node --format=esm --outfile=dist/mcp-server.js --external:fsevents --external:@anthropic-ai/claude-agent-sdk --external:sharp --external:onnxruntime-node --external:better-sqlite3 --external:@huggingface/transformers --external:sqlite-vec --external:proper-lockfile", "postinstall": "node scripts/postinstall.js", - "pretest": "npm run generate-version", + "pretest": "npm run generate-version && tsx scripts/prepare-test-model.ts", "test": "vitest run", "test:claude-e2e": "node scripts/claude-e2e.js", "test:codex-e2e": "node scripts/codex-e2e.js", @@ -58,7 +58,7 @@ "type": "module", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.126", - "@huggingface/transformers": "^4.2.0", + "@huggingface/transformers": "4.2.0", "@modelcontextprotocol/sdk": "^1.20.0", "better-sqlite3": "^12.4.1", "marked": "^16.4.0", diff --git a/scripts/prepare-test-model.ts b/scripts/prepare-test-model.ts new file mode 100644 index 00000000..d7c61ce2 --- /dev/null +++ b/scripts/prepare-test-model.ts @@ -0,0 +1,12 @@ +import { initEmbeddings } from '../src/embeddings.js'; +import { testModelCacheDir } from '../test/test-model-cache.js'; + +process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = testModelCacheDir; + +try { + await initEmbeddings(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Test embedding model preparation failed: ${message}`); + process.exitCode = 1; +} diff --git a/src/embeddings.ts b/src/embeddings.ts index 131f9e40..9a253df5 100644 --- a/src/embeddings.ts +++ b/src/embeddings.ts @@ -1,4 +1,5 @@ import type { FeatureExtractionPipeline } from '@huggingface/transformers'; +import { getModelCacheDir } from './paths.js'; /** * Embedding model configuration. @@ -70,6 +71,8 @@ export class EmbeddingsUnavailableError extends Error { export async function initEmbeddings(): Promise { if (embeddingPipeline) return; + const modelCacheDir = getModelCacheDir(); + const offline = process.env.EPISODIC_MEMORY_OFFLINE === '1'; // Load @huggingface/transformers lazily. Its module graph eagerly requires // `sharp`, so a static top-level import would crash *every* consumer of this @@ -79,9 +82,10 @@ export async function initEmbeddings(): Promise { let pipeline: typeof import('@huggingface/transformers').pipeline; try { const transformers = await import('@huggingface/transformers'); - // Disable progress callbacks / remote cache to prevent stdout pollution in - // MCP context, where stdout is reserved for JSON-RPC communication. - transformers.env.allowLocalModels = true; + // Native settings apply to the shared Transformers.js module instance. + // Keep the durable cache and only restrict existing loading permissions. + transformers.env.cacheDir = modelCacheDir; + if (offline) transformers.env.allowRemoteModels = false; transformers.env.useBrowserCache = false; pipeline = transformers.pipeline; } catch (error) { @@ -106,7 +110,14 @@ export async function initEmbeddings(): Promise { interOpNumThreads: 1, }; } - embeddingPipeline = await pipeline('feature-extraction', MODEL_ID, options); + try { + embeddingPipeline = await pipeline('feature-extraction', MODEL_ID, options); + } catch (error) { + const message = offline + ? 'Embedding model unavailable in the local cache while EPISODIC_MEMORY_OFFLINE=1; pre-seed the model cache or disable offline mode.' + : 'Embedding model unavailable; check the model cache and remote model access.'; + throw new EmbeddingsUnavailableError(message, { cause: error }); + } console.error('Embedding model loaded'); } diff --git a/src/paths.ts b/src/paths.ts index 4a08f2fe..8a02a510 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -204,6 +204,22 @@ export function getArchiveDir(): string { return ensureDir(path.join(getSuperpowersDir(), 'conversation-archive')); } +/** + * Keep downloaded embedding models outside npm-owned node_modules so plugin + * upgrades do not discard the cache. An explicit override supports shared or + * pre-seeded caches on hosts without model-network access. + */ +export function getModelCacheDir(): string { + const override = process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR; + if (override) { + if (!path.isAbsolute(override)) { + throw new Error('EPISODIC_MEMORY_MODEL_CACHE_DIR must be an absolute path'); + } + return ensureDir(override); + } + return ensureDir(path.join(getSuperpowersDir(), 'models')); +} + /** * Get conversation index directory */ diff --git a/src/sync-cli.ts b/src/sync-cli.ts index 297b20f8..bc629f20 100644 --- a/src/sync-cli.ts +++ b/src/sync-cli.ts @@ -224,7 +224,7 @@ async function syncAll() { totals.errors.push(...result.errors); } - console.log(`\n✅ Sync complete!`); + console.log(totals.errors.length > 0 ? '\n⚠️ Sync finished with errors.' : '\n✅ Sync complete!'); console.log(` Copied: ${totals.copied}`); console.log(` Skipped: ${totals.skipped}`); console.log(` Indexed: ${totals.indexed}`); diff --git a/test/codex-plugin.test.ts b/test/codex-plugin.test.ts index 8c6cd2a5..9de5df81 100644 --- a/test/codex-plugin.test.ts +++ b/test/codex-plugin.test.ts @@ -40,6 +40,8 @@ describe('Codex plugin packaging', () => { 'PERSONAL_SUPERPOWERS_DIR', 'XDG_CONFIG_HOME', 'EPISODIC_MEMORY_DB_PATH', + 'EPISODIC_MEMORY_MODEL_CACHE_DIR', + 'EPISODIC_MEMORY_OFFLINE', 'CONVERSATION_SEARCH_EXCLUDE_PROJECTS' ] } diff --git a/test/embeddings-offline.test.ts b/test/embeddings-offline.test.ts new file mode 100644 index 00000000..9a4ececb --- /dev/null +++ b/test/embeddings-offline.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { testModelCacheDir } from './test-model-cache.js'; + +describe('native embedding cache and host permissions', () => { + let root: string; + let previous: NodeJS.ProcessEnv; + let restore: () => void; + let attempts: number; + + beforeEach(async () => { + previous = { ...process.env }; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'episodic-native-cache-')); + process.env.EPISODIC_MEMORY_CONFIG_DIR = path.join(root, 'config'); + process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = path.join(root, 'models'); + process.env.EPISODIC_MEMORY_OFFLINE = '1'; + fs.cpSync(testModelCacheDir, process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR, { recursive: true }); + vi.resetModules(); + const { env } = await import('@huggingface/transformers'); + const original = { cacheDir: env.cacheDir, allowLocalModels: env.allowLocalModels, + allowRemoteModels: env.allowRemoteModels, useBrowserCache: env.useBrowserCache, + fetch: env.fetch }; + restore = () => Object.assign(env, original); + env.cacheDir = path.join(root, 'other-consumer-models'); + env.allowLocalModels = true; + env.allowRemoteModels = true; + attempts = 0; + env.fetch = async () => { attempts++; throw new Error('Unexpected model network access'); }; + }); + + afterEach(() => { + restore?.(); + process.env = previous; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['offline', 'host-disabled'] as const)('loads a seeded model with zero fetch attempts in %s mode', async mode => { + const { env } = await import('@huggingface/transformers'); + if (mode === 'host-disabled') { + delete process.env.EPISODIC_MEMORY_OFFLINE; + env.allowRemoteModels = false; + } + const { generateEmbedding } = await import('../src/embeddings.js'); + + const embedding = await generateEmbedding('A source-linked conversation remains searchable after an upgrade.'); + + expect(embedding).toHaveLength(384); + expect(embedding.every(Number.isFinite)).toBe(true); + expect(Math.hypot(...embedding)).toBeCloseTo(1); + expect(attempts).toBe(0); + expect(env.allowRemoteModels).toBe(false); + expect(env.cacheDir).toBe(process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR); + }); + + it.each(['empty', 'partial'] as const)('rejects an %s offline cache without attempting a model download', async state => { + if (state === 'empty') { + process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = path.join(root, 'empty-models'); + } else { + fs.unlinkSync(path.join(root, 'models', 'Xenova', 'bge-small-en-v1.5', 'onnx', 'model_quantized.onnx')); + } + const { initEmbeddings, EmbeddingsUnavailableError } = await import('../src/embeddings.js'); + + await expect(initEmbeddings()).rejects.toBeInstanceOf(EmbeddingsUnavailableError); + + expect(attempts).toBe(0); + }); + + it('preserves native refusal when local loading is host-disabled and remote loading is offline', async () => { + const { env } = await import('@huggingface/transformers'); + env.allowLocalModels = false; + const { initEmbeddings, EmbeddingsUnavailableError } = await import('../src/embeddings.js'); + + await expect(initEmbeddings()).rejects.toBeInstanceOf(EmbeddingsUnavailableError); + + expect(env.allowLocalModels).toBe(false); + expect(env.allowRemoteModels).toBe(false); + expect(attempts).toBe(0); + }); + + it.each(['empty', 'partial'] as const)('recovers an %s online cache through the native loader', async state => { + delete process.env.EPISODIC_MEMORY_OFFLINE; + const modelDir = path.join(root, 'models', 'Xenova', 'bge-small-en-v1.5'); + if (state === 'empty') { + fs.rmSync(modelDir, { recursive: true }); + } else { + fs.unlinkSync(path.join(modelDir, 'onnx', 'model_quantized.onnx')); + } + const { env } = await import('@huggingface/transformers'); + // Real tokenizer/model/cache loading, with a deterministic fixture transport. + // This is not a live Hub availability or cold-network-download assertion. + env.fetch = async (input, options) => { + attempts++; + const url = new URL(input instanceof Request ? input.url : input.toString()); + const filename = url.pathname.split('/resolve/main/')[1]; + const source = filename && path.join(testModelCacheDir, 'Xenova', 'bge-small-en-v1.5', filename); + if (!source || !fs.existsSync(source)) return new Response(null, { status: 404 }); + const data = fs.readFileSync(source); + if (new Headers(options?.headers).has('Range')) { + return new Response(data.subarray(0, 1), { + status: 206, + headers: { 'Content-Range': `bytes 0-0/${data.length}`, 'Content-Length': '1' }, + }); + } + return new Response(data, { headers: { 'Content-Length': String(data.length) } }); + }; + const { generateEmbedding } = await import('../src/embeddings.js'); + + const embedding = await generateEmbedding('Default downloads recover missing files without changing the model.'); + + expect(embedding).toHaveLength(384); + expect(Math.hypot(...embedding)).toBeCloseTo(1); + expect(attempts).toBeGreaterThan(0); + expect(env.allowRemoteModels).toBe(true); + expect(fs.readFileSync(path.join(modelDir, 'onnx', 'model_quantized.onnx')).equals( + fs.readFileSync(path.join(testModelCacheDir, 'Xenova', 'bge-small-en-v1.5', 'onnx', 'model_quantized.onnx')) + )).toBe(true); + }); +}); diff --git a/test/model-cache-policy.test.ts b/test/model-cache-policy.test.ts new file mode 100644 index 00000000..987da2d7 --- /dev/null +++ b/test/model-cache-policy.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('@huggingface/transformers', () => ({ + env: { cacheDir: 'package-cache', allowLocalModels: true, allowRemoteModels: true, useBrowserCache: true }, + pipeline: vi.fn(async () => async () => ({ data: new Float32Array(384) })), +})); + +describe('embedding model cache and network policy', () => { + let directory: string; + let previous: NodeJS.ProcessEnv; + + beforeEach(async () => { + previous = { ...process.env }; + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'episodic-model-policy-')); + process.env.EPISODIC_MEMORY_CONFIG_DIR = path.join(directory, 'config'); + delete process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR; + delete process.env.EPISODIC_MEMORY_OFFLINE; + vi.resetModules(); + const { env } = await import('@huggingface/transformers'); + Object.assign(env, { + cacheDir: 'package-cache', allowLocalModels: true, + allowRemoteModels: true, useBrowserCache: true, + }); + }); + + afterEach(() => { + process.env = previous; + fs.rmSync(directory, { recursive: true, force: true }); + }); + + async function loadPolicy() { + const transformers = await import('@huggingface/transformers'); + const { initEmbeddings } = await import('../src/embeddings.js'); + await initEmbeddings(); + return transformers.env; + } + + it('keeps the model cache in the durable configuration directory by default', async () => { + const env = await loadPolicy(); + expect(env.cacheDir).toBe(path.join(directory, 'config', 'models')); + expect(fs.statSync(env.cacheDir).isDirectory()).toBe(true); + }); + + it('accepts an explicit cache directory without a machine-specific default', async () => { + const modelCache = path.join(directory, 'shared-models'); + process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = modelCache; + const env = await loadPolicy(); + expect(env.cacheDir).toBe(modelCache); + }); + + it('rejects a relative cache override whose meaning depends on cwd', async () => { + process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = 'relative-model-cache'; + const { getModelCacheDir } = await import('../src/paths.js'); + expect(() => getModelCacheDir()).toThrow(/absolute path/); + }); + + it('blocks remote model loading only when offline mode is explicit', async () => { + process.env.EPISODIC_MEMORY_OFFLINE = '1'; + const env = await loadPolicy(); + expect(env.allowRemoteModels).toBe(false); + }); + + it('retains first-install model download behavior by default', async () => { + const env = await loadPolicy(); + expect(env.allowRemoteModels).toBe(true); + }); + + it.each([undefined, '0', '1'])('never re-enables host-disabled remote access with offline=%s', async offline => { + if (offline !== undefined) process.env.EPISODIC_MEMORY_OFFLINE = offline; + const { env } = await import('@huggingface/transformers'); + env.allowRemoteModels = false; + + await loadPolicy(); + + expect(env.allowRemoteModels).toBe(false); + }); + + it.each([undefined, '1'])('preserves host-disabled local access with offline=%s', async offline => { + if (offline !== undefined) process.env.EPISODIC_MEMORY_OFFLINE = offline; + const { env } = await import('@huggingface/transformers'); + env.allowLocalModels = false; + + await loadPolicy(); + + expect(env.allowLocalModels).toBe(false); + }); + + it('reports an unavailable pre-seeded model clearly in offline mode', async () => { + process.env.EPISODIC_MEMORY_OFFLINE = '1'; + const transformers = await import('@huggingface/transformers'); + vi.mocked(transformers.pipeline).mockRejectedValueOnce(new Error('model files not found')); + const { initEmbeddings, EmbeddingsUnavailableError } = await import('../src/embeddings.js'); + + let failure: unknown; + try { await initEmbeddings(); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(EmbeddingsUnavailableError); + expect((failure as Error).message).toMatch(/offline|cache/i); + }); +}); diff --git a/test/setup-isolated-config.ts b/test/setup-isolated-config.ts index 71b3e795..9394c438 100644 --- a/test/setup-isolated-config.ts +++ b/test/setup-isolated-config.ts @@ -1,6 +1,7 @@ import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { testModelCacheDir } from './test-model-cache.js'; /** * Isolate every test run from the developer's real ~/.config/superpowers. @@ -13,6 +14,9 @@ const isolatedRoot = mkdtempSync(join(tmpdir(), 'episodic-memory-vitest-')); process.env.EPISODIC_MEMORY_CONFIG_DIR = join(isolatedRoot, 'superpowers'); process.env.CLAUDE_CONFIG_DIR = join(isolatedRoot, 'claude'); process.env.CODEX_HOME = join(isolatedRoot, 'codex'); +process.env.OMP_HOME = join(isolatedRoot, 'omp'); +process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR = testModelCacheDir; +process.env.EPISODIC_MEMORY_OFFLINE = '1'; // Clear any ambient summarizer-billing signals so tests get deterministic // cost-guard (#104) and timeout (#160) behavior regardless of the developer's diff --git a/test/sync-cli-single-instance.test.ts b/test/sync-cli-single-instance.test.ts index b1505559..32010a76 100644 --- a/test/sync-cli-single-instance.test.ts +++ b/test/sync-cli-single-instance.test.ts @@ -70,6 +70,8 @@ describe('sync-cli single-instance lock (#97)', () => { TEST_ARCHIVE_DIR: join(testDir, 'archive'), TEST_DB_PATH: join(testDir, 'test.db'), EPISODIC_MEMORY_CONFIG_DIR: join(testDir, 'config'), + EPISODIC_MEMORY_OPENCODE_DB_PATH: join(testDir, 'opencode-missing.db'), + EPISODIC_MEMORY_OPENCODE_TRANSCRIPT_DIR: join(testDir, 'opencode-transcripts'), }; }); @@ -103,6 +105,24 @@ describe('sync-cli single-instance lock (#97)', () => { expect(result.stdout).toMatch(/Sync complete/); }); + it('does not print a green completion headline when the offline model is unavailable', () => { + const transcript = join(testDir, 'projects', 'project-a', '00000000-0000-0000-0000-000000000001.jsonl'); + writeFileSync(transcript, [ + JSON.stringify({ type: 'user', uuid: 'user-1', parentUuid: null, + timestamp: '2026-01-01T00:00:00Z', message: { role: 'user', content: 'What changed?' } }), + JSON.stringify({ type: 'assistant', uuid: 'assistant-1', parentUuid: 'user-1', + timestamp: '2026-01-01T00:00:01Z', message: { role: 'assistant', content: [{ type: 'text', text: 'The build changed.' }] } }) + ].join('\n')); + + const result = runWith({ ...envOverrides, + EPISODIC_MEMORY_MODEL_CACHE_DIR: join(testDir, 'empty-model-cache'), + EPISODIC_MEMORY_OFFLINE: '1', EPISODIC_MEMORY_SKIP_SUMMARIES: '1' }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Sync finished with errors'); + expect(result.stdout).not.toContain('✅ Sync complete!'); + expect(result.stdout).toContain('Indexed: 0'); + }); + it('releases the lock on normal exit — a subsequent run is not skipped', () => { const first = runWith(envOverrides); expect(first.status).toBe(0); diff --git a/test/test-model-cache.ts b/test/test-model-cache.ts new file mode 100644 index 00000000..a48f8c23 --- /dev/null +++ b/test/test-model-cache.ts @@ -0,0 +1,6 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const testModelCacheDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), '..', 'tmp', 'test-model-cache' +); diff --git a/test/test-runtime-isolation.test.ts b/test/test-runtime-isolation.test.ts new file mode 100644 index 00000000..1fc72625 --- /dev/null +++ b/test/test-runtime-isolation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +describe('test runtime isolation', () => { + it('uses a repository-owned model cache and disables worker downloads', () => { + expect(process.env.EPISODIC_MEMORY_MODEL_CACHE_DIR).toBe(path.join(projectRoot, 'tmp', 'test-model-cache')); + expect(process.env.EPISODIC_MEMORY_OFFLINE).toBe('1'); + }); + + it('does not discover ambient OMP sessions', () => { + const configDir = process.env.EPISODIC_MEMORY_CONFIG_DIR; + expect(configDir).toBeDefined(); + expect(process.env.OMP_HOME).toBe(path.join(path.dirname(configDir!), 'omp')); + }); +});