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 TypeScript SDK
+
+A polished TypeScript client for fast, accurate, and explainable agent memory.
+
+
+
+
+
+
+
**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` });