feat(memory): add extraction diagnostics

This commit is contained in:
Mikael Hugo 2026-05-15 16:53:01 +02:00
parent fdc4650016
commit 6214f7c86d
9 changed files with 717 additions and 96 deletions

View file

@ -15,6 +15,7 @@ import {
resolveExecutorContextWindow,
truncateAtSectionBoundary,
} from "./context-budget.js";
import { getErrorMessage } from "./error-utils.js";
import {
formatOverridesSection,
loadActiveOverrides,
@ -66,6 +67,7 @@ import {
isDbAvailable,
} from "./sf-db.js";
import { warnIfManifestHasMissingSkills } from "./skill-manifest.js";
import { loadSkills } from "./skills/index.js";
import {
formatDecisionsCompact,
formatRequirementsCompact,
@ -76,15 +78,16 @@ import {
getDependencyTaskSummaryPaths,
getPriorTaskSummaryPaths,
} from "./summary-helpers.js";
import { composeInlinedContext, composeUnitContext } from "./unit-context-composer.js";
import {
composeInlinedContext,
composeUnitContext,
} from "./unit-context-composer.js";
import { getUatType } from "./verdict-parser.js";
import {
buildCarryForwardSection,
buildResumeSection,
} from "./workflow-helpers.js";
import { logWarning } from "./workflow-logger.js";
import { getErrorMessage } from "./error-utils.js";
import { loadSkills } from "./skills/index.js";
// ─── Preamble Cap ─────────────────────────────────────────────────────────────
/**
@ -502,10 +505,7 @@ export async function inlineProjectFromDb(base) {
}
}
} catch (err) {
logWarning(
"prompt",
`inlineProjectFromDb failed: ${getErrorMessage(err)}`,
);
logWarning("prompt", `inlineProjectFromDb failed: ${getErrorMessage(err)}`);
}
return inlineSfRootFile(base, "project.md", "Project");
}
@ -851,7 +851,9 @@ function buildWorkflowConstraintsBlock(params) {
);
workflowSkills = allPatternSkills.filter(
(skill) =>
!ALWAYS_ON_WORKFLOW_SKILL_NAMES.has(normalizeSkillReference(skill.name)),
!ALWAYS_ON_WORKFLOW_SKILL_NAMES.has(
normalizeSkillReference(skill.name),
),
);
} catch {
return "";
@ -1026,10 +1028,7 @@ export function buildSkillActivationBlock(params) {
matched.add(normalizeSkillReference(skillName));
}
} catch (err) {
logWarning(
"prompt",
`parseTaskPlanFile failed: ${getErrorMessage(err)}`,
);
logWarning("prompt", `parseTaskPlanFile failed: ${getErrorMessage(err)}`);
}
}
const ordered = [...matched]
@ -1048,16 +1047,14 @@ export function buildSkillActivationBlock(params) {
} catch {
// getAutoSession may be unavailable in test contexts — use defaults
}
const workflowBlock = buildWorkflowConstraintsBlock(
{
const workflowBlock = buildWorkflowConstraintsBlock({
base: params.base,
contextTokens,
explicitSkillNames: matched,
avoidedSkillNames: avoided,
workMode,
permissionProfile,
},
);
});
return userSkillBlock + workflowBlock;
}
@ -1441,10 +1438,16 @@ export async function buildResearchSlicePrompt(
case "roadmap": {
// Excerpt with full-roadmap fallback for context reduction.
const excerpt = await inlineRoadmapExcerpt(base, mid, sid);
return excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
return (
excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")
);
}
case "milestone-context":
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
return inlineFileOptional(
contextPath,
contextRel,
"Milestone Context",
);
case "slice-context":
return inlineFileOptional(
sliceContextPath,
@ -1485,7 +1488,8 @@ export async function buildResearchSlicePrompt(
},
},
});
const parts = [prepend, inline].filter(Boolean);
const memorySection = await buildProjectMemoriesSection(`${sid} ${sTitle}`);
const parts = [prepend, memorySection, inline].filter(Boolean);
const inlinedContext = capPreamble(
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
);
@ -1513,6 +1517,28 @@ export async function buildResearchSlicePrompt(
...buildSkillDiscoveryVars(),
});
}
/**
* Build a prompt-ready Project Memories section.
*
* Purpose: keep memory injection consistent across planning, research, and
* execution surfaces while preserving query-aware ranking when available.
*
* Consumer: autonomous prompt builders for research-slice, plan-slice, and
* execute-task.
*/
async function buildProjectMemoriesSection(query, limit = 10) {
const memoryQuery = String(query ?? "").trim();
try {
const usingRanker = !!memoryQuery;
const memories = usingRanker
? await getRelevantMemoriesRanked(memoryQuery, limit)
: getActiveMemoriesRanked(limit);
if (memories.length === 0) return "## Project Memories\n(none yet)";
return `## Project Memories\n${formatMemoriesForPrompt(memories, 2000, usingRanker)}`;
} catch {
return "## Project Memories\n(unavailable)";
}
}
/**
* Shared assembly for plan-slice and refine-slice prompts. Both builders need
* the same inlined context (roadmap excerpt, slice context, research, decisions,
@ -1547,7 +1573,10 @@ async function renderSlicePrompt(options) {
// the overrides prepend and the inline artifacts.
const researchSliceAnchor = readPhaseAnchor(base, mid, "research-slice");
const prefixBlocks = [...prependBlocks];
if (researchSliceAnchor) prefixBlocks.push(formatAnchorForPrompt(researchSliceAnchor));
const memorySection = await buildProjectMemoriesSection(`${sid} ${sTitle}`);
prefixBlocks.push(memorySection);
if (researchSliceAnchor)
prefixBlocks.push(formatAnchorForPrompt(researchSliceAnchor));
const prefixContent =
prefixBlocks.length > 0 ? prefixBlocks.join("\n\n---\n\n") : null;
const depContent = await inlineDependencySummaries(
@ -1563,7 +1592,9 @@ async function renderSlicePrompt(options) {
case "roadmap": {
// Excerpt with full-roadmap fallback for context reduction.
const excerpt = await inlineRoadmapExcerpt(base, mid, sid);
return excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
return (
excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap")
);
}
case "slice-context":
return inlineFileOptional(
@ -1572,7 +1603,11 @@ async function renderSlicePrompt(options) {
"Slice Context (from discussion)",
);
case "slice-research":
return inlineFileOptional(researchPath, researchRel, "Slice Research");
return inlineFileOptional(
researchPath,
researchRel,
"Slice Research",
);
case "decisions":
if (level === "minimal") return null;
return inlineDecisionsFromDb(
@ -1586,7 +1621,8 @@ async function renderSlicePrompt(options) {
return inlineRequirementsFromDb(base, mid, sid, level);
case "templates": {
const tplParts = [inlineTemplate("plan", "Slice Plan")];
if (level === "full") tplParts.push(inlineTemplate("task-plan", "Task Plan"));
if (level === "full")
tplParts.push(inlineTemplate("task-plan", "Task Plan"));
return tplParts.join("\n\n---\n\n");
}
default:
@ -1600,8 +1636,7 @@ async function renderSlicePrompt(options) {
inputs: {},
},
knowledge: {
build: async ({ keywords }, b) =>
inlineKnowledgeScoped(b, keywords),
build: async ({ keywords }, b) => inlineKnowledgeScoped(b, keywords),
inputs: { keywords: extractKeywords(sTitle) },
},
graph: {
@ -1938,23 +1973,9 @@ export async function buildExecuteTaskPrompt(
// the cold static-rank top. Falls back to pure static ranking when no
// gateway is configured or no embeddings exist yet — see
// getRelevantMemoriesRanked for the fallback chain.
const memoryQuery = `${sTitle} ${tTitle}`.trim();
const memoriesSection = await (async () => {
try {
const usingRanker = !!memoryQuery;
const memories = usingRanker
? await getRelevantMemoriesRanked(memoryQuery, 10)
: getActiveMemoriesRanked(10);
if (memories.length === 0) return "## Project Memories\n(none yet)";
// preserveRankOrder=true when the input came from the query-aware
// ranker so semantic relevance dominates over CATEGORY_PRIORITY in
// the rendered list. Static-ranked input keeps the historical
// category-grouped layout.
return `## Project Memories\n${formatMemoriesForPrompt(memories, 2000, usingRanker)}`;
} catch {
return "## Project Memories\n(unavailable)";
}
})();
const memoriesSection = await buildProjectMemoriesSection(
`${sTitle} ${tTitle}`,
);
// SF ADR-011 P2: when the feature is enabled, teach the executor that it can
// surface non-obvious choices via the `escalation` field on complete_task
// rather than silently picking. Autonomous mode auto-accepts the recommendation
@ -2306,7 +2327,11 @@ export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
if (inlineLevel === "minimal") return null;
return inlineProjectFromDb(base);
case "milestone-context":
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
return inlineFileOptional(
contextPath,
contextRel,
"Milestone Context",
);
case "templates":
return inlineTemplate("milestone-summary", "Milestone Summary");
default:
@ -2315,8 +2340,7 @@ export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
},
computed: {
knowledge: {
build: async ({ keywords: kw }, b) =>
inlineKnowledgeBudgeted(b, kw),
build: async ({ keywords: kw }, b) => inlineKnowledgeBudgeted(b, kw),
inputs: { keywords },
},
graph: {
@ -2456,7 +2480,11 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
// Assemble slice-summaries block (summaries + assessments interleaved).
const sliceSummariesParts = [];
const outstandingItems = [];
for (const { summaryInline, assessmentInline, outstandingLines } of valSliceResults) {
for (const {
summaryInline,
assessmentInline,
outstandingLines,
} of valSliceResults) {
sliceSummariesParts.push(summaryInline);
if (assessmentInline) sliceSummariesParts.push(assessmentInline);
outstandingItems.push(...outstandingLines);
@ -2472,7 +2500,9 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
// Pre-compute previous validation for re-validation rounds.
const validationPath = resolveMilestoneFile(base, mid, "VALIDATION");
const validationRel = relMilestoneFile(base, mid, "VALIDATION");
const validationContent = validationPath ? await loadFile(validationPath) : null;
const validationContent = validationPath
? await loadFile(validationPath)
: null;
let remediationRound = 0;
let previousValidationBlock = null;
if (validationContent) {
@ -2507,15 +2537,18 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
if (inlineLevel === "minimal") return null;
return inlineProjectFromDb(base);
case "milestone-context":
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
return inlineFileOptional(
contextPath,
contextRel,
"Milestone Context",
);
default:
return null;
}
},
computed: {
knowledge: {
build: async ({ keywords: kw }, b) =>
inlineKnowledgeBudgeted(b, kw),
build: async ({ keywords: kw }, b) => inlineKnowledgeBudgeted(b, kw),
inputs: { keywords },
},
graph: {
@ -2641,10 +2674,7 @@ export async function buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base) {
.join("\n");
}
} catch (err) {
logWarning(
"prompt",
`loadReplanCaptures failed: ${getErrorMessage(err)}`,
);
logWarning("prompt", `loadReplanCaptures failed: ${getErrorMessage(err)}`);
}
return loadPrompt("replan-slice", {
workingDirectory: base,

View file

@ -14,6 +14,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { projectRoot } from "./commands/context.js";
import { getErrorMessage } from "./error-utils.js";
import {
ingestFile,
ingestNote,
@ -32,7 +33,6 @@ import {
supersedeMemory,
} from "./memory-store.js";
import { _getAdapter, isDbAvailable } from "./sf-db.js";
import { getErrorMessage } from "./error-utils.js";
function parseArgs(raw) {
const tokens = splitArgs(raw);
@ -401,6 +401,12 @@ async function handleStatus(ctx) {
` queued for backfill: ${dbStatus.unembeddedActive}`,
` stored embeddings: ${dbStatus.embeddingsTotal}`,
"",
"Extraction:",
` processed units: ${dbStatus.processedUnits}`,
` attempts: ${dbStatus.extractionAttempts}`,
` last processed: ${formatMemoryStatusRow(dbStatus.lastProcessedUnit, "unit_key", "processed_at")}`,
` last attempt: ${formatMemoryAttempt(dbStatus.lastExtractionAttempt)}`,
"",
"Backfill:",
" trigger: agent_end",
" max per turn: 50",
@ -460,14 +466,50 @@ function readMemoryDbStatus(adapter) {
activeCount > 0
? `${Math.round((embeddedActive / activeCount) * 100)}%`
: "n/a";
const processedUnits =
adapter
.prepare("SELECT count(*) as cnt FROM memory_processed_units")
.get()?.["cnt"] ?? 0;
const extractionAttempts =
adapter
.prepare("SELECT count(*) as cnt FROM memory_extraction_attempts")
.get()?.["cnt"] ?? 0;
const lastProcessedUnit =
adapter
.prepare(`SELECT unit_key, activity_file, processed_at
FROM memory_processed_units
ORDER BY processed_at DESC
LIMIT 1`)
.get() ?? null;
const lastExtractionAttempt =
adapter
.prepare(`SELECT unit_key, status, reason, error, created_at
FROM memory_extraction_attempts
ORDER BY created_at DESC, id DESC
LIMIT 1`)
.get() ?? null;
return {
activeCount,
embeddedActive,
embeddingsTotal,
unembeddedActive,
coverage,
processedUnits,
extractionAttempts,
lastProcessedUnit,
lastExtractionAttempt,
};
}
function formatMemoryStatusRow(row, keyField, timeField) {
if (!row) return "none";
return `${row[keyField]} at ${row[timeField]}`;
}
function formatMemoryAttempt(row) {
if (!row) return "none";
const reason = row["reason"] ? ` (${row["reason"]})` : "";
const error = row["error"] ? ` error=${row["error"]}` : "";
return `${row["unit_key"]}: ${row["status"]}${reason} at ${row["created_at"]}${error}`;
}
async function probeEmbedding(gatewayConfig, createGatewayEmbedFn) {
const startedAt = Date.now();
try {

View file

@ -13,6 +13,7 @@ import {
getActiveMemories,
isUnitProcessed,
markUnitProcessed,
recordMemoryExtractionAttempt,
} from "./memory-store.js";
// ─── Concurrency Guard ──────────────────────────────────────────────────────
@ -259,41 +260,86 @@ export async function extractMemoriesFromUnit(
unitId,
llmCallFn,
) {
const unitKey = `${unitType}/${unitId}`;
const recordAttempt = (status, reason, error) =>
recordMemoryExtractionAttempt({
unitKey,
unitType,
unitId,
activityFile,
status,
reason,
error: error?.message ?? error,
});
// Mutex guard
if (_extracting) {
debugLog("memory-extract", { phase: "skip", reason: "mutex-busy", unitKey: `${unitType}/${unitId}` });
recordAttempt("skipped", "mutex-busy");
debugLog("memory-extract", {
phase: "skip",
reason: "mutex-busy",
unitKey,
});
return;
}
// Rate limit
const now = Date.now();
if (now - _lastExtractionTime < MIN_EXTRACTION_INTERVAL_MS) {
debugLog("memory-extract", { phase: "skip", reason: "rate-limited", unitKey: `${unitType}/${unitId}`, lastExtraction: _lastExtractionTime });
recordAttempt("skipped", "rate-limited");
debugLog("memory-extract", {
phase: "skip",
reason: "rate-limited",
unitKey: `${unitType}/${unitId}`,
lastExtraction: _lastExtractionTime,
});
return;
}
// Skip certain unit types
if (SKIP_TYPES.has(unitType)) {
debugLog("memory-extract", { phase: "skip", reason: "skip-type", unitType });
recordAttempt("skipped", "skip-type");
debugLog("memory-extract", {
phase: "skip",
reason: "skip-type",
unitType,
});
return;
}
const unitKey = `${unitType}/${unitId}`;
// Already processed
if (isUnitProcessed(unitKey)) {
debugLog("memory-extract", { phase: "skip", reason: "already-processed", unitKey });
recordAttempt("skipped", "already-processed");
debugLog("memory-extract", {
phase: "skip",
reason: "already-processed",
unitKey,
});
return;
}
// Check file size
try {
const stat = statSync(activityFile);
if (stat.size < MIN_ACTIVITY_SIZE) {
debugLog("memory-extract", { phase: "skip", reason: "file-too-small", unitKey, size: stat.size, min: MIN_ACTIVITY_SIZE });
recordAttempt("skipped", "file-too-small");
debugLog("memory-extract", {
phase: "skip",
reason: "file-too-small",
unitKey,
size: stat.size,
min: MIN_ACTIVITY_SIZE,
});
return;
}
} catch {
debugLog("memory-extract", { phase: "skip", reason: "stat-failed", unitKey, file: activityFile });
} catch (error) {
recordAttempt("skipped", "stat-failed", error);
debugLog("memory-extract", {
phase: "skip",
reason: "stat-failed",
unitKey,
file: activityFile,
});
return;
}
_extracting = true;
_lastExtractionTime = now;
recordAttempt("started");
debugLog("memory-extract", { phase: "start", unitKey, file: activityFile });
let userPrompt;
try {
@ -301,7 +347,12 @@ export async function extractMemoriesFromUnit(
const raw = readFileSync(activityFile, "utf-8");
const transcript = extractTranscriptFromActivity(raw);
if (!transcript.trim()) {
debugLog("memory-extract", { phase: "skip", reason: "empty-transcript", unitKey });
recordAttempt("skipped", "empty-transcript");
debugLog("memory-extract", {
phase: "skip",
reason: "empty-transcript",
unitKey,
});
return;
}
// Redact secrets
@ -320,34 +371,62 @@ export async function extractMemoriesFromUnit(
safeTranscript,
);
// Call LLM
debugLog("memory-extract", { phase: "llm-call", unitKey, transcriptChars: safeTranscript.length });
debugLog("memory-extract", {
phase: "llm-call",
unitKey,
transcriptChars: safeTranscript.length,
});
const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
// Parse response
const actions = parseMemoryResponse(response);
debugLog("memory-extract", { phase: "parsed", unitKey, actions: actions.length });
debugLog("memory-extract", {
phase: "parsed",
unitKey,
actions: actions.length,
});
// Apply actions (consolidation-path only — add/prune discipline enforced)
if (actions.length > 0) {
applyConsolidationActions(actions, unitType, unitId);
debugLog("memory-extract", { phase: "applied", unitKey, actions: actions.length });
debugLog("memory-extract", {
phase: "applied",
unitKey,
actions: actions.length,
});
}
// Decay stale memories periodically
decayStaleMemories(20);
// Mark unit as processed
markUnitProcessed(unitKey, activityFile);
recordAttempt(
"processed",
actions.length > 0 ? "actions-applied" : "no-actions",
);
debugLog("memory-extract", { phase: "done", unitKey });
} catch (err) {
debugLog("memory-extract", { phase: "error", unitKey, error: err?.message || String(err) });
recordAttempt("error", "extract-failed", err);
debugLog("memory-extract", {
phase: "error",
unitKey,
error: err?.message || String(err),
});
// Retry once after a brief delay
if (userPrompt) {
try {
await delay(2000);
const response2 = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
const actions2 = parseMemoryResponse(response2);
if (actions2.length > 0) applyConsolidationActions(actions2, unitType, unitId);
if (actions2.length > 0)
applyConsolidationActions(actions2, unitType, unitId);
markUnitProcessed(unitKey, activityFile);
recordAttempt("processed", "retry-success");
debugLog("memory-extract", { phase: "retry-success", unitKey });
} catch (err2) {
debugLog("memory-extract", { phase: "retry-failed", unitKey, error: err2?.message || String(err2) });
recordAttempt("error", "retry-failed", err2);
debugLog("memory-extract", {
phase: "retry-failed",
unitKey,
error: err2?.message || String(err2),
});
// Non-fatal — memory extraction failure should never affect autonomous mode
}
}

View file

@ -8,8 +8,10 @@ import {
decayMemoriesBefore,
deleteMemoryEmbedding,
incrementMemoryHitCount,
insertMemoryExtractionAttempt,
insertMemoryRow,
isDbAvailable,
listMemoryExtractionAttempts,
markMemoryUnitProcessed,
rewriteMemoryId,
supersedeLowestRankedMemories,
@ -31,6 +33,22 @@ const CATEGORY_PRIORITY = {
environment: 4,
preference: 5,
};
const QUERY_TOKEN_STOPWORDS = new Set([
"the",
"and",
"for",
"with",
"that",
"this",
"from",
"into",
"when",
"then",
"have",
"should",
"would",
"could",
]);
function safeJsonArray(raw) {
try {
const parsed = JSON.parse(raw);
@ -41,6 +59,36 @@ function safeJsonArray(raw) {
return [];
}
}
function tokenizeMemoryQuery(text) {
return String(text ?? "")
.toLowerCase()
.split(/[^a-z0-9_.-]+/g)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !QUERY_TOKEN_STOPWORDS.has(token));
}
function rankMemoriesByLexicalQuery(memories, query, limit) {
const queryTokens = tokenizeMemoryQuery(query);
if (queryTokens.length === 0) return memories.slice(0, limit);
return memories
.map((memory, index) => {
const haystack =
`${memory.category ?? ""} ${memory.content ?? ""} ${(memory.tags ?? []).join(" ")}`.toLowerCase();
const lexicalHits = queryTokens.reduce(
(count, token) => count + (haystack.includes(token) ? 1 : 0),
0,
);
const lexicalScore = lexicalHits / queryTokens.length;
const staticScore = memory.confidence * (1 + memory.hit_count * 0.1);
return {
memory,
index,
score: staticScore + lexicalScore * 2,
};
})
.sort((a, b) => b.score - a.score || a.index - b.index)
.slice(0, limit)
.map((entry) => entry.memory);
}
// ─── Row Mapping ────────────────────────────────────────────────────────────
function rowToMemory(row) {
return {
@ -158,7 +206,7 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
Promise.resolve(loadEmbeddingMap()),
]);
if (!queryVec || embeddingMap.size === 0) {
return mergedPool.slice(0, limit);
return rankMemoriesByLexicalQuery(mergedPool, query, limit);
}
let ranked = rankMemoriesByEmbedding(
mergedPool.map((m) => ({
@ -233,7 +281,7 @@ export async function getRelevantMemoriesRanked(query, limit = 10) {
}
return topK;
} catch {
return mergedPool.slice(0, limit);
return rankMemoriesByLexicalQuery(mergedPool, query, limit);
}
}
/**
@ -407,6 +455,56 @@ export function markUnitProcessed(unitKey, activityFile) {
return false;
}
}
/**
* Record a memory extraction attempt without marking the unit processed.
*
* Purpose: make provider skips, rate limits, parse errors, and successful
* closeouts queryable from the SF DB.
*
* Consumer: memory-extractor, auto-unit-closeout, and /memory status.
*/
export function recordMemoryExtractionAttempt({
unitKey,
unitType,
unitId,
activityFile,
status,
reason,
error,
}) {
if (!isDbAvailable()) return false;
try {
insertMemoryExtractionAttempt({
unitKey: unitKey ?? `${unitType}/${unitId}`,
unitType,
unitId,
activityFile,
status,
reason,
error,
createdAt: new Date().toISOString(),
});
return true;
} catch {
return false;
}
}
/**
* Return recent memory extraction attempts.
*
* Purpose: give diagnostics and tests a structured view of memory closeout
* lifecycle without relying on sqlite3 shell availability.
*
* Consumer: /memory status and memory lifecycle tests.
*/
export function getRecentMemoryExtractionAttempts(limit = 10) {
if (!isDbAvailable()) return [];
try {
return listMemoryExtractionAttempts(limit);
} catch {
return [];
}
}
// ─── Maintenance ────────────────────────────────────────────────────────────
/**
* Reduce confidence for memories not updated within the last N processed units.
@ -647,6 +745,7 @@ export function formatMemoriesForPrompt(
const header = "## Project Memory (auto-learned)\n";
let output = header;
let remaining = charBudget - header.length;
const renderedMemories = [];
if (preserveRankOrder) {
// Render in input order — caller already ranked semantically. Each
// bullet shows the category inline so the agent can still tell
@ -655,8 +754,10 @@ export function formatMemoriesForPrompt(
const bullet = `- [${item.category}] ${item.content}\n`;
if (remaining < bullet.length) break;
output += bullet;
renderedMemories.push(item);
remaining -= bullet.length;
}
recordMemoryPromptUsage(renderedMemories);
return output.trimEnd();
}
// Group by category
@ -680,8 +781,36 @@ export function formatMemoriesForPrompt(
const bullet = `- ${item.content}\n`;
if (remaining < bullet.length) break;
output += bullet;
renderedMemories.push(item);
remaining -= bullet.length;
}
}
recordMemoryPromptUsage(renderedMemories);
return output.trimEnd();
}
/**
* Increment hit counts for memories that were actually injected into a prompt.
*
* Purpose: make memory ranking reflect real reuse instead of only explicit
* reinforcement events.
*
* Consumer: formatMemoriesForPrompt callers across autonomous prompts.
*/
export function recordMemoryPromptUsage(memories) {
if (!isDbAvailable()) return;
const now = new Date().toISOString();
const seen = new Set();
for (const memory of memories) {
const id = memory?.id;
if (typeof id !== "string" || id.startsWith("sm-") || seen.has(id)) {
continue;
}
seen.add(id);
try {
incrementMemoryHitCount(id, now);
} catch {
// Prompt rendering must never fail because memory telemetry failed.
}
}
}

View file

@ -1,6 +1,5 @@
import { _getAdapter, intBool, parseJsonObject } from './sf-db-core.js';
import { SF_STALE_STATE, SFError } from '../errors.js';
import { logWarning } from '../workflow-logger.js';
import { SF_STALE_STATE, SFError } from "../errors.js";
import { _getAdapter, intBool, parseJsonObject } from "./sf-db-core.js";
export function getActiveMemories({ category, limit = 200 } = {}) {
const currentDb = _getAdapter();
@ -116,6 +115,51 @@ export function markMemoryUnitProcessed(unitKey, activityFile, processedAt) {
.run({ ":key": unitKey, ":file": activityFile, ":at": processedAt });
}
/**
* Insert a memory extraction attempt row.
*
* Purpose: keep skipped and failed memory closeouts visible without marking
* the unit as fully processed.
*
* Consumer: memory-extractor and /memory status diagnostics.
*/
export function insertMemoryExtractionAttempt(args) {
const currentDb = _getAdapter();
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
currentDb
.prepare(`INSERT INTO memory_extraction_attempts
(unit_key, unit_type, unit_id, activity_file, status, reason, error, created_at)
VALUES (:unit_key, :unit_type, :unit_id, :activity_file, :status, :reason, :error, :created_at)`)
.run({
":unit_key": args.unitKey,
":unit_type": args.unitType ?? null,
":unit_id": args.unitId ?? null,
":activity_file": args.activityFile ?? null,
":status": args.status,
":reason": args.reason ?? null,
":error": args.error ?? null,
":created_at": args.createdAt,
});
}
/**
* Return recent memory extraction attempt rows.
*
* Purpose: expose memory closeout health to diagnostics without requiring
* ad-hoc SQL access on machines that lack sqlite3.
*
* Consumer: /memory status and focused memory lifecycle tests.
*/
export function listMemoryExtractionAttempts(limit = 10) {
const currentDb = _getAdapter();
if (!currentDb) return [];
return currentDb
.prepare(`SELECT * FROM memory_extraction_attempts
ORDER BY created_at DESC, id DESC
LIMIT :limit`)
.all({ ":limit": limit });
}
export function decayMemoriesBefore(cutoffTs, now) {
const currentDb = _getAdapter();
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");

View file

@ -840,6 +840,23 @@ export function initSchema(db, fileBacked, options = {}) {
activity_file TEXT,
processed_at TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS memory_extraction_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_key TEXT NOT NULL,
unit_type TEXT,
unit_id TEXT,
activity_file TEXT,
status TEXT NOT NULL,
reason TEXT,
error TEXT,
created_at TEXT NOT NULL
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_memory_extraction_attempts_created
ON memory_extraction_attempts(created_at)
`);
// memory_embeddings, memory_relations, memory_sources used to be referenced
// by helper functions and queries (memory-embeddings.ts, memory-relations.ts,
@ -1623,6 +1640,23 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
processed_at TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS memory_extraction_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_key TEXT NOT NULL,
unit_type TEXT,
unit_id TEXT,
activity_file TEXT,
status TEXT NOT NULL,
reason TEXT,
error TEXT,
created_at TEXT NOT NULL
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_memory_extraction_attempts_created
ON memory_extraction_attempts(created_at)
`);
db.exec(
"CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)",
);
@ -3383,19 +3417,16 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
// CREATE update do need the ALTER. Probe via PRAGMA table_info
// before each ALTER.
const cols = new Set(
db.prepare("PRAGMA table_info(self_feedback)").all().map(
(r) => r.name,
),
db
.prepare("PRAGMA table_info(self_feedback)")
.all()
.map((r) => r.name),
);
if (!cols.has("impact_score")) {
db.exec(
"ALTER TABLE self_feedback ADD COLUMN impact_score INTEGER",
);
db.exec("ALTER TABLE self_feedback ADD COLUMN impact_score INTEGER");
}
if (!cols.has("effort_estimate")) {
db.exec(
"ALTER TABLE self_feedback ADD COLUMN effort_estimate INTEGER",
);
db.exec("ALTER TABLE self_feedback ADD COLUMN effort_estimate INTEGER");
}
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
@ -3439,9 +3470,7 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
db.exec("ALTER TABLE quality_gates ADD COLUMN run_control TEXT");
}
if (!cols.has("permission_profile")) {
db.exec(
"ALTER TABLE quality_gates ADD COLUMN permission_profile TEXT",
);
db.exec("ALTER TABLE quality_gates ADD COLUMN permission_profile TEXT");
}
if (!cols.has("trace_id")) {
db.exec("ALTER TABLE quality_gates ADD COLUMN trace_id TEXT");
@ -3459,6 +3488,41 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
if (ok) appliedVersion = 66;
}
if (appliedVersion < 67) {
const ok = runMigrationStep("v67", () => {
// Schema v67: explicit extraction attempt ledger for memory closeout.
//
// memory_processed_units is only the completed-inspection ledger.
// Skips and errors need their own DB-backed status rows so operators can
// tell whether memory is idle, blocked by provider config, rate-limited,
// or failing at parse/apply time.
db.exec(`
CREATE TABLE IF NOT EXISTS memory_extraction_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_key TEXT NOT NULL,
unit_type TEXT,
unit_id TEXT,
activity_file TEXT,
status TEXT NOT NULL,
reason TEXT,
error TEXT,
created_at TEXT NOT NULL
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_memory_extraction_attempts_created
ON memory_extraction_attempts(created_at)
`);
db.prepare(
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
).run({
":version": 67,
":applied_at": new Date().toISOString(),
});
});
if (ok) appliedVersion = 67;
}
// Post-migration assertion: ensure critical tables created by historical
// migrations are actually present. If a prior migration claimed success but
// the table is missing (e.g., due to a rolled-back transaction that failed

View file

@ -0,0 +1,196 @@
/**
* memory-extraction-lifecycle.test.mjs memory closeout observability.
*
* Purpose: prove autonomous memory extraction records skips, completions, and
* prompt reuse in DB-backed state instead of leaving operators to infer health
* from logs.
*/
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "vitest";
import {
_resetExtractionState,
extractMemoriesFromUnit,
} from "../memory-extractor.js";
import {
createMemory,
formatMemoriesForPrompt,
getActiveMemoriesRanked,
getRecentMemoryExtractionAttempts,
getRelevantMemoriesRanked,
} from "../memory-store.js";
import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js";
const tmpDirs = [];
afterEach(() => {
_resetExtractionState();
closeDatabase();
while (tmpDirs.length > 0) {
rmSync(tmpDirs.pop(), { recursive: true, force: true });
}
});
function makeProject() {
const dir = mkdtempSync(join(tmpdir(), "sf-memory-lifecycle-"));
tmpDirs.push(dir);
mkdirSync(join(dir, ".sf"), { recursive: true });
assert.equal(openDatabase(join(dir, ".sf", "sf.db")), true);
return dir;
}
function writeActivity(dir, name, text) {
const activityFile = join(dir, name);
writeFileSync(
activityFile,
`${JSON.stringify({
type: "custom_message",
customType: "sf-auto",
text,
})}\n`,
"utf8",
);
return activityFile;
}
test("extractMemoriesFromUnit_when_activity_too_small_records_skip_attempt", async () => {
const project = makeProject();
const activityFile = writeActivity(project, "small.jsonl", "short");
await extractMemoriesFromUnit(
activityFile,
"execute-task",
"M001/S01/T01",
async () => "[]",
);
const attempts = getRecentMemoryExtractionAttempts(5);
assert.equal(attempts.length, 1);
assert.equal(attempts[0].status, "skipped");
assert.equal(attempts[0].reason, "file-too-small");
assert.equal(attempts[0].unit_key, "execute-task/M001/S01/T01");
const processed = getDatabase()
.prepare("SELECT count(*) AS cnt FROM memory_processed_units")
.get();
assert.equal(processed.cnt, 0);
});
test("extractMemoriesFromUnit_when_successful_records_processed_unit_and_attempt", async () => {
const project = makeProject();
const activityFile = writeActivity(
project,
"large.jsonl",
"Autonomous memory extraction should persist useful project knowledge. ".repeat(
40,
),
);
await extractMemoriesFromUnit(
activityFile,
"execute-task",
"M001/S01/T02",
async () =>
JSON.stringify([
{
action: "add",
category: "knowledge",
content:
"Memory extraction attempts are stored in SQLite for diagnostics.",
confidence: 0.9,
},
]),
);
const latestAttempt = getRecentMemoryExtractionAttempts(1)[0];
assert.equal(latestAttempt.status, "processed");
assert.equal(latestAttempt.reason, "actions-applied");
const processed = getDatabase()
.prepare("SELECT count(*) AS cnt FROM memory_processed_units")
.get();
assert.equal(processed.cnt, 1);
const memories = getActiveMemoriesRanked(10);
assert.equal(memories.length, 1);
assert.match(memories[0].content, /stored in SQLite/);
});
test("formatMemoriesForPrompt_when_injected_increments_hit_count", () => {
makeProject();
const id = createMemory({
category: "knowledge",
content: "Injected memories should count as used.",
confidence: 0.8,
});
const memories = getActiveMemoriesRanked(10);
const rendered = formatMemoriesForPrompt(memories);
assert.match(rendered, /Injected memories/);
const row = getDatabase()
.prepare("SELECT hit_count FROM memories WHERE id = :id")
.get({ ":id": id });
assert.equal(row.hit_count, 1);
});
test("formatMemoriesForPrompt_when_budget_truncates_skips_hidden_hit_count", () => {
makeProject();
const renderedId = createMemory({
category: "knowledge",
content: "short",
confidence: 0.8,
});
const hiddenId = createMemory({
category: "knowledge",
content:
"This memory is intentionally long enough that the small prompt budget excludes it.",
confidence: 0.8,
});
formatMemoriesForPrompt(
[
{ id: renderedId, category: "knowledge", content: "short" },
{
id: hiddenId,
category: "knowledge",
content:
"This memory is intentionally long enough that the small prompt budget excludes it.",
},
],
15,
true,
);
const rows = getDatabase()
.prepare("SELECT id, hit_count FROM memories ORDER BY id")
.all();
assert.deepEqual(
rows.map((row) => [row.id, row.hit_count]),
[
[renderedId, 1],
[hiddenId, 0],
],
);
});
test("getRelevantMemoriesRanked_when_embeddings_unavailable_uses_query_terms", async () => {
makeProject();
createMemory({
category: "knowledge",
content: "Generic unrelated implementation note.",
confidence: 0.95,
});
const relevantId = createMemory({
category: "knowledge",
content:
"OpenCode provider routing must fall back from paid model failures to another free model.",
confidence: 0.6,
});
const [top] = await getRelevantMemoriesRanked(
"opencode paid model triage fallback",
1,
);
assert.equal(top.id, relevantId);
});

View file

@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, test } from "vitest";
import { initRoutingHistory } from "../routing-history.js";
import {
closeDatabase,
getDatabase,
@ -29,7 +30,6 @@ import {
openDatabase,
reconcileWorktreeDb,
} from "../sf-db.js";
import { initRoutingHistory } from "../routing-history.js";
const tmpDirs = [];
@ -273,7 +273,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version")
.get();
assert.equal(version.version, 66);
assert.equal(version.version, 67);
// v61: intent_chapters table exists
const chaptersTable = db
.prepare(
@ -387,7 +387,7 @@ test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () =
const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version")
.get();
assert.equal(version.version, 66);
assert.equal(version.version, 67);
});
test("openDatabase_when_fresh_db_supports_schedule_entries", () => {
@ -533,6 +533,32 @@ test("openDatabase_memory_indexes_exist", () => {
);
});
test("openDatabase_memory_extraction_attempts_table_exists", () => {
assert.equal(openDatabase(":memory:"), true);
const db = getDatabase();
const columns = db
.prepare("PRAGMA table_info(memory_extraction_attempts)")
.all();
const names = columns.map((row) => row.name);
assert.deepEqual(names, [
"id",
"unit_key",
"unit_type",
"unit_id",
"activity_file",
"status",
"reason",
"error",
"created_at",
]);
const index = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_memory_extraction_attempts_created'",
)
.get();
assert.ok(index, "memory extraction attempts should be indexed by time");
});
test("openDatabase_judgments_table_round_trip", () => {
assert.equal(openDatabase(":memory:"), true);
insertJudgment({

View file

@ -43,6 +43,9 @@ export async function closeoutUnit(
const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import(
"../memory-extractor.js"
);
const { recordMemoryExtractionAttempt } = await import(
"../memory-store.js"
);
const llmCallFn = buildMemoryLLMCall(ctx);
if (llmCallFn) {
extractMemoriesFromUnit(
@ -56,6 +59,14 @@ export async function closeoutUnit(
`memory extraction failed for ${unitType}/${unitId}: ${err.message}`,
);
});
} else {
recordMemoryExtractionAttempt({
unitType,
unitId,
activityFile,
status: "skipped",
reason: "llm-unavailable",
});
}
} catch (err) {
/* non-fatal */