Skip to content
Merged
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
15 changes: 13 additions & 2 deletions packages/codegraph/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 86 additions & 44 deletions packages/codegraph/public/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
<div class="hub-attention">
<div class="hub-attention-head">
<span class="hub-attention-icon">${HUB_ICONS.warn}</span>
Needs attention
</div>
${attnItems.map(item => {
const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit'
return `<div class="hub-attention-item ${sev}" tabindex="0" role="button"
aria-label="${item.label}: ${item.count}"
onclick="location.hash='${item.target}'" ${KEY_CLICK}>
<span class="attn-count">${item.count}</span>
<div class="attn-body">
<span class="attn-label">${item.label}</span>
<span class="attn-hint">${item.hint}</span>
</div>
<span class="attn-arrow">${HUB_ICONS.arrow}</span>
</div>`
}).join('')}
</div>
`
}

async function refreshHubLive() {
const hash = location.hash || '#/'
if (hash !== '#/' && hash !== '#' && hash !== '#/home') { clearHubRefresh(); return }
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -277,7 +351,14 @@ export async function renderHome() {
`).join('') || '<p style="color:var(--text-muted);font-size:12px">No memories yet.</p>'

// ── 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
Expand All @@ -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 ? '' : `
<div class="hub-attention">
<div class="hub-attention-head">
<span class="hub-attention-icon">${HUB_ICONS.warn}</span>
Needs attention
</div>
${attnItems.map(item => {
const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit'
return `<div class="hub-attention-item ${sev}" tabindex="0" role="button"
aria-label="${item.label}: ${item.count}"
onclick="location.hash='${item.target}'" ${KEY_CLICK}>
<span class="attn-count">${item.count}</span>
<div class="attn-body">
<span class="attn-label">${item.label}</span>
<span class="attn-hint">${item.hint}</span>
</div>
<span class="attn-arrow">${HUB_ICONS.arrow}</span>
</div>`
}).join('')}
</div>
`
// ── Attention band ── (built via shared buildAttnBand so refreshHubLive can rebuild it)
const attnBand = buildAttnBand(reviewTotal, decayCount, staleCount)

pageEl.innerHTML = `
<section class="hub-hero">
Expand Down Expand Up @@ -404,7 +445,7 @@ export async function renderHome() {
<div class="card-title">System health</div>
<div class="health-row" data-health="decay" tabindex="0" role="button" aria-label="Decay alerts: ${decayCount}" onclick="location.hash='#/memories'" ${KEY_CLICK}>
<span class="health-dot ${dotClass(decayCount)}"></span>
<span class="health-row-label">Decay alerts <span class="health-row-hint">(conf &lt; 4)</span></span>
<span class="health-row-label">Decay alerts <span class="health-row-hint">(conf &lt; 4 · aged)</span></span>
<span class="health-row-val">${decayCount}</span>
</div>
<div class="health-row" data-health="review" tabindex="0" role="button" aria-label="Pending reviews: ${reviewTotal}" onclick="location.hash='#/review'" ${KEY_CLICK}>
Expand Down Expand Up @@ -767,6 +808,7 @@ export async function openMemoryDetail(id, openDetailFn) {
<span style="color:var(--text-muted);font-size:11px;margin-left:8px">${m.skill || ''} &middot; ${m.scope}</span>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px">
${(m.confidence ?? 10) < 10 ? `<button onclick="reinforceMemory('${m.id}')" title="Confidence +1 and reset staleness — keep this memory healthy" style="padding:4px 12px;border-radius:6px;background:rgba(52,211,153,.1);border:1px solid var(--green);color:var(--green);font-size:11px;cursor:pointer">Reinforce</button>` : ''}
<button onclick="deprecateMemory('${m.id}')" style="padding:4px 12px;border-radius:6px;background:rgba(245,158,11,.1);border:1px solid var(--yellow);color:var(--yellow);font-size:11px;cursor:pointer">Deprecate</button>
<button onclick="deleteMemory('${m.id}')" style="padding:4px 12px;border-radius:6px;background:rgba(248,113,113,.1);border:1px solid var(--red);color:var(--red);font-size:11px;cursor:pointer">Delete</button>
</div>
Expand Down
117 changes: 117 additions & 0 deletions packages/codegraph/scripts/remediate-attention-backlog.sh
Original file line number Diff line number Diff line change
@@ -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';\""
14 changes: 13 additions & 1 deletion packages/codegraph/src/mcp/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions packages/codegraph/src/mcp/routes/memories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
11 changes: 9 additions & 2 deletions packages/codegraph/src/mcp/routes/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading
Loading