diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..59443bd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Package validation +on: + pull_request: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [18, 24] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm ci + - run: npm test + - run: npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2f9a4..85148ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the CueMap TypeScript SDK will be documented in this file. +## [0.7.3] - 2026-08-27 + +### Changed +- Synchronized the SDK patch release and documentation with CueMap Engine v0.7.3. +- Documented compatibility with the engine's Tree-sitter-backed Swift, Dart, Objective-C, and Kotlin ingestion support. +- Changed the default direct-client and embedded-engine port from `8080` to `8735`. + +### Added +- Added project lifecycle methods plus portable project `pack`, `load`, `push`, and `pull`; project listings expose the engine's `loaded` state. +- Added typed `syncProject()` support for fast-forward S3 project history. + ## [0.7.2] - 2026-07-18 ### Added diff --git a/README.md b/README.md index 45a1a91..2738749 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,23 @@ -# CueMap TypeScript SDK +

+ CueMap +

+ +

CueMap TypeScript SDK

+ +

A polished TypeScript client for fast, accurate, and explainable agent memory.

+ +

+ npm + npm downloads + TypeScript + License +

**High-performance temporal-associative memory store** designed for dynamic contextual retrieval. ## Overview -CueMap implements a **Continuous Gradient Algorithm** optimized for associative data structures: +CueMap uses **temporal-associative retrieval**: lexical and structural candidate generation, with optional semantic reranking. Its main components are: 1. **Intersection (Context Filter)**: Triangulates relevant memories by overlapping cues 2. **Local Semantic and Intent Reranking**: Uses bundled qint8 MiniLM-L3 by default, or q4 MiniLM-L3 with the edge profile. @@ -12,9 +25,9 @@ CueMap implements a **Continuous Gradient Algorithm** optimized for associative 4. **Reinforcement (Access-based Learning)**: Frequently accessed memories gain signal strength, remaining highly accessible even as they age. 5. **Deterministic Facets & Intent Routing**: Extracts synchronous source, evidence, temporal, type, and entity facets, then uses sparse intent cues and reranking during recall. -As of v0.7.2, CueMap keeps deterministic lexical candidate discovery and adds bundled qint8 `all-MiniLM-L3-v2` for bounded hybrid semantic and intent reranking. The `edge` engine profile uses a q4 build of the same model. No runtime model download is required, and callers can disable the encoder or provide their own vectors. +As of v0.7.3, CueMap keeps deterministic lexical candidate discovery and adds bundled qint8 `paraphrase-MiniLM-L3-v2` for bounded hybrid semantic and intent reranking. The `edge` engine profile uses a q4 build of the same model. No runtime model download is required, and callers can disable the encoder or provide their own vectors. -v0.7.2 also uses numeric per-project memory IDs everywhere. If callers need deterministic upsert/dedupe identity, pass `source_key`; memory IDs remain compact runtime addresses. +v0.7.3 also uses numeric per-project memory IDs everywhere. If callers need deterministic upsert/dedupe identity, pass `source_key`; memory IDs remain compact runtime addresses. Use this SDK to talk to the Rust engine from TypeScript and JavaScript applications. @@ -29,7 +42,7 @@ npm install cuemap ### 1. Start the Engine ```bash -docker run -p 8080:8080 cuemap/engine:latest +docker run -p 8735:8735 cuemap/engine:latest ``` ### 2. Basic Usage @@ -82,9 +95,9 @@ console.log(response.results[0].explain); // Shows normalized cues, intent cues, and reranking details. ``` -### v0.7.2 Recall Controls +### v0.7.3 Recall Controls -CueMap v0.7.2 adds local semantic query signals alongside temporal query intent and the optional reconstruction passes for longer conversational/codebase context. +CueMap v0.7.3 adds local semantic query signals alongside temporal query intent and the optional reconstruction passes for longer conversational/codebase context. ```typescript const response = await client.recall({ @@ -114,6 +127,27 @@ console.log(response.proof); // Cryptographic proof of context retrieval ``` +### Project memory lifecycle + +The engine can unload inactive project contexts while keeping their snapshots +on disk. Normal project operations demand-load a project when needed, so the +first request after an unload may take longer. Use the explicit helpers when +you want to control residency: + +```typescript +await client.unloadProject("older-repository"); +await client.loadProject("older-repository"); +await client.saveProject("older-repository"); // persist without unloading + +for (const project of await client.listProjects()) { + console.log(project.project_id, project.loaded); +} +``` + +Portable projects use the same four operations as the CLI: `packProject()`, +`loadProjectPackage()`, `pushProject()`, and `pullProject()`. +Use `syncProject(projectId, "s3://bucket/team")` for conflict-safe fast-forward sync. + For a controlled semantic comparison, use `semantic_mode: "lexical"`. Use `"semantic"` for vector candidate discovery or `"hybrid"` (the engine default) to rerank lexical candidates with the configured local encoder. `query_embedding` can supply a precomputed vector when the application owns the embedding provider. Classify query or memory intent with the same local model. Returned scores are ranking signals, not calibrated probabilities: @@ -209,3 +243,15 @@ console.log(`Intent ready: ${status.intent_ready ?? false}`); ## License MIT + +### Recall previews + +The engine's `POST /recall` accepts `response_mode: "preview"` and optional +`preview_chars` (100–2000 UTF-16 code units, default 200). Full content remains +the default. Previews replace each hit's `content` with a leading `preview`, +`content_truncated`, and `content_length`, preserving metadata and ranking. +Use previews for broad discovery, then fetch a selected memory with +`GET /memories/{id}?decoded=true` or read its source. Metadata and diagnostics +are not capped. TypeScript request objects and Python sync/async `recall` +accept these same options; Python returns `RecallPreviewResult` for ungrouped +preview results. The updated engine is required. diff --git a/package-lock.json b/package-lock.json index 579cbe7..b48fd97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cuemap", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cuemap", - "version": "0.7.2", + "version": "0.7.3", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/package.json b/package.json index ce26fe9..71c3ddf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cuemap", - "version": "0.7.2", + "version": "0.7.3", "description": "CueMap TypeScript SDK - High-performance temporal-associative memory", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -17,8 +17,8 @@ "scripts": { "build": "tsc", "prepublishOnly": "npm run build", - "test": "npm run build && node --test test/*.test.js", - "test:e2e": "CUEMAP_E2E=1 node --test test/*.test.js", + "test": "npm run build && node scripts/run-tests.cjs", + "test:e2e": "npm run build && node scripts/run-e2e.cjs", "test:pack": "node scripts/verify-packed-install.mjs" }, "keywords": [ diff --git a/scripts/run-e2e.cjs b/scripts/run-e2e.cjs new file mode 100644 index 0000000..e04d32e --- /dev/null +++ b/scripts/run-e2e.cjs @@ -0,0 +1,6 @@ +const { spawnSync } = require('node:child_process'); +const { readdirSync } = require('node:fs'); +const files = readdirSync('test').filter(name => /\.test\.(c?js)$/.test(name)).map(name => `test/${name}`); +const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', env: { ...process.env, CUEMAP_E2E: '1' } }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs new file mode 100644 index 0000000..2dfc19a --- /dev/null +++ b/scripts/run-tests.cjs @@ -0,0 +1,21 @@ +const { spawnSync } = require('node:child_process'); +const { readdirSync } = require('node:fs'); + +const files = readdirSync('test') + .filter((name) => /\.test\.js$/.test(name)) + .sort() + .map((name) => `test/${name}`); + +if (files.length === 0) { + throw new Error('No test files found in test/'); +} + +const result = spawnSync(process.execPath, ['--test', ...files], { + stdio: 'inherit', +}); + +if (result.error) { + throw result.error; +} + +process.exitCode = result.status ?? 1; diff --git a/scripts/verify-packed-install.mjs b/scripts/verify-packed-install.mjs index b35df74..1a82b63 100644 --- a/scripts/verify-packed-install.mjs +++ b/scripts/verify-packed-install.mjs @@ -9,13 +9,13 @@ const sandbox = mkdtempSync(join(tmpdir(), "cuemap-ts-pack-") ); try { execFileSync("npm", ["pack", "--pack-destination", sandbox], { cwd: packageRoot, stdio: "inherit" }); - const tarball = join(sandbox, "cuemap-0.7.2.tgz"); + const tarball = join(sandbox, "cuemap-0.7.3.tgz"); execFileSync("npm", ["init", "-y"], { cwd: sandbox, stdio: "ignore" }); execFileSync("npm", ["install", "--ignore-scripts", "--no-save", tarball], { cwd: sandbox, stdio: "inherit" }); const probe = [ "const fs = require('node:fs'); const path = require('node:path');", "const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(require.resolve('cuemap')), '..', 'package.json')));", - "if (pkg.version !== '0.7.2') throw new Error('unexpected package version');", + "if (pkg.version !== '0.7.3') throw new Error('unexpected package version');", "const sdk = require('cuemap');", "if (typeof sdk.default !== 'function') throw new Error('default SDK export missing');", "const embedded = require('cuemap/embedded');", diff --git a/src/embedded.ts b/src/embedded.ts index 5b4f0af..8c58dc5 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -1,9 +1,11 @@ import { ChildProcess, spawn } from 'node:child_process'; import { createRequire } from 'node:module'; import { createServer, get as httpGet } from 'node:http'; +import { get as httpsGet } from 'node:https'; import { closeSync, existsSync, mkdirSync, openSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { delimiter, dirname, extname, join, resolve } from 'node:path'; +import CueMap from './index'; export interface EmbeddedCueMapOptions { /** Attach to an already-running engine instead of starting one. */ @@ -30,7 +32,7 @@ export interface EmbeddedCueMapConnection { } const requireFromHere = createRequire(__filename); -const DEFAULT_PORT = 8080; +const DEFAULT_PORT = 8735; const DEFAULT_STARTUP_TIMEOUT_MS = 15_000; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; @@ -46,6 +48,21 @@ function sleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(false); + }, timeoutMs); + child.once('exit', onExit); + }); +} + function normalizeUrl(url: string): string { return url.replace(/\/+$/, ''); } @@ -57,7 +74,8 @@ interface EngineInspection { async function inspectEngine(url: string, apiKey?: string): Promise { return await new Promise((resolve) => { - const request = httpGet( + const get = new URL(url).protocol === 'https:' ? httpsGet : httpGet; + const request = get( `${normalizeUrl(url)}/`, { headers: apiKey ? { 'X-API-Key': apiKey } : undefined }, (response) => { @@ -70,7 +88,7 @@ async function inspectEngine(url: string, apiKey?: string): Promise typeof item === 'string') : [], @@ -133,7 +151,7 @@ export function resolveCueMapBinary(explicitPath?: string): string { const manifest = requireFromHere.resolve(`${packageName}/package.json`); const packageBin = join(dirname(manifest), 'bin'); const candidates = process.platform === 'win32' - ? [join(packageBin, 'cuemap'), join(packageBin, 'cuemap.exe')] + ? [join(packageBin, 'cuemap-native.exe'), join(packageBin, 'cuemap')] : [join(packageBin, 'cuemap')]; const binaryPath = candidates.find((candidate) => existsSync(candidate)); if (binaryPath) return binaryPath; @@ -141,6 +159,15 @@ export function resolveCueMapBinary(explicitPath?: string): string { // The platform package is optional; PATH remains a valid installation mode. } + if (process.platform === 'win32') { + for (const directory of (process.env.PATH || '').split(delimiter)) { + const native = join(directory, 'node_modules', packageName, 'bin', 'cuemap-native.exe'); + if (existsSync(native)) return native; + const executable = join(directory, 'cuemap.exe'); + if (existsSync(executable)) return executable; + } + } + const binaryName = process.platform === 'win32' ? 'cuemap.exe' : 'cuemap'; const workspaceRoot = resolve(__dirname, '..', '..'); const sourceBinaries = ['release', 'debug'] @@ -155,14 +182,17 @@ export function resolveCueMapBinary(explicitPath?: string): string { export class EmbeddedCueMap { private process?: ChildProcess; private readonly shutdownTimeoutMs: number; + private readonly apiKey?: string; private constructor( public readonly connection: EmbeddedCueMapConnection, shutdownTimeoutMs: number, - process?: ChildProcess + process?: ChildProcess, + apiKey?: string ) { this.process = process; this.shutdownTimeoutMs = shutdownTimeoutMs; + this.apiKey = apiKey; } get url(): string { @@ -201,6 +231,9 @@ export class EmbeddedCueMap { const port = preferredInspection.status === 'occupied' ? await findFreePort() : preferredPort; const url = `http://127.0.0.1:${port}`; const executable = resolveCueMapBinary(options.binPath); + if (process.platform === 'win32' && ['.cmd', '.bat', '.ps1'].includes(extname(executable).toLowerCase())) { + throw new Error('Use the native .exe or the npm package bin/cuemap wrapper, not a shell shim'); + } const args = ['start', '--port', String(port)]; if (options.configPath) args.push('--config', options.configPath); @@ -221,11 +254,23 @@ export class EmbeddedCueMap { } } + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...options.env, CUEMAP_PORT: String(port), + CUEMAP_HOST: '127.0.0.1', ...(options.apiKey ? { CUEMAP_API_KEY: options.apiKey } : {}) }; + const tokenizer = join(dirname(dirname(executable)), 'assets', 'en_tokenizer.bin'); + if (process.platform === 'win32' && existsSync(tokenizer)) childEnv.TOKENIZER_PATH ??= tokenizer; let child: ChildProcess; try { - child = spawn(executable, args, { + // The Windows native package exposes a shebang Node wrapper next to the + // .exe. Windows cannot spawn that wrapper directly, so invoke it via the + // current Node runtime. A real .exe or PATH-resolved binary stays direct. + const runThroughNode = process.platform === 'win32' + && existsSync(executable) + && extname(executable).toLowerCase() !== '.exe'; + const spawnExecutable = runThroughNode ? process.execPath : executable; + const spawnArgs = runThroughNode ? [executable, ...args] : args; + child = spawn(spawnExecutable, spawnArgs, { stdio, - env: { ...process.env, ...options.env, CUEMAP_PORT: String(port) }, + env: childEnv, }); } finally { if (logFileDescriptor !== undefined) closeSync(logFileDescriptor); @@ -250,7 +295,7 @@ export class EmbeddedCueMap { throw error; } logger(`CueMap is ready at ${url}`); - return new EmbeddedCueMap({ url, owned: true }, shutdownTimeoutMs, child); + return new EmbeddedCueMap({ url, owned: true }, shutdownTimeoutMs, child, options.apiKey); } await sleep(100); } @@ -259,17 +304,38 @@ export class EmbeddedCueMap { throw new Error(`CueMap did not become ready within ${startupTimeoutMs}ms`); } - async stop(): Promise { + async stop(options: { force?: boolean } = {}): Promise { const child = this.process; - this.process = undefined; - if (!child || child.exitCode !== null || child.signalCode !== null) return; + if (!child || child.exitCode !== null || child.signalCode !== null) { + this.process = undefined; + return; + } + + // On Windows, Node terminates children abruptly for SIGTERM and SIGINT. + // Persist loaded projects before the engine loses its shutdown-save chance. + if (process.platform === 'win32' && !options.force) { + const client = new CueMap({ url: this.url, apiKey: this.apiKey, timeout: this.shutdownTimeoutMs }); + const projects = await client.listProjects(); + for (const project of projects) { + if (project.loaded) await client.saveProject(project.project_id); + } + } + if (child.exitCode !== null || child.signalCode !== null) { + this.process = undefined; + return; + } + + const exited = waitForExit(child, this.shutdownTimeoutMs); + this.process = undefined; child.kill('SIGTERM'); - const exited = new Promise((resolve) => child.once('exit', () => resolve())); - const timedOut = sleep(this.shutdownTimeoutMs).then(() => 'timeout' as const); - if (await Promise.race([exited.then(() => 'exited' as const), timedOut]) === 'timeout') { + if (!(await exited)) { + const forcedExit = waitForExit(child, 1_000); child.kill('SIGKILL'); - await exited; + if (!(await forcedExit)) { + child.unref(); + throw new Error('CueMap did not exit after forced shutdown'); + } } } } diff --git a/src/index.ts b/src/index.ts index ad32f2a..022b15d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,46 @@ export interface Memory { stats?: Record; } +export interface ProjectInfo { + project_id: string; + total_memories: number; + total_cues: number; + created_at: number; + last_activity: number; + loaded: boolean; +} + +export interface ProjectLifecycleResponse { + status: 'loaded' | 'unloaded' | 'already_unloaded'; + project_id: string; + loaded: boolean; + total_memories?: number; +} + +export interface ProjectSaveResponse { + status: 'saved'; + project_id: string; +} + +export interface ProjectPackageResponse { + status: 'loaded' | 'pushed' | 'pulled'; + project_id: string; + file_count: number; + size_bytes: number; + loaded?: boolean; + destination?: string; + source?: string; +} + +export interface ProjectSyncResponse { + action: 'pushed' | 'pulled' | 'up_to_date' | 'adopted'; + project_id: string; + remote: string; + generation: number; + commit_sha256: string; + package_sha256: string; +} + export interface RecallResult { memory_id: MemoryId; content: string; @@ -59,6 +99,13 @@ export interface RecallResult { explain?: Record; } +export interface RecallPreviewResult extends Omit { + preview: string; + content_truncated: boolean; + /** Original content length in UTF-16 code units. */ + content_length: number; +} + export interface AddMemoryRequest { content: string; cues?: string[]; @@ -86,6 +133,10 @@ export interface AddMemoryOptions { } export interface RecallRequest { + /** Engine response shaping; full content by default. */ + response_mode?: "full" | "preview"; + /** Leading excerpt cap in UTF-16 code units (100–2000, default 200). */ + preview_chars?: number; cues?: string[]; query_text?: string; query_time?: string; @@ -166,15 +217,15 @@ export class CueMap { private timeout: number; constructor(config: CueMapConfig = {}) { - this.url = config.url || 'http://localhost:8080'; + this.url = config.url || 'http://localhost:8735'; this.apiKey = config.apiKey; this.projectId = config.projectId; this.timeout = config.timeout || 30000; } - private getHeaders(): Record { + private getHeaders(contentType: string = 'application/json'): Record { const headers: Record = { - 'Content-Type': 'application/json', + 'Content-Type': contentType, }; if (this.apiKey) { @@ -208,14 +259,29 @@ export class CueMap { path: string, body?: any ): Promise { + const response = await this.requestRaw( + method, + path, + body === undefined ? undefined : JSON.stringify(body), + 'application/json' + ); + return await response.json() as T; + } + + private async requestRaw( + method: string, + path: string, + body?: BodyInit, + contentType: string = 'application/json' + ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(`${this.url}${path}`, { method, - headers: this.getHeaders(), - body: body ? JSON.stringify(body) : undefined, + headers: this.getHeaders(contentType), + body, signal: controller.signal as any, }); @@ -228,7 +294,7 @@ export class CueMap { throw new CueMapError(`Request failed: ${response.status}`); } - return await response.json() as T; + return response; } catch (error) { clearTimeout(timeoutId); if (error instanceof CueMapError) { @@ -371,8 +437,8 @@ export class CueMap { /** * List all projects (multi-tenant only) */ - async listProjects(): Promise { - return await this.request('GET', '/projects'); + async listProjects(): Promise { + return await this.request('GET', '/projects'); } /** @@ -382,6 +448,92 @@ export class CueMap { return await this.request('POST', '/projects', { project_id: projectId }); } + /** + * Load a project's persisted snapshot into engine memory. + */ + async loadProject(projectId: string): Promise { + return await this.request( + 'POST', + `/projects/${encodeURIComponent(projectId)}/load` + ); + } + + /** + * Persist a current project snapshot without unloading it. + */ + async saveProject(projectId: string): Promise { + return await this.request( + 'POST', + `/projects/${encodeURIComponent(projectId)}/save` + ); + } + + /** + * Persist and unload a project from engine memory. + */ + async unloadProject(projectId: string): Promise { + return await this.request( + 'POST', + `/projects/${encodeURIComponent(projectId)}/unload` + ); + } + + /** + * Return a ready-to-query project as portable `.cuemap` bytes. + */ + async packProject(projectId: string): Promise { + const response = await this.requestRaw( + 'POST', + `/projects/${encodeURIComponent(projectId)}/pack` + ); + return new Uint8Array(await response.arrayBuffer()); + } + + /** + * Install and warm a portable `.cuemap` package. + */ + async loadProjectPackage(packageData: Blob | ArrayBuffer | Uint8Array): Promise { + const body: BodyInit = packageData instanceof Uint8Array + ? Uint8Array.from(packageData).buffer + : packageData; + const response = await this.requestRaw( + 'POST', + '/projects/load', + body, + 'application/vnd.cuemap.project' + ); + return await response.json() as ProjectPackageResponse; + } + + /** + * Pack and upload a project using the server's configured AWS CLI. + */ + async pushProject(projectId: string, destination: string): Promise { + return await this.request( + 'POST', + `/projects/${encodeURIComponent(projectId)}/push`, + { destination } + ); + } + + /** + * Download, install, and warm a project using the server's configured AWS CLI. + */ + async pullProject(source: string): Promise { + return await this.request('POST', '/projects/pull', { source }); + } + + /** + * Fast-forward a project through its immutable S3 sync history. + */ + async syncProject(projectId: string, remote: string): Promise { + return await this.request( + 'POST', + `/projects/${encodeURIComponent(projectId)}/sync`, + { remote } + ); + } + /** * Set the watch directory for a project */ diff --git a/test/client.test.js b/test/client.test.js index 0ccaa31..5627c1c 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -66,7 +66,78 @@ test('repository scope preview and apply preserve selected paths', async (contex assert.equal(requests[2].method, 'GET'); }); -test('recall sends v0.7.2 semantic controls', async (context) => { +test('project lifecycle methods use the load and unload routes', async (context) => { + const originalFetch = global.fetch; + context.after(() => { + global.fetch = originalFetch; + }); + + const requests = []; + global.fetch = async (url, options) => { + requests.push({ url, method: options.method }); + return { + ok: true, + json: async () => ({ + status: String(url).endsWith('/load') + ? 'loaded' + : String(url).endsWith('/save') ? 'saved' : 'unloaded', + loaded: String(url).endsWith('/load'), + }), + }; + }; + + const client = new CueMap({ projectId: 'lifecycle-test' }); + assert.equal((await client.loadProject('repo/one')).loaded, true); + assert.equal((await client.saveProject('repo/one')).status, 'saved'); + assert.equal((await client.unloadProject('repo/one')).loaded, false); + + assert.match(requests[0].url, /\/projects\/repo%2Fone\/load$/); + assert.match(requests[1].url, /\/projects\/repo%2Fone\/save$/); + assert.match(requests[2].url, /\/projects\/repo%2Fone\/unload$/); + assert.deepEqual(requests.map((request) => request.method), ['POST', 'POST', 'POST']); +}); + +test('project package methods use the matching engine routes', async (context) => { + const originalFetch = global.fetch; + context.after(() => { + global.fetch = originalFetch; + }); + + const requests = []; + global.fetch = async (url, options) => { + requests.push({ url, method: options.method, body: options.body, headers: options.headers }); + if (String(url).endsWith('/pack')) { + return { + ok: true, + arrayBuffer: async () => Uint8Array.from([67, 85, 69]).buffer, + }; + } + return { + ok: true, + json: async () => ({ status: 'loaded', project_id: 'repo-package', file_count: 1, size_bytes: 3 }), + }; + }; + + const client = new CueMap({ projectId: 'package-test' }); + const packageData = await client.packProject('repo/package'); + assert.deepEqual([...packageData], [67, 85, 69]); + await client.loadProjectPackage(packageData); + await client.pushProject('repo/package', 's3://bucket/team/'); + await client.pullProject('s3://bucket/team/repo-package.cuemap'); + await client.syncProject('repo/package', 's3://bucket/team-sync'); + + assert.match(requests[0].url, /\/projects\/repo%2Fpackage\/pack$/); + assert.match(requests[1].url, /\/projects\/load$/); + assert.equal(requests[1].headers['Content-Type'], 'application/vnd.cuemap.project'); + assert.match(requests[2].url, /\/projects\/repo%2Fpackage\/push$/); + assert.deepEqual(JSON.parse(requests[2].body), { destination: 's3://bucket/team/' }); + assert.match(requests[3].url, /\/projects\/pull$/); + assert.deepEqual(JSON.parse(requests[3].body), { source: 's3://bucket/team/repo-package.cuemap' }); + assert.match(requests[4].url, /\/projects\/repo%2Fpackage\/sync$/); + assert.deepEqual(JSON.parse(requests[4].body), { remote: 's3://bucket/team-sync' }); +}); + +test('recall sends v0.7.3 semantic controls', async (context) => { const originalFetch = global.fetch; context.after(() => { global.fetch = originalFetch; @@ -119,3 +190,17 @@ test('intent classification and chunk embeddings match the engine schema', async assert.deepEqual(requests[0].body, { text: 'What did we decide?', target: 'query' }); assert.deepEqual(requests[1].body.embeddings, [[0.1, 0.2], [0.3, 0.4]]); }); + +test('recall forwards engine preview options and preserves excerpts', async (context) => { + const previous = global.fetch; + context.after(() => { global.fetch = previous; }); + const response = { response_mode: 'preview', results: [{ memory_id: 1, preview: 'excerpt', content_truncated: true, content_length: 900 }] }; + global.fetch = async (_url, options) => { + const payload = JSON.parse(options.body); + assert.equal(payload.response_mode, 'preview'); + assert.equal(payload.preview_chars, 100); + return { ok: true, json: async () => response }; + }; + const client = new CueMap({ projectId: 'preview-test' }); + assert.deepEqual(await client.recall({ query_text: 'discovery', response_mode: 'preview', preview_chars: 100 }), response); +}); diff --git a/test/embedded.test.js b/test/embedded.test.js index b74c15b..3a493fc 100644 --- a/test/embedded.test.js +++ b/test/embedded.test.js @@ -11,10 +11,19 @@ const engines = []; const temporaryDirectories = []; afterEach(async () => { - await Promise.all(engines.splice(0).map((engine) => engine.stop())); - await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); + try { + await Promise.all(engines.splice(0).map(async (engine) => { + try { + await engine.stop(); + } finally { + await engine.stop({ force: true }); + } + })); + } finally { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } } }); @@ -46,7 +55,7 @@ async function waitFor(predicate, timeoutMs = 5_000) { } test('attaches to an existing CueMap engine without claiming ownership', async () => { - const url = await listen({ name: 'CueMap Rust Engine', version: '0.7.2' }); + const url = await listen({ name: 'CueMap Rust Engine', version: '0.7.3' }); const engine = await EmbeddedCueMap.start({ url }); assert.equal(engine.url, url); @@ -57,7 +66,7 @@ test('attaches to an existing CueMap engine without claiming ownership', async ( test('attaches when an existing engine advertises every required capability', async () => { const url = await listen({ name: 'CueMap Rust Engine', - version: '0.7.2', + version: '0.7.3', capabilities: ['repository_ingestion_scope_v1'], }); const engine = await EmbeddedCueMap.start({ @@ -71,7 +80,7 @@ test('attaches when an existing engine advertises every required capability', as }); test('rejects an existing CueMap engine missing a required capability', async () => { - const url = await listen({ name: 'CueMap Rust Engine', version: '0.7.2' }); + const url = await listen({ name: 'CueMap Rust Engine', version: '0.7.3' }); await assert.rejects( EmbeddedCueMap.start({ url, @@ -87,7 +96,6 @@ test('rejects an external URL that is not CueMap', async () => { }); test('starts an owned engine and appends its stdout and stderr to the configured log', { - skip: process.platform === 'win32', }, async () => { const directory = mkdtempSync(join(tmpdir(), 'cuemap-embedded-')); temporaryDirectories.push(directory); @@ -99,9 +107,12 @@ const portIndex = process.argv.indexOf('--port'); const port = Number(process.argv[portIndex + 1]); process.stdout.write('fake snapshot stdout\\n'); process.stderr.write('fake snapshot stderr\\n'); -const server = createServer((_request, response) => { +if (process.env.CUEMAP_API_KEY !== 'test-secret' || process.env.CUEMAP_HOST !== '127.0.0.1') process.exit(42); +const server = createServer((request, response) => { response.setHeader('content-type', 'application/json'); - response.end(JSON.stringify({ name: 'CueMap Rust Engine', capabilities: [] })); + response.end(JSON.stringify(request.url === '/projects' + ? [] + : { name: 'CueMap Rust Engine', capabilities: [] })); }); server.listen(port, '127.0.0.1'); for (const signal of ['SIGINT', 'SIGTERM']) { @@ -112,9 +123,14 @@ for (const signal of ['SIGINT', 'SIGTERM']) { const engine = await EmbeddedCueMap.start({ binPath: executable, + apiKey: 'test-secret', + env: { CUEMAP_HOST: '0.0.0.0' }, port: await freePort(), logPath, - startupTimeoutMs: 5_000, + startupTimeoutMs: 15_000, + }).catch(error => { + error.message += `\nEngine log: ${existsSync(logPath) ? readFileSync(logPath, 'utf8') : 'missing'}`; + throw error; }); engines.push(engine); @@ -123,4 +139,118 @@ for (const signal of ['SIGINT', 'SIGTERM']) { const log = readFileSync(logPath, 'utf8'); assert.match(log, /fake snapshot stdout/); assert.match(log, /fake snapshot stderr/); + + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await engine.stop(); + } finally { + Object.defineProperty(process, 'platform', platform); + } +}); + +test('saves loaded projects before terminating an owned Windows engine', async () => { + const directory = mkdtempSync(join(tmpdir(), 'cuemap-windows-stop-')); + temporaryDirectories.push(directory); + const executable = join(directory, 'fake-cuemap'); + const snapshot = join(directory, 'saved-snapshot'); + writeFileSync(executable, `#!/usr/bin/env node +const { createServer } = require('node:http'); +const { writeFileSync } = require('node:fs'); +const port = Number(process.argv[process.argv.indexOf('--port') + 1]); +let saveAttempts = 0; +const server = createServer((request, response) => { + response.setHeader('content-type', 'application/json'); + if (request.url === '/') { + response.end(JSON.stringify({ name: 'CueMap Rust Engine' })); + } else if (request.headers['x-api-key'] !== 'test-secret') { + response.writeHead(401).end('{}'); + } else if (request.url === '/projects') { + response.end(JSON.stringify([ + { project_id: 'repo/one', loaded: true }, + { project_id: 'repo/unused', loaded: false }, + ])); + } else if (request.url === '/projects/repo%2Fone/save') { + if (++saveAttempts === 1) response.writeHead(500).end('{}'); + else { + writeFileSync(${JSON.stringify(snapshot)}, 'saved'); + response.end(JSON.stringify({ status: 'saved', project_id: 'repo/one' })); + } + } else { + response.writeHead(500).end('{}'); + } +}); +server.listen(port, '127.0.0.1'); +process.once('SIGTERM', () => server.close(() => process.exit(0))); +`); + chmodSync(executable, 0o755); + + const engine = await EmbeddedCueMap.start({ + binPath: executable, + apiKey: 'test-secret', + port: await freePort(), + logPath: false, + }); + engines.push(engine); + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await assert.rejects(engine.stop(), /Request failed: 500/); + assert.equal(existsSync(snapshot), false); + await engine.stop(); + } finally { + Object.defineProperty(process, 'platform', platform); + } + assert.equal(readFileSync(snapshot, 'utf8'), 'saved'); +}); + +test('HTTPS attachment uses the TLS transport', async (context) => { + const https = require('node:https'); + const { EventEmitter } = require('node:events'); + let requestedUrl; + context.mock.method(https, 'get', (url, options, callback) => { + requestedUrl = url; + assert.equal(options.headers['X-API-Key'], 'tls-test-key'); + const request = new EventEmitter(); + request.setTimeout = () => request; + process.nextTick(() => { + const response = new EventEmitter(); + response.setEncoding = () => {}; + response.statusCode = 200; + callback(response); + response.emit('data', JSON.stringify({ name: 'CueMap Rust Engine' })); + response.emit('end'); + }); + return request; + }); + const engine = await EmbeddedCueMap.start({ url: 'https://localhost:8735', apiKey: 'tls-test-key' }); + assert.equal(requestedUrl, 'https://localhost:8735/'); + assert.equal(engine.owned, false); +}); + +test('Windows global npm installation resolves its native executable', () => { + const { mkdirSync } = require('node:fs'); + const { resolveCueMapBinary } = require('../dist/embedded.js'); + const directory = mkdtempSync(join(tmpdir(), 'cuemap-windows-npm-')); + temporaryDirectories.push(directory); + const packageBin = join(directory, 'node_modules', '@cuemap-dev', 'engine-win32-x64', 'bin'); + mkdirSync(packageBin, { recursive: true }); + const native = join(packageBin, 'cuemap-native.exe'); + writeFileSync(native, 'test fixture'); + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + const arch = Object.getOwnPropertyDescriptor(process, 'arch'); + const oldPath = process.env.PATH; + const oldBin = process.env.CUEMAP_BIN; + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + process.env.PATH = directory; + delete process.env.CUEMAP_BIN; + assert.equal(resolveCueMapBinary(), native); + } finally { + Object.defineProperty(process, 'platform', platform); + Object.defineProperty(process, 'arch', arch); + if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; + if (oldBin === undefined) delete process.env.CUEMAP_BIN; else process.env.CUEMAP_BIN = oldBin; + } }); diff --git a/test/engine.integration.test.js b/test/engine.integration.test.js index 75208fd..67cc9c2 100644 --- a/test/engine.integration.test.js +++ b/test/engine.integration.test.js @@ -22,18 +22,29 @@ async function freePort() { test('runs the SDK contract against a real release engine', { skip: process.env.CUEMAP_E2E !== '1', + timeout: 90_000, }, async (context) => { const dataDir = mkdtempSync(join(tmpdir(), 'cuemap-ts-e2e-data-')); const port = await freePort(); const binPath = process.env.CUEMAP_E2E_BIN || resolve(__dirname, '../../rust_engine/target/release/cuemap'); const projectId = `ts-e2e-${process.pid}`; let engine; + const progress = (stage) => process.stderr.write(`[cuemap-ts-e2e] ${stage}\n`); context.after(async () => { - await engine?.stop(); - rmSync(dataDir, { recursive: true, force: true }); + progress('tearing down engine'); + try { + await engine?.stop(); + } finally { + try { + await engine?.stop({ force: true }); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + } }); + progress('starting engine'); engine = await EmbeddedCueMap.start({ binPath, port, @@ -47,6 +58,7 @@ test('runs the SDK contract against a real release engine', { }); const client = new CueMap({ url: engine.url, projectId }); + progress('adding and recalling memory'); const memoryId = await client.add( 'On 2026-08-18 we chose Postgres for the billing migration.', ['billing', 'postgres', 'decision'], @@ -72,7 +84,9 @@ test('runs the SDK contract against a real release engine', { }); assert.ok(exported.memories.some((item) => String(item.id) === String(memoryId))); + progress('saving and stopping engine'); await engine.stop(); + progress('restarting engine'); engine = await EmbeddedCueMap.start({ binPath, port, @@ -89,6 +103,7 @@ test('runs the SDK contract against a real release engine', { semantic_mode: 'lexical', limit: 5, }); + progress('checking restored memory'); assert.ok(restored.results.some((item) => String(item.memory_id) === String(memoryId))); const isolated = new CueMap({ url: engine.url, projectId: `${projectId}-other` });