@jhizzard/termdeck 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,14 +76,19 @@
76
76
 
77
77
  // -------- State ----------------------------------------------------------
78
78
 
79
+ const GC = (typeof window !== 'undefined' && window.GraphControls) || null;
80
+
79
81
  const state = {
80
82
  mode: 'project', // 'project' | 'memory'
81
83
  project: null,
82
84
  memoryId: null,
83
85
  depth: 2,
84
- nodes: [],
85
- edges: [],
86
+ nodes: [], // raw nodes from /api/graph/* — unfiltered
87
+ edges: [], // raw edges from /api/graph/* — unfiltered
88
+ visibleNodes: [], // post-filter, what the simulation sees
89
+ visibleEdges: [],
86
90
  activeKinds: new Set(Object.keys(EDGE_COLORS)),
91
+ controls: GC ? GC.defaultControls() : { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' },
87
92
  selectedNodeId: null,
88
93
  hoverNodeId: null,
89
94
  searchTerm: '',
@@ -172,6 +177,7 @@
172
177
  state.mode = 'project';
173
178
  state.project = project;
174
179
  }
180
+ if (GC) state.controls = GC.decodeControls(qs);
175
181
  }
176
182
 
177
183
  function writeUrlState() {
@@ -182,6 +188,7 @@
182
188
  } else if (state.project) {
183
189
  qs.set('project', state.project);
184
190
  }
191
+ if (GC) GC.encodeControls(qs, state.controls);
185
192
  const url = qs.toString() ? `?${qs.toString()}` : window.location.pathname;
186
193
  window.history.replaceState(null, '', url);
187
194
  }
@@ -259,6 +266,8 @@
259
266
  // node render all paint over each other after a mode/project switch.
260
267
  hideEmpty();
261
268
  clearGraphSvg();
269
+ state.rawNodes = [];
270
+ state.rawEdges = [];
262
271
  state.nodes = [];
263
272
  state.edges = [];
264
273
  setLoading('Loading graph…');
@@ -267,18 +276,18 @@
267
276
  if (state.mode === 'project' && state.project === '__all__') {
268
277
  data = await api('/api/graph/all');
269
278
  if (data.enabled === false) return showDisabled(data);
270
- state.nodes = data.nodes || [];
271
- state.edges = data.edges || [];
279
+ state.rawNodes = data.nodes || [];
280
+ state.rawEdges = data.edges || [];
272
281
  if (data.truncated) {
273
282
  showToast(
274
- `Showing ${state.nodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
283
+ `Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
275
284
  );
276
285
  }
277
286
  } else if (state.mode === 'memory') {
278
287
  data = await api(`/api/graph/memory/${encodeURIComponent(state.memoryId)}?depth=${state.depth}`);
279
288
  if (data.enabled === false) return showDisabled(data);
280
- state.nodes = data.nodes || [];
281
- state.edges = data.edges || [];
289
+ state.rawNodes = data.nodes || [];
290
+ state.rawEdges = data.edges || [];
282
291
  // Use the root memory's project as the view's "current project" for
283
292
  // the legend / drawer / fallback color when nodes span projects.
284
293
  if (data.root && data.root.project) state.project = data.root.project;
@@ -287,19 +296,17 @@
287
296
  state.project = name;
288
297
  data = await api(`/api/graph/project/${encodeURIComponent(name)}`);
289
298
  if (data.enabled === false) return showDisabled(data);
290
- state.nodes = data.nodes || [];
291
- state.edges = data.edges || [];
299
+ state.rawNodes = data.nodes || [];
300
+ state.rawEdges = data.edges || [];
292
301
  }
293
302
  writeUrlState();
294
303
  hideLoading();
295
- if (state.nodes.length === 0) {
304
+ if (state.rawNodes.length === 0) {
296
305
  showEmpty();
297
306
  return;
298
307
  }
299
308
  hideEmpty();
300
- renderFilters();
301
- renderGraph();
302
- updateStats();
309
+ applyControlsAndRender();
303
310
  } catch (err) {
304
311
  // Even on error, drop the empty-state overlay so the failure message
305
312
  // shows alone instead of stacking on top of "No memories yet".
@@ -308,6 +315,35 @@
308
315
  }
309
316
  }
310
317
 
318
+ // Sprint 43 T1 — re-derive state.nodes/state.edges from the raw API result
319
+ // and the four user controls, then drive the existing render pipeline.
320
+ // Called after every fetch and after any control change.
321
+ function applyControlsAndRender() {
322
+ const raw = { nodes: state.rawNodes || [], edges: state.rawEdges || [] };
323
+ let visible;
324
+ if (GC) {
325
+ visible = GC.applyControls(raw.nodes, raw.edges, state.controls);
326
+ } else {
327
+ visible = { nodes: raw.nodes.slice(), edges: raw.edges.slice() };
328
+ }
329
+ state.nodes = visible.nodes;
330
+ state.edges = visible.edges;
331
+ renderFilters();
332
+ renderGraph();
333
+ updateStats();
334
+ updateControlStat(raw.nodes.length, visible.nodes.length);
335
+ }
336
+
337
+ function updateControlStat(total, visible) {
338
+ const el = document.getElementById('ctlVisibleStat');
339
+ if (!el) return;
340
+ if (total === visible) {
341
+ el.textContent = '';
342
+ } else {
343
+ el.textContent = `${visible} of ${total} visible`;
344
+ }
345
+ }
346
+
311
347
  // -------- Render --------------------------------------------------------
312
348
 
313
349
  function setLoading(msg) {
@@ -417,6 +453,48 @@
417
453
  svg().setAttribute('height', state.height);
418
454
  }
419
455
 
456
+ // Sprint 43 T1 — apply layout-specific forces to the simulation.
457
+ // force — forceCenter at midpoint (default).
458
+ // hierarchical — forceY from BFS levels over directional edges (supersedes /
459
+ // caused_by / blocks). Roots float top, leaves drift down.
460
+ // radial — forceRadial centered at midpoint, radius from inverse
461
+ // degree (high degree → near center).
462
+ function applyLayoutForces(sim, nodes, links) {
463
+ const cx = state.width / 2;
464
+ const cy = state.height / 2;
465
+ const layout = (state.controls && state.controls.layout) || 'force';
466
+
467
+ sim.force('center', null);
468
+ sim.force('hier-y', null);
469
+ sim.force('hier-x', null);
470
+ sim.force('radial', null);
471
+
472
+ if (layout === 'hierarchical' && GC) {
473
+ const levels = GC.computeHierarchyLevels(state.nodes, state.edges);
474
+ let maxLevel = 0;
475
+ for (const v of levels.values()) if (v > maxLevel) maxLevel = v;
476
+ const denom = Math.max(1, maxLevel);
477
+ const top = state.height * 0.10;
478
+ const bottom = state.height * 0.90;
479
+ sim.force('hier-y', window.d3.forceY((d) => {
480
+ const lvl = levels.get(d.id) || 0;
481
+ return top + (lvl / denom) * (bottom - top);
482
+ }).strength(0.65));
483
+ sim.force('hier-x', window.d3.forceX(cx).strength(0.04));
484
+ return;
485
+ }
486
+
487
+ if (layout === 'radial' && GC) {
488
+ const deg = GC.computeDegrees(state.nodes, state.edges);
489
+ const rMax = Math.min(state.width, state.height) * 0.42;
490
+ const radiusFn = GC.radialRadiusFn(deg, rMax);
491
+ sim.force('radial', window.d3.forceRadial(radiusFn, cx, cy).strength(0.55));
492
+ return;
493
+ }
494
+
495
+ sim.force('center', window.d3.forceCenter(cx, cy));
496
+ }
497
+
420
498
  function renderGraph() {
421
499
  if (!window.d3) {
422
500
  setLoading('D3.js failed to load (CDN blocked?)');
@@ -441,10 +519,11 @@
441
519
  const sim = window.d3.forceSimulation(nodes)
442
520
  .force('link', window.d3.forceLink(links).id((d) => d.id).distance((l) => 60 + (1 - (l.weight ?? 0.5)) * 40))
443
521
  .force('charge', window.d3.forceManyBody().strength(-260))
444
- .force('center', window.d3.forceCenter(state.width / 2, state.height / 2))
445
522
  .force('collide', window.d3.forceCollide().radius((d) => nodeRadius(d) + 4))
446
523
  .alphaDecay(0.02);
447
524
 
525
+ applyLayoutForces(sim, nodes, links);
526
+
448
527
  state.sim = sim;
449
528
 
450
529
  const rootSel = window.d3.select(root());
@@ -742,6 +821,41 @@
742
821
  });
743
822
  $('graphFit').addEventListener('click', () => fitToView());
744
823
 
824
+ // Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
825
+ // wire change handlers that mutate state.controls + URL + re-render from
826
+ // the cached raw fetch so toggling is fast (no API round-trip).
827
+ const ctlHide = $('ctlHideIsolated');
828
+ const ctlMin = $('ctlMinDegree');
829
+ const ctlWin = $('ctlWindow');
830
+ const ctlLay = $('ctlLayout');
831
+ if (ctlHide) ctlHide.checked = !!state.controls.hideIsolated;
832
+ if (ctlMin) ctlMin.value = String(state.controls.minDegree || 0);
833
+ if (ctlWin) ctlWin.value = state.controls.window || 'all';
834
+ if (ctlLay) ctlLay.value = state.controls.layout || 'force';
835
+
836
+ function onControlChange() {
837
+ state.controls = (GC ? GC.normalizeControls(state.controls) : state.controls);
838
+ writeUrlState();
839
+ if ((state.rawNodes || []).length === 0) return;
840
+ applyControlsAndRender();
841
+ }
842
+ if (ctlHide) ctlHide.addEventListener('change', () => {
843
+ state.controls.hideIsolated = !!ctlHide.checked;
844
+ onControlChange();
845
+ });
846
+ if (ctlMin) ctlMin.addEventListener('change', () => {
847
+ state.controls.minDegree = parseInt(ctlMin.value, 10) || 0;
848
+ onControlChange();
849
+ });
850
+ if (ctlWin) ctlWin.addEventListener('change', () => {
851
+ state.controls.window = ctlWin.value;
852
+ onControlChange();
853
+ });
854
+ if (ctlLay) ctlLay.addEventListener('change', () => {
855
+ state.controls.layout = ctlLay.value;
856
+ onControlChange();
857
+ });
858
+
745
859
  $('gdClose').addEventListener('click', closeDrawer);
746
860
  $('gdExpand').addEventListener('click', () => {
747
861
  if (!state.selectedNodeId) return;
@@ -763,7 +877,9 @@
763
877
  window.addEventListener('resize', () => {
764
878
  sizeStage();
765
879
  if (state.sim) {
766
- state.sim.force('center', window.d3.forceCenter(state.width / 2, state.height / 2));
880
+ // Re-apply layout-specific forces with the new dimensions; this
881
+ // handles all three layouts (force / hierarchical / radial).
882
+ applyLayoutForces(state.sim, null, null);
767
883
  state.sim.alpha(0.3).restart();
768
884
  }
769
885
  });
@@ -62,6 +62,7 @@
62
62
  <button id="btn-config">config</button>
63
63
  <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
64
64
  <button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
65
+ <button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
65
66
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
66
67
  <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://termdeck-docs.vercel.app','_blank','noopener')">help</button>
67
68
  </div>
@@ -3567,6 +3567,61 @@
3567
3567
  background: rgba(255, 255, 255, 0.08);
3568
3568
  }
3569
3569
 
3570
+ /* Sprint 43 T1 — second toolbar row: hide-isolated / min-degree / window
3571
+ / layout selectors. Mirrors .graph-filters spacing but shifts the
3572
+ background a notch darker so the two rows are visually distinguishable
3573
+ and the edge-type chips above feel like their own band. */
3574
+ .graph-filters-row2 {
3575
+ gap: 14px;
3576
+ padding: 6px 16px;
3577
+ background: rgba(0, 0, 0, 0.18);
3578
+ border-bottom: 1px solid var(--tg-border);
3579
+ }
3580
+ .graph-control {
3581
+ display: inline-flex;
3582
+ align-items: center;
3583
+ gap: 6px;
3584
+ font-size: 11px;
3585
+ font-family: var(--tg-mono);
3586
+ color: var(--tg-text-dim);
3587
+ }
3588
+ .graph-control select {
3589
+ background: var(--tg-bg);
3590
+ color: var(--tg-text);
3591
+ border: 1px solid var(--tg-border);
3592
+ border-radius: var(--tg-radius-sm);
3593
+ padding: 3px 6px;
3594
+ font-family: var(--tg-mono);
3595
+ font-size: 11px;
3596
+ cursor: pointer;
3597
+ }
3598
+ .graph-control select:focus {
3599
+ outline: none;
3600
+ border-color: var(--tg-accent);
3601
+ }
3602
+ .graph-control-toggle {
3603
+ cursor: pointer;
3604
+ user-select: none;
3605
+ }
3606
+ .graph-control-toggle input[type="checkbox"] {
3607
+ accent-color: var(--tg-accent);
3608
+ cursor: pointer;
3609
+ }
3610
+ .graph-control-stat {
3611
+ margin-left: auto;
3612
+ font-size: 10px;
3613
+ color: var(--tg-text-dim);
3614
+ font-family: var(--tg-mono);
3615
+ padding: 2px 8px;
3616
+ border: 1px solid transparent;
3617
+ border-radius: 999px;
3618
+ }
3619
+ .graph-control-stat:not(:empty) {
3620
+ background: rgba(122, 162, 247, 0.10);
3621
+ border-color: rgba(122, 162, 247, 0.35);
3622
+ color: var(--tg-text);
3623
+ }
3624
+
3570
3625
  .graph-stage {
3571
3626
  position: relative;
3572
3627
  flex: 1;
@@ -2,12 +2,50 @@
2
2
 
3
3
  const path = require('path');
4
4
  const os = require('os');
5
+ const fs = require('fs');
6
+
7
+ // Sprint 43 T2: load a numbered migration .sql file from the repo-root
8
+ // `migrations/` directory if present, falling back to an inline string for
9
+ // packaged npm installs where the `files` allowlist in package.json does not
10
+ // ship `migrations/`. Authoritative source-of-truth is the .sql file; the
11
+ // fallback is kept in lockstep when the schema changes.
12
+ function loadMigrationSql(name, fallback) {
13
+ const candidates = [
14
+ path.join(__dirname, '..', '..', '..', 'migrations', name),
15
+ path.join(__dirname, '..', '..', '..', '..', 'migrations', name),
16
+ ];
17
+ for (const candidate of candidates) {
18
+ try {
19
+ const sql = fs.readFileSync(candidate, 'utf8');
20
+ if (sql && sql.trim().length) return sql;
21
+ } catch (_e) { /* try next */ }
22
+ }
23
+ return fallback;
24
+ }
25
+
26
+ const FLASHBACK_EVENTS_INLINE_SQL = `
27
+ CREATE TABLE IF NOT EXISTS flashback_events (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ fired_at TEXT NOT NULL,
30
+ session_id TEXT NOT NULL,
31
+ project TEXT,
32
+ error_text TEXT NOT NULL,
33
+ hits_count INTEGER NOT NULL DEFAULT 0,
34
+ top_hit_id TEXT,
35
+ top_hit_score REAL,
36
+ dismissed_at TEXT,
37
+ clicked_through INTEGER NOT NULL DEFAULT 0
38
+ );
39
+ CREATE INDEX IF NOT EXISTS flashback_events_fired_at_idx
40
+ ON flashback_events(fired_at DESC);
41
+ CREATE INDEX IF NOT EXISTS flashback_events_session_idx
42
+ ON flashback_events(session_id);
43
+ `;
5
44
 
6
45
  function initDatabase(Database) {
7
46
  const dbPath = path.join(os.homedir(), '.termdeck', 'termdeck.db');
8
47
 
9
48
  // Ensure directory exists
10
- const fs = require('fs');
11
49
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
12
50
 
13
51
  const db = new Database(dbPath);
@@ -115,6 +153,16 @@ function initDatabase(Database) {
115
153
  console.warn('[db] projects.default_theme drop migration failed:', err.message);
116
154
  }
117
155
 
156
+ // Sprint 43 T2: flashback_events durable audit table. Schema lives in
157
+ // migrations/001_flashback_events.sql (repo-root, source-of-truth) with
158
+ // an inline fallback for packaged installs. Idempotent CREATE so this
159
+ // replays safely on every server start.
160
+ try {
161
+ db.exec(loadMigrationSql('001_flashback_events.sql', FLASHBACK_EVENTS_INLINE_SQL));
162
+ } catch (err) {
163
+ console.warn('[db] flashback_events migration failed:', err.message);
164
+ }
165
+
118
166
  return db;
119
167
  }
120
168
 
@@ -1,15 +1,37 @@
1
- // Flashback diagnostic ring buffer (Sprint 39 T1).
1
+ // Flashback diagnostic ring buffer (Sprint 39 T1) + durable audit table
2
+ // (Sprint 43 T2).
2
3
  //
3
- // Six decision points along the Flashback pipeline write structured events
4
- // here so production-flow regressions surface as a readable timeline instead
5
- // of a silent gate failure. The ring is in-memory and lost on restart by
6
- // design — persistence is a Sprint-40+ concern. Public surface:
4
+ // Two layers of observability for the Flashback pipeline:
7
5
  //
8
- // log({ sessionId, event, ...fields }) append one event
9
- // snapshot({ sessionId?, eventType?, limit? }) read back filtered tail
10
- // _resetForTest() — test-only ring clear
6
+ // (1) IN-MEMORY RINGsix decision points along the pipeline write
7
+ // structured events to a 200-event ring. Lost on restart. Powers the
8
+ // /api/flashback/diag endpoint and the live diagnostic UI. This is
9
+ // fine-grained: every pattern match, every rate-limit hit, every
10
+ // bridge query gets logged.
11
11
  //
12
- // Event shape (all events): { ts, sessionId, event, ...event-specific fields }.
12
+ // (2) SQLITE AUDIT TABLE (flashback_events) every actual fire (the
13
+ // moment a proactive_memory frame is sent over WS to the user's
14
+ // panel) gets one durable row. Survives restart. Powers the
15
+ // /flashback-history.html dashboard and the click-through funnel.
16
+ // This is coarse-grained: one row per fire, plus dismiss/click-through
17
+ // outcome.
18
+ //
19
+ // Public surface:
20
+ //
21
+ // In-memory ring (Sprint 39):
22
+ // log({ sessionId, event, ...fields }) — append one event
23
+ // snapshot({ sessionId?, eventType?, limit? }) — read back filtered tail
24
+ // _resetForTest() — test-only ring clear
25
+ //
26
+ // SQLite audit (Sprint 43 T2):
27
+ // recordFlashback(db, { sessionId, project, error_text, hits_count,
28
+ // top_hit_id, top_hit_score, fired_at? }) → id
29
+ // markDismissed(db, eventId, dismissedAt?) → bool
30
+ // markClickedThrough(db, eventId) → bool
31
+ // getRecentFlashbacks(db, { since?, limit? }) → row[]
32
+ // getFunnelStats(db, { since? }) → { fires, dismissed, clicked_through }
33
+ //
34
+ // Event shape (ring): { ts, sessionId, event, ...event-specific fields }.
13
35
  //
14
36
  // Event types and their producers:
15
37
  // pattern_match — session.js _detectErrors (PATTERNS.error /
@@ -21,9 +43,13 @@
21
43
  // bridge_result — mnestra-bridge queryMnestra at call return
22
44
  // proactive_memory_emit — index.js onErrorDetected WS send block
23
45
  //
24
- // The route GET /api/flashback/diag (registered in index.js) returns
25
- // snapshot() output as JSON for ad-hoc inspection by Joshua and consumption
26
- // by T4's production-flow e2e test.
46
+ // The audit table is an EXTENSION of the ring, not a replacement. Ring stays
47
+ // for the live UI; SQLite is for the historical question "did flashback fire
48
+ // when I needed it, and did I act on it?"
49
+ //
50
+ // SQLite functions are SAFE when db is null/undefined: they no-op and return
51
+ // null/false/[] so test fixtures and Database-unavailable installs don't
52
+ // break the live emit path.
27
53
 
28
54
  const RING_SIZE = 200;
29
55
 
@@ -48,4 +74,152 @@ function _resetForTest() {
48
74
  ring = [];
49
75
  }
50
76
 
51
- module.exports = { log, snapshot, _resetForTest, RING_SIZE };
77
+ // ---- SQLite audit (Sprint 43 T2) ----------------------------------------
78
+
79
+ // Persists one row per actual flashback fire. Returns the inserted row id
80
+ // (number) or null when persistence is unavailable. Errors are caught and
81
+ // logged — flashback persistence must never break the live emit path.
82
+ function recordFlashback(db, event) {
83
+ if (!db) return null;
84
+ if (!event || (!event.sessionId && !event.session_id)) return null;
85
+ try {
86
+ const fired_at = event.fired_at || new Date().toISOString();
87
+ const session_id = event.session_id || event.sessionId;
88
+ const hits_count = Number.isFinite(event.hits_count) ? event.hits_count : 0;
89
+ const top_hit_score = (typeof event.top_hit_score === 'number'
90
+ && Number.isFinite(event.top_hit_score)) ? event.top_hit_score : null;
91
+ const result = db.prepare(`
92
+ INSERT INTO flashback_events
93
+ (fired_at, session_id, project, error_text, hits_count,
94
+ top_hit_id, top_hit_score)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?)
96
+ `).run(
97
+ fired_at,
98
+ session_id,
99
+ event.project || null,
100
+ event.error_text || '',
101
+ hits_count,
102
+ event.top_hit_id || null,
103
+ top_hit_score,
104
+ );
105
+ // better-sqlite3 returns BigInt for lastInsertRowid; coerce to Number
106
+ // so it serializes naturally into JSON and the WS frame.
107
+ return Number(result.lastInsertRowid);
108
+ } catch (err) {
109
+ console.warn('[flashback-diag] recordFlashback INSERT failed:', err.message);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // Marks an event as dismissed (toast went away — by user, by 30s timeout,
115
+ // or implicitly via click-through). Idempotent: only writes when
116
+ // dismissed_at is currently NULL, so the FIRST dismiss wins. Returns true
117
+ // when a row was actually updated.
118
+ function markDismissed(db, eventId, dismissedAt) {
119
+ if (!db || !eventId) return false;
120
+ const id = Number(eventId);
121
+ if (!Number.isFinite(id) || id <= 0) return false;
122
+ try {
123
+ const ts = dismissedAt || new Date().toISOString();
124
+ const result = db.prepare(`
125
+ UPDATE flashback_events
126
+ SET dismissed_at = ?
127
+ WHERE id = ? AND dismissed_at IS NULL
128
+ `).run(ts, id);
129
+ return result.changes > 0;
130
+ } catch (err) {
131
+ console.warn('[flashback-diag] markDismissed UPDATE failed:', err.message);
132
+ return false;
133
+ }
134
+ }
135
+
136
+ // Marks an event as clicked-through (user opened the modal). Click-through
137
+ // is also an implicit dismiss, so if dismissed_at is still NULL we set it
138
+ // at the same moment. Idempotent: clicking twice is a no-op on the second
139
+ // pass. Returns true when a row was actually updated.
140
+ function markClickedThrough(db, eventId) {
141
+ if (!db || !eventId) return false;
142
+ const id = Number(eventId);
143
+ if (!Number.isFinite(id) || id <= 0) return false;
144
+ try {
145
+ const ts = new Date().toISOString();
146
+ const result = db.prepare(`
147
+ UPDATE flashback_events
148
+ SET clicked_through = 1,
149
+ dismissed_at = COALESCE(dismissed_at, ?)
150
+ WHERE id = ? AND clicked_through = 0
151
+ `).run(ts, id);
152
+ return result.changes > 0;
153
+ } catch (err) {
154
+ console.warn('[flashback-diag] markClickedThrough UPDATE failed:', err.message);
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // Reads the most-recent N flashback fires, optionally filtered to events
160
+ // fired at-or-after the `since` ISO timestamp. Hard cap of 500 rows so
161
+ // pathological queries can't OOM the dashboard.
162
+ function getRecentFlashbacks(db, { since, limit } = {}) {
163
+ if (!db) return [];
164
+ try {
165
+ const cap = Math.max(1, Math.min(500, Number(limit) || 100));
166
+ const cols = `id, fired_at, session_id, project, error_text, hits_count,
167
+ top_hit_id, top_hit_score, dismissed_at, clicked_through`;
168
+ if (since) {
169
+ return db.prepare(
170
+ `SELECT ${cols} FROM flashback_events
171
+ WHERE fired_at >= ?
172
+ ORDER BY fired_at DESC
173
+ LIMIT ?`
174
+ ).all(since, cap);
175
+ }
176
+ return db.prepare(
177
+ `SELECT ${cols} FROM flashback_events
178
+ ORDER BY fired_at DESC
179
+ LIMIT ?`
180
+ ).all(cap);
181
+ } catch (err) {
182
+ console.warn('[flashback-diag] getRecentFlashbacks SELECT failed:', err.message);
183
+ return [];
184
+ }
185
+ }
186
+
187
+ // Click-through funnel aggregates: total fires, dismissed (any reason),
188
+ // clicked-through (modal opened). Optional `since` ISO timestamp filter.
189
+ // All three are scalar counts — the dashboard renders them as a percentage
190
+ // funnel chart.
191
+ function getFunnelStats(db, { since } = {}) {
192
+ const empty = { fires: 0, dismissed: 0, clicked_through: 0 };
193
+ if (!db) return empty;
194
+ try {
195
+ const where = since ? `WHERE fired_at >= ?` : '';
196
+ const args = since ? [since] : [];
197
+ const row = db.prepare(
198
+ `SELECT
199
+ COUNT(*) AS fires,
200
+ SUM(CASE WHEN dismissed_at IS NOT NULL THEN 1 ELSE 0 END) AS dismissed,
201
+ SUM(CASE WHEN clicked_through = 1 THEN 1 ELSE 0 END) AS clicked_through
202
+ FROM flashback_events ${where}`
203
+ ).get(...args);
204
+ return {
205
+ fires: Number(row?.fires || 0),
206
+ dismissed: Number(row?.dismissed || 0),
207
+ clicked_through: Number(row?.clicked_through || 0),
208
+ };
209
+ } catch (err) {
210
+ console.warn('[flashback-diag] getFunnelStats SELECT failed:', err.message);
211
+ return empty;
212
+ }
213
+ }
214
+
215
+ module.exports = {
216
+ log,
217
+ snapshot,
218
+ _resetForTest,
219
+ RING_SIZE,
220
+ recordFlashback,
221
+ markDismissed,
222
+ markClickedThrough,
223
+ getRecentFlashbacks,
224
+ getFunnelStats,
225
+ };
@@ -911,10 +911,22 @@ function createServer(config) {
911
911
  return;
912
912
  }
913
913
  if (sess.ws && sess.ws.readyState === 1) {
914
- const frame = JSON.stringify({ type: 'proactive_memory', hit });
914
+ // Sprint 43 T2: persist the fire to flashback_events BEFORE
915
+ // serializing the WS frame so we can include the row id. The
916
+ // client uses flashback_event_id to POST dismiss/click-through
917
+ // updates back to the audit dashboard.
918
+ const flashback_event_id = flashbackDiag.recordFlashback(db, {
919
+ sessionId: sess.id,
920
+ project: sess.meta.project || null,
921
+ error_text: question,
922
+ hits_count: count,
923
+ top_hit_id: hit.id || null,
924
+ top_hit_score: typeof hit.similarity === 'number' ? hit.similarity : null,
925
+ });
926
+ const frame = JSON.stringify({ type: 'proactive_memory', hit, flashback_event_id });
915
927
  try {
916
928
  sess.ws.send(frame);
917
- console.log(`[flashback] proactive_memory sent to session ${sess.id} (source_type=${hit.source_type}, project=${hit.project})`);
929
+ console.log(`[flashback] proactive_memory sent to session ${sess.id} (source_type=${hit.source_type}, project=${hit.project}, event_id=${flashback_event_id})`);
918
930
  flashbackDiag.log({
919
931
  sessionId: sess.id,
920
932
  event: 'proactive_memory_emit',
@@ -922,6 +934,7 @@ function createServer(config) {
922
934
  frame_size_bytes: Buffer.byteLength(frame, 'utf8'),
923
935
  result_count_in_frame: 1,
924
936
  outcome: 'emitted',
937
+ flashback_event_id,
925
938
  });
926
939
  } catch (err) {
927
940
  console.error('[flashback] proactive_memory send failed:', err);
@@ -1442,6 +1455,49 @@ function createServer(config) {
1442
1455
  res.json({ count: events.length, events });
1443
1456
  });
1444
1457
 
1458
+ // GET /api/flashback/history - Sprint 43 T2 durable audit dashboard.
1459
+ // Returns the most-recent flashback fires from SQLite (survives restart)
1460
+ // plus the click-through funnel aggregate. The dashboard uses one fetch
1461
+ // for both so it can render the table and the funnel in lockstep.
1462
+ // Optional filters: ?since=<ISO8601>, ?limit=N (default 100, max 500).
1463
+ app.get('/api/flashback/history', (req, res) => {
1464
+ const rawSince = req.query && req.query.since;
1465
+ const since = (typeof rawSince === 'string' && rawSince.length) ? rawSince : undefined;
1466
+ const rawLimit = req.query && req.query.limit;
1467
+ const limit = rawLimit != null ? parseInt(rawLimit, 10) : undefined;
1468
+ const events = flashbackDiag.getRecentFlashbacks(db, {
1469
+ since,
1470
+ limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
1471
+ });
1472
+ const funnel = flashbackDiag.getFunnelStats(db, { since });
1473
+ res.json({ count: events.length, events, funnel });
1474
+ });
1475
+
1476
+ // POST /api/flashback/:id/dismissed - mark a flashback toast as dismissed.
1477
+ // Called by the client when the user clicks ×, presses Escape, lets the
1478
+ // 30s auto-timer fire, OR clicks "Not relevant" / "Dismiss" in the modal.
1479
+ // Idempotent: subsequent calls are no-ops (first dismiss timestamp wins).
1480
+ app.post('/api/flashback/:id/dismissed', (req, res) => {
1481
+ const id = parseInt(req.params.id, 10);
1482
+ if (!Number.isFinite(id) || id <= 0) {
1483
+ return res.status(400).json({ error: 'Invalid id' });
1484
+ }
1485
+ const updated = flashbackDiag.markDismissed(db, id);
1486
+ res.json({ ok: true, updated });
1487
+ });
1488
+
1489
+ // POST /api/flashback/:id/clicked - mark a flashback toast as clicked-
1490
+ // through (user opened the modal). Click-through is also an implicit
1491
+ // dismiss, so this updates dismissed_at if it's still NULL. Idempotent.
1492
+ app.post('/api/flashback/:id/clicked', (req, res) => {
1493
+ const id = parseInt(req.params.id, 10);
1494
+ if (!Number.isFinite(id) || id <= 0) {
1495
+ return res.status(400).json({ error: 'Invalid id' });
1496
+ }
1497
+ const updated = flashbackDiag.markClickedThrough(db, id);
1498
+ res.json({ ok: true, updated });
1499
+ });
1500
+
1445
1501
  // GET /api/pty-reaper/status — Sprint 42 T2 observability surface.
1446
1502
  // Returns the live registry (per-session PTY pid + tracked descendants) and
1447
1503
  // the reaped-history ring buffer so heavy-use installs can tell whether the