重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,21 @@
{
"name": "@openclaw/memory-host-sdk",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
"./runtime": "./src/runtime.ts",
"./runtime-core": "./src/runtime-core.ts",
"./runtime-cli": "./src/runtime-cli.ts",
"./runtime-files": "./src/runtime-files.ts",
"./engine": "./src/engine.ts",
"./engine-foundation": "./src/engine-foundation.ts",
"./engine-storage": "./src/engine-storage.ts",
"./engine-embeddings": "./src/engine-embeddings.ts",
"./engine-qmd": "./src/engine-qmd.ts",
"./multimodal": "./src/multimodal.ts",
"./query": "./src/query.ts",
"./secret": "./src/secret.ts",
"./status": "./src/status.ts"
}
}

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/engine-embeddings.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/engine-foundation.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/engine-qmd.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/engine-storage.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/engine.js";

View file

@ -0,0 +1,591 @@
import syncFs from "node:fs";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
const resolveComparablePath = (value: string, workspaceDir = "/workspace/root"): string =>
path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value);
describe("resolveMemoryBackendConfig", () => {
it("defaults to builtin backend when config missing", () => {
const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } } } as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.backend).toBe("builtin");
expect(resolved.citations).toBe("auto");
expect(resolved.qmd).toBeUndefined();
});
it("resolves qmd backend with default collections", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.backend).toBe("qmd");
expect(resolved.qmd?.collections.length).toBe(2);
expect(resolved.qmd?.command).toBe("qmd");
expect(resolved.qmd?.searchMode).toBe("search");
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
expect(resolved.qmd?.update.waitForBootSync).toBe(false);
expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000);
expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000);
expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000);
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("memory-root-main")).toBe(true);
expect(names.has("memory-dir-main")).toBe(true);
expect(names.has("memory-alt-main")).toBe(false);
const rootCollection = resolved.qmd?.collections.find(
(collection) => collection.name === "memory-root-main",
);
expect(rootCollection?.pattern).toBe("MEMORY.md");
});
it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", () => {
const workspaceDir = "/workspace/root";
const legacyEntry = {
name: "memory.md",
isFile: () => true,
isSymbolicLink: () => false,
} as Dirent;
const readdirSpy = vi
.spyOn(syncFs, "readdirSync")
.mockReturnValue([legacyEntry] as unknown as ReturnType<typeof syncFs.readdirSync>);
try {
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const rootCollection = resolved.qmd?.collections.find(
(collection) => collection.name === "memory-root-main",
);
expect(rootCollection?.pattern).toBe("memory.md");
expect(
(resolved.qmd?.collections ?? []).some(
(collection) => collection.name === "memory-alt-main",
),
).toBe(false);
} finally {
readdirSpy.mockRestore();
}
});
it("prefers MEMORY.md over legacy memory.md when both root files exist", () => {
const workspaceDir = "/workspace/root";
const entries = [
{
name: "MEMORY.md",
isFile: () => true,
isSymbolicLink: () => false,
},
{
name: "memory.md",
isFile: () => true,
isSymbolicLink: () => false,
},
] as Dirent[];
const readdirSpy = vi
.spyOn(syncFs, "readdirSync")
.mockReturnValue(entries as unknown as ReturnType<typeof syncFs.readdirSync>);
try {
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const rootCollection = resolved.qmd?.collections.find(
(collection) => collection.name === "memory-root-main",
);
expect(rootCollection?.pattern).toBe("MEMORY.md");
expect(
(resolved.qmd?.collections ?? []).some(
(collection) => collection.name === "memory-alt-main",
),
).toBe(false);
} finally {
readdirSpy.mockRestore();
}
});
it("parses quoted qmd command paths", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
command: '"/Applications/QMD Tools/qmd" --flag',
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.command).toBe("/Applications/QMD Tools/qmd");
});
it("resolves custom paths relative to workspace", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [{ id: "main", workspace: "/workspace/root" }],
},
memory: {
backend: "qmd",
qmd: {
paths: [
{
path: "notes",
name: "custom-notes",
pattern: "**/*.md",
},
],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("custom-notes"));
expect(custom).toBeDefined();
const workspaceRoot = resolveAgentWorkspaceDir(cfg, "main");
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
});
it("scopes qmd collection names per agent", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [
{ id: "main", default: true, workspace: "/workspace/root" },
{ id: "dev", workspace: "/workspace/dev" },
],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
paths: [{ path: "notes", name: "workspace", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
const mainNames = new Set(
(mainResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
const devNames = new Set(
(devResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
expect(mainNames.has("memory-dir-main")).toBe(true);
expect(devNames.has("memory-dir-dev")).toBe(true);
expect(mainNames.has("workspace-main")).toBe(true);
expect(devNames.has("workspace-dev")).toBe(true);
});
it("merges default and per-agent qmd extra collections", () => {
const cfg = {
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
qmd: {
extraCollections: [
{
path: "/shared/team-notes",
name: "team-notes",
pattern: "**/*.md",
},
],
},
},
},
list: [
{
id: "main",
default: true,
workspace: "/workspace/root",
memorySearch: {
qmd: {
extraCollections: [
{
path: "notes",
name: "notes",
pattern: "**/*.md",
},
],
},
},
},
],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("team-notes")).toBe(true);
expect(names.has("notes-main")).toBe(true);
});
it("preserves explicit custom collection names for paths outside the workspace", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [
{ id: "main", default: true, workspace: "/workspace/root" },
{ id: "dev", workspace: "/workspace/dev" },
],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
const mainNames = new Set(
(mainResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
const devNames = new Set(
(devResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
expect(mainNames.has("memory-dir-main")).toBe(true);
expect(devNames.has("memory-dir-dev")).toBe(true);
expect(mainNames.has("notion-mirror")).toBe(true);
expect(devNames.has("notion-mirror")).toBe(true);
});
it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
const workspaceDir = path.join(tmpRoot, "workspace");
const workspaceAliasDir = path.join(tmpRoot, "workspace-alias");
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.symlink(workspaceDir, workspaceAliasDir);
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("workspace-main")).toBe(true);
expect(names.has("workspace")).toBe(false);
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("keeps unresolved child paths under a symlinked workspace agent-scoped", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
const realRootDir = path.join(tmpRoot, "real-root");
const aliasRootDir = path.join(tmpRoot, "alias-root");
const workspaceDir = path.join(realRootDir, "workspace");
const workspaceAliasDir = path.join(aliasRootDir, "workspace");
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.symlink(realRootDir, aliasRootDir);
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
paths: [
{ path: path.join(workspaceAliasDir, "notes"), name: "notes", pattern: "**/*.md" },
],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("notes-main")).toBe(true);
expect(names.has("notes")).toBe(false);
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("resolves qmd update timeout overrides", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
update: {
waitForBootSync: true,
commandTimeoutMs: 12_000,
updateTimeoutMs: 480_000,
embedTimeoutMs: 360_000,
},
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.update.waitForBootSync).toBe(true);
expect(resolved.qmd?.update.commandTimeoutMs).toBe(12_000);
expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000);
expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000);
});
it("resolves qmd search mode override", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
searchMode: "vsearch",
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.searchMode).toBe("vsearch");
});
it("resolves qmd mcporter search tool override", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
searchMode: "query",
searchTool: " hybrid_search ",
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.searchMode).toBe("query");
expect(resolved.qmd?.searchTool).toBe("hybrid_search");
});
});
describe("memorySearch.extraPaths integration", () => {
it("maps agents.defaults.memorySearch.extraPaths to QMD collections", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/home/user/docs", "/home/user/vault"],
},
},
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "test-agent" });
expect(result.backend).toBe("qmd");
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
expect(customCollections.length).toBeGreaterThanOrEqual(2);
expect(customCollections.map((collection) => collection.path)).toEqual(
expect.arrayContaining([
resolveComparablePath("/home/user/docs"),
resolveComparablePath("/home/user/vault"),
]),
);
});
it("merges default and per-agent memorySearch.extraPaths for QMD collections", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/default/path"],
},
},
list: [
{
id: "my-agent",
memorySearch: {
extraPaths: ["/agent/specific/path"],
},
},
],
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
expect(result.backend).toBe("qmd");
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
const paths = customCollections.map((collection) => collection.path);
expect(paths).toContain(resolveComparablePath("/agent/specific/path"));
expect(paths).toContain(resolveComparablePath("/default/path"));
});
it("falls back to defaults when agent has no overrides", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/default/path"],
},
},
list: [
{
id: "other-agent",
memorySearch: {
extraPaths: ["/other/path"],
},
},
],
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
expect(result.backend).toBe("qmd");
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
const paths = customCollections.map((collection) => collection.path);
expect(paths).toContain(resolveComparablePath("/default/path"));
});
it("deduplicates merged memorySearch.extraPaths for QMD collections", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/shared/path", " /shared/path "],
},
},
list: [
{
id: "my-agent",
memorySearch: {
extraPaths: ["/shared/path", "/agent-only"],
},
},
],
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
const paths = customCollections.map((collection) => collection.path);
expect(
paths.filter((collectionPath) => collectionPath === resolveComparablePath("/shared/path")),
).toHaveLength(1);
expect(paths).toContain(resolveComparablePath("/agent-only"));
});
it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/shared/path"],
},
},
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent");
});
it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
},
list: [
{
id: "My-Agent",
memorySearch: {
extraPaths: ["/agent/mixed-case"],
},
},
],
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
expect(customCollections.map((collection) => collection.path)).toContain(
resolveComparablePath("/agent/mixed-case"),
);
});
it("deduplicates identical roots shared by memory.qmd.paths and memorySearch.extraPaths", () => {
const cfg = {
memory: {
backend: "qmd",
qmd: {
paths: [{ path: "docs", pattern: "**/*.md", name: "workspace-docs" }],
},
},
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["./docs"],
},
},
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
const docsCollections = customCollections.filter(
(collection) =>
collection.path === resolveComparablePath("./docs") && collection.pattern === "**/*.md",
);
expect(docsCollections).toHaveLength(1);
});
});

View file

@ -0,0 +1,464 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
import { parseDurationMs } from "../../../../src/cli/parse-duration.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { SessionSendPolicyConfig } from "../../../../src/config/types.base.js";
import type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
MemoryQmdMcporterConfig,
MemoryQmdSearchMode,
} from "../../../../src/config/types.memory.js";
import { normalizeAgentId } from "../../../../src/routing/session-key.js";
import { normalizeLowercaseStringOrEmpty } from "../../../../src/shared/string-coerce.js";
import { resolveUserPath } from "../../../../src/utils.js";
import { splitShellArgs } from "../../../../src/utils/shell-argv.js";
export type ResolvedMemoryBackendConfig = {
backend: MemoryBackend;
citations: MemoryCitationsMode;
qmd?: ResolvedQmdConfig;
};
export type ResolvedQmdCollection = {
name: string;
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
};
export type ResolvedQmdUpdateConfig = {
intervalMs: number;
debounceMs: number;
onBoot: boolean;
waitForBootSync: boolean;
embedIntervalMs: number;
commandTimeoutMs: number;
updateTimeoutMs: number;
embedTimeoutMs: number;
};
export type ResolvedQmdLimitsConfig = {
maxResults: number;
maxSnippetChars: number;
maxInjectedChars: number;
timeoutMs: number;
};
export type ResolvedQmdSessionConfig = {
enabled: boolean;
exportDir?: string;
retentionDays?: number;
};
export type ResolvedQmdMcporterConfig = {
enabled: boolean;
serverName: string;
startDaemon: boolean;
};
export type ResolvedQmdConfig = {
command: string;
mcporter: ResolvedQmdMcporterConfig;
searchMode: MemoryQmdSearchMode;
searchTool?: string;
collections: ResolvedQmdCollection[];
sessions: ResolvedQmdSessionConfig;
update: ResolvedQmdUpdateConfig;
limits: ResolvedQmdLimitsConfig;
includeDefaultMemory: boolean;
scope?: SessionSendPolicyConfig;
};
const DEFAULT_BACKEND: MemoryBackend = "builtin";
const DEFAULT_CITATIONS: MemoryCitationsMode = "auto";
const DEFAULT_QMD_INTERVAL = "5m";
const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
const DEFAULT_QMD_TIMEOUT_MS = 4_000;
// Defaulting to `query` can be extremely slow on CPU-only systems (query expansion + rerank).
// Prefer a faster mode for interactive use; users can opt into `query` for best recall.
const DEFAULT_QMD_SEARCH_MODE: MemoryQmdSearchMode = "search";
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000;
const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_EMBED_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
maxResults: 4,
maxSnippetChars: 450,
maxInjectedChars: 2_200,
timeoutMs: DEFAULT_QMD_TIMEOUT_MS,
};
const DEFAULT_QMD_MCPORTER: ResolvedQmdMcporterConfig = {
enabled: false,
serverName: "qmd",
startDaemon: true,
};
const DEFAULT_QMD_SCOPE: SessionSendPolicyConfig = {
default: "deny",
rules: [
{
action: "allow",
match: { chatType: "direct" },
},
],
};
function sanitizeName(input: string): string {
const lower = normalizeLowercaseStringOrEmpty(input).replace(/[^a-z0-9-]+/g, "-");
const trimmed = lower.replace(/^-+|-+$/g, "");
return trimmed || "collection";
}
function scopeCollectionBase(base: string, agentId: string): string {
return `${base}-${sanitizeName(agentId)}`;
}
function canonicalizePathForContainment(rawPath: string): string {
const resolved = path.resolve(rawPath);
let current = resolved;
const suffix: string[] = [];
while (true) {
try {
const canonical = path.normalize(fs.realpathSync.native(current));
return path.normalize(path.join(canonical, ...suffix));
} catch {
const parent = path.dirname(current);
if (parent === current) {
return path.normalize(resolved);
}
suffix.unshift(path.basename(current));
current = parent;
}
}
}
function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(
canonicalizePathForContainment(rootPath),
canonicalizePathForContainment(candidatePath),
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function ensureUniqueName(base: string, existing: Set<string>): string {
let name = sanitizeName(base);
if (!existing.has(name)) {
existing.add(name);
return name;
}
let suffix = 2;
while (existing.has(`${name}-${suffix}`)) {
suffix += 1;
}
const unique = `${name}-${suffix}`;
existing.add(unique);
return unique;
}
function resolvePath(raw: string, workspaceDir: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("path required");
}
if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) {
return path.normalize(resolveUserPath(trimmed));
}
return path.normalize(path.resolve(workspaceDir, trimmed));
}
function resolveIntervalMs(raw: string | undefined): number {
const value = raw?.trim();
if (!value) {
return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" });
}
try {
return parseDurationMs(value, { defaultUnit: "m" });
} catch {
return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" });
}
}
function resolveEmbedIntervalMs(raw: string | undefined): number {
const value = raw?.trim();
if (!value) {
return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
}
try {
return parseDurationMs(value, { defaultUnit: "m" });
} catch {
return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
}
}
function resolveDebounceMs(raw: number | undefined): number {
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
return Math.floor(raw);
}
return DEFAULT_QMD_DEBOUNCE_MS;
}
function resolveTimeoutMs(raw: number | undefined, fallback: number): number {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.floor(raw);
}
return fallback;
}
function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig {
const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS };
if (raw?.maxResults && raw.maxResults > 0) {
parsed.maxResults = Math.floor(raw.maxResults);
}
if (raw?.maxSnippetChars && raw.maxSnippetChars > 0) {
parsed.maxSnippetChars = Math.floor(raw.maxSnippetChars);
}
if (raw?.maxInjectedChars && raw.maxInjectedChars > 0) {
parsed.maxInjectedChars = Math.floor(raw.maxInjectedChars);
}
if (raw?.timeoutMs && raw.timeoutMs > 0) {
parsed.timeoutMs = Math.floor(raw.timeoutMs);
}
return parsed;
}
function resolveSearchMode(raw?: MemoryQmdConfig["searchMode"]): MemoryQmdSearchMode {
if (raw === "search" || raw === "vsearch" || raw === "query") {
return raw;
}
return DEFAULT_QMD_SEARCH_MODE;
}
function resolveSearchTool(raw?: MemoryQmdConfig["searchTool"]): string | undefined {
const value = raw?.trim();
return value ? value : undefined;
}
function resolveSessionConfig(
cfg: MemoryQmdConfig["sessions"],
workspaceDir: string,
): ResolvedQmdSessionConfig {
const enabled = Boolean(cfg?.enabled);
const exportDirRaw = cfg?.exportDir?.trim();
const exportDir = exportDirRaw ? resolvePath(exportDirRaw, workspaceDir) : undefined;
const retentionDays =
cfg?.retentionDays && cfg.retentionDays > 0 ? Math.floor(cfg.retentionDays) : undefined;
return {
enabled,
exportDir,
retentionDays,
};
}
function resolveCustomPaths(
rawPaths: MemoryQmdIndexPath[] | undefined,
workspaceDir: string,
existing: Set<string>,
agentId: string,
): ResolvedQmdCollection[] {
if (!rawPaths?.length) {
return [];
}
const collections: ResolvedQmdCollection[] = [];
const seenRoots = new Set<string>();
rawPaths.forEach((entry, index) => {
const trimmedPath = entry?.path?.trim();
if (!trimmedPath) {
return;
}
let resolved: string;
try {
resolved = resolvePath(trimmedPath, workspaceDir);
} catch {
return;
}
const pattern = entry.pattern?.trim() || "**/*.md";
const dedupeKey = `${resolved}\u0000${pattern}`;
if (seenRoots.has(dedupeKey)) {
return;
}
seenRoots.add(dedupeKey);
const explicitName = entry.name?.trim();
const baseName =
explicitName && !isPathInsideRoot(resolved, workspaceDir)
? explicitName
: scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId);
const name = ensureUniqueName(baseName, existing);
collections.push({
name,
path: resolved,
pattern,
kind: "custom",
});
});
return collections;
}
function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcporterConfig {
const parsed: ResolvedQmdMcporterConfig = { ...DEFAULT_QMD_MCPORTER };
if (!raw) {
return parsed;
}
if (raw.enabled !== undefined) {
parsed.enabled = raw.enabled;
}
if (typeof raw.serverName === "string" && raw.serverName.trim()) {
parsed.serverName = raw.serverName.trim();
}
if (raw.startDaemon !== undefined) {
parsed.startDaemon = raw.startDaemon;
}
// When enabled, default startDaemon to true.
if (parsed.enabled && raw.startDaemon === undefined) {
parsed.startDaemon = true;
}
return parsed;
}
function isRegularDefaultMemoryEntry(
entry: Pick<fs.Dirent, "name" | "isFile" | "isSymbolicLink">,
expectedName: string,
): boolean {
return entry.name === expectedName && entry.isFile() && !entry.isSymbolicLink();
}
function findDefaultMemoryRootPattern(workspaceDir: string): string | null {
try {
let sawLegacyFallback = false;
for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) {
if (isRegularDefaultMemoryEntry(entry, "MEMORY.md")) {
return "MEMORY.md";
}
if (isRegularDefaultMemoryEntry(entry, "memory.md")) {
sawLegacyFallback = true;
}
}
return sawLegacyFallback ? "memory.md" : null;
} catch {
return null;
}
}
function resolveDefaultMemoryRootPattern(workspaceDir: string): string {
return findDefaultMemoryRootPattern(workspaceDir) ?? "MEMORY.md";
}
function resolveDefaultCollections(
include: boolean,
workspaceDir: string,
existing: Set<string>,
agentId: string,
): ResolvedQmdCollection[] {
if (!include) {
return [];
}
const entries: Array<{ path: string; pattern: string; base: string }> = [
// The root memory slot is singular: prefer MEMORY.md, but keep lowercase
// memory.md as a legacy fallback when the canonical file is absent.
{
path: workspaceDir,
pattern: resolveDefaultMemoryRootPattern(workspaceDir),
base: "memory-root",
},
{ path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" },
];
return entries.map((entry) => ({
name: ensureUniqueName(scopeCollectionBase(entry.base, agentId), existing),
path: entry.path,
pattern: entry.pattern,
kind: "memory",
}));
}
export function resolveMemoryBackendConfig(params: {
cfg: OpenClawConfig;
agentId: string;
}): ResolvedMemoryBackendConfig {
const normalizedAgentId = normalizeAgentId(params.agentId);
const backend = params.cfg.memory?.backend ?? DEFAULT_BACKEND;
const citations = params.cfg.memory?.citations ?? DEFAULT_CITATIONS;
if (backend !== "qmd") {
return { backend: "builtin", citations };
}
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, normalizedAgentId);
const qmdCfg = params.cfg.memory?.qmd;
const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false;
const nameSet = new Set<string>();
const agentEntry = params.cfg.agents?.list?.find(
(entry) => normalizeAgentId(entry?.id) === normalizedAgentId,
);
const mergedExtraPaths = [
...(params.cfg.agents?.defaults?.memorySearch?.extraPaths ?? []),
...(agentEntry?.memorySearch?.extraPaths ?? []),
]
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
const dedupedExtraPaths = Array.from(new Set(mergedExtraPaths));
const searchExtraPaths = dedupedExtraPaths.map(
(pathValue): { path: string; pattern?: string; name?: string } => ({ path: pathValue }),
);
const mergedExtraCollections = [
...(params.cfg.agents?.defaults?.memorySearch?.qmd?.extraCollections ?? []),
...(agentEntry?.memorySearch?.qmd?.extraCollections ?? []),
].filter(
(value): value is MemoryQmdIndexPath =>
value !== null && typeof value === "object" && typeof value.path === "string",
);
// Combine QMD-specific paths with extraPaths and per-agent cross-agent collections.
const allQmdPaths: MemoryQmdIndexPath[] = [
...(qmdCfg?.paths ?? []),
...searchExtraPaths,
...mergedExtraCollections,
];
const collections = [
...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, normalizedAgentId),
...resolveCustomPaths(allQmdPaths, workspaceDir, nameSet, normalizedAgentId),
];
const rawCommand = qmdCfg?.command?.trim() || "qmd";
const parsedCommand = splitShellArgs(rawCommand);
const command = parsedCommand?.[0] || rawCommand.split(/\s+/)[0] || "qmd";
const resolved: ResolvedQmdConfig = {
command,
mcporter: resolveMcporterConfig(qmdCfg?.mcporter),
searchMode: resolveSearchMode(qmdCfg?.searchMode),
searchTool: resolveSearchTool(qmdCfg?.searchTool),
collections,
includeDefaultMemory,
sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir),
update: {
intervalMs: resolveIntervalMs(qmdCfg?.update?.interval),
debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs),
onBoot: qmdCfg?.update?.onBoot !== false,
waitForBootSync: qmdCfg?.update?.waitForBootSync === true,
embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval),
commandTimeoutMs: resolveTimeoutMs(
qmdCfg?.update?.commandTimeoutMs,
DEFAULT_QMD_COMMAND_TIMEOUT_MS,
),
updateTimeoutMs: resolveTimeoutMs(
qmdCfg?.update?.updateTimeoutMs,
DEFAULT_QMD_UPDATE_TIMEOUT_MS,
),
embedTimeoutMs: resolveTimeoutMs(
qmdCfg?.update?.embedTimeoutMs,
DEFAULT_QMD_EMBED_TIMEOUT_MS,
),
},
limits: resolveLimits(qmdCfg?.limits),
scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE,
};
return {
backend: "qmd",
citations,
qmd: resolved,
};
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
describe("extractBatchErrorMessage", () => {
it("returns the first top-level error message", () => {
expect(
extractBatchErrorMessage([
{ response: { body: { error: { message: "nested" } } } },
{ error: { message: "top-level" } },
]),
).toBe("nested");
});
it("falls back to nested response error message", () => {
expect(
extractBatchErrorMessage([{ response: { body: { error: { message: "nested-only" } } } }, {}]),
).toBe("nested-only");
});
it("accepts plain string response bodies", () => {
expect(extractBatchErrorMessage([{ response: { body: "provider plain-text error" } }])).toBe(
"provider plain-text error",
);
});
});
describe("formatUnavailableBatchError", () => {
it("formats errors and non-error values", () => {
expect(formatUnavailableBatchError(new Error("boom"))).toBe("error file unavailable: boom");
expect(formatUnavailableBatchError("unreachable")).toBe("error file unavailable: unreachable");
});
});

View file

@ -0,0 +1 @@
export * from "../../../../src/memory-host-sdk/host/batch-error-utils.js";

View file

@ -0,0 +1,86 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../../src/infra/retry.js", () => ({
retryAsync: vi.fn(async (run: () => Promise<unknown>) => await run()),
}));
vi.mock("./post-json.js", () => ({
postJson: vi.fn(),
}));
describe("postJsonWithRetry", () => {
let retryAsyncMock: ReturnType<
typeof vi.mocked<typeof import("../../../../src/infra/retry.js").retryAsync>
>;
let postJsonMock: ReturnType<typeof vi.mocked<typeof import("./post-json.js").postJson>>;
let postJsonWithRetry: typeof import("./batch-http.js").postJsonWithRetry;
beforeAll(async () => {
({ postJsonWithRetry } = await import("./batch-http.js"));
const retryModule = await import("../../../../src/infra/retry.js");
const postJsonModule = await import("./post-json.js");
retryAsyncMock = vi.mocked(retryModule.retryAsync);
postJsonMock = vi.mocked(postJsonModule.postJson);
});
beforeEach(() => {
vi.clearAllMocks();
});
it("posts JSON and returns parsed response payload", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({ ok: true, ids: [1, 2] });
});
const result = await postJsonWithRetry<{ ok: boolean; ids: number[] }>({
url: "https://memory.example/v1/batch",
headers: { Authorization: "Bearer test" },
body: { chunks: ["a", "b"] },
errorPrefix: "memory batch failed",
});
expect(result).toEqual({ ok: true, ids: [1, 2] });
expect(postJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://memory.example/v1/batch",
headers: { Authorization: "Bearer test" },
body: { chunks: ["a", "b"] },
errorPrefix: "memory batch failed",
attachStatus: true,
}),
);
const retryOptions = retryAsyncMock.mock.calls[0]?.[1] as
| {
attempts: number;
minDelayMs: number;
maxDelayMs: number;
shouldRetry: (err: unknown) => boolean;
}
| undefined;
expect(retryOptions?.attempts).toBe(3);
expect(retryOptions?.minDelayMs).toBe(300);
expect(retryOptions?.maxDelayMs).toBe(2000);
expect(retryOptions?.shouldRetry({ status: 429 })).toBe(true);
expect(retryOptions?.shouldRetry({ status: 503 })).toBe(true);
expect(retryOptions?.shouldRetry({ status: 400 })).toBe(false);
});
it("attaches status to non-ok errors", async () => {
postJsonMock.mockRejectedValueOnce(
Object.assign(new Error("memory batch failed: 503 backend down"), { status: 503 }),
);
await expect(
postJsonWithRetry({
url: "https://memory.example/v1/batch",
headers: {},
body: { chunks: [] },
errorPrefix: "memory batch failed",
}),
).rejects.toMatchObject({
message: expect.stringContaining("memory batch failed: 503 backend down"),
status: 503,
});
});
});

View file

@ -0,0 +1,35 @@
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import { retryAsync } from "../../../../src/infra/retry.js";
import { postJson } from "./post-json.js";
export async function postJsonWithRetry<T>(params: {
url: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
body: unknown;
errorPrefix: string;
}): Promise<T> {
return await retryAsync(
async () => {
return await postJson<T>({
url: params.url,
headers: params.headers,
ssrfPolicy: params.ssrfPolicy,
body: params.body,
errorPrefix: params.errorPrefix,
attachStatus: true,
parse: async (payload) => payload as T,
});
},
{
attempts: 3,
minDelayMs: 300,
maxDelayMs: 2000,
jitter: 0.2,
shouldRetry: (err) => {
const status = (err as { status?: number }).status;
return status === 429 || (typeof status === "number" && status >= 500);
},
},
);
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { applyEmbeddingBatchOutputLine } from "./batch-output.js";
describe("applyEmbeddingBatchOutputLine", () => {
it("stores embedding for successful response", () => {
const remaining = new Set(["req-1"]);
const errors: string[] = [];
const byCustomId = new Map<string, number[]>();
applyEmbeddingBatchOutputLine({
line: {
custom_id: "req-1",
response: {
status_code: 200,
body: { data: [{ embedding: [0.1, 0.2] }] },
},
},
remaining,
errors,
byCustomId,
});
expect(remaining.has("req-1")).toBe(false);
expect(errors).toEqual([]);
expect(byCustomId.get("req-1")).toEqual([0.1, 0.2]);
});
it("records provider error from line.error", () => {
const remaining = new Set(["req-2"]);
const errors: string[] = [];
const byCustomId = new Map<string, number[]>();
applyEmbeddingBatchOutputLine({
line: {
custom_id: "req-2",
error: { message: "provider failed" },
},
remaining,
errors,
byCustomId,
});
expect(remaining.has("req-2")).toBe(false);
expect(errors).toEqual(["req-2: provider failed"]);
expect(byCustomId.size).toBe(0);
});
it("records non-2xx response errors and empty embedding errors", () => {
const remaining = new Set(["req-3", "req-4"]);
const errors: string[] = [];
const byCustomId = new Map<string, number[]>();
applyEmbeddingBatchOutputLine({
line: {
custom_id: "req-3",
response: {
status_code: 500,
body: { error: { message: "internal" } },
},
},
remaining,
errors,
byCustomId,
});
applyEmbeddingBatchOutputLine({
line: {
custom_id: "req-4",
response: {
status_code: 200,
body: { data: [] },
},
},
remaining,
errors,
byCustomId,
});
expect(errors).toEqual(["req-3: internal", "req-4: empty embedding"]);
expect(byCustomId.size).toBe(0);
});
});

View file

@ -0,0 +1,55 @@
export type EmbeddingBatchOutputLine = {
custom_id?: string;
error?: { message?: string };
response?: {
status_code?: number;
body?:
| {
data?: Array<{
embedding?: number[];
}>;
error?: { message?: string };
}
| string;
};
};
export function applyEmbeddingBatchOutputLine(params: {
line: EmbeddingBatchOutputLine;
remaining: Set<string>;
errors: string[];
byCustomId: Map<string, number[]>;
}) {
const customId = params.line.custom_id;
if (!customId) {
return;
}
params.remaining.delete(customId);
const errorMessage = params.line.error?.message;
if (errorMessage) {
params.errors.push(`${customId}: ${errorMessage}`);
return;
}
const response = params.line.response;
const statusCode = response?.status_code ?? 0;
if (statusCode >= 400) {
const messageFromObject =
response?.body && typeof response.body === "object"
? (response.body as { error?: { message?: string } }).error?.message
: undefined;
const messageFromString = typeof response?.body === "string" ? response.body : undefined;
params.errors.push(`${customId}: ${messageFromObject ?? messageFromString ?? "unknown error"}`);
return;
}
const data =
response?.body && typeof response.body === "object" ? (response.body.data ?? []) : [];
const embedding = data[0]?.embedding ?? [];
if (embedding.length === 0) {
params.errors.push(`${customId}: empty embedding`);
return;
}
params.byCustomId.set(customId, embedding);
}

View file

@ -0,0 +1,12 @@
import type { EmbeddingBatchOutputLine } from "./batch-output.js";
export type EmbeddingBatchStatus = {
id?: string;
status?: string;
output_file_id?: string | null;
error_file_id?: string | null;
};
export type ProviderBatchOutputLine = EmbeddingBatchOutputLine;
export const EMBEDDING_BATCH_ENDPOINT = "/v1/embeddings";

View file

@ -0,0 +1,64 @@
import { splitBatchRequests } from "./batch-utils.js";
import { runWithConcurrency } from "./internal.js";
export type EmbeddingBatchExecutionParams = {
wait: boolean;
pollIntervalMs: number;
timeoutMs: number;
concurrency: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
};
export async function runEmbeddingBatchGroups<TRequest>(params: {
requests: TRequest[];
maxRequests: number;
wait: EmbeddingBatchExecutionParams["wait"];
pollIntervalMs: EmbeddingBatchExecutionParams["pollIntervalMs"];
timeoutMs: EmbeddingBatchExecutionParams["timeoutMs"];
concurrency: EmbeddingBatchExecutionParams["concurrency"];
debugLabel: string;
debug?: EmbeddingBatchExecutionParams["debug"];
runGroup: (args: {
group: TRequest[];
groupIndex: number;
groups: number;
byCustomId: Map<string, number[]>;
}) => Promise<void>;
}): Promise<Map<string, number[]>> {
if (params.requests.length === 0) {
return new Map();
}
const groups = splitBatchRequests(params.requests, params.maxRequests);
const byCustomId = new Map<string, number[]>();
const tasks = groups.map((group, groupIndex) => async () => {
await params.runGroup({ group, groupIndex, groups: groups.length, byCustomId });
});
params.debug?.(params.debugLabel, {
requests: params.requests.length,
groups: groups.length,
wait: params.wait,
concurrency: params.concurrency,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
});
await runWithConcurrency(tasks, params.concurrency);
return byCustomId;
}
export function buildEmbeddingBatchGroupOptions<TRequest>(
params: { requests: TRequest[] } & EmbeddingBatchExecutionParams,
options: { maxRequests: number; debugLabel: string },
) {
return {
requests: params.requests,
maxRequests: options.maxRequests,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
concurrency: params.concurrency,
debug: params.debug,
debugLabel: options.debugLabel,
};
}

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
throwIfBatchTerminalFailure,
} from "./batch-status.js";
describe("batch-status helpers", () => {
it("resolves completion payload from completed status", () => {
expect(
resolveBatchCompletionFromStatus({
provider: "openai",
batchId: "b1",
status: {
output_file_id: "out-1",
error_file_id: "err-1",
},
}),
).toEqual({
outputFileId: "out-1",
errorFileId: "err-1",
});
});
it("throws for terminal failure states", async () => {
await expect(
throwIfBatchTerminalFailure({
provider: "voyage",
status: { id: "b2", status: "failed", error_file_id: "err-file" },
readError: async () => "bad input",
}),
).rejects.toThrow("voyage batch b2 failed: bad input");
});
it("returns completed result directly without waiting", async () => {
const waitForBatch = async () => ({ outputFileId: "out-2" });
const result = await resolveCompletedBatchResult({
provider: "openai",
status: {
id: "b3",
status: "completed",
output_file_id: "out-3",
},
wait: false,
waitForBatch,
});
expect(result).toEqual({ outputFileId: "out-3", errorFileId: undefined });
});
it("throws when wait disabled and batch is not complete", async () => {
await expect(
resolveCompletedBatchResult({
provider: "openai",
status: { id: "b4", status: "pending" },
wait: false,
waitForBatch: async () => ({ outputFileId: "out" }),
}),
).rejects.toThrow("openai batch b4 submitted; enable remote.batch.wait to await completion");
});
});

View file

@ -0,0 +1,69 @@
const TERMINAL_FAILURE_STATES = new Set(["failed", "expired", "cancelled", "canceled"]);
type BatchStatusLike = {
id?: string;
status?: string;
output_file_id?: string | null;
error_file_id?: string | null;
};
export type BatchCompletionResult = {
outputFileId: string;
errorFileId?: string;
};
export function resolveBatchCompletionFromStatus(params: {
provider: string;
batchId: string;
status: BatchStatusLike;
}): BatchCompletionResult {
if (!params.status.output_file_id) {
throw new Error(`${params.provider} batch ${params.batchId} completed without output file`);
}
return {
outputFileId: params.status.output_file_id,
errorFileId: params.status.error_file_id ?? undefined,
};
}
export async function throwIfBatchTerminalFailure(params: {
provider: string;
status: BatchStatusLike;
readError: (errorFileId: string) => Promise<string | undefined>;
}): Promise<void> {
const state = params.status.status ?? "unknown";
if (!TERMINAL_FAILURE_STATES.has(state)) {
return;
}
const detail = params.status.error_file_id
? await params.readError(params.status.error_file_id)
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`${params.provider} batch ${params.status.id ?? "<unknown>"} ${state}${suffix}`);
}
export async function resolveCompletedBatchResult(params: {
provider: string;
status: BatchStatusLike;
wait: boolean;
waitForBatch: () => Promise<BatchCompletionResult>;
}): Promise<BatchCompletionResult> {
const batchId = params.status.id ?? "<unknown>";
if (!params.wait && params.status.status !== "completed") {
throw new Error(
`${params.provider} batch ${batchId} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
params.status.status === "completed"
? resolveBatchCompletionFromStatus({
provider: params.provider,
batchId,
status: params.status,
})
: await params.waitForBatch();
if (!completed.outputFileId) {
throw new Error(`${params.provider} batch ${batchId} completed without output file`);
}
return completed;
}

View file

@ -0,0 +1,44 @@
import {
buildBatchHeaders,
normalizeBatchBaseUrl,
type BatchHttpClientConfig,
} from "./batch-utils.js";
import { hashText } from "./internal.js";
import { withRemoteHttpResponse } from "./remote-http.js";
export async function uploadBatchJsonlFile(params: {
client: BatchHttpClientConfig;
requests: unknown[];
errorPrefix: string;
}): Promise<string> {
const baseUrl = normalizeBatchBaseUrl(params.client);
const jsonl = params.requests.map((request) => JSON.stringify(request)).join("\n");
const form = new FormData();
form.append("purpose", "batch");
form.append(
"file",
new Blob([jsonl], { type: "application/jsonl" }),
`memory-embeddings.${hashText(String(Date.now()))}.jsonl`,
);
const filePayload = await withRemoteHttpResponse({
url: `${baseUrl}/files`,
ssrfPolicy: params.client.ssrfPolicy,
init: {
method: "POST",
headers: buildBatchHeaders(params.client, { json: false }),
body: form,
},
onResponse: async (fileRes) => {
if (!fileRes.ok) {
const text = await fileRes.text();
throw new Error(`${params.errorPrefix}: ${fileRes.status} ${text}`);
}
return (await fileRes.json()) as { id?: string };
},
});
if (!filePayload.id) {
throw new Error(`${params.errorPrefix}: missing file id`);
}
return filePayload.id;
}

View file

@ -0,0 +1,38 @@
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
export type BatchHttpClientConfig = {
baseUrl?: string;
headers?: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
};
export function normalizeBatchBaseUrl(client: BatchHttpClientConfig): string {
return client.baseUrl?.replace(/\/$/, "") ?? "";
}
export function buildBatchHeaders(
client: Pick<BatchHttpClientConfig, "headers">,
params: { json: boolean },
): Record<string, string> {
const headers = client.headers ? { ...client.headers } : {};
if (params.json) {
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
} else {
delete headers["Content-Type"];
delete headers["content-type"];
}
return headers;
}
export function splitBatchRequests<T>(requests: T[], maxRequests: number): T[][] {
if (requests.length <= maxRequests) {
return [requests];
}
const groups: T[][] = [];
for (let i = 0; i < requests.length; i += maxRequests) {
groups.push(requests.slice(i, i + maxRequests));
}
return groups;
}

View file

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { enforceEmbeddingMaxInputTokens } from "./embedding-chunk-limits.js";
import { estimateUtf8Bytes } from "./embedding-input-limits.js";
import type { EmbeddingProvider } from "./embeddings.js";
function createProvider(maxInputTokens: number): EmbeddingProvider {
return {
id: "mock",
model: "mock-embed",
maxInputTokens,
embedQuery: async () => [0],
embedBatch: async () => [[0]],
};
}
function createProviderWithoutMaxInputTokens(params: {
id: string;
model: string;
}): EmbeddingProvider {
return {
id: params.id,
model: params.model,
embedQuery: async () => [0],
embedBatch: async () => [[0]],
};
}
describe("embedding chunk limits", () => {
it("splits oversized chunks so each embedding input stays <= maxInputTokens bytes", () => {
const provider = createProvider(8192);
const input = {
startLine: 1,
endLine: 1,
text: "x".repeat(9000),
hash: "ignored",
};
const out = enforceEmbeddingMaxInputTokens(provider, [input]);
expect(out.length).toBeGreaterThan(1);
expect(out.map((chunk) => chunk.text).join("")).toBe(input.text);
expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8192)).toBe(true);
expect(out.every((chunk) => chunk.startLine === 1 && chunk.endLine === 1)).toBe(true);
expect(out.every((chunk) => typeof chunk.hash === "string" && chunk.hash.length > 0)).toBe(
true,
);
});
it("does not split inside surrogate pairs (emoji)", () => {
const provider = createProvider(8192);
const emoji = "😀";
const inputText = `${emoji.repeat(2100)}\n${emoji.repeat(2100)}`;
const out = enforceEmbeddingMaxInputTokens(provider, [
{ startLine: 1, endLine: 2, text: inputText, hash: "ignored" },
]);
expect(out.length).toBeGreaterThan(1);
expect(out.map((chunk) => chunk.text).join("")).toBe(inputText);
expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8192)).toBe(true);
// If we split inside surrogate pairs we'd likely end up with replacement chars.
expect(out.map((chunk) => chunk.text).join("")).not.toContain("\uFFFD");
});
it("uses conservative fallback limits for local providers without declared maxInputTokens", () => {
const provider = createProviderWithoutMaxInputTokens({
id: "local",
model: "unknown-local-embedding",
});
const out = enforceEmbeddingMaxInputTokens(provider, [
{
startLine: 1,
endLine: 1,
text: "x".repeat(3000),
hash: "ignored",
},
]);
expect(out.length).toBeGreaterThan(1);
expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 2048)).toBe(true);
});
it("honors hard safety caps lower than provider maxInputTokens", () => {
const provider = createProvider(8192);
const out = enforceEmbeddingMaxInputTokens(
provider,
[
{
startLine: 1,
endLine: 1,
text: "x".repeat(8100),
hash: "ignored",
},
],
8000,
);
expect(out.length).toBeGreaterThan(1);
expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8000)).toBe(true);
});
});

View file

@ -0,0 +1,41 @@
import { estimateUtf8Bytes, splitTextToUtf8ByteLimit } from "./embedding-input-limits.js";
import { hasNonTextEmbeddingParts } from "./embedding-inputs.js";
import { resolveEmbeddingMaxInputTokens } from "./embedding-model-limits.js";
import type { EmbeddingProvider } from "./embeddings.js";
import { hashText, type MemoryChunk } from "./internal.js";
export function enforceEmbeddingMaxInputTokens(
provider: EmbeddingProvider,
chunks: MemoryChunk[],
hardMaxInputTokens?: number,
): MemoryChunk[] {
const providerMaxInputTokens = resolveEmbeddingMaxInputTokens(provider);
const maxInputTokens =
typeof hardMaxInputTokens === "number" && hardMaxInputTokens > 0
? Math.min(providerMaxInputTokens, hardMaxInputTokens)
: providerMaxInputTokens;
const out: MemoryChunk[] = [];
for (const chunk of chunks) {
if (hasNonTextEmbeddingParts(chunk.embeddingInput)) {
out.push(chunk);
continue;
}
if (estimateUtf8Bytes(chunk.text) <= maxInputTokens) {
out.push(chunk);
continue;
}
for (const text of splitTextToUtf8ByteLimit(chunk.text, maxInputTokens)) {
out.push({
startLine: chunk.startLine,
endLine: chunk.endLine,
text,
hash: hashText(text),
embeddingInput: { text },
});
}
}
return out;
}

View file

@ -0,0 +1,85 @@
import type { EmbeddingInput } from "./embedding-inputs.js";
// Helpers for enforcing embedding model input size limits.
//
// We use UTF-8 byte length as a conservative upper bound for tokenizer output.
// Tokenizers operate over bytes; a token must contain at least one byte, so
// token_count <= utf8_byte_length.
export function estimateUtf8Bytes(text: string): number {
if (!text) {
return 0;
}
return Buffer.byteLength(text, "utf8");
}
export function estimateStructuredEmbeddingInputBytes(input: EmbeddingInput): number {
if (!input.parts?.length) {
return estimateUtf8Bytes(input.text);
}
let total = 0;
for (const part of input.parts) {
if (part.type === "text") {
total += estimateUtf8Bytes(part.text);
continue;
}
total += estimateUtf8Bytes(part.mimeType);
total += estimateUtf8Bytes(part.data);
}
return total;
}
export function splitTextToUtf8ByteLimit(text: string, maxUtf8Bytes: number): string[] {
if (maxUtf8Bytes <= 0) {
return [text];
}
if (estimateUtf8Bytes(text) <= maxUtf8Bytes) {
return [text];
}
const parts: string[] = [];
let cursor = 0;
while (cursor < text.length) {
// The number of UTF-16 code units is always <= the number of UTF-8 bytes.
// This makes `cursor + maxUtf8Bytes` a safe upper bound on the next split point.
let low = cursor + 1;
let high = Math.min(text.length, cursor + maxUtf8Bytes);
let best = cursor;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const bytes = estimateUtf8Bytes(text.slice(cursor, mid));
if (bytes <= maxUtf8Bytes) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (best <= cursor) {
best = Math.min(text.length, cursor + 1);
}
// Avoid splitting inside a surrogate pair.
if (
best < text.length &&
best > cursor &&
text.charCodeAt(best - 1) >= 0xd800 &&
text.charCodeAt(best - 1) <= 0xdbff &&
text.charCodeAt(best) >= 0xdc00 &&
text.charCodeAt(best) <= 0xdfff
) {
best -= 1;
}
const part = text.slice(cursor, best);
if (!part) {
break;
}
parts.push(part);
cursor = best;
}
return parts;
}

View file

@ -0,0 +1,34 @@
export type EmbeddingInputTextPart = {
type: "text";
text: string;
};
export type EmbeddingInputInlineDataPart = {
type: "inline-data";
mimeType: string;
data: string;
};
export type EmbeddingInputPart = EmbeddingInputTextPart | EmbeddingInputInlineDataPart;
export type EmbeddingInput = {
text: string;
parts?: EmbeddingInputPart[];
};
export function buildTextEmbeddingInput(text: string): EmbeddingInput {
return { text };
}
export function isInlineDataEmbeddingInputPart(
part: EmbeddingInputPart,
): part is EmbeddingInputInlineDataPart {
return part.type === "inline-data";
}
export function hasNonTextEmbeddingParts(input: EmbeddingInput | undefined): boolean {
if (!input?.parts?.length) {
return false;
}
return input.parts.some((part) => isInlineDataEmbeddingInputPart(part));
}

View file

@ -0,0 +1,16 @@
import type { EmbeddingProvider } from "./embeddings.js";
const DEFAULT_EMBEDDING_MAX_INPUT_TOKENS = 8192;
const DEFAULT_LOCAL_EMBEDDING_MAX_INPUT_TOKENS = 2048;
export function resolveEmbeddingMaxInputTokens(provider: EmbeddingProvider): number {
if (typeof provider.maxInputTokens === "number") {
return provider.maxInputTokens;
}
if (provider.id === "local") {
return DEFAULT_LOCAL_EMBEDDING_MAX_INPUT_TOKENS;
}
return DEFAULT_EMBEDDING_MAX_INPUT_TOKENS;
}

View file

@ -0,0 +1,8 @@
export function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}

View file

@ -0,0 +1,13 @@
import { isTruthyEnvValue } from "../../../../src/infra/env.js";
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS);
const log = createSubsystemLogger("memory/embeddings");
export function debugEmbeddingsLog(message: string, meta?: Record<string, unknown>): void {
if (!debugEmbeddings) {
return;
}
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
log.raw(`${message}${suffix}`);
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js";
describe("normalizeEmbeddingModelWithPrefixes", () => {
it("returns default model when input is blank", () => {
expect(
normalizeEmbeddingModelWithPrefixes({
model: " ",
defaultModel: "fallback-model",
prefixes: ["openai/"],
}),
).toBe("fallback-model");
});
it("strips the first matching prefix", () => {
expect(
normalizeEmbeddingModelWithPrefixes({
model: "openai/text-embedding-3-small",
defaultModel: "fallback-model",
prefixes: ["openai/"],
}),
).toBe("text-embedding-3-small");
});
it("keeps explicit model names when no prefix matches", () => {
expect(
normalizeEmbeddingModelWithPrefixes({
model: "voyage-4-large",
defaultModel: "fallback-model",
prefixes: ["voyage/"],
}),
).toBe("voyage-4-large");
});
});

View file

@ -0,0 +1,16 @@
export function normalizeEmbeddingModelWithPrefixes(params: {
model: string;
defaultModel: string;
prefixes: string[];
}): string {
const trimmed = params.model.trim();
if (!trimmed) {
return params.defaultModel;
}
for (const prefix of params.prefixes) {
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}

View file

@ -0,0 +1,39 @@
import { requireApiKey, resolveApiKeyForProvider } from "../../../../src/agents/model-auth.js";
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import type { EmbeddingProviderOptions } from "./embeddings.js";
import { buildRemoteBaseUrlPolicy } from "./remote-http.js";
import { resolveMemorySecretInputString } from "./secret-input.js";
export type RemoteEmbeddingProviderId = string;
export async function resolveRemoteEmbeddingBearerClient(params: {
provider: RemoteEmbeddingProviderId;
options: EmbeddingProviderOptions;
defaultBaseUrl: string;
}): Promise<{ baseUrl: string; headers: Record<string, string>; ssrfPolicy?: SsrFPolicy }> {
const remote = params.options.remote;
const remoteApiKey = resolveMemorySecretInputString({
value: remote?.apiKey,
path: "agents.*.memorySearch.remote.apiKey",
});
const remoteBaseUrl = remote?.baseUrl?.trim();
const providerConfig = params.options.config.models?.providers?.[params.provider];
const apiKey = remoteApiKey
? remoteApiKey
: requireApiKey(
await resolveApiKeyForProvider({
provider: params.provider,
cfg: params.options.config,
agentDir: params.options.agentDir,
}),
params.provider,
);
const baseUrl = remoteBaseUrl || providerConfig?.baseUrl?.trim() || params.defaultBaseUrl;
const headerOverrides = Object.assign({}, providerConfig?.headers, remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
...headerOverrides,
};
return { baseUrl, headers, ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl) };
}

View file

@ -0,0 +1,59 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const postJsonMock = vi.hoisted(() => vi.fn());
vi.mock("./post-json.js", () => ({
postJson: postJsonMock,
}));
type EmbeddingsRemoteFetchModule = typeof import("./embeddings-remote-fetch.js");
let fetchRemoteEmbeddingVectors: EmbeddingsRemoteFetchModule["fetchRemoteEmbeddingVectors"];
describe("fetchRemoteEmbeddingVectors", () => {
beforeAll(async () => {
({ fetchRemoteEmbeddingVectors } = await import("./embeddings-remote-fetch.js"));
});
beforeEach(() => {
postJsonMock.mockReset();
});
it("maps remote embedding response data to vectors", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({
data: [{ embedding: [0.1, 0.2] }, {}, { embedding: [0.3] }],
});
});
const vectors = await fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: { Authorization: "Bearer test" },
body: { input: ["one", "two", "three"] },
errorPrefix: "embedding fetch failed",
});
expect(vectors).toEqual([[0.1, 0.2], [], [0.3]]);
expect(postJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://memory.example/v1/embeddings",
headers: { Authorization: "Bearer test" },
body: { input: ["one", "two", "three"] },
errorPrefix: "embedding fetch failed",
}),
);
});
it("throws a status-rich error on non-ok responses", async () => {
postJsonMock.mockRejectedValueOnce(new Error("embedding fetch failed: 403 forbidden"));
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: ["one"] },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: 403 forbidden");
});
});

View file

@ -0,0 +1,25 @@
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import { postJson } from "./post-json.js";
export async function fetchRemoteEmbeddingVectors(params: {
url: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
body: unknown;
errorPrefix: string;
}): Promise<number[][]> {
return await postJson({
url: params.url,
headers: params.headers,
ssrfPolicy: params.ssrfPolicy,
body: params.body,
errorPrefix: params.errorPrefix,
parse: (payload) => {
const typedPayload = payload as {
data?: Array<{ embedding?: number[] }>;
};
const data = typedPayload.data ?? [];
return data.map((entry) => entry.embedding ?? []);
},
});
}

View file

@ -0,0 +1,63 @@
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import {
resolveRemoteEmbeddingBearerClient,
type RemoteEmbeddingProviderId,
} from "./embeddings-remote-client.js";
import { fetchRemoteEmbeddingVectors } from "./embeddings-remote-fetch.js";
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js";
export type RemoteEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
};
export function createRemoteEmbeddingProvider(params: {
id: string;
client: RemoteEmbeddingClient;
errorPrefix: string;
maxInputTokens?: number;
}): EmbeddingProvider {
const { client } = params;
const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`;
const embed = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) {
return [];
}
return await fetchRemoteEmbeddingVectors({
url,
headers: client.headers,
ssrfPolicy: client.ssrfPolicy,
body: { model: client.model, input },
errorPrefix: params.errorPrefix,
});
};
return {
id: params.id,
model: client.model,
...(typeof params.maxInputTokens === "number" ? { maxInputTokens: params.maxInputTokens } : {}),
embedQuery: async (text) => {
const [vec] = await embed([text]);
return vec ?? [];
},
embedBatch: embed,
};
}
export async function resolveRemoteEmbeddingClient(params: {
provider: RemoteEmbeddingProviderId;
options: EmbeddingProviderOptions;
defaultBaseUrl: string;
normalizeModel: (model: string) => string;
}): Promise<RemoteEmbeddingClient> {
const { baseUrl, headers, ssrfPolicy } = await resolveRemoteEmbeddingBearerClient({
provider: params.provider,
options: params.options,
defaultBaseUrl: params.defaultBaseUrl,
});
const model = params.normalizeModel(params.options.model);
return { baseUrl, headers, ssrfPolicy, model };
}

View file

@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_LOCAL_MODEL } from "./embeddings.js";
describe("package embeddings barrel", () => {
it("re-exports the source local embedding contract", () => {
expect(DEFAULT_LOCAL_MODEL).toContain("embeddinggemma");
});
});

View file

@ -0,0 +1 @@
export * from "../../../../src/memory-host-sdk/host/embeddings.js";

View file

@ -0,0 +1,31 @@
import type { Stats } from "node:fs";
import fs from "node:fs/promises";
export type RegularFileStatResult = { missing: true } | { missing: false; stat: Stats };
export function isFileMissingError(
err: unknown,
): err is NodeJS.ErrnoException & { code: "ENOENT" } {
return Boolean(
err &&
typeof err === "object" &&
"code" in err &&
(err as Partial<NodeJS.ErrnoException>).code === "ENOENT",
);
}
export async function statRegularFile(absPath: string): Promise<RegularFileStatResult> {
let stat: Stats;
try {
stat = await fs.lstat(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return { missing: true };
}
throw err;
}
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new Error("path required");
}
return { missing: false, stat };
}

View file

@ -0,0 +1,459 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildMultimodalChunkForIndexing,
buildFileEntry,
chunkMarkdown,
isMemoryPath,
listMemoryFiles,
normalizeExtraMemoryPaths,
remapChunkLines,
} from "./internal.js";
import {
DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES,
type MemoryMultimodalSettings,
} from "./multimodal.js";
let sharedTempRoot = "";
let sharedTempId = 0;
beforeAll(async () => {
sharedTempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-sdk-tests-"));
});
afterAll(async () => {
if (sharedTempRoot) {
await fs.rm(sharedTempRoot, { recursive: true, force: true });
}
});
function setupTempDirLifecycle(prefix: string): () => string {
let tmpDir = "";
beforeEach(async () => {
tmpDir = path.join(sharedTempRoot, `${prefix}${sharedTempId++}`);
await fs.mkdir(tmpDir, { recursive: true });
});
return () => tmpDir;
}
describe("normalizeExtraMemoryPaths", () => {
it("trims, resolves, and dedupes paths", () => {
const workspaceDir = path.join(os.tmpdir(), "memory-test-workspace");
const absPath = path.resolve(path.sep, "shared-notes");
const result = normalizeExtraMemoryPaths(workspaceDir, [
" notes ",
"./notes",
absPath,
absPath,
"",
]);
expect(result).toEqual([path.resolve(workspaceDir, "notes"), absPath]);
});
});
describe("listMemoryFiles", () => {
const getTmpDir = setupTempDirLifecycle("memory-test-");
const multimodal: MemoryMultimodalSettings = {
enabled: true,
modalities: ["image", "audio"],
maxFileBytes: DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES,
};
it("includes files from additional paths (directory)", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const extraDir = path.join(tmpDir, "extra-notes");
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "note1.md"), "# Note 1");
await fs.writeFile(path.join(extraDir, "note2.md"), "# Note 2");
await fs.writeFile(path.join(extraDir, "ignore.txt"), "Not a markdown file");
const files = await listMemoryFiles(tmpDir, [extraDir]);
expect(files).toHaveLength(3);
expect(files.some((file) => file.endsWith("MEMORY.md"))).toBe(true);
expect(files.some((file) => file.endsWith("note1.md"))).toBe(true);
expect(files.some((file) => file.endsWith("note2.md"))).toBe(true);
expect(files.some((file) => file.endsWith("ignore.txt"))).toBe(false);
});
it("includes files from additional paths (single file)", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const singleFile = path.join(tmpDir, "standalone.md");
await fs.writeFile(singleFile, "# Standalone");
const files = await listMemoryFiles(tmpDir, [singleFile]);
expect(files).toHaveLength(2);
expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true);
});
it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory");
const files = await listMemoryFiles(tmpDir);
expect(files).toEqual([path.join(tmpDir, "memory.md")]);
});
it("prefers MEMORY.md when both root files exist", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory");
const files = await listMemoryFiles(tmpDir);
expect(files).toEqual([path.join(tmpDir, "MEMORY.md")]);
});
it("handles relative paths in additional paths", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const extraDir = path.join(tmpDir, "subdir");
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "nested.md"), "# Nested");
const files = await listMemoryFiles(tmpDir, ["subdir"]);
expect(files).toHaveLength(2);
expect(files.some((file) => file.endsWith("nested.md"))).toBe(true);
});
it("ignores non-existent additional paths", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const files = await listMemoryFiles(tmpDir, ["/does/not/exist"]);
expect(files).toHaveLength(1);
});
it("ignores symlinked files and directories", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const extraDir = path.join(tmpDir, "extra");
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "note.md"), "# Note");
const targetFile = path.join(tmpDir, "target.md");
await fs.writeFile(targetFile, "# Target");
const linkFile = path.join(extraDir, "linked.md");
const targetDir = path.join(tmpDir, "target-dir");
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, "nested.md"), "# Nested");
const linkDir = path.join(tmpDir, "linked-dir");
let symlinksOk = true;
try {
await fs.symlink(targetFile, linkFile, "file");
await fs.symlink(targetDir, linkDir, "dir");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EPERM" || code === "EACCES") {
symlinksOk = false;
} else {
throw err;
}
}
const files = await listMemoryFiles(tmpDir, [extraDir, linkDir]);
expect(files.some((file) => file.endsWith("note.md"))).toBe(true);
if (symlinksOk) {
expect(files.some((file) => file.endsWith("linked.md"))).toBe(false);
expect(files.some((file) => file.endsWith("nested.md"))).toBe(false);
}
});
it("dedupes overlapping extra paths that resolve to the same file", async () => {
const tmpDir = getTmpDir();
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
const files = await listMemoryFiles(tmpDir, [tmpDir, ".", path.join(tmpDir, "MEMORY.md")]);
const memoryMatches = files.filter((file) => file.endsWith("MEMORY.md"));
expect(memoryMatches).toHaveLength(1);
});
it("includes image and audio files from extra paths when multimodal is enabled", async () => {
const tmpDir = getTmpDir();
const extraDir = path.join(tmpDir, "media");
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "diagram.png"), Buffer.from("png"));
await fs.writeFile(path.join(extraDir, "note.wav"), Buffer.from("wav"));
await fs.writeFile(path.join(extraDir, "ignore.bin"), Buffer.from("bin"));
const files = await listMemoryFiles(tmpDir, [extraDir], multimodal);
expect(files.some((file) => file.endsWith("diagram.png"))).toBe(true);
expect(files.some((file) => file.endsWith("note.wav"))).toBe(true);
expect(files.some((file) => file.endsWith("ignore.bin"))).toBe(false);
});
});
describe("isMemoryPath", () => {
it("allows explicit access to top-level dreams.md", () => {
expect(isMemoryPath("dreams.md")).toBe(true);
});
});
describe("buildFileEntry", () => {
const getTmpDir = setupTempDirLifecycle("memory-build-entry-");
const multimodal: MemoryMultimodalSettings = {
enabled: true,
modalities: ["image", "audio"],
maxFileBytes: DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES,
};
it("returns null when the file disappears before reading", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "ghost.md");
await fs.writeFile(target, "ghost", "utf-8");
await fs.rm(target);
const entry = await buildFileEntry(target, tmpDir);
expect(entry).toBeNull();
});
it("returns metadata when the file exists", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "note.md");
await fs.writeFile(target, "hello", "utf-8");
const entry = await buildFileEntry(target, tmpDir);
expect(entry).not.toBeNull();
expect(entry?.path).toBe("note.md");
expect(entry?.size).toBeGreaterThan(0);
});
it("returns multimodal metadata for eligible image files", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "diagram.png");
await fs.writeFile(target, Buffer.from("png"));
const entry = await buildFileEntry(target, tmpDir, multimodal);
expect(entry).toMatchObject({
path: "diagram.png",
kind: "multimodal",
modality: "image",
mimeType: "image/png",
contentText: "Image file: diagram.png",
});
});
it("builds a multimodal chunk lazily for indexing", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "diagram.png");
await fs.writeFile(target, Buffer.from("png"));
const entry = await buildFileEntry(target, tmpDir, multimodal);
const built = await buildMultimodalChunkForIndexing(entry!);
expect(built?.chunk.embeddingInput?.parts).toEqual([
{ type: "text", text: "Image file: diagram.png" },
expect.objectContaining({ type: "inline-data", mimeType: "image/png" }),
]);
expect(built?.structuredInputBytes).toBeGreaterThan(0);
});
it("skips lazy multimodal indexing when the file grows after discovery", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "diagram.png");
await fs.writeFile(target, Buffer.from("png"));
const entry = await buildFileEntry(target, tmpDir, multimodal);
await fs.writeFile(target, Buffer.alloc(entry!.size + 32, 1));
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
});
it("skips lazy multimodal indexing when file bytes change after discovery", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "diagram.png");
await fs.writeFile(target, Buffer.from("png"));
const entry = await buildFileEntry(target, tmpDir, multimodal);
await fs.writeFile(target, Buffer.from("gif"));
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
});
});
describe("chunkMarkdown", () => {
it("splits overly long lines into max-sized chunks", () => {
const chunkTokens = 400;
const maxChars = chunkTokens * 4;
const content = "a".repeat(maxChars * 3 + 25);
const chunks = chunkMarkdown(content, { tokens: chunkTokens, overlap: 0 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(maxChars);
}
});
it("produces more chunks for CJK text than for equal-length ASCII text", () => {
// CJK chars ≈ 1 token each; ASCII chars ≈ 0.25 tokens each.
// For the same raw character count, CJK content should produce more chunks
// because each character "weighs" ~4× more in token estimation.
const chunkTokens = 50;
// 400 ASCII chars → ~100 tokens → fits in ~2 chunks
const asciiLines = Array.from({ length: 20 }, () => "a".repeat(20)).join("\n");
const asciiChunks = chunkMarkdown(asciiLines, { tokens: chunkTokens, overlap: 0 });
// 400 CJK chars → ~400 tokens → needs ~8 chunks
const cjkLines = Array.from({ length: 20 }, () => "你".repeat(20)).join("\n");
const cjkChunks = chunkMarkdown(cjkLines, { tokens: chunkTokens, overlap: 0 });
expect(cjkChunks.length).toBeGreaterThan(asciiChunks.length);
});
it("respects token budget for Chinese text", () => {
// With tokens=100, each CJK char ≈ 1 token, so chunks should hold ~100 CJK chars.
const chunkTokens = 100;
const lines: string[] = [];
for (let i = 0; i < 50; i++) {
lines.push("这是一个测试句子用来验证分块逻辑是否正确处理中文文本内容");
}
const content = lines.join("\n");
const chunks = chunkMarkdown(content, { tokens: chunkTokens, overlap: 0 });
expect(chunks.length).toBeGreaterThan(1);
// Each chunk's CJK content should not vastly exceed the token budget.
// With CJK-aware estimation, each char ≈ 1 token, so chunk text length
// (in CJK chars) should be roughly <= tokens budget (with some tolerance
// for line boundaries).
for (const chunk of chunks) {
// Count actual CJK characters in the chunk
const cjkCount = (chunk.text.match(/[\u4e00-\u9fff]/g) ?? []).length;
// Allow 2× tolerance for line-boundary rounding
expect(cjkCount).toBeLessThanOrEqual(chunkTokens * 2);
}
});
it("keeps English chunking behavior unchanged", () => {
const chunkTokens = 100;
const maxChars = chunkTokens * 4; // 400 chars
const content = "hello world this is a test. ".repeat(50);
const chunks = chunkMarkdown(content, { tokens: chunkTokens, overlap: 0 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.text.length).toBeLessThanOrEqual(maxChars);
}
});
it("handles mixed CJK and ASCII content correctly", () => {
const chunkTokens = 50;
const lines: string[] = [];
for (let i = 0; i < 30; i++) {
lines.push(`Line ${i}: 这是中英文混合的测试内容 with some English text`);
}
const content = lines.join("\n");
const chunks = chunkMarkdown(content, { tokens: chunkTokens, overlap: 0 });
// Should produce multiple chunks and not crash
expect(chunks.length).toBeGreaterThan(1);
// Verify all content is preserved
const reconstructed = chunks.map((c) => c.text).join("\n");
// Due to overlap=0, the concatenated chunks should cover all lines
expect(reconstructed).toContain("Line 0");
expect(reconstructed).toContain("Line 29");
});
it("splits very long CJK lines into budget-sized segments", () => {
// A single line of 2000 CJK characters (no newlines).
// With tokens=200, each CJK char ≈ 1 token.
const longCjkLine = "中".repeat(2000);
const chunks = chunkMarkdown(longCjkLine, { tokens: 200, overlap: 0 });
expect(chunks.length).toBeGreaterThanOrEqual(8);
for (const chunk of chunks) {
const cjkCount = (chunk.text.match(/[\u4E00-\u9FFF]/g) ?? []).length;
expect(cjkCount).toBeLessThanOrEqual(200 * 2);
}
});
it("does not break surrogate pairs when splitting long CJK lines", () => {
// "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character.
// With an odd token budget, the fine-split must not cut inside a pair.
const surrogateChar = "\u{20000}"; // 𠀀
const longLine = surrogateChar.repeat(120);
const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 });
for (const chunk of chunks) {
// No chunk should contain the Unicode replacement character U+FFFD,
// which would indicate a broken surrogate pair.
expect(chunk.text).not.toContain("\uFFFD");
// Every character in the chunk should be a valid string (no lone surrogates).
for (let i = 0; i < chunk.text.length; i += 1) {
const code = chunk.text.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff) {
// High surrogate must be followed by a low surrogate
const next = chunk.text.charCodeAt(i + 1);
expect(next).toBeGreaterThanOrEqual(0xdc00);
expect(next).toBeLessThanOrEqual(0xdfff);
}
}
}
});
it("does not over-split long Latin lines (backward compat)", () => {
// 2000 ASCII chars / 800 maxChars -> about 3 segments, not 10 tiny ones.
const longLatinLine = "a".repeat(2000);
const chunks = chunkMarkdown(longLatinLine, { tokens: 200, overlap: 0 });
expect(chunks.length).toBeLessThanOrEqual(5);
});
});
describe("remapChunkLines", () => {
it("remaps chunk line numbers using a lineMap", () => {
// Simulate 5 content lines that came from JSONL lines [4, 6, 7, 10, 13] (1-indexed)
const lineMap = [4, 6, 7, 10, 13];
// Create chunks from content that has 5 lines
const content = "User: Hello\nAssistant: Hi\nUser: Question\nAssistant: Answer\nUser: Thanks";
const chunks = chunkMarkdown(content, { tokens: 400, overlap: 0 });
expect(chunks.length).toBeGreaterThan(0);
// Before remapping, startLine/endLine reference content line numbers (1-indexed)
expect(chunks[0].startLine).toBe(1);
// Remap
remapChunkLines(chunks, lineMap);
// After remapping, line numbers should reference original JSONL lines
// Content line 1 → JSONL line 4, content line 5 → JSONL line 13
expect(chunks[0].startLine).toBe(4);
const lastChunk = chunks[chunks.length - 1];
expect(lastChunk.endLine).toBe(13);
});
it("preserves original line numbers when lineMap is undefined", () => {
const content = "Line one\nLine two\nLine three";
const chunks = chunkMarkdown(content, { tokens: 400, overlap: 0 });
const originalStart = chunks[0].startLine;
const originalEnd = chunks[chunks.length - 1].endLine;
remapChunkLines(chunks, undefined);
expect(chunks[0].startLine).toBe(originalStart);
expect(chunks[chunks.length - 1].endLine).toBe(originalEnd);
});
it("handles multi-chunk content with correct remapping", () => {
// Use small chunk size to force multiple chunks
// lineMap: 10 content lines from JSONL lines [2, 5, 8, 11, 14, 17, 20, 23, 26, 29]
const lineMap = [2, 5, 8, 11, 14, 17, 20, 23, 26, 29];
const contentLines = lineMap.map((_, i) =>
i % 2 === 0 ? `User: Message ${i}` : `Assistant: Reply ${i}`,
);
const content = contentLines.join("\n");
// Use very small chunk size to force splitting
const chunks = chunkMarkdown(content, { tokens: 10, overlap: 0 });
expect(chunks.length).toBeGreaterThan(1);
remapChunkLines(chunks, lineMap);
// First chunk should start at JSONL line 2
expect(chunks[0].startLine).toBe(2);
// Last chunk should end at JSONL line 29
expect(chunks[chunks.length - 1].endLine).toBe(29);
// Each chunk's startLine should be ≤ its endLine
for (const chunk of chunks) {
expect(chunk.startLine).toBeLessThanOrEqual(chunk.endLine);
}
});
});

View file

@ -0,0 +1,525 @@
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { detectMime } from "../../../../src/media/mime.js";
import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../../../../src/utils/cjk-chars.js";
import { runTasksWithConcurrency } from "../../../../src/utils/run-with-concurrency.js";
import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js";
import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js";
import { isFileMissingError } from "./fs-utils.js";
import {
buildMemoryMultimodalLabel,
classifyMemoryMultimodalPath,
type MemoryMultimodalModality,
type MemoryMultimodalSettings,
} from "./multimodal.js";
export type MemoryFileEntry = {
path: string;
absPath: string;
mtimeMs: number;
size: number;
hash: string;
dataHash?: string;
kind?: "markdown" | "multimodal";
contentText?: string;
modality?: MemoryMultimodalModality;
mimeType?: string;
};
export type MemoryChunk = {
startLine: number;
endLine: number;
text: string;
hash: string;
embeddingInput?: EmbeddingInput;
};
export type MultimodalMemoryChunk = {
chunk: MemoryChunk;
structuredInputBytes: number;
};
const DISABLED_MULTIMODAL_SETTINGS: MemoryMultimodalSettings = {
enabled: false,
modalities: [],
maxFileBytes: 0,
};
export function ensureDir(dir: string): string {
try {
fsSync.mkdirSync(dir, { recursive: true });
} catch {}
return dir;
}
export function normalizeRelPath(value: string): string {
const trimmed = value.trim().replace(/^[./]+/, "");
return trimmed.replace(/\\/g, "/");
}
export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] {
if (!extraPaths?.length) {
return [];
}
const resolved = extraPaths
.map((value) => value.trim())
.filter(Boolean)
.map((value) =>
path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value),
);
return Array.from(new Set(resolved));
}
export function isMemoryPath(relPath: string): boolean {
const normalized = normalizeRelPath(relPath);
if (!normalized) {
return false;
}
if (normalized === "MEMORY.md" || normalized === "memory.md" || normalized === "dreams.md") {
return true;
}
return normalized.startsWith("memory/");
}
function isAllowedMemoryFilePath(filePath: string, multimodal?: MemoryMultimodalSettings): boolean {
if (filePath.endsWith(".md")) {
return true;
}
return (
classifyMemoryMultimodalPath(filePath, multimodal ?? DISABLED_MULTIMODAL_SETTINGS) !== null
);
}
async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimodalSettings) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
await walkDir(full, files, multimodal);
continue;
}
if (!entry.isFile()) {
continue;
}
if (!isAllowedMemoryFilePath(full, multimodal)) {
continue;
}
files.push(full);
}
}
async function resolveDefaultMemoryRootFile(workspaceDir: string): Promise<string | null> {
try {
let legacyFallback: string | null = null;
const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink() || !entry.isFile()) {
continue;
}
if (entry.name === "MEMORY.md") {
return path.join(workspaceDir, entry.name);
}
if (entry.name === "memory.md") {
legacyFallback = path.join(workspaceDir, entry.name);
}
}
return legacyFallback;
} catch {
return null;
}
}
export async function listMemoryFiles(
workspaceDir: string,
extraPaths?: string[],
multimodal?: MemoryMultimodalSettings,
): Promise<string[]> {
const result: string[] = [];
const memoryDir = path.join(workspaceDir, "memory");
const addMarkdownFile = async (absPath: string) => {
try {
const stat = await fs.lstat(absPath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return;
}
if (!absPath.endsWith(".md")) {
return;
}
result.push(absPath);
} catch {}
};
const rootMemoryFile = await resolveDefaultMemoryRootFile(workspaceDir);
if (rootMemoryFile) {
await addMarkdownFile(rootMemoryFile);
}
try {
const dirStat = await fs.lstat(memoryDir);
if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
await walkDir(memoryDir, result);
}
} catch {}
const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
if (normalizedExtraPaths.length > 0) {
for (const inputPath of normalizedExtraPaths) {
try {
const stat = await fs.lstat(inputPath);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
await walkDir(inputPath, result, multimodal);
continue;
}
if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) {
result.push(inputPath);
}
} catch {}
}
}
if (result.length <= 1) {
return result;
}
const seen = new Set<string>();
const deduped: string[] = [];
for (const entry of result) {
let key = entry;
try {
key = await fs.realpath(entry);
} catch {}
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(entry);
}
return deduped;
}
export function hashText(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
export async function buildFileEntry(
absPath: string,
workspaceDir: string,
multimodal?: MemoryMultimodalSettings,
): Promise<MemoryFileEntry | null> {
let stat;
try {
stat = await fs.stat(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
}
const normalizedPath = path.relative(workspaceDir, absPath).replace(/\\/g, "/");
const multimodalSettings = multimodal ?? DISABLED_MULTIMODAL_SETTINGS;
const modality = classifyMemoryMultimodalPath(absPath, multimodalSettings);
if (modality) {
if (stat.size > multimodalSettings.maxFileBytes) {
return null;
}
let buffer: Buffer;
try {
buffer = await fs.readFile(absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
}
const mimeType = await detectMime({ buffer: buffer.subarray(0, 512), filePath: absPath });
if (!mimeType || !mimeType.startsWith(`${modality}/`)) {
return null;
}
const contentText = buildMemoryMultimodalLabel(modality, normalizedPath);
const dataHash = crypto.createHash("sha256").update(buffer).digest("hex");
const chunkHash = hashText(
JSON.stringify({
path: normalizedPath,
contentText,
mimeType,
dataHash,
}),
);
return {
path: normalizedPath,
absPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
hash: chunkHash,
dataHash,
kind: "multimodal",
contentText,
modality,
mimeType,
};
}
let content: string;
try {
content = await fs.readFile(absPath, "utf-8");
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
}
const hash = hashText(content);
return {
path: normalizedPath,
absPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
hash,
kind: "markdown",
};
}
async function loadMultimodalEmbeddingInput(
entry: Pick<
MemoryFileEntry,
"absPath" | "contentText" | "mimeType" | "kind" | "size" | "dataHash"
>,
): Promise<EmbeddingInput | null> {
if (entry.kind !== "multimodal" || !entry.contentText || !entry.mimeType) {
return null;
}
let stat;
try {
stat = await fs.stat(entry.absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
}
if (stat.size !== entry.size) {
return null;
}
let buffer: Buffer;
try {
buffer = await fs.readFile(entry.absPath);
} catch (err) {
if (isFileMissingError(err)) {
return null;
}
throw err;
}
const dataHash = crypto.createHash("sha256").update(buffer).digest("hex");
if (entry.dataHash && entry.dataHash !== dataHash) {
return null;
}
return {
text: entry.contentText,
parts: [
{ type: "text", text: entry.contentText },
{
type: "inline-data",
mimeType: entry.mimeType,
data: buffer.toString("base64"),
},
],
};
}
export async function buildMultimodalChunkForIndexing(
entry: Pick<
MemoryFileEntry,
"absPath" | "contentText" | "mimeType" | "kind" | "hash" | "size" | "dataHash"
>,
): Promise<MultimodalMemoryChunk | null> {
const embeddingInput = await loadMultimodalEmbeddingInput(entry);
if (!embeddingInput) {
return null;
}
return {
chunk: {
startLine: 1,
endLine: 1,
text: entry.contentText ?? embeddingInput.text,
hash: entry.hash,
embeddingInput,
},
structuredInputBytes: estimateStructuredEmbeddingInputBytes(embeddingInput),
};
}
export function chunkMarkdown(
content: string,
chunking: { tokens: number; overlap: number },
): MemoryChunk[] {
const lines = content.split("\n");
if (lines.length === 0) {
return [];
}
const maxChars = Math.max(32, chunking.tokens * CHARS_PER_TOKEN_ESTIMATE);
const overlapChars = Math.max(0, chunking.overlap * CHARS_PER_TOKEN_ESTIMATE);
const chunks: MemoryChunk[] = [];
let current: Array<{ line: string; lineNo: number }> = [];
let currentChars = 0;
const flush = () => {
if (current.length === 0) {
return;
}
const firstEntry = current[0];
const lastEntry = current[current.length - 1];
if (!firstEntry || !lastEntry) {
return;
}
const text = current.map((entry) => entry.line).join("\n");
const startLine = firstEntry.lineNo;
const endLine = lastEntry.lineNo;
chunks.push({
startLine,
endLine,
text,
hash: hashText(text),
embeddingInput: buildTextEmbeddingInput(text),
});
};
const carryOverlap = () => {
if (overlapChars <= 0 || current.length === 0) {
current = [];
currentChars = 0;
return;
}
let acc = 0;
const kept: Array<{ line: string; lineNo: number }> = [];
for (let i = current.length - 1; i >= 0; i -= 1) {
const entry = current[i];
if (!entry) {
continue;
}
acc += estimateStringChars(entry.line) + 1;
kept.unshift(entry);
if (acc >= overlapChars) {
break;
}
}
current = kept;
currentChars = kept.reduce((sum, entry) => sum + estimateStringChars(entry.line) + 1, 0);
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
const lineNo = i + 1;
const segments: string[] = [];
if (line.length === 0) {
segments.push("");
} else {
// First pass: slice at maxChars (preserves original behaviour for Latin).
// Second pass: if a segment's *weighted* size still exceeds the budget
// (happens for CJK-heavy text where 1 char ≈ 1 token), re-split it at
// 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) {
const fineStep = Math.max(1, chunking.tokens);
for (let j = 0; j < coarse.length; ) {
let end = Math.min(j + fineStep, coarse.length);
// Avoid splitting inside a UTF-16 surrogate pair (CJK Extension B+).
if (end < coarse.length) {
const code = coarse.charCodeAt(end - 1);
if (code >= 0xd800 && code <= 0xdbff) {
end += 1; // include the low surrogate
}
}
segments.push(coarse.slice(j, end));
j = end; // advance cursor to the adjusted boundary
}
} else {
segments.push(coarse);
}
}
}
for (const segment of segments) {
const lineSize = estimateStringChars(segment) + 1;
if (currentChars + lineSize > maxChars && current.length > 0) {
flush();
carryOverlap();
}
current.push({ line: segment, lineNo });
currentChars += lineSize;
}
}
flush();
return chunks;
}
/**
* Remap chunk startLine/endLine from content-relative positions to original
* source file positions using a lineMap. Each entry in lineMap gives the
* 1-indexed source line for the corresponding 0-indexed content line.
*
* This is used for session JSONL files where buildSessionEntry() flattens
* messages into a plain-text string before chunking. Without remapping the
* stored line numbers would reference positions in the flattened text rather
* than the original JSONL file.
*/
export function remapChunkLines(chunks: MemoryChunk[], lineMap: number[] | undefined): void {
if (!lineMap || lineMap.length === 0) {
return;
}
for (const chunk of chunks) {
// startLine/endLine are 1-indexed; lineMap is 0-indexed by content line
chunk.startLine = lineMap[chunk.startLine - 1] ?? chunk.startLine;
chunk.endLine = lineMap[chunk.endLine - 1] ?? chunk.endLine;
}
}
export function parseEmbedding(raw: string): number[] {
try {
const parsed = JSON.parse(raw) as number[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || b.length === 0) {
return 0;
}
const len = Math.min(a.length, b.length);
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < len; i += 1) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
dot += av * bv;
normA += av * av;
normB += bv * bv;
}
if (normA === 0 || normB === 0) {
return 0;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
export async function runWithConcurrency<T>(
tasks: Array<() => Promise<T>>,
limit: number,
): Promise<T[]> {
const { results, firstError, hasError } = await runTasksWithConcurrency({
tasks,
limit,
errorMode: "stop",
});
if (hasError) {
throw firstError;
}
return results;
}

View file

@ -0,0 +1,103 @@
import type { DatabaseSync } from "node:sqlite";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
export function ensureMemoryIndexSchema(params: {
db: DatabaseSync;
embeddingCacheTable: string;
cacheEnabled: boolean;
ftsTable: string;
ftsEnabled: boolean;
ftsTokenizer?: "unicode61" | "trigram";
}): { ftsAvailable: boolean; ftsError?: string } {
params.db.exec(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
params.db.exec(`
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
`);
params.db.exec(`
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
if (params.cacheEnabled) {
params.db.exec(`
CREATE TABLE IF NOT EXISTS ${params.embeddingCacheTable} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
`);
params.db.exec(
`CREATE INDEX IF NOT EXISTS idx_embedding_cache_updated_at ON ${params.embeddingCacheTable}(updated_at);`,
);
}
let ftsAvailable = false;
let ftsError: string | undefined;
if (params.ftsEnabled) {
try {
const tokenizer = params.ftsTokenizer ?? "unicode61";
const tokenizeClause = tokenizer === "trigram" ? `, tokenize='trigram case_sensitive 0'` : "";
params.db.exec(
`CREATE VIRTUAL TABLE IF NOT EXISTS ${params.ftsTable} USING fts5(\n` +
` text,\n` +
` id UNINDEXED,\n` +
` path UNINDEXED,\n` +
` source UNINDEXED,\n` +
` model UNINDEXED,\n` +
` start_line UNINDEXED,\n` +
` end_line UNINDEXED\n` +
`${tokenizeClause});`,
);
ftsAvailable = true;
} catch (err) {
const message = formatErrorMessage(err);
ftsAvailable = false;
ftsError = message;
}
}
ensureColumn(params.db, "files", "source", "TEXT NOT NULL DEFAULT 'memory'");
ensureColumn(params.db, "chunks", "source", "TEXT NOT NULL DEFAULT 'memory'");
params.db.exec(`CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);`);
params.db.exec(`CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);`);
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
}
function ensureColumn(
db: DatabaseSync,
table: "files" | "chunks",
column: string,
definition: string,
): void {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (rows.some((row) => row.name === column)) {
return;
}
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}

View file

@ -0,0 +1,102 @@
import { normalizeLowercaseStringOrEmpty } from "../../../../src/shared/string-coerce.js";
const MEMORY_MULTIMODAL_SPECS = {
image: {
labelPrefix: "Image file",
extensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".heif"],
},
audio: {
labelPrefix: "Audio file",
extensions: [".mp3", ".wav", ".ogg", ".opus", ".m4a", ".aac", ".flac"],
},
} as const;
export type MemoryMultimodalModality = keyof typeof MEMORY_MULTIMODAL_SPECS;
export const MEMORY_MULTIMODAL_MODALITIES = Object.keys(
MEMORY_MULTIMODAL_SPECS,
) as MemoryMultimodalModality[];
export type MemoryMultimodalSelection = MemoryMultimodalModality | "all";
export type MemoryMultimodalSettings = {
enabled: boolean;
modalities: MemoryMultimodalModality[];
maxFileBytes: number;
};
export const DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES = 10 * 1024 * 1024;
export function normalizeMemoryMultimodalModalities(
raw: MemoryMultimodalSelection[] | undefined,
): MemoryMultimodalModality[] {
if (raw === undefined || raw.includes("all")) {
return [...MEMORY_MULTIMODAL_MODALITIES];
}
const normalized = new Set<MemoryMultimodalModality>();
for (const value of raw) {
if (value === "image" || value === "audio") {
normalized.add(value);
}
}
return Array.from(normalized);
}
export function normalizeMemoryMultimodalSettings(raw: {
enabled?: boolean;
modalities?: MemoryMultimodalSelection[];
maxFileBytes?: number;
}): MemoryMultimodalSettings {
const enabled = raw.enabled === true;
const maxFileBytes =
typeof raw.maxFileBytes === "number" && Number.isFinite(raw.maxFileBytes)
? Math.max(1, Math.floor(raw.maxFileBytes))
: DEFAULT_MEMORY_MULTIMODAL_MAX_FILE_BYTES;
return {
enabled,
modalities: enabled ? normalizeMemoryMultimodalModalities(raw.modalities) : [],
maxFileBytes,
};
}
export function isMemoryMultimodalEnabled(settings: MemoryMultimodalSettings): boolean {
return settings.enabled && settings.modalities.length > 0;
}
export function getMemoryMultimodalExtensions(
modality: MemoryMultimodalModality,
): readonly string[] {
return MEMORY_MULTIMODAL_SPECS[modality].extensions;
}
export function buildMemoryMultimodalLabel(
modality: MemoryMultimodalModality,
normalizedPath: string,
): string {
return `${MEMORY_MULTIMODAL_SPECS[modality].labelPrefix}: ${normalizedPath}`;
}
export function buildCaseInsensitiveExtensionGlob(extension: string): string {
const normalized = normalizeLowercaseStringOrEmpty(extension).replace(/^\./, "");
if (!normalized) {
return "*";
}
const parts = Array.from(normalized, (char) => `[${char.toLowerCase()}${char.toUpperCase()}]`);
return `*.${parts.join("")}`;
}
export function classifyMemoryMultimodalPath(
filePath: string,
settings: MemoryMultimodalSettings,
): MemoryMultimodalModality | null {
if (!isMemoryMultimodalEnabled(settings)) {
return null;
}
const lower = normalizeLowercaseStringOrEmpty(filePath);
for (const modality of settings.modalities) {
for (const extension of getMemoryMultimodalExtensions(modality)) {
if (lower.endsWith(extension)) {
return modality;
}
}
}
return null;
}

View file

@ -0,0 +1,29 @@
export type LlamaEmbedding = {
vector: Float32Array | number[];
};
export type LlamaEmbeddingContext = {
getEmbeddingFor: (text: string) => Promise<LlamaEmbedding>;
};
export type LlamaModel = {
createEmbeddingContext: () => Promise<LlamaEmbeddingContext>;
};
export type Llama = {
loadModel: (params: { modelPath: string }) => Promise<LlamaModel>;
};
export type NodeLlamaCppModule = {
LlamaLogLevel: {
error: number;
};
getLlama: (params: { logLevel: number }) => Promise<Llama>;
resolveModelFile: (modelPath: string, cacheDir?: string) => Promise<string>;
};
const NODE_LLAMA_CPP_MODULE = "node-llama-cpp";
export async function importNodeLlamaCpp() {
return import(NODE_LLAMA_CPP_MODULE) as Promise<NodeLlamaCppModule>;
}

View file

@ -0,0 +1,60 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./remote-http.js", () => ({
withRemoteHttpResponse: vi.fn(),
}));
let postJson: typeof import("./post-json.js").postJson;
let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse;
describe("postJson", () => {
let remoteHttpMock: ReturnType<typeof vi.mocked<typeof withRemoteHttpResponse>>;
beforeAll(async () => {
({ postJson } = await import("./post-json.js"));
({ withRemoteHttpResponse } = await import("./remote-http.js"));
remoteHttpMock = vi.mocked(withRemoteHttpResponse);
});
beforeEach(() => {
vi.clearAllMocks();
});
it("parses JSON payload on successful response", async () => {
remoteHttpMock.mockImplementationOnce(async (params) => {
return await params.onResponse(
new Response(JSON.stringify({ data: [{ embedding: [1, 2] }] }), { status: 200 }),
);
});
const result = await postJson({
url: "https://memory.example/v1/post",
headers: { Authorization: "Bearer test" },
body: { input: ["x"] },
errorPrefix: "post failed",
parse: (payload) => payload,
});
expect(result).toEqual({ data: [{ embedding: [1, 2] }] });
});
it("attaches status to thrown error when requested", async () => {
remoteHttpMock.mockImplementationOnce(async (params) => {
return await params.onResponse(new Response("bad gateway", { status: 502 }));
});
await expect(
postJson({
url: "https://memory.example/v1/post",
headers: {},
body: {},
errorPrefix: "post failed",
attachStatus: true,
parse: () => ({}),
}),
).rejects.toMatchObject({
message: expect.stringContaining("post failed: 502 bad gateway"),
status: 502,
});
});
});

View file

@ -0,0 +1,35 @@
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import { withRemoteHttpResponse } from "./remote-http.js";
export async function postJson<T>(params: {
url: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
body: unknown;
errorPrefix: string;
attachStatus?: boolean;
parse: (payload: unknown) => T | Promise<T>;
}): Promise<T> {
return await withRemoteHttpResponse({
url: params.url,
ssrfPolicy: params.ssrfPolicy,
init: {
method: "POST",
headers: params.headers,
body: JSON.stringify(params.body),
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
const err = new Error(`${params.errorPrefix}: ${res.status} ${text}`) as Error & {
status?: number;
};
if (params.attachStatus) {
err.status = res.status;
}
throw err;
}
return await params.parse(await res.json());
},
});
}

View file

@ -0,0 +1,154 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
import { checkQmdBinaryAvailability, resolveCliSpawnInvocation } from "./qmd-process.js";
function createMockChild() {
const child = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>;
};
child.kill = vi.fn();
return child;
}
let tempDir = "";
let platformSpy: { mockRestore(): void } | null = null;
const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-"));
platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
});
afterEach(async () => {
platformSpy?.mockRestore();
process.env.PATH = originalPath;
process.env.PATHEXT = originalPathExt;
spawnMock.mockReset();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
}
});
describe("resolveCliSpawnInvocation", () => {
it("unwraps npm cmd shims to a direct node entrypoint", async () => {
const binDir = path.join(tempDir, "node_modules", ".bin");
const packageDir = path.join(tempDir, "node_modules", "qmd");
const scriptPath = path.join(packageDir, "dist", "cli.js");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\n", "utf8");
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({ name: "qmd", version: "0.0.0", bin: { qmd: "dist/cli.js" } }),
"utf8",
);
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
process.env.PATH = `${binDir};${originalPath ?? ""}`;
process.env.PATHEXT = ".CMD;.EXE";
const invocation = resolveCliSpawnInvocation({
command: "qmd",
args: ["query", "hello"],
env: process.env,
packageName: "qmd",
});
expect(invocation.command).toBe(process.execPath);
expect(invocation.argv).toEqual([scriptPath, "query", "hello"]);
expect(invocation.shell).not.toBe(true);
expect(invocation.windowsHide).toBe(true);
});
it("fails closed when a Windows cmd shim cannot be resolved without shell execution", async () => {
const binDir = path.join(tempDir, "bad-bin");
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(path.join(binDir, "qmd.cmd"), "@echo off\r\nREM no entrypoint\r\n", "utf8");
process.env.PATH = `${binDir};${originalPath ?? ""}`;
process.env.PATHEXT = ".CMD;.EXE";
expect(() =>
resolveCliSpawnInvocation({
command: "qmd",
args: ["query", "hello"],
env: process.env,
packageName: "qmd",
}),
).toThrow(/without shell execution/);
});
it("keeps bare commands bare when no Windows wrapper exists on PATH", () => {
process.env.PATH = originalPath ?? "";
process.env.PATHEXT = ".CMD;.EXE";
const invocation = resolveCliSpawnInvocation({
command: "qmd",
args: ["query", "hello"],
env: process.env,
packageName: "qmd",
});
expect(invocation.command).toBe("qmd");
expect(invocation.argv).toEqual(["query", "hello"]);
expect(invocation.shell).not.toBe(true);
});
});
describe("checkQmdBinaryAvailability", () => {
it("returns available when the qmd process spawns successfully", async () => {
const child = createMockChild();
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("spawn"));
return child;
});
await expect(
checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }),
).resolves.toEqual({ available: true });
expect(child.kill).toHaveBeenCalled();
});
it("returns unavailable when the qmd process cannot be spawned", async () => {
const child = createMockChild();
const err = Object.assign(new Error("spawn qmd ENOENT"), { code: "ENOENT" });
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("error", err));
return child;
});
await expect(
checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }),
).resolves.toEqual({ available: false, error: "spawn qmd ENOENT" });
});
it("does not treat close-before-spawn as a successful availability probe", async () => {
const child = createMockChild();
const err = Object.assign(new Error("spawn qmd ENOENT"), { code: "ENOENT" });
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("close"));
queueMicrotask(() => child.emit("error", err));
return child;
});
await expect(
checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }),
).resolves.toEqual({ available: false, error: "spawn qmd ENOENT" });
});
});

View file

@ -0,0 +1,184 @@
import { spawn } from "node:child_process";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../../../../src/plugin-sdk/windows-spawn.js";
export type CliSpawnInvocation = {
command: string;
argv: string[];
shell?: boolean;
windowsHide?: boolean;
};
export type QmdBinaryAvailability = {
available: boolean;
error?: string;
};
export function resolveCliSpawnInvocation(params: {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
packageName: string;
}): CliSpawnInvocation {
const program = resolveWindowsSpawnProgram({
command: params.command,
platform: process.platform,
env: params.env,
execPath: process.execPath,
packageName: params.packageName,
allowShellFallback: false,
});
return materializeWindowsSpawnProgram(program, params.args);
}
export async function checkQmdBinaryAvailability(params: {
command: string;
env: NodeJS.ProcessEnv;
cwd?: string;
timeoutMs?: number;
}): Promise<QmdBinaryAvailability> {
let spawnInvocation: CliSpawnInvocation;
try {
spawnInvocation = resolveCliSpawnInvocation({
command: params.command,
args: [],
env: params.env,
packageName: "qmd",
});
} catch (err) {
return { available: false, error: formatQmdAvailabilityError(err) };
}
return await new Promise((resolve) => {
let settled = false;
let didSpawn = false;
const finish = (result: QmdBinaryAvailability) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
env: params.env,
cwd: params.cwd ?? process.cwd(),
shell: spawnInvocation.shell,
windowsHide: spawnInvocation.windowsHide,
stdio: "ignore",
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish({
available: false,
error: `spawn ${params.command} timed out after ${params.timeoutMs ?? 2_000}ms`,
});
}, params.timeoutMs ?? 2_000);
child.once("error", (err) => {
finish({ available: false, error: formatQmdAvailabilityError(err) });
});
child.once("spawn", () => {
didSpawn = true;
child.kill();
finish({ available: true });
});
child.once("close", () => {
if (!didSpawn) {
return;
}
finish({ available: true });
});
});
}
export async function runCliCommand(params: {
commandSummary: string;
spawnInvocation: CliSpawnInvocation;
env: NodeJS.ProcessEnv;
cwd: string;
timeoutMs?: number;
maxOutputChars: number;
discardStdout?: boolean;
}): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, {
env: params.env,
cwd: params.cwd,
shell: params.spawnInvocation.shell,
windowsHide: params.spawnInvocation.windowsHide,
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
const discardStdout = params.discardStdout === true;
const timer = params.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`${params.commandSummary} timed out after ${params.timeoutMs}ms`));
}, params.timeoutMs)
: null;
child.stdout.on("data", (data) => {
if (discardStdout) {
return;
}
const next = appendOutputWithCap(stdout, data.toString("utf8"), params.maxOutputChars);
stdout = next.text;
stdoutTruncated = stdoutTruncated || next.truncated;
});
child.stderr.on("data", (data) => {
const next = appendOutputWithCap(stderr, data.toString("utf8"), params.maxOutputChars);
stderr = next.text;
stderrTruncated = stderrTruncated || next.truncated;
});
child.on("error", (err) => {
if (timer) {
clearTimeout(timer);
}
reject(err);
});
child.on("close", (code) => {
if (timer) {
clearTimeout(timer);
}
if (!discardStdout && (stdoutTruncated || stderrTruncated)) {
reject(
new Error(
`${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`,
),
);
return;
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`${params.commandSummary} failed (code ${code}): ${stderr || stdout}`));
}
});
});
}
function appendOutputWithCap(
current: string,
chunk: string,
maxChars: number,
): { text: string; truncated: boolean } {
const appended = current + chunk;
if (appended.length <= maxChars) {
return { text: appended, truncated: false };
}
return { text: appended.slice(-maxChars), truncated: true };
}
function formatQmdAvailabilityError(err: unknown): string {
if (err instanceof Error && err.message) {
return err.message;
}
return String(err);
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { parseQmdQueryJson } from "./qmd-query-parser.js";
describe("parseQmdQueryJson", () => {
it("parses clean qmd JSON output", () => {
const results = parseQmdQueryJson('[{"docid":"abc","score":1,"snippet":"@@ -1,1\\none"}]', "");
expect(results).toEqual([
{
docid: "abc",
score: 1,
snippet: "@@ -1,1\none",
},
]);
});
it("extracts embedded result arrays from noisy stdout", () => {
const results = parseQmdQueryJson(
`initializing
{"payload":"ok"}
[{"docid":"abc","score":0.5}]
complete`,
"",
);
expect(results).toEqual([{ docid: "abc", score: 0.5 }]);
});
it("preserves explicit qmd line metadata when present", () => {
const results = parseQmdQueryJson(
'[{"docid":"abc","score":0.5,"start_line":4,"end_line":6,"snippet":"@@ -10,1\\nignored"}]',
"",
);
expect(results).toEqual([
{
docid: "abc",
score: 0.5,
snippet: "@@ -10,1\nignored",
startLine: 4,
endLine: 6,
},
]);
});
it("treats plain-text no-results from stderr as an empty result set", () => {
const results = parseQmdQueryJson("", "No results found\n");
expect(results).toEqual([]);
});
it("treats prefixed no-results marker output as an empty result set", () => {
expect(parseQmdQueryJson("warning: no results found", "")).toEqual([]);
expect(parseQmdQueryJson("", "[qmd] warning: no results found\n")).toEqual([]);
});
it("does not treat arbitrary non-marker text as no-results output", () => {
expect(() =>
parseQmdQueryJson("warning: search completed; no results found for this query", ""),
).toThrow(/qmd query returned invalid JSON/i);
});
it("throws when stdout cannot be interpreted as qmd JSON", () => {
expect(() => parseQmdQueryJson("this is not json", "")).toThrow(
/qmd query returned invalid JSON/i,
);
});
});

View file

@ -0,0 +1,153 @@
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
import { normalizeLowercaseStringOrEmpty } from "../../../../src/shared/string-coerce.js";
const log = createSubsystemLogger("memory");
export type QmdQueryResult = {
docid?: string;
score?: number;
collection?: string;
file?: string;
snippet?: string;
body?: string;
startLine?: number;
endLine?: number;
};
export function parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] {
const trimmedStdout = stdout.trim();
const trimmedStderr = stderr.trim();
const stdoutIsMarker = trimmedStdout.length > 0 && isQmdNoResultsOutput(trimmedStdout);
const stderrIsMarker = trimmedStderr.length > 0 && isQmdNoResultsOutput(trimmedStderr);
if (stdoutIsMarker || (!trimmedStdout && stderrIsMarker)) {
return [];
}
if (!trimmedStdout) {
const context = trimmedStderr ? ` (stderr: ${summarizeQmdStderr(trimmedStderr)})` : "";
const message = `stdout empty${context}`;
log.warn(`qmd query returned invalid JSON: ${message}`);
throw new Error(`qmd query returned invalid JSON: ${message}`);
}
try {
const parsed = parseQmdQueryResultArray(trimmedStdout);
if (parsed !== null) {
return parsed;
}
const noisyPayload = extractFirstJsonArray(trimmedStdout);
if (!noisyPayload) {
throw new Error("qmd query JSON response was not an array");
}
const fallback = parseQmdQueryResultArray(noisyPayload);
if (fallback !== null) {
return fallback;
}
throw new Error("qmd query JSON response was not an array");
} catch (err) {
const message = formatErrorMessage(err);
log.warn(`qmd query returned invalid JSON: ${message}`);
throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err });
}
}
function isQmdNoResultsOutput(raw: string): boolean {
const lines = raw
.split(/\r?\n/)
.map((line) => normalizeLowercaseStringOrEmpty(line).replace(/\s+/g, " "))
.filter((line) => line.length > 0);
return lines.some((line) => isQmdNoResultsLine(line));
}
function isQmdNoResultsLine(line: string): boolean {
if (line === "no results found" || line === "no results found.") {
return true;
}
return /^(?:\[[^\]]+\]\s*)?(?:(?:warn(?:ing)?|info|error|qmd)\s*:\s*)+no results found\.?$/.test(
line,
);
}
function summarizeQmdStderr(raw: string): string {
return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`;
}
function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return null;
}
return parsed.map((item) => {
if (typeof item !== "object" || item === null) {
return item as QmdQueryResult;
}
const record = item as Record<string, unknown>;
const docid = typeof record.docid === "string" ? record.docid : undefined;
const score =
typeof record.score === "number" && Number.isFinite(record.score)
? record.score
: undefined;
const collection = typeof record.collection === "string" ? record.collection : undefined;
const file = typeof record.file === "string" ? record.file : undefined;
const snippet = typeof record.snippet === "string" ? record.snippet : undefined;
const body = typeof record.body === "string" ? record.body : undefined;
return {
docid,
score,
collection,
file,
snippet,
body,
startLine: parseQmdLineNumber(record.start_line ?? record.startLine),
endLine: parseQmdLineNumber(record.end_line ?? record.endLine),
} as QmdQueryResult;
});
} catch {
return null;
}
}
function parseQmdLineNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function extractFirstJsonArray(raw: string): string | null {
const start = raw.indexOf("[");
if (start < 0) {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === undefined) {
break;
}
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "[") {
depth += 1;
} else if (char === "]") {
depth -= 1;
if (depth === 0) {
return raw.slice(start, i + 1);
}
}
}
return null;
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { ResolvedQmdConfig } from "./backend-config.js";
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
describe("qmd scope", () => {
const allowDirect: ResolvedQmdConfig["scope"] = {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }],
};
it("derives channel and chat type from canonical keys once", () => {
expect(deriveQmdScopeChannel("Workspace:group:123")).toBe("workspace");
expect(deriveQmdScopeChatType("Workspace:group:123")).toBe("group");
});
it("derives channel and chat type from stored key suffixes", () => {
expect(deriveQmdScopeChannel("agent:agent-1:workspace:channel:chan-123")).toBe("workspace");
expect(deriveQmdScopeChatType("agent:agent-1:workspace:channel:chan-123")).toBe("channel");
});
it("treats parsed keys with no chat prefix as direct", () => {
expect(deriveQmdScopeChannel("agent:agent-1:peer-direct")).toBeUndefined();
expect(deriveQmdScopeChatType("agent:agent-1:peer-direct")).toBe("direct");
expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer-direct")).toBe(true);
expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer:group:abc")).toBe(false);
});
it("applies scoped key-prefix checks against normalized key", () => {
const scope: ResolvedQmdConfig["scope"] = {
default: "deny",
rules: [{ action: "allow", match: { keyPrefix: "workspace:" } }],
};
expect(isQmdScopeAllowed(scope, "agent:agent-1:workspace:group:123")).toBe(true);
expect(isQmdScopeAllowed(scope, "agent:agent-1:other:group:123")).toBe(false);
});
it("supports rawKeyPrefix matches for agent-prefixed keys", () => {
const scope: ResolvedQmdConfig["scope"] = {
default: "allow",
rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } }],
};
expect(isQmdScopeAllowed(scope, "agent:main:discord:channel:c123")).toBe(false);
expect(isQmdScopeAllowed(scope, "agent:main:slack:channel:c123")).toBe(true);
});
it("keeps legacy agent-prefixed keyPrefix rules working", () => {
const scope: ResolvedQmdConfig["scope"] = {
default: "allow",
rules: [{ action: "deny", match: { keyPrefix: "agent:main:discord:" } }],
};
expect(isQmdScopeAllowed(scope, "agent:main:discord:channel:c123")).toBe(false);
expect(isQmdScopeAllowed(scope, "agent:main:slack:channel:c123")).toBe(true);
});
});

View file

@ -0,0 +1,110 @@
import { parseAgentSessionKey } from "../../../../src/sessions/session-key-utils.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../../../../src/shared/string-coerce.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
type ParsedQmdSessionScope = {
channel?: string;
chatType?: "channel" | "group" | "direct";
normalizedKey?: string;
};
export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?: string): boolean {
if (!scope) {
return true;
}
const parsed = parseQmdSessionScope(sessionKey);
const channel = parsed.channel;
const chatType = parsed.chatType;
const normalizedKey = parsed.normalizedKey ?? "";
const rawKey = normalizeLowercaseStringOrEmpty(sessionKey ?? "");
for (const rule of scope.rules ?? []) {
if (!rule) {
continue;
}
const match = rule.match ?? {};
if (match.channel && match.channel !== channel) {
continue;
}
if (match.chatType && match.chatType !== chatType) {
continue;
}
const normalizedPrefix = normalizeOptionalLowercaseString(match.keyPrefix) || undefined;
const rawPrefix = normalizeOptionalLowercaseString(match.rawKeyPrefix) || undefined;
if (rawPrefix && !rawKey.startsWith(rawPrefix)) {
continue;
}
if (normalizedPrefix) {
// Backward compat: older configs used `keyPrefix: "agent:<id>:..."` to match raw keys.
const isLegacyRaw = normalizedPrefix.startsWith("agent:");
if (isLegacyRaw) {
if (!rawKey.startsWith(normalizedPrefix)) {
continue;
}
} else if (!normalizedKey.startsWith(normalizedPrefix)) {
continue;
}
}
return rule.action === "allow";
}
const fallback = scope.default ?? "allow";
return fallback === "allow";
}
export function deriveQmdScopeChannel(key?: string): string | undefined {
return parseQmdSessionScope(key).channel;
}
export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined {
return parseQmdSessionScope(key).chatType;
}
function parseQmdSessionScope(key?: string): ParsedQmdSessionScope {
const normalized = normalizeQmdSessionKey(key);
if (!normalized) {
return {};
}
const parts = normalized.split(":").filter(Boolean);
let chatType: ParsedQmdSessionScope["chatType"];
if (
parts.length >= 2 &&
(parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm")
) {
if (parts.includes("group")) {
chatType = "group";
} else if (parts.includes("channel")) {
chatType = "channel";
}
return {
normalizedKey: normalized,
channel: normalizeOptionalLowercaseString(parts[0]),
chatType: chatType ?? "direct",
};
}
if (normalized.includes(":group:")) {
return { normalizedKey: normalized, chatType: "group" };
}
if (normalized.includes(":channel:")) {
return { normalizedKey: normalized, chatType: "channel" };
}
return { normalizedKey: normalized, chatType: "direct" };
}
function normalizeQmdSessionKey(key?: string): string | undefined {
if (!key) {
return undefined;
}
const trimmed = key.trim();
if (!trimmed) {
return undefined;
}
const parsed = parseAgentSessionKey(trimmed);
const normalized = normalizeLowercaseStringOrEmpty(parsed?.rest ?? trimmed);
if (normalized.startsWith("subagent:")) {
return undefined;
}
return normalized;
}

View file

@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import { expandQueryForFts, extractKeywords } from "./query-expansion.js";
describe("extractKeywords", () => {
it("extracts keywords from English conversational query", () => {
const keywords = extractKeywords("that thing we discussed about the API");
expect(keywords).toContain("discussed");
expect(keywords).toContain("api");
// Should not include stop words
expect(keywords).not.toContain("that");
expect(keywords).not.toContain("thing");
expect(keywords).not.toContain("we");
expect(keywords).not.toContain("about");
expect(keywords).not.toContain("the");
});
it("extracts keywords from Chinese conversational query", () => {
const keywords = extractKeywords("之前讨论的那个方案");
expect(keywords).toContain("讨论");
expect(keywords).toContain("方案");
// Should not include stop words
expect(keywords).not.toContain("之前");
expect(keywords).not.toContain("的");
expect(keywords).not.toContain("那个");
});
it("extracts keywords from mixed language query", () => {
const keywords = extractKeywords("昨天讨论的 API design");
expect(keywords).toContain("讨论");
expect(keywords).toContain("api");
expect(keywords).toContain("design");
});
it("returns specific technical terms", () => {
const keywords = extractKeywords("what was the solution for the CFR bug");
expect(keywords).toContain("solution");
expect(keywords).toContain("cfr");
expect(keywords).toContain("bug");
});
it("extracts keywords from Korean conversational query", () => {
const keywords = extractKeywords("어제 논의한 배포 전략");
expect(keywords).toContain("논의한");
expect(keywords).toContain("배포");
expect(keywords).toContain("전략");
// Should not include stop words
expect(keywords).not.toContain("어제");
});
it("strips Korean particles to extract stems", () => {
const keywords = extractKeywords("서버에서 발생한 에러를 확인");
expect(keywords).toContain("서버");
expect(keywords).toContain("에러");
expect(keywords).toContain("확인");
});
it("filters Korean stop words including inflected forms", () => {
const keywords = extractKeywords("나는 그리고 그래서");
expect(keywords).not.toContain("나");
expect(keywords).not.toContain("나는");
expect(keywords).not.toContain("그리고");
expect(keywords).not.toContain("그래서");
});
it("filters inflected Korean stop words not explicitly listed", () => {
const keywords = extractKeywords("그녀는 우리는");
expect(keywords).not.toContain("그녀는");
expect(keywords).not.toContain("우리는");
expect(keywords).not.toContain("그녀");
expect(keywords).not.toContain("우리");
});
it("does not produce bogus single-char stems from particle stripping", () => {
const keywords = extractKeywords("논의");
expect(keywords).toContain("논의");
expect(keywords).not.toContain("논");
});
it("strips longest Korean trailing particles first", () => {
const keywords = extractKeywords("기능으로 설명");
expect(keywords).toContain("기능");
expect(keywords).not.toContain("기능으");
});
it("keeps stripped ASCII stems for mixed Korean tokens", () => {
const keywords = extractKeywords("API를 배포했다");
expect(keywords).toContain("api");
expect(keywords).toContain("배포했다");
});
it("handles mixed Korean and English query", () => {
const keywords = extractKeywords("API 배포에 대한 논의");
expect(keywords).toContain("api");
expect(keywords).toContain("배포");
expect(keywords).toContain("논의");
});
it("extracts keywords from Japanese conversational query", () => {
const keywords = extractKeywords("昨日話したデプロイ戦略");
expect(keywords).toContain("デプロイ");
expect(keywords).toContain("戦略");
expect(keywords).not.toContain("昨日");
});
it("handles mixed Japanese and English query", () => {
const keywords = extractKeywords("昨日話したAPIのバグ");
expect(keywords).toContain("api");
expect(keywords).toContain("バグ");
expect(keywords).not.toContain("した");
});
it("filters Japanese stop words", () => {
const keywords = extractKeywords("これ それ そして どう");
expect(keywords).not.toContain("これ");
expect(keywords).not.toContain("それ");
expect(keywords).not.toContain("そして");
expect(keywords).not.toContain("どう");
});
it("extracts keywords from Spanish conversational query", () => {
const keywords = extractKeywords("ayer hablamos sobre la estrategia de despliegue");
expect(keywords).toContain("estrategia");
expect(keywords).toContain("despliegue");
expect(keywords).not.toContain("ayer");
expect(keywords).not.toContain("sobre");
});
it("extracts keywords from Portuguese conversational query", () => {
const keywords = extractKeywords("ontem falamos sobre a estratégia de implantação");
expect(keywords).toContain("estratégia");
expect(keywords).toContain("implantação");
expect(keywords).not.toContain("ontem");
expect(keywords).not.toContain("sobre");
});
it("filters Spanish and Portuguese question stop words", () => {
const keywords = extractKeywords("cómo cuando donde porquê quando onde");
expect(keywords).not.toContain("cómo");
expect(keywords).not.toContain("cuando");
expect(keywords).not.toContain("donde");
expect(keywords).not.toContain("porquê");
expect(keywords).not.toContain("quando");
expect(keywords).not.toContain("onde");
});
it("extracts keywords from Arabic conversational query", () => {
const keywords = extractKeywords("بالأمس ناقشنا استراتيجية النشر");
expect(keywords).toContain("ناقشنا");
expect(keywords).toContain("استراتيجية");
expect(keywords).toContain("النشر");
expect(keywords).not.toContain("بالأمس");
});
it("filters Arabic question stop words", () => {
const keywords = extractKeywords("كيف متى أين ماذا");
expect(keywords).not.toContain("كيف");
expect(keywords).not.toContain("متى");
expect(keywords).not.toContain("أين");
expect(keywords).not.toContain("ماذا");
});
it("handles empty query", () => {
expect(extractKeywords("")).toEqual([]);
expect(extractKeywords(" ")).toEqual([]);
});
it("handles query with only stop words", () => {
const keywords = extractKeywords("the a an is are");
expect(keywords.length).toBe(0);
});
it("removes duplicate keywords", () => {
const keywords = extractKeywords("test test testing");
const testCount = keywords.filter((k) => k === "test").length;
expect(testCount).toBe(1);
});
describe("with trigram tokenizer", () => {
const trigramOpts = { ftsTokenizer: "trigram" as const };
it("emits whole CJK block instead of unigrams in trigram mode", () => {
const defaultKeywords = extractKeywords("之前讨论的那个方案");
const trigramKeywords = extractKeywords("之前讨论的那个方案", trigramOpts);
// Default mode produces bigrams
expect(defaultKeywords).toContain("讨论");
expect(defaultKeywords).toContain("方案");
// Trigram mode emits the whole contiguous CJK block (FTS5 trigram
// requires >= 3 chars per term; individual characters return no results)
expect(trigramKeywords).toContain("之前讨论的那个方案");
expect(trigramKeywords).not.toContain("讨论");
expect(trigramKeywords).not.toContain("方案");
});
it("skips Japanese kanji bigrams in trigram mode", () => {
const defaultKeywords = extractKeywords("経済政策について");
const trigramKeywords = extractKeywords("経済政策について", trigramOpts);
// Default mode adds kanji bigrams: 経済, 済政, 政策
expect(defaultKeywords).toContain("経済");
expect(defaultKeywords).toContain("済政");
expect(defaultKeywords).toContain("政策");
// Trigram mode keeps the full kanji block but skips bigram splitting
expect(trigramKeywords).toContain("経済政策");
expect(trigramKeywords).not.toContain("済政");
});
it("still filters stop words in trigram mode", () => {
const keywords = extractKeywords("これ それ そして どう", trigramOpts);
expect(keywords).not.toContain("これ");
expect(keywords).not.toContain("それ");
expect(keywords).not.toContain("そして");
expect(keywords).not.toContain("どう");
});
it("does not affect English keyword extraction", () => {
const keywords = extractKeywords("that thing we discussed about the API", trigramOpts);
expect(keywords).toContain("discussed");
expect(keywords).toContain("api");
expect(keywords).not.toContain("that");
expect(keywords).not.toContain("the");
});
});
});
describe("expandQueryForFts", () => {
it("returns original query and extracted keywords", () => {
const result = expandQueryForFts("that API we discussed");
expect(result.original).toBe("that API we discussed");
expect(result.keywords).toContain("api");
expect(result.keywords).toContain("discussed");
});
it("builds expanded OR query for FTS", () => {
const result = expandQueryForFts("the solution for bugs");
expect(result.expanded).toContain("OR");
expect(result.expanded).toContain("solution");
expect(result.expanded).toContain("bugs");
});
it("returns original query when no keywords extracted", () => {
const result = expandQueryForFts("the");
expect(result.keywords.length).toBe(0);
expect(result.expanded).toBe("the");
});
});

View file

@ -0,0 +1,830 @@
import { normalizeLowercaseStringOrEmpty } from "../../../../src/shared/string-coerce.js";
/**
* Query expansion for FTS-only search mode.
*
* When no embedding provider is available, we fall back to FTS (full-text search).
* FTS works best with specific keywords, but users often ask conversational queries
* like "that thing we discussed yesterday" or "之前讨论的那个方案".
*
* This module extracts meaningful keywords from such queries to improve FTS results.
*/
// Common stop words that don't add search value
const STOP_WORDS_EN = new Set([
// Articles and determiners
"a",
"an",
"the",
"this",
"that",
"these",
"those",
// Pronouns
"i",
"me",
"my",
"we",
"our",
"you",
"your",
"he",
"she",
"it",
"they",
"them",
// Common verbs
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"can",
"may",
"might",
// Prepositions
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"about",
"into",
"through",
"during",
"before",
"after",
"above",
"below",
"between",
"under",
"over",
// Conjunctions
"and",
"or",
"but",
"if",
"then",
"because",
"as",
"while",
"when",
"where",
"what",
"which",
"who",
"how",
"why",
// Time references (vague, not useful for FTS)
"yesterday",
"today",
"tomorrow",
"earlier",
"later",
"recently",
"before",
"ago",
"just",
"now",
// Vague references
"thing",
"things",
"stuff",
"something",
"anything",
"everything",
"nothing",
// Question words
"please",
"help",
"find",
"show",
"get",
"tell",
"give",
]);
const STOP_WORDS_ES = new Set([
// Articles and determiners
"el",
"la",
"los",
"las",
"un",
"una",
"unos",
"unas",
"este",
"esta",
"ese",
"esa",
// Pronouns
"yo",
"me",
"mi",
"nosotros",
"nosotras",
"tu",
"tus",
"usted",
"ustedes",
"ellos",
"ellas",
// Prepositions and conjunctions
"de",
"del",
"a",
"en",
"con",
"por",
"para",
"sobre",
"entre",
"y",
"o",
"pero",
"si",
"porque",
"como",
// Common verbs / auxiliaries
"es",
"son",
"fue",
"fueron",
"ser",
"estar",
"haber",
"tener",
"hacer",
// Time references (vague)
"ayer",
"hoy",
"mañana",
"antes",
"despues",
"después",
"ahora",
"recientemente",
// Question/request words
"que",
"qué",
"cómo",
"cuando",
"cuándo",
"donde",
"dónde",
"porqué",
"favor",
"ayuda",
]);
const STOP_WORDS_PT = new Set([
// Articles and determiners
"o",
"a",
"os",
"as",
"um",
"uma",
"uns",
"umas",
"este",
"esta",
"esse",
"essa",
// Pronouns
"eu",
"me",
"meu",
"minha",
"nos",
"nós",
"você",
"vocês",
"ele",
"ela",
"eles",
"elas",
// Prepositions and conjunctions
"de",
"do",
"da",
"em",
"com",
"por",
"para",
"sobre",
"entre",
"e",
"ou",
"mas",
"se",
"porque",
"como",
// Common verbs / auxiliaries
"é",
"são",
"foi",
"foram",
"ser",
"estar",
"ter",
"fazer",
// Time references (vague)
"ontem",
"hoje",
"amanhã",
"antes",
"depois",
"agora",
"recentemente",
// Question/request words
"que",
"quê",
"quando",
"onde",
"porquê",
"favor",
"ajuda",
]);
const STOP_WORDS_AR = new Set([
// Articles and connectors
"ال",
"و",
"أو",
"لكن",
"ثم",
"بل",
// Pronouns / references
"أنا",
"نحن",
"هو",
"هي",
"هم",
"هذا",
"هذه",
"ذلك",
"تلك",
"هنا",
"هناك",
// Common prepositions
"من",
"إلى",
"الى",
"في",
"على",
"عن",
"مع",
"بين",
"ل",
"ب",
"ك",
// Common auxiliaries / vague verbs
"كان",
"كانت",
"يكون",
"تكون",
"صار",
"أصبح",
"يمكن",
"ممكن",
// Time references (vague)
"بالأمس",
"امس",
"اليوم",
"غدا",
"الآن",
"قبل",
"بعد",
"مؤخرا",
// Question/request words
"لماذا",
"كيف",
"ماذا",
"متى",
"أين",
"هل",
"من فضلك",
"فضلا",
"ساعد",
]);
const STOP_WORDS_KO = new Set([
// Particles (조사)
"은",
"는",
"이",
"가",
"을",
"를",
"의",
"에",
"에서",
"로",
"으로",
"와",
"과",
"도",
"만",
"까지",
"부터",
"한테",
"에게",
"께",
"처럼",
"같이",
"보다",
"마다",
"밖에",
"대로",
// Pronouns (대명사)
"나",
"나는",
"내가",
"나를",
"너",
"우리",
"저",
"저희",
"그",
"그녀",
"그들",
"이것",
"저것",
"그것",
"여기",
"저기",
"거기",
// Common verbs / auxiliaries (일반 동사/보조 동사)
"있다",
"없다",
"하다",
"되다",
"이다",
"아니다",
"보다",
"주다",
"오다",
"가다",
// Nouns (의존 명사 / vague)
"것",
"거",
"등",
"수",
"때",
"곳",
"중",
"분",
// Adverbs
"잘",
"더",
"또",
"매우",
"정말",
"아주",
"많이",
"너무",
"좀",
// Conjunctions
"그리고",
"하지만",
"그래서",
"그런데",
"그러나",
"또는",
"그러면",
// Question words
"왜",
"어떻게",
"뭐",
"언제",
"어디",
"누구",
"무엇",
"어떤",
// Time (vague)
"어제",
"오늘",
"내일",
"최근",
"지금",
"아까",
"나중",
"전에",
// Request words
"제발",
"부탁",
]);
// Common Korean trailing particles to strip from words for tokenization
// Sorted by descending length so longest-match-first is guaranteed.
const KO_TRAILING_PARTICLES = [
"에서",
"으로",
"에게",
"한테",
"처럼",
"같이",
"보다",
"까지",
"부터",
"마다",
"밖에",
"대로",
"은",
"는",
"이",
"가",
"을",
"를",
"의",
"에",
"로",
"와",
"과",
"도",
"만",
].toSorted((a, b) => b.length - a.length);
function stripKoreanTrailingParticle(token: string): string | null {
for (const particle of KO_TRAILING_PARTICLES) {
if (token.length > particle.length && token.endsWith(particle)) {
return token.slice(0, -particle.length);
}
}
return null;
}
function isUsefulKoreanStem(stem: string): boolean {
// Prevent bogus one-syllable stems from words like "논의" -> "논".
if (/[\uac00-\ud7af]/.test(stem)) {
return stem.length >= 2;
}
// Keep stripped ASCII stems for mixed tokens like "API를" -> "api".
return /^[a-z0-9_]+$/i.test(stem);
}
const STOP_WORDS_JA = new Set([
// Pronouns and references
"これ",
"それ",
"あれ",
"この",
"その",
"あの",
"ここ",
"そこ",
"あそこ",
// Common auxiliaries / vague verbs
"する",
"した",
"して",
"です",
"ます",
"いる",
"ある",
"なる",
"できる",
// Particles / connectors
"の",
"こと",
"もの",
"ため",
"そして",
"しかし",
"また",
"でも",
"から",
"まで",
"より",
"だけ",
// Question words
"なぜ",
"どう",
"何",
"いつ",
"どこ",
"誰",
"どれ",
// Time (vague)
"昨日",
"今日",
"明日",
"最近",
"今",
"さっき",
"前",
"後",
]);
const STOP_WORDS_ZH = new Set([
// Pronouns
"我",
"我们",
"你",
"你们",
"他",
"她",
"它",
"他们",
"这",
"那",
"这个",
"那个",
"这些",
"那些",
// Auxiliary words
"的",
"了",
"着",
"过",
"得",
"地",
"吗",
"呢",
"吧",
"啊",
"呀",
"嘛",
"啦",
// Verbs (common, vague)
"是",
"有",
"在",
"被",
"把",
"给",
"让",
"用",
"到",
"去",
"来",
"做",
"说",
"看",
"找",
"想",
"要",
"能",
"会",
"可以",
// Prepositions and conjunctions
"和",
"与",
"或",
"但",
"但是",
"因为",
"所以",
"如果",
"虽然",
"而",
"也",
"都",
"就",
"还",
"又",
"再",
"才",
"只",
// Time (vague)
"之前",
"以前",
"之后",
"以后",
"刚才",
"现在",
"昨天",
"今天",
"明天",
"最近",
// Vague references
"东西",
"事情",
"事",
"什么",
"哪个",
"哪些",
"怎么",
"为什么",
"多少",
// Question/request words
"请",
"帮",
"帮忙",
"告诉",
]);
export function isQueryStopWordToken(token: string): boolean {
return (
STOP_WORDS_EN.has(token) ||
STOP_WORDS_ES.has(token) ||
STOP_WORDS_PT.has(token) ||
STOP_WORDS_AR.has(token) ||
STOP_WORDS_ZH.has(token) ||
STOP_WORDS_KO.has(token) ||
STOP_WORDS_JA.has(token)
);
}
/**
* Check if a token looks like a meaningful keyword.
* Returns false for short tokens, numbers-only, etc.
*/
function isValidKeyword(token: string): boolean {
if (!token || token.length === 0) {
return false;
}
// Skip very short English words (likely stop words or fragments)
if (/^[a-zA-Z]+$/.test(token) && token.length < 3) {
return false;
}
// Skip pure numbers (not useful for semantic search)
if (/^\d+$/.test(token)) {
return false;
}
// Skip tokens that are all punctuation
if (/^[\p{P}\p{S}]+$/u.test(token)) {
return false;
}
return true;
}
/**
* Simple tokenizer that handles English, Chinese, Korean, and Japanese text.
* For Chinese, we do character-based splitting since we don't have a proper segmenter.
* For English, we split on whitespace and punctuation.
*/
function tokenize(text: string, opts?: { ftsTokenizer?: "unicode61" | "trigram" }): string[] {
const useTrigram = opts?.ftsTokenizer === "trigram";
const tokens: string[] = [];
const normalized = normalizeLowercaseStringOrEmpty(text);
// Split into segments (English words, Chinese character sequences, etc.)
const segments = normalized.split(/[\s\p{P}]+/u).filter(Boolean);
for (const segment of segments) {
// Japanese text often mixes scripts (kanji/kana/ASCII) without spaces.
// Extract script-specific chunks so technical terms like "API" / "バグ" are retained.
if (/[\u3040-\u30ff]/.test(segment)) {
const jpParts =
segment.match(/[a-z0-9_]+|[\u30a0-\u30ffー]+|[\u4e00-\u9fff]+|[\u3040-\u309f]{2,}/g) ?? [];
for (const part of jpParts) {
if (/^[\u4e00-\u9fff]+$/.test(part)) {
tokens.push(part);
if (!useTrigram) {
for (let i = 0; i < part.length - 1; i++) {
tokens.push(part[i] + part[i + 1]);
}
}
} else {
tokens.push(part);
}
}
} else if (/[\u4e00-\u9fff]/.test(segment)) {
// Check if segment contains CJK characters (Chinese)
const chars = Array.from(segment).filter((c) => /[\u4e00-\u9fff]/.test(c));
if (useTrigram) {
// In trigram mode, push the whole contiguous CJK block (mirroring the
// Japanese kanji path). SQLite's trigram FTS requires at least 3 characters
// per query term — individual characters silently return no results.
const block = chars.join("");
if (block.length > 0) {
tokens.push(block);
}
} else {
// Default mode: unigrams + bigrams for phrase matching
tokens.push(...chars);
for (let i = 0; i < chars.length - 1; i++) {
tokens.push(chars[i] + chars[i + 1]);
}
}
} else if (/[\uac00-\ud7af\u3131-\u3163]/.test(segment)) {
// For Korean (Hangul syllables and jamo), keep the word as-is unless it is
// effectively a stop word once trailing particles are removed.
const stem = stripKoreanTrailingParticle(segment);
const stemIsStopWord = stem !== null && STOP_WORDS_KO.has(stem);
if (!STOP_WORDS_KO.has(segment) && !stemIsStopWord) {
tokens.push(segment);
}
// Also emit particle-stripped stems when they are useful keywords.
if (stem && !STOP_WORDS_KO.has(stem) && isUsefulKoreanStem(stem)) {
tokens.push(stem);
}
} else {
// For non-CJK, keep as single token
tokens.push(segment);
}
}
return tokens;
}
/**
* Extract keywords from a conversational query for FTS search.
*
* Examples:
* - "that thing we discussed about the API" → ["discussed", "API"]
* - "之前讨论的那个方案" → ["讨论", "方案"]
* - "what was the solution for the bug" → ["solution", "bug"]
*/
export function extractKeywords(
query: string,
opts?: { ftsTokenizer?: "unicode61" | "trigram" },
): string[] {
const tokens = tokenize(query, opts);
const keywords: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
// Skip stop words
if (isQueryStopWordToken(token)) {
continue;
}
// Skip invalid keywords
if (!isValidKeyword(token)) {
continue;
}
// Skip duplicates
if (seen.has(token)) {
continue;
}
seen.add(token);
keywords.push(token);
}
return keywords;
}
/**
* Expand a query for FTS search.
* Returns both the original query and extracted keywords for OR-matching.
*
* @param query - User's original query
* @returns Object with original query and extracted keywords
*/
export function expandQueryForFts(
query: string,
opts?: { ftsTokenizer?: "unicode61" | "trigram" },
): {
original: string;
keywords: string[];
expanded: string;
} {
const original = query.trim();
const keywords = extractKeywords(original, opts);
// Build expanded query: original terms OR extracted keywords
// This ensures both exact matches and keyword matches are found
const expanded = keywords.length > 0 ? `${original} OR ${keywords.join(" OR ")}` : original;
return { original, keywords, expanded };
}
/**
* Type for an optional LLM-based query expander.
* Can be provided to enhance keyword extraction with semantic understanding.
*/
export type LlmQueryExpander = (query: string) => Promise<string[]>;
/**
* Expand query with optional LLM assistance.
* Falls back to local extraction if LLM is unavailable or fails.
*/
export async function expandQueryWithLlm(
query: string,
llmExpander?: LlmQueryExpander,
opts?: { ftsTokenizer?: "unicode61" | "trigram" },
): Promise<string[]> {
// If LLM expander is provided, try it first
if (llmExpander) {
try {
const llmKeywords = await llmExpander(query);
if (llmKeywords.length > 0) {
return llmKeywords;
}
} catch {
// LLM failed, fall back to local extraction
}
}
// Fall back to local keyword extraction
return extractKeywords(query, opts);
}

View file

@ -0,0 +1,110 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
resolveAgentContextLimits,
resolveAgentWorkspaceDir,
} from "../../../../src/agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../../../../src/agents/memory-search.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
buildMemoryReadResult,
DEFAULT_MEMORY_READ_LINES,
type MemoryReadResult,
} from "../../../../src/memory-host-sdk/host/read-file-shared.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
export async function readMemoryFile(params: {
workspaceDir: string;
extraPaths?: string[];
relPath: string;
from?: number;
lines?: number;
defaultLines?: number;
maxChars?: number;
}): Promise<MemoryReadResult> {
const rawPath = params.relPath.trim();
if (!rawPath) {
throw new Error("path required");
}
const absPath = path.isAbsolute(rawPath)
? path.resolve(rawPath)
: path.resolve(params.workspaceDir, rawPath);
const relPath = path.relative(params.workspaceDir, absPath).replace(/\\/g, "/");
const inWorkspace = relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath);
const allowedWorkspace = inWorkspace && isMemoryPath(relPath);
let allowedAdditional = false;
if (!allowedWorkspace && (params.extraPaths?.length ?? 0) > 0) {
const additionalPaths = normalizeExtraMemoryPaths(params.workspaceDir, params.extraPaths);
for (const additionalPath of additionalPaths) {
try {
const stat = await fs.lstat(additionalPath);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) {
allowedAdditional = true;
break;
}
continue;
}
if (stat.isFile() && absPath === additionalPath && absPath.endsWith(".md")) {
allowedAdditional = true;
break;
}
} catch {}
}
}
if (!allowedWorkspace && !allowedAdditional) {
throw new Error("path required");
}
if (!absPath.endsWith(".md")) {
throw new Error("path required");
}
const statResult = await statRegularFile(absPath);
if (statResult.missing) {
return { text: "", path: relPath };
}
let content: string;
try {
content = await fs.readFile(absPath, "utf-8");
} catch (err) {
if (isFileMissingError(err)) {
return { text: "", path: relPath };
}
throw err;
}
return buildMemoryReadResult({
content,
relPath,
from: params.from,
lines: params.lines,
defaultLines: params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
maxChars: params.maxChars,
suggestReadFallback: allowedWorkspace,
});
}
export async function readAgentMemoryFile(params: {
cfg: OpenClawConfig;
agentId: string;
relPath: string;
from?: number;
lines?: number;
}): Promise<MemoryReadResult> {
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
if (!settings) {
throw new Error("memory search disabled");
}
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
return await readMemoryFile({
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
extraPaths: settings.extraPaths,
relPath: params.relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines,
maxChars: contextLimits?.memoryGetMaxChars,
});
}

View file

@ -0,0 +1,40 @@
import { fetchWithSsrFGuard } from "../../../../src/infra/net/fetch-guard.js";
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
export function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined {
const trimmed = baseUrl.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
// Keep policy tied to the configured host so private operator endpoints
// continue to work, while cross-host redirects stay blocked.
return { allowedHostnames: [parsed.hostname] };
} catch {
return undefined;
}
}
export async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
auditContext?: string;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: params.init,
policy: params.ssrfPolicy,
auditContext: params.auditContext ?? "memory-remote",
});
try {
return await params.onResponse(response);
} finally {
await release();
}
}

View file

@ -0,0 +1,27 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../../../../src/config/types.secrets.js";
export function hasConfiguredMemorySecretInput(value: unknown): boolean {
return hasConfiguredSecretInput(value);
}
export function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
const { ref } = resolveSecretInputRef({ value: params.value });
if (ref?.source === "env") {
const envValue = normalizeSecretInputString(process.env[ref.id]);
if (envValue) {
return envValue;
}
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,
});
}

View file

@ -0,0 +1,153 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js";
let tmpDir: string;
let originalStateDir: string | undefined;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-"));
originalStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = tmpDir;
});
afterEach(async () => {
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDir;
}
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("listSessionFilesForAgent", () => {
it("includes reset and deleted transcripts in session file listing", async () => {
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
await fs.mkdir(path.join(sessionsDir, "archive"), { recursive: true });
const included = [
"active.jsonl",
"active.jsonl.reset.2026-02-16T22-26-33.000Z",
"active.jsonl.deleted.2026-02-16T22-27-33.000Z",
];
const excluded = ["active.jsonl.bak.2026-02-16T22-28-33.000Z", "sessions.json", "notes.md"];
for (const fileName of [...included, ...excluded]) {
await fs.writeFile(path.join(sessionsDir, fileName), "");
}
await fs.writeFile(
path.join(sessionsDir, "archive", "nested.jsonl.deleted.2026-02-16T22-29-33.000Z"),
"",
);
const files = await listSessionFilesForAgent("main");
expect(files.map((filePath) => path.basename(filePath)).toSorted()).toEqual(
included.toSorted(),
);
});
});
describe("buildSessionEntry", () => {
it("returns lineMap tracking original JSONL line numbers", async () => {
// Simulate a real session JSONL file with metadata records interspersed
// Lines 1-3: non-message metadata records
// Line 4: user message
// Line 5: metadata
// Line 6: assistant message
// Line 7: user message
const jsonlLines = [
JSON.stringify({ type: "custom", customType: "model-snapshot", data: {} }),
JSON.stringify({ type: "custom", customType: "openclaw.cache-ttl", data: {} }),
JSON.stringify({ type: "session-meta", agentId: "test" }),
JSON.stringify({ type: "message", message: { role: "user", content: "Hello world" } }),
JSON.stringify({ type: "custom", customType: "tool-result", data: {} }),
JSON.stringify({
type: "message",
message: { role: "assistant", content: "Hi there, how can I help?" },
}),
JSON.stringify({ type: "message", message: { role: "user", content: "Tell me a joke" } }),
];
const filePath = path.join(tmpDir, "session.jsonl");
await fs.writeFile(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
// The content should have 3 lines (3 message records)
const contentLines = entry!.content.split("\n");
expect(contentLines).toHaveLength(3);
expect(contentLines[0]).toContain("User: Hello world");
expect(contentLines[1]).toContain("Assistant: Hi there");
expect(contentLines[2]).toContain("User: Tell me a joke");
// lineMap should map each content line to its original JSONL line (1-indexed)
// Content line 0 → JSONL line 4 (the first user message)
// Content line 1 → JSONL line 6 (the assistant message)
// Content line 2 → JSONL line 7 (the second user message)
expect(entry!.lineMap).toBeDefined();
expect(entry!.lineMap).toEqual([4, 6, 7]);
});
it("returns empty lineMap when no messages are found", async () => {
const jsonlLines = [
JSON.stringify({ type: "custom", customType: "model-snapshot", data: {} }),
JSON.stringify({ type: "session-meta", agentId: "test" }),
];
const filePath = path.join(tmpDir, "empty-session.jsonl");
await fs.writeFile(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry!.content).toBe("");
expect(entry!.lineMap).toEqual([]);
});
it("skips blank lines and invalid JSON without breaking lineMap", async () => {
const jsonlLines = [
"",
"not valid json",
JSON.stringify({ type: "message", message: { role: "user", content: "First" } }),
"",
JSON.stringify({ type: "message", message: { role: "assistant", content: "Second" } }),
];
const filePath = path.join(tmpDir, "gaps.jsonl");
await fs.writeFile(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry!.lineMap).toEqual([3, 5]);
});
it("strips inbound metadata when a user envelope is split across text blocks", async () => {
const jsonlLines = [
JSON.stringify({
type: "message",
message: {
role: "user",
content: [
{ type: "text", text: "Conversation info (untrusted metadata):" },
{ type: "text", text: "```json" },
{ type: "text", text: '{"message_id":"msg-100","chat_id":"-100123"}' },
{ type: "text", text: "```" },
{ type: "text", text: "" },
{ type: "text", text: "Sender (untrusted metadata):" },
{ type: "text", text: "```json" },
{ type: "text", text: '{"label":"Chris","id":"42"}' },
{ type: "text", text: "```" },
{ type: "text", text: "" },
{ type: "text", text: "Actual user text" },
],
},
}),
];
const filePath = path.join(tmpDir, "enveloped-session-array.jsonl");
await fs.writeFile(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry!.content).toBe("User: Actual user text");
});
});

View file

@ -0,0 +1,180 @@
import fs from "node:fs/promises";
import path from "node:path";
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
import { isUsageCountedSessionTranscriptFileName } from "../../../../src/config/sessions/artifacts.js";
import { resolveSessionTranscriptsDirForAgent } from "../../../../src/config/sessions/paths.js";
import { redactSensitiveText } from "../../../../src/logging/redact.js";
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
import { hashText } from "./internal.js";
const log = createSubsystemLogger("memory");
export type SessionFileEntry = {
path: string;
absPath: string;
mtimeMs: number;
size: number;
hash: string;
content: string;
/** Maps each content line (0-indexed) to its 1-indexed JSONL source line. */
lineMap: number[];
/** True when this transcript belongs to an internal dreaming narrative run. */
generatedByDreamingNarrative?: boolean;
};
function isDreamingNarrativeBootstrapRecord(record: unknown): boolean {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return false;
}
const candidate = record as {
type?: unknown;
customType?: unknown;
data?: unknown;
};
if (
candidate.type !== "custom" ||
candidate.customType !== "openclaw:bootstrap-context:full" ||
!candidate.data ||
typeof candidate.data !== "object" ||
Array.isArray(candidate.data)
) {
return false;
}
const runId = (candidate.data as { runId?: unknown }).runId;
return typeof runId === "string" && runId.startsWith("dreaming-narrative-");
}
export async function listSessionFilesForAgent(agentId: string): Promise<string[]> {
const dir = 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))
.map((name) => path.join(dir, name));
} catch {
return [];
}
}
export function sessionPathForFile(absPath: string): string {
return path.join("sessions", path.basename(absPath)).replace(/\\/g, "/");
}
function normalizeSessionText(value: string): string {
return value
.replace(/\s*\n+\s*/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function collectRawSessionText(content: unknown): string | null {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return null;
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const record = block as { type?: unknown; text?: unknown };
if (record.type === "text" && typeof record.text === "string") {
parts.push(record.text);
}
}
return parts.length > 0 ? parts.join("\n") : null;
}
/**
* Strip OpenClaw-injected inbound metadata envelopes from a raw text block
* on user-role messages before normalization. See the authoritative
* implementation in `src/memory-host-sdk/host/session-files.ts` for the
* full rationale; duplicated here to keep this parallel copy bug-free.
*/
function stripInboundMetadataForUserRole(text: string, role: "user" | "assistant"): string {
if (role !== "user") {
return text;
}
return stripInboundMetadata(text);
}
export function extractSessionText(
content: unknown,
role: "user" | "assistant" = "assistant",
): string | null {
const rawText = collectRawSessionText(content);
if (rawText === null) {
return null;
}
const stripped = stripInboundMetadataForUserRole(rawText, role);
const normalized = normalizeSessionText(stripped);
return normalized ? normalized : null;
}
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
try {
const stat = await fs.stat(absPath);
const raw = await fs.readFile(absPath, "utf-8");
const lines = raw.split("\n");
const collected: string[] = [];
const lineMap: number[] = [];
let generatedByDreamingNarrative = false;
for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
const line = lines[jsonlIdx];
if (!line.trim()) {
continue;
}
let record: unknown;
try {
record = JSON.parse(line);
} catch {
continue;
}
if (!generatedByDreamingNarrative && isDreamingNarrativeBootstrapRecord(record)) {
generatedByDreamingNarrative = true;
}
if (
!record ||
typeof record !== "object" ||
(record as { type?: unknown }).type !== "message"
) {
continue;
}
const message = (record as { message?: unknown }).message as
| { role?: unknown; content?: unknown }
| undefined;
if (!message || typeof message.role !== "string") {
continue;
}
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
const text = extractSessionText(message.content, message.role);
if (!text) {
continue;
}
const safe = redactSensitiveText(text, { mode: "tools" });
const label = message.role === "user" ? "User" : "Assistant";
collected.push(`${label}: ${safe}`);
lineMap.push(jsonlIdx + 1);
}
const content = collected.join("\n");
return {
path: sessionPathForFile(absPath),
absPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
hash: hashText(content + "\n" + lineMap.join(",")),
content,
lineMap,
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
};
} catch (err) {
log.debug(`Failed reading session file ${absPath}: ${String(err)}`);
return null;
}
}

View file

@ -0,0 +1,37 @@
import type { DatabaseSync } from "node:sqlite";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import { normalizeOptionalString } from "../../../../src/shared/string-coerce.js";
type SqliteVecModule = {
getLoadablePath: () => string;
load: (db: DatabaseSync) => void;
};
const SQLITE_VEC_MODULE_ID = "sqlite-vec";
async function loadSqliteVecModule(): Promise<SqliteVecModule> {
return import(SQLITE_VEC_MODULE_ID) as Promise<SqliteVecModule>;
}
export async function loadSqliteVecExtension(params: {
db: DatabaseSync;
extensionPath?: string;
}): Promise<{ ok: boolean; extensionPath?: string; error?: string }> {
try {
const sqliteVec = await loadSqliteVecModule();
const resolvedPath = normalizeOptionalString(params.extensionPath);
const extensionPath = resolvedPath ?? sqliteVec.getLoadablePath();
params.db.enableLoadExtension(true);
if (resolvedPath) {
params.db.loadExtension(extensionPath);
} else {
sqliteVec.load(params.db);
}
return { ok: true, extensionPath };
} catch (err) {
const message = formatErrorMessage(err);
return { ok: false, error: message };
}
}

View file

@ -0,0 +1,20 @@
import { createRequire } from "node:module";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import { installProcessWarningFilter } from "../../../../src/infra/warning-filter.js";
const require = createRequire(import.meta.url);
export function requireNodeSqlite(): typeof import("node:sqlite") {
installProcessWarningFilter();
try {
return require("node:sqlite") as typeof import("node:sqlite");
} catch (err) {
const message = formatErrorMessage(err);
// Node distributions can ship without the experimental builtin SQLite module.
// Surface an actionable error instead of the generic "unknown builtin module".
throw new Error(
`SQLite support is unavailable in this Node runtime (missing node:sqlite). ${message}`,
{ cause: err },
);
}
}

View file

@ -0,0 +1,45 @@
export type Tone = "ok" | "warn" | "muted";
export function resolveMemoryVectorState(vector: { enabled: boolean; available?: boolean }): {
tone: Tone;
state: "ready" | "unavailable" | "disabled" | "unknown";
} {
if (!vector.enabled) {
return { tone: "muted", state: "disabled" };
}
if (vector.available === true) {
return { tone: "ok", state: "ready" };
}
if (vector.available === false) {
return { tone: "warn", state: "unavailable" };
}
return { tone: "muted", state: "unknown" };
}
export function resolveMemoryFtsState(fts: { enabled: boolean; available: boolean }): {
tone: Tone;
state: "ready" | "unavailable" | "disabled";
} {
if (!fts.enabled) {
return { tone: "muted", state: "disabled" };
}
return fts.available ? { tone: "ok", state: "ready" } : { tone: "warn", state: "unavailable" };
}
export function resolveMemoryCacheSummary(cache: { enabled: boolean; entries?: number }): {
tone: Tone;
text: string;
} {
if (!cache.enabled) {
return { tone: "muted", text: "cache off" };
}
const suffix = typeof cache.entries === "number" ? ` (${cache.entries})` : "";
return { tone: "ok", text: `cache on${suffix}` };
}
export function resolveMemoryCacheState(cache: { enabled: boolean }): {
tone: Tone;
state: "enabled" | "disabled";
} {
return cache.enabled ? { tone: "ok", state: "enabled" } : { tone: "muted", state: "disabled" };
}

View file

@ -0,0 +1,15 @@
import { vi } from "vitest";
import * as ssrf from "../../../../../src/infra/net/ssrf.js";
import { normalizeLowercaseStringOrEmpty } from "../../../../../src/shared/string-coerce.js";
export function mockPublicPinnedHostname() {
return vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => {
const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, "");
const addresses = ["93.184.216.34"];
return {
hostname: normalized,
addresses,
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
};
});
}

View file

@ -0,0 +1,81 @@
export type MemorySource = "memory" | "sessions";
export type MemorySearchResult = {
path: string;
startLine: number;
endLine: number;
score: number;
snippet: string;
source: MemorySource;
citation?: string;
};
export type MemoryEmbeddingProbeResult = {
ok: boolean;
error?: string;
};
export type MemorySyncProgressUpdate = {
completed: number;
total: number;
label?: string;
};
export type MemoryProviderStatus = {
backend: "builtin" | "qmd";
provider: string;
model?: string;
requestedProvider?: string;
files?: number;
chunks?: number;
dirty?: boolean;
workspaceDir?: string;
dbPath?: string;
extraPaths?: string[];
sources?: MemorySource[];
sourceCounts?: Array<{ source: MemorySource; files: number; chunks: number }>;
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
fts?: { enabled: boolean; available: boolean; error?: string };
fallback?: { from: string; reason?: string };
vector?: {
enabled: boolean;
available?: boolean;
extensionPath?: string;
loadError?: string;
dims?: number;
};
batch?: {
enabled: boolean;
failures: number;
limit: number;
wait: boolean;
concurrency: number;
pollIntervalMs: number;
timeoutMs: number;
lastError?: string;
lastProvider?: string;
};
custom?: Record<string, unknown>;
};
export interface MemorySearchManager {
search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
): Promise<MemorySearchResult[]>;
readFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }>;
status(): MemoryProviderStatus;
sync?(params?: {
reason?: string;
force?: boolean;
sessionFiles?: string[];
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void>;
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
probeVectorAvailability(): Promise<boolean>;
close?(): Promise<void>;
}

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/multimodal.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/query.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/runtime-cli.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/runtime-core.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/runtime-files.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/runtime.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/secret.js";

View file

@ -0,0 +1 @@
export * from "../../../src/memory-host-sdk/status.js";

View file

@ -0,0 +1,9 @@
{
"name": "@openclaw/plugin-package-contract",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS,
listMissingExternalCodePluginFieldPaths,
normalizeExternalPluginCompatibility,
validateExternalCodePluginPackageJson,
} from "./index.js";
describe("@openclaw/plugin-package-contract", () => {
it("normalizes the OpenClaw compatibility block for external plugins", () => {
expect(
normalizeExternalPluginCompatibility({
version: "1.2.3",
openclaw: {
compat: {
pluginApi: ">=2026.3.24-beta.2",
minGatewayVersion: "2026.3.24-beta.2",
},
build: {
openclawVersion: "2026.3.24-beta.2",
pluginSdkVersion: "0.9.0",
},
},
}),
).toEqual({
pluginApiRange: ">=2026.3.24-beta.2",
builtWithOpenClawVersion: "2026.3.24-beta.2",
pluginSdkVersion: "0.9.0",
minGatewayVersion: "2026.3.24-beta.2",
});
});
it("falls back to install.minHostVersion and package version when compatible", () => {
expect(
normalizeExternalPluginCompatibility({
version: "1.2.3",
openclaw: {
compat: {
pluginApi: ">=1.0.0",
},
install: {
minHostVersion: "2026.3.24-beta.2",
},
},
}),
).toEqual({
pluginApiRange: ">=1.0.0",
builtWithOpenClawVersion: "1.2.3",
minGatewayVersion: "2026.3.24-beta.2",
});
});
it("lists the required external code-plugin fields", () => {
expect(EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS).toEqual([
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
]);
});
it("reports missing required fields with stable field paths", () => {
const packageJson = {
openclaw: {
compat: {},
build: {},
},
};
expect(listMissingExternalCodePluginFieldPaths(packageJson)).toEqual([
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
]);
expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([
{
fieldPath: "openclaw.compat.pluginApi",
message:
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
},
{
fieldPath: "openclaw.build.openclawVersion",
message:
"openclaw.build.openclawVersion is required for external code plugins published to ClawHub.",
},
]);
});
});

View file

@ -0,0 +1,91 @@
import { normalizeOptionalString } from "../../../src/shared/string-coerce.js";
import { isRecord } from "../../../src/utils.js";
export type JsonObject = Record<string, unknown>;
export type ExternalPluginCompatibility = {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
pluginSdkVersion?: string;
minGatewayVersion?: string;
};
export type ExternalPluginValidationIssue = {
fieldPath: string;
message: string;
};
export type ExternalCodePluginValidationResult = {
compatibility?: ExternalPluginCompatibility;
issues: ExternalPluginValidationIssue[];
};
export const EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
"openclaw.compat.pluginApi",
"openclaw.build.openclawVersion",
] as const;
function readOpenClawBlock(packageJson: unknown) {
const root = isRecord(packageJson) ? packageJson : undefined;
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
return { root, openclaw, compat, build, install };
}
export function normalizeExternalPluginCompatibility(
packageJson: unknown,
): ExternalPluginCompatibility | undefined {
const { root, compat, build, install } = readOpenClawBlock(packageJson);
const version = normalizeOptionalString(root?.version);
const minHostVersion = normalizeOptionalString(install?.minHostVersion);
const compatibility: ExternalPluginCompatibility = {};
const pluginApi = normalizeOptionalString(compat?.pluginApi);
if (pluginApi) {
compatibility.pluginApiRange = pluginApi;
}
const minGatewayVersion = normalizeOptionalString(compat?.minGatewayVersion) ?? minHostVersion;
if (minGatewayVersion) {
compatibility.minGatewayVersion = minGatewayVersion;
}
const builtWithOpenClawVersion = normalizeOptionalString(build?.openclawVersion) ?? version;
if (builtWithOpenClawVersion) {
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
}
const pluginSdkVersion = normalizeOptionalString(build?.pluginSdkVersion);
if (pluginSdkVersion) {
compatibility.pluginSdkVersion = pluginSdkVersion;
}
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
}
export function listMissingExternalCodePluginFieldPaths(packageJson: unknown): string[] {
const { compat, build } = readOpenClawBlock(packageJson);
const missing: string[] = [];
if (!normalizeOptionalString(compat?.pluginApi)) {
missing.push("openclaw.compat.pluginApi");
}
if (!normalizeOptionalString(build?.openclawVersion)) {
missing.push("openclaw.build.openclawVersion");
}
return missing;
}
export function validateExternalCodePluginPackageJson(
packageJson: unknown,
): ExternalCodePluginValidationResult {
const issues = listMissingExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
fieldPath,
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
}));
return {
compatibility: normalizeExternalPluginCompatibility(packageJson),
issues,
};
}

View file

@ -0,0 +1,160 @@
{
"name": "@openclaw/plugin-sdk",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
"./account-id": {
"types": "./dist/src/plugin-sdk/account-id.d.ts",
"default": "./src/account-id.ts"
},
"./acp-runtime": {
"types": "./dist/src/plugin-sdk/acp-runtime.d.ts",
"default": "./src/acp-runtime.ts"
},
"./browser-config-runtime": {
"types": "./dist/src/plugin-sdk/browser-config-runtime.d.ts",
"default": "./src/browser-config-runtime.ts"
},
"./core": {
"types": "./dist/src/plugin-sdk/core.d.ts",
"default": "./src/core.ts"
},
"./browser-node-runtime": {
"types": "./dist/src/plugin-sdk/browser-node-runtime.d.ts",
"default": "./src/browser-node-runtime.ts"
},
"./config-runtime": {
"types": "./dist/src/plugin-sdk/config-runtime.d.ts",
"default": "./src/config-runtime.ts"
},
"./browser-setup-tools": {
"types": "./dist/src/plugin-sdk/browser-setup-tools.d.ts",
"default": "./src/browser-setup-tools.ts"
},
"./browser-security-runtime": {
"types": "./dist/src/plugin-sdk/browser-security-runtime.d.ts",
"default": "./src/browser-security-runtime.ts"
},
"./channel-secret-runtime": {
"types": "./dist/src/plugin-sdk/channel-secret-runtime.d.ts",
"default": "./src/channel-secret-runtime.ts"
},
"./channel-streaming": {
"types": "./dist/src/plugin-sdk/channel-streaming.d.ts",
"default": "./src/channel-streaming.ts"
},
"./cli-runtime": {
"types": "./dist/src/plugin-sdk/cli-runtime.d.ts",
"default": "./src/cli-runtime.ts"
},
"./error-runtime": {
"types": "./dist/src/plugin-sdk/error-runtime.d.ts",
"default": "./src/error-runtime.ts"
},
"./plugin-entry": {
"types": "./dist/src/plugin-sdk/plugin-entry.d.ts",
"default": "./src/plugin-entry.ts"
},
"./plugin-runtime": {
"types": "./dist/src/plugin-sdk/plugin-runtime.d.ts",
"default": "./src/plugin-runtime.ts"
},
"./provider-auth": {
"types": "./dist/src/plugin-sdk/provider-auth.d.ts",
"default": "./src/provider-auth.ts"
},
"./provider-auth-runtime": {
"types": "./dist/src/plugin-sdk/provider-auth-runtime.d.ts",
"default": "./src/provider-auth-runtime.ts"
},
"./provider-env-vars": {
"types": "./dist/src/plugin-sdk/provider-env-vars.d.ts",
"default": "./src/provider-env-vars.ts"
},
"./provider-entry": {
"types": "./dist/src/plugin-sdk/provider-entry.d.ts",
"default": "./src/provider-entry.ts"
},
"./provider-usage": {
"types": "./dist/src/plugin-sdk/provider-usage.d.ts",
"default": "./src/provider-usage.ts"
},
"./provider-http": {
"types": "./dist/src/plugin-sdk/provider-http.d.ts",
"default": "./src/provider-http.ts"
},
"./provider-model-types": {
"types": "./dist/src/plugin-sdk/provider-model-types.d.ts",
"default": "./src/provider-model-types.ts"
},
"./provider-model-shared": {
"types": "./dist/src/plugin-sdk/provider-model-shared.d.ts",
"default": "./src/provider-model-shared.ts"
},
"./provider-onboard": {
"types": "./dist/src/plugin-sdk/provider-onboard.d.ts",
"default": "./src/provider-onboard.ts"
},
"./provider-stream-shared": {
"types": "./dist/src/plugin-sdk/provider-stream-shared.d.ts",
"default": "./src/provider-stream-shared.ts"
},
"./provider-tools": {
"types": "./dist/src/plugin-sdk/provider-tools.d.ts",
"default": "./src/provider-tools.ts"
},
"./provider-web-search": {
"types": "./dist/src/plugin-sdk/provider-web-search.d.ts",
"default": "./src/provider-web-search.ts"
},
"./provider-web-search-contract": {
"types": "./dist/src/plugin-sdk/provider-web-search-contract.d.ts",
"default": "./src/provider-web-search-contract.ts"
},
"./provider-web-search-config-contract": {
"types": "./dist/src/plugin-sdk/provider-web-search-config-contract.d.ts",
"default": "./src/provider-web-search-config-contract.ts"
},
"./runtime-doctor": {
"types": "./dist/src/plugin-sdk/runtime-doctor.d.ts",
"default": "./src/runtime-doctor.ts"
},
"./runtime-env": {
"types": "./dist/src/plugin-sdk/runtime-env.d.ts",
"default": "./src/runtime-env.ts"
},
"./security-runtime": {
"types": "./dist/src/plugin-sdk/security-runtime.d.ts",
"default": "./src/security-runtime.ts"
},
"./secret-ref-runtime": {
"types": "./dist/src/plugin-sdk/secret-ref-runtime.d.ts",
"default": "./src/secret-ref-runtime.ts"
},
"./ssrf-runtime": {
"types": "./dist/src/plugin-sdk/ssrf-runtime.d.ts",
"default": "./src/ssrf-runtime.ts"
},
"./secret-input": {
"types": "./dist/src/plugin-sdk/secret-input.d.ts",
"default": "./src/secret-input.ts"
},
"./text-runtime": {
"types": "./dist/src/plugin-sdk/text-runtime.d.ts",
"default": "./src/text-runtime.ts"
},
"./testing": {
"types": "./dist/src/plugin-sdk/testing.d.ts",
"default": "./src/testing.ts"
},
"./video-generation": {
"types": "./dist/src/plugin-sdk/video-generation.d.ts",
"default": "./src/video-generation.ts"
},
"./zod": {
"types": "./dist/src/plugin-sdk/zod.d.ts",
"default": "./src/zod.ts"
}
}
}

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/config-runtime.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/plugin-entry.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/plugin-runtime.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-auth-runtime.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-auth.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-entry.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-http.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-model-shared.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-model-types.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-onboard.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-stream-shared.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-tools.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-web-search-config-contract.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/provider-web-search.js";

View file

@ -0,0 +1,13 @@
export { collectProviderDangerousNameMatchingScopes } from "../../../src/config/dangerous-name-matching.js";
export {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
} from "../../../src/config/channel-compat-normalization.js";
export {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../../../src/infra/plugin-install-path-warnings.js";
export { removePluginFromConfig } from "../../../src/plugins/uninstall.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/runtime-env.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/secret-input.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/security-runtime.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/testing.js";

View file

@ -0,0 +1,6 @@
export {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
readStringValue,
} from "../../../src/shared/string-coerce.js";

View file

@ -0,0 +1 @@
export * from "../../../src/plugin-sdk/video-generation.js";

View file

@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"ignoreDeprecations": "6.0",
"incremental": true,
"noEmit": false,
"noEmitOnError": false,
"outDir": "dist",
"rootDir": "../..",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": [
"../../src/plugin-sdk/**/*.ts",
"../../src/video-generation/dashscope-compatible.ts",
"../../src/video-generation/types.ts",
"../../src/types/**/*.d.ts"
],
"exclude": [
"node_modules",
"dist",
"../../src/**/*.test.ts",
"../../src/plugin-sdk/AGENTS.md",
"../../src/plugin-sdk/CLAUDE.md"
]
}