diff --git a/docs/screenshots/memory-gui/01-settings-entry.png b/docs/screenshots/memory-gui/01-settings-entry.png new file mode 100644 index 00000000..38724c34 Binary files /dev/null and b/docs/screenshots/memory-gui/01-settings-entry.png differ diff --git a/docs/screenshots/memory-gui/02-management-panel.png b/docs/screenshots/memory-gui/02-management-panel.png new file mode 100644 index 00000000..08c9063e Binary files /dev/null and b/docs/screenshots/memory-gui/02-management-panel.png differ diff --git a/docs/screenshots/memory-gui/03-create-memory.png b/docs/screenshots/memory-gui/03-create-memory.png new file mode 100644 index 00000000..2640d5ca Binary files /dev/null and b/docs/screenshots/memory-gui/03-create-memory.png differ diff --git a/docs/screenshots/memory-gui/04-scope-dropdown.png b/docs/screenshots/memory-gui/04-scope-dropdown.png new file mode 100644 index 00000000..c927503e Binary files /dev/null and b/docs/screenshots/memory-gui/04-scope-dropdown.png differ diff --git a/docs/screenshots/memory-gui/05-memory-in-chat.png b/docs/screenshots/memory-gui/05-memory-in-chat.png new file mode 100644 index 00000000..599d717f Binary files /dev/null and b/docs/screenshots/memory-gui/05-memory-in-chat.png differ diff --git a/kun/src/memory/memory-store.ts b/kun/src/memory/memory-store.ts index b1bc4c20..7c86e4f0 100644 --- a/kun/src/memory/memory-store.ts +++ b/kun/src/memory/memory-store.ts @@ -91,12 +91,20 @@ export class FileMemoryStore implements MemoryStore { if (!this.options.config.enabled) return [] const active = (await this.list({ workspace: input.workspace })) .filter((record) => !record.disabledAt) - return active + // User-scope memories are persistent identity facts (name, preferences, + // account) — small in number, high in value, and frequently queried by + // semantic prompts ("who am I?", "what do you know about me") that share + // zero keyword overlap with the stored content. Keyword retrieval will + // always miss them, so inject every active user memory unconditionally and + // reserve scored retrieval for the larger workspace/project pool. + const userMemories = active.filter((record) => record.scope === 'user') + const scored = active + .filter((record) => record.scope !== 'user') .map((record) => ({ record, score: scoreMemory(record, input.query) })) .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score || b.record.updatedAt.localeCompare(a.record.updatedAt)) - .slice(0, input.limit) .map((entry) => entry.record) + return [...userMemories, ...scored].slice(0, input.limit) } async diagnostics(): Promise { @@ -145,16 +153,56 @@ export class FileMemoryStore implements MemoryStore { function inScope(record: MemoryRecord, workspace: string | undefined): boolean { if (record.scope === 'user') return true - if (record.scope === 'workspace') return Boolean(workspace && record.workspace === workspace) + if (record.scope === 'workspace') { + // Records created via the GUI may not carry a workspace (e.g. manually + // added before any thread ran). Treat a missing workspace as in-scope so + // they are still retrievable; otherwise require an exact match. + if (!record.workspace) return true + return Boolean(workspace && record.workspace === workspace) + } return true } function scoreMemory(record: MemoryRecord, query: string): number { - const words = new Set(query.toLowerCase().split(/[^a-z0-9_]+/).filter((word) => word.length > 2)) - let score = 0 - const text = `${record.content} ${record.tags.join(' ')}`.toLowerCase() - for (const word of words) { - if (text.includes(word)) score += 1 + // Build n-gram fingerprints so matching works for both Latin words and CJK + // text. The previous implementation split on `[^a-z0-9_]+`, which treated + // every Chinese/Japanese/Korean character as a separator and produced an + // empty token set for CJK queries — memories were never retrieved. + const queryGrams = ngrams(query) + if (queryGrams.size === 0) return 0 + const textGrams = ngrams(`${record.content} ${record.tags.join(' ')}`) + let overlap = 0 + for (const gram of queryGrams) { + if (textGrams.has(gram)) overlap += 1 + } + // Normalize by query coverage so long queries do not drown out short ones. + const coverage = overlap / queryGrams.size + return (overlap + coverage) * record.confidence +} + +/** + * Produce a fingerprint of overlapping n-grams for a string. ASCII/Latin + * segments are tokenized on word boundaries and down to trigrams, while CJK + * runs are split into bigrams. Lower-cased, de-spaced. This keeps matching + * language-agnostic without pulling in a tokenizer dependency. + */ +function ngrams(input: string): Set { + const grams = new Set() + const normalized = input.toLowerCase() + // Pull out ASCII words (letters/digits/underscore) and CJK runs separately. + const asciiWords = normalized.match(/[a-z0-9_]{3,}/g) ?? [] + for (const word of asciiWords) { + for (let i = 0; i + 3 <= word.length; i += 1) { + grams.add(word.slice(i, i + 3)) + } + if (word.length < 3) grams.add(word) + } + const cjkRuns = normalized.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+/g) ?? [] + for (const run of cjkRuns) { + for (let i = 0; i + 2 <= run.length; i += 1) { + grams.add(run.slice(i, i + 2)) + } + if (run.length < 2) grams.add(run) } - return score * record.confidence + return grams } diff --git a/kun/src/prompt/kun-system-prompt.ts b/kun/src/prompt/kun-system-prompt.ts index ea319d3d..06a28d11 100644 --- a/kun/src/prompt/kun-system-prompt.ts +++ b/kun/src/prompt/kun-system-prompt.ts @@ -32,6 +32,12 @@ export const KUN_SYSTEM_PROMPT = [ '- Tool results are part of conversation history. Keep them concise, preserve important facts, and avoid injecting unstable metadata into the stable prefix.', '- If a tool is not advertised in the current turn, do not call it.', '', + 'Memory behavior:', + '- Relevant long-term memories may be injected per turn as context. Treat them as authoritative facts about the user and workspace and use them to ground your answer.', + '- When the user states a durable preference, fact, or decision worth keeping (coding style, environment, account, recurring goal), proactively call `memory_create` to persist it for future turns. Confirm explicit user approval before writing.', + '- Use `memory_update` to refine a memory when the user corrects or extends it, and `memory_delete` to remove one that is outdated or wrong.', + '- Do not create memories for transient task state, content already obvious from the current file, or anything the user asked to forget.', + '', 'Cache behavior:', '- Treat prompt-cache stability as a runtime invariant. Stable system instructions and stable tool schemas should remain byte-stable across turns.', '- Mutable user content, file excerpts, tool results, timestamps, selected text, workspace status, and generated summaries must stay after the stable prefix.', diff --git a/kun/tests/memory-store.test.ts b/kun/tests/memory-store.test.ts index fc790f9a..e756688d 100644 --- a/kun/tests/memory-store.test.ts +++ b/kun/tests/memory-store.test.ts @@ -165,6 +165,63 @@ describe('Memory store and recall', () => { expect(finalInstructions).toContain('') }) + it('retrieves memories for CJK queries (regression: token-split-on-[^a-z0-9_])', async () => { + const store = createStore() + // Use workspace scope so this exercises the scored n-gram path. (user scope + // is now injected unconditionally, which would mask the n-gram behavior.) + const memory = await store.create({ + content: '用户的名字叫小明,喜欢用 TypeScript', + scope: 'workspace', + workspace: '/tmp/ws' + }) + + // A pure-Chinese query must match a Chinese memory. The previous + // implementation split on [^a-z0-9_]+, which treated every CJK character + // as a separator and returned an empty token set, so retrieval always + // missed. With n-gram matching this must now succeed. + const hits = await store.retrieve({ query: '用户叫什么名字', workspace: '/tmp/ws', limit: 3 }) + expect(hits.map((item) => item.id)).toEqual([memory.id]) + + // Unrelated CJK query should not match. + const misses = await store.retrieve({ query: '今天天气怎么样', workspace: '/tmp/ws', limit: 3 }) + expect(misses).toEqual([]) + }) + + it('retrieves workspace-scope memories that have no workspace field (GUI-created)', async () => { + const store = createStore() + // GUI-created workspace memories may omit the workspace field. They should + // still be retrievable instead of being silently filtered out by inScope. + const memory = await store.create({ + content: 'Workspace prefers tabs over spaces', + scope: 'workspace' + }) + expect(memory.workspace).toBeUndefined() + const hits = await store.retrieve({ query: 'tabs spaces indentation', workspace: '/tmp/ws', limit: 3 }) + expect(hits.map((item) => item.id)).toEqual([memory.id]) + }) + + it('injects user-scope memories on semantic queries with zero keyword overlap', async () => { + const store = createStore() + const userMemory = await store.create({ + content: 'whitelonng', + scope: 'user' + }) + // "who am I" shares zero characters with "whitelonng". Keyword retrieval + // (word or n-gram) cannot match this, so user memories must be injected + // unconditionally instead of gated behind scored retrieval. + const hits = await store.retrieve({ query: 'who am I', workspace: '/tmp/ws', limit: 8 }) + expect(hits.map((item) => item.id)).toContain(userMemory.id) + + // Chinese semantic query should also hit the user memory. + const cjkHits = await store.retrieve({ query: '你知道我是谁吗', workspace: '/tmp/ws', limit: 8 }) + expect(cjkHits.map((item) => item.id)).toContain(userMemory.id) + + // Disabled user memories are still excluded. + await store.update(userMemory.id, { disabled: true }) + const afterDisable = await store.retrieve({ query: 'who am I', workspace: '/tmp/ws', limit: 8 }) + expect(afterDisable.map((item) => item.id)).not.toContain(userMemory.id) + }) + it('writes memory records atomically (no .tmp file left on success)', async () => { const store = createStore() await store.create({ content: 'atomic test memory' }) diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index ab88c9a9..2ddcedb3 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -394,7 +394,8 @@ const kunRuntimePatchSchema = z.object({ modelProfiles: z.record( z.string().trim().min(1).max(128), modelProfilePatchSchema.nullable() - ).optional() + ).optional(), + memoryEnabled: z.boolean().optional() }).strict() const logPatchSchema = z.object({ diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index 604f32e9..36bc4fc6 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -358,6 +358,7 @@ export async function syncGuiManagedKunConfig( | 'musicGeneration' | 'videoGeneration' | 'modelProfiles' + | 'memoryEnabled' >, options?: { scheduleMcp?: { @@ -391,6 +392,7 @@ export async function syncGuiManagedKunConfig( const speechGen = objectValue(capabilities.speechGen) const musicGen = objectValue(capabilities.musicGen) const videoGen = objectValue(capabilities.videoGen) + const memory = objectValue(capabilities.memory) const storage = storageConfigForRuntime(runtime.storage) const mcpSearch = runtime.mcpSearch const skillCapability = await skillCapabilityConfigForRuntime(skills, options?.scheduleMcp?.settings) @@ -419,6 +421,10 @@ export async function syncGuiManagedKunConfig( speechGen: speechGenConfigForRuntime(runtime.textToSpeech, speechGen), musicGen: musicGenConfigForRuntime(runtime.musicGeneration, musicGen), videoGen: videoGenConfigForRuntime(runtime.videoGeneration, videoGen), + memory: { + ...memory, + enabled: runtime.memoryEnabled + }, mcp: { ...mcp, ...(options?.scheduleMcp || mcpSearch.enabled || hasImportedEnabledMcpServer diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 71ad9883..71368950 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -646,6 +646,28 @@ export class KunRuntimeProvider implements AgentProvider { ).memories ?? [] } + async createMemory(input: { + content: string + scope?: 'user' | 'workspace' | 'project' + workspace?: string + project?: string + tags?: string[] + confidence?: number + }): Promise { + const response = await rendererRuntimeClient.runtimeRequest( + KUN_MEMORY_PATH, + 'POST', + JSON.stringify(input) + ) + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to create memory')) + } + return readRuntimeJson<{ memory: CoreMemoryRecordJson }>( + response.body, + 'runtime returned an invalid memory response' + ).memory + } + async updateMemory( memoryId: string, patch: { content?: string; tags?: string[]; confidence?: number; disabled?: boolean } diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index 82f12f04..8a3ce510 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -2,6 +2,7 @@ import type { CoreAttachmentContentResponseJson, CoreAttachmentMetadataJson, CoreAttachmentTextFallbackJson, + CoreMemoryDiagnosticsJson, CoreMemoryRecordJson, CoreRuntimeInfoJson, CoreRuntimeSkillJson, @@ -464,11 +465,20 @@ export interface AgentProvider { options?: { threadId?: string; workspace?: string } ): Promise listMemories?(options?: { workspace?: string; includeDeleted?: boolean }): Promise + createMemory?(input: { + content: string + scope?: 'user' | 'workspace' | 'project' + workspace?: string + project?: string + tags?: string[] + confidence?: number + }): Promise updateMemory?( memoryId: string, patch: { content?: string; tags?: string[]; confidence?: number; disabled?: boolean } ): Promise deleteMemory?(memoryId: string): Promise + getMemoryDiagnostics?(): Promise steerUserMessage?(threadId: string, turnId: string, text: string): Promise interruptTurn(threadId: string, turnId: string, options?: { discard?: boolean }): Promise renameThread(threadId: string, title: string): Promise diff --git a/src/renderer/src/components/SettingsSidebar.tsx b/src/renderer/src/components/SettingsSidebar.tsx index 25be4da3..8a7ac2e7 100644 --- a/src/renderer/src/components/SettingsSidebar.tsx +++ b/src/renderer/src/components/SettingsSidebar.tsx @@ -1,7 +1,7 @@ import type { Dispatch, ReactElement, SetStateAction } from 'react' -import { AudioLines, Bot, Bug, ChevronLeft, Globe, ImageIcon, Keyboard, Mic, PencilLine, RefreshCw, ServerCog, Settings, ShieldCheck, Smartphone, Sparkles } from 'lucide-react' +import { AudioLines, Bot, BrainCircuit, Bug, ChevronLeft, Globe, ImageIcon, Keyboard, Mic, PencilLine, RefreshCw, ServerCog, Settings, ShieldCheck, Smartphone, Sparkles } from 'lucide-react' -type SettingsCategory = 'general' | 'providers' | 'write' | 'imageGeneration' | 'mediaGeneration' | 'speechToText' | 'agents' | 'permissions' | 'shortcuts' | 'easterEgg' | 'claw' | 'updates' | 'debug' +type SettingsCategory = 'general' | 'providers' | 'write' | 'imageGeneration' | 'mediaGeneration' | 'speechToText' | 'agents' | 'permissions' | 'memory' | 'shortcuts' | 'easterEgg' | 'claw' | 'updates' | 'debug' export function SettingsSidebar({ category, @@ -79,6 +79,10 @@ export function SettingsSidebar({ {t('permissions')} + + ))} + + + + + {/* Editor */} + {(creating || editingId !== null) && ( +
+
+ + {creating ? t('memoryCreateTitle') : t('memoryEditTitle')} +
+