@jhizzard/termdeck 0.10.4 → 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.
@@ -389,6 +389,31 @@
389
389
  flex: 1;
390
390
  }
391
391
 
392
+ /* Sprint 42 T4 — drag handle for PTY panel reordering. Only the handle
393
+ is grabbable; the rest of the panel header / xterm body are unaffected. */
394
+ .panel-drag-handle {
395
+ cursor: grab;
396
+ color: var(--tg-text-dim);
397
+ font-size: 12px;
398
+ letter-spacing: -2px;
399
+ user-select: none;
400
+ flex-shrink: 0;
401
+ padding: 0 2px;
402
+ opacity: 0.5;
403
+ transition: opacity 0.15s;
404
+ }
405
+ .panel-drag-handle:hover { opacity: 1; color: var(--tg-accent); }
406
+ .panel-drag-handle:active { cursor: grabbing; }
407
+ .term-panel.dragging {
408
+ opacity: 0.5;
409
+ outline: 2px dashed var(--tg-accent);
410
+ outline-offset: -2px;
411
+ }
412
+ .term-panel.drag-over {
413
+ outline: 2px solid var(--tg-accent);
414
+ outline-offset: -2px;
415
+ }
416
+
392
417
  .status-dot {
393
418
  width: 8px; height: 8px;
394
419
  border-radius: 50%;
@@ -768,6 +793,156 @@
768
793
  filter: none;
769
794
  }
770
795
 
796
+ /* ===== REMOVE PROJECT BUTTON + MODAL (Sprint 42 T4) ===== */
797
+ .prompt-remove-project {
798
+ background: var(--tg-bg);
799
+ border: 1px solid var(--tg-border);
800
+ color: var(--tg-text-dim);
801
+ font-size: 18px;
802
+ line-height: 1;
803
+ padding: 0;
804
+ width: 26px;
805
+ height: 26px;
806
+ border-radius: var(--tg-radius-sm);
807
+ cursor: pointer;
808
+ font-family: var(--tg-sans);
809
+ transition: all 0.15s;
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ margin-left: 4px;
814
+ }
815
+ .prompt-remove-project:hover {
816
+ border-color: var(--tg-red, #f7768e);
817
+ color: var(--tg-red, #f7768e);
818
+ }
819
+ .remove-project-modal {
820
+ display: none;
821
+ position: fixed;
822
+ inset: 0;
823
+ z-index: 3000;
824
+ align-items: center;
825
+ justify-content: center;
826
+ }
827
+ .remove-project-modal.open { display: flex; }
828
+ .remove-project-backdrop {
829
+ position: absolute;
830
+ inset: 0;
831
+ background: rgba(0, 0, 0, 0.72);
832
+ }
833
+ .remove-project-card {
834
+ position: relative;
835
+ background: var(--tg-surface);
836
+ border: 1px solid var(--tg-accent-dim);
837
+ border-radius: 10px;
838
+ padding: 22px 24px 18px;
839
+ width: 460px;
840
+ max-width: calc(100vw - 40px);
841
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
842
+ font-family: var(--tg-sans);
843
+ color: var(--tg-text);
844
+ }
845
+ .remove-project-card h3 {
846
+ margin: 0 0 4px;
847
+ font-size: 16px;
848
+ color: var(--tg-accent);
849
+ }
850
+ .remove-project-card .rpm-help {
851
+ margin: 0 0 14px;
852
+ font-size: 12px;
853
+ color: var(--tg-text-dim);
854
+ line-height: 1.5;
855
+ }
856
+ .remove-project-card .rpm-help code {
857
+ background: var(--tg-bg);
858
+ padding: 1px 5px;
859
+ border-radius: 3px;
860
+ font-family: var(--tg-mono);
861
+ font-size: 11px;
862
+ }
863
+ .remove-project-card .rpm-help strong {
864
+ color: var(--tg-green);
865
+ }
866
+ .remove-project-card label {
867
+ display: block;
868
+ margin-bottom: 10px;
869
+ }
870
+ .remove-project-card label > span {
871
+ display: block;
872
+ font-size: 11px;
873
+ color: var(--tg-text-dim);
874
+ margin-bottom: 3px;
875
+ text-transform: uppercase;
876
+ letter-spacing: 0.5px;
877
+ }
878
+ .remove-project-card select {
879
+ width: 100%;
880
+ background: var(--tg-bg);
881
+ border: 1px solid var(--tg-border);
882
+ color: var(--tg-text);
883
+ font-size: 13px;
884
+ padding: 7px 10px;
885
+ border-radius: var(--tg-radius-sm);
886
+ font-family: var(--tg-mono);
887
+ box-sizing: border-box;
888
+ }
889
+ .remove-project-card select:focus {
890
+ outline: none;
891
+ border-color: var(--tg-accent-dim);
892
+ }
893
+ .remove-project-card .rpm-warning {
894
+ background: rgba(247, 118, 142, 0.08);
895
+ border: 1px solid rgba(247, 118, 142, 0.4);
896
+ color: var(--tg-text);
897
+ font-size: 12px;
898
+ padding: 8px 10px;
899
+ border-radius: 4px;
900
+ margin: 6px 0 8px;
901
+ line-height: 1.5;
902
+ }
903
+ .remove-project-card .rpm-status {
904
+ font-size: 12px;
905
+ min-height: 16px;
906
+ margin: 4px 0 8px;
907
+ color: var(--tg-text-dim);
908
+ }
909
+ .remove-project-card .rpm-status.error { color: var(--tg-red); }
910
+ .remove-project-card .rpm-status.ok { color: var(--tg-green); }
911
+ .remove-project-card .rpm-actions {
912
+ display: flex;
913
+ justify-content: flex-end;
914
+ gap: 8px;
915
+ margin-top: 6px;
916
+ }
917
+ .remove-project-card button {
918
+ font-size: 12px;
919
+ font-weight: 600;
920
+ padding: 6px 16px;
921
+ border-radius: 4px;
922
+ cursor: pointer;
923
+ font-family: var(--tg-sans);
924
+ border: 1px solid var(--tg-border);
925
+ }
926
+ .remove-project-card .rpm-cancel {
927
+ background: transparent;
928
+ color: var(--tg-text-dim);
929
+ }
930
+ .remove-project-card .rpm-cancel:hover {
931
+ color: var(--tg-text);
932
+ border-color: var(--tg-border-active);
933
+ }
934
+ .remove-project-card .rpm-confirm {
935
+ background: var(--tg-red, #f7768e);
936
+ color: var(--tg-bg);
937
+ border-color: var(--tg-red, #f7768e);
938
+ }
939
+ .remove-project-card .rpm-confirm:hover { filter: brightness(1.1); }
940
+ .remove-project-card .rpm-confirm:disabled {
941
+ opacity: 0.5;
942
+ cursor: not-allowed;
943
+ filter: none;
944
+ }
945
+
771
946
  /* ===== Orchestration preview button + modal (Sprint 37 T3) ===== */
772
947
  .prompt-preview-project {
773
948
  background: transparent;
@@ -3392,6 +3567,61 @@
3392
3567
  background: rgba(255, 255, 255, 0.08);
3393
3568
  }
3394
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
+
3395
3625
  .graph-stage {
3396
3626
  position: relative;
3397
3627
  flex: 1;
@@ -298,6 +298,54 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
298
298
  return parsed.projects;
299
299
  }
300
300
 
301
+ // Remove a project from ~/.termdeck/config.yaml and return the updated projects
302
+ // map. Mirrors addProject for the inverse operation. Throws ENOENT-shaped
303
+ // errors with `code` set so callers can map cleanly to HTTP status. Files on
304
+ // disk at the project's `path` are NEVER touched — this only edits the YAML
305
+ // entry. The user retains all source code.
306
+ function removeProject(name, configPath = CONFIG_PATH) {
307
+ if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
308
+ const err = new Error('Project name must be non-empty and contain only letters, digits, . _ or -');
309
+ err.code = 'BAD_NAME';
310
+ throw err;
311
+ }
312
+
313
+ const yaml = require('yaml');
314
+ let parsed = {};
315
+ if (fs.existsSync(configPath)) {
316
+ const raw = fs.readFileSync(configPath, 'utf-8');
317
+ try {
318
+ parsed = yaml.parse(raw) || {};
319
+ } catch (err) {
320
+ throw new Error(`config.yaml is not valid YAML — cannot safely rewrite: ${err.message}`);
321
+ }
322
+ }
323
+
324
+ if (!parsed.projects || typeof parsed.projects !== 'object' || !parsed.projects[name]) {
325
+ const err = new Error(`Project "${name}" not found in config.yaml`);
326
+ err.code = 'NOT_FOUND';
327
+ throw err;
328
+ }
329
+
330
+ delete parsed.projects[name];
331
+
332
+ if (fs.existsSync(configPath)) {
333
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
334
+ const bak = `${configPath}.${ts}.bak`;
335
+ try {
336
+ fs.copyFileSync(configPath, bak);
337
+ } catch (err) {
338
+ console.warn('[config] Could not write backup before removing project:', err.message);
339
+ }
340
+ }
341
+
342
+ const out = yaml.stringify(parsed);
343
+ fs.writeFileSync(configPath, out, 'utf-8');
344
+ console.log(`[config] Removed project "${name}" (files on disk untouched)`);
345
+
346
+ return parsed.projects;
347
+ }
348
+
301
349
  // Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
302
350
  // this for the dashboard RAG toggle (PATCH /api/config) but the helper is
303
351
  // generic — pass a deep partial of the config tree, every leaf in `patch` that
@@ -394,6 +442,7 @@ function updateConfig(patch, configPath = CONFIG_PATH) {
394
442
  module.exports = {
395
443
  loadConfig,
396
444
  addProject,
445
+ removeProject,
397
446
  updateConfig,
398
447
  // exported for tests / introspection
399
448
  _parseDotenv: parseDotenv,
@@ -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
+ };