Compare commits

...

8 Commits

Author SHA1 Message Date
Dallin Romney
1b7b9b9547 test: move thread memory scenario to YAML 2026-06-14 18:03:18 -07:00
Peter Steinberger
59b0825d8c fix(memory-host): preserve core resolver exports in sdk shims 2026-04-28 11:54:02 +01:00
Peter Steinberger
b1b9f0094c fix(memory-host): keep sdk shim exports complete 2026-04-28 11:47:52 +01:00
Peter Steinberger
9a6a663d84 test(qa): settle channel before thread memory check 2026-04-28 11:38:31 +01:00
Peter Steinberger
db365d531f fix(logging): honor forced color for subsystem output 2026-04-28 11:38:31 +01:00
Peter Steinberger
b469092d92 test(memory-host): assert package-owned boundary report 2026-04-28 11:38:18 +01:00
Peter Steinberger
064ec5f07c fix(memory-host): harden service overrides 2026-04-28 11:38:18 +01:00
Peter Steinberger
c3f5af897c refactor(memory-host): replace core runtime bridge with services 2026-04-28 11:38:17 +01:00
39 changed files with 1433 additions and 583 deletions

View File

@@ -1,20 +1,5 @@
// Real workspace contract for memory embedding providers and batch helpers.
// Package-local memory embedding helpers.
export {
getMemoryEmbeddingProvider,
listRegisteredMemoryEmbeddingProviders,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
} from "./host/openclaw-runtime-memory.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderCreateResult,
MemoryEmbeddingProviderRuntime,
} from "./host/openclaw-runtime-memory.js";
export { createLocalEmbeddingProvider, DEFAULT_LOCAL_MODEL } from "./host/embeddings.js";
export { extractBatchErrorMessage, formatUnavailableBatchError } from "./host/batch-error-utils.js";
export { postJsonWithRetry } from "./host/batch-http.js";

View File

@@ -1,48 +1,32 @@
// Real workspace contract for memory engine foundation concerns.
// Package-local foundation exports. Core-only helpers are bound by the
// workspace facade.
export {
parseDurationMs,
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "./host/openclaw-runtime-agent.js";
export {
resolveMemorySearchConfig,
resolveMemorySearchSyncConfig,
type ResolvedMemorySearchConfig,
type ResolvedMemorySearchSyncConfig,
} from "./host/openclaw-runtime-agent.js";
export { parseDurationMs } from "./host/openclaw-runtime-config.js";
export { loadConfig } from "./host/openclaw-runtime-config.js";
export { resolveStateDir } from "./host/openclaw-runtime-config.js";
export { resolveSessionTranscriptsDirForAgent } from "./host/openclaw-runtime-config.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "./host/openclaw-runtime-config.js";
export { writeFileWithinRoot } from "./host/openclaw-runtime-io.js";
export { createSubsystemLogger } from "./host/openclaw-runtime-io.js";
export { detectMime } from "./host/openclaw-runtime-io.js";
export { resolveGlobalSingleton } from "./host/openclaw-runtime-io.js";
export { onSessionTranscriptUpdate } from "./host/openclaw-runtime-session.js";
export { splitShellArgs } from "./host/openclaw-runtime-io.js";
export { runTasksWithConcurrency } from "./host/openclaw-runtime-io.js";
export {
shortenHomeInString,
shortenHomePath,
resolveStateDir,
resolveUserPath,
truncateUtf16Safe,
} from "./host/openclaw-runtime-io.js";
export type { OpenClawConfig } from "./host/openclaw-runtime-config.js";
export type { SessionSendPolicyConfig } from "./host/openclaw-runtime-config.js";
export type { SecretInput } from "./host/openclaw-runtime-config.js";
export type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
} from "./host/openclaw-runtime-config.js";
export type { MemorySearchConfig } from "./host/openclaw-runtime-config.js";
splitShellArgs,
type MemoryBackend,
type MemoryCitationsMode,
type MemoryQmdConfig,
type MemoryQmdIndexPath,
type MemoryQmdMcporterConfig,
type MemoryQmdSearchMode,
type MemorySearchConfig,
type OpenClawConfig,
type SecretInput,
type SessionSendPolicyConfig,
} from "./host/config-utils.js";
export {
CHARS_PER_TOKEN_ESTIMATE,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
SILENT_REPLY_TOKEN,
getMemoryHostServices,
setMemoryHostServices,
withMemoryHostServices,
type MemoryHostServices,
} from "./host/services.js";

View File

@@ -1,5 +1,7 @@
// Real workspace contract for QMD/session/query helpers used by the memory engine.
import { getMemoryHostServices } from "./host/services.js";
export { extractKeywords, isQueryStopWordToken } from "./host/query-expansion.js";
export {
buildSessionEntry,
@@ -12,7 +14,8 @@ export {
type SessionFileEntry,
type SessionTranscriptClassification,
} from "./host/session-files.js";
export { parseUsageCountedSessionIdFromFileName } from "./host/openclaw-runtime-session.js";
export const parseUsageCountedSessionIdFromFileName = (fileName: string): string | null =>
getMemoryHostServices().session.parseUsageCountedSessionIdFromFileName(fileName);
export { parseQmdQueryJson, type QmdQueryResult } from "./host/qmd-query-parser.js";
export {
deriveQmdScopeChannel,

View File

@@ -1,7 +1,7 @@
import type { EmbeddingProviderOptions } from "./embeddings.types.js";
import { requireApiKey, resolveApiKeyForProvider } from "./openclaw-runtime-auth.js";
import { buildRemoteBaseUrlPolicy } from "./remote-http.js";
import { resolveMemorySecretInputString } from "./secret-input.js";
import { getMemoryHostServices } from "./services.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
import { normalizeOptionalString } from "./string-utils.js";
@@ -19,10 +19,11 @@ export async function resolveRemoteEmbeddingBearerClient(params: {
});
const remoteBaseUrl = normalizeOptionalString(remote?.baseUrl);
const providerConfig = params.options.config.models?.providers?.[params.provider];
const auth = getMemoryHostServices().auth;
const apiKey = remoteApiKey
? remoteApiKey
: requireApiKey(
await resolveApiKeyForProvider({
: auth.requireApiKey(
await auth.resolveApiKeyForProvider({
provider: params.provider,
cfg: params.options.config,
agentDir: params.options.agentDir,

View File

@@ -12,16 +12,7 @@ import {
type MemoryMultimodalModality,
type MemoryMultimodalSettings,
} from "./multimodal.js";
import {
CHARS_PER_TOKEN_ESTIMATE,
detectMime,
estimateStringChars,
runTasksWithConcurrency,
} from "./openclaw-runtime-io.js";
import {
resolveCanonicalRootMemoryFile,
shouldSkipRootMemoryAuxiliaryPath,
} from "./openclaw-runtime-memory.js";
import { CHARS_PER_TOKEN_ESTIMATE, getMemoryHostServices } from "./services.js";
export { hashText } from "./hash.js";
import { hashText } from "./hash.js";
@@ -144,7 +135,7 @@ export async function listMemoryFiles(
const memoryDir = path.join(workspaceDir, "memory");
const shouldSkipWorkspaceMemoryPath = (absPath: string): boolean =>
shouldSkipRootMemoryAuxiliaryPath({ workspaceDir, absPath });
getMemoryHostServices().memory.shouldSkipRootMemoryAuxiliaryPath({ workspaceDir, absPath });
const addMarkdownFile = async (absPath: string) => {
try {
@@ -159,7 +150,8 @@ export async function listMemoryFiles(
} catch {}
};
const memoryFile = await resolveCanonicalRootMemoryFile(workspaceDir);
const memoryFile =
await getMemoryHostServices().memory.resolveCanonicalRootMemoryFile(workspaceDir);
if (memoryFile) {
await addMarkdownFile(memoryFile);
}
@@ -240,7 +232,10 @@ export async function buildFileEntry(
}
throw err;
}
const mimeType = await detectMime({ buffer: buffer.subarray(0, 512), filePath: absPath });
const mimeType = await getMemoryHostServices().io.detectMime({
buffer: buffer.subarray(0, 512),
filePath: absPath,
});
if (!mimeType || !mimeType.startsWith(`${modality}/`)) {
return null;
}
@@ -405,7 +400,7 @@ export function chunkMarkdown(
if (!entry) {
continue;
}
acc += estimateStringChars(entry.line) + 1;
acc += getMemoryHostServices().io.estimateStringChars(entry.line) + 1;
kept.unshift(entry);
if (acc >= overlapChars) {
break;
@@ -428,7 +423,7 @@ export function chunkMarkdown(
// chunking.tokens so the chunk stays within the token budget.
for (let start = 0; start < line.length; start += maxChars) {
const coarse = line.slice(start, start + maxChars);
if (estimateStringChars(coarse) > maxChars) {
if (getMemoryHostServices().io.estimateStringChars(coarse) > maxChars) {
const fineStep = Math.max(1, chunking.tokens);
for (let j = 0; j < coarse.length; ) {
let end = Math.min(j + fineStep, coarse.length);
@@ -448,7 +443,7 @@ export function chunkMarkdown(
}
}
for (const segment of segments) {
const lineSize = estimateStringChars(segment) + 1;
const lineSize = getMemoryHostServices().io.estimateStringChars(segment) + 1;
if (currentChars + lineSize > maxChars && current.length > 0) {
flush();
carryOverlap();
@@ -516,11 +511,12 @@ export async function runWithConcurrency<T>(
tasks: Array<() => Promise<T>>,
limit: number,
): Promise<T[]> {
const { results, firstError, hasError } = await runTasksWithConcurrency({
tasks,
limit,
errorMode: "stop",
});
const { results, firstError, hasError } =
await getMemoryHostServices().io.runTasksWithConcurrency({
tasks,
limit,
errorMode: "stop",
});
if (hasError) {
throw firstError;
}

View File

@@ -1,21 +0,0 @@
export {
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR,
asToolParamsRecord,
jsonResult,
parseAgentSessionKey,
readNumberParam,
readStringParam,
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveCronStyleNow,
resolveDefaultAgentId,
resolveMemorySearchConfig,
resolveMemorySearchSyncConfig,
resolveSessionAgentId,
} from "./openclaw-runtime.js";
export type {
AnyAgentTool,
ResolvedMemorySearchConfig,
ResolvedMemorySearchSyncConfig,
} from "./openclaw-runtime.js";

View File

@@ -1 +0,0 @@
export { requireApiKey, resolveApiKeyForProvider } from "./openclaw-runtime.js";

View File

@@ -1,17 +0,0 @@
export {
colorize,
defaultRuntime,
formatDocsLink,
formatErrorMessage,
formatHelpExamples,
isRich,
isVerbose,
resolveCommandSecretRefsViaGateway,
setVerbose,
shortenHomeInString,
shortenHomePath,
theme,
withManager,
withProgress,
withProgressTotals,
} from "./openclaw-runtime.js";

View File

@@ -1,22 +0,0 @@
export {
getRuntimeConfig,
hasConfiguredSecretInput,
loadConfig,
normalizeResolvedSecretInputString,
parseDurationMs,
parseNonNegativeByteSize,
resolveSessionTranscriptsDirForAgent,
resolveStateDir,
} from "./openclaw-runtime.js";
export type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
MemorySearchConfig,
OpenClawConfig,
SecretInput,
SessionSendPolicyConfig,
} from "./openclaw-runtime.js";

View File

@@ -1,15 +0,0 @@
export {
CHARS_PER_TOKEN_ESTIMATE,
createSubsystemLogger,
detectMime,
estimateStringChars,
redactSensitiveText,
resolveGlobalSingleton,
resolveUserPath,
runTasksWithConcurrency,
shortenHomeInString,
shortenHomePath,
splitShellArgs,
truncateUtf16Safe,
writeFileWithinRoot,
} from "./openclaw-runtime.js";

View File

@@ -1,29 +0,0 @@
export {
buildActiveMemoryPromptSection,
emptyPluginConfigSchema,
getMemoryCapabilityRegistration,
getMemoryEmbeddingProvider,
listActiveMemoryPublicArtifacts,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
listRegisteredMemoryEmbeddingProviders,
resolveCanonicalRootMemoryFile,
shouldSkipRootMemoryAuxiliaryPath,
} from "./openclaw-runtime.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderCreateResult,
MemoryEmbeddingProviderRuntime,
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
OpenClawPluginApi,
} from "./openclaw-runtime.js";

View File

@@ -1,5 +0,0 @@
export {
fetchWithSsrFGuard,
shouldUseEnvHttpProxyForUrl,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "./openclaw-runtime.js";

View File

@@ -1,18 +0,0 @@
export {
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
SILENT_REPLY_TOKEN,
hasInterSessionUserProvenance,
isCompactionCheckpointTranscriptFileName,
isCronRunSessionKey,
isExecCompletionEvent,
isHeartbeatUserMessage,
isSessionArchiveArtifactName,
isSilentReplyPayloadText,
isUsageCountedSessionTranscriptFileName,
onSessionTranscriptUpdate,
parseUsageCountedSessionIdFromFileName,
resolveSessionTranscriptsDirForAgent,
stripInboundMetadata,
stripInternalRuntimeContext,
} from "./openclaw-runtime.js";

View File

@@ -1,139 +0,0 @@
// Agent/runtime helpers.
export { resolveCronStyleNow } from "../../../../src/agents/current-time.js";
export {
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../../../src/agents/agent-scope.js";
export { requireApiKey, resolveApiKeyForProvider } from "../../../../src/agents/model-auth.js";
export { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../../../src/agents/pi-settings.js";
export {
asToolParamsRecord,
jsonResult,
readNumberParam,
readStringParam,
} from "../../../../src/agents/tools/common.js";
export type { AnyAgentTool } from "../../../../src/agents/tools/common.js";
export {
resolveMemorySearchConfig,
resolveMemorySearchSyncConfig,
type ResolvedMemorySearchConfig,
type ResolvedMemorySearchSyncConfig,
} from "../../../../src/agents/memory-search.js";
// Session and reply helpers.
export { isHeartbeatUserMessage } from "../../../../src/auto-reply/heartbeat-filter.js";
export { HEARTBEAT_PROMPT } from "../../../../src/auto-reply/heartbeat.js";
export { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
export {
HEARTBEAT_TOKEN,
SILENT_REPLY_TOKEN,
isSilentReplyPayloadText,
} from "../../../../src/auto-reply/tokens.js";
// CLI/runtime/config helpers.
export { formatErrorMessage, withManager } from "../../../../src/cli/cli-utils.js";
export { resolveCommandSecretRefsViaGateway } from "../../../../src/cli/command-secret-gateway.js";
export { formatHelpExamples } from "../../../../src/cli/help-format.js";
export { parseDurationMs } from "../../../../src/cli/parse-duration.js";
export { withProgress, withProgressTotals } from "../../../../src/cli/progress.js";
export { parseNonNegativeByteSize } from "../../../../src/config/byte-size.js";
export {
getRuntimeConfig,
/** @deprecated Use getRuntimeConfig(), or pass the already loaded config through the call path. */
loadConfig,
} from "../../../../src/config/config.js";
export type { OpenClawConfig } from "../../../../src/config/config.js";
export { resolveStateDir } from "../../../../src/config/paths.js";
export {
isCompactionCheckpointTranscriptFileName,
isSessionArchiveArtifactName,
isUsageCountedSessionTranscriptFileName,
parseUsageCountedSessionIdFromFileName,
} from "../../../../src/config/sessions/artifacts.js";
export { resolveSessionTranscriptsDirForAgent } from "../../../../src/config/sessions/paths.js";
export type { SessionSendPolicyConfig } from "../../../../src/config/types.base.js";
export type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
} from "../../../../src/config/types.memory.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "../../../../src/config/types.secrets.js";
export type { SecretInput } from "../../../../src/config/types.secrets.js";
export type { MemorySearchConfig } from "../../../../src/config/types.tools.js";
export { isVerbose, setVerbose } from "../../../../src/globals.js";
// IO, network, and logging helpers.
export { isExecCompletionEvent } from "../../../../src/infra/heartbeat-events-filter.js";
export { writeFileWithinRoot } from "../../../../src/infra/fs-safe.js";
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";
export { redactSensitiveText } from "../../../../src/logging/redact.js";
export { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
export { detectMime } from "../../../../src/media/mime.js";
// Memory plugin helpers.
export {
resolveCanonicalRootMemoryFile,
shouldSkipRootMemoryAuxiliaryPath,
} from "../../../../src/memory/root-memory-files.js";
export {
getMemoryEmbeddingProvider,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
listRegisteredMemoryEmbeddingProviders,
} from "../../../../src/plugins/memory-embedding-provider-runtime.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderCreateResult,
MemoryEmbeddingProviderRuntime,
} from "../../../../src/plugins/memory-embedding-providers.js";
export { emptyPluginConfigSchema } from "../../../../src/plugins/config-schema.js";
export {
buildMemoryPromptSection as buildActiveMemoryPromptSection,
getMemoryCapabilityRegistration,
listActiveMemoryPublicArtifacts,
} from "../../../../src/plugins/memory-state.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
} from "../../../../src/plugins/memory-state.js";
export type { OpenClawPluginApi } from "../../../../src/plugins/types.js";
// Shared session/text utilities.
export { defaultRuntime } from "../../../../src/runtime.js";
export { parseAgentSessionKey } from "../../../../src/routing/session-key.js";
export { hasInterSessionUserProvenance } from "../../../../src/sessions/input-provenance.js";
export { isCronRunSessionKey } from "../../../../src/sessions/session-key-utils.js";
export { onSessionTranscriptUpdate } from "../../../../src/sessions/transcript-events.js";
export { formatDocsLink } from "../../../../src/terminal/links.js";
export { colorize, isRich, theme } from "../../../../src/terminal/theme.js";
export { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../../../../src/utils/cjk-chars.js";
export { runTasksWithConcurrency } from "../../../../src/utils/run-with-concurrency.js";
export { splitShellArgs } from "../../../../src/utils/shell-argv.js";
export {
resolveUserPath,
shortenHomeInString,
shortenHomePath,
truncateUtf16Safe,
} from "../../../../src/utils.js";
export { resolveGlobalSingleton } from "../../../../src/shared/global-singleton.js";

View File

@@ -1,28 +1,31 @@
import {
fetchWithSsrFGuard,
shouldUseEnvHttpProxyForUrl,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "./openclaw-runtime-network.js";
getMemoryHostServices,
MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE,
type MemoryHostGuardedFetch,
} from "./services.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
export const MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE = "trusted_env_proxy";
export { MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE };
export const buildRemoteBaseUrlPolicy: (baseUrl: string) => SsrFPolicy | undefined =
ssrfPolicyFromHttpBaseUrlAllowedHostname;
export const buildRemoteBaseUrlPolicy: (baseUrl: string) => SsrFPolicy | undefined = (baseUrl) =>
getMemoryHostServices().network.buildRemoteBaseUrlPolicy(baseUrl);
export async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: typeof fetch;
fetchWithSsrFGuardImpl?: typeof fetchWithSsrFGuard;
shouldUseEnvHttpProxyForUrlImpl?: typeof shouldUseEnvHttpProxyForUrl;
fetchWithSsrFGuardImpl?: MemoryHostGuardedFetch;
shouldUseEnvHttpProxyForUrlImpl?: (url: string) => boolean;
auditContext?: string;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const guardedFetch = params.fetchWithSsrFGuardImpl ?? fetchWithSsrFGuard;
const shouldUseEnvProxy = params.shouldUseEnvHttpProxyForUrlImpl ?? shouldUseEnvHttpProxyForUrl;
const { response, release } = await guardedFetch({
const services = getMemoryHostServices().network;
const guardedFetch = params.fetchWithSsrFGuardImpl ?? services.fetchWithSsrFGuard;
const shouldUseEnvProxy =
params.shouldUseEnvHttpProxyForUrlImpl ??
((url: string) => services.shouldUseEnvHttpProxyForUrl(url));
const guardedResponse = await guardedFetch({
url: params.url,
fetchImpl: params.fetchImpl,
init: params.init,
@@ -31,8 +34,8 @@ export async function withRemoteHttpResponse<T>(params: {
...(shouldUseEnvProxy(params.url) ? { mode: MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE } : {}),
});
try {
return await params.onResponse(response);
return await params.onResponse(guardedResponse.response);
} finally {
await release();
await guardedResponse.release();
}
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
createDefaultMemoryHostServices,
getMemoryHostServices,
setMemoryHostServices,
withMemoryHostServices,
type MemoryHostServices,
} from "./services.js";
function makeServices(charCount: number): MemoryHostServices {
const services = createDefaultMemoryHostServices();
return {
...services,
io: {
...services.io,
estimateStringChars: () => charCount,
},
};
}
describe("MemoryHostServices", () => {
it("returns a restore callback when overriding services", () => {
const baseline = createDefaultMemoryHostServices();
const restoreBaseline = setMemoryHostServices(baseline);
try {
const first = makeServices(11);
const second = makeServices(22);
const restoreFirst = setMemoryHostServices(first);
expect(getMemoryHostServices().io.estimateStringChars("abc")).toBe(11);
const restoreSecond = setMemoryHostServices(second);
expect(getMemoryHostServices().io.estimateStringChars("abc")).toBe(22);
restoreSecond();
expect(getMemoryHostServices()).toBe(first);
expect(getMemoryHostServices().io.estimateStringChars("abc")).toBe(11);
restoreFirst();
expect(getMemoryHostServices()).toBe(baseline);
} finally {
restoreBaseline();
}
});
it("scopes service overrides across async failures", async () => {
const baseline = createDefaultMemoryHostServices();
const restoreBaseline = setMemoryHostServices(baseline);
try {
await expect(
withMemoryHostServices(makeServices(33), async () => {
expect(getMemoryHostServices().io.estimateStringChars("abc")).toBe(33);
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(getMemoryHostServices()).toBe(baseline);
} finally {
restoreBaseline();
}
});
it("keeps host-owned network fetch fail-closed by default", async () => {
const services = createDefaultMemoryHostServices();
await expect(
services.network.fetchWithSsrFGuard({ url: "https://memory.example/v1" }),
).rejects.toThrow("requires a host service binding");
});
it("redacts likely secrets in the package default service", () => {
const services = createDefaultMemoryHostServices();
const secret = "OPENAI_API_KEY=sk-1234567890abcdef";
expect(services.io.redactSensitiveText(secret)).not.toContain("sk-1234567890abcdef");
});
});

View File

@@ -0,0 +1,713 @@
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeAgentId, resolveStateDir } from "./config-utils.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";
export const HEARTBEAT_PROMPT =
"Run the following periodic tasks (only those due based on their intervals):";
export const CHARS_PER_TOKEN_ESTIMATE = 4;
export const MEMORY_REMOTE_TRUSTED_ENV_PROXY_MODE = "trusted_env_proxy";
export type MemoryHostLogger = {
debug(message: string): void;
};
export type MemoryHostGuardedFetch = (params: {
url: string;
fetchImpl?: typeof fetch;
init?: RequestInit;
policy?: SsrFPolicy;
auditContext?: string;
mode?: string;
}) => Promise<{ response: Response; release(): Promise<void> }>;
export type MemoryHostServices = {
auth: {
requireApiKey(apiKey: string | undefined, provider: string): string;
resolveApiKeyForProvider(params: {
provider: string;
cfg: unknown;
agentDir?: string;
}): Promise<string | undefined>;
};
io: {
createSubsystemLogger(name: string): MemoryHostLogger;
detectMime(opts: {
buffer?: Buffer;
headerMime?: string | null;
filePath?: string;
}): Promise<string | undefined>;
estimateStringChars(text: string): number;
redactSensitiveText(text: string, options?: unknown): string;
runTasksWithConcurrency<T>(params: {
tasks: Array<() => Promise<T>>;
limit: number;
errorMode?: "continue" | "stop";
onTaskError?: (error: unknown, index: number) => void;
}): Promise<{ results: T[]; firstError: unknown; hasError: boolean }>;
};
memory: {
resolveCanonicalRootMemoryFile(workspaceDir: string): Promise<string | null>;
shouldSkipRootMemoryAuxiliaryPath(params: { workspaceDir: string; absPath: string }): boolean;
};
network: {
buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined;
fetchWithSsrFGuard: MemoryHostGuardedFetch;
shouldUseEnvHttpProxyForUrl(url: string): boolean;
};
session: {
hasInterSessionUserProvenance(
message: { role?: unknown; provenance?: unknown } | undefined,
): boolean;
isCompactionCheckpointTranscriptFileName(fileName: string): boolean;
isCronRunSessionKey(sessionKey: string | undefined | null): boolean;
isExecCompletionEvent(event: string): boolean;
isHeartbeatUserMessage(
message: { role: string; content?: unknown },
heartbeatPrompt?: string,
): boolean;
isSessionArchiveArtifactName(fileName: string): boolean;
isSilentReplyPayloadText(text: string | undefined): boolean;
isUsageCountedSessionTranscriptFileName(fileName: string): boolean;
parseUsageCountedSessionIdFromFileName(fileName: string): string | null;
resolveSessionTranscriptsDirForAgent(agentId: string): string;
stripInboundMetadata(text: string): string;
stripInternalRuntimeContext(text: string): string;
};
};
let activeServices: MemoryHostServices | undefined;
export function setMemoryHostServices(services: MemoryHostServices): () => void {
const previousServices = activeServices;
activeServices = services;
let restored = false;
return () => {
if (restored) {
return;
}
restored = true;
activeServices = previousServices;
};
}
export async function withMemoryHostServices<T>(
services: MemoryHostServices,
run: () => T | Promise<T>,
): Promise<Awaited<T>> {
const restore = setMemoryHostServices(services);
try {
return await run();
} finally {
restore();
}
}
export function getMemoryHostServices(): MemoryHostServices {
activeServices ??= createDefaultMemoryHostServices();
return activeServices;
}
export function createDefaultMemoryHostServices(): MemoryHostServices {
return {
auth: {
requireApiKey(apiKey, provider) {
const trimmed = apiKey?.trim();
if (!trimmed) {
throw new Error(`${provider} API key required`);
}
return trimmed;
},
async resolveApiKeyForProvider() {
return undefined;
},
},
io: {
createSubsystemLogger: () => ({ debug: () => {} }),
detectMime: async ({ filePath }) => mimeTypeFromFilePath(filePath),
estimateStringChars,
redactSensitiveText,
runTasksWithConcurrency,
},
memory: {
resolveCanonicalRootMemoryFile,
shouldSkipRootMemoryAuxiliaryPath,
},
network: {
buildRemoteBaseUrlPolicy,
async fetchWithSsrFGuard() {
throw new Error(
"@openclaw/memory-host-sdk network.fetchWithSsrFGuard requires a host service binding",
);
},
shouldUseEnvHttpProxyForUrl: () => false,
},
session: {
hasInterSessionUserProvenance,
isCompactionCheckpointTranscriptFileName,
isCronRunSessionKey,
isExecCompletionEvent,
isHeartbeatUserMessage,
isSessionArchiveArtifactName,
isSilentReplyPayloadText,
isUsageCountedSessionTranscriptFileName,
parseUsageCountedSessionIdFromFileName,
resolveSessionTranscriptsDirForAgent,
stripInboundMetadata,
stripInternalRuntimeContext,
},
};
}
const ROOT_MEMORY_REPAIR_RELATIVE_DIR = ".openclaw-repair/root-memory";
const LEGACY_ROOT_MEMORY_FILENAME = "memory.md";
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
const COMPACTION_CHECKPOINT_TRANSCRIPT_RE =
/^(.+)\.checkpoint\.([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.jsonl$/i;
async function resolveCanonicalRootMemoryFile(workspaceDir: string): Promise<string | null> {
try {
const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
const entry = entries.find(
(candidate) =>
candidate.name === "MEMORY.md" && candidate.isFile() && !candidate.isSymbolicLink(),
);
return entry ? path.join(workspaceDir, entry.name) : null;
} catch {
return null;
}
}
function shouldSkipRootMemoryAuxiliaryPath(params: {
workspaceDir: string;
absPath: string;
}): boolean {
const relative = path.relative(params.workspaceDir, params.absPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return false;
}
const normalized = relative.trim().replace(/\\/g, "/").replace(/^\.\//, "");
return (
normalized === LEGACY_ROOT_MEMORY_FILENAME ||
normalized === ROOT_MEMORY_REPAIR_RELATIVE_DIR ||
normalized.startsWith(`${ROOT_MEMORY_REPAIR_RELATIVE_DIR}/`)
);
}
function hasArchiveSuffix(fileName: string, reason: "bak" | "reset" | "deleted"): boolean {
const marker = `.${reason}.`;
const index = fileName.lastIndexOf(marker);
return index >= 0 && ARCHIVE_TIMESTAMP_RE.test(fileName.slice(index + marker.length));
}
function isSessionArchiveArtifactName(fileName: string): boolean {
if (LEGACY_STORE_BACKUP_RE.test(fileName)) {
return true;
}
return (
hasArchiveSuffix(fileName, "deleted") ||
hasArchiveSuffix(fileName, "reset") ||
hasArchiveSuffix(fileName, "bak")
);
}
function isCompactionCheckpointTranscriptFileName(fileName: string): boolean {
return COMPACTION_CHECKPOINT_TRANSCRIPT_RE.test(fileName);
}
function isPrimarySessionTranscriptFileName(fileName: string): boolean {
return (
fileName !== "sessions.json" &&
fileName.endsWith(".jsonl") &&
!fileName.endsWith(".trajectory.jsonl") &&
!isCompactionCheckpointTranscriptFileName(fileName) &&
!isSessionArchiveArtifactName(fileName)
);
}
function isUsageCountedSessionTranscriptFileName(fileName: string): boolean {
return (
isPrimarySessionTranscriptFileName(fileName) ||
hasArchiveSuffix(fileName, "reset") ||
hasArchiveSuffix(fileName, "deleted")
);
}
function parseUsageCountedSessionIdFromFileName(fileName: string): string | null {
if (isPrimarySessionTranscriptFileName(fileName)) {
return fileName.slice(0, -".jsonl".length);
}
for (const reason of ["reset", "deleted"] as const) {
const marker = `.jsonl.${reason}.`;
const index = fileName.lastIndexOf(marker);
if (index > 0 && hasArchiveSuffix(fileName, reason)) {
return fileName.slice(0, index);
}
}
return null;
}
function resolveSessionTranscriptsDirForAgent(agentId: string): string {
return path.join(resolveStateDir(), "agents", normalizeAgentId(agentId), "sessions");
}
function parseAgentSessionKey(sessionKey: string | undefined | null): { rest: string } | null {
const raw = normalizeLowercaseStringOrEmpty(sessionKey ?? "");
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3 || parts[0] !== "agent" || !parts[1]) {
return null;
}
const rest = parts.slice(2).join(":");
return rest ? { rest } : null;
}
function isCronRunSessionKey(sessionKey: string | undefined | null): boolean {
const parsed = parseAgentSessionKey(sessionKey);
return parsed ? /^cron:[^:]+:run:[^:]+$/.test(parsed.rest) : false;
}
function isExecCompletionEvent(event: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(event).trimStart();
return (
/^exec finished(?::|\s*\()/.test(normalized) ||
/^exec (completed|failed) \([a-z0-9_-]{1,64}, (code -?\d+|signal [^)]+)\)( :: .*)?$/.test(
normalized,
)
);
}
function isSilentReplyPayloadText(text: string | undefined): boolean {
if (!text) {
return false;
}
const trimmed = text.trim();
if (new RegExp(`^${escapeRegExp(SILENT_REPLY_TOKEN)}$`, "i").test(trimmed)) {
return true;
}
if (!trimmed.startsWith("{") || !trimmed.endsWith("}") || !trimmed.includes(SILENT_REPLY_TOKEN)) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as { action?: unknown };
return (
Object.keys(parsed).length === 1 &&
typeof parsed.action === "string" &&
parsed.action.trim() === SILENT_REPLY_TOKEN
);
} catch {
return false;
}
}
function resolveMessageText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.filter(
(block): block is { type: "text"; text: string } =>
Boolean(block) &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string",
)
.map((block) => block.text)
.join("");
}
function isHeartbeatUserMessage(
message: { role: string; content?: unknown },
heartbeatPrompt?: string,
): boolean {
if (message.role !== "user") {
return false;
}
const trimmed = resolveMessageText(message.content).trim();
return Boolean(
trimmed &&
((heartbeatPrompt?.trim() && trimmed.startsWith(heartbeatPrompt.trim())) ||
(trimmed.startsWith(HEARTBEAT_PROMPT) && trimmed.includes("HEARTBEAT_OK"))),
);
}
function hasInterSessionUserProvenance(
message: { role?: unknown; provenance?: unknown } | undefined,
): boolean {
return (
message?.role === "user" &&
Boolean(message.provenance) &&
typeof message.provenance === "object" &&
(message.provenance as { kind?: unknown }).kind === "inter_session"
);
}
const LEADING_TIMESTAMP_PREFIX_RE = /^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\] */;
const INTERNAL_RUNTIME_CONTEXT_BEGIN = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
const INTERNAL_RUNTIME_CONTEXT_END = "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
const OPENCLAW_RUNTIME_CONTEXT_NOTICE =
"This context is runtime-generated, not user-authored. Keep internal details private.";
const OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER =
"OpenClaw runtime context for the immediately preceding user message.";
const OPENCLAW_RUNTIME_EVENT_HEADER = "OpenClaw runtime event.";
const LEGACY_INTERNAL_CONTEXT_HEADER =
["OpenClaw runtime context (internal):", OPENCLAW_RUNTIME_CONTEXT_NOTICE, ""].join("\n") + "\n";
const LEGACY_INTERNAL_EVENT_MARKER = "[Internal task completion event]";
const LEGACY_INTERNAL_EVENT_SEPARATOR = "\n\n---\n\n";
const LEGACY_UNTRUSTED_RESULT_BEGIN = "<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>";
const LEGACY_UNTRUSTED_RESULT_END = "<<<END_UNTRUSTED_CHILD_RESULT>>>";
const INBOUND_META_SENTINELS = [
"Conversation info (untrusted metadata):",
"Sender (untrusted metadata):",
"Thread starter (untrusted, for context):",
"Replied message (untrusted, for context):",
"Forwarded message context (untrusted metadata):",
"Chat history since last reply (untrusted, for context):",
] as const;
function stripInboundMetadata(text: string): string {
if (!text) {
return text;
}
const withoutTimestamp = text.replace(LEADING_TIMESTAMP_PREFIX_RE, "");
if (!INBOUND_META_SENTINELS.some((sentinel) => withoutTimestamp.includes(sentinel))) {
return withoutTimestamp;
}
const lines = withoutTimestamp.split("\n");
const result: string[] = [];
let inMetaBlock = false;
let inJsonFence = false;
for (const line of lines) {
if (!inMetaBlock && INBOUND_META_SENTINELS.some((sentinel) => line.trim() === sentinel)) {
inMetaBlock = true;
inJsonFence = false;
continue;
}
if (inMetaBlock) {
if (!inJsonFence && line.trim() === "```json") {
inJsonFence = true;
continue;
}
if (inJsonFence) {
if (line.trim() === "```") {
inMetaBlock = false;
inJsonFence = false;
}
continue;
}
if (line.trim() === "") {
continue;
}
inMetaBlock = false;
}
result.push(line);
}
return result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
}
function findDelimitedTokenIndex(text: string, token: string, from: number): number {
const tokenRe = new RegExp(`(?:^|\\r?\\n)${escapeRegExp(token)}(?=\\r?\\n|$)`, "g");
tokenRe.lastIndex = Math.max(0, from);
const match = tokenRe.exec(text);
if (!match) {
return -1;
}
const prefixLength = match[0].length - token.length;
return match.index + prefixLength;
}
function stripDelimitedBlock(text: string, begin: string, end: string): string {
let next = text;
for (;;) {
const start = findDelimitedTokenIndex(next, begin, 0);
if (start === -1) {
return next;
}
let cursor = start + begin.length;
let depth = 1;
let finish = -1;
while (depth > 0) {
const nextBegin = findDelimitedTokenIndex(next, begin, cursor);
const nextEnd = findDelimitedTokenIndex(next, end, cursor);
if (nextEnd === -1) {
break;
}
if (nextBegin !== -1 && nextBegin < nextEnd) {
depth += 1;
cursor = nextBegin + begin.length;
continue;
}
depth -= 1;
finish = nextEnd;
cursor = nextEnd + end.length;
}
const before = next.slice(0, start).trimEnd();
if (finish === -1 || depth !== 0) {
return before;
}
const after = next.slice(finish + end.length).trimStart();
next = before && after ? `${before}\n\n${after}` : `${before}${after}`;
}
}
function findLegacyInternalEventEnd(text: string, start: number): number | null {
if (!text.startsWith(LEGACY_INTERNAL_EVENT_MARKER, start)) {
return null;
}
const resultBegin = text.indexOf(
LEGACY_UNTRUSTED_RESULT_BEGIN,
start + LEGACY_INTERNAL_EVENT_MARKER.length,
);
if (resultBegin === -1) {
return null;
}
const resultEnd = text.indexOf(
LEGACY_UNTRUSTED_RESULT_END,
resultBegin + LEGACY_UNTRUSTED_RESULT_BEGIN.length,
);
if (resultEnd === -1) {
return null;
}
const actionIndex = text.indexOf("\n\nAction:\n", resultEnd + LEGACY_UNTRUSTED_RESULT_END.length);
if (actionIndex === -1) {
return null;
}
const afterAction = actionIndex + "\n\nAction:\n".length;
const nextEvent = text.indexOf(
`${LEGACY_INTERNAL_EVENT_SEPARATOR}${LEGACY_INTERNAL_EVENT_MARKER}`,
afterAction,
);
if (nextEvent !== -1) {
return nextEvent;
}
const nextParagraph = text.indexOf("\n\n", afterAction);
return nextParagraph === -1 ? text.length : nextParagraph;
}
function stripLegacyInternalRuntimeContext(text: string): string {
let next = text;
let searchFrom = 0;
for (;;) {
const headerStart = next.indexOf(LEGACY_INTERNAL_CONTEXT_HEADER, searchFrom);
if (headerStart === -1) {
return next;
}
const eventStart = headerStart + LEGACY_INTERNAL_CONTEXT_HEADER.length;
if (!next.startsWith(LEGACY_INTERNAL_EVENT_MARKER, eventStart)) {
searchFrom = eventStart;
continue;
}
let blockEnd = findLegacyInternalEventEnd(next, eventStart);
if (blockEnd == null) {
const nextParagraph = next.indexOf("\n\n", eventStart + LEGACY_INTERNAL_EVENT_MARKER.length);
blockEnd = nextParagraph === -1 ? next.length : nextParagraph;
} else {
while (
next.startsWith(
`${LEGACY_INTERNAL_EVENT_SEPARATOR}${LEGACY_INTERNAL_EVENT_MARKER}`,
blockEnd,
)
) {
const nextEventStart = blockEnd + LEGACY_INTERNAL_EVENT_SEPARATOR.length;
const nextEventEnd = findLegacyInternalEventEnd(next, nextEventStart);
if (nextEventEnd == null) {
break;
}
blockEnd = nextEventEnd;
}
}
const before = next.slice(0, headerStart).trimEnd();
const after = next.slice(blockEnd).trimStart();
next = before && after ? `${before}\n\n${after}` : `${before}${after}`;
searchFrom = Math.max(0, before.length - 1);
}
}
function isRuntimeContextPromptHeader(line: string): boolean {
return (
line === OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER || line === OPENCLAW_RUNTIME_EVENT_HEADER
);
}
function stripRuntimeContextPromptPreface(text: string): string {
const lines = text.split(/\r?\n/);
let changed = false;
const output: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1] ?? "";
if (
isRuntimeContextPromptHeader(line.trim()) &&
nextLine.trim() === OPENCLAW_RUNTIME_CONTEXT_NOTICE
) {
changed = true;
index += 1;
while (index + 1 < lines.length && (lines[index + 1] ?? "").trim() === "") {
index += 1;
}
continue;
}
output.push(line);
}
return changed
? output
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
: text;
}
function stripInternalRuntimeContext(text: string): string {
if (!text) {
return text;
}
const withoutDelimitedBlocks = stripDelimitedBlock(
text,
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
);
return stripRuntimeContextPromptPreface(
stripLegacyInternalRuntimeContext(withoutDelimitedBlocks),
);
}
function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined {
const trimmed = baseUrl.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? { allowedHostnames: [parsed.hostname] }
: undefined;
} catch {
return undefined;
}
}
function estimateStringChars(text: string): number {
const nonLatinCount =
text.match(/[\u2E80-\u9FFF\uA000-\uA4FF\uAC00-\uD7AF\uF900-\uFAFF\u{20000}-\u{2FA1F}]/gu)
?.length ?? 0;
return text.length + nonLatinCount * (CHARS_PER_TOKEN_ESTIMATE - 1);
}
async function runTasksWithConcurrency<T>(params: {
tasks: Array<() => Promise<T>>;
limit: number;
errorMode?: "continue" | "stop";
onTaskError?: (error: unknown, index: number) => void;
}): Promise<{ results: T[]; firstError: unknown; hasError: boolean }> {
const results: T[] = Array.from({ length: params.tasks.length });
let cursor = 0;
let firstError: unknown;
let hasError = false;
const limit = Math.max(1, Math.min(params.limit, params.tasks.length || 1));
const workers = Array.from({ length: limit }, async () => {
while (cursor < params.tasks.length && !(params.errorMode === "stop" && hasError)) {
const index = cursor++;
try {
results[index] = await params.tasks[index]();
} catch (error) {
firstError ??= error;
hasError = true;
params.onTaskError?.(error, index);
}
}
});
await Promise.allSettled(workers);
return { results, firstError, hasError };
}
function mimeTypeFromFilePath(filePath?: string): string | undefined {
const ext = filePath ? path.extname(filePath).toLowerCase() : "";
const byExt: Record<string, string> = {
".gif": "image/gif",
".heic": "image/heic",
".heif": "image/heif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
return byExt[ext];
}
const SECRET_PATTERNS: RegExp[] = [
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g,
/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature)=([^&\s"'<>]+)/gi,
/"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"/g,
/--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1/g,
/Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
/\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
/(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd)=([^\s&#]+)/g,
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
/\b(ghp_[A-Za-z0-9]{20,})\b/g,
/\b(github_pat_[A-Za-z0-9_]{20,})\b/g,
/\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g,
/\b(xapp-[A-Za-z0-9-]{10,})\b/g,
/\b(gsk_[A-Za-z0-9_-]{10,})\b/g,
/\b(AIza[0-9A-Za-z\-_]{20,})\b/g,
/\b(pplx-[A-Za-z0-9_-]{10,})\b/g,
/\b(npm_[A-Za-z0-9]{10,})\b/g,
/\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
/\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
];
function maskToken(token: string): string {
if (token.length < 18) {
return "***";
}
return `${token.slice(0, 6)}...${token.slice(-4)}`;
}
function redactPemBlock(block: string): string {
const lines = block.split(/\r?\n/).filter(Boolean);
if (lines.length < 2) {
return "***";
}
return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`;
}
function redactMatch(match: string, groups: string[]): string {
if (match.includes("PRIVATE KEY-----")) {
return redactPemBlock(match);
}
const token = groups.findLast((value) => typeof value === "string" && value.length > 0) ?? match;
const masked = maskToken(token);
return token === match ? masked : match.replace(token, masked);
}
function redactSensitiveText(text: string): string {
let next = text;
for (const pattern of SECRET_PATTERNS) {
next = next.replace(pattern, (...args: string[]) =>
redactMatch(args[0] ?? "", args.slice(1, -2)),
);
}
return next;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -213,4 +213,34 @@ describe("buildSessionEntry", () => {
expect(entry!.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up.");
expect(entry!.lineMap).toEqual([2, 3]);
});
it("strips internal runtime context blocks", async () => {
const jsonlLines = [
JSON.stringify({
type: "message",
message: {
role: "user",
content: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"OpenClaw runtime context (internal):",
"This context is runtime-generated, not user-authored. Keep internal details private.",
"",
"[Internal task completion event]",
"source: subagent",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n"),
},
}),
JSON.stringify({ type: "message", message: { role: "assistant", content: "NO_REPLY" } }),
JSON.stringify({ type: "message", message: { role: "user", content: "Actual user text" } }),
];
const filePath = path.join(tmpDir, "internal-context-session.jsonl");
fsSync.writeFileSync(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry!.content).toBe("User: Actual user text");
expect(entry!.lineMap).toEqual([3]);
});
});

View File

@@ -2,22 +2,7 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { hashText } from "./hash.js";
import { createSubsystemLogger, redactSensitiveText } from "./openclaw-runtime-io.js";
import {
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
hasInterSessionUserProvenance,
isCompactionCheckpointTranscriptFileName,
isCronRunSessionKey,
isExecCompletionEvent,
isHeartbeatUserMessage,
isSessionArchiveArtifactName,
isSilentReplyPayloadText,
isUsageCountedSessionTranscriptFileName,
resolveSessionTranscriptsDirForAgent,
stripInboundMetadata,
stripInternalRuntimeContext,
} from "./openclaw-runtime-session.js";
import { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, getMemoryHostServices } from "./services.js";
const DREAMING_NARRATIVE_RUN_PREFIX = "dreaming-narrative-";
// Keep the historical one-line-per-message export shape for normal turns, but
@@ -64,7 +49,8 @@ type SessionTranscriptStoreEntry = {
function shouldSkipTranscriptFileForDreaming(absPath: string): boolean {
const fileName = path.basename(absPath);
return (
isSessionArchiveArtifactName(fileName) || isCompactionCheckpointTranscriptFileName(fileName)
getMemoryHostServices().session.isSessionArchiveArtifactName(fileName) ||
getMemoryHostServices().session.isCompactionCheckpointTranscriptFileName(fileName)
);
}
@@ -184,7 +170,7 @@ export function loadSessionTranscriptClassificationForSessionsDir(
if (isDreamingNarrativeSessionStoreKey(sessionKey)) {
dreamingTranscriptPaths.add(transcriptPath);
}
if (isCronRunSessionKey(sessionKey)) {
if (getMemoryHostServices().session.isCronRunSessionKey(sessionKey)) {
cronRunTranscriptPaths.add(transcriptPath);
}
}
@@ -218,7 +204,7 @@ export function loadSessionTranscriptClassificationForAgent(
agentId: string,
): SessionTranscriptClassification {
return loadSessionTranscriptClassificationForSessionsDir(
resolveSessionTranscriptsDirForAgent(agentId),
getMemoryHostServices().session.resolveSessionTranscriptsDirForAgent(agentId),
);
}
@@ -237,13 +223,15 @@ function classifySessionTranscriptFromSessionStore(absPath: string): {
}
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
const dir = resolveSessionTranscriptsDirForAgent(agentId);
const dir = getMemoryHostServices().session.resolveSessionTranscriptsDirForAgent(agentId);
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => isUsageCountedSessionTranscriptFileName(name))
.filter((name) =>
getMemoryHostServices().session.isUsageCountedSessionTranscriptFileName(name),
)
.map((name) => path.join(dir, name));
} catch {
return [];
@@ -255,7 +243,9 @@ export function sessionPathForFile(absPath: string): string {
}
async function logSessionFileReadFailure(absPath: string, err: unknown): Promise<void> {
createSubsystemLogger("memory").debug(`Failed reading session file ${absPath}: ${String(err)}`);
getMemoryHostServices()
.io.createSubsystemLogger("memory")
.debug(`Failed reading session file ${absPath}: ${String(err)}`);
}
function normalizeSessionText(value: string): string {
@@ -361,7 +351,7 @@ function stripInboundMetadataForUserRole(text: string, role: "user" | "assistant
if (role !== "user") {
return text;
}
return stripInboundMetadata(text);
return getMemoryHostServices().session.stripInboundMetadata(text);
}
const GENERATED_SYSTEM_MESSAGE_RE = /^System(?: \(untrusted\))?: \[[^\]]+\]\s*/;
@@ -381,12 +371,19 @@ function isGeneratedCronPromptMessage(text: string, role: "user" | "assistant"):
}
function isGeneratedHeartbeatPromptMessage(text: string, role: "user" | "assistant"): boolean {
return role === "user" && isHeartbeatUserMessage({ role, content: text }, HEARTBEAT_PROMPT);
return (
role === "user" &&
getMemoryHostServices().session.isHeartbeatUserMessage(
{ role, content: text },
HEARTBEAT_PROMPT,
)
);
}
function sanitizeSessionText(text: string, role: "user" | "assistant"): string | null {
const strippedInbound = stripInboundMetadataForUserRole(text, role);
const strippedInternal = stripInternalRuntimeContext(strippedInbound);
const strippedInternal =
getMemoryHostServices().session.stripInternalRuntimeContext(strippedInbound);
const normalized = normalizeSessionText(strippedInternal);
if (!normalized) {
return null;
@@ -400,7 +397,7 @@ function sanitizeSessionText(text: string, role: "user" | "assistant"): string |
if (isGeneratedHeartbeatPromptMessage(normalized, role)) {
return null;
}
if (isSilentReplyPayloadText(normalized)) {
if (getMemoryHostServices().session.isSilentReplyPayloadText(normalized)) {
return null;
}
// Assistant-side machinery acks: HEARTBEAT_OK is the canonical "all clear,
@@ -411,7 +408,7 @@ function sanitizeSessionText(text: string, role: "user" | "assistant"): string |
return null;
}
const withoutSystemEnvelope = normalized.replace(GENERATED_SYSTEM_MESSAGE_RE, "").trim();
if (isExecCompletionEvent(withoutSystemEnvelope)) {
if (getMemoryHostServices().session.isExecCompletionEvent(withoutSystemEnvelope)) {
return null;
}
return normalized;
@@ -513,7 +510,10 @@ export async function buildSessionEntry(
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
if (message.role === "user" && hasInterSessionUserProvenance(message)) {
if (
message.role === "user" &&
getMemoryHostServices().session.hasInterSessionUserProvenance(message)
) {
continue;
}
const rawText = collectRawSessionText(message.content);
@@ -534,7 +534,7 @@ export async function buildSessionEntry(
if (generatedByDreamingNarrative || generatedByCronRun) {
continue;
}
const safe = redactSensitiveText(text, { mode: "tools" });
const safe = getMemoryHostServices().io.redactSensitiveText(text, { mode: "tools" });
const label = message.role === "user" ? "User" : "Assistant";
const renderedLines = renderSessionExportLines(label, safe);
const timestampMs = parseSessionTimestampMs(

View File

@@ -1,11 +1,4 @@
// Focused runtime contract for memory CLI/UI helpers.
// CLI helpers are core-bound by the workspace facade. The package surface
// intentionally keeps no core import.
export { formatErrorMessage, withManager } from "./host/openclaw-runtime-cli.js";
export { formatHelpExamples } from "./host/openclaw-runtime-cli.js";
export { resolveCommandSecretRefsViaGateway } from "./host/openclaw-runtime-cli.js";
export { withProgress, withProgressTotals } from "./host/openclaw-runtime-cli.js";
export { defaultRuntime } from "./host/openclaw-runtime-cli.js";
export { formatDocsLink } from "./host/openclaw-runtime-cli.js";
export { colorize, isRich, theme } from "./host/openclaw-runtime-cli.js";
export { isVerbose, setVerbose } from "./host/openclaw-runtime-cli.js";
export { shortenHomeInString, shortenHomePath } from "./host/openclaw-runtime-cli.js";
export type MemoryHostCliRuntime = never;

View File

@@ -1,41 +1,16 @@
// Focused runtime contract for memory plugin config/state/helpers.
// Package-local memory runtime contract. Core binds richer OpenClaw services
// through src/memory-host-sdk; this package stays host-agnostic.
export type { AnyAgentTool } from "./host/openclaw-runtime-agent.js";
export { resolveCronStyleNow } from "./host/openclaw-runtime-agent.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "./host/openclaw-runtime-agent.js";
export { resolveDefaultAgentId, resolveSessionAgentId } from "./host/openclaw-runtime-agent.js";
export { resolveMemorySearchConfig } from "./host/openclaw-runtime-agent.js";
export {
asToolParamsRecord,
jsonResult,
readNumberParam,
readStringParam,
} from "./host/openclaw-runtime-agent.js";
export { SILENT_REPLY_TOKEN } from "./host/openclaw-runtime-session.js";
export { parseNonNegativeByteSize } from "./host/openclaw-runtime-config.js";
SILENT_REPLY_TOKEN,
getMemoryHostServices,
setMemoryHostServices,
withMemoryHostServices,
type MemoryHostServices,
} from "./host/services.js";
export {
getRuntimeConfig,
/** @deprecated Use getRuntimeConfig(), or pass the already loaded config through the call path. */
loadConfig,
} from "./host/openclaw-runtime-config.js";
export { resolveStateDir } from "./host/openclaw-runtime-config.js";
export { resolveSessionTranscriptsDirForAgent } from "./host/openclaw-runtime-config.js";
export { emptyPluginConfigSchema } from "./host/openclaw-runtime-memory.js";
export {
buildActiveMemoryPromptSection,
getMemoryCapabilityRegistration,
listActiveMemoryPublicArtifacts,
} from "./host/openclaw-runtime-memory.js";
export { parseAgentSessionKey } from "./host/openclaw-runtime-agent.js";
export type { OpenClawConfig } from "./host/openclaw-runtime-config.js";
export type { MemoryCitationsMode } from "./host/openclaw-runtime-config.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
} from "./host/openclaw-runtime-memory.js";
export type { OpenClawPluginApi } from "./host/openclaw-runtime-memory.js";
resolveMemorySearchConfig,
resolveStateDir,
type MemoryCitationsMode,
type OpenClawConfig,
} from "./host/config-utils.js";

View File

@@ -1,108 +0,0 @@
# Thread memory isolation
```yaml qa-scenario
id: thread-memory-isolation
title: Thread memory isolation
surface: memory
coverage:
primary:
- memory.thread-isolation
secondary:
- channels.threads
objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
successCriteria:
- Agent uses memory tools inside the thread.
- The hidden fact is answered correctly in the thread.
- No root-channel outbound message leaks during the threaded memory reply.
docsRefs:
- docs/concepts/memory-search.md
- docs/channels/qa-channel.md
- docs/channels/group-messages.md
codeRefs:
- extensions/memory-core/src/tools.ts
- extensions/qa-channel/src/protocol.ts
- extensions/qa-lab/src/suite.ts
execution:
kind: flow
summary: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
config:
memoryFact: "Thread-hidden codename: ORBIT-22."
memoryQuery: "hidden thread codename ORBIT-22"
expectedNeedle: "ORBIT-22"
channelId: qa-room
channelTitle: QA Room
threadTitle: "Thread memory QA"
prompt: "@openclaw Thread memory check: what is the hidden thread codename stored only in memory? Use memory tools first and reply only in this thread."
promptSnippet: "Thread memory check"
```
```yaml qa-flow
steps:
- name: answers the memory-backed fact inside the thread only
actions:
- call: reset
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
- expr: "`${config.memoryFact}\\n`"
- utf8
- call: forceMemoryIndex
args:
- env:
ref: env
query:
expr: config.memoryQuery
expectedNeedle:
expr: config.expectedNeedle
- call: handleQaAction
saveAs: threadPayload
args:
- env:
ref: env
action: thread-create
args:
channelId:
expr: config.channelId
title:
expr: config.threadTitle
- set: threadId
value:
expr: "threadPayload?.thread?.id"
- assert:
expr: Boolean(threadId)
message: missing thread id for memory isolation check
- set: beforeCursor
value:
expr: state.getSnapshot().messages.length
- call: state.addInboundMessage
args:
- conversation:
id:
expr: config.channelId
kind: channel
title:
expr: config.channelTitle
senderId: alice
senderName: Alice
text:
expr: config.prompt
threadId:
ref: threadId
threadTitle:
expr: config.threadTitle
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === config.channelId && candidate.threadId === threadId && candidate.text.includes(config.expectedNeedle)"
- expr: liveTurnTimeoutMs(env, 45000)
- assert:
expr: "!state.getSnapshot().messages.slice(beforeCursor).some((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.channelId && !candidate.threadId)"
message: threaded memory answer leaked into root channel
- assert:
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).some((request) => request.plannedToolName === 'memory_search')"
message: expected memory_search in thread memory flow
detailsExpr: outbound.text
```

View File

@@ -0,0 +1,111 @@
title: Thread memory isolation
scenario:
id: thread-memory-isolation
surface: memory
coverage:
primary:
- memory.thread-isolation
secondary:
- channels.threads
objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
successCriteria:
- Agent uses memory tools inside the thread.
- The hidden fact is answered correctly in the thread.
- No root-channel outbound message leaks during the threaded memory reply.
docsRefs:
- docs/concepts/memory-search.md
- docs/channels/qa-channel.md
- docs/channels/group-messages.md
codeRefs:
- extensions/memory-core/src/tools.ts
- extensions/qa-channel/src/protocol.ts
- extensions/qa-lab/src/suite.ts
execution:
kind: flow
summary: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel.
config:
memoryFact: "Thread-hidden codename: ORBIT-22."
memoryQuery: "hidden thread codename ORBIT-22"
expectedNeedle: "ORBIT-22"
channelId: qa-room
channelTitle: QA Room
threadTitle: "Thread memory QA"
prompt: "@openclaw Thread memory check: what is the hidden thread codename stored only in memory? Use memory tools first and reply only in this thread."
promptSnippet: "Thread memory check"
flow:
steps:
- name: answers the memory-backed fact inside the thread only
actions:
- call: reset
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
- expr: "`${config.memoryFact}\\n`"
- utf8
- call: forceMemoryIndex
args:
- env:
ref: env
query:
expr: config.memoryQuery
expectedNeedle:
expr: config.expectedNeedle
- call: waitForGatewayHealthy
args:
- ref: env
- 60000
- call: waitForQaChannelReady
args:
- ref: env
- 60000
- call: handleQaAction
saveAs: threadPayload
args:
- env:
ref: env
action: thread-create
args:
channelId:
expr: config.channelId
title:
expr: config.threadTitle
- set: threadId
value:
expr: "threadPayload?.thread?.id"
- assert:
expr: Boolean(threadId)
message: missing thread id for memory isolation check
- set: beforeCursor
value:
expr: state.getSnapshot().messages.length
- call: state.addInboundMessage
args:
- conversation:
id:
expr: config.channelId
kind: channel
title:
expr: config.channelTitle
senderId: alice
senderName: Alice
text:
expr: config.prompt
threadId:
ref: threadId
threadTitle:
expr: config.threadTitle
- call: waitForOutboundMessage
saveAs: outbound
args:
- ref: state
- lambda:
params: [candidate]
expr: "candidate.conversation.id === config.channelId && candidate.threadId === threadId && candidate.text.includes(config.expectedNeedle)"
- expr: liveTurnTimeoutMs(env, 45000)
- assert:
expr: "!state.getSnapshot().messages.slice(beforeCursor).some((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.channelId && !candidate.threadId)"
message: threaded memory answer leaked into root channel
- assert:
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).some((request) => request.plannedToolName === 'memory_search')"
message: expected memory_search in thread memory flow
detailsExpr: outbound.text

View File

@@ -101,6 +101,9 @@ function getColorForConsole(): ChalkInstance {
if (process.env.NO_COLOR && !hasForceColor) {
return new Chalk({ level: 0 });
}
if (hasForceColor) {
return new Chalk({ level: 1 });
}
const hasTty = process.stdout.isTTY || process.stderr.isTTY;
return hasTty || isRichConsoleEnv() ? new Chalk({ level: 1 }) : new Chalk({ level: 0 });
}

View File

@@ -1 +1,71 @@
export * from "../../packages/memory-host-sdk/src/engine-embeddings.js";
export {
getMemoryEmbeddingProvider,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
listRegisteredMemoryEmbeddingProviders,
} from "../plugins/memory-embedding-provider-runtime.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderCreateResult,
MemoryEmbeddingProviderRuntime,
} from "../plugins/memory-embedding-providers.js";
export { createLocalEmbeddingProvider, DEFAULT_LOCAL_MODEL } from "./host/embeddings.js";
export { extractBatchErrorMessage, formatUnavailableBatchError } from "./host/batch-error-utils.js";
export { postJsonWithRetry } from "./host/batch-http.js";
export { applyEmbeddingBatchOutputLine } from "./host/batch-output.js";
export {
EMBEDDING_BATCH_ENDPOINT,
type EmbeddingBatchStatus,
type ProviderBatchOutputLine,
} from "./host/batch-provider-common.js";
export {
buildEmbeddingBatchGroupOptions,
runEmbeddingBatchGroups,
type EmbeddingBatchExecutionParams,
} from "./host/batch-runner.js";
export {
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
throwIfBatchTerminalFailure,
type BatchCompletionResult,
} from "./host/batch-status.js";
export { uploadBatchJsonlFile } from "./host/batch-upload.js";
export {
buildBatchHeaders,
normalizeBatchBaseUrl,
type BatchHttpClientConfig,
} from "./host/batch-utils.js";
export { enforceEmbeddingMaxInputTokens } from "./host/embedding-chunk-limits.js";
export {
isMissingEmbeddingApiKeyError,
mapBatchEmbeddingsByIndex,
sanitizeEmbeddingCacheHeaders,
} from "./host/embedding-provider-adapter-utils.js";
export { sanitizeAndNormalizeEmbedding } from "./host/embedding-vectors.js";
export { debugEmbeddingsLog } from "./host/embeddings-debug.js";
export { normalizeEmbeddingModelWithPrefixes } from "./host/embeddings-model-normalize.js";
export {
resolveRemoteEmbeddingBearerClient,
type RemoteEmbeddingProviderId,
} from "./host/embeddings-remote-client.js";
export {
createRemoteEmbeddingProvider,
resolveRemoteEmbeddingClient,
type RemoteEmbeddingClient,
} from "./host/embeddings-remote-provider.js";
export { fetchRemoteEmbeddingVectors } from "./host/embeddings-remote-fetch.js";
export {
estimateStructuredEmbeddingInputBytes,
estimateUtf8Bytes,
} from "./host/embedding-input-limits.js";
export { hasNonTextEmbeddingParts, type EmbeddingInput } from "./host/embedding-inputs.js";
export { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./host/remote-http.js";
export {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "./host/multimodal.js";

View File

@@ -1 +1,46 @@
export * from "../../packages/memory-host-sdk/src/engine-foundation.js";
export {
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../agents/agent-scope.js";
export {
resolveMemorySearchConfig,
resolveMemorySearchSyncConfig,
type ResolvedMemorySearchConfig,
type ResolvedMemorySearchSyncConfig,
} from "../agents/memory-search.js";
export { parseDurationMs } from "../cli/parse-duration.js";
export { loadConfig } from "../config/config.js";
export type { OpenClawConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
type SecretInput,
} from "../config/types.secrets.js";
export type { SessionSendPolicyConfig } from "../config/types.base.js";
export type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
} from "../config/types.memory.js";
export type { MemorySearchConfig } from "../config/types.tools.js";
export { writeFileWithinRoot } from "../infra/fs-safe.js";
export { createSubsystemLogger } from "../logging/subsystem.js";
export { detectMime } from "../media/mime.js";
export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
export { resolveGlobalSingleton } from "../shared/global-singleton.js";
export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
export { splitShellArgs } from "../utils/shell-argv.js";
export {
resolveUserPath,
shortenHomeInString,
shortenHomePath,
truncateUtf16Safe,
} from "../utils.js";

View File

@@ -1 +1,24 @@
export * from "../../packages/memory-host-sdk/src/engine-qmd.js";
export { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js";
export { extractKeywords, isQueryStopWordToken } from "./host/query-expansion.js";
export {
buildSessionEntry,
listSessionFilesForAgent,
loadDreamingNarrativeTranscriptPathSetForAgent,
loadSessionTranscriptClassificationForAgent,
normalizeSessionTranscriptPathForComparison,
sessionPathForFile,
type BuildSessionEntryOptions,
type SessionFileEntry,
type SessionTranscriptClassification,
} from "./host/session-files.js";
export { parseQmdQueryJson, type QmdQueryResult } from "./host/qmd-query-parser.js";
export {
deriveQmdScopeChannel,
deriveQmdScopeChatType,
isQmdScopeAllowed,
} from "./host/qmd-scope.js";
export {
checkQmdBinaryAvailability,
resolveCliSpawnInvocation,
runCliCommand,
} from "./host/qmd-process.js";

View File

@@ -1 +1,46 @@
export * from "../../packages/memory-host-sdk/src/engine-storage.js";
export {
buildFileEntry,
buildMultimodalChunkForIndexing,
chunkMarkdown,
cosineSimilarity,
ensureDir,
hashText,
listMemoryFiles,
normalizeExtraMemoryPaths,
parseEmbedding,
remapChunkLines,
runWithConcurrency,
type MemoryChunk,
type MemoryFileEntry,
} from "./host/internal.js";
export { readMemoryFile } from "./host/read-file.js";
export {
buildMemoryReadResult,
buildMemoryReadResultFromSlice,
DEFAULT_MEMORY_READ_LINES,
DEFAULT_MEMORY_READ_MAX_CHARS,
type MemoryReadResult,
} from "./host/read-file-shared.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "./host/backend-config.js";
export type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchRuntimeDebug,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./host/types.js";
export { ensureMemoryIndexSchema } from "./host/memory-schema.js";
export { loadSqliteVecExtension } from "./host/sqlite-vec.js";
export {
closeMemorySqliteWalMaintenance,
configureMemorySqliteWalMaintenance,
requireNodeSqlite,
} from "./host/sqlite.js";
export { isFileMissingError, statRegularFile } from "./host/fs-utils.js";

View File

@@ -1 +1,4 @@
export * from "../../packages/memory-host-sdk/src/engine.js";
export * from "./engine-foundation.js";
export * from "./engine-storage.js";
export * from "./engine-embeddings.js";
export * from "./engine-qmd.js";

View File

@@ -1 +1,9 @@
export * from "../../packages/memory-host-sdk/src/runtime-cli.js";
export { formatErrorMessage, withManager } from "../cli/cli-utils.js";
export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
export { formatHelpExamples } from "../cli/help-format.js";
export { withProgress, withProgressTotals } from "../cli/progress.js";
export { isVerbose, setVerbose } from "../globals.js";
export { defaultRuntime } from "../runtime.js";
export { formatDocsLink } from "../terminal/links.js";
export { colorize, isRich, theme } from "../terminal/theme.js";
export { shortenHomeInString, shortenHomePath } from "../utils.js";

View File

@@ -1 +1,39 @@
export * from "../../packages/memory-host-sdk/src/runtime-core.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js";
export {
asToolParamsRecord,
jsonResult,
readNumberParam,
readStringParam,
type AnyAgentTool,
} from "../agents/tools/common.js";
export { resolveCronStyleNow } from "../agents/current-time.js";
export { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js";
export { resolveMemorySearchConfig } from "../agents/memory-search.js";
export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
export { parseNonNegativeByteSize } from "../config/byte-size.js";
export {
getRuntimeConfig,
/** @deprecated Use getRuntimeConfig(), or pass the already loaded config through the call path. */
loadConfig,
} from "../config/config.js";
export type { OpenClawConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export type { MemoryCitationsMode } from "../config/types.memory.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export {
buildMemoryPromptSection as buildActiveMemoryPromptSection,
getMemoryCapabilityRegistration,
listActiveMemoryPublicArtifacts,
} from "../plugins/memory-state.js";
export type {
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
} from "../plugins/memory-state.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export { parseAgentSessionKey } from "../routing/session-key.js";

View File

@@ -1 +1,16 @@
export * from "../../packages/memory-host-sdk/src/runtime-files.js";
export { readAgentMemoryFile, readMemoryFile } from "./host/read-file.js";
export { listMemoryFiles, normalizeExtraMemoryPaths } from "./host/internal.js";
export {
buildMemoryReadResult,
buildMemoryReadResultFromSlice,
DEFAULT_MEMORY_READ_LINES,
DEFAULT_MEMORY_READ_MAX_CHARS,
type MemoryReadResult,
} from "./host/read-file-shared.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
ResolvedQmdMcporterConfig,
} from "./host/backend-config.js";
export type { MemorySearchResult, MemorySearchRuntimeDebug } from "./host/types.js";

View File

@@ -1 +1,3 @@
export * from "../../packages/memory-host-sdk/src/runtime.js";
export * from "./runtime-core.js";
export * from "./runtime-cli.js";
export * from "./runtime-files.js";

View File

@@ -1,5 +1,20 @@
export * from "../../packages/memory-host-sdk/src/engine-embeddings.js";
export {
getMemoryEmbeddingProvider,
listMemoryEmbeddingProviders,
listRegisteredMemoryEmbeddingProviderAdapters,
listRegisteredMemoryEmbeddingProviders,
} from "../plugins/memory-embedding-provider-runtime.js";
export {
clearMemoryEmbeddingProviders,
registerMemoryEmbeddingProvider,
} from "../plugins/memory-embedding-providers.js";
export type {
MemoryEmbeddingBatchChunk,
MemoryEmbeddingBatchOptions,
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderCreateResult,
MemoryEmbeddingProviderRuntime,
} from "../plugins/memory-embedding-providers.js";

View File

@@ -1 +1,56 @@
export * from "../../packages/memory-host-sdk/src/engine-foundation.js";
export {
CHARS_PER_TOKEN_ESTIMATE,
HEARTBEAT_PROMPT,
HEARTBEAT_TOKEN,
SILENT_REPLY_TOKEN,
getMemoryHostServices,
setMemoryHostServices,
withMemoryHostServices,
type MemoryHostServices,
} from "../../packages/memory-host-sdk/src/engine-foundation.js";
export {
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../agents/agent-scope.js";
export {
resolveMemorySearchConfig,
resolveMemorySearchSyncConfig,
type ResolvedMemorySearchConfig,
type ResolvedMemorySearchSyncConfig,
} from "../agents/memory-search.js";
export { parseDurationMs } from "../cli/parse-duration.js";
export { loadConfig } from "../config/config.js";
export type { OpenClawConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
type SecretInput,
} from "../config/types.secrets.js";
export type { SessionSendPolicyConfig } from "../config/types.base.js";
export type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
} from "../config/types.memory.js";
export type { MemorySearchConfig } from "../config/types.tools.js";
export { writeFileWithinRoot } from "../infra/fs-safe.js";
export { createSubsystemLogger } from "../logging/subsystem.js";
export { detectMime } from "../media/mime.js";
export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
export { resolveGlobalSingleton } from "../shared/global-singleton.js";
export { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
export { splitShellArgs } from "../utils/shell-argv.js";
export {
resolveUserPath,
shortenHomeInString,
shortenHomePath,
truncateUtf16Safe,
} from "../utils.js";

View File

@@ -1 +1,10 @@
export * from "../../packages/memory-host-sdk/src/runtime-cli.js";
export { formatErrorMessage, withManager } from "../cli/cli-utils.js";
export { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
export { formatHelpExamples } from "../cli/help-format.js";
export { withProgress, withProgressTotals } from "../cli/progress.js";
export { isVerbose, setVerbose } from "../globals.js";
export { defaultRuntime } from "../runtime.js";
export { formatDocsLink } from "../terminal/links.js";
export { colorize, isRich, theme } from "../terminal/theme.js";
export { shortenHomeInString, shortenHomePath } from "../utils.js";

View File

@@ -1,13 +1,49 @@
export * from "../../packages/memory-host-sdk/src/runtime-core.js";
export {
SILENT_REPLY_TOKEN,
getMemoryHostServices,
setMemoryHostServices,
withMemoryHostServices,
type MemoryHostServices,
} from "../../packages/memory-host-sdk/src/runtime-core.js";
export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../agents/pi-settings.js";
export {
asToolParamsRecord,
jsonResult,
readNumberParam,
readStringParam,
type AnyAgentTool,
} from "../agents/tools/common.js";
export { resolveCronStyleNow } from "../agents/current-time.js";
export { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js";
export { resolveMemorySearchConfig } from "../agents/memory-search.js";
export { parseNonNegativeByteSize } from "../config/byte-size.js";
export { getRuntimeConfig, loadConfig } from "../config/config.js";
export type { OpenClawConfig } from "../config/config.js";
export { resolveStateDir } from "../config/paths.js";
export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
export type { MemoryCitationsMode } from "../config/types.memory.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type {
MemoryCorpusGetResult,
MemoryCorpusSearchResult,
MemoryCorpusSupplement,
MemoryCorpusSupplementRegistration,
MemoryFlushPlan,
MemoryFlushPlanResolver,
MemoryPluginCapability,
MemoryPluginPublicArtifact,
MemoryPluginPublicArtifactsProvider,
MemoryPluginRuntime,
MemoryPromptSectionBuilder,
} from "../plugins/memory-state.js";
export {
buildMemoryPromptSection as buildActiveMemoryPromptSection,
clearMemoryPluginState,
getMemoryCapabilityRegistration,
listActiveMemoryPublicArtifacts,
listMemoryCorpusSupplements,
registerMemoryCapability,
registerMemoryCorpusSupplement,
} from "../plugins/memory-state.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export { parseAgentSessionKey } from "../routing/session-key.js";

View File

@@ -55,20 +55,6 @@ const MEMORY_HOST_SDK_EXPORTS = [
"./secret",
"./status",
] as const;
const MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES = [
"packages/memory-host-sdk/src/host/openclaw-runtime.ts",
] as const;
const MEMORY_HOST_SDK_RUNTIME_ADAPTER_FILES = [
"packages/memory-host-sdk/src/host/openclaw-runtime-agent.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-auth.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-cli.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-config.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-io.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-network.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-session.ts",
] as const;
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe JSON file shape.
function readJsonFile<T>(relativePath: string): T {
return JSON.parse(readFileSync(resolve(REPO_ROOT, relativePath), "utf8")) as T;
@@ -253,12 +239,11 @@ describe("opt-in extension package boundaries", () => {
expect(source, target).not.toContain("src/memory-host-sdk/");
}
expect(collectCoreReferenceFiles("packages/memory-host-sdk/src")).toEqual([
...MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES,
]);
expect(collectOpenClawRuntimeDirectImportFiles("packages/memory-host-sdk/src")).toEqual([
...MEMORY_HOST_SDK_RUNTIME_ADAPTER_FILES,
]);
expect(collectCoreReferenceFiles("packages/memory-host-sdk/src")).toEqual([]);
expect(collectOpenClawRuntimeDirectImportFiles("packages/memory-host-sdk/src")).toEqual([]);
expect(existsSync(resolve(REPO_ROOT, "packages/memory-host-sdk/src/host/services.ts"))).toBe(
true,
);
});
it("keeps plugin-package-contract independent from core internals", () => {

View File

@@ -30,14 +30,16 @@ describe("plugin-boundary-report", () => {
unusedReservedCount?: unknown;
};
memoryHostSdk?: {
packageCoreReferenceFileCount?: unknown;
sourceBridgeFileCount?: unknown;
implementation?: unknown;
};
};
expect(summary.pluginSdk?.crossOwnerReservedImportCount).toBe(0);
expect(summary.pluginSdk?.unusedReservedCount).toBe(0);
expect(["private-core-bridge", "private-package-core-integrated"]).toContain(
summary.memoryHostSdk?.implementation,
);
expect(summary.memoryHostSdk?.sourceBridgeFileCount).toBe(0);
expect(summary.memoryHostSdk?.packageCoreReferenceFileCount).toBe(0);
expect(summary.memoryHostSdk?.implementation).toBe("package-owned");
});
});