Skip to content

Commit 68762d4

Browse files
authored
Merge pull request #2 from PIXARTSeu/fix/attention-perpetual-reappearance
fix(hub): stop "Needs attention" items from perpetually reappearing
2 parents 365117f + ea1f61e commit 68762d4

13 files changed

Lines changed: 570 additions & 62 deletions

File tree

packages/codegraph/public/app.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ async function deprecateMemory(id) {
183183
else alert('Update failed')
184184
}
185185

186+
async function reinforceMemory(id) {
187+
const r = await fetch(`/api/memories/${encodeURIComponent(id)}/reinforce`, { method: 'POST' })
188+
if (r.ok) { closeDetail(); route() }
189+
else alert('Reinforce failed')
190+
}
191+
186192
// ── Session actions ──
187193
async function cleanupDuplicates() {
188194
if (!confirm('Delete duplicate in-progress sessions (keeps most recent per project)?')) return
@@ -346,6 +352,7 @@ window.removeStackTag = removeStackTag
346352
window.addMemberRow = addMemberRow
347353
window.deleteMemory = deleteMemory
348354
window.deprecateMemory = deprecateMemory
355+
window.reinforceMemory = reinforceMemory
349356
window.cleanupDuplicates = cleanupDuplicates
350357
window.deleteSession = deleteSession
351358
window.loadEnvVars = loadEnvVars
@@ -436,8 +443,12 @@ window.applySkillUpdate = async (proposalId, btn) => {
436443
async function updateReviewBadge() {
437444
try {
438445
const data = await api.get('/api/review/pending')
439-
const total = (data.memories?.length || 0) + (data.skills?.length || 0) +
440-
(data.components?.length || 0) + (data.proposals?.length || 0) + (data.dsScans?.length || 0)
446+
// Use the authoritative `totals` object, not array lengths: the memories query is
447+
// LIMITed (default 100), so .length undercounts the badge once >100 are pending —
448+
// exactly the backlog scenario. renderReview already uses data.totals; match it.
449+
const t = data.totals || {}
450+
const total = (t.memories || 0) + (t.skills || 0) +
451+
(t.components || 0) + (t.proposals || 0) + (t.dsScans || 0)
441452
const badge = document.getElementById('review-badge')
442453
if (badge) {
443454
badge.textContent = total

packages/codegraph/public/js/render.js

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,53 @@ function clearHubRefresh() {
7171
if (_hubVisibilityHandler) { document.removeEventListener('visibilitychange', _hubVisibilityHandler); _hubVisibilityHandler = null }
7272
}
7373

74+
// Builds the "Needs attention" band HTML from the three counts. Shared by the
75+
// initial renderHome() and the 30s refreshHubLive() so the band stays in sync with
76+
// the adjacent System Health rows instead of freezing at its first-render value.
77+
function buildAttnBand(reviewTotal, decayCount, staleCount) {
78+
const attnItems = []
79+
if (reviewTotal > 0) attnItems.push({
80+
count: reviewTotal,
81+
label: reviewTotal === 1 ? 'Pending review' : 'Pending reviews',
82+
hint: 'Approve or reject queued items',
83+
target: '#/review',
84+
})
85+
if (decayCount > 0) attnItems.push({
86+
count: decayCount,
87+
label: decayCount === 1 ? 'Decaying memory' : 'Decaying memories',
88+
hint: 'Confidence dropped through real decay',
89+
target: '#/memories',
90+
})
91+
if (staleCount > 0) attnItems.push({
92+
count: staleCount,
93+
label: staleCount === 1 ? 'Stale memory' : 'Stale memories',
94+
hint: 'Not updated in 90+ days',
95+
target: '#/memories',
96+
})
97+
if (attnItems.length === 0) return ''
98+
return `
99+
<div class="hub-attention">
100+
<div class="hub-attention-head">
101+
<span class="hub-attention-icon">${HUB_ICONS.warn}</span>
102+
Needs attention
103+
</div>
104+
${attnItems.map(item => {
105+
const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit'
106+
return `<div class="hub-attention-item ${sev}" tabindex="0" role="button"
107+
aria-label="${item.label}: ${item.count}"
108+
onclick="location.hash='${item.target}'" ${KEY_CLICK}>
109+
<span class="attn-count">${item.count}</span>
110+
<div class="attn-body">
111+
<span class="attn-label">${item.label}</span>
112+
<span class="attn-hint">${item.hint}</span>
113+
</div>
114+
<span class="attn-arrow">${HUB_ICONS.arrow}</span>
115+
</div>`
116+
}).join('')}
117+
</div>
118+
`
119+
}
120+
74121
async function refreshHubLive() {
75122
const hash = location.hash || '#/'
76123
if (hash !== '#/' && hash !== '#' && hash !== '#/home') { clearHubRefresh(); return }
@@ -106,6 +153,33 @@ async function refreshHubLive() {
106153
dot.className = 'health-dot ' + (staleCount === 0 ? 'health-dot-ok' : staleCount <= 5 ? 'health-dot-warn' : 'health-dot-crit')
107154
}
108155
}
156+
// Keep the "Needs attention" band in sync with the health rows. It was built once
157+
// by renderHome and otherwise frozen, so it would diverge from the live "Pending
158+
// reviews"/"Decay" rows (and never disappear when a count dropped to 0).
159+
const freshBand = buildAttnBand(
160+
reviewTotal,
161+
typeof health.decayCount === 'number' ? health.decayCount : 0,
162+
typeof health.staleCount === 'number' ? health.staleCount : 0,
163+
)
164+
const attnHost = document.querySelector('.hub-attention')
165+
if (attnHost) {
166+
if (freshBand) {
167+
const tmp = document.createElement('template')
168+
tmp.innerHTML = freshBand.trim()
169+
attnHost.replaceWith(tmp.content.firstElementChild)
170+
} else {
171+
attnHost.remove()
172+
}
173+
} else if (freshBand) {
174+
// Count went 0 -> >0 since first render: insert the band right after the greeting.
175+
const greeting = document.querySelector('.hub-hero .hub-greeting-line')
176+
if (greeting) {
177+
const tmp = document.createElement('template')
178+
tmp.innerHTML = freshBand.trim()
179+
greeting.insertAdjacentElement('afterend', tmp.content.firstElementChild)
180+
}
181+
}
182+
109183
const uptimeStrong = document.querySelector('.health-footer span:first-child strong')
110184
if (uptimeStrong) uptimeStrong.textContent = formatUptime(health.uptime || 0)
111185
const statusEl = document.getElementById('server-status')
@@ -277,7 +351,14 @@ export async function renderHome() {
277351
`).join('') || '<p style="color:var(--text-muted);font-size:12px">No memories yet.</p>'
278352

279353
// ── System Health ──
280-
const decayCount = health.decayCount ?? memories.filter(m => (m.confidence ?? 10) < 4).length
354+
// Mirror the server predicate (http-server.ts computeAttentionCounts): a memory is
355+
// "decaying" only if it actually aged (sessions_since_validation >= 5), not merely
356+
// born below the confidence threshold. Excludes the M-_system_* metadata row.
357+
const decayCount = health.decayCount ?? memories.filter(m =>
358+
(m.confidence ?? 10) < 4 &&
359+
(m.sessionsSinceValidation ?? 0) >= 5 &&
360+
!(m.id || '').startsWith('M-_system_')
361+
).length
281362
const staleCount = health.staleCount ?? memories.filter(m => {
282363
const ts = m.updatedAt || m.updated_at || m.createdAt || m.created_at
283364
return ts && daysSince(ts) > 90
@@ -297,48 +378,8 @@ export async function renderHome() {
297378
? `openProjectDetail('${escHtml(resumeProject)}')`
298379
: recentSession ? `location.hash='#/sessions'` : ''
299380

300-
// ── Attention band ──
301-
const attnItems = []
302-
if (reviewTotal > 0) attnItems.push({
303-
count: reviewTotal,
304-
label: reviewTotal === 1 ? 'Pending review' : 'Pending reviews',
305-
hint: 'Approve or reject queued items',
306-
target: '#/review',
307-
})
308-
if (decayCount > 0) attnItems.push({
309-
count: decayCount,
310-
label: decayCount === 1 ? 'Decaying memory' : 'Decaying memories',
311-
hint: 'Confidence below 4',
312-
target: '#/memories',
313-
})
314-
if (staleCount > 0) attnItems.push({
315-
count: staleCount,
316-
label: staleCount === 1 ? 'Stale memory' : 'Stale memories',
317-
hint: 'Not updated in 90+ days',
318-
target: '#/memories',
319-
})
320-
321-
const attnBand = attnItems.length === 0 ? '' : `
322-
<div class="hub-attention">
323-
<div class="hub-attention-head">
324-
<span class="hub-attention-icon">${HUB_ICONS.warn}</span>
325-
Needs attention
326-
</div>
327-
${attnItems.map(item => {
328-
const sev = item.count <= 5 ? 'attn-warn' : 'attn-crit'
329-
return `<div class="hub-attention-item ${sev}" tabindex="0" role="button"
330-
aria-label="${item.label}: ${item.count}"
331-
onclick="location.hash='${item.target}'" ${KEY_CLICK}>
332-
<span class="attn-count">${item.count}</span>
333-
<div class="attn-body">
334-
<span class="attn-label">${item.label}</span>
335-
<span class="attn-hint">${item.hint}</span>
336-
</div>
337-
<span class="attn-arrow">${HUB_ICONS.arrow}</span>
338-
</div>`
339-
}).join('')}
340-
</div>
341-
`
381+
// ── Attention band ── (built via shared buildAttnBand so refreshHubLive can rebuild it)
382+
const attnBand = buildAttnBand(reviewTotal, decayCount, staleCount)
342383

343384
pageEl.innerHTML = `
344385
<section class="hub-hero">
@@ -404,7 +445,7 @@ export async function renderHome() {
404445
<div class="card-title">System health</div>
405446
<div class="health-row" data-health="decay" tabindex="0" role="button" aria-label="Decay alerts: ${decayCount}" onclick="location.hash='#/memories'" ${KEY_CLICK}>
406447
<span class="health-dot ${dotClass(decayCount)}"></span>
407-
<span class="health-row-label">Decay alerts <span class="health-row-hint">(conf &lt; 4)</span></span>
448+
<span class="health-row-label">Decay alerts <span class="health-row-hint">(conf &lt; 4 · aged)</span></span>
408449
<span class="health-row-val">${decayCount}</span>
409450
</div>
410451
<div class="health-row" data-health="review" tabindex="0" role="button" aria-label="Pending reviews: ${reviewTotal}" onclick="location.hash='#/review'" ${KEY_CLICK}>
@@ -767,6 +808,7 @@ export async function openMemoryDetail(id, openDetailFn) {
767808
<span style="color:var(--text-muted);font-size:11px;margin-left:8px">${m.skill || ''} &middot; ${m.scope}</span>
768809
</div>
769810
<div style="display:flex;gap:8px;margin-bottom:12px">
811+
${(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>` : ''}
770812
<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>
771813
<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>
772814
</div>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Synapse — one-time remediation of the "Needs attention" backlog
4+
# (the historical 308 pending reviews / 30 decaying memories on production).
5+
#
6+
# Policy: AUTO-APPROVE IN-PLACE (chosen by the operator). It drains the
7+
# auto-generated review backlog without manual triage:
8+
# - pending-review memories -> active, staleness reset
9+
# - all active memories with ssv >= 5 -> staleness reset (clean slate; the
10+
# kept auto-enqueue restarts its 15-cycle clock)
11+
# - pending skill_proposals -> dismissed (auto-generated nudges)
12+
# - pending design_system_scans -> dismissed (auto-generated)
13+
# - decay metadata clock -> reset to now
14+
#
15+
# IMPORTANT — run order:
16+
# 1. Deploy the new code FIRST so migration 034 (dedup indexes) has run and the
17+
# new approve/decay logic is live. Otherwise the backlog will simply refill.
18+
# 2. Then run this script on the box that holds the production DB (Coolify container).
19+
#
20+
# It refuses to run without a backup and prints before/after counts.
21+
#
22+
# Usage:
23+
# ./remediate-attention-backlog.sh [/path/to/graph.db]
24+
# Default DB path: /data/.codegraph/graph.db (Synapse production layout)
25+
26+
set -euo pipefail
27+
28+
DB="${1:-/data/.codegraph/graph.db}"
29+
30+
if ! command -v sqlite3 >/dev/null 2>&1; then
31+
echo "ERROR: sqlite3 not found in PATH." >&2
32+
exit 1
33+
fi
34+
if [[ ! -f "$DB" ]]; then
35+
echo "ERROR: database not found at: $DB" >&2
36+
echo "Pass the correct path as the first argument." >&2
37+
exit 1
38+
fi
39+
40+
STAMP="$(date +%Y%m%d-%H%M%S)"
41+
BACKUP="${DB}.bak-pre-attention-remediation-${STAMP}"
42+
43+
echo "==> Database: $DB"
44+
echo "==> Backup: $BACKUP"
45+
# .backup is safe on a live WAL DB (consistent snapshot).
46+
sqlite3 "$DB" ".backup '$BACKUP'"
47+
echo "==> Backup created."
48+
49+
echo
50+
echo "==> BEFORE:"
51+
sqlite3 "$DB" <<'SQL'
52+
.mode column
53+
.headers on
54+
SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review'
55+
UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15
56+
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_%'
57+
UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending'
58+
UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending';
59+
SQL
60+
61+
echo
62+
echo "==> Applying remediation (single transaction)…"
63+
sqlite3 "$DB" <<'SQL'
64+
BEGIN;
65+
66+
-- 1. Auto-approve every queued memory in place + reset its staleness so the next
67+
-- decay cycle does not immediately re-flag it (these were timer-generated, not
68+
-- human-queued).
69+
UPDATE memories
70+
SET status = 'active',
71+
sessions_since_validation = 0,
72+
last_validated = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
73+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
74+
WHERE status = 'pending-review';
75+
76+
-- 2. Clean slate for the kept auto-enqueue mechanism: reset the staleness clock on
77+
-- every active memory that had already aged, so the 15-cycle countdown restarts
78+
-- fresh after the fix instead of immediately re-queuing a wave of old memories.
79+
UPDATE memories
80+
SET sessions_since_validation = 0,
81+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
82+
WHERE status = 'active'
83+
AND sessions_since_validation >= 5
84+
AND id NOT LIKE 'M-_system_%';
85+
86+
-- 3. Dismiss the auto-generated proposal/scan nudges (content is untouched; they
87+
-- re-propose only when genuinely warranted now that dedup indexes exist).
88+
UPDATE skill_proposals SET status = 'dismissed', reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE status = 'pending';
89+
UPDATE design_system_scans SET status = 'dismissed' WHERE status = 'pending';
90+
91+
-- 4. Reset the decay clock so the next scheduler tick starts a fresh 24h window.
92+
UPDATE memories SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = 'M-_system_decay_last_run';
93+
94+
COMMIT;
95+
SQL
96+
97+
echo "==> Done."
98+
echo
99+
echo "==> AFTER:"
100+
sqlite3 "$DB" <<'SQL'
101+
.mode column
102+
.headers on
103+
SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review'
104+
UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15
105+
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_%'
106+
UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending'
107+
UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending';
108+
SQL
109+
110+
echo
111+
echo "All five counters should now read 0. If anything looks wrong, restore with:"
112+
echo " cp '$BACKUP' '$DB' # (stop the server first)"
113+
echo
114+
echo "NOTE: pending skill/component DRAFTS (skills.status='pending' / ui_components.status='pending')"
115+
echo "are intentional human/agent submissions and are NOT auto-approved here. Review them in the"
116+
echo "dashboard, or to also auto-approve them (publishes unreviewed drafts) run:"
117+
echo " sqlite3 '$DB' \"UPDATE skills SET status='active' WHERE status='pending'; UPDATE ui_components SET status='active' WHERE status='pending';\""

packages/codegraph/src/mcp/http-server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,20 @@ export function computeAttentionCounts(db: import('better-sqlite3').Database): {
164164
} {
165165
const staleCutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000).toISOString()
166166

167+
// "Decaying" must mean a memory that has ACTUALLY decayed — not one merely born
168+
// below the threshold. New memories are created with low confidence (default 1),
169+
// and applyDecay only lowers confidence once sessions_since_validation >= 5, so the
170+
// bare `confidence < 4` predicate counted every fresh/unvalidated memory as
171+
// "decaying" — definitional noise that could never be cleared. Requiring
172+
// ssv >= 5 means the row went through >=5 unvalidated decay cycles. The system
173+
// metadata row (M-_system_*) is excluded for parity with allActive().
167174
const decayCount = (db.prepare(
168-
`SELECT COUNT(*) AS n FROM memories WHERE status = 'active' AND confidence IS NOT NULL AND confidence < 4`
175+
`SELECT COUNT(*) AS n FROM memories
176+
WHERE status = 'active'
177+
AND confidence IS NOT NULL
178+
AND confidence < 4
179+
AND sessions_since_validation >= 5
180+
AND id NOT LIKE 'M-_system_%'`
169181
).get() as { n: number }).n
170182

171183
const staleCount = (db.prepare(

packages/codegraph/src/mcp/routes/memories.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,23 @@ export function createMemoriesRouter(ctx: RouteContext): Router {
116116
}
117117
})
118118

119+
// Non-destructive "Reinforce / Keep" action: bumps confidence +1 and resets the
120+
// staleness counter so a valuable low-confidence memory can be rehabilitated from
121+
// the dashboard instead of only Deprecate/Delete. Also doubles as the human
122+
// counterpart to the auto-decay's pending-review flagging.
123+
router.post('/api/memories/:id/reinforce', (req, res) => {
124+
const userId = (req as any).userId as string | undefined
125+
try {
126+
const db = openDb(ctx.skillbrainRoot)
127+
const store = new MemoryStore(db)
128+
const ok = store.reinforce(req.params.id, userId)
129+
closeDb(db)
130+
if (!ok) { res.status(404).json({ error: 'Memory not found' }); return }
131+
res.json({ ok: true })
132+
} catch (err: any) {
133+
res.status(500).json({ error: err.message })
134+
}
135+
})
136+
119137
return router
120138
}

packages/codegraph/src/mcp/routes/review.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,15 @@ export function createReviewRouter(ctx: RouteContext): Router {
6060
router.post('/api/review/memory/:id/approve', (req, res) => {
6161
const db = openDb(ctx.skillbrainRoot)
6262
const now = new Date().toISOString()
63-
db.prepare(`UPDATE memories SET status = 'active', updated_at = ? WHERE id = ?`)
64-
.run(now, req.params.id)
63+
// Reset the staleness counter on approve. A memory lands in 'pending-review'
64+
// because markPendingReview fired (sessions_since_validation >= 15). If we only
65+
// flip status back to 'active', the very next 24h decay cycle increments ssv and
66+
// markPendingReview re-flags it — so the approval never sticks and the queue never
67+
// drains. Treating a human approval as a validation event (ssv=0 + last_validated)
68+
// mirrors reinforceMemory, minus the confidence bump (approve = "leave it", not
69+
// "this proved useful"). The memory must now age 15 fresh cycles before re-queuing.
70+
db.prepare(`UPDATE memories SET status = 'active', sessions_since_validation = 0, last_validated = ?, updated_at = ? WHERE id = ?`)
71+
.run(now, now, req.params.id)
6572
new AuditStore(db).log({ entityType: 'memory', entityId: req.params.id, action: 'approve', reviewedBy: (req as any).userId ?? 'unknown' })
6673
closeDb(db)
6774
res.json({ ok: true })

0 commit comments

Comments
 (0)