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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ After installing, uninstalling, or changing config, restart the OpenClaw gateway
| `experienceRecall` | No | Include experiences in recall results; default `true`. |
| `inferOnAdd` | No | Use PowerMem intelligent extraction when adding; default `true`. |
| `dualWrite` | No | HTTP only: write to remote + local SQLite and queue failed writes. |
| `dualWritePriority` | No | Dual-write priority: `"remote"` (default) tries PowerMem first and falls back to local SQLite; `"local"` writes/searches SQLite first and syncs to remote. |
| `localDbPath` | No | Local SQLite path for `dualWrite`. |
| `localUserId` | No | Local namespace for `dualWrite` (defaults to `userId`). |
| `localAgentId` | No | Local namespace for `dualWrite` (defaults to `agentId`). |
Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ openclaw ltm search "咖啡"
| `experienceRecall` | 否 | 召回结果是否包含经验,默认 `true`。 |
| `inferOnAdd` | 否 | 写入时是否用 PowerMem 智能抽取,默认 `true`。 |
| `dualWrite` | 否 | 仅 HTTP:远端 + 本地 SQLite 双写,远端失败自动排队补传。 |
| `dualWritePriority` | 否 | 双写优先级:`"remote"`(默认)先远端 PowerMem、失败兜底本地 SQLite;`"local"` 先写/查 SQLite,再同步到远端。 |
| `localDbPath` | 否 | 本地 SQLite 路径(`dualWrite`)。 |
| `localUserId` | 否 | 本地命名空间(`dualWrite`,默认 `userId`)。 |
| `localAgentId` | 否 | 本地命名空间(`dualWrite`,默认 `agentId`)。 |
Expand Down
7 changes: 7 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@
"advanced": true,
"help": "When enabled, write to remote HTTP and local sqlite for failover and resync."
},
"dualWritePriority": {
"label": "Dual write priority",
"advanced": true,
"placeholder": "remote",
"help": "Dual-write read/write priority: remote (default) tries PowerMem first and falls back to local SQLite; local writes/searches SQLite first and syncs to remote."
},
"localDbPath": {
"label": "Local sqlite path",
"advanced": true,
Expand Down Expand Up @@ -201,6 +207,7 @@
"debugPerfLog": { "type": "boolean" },
"perfSlowMs": { "type": "number" },
"dualWrite": { "type": "boolean" },
"dualWritePriority": { "type": "string", "enum": ["remote", "local"] },
"localDbPath": { "type": "string" },
"localUserId": { "type": "string" },
"localAgentId": { "type": "string" },
Expand Down
1 change: 1 addition & 0 deletions skills/install-memory-powermem-full/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Quick reference for skill **`install-memory-powermem-full`**. See **SKILL.md** i
| `userId` | auto | Omit or set to `auto` to generate a stable ID saved under `<stateDir>/powermem/identity.json`. |
| `agentId` | auto | Omit or set to `auto` to generate a stable ID saved under `<stateDir>/powermem/identity.json`. |
| `dualWrite` | `false` | HTTP only: remote + local SQLite dual-write. |
| `dualWritePriority` | `remote` | Dual-write priority: `remote` tries PowerMem first and falls back to local SQLite; `local` writes/searches SQLite first and syncs to remote. |
| `localDbPath` | — | Local SQLite path for dual-write. |
| `localUserId` | — | Local namespace for dual-write (defaults to `userId`). |
| `localAgentId` | — | Local namespace for dual-write (defaults to `agentId`). |
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function resolveEnvVars(value: string): string {

export type PowerMemMode = "http" | "cli";
export type PowerMemHttpApiVersion = "v1" | "v2";
export type DualWritePriority = "remote" | "local";

export type PowerMemConfig = {
mode: PowerMemMode;
Expand Down Expand Up @@ -66,6 +67,7 @@ export type PowerMemConfig = {
debugPerfLog?: boolean;
perfSlowMs?: number;
dualWrite?: boolean;
dualWritePriority?: DualWritePriority;
localDbPath?: string;
localUserId?: string;
localAgentId?: string;
Expand Down Expand Up @@ -112,6 +114,7 @@ const ALLOWED_KEYS = [
"debugPerfLog",
"perfSlowMs",
"dualWrite",
"dualWritePriority",
"localDbPath",
"localUserId",
"localAgentId",
Expand Down Expand Up @@ -257,6 +260,7 @@ export const powerMemConfigSchema = {
debugPerfLog: cfg.debugPerfLog === true,
perfSlowMs: toPositiveInt(cfg.perfSlowMs, 800, 1, 600000),
dualWrite: cfg.dualWrite === true,
dualWritePriority: cfg.dualWritePriority === "local" ? "local" : "remote",
localDbPath,
localUserId:
typeof cfg.localUserId === "string" && cfg.localUserId.trim()
Expand Down Expand Up @@ -372,6 +376,7 @@ export const DEFAULT_PLUGIN_CONFIG: PowerMemConfig = {
debugPerfLog: false,
perfSlowMs: 800,
dualWrite: false,
dualWritePriority: "remote",
localDbPath: undefined,
localUserId: undefined,
localAgentId: undefined,
Expand Down
168 changes: 117 additions & 51 deletions src/dual-write-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ export type RemoteClient = {

type Logger = { info?: (msg: string) => void; warn?: (msg: string) => void };

export type DualWritePriority = "remote" | "local";

export type DualWriteOptions = {
localUserId: string;
localAgentId: string;
priority?: DualWritePriority;
syncOnResume: boolean;
syncBatchSize: number;
syncMinIntervalMs: number;
Expand All @@ -51,6 +54,7 @@ export class DualWriteClient {
private local: LocalSqliteStore;
private localUserId: string;
private localAgentId: string;
private priority: DualWritePriority;
private syncOnResume: boolean;
private syncBatchSize: number;
private syncMinIntervalMs: number;
Expand All @@ -67,6 +71,7 @@ export class DualWriteClient {
this.local = local;
this.localUserId = options.localUserId;
this.localAgentId = options.localAgentId;
this.priority = options.priority ?? "remote";
this.syncOnResume = options.syncOnResume;
this.syncBatchSize = options.syncBatchSize;
this.syncMinIntervalMs = options.syncMinIntervalMs;
Expand Down Expand Up @@ -98,6 +103,10 @@ export class DualWriteClient {
content: string,
options: { infer?: boolean; metadata?: Record<string, unknown> } = {},
): Promise<PowerMemAddResult[]> {
if (this.priority === "local") {
return this.addLocalFirst(content, options);
}

try {
const created = await this.remote.add(content, options);
if (created.length > 0) {
Expand Down Expand Up @@ -148,64 +157,121 @@ export class DualWriteClient {
}
}

private async addLocalFirst(
content: string,
options: { infer?: boolean; metadata?: Record<string, unknown> } = {},
): Promise<PowerMemAddResult[]> {
const localId = this.local.addLocalMemory({
content,
metadata: options.metadata,
userId: this.localUserId,
agentId: this.localAgentId,
});
await this.upsertEmbedding(localId, content);
this.local.enqueuePending({
localMemoryId: localId,
content,
metadata: options.metadata,
userId: this.localUserId,
agentId: this.localAgentId,
infer: options.infer ?? true,
});
void this.syncPending("local-add");
return [
{
memory_id: String(localId),
content,
user_id: this.localUserId,
agent_id: this.localAgentId,
metadata: options.metadata,
},
];
}

async search(query: string, limit = 5): Promise<PowerMemSearchResult[]> {
try {
const results = await this.remote.search(query, limit);
if (results.length > 0) {
for (const row of results) {
const remoteId = String(row.memory_id ?? "");
if (!remoteId) continue;
const localId = this.local.upsertRemoteMemory({
remoteId,
content: row.content,
metadata: row.metadata,
userId: this.localUserId,
agentId: this.localAgentId,
});
// Do not await: local embed can be slow or fail (e.g. fetch to embedding API);
// serial await here blocks before_agent_start / autoRecall for minutes.
void this.upsertEmbedding(localId, row.content);
}
if (this.priority === "local") {
const localResults = await this.searchLocal(query, limit);
if (localResults.length > 0) {
void this.syncPending("local-search-success");
return localResults;
}
void this.syncPending("remote-search-success");
return results;
try {
return await this.searchRemoteAndCache(query, limit, "local-search-miss");
} catch (err) {
this.logger?.warn?.(`dual-write: remote search failed after local miss: ${String(err)}`);
return [];
}
}

try {
return await this.searchRemoteAndCache(query, limit, "remote-search-success");
} catch (err) {
this.logger?.warn?.(`dual-write: remote search failed, fallback to local: ${String(err)}`);
const provider = await this.embedding?.get();
if (provider) {
try {
const embedding = await provider.embed(query);
const vectorRows = this.local.searchVector({
embedding,
limit,
userId: this.localUserId,
agentId: this.localAgentId,
});
if (vectorRows.length > 0) {
return vectorRows.map((row) => ({
memory_id: row.remote_id ?? String(row.id),
content: row.content,
score: row.score,
metadata: row.metadata,
}));
}
} catch (embedErr) {
this.logger?.warn?.(`dual-write: local vector search failed: ${String(embedErr)}`);
return this.searchLocal(query, limit);
}
}

private async searchRemoteAndCache(
query: string,
limit: number,
syncTrigger: string,
): Promise<PowerMemSearchResult[]> {
const results = await this.remote.search(query, limit);
if (results.length > 0) {
for (const row of results) {
const remoteId = String(row.memory_id ?? "");
if (!remoteId) continue;
const localId = this.local.upsertRemoteMemory({
remoteId,
content: row.content,
metadata: row.metadata,
userId: this.localUserId,
agentId: this.localAgentId,
});
// Do not await: local embed can be slow or fail (e.g. fetch to embedding API);
// serial await here blocks before_agent_start / autoRecall for minutes.
void this.upsertEmbedding(localId, row.content);
}
}
void this.syncPending(syncTrigger);
return results;
}

private async searchLocal(query: string, limit: number): Promise<PowerMemSearchResult[]> {
const provider = await this.embedding?.get();
if (provider) {
try {
const embedding = await provider.embed(query);
const vectorRows = this.local.searchVector({
embedding,
limit,
userId: this.localUserId,
agentId: this.localAgentId,
});
if (vectorRows.length > 0) {
return vectorRows.map((row) => ({
memory_id: row.remote_id ?? String(row.id),
content: row.content,
score: row.score,
metadata: row.metadata,
}));
}
} catch (embedErr) {
this.logger?.warn?.(`dual-write: local vector search failed: ${String(embedErr)}`);
}
const rows = this.local.search({
query,
limit,
userId: this.localUserId,
agentId: this.localAgentId,
});
return rows.map((row) => ({
memory_id: row.remote_id ?? String(row.id),
content: row.content,
score: row.score,
metadata: row.metadata,
}));
}
const rows = this.local.search({
query,
limit,
userId: this.localUserId,
agentId: this.localAgentId,
});
return rows.map((row) => ({
memory_id: row.remote_id ?? String(row.id),
content: row.content,
score: row.score,
metadata: row.metadata,
}));
}

async delete(memoryId: number | string): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ const memoryPlugin = {
return new DualWriteClient(httpClient, localStore, {
localUserId: localIdentity.userId,
localAgentId: localIdentity.agentId,
priority: cfg.dualWritePriority ?? "remote",
syncOnResume: cfg.syncOnResume !== false,
syncBatchSize: cfg.syncBatchSize ?? 50,
syncMinIntervalMs: cfg.syncMinIntervalMs ?? 5000,
Expand Down
11 changes: 11 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ describe("powerMemConfigSchema", () => {
expect(DEFAULT_PLUGIN_CONFIG.envFile).toBeUndefined();
expect(DEFAULT_PLUGIN_CONFIG.pmemPath).toBe("bundled");
expect(DEFAULT_PLUGIN_CONFIG.useOpenClawModel).toBe(true);
expect(DEFAULT_PLUGIN_CONFIG.dualWritePriority).toBe("remote");
});

it("parses dual-write local priority", () => {
const cfg = powerMemConfigSchema.parse({
mode: "http",
baseUrl: "http://localhost:8000",
dualWrite: true,
dualWritePriority: "local",
}) as PowerMemConfig;
expect(cfg.dualWritePriority).toBe("local");
});

it("rejects non-object config", () => {
Expand Down
64 changes: 64 additions & 0 deletions test/dual-write-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,68 @@ describe("DualWriteClient", () => {
const results = await client.search("local only", 5);
expect(results[0]?.content).toBe("local only");
});

it("uses local-first writes when configured", async () => {
handle = createStore();
const remote = {
health: vi.fn(async () => ({ status: "healthy" })),
add: vi.fn(async () => [
{ memory_id: "r-local", content: "local first", user_id: "u-3", agent_id: "a-3" },
]),
search: vi.fn(),
delete: vi.fn(),
};
const client = new DualWriteClient(remote, handle.store, {
localUserId: "u-3",
localAgentId: "a-3",
priority: "local",
syncOnResume: false,
syncBatchSize: 10,
syncMinIntervalMs: 0,
syncBaseDelayMs: 1,
syncMaxDelayMs: 100,
syncMaxRetries: 3,
});

const created = await client.add("local first", { infer: true });
expect(created[0]?.content).toBe("local first");
expect(remote.add).not.toHaveBeenCalled();
expect(handle.store.pendingCount()).toBe(1);
});

it("uses local-first search and only queries remote on local miss", async () => {
handle = createStore();
handle.store.addLocalMemory({
content: "local preferred",
userId: "u-4",
agentId: "a-4",
});
const remote = {
health: vi.fn(async () => ({ status: "healthy" })),
add: vi.fn(),
search: vi.fn(async () => [
{ memory_id: "r-4", content: "remote fallback", score: 0.9 },
]),
delete: vi.fn(),
};
const client = new DualWriteClient(remote, handle.store, {
localUserId: "u-4",
localAgentId: "a-4",
priority: "local",
syncOnResume: true,
syncBatchSize: 10,
syncMinIntervalMs: 0,
syncBaseDelayMs: 1,
syncMaxDelayMs: 100,
syncMaxRetries: 3,
});

const localResults = await client.search("local preferred", 5);
expect(localResults[0]?.content).toBe("local preferred");
expect(remote.search).not.toHaveBeenCalled();

const remoteResults = await client.search("remote fallback", 5);
expect(remoteResults[0]?.content).toBe("remote fallback");
expect(remote.search).toHaveBeenCalledTimes(1);
});
});
Loading