@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 +1 -1
- package/packages/client/public/app.js +79 -5
- package/packages/client/public/graph.js +19 -1
- package/packages/server/src/agent-adapters/claude.js +28 -1
- package/packages/server/src/flashback-diag.js +79 -0
- package/packages/server/src/graph-routes.js +142 -25
- package/packages/server/src/index.js +34 -6
- package/packages/server/src/rag-mode.js +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0.
|
|
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(() =>
|
|
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
|
-
|
|
3452
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|