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..6394e66 100644 --- a/README_CN.md +++ b/README_CN.md @@ -295,13 +295,16 @@ 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`。 | | `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`。 | @@ -351,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 4c716d1..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", @@ -211,6 +223,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, @@ -231,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" }, @@ -276,6 +296,7 @@ } } }, + "pluginLlmModel": { "type": "string" }, "useOpenClawModel": { "type": "boolean" } }, "required": [] diff --git a/src/config.ts b/src/config.ts index 8ea01fd..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"; @@ -51,8 +82,23 @@ 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; + /** + * 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. */ @@ -114,8 +160,11 @@ const ALLOWED_KEYS = [ "envFile", "pmemPath", "useOpenClawModel", + "pluginLlmModel", "userId", "agentId", + "openclawConfigPath", + "agentListSyncIntervalMs", "recallLimit", "recallScoreThreshold", "walCapture", @@ -272,6 +321,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() @@ -307,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, @@ -377,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 fab33da..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, @@ -1959,7 +2055,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 +2082,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; @@ -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/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; } 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"]); + }); +});