diff --git a/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17.md b/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17.md new file mode 100644 index 0000000..cccc13b --- /dev/null +++ b/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17.md @@ -0,0 +1,18 @@ +# Local memory bugfix notes + +Patched issues: +- allow explicit `retention_until: null` during memory updates +- tighten `sensitivity_level` typing across service/preload/renderer hook +- add minimal IPC validation for local memory create/update/search/list operations +- finalize remote memorization task state after success/failure/error +- treat non-2xx memU retrieve responses as failures instead of successes +- default manual remember action to `memory_type: manual_note` in renderer hook + +Files changed: +- src/main/services/memory/local-memory.store.ts +- src/main/services/local-memory-control.service.ts +- src/main/services/memorization.service.ts +- src/main/tools/memu.executor.ts +- src/main/ipc/memory.handlers.ts +- src/preload/index.d.ts +- src/renderer/src/hooks/useLocalMemoryControls.ts diff --git a/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17_v2.md b/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17_v2.md new file mode 100644 index 0000000..9ec6ae0 --- /dev/null +++ b/LOCAL_MEMORY_BUGFIX_NOTES_2026-04-17_v2.md @@ -0,0 +1,18 @@ +# Local memory bugfix notes (2026-04-17 v2) + +This package includes the following fixes: + +1. Remote memU failures no longer delete queued messages before successful persistence. +2. Remote memU success path no longer removes queued messages twice. +3. Remote provider selection now requires full memU configuration, not only an API key. +4. When remote memorization cannot start, the service falls back to local controlled memory. +5. `doNotRememberThis()` now reuses an existing suppression rule instead of creating duplicates. +6. Conflict notes are deduplicated, and conflict state is recalculated so stale conflict metadata is cleared. +7. The Node engine requirement was relaxed from `>=23.11.1` to `>=22.0.0` to avoid blocking install in common environments. + +Files changed: +- `package.json` +- `src/main/services/memorization.service.ts` +- `src/main/services/local-memory-control.service.ts` +- `src/main/services/memory/remote-memu.provider.ts` +- `src/main/services/memory/local-memory.store.ts` diff --git a/LOCAL_MEMORY_PATCH_NOTES.md b/LOCAL_MEMORY_PATCH_NOTES.md new file mode 100644 index 0000000..83ed82f --- /dev/null +++ b/LOCAL_MEMORY_PATCH_NOTES.md @@ -0,0 +1,36 @@ +# Local memory patch notes + +This package is based on `memUBot-main-local-memory-explainability` and includes a focused follow-up patch to make the local memory path more usable. + +## What was patched + +- Switched memorization to **auto-select local provider** when no remote memU API key is configured. +- Fixed the **duplicate local write / queue retention bug** by clearing queued messages after successful local memorization. +- Added a **local fallback** for `memu_memory` retrieval, so memory retrieval can work without cloud memU. +- Added **delete by source platform** through service, IPC, preload, and renderer hook. +- Expanded local list/search filters with: + - `created_after`, `created_before` + - `updated_after`, `updated_before` + - `min_confidence`, `min_importance` +- Added basic retention enforcement in local listing by excluding expired memories. +- Added lightweight duplicate avoidance in local memorization. + +## Still not fully finished + +- Conflict detection is still not fully implemented. +- Time decay is not yet applied as a scoring factor. +- Sensitivity-specific retrieval policy is still basic. +- The local retrieval scoring is heuristic, not embedding-based. +- I did not run a full project build inside this environment, so treat this as a strong code patch rather than a fully validated release build. + +## Main files changed + +- `src/main/services/memorization.service.ts` +- `src/main/tools/memu.executor.ts` +- `src/main/services/memory/local-memory.store.ts` +- `src/main/services/memory/local-controlled-memory.provider.ts` +- `src/main/services/local-memory-control.service.ts` +- `src/main/ipc/memory.handlers.ts` +- `src/preload/index.ts` +- `src/preload/index.d.ts` +- `src/renderer/src/hooks/useLocalMemoryControls.ts` diff --git a/package-lock.json b/package-lock.json index 66e036d..4f52ce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "socks-proxy-agent": "^8.0.5", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "ws": "^8.18.0", "zustand": "^5.0.10" }, "devDependencies": { @@ -48,6 +49,7 @@ "@types/node-telegram-bot-api": "^0.64.13", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/ws": "^8.18.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", "bufferutil": "^4.1.0", diff --git a/package.json b/package.json index 6ac697f..3147976 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "memU bot - AI Assistant for messaging platforms", "main": "./out/main/index.js", "engines": { - "node": ">=v23.11.1" + "node": ">=22.0.0" }, "author": "NevaMind AI", "license": "AGPL-3.0-only", diff --git a/src/main/apps/discord/bot.service.ts b/src/main/apps/discord/bot.service.ts index 8aca291..e257144 100644 --- a/src/main/apps/discord/bot.service.ts +++ b/src/main/apps/discord/bot.service.ts @@ -450,7 +450,7 @@ export class DiscordBotService { } // Get response from Agent with Discord-specific tools - const response = await agentService.processMessage(fullMessage, 'discord', imageUrls, undefined, { + const response = await agentService.processMessage(fullMessage, 'discord', imageUrls, undefined, undefined, { source: 'message', userId: originalMessage.author.id }) diff --git a/src/main/apps/line/bot.service.ts b/src/main/apps/line/bot.service.ts index 43de87d..925b3f5 100644 --- a/src/main/apps/line/bot.service.ts +++ b/src/main/apps/line/bot.service.ts @@ -180,7 +180,7 @@ export class LineBotService { return } - const response = await agentService.processMessage(userMessage, 'line', [], undefined, { + const response = await agentService.processMessage(userMessage, 'line', [], undefined, undefined, { source: 'message', userId }) diff --git a/src/main/apps/local/local.service.ts b/src/main/apps/local/local.service.ts index b0dffa1..fe85890 100644 --- a/src/main/apps/local/local.service.ts +++ b/src/main/apps/local/local.service.ts @@ -84,7 +84,7 @@ export class LocalChatService { return { success: true, data: userMessage } } - const response = await agentService.processMessage(trimmed, 'local', [], sessionId, { + const response = await agentService.processMessage(trimmed, 'local', [], sessionId, undefined, { source: 'message', isAuthorizedUser: true, userId: 'local-user' diff --git a/src/main/apps/slack/bot.service.ts b/src/main/apps/slack/bot.service.ts index bfecbe1..fff0d3f 100644 --- a/src/main/apps/slack/bot.service.ts +++ b/src/main/apps/slack/bot.service.ts @@ -508,7 +508,7 @@ export class SlackBotService { return } - const response = await agentService.processMessage(fullMessage, 'slack', imageUrls, undefined, { + const response = await agentService.processMessage(fullMessage, 'slack', imageUrls, undefined, undefined, { source: 'message', userId }) diff --git a/src/main/apps/whatsapp/bot.service.ts b/src/main/apps/whatsapp/bot.service.ts index c9d1eed..0529008 100644 --- a/src/main/apps/whatsapp/bot.service.ts +++ b/src/main/apps/whatsapp/bot.service.ts @@ -167,7 +167,7 @@ export class WhatsAppBotService { return } - const response = await agentService.processMessage(userMessage, 'whatsapp', [], undefined, { + const response = await agentService.processMessage(userMessage, 'whatsapp', [], undefined, undefined, { source: 'message', userId }) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b0eb696..e0655a0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -15,6 +15,7 @@ import { setupLLMHandlers } from './llm.handlers' import { registerSkillsHandlers } from './skills.handlers' import { setupServiceHandlers } from './service.handlers' import { setupUpdaterHandlers } from './updater.handlers' +import { setupMemoryHandlers } from './memory.handlers' import { guardFileBoundary } from '../utils/file-boundary' import type { IpcResponse, FileInfo } from '../types' @@ -38,6 +39,7 @@ export async function setupIpcHandlers(): Promise { registerSkillsHandlers() setupServiceHandlers() setupUpdaterHandlers() + setupMemoryHandlers() } /** @@ -49,7 +51,7 @@ function setupAgentHandlers(): void { 'agent:send-message', async (_event, message: string): Promise> => { try { - const response = await agentService.processMessage(message, 'none', [], undefined, { + const response = await agentService.processMessage(message, 'none', [], undefined, undefined, { source: 'system' }) diff --git a/src/main/ipc/memory.handlers.ts b/src/main/ipc/memory.handlers.ts new file mode 100644 index 0000000..8496144 --- /dev/null +++ b/src/main/ipc/memory.handlers.ts @@ -0,0 +1,343 @@ +import { ipcMain } from 'electron' +import type { IpcResponse } from '../types' +import { localMemoryControlService, type DoNotRememberInput } from '../services/local-memory-control.service' +import type { + CreateLocalMemoryInput, + ExplainedLocalMemoryItem, + LocalMemoryItem, + LocalMemoryListFilters, + MemoryEventRecord, + MemoryProvenance, + MemoryRetrievalResult, + MemorySearchFilters, + UpdateLocalMemoryInput, + MemorySensitivityLevel, + MemoryStatus, + MemoryUserControl, + MemoryConflictState, +} from '../services/memory/local-memory.store' + +const MEMORY_SENSITIVITY_LEVELS: readonly MemorySensitivityLevel[] = ['normal', 'work', 'sensitive'] +const MEMORY_STATUSES: readonly MemoryStatus[] = ['active', 'archived', 'deleted'] +const MEMORY_USER_CONTROLS: readonly MemoryUserControl[] = ['auto', 'remember', 'dont_remember', 'modified', 'deleted', 'paused'] +const MEMORY_CONFLICT_STATES: readonly MemoryConflictState[] = ['none', 'potential', 'confirmed'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function assertNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${fieldName} must be a non-empty string`) + } + return value.trim() +} + +function optionalNullableString(value: unknown, fieldName: string): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value !== 'string') { + throw new Error(`${fieldName} must be a string or null`) + } + return value +} + +function optionalNumber(value: unknown, fieldName: string): number | undefined { + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${fieldName} must be a finite number`) + } + return value +} + +function optionalEnum( + value: unknown, + allowed: readonly T[], + fieldName: string +): T | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new Error(`${fieldName} must be one of: ${allowed.join(', ')}`) + } + return value as T +} + +function sanitizeCreateMemoryInput(input: unknown): CreateLocalMemoryInput { + if (!isRecord(input)) throw new Error('memory input must be an object') + + return { + id: optionalNullableString(input.id, 'id') ?? undefined, + content: assertNonEmptyString(input.content, 'content'), + memory_type: assertNonEmptyString(input.memory_type, 'memory_type'), + source_platform: optionalNullableString(input.source_platform, 'source_platform'), + source_excerpt: optionalNullableString(input.source_excerpt, 'source_excerpt'), + confidence: optionalNumber(input.confidence, 'confidence'), + importance: optionalNumber(input.importance, 'importance'), + sensitivity_level: optionalEnum(input.sensitivity_level, MEMORY_SENSITIVITY_LEVELS, 'sensitivity_level'), + retention_until: input.retention_until === null ? null : optionalNumber(input.retention_until, 'retention_until'), + status: optionalEnum(input.status, MEMORY_STATUSES, 'status'), + user_control: optionalEnum(input.user_control, MEMORY_USER_CONTROLS, 'user_control'), + why_stored: optionalNullableString(input.why_stored, 'why_stored'), + conflict_state: optionalEnum(input.conflict_state, MEMORY_CONFLICT_STATES, 'conflict_state'), + conflict_notes: optionalNullableString(input.conflict_notes, 'conflict_notes'), + } +} + +function sanitizeDoNotRememberInput(input: unknown): DoNotRememberInput { + if (!isRecord(input)) throw new Error('do-not-remember input must be an object') + + return { + content: assertNonEmptyString(input.content, 'content'), + memory_type: typeof input.memory_type === 'string' ? input.memory_type : undefined, + source_platform: optionalNullableString(input.source_platform, 'source_platform'), + source_excerpt: optionalNullableString(input.source_excerpt, 'source_excerpt'), + sensitivity_level: optionalEnum(input.sensitivity_level, MEMORY_SENSITIVITY_LEVELS, 'sensitivity_level'), + reason: optionalNullableString(input.reason, 'reason'), + } +} + +function sanitizeUpdateMemoryInput(input: unknown): UpdateLocalMemoryInput { + if (!isRecord(input)) throw new Error('memory update input must be an object') + + return { + content: typeof input.content === 'string' ? input.content : undefined, + memory_type: typeof input.memory_type === 'string' ? input.memory_type : undefined, + source_platform: optionalNullableString(input.source_platform, 'source_platform'), + source_excerpt: optionalNullableString(input.source_excerpt, 'source_excerpt'), + confidence: optionalNumber(input.confidence, 'confidence'), + importance: optionalNumber(input.importance, 'importance'), + sensitivity_level: optionalEnum(input.sensitivity_level, MEMORY_SENSITIVITY_LEVELS, 'sensitivity_level'), + retention_until: input.retention_until === null ? null : optionalNumber(input.retention_until, 'retention_until'), + status: optionalEnum(input.status, MEMORY_STATUSES, 'status'), + user_control: optionalEnum(input.user_control, MEMORY_USER_CONTROLS, 'user_control'), + why_stored: optionalNullableString(input.why_stored, 'why_stored'), + conflict_state: optionalEnum(input.conflict_state, MEMORY_CONFLICT_STATES, 'conflict_state'), + conflict_notes: optionalNullableString(input.conflict_notes, 'conflict_notes'), + } +} + +function sanitizeSearchFilters(input: unknown): MemorySearchFilters { + if (input === undefined) return {} + if (!isRecord(input)) throw new Error('memory search filters must be an object') + + return { + query: typeof input.query === 'string' ? input.query : undefined, + limit: optionalNumber(input.limit, 'limit'), + include_archived: typeof input.include_archived === 'boolean' ? input.include_archived : undefined, + created_after: optionalNumber(input.created_after, 'created_after'), + created_before: optionalNumber(input.created_before, 'created_before'), + updated_after: optionalNumber(input.updated_after, 'updated_after'), + updated_before: optionalNumber(input.updated_before, 'updated_before'), + min_confidence: optionalNumber(input.min_confidence, 'min_confidence'), + min_importance: optionalNumber(input.min_importance, 'min_importance'), + exclude_sensitive: typeof input.exclude_sensitive === 'boolean' ? input.exclude_sensitive : undefined, + status: optionalEnum(input.status, MEMORY_STATUSES, 'status'), + memory_type: typeof input.memory_type === 'string' ? input.memory_type : undefined, + source_platform: typeof input.source_platform === 'string' ? input.source_platform : undefined, + sensitivity_level: optionalEnum(input.sensitivity_level, MEMORY_SENSITIVITY_LEVELS, 'sensitivity_level'), + user_control: optionalEnum(input.user_control, MEMORY_USER_CONTROLS, 'user_control'), + conflict_state: optionalEnum(input.conflict_state, MEMORY_CONFLICT_STATES, 'conflict_state'), + } +} + +function sanitizeListFilters(input: unknown): LocalMemoryListFilters { + if (input === undefined) return {} + if (!isRecord(input)) throw new Error('memory list filters must be an object') + + return { + status: optionalEnum(input.status, MEMORY_STATUSES, 'status'), + memory_type: typeof input.memory_type === 'string' ? input.memory_type : undefined, + source_platform: typeof input.source_platform === 'string' ? input.source_platform : undefined, + sensitivity_level: optionalEnum(input.sensitivity_level, MEMORY_SENSITIVITY_LEVELS, 'sensitivity_level'), + user_control: optionalEnum(input.user_control, MEMORY_USER_CONTROLS, 'user_control'), + created_after: optionalNumber(input.created_after, 'created_after'), + created_before: optionalNumber(input.created_before, 'created_before'), + updated_after: optionalNumber(input.updated_after, 'updated_after'), + updated_before: optionalNumber(input.updated_before, 'updated_before'), + min_confidence: optionalNumber(input.min_confidence, 'min_confidence'), + min_importance: optionalNumber(input.min_importance, 'min_importance'), + conflict_state: optionalEnum(input.conflict_state, MEMORY_CONFLICT_STATES, 'conflict_state'), + } +} + +export function setupMemoryHandlers(): void { + ipcMain.handle('memory:get-status', async (): Promise> => { + try { + const status = await localMemoryControlService.getCaptureStatus() + return { success: true, data: status } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + ipcMain.handle( + 'memory:remember-this', + async (_event, input: CreateLocalMemoryInput): Promise> => { + try { + const memory = await localMemoryControlService.rememberThis(sanitizeCreateMemoryInput(input)) + return { success: true, data: memory } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:do-not-remember-this', + async (_event, input: DoNotRememberInput): Promise> => { + try { + const memory = await localMemoryControlService.doNotRememberThis(sanitizeDoNotRememberInput(input)) + return { success: true, data: memory } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle('memory:get', async (_event, id: string): Promise> => { + try { + const memory = await localMemoryControlService.getMemoryById(assertNonEmptyString(id, 'id')) + return { success: true, data: memory } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + + ipcMain.handle( + 'memory:get-explained', + async (_event, id: string): Promise> => { + try { + const memory = await localMemoryControlService.getExplainedMemoryById(assertNonEmptyString(id, 'id')) + return { success: true, data: memory } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:get-provenance', + async (_event, id: string): Promise> => { + try { + const provenance = await localMemoryControlService.getMemoryProvenance(assertNonEmptyString(id, 'id')) + return { success: true, data: provenance } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:list', + async (_event, filters?: LocalMemoryListFilters): Promise> => { + try { + const memories = await localMemoryControlService.listMemories(sanitizeListFilters(filters)) + return { success: true, data: memories } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + + ipcMain.handle( + 'memory:list-explained', + async (_event, filters?: LocalMemoryListFilters): Promise> => { + try { + const memories = await localMemoryControlService.listExplainedMemories(sanitizeListFilters(filters)) + return { success: true, data: memories } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:search', + async (_event, filters?: MemorySearchFilters): Promise> => { + try { + const results = await localMemoryControlService.searchMemories(sanitizeSearchFilters(filters)) + return { success: true, data: results } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:update', + async ( + _event, + id: string, + updates: UpdateLocalMemoryInput + ): Promise> => { + try { + const memory = await localMemoryControlService.updateMemory(assertNonEmptyString(id, 'id'), sanitizeUpdateMemoryInput(updates)) + return { success: true, data: memory } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle('memory:delete', async (_event, id: string): Promise> => { + try { + const deleted = await localMemoryControlService.deleteMemory(assertNonEmptyString(id, 'id')) + return { success: true, data: { deleted } } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + + ipcMain.handle( + 'memory:delete-by-source', + async (_event, sourcePlatform: string): Promise> => { + try { + const deletedCount = await localMemoryControlService.deleteMemoriesBySource(assertNonEmptyString(sourcePlatform, 'sourcePlatform')) + return { success: true, data: { deletedCount } } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:list-events', + async (_event, memoryId: string): Promise> => { + try { + const events = await localMemoryControlService.listMemoryEvents(assertNonEmptyString(memoryId, 'memoryId')) + return { success: true, data: events } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:pause-capture', + async (_event, reason?: string): Promise> => { + try { + const status = await localMemoryControlService.pauseCapture(reason) + return { success: true, data: status } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + ipcMain.handle( + 'memory:resume-capture', + async (_event, reason?: string): Promise> => { + try { + const status = await localMemoryControlService.resumeCapture(reason) + return { success: true, data: status } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + ) + + console.log('[Memory IPC] Handlers registered') +} diff --git a/src/main/ipc/settings.handlers.ts b/src/main/ipc/settings.handlers.ts index fed90e5..c8b4aba 100644 --- a/src/main/ipc/settings.handlers.ts +++ b/src/main/ipc/settings.handlers.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import { loadSettings, saveSettings, type AppSettings } from '../config/settings.config' import { mcpService } from '../services/mcp.service' -import { secureStorage } from '../services/secure-storage.service' +import { secureStorage, createMcpEnvKey, MCP_ENV_PREFIX } from '../services/secure-storage.service' import { detectCustomProtocol } from '../services/agent/utils' import type { IpcResponse } from '../types' @@ -90,8 +90,8 @@ async function loadUserMcpConfig(): Promise { if (serverConfig.env) { const decryptedEnv: Record = {} for (const [envKey, envValue] of Object.entries(serverConfig.env)) { - const storageKey = secureStorage.createMcpEnvKey(serverName, envKey) - const decrypted = secureStorage.get(storageKey) + const storageKey = createMcpEnvKey(serverName, envKey) + const decrypted = await secureStorage.get(storageKey) if (decrypted !== null) { decryptedEnv[envKey] = decrypted } else { @@ -128,9 +128,9 @@ async function saveMcpConfig(config: McpServerConfig): Promise { if (rest.env && Object.keys(rest.env).length > 0) { const envPlaceholders: Record = {} for (const [envKey, envValue] of Object.entries(rest.env)) { - const storageKey = secureStorage.createMcpEnvKey(name, envKey) + const storageKey = createMcpEnvKey(name, envKey) // Store actual value in secureStorage - secureStorage.set(storageKey, envValue) + await secureStorage.set(storageKey, envValue) allSecureEnvKeys.add(storageKey) // Store placeholder in config file envPlaceholders[envKey] = `[SECURE:${storageKey}]` @@ -153,16 +153,16 @@ async function cleanupSecureMcpEnvKeys(currentConfig: McpServerConfig): Promise< for (const [name, serverConfig] of Object.entries(currentConfig)) { if (serverConfig.env) { for (const envKey of Object.keys(serverConfig.env)) { - currentKeys.add(secureStorage.createMcpEnvKey(name, envKey)) + currentKeys.add(createMcpEnvKey(name, envKey)) } } } // Get all MCP env keys from secureStorage - const allSecureStorage = secureStorage.getAll() + const allSecureStorage = await secureStorage.getAll() for (const key of Object.keys(allSecureStorage)) { - if (key.startsWith('mcp:env:') && !currentKeys.has(key)) { - secureStorage.delete(key) + if (key.startsWith(MCP_ENV_PREFIX) && !currentKeys.has(key)) { + await secureStorage.delete(key) console.log(`[Settings] Cleaned up orphaned MCP env key: ${key}`) } } diff --git a/src/main/services/__tests__/secure-storage.test.ts b/src/main/services/__tests__/secure-storage.test.ts index f3d496d..3bc6b35 100644 --- a/src/main/services/__tests__/secure-storage.test.ts +++ b/src/main/services/__tests__/secure-storage.test.ts @@ -29,14 +29,19 @@ vi.mock('electron', () => ({ } })) -vi.mock('fs/promises', () => ({ - default: { - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - rm: vi.fn() +vi.mock('fs/promises', () => { + const readFile = vi.fn() + const writeFile = vi.fn() + const mkdir = vi.fn() + const rm = vi.fn() + return { + readFile, + writeFile, + mkdir, + rm, + default: { readFile, writeFile, mkdir, rm } } -})) +}) vi.mock('path', () => ({ join: vi.fn((...args: string[]) => args.join('/')) @@ -45,6 +50,7 @@ vi.mock('path', () => ({ // Import after mocks import { secureStorage, SENSITIVE_FIELDS, isSensitiveField, MCP_ENV_PREFIX, createMcpEnvKey, parseMcpEnvKey } from '../secure-storage.service' import * as fs from 'fs/promises' +import { safeStorage } from 'electron' describe('SecureStorageService', () => { beforeEach(async () => { @@ -145,7 +151,6 @@ describe('SecureStorageService', () => { describe('isEncryptionAvailable', () => { it('should check if encryption is available', () => { - const { safeStorage } = require('electron') secureStorage.isEncryptionAvailable() expect(safeStorage.isEncryptionAvailable).toHaveBeenCalled() }) diff --git a/src/main/services/agent/__tests__/gemini-adapter.test.ts b/src/main/services/agent/__tests__/gemini-adapter.test.ts index a7a37d5..7d421b8 100644 --- a/src/main/services/agent/__tests__/gemini-adapter.test.ts +++ b/src/main/services/agent/__tests__/gemini-adapter.test.ts @@ -56,7 +56,7 @@ describe('convertToolsToGemini', () => { ]; const result = convertToolsToGemini(tools); - const params = result[0].functionDeclarations![0].parameters as Record; + const params = result[0].functionDeclarations![0].parameters as unknown as Record; expect(params).not.toHaveProperty('$schema'); expect(params).not.toHaveProperty('additionalProperties'); expect(params).not.toHaveProperty('type'); diff --git a/src/main/services/agent/context/layered/__tests__/temporary-topic.test.ts b/src/main/services/agent/context/layered/__tests__/temporary-topic.test.ts new file mode 100644 index 0000000..595da34 --- /dev/null +++ b/src/main/services/agent/context/layered/__tests__/temporary-topic.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + createHeuristicTopicScorer, + decideTemporaryTopicTransition +} from '../temporary-topic' + +describe('createHeuristicTopicScorer', () => { + it('scores topic overlap without requiring an external API key', async () => { + const scorer = createHeuristicTopicScorer() + + const result = await scorer( + 'Show the project roadmap and client deadline', + 'We discussed the project roadmap and the client deadline yesterday', + 'Dinner plans and movie night' + ) + + expect(result.relMain).toBeGreaterThan(result.relTemp) + expect(result.relMain).toBeGreaterThan(0.5) + }) +}) + +describe('decideTemporaryTopicTransition with heuristic scorer', () => { + it('enters a temporary topic when the query no longer matches the main thread', async () => { + const scorer = createHeuristicTopicScorer() + + const transition = await decideTemporaryTopicTransition({ + mode: 'MAIN', + query: 'What movie should we watch tonight?', + mainTopicReference: 'Project roadmap, sprint goals, engineering deadline' + }, scorer) + + expect(transition.decision).toBe('enter-temp') + }) + + it('exits a temporary topic when the query returns to the main thread', async () => { + const scorer = createHeuristicTopicScorer() + + const transition = await decideTemporaryTopicTransition({ + mode: 'TEMP', + query: 'Back to the sprint roadmap and engineering deadline', + mainTopicReference: 'Project roadmap, sprint goals, engineering deadline', + tempTopicReference: 'Movie night, dinner reservation, popcorn flavors' + }, scorer) + + expect(transition.decision).toBe('exit-temp') + }) +}) diff --git a/src/main/services/agent/context/layered/temporary-topic.ts b/src/main/services/agent/context/layered/temporary-topic.ts index 6d69b37..bc88c95 100644 --- a/src/main/services/agent/context/layered/temporary-topic.ts +++ b/src/main/services/agent/context/layered/temporary-topic.ts @@ -55,6 +55,36 @@ export type TopicScorer = ( tempTopicReference: string ) => Promise +function buildTokenSet(value: string): Set { + return new Set( + normalizeWhitespace(value) + .toLowerCase() + .split(/[^a-z0-9]+/i) + .map((token) => token.trim()) + .filter((token) => token.length >= 3) + ) +} + +function estimateTopicOverlap(query: string, topicReference: string): number { + const queryTokens = buildTokenSet(query) + const topicTokens = buildTokenSet(topicReference) + if (queryTokens.size === 0 || topicTokens.size === 0) return 0 + + let overlap = 0 + for (const token of queryTokens) { + if (topicTokens.has(token)) overlap += 1 + } + + return clampScore(overlap / queryTokens.size) +} + +export function createHeuristicTopicScorer(): TopicScorer { + return async (query, mainTopicReference, tempTopicReference) => ({ + relMain: estimateTopicOverlap(query, mainTopicReference), + relTemp: estimateTopicOverlap(query, tempTopicReference) + }) +} + // ============================================ // LLM Topic Scorer // ============================================ @@ -207,6 +237,10 @@ function classificationToScores(decision: TemporaryTopicDecision): TopicRelevanc } export function createLLMTopicClassifier(options: LLMTopicScorerOptions): TopicScorer { + if (!options.apiKey.trim()) { + return createHeuristicTopicScorer() + } + const client = new Anthropic({ apiKey: options.apiKey }) diff --git a/src/main/services/local-memory-control.service.ts b/src/main/services/local-memory-control.service.ts new file mode 100644 index 0000000..0e58376 --- /dev/null +++ b/src/main/services/local-memory-control.service.ts @@ -0,0 +1,179 @@ +import { LocalControlledMemoryProvider } from './memory/local-controlled-memory.provider' +import { memorizationStorage } from './memorization.storage' +import type { + CreateLocalMemoryInput, + ExplainedLocalMemoryItem, + LocalMemoryItem, + LocalMemoryListFilters, + MemoryEventRecord, + MemoryProvenance, + MemoryRetrievalResult, + MemorySearchFilters, + UpdateLocalMemoryInput, + MemorySensitivityLevel, +} from './memory/local-memory.store' + +export interface DoNotRememberInput { + content: string + memory_type?: string + source_platform?: string | null + source_excerpt?: string | null + sensitivity_level?: MemorySensitivityLevel + reason?: string | null +} + +class LocalMemoryControlService { + private readonly provider = new LocalControlledMemoryProvider() + + async initialize(): Promise { + await this.provider.isConfigured() + } + + async getCaptureStatus(): Promise<{ paused: boolean }> { + await this.initialize() + return this.provider.getCaptureStatus() + } + + async rememberThis(input: CreateLocalMemoryInput): Promise { + await this.initialize() + return this.provider.createMemory({ + ...input, + status: input.status ?? 'active', + user_control: 'remember', + why_stored: input.why_stored ?? 'User explicitly chose to remember this', + }) + } + + async doNotRememberThis(input: DoNotRememberInput): Promise { + await this.initialize() + + const existingMatches = await this.provider.searchMemories({ + query: input.content, + limit: 10, + include_archived: true, + source_platform: input.source_platform ?? undefined, + exclude_sensitive: false, + }) + + let existingSuppressionRule: LocalMemoryItem | null = null + + for (const match of existingMatches) { + if (match.memory.content !== input.content) { + continue + } + + if (match.memory.user_control === 'dont_remember') { + existingSuppressionRule = match.memory + continue + } + + await this.provider.updateMemory(match.memory.id, { + status: 'archived', + user_control: 'dont_remember', + why_stored: input.reason ?? 'Archived because the user explicitly chose not to remember this', + }) + } + + if (existingSuppressionRule) { + return ( + (await this.provider.updateMemory(existingSuppressionRule.id, { + memory_type: input.memory_type ?? existingSuppressionRule.memory_type ?? 'suppression_rule', + source_platform: input.source_platform ?? existingSuppressionRule.source_platform ?? null, + source_excerpt: input.source_excerpt ?? input.content.slice(0, 240), + sensitivity_level: input.sensitivity_level ?? existingSuppressionRule.sensitivity_level ?? 'normal', + status: 'archived', + user_control: 'dont_remember', + why_stored: input.reason ?? 'User explicitly chose not to remember this', + retention_until: null, + confidence: 1, + importance: 0, + })) ?? existingSuppressionRule + ) + } + + return this.provider.createMemory({ + content: input.content, + memory_type: input.memory_type ?? 'suppression_rule', + source_platform: input.source_platform ?? null, + source_excerpt: input.source_excerpt ?? input.content.slice(0, 240), + confidence: 1, + importance: 0, + sensitivity_level: input.sensitivity_level ?? 'normal', + retention_until: null, + status: 'archived', + user_control: 'dont_remember', + why_stored: input.reason ?? 'User explicitly chose not to remember this', + }) + } + + async getMemoryById(id: string): Promise { + await this.initialize() + return this.provider.getMemoryById(id) + } + + + async getExplainedMemoryById(id: string): Promise { + await this.initialize() + return this.provider.getExplainedMemoryById(id) + } + + async getMemoryProvenance(id: string): Promise { + await this.initialize() + return this.provider.getMemoryProvenance(id) + } + + async searchMemories(filters: MemorySearchFilters = {}): Promise { + await this.initialize() + return this.provider.searchMemories(filters) + } + + async listExplainedMemories(filters: LocalMemoryListFilters = {}): Promise { + await this.initialize() + return this.provider.listExplainedMemories(filters) + } + + async listMemories(filters: LocalMemoryListFilters = {}): Promise { + await this.initialize() + return this.provider.listMemories(filters) + } + + async updateMemory(id: string, updates: UpdateLocalMemoryInput): Promise { + await this.initialize() + return this.provider.updateMemory(id, { + ...updates, + user_control: updates.user_control ?? 'modified', + }) + } + + async deleteMemory(id: string): Promise { + await this.initialize() + return this.provider.deleteMemory(id) + } + + async deleteMemoriesBySource(sourcePlatform: string): Promise { + await this.initialize() + return this.provider.deleteMemoriesBySource(sourcePlatform) + } + + async listMemoryEvents(memoryId: string): Promise { + await this.initialize() + return this.provider.listMemoryEvents(memoryId) + } + + async pauseCapture(reason?: string): Promise<{ paused: boolean }> { + await this.initialize() + const result = await this.provider.pauseCapture(reason) + await memorizationStorage.initialize() + await memorizationStorage.clearMessages() + await memorizationStorage.clearTaskState() + await memorizationStorage.updateFirstMessageTimestamp() + return result + } + + async resumeCapture(reason?: string): Promise<{ paused: boolean }> { + await this.initialize() + return this.provider.resumeCapture(reason) + } +} + +export const localMemoryControlService = new LocalMemoryControlService() diff --git a/src/main/services/memorization.service.ts b/src/main/services/memorization.service.ts index ddf7184..02a3251 100644 --- a/src/main/services/memorization.service.ts +++ b/src/main/services/memorization.service.ts @@ -1,13 +1,13 @@ -import { loadSettings } from '../config/settings.config' import { infraService, type IncomingMessageEvent, type OutgoingMessageEvent, } from './infra.service' -import { - memorizationStorage, - type StoredUnmemorizedMessage, -} from './memorization.storage' +import { memorizationStorage, type StoredUnmemorizedMessage } from './memorization.storage' +import { loadSettings } from '../config/settings.config' +import type { MemoryProvider } from './memory/memory-provider' +import { RemoteMemuProvider } from './memory/remote-memu.provider' +import { LocalControlledMemoryProvider } from './memory/local-controlled-memory.provider' const CHAT_MEMORIZE_MESSAGE_THRESHOLD = 20 const CHAT_MEMORIZE_TIME_THRESHOLD_MS = 60 * 60 * 1000 // 60 minutes @@ -15,58 +15,42 @@ const CHAT_MEMORIZE_TIME_THRESHOLD_MS = 60 * 60 * 1000 // 60 minutes const MEMORIZE_MIN_MESSAGE_COUNT = 2 const MEMORIZE_MAX_MESSAGE_COUNT = 200 -const MEMORIZE_STATUS_POLL_INTERVAL_MS = 10_000 -const MEMORIZE_MAX_WAIT_MS = 5 * 60 * 1000 - -type MemorizeStatus = 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' - -interface MemorizeStatusResponse { - task_id: string - status: MemorizeStatus - detail_info: string -} - class MemorizationService { private unsubscribers: (() => void)[] = [] private isMemorizing = false private debounceTimer: ReturnType | null = null + private readonly remoteMemoryProvider = new RemoteMemuProvider() + private readonly localMemoryProvider = new LocalControlledMemoryProvider() - // ==================== Config ==================== - - private async getMemuConfig() { + private async getMemoryProvider(): Promise { const settings = await loadSettings() - return { - baseUrl: settings.memuBaseUrl, - apiKey: settings.memuApiKey, - userId: settings.memuUserId, - agentId: settings.memuAgentId, - } + const hasRemoteConfiguration = !!( + settings.memuApiKey && settings.memuApiKey.trim() && + settings.memuBaseUrl && settings.memuBaseUrl.trim() && + settings.memuUserId && settings.memuUserId.trim() && + settings.memuAgentId && settings.memuAgentId.trim() + ) + return hasRemoteConfiguration ? this.remoteMemoryProvider : this.localMemoryProvider } // ==================== Lifecycle ==================== - private async isApiKeyConfigured(): Promise { - const settings = await loadSettings() - return !!(settings.memuApiKey && settings.memuApiKey.trim()) - } - async start(): Promise { await memorizationStorage.initialize() - const hasKey = await this.isApiKeyConfigured() - if (!hasKey) { - console.log('[Memorization] memuApiKey not configured, messages will be queued locally until key is set') + const provider = await this.getMemoryProvider() + const isRemote = provider.kind === 'remote-memu' + + if (!isRemote) { + console.log('[Memorization] Remote memU not configured, using local controlled memory provider') } - // Recover from previous run (only if API key is available) - if (hasKey) { - await this.recoverPendingTask() + if (isRemote && (await provider.isConfigured())) { + await this.recoverPendingTask(provider) } - // Check if memorization conditions are already met from persisted messages await this.checkAndTrigger() - // Subscribe to both incoming and outgoing messages this.unsubscribers.push( infraService.subscribe('message:incoming', (event) => { this.handleMessage(event, 'incoming') @@ -89,14 +73,24 @@ class MemorizationService { console.log('[Memorization] Service stopped') } - // ==================== Message handling ==================== - private handleMessage( event: IncomingMessageEvent | OutgoingMessageEvent, _direction: 'incoming' | 'outgoing' ): void { + void this.handleMessageAsync(event) + } + + private async handleMessageAsync(event: IncomingMessageEvent | OutgoingMessageEvent): Promise { if (event.platform === 'none') return + const provider = await this.getMemoryProvider() + if (provider.kind === 'local-controlled-memory') { + const localStatus = await this.localMemoryProvider.getCaptureStatus() + if (localStatus.paused) { + return + } + } + const content = typeof event.message.content === 'string' ? event.message.content @@ -109,20 +103,18 @@ class MemorizationService { timestamp: event.timestamp, } - memorizationStorage.appendMessage(stored).then(() => { - console.log( - `[Memorization] Queued message from ${event.platform} (queue size: ~${stored.timestamp})` - ) - this.checkAndTrigger() - }) + await memorizationStorage.appendMessage(stored) + console.log( + `[Memorization] Queued message from ${event.platform} (queue size: ~${stored.timestamp})` + ) + await this.checkAndTrigger() } - // ==================== Trigger logic ==================== - private async checkAndTrigger(): Promise { - // If a task is in-flight, do a single status check instead of blocking + const provider = await this.getMemoryProvider() + if (this.isMemorizing) { - await this.checkActiveTask() + await this.checkActiveTask(provider) if (this.isMemorizing) return } @@ -138,7 +130,6 @@ class MemorizationService { return } - // Count below threshold — use debounce timer if (count >= MEMORIZE_MIN_MESSAGE_COUNT) { this.resetDebounceTimer() } @@ -160,70 +151,35 @@ class MemorizationService { } } - // ==================== Task status helpers ==================== - - /** - * Fetch the status of a memorization task and handle storage cleanup - * on terminal states (SUCCESS / FAILURE). - * - * Returns: - * - 'success' — task completed, queued messages removed from storage - * - 'failure' — task failed, task state cleared - * - 'pending' — task still PENDING or PROCESSING - * - 'error' — network / parse error (nothing changed) - */ private async resolveTaskStatus( + provider: MemoryProvider, taskId: string, messageCount: number ): Promise<'success' | 'failure' | 'pending' | 'error'> { try { - const memuConfig = await this.getMemuConfig() - const response = await fetch( - `${memuConfig.baseUrl}/api/v3/memory/memorize/status/${taskId}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${memuConfig.apiKey}`, - 'Content-Type': 'application/json', - }, - } - ) - - if (!response.ok) { - console.error(`[Memorization] Status check failed for ${taskId}: ${response.status}`) - return 'error' - } - - const result = (await response.json()) as MemorizeStatusResponse - console.log(`[Memorization] Task ${taskId} status: ${result.status}`) - - if (result.status === 'SUCCESS') { - await memorizationStorage.removeFirstN(messageCount) - await memorizationStorage.clearTaskState() - await memorizationStorage.updateFirstMessageTimestamp() - return 'success' - } - - if (result.status === 'FAILURE') { - console.error(`[Memorization] Task ${taskId} failed: ${result.detail_info}`) - await memorizationStorage.clearTaskState() - return 'failure' - } - - return 'pending' + const result = await provider.checkTaskStatus(taskId, messageCount) + return result.status } catch (error) { console.error(`[Memorization] Error checking task ${taskId}:`, error) return 'error' } } - /** - * Single non-blocking status check for the current in-flight task. - * Called lazily from checkAndTrigger so we only hit the server when a - * new message arrives or the debounce timer fires. - */ - private async checkActiveTask(): Promise { - if (!(await this.isApiKeyConfigured())) return + private async finalizeResolvedTask( + outcome: 'success' | 'failure' | 'error', + messageCount: number + ): Promise { + if (outcome === 'success' && messageCount > 0) { + await memorizationStorage.removeFirstN(messageCount) + await memorizationStorage.updateFirstMessageTimestamp() + } + + await memorizationStorage.clearTaskState() + this.isMemorizing = false + } + + private async checkActiveTask(provider: MemoryProvider): Promise { + if (!(await provider.isConfigured())) return const state = await memorizationStorage.getState() if (!state.lastTaskId) { @@ -232,18 +188,16 @@ class MemorizationService { } const outcome = await this.resolveTaskStatus( + provider, state.lastTaskId, state.messagesToRemoveOnSuccess ) - if (outcome === 'success' || outcome === 'failure') { - this.isMemorizing = false + if (outcome === 'success' || outcome === 'failure' || outcome === 'error') { + await this.finalizeResolvedTask(outcome, state.messagesToRemoveOnSuccess) } - // 'pending' / 'error' — keep isMemorizing true, will retry on next call } - // ==================== Memorization execution ==================== - private triggerMemorization(): void { if (this.isMemorizing) return this.isMemorizing = true @@ -255,73 +209,63 @@ class MemorizationService { private async runMemorization(): Promise { try { - if (!(await this.isApiKeyConfigured())) { - console.log('[Memorization] memuApiKey not configured, skipping memorize POST (messages remain queued)') + const provider = await this.getMemoryProvider() + if (!(await provider.isConfigured())) { + console.log('[Memorization] No available memory provider configured') this.isMemorizing = false return } - const memuConfig = await this.getMemuConfig() const allMessages = await memorizationStorage.getMessages() if (allMessages.length < MEMORIZE_MIN_MESSAGE_COUNT) { - console.log('[Memorization] Not enough messages to memorize, skipping memorize POST (messages remain queued)') + console.log('[Memorization] Not enough messages to memorize') this.isMemorizing = false return } const messages = allMessages.slice(0, MEMORIZE_MAX_MESSAGE_COUNT) - const messageCount = messages.length - - const formattedMessages = messages.map((m) => ({ - role: m.role, - content: `[${m.platform}] ${m.content}`, - })) - - console.log( - `[Memorization] Sending ${formattedMessages.length} messages to memorize` - ) - - const response = await fetch( - `${memuConfig.baseUrl}/api/v3/memory/memorize`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${memuConfig.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - user_id: memuConfig.userId, - agent_id: memuConfig.agentId, - conversation: formattedMessages, - }), + let taskId: string | null = null + let messageCount = 0 + + try { + const result = await provider.startMemorization(messages) + taskId = result.taskId + messageCount = result.messageCount + } catch (error) { + if (provider.kind === 'remote-memu') { + console.warn('[Memorization] Remote memorization crashed before task creation; falling back to local controlled memory') + const fallbackResult = await this.localMemoryProvider.startMemorization(messages) + if (fallbackResult.messageCount > 0) { + await memorizationStorage.removeFirstN(fallbackResult.messageCount) + await memorizationStorage.updateFirstMessageTimestamp() + } + await memorizationStorage.clearTaskState() + this.isMemorizing = false + return } - ) - if (!response.ok) { - console.error( - '[Memorization] API returned status:', - response.status - ) - this.isMemorizing = false - return + throw error } - const result = (await response.json()) as { task_id?: string } - const taskId = result.task_id - if (!taskId) { - console.error('[Memorization] No task_id returned') + if (provider.kind === 'local-controlled-memory' && messageCount > 0) { + await memorizationStorage.removeFirstN(messageCount) + await memorizationStorage.clearTaskState() + await memorizationStorage.updateFirstMessageTimestamp() + } else if (provider.kind === 'remote-memu') { + console.warn('[Memorization] Remote memorization did not start; falling back to local controlled memory') + const fallbackResult = await this.localMemoryProvider.startMemorization(messages) + if (fallbackResult.messageCount > 0) { + await memorizationStorage.removeFirstN(fallbackResult.messageCount) + await memorizationStorage.updateFirstMessageTimestamp() + } + await memorizationStorage.clearTaskState() + } this.isMemorizing = false return } - console.log(`[Memorization] Task started: ${taskId}`) - - // Persist so we can recover if the process restarts. - // Status will be checked lazily in checkActiveTask on the next - // checkAndTrigger call (i.e. when a new message arrives or the - // debounce timer fires). await memorizationStorage.setState({ lastTaskId: taskId, messagesToRemoveOnSuccess: messageCount, @@ -332,40 +276,8 @@ class MemorizationService { } } - private async monitorTask( - taskId: string, - messageCount: number - ): Promise { - const startTime = Date.now() - - while (Date.now() - startTime < MEMORIZE_MAX_WAIT_MS) { - const outcome = await this.resolveTaskStatus(taskId, messageCount) - - if (outcome === 'success') { - console.log('[Memorization] Memorization succeeded') - this.isMemorizing = false - await this.checkAndTrigger() - return - } - - if (outcome === 'failure') { - this.isMemorizing = false - return - } - - // 'pending' or 'error' — wait and retry - await this.sleep(MEMORIZE_STATUS_POLL_INTERVAL_MS) - } - - console.error(`[Memorization] Task ${taskId} timed out`) - await memorizationStorage.clearTaskState() - this.isMemorizing = false - } - - // ==================== Recovery ==================== - - private async recoverPendingTask(): Promise { - if (!(await this.isApiKeyConfigured())) return + private async recoverPendingTask(provider: MemoryProvider): Promise { + if (!(await provider.isConfigured())) return const state = await memorizationStorage.getState() if (!state.lastTaskId) return @@ -377,31 +289,18 @@ class MemorizationService { this.isMemorizing = true const outcome = await this.resolveTaskStatus( + provider, state.lastTaskId, state.messagesToRemoveOnSuccess ) - if (outcome === 'success' || outcome === 'failure') { - this.isMemorizing = false + if (outcome === 'success' || outcome === 'failure' || outcome === 'error') { + await this.finalizeResolvedTask(outcome, state.messagesToRemoveOnSuccess) return } - if (outcome === 'error') { - await memorizationStorage.clearTaskState() - this.isMemorizing = false - return - } - - // 'pending' — leave isMemorizing true; - // checkActiveTask will resolve it lazily on the next checkAndTrigger call console.log('[Memorization] Recovered task still pending, will check again lazily') } - - // ==================== Helpers ==================== - - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) - } } export const memorizationService = new MemorizationService() diff --git a/src/main/services/memorization.storage.ts b/src/main/services/memorization.storage.ts index ee56500..483879f 100644 --- a/src/main/services/memorization.storage.ts +++ b/src/main/services/memorization.storage.ts @@ -98,6 +98,13 @@ class MemorizationStorage { console.log(`[MemorizationStorage] Removed first ${count} messages, ${this.messages.length} remaining`) } + + async clearMessages(): Promise { + await this.ensureInitialized() + this.messages = [] + await this.saveMessages() + } + // ==================== State ==================== private async loadState(): Promise { diff --git a/src/main/services/memory/local-controlled-memory.provider.ts b/src/main/services/memory/local-controlled-memory.provider.ts new file mode 100644 index 0000000..aefc492 --- /dev/null +++ b/src/main/services/memory/local-controlled-memory.provider.ts @@ -0,0 +1,235 @@ +import * as fs from 'fs/promises' +import * as path from 'path' +import { app } from 'electron' +import type { StoredUnmemorizedMessage } from '../memorization.storage' +import { + localMemoryStore, + type CreateLocalMemoryInput, + type ExplainedLocalMemoryItem, + type LocalMemoryItem, + type LocalMemoryListFilters, + type MemoryEventRecord, + type MemoryProvenance, + type MemoryRetrievalResult, + type MemorySearchFilters, + type UpdateLocalMemoryInput, +} from './local-memory.store' +import type { MemoryProvider, MemoryProviderResult } from './memory-provider' + +const STORAGE_DIR = 'memorization-data' +const PROVIDER_STATE_FILE = 'local-memory-provider-state.json' + +interface LocalMemoryProviderState { + capturePaused: boolean + updatedAt: number + reason?: string | null +} + +const DEFAULT_PROVIDER_STATE: LocalMemoryProviderState = { + capturePaused: false, + updatedAt: 0, + reason: null, +} + +export class LocalControlledMemoryProvider implements MemoryProvider { + readonly kind = 'local-controlled-memory' + private statePath = path.join(app.getPath('userData'), STORAGE_DIR, PROVIDER_STATE_FILE) + private state: LocalMemoryProviderState = { ...DEFAULT_PROVIDER_STATE } + private stateLoaded = false + + async isConfigured(): Promise { + await localMemoryStore.initialize() + await this.ensureStateLoaded() + return true + } + + async startMemorization(messages: StoredUnmemorizedMessage[]): Promise<{ + taskId: string | null + messageCount: number + }> { + await localMemoryStore.initialize() + await this.ensureStateLoaded() + + if (this.state.capturePaused) { + return { + taskId: null, + messageCount: 0, + } + } + + let storedCount = 0 + + for (const message of messages) { + if (await localMemoryStore.hasSuppressedMatch(message.content, message.platform)) { + continue + } + + const inferredSensitivity = this.inferAutomaticSensitivity(message.content, message.platform) + if (inferredSensitivity === 'sensitive') { + continue + } + + const existing = await localMemoryStore.searchMemories({ + query: message.content, + limit: 1, + source_platform: message.platform, + memory_type: 'conversation_message', + include_archived: true, + exclude_sensitive: false, + }) + + const best = existing[0] + const looksDuplicated = best && best.memory.content === message.content && best.retrieval_explanation.score >= 4.5 + if (looksDuplicated) { + continue + } + + await this.createMemory({ + content: message.content, + memory_type: 'conversation_message', + source_platform: message.platform, + source_excerpt: message.content.slice(0, 240), + confidence: inferredSensitivity === 'work' ? 0.6 : 0.5, + importance: inferredSensitivity === 'work' ? 0.65 : 0.5, + sensitivity_level: inferredSensitivity, + status: 'active', + user_control: 'auto', + why_stored: 'Captured from message stream by LocalControlledMemoryProvider', + }) + storedCount += 1 + } + + return { + taskId: null, + messageCount: storedCount, + } + } + + + private inferAutomaticSensitivity(content: string, sourcePlatform?: string | null): 'normal' | 'work' | 'sensitive' { + const text = `${sourcePlatform ?? ''} ${content}`.toLowerCase() + if (/password|passcode|otp|verification code|ssn|social security|bank account|credit card|api key|secret|token|passport|diagnosis|medical/.test(text)) { + return 'sensitive' + } + if (/slack|meeting|project|roadmap|deadline|client|customer|jira|notion|github|repo|work/.test(text)) { + return 'work' + } + return 'normal' + } + + async checkTaskStatus(_taskId: string, _messageCount: number): Promise { + return { status: 'success' } + } + + async createMemory(input: CreateLocalMemoryInput): Promise { + await localMemoryStore.initialize() + return localMemoryStore.createMemory(input) + } + + async getMemoryById(id: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.getMemoryById(id) + } + + + async getExplainedMemoryById(id: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.getExplainedMemoryById(id) + } + + async getMemoryProvenance(id: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.getProvenanceById(id) + } + + async searchMemories(filters: MemorySearchFilters = {}): Promise { + await localMemoryStore.initialize() + return localMemoryStore.searchMemories(filters) + } + + async listExplainedMemories(filters: LocalMemoryListFilters = {}): Promise { + await localMemoryStore.initialize() + return localMemoryStore.listExplainedMemories(filters) + } + + async listMemories(filters: LocalMemoryListFilters = {}): Promise { + await localMemoryStore.initialize() + return localMemoryStore.listMemories(filters) + } + + async updateMemory(id: string, updates: UpdateLocalMemoryInput): Promise { + await localMemoryStore.initialize() + return localMemoryStore.updateMemory(id, updates) + } + + async deleteMemory(id: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.deleteMemory(id) + } + + async deleteMemoriesBySource(sourcePlatform: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.deleteMemoriesBySource(sourcePlatform) + } + + async listMemoryEvents(memoryId: string): Promise { + await localMemoryStore.initialize() + return localMemoryStore.listEvents(memoryId) + } + + async getCaptureStatus(): Promise<{ paused: boolean }> { + await this.ensureStateLoaded() + return { paused: this.state.capturePaused } + } + + async pauseCapture(reason?: string): Promise<{ paused: boolean }> { + await this.ensureStateLoaded() + if (!this.state.capturePaused) { + this.state = { + capturePaused: true, + updatedAt: Date.now(), + reason: reason ?? null, + } + await this.saveState() + } + return { paused: true } + } + + async resumeCapture(reason?: string): Promise<{ paused: boolean }> { + await this.ensureStateLoaded() + if (this.state.capturePaused) { + this.state = { + capturePaused: false, + updatedAt: Date.now(), + reason: reason ?? null, + } + await this.saveState() + } + return { paused: false } + } + + private async ensureStateLoaded(): Promise { + if (this.stateLoaded) return + + try { + await fs.mkdir(path.dirname(this.statePath), { recursive: true }) + const raw = await fs.readFile(this.statePath, 'utf-8') + const parsed = JSON.parse(raw) as Partial + this.state = { + capturePaused: parsed.capturePaused ?? DEFAULT_PROVIDER_STATE.capturePaused, + updatedAt: parsed.updatedAt ?? DEFAULT_PROVIDER_STATE.updatedAt, + reason: parsed.reason ?? DEFAULT_PROVIDER_STATE.reason, + } + } catch { + this.state = { ...DEFAULT_PROVIDER_STATE } + await this.saveState() + } + + this.stateLoaded = true + } + + private async saveState(): Promise { + await fs.mkdir(path.dirname(this.statePath), { recursive: true }) + await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8') + } +} diff --git a/src/main/services/memory/local-memory.store.ts b/src/main/services/memory/local-memory.store.ts new file mode 100644 index 0000000..b575f66 --- /dev/null +++ b/src/main/services/memory/local-memory.store.ts @@ -0,0 +1,1015 @@ +import * as fs from 'fs/promises' +import * as path from 'path' +import { app } from 'electron' + +const STORAGE_DIR = 'memorization-data' +const DB_FILE = 'local-controlled-memory.sqlite' +const TIME_DECAY_HALF_LIFE_DAYS = 30 + +const { DatabaseSync } = require('node:sqlite') as { + DatabaseSync: new (path: string) => { + exec(sql: string): void + prepare(sql: string): { + run(params?: Record | unknown[]): { changes?: number; lastInsertRowid?: number | bigint } + get(params?: Record | unknown[]): T | undefined + all(params?: Record | unknown[]): T[] + } + close(): void + } +} + +export type MemoryStatus = 'active' | 'archived' | 'deleted' +export type MemoryUserControl = 'auto' | 'remember' | 'dont_remember' | 'modified' | 'deleted' | 'paused' +export type MemorySensitivityLevel = 'normal' | 'work' | 'sensitive' +export type MemoryConflictState = 'none' | 'potential' | 'confirmed' + +export interface LocalMemoryItem { + id: string + content: string + memory_type: string + source_platform: string | null + source_excerpt: string | null + created_at: number + updated_at: number + confidence: number + importance: number + sensitivity_level: MemorySensitivityLevel + retention_until: number | null + status: MemoryStatus + user_control: MemoryUserControl + why_stored: string | null + conflict_state: MemoryConflictState + conflict_notes: string | null +} + +export interface MemoryProvenance { + source_platform: string | null + source_excerpt: string | null + created_at: number + updated_at: number + retention_until: number | null + why_stored: string | null + sensitivity_level: MemorySensitivityLevel + conflict_state: MemoryConflictState + conflict_notes: string | null +} + +export interface MemoryExplanation { + provenance: MemoryProvenance +} + +export interface ExplainedLocalMemoryItem extends LocalMemoryItem { + explanation: MemoryExplanation +} + +export interface MemoryMatchField { + field: 'content' | 'memory_type' | 'source_platform' | 'source_excerpt' | 'why_stored' | 'status' | 'user_control' | 'sensitivity_level' | 'conflict_notes' + value_excerpt: string + matched_terms: string[] + score_contribution: number + reason: string +} + +export interface MemoryRetrievalExplanation { + query: string + matched_fields: MemoryMatchField[] + source_excerpt: string | null + created_at: number + updated_at: number + retention_until: number | null + score: number + score_reasons: string[] +} + +export interface MemoryRetrievalResult { + memory: ExplainedLocalMemoryItem + retrieval_explanation: MemoryRetrievalExplanation +} + +export interface MemorySearchFilters extends LocalMemoryListFilters { + query?: string + limit?: number + include_archived?: boolean + created_after?: number + created_before?: number + updated_after?: number + updated_before?: number + min_confidence?: number + min_importance?: number + exclude_sensitive?: boolean +} + +export interface CreateLocalMemoryInput { + id?: string + content: string + memory_type: string + source_platform?: string | null + source_excerpt?: string | null + confidence?: number + importance?: number + sensitivity_level?: MemorySensitivityLevel + retention_until?: number | null + status?: MemoryStatus + user_control?: MemoryUserControl + why_stored?: string | null + conflict_state?: MemoryConflictState + conflict_notes?: string | null +} + +export interface UpdateLocalMemoryInput { + content?: string + memory_type?: string + source_platform?: string | null + source_excerpt?: string | null + confidence?: number + importance?: number + sensitivity_level?: MemorySensitivityLevel + retention_until?: number | null + status?: MemoryStatus + user_control?: MemoryUserControl + why_stored?: string | null + conflict_state?: MemoryConflictState + conflict_notes?: string | null +} + +export interface MemoryEventRecord { + id: number + memory_id: string + event_type: string + actor: string + previous_status: string | null + new_status: string | null + reason: string | null + payload_json: string | null + created_at: number +} + +export interface CreateMemoryEventInput { + memory_id: string + event_type: string + actor?: string + previous_status?: string | null + new_status?: string | null + reason?: string | null + payload_json?: string | null + created_at?: number +} + +export interface LocalMemoryListFilters { + status?: MemoryStatus + memory_type?: string + source_platform?: string + sensitivity_level?: MemorySensitivityLevel + user_control?: MemoryUserControl + created_after?: number + created_before?: number + updated_after?: number + updated_before?: number + min_confidence?: number + min_importance?: number + conflict_state?: MemoryConflictState +} + +interface ParsedFactSignature { + subject: string + relation: string + value: string +} + +function generateMemoryId(): string { + return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` +} + +function clampScore(value: number | undefined, fallback: number): number { + const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback + return Math.max(0, Math.min(1, numeric)) +} + +function hasOwnProperty(value: T, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function excerptText(value: string | null | undefined, maxLength = 180): string { + if (!value) return '' + return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value +} + +function tokenizeQuery(query: string): string[] { + const normalized = query.trim().toLowerCase() + if (!normalized) return [] + return Array.from(new Set(normalized.split(/\s+/).map((term) => term.trim()).filter((term) => term.length > 0))) +} + +function countMatchedTerms(value: string, terms: string[]): string[] { + const haystack = value.toLowerCase() + return terms.filter((term) => haystack.includes(term)) +} + +function buildValueExcerpt(value: string, matchedTerms: string[]): string { + if (!value) return '' + if (matchedTerms.length === 0) return excerptText(value) + + const lower = value.toLowerCase() + const positions = matchedTerms + .map((term) => lower.indexOf(term)) + .filter((pos) => pos >= 0) + .sort((a, b) => a - b) + + if (positions.length === 0) return excerptText(value) + + const start = Math.max(0, positions[0] - 40) + const end = Math.min(value.length, positions[0] + 140) + const snippet = value.slice(start, end) + + return `${start > 0 ? '…' : ''}${snippet}${end < value.length ? '…' : ''}` +} + +function normalizeComparableText(value: string): string { + return value.toLowerCase().replace(/\s+/g, ' ').trim() +} + +function tokenizeComparableText(value: string): string[] { + return normalizeComparableText(value) + .split(/[^a-z0-9]+/i) + .map((token) => token.trim()) + .filter((token) => token.length >= 3) +} + +function computeJaccardSimilarity(left: string, right: string): number { + const leftTokens = new Set(tokenizeComparableText(left)) + const rightTokens = new Set(tokenizeComparableText(right)) + if (leftTokens.size === 0 || rightTokens.size === 0) return 0 + + let intersection = 0 + for (const token of leftTokens) { + if (rightTokens.has(token)) intersection += 1 + } + + const union = new Set([...leftTokens, ...rightTokens]).size + return union === 0 ? 0 : intersection / union +} + +function parseFactSignature(content: string): ParsedFactSignature | null { + const normalized = content.trim().replace(/\s+/g, ' ') + const match = normalized.match(/^(.+?)\s+(is|are|was|were|lives in|located in|works at|prefers|likes|dislikes|email is|phone is)\s+(.+)$/i) + if (!match) return null + + const [, subjectRaw, relationRaw, valueRaw] = match + const subject = normalizeComparableText(subjectRaw) + const relation = normalizeComparableText(relationRaw) + const value = normalizeComparableText(valueRaw) + + if (!subject || !relation || !value) return null + return { subject, relation, value } +} + +function inferSensitivityLevel(content: string, sourcePlatform: string | null | undefined, explicit?: MemorySensitivityLevel): MemorySensitivityLevel { + if (explicit) return explicit + const text = `${sourcePlatform ?? ''} ${content}`.toLowerCase() + const sensitivePatterns = [ + /password|passcode|otp|verification code|ssn|social security|bank account|routing number|credit card|debit card/, + /medical|diagnosis|prescription|patient|insurance id|passport|driver'?s license/, + /private key|secret|token|api key|auth code/ + ] + if (sensitivePatterns.some((pattern) => pattern.test(text))) return 'sensitive' + + const workPatterns = [/slack|meeting|project|roadmap|deadline|client|customer|jira|notion|github|repo|work/] + if (workPatterns.some((pattern) => pattern.test(text))) return 'work' + + return 'normal' +} + +function defaultRetentionUntil(sensitivity: MemorySensitivityLevel, explicit?: number | null): number | null { + if (explicit !== undefined) return explicit + const now = Date.now() + switch (sensitivity) { + case 'sensitive': + return now + 30 * 24 * 60 * 60 * 1000 + case 'work': + return now + 180 * 24 * 60 * 60 * 1000 + default: + return null + } +} + +function buildProvenance(memory: LocalMemoryItem): MemoryProvenance { + return { + source_platform: memory.source_platform, + source_excerpt: memory.source_excerpt, + created_at: memory.created_at, + updated_at: memory.updated_at, + retention_until: memory.retention_until, + why_stored: memory.why_stored, + sensitivity_level: memory.sensitivity_level, + conflict_state: memory.conflict_state, + conflict_notes: memory.conflict_notes, + } +} + +function attachExplanation(memory: LocalMemoryItem): ExplainedLocalMemoryItem { + return { + ...memory, + explanation: { + provenance: buildProvenance(memory), + }, + } +} + +function computeTimeDecayMultiplier(updatedAt: number, now: number): number { + const ageMs = Math.max(0, now - updatedAt) + const ageDays = ageMs / (24 * 60 * 60 * 1000) + return Math.pow(0.5, ageDays / TIME_DECAY_HALF_LIFE_DAYS) +} + +function scoreMemoryMatch(memory: LocalMemoryItem, query: string, now = Date.now()): MemoryRetrievalResult | null { + const terms = tokenizeQuery(query) + if (terms.length === 0) return null + + const fieldWeights: Array<{ + field: MemoryMatchField['field'] + value: string + weight: number + reason: string + }> = [ + { field: 'content', value: memory.content, weight: 5, reason: 'Matched memory content' }, + { field: 'source_excerpt', value: memory.source_excerpt ?? '', weight: 4, reason: 'Matched source excerpt' }, + { field: 'why_stored', value: memory.why_stored ?? '', weight: 3, reason: 'Matched why this memory was stored' }, + { field: 'memory_type', value: memory.memory_type, weight: 2, reason: 'Matched memory type' }, + { field: 'source_platform', value: memory.source_platform ?? '', weight: 1.5, reason: 'Matched source platform' }, + { field: 'status', value: memory.status, weight: 1, reason: 'Matched status' }, + { field: 'user_control', value: memory.user_control, weight: 1, reason: 'Matched user control state' }, + { field: 'sensitivity_level', value: memory.sensitivity_level, weight: 1, reason: 'Matched sensitivity level' }, + { field: 'conflict_notes', value: memory.conflict_notes ?? '', weight: 0.75, reason: 'Matched conflict notes' }, + ] + + const matched_fields: MemoryMatchField[] = [] + let rawScore = 0 + const score_reasons: string[] = [] + + for (const field of fieldWeights) { + const matchedTerms = countMatchedTerms(field.value, terms) + if (matchedTerms.length === 0) continue + + const contribution = matchedTerms.length * field.weight + rawScore += contribution + matched_fields.push({ + field: field.field, + value_excerpt: buildValueExcerpt(field.value, matchedTerms), + matched_terms: matchedTerms, + score_contribution: contribution, + reason: field.reason, + }) + score_reasons.push(`${field.reason}: ${matchedTerms.join(', ')}`) + } + + if (matched_fields.length === 0) return null + + const confidenceBonus = Math.round(memory.confidence * 100) / 100 + const importanceBonus = Math.round(memory.importance * 100) / 100 + rawScore += confidenceBonus + importanceBonus + score_reasons.push(`Confidence bonus: ${confidenceBonus.toFixed(2)}`) + score_reasons.push(`Importance bonus: ${importanceBonus.toFixed(2)}`) + + const decayMultiplier = computeTimeDecayMultiplier(memory.updated_at, now) + rawScore *= decayMultiplier + score_reasons.push(`Time decay multiplier: ${decayMultiplier.toFixed(3)}`) + + if (memory.user_control === 'remember') { + rawScore += 1 + score_reasons.push('User explicitly marked this to remember') + } + if (memory.user_control === 'dont_remember') { + rawScore -= 3 + score_reasons.push('User explicitly marked this as do not remember') + } + if (memory.status === 'archived') { + rawScore -= 1 + score_reasons.push('Archived memories are de-prioritized') + } + if (memory.conflict_state === 'potential') { + rawScore -= 0.75 + score_reasons.push('Potentially conflicting memory is de-prioritized') + } + if (memory.conflict_state === 'confirmed') { + rawScore -= 1.5 + score_reasons.push('Confirmed conflicting memory is strongly de-prioritized') + } + + const score = Math.max(0, Number(rawScore.toFixed(3))) + + return { + memory: attachExplanation(memory), + retrieval_explanation: { + query, + matched_fields, + source_excerpt: memory.source_excerpt, + created_at: memory.created_at, + updated_at: memory.updated_at, + retention_until: memory.retention_until, + score, + score_reasons, + }, + } +} + +export class LocalMemoryStore { + private dbPath: string + private db: InstanceType | null = null + private initialized = false + + constructor() { + this.dbPath = path.join(app.getPath('userData'), STORAGE_DIR, DB_FILE) + } + + async initialize(): Promise { + if (this.initialized) return + + await fs.mkdir(path.dirname(this.dbPath), { recursive: true }) + + this.db = new DatabaseSync(this.dbPath) + this.db.exec('PRAGMA journal_mode = WAL;') + this.db.exec('PRAGMA foreign_keys = ON;') + + this.db.exec(` + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + memory_type TEXT NOT NULL, + source_platform TEXT, + source_excerpt TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + confidence REAL NOT NULL, + importance REAL NOT NULL, + sensitivity_level TEXT NOT NULL, + retention_until INTEGER, + status TEXT NOT NULL, + user_control TEXT NOT NULL, + why_stored TEXT, + conflict_state TEXT NOT NULL DEFAULT 'none', + conflict_notes TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status); + CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type); + CREATE INDEX IF NOT EXISTS idx_memories_platform ON memories(source_platform); + CREATE INDEX IF NOT EXISTS idx_memories_updated_at ON memories(updated_at); + + CREATE TABLE IF NOT EXISTS memory_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id TEXT NOT NULL, + event_type TEXT NOT NULL, + actor TEXT NOT NULL, + previous_status TEXT, + new_status TEXT, + reason TEXT, + payload_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (memory_id) REFERENCES memories(id) + ); + + CREATE INDEX IF NOT EXISTS idx_memory_events_memory_id ON memory_events(memory_id); + CREATE INDEX IF NOT EXISTS idx_memory_events_created_at ON memory_events(created_at); + `) + + this.ensureColumn('memories', 'conflict_state', `ALTER TABLE memories ADD COLUMN conflict_state TEXT NOT NULL DEFAULT 'none'`) + this.ensureColumn('memories', 'conflict_notes', 'ALTER TABLE memories ADD COLUMN conflict_notes TEXT') + + this.initialized = true + console.log('[LocalMemoryStore] Initialized') + } + + private ensureColumn(tableName: string, columnName: string, alterSql: string): void { + const db = this.getDatabase() + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }> + if (!columns.some((column) => column.name === columnName)) { + db.exec(alterSql) + } + } + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.initialize() + } + } + + private getDatabase() { + if (!this.db) { + throw new Error('[LocalMemoryStore] Database is not initialized') + } + return this.db + } + + async createMemory(input: CreateLocalMemoryInput): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + const now = Date.now() + const sensitivity = inferSensitivityLevel(input.content, input.source_platform, input.sensitivity_level) + + const record: LocalMemoryItem = { + id: input.id ?? generateMemoryId(), + content: input.content, + memory_type: input.memory_type, + source_platform: input.source_platform ?? null, + source_excerpt: input.source_excerpt ?? null, + created_at: now, + updated_at: now, + confidence: clampScore(input.confidence, 0.5), + importance: clampScore(input.importance, 0.5), + sensitivity_level: sensitivity, + retention_until: defaultRetentionUntil(sensitivity, input.retention_until), + status: input.status ?? 'active', + user_control: input.user_control ?? 'auto', + why_stored: input.why_stored ?? null, + conflict_state: input.conflict_state ?? 'none', + conflict_notes: input.conflict_notes ?? null, + } + + db.prepare(` + INSERT INTO memories ( + id, content, memory_type, source_platform, source_excerpt, created_at, updated_at, + confidence, importance, sensitivity_level, retention_until, status, user_control, why_stored, + conflict_state, conflict_notes + ) VALUES ( + @id, @content, @memory_type, @source_platform, @source_excerpt, @created_at, @updated_at, + @confidence, @importance, @sensitivity_level, @retention_until, @status, @user_control, @why_stored, + @conflict_state, @conflict_notes + ) + `).run(record as unknown as Record) + + await this.createEvent({ + memory_id: record.id, + event_type: 'created', + actor: 'system', + previous_status: null, + new_status: record.status, + reason: 'Local memory record created', + payload_json: JSON.stringify({ + memory_type: record.memory_type, + source_platform: record.source_platform, + user_control: record.user_control, + sensitivity_level: record.sensitivity_level, + }), + created_at: now, + }) + + await this.recalculateConflictsForMemory(record.id) + return (await this.getMemoryById(record.id)) ?? record + } + + async getMemoryById(id: string): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + const row = db.prepare('SELECT * FROM memories WHERE id = ?').get([id]) + return row ?? null + } + + async getExplainedMemoryById(id: string): Promise { + const memory = await this.getMemoryById(id) + return memory ? attachExplanation(memory) : null + } + + async listMemories(filters: LocalMemoryListFilters = {}): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + + const clauses: string[] = ['(retention_until IS NULL OR retention_until >= @now)'] + const params: Record = { now: Date.now() } + + if (filters.status) { + clauses.push('status = @status') + params.status = filters.status + } else { + clauses.push('status != "deleted"') + } + if (filters.memory_type) { + clauses.push('memory_type = @memory_type') + params.memory_type = filters.memory_type + } + if (filters.source_platform) { + clauses.push('source_platform = @source_platform') + params.source_platform = filters.source_platform + } + if (filters.sensitivity_level) { + clauses.push('sensitivity_level = @sensitivity_level') + params.sensitivity_level = filters.sensitivity_level + } + if (filters.user_control) { + clauses.push('user_control = @user_control') + params.user_control = filters.user_control + } + if (filters.created_after != null) { + clauses.push('created_at >= @created_after') + params.created_after = filters.created_after + } + if (filters.created_before != null) { + clauses.push('created_at <= @created_before') + params.created_before = filters.created_before + } + if (filters.updated_after != null) { + clauses.push('updated_at >= @updated_after') + params.updated_after = filters.updated_after + } + if (filters.updated_before != null) { + clauses.push('updated_at <= @updated_before') + params.updated_before = filters.updated_before + } + if (filters.min_confidence != null) { + clauses.push('confidence >= @min_confidence') + params.min_confidence = clampScore(filters.min_confidence, 0) + } + if (filters.min_importance != null) { + clauses.push('importance >= @min_importance') + params.min_importance = clampScore(filters.min_importance, 0) + } + if (filters.conflict_state) { + clauses.push('conflict_state = @conflict_state') + params.conflict_state = filters.conflict_state + } + + const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + + return db.prepare(`SELECT * FROM memories ${whereClause} ORDER BY updated_at DESC, created_at DESC`).all(params) + } + + async listExplainedMemories(filters: LocalMemoryListFilters = {}): Promise { + const memories = await this.listMemories(filters) + return memories.map(attachExplanation) + } + + async searchMemories(filters: MemorySearchFilters = {}): Promise { + const { query = '', limit = 20, include_archived = false, exclude_sensitive = false, ...listFilters } = filters + const baseFilters: LocalMemoryListFilters = { ...listFilters } + if (!include_archived && !baseFilters.status) { + baseFilters.status = 'active' + } + + let memories = await this.listMemories(baseFilters) + if (exclude_sensitive) { + memories = memories.filter((memory) => memory.sensitivity_level !== 'sensitive') + } + + const now = Date.now() + const scored = memories + .map((memory) => scoreMemoryMatch(memory, query, now)) + .filter((result): result is MemoryRetrievalResult => result !== null) + .sort((a, b) => b.retrieval_explanation.score - a.retrieval_explanation.score) + + return scored.slice(0, Math.max(1, limit)) + } + + async updateMemory(id: string, updates: UpdateLocalMemoryInput): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + const existing = await this.getMemoryById(id) + + if (!existing) { + return null + } + + const nextSensitivity = inferSensitivityLevel( + updates.content ?? existing.content, + updates.source_platform ?? existing.source_platform, + updates.sensitivity_level ?? existing.sensitivity_level + ) + const requestedRetentionUntil = hasOwnProperty(updates, 'retention_until') + ? updates.retention_until ?? null + : existing.retention_until + const next: LocalMemoryItem = { + ...existing, + ...updates, + confidence: updates.confidence != null ? clampScore(updates.confidence, existing.confidence) : existing.confidence, + importance: updates.importance != null ? clampScore(updates.importance, existing.importance) : existing.importance, + sensitivity_level: nextSensitivity, + retention_until: defaultRetentionUntil(nextSensitivity, requestedRetentionUntil), + conflict_state: updates.conflict_state ?? existing.conflict_state, + conflict_notes: updates.conflict_notes ?? existing.conflict_notes, + updated_at: Date.now(), + } + + db.prepare(` + UPDATE memories + SET + content = @content, + memory_type = @memory_type, + source_platform = @source_platform, + source_excerpt = @source_excerpt, + updated_at = @updated_at, + confidence = @confidence, + importance = @importance, + sensitivity_level = @sensitivity_level, + retention_until = @retention_until, + status = @status, + user_control = @user_control, + why_stored = @why_stored, + conflict_state = @conflict_state, + conflict_notes = @conflict_notes + WHERE id = @id + `).run(next as unknown as Record) + + await this.createEvent({ + memory_id: id, + event_type: 'updated', + actor: 'system', + previous_status: existing.status, + new_status: next.status, + reason: 'Local memory record updated', + payload_json: JSON.stringify({ updates }), + }) + + await this.recalculateConflictsForMemory(id) + return (await this.getMemoryById(id)) ?? next + } + + async deleteMemory(id: string): Promise { + await this.ensureInitialized() + const existing = await this.getMemoryById(id) + + if (!existing) { + return false + } + + const updated = await this.updateMemory(id, { + status: 'deleted', + user_control: 'deleted', + why_stored: existing.why_stored, + }) + + await this.createEvent({ + memory_id: id, + event_type: 'deleted', + actor: 'system', + previous_status: existing.status, + new_status: 'deleted', + reason: 'Local memory record soft-deleted', + payload_json: JSON.stringify({ id }), + }) + + return !!updated + } + + async deleteMemoriesBySource(sourcePlatform: string): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + const existing = db.prepare(` + SELECT * FROM memories + WHERE source_platform = @source_platform AND status != 'deleted' + ORDER BY updated_at DESC + `).all({ source_platform: sourcePlatform }) as LocalMemoryItem[] + + for (const memory of existing) { + await this.updateMemory(memory.id, { + status: 'deleted', + user_control: 'deleted', + }) + await this.createEvent({ + memory_id: memory.id, + event_type: 'deleted_by_source', + actor: 'system', + previous_status: memory.status, + new_status: 'deleted', + reason: `Deleted memories by source platform: ${sourcePlatform}`, + payload_json: JSON.stringify({ source_platform: sourcePlatform, id: memory.id }), + }) + } + + return existing.length + } + + async hasSuppressedMatch(content: string, sourcePlatform?: string | null): Promise { + await this.ensureInitialized() + const normalized = normalizeComparableText(content) + const candidates = await this.listMemories({ user_control: 'dont_remember' }) + + return candidates.some((memory) => { + if (sourcePlatform && memory.source_platform && memory.source_platform !== sourcePlatform) { + return false + } + const candidate = normalizeComparableText(memory.content) + return candidate === normalized || candidate.includes(normalized) || normalized.includes(candidate) + }) + } + + private async recalculateConflictsForMemory(targetId: string): Promise { + const target = await this.getMemoryById(targetId) + if (!target) return + + const impactedPeerIds = new Set(this.extractConflictPeerIds(target.conflict_notes)) + await this.clearConflictState(target.id) + + if (target.status === 'deleted' || target.user_control === 'dont_remember') { + for (const peerId of impactedPeerIds) { + await this.rebuildConflictState(peerId) + } + return + } + if (target.memory_type === 'conversation_message' || target.memory_type === 'suppression_rule') { + for (const peerId of impactedPeerIds) { + await this.rebuildConflictState(peerId) + } + return + } + + const peers = await this.listMemories({ memory_type: target.memory_type }) + const targetFact = parseFactSignature(target.content) + + for (const peer of peers) { + if (peer.id === target.id || peer.status === 'deleted') continue + if (peer.source_platform && target.source_platform && peer.source_platform !== target.source_platform) continue + + let isConflict = false + let note = '' + + const peerFact = parseFactSignature(peer.content) + if (targetFact && peerFact && targetFact.subject === peerFact.subject && targetFact.relation === peerFact.relation && targetFact.value !== peerFact.value) { + isConflict = true + note = `Conflicts with ${peer.id}: same subject and relation, different value` + } else { + const similarity = computeJaccardSimilarity(target.content, peer.content) + const sameNormalized = normalizeComparableText(target.content) === normalizeComparableText(peer.content) + if (!sameNormalized && similarity >= 0.35 && similarity < 0.95) { + isConflict = true + note = `Potentially conflicts with ${peer.id}: overlap similarity ${similarity.toFixed(2)}` + } + } + + impactedPeerIds.add(peer.id) + if (!isConflict) { + continue + } + + const targetConflictState: MemoryConflictState = note.startsWith('Conflicts with') ? 'confirmed' : 'potential' + const targetConfidence = clampScore(target.confidence - (targetConflictState === 'confirmed' ? 0.2 : 0.1), target.confidence) + + await this.applyConflictUpdate(target.id, targetConflictState, note, targetConfidence) + } + + for (const peerId of impactedPeerIds) { + await this.rebuildConflictState(peerId) + } + } + + private async rebuildConflictState(id: string): Promise { + const memory = await this.getMemoryById(id) + if (!memory || memory.status === 'deleted' || memory.user_control === 'dont_remember') return + if (memory.memory_type === 'conversation_message' || memory.memory_type === 'suppression_rule') return + + await this.clearConflictState(memory.id) + + const peers = await this.listMemories({ memory_type: memory.memory_type }) + for (const peer of peers) { + if (peer.id === memory.id || peer.status === 'deleted') continue + if (peer.source_platform && memory.source_platform && peer.source_platform !== memory.source_platform) continue + + let isConflict = false + let note = '' + const memoryFact = parseFactSignature(memory.content) + const peerFact = parseFactSignature(peer.content) + if (memoryFact && peerFact && memoryFact.subject === peerFact.subject && memoryFact.relation === peerFact.relation && memoryFact.value !== peerFact.value) { + isConflict = true + note = `Conflicts with ${peer.id}: same subject and relation, different value` + } else { + const similarity = computeJaccardSimilarity(memory.content, peer.content) + const sameNormalized = normalizeComparableText(memory.content) === normalizeComparableText(peer.content) + if (!sameNormalized && similarity >= 0.35 && similarity < 0.95) { + isConflict = true + note = `Potentially conflicts with ${peer.id}: overlap similarity ${similarity.toFixed(2)}` + } + } + + if (!isConflict) continue + + const conflictState: MemoryConflictState = note.startsWith('Conflicts with') ? 'confirmed' : 'potential' + const nextConfidence = clampScore(memory.confidence - (conflictState === 'confirmed' ? 0.2 : 0.1), memory.confidence) + await this.applyConflictUpdate(memory.id, conflictState, note, nextConfidence) + } + } + + private extractConflictPeerIds(notes?: string | null): string[] { + if (!notes) return [] + + const ids = new Set() + const patterns = [ + /Conflicts with ([^:\s;]+)/g, + /Potentially conflicts with ([^:\s;]+)/g, + /Potential conflict with ([^:\s;]+)/g, + ] + + for (const pattern of patterns) { + for (const match of notes.matchAll(pattern)) { + const peerId = match[1]?.trim() + if (peerId) { + ids.add(peerId) + } + } + } + + return Array.from(ids) + } + + private async clearConflictState(id: string): Promise { + const memory = await this.getMemoryById(id) + if (!memory || (memory.conflict_state === 'none' && !memory.conflict_notes)) return + + const db = this.getDatabase() + db.prepare(` + UPDATE memories + SET conflict_state = 'none', conflict_notes = NULL, updated_at = @updated_at + WHERE id = @id + `).run({ + id, + updated_at: Date.now(), + }) + } + + private async applyConflictUpdate(id: string, conflictState: MemoryConflictState, note: string, confidence: number): Promise { + const memory = await this.getMemoryById(id) + if (!memory || memory.status === 'deleted') return + const existingNotes = memory.conflict_notes + ? memory.conflict_notes.split(';').map((item) => item.trim()).filter(Boolean) + : [] + const dedupedNotes = Array.from(new Set([...existingNotes, note.trim()])) + const nextNotes = dedupedNotes.join('; ') + const nextState: MemoryConflictState = memory.conflict_state === 'confirmed' || conflictState === 'confirmed' + ? 'confirmed' + : conflictState + + const db = this.getDatabase() + db.prepare(` + UPDATE memories + SET conflict_state = @conflict_state, conflict_notes = @conflict_notes, confidence = @confidence, updated_at = @updated_at + WHERE id = @id + `).run({ + id, + conflict_state: nextState, + conflict_notes: excerptText(nextNotes, 500), + confidence, + updated_at: Date.now(), + }) + + await this.createEvent({ + memory_id: id, + event_type: 'conflict_detected', + actor: 'system', + previous_status: memory.status, + new_status: memory.status, + reason: note, + payload_json: JSON.stringify({ conflict_state: nextState, confidence }), + }) + } + + async createEvent(input: CreateMemoryEventInput): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + const now = input.created_at ?? Date.now() + + const result = db.prepare(` + INSERT INTO memory_events ( + memory_id, event_type, actor, previous_status, new_status, reason, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run([ + input.memory_id, + input.event_type, + input.actor ?? 'system', + input.previous_status ?? null, + input.new_status ?? null, + input.reason ?? null, + input.payload_json ?? null, + now, + ]) + + return { + id: Number(result.lastInsertRowid ?? 0), + memory_id: input.memory_id, + event_type: input.event_type, + actor: input.actor ?? 'system', + previous_status: input.previous_status ?? null, + new_status: input.new_status ?? null, + reason: input.reason ?? null, + payload_json: input.payload_json ?? null, + created_at: now, + } + } + + async listEvents(memoryId: string): Promise { + await this.ensureInitialized() + const db = this.getDatabase() + + return db.prepare('SELECT * FROM memory_events WHERE memory_id = ? ORDER BY created_at DESC, id DESC').all([memoryId]) + } + + async getProvenanceById(id: string): Promise { + const memory = await this.getMemoryById(id) + return memory ? buildProvenance(memory) : null + } + + close(): void { + if (this.db) { + this.db.close() + this.db = null + this.initialized = false + } + } +} + +export const localMemoryStore = new LocalMemoryStore() diff --git a/src/main/services/memory/memory-provider.ts b/src/main/services/memory/memory-provider.ts new file mode 100644 index 0000000..b48a789 --- /dev/null +++ b/src/main/services/memory/memory-provider.ts @@ -0,0 +1,20 @@ +import type { StoredUnmemorizedMessage } from '../memorization.storage' + +export type MemoryProviderResult = + | { status: 'success' } + | { status: 'failure' } + | { status: 'pending' } + | { status: 'error' } + +export interface MemoryProvider { + readonly kind: string + + isConfigured(): Promise + + startMemorization(messages: StoredUnmemorizedMessage[]): Promise<{ + taskId: string | null + messageCount: number + }> + + checkTaskStatus(taskId: string, messageCount: number): Promise +} diff --git a/src/main/services/memory/remote-memu.provider.ts b/src/main/services/memory/remote-memu.provider.ts new file mode 100644 index 0000000..614d2d1 --- /dev/null +++ b/src/main/services/memory/remote-memu.provider.ts @@ -0,0 +1,125 @@ +import { loadSettings } from '../../config/settings.config' +import type { StoredUnmemorizedMessage } from '../memorization.storage' +import type { MemoryProvider, MemoryProviderResult } from './memory-provider' + +type MemorizeStatus = 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' + +interface MemorizeStatusResponse { + task_id: string + status: MemorizeStatus + detail_info: string +} + +export class RemoteMemuProvider implements MemoryProvider { + readonly kind = 'remote-memu' + + private async getMemuConfig() { + const settings = await loadSettings() + return { + baseUrl: settings.memuBaseUrl, + apiKey: settings.memuApiKey, + userId: settings.memuUserId, + agentId: settings.memuAgentId, + } + } + + async isConfigured(): Promise { + const settings = await loadSettings() + return !!( + settings.memuApiKey && settings.memuApiKey.trim() && + settings.memuBaseUrl && settings.memuBaseUrl.trim() && + settings.memuUserId && settings.memuUserId.trim() && + settings.memuAgentId && settings.memuAgentId.trim() + ) + } + + async startMemorization(messages: StoredUnmemorizedMessage[]): Promise<{ + taskId: string | null + messageCount: number + }> { + const memuConfig = await this.getMemuConfig() + const formattedMessages = messages.map((m) => ({ + role: m.role, + content: `[${m.platform}] ${m.content}`, + })) + + console.log(`[Memorization] Sending ${formattedMessages.length} messages to memorize`) + + const response = await fetch(`${memuConfig.baseUrl}/api/v3/memory/memorize`, { + method: 'POST', + headers: { + Authorization: `Bearer ${memuConfig.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user_id: memuConfig.userId, + agent_id: memuConfig.agentId, + conversation: formattedMessages, + }), + }) + + if (!response.ok) { + console.error('[Memorization] API returned status:', response.status) + return { + taskId: null, + messageCount: messages.length, + } + } + + const result = (await response.json()) as { task_id?: string } + const taskId = result.task_id + + if (!taskId) { + console.error('[Memorization] No task_id returned') + return { + taskId: null, + messageCount: messages.length, + } + } + + console.log(`[Memorization] Task started: ${taskId}`) + + return { + taskId, + messageCount: messages.length, + } + } + + async checkTaskStatus(taskId: string, messageCount: number): Promise { + try { + const memuConfig = await this.getMemuConfig() + const response = await fetch( + `${memuConfig.baseUrl}/api/v3/memory/memorize/status/${taskId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${memuConfig.apiKey}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + console.error(`[Memorization] Status check failed for ${taskId}: ${response.status}`) + return { status: 'error' } + } + + const result = (await response.json()) as MemorizeStatusResponse + console.log(`[Memorization] Task ${taskId} status: ${result.status}`) + + if (result.status === 'SUCCESS') { + return { status: 'success' } + } + + if (result.status === 'FAILURE') { + console.error(`[Memorization] Task ${taskId} failed: ${result.detail_info}`) + return { status: 'failure' } + } + + return { status: 'pending' } + } catch (error) { + console.error(`[Memorization] Error checking task ${taskId}:`, error) + return { status: 'error' } + } + } +} diff --git a/src/main/services/proactive.service.ts b/src/main/services/proactive.service.ts index 9df4f18..cef7f62 100644 --- a/src/main/services/proactive.service.ts +++ b/src/main/services/proactive.service.ts @@ -19,6 +19,7 @@ import { slackBotService } from '../apps/slack/bot.service' import { whatsappBotService } from '../apps/whatsapp/bot.service' import { lineBotService } from '../apps/line/bot.service' import { localChatService } from '../apps/local' +import { executeMemuMemory as executeSharedMemuMemory } from '../tools/memu.executor' import type { AgentResponse } from '../types' /** @@ -200,25 +201,7 @@ class ProactiveService { * This tool use main user/agent ids to retrieve memory from the main service. */ private async executeMemuMemory(query: string): Promise<{ success: boolean; data?: unknown; error?: string }> { - try { - const memuConfig = await this.getMemuConfig() - const response = await fetch(`${memuConfig.baseUrl}/api/v3/memory/retrieve`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${memuConfig.apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - user_id: memuConfig.userId, - agent_id: memuConfig.agentId, - query - }) - }) - const result = await response.json() - return { success: true, data: result } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } - } + return executeSharedMemuMemory(query) } /** diff --git a/src/main/tools/__tests__/memu.executor.test.ts b/src/main/tools/__tests__/memu.executor.test.ts new file mode 100644 index 0000000..5928c52 --- /dev/null +++ b/src/main/tools/__tests__/memu.executor.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { loadSettingsMock, searchMemoriesMock } = vi.hoisted(() => ({ + loadSettingsMock: vi.fn(), + searchMemoriesMock: vi.fn() +})) + +vi.mock('../../config/settings.config', () => ({ + loadSettings: loadSettingsMock +})) + +vi.mock('../../services/local-memory-control.service', () => ({ + localMemoryControlService: { + searchMemories: searchMemoriesMock + } +})) + +import { executeMemuMemory } from '../memu.executor' + +describe('executeMemuMemory', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + it('falls back to local memory when remote memU config is incomplete', async () => { + loadSettingsMock.mockResolvedValue({ + memuBaseUrl: '', + memuApiKey: 'token-only', + memuUserId: '', + memuAgentId: '' + }) + searchMemoriesMock.mockResolvedValue([{ memory: { id: 'mem_1' } }]) + + const result = await executeMemuMemory('trip plans') + + expect(searchMemoriesMock).toHaveBeenCalledWith({ + query: 'trip plans', + limit: 10, + include_archived: false, + exclude_sensitive: true, + min_confidence: 0.2 + }) + expect(result).toEqual({ + success: true, + data: { + source: 'local-controlled-memory', + query: 'trip plans', + results: [{ memory: { id: 'mem_1' } }] + } + }) + }) + + it('falls back to local memory when remote retrieve returns a non-OK response', async () => { + loadSettingsMock.mockResolvedValue({ + memuBaseUrl: 'https://memu.example', + memuApiKey: 'token', + memuUserId: 'user-1', + memuAgentId: 'agent-1' + }) + searchMemoriesMock.mockResolvedValue([{ memory: { id: 'mem_2' } }]) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ message: 'service unavailable' }) + })) + + const result = await executeMemuMemory('roadmap') + + expect(result).toEqual({ + success: true, + data: { + source: 'local-controlled-memory', + query: 'roadmap', + results: [{ memory: { id: 'mem_2' } }], + fallback_reason: 'service unavailable' + } + }) + }) +}) diff --git a/src/main/tools/memu.executor.ts b/src/main/tools/memu.executor.ts index f3921b5..b184c1b 100644 --- a/src/main/tools/memu.executor.ts +++ b/src/main/tools/memu.executor.ts @@ -1,3 +1,6 @@ +import { loadSettings } from '../config/settings.config' +import { localMemoryControlService } from '../services/local-memory-control.service' + type ToolResult = { success: boolean; data?: unknown; error?: string } export interface MemuConfig { @@ -7,11 +10,7 @@ export interface MemuConfig { agentId: string } -/** - * Get Memu API config from settings. - */ async function getMemuConfig(): Promise { - const { loadSettings } = await import('../config/settings.config') const settings = await loadSettings() return { @@ -22,12 +21,46 @@ async function getMemuConfig(): Promise { } } -/** - * Execute memu_memory: retrieve memory by query from the Memu API. - */ +function hasRemoteConfig(config: MemuConfig): boolean { + return !!( + config.apiKey && config.apiKey.trim() && + config.baseUrl && config.baseUrl.trim() && + config.userId && config.userId.trim() && + config.agentId && config.agentId.trim() + ) +} + +async function executeLocalMemory(query: string): Promise { + try { + const results = await localMemoryControlService.searchMemories({ + query, + limit: 10, + include_archived: false, + exclude_sensitive: true, + min_confidence: 0.2, + }) + + return { + success: true, + data: { + source: 'local-controlled-memory', + query, + results, + }, + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + export async function executeMemuMemory(query: string): Promise { try { const memuConfig = await getMemuConfig() + + if (!hasRemoteConfig(memuConfig)) { + return await executeLocalMemory(query) + } + const response = await fetch(`${memuConfig.baseUrl}/api/v3/memory/retrieve`, { method: 'POST', headers: { @@ -40,16 +73,51 @@ export async function executeMemuMemory(query: string): Promise { query }) }) - const result = await response.json() + + let result: unknown = null + try { + result = await response.json() + } catch { + result = null + } + + if (!response.ok) { + const message = + typeof result === 'object' && result && 'message' in result && typeof (result as { message?: unknown }).message === 'string' + ? (result as { message: string }).message + : `memU retrieve failed with HTTP ${response.status}` + + const fallback = await executeLocalMemory(query) + if (fallback.success) { + return { + success: true, + data: { + ...(typeof fallback.data === 'object' && fallback.data ? fallback.data as Record : {}), + fallback_reason: message, + }, + } + } + + return { success: false, error: message } + } + return { success: true, data: result } } catch (error) { + const fallback = await executeLocalMemory(query) + if (fallback.success) { + return { + success: true, + data: { + ...(typeof fallback.data === 'object' && fallback.data ? fallback.data as Record : {}), + fallback_reason: error instanceof Error ? error.message : String(error), + }, + } + } + return { success: false, error: error instanceof Error ? error.message : String(error) } } } -/** - * Execute a Memu tool by name - */ export async function executeMemuTool(name: string, input: unknown): Promise { switch (name) { case 'memu_memory': { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 58e79fb..5c5bade 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -7,6 +7,11 @@ interface IpcResponse { error?: string } +type MemoryStatus = 'active' | 'archived' | 'deleted' +type MemoryUserControl = 'auto' | 'remember' | 'dont_remember' | 'modified' | 'deleted' | 'paused' +type MemorySensitivityLevel = 'normal' | 'work' | 'sensitive' +type MemoryConflictState = 'none' | 'potential' | 'confirmed' + // Conversation message type interface ConversationMessage { role: 'user' | 'assistant' @@ -558,6 +563,146 @@ interface UpdateDownloadProgress { total: number } + + +// Local controlled memory types +interface LocalMemoryItem { + id: string + content: string + memory_type: string + source_platform: string | null + source_excerpt: string | null + created_at: number + updated_at: number + confidence: number + importance: number + sensitivity_level: MemorySensitivityLevel + retention_until: number | null + status: 'active' | 'archived' | 'deleted' + user_control: 'auto' | 'remember' | 'dont_remember' | 'modified' | 'deleted' | 'paused' + why_stored: string | null + conflict_state: 'none' | 'potential' | 'confirmed' + conflict_notes: string | null +} + +interface MemoryProvenance { + source_platform: string | null + source_excerpt: string | null + created_at: number + updated_at: number + retention_until: number | null + why_stored: string | null + sensitivity_level: MemorySensitivityLevel + conflict_state: 'none' | 'potential' | 'confirmed' + conflict_notes: string | null +} + +interface MemoryExplanation { + provenance: MemoryProvenance +} + +interface ExplainedLocalMemoryItem extends LocalMemoryItem { + explanation: MemoryExplanation +} + +interface MemoryMatchField { + field: 'content' | 'memory_type' | 'source_platform' | 'source_excerpt' | 'why_stored' | 'status' | 'user_control' | 'sensitivity_level' | 'conflict_notes' + value_excerpt: string + matched_terms: string[] + score_contribution: number + reason: string +} + +interface MemoryRetrievalExplanation { + query: string + matched_fields: MemoryMatchField[] + source_excerpt: string | null + created_at: number + updated_at: number + retention_until: number | null + score: number + score_reasons: string[] +} + +interface MemoryRetrievalResult { + memory: ExplainedLocalMemoryItem + retrieval_explanation: MemoryRetrievalExplanation +} + +interface MemoryEventRecord { + id: number + memory_id: string + event_type: string + actor: string + previous_status: MemoryStatus | null + new_status: MemoryStatus | null + reason: string | null + payload_json: string | null + created_at: number +} + +interface MemoryCaptureStatus { + paused: boolean +} + +interface RememberThisInput { + content: string + memory_type: string + source_platform?: string | null + source_excerpt?: string | null + confidence?: number + importance?: number + sensitivity_level?: MemorySensitivityLevel + retention_until?: number | null + status?: 'active' | 'archived' | 'deleted' + user_control?: 'auto' | 'remember' | 'dont_remember' | 'modified' | 'deleted' | 'paused' + why_stored?: string | null +} + +interface DoNotRememberInput { + content: string + memory_type?: string + source_platform?: string | null + source_excerpt?: string | null + sensitivity_level?: MemorySensitivityLevel + reason?: string | null +} + +interface MemorySearchInput { + query?: string + limit?: number + include_archived?: boolean + status?: 'active' | 'archived' | 'deleted' + memory_type?: string + source_platform?: string + sensitivity_level?: MemorySensitivityLevel + user_control?: 'auto' | 'remember' | 'dont_remember' | 'modified' | 'deleted' | 'paused' + created_after?: number + created_before?: number + updated_after?: number + updated_before?: number + min_confidence?: number + min_importance?: number +} + +interface MemoryApi { + getStatus: () => Promise> + rememberThis: (input: RememberThisInput) => Promise> + doNotRememberThis: (input: DoNotRememberInput) => Promise> + get: (id: string) => Promise> + getExplained: (id: string) => Promise> + getProvenance: (id: string) => Promise> + list: (filters?: Record) => Promise> + listExplained: (filters?: Record) => Promise> + search: (filters?: MemorySearchInput) => Promise> + update: (id: string, updates: Record) => Promise> + delete: (id: string) => Promise> + deleteBySource: (sourcePlatform: string) => Promise> + listEvents: (memoryId: string) => Promise> + pauseCapture: (reason?: string) => Promise> + resumeCapture: (reason?: string) => Promise> +} + // Updater API interface (auto-update) interface UpdaterApi { checkForUpdates: () => Promise @@ -585,5 +730,6 @@ declare global { skills: SkillsApi services: ServicesApi updater: UpdaterApi + memory: MemoryApi } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 73230b5..c0935e6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -322,6 +322,27 @@ const servicesApi = { } } + + +// Memory API (local controlled memory actions) +const memoryApi = { + getStatus: () => ipcRenderer.invoke('memory:get-status'), + rememberThis: (input: unknown) => ipcRenderer.invoke('memory:remember-this', input), + doNotRememberThis: (input: unknown) => ipcRenderer.invoke('memory:do-not-remember-this', input), + get: (id: string) => ipcRenderer.invoke('memory:get', id), + getExplained: (id: string) => ipcRenderer.invoke('memory:get-explained', id), + getProvenance: (id: string) => ipcRenderer.invoke('memory:get-provenance', id), + list: (filters?: unknown) => ipcRenderer.invoke('memory:list', filters), + listExplained: (filters?: unknown) => ipcRenderer.invoke('memory:list-explained', filters), + search: (filters?: unknown) => ipcRenderer.invoke('memory:search', filters), + update: (id: string, updates: unknown) => ipcRenderer.invoke('memory:update', id, updates), + delete: (id: string) => ipcRenderer.invoke('memory:delete', id), + deleteBySource: (sourcePlatform: string) => ipcRenderer.invoke('memory:delete-by-source', sourcePlatform), + listEvents: (memoryId: string) => ipcRenderer.invoke('memory:list-events', memoryId), + pauseCapture: (reason?: string) => ipcRenderer.invoke('memory:pause-capture', reason), + resumeCapture: (reason?: string) => ipcRenderer.invoke('memory:resume-capture', reason), +} + // Updater API (auto-update) const updaterApi = { checkForUpdates: () => ipcRenderer.invoke('updater:check-for-updates'), @@ -356,6 +377,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('skills', skillsApi) contextBridge.exposeInMainWorld('services', servicesApi) contextBridge.exposeInMainWorld('updater', updaterApi) + contextBridge.exposeInMainWorld('memory', memoryApi) } catch (error) { console.error(error) } @@ -396,4 +418,6 @@ if (process.contextIsolated) { window.services = servicesApi // @ts-ignore (define in dts) window.updater = updaterApi + // @ts-ignore (define in dts) + window.memory = memoryApi } diff --git a/src/renderer/src/components/Settings/ObservabilitySettings.tsx b/src/renderer/src/components/Settings/ObservabilitySettings.tsx index d2fd428..ac1acd6 100644 --- a/src/renderer/src/components/Settings/ObservabilitySettings.tsx +++ b/src/renderer/src/components/Settings/ObservabilitySettings.tsx @@ -457,7 +457,7 @@ function TraceRow({ trace, expanded, onToggle }: { trace: TraceEntry; expanded: {trace.spans[0]?.tokenUsage && (
- {t('settings.observability.traces.totalTokens', { count: trace.spans[0].tokenUsage.total.toLocaleString() })} + {t('settings.observability.traces.totalTokens', { count: trace.spans[0].tokenUsage.total })} ({t('settings.observability.traces.tokenBreakdown', { input: trace.spans[0].tokenUsage.input.toLocaleString(), output: trace.spans[0].tokenUsage.output.toLocaleString() })}) @@ -494,7 +494,7 @@ function MetricsDashboard({ metrics }: { metrics: MetricsSummary }): JSX.Element
diff --git a/src/renderer/src/hooks/useLocalMemoryControls.ts b/src/renderer/src/hooks/useLocalMemoryControls.ts new file mode 100644 index 0000000..54a1216 --- /dev/null +++ b/src/renderer/src/hooks/useLocalMemoryControls.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useState } from 'react' + +export interface LocalMemoryDraftInput { + content: string + memory_type?: string + source_platform?: string | null + source_excerpt?: string | null + confidence?: number + importance?: number + sensitivity_level?: 'normal' | 'work' | 'sensitive' + retention_until?: number | null + why_stored?: string | null +} + +/** + * Minimal renderer hook for local controlled-memory actions. + * This is intentionally thin so UI components can adopt it later + * without needing a dedicated memory page yet. + */ +export function useLocalMemoryControls() { + const [capturePaused, setCapturePaused] = useState(false) + const [loadingStatus, setLoadingStatus] = useState(true) + + const refreshStatus = useCallback(async () => { + setLoadingStatus(true) + try { + const result = await window.memory.getStatus() + if (result.success && result.data) { + setCapturePaused(result.data.paused) + } + } finally { + setLoadingStatus(false) + } + }, []) + + useEffect(() => { + void refreshStatus() + }, [refreshStatus]) + + const rememberThis = useCallback(async (input: LocalMemoryDraftInput) => { + const { memory_type, ...rest } = input + return window.memory.rememberThis({ + memory_type: memory_type ?? 'manual_note', + status: 'active', + user_control: 'remember', + ...rest, + }) + }, []) + + const doNotRememberThis = useCallback(async (input: { content: string; source_platform?: string | null; source_excerpt?: string | null; reason?: string | null }) => { + return window.memory.doNotRememberThis(input) + }, []) + + const updateMemory = useCallback(async (id: string, updates: Record) => { + return window.memory.update(id, updates) + }, []) + + const getExplainedMemory = useCallback(async (id: string) => { + return window.memory.getExplained(id) + }, []) + + const getMemoryProvenance = useCallback(async (id: string) => { + return window.memory.getProvenance(id) + }, []) + + const searchMemories = useCallback(async (query: string, filters: Record = {}) => { + return window.memory.search({ query, ...filters }) + }, []) + + const listExplainedMemories = useCallback(async (filters: Record = {}) => { + return window.memory.listExplained(filters) + }, []) + + const deleteMemory = useCallback(async (id: string) => { + return window.memory.delete(id) + }, []) + + const deleteMemoriesBySource = useCallback(async (sourcePlatform: string) => { + return window.memory.deleteBySource(sourcePlatform) + }, []) + + const pauseCapture = useCallback(async (reason?: string) => { + const result = await window.memory.pauseCapture(reason) + if (result.success && result.data) { + setCapturePaused(result.data.paused) + } + return result + }, []) + + const resumeCapture = useCallback(async (reason?: string) => { + const result = await window.memory.resumeCapture(reason) + if (result.success && result.data) { + setCapturePaused(result.data.paused) + } + return result + }, []) + + return { + capturePaused, + loadingStatus, + refreshStatus, + rememberThis, + doNotRememberThis, + updateMemory, + getExplainedMemory, + getMemoryProvenance, + searchMemories, + listExplainedMemories, + deleteMemory, + deleteMemoriesBySource, + pauseCapture, + resumeCapture, + } +}