@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +72 -7
- package/packages/cli/src/index.js +115 -2
- package/packages/cli/src/init-mnestra.js +14 -5
- package/packages/cli/src/stack.js +39 -10
- package/packages/client/public/app.js +164 -5
- package/packages/client/public/graph.js +19 -1
- package/packages/client/public/style.css +5 -0
- package/packages/server/src/agent-adapters/claude.js +28 -1
- package/packages/server/src/config.js +4 -2
- package/packages/server/src/flashback-diag.js +79 -0
- package/packages/server/src/graph-routes.js +142 -25
- package/packages/server/src/index.js +111 -7
- package/packages/server/src/rag-mode.js +43 -0
- package/packages/server/src/setup/dotenv-io.js +12 -3
- package/packages/server/src/setup/supabase-url.js +39 -8
- package/packages/server/src/spawn-shell.js +27 -0
|
@@ -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.
|
|
@@ -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
|
-
|
|
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
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
-
//
|
|
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#"'
|
|
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}"`;
|