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
Binary file added docs/screenshots/memory-gui/01-settings-entry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/memory-gui/03-create-memory.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/memory-gui/04-scope-dropdown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 57 additions & 9 deletions kun/src/memory/memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemoryDiagnostics> {
Expand Down Expand Up @@ -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<string> {
const grams = new Set<string>()
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
}
6 changes: 6 additions & 0 deletions kun/src/prompt/kun-system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
57 changes: 57 additions & 0 deletions kun/tests/memory-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,63 @@ describe('Memory store and recall', () => {
expect(finalInstructions).toContain('<shell_environment>')
})

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' })
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/app-ipc-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions src/main/kun-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ export async function syncGuiManagedKunConfig(
| 'musicGeneration'
| 'videoGeneration'
| 'modelProfiles'
| 'memoryEnabled'
>,
options?: {
scheduleMcp?: {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/agent/kun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoreMemoryRecordJson> {
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 }
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
CoreAttachmentContentResponseJson,
CoreAttachmentMetadataJson,
CoreAttachmentTextFallbackJson,
CoreMemoryDiagnosticsJson,
CoreMemoryRecordJson,
CoreRuntimeInfoJson,
CoreRuntimeSkillJson,
Expand Down Expand Up @@ -464,11 +465,20 @@ export interface AgentProvider {
options?: { threadId?: string; workspace?: string }
): Promise<CoreAttachmentContentResponseJson>
listMemories?(options?: { workspace?: string; includeDeleted?: boolean }): Promise<CoreMemoryRecordJson[]>
createMemory?(input: {
content: string
scope?: 'user' | 'workspace' | 'project'
workspace?: string
project?: string
tags?: string[]
confidence?: number
}): Promise<CoreMemoryRecordJson>
updateMemory?(
memoryId: string,
patch: { content?: string; tags?: string[]; confidence?: number; disabled?: boolean }
): Promise<CoreMemoryRecordJson>
deleteMemory?(memoryId: string): Promise<CoreMemoryRecordJson>
getMemoryDiagnostics?(): Promise<CoreMemoryDiagnosticsJson>
steerUserMessage?(threadId: string, turnId: string, text: string): Promise<void>
interruptTurn(threadId: string, turnId: string, options?: { discard?: boolean }): Promise<void>
renameThread(threadId: string, title: string): Promise<void>
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/src/components/SettingsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -79,6 +79,10 @@ export function SettingsSidebar({
<ShieldCheck className="h-4 w-4 shrink-0 opacity-70" strokeWidth={1.75} />
{t('permissions')}
</button>
<button type="button" className={catCls('memory')} onClick={() => setCategory('memory')}>
<BrainCircuit className="h-4 w-4 shrink-0 opacity-70" strokeWidth={1.75} />
{t('memory')}
</button>
<button type="button" className={catCls('shortcuts')} onClick={() => setCategory('shortcuts')}>
<Keyboard className="h-4 w-4 shrink-0 opacity-70" strokeWidth={1.75} />
{t('keyboardShortcuts')}
Expand Down
Loading