* 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
155 lines
6 KiB
TypeScript
155 lines
6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdtempSync, symlinkSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { pathToFileURL } from 'url';
|
|
import type { BankStats, PluginConfig } from './types.js';
|
|
import type { BackfillCheckpoint, BackfillPlanEntry } from './backfill-lib.js';
|
|
|
|
const managerStart = vi.fn();
|
|
const managerStop = vi.fn();
|
|
const managerGetBaseUrl = vi.fn(() => 'http://127.0.0.1:9077');
|
|
|
|
vi.mock('@vectorize-io/hindsight-all', async () => {
|
|
const actual = await vi.importActual<typeof import('@vectorize-io/hindsight-all')>('@vectorize-io/hindsight-all');
|
|
return {
|
|
...actual,
|
|
HindsightServer: vi.fn(class {
|
|
start = managerStart;
|
|
stop = managerStop;
|
|
getBaseUrl = managerGetBaseUrl;
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
managerStart.mockReset();
|
|
managerStop.mockReset();
|
|
managerGetBaseUrl.mockClear();
|
|
});
|
|
|
|
function makeEntry(bankId: string, sessionId: string): BackfillPlanEntry {
|
|
return {
|
|
filePath: `/tmp/${sessionId}.jsonl`,
|
|
agentId: bankId,
|
|
sessionId,
|
|
bankId,
|
|
documentId: `backfill::${bankId}::${sessionId}`,
|
|
transcript: '[role: user]\nhello\n[user:end]',
|
|
messageCount: 1,
|
|
};
|
|
}
|
|
|
|
function makeStats(overrides: Partial<BankStats> = {}): BankStats {
|
|
return {
|
|
bank_id: 'bank',
|
|
total_nodes: 0,
|
|
total_links: 0,
|
|
total_documents: 0,
|
|
pending_operations: 0,
|
|
failed_operations: 0,
|
|
pending_consolidation: 0,
|
|
last_consolidated_at: null,
|
|
total_observations: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('backfill helpers', () => {
|
|
it('resume skips only completed entries', async () => {
|
|
const { filterEntriesForResume, splitResumeEntries } = await import('./backfill.js');
|
|
const entries = [makeEntry('bank-a', '1'), makeEntry('bank-a', '2'), makeEntry('bank-a', '3')];
|
|
const checkpoint: BackfillCheckpoint = {
|
|
version: 1,
|
|
entries: {
|
|
'bank-a::backfill::bank-a::1': { status: 'completed', bankId: 'bank-a', filePath: '/tmp/1', sessionId: '1', updatedAt: 'now' },
|
|
'bank-a::backfill::bank-a::2': { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/2', sessionId: '2', updatedAt: 'now' },
|
|
'bank-a::backfill::bank-a::3': { status: 'failed', bankId: 'bank-a', filePath: '/tmp/3', sessionId: '3', updatedAt: 'now' },
|
|
},
|
|
};
|
|
const resumable = filterEntriesForResume(entries, checkpoint, true);
|
|
expect(resumable.map((entry) => entry.sessionId)).toEqual(['2', '3']);
|
|
expect(splitResumeEntries(resumable, checkpoint, false).entriesToEnqueue.map((entry) => entry.sessionId)).toEqual(['2', '3']);
|
|
expect(splitResumeEntries(resumable, checkpoint, true)).toEqual({
|
|
entriesToEnqueue: [entries[2]],
|
|
alreadyEnqueuedKeys: ['bank-a::backfill::bank-a::2'],
|
|
});
|
|
});
|
|
|
|
it('normalizes legacy queued checkpoint entries', async () => {
|
|
const { loadCheckpoint } = await import('./backfill-lib.js');
|
|
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-'));
|
|
const checkpointPath = join(dir, 'checkpoint.json');
|
|
writeFileSync(checkpointPath, JSON.stringify({
|
|
version: 1,
|
|
entries: {
|
|
legacy: { status: 'queued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
|
|
},
|
|
}), 'utf8');
|
|
|
|
const checkpoint = loadCheckpoint(checkpointPath);
|
|
expect(checkpoint.entries.legacy.status).toBe('enqueued');
|
|
});
|
|
|
|
it('marks drained entries completed and leaves aggregate-failure banks enqueued', async () => {
|
|
const { applyDrainResults } = await import('./backfill.js');
|
|
const checkpoint: BackfillCheckpoint = {
|
|
version: 1,
|
|
entries: {
|
|
a: { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
|
|
b: { status: 'enqueued', bankId: 'bank-b', filePath: '/tmp/b', sessionId: 'b', updatedAt: 'now' },
|
|
},
|
|
};
|
|
const touchedEntriesByBank = new Map([
|
|
['bank-a', ['a']],
|
|
['bank-b', ['b']],
|
|
]);
|
|
const finalStatsByBank = new Map<string, BankStats>([
|
|
['bank-a', makeStats({ bank_id: 'bank-a', pending_operations: 0, failed_operations: 0 })],
|
|
['bank-b', makeStats({ bank_id: 'bank-b', pending_operations: 0, failed_operations: 2 })],
|
|
]);
|
|
const initialFailedByBank = new Map([
|
|
['bank-a', 0],
|
|
['bank-b', 0],
|
|
]);
|
|
|
|
const result = applyDrainResults(checkpoint, touchedEntriesByBank, finalStatsByBank, initialFailedByBank);
|
|
expect(result.completed).toBe(1);
|
|
expect(result.unresolved).toBe(1);
|
|
expect(result.warnings).toEqual([
|
|
'bank bank-b reported 2 new failed operations during drain; leaving 1 checkpoint entries enqueued',
|
|
]);
|
|
expect(checkpoint.entries.a.status).toBe('completed');
|
|
expect(checkpoint.entries.b.status).toBe('enqueued');
|
|
});
|
|
|
|
it('starts local daemon when no external API is configured and health check fails', async () => {
|
|
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const { createBackfillRuntime } = await import('./backfill.js');
|
|
const pluginConfig: PluginConfig = {
|
|
apiPort: 9077,
|
|
llmProvider: 'openai-codex',
|
|
llmModel: 'gpt-5.4',
|
|
};
|
|
const runtime = await createBackfillRuntime(pluginConfig);
|
|
expect(managerStart).toHaveBeenCalledTimes(1);
|
|
expect(runtime.apiUrl).toBe('http://127.0.0.1:9077');
|
|
await runtime.stop();
|
|
expect(managerStop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('treats a symlinked bin path as direct execution', async () => {
|
|
const { isDirectExecution } = await import('./backfill.js');
|
|
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-bin-'));
|
|
const modulePath = join(process.cwd(), 'dist', 'backfill.js');
|
|
const symlinkPath = join(dir, 'hindsight-openclaw-backfill');
|
|
symlinkSync(modulePath, symlinkPath);
|
|
|
|
const moduleUrl = pathToFileURL(modulePath).href;
|
|
expect(isDirectExecution(symlinkPath, moduleUrl)).toBe(true);
|
|
expect(isDirectExecution(join(dir, 'other-entrypoint'), moduleUrl)).toBe(false);
|
|
});
|
|
});
|