From 46be21c1b1f8eb0378ae2d16901853242f406477 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 8 May 2026 14:29:03 +0800 Subject: [PATCH 1/5] log time --- src/dual-write-client.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dual-write-client.ts b/src/dual-write-client.ts index 17538b7..dac46fc 100644 --- a/src/dual-write-client.ts +++ b/src/dual-write-client.ts @@ -371,12 +371,18 @@ export class DualWriteClient { this.syncMaxDelayMs, this.syncBaseDelayMs * Math.pow(2, row.retries), ); - const nextRetryAt = new Date(Date.now() + delay).toISOString(); + const nextRetry = new Date(Date.now() + delay); + const nextRetryAt = nextRetry.toISOString(); + const nextRetryAtLog = nextRetry.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + timeZoneName: "short", + }); const reasonRaw = err instanceof Error ? err.message : String(err); const reason = reasonRaw.slice(0, 500); this.local.scheduleRetries([row.id], nextRetryAt, reason); this.logger?.warn?.( - `dual-write: pending id=${row.id} retry scheduled at ${nextRetryAt}, reason=${reason}`, + `dual-write: pending id=${row.id} retry scheduled at ${nextRetryAtLog}, reason=${reason}`, ); break; } From f9321487e8975d4a37cdcde06212504fdc349a22 Mon Sep 17 00:00:00 2001 From: Teingi Date: Fri, 8 May 2026 19:18:11 +0800 Subject: [PATCH 2/5] add openclaw ltm agent-identities/identity --- README.md | 8 ++ README_CN.md | 8 ++ src/index.ts | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 266 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf77436..623bb37 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,14 @@ Exposed to OpenClaw agents: - `openclaw ltm import-md [paths...] [--force] [--dry-run] [--delay-ms n] [--max-file-bytes n] [--max-files n] [--max-chunks n]` — Import existing markdown memories. With no paths, scans `memory/`, `MEMORY.md`, and `USER.md`. - `openclaw ltm import-md-status [paths...] [--json]` — Show per-file markdown import status: imported, changed, skipped, failed, or not imported. +**Identity files (optional):** When plugin config leaves `userId` / `agentId` as `auto` (or omits them), stable IDs are stored under `/powermem/identity.json` (defaults) and `/powermem/agent-identities.json` (per OpenClaw agent key). If you set `userId` or `agentId` explicitly in `openclaw.json`, those values override the files at runtime. + +- `openclaw ltm identity show [--json]` — Print path and stored `userId` / `agentId` in `identity.json`. +- `openclaw ltm identity set --user-id ` / `--agent-id ` — Set one or both (missing fields keep existing values or are auto-generated). +- `openclaw ltm agent-identities show [--json]` — List each OpenClaw agent key and its PowerMem `userId` / `agentId`. +- `openclaw ltm agent-identities set --agent --user-id ` / `--agent-id ` — Update one entry; creating a new key requires both `--user-id` and `--agent-id`. +- `openclaw ltm sync-user-id [--user-id ] [--from identity|agent] [--agent ]` — Use a single `userId` in `identity.json` and in every `agent-identities.json` entry (each entry’s PowerMem `agentId` is unchanged). With no `--user-id`, reads the canonical id from `identity.json` (`--from identity`, default) or from one map entry (`--from agent --agent `). + --- ## Troubleshooting diff --git a/README_CN.md b/README_CN.md index 768142c..445d40b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -351,6 +351,14 @@ openclaw ltm search "咖啡" - `openclaw ltm import-md [paths...] [--force] [--dry-run] [--delay-ms n] [--max-file-bytes n] [--max-files n] [--max-chunks n]` — 导入已有 markdown 记忆;不传路径时扫描 `memory/`、`MEMORY.md`、`USER.md` - `openclaw ltm import-md-status [paths...] [--json]` — 查看每个 markdown 文件的导入状态:已导入、已变更、跳过、失败或未导入 +**身份文件(可选):** 若插件配置将 `userId` / `agentId` 设为 `auto`(或未填写),稳定 ID 会保存在 `/powermem/identity.json`(默认值)与 `/powermem/agent-identities.json`(按 OpenClaw agent key)。若在 `openclaw.json` 中显式设置了 `userId` 或 `agentId`,运行时将优先使用该配置,覆盖文件中的值。 + +- `openclaw ltm identity show [--json]` — 打印路径及 `identity.json` 中存储的 `userId` / `agentId`。 +- `openclaw ltm identity set --user-id ` / `--agent-id ` — 设置其一或两者(未指定的字段保留已有值或自动生成)。 +- `openclaw ltm agent-identities show [--json]` — 列出每个 OpenClaw agent key 及其对应的 PowerMem `userId` / `agentId`。 +- `openclaw ltm agent-identities set --agent --user-id ` / `--agent-id ` — 更新一条映射;新建 key 时需同时提供 `--user-id` 与 `--agent-id`。 +- `openclaw ltm sync-user-id [--user-id ] [--from identity|agent] [--agent ]` — 在 `identity.json` 与 `agent-identities.json` 的每条记录中使用同一个 `userId`(各条目的 PowerMem `agentId` 不变)。省略 `--user-id` 时,从 `identity.json` 读取规范 id(`--from identity`,默认)或从某条映射读取(`--from agent --agent `)。 + --- ## 常见问题 diff --git a/src/index.ts b/src/index.ts index fe7e6c0..fab33da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,6 +164,41 @@ function saveAgentIdentityMap( } } +type StoredIdentityFile = { userId?: string; agentId?: string }; + +function readStoredIdentityFile(identityPath: string): StoredIdentityFile { + try { + const raw = readFileSync(identityPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const o = parsed as Record; + return { + userId: typeof o.userId === "string" ? o.userId : undefined, + agentId: typeof o.agentId === "string" ? o.agentId : undefined, + }; + } + } catch { + /* missing or invalid */ + } + return {}; +} + +function writeStoredIdentityFile( + powermemDir: string, + identityPath: string, + data: { userId: string; agentId: string }, + logger: Logger, +): boolean { + try { + mkdirSync(powermemDir, { recursive: true }); + writeFileSync(identityPath, JSON.stringify(data, null, 2), "utf-8"); + return true; + } catch (err) { + logger.warn?.(`memory-powermem: failed to write identity.json: ${String(err)}`); + return false; + } +} + type MemoryClient = { health: () => Promise<{ status: string; error?: string }>; add: ( @@ -266,7 +301,9 @@ const memoryPlugin = { : undefined; const defaultIdentity: AgentIdentity = { userId, agentId }; - const agentIdentityPath = join(stateDir, "powermem", "agent-identities.json"); + const powermemDir = join(stateDir, "powermem"); + const identityPath = join(powermemDir, "identity.json"); + const agentIdentityPath = join(powermemDir, "agent-identities.json"); const agentIdentityMap = loadAgentIdentityMap(agentIdentityPath, api.logger); let defaultIdentityBound = agentIdentityMap.size > 0; @@ -1391,6 +1428,218 @@ const memoryPlugin = { .command("ltm") .description("PowerMem long-term memory plugin commands"); + const identityCmd = ltm + .command("identity") + .description( + "Read or edit /powermem/identity.json (default user/agent ids when config uses auto)", + ); + + identityCmd + .command("show") + .description("Print identity.json path and stored userId / agentId") + .option("--json", "Machine-readable JSON only") + .action((...args: unknown[]) => { + const opts = (args[0] ?? {}) as { json?: boolean }; + const stored = readStoredIdentityFile(identityPath); + if (opts.json === true) { + console.log(JSON.stringify({ path: identityPath, ...stored }, null, 2)); + return; + } + console.log(`path: ${identityPath}`); + console.log(`userId: ${stored.userId ?? "(unset)"}`); + console.log(`agentId: ${stored.agentId ?? "(unset)"}`); + }); + + identityCmd + .command("set") + .description("Set userId and/or agentId in identity.json (omitted fields keep existing or auto-generate)") + .option("--user-id ", "PowerMem user id") + .option("--agent-id ", "PowerMem agent id") + .action(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { userId?: string; agentId?: string }; + const rawUser = opts.userId?.trim(); + const rawAgent = opts.agentId?.trim(); + if (!rawUser && !rawAgent) { + console.error("Provide at least one of --user-id or --agent-id."); + process.exitCode = 1; + return; + } + const stored = readStoredIdentityFile(identityPath); + const nextUserId = + rawUser ?? stored.userId?.trim() ?? `user-${randomUUID()}`; + const nextAgentId = + rawAgent ?? stored.agentId?.trim() ?? `agent-${randomUUID()}`; + if ( + !writeStoredIdentityFile(powermemDir, identityPath, { userId: nextUserId, agentId: nextAgentId }, api.logger) + ) { + process.exitCode = 1; + return; + } + console.log(`Updated ${identityPath}`); + console.log(JSON.stringify({ userId: nextUserId, agentId: nextAgentId }, null, 2)); + }); + + const agentIdentitiesCmd = ltm + .command("agent-identities") + .description( + "Read or edit /powermem/agent-identities.json (per OpenClaw agent → PowerMem ids)", + ); + + agentIdentitiesCmd + .command("show") + .description("List OpenClaw agent keys and their PowerMem userId / agentId") + .option("--json", "Machine-readable JSON only") + .action((...args: unknown[]) => { + const opts = (args[0] ?? {}) as { json?: boolean }; + const map = loadAgentIdentityMap(agentIdentityPath, api.logger); + const agents: Record = {}; + for (const [k, v] of map.entries()) { + agents[k] = v; + } + if (opts.json === true) { + console.log(JSON.stringify({ path: agentIdentityPath, agents }, null, 2)); + return; + } + console.log(`path: ${agentIdentityPath}`); + const keys = Object.keys(agents); + if (keys.length === 0) { + console.log("(no entries)"); + return; + } + for (const key of keys) { + const v = agents[key]; + console.log(`${key}\tuserId=${v.userId}\tagentId=${v.agentId}`); + } + }); + + agentIdentitiesCmd + .command("set") + .description( + "Set PowerMem userId and/or agentId for one OpenClaw agent key (creates entry only if both ids are provided when missing)", + ) + .option("--agent ", "OpenClaw agent id (JSON object key)", "") + .option("--user-id ", "PowerMem user id") + .option("--agent-id ", "PowerMem agent id") + .action(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + agent?: string; + userId?: string; + agentId?: string; + }; + const key = opts.agent?.trim(); + const rawUser = opts.userId?.trim(); + const rawAgent = opts.agentId?.trim(); + if (!key) { + console.error("Missing --agent."); + process.exitCode = 1; + return; + } + if (!rawUser && !rawAgent) { + console.error("Provide at least one of --user-id or --agent-id."); + process.exitCode = 1; + return; + } + const map = loadAgentIdentityMap(agentIdentityPath, api.logger); + const existing = map.get(key); + if (!existing) { + if (!rawUser || !rawAgent) { + console.error( + "No existing entry for this --agent; provide both --user-id and --agent-id to create one.", + ); + process.exitCode = 1; + return; + } + map.set(key, { userId: rawUser, agentId: rawAgent }); + } else { + map.set(key, { + userId: rawUser ?? existing.userId, + agentId: rawAgent ?? existing.agentId, + }); + } + saveAgentIdentityMap(agentIdentityPath, map, api.logger); + const updated = map.get(key)!; + console.log(`Updated ${agentIdentityPath} entry "${key}"`); + console.log(JSON.stringify(updated, null, 2)); + }); + + ltm + .command("sync-user-id") + .description( + "Use one userId in identity.json and in every agent-identities.json entry (PowerMem agent ids unchanged)", + ) + .option("--user-id ", "Use this user id everywhere") + .option( + "--from ", + "Where to read the canonical user id when --user-id is omitted: identity | agent", + "identity", + ) + .option("--agent ", "OpenClaw agent key when --from agent") + .action(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { + userId?: string; + from?: string; + agent?: string; + }; + const explicit = opts.userId?.trim(); + const from = (opts.from ?? "identity").trim().toLowerCase(); + let canonical = explicit; + if (!canonical) { + if (from === "identity") { + canonical = readStoredIdentityFile(identityPath).userId?.trim(); + } else if (from === "agent") { + const agentKey = opts.agent?.trim(); + if (!agentKey) { + console.error("With --from agent, pass --agent ."); + process.exitCode = 1; + return; + } + const map = loadAgentIdentityMap(agentIdentityPath, api.logger); + canonical = map.get(agentKey)?.userId?.trim(); + } else { + console.error('--from must be "identity" or "agent".'); + process.exitCode = 1; + return; + } + } + if (!canonical) { + console.error( + "Could not resolve user id: use --user-id, or ensure identity.json / the chosen agent entry has userId.", + ); + process.exitCode = 1; + return; + } + + const storedId = readStoredIdentityFile(identityPath); + const nextAgentIdForFile = + storedId.agentId?.trim() ?? `agent-${randomUUID()}`; + if ( + !writeStoredIdentityFile( + powermemDir, + identityPath, + { userId: canonical, agentId: nextAgentIdForFile }, + api.logger, + ) + ) { + process.exitCode = 1; + return; + } + + const map = loadAgentIdentityMap(agentIdentityPath, api.logger); + if (map.size === 0) { + console.log( + `Set identity.json userId to ${canonical} (${agentIdentityPath} has no entries to update).`, + ); + return; + } + for (const [k, v] of map.entries()) { + map.set(k, { userId: canonical, agentId: v.agentId }); + } + saveAgentIdentityMap(agentIdentityPath, map, api.logger); + console.log( + `Synced userId "${canonical}" to identity.json and ${map.size} agent-identities entr${map.size === 1 ? "y" : "ies"}.`, + ); + }); + ltm .command("search") .description("Search memories") From e41af80653af8225418eb0a9f05e07b01a0467f8 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 9 May 2026 09:18:23 +0800 Subject: [PATCH 3/5] fixed: node scripts/ensure-native-deps.cjs --- scripts/ensure-native-deps.cjs | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/scripts/ensure-native-deps.cjs b/scripts/ensure-native-deps.cjs index bbb9f2a..ca05b39 100644 --- a/scripts/ensure-native-deps.cjs +++ b/scripts/ensure-native-deps.cjs @@ -41,6 +41,22 @@ function printFailures(failures) { } } +function tryRebuild() { + const { execSync } = require("node:child_process"); + const packages = nativePackages.join(" "); + try { + log(`native binaries incompatible, rebuilding from source: npm rebuild ${packages} --build-from-source`); + execSync(`npm rebuild ${packages} --build-from-source`, { + cwd: rootDir, + stdio: "inherit", + }); + return true; + } catch (err) { + warn(`rebuild failed: ${err && err.message ? err.message : String(err)}`); + return false; + } +} + function main() { const initialFailures = verifyNativePackages(); if (initialFailures.length === 0) { @@ -49,9 +65,22 @@ function main() { } printFailures(initialFailures); - warn("native dependency verification failed"); - warn("install build tools first, then run: npm rebuild better-sqlite3 sqlite-vec --build-from-source"); - warn("Debian/Ubuntu example: apt-get update && apt-get install -y python3 make gcc g++"); + + if (!tryRebuild()) { + warn("install build tools first, then reinstall the plugin"); + warn("Debian/Ubuntu: apt-get update && apt-get install -y python3 make gcc g++"); + process.exit(1); + } + + const afterFailures = verifyNativePackages(); + if (afterFailures.length === 0) { + log("native dependencies rebuilt and verified"); + return; + } + + printFailures(afterFailures); + warn("rebuild succeeded but native packages still fail to load"); + warn("Debian/Ubuntu: apt-get update && apt-get install -y python3 make gcc g++"); process.exit(1); } From 32ff55a36857a223c52a33de8321d082527efd77 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sat, 9 May 2026 10:23:19 +0800 Subject: [PATCH 4/5] feat: LLM support auto-router/auto --- README.md | 1 + README_CN.md | 1 + openclaw.plugin.json | 7 ++ src/config.ts | 10 +++ src/index.ts | 4 +- src/llm.ts | 186 ++++++++++++++++++++++++++++++++++++------- 6 files changed, 180 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 623bb37..c30e8f5 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ After installing, uninstalling, or changing config, restart the OpenClaw gateway | `autoExperience` | No | Auto-extract procedural experiences via LLM; default `true`. | | `experienceRecall` | No | Include experiences in recall results; default `true`. | | `inferOnAdd` | No | Use PowerMem intelligent extraction when adding; default `true`. | +| `pluginLlmModel` | No | Optional `provider/model` for plugin-side LLM only (WAL capture, auto-experience). Use when `agents.defaults.model` is a router (e.g. `auto-router/auto`). Must match `models.providers`. Env: `MEMORY_POWERMEM_PLUGIN_LLM_MODEL`. If unset with a router primary: env → **first non-router key in `agents.defaults.models`** → first model under `models.providers`. | | `importMarkdownOnStart` | No | One-time import of existing OpenClaw markdown memories on startup; default `false`. | | `importMarkdownPaths` | No | Markdown files/directories to import. Defaults to `memory/`, `MEMORY.md`, and `USER.md`; relative paths resolve from the OpenClaw workspace. | | `importMarkdownMaxFileBytes` | No | Max size for a single markdown file; default `10485760` (10 MiB). Larger files are marked `skipped_too_large`. | diff --git a/README_CN.md b/README_CN.md index 445d40b..249d8d6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -302,6 +302,7 @@ openclaw ltm search "咖啡" | `autoExperience` | 否 | LLM 自动提炼经验,默认 `true`。 | | `experienceRecall` | 否 | 召回结果是否包含经验,默认 `true`。 | | `inferOnAdd` | 否 | 写入时是否用 PowerMem 智能抽取,默认 `true`。 | +| `pluginLlmModel` | 否 | 可选,仅用于插件内 LLM(WAL、自动经验)。当 `agents.defaults.model` 为路由占位(如 `auto-router/auto`)时填写 `provider/model`,且须与 `models.providers` 一致。也可设环境变量 `MEMORY_POWERMEM_PLUGIN_LLM_MODEL`;未配置时依次尝试:**env** → **`agents.defaults.models` 里第一个非 `auto-router` 的键** → **`models.providers` 中第一个模型**。 | | `importMarkdownOnStart` | 否 | 启动时一次性导入已有 OpenClaw markdown 记忆,默认 `false`。 | | `importMarkdownPaths` | 否 | 要导入的 markdown 文件或目录。默认扫描 `memory/`、`MEMORY.md`、`USER.md`;相对路径基于 OpenClaw workspace。 | | `importMarkdownMaxFileBytes` | 否 | 单个 markdown 文件最大大小,默认 `10485760`(10 MiB);超出的文件标记为 `skipped_too_large`。 | diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 4c716d1..29905c2 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -211,6 +211,12 @@ "advanced": true, "help": "Optional local vector fallback for dual-write (OpenAI/Ollama compatible embeddings + sqlite-vec)." }, + "pluginLlmModel": { + "label": "Plugin LLM model (provider/model)", + "advanced": true, + "placeholder": "e.g. glm-5_1", + "help": "Optional. For plugin-only LLM calls when agents.defaults.model is a router (e.g. auto-router/auto). Must match models.providers. Fallback order: env MEMORY_POWERMEM_PLUGIN_LLM_MODEL, then first non-router key in agents.defaults.models, then first model in models.providers." + }, "useOpenClawModel": { "label": "Use OpenClaw LLM for PowerMem", "advanced": true, @@ -276,6 +282,7 @@ } } }, + "pluginLlmModel": { "type": "string" }, "useOpenClawModel": { "type": "boolean" } }, "required": [] diff --git a/src/config.ts b/src/config.ts index 8ea01fd..96ad171 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,11 @@ export type PowerMemConfig = { * (overrides the same keys from an optional .env file). SQLite defaults live under the OpenClaw state dir. */ useOpenClawModel?: boolean; + /** + * Optional `provider/model` for plugin-only LLM calls (WAL capture, auto-experience). + * Use when `agents.defaults.model.primary` is a router placeholder (e.g. `auto-router/auto`) that OpenClaw resolves internally but this plugin cannot load via `models.providers`. + */ + pluginLlmModel?: string; userId?: string; agentId?: string; /** Max memories to return in recall / inject in auto-recall. Default 5. */ @@ -114,6 +119,7 @@ const ALLOWED_KEYS = [ "envFile", "pmemPath", "useOpenClawModel", + "pluginLlmModel", "userId", "agentId", "recallLimit", @@ -272,6 +278,10 @@ export const powerMemConfigSchema = { envFile, pmemPath, useOpenClawModel: cfg.useOpenClawModel !== false, + pluginLlmModel: + typeof cfg.pluginLlmModel === "string" && cfg.pluginLlmModel.trim() + ? cfg.pluginLlmModel.trim() + : undefined, userId: typeof cfg.userId === "string" && cfg.userId.trim() ? cfg.userId.trim() diff --git a/src/index.ts b/src/index.ts index fab33da..bde54f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1959,7 +1959,7 @@ const memoryPlugin = { async function extractExperiencesWithLlm(messages: unknown[]): Promise { const prompt = buildExperiencePrompt(messages); if (!prompt.trim()) return []; - const reply = await callLlm(api, prompt, { + const reply = await callLlm(api, cfg, prompt, { systemPrompt: EXPERIENCE_SYSTEM_PROMPT, maxTokens: 512, temperature: 0.2, @@ -1986,7 +1986,7 @@ const memoryPlugin = { async function walCapture(prompt: string, sessionKey: string, ctxAgentId?: string): Promise { const agentClient = getClientForAgent(ctxAgentId); await walCaptureCore(prompt, sessionKey, walSession, { - callLlm: (p, opts) => callLlm(api, p, opts), + callLlm: (p, opts) => callLlm(api, cfg, p, opts), store: async (content, metadata) => { const created = await agentClient.add(content, { infer: false, metadata }); const id = created[0]?.memory_id; diff --git a/src/llm.ts b/src/llm.ts index 93542e4..2ec64fe 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -7,11 +7,15 @@ import { type Model, } from "@mariozechner/pi-ai"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; +import type { PowerMemConfig } from "./config.js"; const API_REMAP: Record = { ollama: "openai-completions", }; +/** OpenClaw router placeholders: chat resolves these internally; plugins need a concrete provider/model. */ +const ROUTER_PROVIDER_MARKERS = ["auto-router"]; + function resolveCompatBaseUrl(originalApi: string, baseUrl: string | undefined): string | undefined { if (originalApi === "ollama") { const base = (baseUrl ?? "http://localhost:11434").replace(/\/+$/, ""); @@ -28,10 +32,124 @@ type GatewayConfig = { agents?: { defaults?: { model?: unknown; + /** Catalog of `provider/model` keys → aliases (OpenClaw router); keys are concrete models except `auto-router/auto`. */ + models?: Record; }; }; }; +function extractPrimaryFromGateway(cfg: unknown): string | undefined { + const defaultModel = (cfg as GatewayConfig | undefined)?.agents?.defaults?.model; + if (typeof defaultModel === "string") return defaultModel.trim(); + const primary = (defaultModel as Record | undefined)?.primary; + return typeof primary === "string" ? primary.trim() : undefined; +} + +/** First non-router `provider/model` key in `agents.defaults.models` (alias catalog). Pairs with `primary: auto-router/auto`. */ +function firstConcreteModelFromAgentsDefaultsCatalog(cfg: unknown, logger: Logger): string | null { + const raw = (cfg as GatewayConfig | undefined)?.agents?.defaults?.models; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const catalog = raw as Record; + for (const key of Object.keys(catalog)) { + const trimmed = key.trim(); + const parsed = parseProviderModel(trimmed); + if (!parsed) continue; + if (ROUTER_PROVIDER_MARKERS.includes(parsed.provider.toLowerCase())) continue; + logger.info?.(`powermem/llm: using agents.defaults.models catalog key — ${trimmed}`); + return trimmed; + } + return null; +} + +/** First provider/model found under models.providers (skips router keys). Best-effort when primary is auto-router. */ +function firstConcreteModelFromProviders(cfg: unknown, logger: Logger): string | null { + const providers = (cfg as GatewayConfig | undefined)?.models?.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return null; + } + const record = providers as Record; + for (const key of Object.keys(record)) { + if (ROUTER_PROVIDER_MARKERS.includes(key.toLowerCase())) continue; + const p = record[key]; + if (!p || typeof p !== "object") continue; + const modelsList = (p as { models?: Array<{ id?: unknown }> }).models; + if (!Array.isArray(modelsList)) continue; + for (const m of modelsList) { + if (m && typeof m === "object" && typeof (m as { id?: unknown }).id === "string") { + const id = String((m as { id: string }).id).trim(); + if (id) { + const spec = `${key}/${id}`; + logger.info?.(`powermem/llm: using first models.providers model as fallback — ${spec}`); + return spec; + } + } + } + } + return null; +} + +function parseProviderModel(spec: string): { provider: string; modelId: string } | null { + const slashIdx = spec.indexOf("/"); + if (slashIdx <= 0 || slashIdx >= spec.length - 1) return null; + const provider = spec.slice(0, slashIdx); + const modelId = spec.slice(slashIdx + 1); + if (!provider.trim() || !modelId.trim()) return null; + return { provider, modelId }; +} + +function pickProviderModelSpec( + api: OpenClawPluginApi, + gatewayCfg: unknown, + memoryCfg: PowerMemConfig | undefined, +): string | null { + const override = memoryCfg?.pluginLlmModel?.trim(); + if (override) { + if (!parseProviderModel(override)) { + api.logger.warn(`powermem/llm: pluginLlmModel must be "provider/model", got "${override}"`); + return null; + } + api.logger.info?.(`powermem/llm: using pluginLlmModel — ${override}`); + return override; + } + + const primary = extractPrimaryFromGateway(gatewayCfg); + if (!primary?.trim()) { + api.logger.warn("powermem/llm: no default model configured, skipping"); + return null; + } + + const parsed = parseProviderModel(primary); + if (!parsed) { + api.logger.warn(`powermem/llm: invalid model format "${primary}", expected "provider/model"`); + return null; + } + + const { provider } = parsed; + if (ROUTER_PROVIDER_MARKERS.includes(provider.toLowerCase())) { + const envSpec = process.env.MEMORY_POWERMEM_PLUGIN_LLM_MODEL?.trim(); + if (envSpec && parseProviderModel(envSpec)) { + api.logger.info?.( + `powermem/llm: agents.defaults.model is router; using MEMORY_POWERMEM_PLUGIN_LLM_MODEL — ${envSpec}`, + ); + return envSpec; + } + const catalogFallback = firstConcreteModelFromAgentsDefaultsCatalog(gatewayCfg, api.logger); + if (catalogFallback) return catalogFallback; + + const fallback = firstConcreteModelFromProviders(gatewayCfg, api.logger); + if (fallback) return fallback; + + api.logger.warn?.( + `powermem/llm: agents.defaults.model is "${primary}" (router). Set pluginLlmModel, env MEMORY_POWERMEM_PLUGIN_LLM_MODEL, add concrete keys under agents.defaults.models, or ensure models.providers lists models.`, + ); + return null; + } + + return primary; +} + function buildModelFromConfig( provider: string, modelId: string, @@ -120,7 +238,10 @@ async function resolveApiKey(api: OpenClawPluginApi, provider: string): Promise< // ignore } - const providers = ((cfg as GatewayConfig | undefined)?.models?.providers ?? {}) as Record>; + const providers = ((cfg as GatewayConfig | undefined)?.models?.providers ?? {}) as Record< + string, + Record + >; const providerCfg = providers[provider] ?? Object.values(providers).find( @@ -135,8 +256,23 @@ async function resolveApiKey(api: OpenClawPluginApi, provider: string): Promise< return undefined; } +function resolveModelFromSpec( + spec: string, + gatewayCfg: unknown, + logger: Logger, +): { model: Model; provider: string } | null { + const parsed = parseProviderModel(spec); + if (!parsed) return null; + const { provider, modelId } = parsed; + logger.info?.(`powermem/llm: resolving model ${provider}/${modelId}`); + const model = resolveModel(provider, modelId, gatewayCfg, logger); + if (!model) return null; + return { model, provider }; +} + export async function callLlm( api: OpenClawPluginApi, + memoryCfg: PowerMemConfig | undefined, prompt: string, opts?: { maxTokens?: number; @@ -144,44 +280,40 @@ export async function callLlm( systemPrompt?: string; }, ): Promise { - const cfg = api.config; - const defaultModel = (cfg as GatewayConfig | undefined)?.agents?.defaults?.model; - const primary = - typeof defaultModel === "string" - ? defaultModel - : ((defaultModel as Record | undefined)?.primary as string | undefined); + const gatewayCfg = api.config; - if (!primary?.trim()) { - api.logger.warn("powermem/llm: no default model configured, skipping"); - return null; - } + let spec = pickProviderModelSpec(api, gatewayCfg, memoryCfg); + let resolved = spec ? resolveModelFromSpec(spec, gatewayCfg, api.logger) : null; - const slashIdx = primary.indexOf("/"); - if (slashIdx < 0) { - api.logger.warn( - `powermem/llm: invalid model format "${primary}", expected "provider/model"`, - ); - return null; + if (!resolved) { + const envSpec = process.env.MEMORY_POWERMEM_PLUGIN_LLM_MODEL?.trim(); + if (envSpec && envSpec !== spec && parseProviderModel(envSpec)) { + api.logger.info?.(`powermem/llm: retry with MEMORY_POWERMEM_PLUGIN_LLM_MODEL — ${envSpec}`); + resolved = resolveModelFromSpec(envSpec, gatewayCfg, api.logger); + if (resolved) spec = envSpec; + } } - const provider = primary.slice(0, slashIdx); - const modelId = primary.slice(slashIdx + 1); - - api.logger.info?.(`powermem/llm: resolving model ${provider}/${modelId}`); - const model = resolveModel(provider, modelId, cfg, api.logger); - if (!model) { - api.logger.warn( - `powermem/llm: could not resolve model ${provider}/${modelId}, skipping LLM call`, - ); + if (!resolved || !spec) { + if (spec) { + const parsed = parseProviderModel(spec); + if (parsed) { + api.logger.warn( + `powermem/llm: could not resolve model ${parsed.provider}/${parsed.modelId}, skipping LLM call`, + ); + } + } return null; } + const { model, provider } = resolved; + api.logger.info?.(`powermem/llm: resolving auth for provider "${provider}"`); const apiKey = await resolveApiKey(api, provider); if (!apiKey) { api.logger.warn( `powermem/llm: no apiKey found for provider "${provider}". ` + - `Ensure openclaw auth, env var, or models.providers.${provider}.apiKey.`, + `Ensure openclaw auth, env var, or models.providers.${provider}.apiKey.`, ); return null; } From 13c02a8a5758dd017af502d9e8579681fa7b3907 Mon Sep 17 00:00:00 2001 From: Teingi Date: Sun, 10 May 2026 22:35:56 +0800 Subject: [PATCH 5/5] feat(identity): env-based userId, auto agent list sync, and install-safe timers --- README_CN.md | 8 +- openclaw.plugin.json | 18 +++- src/config.ts | 70 +++++++++++++ src/index.ts | 148 +++++++++++++++++++++++++--- src/openclaw-config-agents.ts | 52 ++++++++++ test/config.test.ts | 34 +++++++ test/openclaw-config-agents.test.ts | 25 +++++ 7 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 src/openclaw-config-agents.ts create mode 100644 test/openclaw-config-agents.test.ts diff --git a/README_CN.md b/README_CN.md index 249d8d6..6394e66 100644 --- a/README_CN.md +++ b/README_CN.md @@ -295,8 +295,10 @@ openclaw ltm search "咖啡" | `requestConfig` | 否 | HTTP v2 专用:按请求透传 `config`(如 `memory_db`)。 | | `envFile` | 否 | CLI:PowerMem `.env`;插件默认约定 `~/.openclaw/powermem/powermem.env`。 | | `pmemPath` | 否 | CLI:`bundled`(默认)、`auto` 或 `pmem` 的路径/命令。 | -| `userId` | 否 | 用于多用户隔离。未填或为 `"auto"` 时自动生成并保存到 `/powermem/identity.json`。 | -| `agentId` | 否 | 用于多 Agent 隔离。未填或为 `"auto"` 时自动生成并保存到 `/powermem/identity.json`。 | +| `userId` | 否 | 多用户隔离。支持占位符如 `"${OPENCLAW_USER_NAME}"`(或任意 `${环境变量名}`):全部变量存在且非空则展开;否则与未填/`"auto"` 一样,回退到 `identity.json` 中已有值或生成新 UUID。 | +| `agentId` | 否 | 多 Agent 隔离。设为 **`"auto"`** 时,从 OpenClaw 配置里的 `agents.list[].id` 同步到 `/powermem/agent-identities.json`(每条 OpenClaw agent 对应一条映射,PowerMem 的 `agentId` 等于该条目的 `id`,如 `main`、`researcher`);`identity.json` 中的默认 `agentId` 取列表中的第一个。未填或非 `auto` 时行为与原先一致(单条默认 id)。 | +| `openclawConfigPath` | 否 | 仅在 `agentId` 为 `"auto"` 时使用:要读取的 OpenClaw JSON 路径,默认 `/openclaw.json`。 | +| `agentListSyncIntervalMs` | 否 | 仅在 `agentId` 为 `"auto"` 时:定时重新读取上述 JSON 并合并**新增**的 agent 到 `agent-identities.json`(毫秒)。`0` 表示不在运行时轮询(仅启动时同步一次)。省略时默认 `60000`(60 秒)。 | | `autoCapture` | 否 | 会话结束后是否自动把对话交给 PowerMem 抽取记忆,默认 `true`。 | | `autoRecall` | 否 | 会话开始前是否自动注入相关记忆,默认 `true`。 | | `autoExperience` | 否 | LLM 自动提炼经验,默认 `true`。 | @@ -352,7 +354,7 @@ openclaw ltm search "咖啡" - `openclaw ltm import-md [paths...] [--force] [--dry-run] [--delay-ms n] [--max-file-bytes n] [--max-files n] [--max-chunks n]` — 导入已有 markdown 记忆;不传路径时扫描 `memory/`、`MEMORY.md`、`USER.md` - `openclaw ltm import-md-status [paths...] [--json]` — 查看每个 markdown 文件的导入状态:已导入、已变更、跳过、失败或未导入 -**身份文件(可选):** 若插件配置将 `userId` / `agentId` 设为 `auto`(或未填写),稳定 ID 会保存在 `/powermem/identity.json`(默认值)与 `/powermem/agent-identities.json`(按 OpenClaw agent key)。若在 `openclaw.json` 中显式设置了 `userId` 或 `agentId`,运行时将优先使用该配置,覆盖文件中的值。 +**身份文件(可选):** `userId` 可用 `${VAR}` 从环境变量取值;失败时回退到文件或自动生成。`agentId` 为 **`auto`** 时,会按 `agents.list` 维护 `agent-identities.json`(并可选按 `agentListSyncIntervalMs` 轮询 `openclawConfigPath` 以发现新 agent)。其它情况下:`userId` / `agentId` 为 `auto`(或未填)时,稳定 ID 写在 `/powermem/identity.json`,按 agent key 的映射写在 `agent-identities.json`。若在 `openclaw.json` 插件配置里显式设置了非 `auto` 的 `userId` 或 `agentId`,运行时将优先使用该配置,覆盖文件中的对应逻辑(`userId` 仍以环境展开结果为准)。 - `openclaw ltm identity show [--json]` — 打印路径及 `identity.json` 中存储的 `userId` / `agentId`。 - `openclaw ltm identity set --user-id ` / `--agent-id ` — 设置其一或两者(未指定的字段保留已有值或自动生成)。 diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 29905c2..46af698 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -52,13 +52,25 @@ "label": "User ID", "placeholder": "openclaw-user", "advanced": true, - "help": "PowerMem user_id for memory isolation (optional)" + "help": "PowerMem user_id (optional). Use ${ENV_NAME} to read from the process environment; if any referenced variable is unset, falls back to identity.json / a new UUID. \"auto\" or omit = generated stable id." }, "agentId": { "label": "Agent ID", "placeholder": "openclaw-agent", "advanced": true, - "help": "PowerMem agent_id for memory isolation (optional)" + "help": "PowerMem agent_id (optional). Set to \"auto\" to mirror OpenClaw `agents.list[].id` into agent-identities.json (PowerMem agentId = that id); default row in identity.json uses the first listed agent." + }, + "openclawConfigPath": { + "label": "OpenClaw config path", + "placeholder": "", + "advanced": true, + "help": "When agentId is auto: JSON file to read for `agents.list` (default: /openclaw.json). Used for startup + periodic sync." + }, + "agentListSyncIntervalMs": { + "label": "Agent list sync interval (ms)", + "placeholder": "60000", + "advanced": true, + "help": "When agentId is auto: re-read the OpenClaw config file on this interval and merge new agents into agent-identities.json. 0 = no polling (sync once at startup). Omit for 60000." }, "recallLimit": { "label": "Recall limit", @@ -237,6 +249,8 @@ "pmemPath": { "type": "string" }, "userId": { "type": "string" }, "agentId": { "type": "string" }, + "openclawConfigPath": { "type": "string" }, + "agentListSyncIntervalMs": { "type": "number" }, "recallLimit": { "type": "number" }, "recallScoreThreshold": { "type": "number" }, "walCapture": { "type": "boolean" }, diff --git a/src/config.ts b/src/config.ts index 96ad171..49a1a15 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,37 @@ function resolveEnvVars(value: string): string { }); } +/** + * Replace `${VAR}` with `process.env[VAR]`. If any referenced variable is + * missing or empty, returns `undefined` (caller should fall back to file / UUID). + * Strings without placeholders are returned trimmed as-is. + */ +export function expandOptionalEnvPlaceholders(input: string | undefined): string | undefined { + if (input === undefined) return undefined; + const trimmed = input.trim(); + if (!trimmed) return undefined; + const re = /\$\{([^}]+)\}/g; + if (!re.test(trimmed)) { + return trimmed; + } + re.lastIndex = 0; + let out = ""; + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(trimmed)) !== null) { + const name = m[1].trim(); + const val = process.env[name]; + if (val === undefined || val === "") { + return undefined; + } + out += trimmed.slice(lastIndex, m.index) + val; + lastIndex = m.index + m[0].length; + } + out += trimmed.slice(lastIndex); + const merged = out.trim(); + return merged.length > 0 ? merged : undefined; +} + export type PowerMemMode = "http" | "cli"; export type PowerMemHttpApiVersion = "v1" | "v2"; export type DualWritePriority = "remote" | "local"; @@ -58,6 +89,16 @@ export type PowerMemConfig = { pluginLlmModel?: string; userId?: string; agentId?: string; + /** + * When `agentId` is `auto`, poll this path (JSON) for `agents.list` and merge + * into `agent-identities.json`. Default: `/openclaw.json`. + */ + openclawConfigPath?: string; + /** + * Interval (ms) to re-read OpenClaw config for new agents when `agentId` is `auto`. + * `0` disables polling (sync once at startup). Default when omitted: `60000`. + */ + agentListSyncIntervalMs?: number; /** Max memories to return in recall / inject in auto-recall. Default 5. */ recallLimit?: number; /** Min score (0–1) for recall; memories below are filtered. Default 0. */ @@ -122,6 +163,8 @@ const ALLOWED_KEYS = [ "pluginLlmModel", "userId", "agentId", + "openclawConfigPath", + "agentListSyncIntervalMs", "recallLimit", "recallScoreThreshold", "walCapture", @@ -317,6 +360,14 @@ export const powerMemConfigSchema = { typeof cfg.localAgentId === "string" && cfg.localAgentId.trim() ? cfg.localAgentId.trim() : undefined, + openclawConfigPath: + typeof cfg.openclawConfigPath === "string" && cfg.openclawConfigPath.trim() + ? cfg.openclawConfigPath.trim() + : undefined, + agentListSyncIntervalMs: toOptionalNonNegativeInt( + cfg.agentListSyncIntervalMs, + 86400000, + ), syncOnResume: cfg.syncOnResume !== false, syncBatchSize, syncMinIntervalMs, @@ -387,6 +438,25 @@ function toOptionalPositiveInt(v: unknown, min: number, max: number): number | u return undefined; } +/** Optional non-negative int (e.g. sync interval); clamped to `max`. */ +function toOptionalNonNegativeInt(v: unknown, max: number): number | undefined { + if (v === undefined || v === null || v === "") { + return undefined; + } + if (typeof v === "number" && Number.isFinite(v)) { + const n = Math.floor(v); + return n >= 0 ? Math.min(max, n) : undefined; + } + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + if (Number.isFinite(n)) { + const floored = Math.floor(n); + return floored >= 0 ? Math.min(max, floored) : undefined; + } + } + return undefined; +} + function parseHeaderMap(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; diff --git a/src/index.ts b/src/index.ts index bde54f5..0f2682a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,13 @@ import { powerMemConfigSchema, DEFAULT_PLUGIN_CONFIG, DEFAULT_PMEM_PATH, + expandOptionalEnvPlaceholders, type PowerMemConfig, } from "./config.js"; +import { + extractAgentIdsFromOpenClawConfig, + readOpenClawJsonFile, +} from "./openclaw-config-agents.js"; import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -85,6 +90,7 @@ function resolveIdentityIds( cfg: PowerMemConfig, stateDir: string, logger: Logger, + opts?: { defaultAgentIdFromOpenClawList?: string; forceWriteIdentity?: boolean }, ): { userId: string; agentId: string } { const baseDir = join(stateDir, "powermem"); const identityPath = join(baseDir, "identity.json"); @@ -99,11 +105,24 @@ function resolveIdentityIds( // ignore missing or invalid file } - const userId = normalizeId(cfg.userId) ?? normalizeId(stored.userId) ?? `user-${randomUUID()}`; - const agentId = - normalizeId(cfg.agentId) ?? normalizeId(stored.agentId) ?? `agent-${randomUUID()}`; + const expandedUser = expandOptionalEnvPlaceholders( + typeof cfg.userId === "string" ? cfg.userId : undefined, + ); + const userFromCfg = + expandedUser !== undefined ? normalizeId(expandedUser) : undefined; + const userId = + userFromCfg ?? normalizeId(stored.userId) ?? `user-${randomUUID()}`; - if (userId !== stored.userId || agentId !== stored.agentId) { + const hint = opts?.defaultAgentIdFromOpenClawList?.trim(); + const agentId = + normalizeId(cfg.agentId) ?? + (hint || undefined) ?? + normalizeId(stored.agentId) ?? + `agent-${randomUUID()}`; + + const shouldWrite = + opts?.forceWriteIdentity === true || userId !== stored.userId || agentId !== stored.agentId; + if (shouldWrite) { try { mkdirSync(baseDir, { recursive: true }); writeFileSync(identityPath, JSON.stringify({ userId, agentId }, null, 2), "utf-8"); @@ -243,6 +262,36 @@ function resolveLocalDbPath(cfg: PowerMemConfig, stateDir: string): string { return join(stateDir, "powermem", "local-memories.sqlite"); } +/** Same Node process may load this plugin twice (e.g. dev path + extensions copy); use globalThis so only one poll runs. */ +const MEMORY_POWERMEM_DYNAMIC_TIMER_KEY = "__memoryPowermemDynamicAgentTimers"; + +type MemoryPowermemDynamicTimerHolder = { + poll?: ReturnType; + deferred?: ReturnType; +}; + +function getMemoryPowermemDynamicTimerHolder(): MemoryPowermemDynamicTimerHolder { + const g = globalThis as typeof globalThis & Record; + let h = g[MEMORY_POWERMEM_DYNAMIC_TIMER_KEY] as MemoryPowermemDynamicTimerHolder | undefined; + if (!h) { + h = {}; + g[MEMORY_POWERMEM_DYNAMIC_TIMER_KEY] = h; + } + return h; +} + +function clearMemoryPowermemDynamicTimers(): void { + const h = getMemoryPowermemDynamicTimerHolder(); + if (h.poll !== undefined) { + clearInterval(h.poll); + h.poll = undefined; + } + if (h.deferred !== undefined) { + clearTimeout(h.deferred); + h.deferred = undefined; + } +} + // ============================================================================ // Plugin Definition // ============================================================================ @@ -278,7 +327,20 @@ const memoryPlugin = { api.logger.info(`memory-powermem: perf ${stage} ${elapsedMs}ms${detail}`); }; const stateDir = resolveOpenClawStateDir(gw); - const { userId, agentId } = resolveIdentityIds(cfg, stateDir, api.logger); + const wantsDynamicAgents = + typeof cfg.agentId === "string" && cfg.agentId.trim().toLowerCase() === "auto"; + const openclawJsonPath = + cfg.openclawConfigPath?.trim() || join(stateDir, "openclaw.json"); + const openclawConfigForAgents = + readOpenClawJsonFile(openclawJsonPath) ?? gw.config; + const openclawAgentIds = extractAgentIdsFromOpenClawConfig(openclawConfigForAgents); + const defaultAgentIdFromOpenClawList = + wantsDynamicAgents && openclawAgentIds.length > 0 ? openclawAgentIds[0] : undefined; + const initialIdentity = resolveIdentityIds(cfg, stateDir, api.logger, { + defaultAgentIdFromOpenClawList: defaultAgentIdFromOpenClawList, + }); + let userId = initialIdentity.userId; + let agentId = initialIdentity.agentId; const buildProcessEnv = cfg.mode === "cli" ? cfg.useOpenClawModel !== false @@ -405,6 +467,44 @@ const memoryPlugin = { return created; }; + function performDynamicAgentListSync(reason: string): void { + if (!wantsDynamicAgents) return; + const parsed = readOpenClawJsonFile(openclawJsonPath) ?? gw.config; + const ids = extractAgentIdsFromOpenClawConfig(parsed); + if (ids.length === 0) return; + + const idn = resolveIdentityIds(cfg, stateDir, api.logger, { + defaultAgentIdFromOpenClawList: ids[0], + forceWriteIdentity: true, + }); + + agentIdentityMap.clear(); + for (const id of ids) { + agentIdentityMap.set(id, { userId: idn.userId, agentId: id }); + } + saveAgentIdentityMap(agentIdentityPath, agentIdentityMap, api.logger); + + userId = idn.userId; + agentId = idn.agentId; + defaultIdentity.userId = idn.userId; + defaultIdentity.agentId = idn.agentId; + defaultIdentityBound = agentIdentityMap.size > 0; + + clientCache.clear(); + embeddingFactoryCache.clear(); + + api.logger.info?.( + `memory-powermem: dynamic agent sync (${reason}) ids=${ids.join(",")} ` + + `userId=${idn.userId} defaultAgentId=${idn.agentId}`, + ); + } + + const agentListPollMs = wantsDynamicAgents + ? cfg.agentListSyncIntervalMs === 0 + ? 0 + : (cfg.agentListSyncIntervalMs ?? 60_000) + : 0; + function dedupeSearchResults(items: PowerMemSearchResult[]): PowerMemSearchResult[] { const deduped = new Map(); for (const item of items) { @@ -478,10 +578,6 @@ const memoryPlugin = { return { merged: dedupeSearchResults(merged), sharedFetched }; } - const client = getClientForAgent(); - if (needsDualWriteSqlite && "syncPending" in client) { - void (client as DualWriteClient).syncPending("startup"); - } const markdownImportMarkerPath = join(stateDir, "powermem", "markdown-imports.json"); const configuredMarkdownImportPaths = cfg.importMarkdownPaths && cfg.importMarkdownPaths.length > 0 @@ -508,7 +604,7 @@ const memoryPlugin = { paths, }); return importMarkdownMemories({ - client, + client: getClientForAgent(), markerPath: markdownImportMarkerPath, markerKey, workspaceDir: params.workspaceDir, @@ -1657,7 +1753,7 @@ const memoryPlugin = { ? Math.max(0, Math.min(1, Number(rawThreshold))) : undefined; const searchStartedAt = perfNow(); - const results = await client.search(query, limit); + const results = await getClientForAgent().search(query, limit); const filtered = threshold === undefined ? results @@ -1680,7 +1776,7 @@ const memoryPlugin = { const cliHealthStartedAt = perfNow(); try { const healthStartedAt = perfNow(); - const h = await client.health(); + const h = await getClientForAgent().health(); perfLog("cli.ltm.health", cliHealthStartedAt, { status: h.status, upstreamMs: perfNow() - healthStartedAt, @@ -1706,7 +1802,7 @@ const memoryPlugin = { const text = String(args[0] ?? ""); try { const addStartedAt = perfNow(); - const created = await client.add(text.trim(), { infer: cfg.inferOnAdd }); + const created = await getClientForAgent().add(text.trim(), { infer: cfg.inferOnAdd }); perfLog("cli.ltm.add", cliAddStartedAt, { textLen: text.trim().length, infer: cfg.inferOnAdd, @@ -2267,8 +2363,31 @@ const memoryPlugin = { api.registerService({ id: "memory-powermem", start: async (ctx: OpenClawPluginServiceContext) => { + clearMemoryPowermemDynamicTimers(); + if (wantsDynamicAgents) { + const timers = getMemoryPowermemDynamicTimerHolder(); + performDynamicAgentListSync("service-start"); + timers.deferred = setTimeout(() => { + performDynamicAgentListSync("service-start-deferred"); + timers.deferred = undefined; + }, 0); + timers.deferred.unref(); + if (agentListPollMs > 0) { + timers.poll = setInterval( + () => performDynamicAgentListSync("poll"), + agentListPollMs, + ); + timers.poll.unref(); + } + } + if (needsDualWriteSqlite) { + const c0 = getClientForAgent(); + if ("syncPending" in c0) { + void (c0 as DualWriteClient).syncPending("startup"); + } + } try { - const h = await client.health(); + const h = await getClientForAgent().health(); const where = cfg.mode === "cli" ? `cli ${resolvePmemExecutable(cfg.pmemPath ?? DEFAULT_PMEM_PATH)}` @@ -2311,6 +2430,7 @@ const memoryPlugin = { } }, stop: (_ctx: OpenClawPluginServiceContext) => { + clearMemoryPowermemDynamicTimers(); api.logger.info("memory-powermem: stopped"); }, }); diff --git a/src/openclaw-config-agents.ts b/src/openclaw-config-agents.ts new file mode 100644 index 0000000..cb20eef --- /dev/null +++ b/src/openclaw-config-agents.ts @@ -0,0 +1,52 @@ +/** + * Read OpenClaw multi-agent ids from gateway config / openclaw.json shape. + * Expected: `agents.list[]` entries with string `id` (OpenClaw agent key). + */ + +import { readFileSync } from "node:fs"; + +export function readOpenClawJsonFile(path: string): unknown | undefined { + try { + return JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch { + return undefined; + } +} + +function asRecord(v: unknown): Record | undefined { + if (v && typeof v === "object" && !Array.isArray(v)) { + return v as Record; + } + return undefined; +} + +/** + * Collect agent keys from `agents.list` (and a few defensive fallbacks). + */ +export function extractAgentIdsFromOpenClawConfig(config: unknown): string[] { + const c = asRecord(config); + if (!c) return []; + + const agents = asRecord(c.agents); + const lists: unknown[] = []; + if (agents?.list !== undefined) lists.push(agents.list); + const nestedAgents = asRecord(agents?.agents); + if (nestedAgents?.list !== undefined) lists.push(nestedAgents.list); + + const out: string[] = []; + const seen = new Set(); + + for (const list of lists) { + if (!Array.isArray(list)) continue; + for (const item of list) { + const row = asRecord(item); + const id = typeof row?.id === "string" && row.id.trim() ? row.id.trim() : undefined; + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + } + + return out; +} diff --git a/test/config.test.ts b/test/config.test.ts index 9b938ab..35e6975 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -6,6 +6,7 @@ import { powerMemConfigSchema, resolveUserId, resolveAgentId, + expandOptionalEnvPlaceholders, DEFAULT_USER_ID, DEFAULT_AGENT_ID, DEFAULT_PLUGIN_CONFIG, @@ -139,3 +140,36 @@ describe("resolveUserId / resolveAgentId", () => { expect(resolveAgentId(cfg)).toBe("agent-1"); }); }); + +describe("expandOptionalEnvPlaceholders", () => { + it("returns literal when no placeholders", () => { + expect(expandOptionalEnvPlaceholders("alice")).toBe("alice"); + }); + + it("substitutes env when set", () => { + process.env.PM_TEST_EXPAND_X = "bob"; + expect(expandOptionalEnvPlaceholders("${PM_TEST_EXPAND_X}")).toBe("bob"); + expect(expandOptionalEnvPlaceholders("pre-${PM_TEST_EXPAND_X}-suf")).toBe("pre-bob-suf"); + delete process.env.PM_TEST_EXPAND_X; + }); + + it("returns undefined when referenced env is missing", () => { + delete process.env.PM_TEST_EXPAND_MISSING; + expect(expandOptionalEnvPlaceholders("${PM_TEST_EXPAND_MISSING}")).toBeUndefined(); + }); +}); + +describe("agent list sync config", () => { + it("parses optional agentListSyncIntervalMs and openclawConfigPath", () => { + const cfg = powerMemConfigSchema.parse({ + mode: "cli", + agentListSyncIntervalMs: 0, + openclawConfigPath: "/tmp/oc.json", + autoCapture: true, + autoRecall: true, + inferOnAdd: true, + }) as PowerMemConfig; + expect(cfg.agentListSyncIntervalMs).toBe(0); + expect(cfg.openclawConfigPath).toBe("/tmp/oc.json"); + }); +}); diff --git a/test/openclaw-config-agents.test.ts b/test/openclaw-config-agents.test.ts new file mode 100644 index 0000000..182ae95 --- /dev/null +++ b/test/openclaw-config-agents.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { extractAgentIdsFromOpenClawConfig } from "../src/openclaw-config-agents.js"; + +describe("extractAgentIdsFromOpenClawConfig", () => { + it("reads agents.list[].id in order", () => { + const ids = extractAgentIdsFromOpenClawConfig({ + agents: { + list: [{ id: "main" }, { id: "researcher", "name": "researcher" }], + }, + }); + expect(ids).toEqual(["main", "researcher"]); + }); + + it("returns empty when list missing", () => { + expect(extractAgentIdsFromOpenClawConfig({ agents: {} })).toEqual([]); + expect(extractAgentIdsFromOpenClawConfig(null)).toEqual([]); + }); + + it("dedupes duplicate ids", () => { + const ids = extractAgentIdsFromOpenClawConfig({ + agents: { list: [{ id: "a" }, { id: "a" }] }, + }); + expect(ids).toEqual(["a"]); + }); +});