diff --git a/README.md b/README.md index 58aa74f..314ab82 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,43 @@ docker exec -it hermes-memory hermes --- +### 3. Existing Hermes Agent + +If Hermes is already installed on the host, install the memory provider into that existing agent instead of using the Docker image. The installer expects Hermes under `~/.hermes/hermes-agent`; when running as root for another account, pass `INSTALL_AS_USER=`. + +```bash +# Download the npm package globally so the install script is available. +npm install -g @tencentdb-agent-memory/memory-tencentdb + +# Link memory_tencentdb into the existing Hermes plugin directory. +INSTALL_SCRIPT="$(npm root -g)/@tencentdb-agent-memory/memory-tencentdb/scripts/install_hermes_memory_tencentdb.sh" +bash "$INSTALL_SCRIPT" + +# Configure the Gateway used by the Hermes provider. +CTL="$HOME/.memory-tencentdb/tdai-memory-openclaw-plugin/scripts/memory-tencentdb-ctl.sh" +bash "$CTL" --hermes config llm \ + --api-key "sk-..." \ + --base-url "https://api.openai.com/v1" \ + --model "gpt-4o" + +# Optional: enable remote embedding. ZeroEntropy uses its native embed API. +bash "$CTL" --hermes config embedding \ + --provider zeroentropy \ + --api-key "$ZEROENTROPY_API_KEY" \ + --base-url "https://api.zeroentropy.dev" \ + --model "zembed-1" \ + --dimensions 2560 \ + --restart + +# Enable the Hermes memory provider and verify the Gateway. +bash "$CTL" --hermes enable-hermes-memory +curl http://127.0.0.1:8420/health +``` + +For OpenAI-compatible embedding providers, use the same `config embedding` command with `--provider openai` or your provider name, an OpenAI-compatible `--base-url`, model, and dimensions. + +--- + ## 🔧 Configurable Parameters @@ -288,9 +325,9 @@ docker exec -it hermes-memory hermes
🔴 Level 3 · Full parameter reference (ops / custom models / remote embedding) -For all fields, types, and constraints see [`openclaw.plugin.json`](./openclaw.plugin.json)。 +For all fields, types, and constraints see [`openclaw.plugin.json`](./openclaw.plugin.json). -- `embedding.*` — remote embedding service (OpenAI-compatible API) +- `embedding.*` — remote embedding service (OpenAI-compatible API or ZeroEntropy native API) - `llm.*` — standalone LLM mode (bypass OpenClaw's built-in model and run L1/L2/L3 with a designated API) - `offload.backendUrl / backendApiKey` — offload the L1/L1.5/L2/L4 flow to a backend service - `report.*` — metrics reporting diff --git a/README_CN.md b/README_CN.md index c1180c5..bd6fcee 100644 --- a/README_CN.md +++ b/README_CN.md @@ -248,6 +248,43 @@ docker exec -it hermes-memory hermes > 镜像内置了腾讯云 DeepSeek-V3.2 的默认值,如果你使用该模型,`MODEL_BASE_URL`/`MODEL_NAME`/`MODEL_PROVIDER` 可以省略,只传 `MODEL_API_KEY` 即可。 +--- + +### 3. 接入已有 Hermes Agent + +如果机器上已经安装了 Hermes,不需要改用 Docker 镜像,可以把 `memory_tencentdb` provider 安装到现有 Hermes。安装脚本默认查找 `~/.hermes/hermes-agent`;如果用 root 给其他用户安装,请传 `INSTALL_AS_USER=`。 + +```bash +# 先全局下载 npm 包,拿到安装脚本。 +npm install -g @tencentdb-agent-memory/memory-tencentdb + +# 将 memory_tencentdb 链接到现有 Hermes 插件目录。 +INSTALL_SCRIPT="$(npm root -g)/@tencentdb-agent-memory/memory-tencentdb/scripts/install_hermes_memory_tencentdb.sh" +bash "$INSTALL_SCRIPT" + +# 配置 Hermes provider 背后的 Gateway。 +CTL="$HOME/.memory-tencentdb/tdai-memory-openclaw-plugin/scripts/memory-tencentdb-ctl.sh" +bash "$CTL" --hermes config llm \ + --api-key "sk-..." \ + --base-url "https://api.openai.com/v1" \ + --model "gpt-4o" + +# 可选:开启远程 embedding。ZeroEntropy 会走原生 embed API。 +bash "$CTL" --hermes config embedding \ + --provider zeroentropy \ + --api-key "$ZEROENTROPY_API_KEY" \ + --base-url "https://api.zeroentropy.dev" \ + --model "zembed-1" \ + --dimensions 2560 \ + --restart + +# 启用 Hermes memory provider,并验证 Gateway。 +bash "$CTL" --hermes enable-hermes-memory +curl http://127.0.0.1:8420/health +``` + +如果使用 OpenAI 兼容的 embedding provider,同样执行 `config embedding`,把 `--provider` 换成 `openai` 或对应 provider 名称,并填写兼容 OpenAI 的 `--base-url`、模型名和维度。 + --- ## 🔧 可调参数 @@ -293,7 +330,7 @@ docker exec -it hermes-memory hermes 完整字段、类型、约束见 [`openclaw.plugin.json`](./openclaw.plugin.json) 。 -- `embedding.*` — 远程 embedding 服务(OpenAI 兼容 API) +- `embedding.*` — 远程 embedding 服务(OpenAI 兼容 API 或 ZeroEntropy 原生 API) - `llm.*` — 独立 LLM 模式(绕过 OpenClaw 内置模型,用指定 API 跑 L1/L2/L3) - `offload.backendUrl / backendApiKey` — 将 L1/L1.5/L2/L4 offload 流程卸载到后端服务 - `report.*` — 指标上报 diff --git a/bin/memory-tdai.mjs b/bin/memory-tdai.mjs new file mode 100755 index 0000000..83d44b1 --- /dev/null +++ b/bin/memory-tdai.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/cli.mjs"; diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f6ea5fd..b8195d0 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -56,6 +56,8 @@ "properties": { "everyNConversations": { "type": "number", "default": 5, "description": "每 N 轮对话触发 L1 批处理" }, "enableWarmup": { "type": "boolean", "default": true, "description": "Warm-up 模式:新 session 从 1 轮触发开始,每次 L1 后翻倍(1→2→4→...→everyN),加速早期记忆提取" }, + "enableL2": { "type": "boolean", "default": true, "description": "是否启用 L2 场景归纳阶段。关闭后只保留 L0/L1 记忆,不再触发场景抽取。" }, + "enableL3": { "type": "boolean", "default": true, "description": "是否启用 L3 用户画像生成阶段。关闭后 L2 完成也不会触发画像生成。" }, "l1IdleTimeoutSeconds": { "type": "number", "default": 600, "description": "L1 空闲超时(秒):用户停止对话后多久触发 L1 批处理" }, "l2DelayAfterL1Seconds": { "type": "number", "default": 10, "description": "L1 完成后延迟多久触发 L2(秒)" }, "l2MinIntervalSeconds": { "type": "number", "default": 900, "description": "同一 session 两次 L2 抽取的最小间隔(秒)" }, @@ -79,7 +81,7 @@ "description": "向量搜索 (Embedding) 配置", "properties": { "enabled": { "type": "boolean", "default": true, "description": "是否启用向量搜索(若 provider=none,则实际会被禁用)" }, - "provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写兼容 OpenAI API 的远端服务名称(如 openai、deepseek 等);不填或填 none 则禁用向量搜索" }, + "provider": { "type": "string", "default": "none", "description": "Embedding 服务提供者:填写远端服务名称(如 openai、deepseek、zeroentropy、qclaw 等);zeroentropy 使用原生 /v1/models/embed 接口,其余默认按 OpenAI 兼容接口调用;不填或填 none 则禁用向量搜索" }, "proxyUrl": { "type": "string", "description": "本地代理地址(仅 provider=qclaw 时必填)。配置后 embedding 请求将通过该代理转发,原始 baseUrl 作为 Remote-URL 头传递" }, "baseUrl": { "type": "string", "description": "API Base URL(必填):填写对应 provider 的 API 地址" }, "apiKey": { "type": "string", "description": "API Key(必填)" }, @@ -131,7 +133,8 @@ "apiKey": { "type": "string", "description": "API Key" }, "model": { "type": "string", "default": "gpt-4o", "description": "模型名称(如 gpt-4o, deepseek-v3, claude-sonnet-4-6)" }, "maxTokens": { "type": "number", "default": 4096, "description": "最大输出 token 数" }, - "timeoutMs": { "type": "number", "default": 120000, "description": "请求超时(毫秒)" } + "timeoutMs": { "type": "number", "default": 120000, "description": "请求超时(毫秒)" }, + "providerOptions": { "type": "object", "additionalProperties": true, "description": "透传给 AI SDK providerOptions 的供应商专属参数,例如关闭 thinking 或设置 extraBody。" } } }, "offload": { @@ -152,7 +155,9 @@ "mmdMaxTokenRatio": { "type": "number", "default": 0.2, "description": "MMD 注入 token 预算比例" }, "backendUrl": { "type": "string", "description": "后端服务 URL(如 https://offload-api.example.com),配置后 L1/L1.5/L2/L4 走后端" }, "backendApiKey": { "type": "string", "description": "后端 API 认证 token" }, - "backendTimeoutMs": { "type": "number", "default": 10000, "description": "后端调用超时(毫秒)" } + "backendTimeoutMs": { "type": "number", "default": 10000, "description": "后端调用超时(毫秒)" }, + "allowInsecureTls": { "type": "boolean", "default": false, "description": "是否显式允许 HTTPS 后端跳过证书校验。默认 false;仅本地调试或内网自签证书临时使用。" }, + "backendCaPemPath": { "type": "string", "description": "HTTPS 后端自定义 CA 证书 PEM 路径。优先用于自签/企业 CA,避免关闭证书校验。" } } } } diff --git a/package.json b/package.json index 0609611..906f659 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "./dist/index.mjs", "bin": { + "memory-tdai": "./bin/memory-tdai.mjs", "migrate-sqlite-to-tcvdb": "./bin/migrate-sqlite-to-tcvdb.mjs", "export-tencent-vdb": "./bin/export-tencent-vdb.mjs", "read-local-memory": "./bin/read-local-memory.mjs" @@ -34,14 +35,12 @@ "files": [ "dist/", "bin/", - "index.ts", "scripts/migrate-sqlite-to-tcvdb/dist/", "scripts/export-tencent-vdb/dist/", "scripts/read-local-memory/dist/", "scripts/memory-tencentdb-ctl.sh", "scripts/install_hermes_memory_tencentdb.sh", "scripts/README.memory-tencentdb-ctl.md", - "src/", "scripts/openclaw-after-tool-call-messages.patch.sh", "scripts/setup-offload.sh", "hermes-plugin/", @@ -77,6 +76,7 @@ "@node-rs/jieba": "^2.0.1", "@tencentdb-agent-memory/tcvdb-text": "^0.1.1", "ai": "^6.0.164", + "commander": "^14.0.3", "js-tiktoken": "^1.0.18", "json5": "^2.2.3", "sqlite-vec": "0.1.7-alpha.2", @@ -102,7 +102,7 @@ }, "openclaw": { "extensions": [ - "./index.ts" + "./dist/index.mjs" ], "compat": { "pluginApi": ">=2026.3.13", diff --git a/scripts/README.memory-tencentdb-ctl.md b/scripts/README.memory-tencentdb-ctl.md index 2d6a645..f46932f 100644 --- a/scripts/README.memory-tencentdb-ctl.md +++ b/scripts/README.memory-tencentdb-ctl.md @@ -157,11 +157,21 @@ memory-tencentdb-ctl config embedding \ --dimensions 1536 \ --restart +# ZeroEntropy 使用原生 /v1/models/embed 接口,base-url 不需要带 /v1 +memory-tencentdb-ctl config embedding \ + --provider zeroentropy \ + --api-key "$ZEROENTROPY_API_KEY" \ + --base-url "https://api.zeroentropy.dev" \ + --model "zembed-1" \ + --dimensions 2560 \ + --restart + # 关闭 embedding(退化为 BM25/关键词召回) memory-tencentdb-ctl config embedding --provider none --restart ``` - JSON 写入点:`$.memory.embedding.{provider, baseUrl, apiKey, model, dimensions, enabled, proxyUrl?}`。 +- `zeroentropy` provider 会调用 `POST /v1/models/embed` 并解析 `results[].embedding`。 - `qclaw` provider 额外要求 `--proxy-url`。 - 校验规则与 `src/config.ts` 的 `parseConfig()` 对齐:`dimensions` 为正整数,非 `none` 必须带 `apiKey/baseUrl/model/dimensions`;缺项直接报错不写半残 JSON。 diff --git a/scripts/memory-tencentdb-ctl.sh b/scripts/memory-tencentdb-ctl.sh index 812cd4e..df559db 100755 --- a/scripts/memory-tencentdb-ctl.sh +++ b/scripts/memory-tencentdb-ctl.sh @@ -507,7 +507,7 @@ cmd_config_embedding() { *) die "config embedding: 未知参数 $1" 1 ;; esac done - [[ -n "$provider" ]] || die "--provider 必填(none/openai/deepseek/qclaw/...)" + [[ -n "$provider" ]] || die "--provider 必填(none/openai/deepseek/zeroentropy/qclaw/...)" # provider=none 只写 provider,其余置空,相当于关闭向量检索 if [[ "$provider" == "none" ]]; then diff --git a/scripts/migrate-sqlite-to-tcvdb/sqlite-to-tcvdb.ts b/scripts/migrate-sqlite-to-tcvdb/sqlite-to-tcvdb.ts index 25f9765..13a3af1 100644 --- a/scripts/migrate-sqlite-to-tcvdb/sqlite-to-tcvdb.ts +++ b/scripts/migrate-sqlite-to-tcvdb/sqlite-to-tcvdb.ts @@ -297,14 +297,16 @@ function compactTimestamps(row: L1RecordRow): string[] { function mapL1RowToMemoryRecord(row: L1RecordRow): MemoryRecord { const timestamps = compactTimestamps(row); const fallbackIso = row.updated_time || row.created_time || row.timestamp_end || row.timestamp_str || new Date(0).toISOString(); + const metadata = safeParseMetadata(row.metadata_json); return { id: row.record_id, content: row.content, type: row.type as MemoryRecord["type"], + scope: normalizeScope(metadata.scope, row.content, row.type as MemoryRecord["type"]), priority: row.priority, scene_name: row.scene_name, source_message_ids: [], - metadata: safeParseMetadata(row.metadata_json), + metadata, timestamps, createdAt: row.created_time || fallbackIso, updatedAt: row.updated_time || row.created_time || fallbackIso, @@ -313,6 +315,19 @@ function mapL1RowToMemoryRecord(row: L1RecordRow): MemoryRecord { }; } +function normalizeScope(raw: unknown, content: string, type: MemoryRecord["type"]): MemoryRecord["scope"] { + if (raw === "global" || raw === "project" || raw === "session") { + return raw; + } + if (/(这个项目|本项目|当前项目|当前仓库|这个仓库|工作区|PR|issue|腾讯这个项目)/i.test(content)) { + return "project"; + } + if (/(这次|本次|当前任务|本轮|临时|今天刚提|刚刚)/i.test(content)) { + return "session"; + } + return type === "episodic" ? "session" : "global"; +} + function mapL0RowToRecord(row: L0RecordRow): L0Record { return { id: row.record_id, diff --git a/src/adapters/standalone/llm-runner.test.ts b/src/adapters/standalone/llm-runner.test.ts new file mode 100644 index 0000000..e3fd612 --- /dev/null +++ b/src/adapters/standalone/llm-runner.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const generateTextMock = vi.fn(); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: generateTextMock, + }; +}); + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: () => ({ + chat: (model: string) => ({ model }), + }), +})); + +describe("StandaloneLLMRunner", () => { + beforeEach(() => { + generateTextMock.mockReset(); + generateTextMock.mockResolvedValue({ text: "[]", steps: [] }); + }); + + it("does not expose tools when enableTools is false", async () => { + const { StandaloneLLMRunner } = await import("./llm-runner.js"); + const runner = new StandaloneLLMRunner({ + config: { + baseUrl: "https://api.example.test/v1", + apiKey: "test-key", + model: "deepseek-ai/DeepSeek-V4-Flash", + }, + enableTools: false, + }); + + await runner.run({ prompt: "return JSON", taskId: "l1" }); + + expect(generateTextMock).toHaveBeenCalledOnce(); + expect(generateTextMock.mock.calls[0]![0]).not.toHaveProperty("tools"); + }); + + it("passes configured provider options to disable provider thinking modes", async () => { + const { StandaloneLLMRunner } = await import("./llm-runner.js"); + const runner = new StandaloneLLMRunner({ + config: { + baseUrl: "https://api.example.test/v1", + apiKey: "test-key", + model: "deepseek-ai/DeepSeek-V4-Flash", + providerOptions: { + openai: { + extraBody: { + enable_thinking: false, + }, + }, + }, + }, + enableTools: false, + }); + + await runner.run({ prompt: "return JSON", taskId: "l1" }); + + expect(generateTextMock.mock.calls[0]![0]).toMatchObject({ + providerOptions: { + openai: { + extraBody: { + enable_thinking: false, + }, + }, + }, + }); + }); +}); diff --git a/src/adapters/standalone/llm-runner.ts b/src/adapters/standalone/llm-runner.ts index 2f7c9e1..935703e 100644 --- a/src/adapters/standalone/llm-runner.ts +++ b/src/adapters/standalone/llm-runner.ts @@ -49,6 +49,8 @@ export interface StandaloneLLMConfig { maxTokens?: number; /** Request timeout in milliseconds (default: 120_000). */ timeoutMs?: number; + /** Provider-specific options passed through to the AI SDK. */ + providerOptions?: Record; } // ============================ @@ -149,12 +151,6 @@ function createSandboxedTools(workspaceDir: string, logger?: Logger) { }; } -/** Read-only tool subset — used when enableTools=false to avoid empty tools rejection. */ -function createReadOnlyTools(workspaceDir: string, logger?: Logger) { - const all = createSandboxedTools(workspaceDir, logger); - return { read_file: all.read_file }; -} - // ============================ // StandaloneLLMRunner // ============================ @@ -188,30 +184,28 @@ export class StandaloneLLMRunner implements LLMRunner { `tools=${this.enableTools}, timeout=${timeoutMs}ms`, ); - // Create OpenAI-compatible provider via AI SDK - // Use "compatible" mode to call /chat/completions (not Responses API), - // which works with all OpenAI-compatible backends (DeepSeek, Qwen, etc.) + // Create OpenAI-compatible provider via AI SDK. const provider = createOpenAI({ baseURL: this.config.baseUrl, apiKey: this.config.apiKey, - compatibility: "compatible", }); - // Select tools based on mode - const tools = this.enableTools - ? createSandboxedTools(workspaceDir, this.logger) - : createReadOnlyTools(workspaceDir, this.logger); - try { - const result = await generateText({ + const request: Parameters[0] = { model: provider.chat(this.model), system: params.systemPrompt, prompt: params.prompt, - tools, stopWhen: stepCountIs(this.enableTools ? MAX_TOOL_ITERATIONS : 1), maxOutputTokens: maxTokens, abortSignal: AbortSignal.timeout(timeoutMs), - }); + providerOptions: (params.providerOptions ?? this.config.providerOptions) as any, + }; + + if (this.enableTools) { + request.tools = createSandboxedTools(workspaceDir, this.logger); + } + + const result = await generateText(request); const text = result.text.trim(); const totalMs = Date.now() - runStartMs; diff --git a/src/cli/commands/seed.ts b/src/cli/commands/seed.ts index 611af05..e9f996a 100644 --- a/src/cli/commands/seed.ts +++ b/src/cli/commands/seed.ts @@ -57,7 +57,7 @@ Examples: // Command handler // ============================ -async function runSeedCommand(opts: SeedCommandOptions, ctx: SeedCliContext): Promise { +export async function runSeedCommand(opts: SeedCommandOptions, ctx: SeedCliContext): Promise { const { logger } = ctx; logger.info(`${TAG} Starting seed command...`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 6015d68..6397429 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,11 +5,14 @@ * wires up all subcommands (currently: `seed`). * * Integration path: - * index.ts → api.registerCli() → registerMemoryTdaiCli() → registerSeedCommand() + * index.ts → api.registerCli() → registerMemoryTdaiCli() */ -import type { Command } from "commander"; -import { registerSeedCommand } from "./commands/seed.js"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { tsImport } from "tsx/esm/api"; +import type { SeedCommandOptions } from "../core/seed/types.js"; // ============================ // Context type @@ -51,10 +54,55 @@ export interface SeedCliContext { * @param program - The `memory-tdai` Commander command (already created by the registrar) * @param ctx - CLI context with config, state dir, and logger */ -export function registerMemoryTdaiCli(program: Command, ctx: SeedCliContext): void { - // Register subcommands - registerSeedCommand(program, ctx); +export function registerMemoryTdaiCli(program: any, ctx: SeedCliContext): void { + program + .command("seed") + .description("Seed historical conversation data into the memory pipeline (L0 → L1)") + .requiredOption("--input ", "Path to input JSON file") + .option("--output-dir ", "Output directory for pipeline data (default: auto-generated)") + .option("--session-key ", "Fallback session key when input lacks one") + .option("--config ", "Path to memory-tdai config override file (JSON, deep-merged on top of current plugin config)") + .option("--strict-round-role", "Require each round to have both user and assistant messages", false) + .option("--yes", "Skip interactive confirmations (e.g. timestamp auto-fill)", false) + .addHelpText("after", ` +Examples: + openclaw memory-tdai seed --input conversations.json + openclaw memory-tdai seed --input data.json --output-dir ./seed-output --strict-round-role + openclaw memory-tdai seed --input data.json --config ./seed-config.json + openclaw memory-tdai seed --input data.json --yes +`) + .action(async (rawOpts: Record) => { + const opts: SeedCommandOptions = { + input: rawOpts.input as string, + outputDir: rawOpts.outputDir as string | undefined, + sessionKey: rawOpts.sessionKey as string | undefined, + strictRoundRole: rawOpts.strictRoundRole === true, + yes: rawOpts.yes === true, + configFile: rawOpts.config as string | undefined, + }; + + const seedModule = await loadSeedCommandModule(); + await seedModule.runSeedCommand(opts, ctx); + }); // Future: registerQueryCommand(program, ctx); // Future: registerStatsCommand(program, ctx); } + +async function loadSeedCommandModule(): Promise<{ + runSeedCommand: (opts: SeedCommandOptions, ctx: SeedCliContext) => Promise; +}> { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, "commands", "seed.ts"), + path.join(here, "..", "src", "cli", "commands", "seed.ts"), + path.join(process.cwd(), "src", "cli", "commands", "seed.ts"), + ]; + const seedPath = candidates.find((candidate) => fs.existsSync(candidate)); + if (!seedPath) { + throw new Error("Unable to locate src/cli/commands/seed.ts"); + } + return tsImport(pathToFileURL(seedPath).href, import.meta.url) as Promise<{ + runSeedCommand: (opts: SeedCommandOptions, ctx: SeedCliContext) => Promise; + }>; +} diff --git a/src/cli/standalone.ts b/src/cli/standalone.ts new file mode 100644 index 0000000..df4590c --- /dev/null +++ b/src/cli/standalone.ts @@ -0,0 +1,28 @@ +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { registerMemoryTdaiCli } from "./index.js"; + +const logger = { + debug: process.env.MEMORY_TDAI_DEBUG ? (message: string) => console.debug(message) : undefined, + info: (message: string) => console.info(message), + warn: (message: string) => console.warn(message), + error: (message: string) => console.error(message), +}; + +const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(os.homedir(), ".openclaw"); + +const program = new Command(); +program + .name("memory-tdai") + .description("TencentDB Agent Memory CLI") + .showHelpAfterError(); + +registerMemoryTdaiCli(program, { + config: {}, + pluginConfig: {}, + stateDir, + logger, +}); + +await program.parseAsync(process.argv); diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..3df90f6 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "./config.js"; + +describe("parseConfig", () => { + it("parses secure offload backend TLS options", () => { + const cfg = parseConfig({ + offload: { + allowInsecureTls: true, + backendCaPemPath: "/tmp/custom-ca.pem", + }, + }); + + expect(cfg.offload.allowInsecureTls).toBe(true); + expect(cfg.offload.backendCaPemPath).toBe("/tmp/custom-ca.pem"); + }); + + it("parses standalone LLM provider options used to disable thinking", () => { + const cfg = parseConfig({ + llm: { + providerOptions: { + openai: { + extraBody: { + enable_thinking: false, + }, + }, + }, + }, + }); + + expect(cfg.llm.providerOptions).toEqual({ + openai: { + extraBody: { + enable_thinking: false, + }, + }, + }); + }); + + it("parses independent L2 and L3 pipeline stage switches", () => { + const cfg = parseConfig({ + pipeline: { + enableL2: false, + enableL3: false, + }, + }); + + expect(cfg.pipeline.enableL2).toBe(false); + expect(cfg.pipeline.enableL3).toBe(false); + }); + + it("parses ZeroEntropy embedding provider configuration", () => { + const cfg = parseConfig({ + embedding: { + provider: "zeroentropy", + baseUrl: "https://api.zeroentropy.dev", + apiKey: "ze-key", + model: "zembed-1", + dimensions: 2560, + }, + }); + + expect(cfg.embedding.enabled).toBe(true); + expect(cfg.embedding.provider).toBe("zeroentropy"); + expect(cfg.embedding.baseUrl).toBe("https://api.zeroentropy.dev"); + expect(cfg.embedding.model).toBe("zembed-1"); + expect(cfg.embedding.dimensions).toBe(2560); + expect(cfg.embedding.configError).toBeUndefined(); + }); +}); diff --git a/src/config.ts b/src/config.ts index e09cff5..3357083 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,10 @@ export interface PersonaConfig { /** Pipeline trigger settings (L1→L2→L3 scheduling). */ export interface PipelineTriggerConfig { + /** Enable L2 scene extraction stage (default: true) */ + enableL2: boolean; + /** Enable L3 persona generation stage (default: true) */ + enableL3: boolean; /** Trigger L1 after every N conversation rounds (default: 5) */ everyNConversations: number; /** Enable warm-up: start threshold at 1, double after each L1 (1→2→4→...→everyN) (default: true) */ @@ -192,6 +196,8 @@ export interface StandaloneLLMOverrideConfig { maxTokens: number; /** Request timeout in milliseconds (default: 120000). */ timeoutMs: number; + /** Provider-specific options passed through to the AI SDK generateText call. */ + providerOptions?: Record; } /** Context Offload settings — controls multi-layer context compression. */ @@ -233,6 +239,10 @@ export interface OffloadConfig { backendApiKey?: string; /** Backend call timeout in milliseconds (default: 10000) */ backendTimeoutMs: number; + /** Explicitly disable HTTPS certificate verification for backend calls. Default false. */ + allowInsecureTls: boolean; + /** Optional CA certificate PEM path used for HTTPS backend calls. */ + backendCaPemPath?: string; /** * Offload data retention days. Sessions/refs/mmds older than this are cleaned up. * 0 = disabled (default). Values in (0, 3) are treated as invalid and forced to 0. @@ -449,6 +459,8 @@ export function parseConfig(raw: Record | undefined): MemoryTda backendUrl: optStr(offloadGroup, "backendUrl"), backendApiKey: optStr(offloadGroup, "backendApiKey"), backendTimeoutMs: num(offloadGroup, "backendTimeoutMs") ?? 120000, + allowInsecureTls: bool(offloadGroup, "allowInsecureTls") ?? false, + backendCaPemPath: optStr(offloadGroup, "backendCaPemPath"), offloadRetentionDays: normalizeOffloadRetentionDays(num(offloadGroup, "offloadRetentionDays") ?? 0), logMaxSizeMb: num(offloadGroup, "logMaxSizeMb") ?? 50, userId: optStr(offloadGroup, "userId"), @@ -475,6 +487,8 @@ export function parseConfig(raw: Record | undefined): MemoryTda model: optStr(personaGroup, "model"), }, pipeline: { + enableL2: bool(pipelineGroup, "enableL2") ?? true, + enableL3: bool(pipelineGroup, "enableL3") ?? true, everyNConversations: num(pipelineGroup, "everyNConversations") ?? 5, enableWarmup: bool(pipelineGroup, "enableWarmup") ?? true, l1IdleTimeoutSeconds: num(pipelineGroup, "l1IdleTimeoutSeconds") ?? 600, @@ -535,6 +549,7 @@ export function parseConfig(raw: Record | undefined): MemoryTda model: str(llmGroup, "model") ?? "gpt-4o", maxTokens: num(llmGroup, "maxTokens") ?? 4096, timeoutMs: num(llmGroup, "timeoutMs") ?? 120_000, + providerOptions: plainObject(llmGroup, "providerOptions"), }; })(), offload, @@ -571,6 +586,11 @@ function bool(src: Record, key: string): boolean | undefined { return typeof v === "boolean" ? v : undefined; } +function plainObject(src: Record, key: string): Record | undefined { + const v = src[key]; + return v && typeof v === "object" && !Array.isArray(v) ? v as Record : undefined; +} + function strArray(src: Record, key: string): string[] | undefined { const v = src[key]; if (!Array.isArray(v)) return undefined; diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index cccb864..b29e385 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -375,7 +375,7 @@ async function searchMemories( // Hybrid: if the store natively supports hybrid search (e.g. TCVDB does // server-side dense + sparse + RRF in a single API call), short-circuit // to avoid a redundant second HTTP request and a wasted local embed(). - if (vectorStore?.getCapabilities().nativeHybridSearch) { + if (vectorStore?.getCapabilities().nativeHybridSearch && vectorStore.searchL1Hybrid) { const tNative = performance.now(); const results = await vectorStore.searchL1Hybrid({ query: cleanText, topK: maxResults }); const nativeMs = performance.now() - tNative; @@ -535,23 +535,27 @@ async function searchHybrid( if (ftsResults.length > 0) { logger?.debug?.(`${TAG} [hybrid-keyword-fts] FTS5 found ${ftsResults.length} candidates`); // Convert FtsSearchResult to ScoredRecord for RRF merge - const records = ftsResults.map((r): ScoredRecord => ({ - record: { - id: r.record_id, - content: r.content, - type: r.type as MemoryRecord["type"], - priority: r.priority, - scene_name: r.scene_name, - source_message_ids: [], - metadata: r.metadata_json ? (() => { try { return JSON.parse(r.metadata_json); } catch { return {}; } })() : {}, - timestamps: [r.timestamp_str].filter(Boolean), - createdAt: "", - updatedAt: "", - sessionKey: r.session_key, - sessionId: r.session_id, - }, - score: r.score, - })); + const records = ftsResults.map((r): ScoredRecord => { + const metadata = parseMetadataJson(r.metadata_json); + return { + record: { + id: r.record_id, + content: r.content, + type: r.type as MemoryRecord["type"], + scope: normalizeScope(metadata.scope, r.content, r.type as MemoryRecord["type"]), + priority: r.priority, + scene_name: r.scene_name, + source_message_ids: [], + metadata, + timestamps: [r.timestamp_str].filter(Boolean), + createdAt: "", + updatedAt: "", + sessionKey: r.session_key, + sessionId: r.session_id, + }, + score: r.score, + }; + }); return { records, ms: performance.now() - tStart }; } } @@ -788,3 +792,28 @@ function ftsResultToFormatable(r: L1FtsResult): FormatableMemory { timestamp: r.timestamp_str || undefined, }; } + +function parseMetadataJson(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function normalizeScope(raw: unknown, content: string, type: MemoryRecord["type"]): MemoryRecord["scope"] { + if (raw === "global" || raw === "project" || raw === "session") { + return raw; + } + if (/(这个项目|本项目|当前项目|当前仓库|这个仓库|工作区|PR|issue|腾讯这个项目)/i.test(content)) { + return "project"; + } + if (/(这次|本次|当前任务|本轮|临时|今天刚提|刚刚)/i.test(content)) { + return "session"; + } + return type === "episodic" ? "session" : "global"; +} diff --git a/src/core/prompts/l1-extraction.test.ts b/src/core/prompts/l1-extraction.test.ts new file mode 100644 index 0000000..c8f60d7 --- /dev/null +++ b/src/core/prompts/l1-extraction.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { EXTRACT_MEMORIES_SYSTEM_PROMPT } from "./l1-extraction.js"; + +describe("EXTRACT_MEMORIES_SYSTEM_PROMPT", () => { + it("requires every memory to carry an explicit scope", () => { + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain('"scope"'); + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain("global"); + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain("project"); + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain("session"); + }); + + it("prevents scene-limited instructions from becoming global rules", () => { + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain("场景受限"); + expect(EXTRACT_MEMORIES_SYSTEM_PROMPT).toContain("不得泛化为全局"); + }); +}); diff --git a/src/core/prompts/l1-extraction.ts b/src/core/prompts/l1-extraction.ts index 6378598..daaa68e 100644 --- a/src/core/prompts/l1-extraction.ts +++ b/src/core/prompts/l1-extraction.ts @@ -31,6 +31,11 @@ export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与 1. 宁缺毋滥:过滤琐碎闲聊、临时性指令和一次性操作(如"这次、本单");剔除不可靠的边缘信息。 2. 独立完整:记忆必须"跳出当前对话依然成立",无上下文也能看懂。提取主体必须以"用户(姓名)"或"AI"为核心。 3. 归纳合并:强关联或因果关系的多条消息,必须合并为一条完整记忆,不可碎片化。 +4. 范围标注:每条记忆必须输出 "scope",取值只能是 "global"、"project" 或 "session"。 + - global:跨项目长期成立的身份、偏好、能力、稳定规则。 + - project:明确限定在当前项目、仓库、工作区、业务域、PR 或 issue 的要求。 + - session:只对本轮、本次、当前任务、临时排查或一次性操作成立的上下文。 + - 场景受限、项目受限、本次任务限定的要求不得泛化为全局,必须标为 project 或 session。 【支持提取的三大类型】(必须严格遵守类型规则) @@ -46,11 +51,12 @@ export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与 - 时间约束:尽量基于消息的 timestamp 推算绝对时间,如能确定则在 metadata 中输出 activity_start_time 和 activity_end_time(ISO 8601格式)。无法确定时可省略。 - 打分 (priority):80-100(重要事件/计划);60-70(一般完整活动);<60(琐碎事项,直接丢弃)。 -3. 全局指令记忆 (type: "instruction") - - 定义:用户对 AI 提出的长期行为规则、格式偏好、语气控制。 +3. 指令记忆 (type: "instruction") + - 定义:用户对 AI 提出的行为规则、格式偏好、语气控制。必须结合 scope 判断长期性。 - 提取句式:"用户要求/希望 AI 以后回答时..." - 触发词:以后都、从现在开始、记住、必须。 - 打分 (priority):-1(极其严格的全局死命令);90-100(核心行为规则);70-80(重要要求);<70(临时要求,直接丢弃)。 + - 关键约束:只有明确跨项目、长期稳定的规则才可标为 scope="global";当前项目/当前仓库/这次任务里的规则必须标为 project 或 session。 --- @@ -74,6 +80,7 @@ export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与 { "content": "完整、独立的记忆陈述(按对应类型的句式要求)", "type": "persona|episodic|instruction", + "scope": "global|project|session", "priority": 80, "source_message_ids": ["消息ID_1", "消息ID_2"], "metadata": {} @@ -84,6 +91,7 @@ export const EXTRACT_MEMORIES_SYSTEM_PROMPT = `你是专业的"情境切分与 metadata 字段说明: - episodic 类型:如能确定活动时间,填入 {"activity_start_time": "ISO8601", "activity_end_time": "ISO8601"} +- 所有类型:scope 必须放在顶层字段,不要只放在 metadata 中。 - 其他类型或无法确定时间:输出空对象 {} 如果整段对话无有意义的记忆,也要输出情境分割结果,memories 为空数组: diff --git a/src/core/record/l1-dedup.ts b/src/core/record/l1-dedup.ts index 5d833e1..9268114 100644 --- a/src/core/record/l1-dedup.ts +++ b/src/core/record/l1-dedup.ts @@ -233,20 +233,24 @@ async function findCandidatesByVector( const candidates: MemoryRecord[] = searchResults .filter((r) => !newRecordIds.has(r.record_id)) .slice(0, topK) - .map((r) => ({ - id: r.record_id, - content: r.content, - type: r.type as MemoryRecord["type"], - priority: r.priority, - scene_name: r.scene_name, - source_message_ids: [], - metadata: {}, - timestamps: [r.timestamp_str].filter(Boolean), - createdAt: "", - updatedAt: "", - sessionKey: r.session_key, - sessionId: r.session_id, - })); + .map((r) => { + const metadata = parseMetadataJson(r.metadata_json); + return { + id: r.record_id, + content: r.content, + type: r.type as MemoryRecord["type"], + scope: normalizeScope(metadata.scope, r.content, r.type as MemoryRecord["type"]), + priority: r.priority, + scene_name: r.scene_name, + source_message_ids: [], + metadata, + timestamps: [r.timestamp_str].filter(Boolean), + createdAt: "", + updatedAt: "", + sessionKey: r.session_key, + sessionId: r.session_id, + }; + }); matches.push({ newMemory: mem, candidates }); } @@ -279,20 +283,24 @@ async function findCandidatesByFts( const candidates: MemoryRecord[] = ftsResults .filter((r) => !newRecordIds.has(r.record_id)) .slice(0, 5) - .map((r) => ({ - id: r.record_id, - content: r.content, - type: r.type as MemoryRecord["type"], - priority: r.priority, - scene_name: r.scene_name, - source_message_ids: [], - metadata: r.metadata_json ? (() => { try { return JSON.parse(r.metadata_json); } catch { return {}; } })() : {}, - timestamps: [r.timestamp_str].filter(Boolean), - createdAt: "", - updatedAt: "", - sessionKey: r.session_key, - sessionId: r.session_id, - })); + .map((r) => { + const metadata = parseMetadataJson(r.metadata_json); + return { + id: r.record_id, + content: r.content, + type: r.type as MemoryRecord["type"], + scope: normalizeScope(metadata.scope, r.content, r.type as MemoryRecord["type"]), + priority: r.priority, + scene_name: r.scene_name, + source_message_ids: [], + metadata, + timestamps: [r.timestamp_str].filter(Boolean), + createdAt: "", + updatedAt: "", + sessionKey: r.session_key, + sessionId: r.session_id, + }; + }); matches.push({ newMemory: mem, candidates }); } else { matches.push({ newMemory: mem, candidates: [] }); @@ -403,3 +411,28 @@ function fallbackStoreAll(memories: Array { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function normalizeScope(raw: unknown, content: string, type: MemoryRecord["type"]): MemoryRecord["scope"] { + if (raw === "global" || raw === "project" || raw === "session") { + return raw; + } + if (/(这个项目|本项目|当前项目|当前仓库|这个仓库|工作区|PR|issue|腾讯这个项目)/i.test(content)) { + return "project"; + } + if (/(这次|本次|当前任务|本轮|临时|今天刚提|刚刚)/i.test(content)) { + return "session"; + } + return type === "episodic" ? "session" : "global"; +} diff --git a/src/core/record/l1-extractor.ts b/src/core/record/l1-extractor.ts index ad0a8f1..3e15c8c 100644 --- a/src/core/record/l1-extractor.ts +++ b/src/core/record/l1-extractor.ts @@ -15,8 +15,8 @@ import type { ConversationMessage } from "../conversation/l0-recorder.js"; import { EXTRACT_MEMORIES_SYSTEM_PROMPT, formatExtractionPrompt } from "../prompts/l1-extraction.js"; import { batchDedup } from "./l1-dedup.js"; -import { writeMemory, generateMemoryId } from "./l1-writer.js"; -import type { ExtractedMemory, MemoryRecord, MemoryType, DedupDecision } from "./l1-writer.js"; +import { writeMemory, generateMemoryId, normalizeMemoryScope } from "./l1-writer.js"; +import type { ExtractedMemory, MemoryRecord, MemoryType, DedupDecision, MemoryScope } from "./l1-writer.js"; import { CleanContextRunner } from "../../utils/clean-context-runner.js"; import { sanitizeJsonForParse, shouldExtractL1 } from "../../utils/sanitize.js"; import type { IMemoryStore } from "../store/types.js"; @@ -44,6 +44,7 @@ interface SceneSegment { memories: Array<{ content: string; type: string; + scope?: string; priority: number; source_message_ids: string[]; metadata: Record; @@ -188,6 +189,7 @@ export async function extractL1Memories(params: { allExtracted.push({ content: mem.content, type: memType, + scope: normalizeMemoryScope(mem.scope) ?? inferMemoryScope(mem.scope, mem.content, memType), priority: typeof mem.priority === "number" ? mem.priority : 50, source_message_ids: Array.isArray(mem.source_message_ids) ? mem.source_message_ids : [], metadata: mem.metadata ?? {}, @@ -274,6 +276,7 @@ export async function extractL1Memories(params: { memoriesStoredContent: storedRecords.map((r) => ({ content: r.content, type: r.type, + scope: r.scope, scene: r.scene_name ?? null, })), memoriesByType, @@ -400,6 +403,7 @@ function parseExtractionResult(raw: string, logger?: Logger): SceneSegment[] { .map((m) => ({ content: String(m.content), type: String(m.type ?? "episodic"), + scope: typeof m.scope === "string" ? m.scope : undefined, priority: typeof m.priority === "number" ? m.priority : 50, source_message_ids: Array.isArray(m.source_message_ids) ? m.source_message_ids.map(String) : [], metadata: (m.metadata && typeof m.metadata === "object" ? m.metadata : {}) as Record, @@ -533,3 +537,16 @@ function normalizeType(raw: string): MemoryType | null { if (lower === "preference") return "persona"; // fold preference into persona return null; } + +function inferMemoryScope(rawScope: unknown, content: string, type: MemoryType): MemoryScope { + const normalized = normalizeMemoryScope(rawScope); + if (normalized) return normalized; + + if (/(这个项目|本项目|当前项目|当前仓库|这个仓库|工作区|PR|issue|腾讯这个项目)/i.test(content)) { + return "project"; + } + if (/(这次|本次|当前任务|本轮|临时|今天刚提|刚刚)/i.test(content)) { + return "session"; + } + return type === "episodic" ? "session" : "global"; +} diff --git a/src/core/record/l1-reader.ts b/src/core/record/l1-reader.ts index 66e8d39..f0a7aaa 100644 --- a/src/core/record/l1-reader.ts +++ b/src/core/record/l1-reader.ts @@ -13,7 +13,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { MemoryRecord, MemoryType, EpisodicMetadata } from "./l1-writer.js"; +import { normalizeMemoryScope } from "./l1-writer.js"; +import type { MemoryRecord, MemoryType, EpisodicMetadata, MemoryScope } from "./l1-writer.js"; import type { IMemoryStore, L1RecordRow, L1QueryFilter } from "../store/types.js"; // Re-export types that readers need @@ -62,9 +63,9 @@ export async function queryMemoryRecords( * Convert a raw SQLite L1RecordRow to a MemoryRecord (same shape as JSONL records). */ function rowToMemoryRecord(row: L1RecordRow): MemoryRecord { - let metadata: EpisodicMetadata | Record = {}; + let metadata: Record = {}; try { - metadata = JSON.parse(row.metadata_json) as EpisodicMetadata | Record; + metadata = JSON.parse(row.metadata_json) as Record; } catch { // malformed JSON — use empty object } @@ -81,6 +82,7 @@ function rowToMemoryRecord(row: L1RecordRow): MemoryRecord { id: row.record_id, content: row.content, type: row.type as MemoryType, + scope: normalizeRecordScope(metadata.scope, row.content, row.type as MemoryType), priority: row.priority, scene_name: row.scene_name, source_message_ids: [], // not stored in SQLite (vector search doesn't need them) @@ -149,7 +151,7 @@ export async function readMemoryRecords( if (parsed.sessionKey !== sessionKey) { continue; } - records.push(parsed as MemoryRecord); + records.push(normalizeRecord(parsed)); } catch { logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`); } @@ -185,7 +187,7 @@ export async function readAllMemoryRecords( const lines = raw.split("\n").filter((line: string) => line.trim()); for (const line of lines) { try { - allRecords.push(JSON.parse(line) as MemoryRecord); + allRecords.push(normalizeRecord(JSON.parse(line) as Partial)); } catch { logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${file}`); } @@ -209,6 +211,38 @@ export async function readAllMemoryRecords( } } +function normalizeRecord(parsed: Partial): MemoryRecord { + const type = (parsed.type ?? "episodic") as MemoryType; + return { + ...parsed, + type, + scope: normalizeRecordScope(parsed.scope, parsed.content ?? "", type), + metadata: parsed.metadata && typeof parsed.metadata === "object" ? parsed.metadata : {}, + source_message_ids: Array.isArray(parsed.source_message_ids) ? parsed.source_message_ids : [], + timestamps: Array.isArray(parsed.timestamps) ? parsed.timestamps : [], + id: parsed.id ?? "", + content: parsed.content ?? "", + priority: typeof parsed.priority === "number" ? parsed.priority : 50, + scene_name: parsed.scene_name ?? "", + createdAt: parsed.createdAt ?? "", + updatedAt: parsed.updatedAt ?? "", + sessionKey: parsed.sessionKey ?? "", + sessionId: parsed.sessionId ?? "", + }; +} + +function normalizeRecordScope(rawScope: unknown, content: string, type: MemoryType): MemoryScope { + const normalized = normalizeMemoryScope(rawScope); + if (normalized) return normalized; + if (/(这个项目|本项目|当前项目|当前仓库|这个仓库|工作区|PR|issue|腾讯这个项目)/i.test(content)) { + return "project"; + } + if (/(这次|本次|当前任务|本轮|临时|今天刚提|刚刚)/i.test(content)) { + return "session"; + } + return type === "episodic" ? "session" : "global"; +} + // ============================ // Helpers // ============================ diff --git a/src/core/record/l1-writer.ts b/src/core/record/l1-writer.ts index b5e618d..c4b1e5b 100644 --- a/src/core/record/l1-writer.ts +++ b/src/core/record/l1-writer.ts @@ -29,12 +29,18 @@ import type { EmbeddingService } from "../store/embedding.js"; /** v3: 3 memory types aligned with Kenty's extraction prompt */ export type MemoryType = "persona" | "episodic" | "instruction"; +/** Scope where a memory should be applied. */ +export type MemoryScope = "global" | "project" | "session"; + /** Metadata for episodic memories (activity time range) */ export interface EpisodicMetadata { activity_start_time?: string; // ISO 8601 activity_end_time?: string; // ISO 8601 } +/** Flexible persisted metadata object. */ +export type MemoryMetadata = Record; + /** * A persisted memory record in L1 JSONL files. * @@ -51,6 +57,8 @@ export interface MemoryRecord { content: string; /** Memory type: persona / episodic / instruction */ type: MemoryType; + /** Application scope: global / project / session */ + scope: MemoryScope; /** Priority score: 0-100 (higher = more important), -1 = strict global instruction */ priority: number; /** Scene name this memory belongs to */ @@ -58,7 +66,7 @@ export interface MemoryRecord { /** Source message IDs that contributed to this memory */ source_message_ids: string[]; /** Type-specific metadata (e.g., activity_start_time for episodic) */ - metadata: EpisodicMetadata | Record; + metadata: MemoryMetadata; /** Timestamp trail: all timestamps related to this memory (for merge history tracking) */ timestamps: string[]; /** Creation timestamp (ISO) */ @@ -78,9 +86,10 @@ export interface MemoryRecord { export interface ExtractedMemory { content: string; type: MemoryType; + scope: MemoryScope; priority: number; source_message_ids: string[]; - metadata: EpisodicMetadata | Record; + metadata: MemoryMetadata; /** Scene name this memory was extracted in */ scene_name: string; } @@ -130,6 +139,15 @@ export function generateMemoryId(): string { return `m_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`; } +export function normalizeMemoryScope(raw: unknown): MemoryScope | null { + if (typeof raw !== "string") return null; + const lower = raw.toLowerCase().trim(); + if (lower === "global" || lower === "project" || lower === "session") { + return lower; + } + return null; +} + /** * Write a memory record according to the dedup decision. * @@ -165,18 +183,21 @@ export async function writeMemory(params: { // Determine final content, type, priority based on action let finalContent: string; let finalType: MemoryType; + let finalScope: MemoryScope; let finalPriority: number; let finalTimestamps: string[]; if (decision.action === "merge" || decision.action === "update") { finalContent = decision.merged_content ?? memory.content; finalType = decision.merged_type ?? memory.type; + finalScope = memory.scope; finalPriority = decision.merged_priority ?? memory.priority; finalTimestamps = decision.merged_timestamps ?? [now]; } else { // store finalContent = memory.content; finalType = memory.type; + finalScope = memory.scope; finalPriority = memory.priority; finalTimestamps = [now]; } @@ -185,6 +206,7 @@ export async function writeMemory(params: { id: decision.record_id || generateMemoryId(), content: finalContent, type: finalType, + scope: finalScope, priority: finalPriority, scene_name: memory.scene_name, source_message_ids: memory.source_message_ids, @@ -277,4 +299,4 @@ function formatLocalDate(d: Date): string { const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; -} \ No newline at end of file +} diff --git a/src/core/store/embedding.test.ts b/src/core/store/embedding.test.ts new file mode 100644 index 0000000..a55d96a --- /dev/null +++ b/src/core/store/embedding.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { OpenAIEmbeddingService } from "./embedding.js"; + +describe("OpenAIEmbeddingService provider adapters", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + test("calls ZeroEntropy native embed API and parses results embeddings", async () => { + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => { + return new Response(JSON.stringify({ + results: [ + { embedding: [3, 4] }, + { embedding: [0, 5] }, + ], + }), { status: 200 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const service = new OpenAIEmbeddingService({ + provider: "zeroentropy", + baseUrl: "https://api.zeroentropy.dev/", + apiKey: "ze-key", + model: "zembed-1", + dimensions: 2, + }); + + const embeddings = await service.embedBatch(["query one", "query two"]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://api.zeroentropy.dev/v1/models/embed"); + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ + input: ["query one", "query two"], + input_type: "query", + model: "zembed-1", + }); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "Bearer ze-key", + }); + expect(Array.from(embeddings[0])).toEqual([0.6000000238418579, 0.800000011920929]); + expect(Array.from(embeddings[1])).toEqual([0, 1]); + }); +}); diff --git a/src/core/store/embedding.ts b/src/core/store/embedding.ts index 2850334..79e65a2 100644 --- a/src/core/store/embedding.ts +++ b/src/core/store/embedding.ts @@ -1,8 +1,9 @@ /** * Embedding Service: converts text to vector embeddings. * - * Supports two providers: - * - "openai": OpenAI-compatible embedding APIs (OpenAI, Azure OpenAI, self-hosted) + * Supports embedding providers: + * - OpenAI-compatible APIs (OpenAI, Azure OpenAI, DeepSeek, self-hosted) + * - "zeroentropy": ZeroEntropy native /v1/models/embed API * - "local": node-llama-cpp with embeddinggemma-300m GGUF model (fully offline) * * When no remote embedding is configured, automatically falls back to local provider. @@ -18,7 +19,7 @@ // ============================ export interface OpenAIEmbeddingConfig { - /** Provider identifier — any value other than "local" (e.g. "openai", "deepseek", "azure", "qclaw") */ + /** Provider identifier — any value other than "local" (e.g. "openai", "deepseek", "zeroentropy", "qclaw") */ provider: string; /** API base URL (required — must be specified by user, e.g. "https://api.openai.com/v1") */ baseUrl: string; @@ -401,6 +402,12 @@ interface OpenAIEmbeddingResponse { }; } +interface ZeroEntropyEmbeddingResponse { + results: Array<{ + embedding: number[]; + }>; +} + export class OpenAIEmbeddingService implements EmbeddingService { private readonly baseUrl: string; private readonly apiKey: string; @@ -494,21 +501,29 @@ export class OpenAIEmbeddingService implements EmbeddingService { } private async _callApi(texts: string[], timeoutOverride?: number): Promise { - const body: Record = { - input: texts, - model: this.model, - dimensions: this.dims, - }; + const isZeroEntropy = this.providerName.toLowerCase() === "zeroentropy"; + const body: Record = isZeroEntropy + ? { + input: texts, + input_type: "query", + model: this.model, + } + : { + input: texts, + model: this.model, + dimensions: this.dims, + }; // Determine fetch URL and headers based on proxy mode const useProxy = this.providerName === "qclaw" && !!this.proxyUrl; - const fetchUrl = useProxy ? this.proxyUrl! : `${this.baseUrl}/embeddings`; + const remoteUrl = isZeroEntropy ? `${this.baseUrl}/v1/models/embed` : `${this.baseUrl}/embeddings`; + const fetchUrl = useProxy ? this.proxyUrl! : remoteUrl; const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, }; if (useProxy) { - headers["Remote-URL"] = `${this.baseUrl}/embeddings`; + headers["Remote-URL"] = remoteUrl; this.logger?.debug?.( `${TAG} [qclaw-proxy] Forwarding embedding request via proxy: ${fetchUrl}, Remote-URL: ${headers["Remote-URL"]}`, ); @@ -543,14 +558,23 @@ export class OpenAIEmbeddingService implements EmbeddingService { continue; } - const json = (await resp.json()) as OpenAIEmbeddingResponse; + const json = await resp.json() as OpenAIEmbeddingResponse | ZeroEntropyEmbeddingResponse; + + if (isZeroEntropy) { + const zeJson = json as ZeroEntropyEmbeddingResponse; + if (!zeJson.results || !Array.isArray(zeJson.results)) { + throw new Error("ZeroEntropy embedding API returned unexpected format: missing 'results' array"); + } + return zeJson.results.map((d) => sanitizeAndNormalize(d.embedding)); + } - if (!json.data || !Array.isArray(json.data)) { + const openAiJson = json as OpenAIEmbeddingResponse; + if (!openAiJson.data || !Array.isArray(openAiJson.data)) { throw new Error("Embedding API returned unexpected format: missing 'data' array"); } // Sort by index to ensure correct order, then sanitize+normalize for consistency with local provider - const sorted = [...json.data].sort((a, b) => a.index - b.index); + const sorted = [...openAiJson.data].sort((a, b) => a.index - b.index); return sorted.map((d) => sanitizeAndNormalize(d.embedding)); } finally { clearTimeout(timeoutId); @@ -582,7 +606,7 @@ export class OpenAIEmbeddingService implements EmbeddingService { * Create an EmbeddingService from config. * * Strategy: - * - If config has provider != "local" with valid apiKey, model, and dimensions → use remote OpenAI-compatible embedding + * - If config has provider != "local" with valid apiKey, model, and dimensions → use remote embedding * - If config has provider="local" → use node-llama-cpp local embedding * - If config is undefined or missing required fields → fall back to local embedding * @@ -595,7 +619,7 @@ export function createEmbeddingService( config: EmbeddingConfig | undefined, logger?: Logger, ): EmbeddingService { - // Remote OpenAI-compatible provider: any provider value other than "local" + // Remote provider: any provider value other than "local" if (config && config.provider !== "local" && "apiKey" in config && config.apiKey) { logger?.debug?.(`${TAG} Using remote embedding (provider=${config.provider}, model=${config.model})`); return new OpenAIEmbeddingService(config as OpenAIEmbeddingConfig, logger); diff --git a/src/core/store/factory.test.ts b/src/core/store/factory.test.ts new file mode 100644 index 0000000..6145653 --- /dev/null +++ b/src/core/store/factory.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { parseConfig } from "../../config.js"; +import { createStoreBundle } from "./factory.js"; + +describe("createStoreBundle", () => { + const originalFetch = globalThis.fetch; + let dataDir: string | undefined; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }); + dataDir = undefined; + } + }); + + test("passes proxy and timeout options to remote embedding service", async () => { + dataDir = mkdtempSync(path.join(tmpdir(), "memory-tdai-store-")); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => { + return new Response(JSON.stringify({ + data: [{ index: 0, embedding: [1, 0] }], + }), { status: 200 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const bundle = createStoreBundle(parseConfig({ + embedding: { + provider: "qclaw", + proxyUrl: "http://127.0.0.1:8787/proxy", + baseUrl: "https://embedding.example/v1", + apiKey: "qclaw-key", + model: "qclaw-embedding", + dimensions: 2, + timeoutMs: 1234, + }, + }), { dataDir }); + + try { + await bundle.embedding.embed("hello"); + } finally { + bundle.store.close(); + } + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/proxy"); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + "Remote-URL": "https://embedding.example/v1/embeddings", + }); + expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); + }); +}); diff --git a/src/core/store/factory.ts b/src/core/store/factory.ts index 23cdb70..239853f 100644 --- a/src/core/store/factory.ts +++ b/src/core/store/factory.ts @@ -98,7 +98,9 @@ export function createStoreBundle( apiKey: config.embedding.apiKey, model: config.embedding.model, dimensions: config.embedding.dimensions, + proxyUrl: config.embedding.proxyUrl, maxInputChars: config.embedding.maxInputChars, + timeoutMs: config.embedding.timeoutMs, }, logger); } diff --git a/src/core/store/sqlite.ts b/src/core/store/sqlite.ts index 6252419..ffdd619 100644 --- a/src/core/store/sqlite.ts +++ b/src/core/store/sqlite.ts @@ -1046,7 +1046,7 @@ export class VectorStore implements IMemoryStore { tsEnd, record.createdAt, record.updatedAt, - JSON.stringify(record.metadata), + JSON.stringify({ ...record.metadata, scope: record.scope }), ); if (!skipVec) { @@ -1075,7 +1075,7 @@ export class VectorStore implements IMemoryStore { tsStr, tsStart, tsEnd, - JSON.stringify(record.metadata), + JSON.stringify({ ...record.metadata, scope: record.scope }), ); } catch (ftsErr) { // FTS write failure is non-fatal — log and continue diff --git a/src/core/store/tcvdb.ts b/src/core/store/tcvdb.ts index 35fe6a9..f62cb3e 100644 --- a/src/core/store/tcvdb.ts +++ b/src/core/store/tcvdb.ts @@ -424,7 +424,7 @@ export class TcvdbMemoryStore implements IMemoryStore { timestamp_end: tsEnd, created_time_ms: isoToEpochMs(record.createdAt), updated_time_ms: isoToEpochMs(record.updatedAt), - metadata_json: JSON.stringify(record.metadata), + metadata_json: JSON.stringify({ ...record.metadata, scope: record.scope }), }; // BM25 sparse vector (if sidecar available) @@ -469,7 +469,7 @@ export class TcvdbMemoryStore implements IMemoryStore { timestamp_end: tsEnd, created_time_ms: isoToEpochMs(record.createdAt), updated_time_ms: isoToEpochMs(record.updatedAt), - metadata_json: JSON.stringify(record.metadata), + metadata_json: JSON.stringify({ ...record.metadata, scope: record.scope }), }; if (this.bm25Encoder) { diff --git a/src/core/types.test.ts b/src/core/types.test.ts new file mode 100644 index 0000000..dcf6045 --- /dev/null +++ b/src/core/types.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_HOST_CAPABILITIES } from "./types.js"; + +describe("DEFAULT_HOST_CAPABILITIES", () => { + it("documents hook and transcript capabilities for planned hosts", () => { + expect(DEFAULT_HOST_CAPABILITIES.codex).toMatchObject({ + asyncHooks: false, + transcriptFormat: "codex-jsonl", + }); + expect(DEFAULT_HOST_CAPABILITIES.opencode).toMatchObject({ + transcriptFormat: "opencode", + }); + expect(DEFAULT_HOST_CAPABILITIES.openclaw).toMatchObject({ + transcriptFormat: "openclaw-messages", + }); + }); +}); diff --git a/src/core/types.ts b/src/core/types.ts index 8585b50..3df968e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -81,6 +81,8 @@ export interface LLMRunParams { workspaceDir?: string; /** Plugin instance ID for metric reporting (optional). */ instanceId?: string; + /** Provider-specific options passed through to the LLM provider. */ + providerOptions?: Record; } /** @@ -153,7 +155,7 @@ export interface LLMRunnerFactory { */ export interface HostAdapter { /** Identifies the host type for conditional behavior (should be rare). */ - readonly hostType: "openclaw" | "hermes" | "standalone"; + readonly hostType: "openclaw" | "hermes" | "standalone" | "codex" | "opencode" | "deepagent"; /** Get the unified runtime context for the current session. */ getRuntimeContext(): RuntimeContext; @@ -165,6 +167,57 @@ export interface HostAdapter { getLLMRunnerFactory(): LLMRunnerFactory; } +export interface HostCapabilities { + asyncHooks: boolean; + transcriptFormat: + | "openclaw-messages" + | "hermes-provider" + | "gateway-http" + | "codex-jsonl" + | "opencode"; + automaticRecall: boolean; + automaticCapture: boolean; +} + +export const DEFAULT_HOST_CAPABILITIES: Record = { + openclaw: { + asyncHooks: true, + transcriptFormat: "openclaw-messages", + automaticRecall: true, + automaticCapture: true, + }, + hermes: { + asyncHooks: false, + transcriptFormat: "hermes-provider", + automaticRecall: true, + automaticCapture: true, + }, + standalone: { + asyncHooks: false, + transcriptFormat: "gateway-http", + automaticRecall: true, + automaticCapture: true, + }, + codex: { + asyncHooks: false, + transcriptFormat: "codex-jsonl", + automaticRecall: true, + automaticCapture: true, + }, + opencode: { + asyncHooks: false, + transcriptFormat: "opencode", + automaticRecall: true, + automaticCapture: true, + }, + deepagent: { + asyncHooks: false, + transcriptFormat: "gateway-http", + automaticRecall: true, + automaticCapture: true, + }, +}; + // ============================ // CompletedTurn — represents a finished conversation turn // ============================ diff --git a/src/gateway/config.ts b/src/gateway/config.ts index 81c55ad..305ddd4 100644 --- a/src/gateway/config.ts +++ b/src/gateway/config.ts @@ -92,6 +92,7 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo model: env("TDAI_LLM_MODEL") ?? str(llmConfig, "model") ?? "gpt-4o", maxTokens: envInt("TDAI_LLM_MAX_TOKENS") ?? num(llmConfig, "maxTokens") ?? 4096, timeoutMs: envInt("TDAI_LLM_TIMEOUT_MS") ?? num(llmConfig, "timeoutMs") ?? 120_000, + providerOptions: plainObject(llmConfig, "providerOptions"), }; // Memory config (reuse the plugin's parseConfig for full compatibility) @@ -199,6 +200,11 @@ function num(src: Record, key: string): number | undefined { return typeof v === "number" && Number.isFinite(v) ? v : undefined; } +function plainObject(src: Record, key: string): Record | undefined { + const v = src[key]; + return v && typeof v === "object" && !Array.isArray(v) ? v as Record : undefined; +} + /** * Recursively replace ``${VAR_NAME}`` placeholders in string leaves with * the corresponding ``process.env`` value. Missing variables expand to an diff --git a/src/gateway/diagnostics.test.ts b/src/gateway/diagnostics.test.ts new file mode 100644 index 0000000..5c397ab --- /dev/null +++ b/src/gateway/diagnostics.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { buildGatewayDiagnostics } from "./server.js"; + +describe("gateway diagnostics", () => { + test("exposes process identity needed to verify which gateway is serving health checks", () => { + const diagnostics = buildGatewayDiagnostics("/tmp/memory-tdai"); + + expect(diagnostics.pid).toBe(process.pid); + expect(diagnostics.cwd).toBe(process.cwd()); + expect(diagnostics.dataDir).toBe("/tmp/memory-tdai"); + expect(diagnostics.user).toBe(process.env.USER ?? process.env.USERNAME ?? ""); + expect(diagnostics.home).toBe(process.env.HOME ?? process.env.USERPROFILE ?? ""); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bd7d0a0..b1db2b6 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -14,7 +14,9 @@ * Designed to run as a managed sidecar alongside Hermes. */ +import { createHash } from "node:crypto"; import http from "node:http"; +import path from "node:path"; import { URL } from "node:url"; import { TdaiCore } from "../core/tdai-core.js"; import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js"; @@ -46,6 +48,61 @@ import type { SeedProgress } from "../core/seed/types.js"; const TAG = "[tdai-gateway]"; const VERSION = "0.1.0"; +// ============================ +// User data scope +// ============================ + +export interface GatewayUserScope { + cacheKey: string; + dataDir: string; + isolated: boolean; +} + +function safePathSegment(value: string): string { + const normalized = value.normalize("NFKC").trim(); + const slug = normalized + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "user"; + const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 12); + return `${slug}-${digest}`; +} + +export function resolveGatewayUserScope(baseDir: string, userId?: string): GatewayUserScope { + const identity = userId?.trim(); + if (!identity) { + return { + cacheKey: "legacy", + dataDir: baseDir, + isolated: false, + }; + } + + return { + cacheKey: `user:${identity}`, + dataDir: path.join(baseDir, "users", safePathSegment(identity)), + isolated: true, + }; +} + +export interface GatewayDiagnostics { + pid: number; + cwd: string; + dataDir: string; + user: string; + home: string; +} + +export function buildGatewayDiagnostics(dataDir: string): GatewayDiagnostics { + return { + pid: process.pid, + cwd: process.cwd(), + dataDir, + user: process.env.USER ?? process.env.USERNAME ?? "", + home: process.env.HOME ?? process.env.USERPROFILE ?? "", + }; +} + // ============================ // Console logger (for standalone gateway — no OpenClaw logger available) // ============================ @@ -100,6 +157,7 @@ export class TdaiGateway { private config: GatewayConfig; private logger: Logger; private core: TdaiCore; + private readonly scopedCores = new Map>(); private server: http.Server | null = null; private startTime = Date.now(); @@ -121,6 +179,7 @@ export class TdaiGateway { config: this.config.memory, sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents), }); + this.scopedCores.set("legacy", Promise.resolve(this.core)); } /** @@ -160,7 +219,17 @@ export class TdaiGateway { }); } - await this.core.destroy(); + const corePromises = [...this.scopedCores.values()]; + const settled = await Promise.allSettled(corePromises); + const cores = new Set([this.core]); + for (const item of settled) { + if (item.status === "fulfilled") cores.add(item.value); + } + for (const core of cores) { + await core.destroy(); + } + this.scopedCores.clear(); + this.scopedCores.set("legacy", Promise.resolve(this.core)); this.logger.info("Gateway stopped"); } @@ -219,6 +288,7 @@ export class TdaiGateway { status: this.core.getVectorStore() ? "ok" : "degraded", version: VERSION, uptime: Math.floor((Date.now() - this.startTime) / 1000), + diagnostics: buildGatewayDiagnostics(this.config.data.baseDir), stores: { vectorStore: !!this.core.getVectorStore(), embeddingService: !!this.core.getEmbeddingService(), @@ -236,7 +306,8 @@ export class TdaiGateway { } const startMs = Date.now(); - const result = await this.core.handleBeforeRecall(body.query, body.session_key); + const core = await this.getCoreForUser(body.user_id); + const result = await core.handleBeforeRecall(body.query, body.session_key); const elapsed = Date.now() - startMs; this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`); @@ -258,7 +329,8 @@ export class TdaiGateway { } const startMs = Date.now(); - const result = await this.core.handleTurnCommitted({ + const core = await this.getCoreForUser(body.user_id); + const result = await core.handleTurnCommitted({ userText: body.user_content, assistantText: body.assistant_content, messages: body.messages ?? [ @@ -287,7 +359,8 @@ export class TdaiGateway { return; } - const result = await this.core.searchMemories({ + const core = await this.getCoreForUser(body.user_id); + const result = await core.searchMemories({ query: body.query, limit: body.limit, type: body.type, @@ -310,7 +383,8 @@ export class TdaiGateway { return; } - const result = await this.core.searchConversations({ + const core = await this.getCoreForUser(body.user_id); + const result = await core.searchConversations({ query: body.query, limit: body.limit, sessionKey: body.session_key, @@ -331,7 +405,8 @@ export class TdaiGateway { return; } - await this.core.handleSessionEnd(body.session_key); + const core = await this.getCoreForUser(body.user_id); + await core.handleSessionEnd(body.session_key); const response: SessionEndResponse = { flushed: true }; sendJson(res, 200, response); @@ -375,7 +450,8 @@ export class TdaiGateway { const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - const outputDir = `${this.config.data.baseDir}/seed-${ts}`; + const userScope = resolveGatewayUserScope(this.config.data.baseDir, body.user_id); + const outputDir = path.join(userScope.dataDir, `seed-${ts}`); // Merge config overrides if provided // Start with the base memory config + inject llm config from gateway settings @@ -389,6 +465,7 @@ export class TdaiGateway { model: this.config.llm.model, maxTokens: this.config.llm.maxTokens, timeoutMs: this.config.llm.timeoutMs, + providerOptions: this.config.llm.providerOptions, }, }; if (body.config_override) { @@ -433,6 +510,42 @@ export class TdaiGateway { }; sendJson(res, 200, response); } + + private async getCoreForUser(userId?: string): Promise { + const scope = resolveGatewayUserScope(this.config.data.baseDir, userId); + if (!scope.isolated) return this.core; + + const existing = this.scopedCores.get(scope.cacheKey); + if (existing) return existing; + + const corePromise = this.createScopedCore(scope.dataDir).catch((err) => { + this.scopedCores.delete(scope.cacheKey); + throw err; + }); + this.scopedCores.set(scope.cacheKey, corePromise); + return corePromise; + } + + private async createScopedCore(dataDir: string): Promise { + initDataDirectories(dataDir); + + const adapter = new StandaloneHostAdapter({ + dataDir, + llmConfig: this.config.llm, + logger: this.logger, + platform: "gateway", + }); + + const core = new TdaiCore({ + hostAdapter: adapter, + config: this.config.memory, + sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents), + }); + + await core.initialize(); + this.logger.info(`Gateway user scope initialized: ${dataDir}`); + return core; + } } // ============================ diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 50b2ff4..776bb57 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -19,6 +19,13 @@ export interface HealthResponse { status: "ok" | "degraded"; version: string; uptime: number; + diagnostics: { + pid: number; + cwd: string; + dataDir: string; + user: string; + home: string; + }; stores: { vectorStore: boolean; embeddingService: boolean; @@ -68,6 +75,7 @@ export interface MemorySearchRequest { limit?: number; type?: string; scene?: string; + user_id?: string; } export interface MemorySearchResponse { @@ -84,6 +92,7 @@ export interface ConversationSearchRequest { query: string; limit?: number; session_key?: string; + user_id?: string; } export interface ConversationSearchResponse { @@ -125,6 +134,8 @@ export interface SeedRequest { data: unknown; /** Fallback session key when input sessions lack one. */ session_key?: string; + /** Optional user identity used to route seeded data into an isolated gateway data scope. */ + user_id?: string; /** Require each round to have both user and assistant messages. */ strict_round_role?: boolean; /** Auto-fill missing timestamps (default: true). */ diff --git a/src/gateway/user-scope.test.ts b/src/gateway/user-scope.test.ts new file mode 100644 index 0000000..0b2f1ff --- /dev/null +++ b/src/gateway/user-scope.test.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { resolveGatewayUserScope } from "./server.js"; + +describe("gateway user data scope", () => { + test("keeps anonymous requests on the legacy base data directory", () => { + const baseDir = path.join("/tmp", "memory-tdai"); + + const scope = resolveGatewayUserScope(baseDir); + + expect(scope.isolated).toBe(false); + expect(scope.cacheKey).toBe("legacy"); + expect(scope.dataDir).toBe(baseDir); + }); + + test("routes an explicit user id into a stable isolated data directory", () => { + const baseDir = path.join("/tmp", "memory-tdai"); + + const scope = resolveGatewayUserScope(baseDir, " default "); + + expect(scope.isolated).toBe(true); + expect(scope.cacheKey).toBe("user:default"); + expect(scope.dataDir).toMatch(/\/tmp\/memory-tdai\/users\/default-[0-9a-f]{12}$/); + }); + + test("sanitizes malicious user ids so they cannot escape the base directory", () => { + const baseDir = path.join("/tmp", "memory-tdai"); + + const scope = resolveGatewayUserScope(baseDir, "../lejun"); + + expect(scope.isolated).toBe(true); + expect(scope.dataDir.startsWith(path.join(baseDir, "users") + path.sep)).toBe(true); + expect(scope.dataDir).not.toContain(".."); + }); +}); diff --git a/src/offload/backend-client.test.ts b/src/offload/backend-client.test.ts new file mode 100644 index 0000000..d78287b --- /dev/null +++ b/src/offload/backend-client.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { buildHttpsRequestOptions } from "./backend-client.js"; + +describe("buildHttpsRequestOptions", () => { + it("keeps TLS certificate verification enabled by default", () => { + const options = buildHttpsRequestOptions({}); + + expect(options).not.toHaveProperty("rejectUnauthorized", false); + }); + + it("only disables TLS verification when explicitly requested", () => { + const options = buildHttpsRequestOptions({ allowInsecureTls: true }); + + expect(options).toMatchObject({ rejectUnauthorized: false }); + }); + + it("passes a configured CA certificate without disabling verification", () => { + const ca = Buffer.from("-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n"); + + const options = buildHttpsRequestOptions({ ca }); + + expect(options).toMatchObject({ ca }); + expect(options).not.toHaveProperty("rejectUnauthorized", false); + }); +}); diff --git a/src/offload/backend-client.ts b/src/offload/backend-client.ts index 30dc622..66b3f2c 100644 --- a/src/offload/backend-client.ts +++ b/src/offload/backend-client.ts @@ -12,6 +12,7 @@ import type { OffloadEntry, ToolPair, TaskJudgment, PluginLogger } from "./types import { traceOffloadModelIo } from "./opik-tracer.js"; import * as https from "node:https"; import * as http from "node:http"; +import { readFile } from "node:fs/promises"; // ─── Request / Response Types ──────────────────────────────────────────────── @@ -101,6 +102,25 @@ export interface StoreStateResponse { insertedId?: string; } +export interface BackendClientTlsOptions { + allowInsecureTls?: boolean; + backendCaPemPath?: string; +} + +export function buildHttpsRequestOptions(options: { + allowInsecureTls?: boolean; + ca?: Buffer; +}): Pick { + const requestOptions: Pick = {}; + if (options.ca) { + requestOptions.ca = options.ca; + } + if (options.allowInsecureTls) { + requestOptions.rejectUnauthorized = false; + } + return requestOptions; +} + // ─── BackendClient ─────────────────────────────────────────────────────────── export class BackendClient { @@ -114,6 +134,8 @@ export class BackendClient { private userIdFn: () => string | null; /** Resolves the value of the `X-Task-Id` header sent on every call (optional). */ private taskIdFn: () => string | null; + private tlsOptions: BackendClientTlsOptions; + private warnedInsecureTls = false; constructor( baseUrl: string, @@ -123,6 +145,7 @@ export class BackendClient { sessionKeyFn?: () => string | null, userIdFn?: () => string | null, taskIdFn?: () => string | null, + tlsOptions?: BackendClientTlsOptions, ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.apiKey = apiKey; @@ -130,6 +153,7 @@ export class BackendClient { this.sessionKeyFn = sessionKeyFn ?? (() => null); this.userIdFn = userIdFn ?? (() => null); this.taskIdFn = taskIdFn ?? (() => null); + this.tlsOptions = tlsOptions ?? {}; } /** L1 Summarize — synchronous await (used by assemble flush + force trigger) */ @@ -296,6 +320,22 @@ export class BackendClient { const parsed = new URL(url); const isHttps = parsed.protocol === "https:"; const transport = isHttps ? https : http; + const ca = isHttps && this.tlsOptions.backendCaPemPath + ? await readFile(this.tlsOptions.backendCaPemPath) + : undefined; + const httpsRequestOptions = isHttps + ? buildHttpsRequestOptions({ + allowInsecureTls: this.tlsOptions.allowInsecureTls, + ca, + }) + : {}; + + if (isHttps && this.tlsOptions.allowInsecureTls && !this.warnedInsecureTls) { + this.warnedInsecureTls = true; + this.logger.warn( + "[context-offload] HTTPS backend TLS certificate verification is disabled by explicit allowInsecureTls=true", + ); + } return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -309,7 +349,7 @@ export class BackendClient { path: parsed.pathname + parsed.search, method: "POST", headers: reqHeaders, - ...(isHttps ? { rejectUnauthorized: false } : {}), + ...httpsRequestOptions, }, (res) => { let data = ""; diff --git a/src/offload/index.ts b/src/offload/index.ts index 33283df..c6edbe2 100644 --- a/src/offload/index.ts +++ b/src/offload/index.ts @@ -117,6 +117,17 @@ function simpleHash(str: string): number { return hash; } +export function parseProviderModelRef(modelRef: string): { providerKey: string; modelId: string } { + const slash = modelRef.indexOf("/"); + if (slash <= 0) { + return { providerKey: "", modelId: modelRef }; + } + return { + providerKey: modelRef.slice(0, slash), + modelId: modelRef.slice(slash + 1), + }; +} + function _extractLatestTurn(_messages: any[], currentPrompt: string | null): string | null { const effectivePrompt = _isHeartbeatText(currentPrompt ?? "") ? null : currentPrompt; @@ -326,6 +337,10 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { () => _lastActiveSessionKey, () => _resolvedUserId, () => _lastActiveSessionKey, + { + allowInsecureTls: offloadConfig.allowInsecureTls, + backendCaPemPath: offloadConfig.backendCaPemPath, + }, ); } } else { @@ -351,9 +366,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { } if (resolvedModelRef) { - const modelParts = resolvedModelRef.split("/", 2); - const providerKey = modelParts[0]; - const modelId = modelParts[1] ?? resolvedModelRef; + const { providerKey, modelId } = parseProviderModelRef(resolvedModelRef); const models = (api.config as any)?.models; const providerCfg = models?.providers?.[providerKey]; const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL; @@ -1242,17 +1255,17 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { // ─── OffloadContextEngine ──────────────────────────────────────────────────── class OffloadContextEngine { - private _sessions: SessionRegistry; - private _logger: PluginLogger; - private _pCfg: Partial; - private _getContextWindow: () => number; - private _notifyL2NewNullEntries: (count: number) => void; - private _clearL2Timeout: () => void; - private _l4State: { pendingResult: any }; - private _flushL1: (mgr: OffloadStateManager, triggerSource: string, fireAndForget?: boolean, maxCount?: number) => Promise; - private _backendClient: BackendClient | null; - private _judgeL15: (mgr: OffloadStateManager, event: any, ctx: any) => Promise; - private _disposeL15: () => void; + private _sessions!: SessionRegistry; + private _logger!: PluginLogger; + private _pCfg!: Partial; + private _getContextWindow!: () => number; + private _notifyL2NewNullEntries!: (count: number) => void; + private _clearL2Timeout!: () => void; + private _l4State!: { pendingResult: any }; + private _flushL1!: (mgr: OffloadStateManager, triggerSource: string, fireAndForget?: boolean, maxCount?: number) => Promise; + private _backendClient!: BackendClient | null; + private _judgeL15!: (mgr: OffloadStateManager, event: any, ctx: any) => Promise; + private _disposeL15!: () => void; constructor(opts: any) { this.update(opts); diff --git a/src/offload/local-llm/llm-caller.ts b/src/offload/local-llm/llm-caller.ts index 95ae691..b44d7c4 100644 --- a/src/offload/local-llm/llm-caller.ts +++ b/src/offload/local-llm/llm-caller.ts @@ -51,7 +51,6 @@ export async function callLlm( const provider = createOpenAI({ baseURL: config.baseUrl, apiKey: config.apiKey, - compatibility: "compatible", }); try { diff --git a/src/offload/local-llm/parsers/l1-parser.ts b/src/offload/local-llm/parsers/l1-parser.ts index 4e79021..e0f9052 100644 --- a/src/offload/local-llm/parsers/l1-parser.ts +++ b/src/offload/local-llm/parsers/l1-parser.ts @@ -31,6 +31,7 @@ export function parseL1Response(raw: string): OffloadEntry[] { tool_call_id: toolCallId, tool_call: item.tool_call ?? "", summary: item.summary ?? "", + result_ref: "", timestamp: item.timestamp ?? "", score: typeof item.score === "number" ? item.score : 5, node_id: null, diff --git a/src/offload/model-ref.test.ts b/src/offload/model-ref.test.ts new file mode 100644 index 0000000..0279d1f --- /dev/null +++ b/src/offload/model-ref.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { parseProviderModelRef } from "./index.js"; + +describe("parseProviderModelRef", () => { + it("splits provider and model at the first slash only", () => { + expect(parseProviderModelRef("siliconflow/deepseek-ai/DeepSeek-V4-Flash")).toEqual({ + providerKey: "siliconflow", + modelId: "deepseek-ai/DeepSeek-V4-Flash", + }); + }); + + it("uses the whole reference as modelId when no provider prefix exists", () => { + expect(parseProviderModelRef("DeepSeek-V4-Flash")).toEqual({ + providerKey: "", + modelId: "DeepSeek-V4-Flash", + }); + }); +}); diff --git a/src/offload/session-registry.ts b/src/offload/session-registry.ts index 0ace53b..12f8135 100644 --- a/src/offload/session-registry.ts +++ b/src/offload/session-registry.ts @@ -28,10 +28,10 @@ const MAX_CACHED_SESSIONS = 20; /** Routes sessionKey → per-session OffloadStateManager with LRU eviction. */ export class SessionRegistry { + private static _registryCounter = 0; private _sessions = new Map(); private _dataRoot: string; readonly _registryId = ++SessionRegistry._registryCounter; - private static _registryCounter = 0; constructor(dataRoot: string) { this._dataRoot = dataRoot; diff --git a/src/offload/state-manager.ts b/src/offload/state-manager.ts index 35d0045..389a8f2 100644 --- a/src/offload/state-manager.ts +++ b/src/offload/state-manager.ts @@ -31,6 +31,8 @@ const DEFAULT_STATE: PluginState & { estimatedSystemOverhead: number | null } = }; export class OffloadStateManager { + private static _instanceCounter = 0; + /** Immutable storage path context — set by init() or switchSession() */ private _ctx: StorageContext | null = null; @@ -54,7 +56,6 @@ export class OffloadStateManager { l15Settled = false; /** Unique instance ID for debugging (each new OffloadStateManager gets a new id). */ readonly _instanceId = ++OffloadStateManager._instanceCounter; - private static _instanceCounter = 0; /** Set of toolCallIds confirmed offloaded in previous rounds. */ confirmedOffloadIds = new Set(); diff --git a/src/offload/storage.test.ts b/src/offload/storage.test.ts index c22eded..fb380f0 100644 --- a/src/offload/storage.test.ts +++ b/src/offload/storage.test.ts @@ -1,17 +1,13 @@ import { describe, expect, it } from "vitest"; -import { sanitizeText } from "./storage.js"; +import { sanitizeJsonLine, sanitizeText } from "./storage.js"; -describe("sanitizeText", () => { +describe("offload storage sanitization", () => { it("preserves plain ASCII", () => { expect(sanitizeText("hello world")).toBe("hello world"); }); it("preserves emoji and other non-BMP code points", () => { - // 🎉 = U+1F389, 𠮷 = U+20BB7 (CJK Extension B), 𝐀 = U+1D400 (math bold A). - // Each is a surrogate pair in UTF-16. Without the `u` flag, the - // [\uD800-\uDFFF] range in UNSAFE_CHAR_RE would strip each half - // independently and silently destroy these characters. expect(sanitizeText("emoji \u{1F389} here")).toBe("emoji \u{1F389} here"); expect(sanitizeText("CJK ext-B \u{20BB7} here")).toBe( "CJK ext-B \u{20BB7} here", @@ -19,25 +15,36 @@ describe("sanitizeText", () => { expect(sanitizeText("math bold \u{1D400} here")).toBe( "math bold \u{1D400} here", ); + expect(sanitizeText("用户使用𠀀字和😀表情\u0000")).toBe("用户使用𠀀字和😀表情"); }); - it("strips lone (malformed) surrogates", () => { + it("strips lone malformed surrogates", () => { expect(sanitizeText("lone \uD800 surrogate")).toBe("lone surrogate"); expect(sanitizeText("lone \uDC00 surrogate")).toBe("lone surrogate"); }); it("strips C0 and C1 control characters", () => { - expect(sanitizeText("ctrlhere")).toBe("ctrlhere"); - expect(sanitizeText("c1…here")).toBe("c1here"); + expect(sanitizeText("ctrl\u0001here")).toBe("ctrlhere"); + expect(sanitizeText("c1\u0085here")).toBe("c1here"); }); it("strips zero-width characters and BOM", () => { - expect(sanitizeText("a​b")).toBe("ab"); - expect(sanitizeText("ab")).toBe("ab"); + expect(sanitizeText("a\u200Bb")).toBe("ab"); + expect(sanitizeText("a\uFEFFb")).toBe("ab"); + }); + + it("preserves valid non-BMP characters in JSONL rows", () => { + const row = JSON.stringify({ + tool_call_id: "tc_1", + summary: "保留𠀀和😀", + }); + + const parsed = JSON.parse(sanitizeJsonLine(row)) as { summary: string }; + + expect(parsed.summary).toBe("保留𠀀和😀"); }); it("returns non-string input unchanged", () => { - // Matches the existing typeof guard in sanitizeText. expect(sanitizeText(42 as unknown as string)).toBe(42); }); }); diff --git a/src/offload/storage.ts b/src/offload/storage.ts index 222a66b..a8df9f1 100644 --- a/src/offload/storage.ts +++ b/src/offload/storage.ts @@ -162,27 +162,45 @@ export async function listRegisteredSessions( // ─── JSONL Defense Layer ───────────────────────────────────────────────────── const UNSAFE_CHAR_RE = - /[\uFFFD\u0000-\u0008\u000B\u000C\u000E-\u001F\u0080-\u009F\uD800-\uDFFF\u200B-\u200F\u2028\u2029\uFEFF]/gu; + /[\uFFFD\u0000-\u0008\u000B\u000C\u000E-\u001F\u0080-\u009F\u200B-\u200F\u2028\u2029\uFEFF]/g; + +function stripUnsafeCharacters(text: string): string { + const withoutUnsafe = text.replace(UNSAFE_CHAR_RE, ""); + let out = ""; + for (let i = 0; i < withoutUnsafe.length; i++) { + const code = withoutUnsafe.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = withoutUnsafe.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + out += withoutUnsafe[i] + withoutUnsafe[i + 1]; + i++; + } + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) { + continue; + } + out += withoutUnsafe[i]; + } + return out; +} /** Layer 0 — Source text sanitize. Strips unsafe characters from arbitrary text. */ export function sanitizeText(text: string): string { if (typeof text !== "string") return text; - return text.replace(UNSAFE_CHAR_RE, ""); + return stripUnsafeCharacters(text); } /** Layer 1 — Write sanitize. Strips unsafe characters from a JSON string with roundtrip verification. */ export function sanitizeJsonLine(jsonStr: string): string { - let cleaned = jsonStr.replace(UNSAFE_CHAR_RE, ""); + let cleaned = stripUnsafeCharacters(jsonStr); try { JSON.parse(cleaned); return cleaned; } catch { /* fall through */ } - cleaned = jsonStr.replace( - /[^\x09\x0A\x0D\x20-\x7E\u00A0-\u024F\u3400-\u4DBF\u4E00-\u9FFF\uFF00-\uFFEF]/g, - "", - ); + cleaned = stripUnsafeCharacters(jsonStr.replace(/[\u0000-\u001F]/g, "")); try { JSON.parse(cleaned); return cleaned; @@ -190,7 +208,7 @@ export function sanitizeJsonLine(jsonStr: string): string { /* fall through */ } try { - const obj = JSON.parse(jsonStr.replace(/[^\x20-\x7E\t\n\r]/g, "")); + const obj = JSON.parse(stripUnsafeCharacters(jsonStr)); return JSON.stringify(obj); } catch { return "{}"; diff --git a/src/utils/pipeline-factory.ts b/src/utils/pipeline-factory.ts index 4595753..225dd5b 100644 --- a/src/utils/pipeline-factory.ts +++ b/src/utils/pipeline-factory.ts @@ -666,11 +666,15 @@ export function createPipelineManager( enableWarmup: cfg.pipeline.enableWarmup, l1: { idleTimeoutSeconds: cfg.pipeline.l1IdleTimeoutSeconds }, l2: { + enabled: cfg.pipeline.enableL2, delayAfterL1Seconds: cfg.pipeline.l2DelayAfterL1Seconds, minIntervalSeconds: cfg.pipeline.l2MinIntervalSeconds, maxIntervalSeconds: cfg.pipeline.l2MaxIntervalSeconds, sessionActiveWindowHours: cfg.pipeline.sessionActiveWindowHours, }, + l3: { + enabled: cfg.pipeline.enableL3, + }, }, logger, sessionFilter ?? new SessionFilter([]), diff --git a/src/utils/pipeline-manager.test.ts b/src/utils/pipeline-manager.test.ts new file mode 100644 index 0000000..ec3ec15 --- /dev/null +++ b/src/utils/pipeline-manager.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { MemoryPipelineManager } from "./pipeline-manager.js"; + +const logger = { + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +describe("MemoryPipelineManager stage controls", () => { + it("exposes disabled L2/L3 stage configuration", () => { + const manager = new MemoryPipelineManager({ + everyNConversations: 5, + enableWarmup: true, + l1: { idleTimeoutSeconds: 600 }, + l2: { + enabled: false, + delayAfterL1Seconds: 90, + minIntervalSeconds: 900, + maxIntervalSeconds: 3600, + sessionActiveWindowHours: 24, + }, + l3: { + enabled: false, + }, + }, logger); + + expect(manager.getStageConfig()).toEqual({ l2Enabled: false, l3Enabled: false }); + }); +}); diff --git a/src/utils/pipeline-manager.ts b/src/utils/pipeline-manager.ts index b56234c..cf882d2 100644 --- a/src/utils/pipeline-manager.ts +++ b/src/utils/pipeline-manager.ts @@ -126,6 +126,8 @@ export interface PipelineConfig { }; l2: { + /** Whether L2 scene extraction is enabled. Default: true. */ + enabled?: boolean; /** * Delay after L1 completes before triggering L2 (seconds, default: 90). * Allows remote L1 to finish generating records asynchronously. @@ -144,6 +146,11 @@ export interface PipelineConfig { */ sessionActiveWindowHours: number; }; + + l3?: { + /** Whether L3 persona generation is enabled. Default: true. */ + enabled?: boolean; + }; } /** Result returned by the L1 runner. */ @@ -198,6 +205,8 @@ export class MemoryPipelineManager { private readonly l1IdleTimeoutMs: number; private readonly everyNConversations: number; private readonly enableWarmup: boolean; + private readonly l2Enabled: boolean; + private readonly l3Enabled: boolean; private readonly l2DelayAfterL1Ms: number; private readonly l2MinIntervalMs: number; private readonly l2MaxIntervalMs: number; @@ -255,6 +264,8 @@ export class MemoryPipelineManager { this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000; this.everyNConversations = config.everyNConversations; this.enableWarmup = config.enableWarmup; + this.l2Enabled = config.l2.enabled ?? true; + this.l3Enabled = config.l3?.enabled ?? true; this.l2DelayAfterL1Ms = config.l2.delayAfterL1Seconds * 1000; this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000; this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000; @@ -265,6 +276,8 @@ export class MemoryPipelineManager { this.logger?.debug?.( `${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` + `warmup=${config.enableWarmup ? "enabled" : "disabled"}, ` + + `l2=${this.l2Enabled ? "enabled" : "disabled"}, ` + + `l3=${this.l3Enabled ? "enabled" : "disabled"}, ` + `l1IdleTimeout=${config.l1.idleTimeoutSeconds}s, ` + `l2DelayAfterL1=${config.l2.delayAfterL1Seconds}s, ` + `l2MinInterval=${config.l2.minIntervalSeconds}s, ` + @@ -580,6 +593,10 @@ export class MemoryPipelineManager { // Step 3: Flush all L2 schedule timers for (const [sessionKey, timers] of this.sessionTimers) { if (timers.l2Schedule.pending) { + if (!this.l2Enabled) { + timers.l2Schedule.cancel(); + continue; + } this.logger?.debug?.(`${TAG} [${sessionKey}] Flush: triggering L2 schedule timer`); timers.l2Schedule.flush(); } @@ -690,7 +707,9 @@ export class MemoryPipelineManager { state.conversation_count = 0; this.advanceWarmupThreshold(state); await this.persistStates(); - this.advanceL2Timer(sessionKey); + if (this.l2Enabled) { + this.advanceL2Timer(sessionKey); + } return; } @@ -744,7 +763,9 @@ export class MemoryPipelineManager { await this.persistStates(); // Advance the L2 timer (downward-only) to fire after delay, respecting minInterval - this.advanceL2Timer(sessionKey); + if (this.l2Enabled) { + this.advanceL2Timer(sessionKey); + } } // ============================ @@ -762,6 +783,10 @@ export class MemoryPipelineManager { */ private advanceL2Timer(sessionKey: string): void { if (this.destroyed) return; + if (!this.l2Enabled) { + this.logger?.debug?.(`${TAG} [${sessionKey}] L2 timer not armed: L2 disabled`); + return; + } const timers = this.getOrCreateTimers(sessionKey); const now = Date.now(); @@ -796,6 +821,7 @@ export class MemoryPipelineManager { */ private armL2MaxInterval(sessionKey: string): void { if (this.destroyed) return; + if (!this.l2Enabled) return; const timers = this.getOrCreateTimers(sessionKey); const fireAt = Date.now() + this.l2MaxIntervalMs; @@ -819,6 +845,8 @@ export class MemoryPipelineManager { * - "max-interval": periodic timer — apply cold check normally. */ private onL2TimerFired(sessionKey: string, source: "delay-after-l1" | "max-interval"): void { + if (!this.l2Enabled) return; + const state = this.sessionStates.get(sessionKey); if (!state) return; @@ -844,6 +872,11 @@ export class MemoryPipelineManager { // ============================ private enqueueL2(sessionKey: string, trigger: string): void { + if (!this.l2Enabled) { + this.logger?.debug?.(`${TAG} [${sessionKey}] L2 enqueue skipped: L2 disabled (trigger=${trigger})`); + return; + } + const timers = this.getOrCreateTimers(sessionKey); // Cancel any pending L2 timer (we're about to run L2) @@ -895,7 +928,9 @@ export class MemoryPipelineManager { `${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`, ); // Even on failure, arm maxInterval so we retry eventually - this.armL2MaxInterval(sessionKey); + if (this.l2Enabled) { + this.armL2MaxInterval(sessionKey); + } return; } @@ -907,7 +942,7 @@ export class MemoryPipelineManager { this.l2LastRunTime.set(sessionKey, now); // Advance cursor using the record timestamp returned by the runner - if (result?.latestCursor) { + if (result && result.latestCursor) { state.last_extraction_updated_time = result.latestCursor; } @@ -919,7 +954,9 @@ export class MemoryPipelineManager { this.armL2MaxInterval(sessionKey); // Trigger L3 - this.triggerL3(); + if (this.l3Enabled) { + this.triggerL3(); + } } // ============================ @@ -928,6 +965,10 @@ export class MemoryPipelineManager { private triggerL3(): void { if (this.destroyed) return; + if (!this.l3Enabled) { + this.logger?.debug?.(`${TAG} L3 trigger skipped: L3 disabled`); + return; + } if (this.l3Running) { // L3 is in progress — mark pending so it runs again after current finishes @@ -956,7 +997,7 @@ export class MemoryPipelineManager { this.l3Running = false; // If new L2 completions happened while L3 was running, run again - if (this.l3Pending && !this.destroyed) { + if (this.l3Pending && !this.destroyed && this.l3Enabled) { this.logger?.debug?.(`${TAG} L3 has pending work, re-running`); this.enqueueL3(); } @@ -1109,7 +1150,9 @@ export class MemoryPipelineManager { state.conversation_count = 0; // Arm L2 timer with delay (gives the system time to fully start) - this.advanceL2Timer(sessionKey); + if (this.l2Enabled) { + this.advanceL2Timer(sessionKey); + } } } @@ -1138,6 +1181,14 @@ export class MemoryPipelineManager { return this.destroyed; } + /** Enabled/disabled status for optional pipeline stages. */ + getStageConfig(): { l2Enabled: boolean; l3Enabled: boolean } { + return { + l2Enabled: this.l2Enabled, + l3Enabled: this.l3Enabled, + }; + } + /** Queue sizes and running state for monitoring. */ getQueueSizes(): { l1: number; l2: number; l3: number; diff --git a/tsdown.config.ts b/tsdown.config.ts index 16b0073..22dd8e6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,10 @@ function collectExternalDependencies(): string[] { } export default defineConfig({ - entry: ["./index.ts"], + entry: { + index: "./index.ts", + cli: "./src/cli/standalone.ts", + }, outDir: "./dist", format: "esm", platform: "node",