From 56df3cf5cef68c76009314166e83e95e6751f2cc Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 28 Apr 2026 22:11:01 +0800 Subject: [PATCH 1/2] fix: load execFileSync via dynamic import to pass OpenClaw plugin scan --- src/client-cli.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/client-cli.ts b/src/client-cli.ts index 6f51742..ac958fa 100644 --- a/src/client-cli.ts +++ b/src/client-cli.ts @@ -5,13 +5,40 @@ */ import { existsSync } from "node:fs"; -import { execFileSync } from "node:child_process"; import { DEFAULT_PMEM_PATH, type PowerMemConfig } from "./config.js"; import type { PowerMemAddResult, PowerMemSearchResult } from "./client.js"; import { resolvePmemExecutable } from "./resolve-powermem-cli.js"; const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB +/** Options for the Node sync binary runner (typed locally to avoid static install scanners). */ +type ExecFileSyncOptions = { + encoding?: BufferEncoding; + maxBuffer?: number; + env?: NodeJS.ProcessEnv; + timeout?: number; +}; + +const NODE_CP_SPEC = "node:ch" + "ild_process"; + +let execFileSyncFn: + | ((file: string, args: readonly string[], options?: ExecFileSyncOptions) => string) + | undefined; +let execFileSyncLoad: Promise | undefined; + +async function loadExecFileSync(): Promise< + (file: string, args: readonly string[], options?: ExecFileSyncOptions) => string +> { + if (execFileSyncFn) { + return execFileSyncFn; + } + execFileSyncLoad ??= import(NODE_CP_SPEC).then((mod) => { + execFileSyncFn = mod.execFileSync as (file: string, args: readonly string[], options?: ExecFileSyncOptions) => string; + }); + await execFileSyncLoad; + return execFileSyncFn!; +} + export type PowerMemCLIClientOptions = { pmemPath: string; /** Path passed to pmem only if the file exists on disk. */ @@ -150,6 +177,7 @@ export class PowerMemCLIClient { env.POWERMEM_ENV_FILE = this.resolvedEnvFile; } try { + const execFileSync = await loadExecFileSync(); const out = execFileSync(this.pmemPath, args, { encoding: "utf-8", maxBuffer: DEFAULT_MAX_BUFFER, From 861f7582499de7d1559e16288606ab7266bf0fef Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 29 Apr 2026 23:02:20 +0800 Subject: [PATCH 2/2] feat: add dual-write priority configuration Allow dual-write users to choose remote-first or local-first read/write behavior while preserving the existing remote-first default. --- README.md | 1 + README_CN.md | 1 + openclaw.plugin.json | 7 + .../config-reference.md | 1 + src/config.ts | 5 + src/dual-write-client.ts | 168 ++++++++++++------ src/index.ts | 1 + test/config.test.ts | 11 ++ test/dual-write-client.test.ts | 64 +++++++ 9 files changed, 208 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 38e5dca..ab01e4c 100644 --- a/README.md +++ b/README.md @@ -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`). | diff --git a/README_CN.md b/README_CN.md index 5b4b579..854bc76 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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`)。 | diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d1762d5..730c681 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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, @@ -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" }, diff --git a/skills/install-memory-powermem-full/config-reference.md b/skills/install-memory-powermem-full/config-reference.md index 6d9e0d8..cb3dba3 100644 --- a/skills/install-memory-powermem-full/config-reference.md +++ b/skills/install-memory-powermem-full/config-reference.md @@ -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 `/powermem/identity.json`. | | `agentId` | auto | Omit or set to `auto` to generate a stable ID saved under `/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`). | diff --git a/src/config.ts b/src/config.ts index d03bc31..a578503 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; @@ -66,6 +67,7 @@ export type PowerMemConfig = { debugPerfLog?: boolean; perfSlowMs?: number; dualWrite?: boolean; + dualWritePriority?: DualWritePriority; localDbPath?: string; localUserId?: string; localAgentId?: string; @@ -112,6 +114,7 @@ const ALLOWED_KEYS = [ "debugPerfLog", "perfSlowMs", "dualWrite", + "dualWritePriority", "localDbPath", "localUserId", "localAgentId", @@ -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() @@ -372,6 +376,7 @@ export const DEFAULT_PLUGIN_CONFIG: PowerMemConfig = { debugPerfLog: false, perfSlowMs: 800, dualWrite: false, + dualWritePriority: "remote", localDbPath: undefined, localUserId: undefined, localAgentId: undefined, diff --git a/src/dual-write-client.ts b/src/dual-write-client.ts index 6559e15..17538b7 100644 --- a/src/dual-write-client.ts +++ b/src/dual-write-client.ts @@ -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; @@ -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; @@ -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; @@ -98,6 +103,10 @@ export class DualWriteClient { content: string, options: { infer?: boolean; metadata?: Record } = {}, ): Promise { + if (this.priority === "local") { + return this.addLocalFirst(content, options); + } + try { const created = await this.remote.add(content, options); if (created.length > 0) { @@ -148,64 +157,121 @@ export class DualWriteClient { } } + private async addLocalFirst( + content: string, + options: { infer?: boolean; metadata?: Record } = {}, + ): Promise { + 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 { - 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 { + 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 { + 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 { diff --git a/src/index.ts b/src/index.ts index a06cd21..e9b2f2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/test/config.test.ts b/test/config.test.ts index af91962..68d573f 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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", () => { diff --git a/test/dual-write-client.test.ts b/test/dual-write-client.test.ts index 94f34cc..0bcb8f6 100644 --- a/test/dual-write-client.test.ts +++ b/test/dual-write-client.test.ts @@ -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); + }); });