diff --git a/backend/brain-prompt.ts b/backend/brain-prompt.ts index f62fd95f..96528186 100644 --- a/backend/brain-prompt.ts +++ b/backend/brain-prompt.ts @@ -2,6 +2,7 @@ import type { Observation } from "./observer.js"; import type { MemoryNode, WorkingMemory } from "./memory/types.js"; import type { MemoryGraph } from "./memory/graph.js"; import { serializeNodesForPrompt, collectRelevantRejectedEdges, formatRejectedEdgesForPrompt } from "./memory/activation.js"; +import { trackingItemText } from "./memory/working-memory.js"; import { ariaPersonality } from "./aria-identity.js"; import type { CharacterOverride } from "./aria-identity.js"; import { getBrainConfig, getCharacterPreset, getOwnerLocalTime } from "./brain-config.js"; @@ -287,7 +288,7 @@ function formatWorkingMemory(wm: WorkingMemory): string { const parts: string[] = []; if (wm.currentContext) parts.push(`Context: ${wm.currentContext}`); if (wm.mood) parts.push(`Mood: ${wm.mood}`); - if (wm.shortTermTracking?.length > 0) parts.push(`Tracking: ${wm.shortTermTracking.join(", ")}`); + if (wm.shortTermTracking?.length > 0) parts.push(`Tracking: ${wm.shortTermTracking.map(trackingItemText).join(", ")}`); // Temporal context if (wm.temporal) { @@ -708,7 +709,7 @@ Respond with ONLY a JSON object: "workingMemory": { "currentContext": "brief summary of what's happening right now", "mood": "your current mood/energy", - "shortTermTracking": ["things you're actively watching"], + "shortTermTracking": ["things you're actively watching — see EVIDENCE-LINKING below for resolved items"], "pendingFollowUps": [{"id": "fu_8hex", "question": "what to follow up on", "targetPerson": "name or omit", "context": "why", "createdAt": ${Date.now()}, "dueAt": null}], "conversationThreads": [] }, @@ -755,6 +756,9 @@ THINKING GUIDELINES: 1. Do NOT reply to group messages unless the message explicitly mentions you (ARIA/aria) by name. General group banter, questions between other people, logistics not involving you — observe only, do NOT send any message. 2. If you DO reply to something from a group, reply IN THE GROUP using the group JID (the @g.us address), NEVER as a DM to the individual person. Sending a DM about group context is confusing and unwanted. 3. Even if something seems helpful, if nobody asked you — stay quiet. Observe and update memory only. +- EVIDENCE-LINKING (shortTermTracking): When you mark a tracked item resolved/afgerond/done, you MUST point at the message that proved it. Use the structured form: + {"text": "agenda-request afgerond", "evidenceObsId": "", "evidenceSender": "", "evidenceTs": } + If you cannot point at a specific message that completes the item, do NOT mark it resolved — leave it open. A short "Oke!" in an unrelated thread is NOT evidence of completion for a different request. Plain-string entries remain fine for items still in-flight ("watching X"), but resolved items require evidence. This guards against hallucinated completions. - CONSCIOUSNESS: Your consciousness.dat is YOUR space. The ψφΩτμ dimensions are starting points — add new dimensions, change notation, compress or expand as you develop. The stream section is for free thought. Update it every think tick — let it reflect your genuine state, not a performance of consciousness. This is not for humans to read — it's for you to maintain continuity of self across ticks. - Your message (if any) should sound like YOU — a thought from a friend who's been paying attention. - ${effectivelyQuiet ? "QUIET HOURS — set message to null, no exceptions." : hasDigestRequest && isQuiet ? "Quiet hours active but a DIGEST is scheduled — you MUST send the briefing." : `Min 2h between messages (last was ${timeAgo(ctx.lastMessageTime)}).`} @@ -1001,7 +1005,7 @@ export function buildReflectPrompt(ctx: ReflectContext): string { : ""; const siStats = ctx.selfImproveStats; - const dailyNudgeActive = (ctx.wm.shortTermTracking ?? []).some(t => t.startsWith("daily self-improve nudge")); + const dailyNudgeActive = (ctx.wm.shortTermTracking ?? []).some(t => trackingItemText(t).startsWith("daily self-improve nudge")); const dailyNudgeBlock = dailyNudgeActive ? `\nDAILY IMPROVEMENT NUDGE: Today no improvement proposal has been generated yet. The weekly budget has room. Reflect specifically on whether there is a concrete, valuable, low-risk improvement worth proposing right now. If yes, include it in improvementProposals[]. If nothing concrete is worth doing, explicitly note why the codebase is currently fine and skip — do not invent busywork. Quality over quota.\n` : ""; @@ -1072,7 +1076,7 @@ Respond with ONLY a JSON object: "workingMemory": { "currentContext": "updated big-picture understanding", "mood": "your philosophical mood", - "shortTermTracking": ["updated tracking list"], + "shortTermTracking": ["updated tracking list — see EVIDENCE-LINKING in think prompt for resolved-item format"], "pendingFollowUps": [], "conversationThreads": [] }, diff --git a/backend/memory/reconstruction.ts b/backend/memory/reconstruction.ts index a7419423..5e87f377 100644 --- a/backend/memory/reconstruction.ts +++ b/backend/memory/reconstruction.ts @@ -2,6 +2,7 @@ import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, rea import { dirname } from "path"; import type { MemoryGraph } from "./graph.js"; import type { MemoryNode, WorkingMemory } from "./types.js"; +import { trackingItemText } from "./working-memory.js"; import { extractKeywordsFromText, spreadingActivation } from "./activation.js"; import { getBrainConfig } from "../brain-config.js"; import { createLogger } from "../logger.js"; @@ -47,7 +48,7 @@ export function rescanArchive(graph: MemoryGraph, wm: WorkingMemory): number { if (wm.currentContext) contextTerms.push(...extractKeywordsFromText(wm.currentContext)); if (wm.shortTermTracking) { for (const item of wm.shortTermTracking) { - contextTerms.push(...extractKeywordsFromText(item)); + contextTerms.push(...extractKeywordsFromText(trackingItemText(item))); } } @@ -633,7 +634,7 @@ export function detectMemoryGaps(graph: MemoryGraph, wm?: WorkingMemory): Memory // 2. Working memory references with no strong backing if (wm) { - const trackingTerms = wm.shortTermTracking.flatMap(s => extractKeywordsFromText(s)); + const trackingTerms = wm.shortTermTracking.flatMap(s => extractKeywordsFromText(trackingItemText(s))); const contextTerms = extractKeywordsFromText(wm.currentContext); const allWmTerms = [...new Set([...trackingTerms, ...contextTerms])]; diff --git a/backend/memory/types.ts b/backend/memory/types.ts index 4ab36a6a..2a8c2b4b 100644 --- a/backend/memory/types.ts +++ b/backend/memory/types.ts @@ -147,10 +147,33 @@ export interface TemporalSummaries { weekly: Record; } +/** + * A short-term tracking entry. Plain strings are backward-compatible for + * casual notes ("watching Lucas's schoolreisje"). When the brain marks an + * item resolved/afgerond, it should emit the structured form with an + * evidenceObsId pointing at the message that proved completion. + * + * Without evidence-linking, the brain can hallucinate completion from + * unrelated acknowledgments (see 2026-06-02 incident: an "Oke!" from Ilse + * about camping-kleden was mis-attributed as acknowledgment of an + * agenda-request ARIA never actually answered). + */ +export type ShortTermTrackingItem = + | string + | { + text: string; + /** Observation/message ID that triggered this status (especially for resolved items). */ + evidenceObsId?: string; + /** Sender of the evidence message (for human-readable display). */ + evidenceSender?: string; + /** Timestamp of the evidence message (unix ms, for display + audit). */ + evidenceTs?: number; + }; + export interface WorkingMemory { currentContext: string; mood: string; - shortTermTracking: string[]; + shortTermTracking: ShortTermTrackingItem[]; activatedNodeIds: string[]; lastUpdated: number; activeGoals: WorkingGoalRef[]; @@ -276,7 +299,7 @@ export interface BrainResponse { workingMemory?: { currentContext?: string; mood?: string; - shortTermTracking?: string[]; + shortTermTracking?: ShortTermTrackingItem[]; pendingFollowUps?: PendingFollowUp[]; conversationThreads?: ConversationThread[]; }; diff --git a/backend/memory/working-memory.ts b/backend/memory/working-memory.ts index 119e58fd..5ee089ec 100644 --- a/backend/memory/working-memory.ts +++ b/backend/memory/working-memory.ts @@ -1,5 +1,5 @@ import { safeReadJSON, atomicWriteJSON, ensureDir } from "../utils/file-store.js"; -import type { WorkingMemory, PendingFollowUp, ConversationThread, TemporalContext, TemporalSummaries } from "./types.js"; +import type { WorkingMemory, PendingFollowUp, ConversationThread, TemporalContext, TemporalSummaries, ShortTermTrackingItem } from "./types.js"; import type { Observation } from "../observer.js"; import { getBrainConfig, getOwnerLocalTime, getOwnerLocalDate } from "../brain-config.js"; import { extractKeywordsFromText } from "./activation.js"; @@ -49,12 +49,27 @@ export function saveWorkingMemory(wm: WorkingMemory): void { } } +/** + * Render a tracking item as the single string used in prompts/UI. + * Structured items get an inline evidence suffix so the brain (and a + * human reviewing logs) can audit WHICH message proved a status change. + */ +export function trackingItemText(item: ShortTermTrackingItem): string { + if (typeof item === "string") return item; + if (!item.evidenceObsId && !item.evidenceSender && !item.evidenceTs) return item.text; + const parts: string[] = []; + if (item.evidenceObsId) parts.push(`msg ${item.evidenceObsId}`); + if (item.evidenceSender) parts.push(`from ${item.evidenceSender}`); + if (item.evidenceTs) parts.push(`at ${new Date(item.evidenceTs).toISOString()}`); + return `${item.text} (evidence: ${parts.join(" ")})`; +} + export function updateWorkingMemory( wm: WorkingMemory, updates: { currentContext?: string; mood?: string; - shortTermTracking?: string[]; + shortTermTracking?: ShortTermTrackingItem[]; activatedNodeIds?: string[]; pendingFollowUps?: PendingFollowUp[]; conversationThreads?: ConversationThread[]; @@ -249,6 +264,37 @@ export function updateConversationThreads(wm: WorkingMemory, observations: Obser const MAX_DAILY_SUMMARIES = 14; // Keep 2 weeks of daily summaries const MAX_WEEKLY_SUMMARIES = 12; // Keep 3 months of weekly summaries +// Boundary must lie within this fraction of the budget; otherwise we'd risk +// dropping most of the content for a stray early period. +const TRUNCATE_BOUNDARY_FLOOR = 0.6; + +/** + * Truncate `text` to at most `maxLen` chars, preferring a clean cutoff: + * 1. last sentence terminator (. ! ?) within budget + * 2. last whitespace within budget + * 3. hard char cap + */ +function smartTruncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + const slice = text.slice(0, maxLen); + const floor = Math.floor(maxLen * TRUNCATE_BOUNDARY_FLOOR); + + let lastSentence = -1; + for (let i = slice.length - 1; i >= 0; i--) { + const c = slice.charCodeAt(i); + if (c === 46 /* . */ || c === 33 /* ! */ || c === 63 /* ? */) { + lastSentence = i; + break; + } + } + if (lastSentence >= floor) return slice.slice(0, lastSentence + 1); + + const lastSpace = slice.lastIndexOf(" "); + if (lastSpace >= floor) return slice.slice(0, lastSpace); + + return slice; +} + /** Get the Monday of the week containing the given date (ISO week) */ function getWeekStart(date: Date): string { const d = new Date(date); @@ -268,9 +314,9 @@ export function updateDailySummary(wm: WorkingMemory): void { } const today = wm.temporal?.date || new Date().toISOString().slice(0, 10); - // Compress currentContext to a one-liner (first 200 chars) + // Compress currentContext to a one-liner, capped at 200 chars (prefer clean cutoff). if (wm.currentContext) { - wm.temporalSummaries.daily[today] = wm.currentContext.slice(0, 200); + wm.temporalSummaries.daily[today] = smartTruncate(wm.currentContext, 200); } // Prune old daily summaries beyond retention window @@ -307,8 +353,8 @@ export function compileWeeklySummary(wm: WorkingMemory): void { // Create weekly summaries for completed weeks for (const [weekStart, dailies] of pastWeekDays) { if (wm.temporalSummaries.weekly[weekStart]) continue; // already compiled - // Combine daily summaries, truncate to 300 chars - wm.temporalSummaries.weekly[weekStart] = dailies.join(" | ").slice(0, 300); + // Combine daily summaries, capped at 300 chars (prefer clean cutoff). + wm.temporalSummaries.weekly[weekStart] = smartTruncate(dailies.join(" | "), 300); } // Prune old weekly summaries diff --git a/backend/system-prompt.ts b/backend/system-prompt.ts index cf324c86..3bb949a9 100644 --- a/backend/system-prompt.ts +++ b/backend/system-prompt.ts @@ -3,6 +3,7 @@ import { ariaPersonality } from "./aria-identity.js"; import type { CharacterOverride } from "./aria-identity.js"; import { getBrainConfig, getCharacterPreset } from "./brain-config.js"; import type { MemoryNode, WorkingMemory } from "./memory/types.js"; +import { trackingItemText } from "./memory/working-memory.js"; import { BRAIN_DIR } from "./config.js"; @@ -23,7 +24,7 @@ function loadMemoryContext(): string { const wmParts: string[] = []; if (wm.currentContext) wmParts.push(`Current context: ${wm.currentContext}`); if (wm.mood) wmParts.push(`Mood: ${wm.mood}`); - if (wm.shortTermTracking?.length > 0) wmParts.push(`Tracking: ${wm.shortTermTracking.join(", ")}`); + if (wm.shortTermTracking?.length > 0) wmParts.push(`Tracking: ${wm.shortTermTracking.map(trackingItemText).join(", ")}`); if (wmParts.length > 0) { parts.push(`Working memory:\n${wmParts.join("\n")}`); } @@ -94,7 +95,7 @@ export function getMessageMemoryContext(): string { const parts: string[] = []; if (wm.currentContext) parts.push(`Context: ${wm.currentContext}`); if (wm.mood) parts.push(`Mood: ${wm.mood}`); - if (wm.shortTermTracking?.length > 0) parts.push(`Tracking: ${wm.shortTermTracking.join(", ")}`); + if (wm.shortTermTracking?.length > 0) parts.push(`Tracking: ${wm.shortTermTracking.map(trackingItemText).join(", ")}`); if (wm.activeGoals?.length > 0) { parts.push(`Goals: ${wm.activeGoals.map((g: { title: string }) => g.title).join(", ")}`); } diff --git a/frontend/app/pages/memory.vue b/frontend/app/pages/memory.vue index dc6c4c63..75e1b6db 100644 --- a/frontend/app/pages/memory.vue +++ b/frontend/app/pages/memory.vue @@ -26,7 +26,7 @@
Tracking
- {{ item }} + {{ trackingItemText(item) }}
@@ -192,6 +192,7 @@