Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stateDir>/powermem/identity.json` (defaults) and `<stateDir>/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 <id>` / `--agent-id <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 <openclawAgentKey> --user-id <id>` / `--agent-id <id>` — Update one entry; creating a new key requires both `--user-id` and `--agent-id`.
- `openclaw ltm sync-user-id [--user-id <id>] [--from identity|agent] [--agent <key>]` — 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 <key>`).

---

## Troubleshooting
Expand Down
8 changes: 8 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 会保存在 `<stateDir>/powermem/identity.json`(默认值)与 `<stateDir>/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 <id>` / `--agent-id <id>` — 设置其一或两者(未指定的字段保留已有值或自动生成)。
- `openclaw ltm agent-identities show [--json]` — 列出每个 OpenClaw agent key 及其对应的 PowerMem `userId` / `agentId`。
- `openclaw ltm agent-identities set --agent <openclawAgentKey> --user-id <id>` / `--agent-id <id>` — 更新一条映射;新建 key 时需同时提供 `--user-id` 与 `--agent-id`。
- `openclaw ltm sync-user-id [--user-id <id>] [--from identity|agent] [--agent <key>]` — 在 `identity.json` 与 `agent-identities.json` 的每条记录中使用同一个 `userId`(各条目的 PowerMem `agentId` 不变)。省略 `--user-id` 时,从 `identity.json` 读取规范 id(`--from identity`,默认)或从某条映射读取(`--from agent --agent <key>`)。

---

## 常见问题
Expand Down
10 changes: 8 additions & 2 deletions src/dual-write-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
251 changes: 250 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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: (
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1391,6 +1428,218 @@ const memoryPlugin = {
.command("ltm")
.description("PowerMem long-term memory plugin commands");

const identityCmd = ltm
.command("identity")
.description(
"Read or edit <stateDir>/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 <id>", "PowerMem user id")
.option("--agent-id <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 <stateDir>/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<string, AgentIdentity> = {};
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 <key>", "OpenClaw agent id (JSON object key)", "")
.option("--user-id <id>", "PowerMem user id")
.option("--agent-id <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 <id>", "Use this user id everywhere")
.option(
"--from <mode>",
"Where to read the canonical user id when --user-id is omitted: identity | agent",
"identity",
)
.option("--agent <key>", "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 <OpenClaw agent key>.");
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")
Expand Down
Loading