feat: Enable FTS-only memory search when no OpenAI embedding key is configured. (#1205)

This commit is contained in:
paisley
2026-07-30 18:40:45 +08:00
committed by GitHub
parent 9591deba7e
commit 76f22a0e8e
9 changed files with 243 additions and 42 deletions
+36 -11
View File
@@ -29,9 +29,13 @@ import {
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import { withConfigLock } from './config-mutex';
import { ensureMemorySearchDisabledDefault, hasUserMemorySearchConfig } from './openclaw-memory-search';
import {
ensureMemorySearchFtsDefault,
hasUserMemorySearchConfig,
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
} from './openclaw-memory-search';
import { PORTS } from './config';
import { getSetting } from './store';
import { getSetting, setSetting } from './store';
import {
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
@@ -2713,16 +2717,31 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
}
// ── Memory search default ──
// OpenClaw defaults to the openai embedding provider; without a key that
// yields doctor errors and a broken memory_search tool. Seed enabled=false
// only when the user has no memorySearch config anywhere AND no OpenAI key
// (i.e. the default embedding model is unusable). Existing user config is
// never modified.
if (!hasUserMemorySearchConfig(config)
&& !(await getProviderApiKeyFromOpenClaw('openai'))
&& ensureMemorySearchDisabledDefault(config)) {
// OpenClaw 2026.7.1 supports provider=none as an explicit FTS-only mode.
// Migrate ClawX's exact legacy disabled default once, and otherwise seed
// FTS only when the user has no memorySearch config or OpenAI embedding key.
const memorySearchMigrationVersion = Number(
await getSetting('memorySearchFtsMigrationVersion'),
) || 0;
const shouldMigrateLegacyMemorySearch =
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
let memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
&& hasUserMemorySearchConfig(config)
? ensureMemorySearchFtsDefault(config, true)
: 'unchanged';
if (memorySearchDefaultResult === 'unchanged'
&& !hasUserMemorySearchConfig(config)
&& !(await getProviderApiKeyFromOpenClaw('openai'))) {
memorySearchDefaultResult = ensureMemorySearchFtsDefault(config);
}
if (memorySearchDefaultResult !== 'unchanged') {
modified = true;
console.log('[batch-sync] Seeded agents.defaults.memorySearch.enabled=false (no embedding provider configured)');
console.log(
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
+ 'agents.defaults.memorySearch to FTS-only mode',
);
}
// ── Custom provider contextWindow backfill ──
@@ -2736,6 +2755,12 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
await writeOpenClawJson(config);
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
}
if (shouldMigrateLegacyMemorySearch) {
await setSetting(
'memorySearchFtsMigrationVersion',
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
);
}
});
}
+33 -15
View File
@@ -1,14 +1,15 @@
/**
* Memory search default seeding for openclaw.json.
*
* OpenClaw enables semantic memory search by default with the `openai`
* embedding provider, so a user without an OpenAI key gets doctor errors and
* a broken memory_search tool. ClawX seeds `agents.defaults.memorySearch =
* { enabled: false }` at Gateway prelaunch — but only when the user has no
* memorySearch config anywhere (global defaults or per-agent overrides).
* Existing user config is never modified.
* OpenClaw defaults to the `openai` embedding provider. When no OpenAI key is
* available, ClawX explicitly selects OpenClaw's keyword-only FTS provider so
* memory_search remains useful without making an embedding request.
*/
export const MEMORY_SEARCH_FTS_MIGRATION_VERSION = 1;
export type MemorySearchDefaultResult = 'unchanged' | 'seeded' | 'migrated';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -29,18 +30,35 @@ export function hasUserMemorySearchConfig(config: Record<string, unknown>): bool
}
/**
* Seed `agents.defaults.memorySearch = { enabled: false }` when the user has
* no memorySearch config at all. Mutates `config` in place and returns true
* when a change was made. Never touches existing memorySearch objects.
* Seed OpenClaw's explicit FTS-only mode when no memorySearch config exists.
* When requested, also migrate the exact legacy ClawX-managed disabled
* default. Objects with any additional fields and per-agent overrides remain
* user-owned.
*/
export function ensureMemorySearchDisabledDefault(config: Record<string, unknown>): boolean {
if (hasUserMemorySearchConfig(config)) return false;
export function ensureMemorySearchFtsDefault(
config: Record<string, unknown>,
migrateLegacyDisabledDefault = false,
): MemorySearchDefaultResult {
const agents = (isRecord(config.agents) ? config.agents : {}) as Record<string, unknown>;
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
const list = Array.isArray(agents.list) ? agents.list : [];
if (list.some((entry) => isRecord(entry) && entry.memorySearch !== undefined)) {
return 'unchanged';
}
defaults.memorySearch = { enabled: false };
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
const memorySearch = defaults.memorySearch;
if (memorySearch !== undefined) {
const isLegacyDisabledDefault = isRecord(memorySearch)
&& Object.keys(memorySearch).length === 1
&& memorySearch.enabled === false;
if (!migrateLegacyDisabledDefault || !isLegacyDisabledDefault) {
return 'unchanged';
}
}
defaults.memorySearch = { enabled: true, provider: 'none' };
agents.defaults = defaults;
config.agents = agents;
return true;
return memorySearch === undefined ? 'seeded' : 'migrated';
}
+2
View File
@@ -42,6 +42,7 @@ export interface AppSettings {
proxyHttpsServer: string;
proxyAllServer: string;
proxyBypassRules: string;
memorySearchFtsMigrationVersion: number;
// Update
updateChannel: 'stable' | 'beta' | 'dev';
@@ -96,6 +97,7 @@ function createDefaultSettings(): AppSettings {
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
memorySearchFtsMigrationVersion: 0,
// Update
updateChannel: 'stable',
@@ -15,4 +15,6 @@ Rules:
- allowlists and entries must agree about which package owns a single-owner capability
- disabling a bundled plugin is required when removing it from an allowlist is not sufficient to stop runtime loading
- stale plugin registrations for unconfigured capabilities must be removed during sanitize or recovery paths
- when no embedding credentials or user-owned memory-search config exist, preserve `memory_search` through OpenClaw's explicit FTS-only provider instead of disabling the tool
- migrations may replace only the exact legacy ClawX-managed memory-search default, must run at most once, and must preserve later user opt-outs
- tests for config rewrites should assert the final active config, not only intermediate helper output
@@ -104,7 +104,7 @@ The authoritative durable requirements are `harness/reference/acp-attachment-acc
| Acceptance behavior | Test or durable rule |
| --- | --- |
| Deterministic handler normalization, presentation-only caching, 256/512/4096 and process/protocol bounds, icon degradation, sanitized environment, static JXA, SHA-256 Windows IDs, Main-owned association input, and post-ready invocation | `tests/unit/attachment-open-with.test.ts`, `attachment-access-safety` |
| Real macOS and Windows native bridge validity, static bundled helper resolution, and packaged-resource identity | `tests/unit/attachment-open-with-native.test.ts`, `.github/workflows/check.yml`, `.github/workflows/release.yml` |
| Real macOS and Windows native bridge validity, static bundled helper resolution, and packaged-resource identity; native CI smoke allows cold PowerShell compilation overhead while mocked service tests enforce the production process timeout | `tests/unit/attachment-open-with-native.test.ts`, `tests/unit/attachment-open-with.test.ts`, `.github/workflows/check.yml`, `.github/workflows/release.yml` |
| Per-operation attachment authorization, generation revalidation, forged-handler rejection, scoped reveal, and sensitive diagnostic-payload exclusion | `tests/unit/attachment-access.test.ts`, `attachment-access-safety` |
| Shared `AcpFileCard` sibling controls, exact attachment eligibility, lazy/repeated discovery, stale-result rejection, sorting, icon fallback, silent failure, localization, and keyboard interaction | `tests/unit/acp-chat-components.test.tsx`, `ui-i18n-design-tokens` |
| End-to-end click routing, typed host requests, platform menu behavior, and failure isolation | `tests/e2e/chat-acp-attachments.spec.ts` |
@@ -0,0 +1,38 @@
---
id: enable-fts-memory-search-default
title: Enable keyword-only memory search when embeddings are unavailable
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Keep OpenClaw memory_search usable without an OpenAI embedding key by selecting its explicit FTS-only provider.
touchedAreas:
- electron/utils/openclaw-memory-search.ts
- electron/utils/openclaw-auth.ts
- electron/utils/store.ts
- tests/unit/openclaw-memory-search.test.ts
- tests/unit/openclaw-auth.test.ts
- harness/specs/rules/active-config-guards.md
- harness/specs/tasks/enable-fts-memory-search-default.md
expectedUserBehavior:
- A user without memory-search configuration or an OpenAI embedding key gets keyword-only memory search instead of a disabled memory_search tool.
- A user with an OpenAI embedding key and no memory-search configuration retains OpenClaw's default embedding-backed behavior.
- Existing global or per-agent memory-search configuration remains user-owned.
- The exact legacy ClawX-managed disabled default is migrated to FTS-only once, after which an explicit user opt-out remains respected.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/openclaw-memory-search.test.ts
- tests/unit/openclaw-auth.test.ts
acceptance:
- ClawX seeds agents.defaults.memorySearch with enabled true and provider none only when no memory-search configuration and no OpenAI embedding key exist.
- The exact legacy agents.defaults.memorySearch shape with only enabled false migrates to the FTS-only default at most once.
- Any memory-search object with additional fields and all per-agent overrides remain unchanged.
- The migration marker is persisted outside openclaw.json so OpenClaw schema validation is unaffected.
- Targeted unit tests, type checks, communication regression checks, and harness validation pass.
docs:
required: false
---
OpenClaw 2026.7.1 supports deliberate keyword-only recall through
`agents.defaults.memorySearch.provider: "none"`. Use that mode as ClawX's
safe no-key default instead of disabling memory search.
@@ -27,12 +27,15 @@ import {
HANDLER_NAME_MAX_LENGTH,
NATIVE_PATH_MAX_LENGTH,
PROCESS_MAX_BUFFER_BYTES,
PROCESS_TIMEOUT_MS,
createAttachmentOpenWithService,
type AttachmentOpenWithDependencies,
} from '@electron/services/attachment-open-with';
const temporaryDirectories: string[] = [];
// Native smoke tests include cold PowerShell startup, Add-Type compilation,
// and Windows Defender scanning on a fresh CI runner. Production still keeps
// its separate five-second process bound, covered by mocked service tests.
const NATIVE_SMOKE_TIMEOUT_MS = 30_000;
function hasControlCharacters(value: string): boolean {
return [...value].some((character) => {
@@ -117,7 +120,7 @@ async function runWindowsHelper(...args: string[]): Promise<{
const timeout = setTimeout(() => {
child.kill();
finish(new Error('native Windows helper timeout'));
}, PROCESS_TIMEOUT_MS);
}, NATIVE_SMOKE_TIMEOUT_MS);
const finish = (error?: Error, code: number | null = null) => {
if (settled) return;
settled = true;
@@ -214,7 +217,7 @@ describe('attachment open-with native bridges', () => {
const timeout = setTimeout(() => {
child.kill();
finish(new Error('native matched prepare-open timeout'));
}, PROCESS_TIMEOUT_MS);
}, NATIVE_SMOKE_TIMEOUT_MS);
const finish = (error?: Error, code: number | null = null) => {
if (settled) return;
settled = true;
@@ -266,7 +269,7 @@ describe('attachment open-with native bridges', () => {
const nonmatchingResult = await runWindowsHelper('prepare-open', filePath, '0'.repeat(64));
expect(nonmatchingResult.code).not.toBe(0);
expect(nonmatchingResult.stdout).not.toContain('{"ready":true}');
}, PROCESS_TIMEOUT_MS * 4);
}, NATIVE_SMOKE_TIMEOUT_MS * 4);
it('resolves and executes the exact helper staged in a packaged resources tree', async () => {
const root = await mkdtemp(join(tmpdir(), 'clawx-packaged-open-with-'));
+77 -1
View File
@@ -4,12 +4,13 @@ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { testHome, testUserData, getSettingMock } = vi.hoisted(() => {
const { testHome, testUserData, getSettingMock, setSettingMock } = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
return {
testHome: `/tmp/clawx-openclaw-auth-${suffix}`,
testUserData: `/tmp/clawx-openclaw-auth-user-data-${suffix}`,
getSettingMock: vi.fn(),
setSettingMock: vi.fn(),
};
});
@@ -35,6 +36,7 @@ vi.mock('electron', () => ({
vi.mock('@electron/utils/store', () => ({
getSetting: getSettingMock,
setSetting: setSettingMock,
}));
vi.mock('@electron/utils/paths', async () => {
@@ -2292,10 +2294,14 @@ describe('batchSyncConfigFields', () => {
beforeEach(async () => {
vi.resetModules();
vi.restoreAllMocks();
getSettingMock.mockReset();
setSettingMock.mockReset();
getSettingMock.mockImplementation(async (key: string) => {
if (key === 'gatewayPort') return 18789;
if (key === 'memorySearchFtsMigrationVersion') return 0;
return undefined;
});
setSettingMock.mockResolvedValue(undefined);
await rm(testHome, { recursive: true, force: true });
await rm(testUserData, { recursive: true, force: true });
});
@@ -2391,6 +2397,76 @@ describe('batchSyncConfigFields', () => {
expect(defaults.compaction).toEqual({ mode: 'default', reserveTokensFloor: 30000 });
});
it('seeds FTS-only memory search when no OpenAI embedding key exists', async () => {
await writeOpenClawJson({ gateway: { auth: { mode: 'token', token: 'old' } } });
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.memorySearch).toEqual({ enabled: true, provider: 'none' });
expect(setSettingMock).toHaveBeenCalledWith('memorySearchFtsMigrationVersion', 1);
});
it('keeps OpenClaw defaults when an OpenAI embedding key exists', async () => {
await writeOpenClawJson({ gateway: { auth: { mode: 'token', token: 'old' } } });
await writeAgentAuthProfiles('main', {
version: 1,
profiles: {
'openai:default': {
type: 'api_key',
provider: 'openai',
key: 'sk-openai-test',
},
},
order: { openai: ['openai:default'] },
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.memorySearch).toBeUndefined();
expect(setSettingMock).toHaveBeenCalledWith('memorySearchFtsMigrationVersion', 1);
});
it('migrates the exact legacy disabled memory-search default once', async () => {
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
agents: { defaults: { memorySearch: { enabled: false } } },
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.memorySearch).toEqual({ enabled: true, provider: 'none' });
expect(setSettingMock).toHaveBeenCalledWith('memorySearchFtsMigrationVersion', 1);
});
it('respects an explicit memory-search opt-out after the migration completed', async () => {
getSettingMock.mockImplementation(async (key: string) => {
if (key === 'gatewayPort') return 18789;
if (key === 'memorySearchFtsMigrationVersion') return 1;
return undefined;
});
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
agents: { defaults: { memorySearch: { enabled: false } } },
});
const { batchSyncConfigFields } = await import('@electron/utils/openclaw-auth');
await batchSyncConfigFields('new-token');
const config = await readOpenClawJson();
const defaults = ((config.agents as Record<string, unknown>).defaults as Record<string, unknown>);
expect(defaults.memorySearch).toEqual({ enabled: false });
expect(setSettingMock).not.toHaveBeenCalled();
});
it('backfills contextWindow on custom provider model rows that lack one', async () => {
await writeOpenClawJson({
gateway: { auth: { mode: 'token', token: 'old' } },
+47 -10
View File
@@ -1,20 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
ensureMemorySearchDisabledDefault,
ensureMemorySearchFtsDefault,
hasUserMemorySearchConfig,
} from '@electron/utils/openclaw-memory-search';
describe('openclaw-memory-search', () => {
it('seeds memorySearch.enabled=false when no memorySearch config exists', () => {
it('seeds FTS-only memory search when no memorySearch config exists', () => {
const config: Record<string, unknown> = {
agents: { defaults: { model: { primary: 'custom-customfc/gpt-5.5' } } },
};
expect(ensureMemorySearchDisabledDefault(config)).toBe(true);
expect(ensureMemorySearchFtsDefault(config)).toBe('seeded');
expect(config).toEqual({
agents: {
defaults: {
model: { primary: 'custom-customfc/gpt-5.5' },
memorySearch: { enabled: false },
memorySearch: { enabled: true, provider: 'none' },
},
},
});
@@ -22,9 +22,11 @@ describe('openclaw-memory-search', () => {
it('seeds on a completely empty config', () => {
const config: Record<string, unknown> = {};
expect(ensureMemorySearchDisabledDefault(config)).toBe(true);
expect(ensureMemorySearchFtsDefault(config)).toBe('seeded');
expect(config).toEqual({
agents: { defaults: { memorySearch: { enabled: false } } },
agents: {
defaults: { memorySearch: { enabled: true, provider: 'none' } },
},
});
});
@@ -42,7 +44,7 @@ describe('openclaw-memory-search', () => {
},
};
const before = JSON.parse(JSON.stringify(config));
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(ensureMemorySearchFtsDefault(config, true)).toBe('unchanged');
expect(config).toEqual(before);
});
@@ -57,7 +59,7 @@ describe('openclaw-memory-search', () => {
},
};
const before = JSON.parse(JSON.stringify(config));
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(ensureMemorySearchFtsDefault(config, true)).toBe('unchanged');
expect(config).toEqual(before);
});
@@ -66,7 +68,7 @@ describe('openclaw-memory-search', () => {
agents: { defaults: { memorySearch: { enabled: true } } },
};
expect(hasUserMemorySearchConfig(config)).toBe(true);
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(ensureMemorySearchFtsDefault(config, true)).toBe('unchanged');
expect((config.agents as { defaults: { memorySearch: { enabled: boolean } } }).defaults.memorySearch.enabled).toBe(true);
});
@@ -75,6 +77,41 @@ describe('openclaw-memory-search', () => {
agents: { defaults: { memorySearch: {} } },
};
expect(hasUserMemorySearchConfig(config)).toBe(true);
expect(ensureMemorySearchDisabledDefault(config)).toBe(false);
expect(ensureMemorySearchFtsDefault(config, true)).toBe('unchanged');
});
it('migrates only the exact legacy disabled default when requested', () => {
const config: Record<string, unknown> = {
agents: { defaults: { memorySearch: { enabled: false } } },
};
expect(ensureMemorySearchFtsDefault(config, true)).toBe('migrated');
expect(config).toEqual({
agents: {
defaults: { memorySearch: { enabled: true, provider: 'none' } },
},
});
});
it('preserves the exact legacy disabled shape when migration is complete', () => {
const config: Record<string, unknown> = {
agents: { defaults: { memorySearch: { enabled: false } } },
};
expect(ensureMemorySearchFtsDefault(config)).toBe('unchanged');
expect(config).toEqual({
agents: { defaults: { memorySearch: { enabled: false } } },
});
});
it('preserves disabled configs with additional user-owned fields', () => {
const config: Record<string, unknown> = {
agents: {
defaults: {
memorySearch: { enabled: false, provider: 'none' },
},
},
};
const before = JSON.parse(JSON.stringify(config));
expect(ensureMemorySearchFtsDefault(config, true)).toBe('unchanged');
expect(config).toEqual(before);
});
});