/**
* Builds a context block from memory for injection into the system prompt.
*
* Two modes:
* - Selective (prompt provided): search semantic memory for entries relevant
* to the user's current prompt, plus always-inject lessons.
* - Fallback (no prompt): dump top entries by prefix (old behavior).
*/
import type { MemoryStore, SemanticEntry, LessonEntry } from "./store.js";
import os from "node:os";
const MAX_CONTEXT_CHARS = 8000;
const SEARCH_LIMIT = 15;
const LESSON_SEARCH_LIMIT = 15;
export interface ContextBlock {
text: string;
stats: { semantic: number; lessons: number };
}
/**
* Configuration for lesson injection behavior.
* - "all": inject all lessons (original behavior, default)
* - "selective": use semantic search to pick relevant lessons + category filtering
*/
export type LessonInjectionMode = "all" | "selective";
export interface InjectorConfig {
lessonInjection?: LessonInjectionMode;
/**
* Opt-in: restore per-user-message selective injection.
*
* When false (default), pi-memory injects a one-shot fallback block at
* session_start (correct message ordering, stable prefix cache).
*
* When true, the session_start dump is skipped and each turn runs a
* semantic search against the user's current prompt; the result is
* appended to `event.systemPrompt` in `before_agent_start`.
*
* Tradeoffs:
* - Pro: per-query relevance — facts outside the 8KB fallback dump reach
* the model when they match the current prompt.
* - Con: the system prompt mutates every turn, invalidating the provider's
* prefix cache after the system block (Bedrock / Anthropic cache_control).
* Conversation suffix gets re-written at cacheWrite rates on every user
* turn boundary (~12.5x cacheRead on Claude).
*
* Correctness is preserved either way: systemPrompt is a separate field
* from the messages list, so the user's question remains the last
* user-role message and the model responds to it.
*/
perTurnInjection?: boolean;
/**
* Model string passed to `pi --model` for session-end consolidation.
* When omitted, the built-in default is used. Useful for users on
* non-Anthropic providers (OpenAI/Codex/OpenRouter/Ollama/local),
* or for picking a cheaper/faster model for background extraction.
*
* Invalid model strings will cause the consolidation sub-process to
* fail — the existing try/catch swallows that silently, so the worst
* case is that consolidation skips this session.
*/
consolidationModel?: string;
}
/**
* Build context block. When `prompt` is provided, uses selective injection
* (search-based). Otherwise falls back to prefix-based dump.
*/
export function buildContextBlock(store: MemoryStore, cwd?: string, prompt?: string, config?: InjectorConfig): ContextBlock {
if (prompt?.trim()) {
return buildSelectiveBlock(store, prompt, cwd, config);
}
return buildFallbackBlock(store, cwd);
}
// ─── Selective injection ─────────────────────────────────────────────
function buildSelectiveBlock(store: MemoryStore, prompt: string, cwd?: string, config?: InjectorConfig): ContextBlock {
const sections: string[] = [];
let semanticCount = 0;
let lessonCount = 0;
const mode = config?.lessonInjection ?? "all";
// Search semantic memory using the user's prompt
const results = store.searchSemantic(prompt, SEARCH_LIMIT);
// Also search with project slug if we have a cwd, to pull in project context
const slug = cwd ? projectSlug(cwd) : "";
if (slug) {
const projectResults = store.searchSemantic(slug, 5);
// Merge, dedup by key
const seen = new Set(results.map(r => r.key));
for (const r of projectResults) {
if (!seen.has(r.key)) {
results.push(r);
seen.add(r.key);
}
}
}
if (results.length > 0) {
sections.push(formatSection("Relevant Memory", results.map(formatSemantic)));
semanticCount = results.length;
// Track access time for these memories
store.touchAccessed(results.map(r => r.key));
}
// Inject lessons — either all or filtered by relevance
const lessons = mode === "selective"
? getRelevantLessons(store, prompt, cwd)
: store.listLessons(undefined, 50);
if (lessons.length > 0) {
const corrections = lessons.filter(l => l.negative);
const positives = lessons.filter(l => !l.negative);
if (corrections.length > 0) {
const formatted = corrections.map(l =>
`DON'T: ${l.rule}${l.category !== "general" ? ` [${l.category}]` : ""}`
);
sections.push(formatSection("Learned Corrections", formatted));
}
if (positives.length > 0) {
const formatted = positives.map(l =>
`${l.rule}${l.category !== "general" ? ` [${l.category}]` : ""}`
);
sections.push(formatSection("Validated Approaches", formatted));
}
lessonCount = lessons.length;
}
if (sections.length === 0) {
return { text: "", stats: { semantic: 0, lessons: 0 } };
}
let text = `\n${sections.join("\n")}\n\n${MEMORY_DRIFT_CAVEAT}\n`;
if (text.length > MAX_CONTEXT_CHARS) {
text = text.slice(0, MAX_CONTEXT_CHARS - 20) + "\n... (truncated)\n";
}
return { text, stats: { semantic: semanticCount, lessons: lessonCount } };
}
// ─── Selective lesson injection ──────────────────────────────────────
/**
* Get lessons relevant to the current prompt + project context.
*
* Strategy:
* 1. Search lessons by prompt terms (semantic/FTS match)
* 2. If cwd implies a project, also search by project slug
* 3. Always include "general" category lessons (broadly applicable)
* 4. Dedup and cap at LESSON_SEARCH_LIMIT
*/
function getRelevantLessons(store: MemoryStore, prompt: string, cwd?: string): LessonEntry[] {
const seen = new Set();
const result: LessonEntry[] = [];
function add(lessons: LessonEntry[]) {
for (const l of lessons) {
if (!seen.has(l.id)) {
seen.add(l.id);
result.push(l);
}
}
}
// 1. Search by prompt relevance (FTS across rule text + category)
add(store.searchLessons(prompt, LESSON_SEARCH_LIMIT));
// 2. Search by project slug if we have a cwd
const slug = cwd ? projectSlug(cwd) : "";
if (slug) {
add(store.searchLessons(slug, 5));
}
// 3. Always include general lessons (they're broadly applicable)
add(store.listLessons("general", 10));
return result.slice(0, LESSON_SEARCH_LIMIT);
}
// ─── Fallback (no prompt) ────────────────────────────────────────────
function buildFallbackBlock(store: MemoryStore, cwd?: string): ContextBlock {
const sections: string[] = [];
let semanticCount = 0;
let lessonCount = 0;
const prefs = store.listSemantic("pref.", 50);
if (prefs.length > 0) {
sections.push(formatSection("User Preferences", prefs.map(formatSemantic)));
semanticCount += prefs.length;
}
const projects = store.listSemantic("project.", 50);
const relevant = cwd
? projects.filter(p => p.key.includes(projectSlug(cwd)) || p.confidence >= 0.9)
: projects;
if (relevant.length > 0) {
sections.push(formatSection("Project Context", relevant.map(formatSemantic)));
semanticCount += relevant.length;
}
const tools = store.listSemantic("tool.", 20);
if (tools.length > 0) {
sections.push(formatSection("Tool Preferences", tools.map(formatSemantic)));
semanticCount += tools.length;
}
const lessons = store.listLessons(undefined, 50);
if (lessons.length > 0) {
const corrections = lessons.filter(l => l.negative);
const positives = lessons.filter(l => !l.negative);
if (corrections.length > 0) {
const formatted = corrections.map(l =>
`DON'T: ${l.rule}${l.category !== "general" ? ` [${l.category}]` : ""}`
);
sections.push(formatSection("Learned Corrections", formatted));
}
if (positives.length > 0) {
const formatted = positives.map(l =>
`${l.rule}${l.category !== "general" ? ` [${l.category}]` : ""}`
);
sections.push(formatSection("Validated Approaches", formatted));
}
lessonCount = lessons.length;
}
const user = store.listSemantic("user.", 10);
if (user.length > 0) {
sections.push(formatSection("User", user.map(formatSemantic)));
semanticCount += user.length;
}
if (sections.length === 0) {
return { text: "", stats: { semantic: 0, lessons: 0 } };
}
let text = `\n${sections.join("\n")}\n\n${MEMORY_DRIFT_CAVEAT}\n`;
if (text.length > MAX_CONTEXT_CHARS) {
text = text.slice(0, MAX_CONTEXT_CHARS - 20) + "\n... (truncated)\n";
}
return { text, stats: { semantic: semanticCount, lessons: lessonCount } };
}
// ─── Helpers ─────────────────────────────────────────────────────────
/** Staleness thresholds (in days) */
const STALE_WARNING_DAYS = 30;
const VERY_STALE_DAYS = 90;
function formatSection(title: string, items: string[]): string {
return `## ${title}\n${items.map(i => `- ${i}`).join("\n")}`;
}
/**
* Format a semantic entry with staleness indicator.
* Memories older than 30 days get a warning; older than 90 days get a strong warning.
* This prevents the agent from treating stale facts as current truth.
*/
function formatSemantic(entry: SemanticEntry): string {
const key = entry.key.split(".").slice(1).join(".");
const ageDays = daysSince(entry.updated_at);
const staleTag = ageDays >= VERY_STALE_DAYS
? ` ⚠️ ${ageDays}d old — verify before acting on this`
: ageDays >= STALE_WARNING_DAYS
? ` (${ageDays}d ago)`
: "";
return `${key}: ${entry.value}${staleTag}`;
}
/**
* Calculate days since a date string.
*/
function daysSince(dateStr: string): number {
try {
const then = new Date(dateStr).getTime();
const now = Date.now();
return Math.floor((now - then) / (1000 * 60 * 60 * 24));
} catch {
return 0;
}
}
/**
* Memory drift caveat — appended to the memory block so the agent knows
* to verify recalled facts against current state before acting on them.
*/
const MEMORY_DRIFT_CAVEAT = `## Before acting on memory
- Memory records can become stale. If a memory names a file, function, or flag — verify it still exists before recommending it. "The memory says X exists" is not the same as "X exists now."
- If a recalled memory conflicts with what you observe in the current code or project state, trust what you observe now.
- Memories about project state (deadlines, decisions, architecture) decay fastest — check if still relevant.`;
function projectSlug(cwd: string): string {
const parts = cwd.split("/").filter(Boolean);
const skip = new Set(["workplace", "local", "home", "src", "scratch", os.userInfo().username]);
for (const p of parts.reverse()) {
if (!skip.has(p.toLowerCase()) && p.length > 1) return p.toLowerCase();
}
return "";
}