feat(FN-2000): add memory auto-summarize settings and automation sync

- Add Memory section controls for enabling auto-summarize with threshold and cron schedule inputs
- Wire ProjectEngine to sync auto-summarize automation on startup and when related settings change
- Reuse a single startup settings snapshot when syncing insight extraction and auto-summarize automations
- Add SettingsModal and ProjectEngine tests covering auto-summarize UI persistence and automation re-sync behavior
This commit is contained in:
Fusion
2026-04-17 09:08:26 -07:00
committed by gsxdsm
parent 3a9c56fda3
commit 28accdb085
19 changed files with 1805 additions and 60 deletions

View File

@@ -45,6 +45,12 @@ export interface ChatSession {
*/
export type ChatSessionSummary = ChatSession;
/** A parsed @ mention of an agent in a chat message */
export interface ChatMention {
agentId: string;
agentName: string;
}
/**
* A single message within a chat session.
*/

View File

@@ -420,6 +420,7 @@ export {
ensureMemoryFileWithBackend,
buildTriageMemoryInstructions,
buildExecutionMemoryInstructions,
buildReviewerMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
@@ -438,10 +439,17 @@ export {
MEMORY_LONG_TERM_FILENAME,
MEMORY_DREAMS_FILENAME,
LEGACY_MEMORY_FILE_PATH,
QMD_INSTALL_COMMAND,
QMD_REFRESH_INTERVAL_MS,
memoryWorkspacePath,
memoryLongTermPath,
memoryDreamsPath,
qmdMemoryCollectionName,
buildQmdSearchArgs,
buildQmdCollectionAddArgs,
buildQmdRefreshCommands,
refreshQmdProjectMemoryIndex,
scheduleQmdProjectMemoryRefresh,
dailyMemoryPath,
getDefaultLongTermMemoryScaffold,
getDefaultDailyMemoryScaffold,
@@ -464,7 +472,6 @@ export {
memoryExists,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
QMD_INSTALL_COMMAND,
isQmdAvailable,
} from "./memory-backend.js";
@@ -568,6 +575,7 @@ export type {
ChatMessageRole,
ChatSession,
ChatSessionSummary,
ChatMention,
ChatMessage,
ChatMessageCreateInput,
ChatSessionCreateInput,

View File

@@ -18,7 +18,8 @@ export const MEMORY_WORKSPACE_PATH = ".fusion/memory";
export const MEMORY_LONG_TERM_FILENAME = "MEMORY.md";
export const MEMORY_DREAMS_FILENAME = "DREAMS.md";
export const LEGACY_MEMORY_FILE_PATH = ".fusion/memory.md";
export const QMD_INSTALL_COMMAND = "bun add -g qmd";
export const QMD_INSTALL_COMMAND = "bun install -g @tobilu/qmd";
export const QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const DAILY_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
const MAX_MEMORY_SNIPPET_CHARS = 700;
@@ -26,6 +27,14 @@ const DEFAULT_MEMORY_GET_LINES = 120;
const MAX_MEMORY_GET_LINES = 400;
const QMD_COLLECTION_PREFIX = "fusion-memory";
type ExecFileAsync = (
file: string,
args: readonly string[],
options?: { cwd?: string; timeout?: number; maxBuffer?: number },
) => Promise<{ stdout: string; stderr: string }>;
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
// ── Type Definitions ────────────────────────────────────────────────
/**
@@ -420,6 +429,7 @@ export class QmdMemoryBackend implements MemoryBackend {
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
// Delegate to file backend, but return "qmd" as the backend identifier
const result = await this.fileBackend.write(rootDir, content);
scheduleQmdProjectMemoryRefresh(rootDir);
return {
...result,
backend: this.type,
@@ -485,6 +495,26 @@ export function buildQmdSearchArgs(rootDir: string, options: MemorySearchOptions
];
}
export function buildQmdCollectionAddArgs(rootDir: string): string[] {
return [
"collection",
"add",
memoryWorkspacePath(rootDir),
"--name",
qmdMemoryCollectionName(rootDir),
"--mask",
"**/*.md",
];
}
export function buildQmdRefreshCommands(rootDir: string): string[][] {
return [
buildQmdCollectionAddArgs(rootDir),
["update"],
["embed"],
];
}
export function dailyMemoryPath(rootDir: string, date = new Date()): string {
return join(memoryWorkspacePath(rootDir), `${date.toISOString().slice(0, 10)}.md`);
}
@@ -774,6 +804,52 @@ async function searchMemoryFiles(rootDir: string, options: MemorySearchOptions,
.slice(0, limit);
}
function normalizeQmdSearchResultPath(rootDir: string, rawPath: unknown): string {
const original = String(rawPath ?? "").trim();
if (!original) {
return "";
}
let candidate = original.replace(/\\/g, "/");
const uriMatch = candidate.match(/^qmd:\/\/[^/]+\/(.+)$/i);
if (uriMatch?.[1]) {
candidate = uriMatch[1];
}
candidate = candidate.split("?")[0]?.split("#")[0] ?? "";
candidate = candidate.replace(/^\.\/+/, "");
if (isAbsolute(candidate)) {
const rel = relative(resolve(rootDir), resolve(candidate)).replace(/\\/g, "/");
if (rel && rel !== "." && rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel)) {
candidate = rel;
}
}
const lowerCandidate = candidate.toLowerCase();
const legacyLower = LEGACY_MEMORY_FILE_PATH.toLowerCase();
if (lowerCandidate === legacyLower || lowerCandidate.endsWith(`/${legacyLower}`)) {
return LEGACY_MEMORY_FILE_PATH;
}
const normalizedBaseName = basename(candidate).toLowerCase();
if (normalizedBaseName === MEMORY_LONG_TERM_FILENAME.toLowerCase()) {
return `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}`;
}
if (normalizedBaseName === MEMORY_DREAMS_FILENAME.toLowerCase()) {
return `${MEMORY_WORKSPACE_PATH}/${MEMORY_DREAMS_FILENAME}`;
}
if (DAILY_MEMORY_RE.test(normalizedBaseName)) {
return `${MEMORY_WORKSPACE_PATH}/${normalizedBaseName}`;
}
try {
return normalizeMemoryRequestPath(candidate);
} catch {
return original;
}
}
async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
const command = "qmd";
const limit = Math.max(1, Math.min(options.limit ?? 5, 20));
@@ -782,6 +858,7 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await ensureQmdProjectMemoryCollection(rootDir, execFileAsync);
scheduleQmdProjectMemoryRefresh(rootDir);
const args = buildQmdSearchArgs(rootDir, options);
const { stdout } = await execFileAsync(command, args, {
cwd: rootDir,
@@ -790,14 +867,23 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
});
const parsed = JSON.parse(stdout);
const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : [];
return rawResults.slice(0, limit).map((result: Record<string, unknown>, index: number) => ({
path: String(result.path ?? result.file ?? `qmd/result-${index + 1}`),
lineStart: Number(result.lineStart ?? result.startLine ?? 1),
lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1),
snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(0, MAX_MEMORY_SNIPPET_CHARS),
score: Number(result.score ?? 1),
backend: "qmd",
})).filter((result: MemorySearchResult) => result.snippet.trim().length > 0);
return rawResults
.slice(0, limit)
.map((result: Record<string, unknown>, index: number) => {
const rawPath = result.path ?? result.file ?? `qmd/result-${index + 1}`;
return {
path: normalizeQmdSearchResultPath(rootDir, rawPath) || String(rawPath),
lineStart: Number(result.lineStart ?? result.startLine ?? 1),
lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1),
snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(
0,
MAX_MEMORY_SNIPPET_CHARS,
),
score: Number(result.score ?? 1),
backend: "qmd",
};
})
.filter((result: MemorySearchResult) => result.snippet.trim().length > 0);
} catch {
return [];
}
@@ -805,18 +891,14 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
async function ensureQmdProjectMemoryCollection(
rootDir: string,
execFileAsync: (
file: string,
args: readonly string[],
options?: { cwd?: string; timeout?: number; maxBuffer?: number },
) => Promise<{ stdout: string; stderr: string }>,
execFileAsync: ExecFileAsync,
): Promise<string> {
const collectionName = qmdMemoryCollectionName(rootDir);
const memoryDir = memoryWorkspacePath(rootDir);
await mkdir(memoryDir, { recursive: true });
try {
await execFileAsync("qmd", ["collection", "add", memoryDir, "--name", collectionName, "--mask", "**/*.md"], {
await execFileAsync("qmd", buildQmdCollectionAddArgs(rootDir), {
cwd: rootDir,
timeout: 4000,
maxBuffer: 512 * 1024,
@@ -832,6 +914,62 @@ async function ensureQmdProjectMemoryCollection(
return collectionName;
}
async function getDefaultExecFileAsync(): Promise<ExecFileAsync> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
return promisify(execFile);
}
export async function refreshQmdProjectMemoryIndex(
rootDir: string,
options?: { force?: boolean; execFileAsync?: ExecFileAsync },
): Promise<void> {
const key = resolve(rootDir);
const now = Date.now();
const current = qmdRefreshState.get(key);
if (!options?.force) {
if (current?.inFlight) {
return current.inFlight;
}
if (current && now - current.lastStartedAt < QMD_REFRESH_INTERVAL_MS) {
return;
}
}
const promise = (async () => {
const execFileAsync = options?.execFileAsync ?? await getDefaultExecFileAsync();
await ensureQmdProjectMemoryCollection(rootDir, execFileAsync);
await execFileAsync("qmd", ["update"], {
cwd: rootDir,
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
await execFileAsync("qmd", ["embed"], {
cwd: rootDir,
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
})();
qmdRefreshState.set(key, { lastStartedAt: now, inFlight: promise });
try {
await promise;
} finally {
const latest = qmdRefreshState.get(key);
if (latest?.inFlight === promise) {
qmdRefreshState.set(key, { lastStartedAt: latest.lastStartedAt });
}
}
}
export function scheduleQmdProjectMemoryRefresh(rootDir: string): void {
void refreshQmdProjectMemoryIndex(rootDir).catch(() => {
// qmd is optional. Search falls back to local file scanning when refresh fails.
});
}
export async function isQmdAvailable(): Promise<boolean> {
try {
const { execFile } = await import("node:child_process");

View File

@@ -120,6 +120,7 @@ async function getMemoryBackendUtils() {
getMemoryBackendCapabilities: module.getMemoryBackendCapabilities,
MEMORY_BACKEND_SETTINGS_KEYS: module.MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND: module.DEFAULT_MEMORY_BACKEND,
scheduleQmdProjectMemoryRefresh: module.scheduleQmdProjectMemoryRefresh,
};
}
@@ -239,13 +240,22 @@ export async function ensureMemoryFileWithBackend(
rootDir: string,
settings?: MemorySettings,
): Promise<boolean> {
const { resolveMemoryBackend, MEMORY_BACKEND_SETTINGS_KEYS, DEFAULT_MEMORY_BACKEND } =
await getMemoryBackendUtils();
const {
resolveMemoryBackend,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
scheduleQmdProjectMemoryRefresh,
} = await getMemoryBackendUtils();
const backendType =
(settings?.[MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE] as string) ||
DEFAULT_MEMORY_BACKEND;
const backend = resolveMemoryBackend(settings);
const refreshQmdIfNeeded = () => {
if (backend.type === "qmd" || backendType === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
};
// Check if memory already exists using the backend
if (backend.exists) {
@@ -260,6 +270,7 @@ export async function ensureMemoryFileWithBackend(
}
}
}
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
} else {
@@ -275,6 +286,7 @@ export async function ensureMemoryFileWithBackend(
}
}
}
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
}
@@ -295,6 +307,7 @@ export async function ensureMemoryFileWithBackend(
// Try to write using the backend
try {
const result = await backend.write(rootDir, getDefaultMemoryScaffold());
refreshQmdIfNeeded();
return result.success;
} catch (err) {
// Non-writable backends (readonly) don't throw during bootstrap
@@ -547,6 +560,30 @@ This project has a memory system that stores durable project learnings accumulat
`;
}
export function buildReviewerMemoryInstructions(
rootDir: string,
settings?: MemorySettings,
): string {
void rootDir;
const ctx = resolveMemoryInstructionContext(settings);
if (!ctx.capabilities.readable) {
return "";
}
return `
## Project Memory
This project has a memory system that stores durable project learnings.
**During review:**
1. Use \`memory_search\` for task-relevant project conventions, pitfalls, and prior decisions when they could affect your verdict
2. Use \`memory_get\` only for specific memory files/line ranges returned by search
3. Treat documented durable conventions and pitfalls as review evidence when deciding APPROVE, REVISE, or RETHINK
4. Do not update memory during review; reviewer memory access is read-only
5. Skip memory reads when they are not relevant to the reviewed plan or code
`;
}
/**
* Read the project memory file content.
*