@jhizzard/termdeck 1.0.11 → 1.0.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -2825,6 +2825,63 @@
2825
2825
  }
2826
2826
  }
2827
2827
 
2828
+ // Sprint 57 T2 — post-resize layout-health assertion + forced reflow.
2829
+ // Sprint 55 T2 saw rapid Playwright resize chains crush #termGrid into
2830
+ // the corner with no manual recovery. Codex T4-SWEEP-CELLS audit was
2831
+ // explicit: the right shape is a health check + forced reflow at the
2832
+ // tail of the existing debounced fitAll(), not a second window-resize
2833
+ // listener. Reentrancy guarded so a degenerate state can't loop.
2834
+ function verifyLayoutHealth() {
2835
+ const grid = document.getElementById('termGrid');
2836
+ if (!grid) return;
2837
+ if (verifyLayoutHealth._inFlight) return;
2838
+ const rect = grid.getBoundingClientRect();
2839
+ // The grid spans the viewport horizontally (topbar is above it; the
2840
+ // guide-rail is fixed-position overlay reserved by 38px right padding,
2841
+ // not a flex sibling). A healthy grid's getBoundingClientRect().width
2842
+ // tracks window.innerWidth modulo body margins. Flag if it shrinks
2843
+ // below 90% of the usable viewport (briefed threshold; T4-CODEX
2844
+ // 14:12 ET audit confirms 90% is the spec, not the looser 85%).
2845
+ const viewportW = window.innerWidth || document.documentElement.clientWidth || 0;
2846
+ const gridUnderwidth = viewportW > 0 && rect.width < viewportW * 0.90;
2847
+ // Each visible terminal panel must have positive width AND height.
2848
+ // Skip panels intentionally hidden by layout (control mode CSS-hides
2849
+ // .term-panel via `display:none`; layout-focus hides non-focused).
2850
+ let panelDegenerate = false;
2851
+ let panelDegenerateId = null;
2852
+ for (const [sid, entry] of state.sessions) {
2853
+ if (!entry || !entry.el) continue;
2854
+ const style = window.getComputedStyle(entry.el);
2855
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
2856
+ const r = entry.el.getBoundingClientRect();
2857
+ if (r.width <= 0 || r.height <= 0) {
2858
+ panelDegenerate = true;
2859
+ panelDegenerateId = sid;
2860
+ break;
2861
+ }
2862
+ }
2863
+ if (!gridUnderwidth && !panelDegenerate) return;
2864
+ verifyLayoutHealth._inFlight = true;
2865
+ console.warn(
2866
+ '[client] layout health check failed (gridUnderwidth=' + gridUnderwidth
2867
+ + ', panelDegenerate=' + panelDegenerate
2868
+ + (panelDegenerateId ? ', sid=' + panelDegenerateId : '')
2869
+ + ') — forcing recovery'
2870
+ );
2871
+ // Recovery: detach + reapply the current layout class to force the
2872
+ // CSS Grid templates to recompute, then refit all panels. Two RAFs so
2873
+ // the browser commits the className=''→className=cls round-trip.
2874
+ requestAnimationFrame(() => {
2875
+ const cls = grid.className;
2876
+ grid.className = '';
2877
+ void grid.offsetHeight; // force synchronous reflow
2878
+ grid.className = cls;
2879
+ requestAnimationFrame(() => {
2880
+ try { fitAll(); } finally { verifyLayoutHealth._inFlight = false; }
2881
+ });
2882
+ });
2883
+ }
2884
+
2828
2885
  // Debounce: collapse a burst of calls (e.g. a window-resize drag firing
2829
2886
  // dozens of events/sec) into a single invocation after `wait` ms of quiet.
2830
2887
  function debounce(fn, wait) {
@@ -2836,7 +2893,13 @@
2836
2893
  }
2837
2894
 
2838
2895
  const fitAllDebounced = debounce(() => {
2839
- requestAnimationFrame(() => fitAll());
2896
+ requestAnimationFrame(() => {
2897
+ fitAll();
2898
+ // Sprint 57 T2 — post-fit layout-health probe (~250 ms after fit so
2899
+ // the browser has committed the resize). Extends the existing window
2900
+ // resize listener (no second listener added).
2901
+ setTimeout(verifyLayoutHealth, 250);
2902
+ });
2840
2903
  }, 100);
2841
2904
 
2842
2905
  // ===== ONBOARDING TOUR =====
@@ -3444,18 +3507,29 @@
3444
3507
  // Topbar RAG indicator. The #stat-rag stub in index.html was hidden by
3445
3508
  // Sprint 9 T2; re-purpose it as a live state line so users can see, at a
3446
3509
  // glance, what the toggle is doing without opening Settings each time.
3510
+ //
3511
+ // Sprint 57 T2 (F-T2-2 + F-T2-6) — consumes the server-derived `ragMode`
3512
+ // enum directly instead of re-deriving from `ragEnabled` + `ragConfigEnabled`
3513
+ // booleans. The single source of truth lives in `packages/server/src/rag-mode.js`.
3514
+ // Falls back to legacy boolean derivation for older servers (pre-Sprint-57)
3515
+ // during a rolling upgrade.
3447
3516
  function updateRagIndicator() {
3448
3517
  const el = document.getElementById('stat-rag');
3449
3518
  if (!el) return;
3450
3519
  const cfg = state.config || {};
3451
- const intent = !!cfg.ragConfigEnabled;
3452
- const effective = !!cfg.ragEnabled;
3520
+ let mode = cfg.ragMode;
3521
+ if (!mode) {
3522
+ // Pre-Sprint-57 server fallback — replicate the legacy derivation.
3523
+ const intent = !!cfg.ragConfigEnabled;
3524
+ const effective = !!cfg.ragEnabled;
3525
+ mode = effective ? 'active' : (intent ? 'pending' : 'off');
3526
+ }
3453
3527
  el.style.display = '';
3454
- if (effective) {
3528
+ if (mode === 'active') {
3455
3529
  el.textContent = 'RAG · on';
3456
3530
  el.className = 'topbar-stat rag-on';
3457
3531
  el.title = 'Mnestra hybrid search + termdeck flashback enabled';
3458
- } else if (intent) {
3532
+ } else if (mode === 'pending') {
3459
3533
  el.textContent = 'RAG · pending';
3460
3534
  el.className = 'topbar-stat rag-pending';
3461
3535
  el.title = 'RAG enabled in config.yaml but Supabase not wired — see Settings';
@@ -274,11 +274,29 @@
274
274
  try {
275
275
  let data;
276
276
  if (state.mode === 'project' && state.project === '__all__') {
277
+ // Sprint 57 T2 (F-T2-4) — /api/graph/all is now paginated by default
278
+ // (200 rows/page). Per ORCH GREEN-LIGHT 2026-05-05 14:21 ET, the
279
+ // dashboard renders the first 200-node page intentionally rather
280
+ // than accumulating across pages. Trade-offs:
281
+ // - Avoids the 1.2 MB / 862 ms single-shot payload (Sprint 55
282
+ // measurement that motivated F-T2-4).
283
+ // - Keeps edge fidelity simple: the server's both-endpoints-in-page
284
+ // edge query still gives a coherent intra-page subgraph; no
285
+ // cross-page-edges concern, no accumulator de-dup.
286
+ // - User narrows by project to see specific clusters (same UX
287
+ // guidance as the pre-Sprint-57 truncation toast).
288
+ // If a future sprint wants "load more" pagination, the client can
289
+ // loop via `data.nextCursor` and the server's edge query will need
290
+ // to widen to source_id OR target_id IN page (touch-page) so cross-
291
+ // page edges are recoverable.
277
292
  data = await api('/api/graph/all');
278
293
  if (data.enabled === false) return showDisabled(data);
279
294
  state.rawNodes = data.nodes || [];
280
295
  state.rawEdges = data.edges || [];
281
- if (data.truncated) {
296
+ // Truncation message: trigger when totalAvailable > what we
297
+ // rendered (i.e., this page is partial), regardless of whether
298
+ // the corpus is also above the historical 2000-node cap.
299
+ if (data.totalAvailable && data.totalAvailable > state.rawNodes.length) {
282
300
  showToast(
283
301
  `Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
284
302
  );
@@ -47,7 +47,34 @@ const IDLE = /^>\s*$/m;
47
47
  // sessions whose tool output (grep results, test logs, file dumps) routinely
48
48
  // mentions "Error" mid-line without representing an actual failure.
49
49
  // Sprint 40 T2 added mixed-case `Fatal` + the special-cased `npm ERR!` shape.
50
- const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|Fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied)\b|npm ERR!)/m;
50
+ //
51
+ // Sprint 57 T1 (F-T2-3): tightened from `\b`-after-keyword to require a
52
+ // trailing colon + content for prose-shape keywords. The pre-Sprint-57
53
+ // pattern matched line-start prose like "Error handling docs" and
54
+ // "Error handling pattern" (`Error` + space, `\b` satisfied) — Sprint 55
55
+ // T2 + T4 logged 196 fires / 22 dismissed (11%) on the daily-driver
56
+ // project, mostly from boot-prompt content + markdown headings tripping
57
+ // the pattern. The new shape mirrors the proven `PATTERNS.error` rule in
58
+ // session.js (already locked by `tests/analyzer-error-fixtures.test.js`):
59
+ // real errors say `Error: <msg>`, not bare `Error`.
60
+ //
61
+ // Kept as structural shapes (no colon required — the structure itself
62
+ // disambiguates):
63
+ // • `Traceback (most recent call last):`
64
+ // • `npm ERR!`
65
+ // • `error[Ennn]:` (Rust borrow-checker / clippy errors)
66
+ // • `failed with exit code <digit>` (CI failure marker; trailing
67
+ // digit eliminates the "the build failed with exit code N last
68
+ // time" prose false positive)
69
+ //
70
+ // Removed entirely (caught by `PATTERNS.shellError` as the secondary
71
+ // fallback in `_detectErrors` when they appear in real
72
+ // `<cmd>: <path>: <phrase>` shape):
73
+ // • `command not found`, `undefined reference`, `cannot find module`
74
+ // • `No such file or directory`, `Permission denied`
75
+ // • `segmentation fault` (also covered by `\s*Segmentation fault\b`
76
+ // in shellError)
77
+ const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|fatal|Fatal|FATAL|EACCES|ECONNREFUSED|ENOENT|panic):\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|failed with exit code\s+\d+\b)/m;
51
78
 
52
79
  // ──────────────────────────────────────────────────────────────────────────
53
80
  // statusFor — replaces the `case 'claude-code':` block of _updateStatus.
@@ -156,6 +156,83 @@ function markClickedThrough(db, eventId) {
156
156
  }
157
157
  }
158
158
 
159
+ // Sprint 57 T1 (#4): negative-feedback persistence read-side. Returns true
160
+ // when ANY prior flashback_events row for this memory_id (`top_hit_id`)
161
+ // has `dismissed_at` set — meaning the user previously dismissed (or
162
+ // click-through-then-implicitly-dismissed) a flashback featuring this
163
+ // same memory. The proactive emit path uses this to skip already-dismissed
164
+ // memories before sending the next `proactive_memory` frame, so a
165
+ // low-confidence hit the user marked "Not relevant" stops resurfacing.
166
+ //
167
+ // Scope is global (no `session_id` filter) — user intent is "this memory
168
+ // isn't useful," not "this memory isn't useful in THIS session." Matches
169
+ // the Sprint 55 T4-Codex audit-addendum brief shape
170
+ // (`WHERE NOT EXISTS (... memory_id = X AND dismissed_at IS NOT NULL)`),
171
+ // adapted to the actual schema column name `top_hit_id`.
172
+ //
173
+ // SAFE when db is null (returns false → caller falls back to default emit
174
+ // path, identical to pre-Sprint-57 behavior) and when memoryId is empty
175
+ // or non-string (returns false). Errors are caught and logged — must
176
+ // never break the live emit path.
177
+ function isMemoryDismissed(db, memoryId) {
178
+ if (!db || !memoryId || typeof memoryId !== 'string') return false;
179
+ try {
180
+ const row = db.prepare(`
181
+ SELECT 1 AS hit FROM flashback_events
182
+ WHERE top_hit_id = ? AND dismissed_at IS NOT NULL
183
+ LIMIT 1
184
+ `).get(memoryId);
185
+ return Boolean(row);
186
+ } catch (err) {
187
+ console.warn('[flashback-diag] isMemoryDismissed SELECT failed:', err.message);
188
+ return false;
189
+ }
190
+ }
191
+
192
+ // Sprint 57 T1 (#4): pure selection helper used by the proactive-emit path
193
+ // in `packages/server/src/index.js`. Walks the score-ordered Mnestra
194
+ // candidate list and returns:
195
+ // { hit, dismissedCount, scannedCount }
196
+ // where:
197
+ // • hit — the first candidate whose `candidate.id` is not in
198
+ // the dismissed set (or null when every candidate was
199
+ // dismissed / the list is empty)
200
+ // • dismissedCount — how many candidates were skipped because their
201
+ // memory id is in the dismissed set
202
+ // • scannedCount — how many candidates were inspected before stopping
203
+ // (= dismissedCount + 1 when a hit was returned;
204
+ // = list length when nothing matched)
205
+ //
206
+ // Extracting this from `index.js` lets the integration path (see Sprint 57
207
+ // T4-CODEX 14:24 ET audit) be tested directly without spawning a real PTY
208
+ // + WS + Mnestra bridge. The pre-extraction inline version was unreachable
209
+ // from a unit test.
210
+ //
211
+ // SAFE on null db (returns the first candidate without filtering — same
212
+ // semantics as pre-Sprint-57 `memories[0]`) so non-DB installs still get
213
+ // the legacy default-pick behavior. Empty / non-array memories returns
214
+ // the empty-skip shape `{ hit: null, dismissedCount: 0, scannedCount: 0 }`.
215
+ function pickNextNonDismissed(db, memories) {
216
+ const list = Array.isArray(memories) ? memories : [];
217
+ if (list.length === 0) {
218
+ return { hit: null, dismissedCount: 0, scannedCount: 0 };
219
+ }
220
+ if (!db) {
221
+ return { hit: list[0] || null, dismissedCount: 0, scannedCount: 1 };
222
+ }
223
+ let dismissedCount = 0;
224
+ let scannedCount = 0;
225
+ for (const candidate of list) {
226
+ scannedCount += 1;
227
+ if (candidate && candidate.id && isMemoryDismissed(db, candidate.id)) {
228
+ dismissedCount += 1;
229
+ continue;
230
+ }
231
+ return { hit: candidate || null, dismissedCount, scannedCount };
232
+ }
233
+ return { hit: null, dismissedCount, scannedCount };
234
+ }
235
+
159
236
  // Reads the most-recent N flashback fires, optionally filtered to events
160
237
  // fired at-or-after the `since` ISO timestamp. Hard cap of 500 rows so
161
238
  // pathological queries can't OOM the dashboard.
@@ -220,6 +297,8 @@ module.exports = {
220
297
  recordFlashback,
221
298
  markDismissed,
222
299
  markClickedThrough,
300
+ isMemoryDismissed,
301
+ pickNextNonDismissed,
223
302
  getRecentFlashbacks,
224
303
  getFunnelStats,
225
304
  };
@@ -114,6 +114,36 @@ function disabledPayload(extra = {}) {
114
114
  return Object.assign({ enabled: false, reason: 'DATABASE_URL not configured' }, extra);
115
115
  }
116
116
 
117
+ // Sprint 57 T2 — opaque cursor for /api/graph/all pagination (F-T2-4).
118
+ // The Sprint 55 sweep measured 1.2 MB / 862 ms for the single-shot all-projects
119
+ // graph response. Cursor pagination lets callers fetch progressive pages while
120
+ // preserving backward-compat: a no-cursor / no-limit request still returns the
121
+ // historical single-shot ceiling (MAX_NODES_GLOBAL nodes).
122
+ //
123
+ // Cursor encodes the (created_at, id) row-key of the last item returned so the
124
+ // next page resumes after it. Composite key disambiguates rows that share a
125
+ // created_at timestamp.
126
+ function encodeCursor(createdAt, id) {
127
+ if (!createdAt || !id) return null;
128
+ const iso = createdAt instanceof Date ? createdAt.toISOString() : String(createdAt);
129
+ return Buffer.from(JSON.stringify({ createdAt: iso, id })).toString('base64');
130
+ }
131
+
132
+ function decodeCursor(raw) {
133
+ if (raw == null || raw === '') return null;
134
+ try {
135
+ const json = Buffer.from(String(raw), 'base64').toString('utf8');
136
+ const obj = JSON.parse(json);
137
+ if (!obj || typeof obj.createdAt !== 'string' || typeof obj.id !== 'string') return null;
138
+ if (!UUID_RE.test(obj.id)) return null;
139
+ const t = new Date(obj.createdAt);
140
+ if (Number.isNaN(t.getTime())) return null;
141
+ return { createdAt: obj.createdAt, id: obj.id };
142
+ } catch (_e) {
143
+ return null;
144
+ }
145
+ }
146
+
117
147
  async function fetchProjectGraph(pool, projectName) {
118
148
  // One round-trip:
119
149
  // 1. nodes for the project (with degree computed via subquery so we don't
@@ -162,12 +192,30 @@ async function fetchProjectGraph(pool, projectName) {
162
192
  return { nodes, edges };
163
193
  }
164
194
 
165
- async function fetchAllGraph(pool) {
195
+ async function fetchAllGraph(pool, opts = {}) {
166
196
  // Sprint 41 T3 — backs the "All projects" picker option in /graph.html.
167
- // Returns the most-recent MAX_NODES_GLOBAL active+non-archived memories plus
168
- // every edge whose endpoints both land in the result set. `totalAvailable`
197
+ // Returns active+non-archived memories ordered by `(created_at DESC, id DESC)`
198
+ // plus every edge whose endpoints both land in the result set. `totalAvailable`
169
199
  // and `truncated` let the client surface a toast when the corpus overflows
170
200
  // the cap.
201
+ //
202
+ // Sprint 57 T2 (F-T2-4) — cursor pagination is now the DEFAULT path, not
203
+ // opt-in. Default page is 200 rows (matches the Sprint 55 measurement that
204
+ // 1.2 MB / 862 ms single-shot was the user-visible bug). Callers may pass
205
+ // `opts.limit` to override (capped at MAX_NODES_GLOBAL=2000). When
206
+ // `opts.cursor` is present, returns rows strictly past that (created_at,
207
+ // id) row-key. The returned `nextCursor` is non-null when more rows exist;
208
+ // clients loop until `nextCursor === null`. ORCH CLARIFICATION 14:21 ET:
209
+ // the default-payload bug doesn't close until pagination applies by
210
+ // default, so opt-in-only is not enough.
211
+ const DEFAULT_PAGE = 200;
212
+ const cursor = opts.cursor || null;
213
+ let limit = opts.limit;
214
+ if (limit == null || !Number.isFinite(Number(limit))) {
215
+ limit = DEFAULT_PAGE;
216
+ }
217
+ limit = Math.max(1, Math.min(MAX_NODES_GLOBAL, Math.floor(Number(limit))));
218
+
171
219
  const totalSql = `
172
220
  SELECT COUNT(*)::int AS c
173
221
  FROM memory_items
@@ -176,27 +224,62 @@ async function fetchAllGraph(pool) {
176
224
  const totalRes = await pool.query(totalSql);
177
225
  const totalAvailable = Number(totalRes.rows[0]?.c || 0);
178
226
 
179
- const nodesSql = `
180
- WITH all_nodes AS (
181
- SELECT ${NODE_COLUMNS_SQL}
182
- FROM memory_items
183
- WHERE is_active = TRUE AND archived = FALSE
184
- ORDER BY created_at DESC
185
- LIMIT ${MAX_NODES_GLOBAL}
186
- )
187
- SELECT
188
- n.*,
189
- COALESCE((
190
- SELECT COUNT(*)::int
191
- FROM memory_relationships r
192
- WHERE r.source_id = n.id OR r.target_id = n.id
193
- ), 0) AS degree
194
- FROM all_nodes n
195
- `;
196
- const nodesRes = await pool.query(nodesSql);
197
- const nodes = nodesRes.rows.map(rowToNode);
227
+ // Fetch limit+1 rows so we know whether a next page exists without an extra
228
+ // count query. The +1 row is dropped before mapping.
229
+ let nodesSql;
230
+ let nodesParams;
231
+ if (cursor) {
232
+ nodesSql = `
233
+ WITH all_nodes AS (
234
+ SELECT ${NODE_COLUMNS_SQL}
235
+ FROM memory_items
236
+ WHERE is_active = TRUE AND archived = FALSE
237
+ AND (created_at, id) < ($1::timestamptz, $2::uuid)
238
+ ORDER BY created_at DESC, id DESC
239
+ LIMIT ${limit + 1}
240
+ )
241
+ SELECT
242
+ n.*,
243
+ COALESCE((
244
+ SELECT COUNT(*)::int
245
+ FROM memory_relationships r
246
+ WHERE r.source_id = n.id OR r.target_id = n.id
247
+ ), 0) AS degree
248
+ FROM all_nodes n
249
+ `;
250
+ nodesParams = [cursor.createdAt, cursor.id];
251
+ } else {
252
+ nodesSql = `
253
+ WITH all_nodes AS (
254
+ SELECT ${NODE_COLUMNS_SQL}
255
+ FROM memory_items
256
+ WHERE is_active = TRUE AND archived = FALSE
257
+ ORDER BY created_at DESC, id DESC
258
+ LIMIT ${limit + 1}
259
+ )
260
+ SELECT
261
+ n.*,
262
+ COALESCE((
263
+ SELECT COUNT(*)::int
264
+ FROM memory_relationships r
265
+ WHERE r.source_id = n.id OR r.target_id = n.id
266
+ ), 0) AS degree
267
+ FROM all_nodes n
268
+ `;
269
+ nodesParams = [];
270
+ }
271
+ const nodesRes = await pool.query(nodesSql, nodesParams);
272
+ let rows = nodesRes.rows;
273
+ const hasMore = rows.length > limit;
274
+ if (hasMore) rows = rows.slice(0, limit);
275
+ const nodes = rows.map(rowToNode);
276
+ let nextCursor = null;
277
+ if (hasMore && rows.length > 0) {
278
+ const last = rows[rows.length - 1];
279
+ nextCursor = encodeCursor(last.created_at, last.id);
280
+ }
198
281
  if (nodes.length === 0) {
199
- return { nodes: [], edges: [], totalAvailable, truncated: false };
282
+ return { nodes: [], edges: [], totalAvailable, truncated: false, nextCursor: null };
200
283
  }
201
284
  const ids = nodes.map((n) => n.id);
202
285
 
@@ -208,11 +291,18 @@ async function fetchAllGraph(pool) {
208
291
  `;
209
292
  const edgesRes = await pool.query(edgesSql, [ids]);
210
293
  const edges = edgesRes.rows.map(rowToEdge);
294
+ // `truncated` preserves the pre-Sprint-57 semantic for the no-cursor case
295
+ // ("the corpus has more rows than this single-shot response can carry").
296
+ // For paginated callers, `nextCursor !== null` is the more-pages signal;
297
+ // `truncated` always returns false on cursor pages so a single-shot client
298
+ // doesn't mistakenly read intermediate page boundaries as global truncation.
299
+ const truncated = !cursor && totalAvailable > MAX_NODES_GLOBAL;
211
300
  return {
212
301
  nodes,
213
302
  edges,
214
303
  totalAvailable,
215
- truncated: totalAvailable > MAX_NODES_GLOBAL,
304
+ truncated,
305
+ nextCursor,
216
306
  };
217
307
  }
218
308
 
@@ -537,10 +627,34 @@ function createGraphRoutes({ app, getPool }) {
537
627
  edges: [],
538
628
  totalAvailable: 0,
539
629
  truncated: false,
630
+ nextCursor: null,
540
631
  }));
541
632
  }
633
+ // Sprint 57 T2 — cursor pagination IS the default path (F-T2-4).
634
+ // No-cursor / no-limit requests get the first 200-row page; clients
635
+ // loop via `nextCursor` to fetch additional pages. ORCH CLARIFICATION
636
+ // 14:21 ET: pagination must apply by default, not opt-in, so the
637
+ // 1.2 MB / 862 ms single-shot payload measured in Sprint 55 is the
638
+ // bug we're closing. A malformed `cursor` returns 400 rather than
639
+ // silently restarting from page 1, so pagination loops can't
640
+ // accidentally infinite-loop on garbled tokens.
641
+ let cursor = null;
642
+ if (req.query.cursor != null && req.query.cursor !== '') {
643
+ cursor = decodeCursor(req.query.cursor);
644
+ if (!cursor) {
645
+ return res.status(400).json({ error: 'invalid cursor' });
646
+ }
647
+ }
648
+ let limit = null;
649
+ if (req.query.limit != null && req.query.limit !== '') {
650
+ const n = Number(req.query.limit);
651
+ if (!Number.isFinite(n) || n < 1) {
652
+ return res.status(400).json({ error: 'invalid limit' });
653
+ }
654
+ limit = n;
655
+ }
542
656
  try {
543
- const { nodes, edges, totalAvailable, truncated } = await fetchAllGraph(pool);
657
+ const { nodes, edges, totalAvailable, truncated, nextCursor } = await fetchAllGraph(pool, { cursor, limit });
544
658
  const byType = {};
545
659
  for (const e of edges) {
546
660
  byType[e.kind] = (byType[e.kind] || 0) + 1;
@@ -558,6 +672,7 @@ function createGraphRoutes({ app, getPool }) {
558
672
  edges,
559
673
  totalAvailable,
560
674
  truncated,
675
+ nextCursor,
561
676
  });
562
677
  } catch (err) {
563
678
  console.warn('[graph] /api/graph/all failed:', err.message);
@@ -646,6 +761,8 @@ module.exports = {
646
761
  rowToEdge,
647
762
  rowToFullMemory,
648
763
  snippet,
764
+ encodeCursor,
765
+ decodeCursor,
649
766
  UUID_RE,
650
767
  PROJECT_RE,
651
768
  MAX_NODES_PER_PROJECT,
@@ -81,6 +81,7 @@ const { createProjectsRoutes } = require('./projects-routes');
81
81
  const orchestrationPreview = require('./orchestration-preview');
82
82
  const { createPtyReaper } = require('./pty-reaper');
83
83
  const { AGENT_ADAPTERS } = require('./agent-adapters');
84
+ const { deriveRagMode } = require('./rag-mode');
84
85
 
85
86
  // Sprint 48 T4 deliverable 2: PTY env-var propagation.
86
87
  // Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
@@ -1082,17 +1083,32 @@ function createServer(config) {
1082
1083
  const memories = (result && result.memories) || [];
1083
1084
  const count = memories.length;
1084
1085
  console.log(`[flashback] query returned ${count} matches for session ${sess.id}`);
1085
- const hit = memories[0];
1086
+ // Sprint 57 T1 (#4): negative-feedback persistence. Skip any
1087
+ // memory the user previously dismissed (across all sessions);
1088
+ // iterate candidates in score order, first non-dismissed wins.
1089
+ // Without this, a low-confidence match the user marked
1090
+ // "Not relevant" would resurface on the next error fire —
1091
+ // exactly the resurfacing-after-dismiss bug Sprint 55 T2 + T4
1092
+ // diagnosed (T4 audit addendum: index.js:1058-1100 emits
1093
+ // memories[0] without consulting dismissed history). Selection
1094
+ // logic lives in `flashbackDiag.pickNextNonDismissed` so the
1095
+ // integration shape stays testable without a live PTY.
1096
+ const { hit, dismissedCount } =
1097
+ flashbackDiag.pickNextNonDismissed(db, memories);
1086
1098
  const wsReadyState = sess.ws ? sess.ws.readyState : null;
1087
1099
  if (!hit) {
1088
- console.log(`[flashback] no matches skipping proactive_memory send for session ${sess.id}`);
1100
+ const allDismissed = count > 0 && dismissedCount === count;
1101
+ const outcome = allDismissed ? 'dropped_dismissed' : 'dropped_empty';
1102
+ console.log(`[flashback] ${allDismissed
1103
+ ? `all ${count} candidate(s) previously dismissed`
1104
+ : 'no matches'} — skipping proactive_memory send for session ${sess.id}`);
1089
1105
  flashbackDiag.log({
1090
1106
  sessionId: sess.id,
1091
1107
  event: 'proactive_memory_emit',
1092
1108
  ws_ready_state: wsReadyState,
1093
1109
  frame_size_bytes: 0,
1094
- result_count_in_frame: 0,
1095
- outcome: 'dropped_empty',
1110
+ result_count_in_frame: allDismissed ? dismissedCount : 0,
1111
+ outcome,
1096
1112
  });
1097
1113
  return;
1098
1114
  }
@@ -1492,6 +1508,11 @@ function createServer(config) {
1492
1508
  ragConfigEnabled: !!(config.rag && config.rag.enabled),
1493
1509
  ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
1494
1510
  aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
1511
+ // Sprint 57 T2 (F-T2-2 + F-T2-6) — derived 3-state enum: 'off' |
1512
+ // 'pending' | 'active'. Single source of truth across /api/config,
1513
+ // /api/rag/status, /api/status. Replaces per-client derivation of
1514
+ // the "RAG · on / pending / mcp-only" label.
1515
+ ragMode: deriveRagMode(rag, config),
1495
1516
  statusColors,
1496
1517
  firstRun
1497
1518
  };
@@ -1644,7 +1665,9 @@ function createServer(config) {
1644
1665
  byType,
1645
1666
  uptime: process.uptime(),
1646
1667
  memory: process.memoryUsage(),
1647
- ragEnabled: rag.enabled
1668
+ ragEnabled: rag.enabled,
1669
+ // Sprint 57 T2 — single-source-of-truth ragMode enum (see rag-mode.js).
1670
+ ragMode: deriveRagMode(rag, config)
1648
1671
  });
1649
1672
  });
1650
1673
 
@@ -1660,11 +1683,16 @@ function createServer(config) {
1660
1683
 
1661
1684
  // GET /api/rag/status - RAG system status
1662
1685
  app.get('/api/rag/status', (req, res) => {
1663
- if (!db) return res.json({ enabled: false, localEvents: 0, unsynced: 0 });
1686
+ if (!db) return res.json({ enabled: false, ragMode: deriveRagMode(rag, config), localEvents: 0, unsynced: 0 });
1664
1687
  const total = db.prepare('SELECT COUNT(*) as n FROM rag_events').get().n;
1665
1688
  const unsynced = db.prepare('SELECT COUNT(*) as n FROM rag_events WHERE synced = 0').get().n;
1666
1689
  res.json({
1667
1690
  enabled: rag.enabled,
1691
+ // Sprint 57 T2 — single-source-of-truth ragMode enum. Programmatic
1692
+ // clients (CLI, MCP, CI) consume this directly instead of re-deriving
1693
+ // from the flat `enabled` boolean which can't distinguish "MCP-only by
1694
+ // intent" from "intent on but Supabase missing."
1695
+ ragMode: deriveRagMode(rag, config),
1668
1696
  supabaseConfigured: !!(rag.supabaseUrl),
1669
1697
  localEvents: total,
1670
1698
  unsynced,
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // Sprint 57 T2 (F-T2-2 + F-T2-6) — single source of truth for the RAG mode
4
+ // enum surfaced across /api/config, /api/rag/status, and /api/status.
5
+ //
6
+ // Sprint 55's T2 sweep found the three endpoints exposed inconsistent
7
+ // shapes: /api/config carried four booleans (ragEnabled, ragConfigEnabled,
8
+ // ragSupabaseConfigured, aiQueryAvailable) and the dashboard derived a
9
+ // 3-state label (`RAG · on` / `pending` / `mcp-only`) from them, while
10
+ // /api/rag/status and /api/status only exposed a flat `enabled` boolean —
11
+ // programmatic clients (CLI, MCP wrapper, CI smoke tests, future Telegram
12
+ // bot) couldn't distinguish "MCP-only by user intent" from "intent on but
13
+ // Supabase missing." Every client re-derived its own mode rule, which is
14
+ // the cross-endpoint inconsistency Sprint 57 closes.
15
+ //
16
+ // Direction (a) per orchestrator GREEN-LIGHT 2026-05-05 14:16 ET: keep the
17
+ // existing booleans on /api/config (backward compat), add a single derived
18
+ // `ragMode` enum on all three endpoints. The enum is computed once via
19
+ // `deriveRagMode(rag, config)` and consumed directly by the dashboard's
20
+ // `updateRagIndicator()`.
21
+ //
22
+ // Returns:
23
+ // "off" — user opted into MCP-only mode (intent=false). Memory tools
24
+ // still work via MCP; the in-CLI `termdeck flashback`
25
+ // command + hybrid search are disabled. UI label "RAG · mcp-only".
26
+ // "pending" — user enabled RAG in config.yaml but the runtime isn't
27
+ // actually serving (Supabase missing/unreachable at boot,
28
+ // or otherwise not effective). UI label "RAG · pending".
29
+ // "active" — RAG fully operational (effective=true). UI label "RAG · on".
30
+ //
31
+ // Forward-compat: future fourth states (e.g. "degraded" for partial
32
+ // Supabase failure modes) MUST extend this union, not replace it. New
33
+ // endpoints should consume `ragMode` rather than re-derive from booleans
34
+ // — that's the whole point of the helper.
35
+ function deriveRagMode(rag, config) {
36
+ const effective = !!(rag && rag.enabled);
37
+ const intent = !!(config && config.rag && config.rag.enabled);
38
+ if (effective) return 'active';
39
+ if (intent) return 'pending';
40
+ return 'off';
41
+ }
42
+
43
+ module.exports = { deriveRagMode };