| import { useEffect, useMemo, useRef, useState } from 'react'; |
| import type { AppSettings, LocalMemoryState, UiLocale } from '@maka/core'; |
| import { |
| appendManualLocalMemoryEntryDraft, |
| findLocalMemoryEntryDraftRange, |
| setLocalMemoryEntryStatusDraft, |
| } from '@maka/core'; |
| import { useToast, useUiLocale } from '@maka/ui'; |
| import { openPathFailureCopy, openPathActionLabel } from '../open-path'; |
| import { settingsActionErrorMessage } from './settings-error-copy'; |
| import { |
| formatLocalMemorySaveSummary, |
| localMemoryBackupKindLabel, |
| localMemoryBackupSummary, |
| memoryEntryStatusLabel, |
| memoryOriginLabel, |
| } from './memory-settings-labels'; |
| import { deriveMemorySettingsViewModel } from './memory-settings-view-model'; |
| import { useKeyedActionGuard } from './use-action-guard'; |
| import { getMemorySettingsCopy } from '../locales/settings-memory-copy'; |
| |
| export interface MemoryDocumentControllerProps { |
| settings: AppSettings; |
| onReloadSettings(): Promise<void>; |
| } |
| |
| /** Owns the MEMORY.md document lifecycle; workspace instructions have a separate authority. */ |
| export function useMemoryDocumentController(props: MemoryDocumentControllerProps) { |
| const locale = useUiLocale(); |
| const copy = getMemorySettingsCopy(locale); |
| type MemoryWriteAction = 'reload' | 'enable' | 'agent-read' | 'save' | 'reset' | 'restore' | 'entry-status'; |
| |
| const [state, setState] = useState<LocalMemoryState | null>(null); |
| const [draft, setDraft] = useState(''); |
| const [newMemoryTitle, setNewMemoryTitle] = useState(''); |
| const [newMemoryTags, setNewMemoryTags] = useState(''); |
| const [newMemoryContent, setNewMemoryContent] = useState(''); |
| const [memoryEntryQuery, setMemoryEntryQuery] = useState(''); |
| const [lastSaveSummary, setLastSaveSummary] = useState<{ |
| title: string; |
| detail: string; |
| savedAt: number; |
| } | null>(null); |
| const [loadingMemory, setLoadingMemory] = useState(true); |
| const [busy, setBusy] = useState(false); |
| const [pendingMemoryWriteAction, setPendingMemoryWriteAction] = useState<MemoryWriteAction | null>(null); |
| const [pendingMemoryActions, setPendingMemoryActions] = useState<Set<string>>(() => new Set()); |
| const editorRef = useRef<HTMLTextAreaElement | null>(null); |
| // One keyed guard holds both the single write latch (key 'write', the old |
| // memoryWriteBusyRef) and the per-action latches (the old |
| // pendingMemoryActionKeysRef set) with owner-checked releases. |
| const memoryActionGuard = useKeyedActionGuard<string>(); |
| const memoryPageMountedRef = useRef(false); |
| const memoryPageLifecycleRef = useRef(0); |
| const memoryReloadTicketRef = useRef(0); |
| const toast = useToast(); |
| |
| useEffect(() => { |
| memoryPageLifecycleRef.current += 1; |
| memoryPageMountedRef.current = true; |
| const lifecycle = memoryPageLifecycleRef.current; |
| return () => { |
| if (memoryPageLifecycleRef.current !== lifecycle) return; |
| memoryPageMountedRef.current = false; |
| memoryReloadTicketRef.current += 1; |
| }; |
| }, []); |
| |
| function isMemoryPageCurrent(lifecycle: number): boolean { |
| return memoryPageMountedRef.current && memoryPageLifecycleRef.current === lifecycle; |
| } |
| |
| async function runMemoryWriteAction<T>( |
| action: MemoryWriteAction, |
| run: (isCurrent: () => boolean) => Promise<T>, |
| ): Promise<T | undefined> { |
| const releaseWrite = memoryActionGuard.begin('write'); |
| if (!releaseWrite) return undefined; |
| const lifecycle = memoryPageLifecycleRef.current; |
| setPendingMemoryWriteAction(action); |
| setBusy(true); |
| try { |
| return await run(() => isMemoryPageCurrent(lifecycle)); |
| } catch (error) { |
| if (!isMemoryPageCurrent(lifecycle)) return undefined; |
| throw error; |
| } finally { |
| releaseWrite(); |
| if (isMemoryPageCurrent(lifecycle)) { |
| setPendingMemoryWriteAction(null); |
| setBusy(false); |
| } |
| } |
| } |
| |
| async function runMemoryAction<T>( |
| key: string, |
| action: (isCurrent: () => boolean) => Promise<T>, |
| ): Promise<T | undefined> { |
| const release = memoryActionGuard.begin(key); |
| if (!release) return undefined; |
| const lifecycle = memoryPageLifecycleRef.current; |
| setPendingMemoryActions((current) => new Set(current).add(key)); |
| try { |
| return await action(() => isMemoryPageCurrent(lifecycle)); |
| } catch (error) { |
| if (!isMemoryPageCurrent(lifecycle)) return undefined; |
| throw error; |
| } finally { |
| release(); |
| if (isMemoryPageCurrent(lifecycle)) { |
| setPendingMemoryActions((current) => { |
| const next = new Set(current); |
| next.delete(key); |
| return next; |
| }); |
| } |
| } |
| } |
| |
| async function reload(): Promise<boolean> { |
| const lifecycle = memoryPageLifecycleRef.current; |
| const ticket = ++memoryReloadTicketRef.current; |
| try { |
| const next = await window.maka.memory.getState(); |
| if (!isMemoryPageCurrent(lifecycle) || ticket !== memoryReloadTicketRef.current) return false; |
| setState(next); |
| setDraft(next.content); |
| setLastSaveSummary(null); |
| return true; |
| } catch (error) { |
| if (isMemoryPageCurrent(lifecycle) && ticket === memoryReloadTicketRef.current) { |
| toast.error(copy.text.loadFailed, settingsActionErrorMessage(error, locale)); |
| } |
| return false; |
| } finally { |
| if (isMemoryPageCurrent(lifecycle) && ticket === memoryReloadTicketRef.current) { |
| setLoadingMemory(false); |
| } |
| } |
| } |
| |
| async function reloadDraftFromDisk() { |
| await runMemoryWriteAction('reload', async (isCurrent) => { |
| const ok = await reload(); |
| if (ok && isCurrent()) toast.success(copy.text.reloaded, copy.text.reloadDiscarded); |
| }); |
| } |
| |
| useEffect(() => { |
| void reload(); |
| }, []); |
| |
| async function setEnabled(enabled: boolean) { |
| try { |
| await runMemoryWriteAction('enable', async (isCurrent) => { |
| const next = await window.maka.memory.setEnabled(enabled); |
| await props.onReloadSettings(); |
| if (!isCurrent()) return; |
| setState(next); |
| setDraft(next.content); |
| }); |
| } catch (error) { |
| toast.error(copy.text.toggleFailed, settingsActionErrorMessage(error, locale)); |
| } |
| } |
| |
| async function setAgentReadEnabled(agentReadEnabled: boolean) { |
| try { |
| await runMemoryWriteAction('agent-read', async (isCurrent) => { |
| const next = await window.maka.memory.setAgentReadEnabled(agentReadEnabled); |
| await props.onReloadSettings(); |
| if (!isCurrent()) return; |
| setState(next); |
| setDraft(next.content); |
| }); |
| } catch (error) { |
| toast.error(copy.text.agentReadFailed, settingsActionErrorMessage(error, locale)); |
| } |
| } |
| |
| async function save() { |
| try { |
| await runMemoryWriteAction('save', async (isCurrent) => { |
| const next = await window.maka.memory.save(draft); |
| if (!isCurrent()) return; |
| const redacted = next.content !== draft; |
| setState(next); |
| setDraft(next.content); |
| if (next.status === 'safe_mode') { |
| setLastSaveSummary(null); |
| toast.error(copy.text.saveBlocked, copy.text.safeMode); |
| } else if (redacted) { |
| const detail = copy.redactedDetail(formatLocalMemorySaveSummary(next, copy)); |
| setLastSaveSummary({ |
| title: copy.text.savedRedacted, |
| detail, |
| savedAt: Date.now(), |
| }); |
| toast.success(copy.text.savedRedacted, detail); |
| } else { |
| const detail = formatLocalMemorySaveSummary(next, copy); |
| setLastSaveSummary({ |
| title: copy.text.savedFile, |
| detail, |
| savedAt: Date.now(), |
| }); |
| toast.success(copy.text.savedFile, detail); |
| } |
| }); |
| } catch (error) { |
| toast.error(copy.text.saveFailed, settingsActionErrorMessage(error, locale)); |
| } |
| } |
| |
| async function reset() { |
| try { |
| await runMemoryWriteAction('reset', async (isCurrent) => { |
| const next = await window.maka.memory.reset(); |
| if (!isCurrent()) return; |
| setState(next); |
| setDraft(next.content); |
| setLastSaveSummary(null); |
| toast.success(copy.text.resetDone, copy.text.resetDoneDetail); |
| }); |
| } catch (error) { |
| toast.error(copy.text.resetFailed, settingsActionErrorMessage(error, locale)); |
| } |
| } |
| |
| async function restoreLatestBackup() { |
| await runMemoryAction('backup:latest:restore', async () => { |
| try { |
| await runMemoryWriteAction('restore', async (isCurrent) => { |
| const backup = state?.latestBackup; |
| if (!backup) { |
| toast.error(copy.text.noBackup, copy.text.noBackupDetail); |
| return; |
| } |
| const backupLabel = `${localMemoryBackupKindLabel(backup.kind, copy)} · ${localMemoryBackupSummary(backup, copy)} · ${new Date(backup.updatedAt).toLocaleString(copy.intlLocale)}`; |
| const ok = await toast.confirm({ |
| title: copy.text.restoreLatestTitle, |
| description: copy.restoreLatestDescription(backupLabel), |
| confirmLabel: copy.text.confirmRestore, |
| cancelLabel: copy.text.cancel, |
| destructive: true, |
| }); |
| if (!ok) return; |
| if (!isCurrent()) return; |
| const result = await window.maka.memory.restoreLatestBackup(); |
| if (!isCurrent()) return; |
| setState(result.state); |
| setDraft(result.state.content); |
| setLastSaveSummary(null); |
| if (result.ok) { |
| toast.success(copy.text.restoredLatest, `${backupLabel} · ${copy.text.restoredDetail}`); |
| } else { |
| toast.error(copy.text.restoreFailed, memoryResultMessage(result.message, locale, copy.text.restoreFailed)); |
| } |
| }); |
| } catch (error) { |
| toast.error(copy.text.restoreLatestFailed, settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function restoreBackupCandidate(backup: NonNullable<LocalMemoryState['latestBackup']>) { |
| await runMemoryAction(`backup:${backup.kind}:restore`, async () => { |
| try { |
| await runMemoryWriteAction('restore', async (isCurrent) => { |
| const backupLabel = `${localMemoryBackupKindLabel(backup.kind, copy)} · ${localMemoryBackupSummary(backup, copy)} · ${new Date(backup.updatedAt).toLocaleString(copy.intlLocale)}`; |
| const ok = await toast.confirm({ |
| title: copy.text.restoreCandidateTitle, |
| description: copy.restoreCandidateDescription(backupLabel), |
| confirmLabel: copy.text.confirmRestore, |
| cancelLabel: copy.text.cancel, |
| destructive: true, |
| }); |
| if (!ok) return; |
| if (!isCurrent()) return; |
| const result = await window.maka.memory.restoreBackup(backup.kind); |
| if (!isCurrent()) return; |
| setState(result.state); |
| setDraft(result.state.content); |
| setLastSaveSummary(null); |
| if (result.ok) { |
| toast.success(copy.text.restoredCandidate, `${backupLabel} · ${copy.text.restoredDetail}`); |
| } else { |
| toast.error(copy.text.restoreFailed, memoryResultMessage(result.message, locale, copy.text.restoreFailed)); |
| } |
| }); |
| } catch (error) { |
| toast.error(copy.text.restoreCandidateFailed, settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function openFile() { |
| await runMemoryAction('memory:file:open', async (isCurrent) => { |
| try { |
| const result = await window.maka.memory.openFile(); |
| if (!isCurrent()) return; |
| if (!result.ok) toast.error(copy.text.openFailed, memoryResultMessage(result.message, locale, copy.text.openFailed)); |
| } catch (error) { |
| if (isCurrent()) toast.error(copy.text.openFailed, settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function openLatestBackup() { |
| await runMemoryAction('backup:latest:open', async (isCurrent) => { |
| try { |
| const result = await window.maka.memory.openLatestBackup(); |
| if (!isCurrent()) return; |
| if (!result.ok) toast.error(copy.text.openPreviousFailed, memoryResultMessage(result.message, locale, copy.text.openPreviousFailed)); |
| } catch (error) { |
| if (isCurrent()) toast.error(copy.text.openPreviousFailed, settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function openBackupCandidate(backup: NonNullable<LocalMemoryState['latestBackup']>) { |
| await runMemoryAction(`backup:${backup.kind}:open`, async (isCurrent) => { |
| try { |
| const result = await window.maka.memory.openBackup(backup.kind); |
| if (!isCurrent()) return; |
| if (!result.ok) { |
| toast.error(copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), memoryResultMessage(result.message, locale, copy.text.openFailed)); |
| } |
| } catch (error) { |
| if (isCurrent()) |
| toast.error(copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function openFolder() { |
| await runMemoryAction('memory:folder:open', async (isCurrent) => { |
| try { |
| const result = await window.maka.app.openPath('memory'); |
| if (!isCurrent()) return; |
| if (!result.ok) { |
| toast.error(copy.openBackupFailed(openPathActionLabel('memory', locale)), openPathFailureCopy(result.reason, locale)); |
| } |
| } catch (error) { |
| if (isCurrent()) |
| toast.error(copy.openBackupFailed(openPathActionLabel('memory', locale)), settingsActionErrorMessage(error, locale)); |
| } |
| }); |
| } |
| |
| async function copyPath() { |
| await runMemoryAction('memory:path:copy', async (isCurrent) => { |
| if (!state?.path) return; |
| try { |
| await navigator.clipboard.writeText(state.path); |
| if (isCurrent()) toast.success(copy.text.pathCopied, state.path); |
| } catch { |
| if (isCurrent()) toast.error(copy.text.copyFailed, copy.text.copyFailedDetail); |
| } |
| }); |
| } |
| |
| async function copyBackupReference(backup: NonNullable<LocalMemoryState['latestBackup']>) { |
| await runMemoryAction(`backup:${backup.kind}:copy`, async (isCurrent) => { |
| const reference = [ |
| `Memory backup: ${localMemoryBackupKindLabel(backup.kind, copy)}`, |
| `Path: ${backup.path}`, |
| `Updated: ${new Date(backup.updatedAt).toISOString()}`, |
| `Entries: ${localMemoryBackupSummary(backup, copy)}`, |
| `Size: ${backup.sizeBytes} bytes`, |
| backup.safeMode ? `Safe mode: ${backup.reason ?? 'oversize'}` : 'Safe mode: false', |
| ].join('\n'); |
| try { |
| await navigator.clipboard.writeText(reference); |
| if (isCurrent()) toast.success(copy.text.backupReferenceCopied, localMemoryBackupSummary(backup, copy)); |
| } catch { |
| if (isCurrent()) toast.error(copy.text.copyFailed, copy.text.copyFailedDetail); |
| } |
| }); |
| } |
| |
| async function copyLatestBackupReference() { |
| const backup = state?.latestBackup; |
| if (!backup) return; |
| await copyBackupReference(backup); |
| } |
| |
| async function copyMemoryEntryReference(entry: LocalMemoryState['entries'][number]) { |
| await runMemoryAction(`entry:${entry.id}:copy`, async (isCurrent) => { |
| const reference = [ |
| `Memory entry: ${entry.title}`, |
| `ID: ${entry.id}`, |
| `Status: ${memoryEntryStatusLabel(entry.status, copy)}`, |
| `Origin: ${memoryOriginLabel(entry.origin, copy)}`, |
| entry.createdAt === undefined ? '' : `Created: ${new Date(entry.createdAt).toISOString()}`, |
| entry.updatedAt === undefined ? '' : `Updated: ${new Date(entry.updatedAt).toISOString()}`, |
| entry.tags.length > 0 ? `Tags: ${entry.tags.join(', ')}` : '', |
| ] |
| .filter(Boolean) |
| .join('\n'); |
| try { |
| await navigator.clipboard.writeText(reference); |
| if (isCurrent()) toast.success(copy.text.entryReferenceCopied, entry.id); |
| } catch { |
| if (isCurrent()) toast.error(copy.text.copyFailed, copy.text.copyFailedDetail); |
| } |
| }); |
| } |
| |
| function focusMemoryEntryInDraft(entry: LocalMemoryState['entries'][number]) { |
| const range = findLocalMemoryEntryDraftRange(draft, entry.id); |
| if (!range) { |
| toast.error(copy.text.locateFailed, copy.text.locateFailedDetail); |
| return; |
| } |
| requestAnimationFrame(() => { |
| editorRef.current?.focus(); |
| editorRef.current?.setSelectionRange(range.start, range.end); |
| editorRef.current?.scrollIntoView({ |
| block: 'center', |
| behavior: 'smooth', |
| }); |
| }); |
| } |
| |
| function addManualMemoryDraftEntry() { |
| const result = appendManualLocalMemoryEntryDraft(draft, { |
| title: newMemoryTitle, |
| content: newMemoryContent, |
| tags: newMemoryTags.split(','), |
| }); |
| if (!result.ok) { |
| switch (result.reason) { |
| case 'empty_title': |
| toast.error(copy.text.emptyTitle, copy.text.emptyTitleDetail); |
| return; |
| case 'empty_content': |
| toast.error(copy.text.emptyContent, copy.text.emptyContentDetail); |
| return; |
| case 'oversize': |
| toast.error(copy.text.draftOversize, copy.text.oversizeDetail); |
| return; |
| } |
| } |
| setDraft(result.draft); |
| setNewMemoryTitle(''); |
| setNewMemoryTags(''); |
| setNewMemoryContent(''); |
| toast.success(copy.text.addedDraft, copy.text.addedDraftDetail); |
| requestAnimationFrame(() => { |
| editorRef.current?.focus(); |
| editorRef.current?.setSelectionRange(result.draft.length, result.draft.length); |
| }); |
| } |
| |
| async function updateMemoryEntryStatus( |
| entry: LocalMemoryState['activeEntries'][number], |
| status: 'active' | 'archived', |
| ) { |
| const result = setLocalMemoryEntryStatusDraft(draft, { |
| id: entry.id, |
| status, |
| }); |
| if (!result.ok) { |
| switch (result.reason) { |
| case 'invalid_id': |
| toast.error(copy.text.updateFailed, copy.text.invalidIdDetail); |
| return; |
| case 'not_found': |
| toast.error(copy.text.updateFailed, copy.text.locateFailedDetail); |
| return; |
| case 'oversize': |
| toast.error(copy.text.updateFailed, copy.text.oversizeDetail); |
| return; |
| } |
| } |
| |
| if (memoryDraftDirty) { |
| setDraft(result.draft); |
| toast.success(status === 'archived' ? copy.text.archivedDraft : copy.text.restoredDraft, copy.text.addedDraftDetail); |
| return; |
| } |
| |
| try { |
| await runMemoryWriteAction('entry-status', async (isCurrent) => { |
| const next = await window.maka.memory.save(result.draft); |
| if (!isCurrent()) return; |
| setState(next); |
| setDraft(next.content); |
| if (next.status === 'safe_mode') { |
| toast.error(copy.text.updateBlocked, copy.text.safeMode); |
| } else { |
| toast.success(status === 'archived' ? copy.text.archived : copy.text.restored, entry.title); |
| } |
| }); |
| } catch (error) { |
| toast.error(status === 'archived' ? copy.text.archiveFailed : copy.text.entryRestoreFailed, settingsActionErrorMessage(error, locale)); |
| } |
| } |
| |
| const viewModel = useMemo( |
| () => |
| deriveMemorySettingsViewModel({ |
| state, |
| localMemorySettings: props.settings.localMemory, |
| draft, |
| query: memoryEntryQuery, |
| copy, |
| }), |
| [copy, state, props.settings, draft, memoryEntryQuery], |
| ); |
| const { |
| effective, |
| memoryDraftDirty, |
| visibleMemoryEntries, |
| memoryEntryPreviewBlockedReason, |
| normalizedMemoryEntryQuery, |
| filteredActiveEntries, |
| filteredArchivedEntries, |
| filteredEntryCount, |
| localMemoryPromptPreview, |
| promptPreviewBlockedReason, |
| promptPreviewWillInject, |
| localMemoryPromptPreviewBudgetLabel, |
| memoryDraftHasSensitiveFields, |
| } = viewModel; |
| const memoryControlsDisabled = loadingMemory || busy; |
| const isMemoryActionPending = (key: string) => pendingMemoryActions.has(key); |
| |
| async function copyLocalMemoryPromptPreview() { |
| if (!localMemoryPromptPreview) return; |
| await runMemoryAction('memory:prompt-preview:copy', async (isCurrent) => { |
| try { |
| await navigator.clipboard.writeText(localMemoryPromptPreview); |
| if (isCurrent()) toast.success(copy.text.promptCopied, copy.text.promptCopiedDetail); |
| } catch { |
| if (isCurrent()) toast.error(copy.text.copyFailed, copy.text.copyFailedDetail); |
| } |
| }); |
| } |
| |
| return { |
| draft, |
| setDraft, |
| newMemoryTitle, |
| setNewMemoryTitle, |
| newMemoryTags, |
| setNewMemoryTags, |
| newMemoryContent, |
| setNewMemoryContent, |
| memoryEntryQuery, |
| setMemoryEntryQuery, |
| lastSaveSummary, |
| pendingMemoryWriteAction, |
| pendingMemoryActions, |
| editorRef, |
| reloadDraftFromDisk, |
| setEnabled, |
| setAgentReadEnabled, |
| save, |
| reset, |
| restoreLatestBackup, |
| restoreBackupCandidate, |
| openFile, |
| openLatestBackup, |
| openBackupCandidate, |
| openFolder, |
| copyPath, |
| copyBackupReference, |
| copyLatestBackupReference, |
| copyMemoryEntryReference, |
| focusMemoryEntryInDraft, |
| addManualMemoryDraftEntry, |
| updateMemoryEntryStatus, |
| effective, |
| memoryDraftDirty, |
| visibleMemoryEntries, |
| memoryEntryPreviewBlockedReason, |
| normalizedMemoryEntryQuery, |
| filteredActiveEntries, |
| filteredArchivedEntries, |
| filteredEntryCount, |
| localMemoryPromptPreview, |
| promptPreviewBlockedReason, |
| promptPreviewWillInject, |
| localMemoryPromptPreviewBudgetLabel, |
| memoryDraftHasSensitiveFields, |
| memoryControlsDisabled, |
| isMemoryActionPending, |
| copyLocalMemoryPromptPreview, |
| }; |
| } |
| |
| function memoryResultMessage(message: string, locale: UiLocale, fallback: string): string { |
| return locale === 'zh' || !/[\u3400-\u9fff]/u.test(message) ? message : fallback; |
| } |