fix(FN-2545): persist memory audit extraction state and polish MemoryView
- Persist extract-to-audit continuity state in memory insights so extraction progress survives follow-up runs - Add core and dashboard route tests that cover audit extraction flow and prevent regressions - Restore lint/typecheck baseline by removing obsolete planning subtask route wiring from dashboard routes - Apply MemoryView UX refinements with updated component logic and dedicated styling adjustments
This commit is contained in:
@@ -1100,6 +1100,31 @@ describe("memory-insights audit generation", () => {
|
||||
expect(report.extraction.duplicateCount).toBe(2);
|
||||
});
|
||||
|
||||
it("reuses persisted extraction state when explicit metadata is omitted", async () => {
|
||||
const runAt = new Date().toISOString();
|
||||
|
||||
writeFileSync(
|
||||
join(tempDir, MEMORY_WORKING_PATH),
|
||||
"## Architecture\n\nDurable notes\n\n## Conventions\n\nConventions",
|
||||
);
|
||||
|
||||
await processAndAuditInsightExtraction(tempDir, {
|
||||
rawResponse: JSON.stringify({
|
||||
summary: "Persisted extraction summary",
|
||||
insights: [{ category: "pattern", content: "Persisted insight" }],
|
||||
}),
|
||||
stepSuccess: true,
|
||||
runAt,
|
||||
});
|
||||
|
||||
const report = await generateMemoryAudit(tempDir);
|
||||
|
||||
expect(report.extraction.runAt).toBe(runAt);
|
||||
expect(report.extraction.success).toBe(true);
|
||||
expect(report.extraction.summary).toBe("Persisted extraction summary");
|
||||
expect(report.checks.find((c) => c.id === "recent-extraction")?.passed).toBe(true);
|
||||
});
|
||||
|
||||
it("should include pruning outcome in report", async () => {
|
||||
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
|
||||
/** Path to memory audit report relative to project root. */
|
||||
export const MEMORY_AUDIT_PATH = ".fusion/memory-audit.md";
|
||||
|
||||
/** Path to persisted memory audit state (latest extraction/pruning metadata). */
|
||||
export const MEMORY_AUDIT_STATE_PATH = ".fusion/memory-audit-state.json";
|
||||
|
||||
/** Default cron schedule for insight extraction: daily at 2 AM. */
|
||||
export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *";
|
||||
|
||||
@@ -167,6 +170,17 @@ export interface MemoryAuditCheck {
|
||||
details: string;
|
||||
}
|
||||
|
||||
/** Persisted extraction metadata used by audits/routes. */
|
||||
export interface MemoryExtractionMetadata {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of a memory audit run. */
|
||||
export interface MemoryAuditReport {
|
||||
/** ISO-8601 timestamp of the audit. */
|
||||
@@ -187,15 +201,7 @@ export interface MemoryAuditReport {
|
||||
lastUpdated?: string;
|
||||
};
|
||||
/** Extraction metadata. */
|
||||
extraction: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
};
|
||||
extraction: MemoryExtractionMetadata;
|
||||
/** Pruning operation outcome. */
|
||||
pruning: {
|
||||
applied: boolean;
|
||||
@@ -210,6 +216,13 @@ export interface MemoryAuditReport {
|
||||
health: "healthy" | "warning" | "issues";
|
||||
}
|
||||
|
||||
/** Persisted state for audit continuity across requests. */
|
||||
interface MemoryAuditState {
|
||||
extraction?: MemoryExtractionMetadata;
|
||||
pruning?: PruneOutcome;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for processing an insight extraction run. */
|
||||
export interface ProcessRunInput {
|
||||
/** Raw AI response text from the insight extraction step. */
|
||||
@@ -326,6 +339,81 @@ export async function writeMemoryAudit(rootDir: string, content: string): Promis
|
||||
await writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read persisted memory audit state (`memory-audit-state.json`).
|
||||
*
|
||||
* Returns `null` when no prior state exists.
|
||||
*/
|
||||
async function readMemoryAuditState(rootDir: string): Promise<MemoryAuditState | null> {
|
||||
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MemoryAuditState>;
|
||||
|
||||
const extraction = isValidExtractionMetadata(parsed.extraction) ? parsed.extraction : undefined;
|
||||
const pruning = isValidPruneOutcome(parsed.pruning) ? parsed.pruning : undefined;
|
||||
|
||||
return {
|
||||
extraction,
|
||||
pruning,
|
||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim()
|
||||
? parsed.updatedAt
|
||||
: new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist memory audit state (`memory-audit-state.json`).
|
||||
*/
|
||||
async function writeMemoryAuditState(rootDir: string, state: MemoryAuditState): Promise<void> {
|
||||
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
|
||||
const dir = join(rootDir, ".fusion");
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function isValidExtractionMetadata(value: unknown): value is MemoryExtractionMetadata {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<MemoryExtractionMetadata>;
|
||||
return (
|
||||
typeof candidate.runAt === "string" &&
|
||||
typeof candidate.success === "boolean" &&
|
||||
typeof candidate.insightCount === "number" &&
|
||||
typeof candidate.duplicateCount === "number" &&
|
||||
typeof candidate.skippedCount === "number" &&
|
||||
typeof candidate.summary === "string" &&
|
||||
(candidate.error === undefined || typeof candidate.error === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isValidPruneOutcome(value: unknown): value is PruneOutcome {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PruneOutcome>;
|
||||
return (
|
||||
typeof candidate.applied === "boolean" &&
|
||||
typeof candidate.reason === "string" &&
|
||||
typeof candidate.sizeDelta === "number" &&
|
||||
typeof candidate.originalSize === "number" &&
|
||||
typeof candidate.newSize === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Prompt Construction ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1103,20 +1191,19 @@ function countInsightsInMarkdown(markdown: string): number {
|
||||
*/
|
||||
export async function generateMemoryAudit(
|
||||
rootDir: string,
|
||||
lastExtraction?: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
},
|
||||
lastExtraction?: MemoryExtractionMetadata,
|
||||
pruningOutcome?: PruneOutcome,
|
||||
): Promise<MemoryAuditReport> {
|
||||
const checks: MemoryAuditCheck[] = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const persistedState =
|
||||
lastExtraction === undefined || pruningOutcome === undefined
|
||||
? await readMemoryAuditState(rootDir)
|
||||
: null;
|
||||
const effectiveExtraction = lastExtraction ?? persistedState?.extraction;
|
||||
const effectivePruning = pruningOutcome ?? persistedState?.pruning;
|
||||
|
||||
// ── Check 1: Working memory file presence ──────────────────────────
|
||||
const workingMemoryPath = join(rootDir, MEMORY_WORKING_PATH);
|
||||
const workingMemoryExists = existsSync(workingMemoryPath);
|
||||
@@ -1261,26 +1348,26 @@ export async function generateMemoryAudit(
|
||||
}
|
||||
|
||||
// ── Check 6: Recent extraction activity ───────────────────────────
|
||||
if (lastExtraction) {
|
||||
const extractionAge = Date.now() - new Date(lastExtraction.runAt).getTime();
|
||||
if (effectiveExtraction) {
|
||||
const extractionAge = Date.now() - new Date(effectiveExtraction.runAt).getTime();
|
||||
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
checks.push({
|
||||
id: "recent-extraction",
|
||||
name: "Recent extraction activity",
|
||||
passed: lastExtraction.success && extractionAge < oneWeekMs,
|
||||
details: lastExtraction.success
|
||||
? `Last successful extraction ${formatTimeAgo(lastExtraction.runAt)} (${lastExtraction.insightCount} insights, ${lastExtraction.duplicateCount} duplicates skipped)`
|
||||
: `Last extraction failed: ${lastExtraction.error || "Unknown error"}`,
|
||||
passed: effectiveExtraction.success && extractionAge < oneWeekMs,
|
||||
details: effectiveExtraction.success
|
||||
? `Last successful extraction ${formatTimeAgo(effectiveExtraction.runAt)} (${effectiveExtraction.insightCount} insights, ${effectiveExtraction.duplicateCount} duplicates skipped)`
|
||||
: `Last extraction failed: ${effectiveExtraction.error || "Unknown error"}`,
|
||||
});
|
||||
|
||||
// Check 7: Extraction summary quality
|
||||
checks.push({
|
||||
id: "extraction-summary",
|
||||
name: "Extraction produces meaningful summaries",
|
||||
passed: lastExtraction.success && lastExtraction.summary.length > 10,
|
||||
details: lastExtraction.success
|
||||
? `Summary: "${lastExtraction.summary.slice(0, 100)}${lastExtraction.summary.length > 100 ? "..." : ""}"`
|
||||
passed: effectiveExtraction.success && effectiveExtraction.summary.length > 10,
|
||||
details: effectiveExtraction.success
|
||||
? `Summary: "${effectiveExtraction.summary.slice(0, 100)}${effectiveExtraction.summary.length > 100 ? "..." : ""}"`
|
||||
: "No meaningful summary available",
|
||||
});
|
||||
} else {
|
||||
@@ -1293,14 +1380,14 @@ export async function generateMemoryAudit(
|
||||
}
|
||||
|
||||
// ── Check 8: Pruning outcome ──────────────────────────────────────
|
||||
if (pruningOutcome) {
|
||||
if (effectivePruning) {
|
||||
checks.push({
|
||||
id: "pruning-applied",
|
||||
name: "Memory pruning outcome",
|
||||
passed: pruningOutcome.applied,
|
||||
details: pruningOutcome.applied
|
||||
? `Pruning applied: ${pruningOutcome.originalSize} → ${pruningOutcome.newSize} chars (${pruningOutcome.sizeDelta >= 0 ? "+" : ""}${pruningOutcome.sizeDelta} chars)`
|
||||
: `Pruning skipped: ${pruningOutcome.reason}`,
|
||||
passed: effectivePruning.applied,
|
||||
details: effectivePruning.applied
|
||||
? `Pruning applied: ${effectivePruning.originalSize} → ${effectivePruning.newSize} chars (${effectivePruning.sizeDelta >= 0 ? "+" : ""}${effectivePruning.sizeDelta} chars)`
|
||||
: `Pruning skipped: ${effectivePruning.reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1332,15 +1419,15 @@ export async function generateMemoryAudit(
|
||||
categories: categoryCounts,
|
||||
lastUpdated,
|
||||
},
|
||||
extraction: lastExtraction
|
||||
extraction: effectiveExtraction
|
||||
? {
|
||||
runAt: lastExtraction.runAt,
|
||||
success: lastExtraction.success,
|
||||
insightCount: lastExtraction.insightCount,
|
||||
duplicateCount: lastExtraction.duplicateCount,
|
||||
skippedCount: lastExtraction.skippedCount,
|
||||
summary: lastExtraction.summary,
|
||||
error: lastExtraction.error,
|
||||
runAt: effectiveExtraction.runAt,
|
||||
success: effectiveExtraction.success,
|
||||
insightCount: effectiveExtraction.insightCount,
|
||||
duplicateCount: effectiveExtraction.duplicateCount,
|
||||
skippedCount: effectiveExtraction.skippedCount,
|
||||
summary: effectiveExtraction.summary,
|
||||
error: effectiveExtraction.error,
|
||||
}
|
||||
: {
|
||||
runAt: "",
|
||||
@@ -1350,7 +1437,7 @@ export async function generateMemoryAudit(
|
||||
skippedCount: 0,
|
||||
summary: "No extraction runs recorded",
|
||||
},
|
||||
pruning: pruningOutcome ?? {
|
||||
pruning: effectivePruning ?? {
|
||||
applied: false,
|
||||
reason: "No pruning run recorded",
|
||||
sizeDelta: 0,
|
||||
@@ -1536,15 +1623,7 @@ export async function processAndAuditInsightExtraction(
|
||||
input: ProcessRunInput,
|
||||
): Promise<MemoryAuditReport> {
|
||||
// Track extraction info for the audit
|
||||
let extractionInfo: {
|
||||
runAt: string;
|
||||
success: boolean;
|
||||
insightCount: number;
|
||||
duplicateCount: number;
|
||||
skippedCount: number;
|
||||
summary: string;
|
||||
error?: string;
|
||||
};
|
||||
let extractionInfo: MemoryExtractionMetadata;
|
||||
let pruneOutcome: PruneOutcome | undefined;
|
||||
|
||||
try {
|
||||
@@ -1585,6 +1664,19 @@ export async function processAndAuditInsightExtraction(
|
||||
};
|
||||
}
|
||||
|
||||
// Persist latest extraction/pruning state for future audit reads (best-effort)
|
||||
try {
|
||||
await writeMemoryAuditState(rootDir, {
|
||||
extraction: extractionInfo,
|
||||
pruning: pruneOutcome,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[memory-audit] Failed to persist audit state: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate the audit report with pruning info
|
||||
const auditReport = await generateMemoryAudit(rootDir, extractionInfo, pruneOutcome);
|
||||
|
||||
|
||||
@@ -175,6 +175,102 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memory-empty-extract-button {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.memory-stat-value--updated {
|
||||
font-size: var(--space-lg);
|
||||
}
|
||||
|
||||
.memory-capability-row {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.memory-emphasis-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-health-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.memory-health-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.memory-health-label {
|
||||
font-size: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.memory-health-detail {
|
||||
font-size: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-health-section {
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.memory-status-text--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.memory-status-text--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.memory-status-text--warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.memory-status-text--muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-audit-check-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-settings-note {
|
||||
margin-top: var(--space-lg);
|
||||
font-size: var(--space-md);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.memory-settings-note-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.memory-settings-note-button:focus-visible {
|
||||
outline: none;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.memory-empty-state {
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-2xl);
|
||||
@@ -279,6 +375,10 @@
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.memory-flex-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.memory-qmd-card {
|
||||
border-style: solid;
|
||||
}
|
||||
@@ -346,6 +446,10 @@
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.memory-health-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.memory-config-section {
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
|
||||
@@ -462,7 +462,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
|
||||
<div className="memory-action-bar">
|
||||
<span className="memory-char-count">{selectedFileContent.length} characters</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div className="memory-flex-spacer" />
|
||||
{isWritable && selectedFileContent.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -681,10 +681,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
className="btn btn-primary btn-sm memory-empty-extract-button"
|
||||
onClick={handleExtractInsights}
|
||||
disabled={extracting}
|
||||
style={{ marginTop: "var(--space-md)" }}
|
||||
>
|
||||
{extracting ? (
|
||||
<>
|
||||
@@ -710,7 +709,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<div className="memory-stat-card">
|
||||
<div className="memory-stat-value" style={{ fontSize: "16px" }}>{lastUpdated}</div>
|
||||
<div className="memory-stat-value memory-stat-value--updated">{lastUpdated}</div>
|
||||
<div className="memory-stat-label">Last Updated</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -819,7 +818,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
<span className="memory-char-count">Checking qmd availability…</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
|
||||
<div className="memory-capability-row">
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">Readable</span>
|
||||
)}
|
||||
@@ -888,9 +887,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
<div className="memory-engine-card">
|
||||
<h3>Current Backend</h3>
|
||||
<div className="memory-engine-status">
|
||||
<span style={{ fontWeight: 500 }}>{getBackendDisplayName(backendStatus?.currentBackend ?? "unknown")}</span>
|
||||
<span className="memory-emphasis-text">{getBackendDisplayName(backendStatus?.currentBackend ?? "unknown")}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
|
||||
<div className="memory-capability-row">
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">Readable</span>
|
||||
)}
|
||||
@@ -909,63 +908,55 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{/* Health Status Card */}
|
||||
{auditReport && (
|
||||
<div className="memory-engine-card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-md)" }}>
|
||||
<div className="memory-health-header">
|
||||
<h3>Health Status</h3>
|
||||
<span className={`memory-health-badge memory-health-badge--${auditReport.health}`}>
|
||||
{getHealthBadgeText(auditReport.health)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
|
||||
<div className="memory-health-grid">
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
|
||||
Working Memory
|
||||
</div>
|
||||
<div style={{ fontWeight: 500 }}>{auditReport.workingMemory.size} chars</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
|
||||
<div className="memory-health-label">Working Memory</div>
|
||||
<div className="memory-emphasis-text">{auditReport.workingMemory.size} chars</div>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.workingMemory.sectionCount} sections
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
|
||||
Insights Memory
|
||||
</div>
|
||||
<div style={{ fontWeight: 500 }}>{auditReport.insightsMemory.size} chars</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
|
||||
<div className="memory-health-label">Insights Memory</div>
|
||||
<div className="memory-emphasis-text">{auditReport.insightsMemory.size} chars</div>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.insightsMemory.insightCount} insights
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "var(--space-md)", paddingTop: "var(--space-md)", borderTop: "1px solid var(--border)" }}>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
|
||||
Last Extraction
|
||||
</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
<div className="memory-health-section">
|
||||
<div className="memory-health-label">Last Extraction</div>
|
||||
<div className="memory-emphasis-text">
|
||||
{auditReport.extraction.success ? (
|
||||
<span style={{ color: "var(--color-success)" }}>Success</span>
|
||||
<span className="memory-status-text memory-status-text--success">Success</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--color-error)" }}>Failed</span>
|
||||
<span className="memory-status-text memory-status-text--error">Failed</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.extraction.summary || `${auditReport.extraction.insightCount} insights extracted`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "var(--space-md)", paddingTop: "var(--space-md)", borderTop: "1px solid var(--border)" }}>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
|
||||
Pruning
|
||||
</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
<div className="memory-health-section">
|
||||
<div className="memory-health-label">Pruning</div>
|
||||
<div className="memory-emphasis-text">
|
||||
{auditReport.pruning.applied ? (
|
||||
<span style={{ color: "var(--color-warning)" }}>Applied</span>
|
||||
<span className="memory-status-text memory-status-text--warning">Applied</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-muted)" }}>Not needed</span>
|
||||
<span className="memory-status-text memory-status-text--muted">Not needed</span>
|
||||
)}
|
||||
</div>
|
||||
{auditReport.pruning.applied && (
|
||||
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.pruning.reason}
|
||||
</div>
|
||||
)}
|
||||
@@ -983,9 +974,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
<span className={check.passed ? "memory-audit-check-passed" : "memory-audit-check-failed"}>
|
||||
{check.passed ? "✓" : "✗"}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>{check.name}</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>{check.details}</div>
|
||||
<div className="memory-audit-check-content">
|
||||
<div className="memory-emphasis-text">{check.name}</div>
|
||||
<div className="memory-health-detail">{check.details}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1005,10 +996,11 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
|
||||
{/* Note about Settings */}
|
||||
<div style={{ marginTop: "var(--space-lg)", fontSize: "13px", color: "var(--text-muted)" }}>
|
||||
Note: Change backend type in{' '}
|
||||
<span
|
||||
style={{ cursor: "pointer", textDecoration: "underline" }}
|
||||
<div className="memory-settings-note">
|
||||
<span>Note: Change backend type in</span>
|
||||
<button
|
||||
type="button"
|
||||
className="memory-settings-note-button"
|
||||
onClick={() => {
|
||||
// This would open the settings modal with memory section focused
|
||||
// For now, just add a toast hint
|
||||
@@ -1016,7 +1008,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
}}
|
||||
>
|
||||
Settings → Memory
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14118,6 +14118,31 @@ describe("GET /api/memory/audit", () => {
|
||||
expect(res.body.workingMemory).toHaveProperty("size");
|
||||
expect(res.body.workingMemory).toHaveProperty("sectionCount");
|
||||
});
|
||||
|
||||
it("preserves extraction metadata across extract then audit requests", async () => {
|
||||
writeFileSync(
|
||||
join(rootDir, ".fusion", "memory", "MEMORY.md"),
|
||||
"## Architecture\n\nDurable architecture\n\n## Conventions\n\nDurable conventions\n\n## Pitfalls\n\nDurable pitfalls",
|
||||
);
|
||||
|
||||
const extractRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/memory/extract",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(extractRes.status).toBe(200);
|
||||
expect(extractRes.body.success).toBe(true);
|
||||
|
||||
const auditRes = await GET(buildApp(), "/api/memory/audit");
|
||||
|
||||
expect(auditRes.status).toBe(200);
|
||||
expect(auditRes.body.extraction.runAt).toBeTruthy();
|
||||
expect(auditRes.body.extraction.summary).not.toBe("No extraction runs recorded");
|
||||
expect(auditRes.body.checks.find((check: { id: string; details: string }) => check.id === "recent-extraction")?.details).not.toContain("No extraction runs recorded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/memory/stats", () => {
|
||||
|
||||
@@ -2147,61 +2147,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const heartbeatMonitor = options?.heartbeatMonitor;
|
||||
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
||||
|
||||
const REMOTE_MIN_TTL_MS = 60_000;
|
||||
const REMOTE_MAX_TTL_MS = 86_400_000;
|
||||
const remoteShortLivedTokens = new Map<string, { expiresAt: number }>();
|
||||
|
||||
function generateRemoteToken(): string {
|
||||
return `rtok_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function maskRemoteToken(token: string): string {
|
||||
if (token.length <= 8) return "********";
|
||||
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
async function ensurePersistentRemoteToken(scopedStore: TaskStore): Promise<string> {
|
||||
const settings = await scopedStore.getSettings();
|
||||
const existing = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
|
||||
if (existing) return existing;
|
||||
const token = generateRemoteToken();
|
||||
await scopedStore.updateSettings({ remotePersistentToken: token });
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveRemoteOrigin(req: Request): string {
|
||||
const protocol = req.protocol || "http";
|
||||
const hostHeader = req.get("host") ?? "127.0.0.1:4040";
|
||||
return `${protocol}://${hostHeader}`;
|
||||
}
|
||||
|
||||
async function buildRemoteUrlForTokenType(
|
||||
scopedStore: TaskStore,
|
||||
req: Request,
|
||||
tokenType: "persistent" | "short-lived",
|
||||
ttlMs?: number,
|
||||
): Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
const baseUrl = new URL(resolveRemoteOrigin(req));
|
||||
let token: string;
|
||||
let expiresAt: string | null = null;
|
||||
|
||||
if (tokenType === "short-lived") {
|
||||
const ttl = Math.floor(Number(ttlMs ?? 900_000));
|
||||
if (!Number.isFinite(ttl) || ttl < REMOTE_MIN_TTL_MS || ttl > REMOTE_MAX_TTL_MS) {
|
||||
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
|
||||
}
|
||||
token = generateRemoteToken();
|
||||
const expiryMs = Date.now() + ttl;
|
||||
remoteShortLivedTokens.set(token, { expiresAt: expiryMs });
|
||||
expiresAt = new Date(expiryMs).toISOString();
|
||||
} else {
|
||||
token = await ensurePersistentRemoteToken(scopedStore);
|
||||
}
|
||||
|
||||
baseUrl.searchParams.set("token", token);
|
||||
return { url: baseUrl.toString(), tokenType, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
|
||||
* Returns false when the monitor's rootDir is set and differs from the store's root.
|
||||
|
||||
@@ -14,7 +14,7 @@ interface PlanningSubtaskRouteDeps {
|
||||
|
||||
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
||||
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
||||
const { store, aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
|
||||
const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
|
||||
|
||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||
// UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane.
|
||||
@@ -185,7 +185,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
}
|
||||
|
||||
// Fetch parent task to inherit model settings if parentTaskId is provided
|
||||
let parentTask: Awaited<ReturnType<typeof store.getTask>> | undefined;
|
||||
let parentTask: Awaited<ReturnType<TaskStore["getTask"]>> | undefined;
|
||||
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
|
||||
try {
|
||||
parentTask = await scopedStore.getTask(parentTaskId);
|
||||
@@ -195,7 +195,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
}
|
||||
}
|
||||
|
||||
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
|
||||
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
|
||||
const tempIdToTaskId = new Map<string, string>();
|
||||
|
||||
for (const item of subtasks) {
|
||||
@@ -912,7 +912,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
}
|
||||
}
|
||||
|
||||
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
|
||||
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
|
||||
const tempIdToTaskId = new Map<string, string>();
|
||||
|
||||
// Create tasks
|
||||
|
||||
Reference in New Issue
Block a user