@jhizzard/termdeck 1.0.11 → 1.0.13

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.
@@ -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.
@@ -59,8 +59,10 @@ function loadSecretsEnv() {
59
59
  const parsed = parseDotenv(raw);
60
60
  const keys = [];
61
61
  for (const [k, v] of Object.entries(parsed)) {
62
- // Do not clobber pre-set process env; shell wins.
63
- if (process.env[k] === undefined) {
62
+ // Do not clobber pre-set process env; shell wins. Sprint 59 T4-CODEX residual
63
+ // fix: also fill when parent env is empty string (Brad's actual failure shape
64
+ // includes DATABASE_URL= in the parent service environment, not only missing).
65
+ if (process.env[k] === undefined || process.env[k] === '') {
64
66
  process.env[k] = v;
65
67
  }
66
68
  keys.push(k);
@@ -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,8 @@ 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');
85
+ const { resolveSpawnShell } = require('./spawn-shell');
84
86
 
85
87
  // Sprint 48 T4 deliverable 2: PTY env-var propagation.
86
88
  // Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
@@ -324,6 +326,26 @@ function createServer(config) {
324
326
  if (orphaned.changes > 0) {
325
327
  console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
326
328
  }
329
+ // Sprint 59 T4-CODEX cleanup: reap upload tempdirs whose owning session is
330
+ // exited or unknown (crashed processes, hard kills, pre-this-version dirs).
331
+ try {
332
+ const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads');
333
+ if (fs.existsSync(uploadsRoot)) {
334
+ const liveIds = new Set();
335
+ try {
336
+ for (const row of db.prepare('SELECT id FROM sessions WHERE exited_at IS NULL').all()) {
337
+ liveIds.add(row.id);
338
+ }
339
+ } catch (_e) { /* live-set empty → all dirs are stale */ }
340
+ let reaped = 0;
341
+ for (const dir of fs.readdirSync(uploadsRoot)) {
342
+ if (!liveIds.has(dir)) {
343
+ try { fs.rmSync(path.join(uploadsRoot, dir), { recursive: true, force: true }); reaped++; } catch (_e) {}
344
+ }
345
+ }
346
+ if (reaped > 0) console.log(`[uploads] Reaped ${reaped} stale upload tempdir(s)`);
347
+ }
348
+ } catch (_err) { /* non-blocking */ }
327
349
  console.log('[db] SQLite initialized');
328
350
  } catch (err) {
329
351
  console.warn('[db] SQLite init failed:', err.message);
@@ -954,7 +976,13 @@ function createServer(config) {
954
976
  const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
955
977
  const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
956
978
 
957
- const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
979
+ // Sprint 59 T2 Brad #5: resolveSpawnShell chains config.shell
980
+ // $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
981
+ // `apt remove zsh`) still spawns a working interactive shell instead of
982
+ // failing silently from execvp(/bin/zsh).
983
+ const spawnShell = isPlainShell
984
+ ? cmdTrim
985
+ : resolveSpawnShell('', config.shell, process.env.SHELL);
958
986
  const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
959
987
 
960
988
  try {
@@ -1041,6 +1069,14 @@ function createServer(config) {
1041
1069
  onPanelClose(session).catch((err) => {
1042
1070
  console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
1043
1071
  });
1072
+
1073
+ // Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
1074
+ // per-session upload tempdir so dropped files don't outlive the panel
1075
+ // that received them. Fire-and-forget; never blocks teardown.
1076
+ try {
1077
+ const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
1078
+ fs.rmSync(sessUploadDir, { recursive: true, force: true });
1079
+ } catch (_err) { /* non-blocking */ }
1044
1080
  });
1045
1081
 
1046
1082
  // Wire command logging to SQLite + RAG
@@ -1082,17 +1118,32 @@ function createServer(config) {
1082
1118
  const memories = (result && result.memories) || [];
1083
1119
  const count = memories.length;
1084
1120
  console.log(`[flashback] query returned ${count} matches for session ${sess.id}`);
1085
- const hit = memories[0];
1121
+ // Sprint 57 T1 (#4): negative-feedback persistence. Skip any
1122
+ // memory the user previously dismissed (across all sessions);
1123
+ // iterate candidates in score order, first non-dismissed wins.
1124
+ // Without this, a low-confidence match the user marked
1125
+ // "Not relevant" would resurface on the next error fire —
1126
+ // exactly the resurfacing-after-dismiss bug Sprint 55 T2 + T4
1127
+ // diagnosed (T4 audit addendum: index.js:1058-1100 emits
1128
+ // memories[0] without consulting dismissed history). Selection
1129
+ // logic lives in `flashbackDiag.pickNextNonDismissed` so the
1130
+ // integration shape stays testable without a live PTY.
1131
+ const { hit, dismissedCount } =
1132
+ flashbackDiag.pickNextNonDismissed(db, memories);
1086
1133
  const wsReadyState = sess.ws ? sess.ws.readyState : null;
1087
1134
  if (!hit) {
1088
- console.log(`[flashback] no matches skipping proactive_memory send for session ${sess.id}`);
1135
+ const allDismissed = count > 0 && dismissedCount === count;
1136
+ const outcome = allDismissed ? 'dropped_dismissed' : 'dropped_empty';
1137
+ console.log(`[flashback] ${allDismissed
1138
+ ? `all ${count} candidate(s) previously dismissed`
1139
+ : 'no matches'} — skipping proactive_memory send for session ${sess.id}`);
1089
1140
  flashbackDiag.log({
1090
1141
  sessionId: sess.id,
1091
1142
  event: 'proactive_memory_emit',
1092
1143
  ws_ready_state: wsReadyState,
1093
1144
  frame_size_bytes: 0,
1094
- result_count_in_frame: 0,
1095
- outcome: 'dropped_empty',
1145
+ result_count_in_frame: allDismissed ? dismissedCount : 0,
1146
+ outcome,
1096
1147
  });
1097
1148
  return;
1098
1149
  }
@@ -1276,6 +1327,47 @@ function createServer(config) {
1276
1327
  res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
1277
1328
  });
1278
1329
 
1330
+ // POST /api/sessions/:id/upload?name=<filename> - File drop / clipboard image paste
1331
+ // Body: raw octet-stream of the file content (max 50MB).
1332
+ // Writes to /tmp/termdeck-uploads/<sessionId>/<sanitizedName>, returns {ok, path, name, size}.
1333
+ // Client typically follows up with POST /api/sessions/:id/input { text: "@<path> " } so
1334
+ // the agent (Claude/Codex/Gemini/Grok) sees the standard @filepath attachment syntax.
1335
+ // Added Sprint 59 (2026-05-07) to close Brad's "how do I drop a zip into Codex" gap.
1336
+ app.post('/api/sessions/:id/upload',
1337
+ express.raw({ type: '*/*', limit: '50mb' }),
1338
+ (req, res) => {
1339
+ const session = sessions.get(req.params.id);
1340
+ if (!session) return res.status(404).json({ error: 'Session not found' });
1341
+ if (session.meta.status === 'exited' || !session.pty) {
1342
+ return res.status(404).json({ error: 'Session is exited' });
1343
+ }
1344
+
1345
+ const rawName = (req.query.name || '').toString();
1346
+ if (!rawName) return res.status(400).json({ error: 'Missing ?name=' });
1347
+ // Sanitize: strip path traversal + control chars; cap at 200 chars.
1348
+ // Replace anything not alphanumeric / dash / underscore / dot / space with _
1349
+ const safeName = rawName
1350
+ .replace(/[\x00-\x1f\x7f/\\]/g, '_')
1351
+ .replace(/^\.+/, '_')
1352
+ .replace(/\.\.+/g, '_')
1353
+ .slice(0, 200) || 'upload.bin';
1354
+
1355
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
1356
+ return res.status(400).json({ error: 'Empty body' });
1357
+ }
1358
+
1359
+ const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
1360
+ try {
1361
+ fs.mkdirSync(uploadsRoot, { recursive: true, mode: 0o700 });
1362
+ const fullPath = path.join(uploadsRoot, safeName);
1363
+ fs.writeFileSync(fullPath, req.body, { mode: 0o600 });
1364
+ res.json({ ok: true, path: fullPath, name: safeName, size: req.body.length });
1365
+ } catch (err) {
1366
+ return res.status(500).json({ error: err.message });
1367
+ }
1368
+ }
1369
+ );
1370
+
1279
1371
  // POST /api/sessions/:id/poke - PTY-flush recovery endpoint
1280
1372
  // Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
1281
1373
  // Used to recover from the post-stop PTY delivery gap where injected input via /input
@@ -1492,6 +1584,11 @@ function createServer(config) {
1492
1584
  ragConfigEnabled: !!(config.rag && config.rag.enabled),
1493
1585
  ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
1494
1586
  aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
1587
+ // Sprint 57 T2 (F-T2-2 + F-T2-6) — derived 3-state enum: 'off' |
1588
+ // 'pending' | 'active'. Single source of truth across /api/config,
1589
+ // /api/rag/status, /api/status. Replaces per-client derivation of
1590
+ // the "RAG · on / pending / mcp-only" label.
1591
+ ragMode: deriveRagMode(rag, config),
1495
1592
  statusColors,
1496
1593
  firstRun
1497
1594
  };
@@ -1644,7 +1741,9 @@ function createServer(config) {
1644
1741
  byType,
1645
1742
  uptime: process.uptime(),
1646
1743
  memory: process.memoryUsage(),
1647
- ragEnabled: rag.enabled
1744
+ ragEnabled: rag.enabled,
1745
+ // Sprint 57 T2 — single-source-of-truth ragMode enum (see rag-mode.js).
1746
+ ragMode: deriveRagMode(rag, config)
1648
1747
  });
1649
1748
  });
1650
1749
 
@@ -1660,11 +1759,16 @@ function createServer(config) {
1660
1759
 
1661
1760
  // GET /api/rag/status - RAG system status
1662
1761
  app.get('/api/rag/status', (req, res) => {
1663
- if (!db) return res.json({ enabled: false, localEvents: 0, unsynced: 0 });
1762
+ if (!db) return res.json({ enabled: false, ragMode: deriveRagMode(rag, config), localEvents: 0, unsynced: 0 });
1664
1763
  const total = db.prepare('SELECT COUNT(*) as n FROM rag_events').get().n;
1665
1764
  const unsynced = db.prepare('SELECT COUNT(*) as n FROM rag_events WHERE synced = 0').get().n;
1666
1765
  res.json({
1667
1766
  enabled: rag.enabled,
1767
+ // Sprint 57 T2 — single-source-of-truth ragMode enum. Programmatic
1768
+ // clients (CLI, MCP, CI) consume this directly instead of re-deriving
1769
+ // from the flat `enabled` boolean which can't distinguish "MCP-only by
1770
+ // intent" from "intent on but Supabase missing."
1771
+ ragMode: deriveRagMode(rag, config),
1668
1772
  supabaseConfigured: !!(rag.supabaseUrl),
1669
1773
  localEvents: total,
1670
1774
  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 };
@@ -40,13 +40,22 @@ function readSecretsRaw(filepath = SECRETS_PATH) {
40
40
  }
41
41
 
42
42
  // Escape a value for safe re-serialization. Wraps in double quotes if the
43
- // value contains whitespace, `#`, or `"`. Always safe to wrap we wrap when
44
- // in doubt to avoid ambiguity with the dotenv parser.
43
+ // value contains whitespace, `#`, or a quote char. `=` was previously in the
44
+ // regex but excluded after Sprint 59 Brad #2 — every Postgres URL with query
45
+ // params (e.g. `?sslmode=require`) contains `=`, and dotenv splits a line on
46
+ // the FIRST `=` only, so subsequent `=` chars in the value need no quoting.
47
+ // Quoting URLs broke the "writer must never add surrounding quotes to a
48
+ // DATABASE_URL" contract; the value still round-tripped because every reader
49
+ // strips matching quotes, but a downstream consumer that sourced the file
50
+ // via `set -a; . secrets.env` and didn't strip would see a literal-quoted
51
+ // value re-introduced into process.env. Keeping the regex tight to actual
52
+ // dotenv ambiguities (whitespace, `#` for comments, embedded quote chars)
53
+ // avoids that round-trip-but-not-quite class of bug at the source.
45
54
  function formatValue(value) {
46
55
  if (value == null) return '';
47
56
  const str = String(value);
48
57
  if (str === '') return '';
49
- const needsQuoting = /[\s#"'=]/.test(str);
58
+ const needsQuoting = /[\s#"']/.test(str);
50
59
  if (!needsQuoting) return str;
51
60
  const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
52
61
  return `"${escaped}"`;