docs: document memory host runtime helpers

This commit is contained in:
Peter Steinberger
2026-06-04 01:34:57 -04:00
parent eecec7495f
commit 5b98f03c64
17 changed files with 108 additions and 0 deletions

View File

@@ -1,6 +1,9 @@
import { createLocalEmbeddingProviderInProcess } from "./embeddings.js";
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
// Child process entrypoint for local embedding work.
/** Request payloads accepted from the parent worker client. */
type LocalEmbeddingWorkerRequest =
| {
id: number;
@@ -24,6 +27,7 @@ type LocalEmbeddingWorkerRequest =
type: "close";
};
/** Serialized error shape returned over JSON IPC. */
type LocalEmbeddingWorkerSerializedError = {
message: string;
code?: string;
@@ -33,12 +37,14 @@ let provider: EmbeddingProvider | null = null;
let providerOptionsKey: string | null = null;
let requestQueue: Promise<void> = Promise.resolve();
/** Send one JSON IPC message when the child still has an IPC channel. */
function send(message: unknown): void {
if (typeof process.send === "function") {
process.send(message);
}
}
/** Reuse the current provider while options are unchanged, otherwise rebuild it. */
async function getProvider(options: EmbeddingProviderOptions): Promise<EmbeddingProvider> {
const key = JSON.stringify(options);
if (provider && providerOptionsKey === key) {
@@ -50,6 +56,7 @@ async function getProvider(options: EmbeddingProviderOptions): Promise<Embedding
return provider;
}
/** Close and forget the active in-process provider. */
async function closeProvider(): Promise<void> {
const current = provider;
provider = null;
@@ -57,6 +64,7 @@ async function closeProvider(): Promise<void> {
await current?.close?.();
}
/** Preserve error message and code across JSON IPC. */
function serializeError(err: unknown): LocalEmbeddingWorkerSerializedError {
if (!(err instanceof Error)) {
return { message: String(err) };
@@ -68,6 +76,7 @@ function serializeError(err: unknown): LocalEmbeddingWorkerSerializedError {
};
}
/** Handle one parent request after queue serialization. */
async function handleRequest(request: LocalEmbeddingWorkerRequest): Promise<void> {
if (request.type === "close") {
await closeProvider();
@@ -90,6 +99,7 @@ async function handleRequest(request: LocalEmbeddingWorkerRequest): Promise<void
send({ id: request.id, ok: true, value });
}
// Requests are serialized so node-llama-cpp context state is not used concurrently.
process.on("message", (message) => {
const request = message as LocalEmbeddingWorkerRequest;
requestQueue = requestQueue.then(async () => {
@@ -101,6 +111,7 @@ process.on("message", (message) => {
});
});
// Parent disconnect means the worker is orphaned; close provider resources before exiting.
process.once("disconnect", () => {
void closeProvider().finally(() => {
process.exit(0);

View File

@@ -14,6 +14,9 @@ import type {
} from "./embeddings.types.js";
import { normalizeOptionalString } from "./string-utils.js";
// Parent-side local embedding worker client for isolating node-llama-cpp state.
/** Request payloads sent from the parent process to the local embedding worker child. */
type LocalEmbeddingWorkerRequestPayload =
| {
type: "initialize";
@@ -35,6 +38,7 @@ type LocalEmbeddingWorkerRequestPayload =
type LocalEmbeddingWorkerRequest = LocalEmbeddingWorkerRequestPayload & { id: number };
/** Response payloads sent from the local embedding worker child back to the parent. */
type LocalEmbeddingWorkerResponse =
| {
id: number;
@@ -52,12 +56,14 @@ type LocalEmbeddingWorkerResponse =
};
};
/** Pending parent request plus abort cleanup. */
type PendingRequest = {
resolve: (value: number[] | number[][] | undefined) => void;
reject: (err: unknown) => void;
abort?: () => void;
};
/** Resolve the worker child script for source, package, and bundled runtime layouts. */
function resolveDefaultWorkerScriptPath(): string {
const currentPath = fileURLToPath(import.meta.url);
const extension = path.extname(currentPath);
@@ -71,6 +77,7 @@ function resolveDefaultWorkerScriptPath(): string {
return path.join(path.dirname(currentPath), sibling);
}
/** Keep only local embedding options that are safe and necessary to send over IPC. */
function serializeLocalEmbeddingOptions(
options: EmbeddingProviderOptions,
): EmbeddingProviderOptions {
@@ -83,6 +90,7 @@ function serializeLocalEmbeddingOptions(
};
}
/** Create a typed failure for unexpected worker process exits. */
function createWorkerExitError(code: number | null, signal: NodeJS.Signals | null): Error {
const detail = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
return createLocalEmbeddingWorkerFailureError({
@@ -94,6 +102,7 @@ function createWorkerExitError(code: number | null, signal: NodeJS.Signals | nul
});
}
/** Convert worker response errors into Error objects while preserving worker error codes. */
function createWorkerResponseError(error: LocalEmbeddingWorkerResponse & { ok: false }): Error {
if (typeof error.error === "object" && error.error) {
const message = error.error.message || "Local embedding worker failed";
@@ -128,6 +137,7 @@ const WORKER_UNSAFE_EXEC_ARGV_OPTION_PREFIXES = [
const WORKER_CLOSE_GRACE_MS = 250;
/** Drop execArgv flags that would make forked workers debug/eval stateful or unsafe. */
function resolveWorkerExecArgv(): string[] {
const args: string[] = [];
let skipNext = false;
@@ -151,6 +161,7 @@ function resolveWorkerExecArgv(): string[] {
return args;
}
/** IPC client that serializes local embedding calls through one child process. */
class LocalEmbeddingWorkerClient {
private child: ChildProcess | null = null;
private nextRequestId = 1;
@@ -158,10 +169,12 @@ class LocalEmbeddingWorkerClient {
constructor(private readonly scriptPath: string) {}
/** Start or reuse the child worker and initialize its provider. */
async initialize(options: EmbeddingProviderOptions): Promise<void> {
await this.send({ type: "initialize", options });
}
/** Request one query embedding from the child worker. */
async embedQuery(
options: EmbeddingProviderOptions,
text: string,
@@ -171,6 +184,7 @@ class LocalEmbeddingWorkerClient {
return Array.isArray(result) ? (result as number[]) : [];
}
/** Request a batch of embeddings from the child worker. */
async embedBatch(
options: EmbeddingProviderOptions,
texts: string[],
@@ -180,6 +194,7 @@ class LocalEmbeddingWorkerClient {
return Array.isArray(result) ? (result as number[][]) : [];
}
/** Ask the child to close gracefully, then force shutdown after a short grace period. */
async close(): Promise<void> {
const child = this.child;
if (!child) {
@@ -204,6 +219,7 @@ class LocalEmbeddingWorkerClient {
}
}
/** Ensure the child process exists and has lifecycle failure handlers installed. */
private ensureChild(): ChildProcess {
if (this.child?.connected) {
return this.child;
@@ -238,6 +254,7 @@ class LocalEmbeddingWorkerClient {
return child;
}
/** Send one request over IPC and bind its abort signal to child shutdown. */
private async send(
request: LocalEmbeddingWorkerRequestPayload,
options?: EmbeddingProviderCallOptions,
@@ -280,6 +297,7 @@ class LocalEmbeddingWorkerClient {
});
}
/** Route one worker response to the matching pending request. */
private handleMessage(message: unknown): void {
const response = message as Partial<LocalEmbeddingWorkerResponse>;
if (typeof response.id !== "number") {
@@ -300,6 +318,7 @@ class LocalEmbeddingWorkerClient {
);
}
/** Disconnect and kill the current child process if it is still alive. */
private shutdownChild(): void {
const child = this.child;
this.child = null;
@@ -314,6 +333,7 @@ class LocalEmbeddingWorkerClient {
}
}
/** Reject all pending requests after child process failure. */
private rejectPending(err: unknown): void {
const pending = [...this.pending.values()];
this.pending.clear();
@@ -324,6 +344,7 @@ class LocalEmbeddingWorkerClient {
}
}
/** Create the public local embedding provider backed by the child worker client. */
export async function createLocalEmbeddingWorkerProvider(
options: EmbeddingProviderOptions,
runtimeOptions?: LocalEmbeddingProviderRuntimeOptions,
@@ -368,6 +389,7 @@ export async function createLocalEmbeddingWorkerProvider(
};
}
/** Convert abort reasons or arbitrary thrown values into lint-safe Error objects. */
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;

View File

@@ -1,6 +1,9 @@
import type { DatabaseSync } from "node:sqlite";
import { formatErrorMessage } from "./error-utils.js";
// SQLite schema setup for builtin memory index, embedding cache, and FTS.
/** Ensure memory index tables and optional FTS/cache tables exist. */
export function ensureMemoryIndexSchema(params: {
db: DatabaseSync;
embeddingCacheTable: string;
@@ -89,6 +92,7 @@ export function ensureMemoryIndexSchema(params: {
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
}
/** Add a missing shipped column without rebuilding existing memory tables. */
function ensureColumn(
db: DatabaseSync,
table: "files" | "chunks",

View File

@@ -1,5 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
// Multimodal memory settings and file classification helpers.
const MEMORY_MULTIMODAL_SPECS = {
image: {
labelPrefix: "Image file",
@@ -11,20 +13,26 @@ const MEMORY_MULTIMODAL_SPECS = {
},
} as const;
/** Supported multimodal memory modality. */
export type MemoryMultimodalModality = keyof typeof MEMORY_MULTIMODAL_SPECS;
/** All supported multimodal memory modalities in stable config order. */
export const MEMORY_MULTIMODAL_MODALITIES = Object.keys(
MEMORY_MULTIMODAL_SPECS,
) as MemoryMultimodalModality[];
/** User selection for one modality or all modalities. */
export type MemoryMultimodalSelection = MemoryMultimodalModality | "all";
/** Normalized multimodal memory ingestion settings. */
export type MemoryMultimodalSettings = {
enabled: boolean;
modalities: MemoryMultimodalModality[];
maxFileBytes: number;
};
/** Default max bytes for one multimodal memory file. */
export const DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES = 10 * 1024 * 1024;
/** Normalize user modality selections to supported modalities. */
export function normalizeMemoryMultimodalModalities(
raw: MemoryMultimodalSelection[] | undefined,
): MemoryMultimodalModality[] {
@@ -40,6 +48,7 @@ export function normalizeMemoryMultimodalModalities(
return Array.from(normalized);
}
/** Normalize user multimodal settings, including disabled-state empty modality list. */
export function normalizeMemoryMultimodalSettings(raw: {
enabled?: boolean;
modalities?: MemoryMultimodalSelection[];
@@ -57,16 +66,19 @@ export function normalizeMemoryMultimodalSettings(raw: {
};
}
/** Return true when multimodal memory ingestion has at least one enabled modality. */
export function isMemoryMultimodalEnabled(settings: MemoryMultimodalSettings): boolean {
return settings.enabled && settings.modalities.length > 0;
}
/** Return accepted file extensions for a modality. */
export function getMemoryMultimodalExtensions(
modality: MemoryMultimodalModality,
): readonly string[] {
return MEMORY_MULTIMODAL_SPECS[modality].extensions;
}
/** Build the text label that accompanies embedded multimodal file content. */
export function buildMemoryMultimodalLabel(
modality: MemoryMultimodalModality,
normalizedPath: string,
@@ -74,6 +86,7 @@ export function buildMemoryMultimodalLabel(
return `${MEMORY_MULTIMODAL_SPECS[modality].labelPrefix}: ${normalizedPath}`;
}
/** Build a glob that matches an extension case-insensitively for QMD sources. */
export function buildCaseInsensitiveExtensionGlob(extension: string): string {
const normalized = normalizeLowercaseStringOrEmpty(extension).replace(/^\./, "");
if (!normalized) {
@@ -83,6 +96,7 @@ export function buildCaseInsensitiveExtensionGlob(extension: string): string {
return `*.${parts.join("")}`;
}
/** Classify a file path into a supported multimodal modality under current settings. */
export function classifyMemoryMultimodalPath(
filePath: string,
settings: MemoryMultimodalSettings,

View File

@@ -1,12 +1,17 @@
// Minimal node-llama-cpp type facade used by the local embedding provider.
/** Embedding vector returned by node-llama-cpp. */
export type LlamaEmbedding = {
vector: Float32Array | number[];
};
/** Embedding context created from a loaded llama model. */
export type LlamaEmbeddingContext = {
getEmbeddingFor: (text: string) => Promise<LlamaEmbedding>;
dispose?: () => Promise<void> | void;
};
/** Loaded llama model capable of creating embedding contexts. */
export type LlamaModel = {
createEmbeddingContext: (options?: {
contextSize?: number | "auto";
@@ -15,16 +20,19 @@ export type LlamaModel = {
dispose?: () => Promise<void> | void;
};
/** Options accepted by node-llama-cpp model file resolution. */
export type ResolveModelFileOptions = {
directory?: string;
signal?: AbortSignal;
};
/** Root llama runtime object exposed by node-llama-cpp. */
export type Llama = {
loadModel: (params: { modelPath: string; loadSignal?: AbortSignal }) => Promise<LlamaModel>;
dispose?: () => Promise<void> | void;
};
/** Imported node-llama-cpp module shape used by local embeddings. */
export type NodeLlamaCppModule = {
LlamaLogLevel: {
error: number;
@@ -38,6 +46,7 @@ export type NodeLlamaCppModule = {
const NODE_LLAMA_CPP_MODULE = "node-llama-cpp";
/** Dynamically import node-llama-cpp so the optional dependency is loaded only when needed. */
export async function importNodeLlamaCpp() {
return import(NODE_LLAMA_CPP_MODULE) as Promise<NodeLlamaCppModule>;
}

View File

@@ -1,8 +1,11 @@
import { requireApiKey } from "../../../../src/agents/model-auth-runtime-shared.js";
import type { resolveApiKeyForProvider as ResolveApiKeyForProvider } from "../../../../src/agents/model-auth.js";
// Lazy auth facade so memory host helpers avoid eager model-auth module loading.
export { requireApiKey };
/** Resolve a provider API key through the core model-auth runtime. */
export const resolveApiKeyForProvider: typeof ResolveApiKeyForProvider = async (...args) => {
const auth = await import("../../../../src/agents/model-auth.js");
return auth.resolveApiKeyForProvider(...args);

View File

@@ -1,3 +1,5 @@
// Narrow CLI/runtime facade re-exported for memory host helpers.
export {
colorize,
defaultRuntime,

View File

@@ -1,3 +1,5 @@
// Narrow IO/runtime facade re-exported for memory host helpers.
export {
CHARS_PER_TOKEN_ESTIMATE,
DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES,

View File

@@ -1,3 +1,5 @@
// Narrow network/runtime facade re-exported for memory remote HTTP helpers.
export { fetchWithSsrFGuard } from "../../../../src/infra/net/fetch-guard.js";
export { shouldUseEnvHttpProxyForUrl } from "../../../../src/infra/net/proxy-env.js";
export { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "../../../../src/infra/net/ssrf.js";

View File

@@ -1,3 +1,5 @@
// Narrow session/runtime facade re-exported for memory transcript helpers.
export {
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,

View File

@@ -1,6 +1,9 @@
import { formatErrorMessage } from "./error-utils.js";
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
// Parser for qmd query JSON output, including noisy CLI wrapper output.
/** Normalized qmd query result consumed by memory search. */
export type QmdQueryResult = {
docid?: string;
score?: number;
@@ -12,6 +15,7 @@ export type QmdQueryResult = {
endLine?: number;
};
/** Parse qmd stdout/stderr into normalized results, accepting known no-result markers. */
export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] {
const trimmedStdout = stdout.trim();
const trimmedStderr = stderr.trim();
@@ -47,6 +51,7 @@ export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResul
}
}
/** Emit parse warnings outside tests so broken qmd output is visible to operators. */
function warnQmdQueryParseError(message: string): void {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return;
@@ -54,6 +59,7 @@ function warnQmdQueryParseError(message: string): void {
process.stderr.write(`qmd query returned invalid JSON: ${message}\n`);
}
/** Detect qmd no-result marker output on stdout or stderr. */
function isQmdNoResultsOutput(raw: string): boolean {
const lines = raw
.split(/\r?\n/)
@@ -62,6 +68,7 @@ function isQmdNoResultsOutput(raw: string): boolean {
return lines.some((line) => isQmdNoResultsLine(line));
}
/** Match qmd no-result lines with optional warning/info prefixes. */
function isQmdNoResultsLine(line: string): boolean {
if (line === "no results found" || line === "no results found.") {
return true;
@@ -71,10 +78,12 @@ function isQmdNoResultsLine(line: string): boolean {
);
}
/** Bound stderr context included in parse errors. */
function summarizeQmdStderr(raw: string): string {
return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`;
}
/** Parse and normalize a strict qmd JSON array payload. */
function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null {
try {
const parsed = JSON.parse(raw) as unknown;
@@ -111,10 +120,12 @@ function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null {
}
}
/** Normalize qmd line numbers, rejecting zero, negative, and non-integer values. */
function parseQmdLineNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
/** Extract the first complete JSON array from noisy stdout. */
function extractFirstJsonArray(raw: string): string | null {
const start = raw.indexOf("[");
if (start < 0) {

View File

@@ -1,10 +1,15 @@
import type { MemoryReadResult } from "./types.js";
// Shared memory-file read result shaping and truncation notices.
/** Default number of lines returned by memory read helpers. */
export const DEFAULT_MEMORY_READ_LINES = 120;
/** Default max character budget for memory read helper output. */
export const DEFAULT_MEMORY_READ_MAX_CHARS = 12_000;
export type { MemoryReadResult } from "./types.js";
/** Build the continuation notice appended to truncated memory excerpts. */
function buildContinuationNotice(params: {
nextFrom: number | undefined;
suggestReadFallback?: boolean;
@@ -19,6 +24,7 @@ function buildContinuationNotice(params: {
return `\n\n${base.slice(0, -1)}${fallback}]`;
}
/** Fit line slices to the response character budget while preserving line boundaries. */
function fitLinesToCharBudget(params: { lines: string[]; maxChars: number }): {
text: string;
includedLines: number;
@@ -47,12 +53,14 @@ function fitLinesToCharBudget(params: { lines: string[]; maxChars: number }): {
};
}
/** Normalize optional numeric config to a positive integer fallback. */
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(1, Math.floor(value))
: fallback;
}
/** Build a memory read result from an already-selected line slice. */
export function buildMemoryReadResultFromSlice(params: {
selectedLines: string[];
relPath: string;
@@ -92,6 +100,7 @@ export function buildMemoryReadResultFromSlice(params: {
};
}
/** Build a memory read result from raw file content and caller range options. */
export function buildMemoryReadResult(params: {
content: string;
relPath: string;

View File

@@ -23,6 +23,9 @@ import {
} from "./read-file-shared.js";
import { retryTransientMemoryRead } from "./read-retry.js";
// Secure markdown memory-file reader for workspace and configured extra paths.
/** Check that an absolute path stays inside an allowed extra directory without symlink escapes. */
async function isAllowedAdditionalDirectoryPath(
additionalPath: string,
absPath: string,
@@ -46,6 +49,7 @@ async function isAllowedAdditionalDirectoryPath(
return true;
}
/** Return true when a file vanished after path validation but before content read. */
function isFileDisappearedDuringReadError(err: unknown): boolean {
return (
isFileMissingError(err) ||
@@ -58,6 +62,7 @@ function isFileDisappearedDuringReadError(err: unknown): boolean {
);
}
/** Read a validated memory markdown file from workspace or configured extra paths. */
export async function readMemoryFile(params: {
workspaceDir: string;
extraPaths?: string[];
@@ -112,6 +117,7 @@ export async function readMemoryFile(params: {
}
if (allowedWorkspace) {
try {
// Workspace reads use the safe fs root so symlink escapes are rejected before file IO.
const workspaceRoot = await root(params.workspaceDir);
await workspaceRoot.resolve(relPath);
} catch (err) {
@@ -150,6 +156,7 @@ export async function readMemoryFile(params: {
});
}
/** Resolve agent memory config and read one memory file for that agent. */
export async function readAgentMemoryFile(params: {
cfg: OpenClawConfig;
agentId: string;

View File

@@ -1,5 +1,8 @@
import { createRequire } from "node:module";
// Resolves optional sqlite-vec native extension packages for the current platform.
/** Package/file pair for one sqlite-vec native platform build. */
type PlatformVariant = { readonly pkg: string; readonly file: string };
const PLATFORM_VARIANTS: Readonly<Record<string, PlatformVariant | undefined>> = {
@@ -10,6 +13,7 @@ const PLATFORM_VARIANTS: Readonly<Record<string, PlatformVariant | undefined>> =
"win32-x64": { pkg: "sqlite-vec-windows-x64", file: "vec0.dll" },
};
/** Resolve the installed sqlite-vec native extension for the current platform if present. */
export function resolveSqliteVecPlatformVariant():
| { pkg: string; extensionPath: string }
| undefined {

View File

@@ -1,3 +1,5 @@
// Public SQLite WAL maintenance facade for memory database callers.
export {
DEFAULT_SQLITE_WAL_AUTOCHECKPOINT_PAGES,
DEFAULT_SQLITE_WAL_TRUNCATE_INTERVAL_MS,

View File

@@ -1,2 +1,4 @@
// Public process warning filter facade for memory host callers.
export { installProcessWarningFilter, shouldIgnoreWarning } from "./openclaw-runtime-io.js";
export type { ProcessWarning } from "./openclaw-runtime-io.js";

View File

@@ -1,3 +1,5 @@
// Public Windows spawn facade for memory host callers.
export {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,