fleet-memory/hindsight-integrations/openclaw/tests/integration.test.ts
Nicolò Boschi 576016f5dc
feat: add @vectorize-io/hindsight-all daemon lifecycle package (#949)
* feat: add @vectorize-io/hindsight-embed daemon lifecycle package

Create a new top-level `hindsight-embed-npm/` package that owns the daemon
lifecycle for the Python `hindsight-embed` CLI: spawning via `uvx`, writing
the profile, waiting for `/health`, and shutting down. Nothing more.

Deliberately does not ship an HTTP client — `@vectorize-io/hindsight-client`
already covers retain / recall / reflect / createBank against the Hindsight
API, and the two packages compose: once `manager.start()` returns, consumers
talk to the daemon via `new HindsightClient({ baseUrl: manager.getBaseUrl() })`.

`HindsightEmbedManagerOptions.env` forwards an arbitrary `Record<string,
string>` to both the daemon process and the profile config via `--env K=V`,
and `extraProfileCreateArgs` / `extraDaemonStartArgs` escape hatches cover
any new CLI flag without waiting for a wrapper release.

Refactor `hindsight-integrations/openclaw` to consume both packages:
`HindsightEmbedManager` for daemon lifecycle in local mode, `HindsightClient`
for all HTTP memory operations. Drop the bespoke subprocess/HTTP client that
used to live in openclaw. The retain queue stays local to openclaw (it's a
client-side reliability workaround with a single consumer today — will move
to the client package or server-side when a second consumer needs it).

Wire the new package into the main release pipeline (versioned alongside
the other core packages, published from `v*` tags) and add a CI build job.

* docs: add Embedded Node.js SDK page for @vectorize-io/hindsight-embed

* refactor: rename hindsight-embed-npm to hindsight-all, restructure docs sidebar

The Node package previously named @vectorize-io/hindsight-embed was
semantically misnamed: hindsight-embed (Python) is a CLI tool, while what
this Node package actually provides is the Node equivalent of hindsight-all
— a programmatic lifecycle manager for a local Hindsight daemon. Rename to
match.

Package rename
  - hindsight-embed-npm/ → hindsight-all-npm/ (git mv, history preserved)
  - @vectorize-io/hindsight-embed → @vectorize-io/hindsight-all
  - class HindsightEmbedManager → HindsightServer (matches Python hindsight-all)
  - HindsightEmbedManagerOptions → HindsightServerOptions
  - src/manager.ts → src/server.ts, src/manager.test.ts → src/server.test.ts
  - openclaw (index.ts, backfill.ts, tests) and the claude-code Python port
    updated to reference the new names

Docs restructure
  - Split sdks/python.md: now client-only content. New sdks/hindsight-all.md
    covers the programmatic hindsight-all Python package (HindsightServer and
    HindsightEmbedded).
  - Rename sdks/embed-npm.md → sdks/hindsight-all-npm.md with HindsightServer
    examples.
  - New "Installation" sidebar section, placed after Hosting, containing
    Docker / Kubernetes / Bare Metal (anchor links into developer/installation)
    plus Programmatic API (Python), Programmatic API (Node.js), and Daemon CLI.
  - Add si-docker, si-kubernetes, si-nodedotjs, lu-hard-drive to the sidebar
    ICON_MAP.

Docs dev-server fix
  - docusaurus.config.ts: drop the flaky NODE_ENV sniff for including the
    "Next" version. Use INCLUDE_CURRENT_VERSION exclusively. NODE_ENV was
    unreliable across hot-reload paths and caused the Next version to
    disappear intermittently when editing files.
  - scripts/dev/start-docs.sh: export INCLUDE_CURRENT_VERSION=true so local
    dev always shows Next; production builds leave it unset.

Lockfile cleanup
  - package-lock.json and hindsight-integrations/openclaw/package-lock.json
    had extraneous hindsight-embed-npm blocks left over from the rename.
    Removed manually and verified with npm install.

* ci: fix openclaw jobs by pre-building workspace deps; regenerate docs-skill

The build-openclaw-integration and test-openclaw-integration jobs failed
with "Failed to resolve entry for package @vectorize-io/hindsight-all"
because openclaw depends on two monorepo workspaces via `file:` deps
(@vectorize-io/hindsight-client and @vectorize-io/hindsight-all) whose
`dist/` directories are gitignored and never built before openclaw's npm ci.
Both jobs now install the root workspace and build the two deps first,
mirroring the release-control-plane pattern.

Also regenerate skills/hindsight-docs/references/* via
./scripts/generate-docs-skill.sh:
  - new skill pages for sdks/hindsight-all{.md,-npm.md}
  - updated skill pages for sdks/embed.md and sdks/python.md to match
    the new H1s and split content
  - incidental refreshes to changelog/index.md, developer/models.md,
    openapi.json, and uv.lock that verify-generated-files picked up

* ci: build openclaw before running tests so symlink test can realpath dist
2026-04-10 15:51:44 +02:00

258 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration tests for the Hindsight OpenClaw integration.
*
* Exercises both HTTP mode (direct API calls) and Embed mode (local daemon
* spawned via HindsightServer), talking to Hindsight through
* `@vectorize-io/hindsight-client`.
*
* Requirements:
* HTTP mode: Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
* Embed mode: hindsight-embed package at HINDSIGHT_EMBED_PACKAGE_PATH
* + LLM credentials (HINDSIGHT_API_LLM_PROVIDER / HINDSIGHT_API_LLM_API_KEY)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HindsightServer } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ---------------------------------------------------------------------------
// Test configuration (driven by environment variables)
// ---------------------------------------------------------------------------
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || '';
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || '';
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || '';
// Embed package path defaults to the sibling hindsight-embed directory in the repo
const EMBED_PACKAGE_PATH =
process.env.HINDSIGHT_EMBED_PACKAGE_PATH ||
join(__dirname, '..', '..', '..', 'hindsight-embed');
// Port for the test embed daemon (different from production default 9077 to avoid conflicts)
const EMBED_TEST_PORT = 19077;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomBankId(): string {
return `openclaw_test_${Math.random().toString(36).slice(2, 14)}`;
}
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
// ---------------------------------------------------------------------------
// HTTP Mode Tests
// ---------------------------------------------------------------------------
describe('openclaw integration — HTTP mode', () => {
let client: HindsightClient;
beforeAll(async () => {
const reachable = await waitForApi(HINDSIGHT_API_URL);
if (!reachable) {
throw new Error(
`Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` +
'Start the server before running integration tests.',
);
}
client = new HindsightClient({ baseUrl: HINDSIGHT_API_URL });
});
it('should retain a conversation', async () => {
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' +
'[role: assistant]\nNice to meet you, Alice!\n[assistant:end]',
{
documentId: 'http-retain-test-1',
metadata: { channel_type: 'slack', sender_id: 'U001' },
async: true,
},
);
expect(response).toBeDefined();
});
it('should retain with auto-generated document id', async () => {
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nI work at TechCorp as a software engineer.\n[user:end]',
{ async: true },
);
expect(response).toBeDefined();
});
it('should recall from an empty bank without error', async () => {
const bankId = randomBankId();
const response = await client.recall(bankId, 'What do I like?', { maxTokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should set bank mission via createBank after retain creates the bank', async () => {
const bankId = randomBankId();
await client.retain(bankId, '[role: user]\nHello\n[user:end]', { async: true });
await expect(
client.createBank(bankId, { reflectMission: 'You are a helpful AI assistant.' }),
).resolves.toBeDefined();
});
it('should retain and then recall relevant memories', async () => {
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' +
'[role: assistant]\nPython is a great choice!\n[assistant:end]',
{ documentId: `session-${Date.now()}`, async: true },
);
const response = await client.recall(bankId, 'What programming language do I like?', {
maxTokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should use custom maxTokens in recall request', async () => {
const bankId = randomBankId();
const response = await client.recall(bankId, 'anything', { maxTokens: 256 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should map recall results to the RecallResult shape', async () => {
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' +
'[role: assistant]\nSounds like a great hobby!\n[assistant:end]',
{ documentId: 'mapping-test', async: true },
);
const response = await client.recall(bankId, 'What are my hobbies?', { maxTokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
}
});
});
// ---------------------------------------------------------------------------
// Embed Mode Tests (local daemon spawned by HindsightServer)
// ---------------------------------------------------------------------------
describe('openclaw integration — embed mode', () => {
let client: HindsightClient;
let server: HindsightServer;
const hasEmbedCredentials = Boolean(LLM_PROVIDER && LLM_API_KEY);
beforeAll(async () => {
if (!hasEmbedCredentials) {
console.warn(
'[Integration] Skipping embed mode tests: ' +
'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.',
);
return;
}
server = new HindsightServer({
profile: 'openclaw-test',
port: EMBED_TEST_PORT,
embedVersion: 'latest',
embedPackagePath: EMBED_PACKAGE_PATH,
env: {
HINDSIGHT_API_LLM_PROVIDER: LLM_PROVIDER,
HINDSIGHT_API_LLM_API_KEY: LLM_API_KEY,
HINDSIGHT_API_LLM_MODEL: LLM_MODEL || undefined,
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: '0',
},
});
await server.start();
client = new HindsightClient({ baseUrl: server.getBaseUrl() });
}, 120_000); // daemon startup can take up to 2 minutes
afterAll(async () => {
if (server) {
await server.stop();
}
}, 30_000);
it('should retain a conversation against the local daemon', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
const response = await client.retain(
bankId,
'[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' +
'[role: assistant]\nSounds adventurous!\n[assistant:end]',
{ documentId: 'embed-retain-test-1', async: true },
);
expect(response).toBeDefined();
}, 60_000);
it('should recall from an empty bank against the local daemon', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
const response = await client.recall(bankId, 'What do I like?', { maxTokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should set bank mission against the local daemon', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
// Create bank by retaining first, then set mission
await client.retain(bankId, '[role: user]\nHello\n[user:end]', { async: true });
await expect(
client.createBank(bankId, { reflectMission: 'Test mission for embed integration tests.' }),
).resolves.toBeDefined();
}, 60_000);
it('should retain and recall against the local daemon', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
await client.retain(
bankId,
'[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' +
'[role: assistant]\nWhat a lovely name!\n[assistant:end]',
{ documentId: `embed-e2e-${Date.now()}`, async: true },
);
const response = await client.recall(bankId, "What is my cat's name?", { maxTokens: 1024 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
});