430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
/**
|
|
* Memory section (U9 / KTD-10).
|
|
*
|
|
* Project-scoped memory configuration: enable toggle, qmd install affordance,
|
|
* auto-summarize schedule, dream processing, the retrieval test panel, and the
|
|
* file editor with backend-writability gating. All memory fetch/state/handlers
|
|
* and the backend-status hook live in the shell (they touch the API, share state
|
|
* with the save flow, and the backend hook is enabled only while this section is
|
|
* active) and are relayed through a `memory` prop bag — mirroring the
|
|
* Authentication/Remote section conventions. The option-label truncation helpers
|
|
* are co-located. Keys, conditional gating, and editor wiring are preserved
|
|
* verbatim from the original inline JSX.
|
|
*/
|
|
import type { ReactNode } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type {
|
|
MemoryBackendCapabilities,
|
|
MemoryBackendStatus,
|
|
MemoryFileInfo,
|
|
MemoryRetrievalTestResult,
|
|
} from "../../../api";
|
|
import { FileEditor } from "../../FileEditor";
|
|
import type { SectionBaseProps } from "./context";
|
|
|
|
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
|
|
|
|
function truncateMiddle(value: string, maxChars: number): string {
|
|
if (value.length <= maxChars) {
|
|
return value;
|
|
}
|
|
|
|
const visibleChars = Math.max(1, maxChars - 1);
|
|
const startChars = Math.ceil(visibleChars / 2);
|
|
const endChars = Math.floor(visibleChars / 2);
|
|
return `${value.slice(0, startChars)}…${value.slice(value.length - endChars)}`;
|
|
}
|
|
|
|
function formatMemoryFileOptionLabel(file: MemoryFileInfo): string {
|
|
const fullLabel = `${file.label} — ${file.path}`;
|
|
return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS);
|
|
}
|
|
|
|
export interface MemorySectionMemoryProps {
|
|
memoryCapabilities: MemoryBackendCapabilities | null;
|
|
memoryBackendStatus: MemoryBackendStatus | null;
|
|
memoryBackendLoading: boolean;
|
|
memoryBackendError: string | null;
|
|
memoryFiles: MemoryFileInfo[];
|
|
selectedMemoryPath: string;
|
|
setSelectedMemoryPath: (path: string) => void;
|
|
memoryContent: string;
|
|
setMemoryContent: (content: string) => void;
|
|
memoryLoading: boolean;
|
|
memoryDirty: boolean;
|
|
setMemoryDirty: (dirty: boolean) => void;
|
|
memoryTestQuery: string;
|
|
setMemoryTestQuery: (query: string) => void;
|
|
memoryTestLoading: boolean;
|
|
memoryTestResult: MemoryRetrievalTestResult | null;
|
|
qmdInstallLoading: boolean;
|
|
dreamRunning: boolean;
|
|
memoryCompactLoading: boolean;
|
|
onInstallQmd: () => void;
|
|
onTestMemoryRetrieval: () => void;
|
|
onDreamNow: () => void;
|
|
onCompactMemory: () => void;
|
|
onSaveMemory: () => void;
|
|
}
|
|
|
|
export interface MemorySectionProps extends SectionBaseProps {
|
|
scopeBanner: ReactNode;
|
|
memory: MemorySectionMemoryProps;
|
|
}
|
|
|
|
export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySectionProps) {
|
|
const { t } = useTranslation("app");
|
|
const {
|
|
memoryCapabilities: capabilities,
|
|
memoryBackendStatus: backendStatus,
|
|
memoryBackendLoading: backendLoading,
|
|
memoryBackendError: backendError,
|
|
memoryFiles,
|
|
selectedMemoryPath,
|
|
setSelectedMemoryPath,
|
|
memoryContent,
|
|
setMemoryContent,
|
|
memoryLoading,
|
|
memoryDirty,
|
|
setMemoryDirty,
|
|
memoryTestQuery,
|
|
setMemoryTestQuery,
|
|
memoryTestLoading,
|
|
memoryTestResult,
|
|
qmdInstallLoading,
|
|
dreamRunning,
|
|
memoryCompactLoading,
|
|
onInstallQmd,
|
|
onTestMemoryRetrieval,
|
|
onDreamNow,
|
|
onCompactMemory,
|
|
onSaveMemory,
|
|
} = memory;
|
|
|
|
// Determine if editing is allowed
|
|
const isMemoryEnabled = form.memoryEnabled !== false;
|
|
const backendStatusResolved = !backendLoading && backendStatus !== null;
|
|
const isBackendWritable = backendStatusResolved ? (capabilities?.writable ?? true) : true;
|
|
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
|
|
|
|
const selectedMemoryFile = memoryFiles.find((file) => file.path === selectedMemoryPath);
|
|
const memoryLayerNames: Record<MemoryFileInfo["layer"], string> = {
|
|
"long-term": "Long-term",
|
|
daily: "Daily",
|
|
dreams: "Dreams",
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{scopeBanner}
|
|
<h4 className="settings-section-heading">Memory</h4>
|
|
<div className="form-group">
|
|
<small className="settings-muted">
|
|
Memory lives in <code>.fusion/memory/</code>. Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed.
|
|
</small>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="memoryEnabled" className="checkbox-label">
|
|
<input
|
|
id="memoryEnabled"
|
|
type="checkbox"
|
|
checked={form.memoryEnabled !== false}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
|
|
}
|
|
/>
|
|
Enable memory tools
|
|
</label>
|
|
<small>Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.</small>
|
|
</div>
|
|
|
|
{backendLoading ? (
|
|
<div className="form-group">
|
|
<small className="settings-muted">Checking memory write access...</small>
|
|
</div>
|
|
) : backendError ? (
|
|
<div className="form-group">
|
|
<small className="field-error">Failed to load backend status: {backendError}</small>
|
|
</div>
|
|
) : null}
|
|
|
|
{backendStatusResolved && backendStatus.qmdAvailable === false && (
|
|
<div className="settings-empty-state memory-status-message">
|
|
<span>
|
|
qmd is not installed. Search will use local files.
|
|
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={onInstallQmd}
|
|
disabled={qmdInstallLoading}
|
|
>
|
|
{qmdInstallLoading ? t("settings.memory.installing", "Installing…") : t("settings.memory.installQmd", "Install qmd")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
|
|
<input
|
|
id="memoryAutoSummarizeEnabled"
|
|
type="checkbox"
|
|
checked={form.memoryAutoSummarizeEnabled || false}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))
|
|
}
|
|
/>
|
|
Auto-Summarize Memory
|
|
</label>
|
|
<small>Automatically compact memory when it exceeds the threshold on a schedule</small>
|
|
</div>
|
|
|
|
{(form.memoryAutoSummarizeEnabled || false) && (
|
|
<>
|
|
<div className="form-group">
|
|
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold (chars)</label>
|
|
<input
|
|
id="memoryAutoSummarizeThresholdChars"
|
|
type="number"
|
|
className="input"
|
|
value={form.memoryAutoSummarizeThresholdChars ?? 50000}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
|
|
}))
|
|
}
|
|
min={1000}
|
|
/>
|
|
<small>Memory will be compacted when it exceeds this character count</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="memoryAutoSummarizeSchedule">Schedule (cron)</label>
|
|
<input
|
|
id="memoryAutoSummarizeSchedule"
|
|
type="text"
|
|
className="input"
|
|
value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))
|
|
}
|
|
placeholder="0 3 * * *"
|
|
/>
|
|
<small>Cron expression for auto-summarize schedule (default: daily at 3 AM)</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
|
|
<input
|
|
id="memoryDreamsEnabled"
|
|
type="checkbox"
|
|
checked={form.memoryDreamsEnabled === true}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))
|
|
}
|
|
disabled={!isMemoryEnabled}
|
|
/>
|
|
Process dreams from daily memory
|
|
</label>
|
|
<small>Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.</small>
|
|
</div>
|
|
|
|
{isMemoryEnabled && form.memoryDreamsEnabled === true && (
|
|
<>
|
|
<div className="form-group">
|
|
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
|
<input
|
|
id="memoryDreamsSchedule"
|
|
type="text"
|
|
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))
|
|
}
|
|
/>
|
|
<small>Cron expression for dream processing.</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={onDreamNow}
|
|
disabled={dreamRunning || form.memoryDreamsEnabled !== true}
|
|
>
|
|
{dreamRunning ? (
|
|
<>
|
|
<Loader2 size={14} className="animate-spin" />
|
|
Dreaming…
|
|
</>
|
|
) : (
|
|
t("settings.memory.dreamNow", "Dream Now")
|
|
)}
|
|
</button>
|
|
<small>Manually trigger dream processing now.</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="memory-retrieval-test">
|
|
<div className="form-group">
|
|
<label htmlFor="memoryRetrievalQuery">Test Retrieval</label>
|
|
<input
|
|
id="memoryRetrievalQuery"
|
|
type="text"
|
|
value={memoryTestQuery}
|
|
onChange={(e) => setMemoryTestQuery(e.target.value)}
|
|
placeholder="Search memory with qmd"
|
|
/>
|
|
<small>Runs the same qmd-backed memory_search path agents use.</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={onTestMemoryRetrieval}
|
|
disabled={memoryTestLoading}
|
|
>
|
|
{memoryTestLoading ? t("settings.memory.testing", "Testing…") : t("settings.memory.testRetrieval", "Test Retrieval")}
|
|
</button>
|
|
</div>
|
|
{memoryTestResult && (
|
|
<div className="memory-test-result">
|
|
<strong>
|
|
{memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"}
|
|
{" "}for "{memoryTestResult.query}"
|
|
</strong>
|
|
<small>
|
|
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
|
|
</small>
|
|
{memoryTestResult.results.length > 0 ? (
|
|
<ul>
|
|
{memoryTestResult.results.map((result, index) => (
|
|
<li key={`${result.path}-${result.lineStart}-${index}`}>
|
|
<span>{result.path}:{result.lineStart}</span>
|
|
<p>{result.snippet}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<small>No matching memory found.</small>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!isMemoryEnabled && (
|
|
<div className="settings-empty-state memory-status-message">
|
|
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
|
|
</div>
|
|
)}
|
|
{isMemoryEnabled && backendStatusResolved && !isBackendWritable && (
|
|
<div className="settings-empty-state memory-status-message">
|
|
Memory is configured with a read-only backend. You can view the file, but saving is disabled.
|
|
</div>
|
|
)}
|
|
|
|
{memoryLoading ? (
|
|
<div className="settings-empty-state">Loading memory…</div>
|
|
) : (
|
|
<div className="memory-editor-section">
|
|
<div className="form-group">
|
|
<label htmlFor="memoryFilePath">Memory File</label>
|
|
<select
|
|
id="memoryFilePath"
|
|
value={selectedMemoryPath}
|
|
onChange={(e) => {
|
|
setSelectedMemoryPath(e.target.value);
|
|
setMemoryDirty(false);
|
|
}}
|
|
disabled={memoryDirty}
|
|
>
|
|
{memoryFiles.map((file) => (
|
|
<option key={file.path} value={file.path} title={`${file.label} — ${file.path}`}>
|
|
{formatMemoryFileOptionLabel(file)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small>
|
|
{memoryDirty
|
|
? "Save or discard the current edits before switching files."
|
|
: "Choose any project memory file to view or edit. Dreams is selected by default."}
|
|
</small>
|
|
</div>
|
|
{selectedMemoryFile && (
|
|
<div className="memory-file-summary">
|
|
<span>{memoryLayerNames[selectedMemoryFile.layer]}</span>
|
|
<strong>{selectedMemoryFile.path}</strong>
|
|
<small>
|
|
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
|
|
</small>
|
|
</div>
|
|
)}
|
|
<div className="form-group memory-editor-form-group">
|
|
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
|
|
<small>
|
|
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams."}
|
|
{selectedMemoryFile?.layer === "daily" && "Raw daily observations, open loops, and running context for dream processing."}
|
|
{selectedMemoryFile?.layer === "dreams" && "Synthesized patterns and open loops promoted from daily memory."}
|
|
{!selectedMemoryFile && "Edits the selected memory file."}
|
|
</small>
|
|
<div className="memory-editor-frame">
|
|
<FileEditor
|
|
content={memoryContent}
|
|
onChange={(content) => {
|
|
setMemoryContent(content);
|
|
setMemoryDirty(true);
|
|
}}
|
|
readOnly={!isEditingAllowed}
|
|
filePath={selectedMemoryPath}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!memoryLoading && (
|
|
<div className="form-group">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={onCompactMemory}
|
|
disabled={!isEditingAllowed || memoryDirty || memoryCompactLoading}
|
|
>
|
|
{memoryCompactLoading ? t("settings.memory.compacting", "Compacting…") : t("settings.memory.compactSelectedFile", "Compact Selected File")}
|
|
</button>
|
|
<small>
|
|
{memoryDirty
|
|
? "Save or discard edits before compacting this file."
|
|
: `Compacts ${selectedMemoryPath} and writes the result back to the same file.`}
|
|
</small>
|
|
</div>
|
|
)}
|
|
|
|
{memoryDirty && isEditingAllowed && (
|
|
<div className="form-group">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={onSaveMemory}
|
|
>
|
|
{t("settings.memory.saveMemory", "Save Memory")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{memoryDirty && !isEditingAllowed && (
|
|
<div className="form-group">
|
|
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default MemorySection;
|