Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions backend/brain-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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": []
},
Expand Down Expand Up @@ -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": "<obs id from observations>", "evidenceSender": "<sender name>", "evidenceTs": <unix ms>}
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)}).`}
Expand Down Expand Up @@ -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`
: "";
Expand Down Expand Up @@ -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": []
},
Expand Down
5 changes: 3 additions & 2 deletions backend/memory/reconstruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)));
}
}

Expand Down Expand Up @@ -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])];

Expand Down
27 changes: 25 additions & 2 deletions backend/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,33 @@ export interface TemporalSummaries {
weekly: Record<string, string>;
}

/**
* 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[];
Expand Down Expand Up @@ -276,7 +299,7 @@ export interface BrainResponse {
workingMemory?: {
currentContext?: string;
mood?: string;
shortTermTracking?: string[];
shortTermTracking?: ShortTermTrackingItem[];
pendingFollowUps?: PendingFollowUp[];
conversationThreads?: ConversationThread[];
};
Expand Down
58 changes: 52 additions & 6 deletions backend/memory/working-memory.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions backend/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";


Expand All @@ -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")}`);
}
Expand Down Expand Up @@ -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(", ")}`);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/pages/memory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<div v-if="wm.shortTermTracking?.length" class="wm-tracking">
<span class="wm-label">Tracking</span>
<div class="wm-tags">
<span v-for="(item, i) in wm.shortTermTracking" :key="i" class="tag">{{ item }}</span>
<span v-for="(item, i) in wm.shortTermTracking" :key="i" class="tag">{{ trackingItemText(item) }}</span>
</div>
</div>
<div v-if="wm.activatedNodeIds?.length" class="wm-activated">
Expand Down Expand Up @@ -192,6 +192,7 @@

<script setup lang="ts">
import type { AriaStatus, GraphNode, ConceptTreeNode, RetentionTier } from '~/types/aria'
import { trackingItemText } from '~/types/aria'

const { api } = useApi()
const { showToast } = useToast()
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/pages/overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
</div>
<div v-if="wm.shortTermTracking && wm.shortTermTracking.length" class="wm-field">
<div class="wm-label">Tracking</div>
<div class="wm-val">{{ wm.shortTermTracking.join(', ') }}</div>
<div class="wm-val">{{ wm.shortTermTracking.map(trackingItemText).join(', ') }}</div>
</div>
<UiKvRow label="Last Updated" :value="timeAgo(wm.lastUpdated)" />
</template>
Expand All @@ -102,6 +102,7 @@

<script setup lang="ts">
import type { DashboardData } from '~/types/aria'
import { trackingItemText } from '~/types/aria'

const { api } = useApi()
const { timeAgo } = useTimeAgo()
Expand Down
21 changes: 20 additions & 1 deletion frontend/app/types/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,33 @@ export interface MemoryNode {
accessCount: number
}

export type ShortTermTrackingItem =
| string
| {
text: string
evidenceObsId?: string
evidenceSender?: string
evidenceTs?: number
}

export interface WorkingMemory {
currentContext: string
mood: string
shortTermTracking: string[]
shortTermTracking: ShortTermTrackingItem[]
activatedNodeIds: string[]
lastUpdated: number
}

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 interface BrainState {
lastObserveTick: number
lastThinkTick: number
Expand Down