diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b9eac..9658f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed +- **Memory unification follow-up (aegis-oss#78)**: `consolidateEpisodicToSemantic` (`kernel/memory/consolidation.ts`) redesigned to write through `writeDreamFact()` (new `kernel/memory/dream-write.ts`) into the wiki's `dreams` scope, instead of writing raw fragments straight into the memory-worker fragment store — this was the deferred piece of the #457 wiki unification the original decision doc flagged as needing design work. Consolidation is now purely additive (extract 0-3 genuine facts per cycle, write each as its own dream page); the old ADD/UPDATE/DELETE-by-fragment-id model doesn't map cleanly onto wiki pages, so update/delete semantics are dropped in favor of the dreams lifecycle (promote on corroboration, archive if stale) already used by PRISM's cross-domain synthesis. +- Removed `publishInsightsFromMemory()` from `kernel/scheduled/consolidation.ts` — this CRIX insight-publishing step called `publishInsight()`/`validateInsight()` against a D1 table literally named `memory`, which is never created by any migration in this repo or in aegis-daemon. Every invocation was silently failing (caught and logged as a warning) on every consolidation cycle. `insights.ts`'s public API (`publishInsight`, `validateInsight`, `promoteInsight`, `listInsights`, etc.) is left intact as OSS surface for downstream consumers — only the internal, broken call site was removed. + ### Added - OTDD contract test suites for all five bounded contexts — AgendaItem (48 tests), CCTask (53 tests), Goal (65 tests), ExecutorRouter I1/I2 violation paths (4 tests), and MemoryEntry/CRIX pipeline (33 tests). Tests are derived directly from `.contract.ts` schema, state machine, invariant, and authority declarations with no mocks. diff --git a/web/package.json b/web/package.json index f010716..4b58d2f 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.8.4", + "version": "0.8.7", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/src/email.ts b/web/src/email.ts index 635467d..ace634d 100755 --- a/web/src/email.ts +++ b/web/src/email.ts @@ -280,7 +280,47 @@ export async function sendDailyDigest( return `${s.toUpperCase()}`; }; - // ── Section 1: WORK SHIPPED ── + // ── LEAD SIGNAL ── + // Single highest-priority banner. Only fires when truly actionable. + let leadSignal: { label: string; text: string; color: string } | null = null; + + const critAlerts = sections.serviceAlerts.filter(a => a.severity === 'critical'); + const highAlerts = sections.serviceAlerts.filter(a => a.severity === 'high'); + const hasFailedPayment = sections.eventNotifications.some(e => + e.event_type.includes('payment') && e.event_type.includes('fail'), + ); + const revenueEvents = sections.eventNotifications.filter(e => + e.event_type.includes('invoice.paid') || e.event_type.includes('checkout.session.completed'), + ); + const deadlineAgenda = sections.agendaItems.filter(a => /^upcoming_deadlines:/i.test(a.item)); + const failRate = sections.failedTasks.length / Math.max(1, sections.completedTasks.length + sections.failedTasks.length); + const critHealthChecks = sections.healthChecks.filter(h => h.severity === 'critical'); + + if (critAlerts.length > 0) { + leadSignal = { label: 'CRITICAL', text: critAlerts[0].summary, color: '#ef4444' }; + } else if (critHealthChecks.length > 0) { + leadSignal = { label: 'CRITICAL', text: `System health: ${critHealthChecks[0].checks.find(c => c.status !== 'ok')?.name ?? 'check failed'}`, color: '#ef4444' }; + } else if (hasFailedPayment) { + leadSignal = { label: 'PAYMENT FAILURE', text: 'Failed payment detected — check Stripe', color: '#ef4444' }; + } else if (highAlerts.length > 0) { + leadSignal = { label: 'ALERT', text: highAlerts[0].summary, color: '#f5a623' }; + } else if (failRate > 0.5 && sections.failedTasks.length >= 2) { + leadSignal = { label: 'FAIL RATE', text: `${Math.round(failRate * 100)}% task failure — review before queuing more`, color: '#f5a623' }; + } else if (deadlineAgenda.length > 0) { + const text = deadlineAgenda[0].item.replace(/^upcoming_deadlines:\s*/i, ''); + leadSignal = { label: 'DEADLINE', text, color: '#f5a623' }; + } else if (revenueEvents.length > 0) { + leadSignal = { label: 'REVENUE', text: `${revenueEvents.length} payment${revenueEvents.length !== 1 ? 's' : ''} received`, color: '#2dd4a0' }; + } + + const leadHtml = leadSignal + ? `
+ ${leadSignal.label} +

${leadSignal.text}

+
` + : ''; + + // ── WORK SHIPPED ── let workShippedHtml = ''; if (sections.failedTasks.length > 0 || sections.completedTasks.length > 0) { const taskRow = (t: DigestTask) => { @@ -303,122 +343,72 @@ export async function sendDailyDigest( `; } - // ── Section 2: OPERATOR'S LOG ── - let operatorLogHtml = ''; - if (sections.operatorLog) { - const contentHtml = sections.operatorLog - .split('\n\n') - .filter(p => p.trim()) - .map(p => { - if (p.startsWith('## ')) return `

${p.slice(3)}

`; - if (p.startsWith('### ')) return `

${p.slice(4)}

`; - const formatted = p.replace(/\*\*(.+?)\*\*/g, '$1'); - return `

${formatted}

`; - }) - .join(''); - - operatorLogHtml = ` -
-

Operator's Log

-
- ${contentHtml} -
-
`; - } - - // ── Section 3: SYSTEM HEALTH ── - let healthHtml = ''; - if (sections.healthChecks.length > 0) { - const allChecks = sections.healthChecks.flatMap(h => h.checks.map(c => ({ ...c, severity: h.severity }))); - const checksRows = allChecks.map(c => { - const color = c.status === 'alert' ? '#ff6b6b' : '#ffd93d'; - return ` + // ── SERVICE ALERTS (high/critical only) ── + let serviceAlertsHtml = ''; + const actionableAlerts = sections.serviceAlerts.filter(a => + a.severity === 'critical' || a.severity === 'high', + ); + if (actionableAlerts.length > 0) { + const sevColor = (s: string) => s === 'critical' ? '#ef4444' : '#f5a623'; + const alertRows = actionableAlerts.map(a => ` - ${c.name} - ${c.detail} - `; - }).join(''); - - healthHtml = ` -
-

System Health

- - - - - - - - ${checksRows} -
CheckDetail
-
`; - } + ${a.severity.toUpperCase()} + ${a.source} + ${a.summary} + `).join(''); - // ── Section 3b: ARGUS EVENTS ── - let eventsHtml = ''; - if (sections.eventNotifications.length > 0) { - const eventRows = sections.eventNotifications.map(e => { - const color = e.priority === 'high' ? '#f5a623' : '#888'; - return ` - - ${e.source} - ${e.event_type} - ${e.summary} - ${e.ts.slice(0, 16)} - `; - }).join(''); - - eventsHtml = ` + serviceAlertsHtml = `
-

ARGUS Events (${sections.eventNotifications.length})

+

Alerts (${actionableAlerts.length})

+ - - - ${eventRows} + ${alertRows}
Sev SourceEvent SummaryTime
`; } - // ── Section 3c: COGNITIVE SCORECARD ── - let metricsHtml = ''; - if (sections.cognitiveMetrics) { - const m = sections.cognitiveMetrics; - const arrow = m.score_delta > 0 ? '▲' : m.score_delta < 0 ? '▼' : '▬'; - const arrowColor = m.score_delta > 0 ? '#2dd4a0' : m.score_delta < 0 ? '#ef4444' : '#888'; - const scoreColor = m.cognitive_score >= 75 ? '#2dd4a0' : m.cognitive_score >= 50 ? '#f5a623' : '#ef4444'; - const costDelta = m.avg_cost_prior_7d > 0 - ? ((m.avg_cost_7d - m.avg_cost_prior_7d) / m.avg_cost_prior_7d * 100).toFixed(0) - : '0'; - const costArrow = Number(costDelta) <= 0 ? '#2dd4a0' : '#ef4444'; - - metricsHtml = ` -
-

Cognitive Scorecard

-
-
- ${m.cognitive_score} - ${arrow} ${Math.abs(m.score_delta)} - /100 -
- - - - - - - ${m.top_failure_kind ? `` : ''} + // ── SYSTEM HEALTH (critical/high only) ── + let healthHtml = ''; + const actionableHealthChecks = sections.healthChecks.filter(h => + h.severity === 'critical' || h.severity === 'high', + ); + if (actionableHealthChecks.length > 0) { + const nonOkChecks = actionableHealthChecks.flatMap(h => + h.checks.filter(c => c.status !== 'ok').map(c => ({ ...c, severity: h.severity })), + ); + if (nonOkChecks.length > 0) { + const checksRows = nonOkChecks.map(c => { + const color = c.status === 'alert' ? '#ff6b6b' : '#ffd93d'; + return ` + + + + `; + }).join(''); + + healthHtml = ` +
+

System Health

+
Dispatch success${Math.round(m.dispatch_success_rate_7d * 100)}%
Procedures learned${Math.round(m.procedure_convergence_rate * 100)}%
Tasks shipped (7d)${m.tasks_completed_7d} / ${m.tasks_completed_7d + m.tasks_failed_7d}
Avg cost/dispatch$${m.avg_cost_7d.toFixed(4)} (${Number(costDelta) <= 0 ? '' : '+'}${costDelta}%)
Memory entries${m.memory_count}
Top failure mode${m.top_failure_kind}
${c.name}${c.detail}
+ + + + + + + ${checksRows}
CheckDetail
-
-
`; + `; + } } - // ── Section 3c2: ANALYTICS ── + // ── TRAFFIC (GA4) — numbers + tables, no auto-commentary ── let analyticsHtml = ''; if (sections.analytics && sections.analytics.sessions_7d > 0) { const a = sections.analytics; @@ -442,12 +432,6 @@ export async function sendDailyDigest( ${s.sessions} `).join(''); - const insightsHtml = a.insights.length > 0 - ? `
- -
` - : ''; - analyticsHtml = `

Traffic (GA4)

@@ -469,242 +453,118 @@ export async function sendDailyDigest( Medium Sessions ${sourcesRows}` : ''} - ${insightsHtml} -
- `; - } - - // ── Section 3c3: DEVELOPER ACTIVITY ── - let devActivityHtml = ''; - if (sections.devActivity) { - const d = sections.devActivity; - const tierBadges = d.tier_breakdown.map(t => { - const colors: Record = { free: '#888', hobby: '#3dd6c8', pro: '#8b8bff', enterprise: '#f5a623' }; - return `${t.tier} ${t.count}`; - }).join(''); - - const signupRows = d.recent_signups.slice(0, 10).map(s => ` - - ${s.name || '(no name)'} - ${s.email} - ${s.tier} - ${s.created_at.slice(0, 10)} - `).join(''); - - devActivityHtml = ` -
-

Developer Activity

-
-
-
${d.total_users} users
-
${d.total_tenants} tenants
-
${d.keys_active_24h} active (24h)
-
- - - - - -
Keys created (24h)${d.keys_created_24h}
Keys created (7d)${d.keys_created_7d}
Keys created (all-time)${d.keys_created_all_time}
Active keys (7d)${d.keys_active_7d}
-
${tierBadges || 'No active keys'}
- ${signupRows ? `

Recent Signups (7d)

- - - - - - - ${signupRows}
NameEmailTierJoined
` : ''} -
-
`; - } - - // ── Section 3b2: SERVICE ALERTS ── - let serviceAlertsHtml = ''; - if (sections.serviceAlerts.length > 0) { - const sevColor = (s: string) => s === 'critical' ? '#ef4444' : s === 'high' ? '#f5a623' : s === 'medium' ? '#ffd93d' : '#888'; - const alertRows = sections.serviceAlerts.map(a => ` - - ${a.severity.toUpperCase()} - ${a.source} - ${a.summary}${a.findingsCount > 0 ? ` (${a.findingsCount} findings)` : ''} - `).join(''); - - serviceAlertsHtml = ` -
-

Service Alerts (${sections.serviceAlerts.length})

- - - - - - - - - ${alertRows} -
SeveritySourceSummary
-
`; - } - - // ── Section 3d: CO-FOUNDER'S TAKE ── - // Opinionated synthesis: what matters, what's off track, what nobody's working on - let cofounderHtml = ''; - { - const takes: string[] = []; - - // Revenue signal - const hasRevenue = sections.eventNotifications.some(e => e.event_type.includes('payment') || e.event_type.includes('checkout') || e.event_type.includes('invoice.paid')); - const hasFailedPayment = sections.eventNotifications.some(e => e.event_type.includes('failed')); - if (hasRevenue && !hasFailedPayment) { - takes.push('Revenue is flowing. Keep shipping.'); - } else if (hasFailedPayment) { - takes.push('Payment failures detected. Check Stripe dashboard before anything else today.'); - } - - // Task health - const failRate = sections.failedTasks.length / Math.max(1, sections.completedTasks.length + sections.failedTasks.length); - if (failRate > 0.4 && sections.failedTasks.length >= 3) { - takes.push(`Task failure rate is ${Math.round(failRate * 100)}% — the taskrunner is burning cycles. Review failed tasks before queuing more.`); - } - - // Stale proposals - const staleProposals = sections.agendaItems.filter(a => { - if (!a.item.startsWith('[PROPOSED ACTION]') || !a.created_at) return false; - const ageDays = (Date.now() - new Date(a.created_at).getTime()) / 86_400_000; - return ageDays > 4; - }); - if (staleProposals.length > 0) { - takes.push(`${staleProposals.length} proposed action${staleProposals.length > 1 ? 's' : ''} aging out. Approve or dismiss — stale proposals mean I'm doing work you're not reviewing.`); - } - - // High-priority agenda items piling up - const highItems = sections.agendaItems.filter(a => a.priority === 'high' && !a.item.startsWith('[PROPOSED')); - if (highItems.length >= 4) { - takes.push(`${highItems.length} high-priority agenda items. That's too many "high" items — either some aren't really high, or we need a focused triage session.`); - } - - // Nothing shipped - if (sections.completedTasks.length === 0 && sections.failedTasks.length === 0) { - takes.push('No tasks ran in the last 24h. Is the taskrunner down, or is this intentional?'); - } - - // Health checks surfacing - if (sections.healthChecks.length > 0) { - const critChecks = sections.healthChecks.filter(h => h.severity === 'critical'); - if (critChecks.length > 0) { - takes.push('Critical health checks in the system. This takes priority over feature work.'); - } - } - - // Service alerts - const critAlerts = sections.serviceAlerts.filter(a => a.severity === 'critical'); - if (critAlerts.length > 0) { - takes.push(`${critAlerts.length} critical service alert${critAlerts.length > 1 ? 's' : ''} from ${[...new Set(critAlerts.map(a => a.source))].join(', ')}. Review immediately.`); - } - - // Developer activity signals - if (sections.devActivity) { - if (sections.devActivity.recent_signups.length > 0) { - takes.push(`${sections.devActivity.recent_signups.length} new signup${sections.devActivity.recent_signups.length !== 1 ? 's' : ''} this week. People are finding us.`); - } - if (sections.devActivity.keys_active_24h === 0 && sections.devActivity.keys_created_all_time > 0) { - takes.push('No active API keys in the last 24h. Users signed up but aren\'t hitting endpoints — check onboarding friction.'); - } - } - - // Memory reflection available - if (sections.memoryReflection) { - takes.push('Weekly reflection attached below — worth a 2-minute read to see what I\'m learning.'); - } - - if (takes.length > 0) { - const takesHtml = takes.map(t => `
  • ${t}
  • `).join(''); - cofounderHtml = ` -
    -

    Co-Founder's Take

    -
    -
      ${takesHtml}
    `; - } } - // ── Section 3d: MEMORY REFLECTION ── - let reflectionHtml = ''; - if (sections.memoryReflection) { - const formatted = sections.memoryReflection - .split('\n\n') - .filter(p => p.trim()) - .map(p => `

    ${p.replace(/\*\*(.+?)\*\*/g, '$1')}

    `) - .join(''); - reflectionHtml = ` -
    -

    Weekly Reflection

    -
    ${formatted}
    -
    `; - } - - // ── Section 4: AWAITING ACTION ── + // ── AWAITING ACTION — capped, grouped ── let awaitingHtml = ''; - // Split agenda into proposed actions vs regular high-priority items - const proposedAgenda = sections.agendaItems.filter(a => a.item.startsWith('[PROPOSED ACTION]')); - const highAgenda = sections.agendaItems.filter(a => a.priority === 'high' && !a.item.startsWith('[PROPOSED ACTION]')); - if (sections.proposedTasks.length > 0 || proposedAgenda.length > 0 || highAgenda.length > 0) { - const proposedTaskRows = sections.proposedTasks.map(t => ` + { + const isSkillEvo = (t: DigestTask) => + /\[skill-evolution\]|prism synthesis|promote mindspring/i.test(t.title); + const skillEvoTasks = sections.proposedTasks.filter(isSkillEvo); + const otherProposedTasks = sections.proposedTasks.filter(t => !isSkillEvo(t)).slice(0, 3); + + const isSentinel = (a: DigestAgendaItem) => /^\[SENTINEL/i.test(a.item); + const isDeadline = (a: DigestAgendaItem) => /^upcoming_deadlines:/i.test(a.item); + const isProposedAction = (a: DigestAgendaItem) => a.item.startsWith('[PROPOSED ACTION]'); + + const sentinelItems = sections.agendaItems.filter(isSentinel); + const proposedActionItems = sections.agendaItems.filter(isProposedAction).slice(0, 2); + const regularHigh = sections.agendaItems + .filter(a => a.priority === 'high' && !isSentinel(a) && !isDeadline(a) && !isProposedAction(a)) + .slice(0, 3); + + const hasContent = + otherProposedTasks.length > 0 || skillEvoTasks.length > 0 || + sentinelItems.length > 0 || deadlineAgenda.length > 0 || + proposedActionItems.length > 0 || regularHigh.length > 0; + + if (hasContent) { + // Deadlines first — time-sensitive + const deadlineRows = deadlineAgenda.map(a => { + const text = a.item.replace(/^upcoming_deadlines:\s*/i, ''); + return ` +
    + DEADLINE +

    ${text}

    +
    `; + }).join(''); + + // Non-skill-evo proposed tasks (capped at 3) + const proposedTaskRows = otherProposedTasks.map(t => `
    ${statusBadge('pending')}${t.repo} · ${t.category}

    ${t.title}

    Awaiting approval · ID: ${t.id.slice(0, 8)}

    `).join(''); - // Proposed action agenda items with expiry countdown (auto-expire at 7d) - const proposedAgendaRows = proposedAgenda.map(a => { - const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); - const daysLeft = Math.max(0, 7 - ageDays); - const expiryNote = daysLeft <= 2 ? ` expires in ${daysLeft}d` : ` ${daysLeft}d until auto-expire`; - return ` -
    - #${a.id} · proposed${expiryNote} -

    ${a.item.replace('[PROPOSED ACTION] ', '')}

    -
    `; - }).join(''); - - const agendaRows = highAgenda.map(a => ` + // Skill-evolution collapsed to one row + const skillEvoRow = skillEvoTasks.length > 0 + ? `
    +
    ${statusBadge('pending')}skill-evolution · batch
    +

    ${skillEvoTasks.length} skill-evolution proposal${skillEvoTasks.length !== 1 ? 's' : ''} pending — use aegis_batch_approve to review

    +
    ` + : ''; + + // Proposed action agenda items (capped at 2) + const proposedActionRows = proposedActionItems.map(a => { + const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); + const daysLeft = Math.max(0, 7 - ageDays); + const expiryNote = daysLeft <= 2 + ? ` expires in ${daysLeft}d` + : ` ${daysLeft}d left`; + return ` +
    + #${a.id} · proposed${expiryNote} +

    ${a.item.replace('[PROPOSED ACTION] ', '')}

    +
    `; + }).join(''); + + // Regular high-priority items (capped at 3) + const regularRows = regularHigh.map(a => `
    - #${a.id} · ${a.priority} + #${a.id} · high

    ${a.item}

    `).join(''); - // Review prompt only when proposals exist - const reviewPrompt = proposedAgenda.length > 0 - ? `

    ${proposedAgenda.length} proposed action${proposedAgenda.length !== 1 ? 's' : ''} awaiting review. Unapproved proposals auto-expire after 7 days.

    ` - : ''; - - awaitingHtml = ` -
    -

    Awaiting Action

    - ${proposedTaskRows} - ${proposedAgendaRows} - ${agendaRows} - ${reviewPrompt} -
    `; + // Sentinel items — shown as chronic, not daily action items + const sentinelRows = sentinelItems.length > 0 + ? `
    +

    Chronic (${sentinelItems.length})

    + ${sentinelItems.map(a => { + const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); + const label = a.item.replace(/^\[SENTINEL[^\]]*\]\s*/i, '').slice(0, 80); + return `

    #${a.id} · day ${ageDays} · ${label}

    `; + }).join('')} +
    ` + : ''; + + awaitingHtml = ` +
    +

    Awaiting Action

    + ${deadlineRows} + ${proposedTaskRows} + ${skillEvoRow} + ${proposedActionRows} + ${regularRows} + ${sentinelRows} +
    `; + } } - // ── Section 5: STATS BAR ── + // ── STATS BAR ── const stats = [ `${sections.completedTasks.length + sections.failedTasks.length} tasks`, `${sections.proposedTasks.length} proposed`, `${sections.agendaItems.length} agenda`, - `${sections.healthChecks.length} health checks`, ]; - if (sections.eventNotifications.length > 0) stats.push(`${sections.eventNotifications.length} events`); if (sections.serviceAlerts.length > 0) stats.push(`${sections.serviceAlerts.length} alerts`); if (sections.devActivity) stats.push(`${sections.devActivity.total_users} users`); - if (sections.bizopsInteractions !== null) stats.push(`${sections.bizopsInteractions} interactions`); const statsHtml = `

    ${stats.join(' · ')}

    `; - const hasContent = metricsHtml || cofounderHtml || workShippedHtml || operatorLogHtml || healthHtml || eventsHtml || serviceAlertsHtml || analyticsHtml || devActivityHtml || reflectionHtml || awaitingHtml; + const hasContent = leadHtml || workShippedHtml || serviceAlertsHtml || healthHtml || analyticsHtml || awaitingHtml; const html = ` @@ -715,7 +575,7 @@ export async function sendDailyDigest(

    AEGIS — Co-Founder Brief

    ${date}

    - ${hasContent ? `${metricsHtml}${cofounderHtml}${workShippedHtml}${operatorLogHtml}${healthHtml}${eventsHtml}${serviceAlertsHtml}${analyticsHtml}${devActivityHtml}${reflectionHtml}${awaitingHtml}` : '

    No significant activity in the last 24 hours.

    '} + ${hasContent ? `${leadHtml}${workShippedHtml}${serviceAlertsHtml}${healthHtml}${analyticsHtml}${awaitingHtml}` : '

    No significant activity in the last 24 hours.

    '} ${statsHtml}

    aegis-web · daily digest · ${new Date().toISOString()}

    diff --git a/web/src/kernel/memory/consolidation.ts b/web/src/kernel/memory/consolidation.ts index d68f092..623e490 100755 --- a/web/src/kernel/memory/consolidation.ts +++ b/web/src/kernel/memory/consolidation.ts @@ -1,194 +1,109 @@ -import { askGroq } from '../../groq.js'; -import { normalizeTopic, factHash } from './semantic.js'; -import { extractNodes, createEdges } from './graph.js'; - -// ─── Memory Consolidation ─────────────────────────────────── - -const CONSOLIDATION_SYSTEM = `You are AEGIS memory consolidation. Analyze recent episodes against existing memory and produce structured operations. - -Operations: -- ADD: a genuinely new fact not covered by existing memory -- UPDATE: an existing fact that needs correction or has new information (reference its id via supersedes_id) -- DELETE: an existing fact that is no longer accurate (reference its id via target_id, provide reason) -- NOOP: nothing to do — return [] - -Rules: -1. Every fact MUST contain at least one specific detail: a name, date, number, ID, URL, or version. Never write vague observations. -2. If episodes merely confirm existing facts, return []. -3. Prefer UPDATE over ADD when a fact evolves (e.g., "Phase 2 planned" → "Phase 2 complete"). -4. DELETE facts that are provably wrong or obsolete based on episode evidence. -5. Max 3 operations per run. Quality over quantity. -6. Good facts: "Delaware PBC franchise tax filed 2026-03-03, 2 days late" or "BizOps dashboard_summary tool fails when org has no projects (undefined.id)". -7. Bad facts: "Financial metrics are important" or "Document gaps exist and require attention". - -KNOWN TOPICS — reuse these whenever the fact fits. Only create a new topic if genuinely none of these apply: -- aegis: cognitive kernel internals, architecture, infrastructure, versioning, deployment -- img_forge: img-forge product, economics, integrations, API -- bizops: BizOps tool, MCP tools, operational improvements -- content: roundtable, dispatch, column generation pipelines -- self_improvement: self-improvement analysis, outcomes, patterns -- organization: org-level priorities, governance, go-to-market -- auth: auth product, Better Auth, API key formats, middleware chain, auth-contract -- product_strategy: product positioning, competitive landscape -- compliance: legal, tax, compliance deadlines, regulatory -- finance: financial metrics, costs, revenue, billing -- operator_preferences: operator preferences, workflow choices -- milestones: key dates, launches, completed phases -- mcp_strategy: MCP protocol, OAuth, remote MCP, tool design - -Return ONLY a JSON array (no markdown): -[ - { "operation": "ADD", "topic": "aegis", "fact": "specific fact", "confidence": 0.8 }, - { "operation": "UPDATE", "supersedes_id": 42, "topic": "auth", "fact": "updated fact", "confidence": 0.9 }, - { "operation": "DELETE", "target_id": 37, "reason": "no longer accurate" } -] - -Return [] if nothing needs to change.`; - -export async function consolidateEpisodicToSemantic( - db: D1Database, - groqApiKey: string, - groqModel: string, - groqBaseUrl?: string, - memoryBinding?: import('../../types.js').MemoryServiceBinding, -): Promise { - // High-water mark: only process episodes since last consolidation (not a rolling 24h window) - const lastRun = await db.prepare( - "SELECT received_at FROM web_events WHERE event_id = 'last_consolidation_at'" - ).first<{ received_at: string }>(); - const since = lastRun?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); - - const result = await db.prepare( - 'SELECT id, intent_class, channel, summary, outcome, cost FROM episodic_memory WHERE created_at > ? ORDER BY created_at DESC LIMIT 20' - ).bind(since).all(); - - const episodes = result.results as unknown as Array<{ - id: number; - intent_class: string; - channel: string; - summary: string; - outcome: string; - cost: number; - }>; - - // Guard: skip if not enough new signal - if (episodes.length < 3) return; - - // Fetch existing active memory with IDs so the model can reference them for UPDATE/DELETE - // Memory Worker is the sole knowledge store — D1 reads removed - let existingMemoryList: Array<{ id: string | number; topic: string; fact: string }> = []; - if (memoryBinding) { - try { - const fragments = await memoryBinding.recall('aegis', { limit: 40 }); - existingMemoryList = fragments.map(f => ({ id: f.id, topic: f.topic, fact: f.content })); - } catch (err) { - console.error('[consolidation] Memory Worker recall failed:', err instanceof Error ? err.message : String(err)); - } - } - - const memoryContext = existingMemoryList.length > 0 - ? `\n\nExisting memory (reference by id for UPDATE/DELETE operations):\n${existingMemoryList.map(m => `- [id=${m.id}] [${m.topic}] ${m.fact}`).join('\n')}` - : ''; - - const userPrompt = `Recent agent episodes (since last consolidation):\n\n${episodes.map((e, i) => - `${i + 1}. [${e.intent_class}/${e.outcome}] ${e.summary}` - ).join('\n')}${memoryContext}`; - - let rawResponse: string; - try { - rawResponse = await askGroq(groqApiKey, groqModel, CONSOLIDATION_SYSTEM, userPrompt, groqBaseUrl); - } catch { - return; // Groq failure — skip silently, will retry next cron cycle - } - - if (!rawResponse) return; - const cleaned = rawResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); - let ops: Array<{ - operation: 'ADD' | 'UPDATE' | 'DELETE' | 'NOOP'; - topic?: string; fact?: string; confidence?: number; - supersedes_id?: string | number; - target_id?: string | number; reason?: string; - }>; - try { - ops = JSON.parse(cleaned); - if (!Array.isArray(ops)) return; - } catch { - return; - } - - for (const op of ops.slice(0, 3)) { // Hard cap: max 3 per run - switch (op.operation) { - case 'ADD': { - if (!op.topic || !op.fact) continue; - if (op.fact.length < 30) continue; - if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue; - if (!memoryBinding) continue; - await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.7, source: 'episodic_consolidation' }]); - console.log(`[consolidation] ADD [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.7})`); - // Phase 2: Extract knowledge graph nodes + edges from new fact - try { - const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic)); - if (nodeIds.length >= 2) { - await createEdges(db, nodeIds); - } - } catch (err) { - console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); - } - break; - } - case 'UPDATE': { - if (!op.supersedes_id || !op.topic || !op.fact) continue; - if (op.fact.length < 30) continue; - if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue; - if (memoryBinding) { - // Forget old → store new - await memoryBinding.forget('aegis', { ids: [String(op.supersedes_id)] }); - await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.8, source: 'episodic_consolidation' }]); - } else { - await db.prepare( - "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL" - ).bind(op.supersedes_id).run(); - const topic = normalizeTopic(op.topic); - const hash = factHash(topic, op.fact); - const insertResult = await db.prepare( - 'INSERT INTO memory_entries (topic, fact, fact_hash, confidence, source, superseded_by) VALUES (?, ?, ?, ?, ?, ?)' - ).bind(topic, op.fact, hash, op.confidence ?? 0.8, 'episodic_consolidation', op.supersedes_id).run(); - if (insertResult.meta.last_row_id) { - await db.prepare( - 'UPDATE memory_entries SET superseded_by = ? WHERE id = ?' - ).bind(insertResult.meta.last_row_id, op.supersedes_id).run(); - } - } - console.log(`[consolidation] UPDATE ${op.supersedes_id} → [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.8})`); - // Phase 2: Extract knowledge graph nodes + edges from updated fact - try { - const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic)); - if (nodeIds.length >= 2) { - await createEdges(db, nodeIds); - } - } catch (err) { - console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); - } - break; - } - case 'DELETE': { - if (!op.target_id) continue; - if (memoryBinding) { - await memoryBinding.forget('aegis', { ids: [String(op.target_id)] }); - } else { - await db.prepare( - "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL" - ).bind(op.target_id).run(); - } - console.log(`[consolidation] Soft-deleted memory ${op.target_id}: ${op.reason ?? 'no reason'}`); - break; - } - // NOOP — skip - } - } - - // Advance the high-water mark — these episodes won't be re-processed - await db.prepare( - "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_consolidation_at', datetime('now'))" - ).run(); -} +import { askGroq } from '../../groq.js'; +import { extractNodes, createEdges } from './graph.js'; +import { writeDreamFact } from './dream-write.js'; +import type { WikiClientEnv } from '../../wiki/client.js'; + +// ─── Memory Consolidation ─────────────────────────────────── +// Redesigned for the #457 wiki unification (aegis-oss#78): writes go to +// the wiki's `dreams` scope instead of the memory-worker fragment store. +// Dreams are additive, provisional pages (confidence: drifting) with their +// own lifecycle rules (promote on corroboration, archive if stale) — that +// supersedes the old ADD/UPDATE/DELETE-by-id fragment model, which relied +// on reading back an existing fragment list to pick a target id. There is +// no equivalent "patch this specific prior fact" operation for wiki pages +// written this way; letting the dreams lifecycle handle staleness is the +// same tradeoff PRISM's synthesis.ts already makes in production. + +const CONSOLIDATION_SYSTEM = `You are AEGIS memory consolidation. Analyze recent agent episodes and extract genuinely new, specific facts worth remembering long-term. + +Rules: +1. Every fact MUST contain at least one specific detail: a name, date, number, ID, URL, or version. Never write vague observations. +2. Max 3 facts per run. Quality over quantity. +3. Good facts: "Delaware PBC franchise tax filed 2026-03-03, 2 days late" or "BizOps dashboard_summary tool fails when org has no projects (undefined.id)". +4. Bad facts: "Financial metrics are important" or "Document gaps exist and require attention". +5. If episodes contain nothing worth remembering, return []. + +Return ONLY a JSON array (no markdown): +[ + { "topic": "aegis", "fact": "specific fact", "confidence": 0.8 } +] + +Return [] if nothing needs to change.`; + +export async function consolidateEpisodicToSemantic( + db: D1Database, + groqApiKey: string, + groqModel: string, + groqBaseUrl?: string, + wikiEnv?: WikiClientEnv, +): Promise { + if (!wikiEnv?.wikiBinding || !wikiEnv.wikiToken) return; + + // High-water mark: only process episodes since last consolidation (not a rolling 24h window) + const lastRun = await db.prepare( + "SELECT received_at FROM web_events WHERE event_id = 'last_consolidation_at'" + ).first<{ received_at: string }>(); + const since = lastRun?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); + + const result = await db.prepare( + 'SELECT id, intent_class, channel, summary, outcome, cost FROM episodic_memory WHERE created_at > ? ORDER BY created_at DESC LIMIT 20' + ).bind(since).all(); + + const episodes = result.results as unknown as Array<{ + id: number; + intent_class: string; + channel: string; + summary: string; + outcome: string; + cost: number; + }>; + + // Guard: skip if not enough new signal + if (episodes.length < 3) return; + + const userPrompt = `Recent agent episodes (since last consolidation):\n\n${episodes.map((e, i) => + `${i + 1}. [${e.intent_class}/${e.outcome}] ${e.summary}` + ).join('\n')}`; + + let rawResponse: string; + try { + rawResponse = await askGroq(groqApiKey, groqModel, CONSOLIDATION_SYSTEM, userPrompt, groqBaseUrl); + } catch { + return; // Groq failure — skip silently, will retry next cron cycle + } + + if (!rawResponse) return; + const cleaned = rawResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); + let facts: Array<{ topic?: string; fact?: string; confidence?: number }>; + try { + facts = JSON.parse(cleaned); + if (!Array.isArray(facts)) return; + } catch { + return; + } + + for (const item of facts.slice(0, 3)) { // Hard cap: max 3 per run + if (!item.fact || item.fact.length < 30) continue; + if (!/\d/.test(item.fact) && !/[A-Z][a-z]/.test(item.fact.slice(1))) continue; + + const topic = item.topic || 'aegis'; + const confidence = item.confidence ?? 0.7; + await writeDreamFact(wikiEnv, { fact: item.fact, confidence, source: 'episodic_consolidation' }); + console.log(`[consolidation] Wrote dream fact [${topic}] "${item.fact.slice(0, 80)}" (conf:${confidence})`); + + // Knowledge graph extraction — decoupled from storage backend, keep as-is + try { + const nodeIds = await extractNodes(db, item.fact, topic); + if (nodeIds.length >= 2) { + await createEdges(db, nodeIds); + } + } catch (err) { + console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); + } + } + + // Advance the high-water mark — these episodes won't be re-processed + await db.prepare( + "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_consolidation_at', datetime('now'))" + ).run(); +} diff --git a/web/src/kernel/memory/dream-write.ts b/web/src/kernel/memory/dream-write.ts new file mode 100644 index 0000000..c3c1d74 --- /dev/null +++ b/web/src/kernel/memory/dream-write.ts @@ -0,0 +1,63 @@ +// ─── Dream-scope fact writer (aegis#684 / aegis-oss#78) ──────── +// Writes a fact into the wiki's `dreams` scope instead of the legacy +// memory-worker fragment store. Mirrors the pattern the daemon's +// synthesis.ts (PRISM) already proves in production: one wiki page per +// fact, scope: dreams, type: synthesis, confidence: drifting, unique +// timestamped slug — no merge/lint conflicts, subject to the same dreams +// lifecycle rules (archive if uncorroborated). +// +// Never throws — write failures log and fall through, matching the +// grounding-layer invariant that non-critical writes must not block the +// caller's scheduled task. + +import { writePage } from '../../wiki/client.js'; +import type { WikiClientEnv } from '../../wiki/client.js'; + +export interface DreamFactInput { + fact: string; + confidence: number; + source: string; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +export async function writeDreamFact(env: WikiClientEnv, input: DreamFactInput): Promise { + if (!env.wikiBinding || !env.wikiToken) return; + + const { fact, confidence, source } = input; + const sourceSlug = slugify(source); + const slug = `dream-${sourceSlug}-${Date.now()}`; + + try { + await writePage(env, { + slug, + scope: 'dreams', + type: 'synthesis', + title: `${source}: ${fact.slice(0, 60)}`, + summary: fact.slice(0, 200), + body: [ + '## Fact', + '', + fact, + '', + '## Confidence', + '', + `${(confidence * 100).toFixed(0)}%`, + '', + '## Source', + '', + source, + ].join('\n'), + confidence: 'drifting', + canonical: false, + sources: [{ type: 'core_scheduled_task', ref: source, verified_date: new Date().toISOString().slice(0, 10) }], + }); + } catch (err) { + console.warn(`[dream-write] Failed to write dream page for source "${source}":`, err instanceof Error ? err.message : String(err)); + } +} diff --git a/web/src/kernel/memory/graph.ts b/web/src/kernel/memory/graph.ts index e068072..1ba2821 100755 --- a/web/src/kernel/memory/graph.ts +++ b/web/src/kernel/memory/graph.ts @@ -9,7 +9,18 @@ // ─── Node Type Classification ──────────────────────────────────────────────── -import { WasmGraph } from '@stackbilt/wasm-core'; +// Lazy import: @stackbilt/wasm-core calls wasm.__wbindgen_start() at module +// init time, which fails CF Workers validation (wasm is a Module, not an +// instantiated Instance, during the validation pass). Dynamic import defers +// initialization until the function body runs under the actual CF runtime. +let _WasmGraph: typeof import('@stackbilt/wasm-core').WasmGraph | null = null; +async function getWasmGraph(): Promise { + if (!_WasmGraph) { + const mod = await import('@stackbilt/wasm-core'); + _WasmGraph = mod.WasmGraph; + } + return _WasmGraph; +} import type { NodeType, SourceSystem } from '../../schema-enums.js'; export type { SourceSystem } from '../../schema-enums.js'; @@ -216,6 +227,7 @@ export async function activateGraph( if (nodesResult.results.length === 0) return []; + const WasmGraph = await getWasmGraph(); const graph = WasmGraph.fromSnapshotArrays(nodesResult.results, edgesResult.results); try { const raw = graph.spreadActivation(query, hops, 10); diff --git a/web/src/kernel/scheduled/consolidation.ts b/web/src/kernel/scheduled/consolidation.ts index 069d4bc..13ee267 100755 --- a/web/src/kernel/scheduled/consolidation.ts +++ b/web/src/kernel/scheduled/consolidation.ts @@ -1,7 +1,6 @@ import { type EdgeEnv } from '../dispatch.js'; -import { consolidateEpisodicToSemantic, maintainProcedures, getAllProcedures, PROCEDURE_MIN_SUCCESSES, PROCEDURE_MIN_SUCCESS_RATE } from '../memory/index.js'; +import { consolidateEpisodicToSemantic, maintainProcedures } from '../memory/index.js'; import { garbageCollectTools, promoteHighUsageTools } from '../dynamic-tools.js'; -import { publishInsight, type InsightType } from '../memory/insights.js'; import { pruneMemory } from '../memory-adapter.js'; import { runCrossDomainSynthesis } from '../memory/synthesis.js'; import { maintainNarratives, detectStaleNarratives, precomputeCognitiveState, pruneNarratives, getCognitiveState, type ProductPortfolioEntry } from '../cognition.js'; @@ -13,7 +12,10 @@ import { McpClient } from '../../mcp-client.js'; import { operatorConfig } from '../../operator/index.js'; export async function runMemoryConsolidation(env: EdgeEnv): Promise { - await consolidateEpisodicToSemantic(env.db, env.groqApiKey, env.groqModel, env.groqBaseUrl, env.memoryBinding); + await consolidateEpisodicToSemantic(env.db, env.groqApiKey, env.groqModel, env.groqBaseUrl, { + wikiBinding: env.wikiBinding, + wikiToken: env.wikiToken, + }); if (env.memoryBinding) { await pruneMemory(env.memoryBinding, env.db); } @@ -49,9 +51,6 @@ export async function runMemoryConsolidation(env: EdgeEnv): Promise { // Update active_context block from freshly computed CognitiveState await refreshActiveContextBlock(env.db); - - // ─── CRIX Phase 2c: Publish insights from procedural + semantic memory ─── - await publishInsightsFromMemory(env); } export async function fetchProductPortfolio(env: EdgeEnv): Promise { @@ -80,102 +79,6 @@ export async function fetchProductPortfolio(env: EdgeEnv): Promise { - let published = 0; - - // Source 1: Learned procedures — patterns with ≥70% success rate and 3+ successes - try { - const procedures = await getAllProcedures(env.db); - for (const proc of procedures) { - if (published >= INSIGHT_RATE_LIMIT) break; - if (proc.status !== 'learned') continue; - if (proc.success_count < PROCEDURE_MIN_SUCCESSES) continue; - - const successRate = proc.success_count / (proc.success_count + proc.fail_count); - if (successRate < PROCEDURE_MIN_SUCCESS_RATE) continue; - - // Already published check: handled by publishInsight() fact hash gate - const fact = `Learned procedure: "${proc.task_pattern}" routes to ${proc.executor} executor with ${(successRate * 100).toFixed(0)}% success rate (${proc.success_count} successes). Config: ${proc.executor_config?.slice(0, 200) ?? 'default'}`; - - const result = await publishInsight(env.db, { - fact, - insight_type: 'pattern', - origin_repo: 'aegis', - keywords: [proc.task_pattern, proc.executor, 'routing', 'procedure'], - confidence: Math.min(0.95, 0.75 + successRate * 0.2), - }, env.memoryBinding); - - if (result.published) { - published++; - console.log(`[crix] Published procedure insight: ${proc.task_pattern}`); - } - } - } catch (err) { - console.warn('[crix] Procedure scan failed:', err instanceof Error ? err.message : String(err)); - } - - // Source 2: High-confidence memory entries from the last 24h via Memory Worker - // Look for entries tagged with bug/perf/arch topics that could be cross-repo insights - if (env.memoryBinding) { - try { - const INSIGHT_TOPICS = new Map([ - ['bug_signature', 'pattern'], - ['perf_pattern', 'heuristic'], - ['arch_improvement', 'principle'], - ]); - - const fragments = await env.memoryBinding.recall('aegis', { limit: 20 }); - // Filter to high-confidence entries suitable for cross-repo insight publishing - const recentHighConf = fragments.filter(f => f.confidence >= 0.85).slice(0, 10); - - for (const entry of recentHighConf) { - if (published >= INSIGHT_RATE_LIMIT) break; - - // Infer insight type from topic - let insightType: InsightType = 'pattern'; - for (const [topicFragment, type] of INSIGHT_TOPICS) { - if (entry.topic.includes(topicFragment)) { - insightType = type; - break; - } - } - - // Extract keywords from the fact text - const keywords = entry.content.toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .split(/\s+/) - .filter(w => w.length > 4) - .slice(0, 8); - - if (keywords.length < 2) continue; // Not enough signal - - const result = await publishInsight(env.db, { - fact: entry.content, - insight_type: insightType, - origin_repo: 'aegis', - keywords, - confidence: entry.confidence, - }, env.memoryBinding); - - if (result.published) { - published++; - } - } - } catch (err) { - console.warn('[crix] Semantic scan failed:', err instanceof Error ? err.message : String(err)); - } - } - - if (published > 0) { - console.log(`[crix] Published ${published} insights during consolidation cycle`); - } -} - // ─── Active Context Block Refresh ──────────────────────────── async function refreshActiveContextBlock(db: D1Database): Promise { diff --git a/web/src/version.ts b/web/src/version.ts index ac4f50a..c40f136 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -1,3 +1,3 @@ // Semantic version - bump on each deploy // Used in /health endpoint and changelog tracking -export const VERSION = '0.8.0'; +export const VERSION = '0.8.6'; diff --git a/web/tests/memory.test.ts b/web/tests/memory.test.ts index ade31c5..b8c1887 100755 --- a/web/tests/memory.test.ts +++ b/web/tests/memory.test.ts @@ -1174,8 +1174,11 @@ describe('insights.ts', () => { // consolidation.ts // ════════════════════════════════════════════════════════════════ -// consolidateEpisodicToSemantic depends on askGroq, so we test -// the guards and parsing logic by controlling the DB mock returns. +// consolidateEpisodicToSemantic depends on askGroq + writeDreamFact (wiki), +// so we test the guards and parsing logic by controlling the DB mock +// returns and mocking the dream-write module. Redesigned for the #457 +// wiki unification (aegis-oss#78) — writes route through scope:dreams +// wiki pages instead of the memory-worker fragment store. import { consolidateEpisodicToSemantic } from '../src/kernel/memory/consolidation.js'; @@ -1187,12 +1190,29 @@ vi.mock('../src/groq.js', () => ({ import { askGroq } from '../src/groq.js'; const mockAskGroq = vi.mocked(askGroq); +vi.mock('../src/kernel/memory/dream-write.js', () => ({ + writeDreamFact: vi.fn(), +})); + +import { writeDreamFact } from '../src/kernel/memory/dream-write.js'; +const mockWriteDreamFact = vi.mocked(writeDreamFact); + +const testWikiEnv = { wikiBinding: {} as any, wikiToken: 'test-token' }; + describe('consolidation.ts', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('consolidateEpisodicToSemantic', () => { + it('skips when no wiki env is configured', async () => { + const db = createMockDb({}); + + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + + expect(mockAskGroq).not.toHaveBeenCalled(); + }); + it('skips when fewer than 3 episodes since last run', async () => { const db = createMockDb({ // last consolidation watermark @@ -1203,7 +1223,7 @@ describe('consolidation.ts', () => { ]], }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); expect(mockAskGroq).not.toHaveBeenCalled(); }); @@ -1218,59 +1238,20 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], // no watermark - allResults: [ - episodes, // episodes query - [], // existing memory - ], + allResults: [episodes], // episodes query runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); expect(mockAskGroq).toHaveBeenCalledOnce(); }); - it('processes ADD operations from Groq response', async () => { - const addOps = JSON.stringify([ - { operation: 'ADD', topic: 'aegis', fact: 'Version 1.30.1 deployed successfully on 2026-03-10', confidence: 0.85 }, + it('writes a dream page for genuine facts from Groq response', async () => { + const facts = JSON.stringify([ + { topic: 'aegis', fact: 'Version 1.30.1 deployed successfully on 2026-03-10', confidence: 0.85 }, ]); - mockAskGroq.mockResolvedValue(addOps); - - const episodes = Array.from({ length: 5 }, (_, i) => ({ - id: i + 1, intent_class: 'chat', channel: 'web', - summary: `Episode ${i}`, outcome: 'success', cost: 0.01, - })); - - const db = createMockDb({ - firstResults: [ - null, // watermark - ], - allResults: [ - episodes, // episodes query - ], - runMeta: [ - { changes: 1 }, // watermark update - ], - }); - - // ADD operations require memoryBinding (source uses memoryBinding.store) - const mockStore = vi.fn().mockResolvedValue(undefined); - const mockRecallFn = vi.fn().mockResolvedValue([]); - const memBinding = { store: mockStore, recall: mockRecallFn, forget: vi.fn(), health: vi.fn() }; - - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, memBinding as any); - - // Should have stored via memoryBinding - expect(mockStore).toHaveBeenCalledWith('aegis', expect.arrayContaining([ - expect.objectContaining({ content: expect.stringContaining('Version 1.30.1'), topic: 'aegis' }), - ])); - }); - - it('processes DELETE operations from Groq response', async () => { - const deleteOps = JSON.stringify([ - { operation: 'DELETE', target_id: 42, reason: 'obsolete' }, - ]); - mockAskGroq.mockResolvedValue(deleteOps); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', @@ -1279,27 +1260,23 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], // watermark - allResults: [ - episodes, - [], // existing memory - ], - runMeta: [ - { changes: 1 }, // soft delete - { changes: 1 }, // watermark - ], + allResults: [episodes], // episodes query + runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - const deleteQuery = db._queries.find(q => q.sql.includes('valid_until') && q.sql.includes('UPDATE')); - expect(deleteQuery).toBeDefined(); + expect(mockWriteDreamFact).toHaveBeenCalledWith(testWikiEnv, expect.objectContaining({ + fact: expect.stringContaining('Version 1.30.1'), + source: 'episodic_consolidation', + })); }); - it('skips ADD operations with short facts (< 30 chars)', async () => { - const ops = JSON.stringify([ - { operation: 'ADD', topic: 'aegis', fact: 'Too short', confidence: 0.8 }, + it('skips facts with short text (< 30 chars)', async () => { + const facts = JSON.stringify([ + { topic: 'aegis', fact: 'Too short', confidence: 0.8 }, ]); - mockAskGroq.mockResolvedValue(ops); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', @@ -1308,15 +1285,13 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], - allResults: [episodes, []], - runMeta: [{ changes: 1 }], // only watermark + allResults: [episodes], + runMeta: [{ changes: 1 }], // watermark only }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - // Should NOT have inserted any memory (short fact filtered) - const insertQuery = db._queries.find(q => q.sql.includes('INSERT INTO memory_entries')); - expect(insertQuery).toBeUndefined(); + expect(mockWriteDreamFact).not.toHaveBeenCalled(); }); it('handles malformed Groq response gracefully', async () => { @@ -1329,55 +1304,40 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], - allResults: [episodes, []], + allResults: [episodes], }); // Should not throw await expect( - consolidateEpisodicToSemantic(db, 'fake-key', 'llama3') + consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv) ).resolves.not.toThrow(); + + expect(mockWriteDreamFact).not.toHaveBeenCalled(); }); - it('caps operations at 3 per run', async () => { - const ops = JSON.stringify([ - { operation: 'ADD', topic: 'a', fact: 'Fact about system A with version 1.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'b', fact: 'Fact about system B with version 2.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'c', fact: 'Fact about system C with version 3.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'd', fact: 'Fact about system D with version 4.0 should be skipped', confidence: 0.8 }, + it('caps facts at 3 per run', async () => { + const facts = JSON.stringify([ + { topic: 'a', fact: 'Fact about system A with version 1.0 deployed', confidence: 0.8 }, + { topic: 'b', fact: 'Fact about system B with version 2.0 deployed', confidence: 0.8 }, + { topic: 'c', fact: 'Fact about system C with version 3.0 deployed', confidence: 0.8 }, + { topic: 'd', fact: 'Fact about system D with version 4.0 should be skipped', confidence: 0.8 }, ]); - mockAskGroq.mockResolvedValue(ops); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', summary: `Episode ${i}`, outcome: 'success', cost: 0.01, })); - // We need enough mock results for 3 recordMemory calls const db = createMockDb({ - firstResults: [ - null, // watermark - null, null, null, // 3x recordMemory phase 1 (no hash dup) - ], - allResults: [ - episodes, - [], // existing memory - [], [], [], // 3x recordMemory phase 2 - ], - runMeta: [ - { changes: 1, last_row_id: 50 }, - { changes: 1, last_row_id: 51 }, - { changes: 1, last_row_id: 52 }, - { changes: 1 }, // watermark - ], + firstResults: [null], // watermark + allResults: [episodes], + runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - // Count INSERT queries for memory_entries (not watermark) - const inserts = db._queries.filter(q => - q.sql.includes('INSERT INTO memory_entries') - ); - expect(inserts.length).toBeLessThanOrEqual(3); + expect(mockWriteDreamFact).toHaveBeenCalledTimes(3); }); }); });