#!/usr/bin/env node /** * Preview or update OpenClaw memory model settings in openclaw.json files. * * Without --write, prints a preview only. */ import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import { dirname, join } from "node:path"; import { parseArgs } from "node:util"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; const SENSITIVE_KEY_MARKERS = [ "apikey", "api_key", "token", "secret", "authorization", "password", "passwd", "credential", ]; function usageText(): string { return [ "Usage:", " node scripts/set_openclaw_memory_model.ts --model [--provider ] [--set KEY=VALUE...] [--write] ", "", "Targets:", " Profile directories (containing openclaw.json) or direct openclaw.json paths.", "", "Options:", " --model Required, e.g. embeddinggemma:300m-qat-q8_0", " --provider Default: ollama", " --set KEY=VALUE Keys are relative to agents.defaults.memorySearch; repeatable", " --write Apply the change (otherwise preview only)", ].join("\n"); } function expandUser(rawPath: string): string { const text = String(rawPath ?? ""); if (text === "~") return os.homedir(); if (text.startsWith("~/")) return join(os.homedir(), text.slice(2)); return text; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } async function fileExists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } async function resolveConfigPath(rawTarget: string): Promise { const target = expandUser(rawTarget); if (target.endsWith("/")) { return resolveConfigPath(target.slice(0, -1)); } const looksLikeConfig = target.endsWith("openclaw.json"); if (looksLikeConfig) { if (!target.endsWith("/openclaw.json") && target.split("/").at(-1) !== "openclaw.json") { throw new Error(`Refusing to edit non-OpenClaw config file: ${target}`); } return target; } const asDirConfig = join(target, "openclaw.json"); if (await fileExists(asDirConfig)) { return asDirConfig; } if (await fileExists(target)) { throw new Error(`Refusing to edit non-OpenClaw config file: ${target}`); } throw new Error(`Config not found: ${asDirConfig}`); } async function loadConfig(path: string): Promise> { let raw: string; try { raw = await readFile(path, "utf8"); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Config not found: ${path} (${message})`); } try { const parsed = JSON.parse(raw) as unknown; if (!isRecord(parsed)) { throw new Error("Top-level JSON must be an object"); } return parsed; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Invalid JSON in ${path}: ${message}`); } } function ensureMemorySearch(config: Record): Record { if (!("agents" in config)) { throw new Error("Refusing to edit a config without a top-level 'agents' object"); } const agents = config.agents; if (!isRecord(agents)) { throw new Error("Top-level 'agents' must be a JSON object"); } const defaults = agents.defaults; if (defaults === undefined) { agents.defaults = {}; } else if (!isRecord(defaults)) { throw new Error("'agents.defaults' must be a JSON object"); } const ensuredDefaults = agents.defaults as Record; const memorySearch = ensuredDefaults.memorySearch; if (memorySearch === undefined) { ensuredDefaults.memorySearch = {}; } else if (!isRecord(memorySearch)) { throw new Error("'agents.defaults.memorySearch' must be a JSON object"); } return ensuredDefaults.memorySearch as Record; } function deepClone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function parseExtraSettings(entries: string[]): Array<{ keyParts: string[]; value: unknown }> { return entries.map((entry) => { const index = entry.indexOf("="); if (index < 0) { throw new Error(`Invalid --set value (expected KEY=VALUE): ${entry}`); } const keyText = entry.slice(0, index); const valueText = entry.slice(index + 1); const keyParts = keyText .split(".") .map((part) => part.trim()) .filter(Boolean); if (!keyParts.length) { throw new Error(`Invalid --set key: ${entry}`); } try { return { keyParts, value: JSON.parse(valueText) }; } catch { return { keyParts, value: valueText }; } }); } function setDottedValue(root: Record, keyParts: string[], value: unknown): void { let current: Record = root; for (const part of keyParts.slice(0, -1)) { const existing = current[part]; if (existing === undefined) { current[part] = {}; } else if (!isRecord(existing)) { throw new Error(`Cannot set ${keyParts.join(".")}: ${part} is not a JSON object`); } current = current[part] as Record; } current[keyParts[keyParts.length - 1]!] = value; } function jsonDeepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b) return false; if (a === null || b === null) return false; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let index = 0; index < a.length; index += 1) { if (!jsonDeepEqual(a[index], b[index])) return false; } return true; } if (isRecord(a) && isRecord(b)) { const keysA = Object.keys(a).sort(); const keysB = Object.keys(b).sort(); if (keysA.length !== keysB.length) return false; for (let index = 0; index < keysA.length; index += 1) { if (keysA[index] !== keysB[index]) return false; } for (const key of keysA) { if (!jsonDeepEqual(a[key], b[key])) return false; } return true; } return false; } function updateConfig(params: { config: Record; provider: string; model: string; extraSettings: string[]; }): { before: unknown; after: unknown } { const memorySearch = ensureMemorySearch(params.config); const before = deepClone(memorySearch); const providerChanged = String(memorySearch.provider ?? "") !== params.provider; if (providerChanged) { for (const key of Object.keys(memorySearch)) { delete memorySearch[key]; } } memorySearch.provider = params.provider; memorySearch.model = params.model; for (const entry of parseExtraSettings(params.extraSettings)) { setDottedValue(memorySearch, entry.keyParts, entry.value); } const after = deepClone(memorySearch); return { before, after }; } function keyLooksSensitive(key: string): boolean { const normalized = key.toLowerCase().replace(/[-_]/g, ""); return SENSITIVE_KEY_MARKERS.some((marker) => normalized.includes(marker.replace(/_/g, ""))); } function redactForDisplay(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => redactForDisplay(item)); } if (isRecord(value)) { const redacted: Record = {}; for (const [key, nested] of Object.entries(value)) { redacted[key] = keyLooksSensitive(key) ? "" : redactForDisplay(nested); } return redacted; } return value; } function sortKeysRecursively(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => sortKeysRecursively(item)); } if (isRecord(value)) { const out: Record = {}; for (const key of Object.keys(value).sort()) { out[key] = sortKeysRecursively(value[key]); } return out; } return value; } function stableStringify(value: unknown): string { return JSON.stringify(sortKeysRecursively(value)); } function formatStamp(date: Date): string { const pad2 = (n: number) => String(n).padStart(2, "0"); return ( `${date.getFullYear()}` + `${pad2(date.getMonth() + 1)}` + `${pad2(date.getDate())}-` + `${pad2(date.getHours())}` + `${pad2(date.getMinutes())}` + `${pad2(date.getSeconds())}` ); } async function backupPath(configPath: string, stamp: string): Promise { let candidate = `${configPath}.bak.${stamp}`; let index = 1; while (await fileExists(candidate)) { candidate = `${configPath}.bak.${stamp}-${index}`; index += 1; } return candidate; } async function writeConfig(configPath: string, config: Record, stamp: string): Promise { const backup = await backupPath(configPath, stamp); await copyFile(configPath, backup); const tempPath = join(dirname(configPath), `openclaw.json.tmp.${stamp}.${Math.random().toString(16).slice(2)}`); await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); await rename(tempPath, configPath); return backup; } async function main(): Promise { const parsed = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: { model: { type: "string" }, provider: { type: "string", default: "ollama" }, set: { type: "string", multiple: true, default: [] }, write: { type: "boolean", default: false }, help: { type: "boolean", default: false }, }, }); if (parsed.values.help) { process.stdout.write(`${usageText()}\n`); return 0; } const model = String(parsed.values.model ?? "").trim(); if (!model) { process.stderr.write(`${usageText()}\n`); return 1; } const targets = parsed.positionals.map((value) => String(value)).filter(Boolean); if (!targets.length) { process.stderr.write(`${usageText()}\n`); return 1; } const provider = String(parsed.values.provider ?? "ollama").trim() || "ollama"; const extraSettings = (parsed.values.set ?? []) as unknown as string[]; const doWrite = Boolean(parsed.values.write); const stamp = formatStamp(new Date()); let exitCode = 0; for (const rawTarget of targets) { try { const configPath = await resolveConfigPath(rawTarget); const config = await loadConfig(configPath); const { before, after } = updateConfig({ config, provider, model, extraSettings, }); const changed = !jsonDeepEqual(before, after); process.stdout.write(`${configPath}\n`); process.stdout.write(` before: ${stableStringify(redactForDisplay(before))}\n`); process.stdout.write(` after: ${stableStringify(redactForDisplay(after))}\n`); if (!changed) { process.stdout.write(" result: unchanged\n"); continue; } if (doWrite) { const backup = await writeConfig(configPath, config, stamp); process.stdout.write(" result: written\n"); process.stdout.write(` backup: ${backup}\n`); } else { process.stdout.write(" result: preview only (add --write to apply)\n"); } } catch (err) { exitCode = 1; const message = err instanceof Error ? err.message : String(err); process.stdout.write(`${rawTarget}\n error: ${message}\n`); } } return exitCode; } void main() .then((code) => { process.exitCode = code; }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`${message}\n`); process.exitCode = 1; });