* 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
258 lines
8.9 KiB
TypeScript
258 lines
8.9 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|