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/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; } 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")