diff --git a/packages/codegraph/public/app.js b/packages/codegraph/public/app.js index 6106671..4b746fc 100644 --- a/packages/codegraph/public/app.js +++ b/packages/codegraph/public/app.js @@ -183,6 +183,12 @@ async function deprecateMemory(id) { else alert('Update failed') } +async function reinforceMemory(id) { + const r = await fetch(`/api/memories/${encodeURIComponent(id)}/reinforce`, { method: 'POST' }) + if (r.ok) { closeDetail(); route() } + else alert('Reinforce failed') +} + // ── Session actions ── async function cleanupDuplicates() { if (!confirm('Delete duplicate in-progress sessions (keeps most recent per project)?')) return @@ -346,6 +352,7 @@ window.removeStackTag = removeStackTag window.addMemberRow = addMemberRow window.deleteMemory = deleteMemory window.deprecateMemory = deprecateMemory +window.reinforceMemory = reinforceMemory window.cleanupDuplicates = cleanupDuplicates window.deleteSession = deleteSession window.loadEnvVars = loadEnvVars @@ -436,8 +443,12 @@ window.applySkillUpdate = async (proposalId, btn) => { async function updateReviewBadge() { try { const data = await api.get('/api/review/pending') - const total = (data.memories?.length || 0) + (data.skills?.length || 0) + - (data.components?.length || 0) + (data.proposals?.length || 0) + (data.dsScans?.length || 0) + // Use the authoritative `totals` object, not array lengths: the memories query is + // LIMITed (default 100), so .length undercounts the badge once >100 are pending — + // exactly the backlog scenario. renderReview already uses data.totals; match it. + const t = data.totals || {} + const total = (t.memories || 0) + (t.skills || 0) + + (t.components || 0) + (t.proposals || 0) + (t.dsScans || 0) const badge = document.getElementById('review-badge') if (badge) { badge.textContent = total diff --git a/packages/codegraph/public/js/render.js b/packages/codegraph/public/js/render.js index a197d23..3abe539 100644 --- a/packages/codegraph/public/js/render.js +++ b/packages/codegraph/public/js/render.js @@ -71,6 +71,53 @@ function clearHubRefresh() { if (_hubVisibilityHandler) { document.removeEventListener('visibilitychange', _hubVisibilityHandler); _hubVisibilityHandler = null } } +// Builds the "Needs attention" band HTML from the three counts. Shared by the +// initial renderHome() and the 30s refreshHubLive() so the band stays in sync with +// the adjacent System Health rows instead of freezing at its first-render value. +function buildAttnBand(reviewTotal, decayCount, staleCount) { + const attnItems = [] + if (reviewTotal > 0) attnItems.push({ + count: reviewTotal, + label: reviewTotal === 1 ? 'Pending review' : 'Pending reviews', + hint: 'Approve or reject queued items', + target: '#/review', + }) + if (decayCount > 0) attnItems.push({ + count: decayCount, + label: decayCount === 1 ? 'Decaying memory' : 'Decaying memories', + hint: 'Confidence dropped through real decay', + target: '#/memories', + }) + if (staleCount > 0) attnItems.push({ + count: staleCount, + label: staleCount === 1 ? 'Stale memory' : 'Stale memories', + hint: 'Not updated in 90+ days', + target: '#/memories', + }) + if (attnItems.length === 0) return '' + return ` +
+
+ ${HUB_ICONS.warn} + Needs attention +
+ ${attnItems.map(item => { + const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit' + return `
+ ${item.count} +
+ ${item.label} + ${item.hint} +
+ ${HUB_ICONS.arrow} +
` + }).join('')} +
+ ` +} + async function refreshHubLive() { const hash = location.hash || '#/' if (hash !== '#/' && hash !== '#' && hash !== '#/home') { clearHubRefresh(); return } @@ -106,6 +153,33 @@ async function refreshHubLive() { dot.className = 'health-dot ' + (staleCount === 0 ? 'health-dot-ok' : staleCount <= 5 ? 'health-dot-warn' : 'health-dot-crit') } } + // Keep the "Needs attention" band in sync with the health rows. It was built once + // by renderHome and otherwise frozen, so it would diverge from the live "Pending + // reviews"/"Decay" rows (and never disappear when a count dropped to 0). + const freshBand = buildAttnBand( + reviewTotal, + typeof health.decayCount === 'number' ? health.decayCount : 0, + typeof health.staleCount === 'number' ? health.staleCount : 0, + ) + const attnHost = document.querySelector('.hub-attention') + if (attnHost) { + if (freshBand) { + const tmp = document.createElement('template') + tmp.innerHTML = freshBand.trim() + attnHost.replaceWith(tmp.content.firstElementChild) + } else { + attnHost.remove() + } + } else if (freshBand) { + // Count went 0 -> >0 since first render: insert the band right after the greeting. + const greeting = document.querySelector('.hub-hero .hub-greeting-line') + if (greeting) { + const tmp = document.createElement('template') + tmp.innerHTML = freshBand.trim() + greeting.insertAdjacentElement('afterend', tmp.content.firstElementChild) + } + } + const uptimeStrong = document.querySelector('.health-footer span:first-child strong') if (uptimeStrong) uptimeStrong.textContent = formatUptime(health.uptime || 0) const statusEl = document.getElementById('server-status') @@ -277,7 +351,14 @@ export async function renderHome() { `).join('') || '

No memories yet.

' // ── System Health ── - const decayCount = health.decayCount ?? memories.filter(m => (m.confidence ?? 10) < 4).length + // Mirror the server predicate (http-server.ts computeAttentionCounts): a memory is + // "decaying" only if it actually aged (sessions_since_validation >= 5), not merely + // born below the confidence threshold. Excludes the M-_system_* metadata row. + const decayCount = health.decayCount ?? memories.filter(m => + (m.confidence ?? 10) < 4 && + (m.sessionsSinceValidation ?? 0) >= 5 && + !(m.id || '').startsWith('M-_system_') + ).length const staleCount = health.staleCount ?? memories.filter(m => { const ts = m.updatedAt || m.updated_at || m.createdAt || m.created_at return ts && daysSince(ts) > 90 @@ -297,48 +378,8 @@ export async function renderHome() { ? `openProjectDetail('${escHtml(resumeProject)}')` : recentSession ? `location.hash='#/sessions'` : '' - // ── Attention band ── - const attnItems = [] - if (reviewTotal > 0) attnItems.push({ - count: reviewTotal, - label: reviewTotal === 1 ? 'Pending review' : 'Pending reviews', - hint: 'Approve or reject queued items', - target: '#/review', - }) - if (decayCount > 0) attnItems.push({ - count: decayCount, - label: decayCount === 1 ? 'Decaying memory' : 'Decaying memories', - hint: 'Confidence below 4', - target: '#/memories', - }) - if (staleCount > 0) attnItems.push({ - count: staleCount, - label: staleCount === 1 ? 'Stale memory' : 'Stale memories', - hint: 'Not updated in 90+ days', - target: '#/memories', - }) - - const attnBand = attnItems.length === 0 ? '' : ` -
-
- ${HUB_ICONS.warn} - Needs attention -
- ${attnItems.map(item => { - const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit' - return `
- ${item.count} -
- ${item.label} - ${item.hint} -
- ${HUB_ICONS.arrow} -
` - }).join('')} -
- ` + // ── Attention band ── (built via shared buildAttnBand so refreshHubLive can rebuild it) + const attnBand = buildAttnBand(reviewTotal, decayCount, staleCount) pageEl.innerHTML = `
@@ -404,7 +445,7 @@ export async function renderHome() {
System health
- Decay alerts (conf < 4) + Decay alerts (conf < 4 · aged) ${decayCount}
@@ -767,6 +808,7 @@ export async function openMemoryDetail(id, openDetailFn) { ${m.skill || ''} · ${m.scope}
+ ${(m.confidence ?? 10) < 10 ? `` : ''}
diff --git a/packages/codegraph/scripts/remediate-attention-backlog.sh b/packages/codegraph/scripts/remediate-attention-backlog.sh new file mode 100644 index 0000000..1a71444 --- /dev/null +++ b/packages/codegraph/scripts/remediate-attention-backlog.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Synapse — one-time remediation of the "Needs attention" backlog +# (the historical 308 pending reviews / 30 decaying memories on production). +# +# Policy: AUTO-APPROVE IN-PLACE (chosen by the operator). It drains the +# auto-generated review backlog without manual triage: +# - pending-review memories -> active, staleness reset +# - all active memories with ssv >= 5 -> staleness reset (clean slate; the +# kept auto-enqueue restarts its 15-cycle clock) +# - pending skill_proposals -> dismissed (auto-generated nudges) +# - pending design_system_scans -> dismissed (auto-generated) +# - decay metadata clock -> reset to now +# +# IMPORTANT — run order: +# 1. Deploy the new code FIRST so migration 034 (dedup indexes) has run and the +# new approve/decay logic is live. Otherwise the backlog will simply refill. +# 2. Then run this script on the box that holds the production DB (Coolify container). +# +# It refuses to run without a backup and prints before/after counts. +# +# Usage: +# ./remediate-attention-backlog.sh [/path/to/graph.db] +# Default DB path: /data/.codegraph/graph.db (Synapse production layout) + +set -euo pipefail + +DB="${1:-/data/.codegraph/graph.db}" + +if ! command -v sqlite3 >/dev/null 2>&1; then + echo "ERROR: sqlite3 not found in PATH." >&2 + exit 1 +fi +if [[ ! -f "$DB" ]]; then + echo "ERROR: database not found at: $DB" >&2 + echo "Pass the correct path as the first argument." >&2 + exit 1 +fi + +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP="${DB}.bak-pre-attention-remediation-${STAMP}" + +echo "==> Database: $DB" +echo "==> Backup: $BACKUP" +# .backup is safe on a live WAL DB (consistent snapshot). +sqlite3 "$DB" ".backup '$BACKUP'" +echo "==> Backup created." + +echo +echo "==> BEFORE:" +sqlite3 "$DB" <<'SQL' +.mode column +.headers on +SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review' +UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15 +UNION ALL SELECT 'decaying(real: conf<4 & ssv>=5)', COUNT(*) FROM memories WHERE status='active' AND confidence<4 AND sessions_since_validation>=5 AND id NOT LIKE 'M-_system_%' +UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending' +UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending'; +SQL + +echo +echo "==> Applying remediation (single transaction)…" +sqlite3 "$DB" <<'SQL' +BEGIN; + +-- 1. Auto-approve every queued memory in place + reset its staleness so the next +-- decay cycle does not immediately re-flag it (these were timer-generated, not +-- human-queued). +UPDATE memories + SET status = 'active', + sessions_since_validation = 0, + last_validated = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE status = 'pending-review'; + +-- 2. Clean slate for the kept auto-enqueue mechanism: reset the staleness clock on +-- every active memory that had already aged, so the 15-cycle countdown restarts +-- fresh after the fix instead of immediately re-queuing a wave of old memories. +UPDATE memories + SET sessions_since_validation = 0, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE status = 'active' + AND sessions_since_validation >= 5 + AND id NOT LIKE 'M-_system_%'; + +-- 3. Dismiss the auto-generated proposal/scan nudges (content is untouched; they +-- re-propose only when genuinely warranted now that dedup indexes exist). +UPDATE skill_proposals SET status = 'dismissed', reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE status = 'pending'; +UPDATE design_system_scans SET status = 'dismissed' WHERE status = 'pending'; + +-- 4. Reset the decay clock so the next scheduler tick starts a fresh 24h window. +UPDATE memories SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = 'M-_system_decay_last_run'; + +COMMIT; +SQL + +echo "==> Done." +echo +echo "==> AFTER:" +sqlite3 "$DB" <<'SQL' +.mode column +.headers on +SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review' +UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15 +UNION ALL SELECT 'decaying(real: conf<4 & ssv>=5)', COUNT(*) FROM memories WHERE status='active' AND confidence<4 AND sessions_since_validation>=5 AND id NOT LIKE 'M-_system_%' +UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending' +UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending'; +SQL + +echo +echo "All five counters should now read 0. If anything looks wrong, restore with:" +echo " cp '$BACKUP' '$DB' # (stop the server first)" +echo +echo "NOTE: pending skill/component DRAFTS (skills.status='pending' / ui_components.status='pending')" +echo "are intentional human/agent submissions and are NOT auto-approved here. Review them in the" +echo "dashboard, or to also auto-approve them (publishes unreviewed drafts) run:" +echo " sqlite3 '$DB' \"UPDATE skills SET status='active' WHERE status='pending'; UPDATE ui_components SET status='active' WHERE status='pending';\"" diff --git a/packages/codegraph/src/mcp/http-server.ts b/packages/codegraph/src/mcp/http-server.ts index 7154d45..193905d 100644 --- a/packages/codegraph/src/mcp/http-server.ts +++ b/packages/codegraph/src/mcp/http-server.ts @@ -164,8 +164,20 @@ export function computeAttentionCounts(db: import('better-sqlite3').Database): { } { const staleCutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000).toISOString() + // "Decaying" must mean a memory that has ACTUALLY decayed — not one merely born + // below the threshold. New memories are created with low confidence (default 1), + // and applyDecay only lowers confidence once sessions_since_validation >= 5, so the + // bare `confidence < 4` predicate counted every fresh/unvalidated memory as + // "decaying" — definitional noise that could never be cleared. Requiring + // ssv >= 5 means the row went through >=5 unvalidated decay cycles. The system + // metadata row (M-_system_*) is excluded for parity with allActive(). const decayCount = (db.prepare( - `SELECT COUNT(*) AS n FROM memories WHERE status = 'active' AND confidence IS NOT NULL AND confidence < 4` + `SELECT COUNT(*) AS n FROM memories + WHERE status = 'active' + AND confidence IS NOT NULL + AND confidence < 4 + AND sessions_since_validation >= 5 + AND id NOT LIKE 'M-_system_%'` ).get() as { n: number }).n const staleCount = (db.prepare( diff --git a/packages/codegraph/src/mcp/routes/memories.ts b/packages/codegraph/src/mcp/routes/memories.ts index 57f4536..254ea97 100644 --- a/packages/codegraph/src/mcp/routes/memories.ts +++ b/packages/codegraph/src/mcp/routes/memories.ts @@ -116,5 +116,23 @@ export function createMemoriesRouter(ctx: RouteContext): Router { } }) + // Non-destructive "Reinforce / Keep" action: bumps confidence +1 and resets the + // staleness counter so a valuable low-confidence memory can be rehabilitated from + // the dashboard instead of only Deprecate/Delete. Also doubles as the human + // counterpart to the auto-decay's pending-review flagging. + router.post('/api/memories/:id/reinforce', (req, res) => { + const userId = (req as any).userId as string | undefined + try { + const db = openDb(ctx.skillbrainRoot) + const store = new MemoryStore(db) + const ok = store.reinforce(req.params.id, userId) + closeDb(db) + if (!ok) { res.status(404).json({ error: 'Memory not found' }); return } + res.json({ ok: true }) + } catch (err: any) { + res.status(500).json({ error: err.message }) + } + }) + return router } diff --git a/packages/codegraph/src/mcp/routes/review.ts b/packages/codegraph/src/mcp/routes/review.ts index 65101e9..cce8fcf 100644 --- a/packages/codegraph/src/mcp/routes/review.ts +++ b/packages/codegraph/src/mcp/routes/review.ts @@ -60,8 +60,15 @@ export function createReviewRouter(ctx: RouteContext): Router { router.post('/api/review/memory/:id/approve', (req, res) => { const db = openDb(ctx.skillbrainRoot) const now = new Date().toISOString() - db.prepare(`UPDATE memories SET status = 'active', updated_at = ? WHERE id = ?`) - .run(now, req.params.id) + // Reset the staleness counter on approve. A memory lands in 'pending-review' + // because markPendingReview fired (sessions_since_validation >= 15). If we only + // flip status back to 'active', the very next 24h decay cycle increments ssv and + // markPendingReview re-flags it — so the approval never sticks and the queue never + // drains. Treating a human approval as a validation event (ssv=0 + last_validated) + // mirrors reinforceMemory, minus the confidence bump (approve = "leave it", not + // "this proved useful"). The memory must now age 15 fresh cycles before re-queuing. + db.prepare(`UPDATE memories SET status = 'active', sessions_since_validation = 0, last_validated = ?, updated_at = ? WHERE id = ?`) + .run(now, now, req.params.id) new AuditStore(db).log({ entityType: 'memory', entityId: req.params.id, action: 'approve', reviewedBy: (req as any).userId ?? 'unknown' }) closeDb(db) res.json({ ok: true }) diff --git a/packages/codegraph/src/mcp/tools/sessions.ts b/packages/codegraph/src/mcp/tools/sessions.ts index 36415d9..1ee9fe6 100644 --- a/packages/codegraph/src/mcp/tools/sessions.ts +++ b/packages/codegraph/src/mcp/tools/sessions.ts @@ -185,10 +185,15 @@ export function registerSessionTools(server: McpServer, ctx: ToolContext): void if (memIds.length >= 2) { const propId = `SP-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` try { - ;(store as any).db.prepare( + // The partial unique index idx_skill_proposals_open (migration 034) + // enforces one OPEN proposal per skill, so OR IGNORE now actually + // dedups across sessions. Only report a proposal as "created" when a + // row was truly inserted — otherwise every session would re-announce + // the same pending proposal that nothing cleared. + const info = (store as any).db.prepare( `INSERT OR IGNORE INTO skill_proposals (id, skill_name, session_id, memory_ids) VALUES (?, ?, ?, ?)` ).run(propId, skillName, sessionId, JSON.stringify(memIds)) - created.push(skillName) + if (info.changes > 0) created.push(skillName) } catch { /* table may not exist yet in old DBs */ } } } diff --git a/packages/codegraph/tests/attention-counts.test.ts b/packages/codegraph/tests/attention-counts.test.ts index 7d128db..6caa9e8 100644 --- a/packages/codegraph/tests/attention-counts.test.ts +++ b/packages/codegraph/tests/attention-counts.test.ts @@ -40,21 +40,43 @@ describe('computeAttentionCounts', () => { expect(result.pendingReviews).toBe(0) }) - it('counts decayCount: active memories with confidence < 4', () => { - // confidence 3 (active) — should be counted - store.add({ type: 'Pattern', context: 'ctx-decay', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 }) - // confidence 4 — boundary, should NOT be counted (< 4, not <= 4) - store.add({ type: 'Pattern', context: 'ctx-boundary', problem: '', solution: 'sol', reason: '', tags: [], confidence: 4 }) + it('counts decayCount: active memories with confidence < 4 that have ACTUALLY decayed (ssv >= 5)', () => { + // confidence 3, aged (ssv >= 5) — should be counted (real decay) + const m1 = store.add({ type: 'Pattern', context: 'ctx-decay', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 }) + db.prepare('UPDATE memories SET sessions_since_validation = 6 WHERE id = ?').run(m1.id) + // confidence 4, aged — boundary, should NOT be counted (< 4, not <= 4) + const m2 = store.add({ type: 'Pattern', context: 'ctx-boundary', problem: '', solution: 'sol', reason: '', tags: [], confidence: 4 }) + db.prepare('UPDATE memories SET sessions_since_validation = 6 WHERE id = ?').run(m2.id) // confidence 7 — should NOT be counted store.add({ type: 'Pattern', context: 'ctx-healthy', problem: '', solution: 'sol', reason: '', tags: [], confidence: 7 }) // confidence 2, deprecated — should NOT be counted (not active) const m4 = store.add({ type: 'Pattern', context: 'ctx-dep', problem: '', solution: 'sol', reason: '', tags: [], confidence: 2 }) - db.prepare("UPDATE memories SET status = 'deprecated' WHERE id = ?").run(m4.id) + db.prepare("UPDATE memories SET status = 'deprecated', sessions_since_validation = 6 WHERE id = ?").run(m4.id) const result = computeAttentionCounts(db) expect(result.decayCount).toBe(1) }) + it('decayCount excludes BORN-low-confidence memories that have not aged (ssv < 5)', () => { + // Fresh low-confidence memories (the common case: created at default/seed confidence) + // are NOT decay — they have ssv 0/1 and must not pollute the "decaying" signal. + store.add({ type: 'Pattern', context: 'born-low-1', problem: '', solution: 'sol', reason: '', tags: [], confidence: 1 }) + store.add({ type: 'Pattern', context: 'born-low-2', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 }) + + const result = computeAttentionCounts(db) + expect(result.decayCount).toBe(0) + }) + + it('decayCount excludes the M-_system_ metadata row even if low confidence', () => { + const m = store.add({ type: 'Fact', context: 'sys', problem: '', solution: 'sol', reason: '', tags: [], confidence: 1 }) + // Rename to a system id and age it — must still be excluded. + db.prepare("UPDATE memories SET id = 'M-_system_decay_last_run', sessions_since_validation = 99 WHERE id = ?").run(m.id) + db.prepare("UPDATE memories SET confidence = 1 WHERE id = 'M-_system_decay_last_run'") + + const result = computeAttentionCounts(db) + expect(result.decayCount).toBe(0) + }) + it('counts staleCount: active memories with COALESCE(updated_at, created_at) older than 90 days', () => { const old = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString() const recent = new Date().toISOString() diff --git a/packages/codegraph/tests/attention-reappearance-fix.test.ts b/packages/codegraph/tests/attention-reappearance-fix.test.ts new file mode 100644 index 0000000..7e782b4 --- /dev/null +++ b/packages/codegraph/tests/attention-reappearance-fix.test.ts @@ -0,0 +1,173 @@ +/* + * Synapse — The intelligence layer for AI workflows + * Copyright (c) 2026 Daniel De Vecchi + * + * Licensed under AGPL-3.0-or-later. + * See LICENSE for details. + * + * Commercial license: daniel@pixarts.eu + */ + +// Regression tests for the "Needs attention items keep reappearing" bug cluster. +// See the root-cause analysis: approve didn't reset staleness (memories bounced back +// to pending-review every decay cycle), skill upsert wiped autolearning state, and +// session_end re-created duplicate skill_proposals forever. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { openDb, closeDb, runMigrations, MemoryStore, SkillsStore } from '@skillbrain/storage' +import type Database from 'better-sqlite3' + +describe('attention reappearance fixes', () => { + let dir: string + let db: Database.Database + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sb-reappear-')) + db = openDb(dir) + runMigrations(db) + }) + + afterEach(() => { + closeDb(db) + rmSync(dir, { recursive: true, force: true }) + }) + + // The exact SQL the dashboard approve endpoint runs (review.ts). + const approveMemory = (id: string) => { + const now = new Date().toISOString() + db.prepare( + `UPDATE memories SET status = 'active', sessions_since_validation = 0, last_validated = ?, updated_at = ? WHERE id = ?` + ).run(now, now, id) + } + + describe('FIX 1.1 — approve resets staleness so the memory does not bounce back', () => { + it('an approved memory stays active across the next decay cycle', () => { + const store = new MemoryStore(db) + const m = store.add({ type: 'Pattern', context: 'bounce', problem: '', solution: 'sol', reason: '', tags: [] }) + // Simulate decay having pushed it past the pending-review threshold. + db.prepare("UPDATE memories SET status = 'pending-review', sessions_since_validation = 15 WHERE id = ?").run(m.id) + + approveMemory(m.id) + expect(store.get(m.id)!.status).toBe('active') + expect(store.get(m.id)!.sessionsSinceValidation).toBe(0) + + // Next 24h auto-decay cycle (no validated ids). + store.applyDecay([], new Date().toISOString().split('T')[0]) + + // The fix: counter is now 1, well below 15, so it is NOT re-flagged. + expect(store.get(m.id)!.status).toBe('active') + }) + + it('WITHOUT the staleness reset the memory bounces straight back (proves the bug)', () => { + const store = new MemoryStore(db) + const m = store.add({ type: 'Pattern', context: 'bug', problem: '', solution: 'sol', reason: '', tags: [] }) + db.prepare("UPDATE memories SET status = 'pending-review', sessions_since_validation = 15 WHERE id = ?").run(m.id) + + // Old approve behaviour: flip status only, leave ssv untouched. + db.prepare("UPDATE memories SET status = 'active', updated_at = ? WHERE id = ?").run(new Date().toISOString(), m.id) + store.applyDecay([], new Date().toISOString().split('T')[0]) + + // ssv went 15 -> 16, markPendingReview re-flagged it. + expect(store.get(m.id)!.status).toBe('pending-review') + }) + }) + + describe('FIX 2.2 — manual reinforce', () => { + it('bumps confidence +1 and resets the staleness counter', () => { + const store = new MemoryStore(db) + const m = store.add({ type: 'Pattern', context: 'reinf', problem: '', solution: 'sol', reason: '', tags: [], confidence: 2 }) + db.prepare('UPDATE memories SET sessions_since_validation = 20 WHERE id = ?').run(m.id) + + expect(store.reinforce(m.id, 'tester')).toBe(true) + const after = store.get(m.id)! + expect(after.confidence).toBe(3) + expect(after.sessionsSinceValidation).toBe(0) + }) + + it('returns false for an unknown id', () => { + const store = new MemoryStore(db) + expect(store.reinforce('M-does-not-exist')).toBe(false) + }) + }) + + describe('FIX 3.1/3.2 — skill upsert preserves autolearning state and status on replace', () => { + const baseSkill = (over: Record = {}) => ({ + name: 'nextjs', category: 'frontend', description: 'desc', content: '# c', + type: 'domain' as const, tags: [] as string[], lines: 1, + updatedAt: new Date().toISOString(), + ...over, + }) + + it('keeps confidence/usage/useful/ssv/last_validated across a metadata re-upsert', () => { + const store = new SkillsStore(db) + store.upsert(baseSkill({ status: 'active' }) as any) + const ts = new Date().toISOString() + db.prepare( + `UPDATE skills SET confidence = 9, usage_count = 42, useful_count = 7, sessions_since_validation = 3, last_validated = ? WHERE name = 'nextjs'` + ).run(ts) + + // Re-upsert with NO autolearning fields (dashboard edit / re-import shape). + store.upsert(baseSkill({ status: 'active', category: 'web', content: '# new', lines: 2 }) as any) + + const row = db.prepare('SELECT * FROM skills WHERE name = ?').get('nextjs') as any + expect(row.confidence).toBe(9) + expect(row.usage_count).toBe(42) + expect(row.useful_count).toBe(7) + expect(row.sessions_since_validation).toBe(3) + expect(row.last_validated).toBe(ts) + // ...while the metadata the caller DID pass is updated. + expect(row.category).toBe('web') + expect(row.content).toBe('# new') + }) + + it('re-import (no status passed) preserves a deprecated skill instead of resurrecting it', () => { + const store = new SkillsStore(db) + store.upsert(baseSkill({ status: 'active' }) as any) + db.prepare("UPDATE skills SET status = 'deprecated' WHERE name = 'nextjs'").run() + + // importSkills builds Skill objects WITHOUT a status field. + store.upsert(baseSkill() as any) + + const row = db.prepare('SELECT status FROM skills WHERE name = ?').get('nextjs') as any + expect(row.status).toBe('deprecated') + }) + + it('a brand-new skill with no status still defaults to active', () => { + const store = new SkillsStore(db) + store.upsert(baseSkill({ name: 'fresh-skill' }) as any) + const row = db.prepare('SELECT status FROM skills WHERE name = ?').get('fresh-skill') as any + expect(row.status).toBe('active') + }) + }) + + describe('FIX 1.3 — skill_proposals dedup (migration 034)', () => { + it('creates a partial unique index on open proposals', () => { + const idx = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_open'" + ).get() + expect(idx).toBeTruthy() + }) + + it('INSERT OR IGNORE dedups a second OPEN proposal for the same skill', () => { + const ins = (id: string, sid: string) => + db.prepare( + `INSERT OR IGNORE INTO skill_proposals (id, skill_name, session_id, memory_ids) VALUES (?, ?, ?, '[]')` + ).run(id, 'nextjs', sid) + + expect(ins('SP-1', 's1').changes).toBe(1) + expect(ins('SP-2', 's2').changes).toBe(0) // deduped — already an open proposal + + const open = db.prepare( + "SELECT COUNT(*) AS n FROM skill_proposals WHERE skill_name = 'nextjs' AND status = 'pending'" + ).get() as { n: number } + expect(open.n).toBe(1) + + // Once the open one is actioned, a fresh proposal is allowed again. + db.prepare("UPDATE skill_proposals SET status = 'dismissed' WHERE id = 'SP-1'").run() + expect(ins('SP-3', 's3').changes).toBe(1) + }) + }) +}) diff --git a/packages/storage/src/components-store.ts b/packages/storage/src/components-store.ts index 89b8636..2d32421 100644 --- a/packages/storage/src/components-store.ts +++ b/packages/storage/src/components-store.ts @@ -410,6 +410,28 @@ export class ComponentsStore { conflicts: Conflict[] }): DesignSystemScan { const now = new Date().toISOString() + // One OPEN scan per project (partial unique index idx_dss_open, migration 034). + // session_start re-scans on every run; without this, an unresolved-conflict + // project stacked a brand-new pending scan each session and flooded the review + // queue. If an open scan already exists, refresh it in place with the latest + // conflict data instead of inserting a duplicate. + const existing = this.db.prepare( + `SELECT id FROM design_system_scans WHERE project = ? AND status = 'pending' LIMIT 1` + ).get(scan.project) as { id: string } | undefined + if (existing) { + this.db.prepare(` + UPDATE design_system_scans + SET scanned_at = ?, sources = ?, merged = ?, conflicts = ? + WHERE id = ? + `).run( + now, + JSON.stringify(scan.sources), + JSON.stringify(scan.merged), + JSON.stringify(scan.conflicts), + existing.id, + ) + return { id: existing.id, scannedAt: now, status: 'pending' as const, ...scan } + } const id = `DSS-${randomId()}` this.db.prepare(` INSERT INTO design_system_scans (id, project, scanned_at, sources, merged, conflicts, status) diff --git a/packages/storage/src/memory-store.ts b/packages/storage/src/memory-store.ts index d4091bb..1f90dc7 100644 --- a/packages/storage/src/memory-store.ts +++ b/packages/storage/src/memory-store.ts @@ -760,6 +760,23 @@ export class MemoryStore { // ── Decay ───────────────────────────────────────── + /** + * Manually reinforce a single memory — the non-destructive "Keep / Reinforce" + * action exposed in the dashboard. Bumps confidence +1 (capped at 10), resets + * sessions_since_validation to 0, and appends `by` to validated_by. This is the + * only dashboard path that can RAISE a memory's confidence; previously the UI + * could only Deprecate/Delete, so a valuable-but-low-confidence memory had no + * non-destructive remedy. Returns false if the id does not exist. + */ + reinforce(id: string, by?: string): boolean { + const mem = this.get(id) + if (!mem) return false + const now = new Date().toISOString() + const newValidatedBy = [...mem.validatedBy, by ?? 'dashboard'] + this.stmts.reinforceMemory.run(now, JSON.stringify(newValidatedBy), now, id) + return true + } + applyDecay(validatedIds: string[], sessionDate: string): DecayResult { const now = new Date().toISOString() let reinforced = 0 diff --git a/packages/storage/src/migrations/034_review_queue_dedup.sql b/packages/storage/src/migrations/034_review_queue_dedup.sql new file mode 100644 index 0000000..f7356c5 --- /dev/null +++ b/packages/storage/src/migrations/034_review_queue_dedup.sql @@ -0,0 +1,33 @@ +-- 034 — Dedup guards for the auto-generated review queues +-- Why: session_end re-creates skill_proposals on EVERY session, and session_start +-- re-creates design_system_scans on every scan with unresolved token conflicts. +-- Both use `INSERT OR IGNORE` but with a fresh-random primary-key id each call, so +-- OR IGNORE never actually deduped — it only guarded against a missing table. The +-- pending "Needs attention" queue therefore grew without bound (the primary driver +-- of the perpetually-high "Pending reviews" count on the dashboard). +-- +-- Fix: a PARTIAL UNIQUE INDEX scoped to status='pending' makes "at most one OPEN +-- item per key" an invariant, so INSERT OR IGNORE finally fires when an open item +-- already exists. dismissed/applied rows are excluded from the index, so a skill or +-- project can be re-proposed once its previous proposal has been actioned. + +-- Collapse any pre-existing duplicate OPEN proposals to the earliest per skill first; +-- otherwise creating the unique index would fail on the existing duplicates. +DELETE FROM skill_proposals + WHERE status = 'pending' + AND id NOT IN ( + SELECT MIN(id) FROM skill_proposals WHERE status = 'pending' GROUP BY skill_name + ); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_proposals_open + ON skill_proposals(skill_name) WHERE status = 'pending'; + +-- Same treatment for design-system scans: one open scan per project at a time. +DELETE FROM design_system_scans + WHERE status = 'pending' + AND id NOT IN ( + SELECT MIN(id) FROM design_system_scans WHERE status = 'pending' GROUP BY project + ); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_dss_open + ON design_system_scans(project) WHERE status = 'pending'; diff --git a/packages/storage/src/skills-store.ts b/packages/storage/src/skills-store.ts index 5b5b136..99546da 100644 --- a/packages/storage/src/skills-store.ts +++ b/packages/storage/src/skills-store.ts @@ -105,9 +105,33 @@ export class SkillsStore { private prepareStatements() { return { + // INSERT OR REPLACE deletes the existing row and inserts a fresh one, so any + // column NOT listed here reverts to its schema default. The original statement + // omitted the five autolearning columns (confidence, usage_count, useful_count, + // sessions_since_validation, last_validated), silently wiping all skill decay/ + // reinforcement state on EVERY write — re-import, dashboard edit, skill_update, + // and proposal-apply. We COALESCE each from the existing row (same pattern the + // statement already used for created_by_user_id) so accumulated learning + // survives a replace. `status` is likewise COALESCE'd: callers that pass an + // explicit status (dashboard edit, skill_add/update, soft-delete, proposal + // apply) keep that value; the importer passes NULL, so a re-import preserves the + // skill's current status instead of forcing every skill back to 'active' + // (which previously resurrected soft-deleted/deprecated skills and auto-approved + // pending ones). New skills (no existing row) still default to 'active'. upsert: this.db.prepare(` - INSERT OR REPLACE INTO skills (name, category, description, content, type, tags, lines, updated_at, status, created_by_user_id, updated_by_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_by_user_id FROM skills WHERE name = ?), ?), ?) + INSERT OR REPLACE INTO skills + (name, category, description, content, type, tags, lines, updated_at, status, + created_by_user_id, updated_by_user_id, + confidence, usage_count, useful_count, sessions_since_validation, last_validated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, + COALESCE(?, (SELECT status FROM skills WHERE name = ?), 'active'), + COALESCE((SELECT created_by_user_id FROM skills WHERE name = ?), ?), + ?, + COALESCE((SELECT confidence FROM skills WHERE name = ?), 5), + COALESCE((SELECT usage_count FROM skills WHERE name = ?), 0), + COALESCE((SELECT useful_count FROM skills WHERE name = ?), 0), + COALESCE((SELECT sessions_since_validation FROM skills WHERE name = ?), 0), + (SELECT last_validated FROM skills WHERE name = ?)) `), get: this.db.prepare('SELECT * FROM skills WHERE name = ?'), getUpdatedAt: this.db.prepare('SELECT updated_at FROM skills WHERE name = ?'), @@ -262,9 +286,14 @@ export class SkillsStore { this.stmts.upsert.run( skill.name, skill.category, skill.description, skill.content, skill.type, JSON.stringify(skill.tags), skill.lines, skill.updatedAt, - skill.status ?? 'active', - skill.name, skill.createdByUserId ?? null, - changedBy ?? null, + skill.status ?? null, skill.name, // status: explicit value, else preserve existing, else 'active' + skill.name, skill.createdByUserId ?? null, // created_by_user_id: preserve existing on replace + changedBy ?? null, // updated_by_user_id + skill.name, // confidence: preserve + skill.name, // usage_count: preserve + skill.name, // useful_count: preserve + skill.name, // sessions_since_validation: preserve + skill.name, // last_validated: preserve ) this.saveVersion(skill, changedBy ?? null, reason)