@jhizzard/termdeck 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,14 +76,19 @@
76
76
 
77
77
  // -------- State ----------------------------------------------------------
78
78
 
79
+ const GC = (typeof window !== 'undefined' && window.GraphControls) || null;
80
+
79
81
  const state = {
80
82
  mode: 'project', // 'project' | 'memory'
81
83
  project: null,
82
84
  memoryId: null,
83
85
  depth: 2,
84
- nodes: [],
85
- edges: [],
86
+ nodes: [], // raw nodes from /api/graph/* — unfiltered
87
+ edges: [], // raw edges from /api/graph/* — unfiltered
88
+ visibleNodes: [], // post-filter, what the simulation sees
89
+ visibleEdges: [],
86
90
  activeKinds: new Set(Object.keys(EDGE_COLORS)),
91
+ controls: GC ? GC.defaultControls() : { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' },
87
92
  selectedNodeId: null,
88
93
  hoverNodeId: null,
89
94
  searchTerm: '',
@@ -172,6 +177,7 @@
172
177
  state.mode = 'project';
173
178
  state.project = project;
174
179
  }
180
+ if (GC) state.controls = GC.decodeControls(qs);
175
181
  }
176
182
 
177
183
  function writeUrlState() {
@@ -182,6 +188,7 @@
182
188
  } else if (state.project) {
183
189
  qs.set('project', state.project);
184
190
  }
191
+ if (GC) GC.encodeControls(qs, state.controls);
185
192
  const url = qs.toString() ? `?${qs.toString()}` : window.location.pathname;
186
193
  window.history.replaceState(null, '', url);
187
194
  }
@@ -259,6 +266,8 @@
259
266
  // node render all paint over each other after a mode/project switch.
260
267
  hideEmpty();
261
268
  clearGraphSvg();
269
+ state.rawNodes = [];
270
+ state.rawEdges = [];
262
271
  state.nodes = [];
263
272
  state.edges = [];
264
273
  setLoading('Loading graph…');
@@ -267,18 +276,18 @@
267
276
  if (state.mode === 'project' && state.project === '__all__') {
268
277
  data = await api('/api/graph/all');
269
278
  if (data.enabled === false) return showDisabled(data);
270
- state.nodes = data.nodes || [];
271
- state.edges = data.edges || [];
279
+ state.rawNodes = data.nodes || [];
280
+ state.rawEdges = data.edges || [];
272
281
  if (data.truncated) {
273
282
  showToast(
274
- `Showing ${state.nodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
283
+ `Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
275
284
  );
276
285
  }
277
286
  } else if (state.mode === 'memory') {
278
287
  data = await api(`/api/graph/memory/${encodeURIComponent(state.memoryId)}?depth=${state.depth}`);
279
288
  if (data.enabled === false) return showDisabled(data);
280
- state.nodes = data.nodes || [];
281
- state.edges = data.edges || [];
289
+ state.rawNodes = data.nodes || [];
290
+ state.rawEdges = data.edges || [];
282
291
  // Use the root memory's project as the view's "current project" for
283
292
  // the legend / drawer / fallback color when nodes span projects.
284
293
  if (data.root && data.root.project) state.project = data.root.project;
@@ -287,19 +296,17 @@
287
296
  state.project = name;
288
297
  data = await api(`/api/graph/project/${encodeURIComponent(name)}`);
289
298
  if (data.enabled === false) return showDisabled(data);
290
- state.nodes = data.nodes || [];
291
- state.edges = data.edges || [];
299
+ state.rawNodes = data.nodes || [];
300
+ state.rawEdges = data.edges || [];
292
301
  }
293
302
  writeUrlState();
294
303
  hideLoading();
295
- if (state.nodes.length === 0) {
304
+ if (state.rawNodes.length === 0) {
296
305
  showEmpty();
297
306
  return;
298
307
  }
299
308
  hideEmpty();
300
- renderFilters();
301
- renderGraph();
302
- updateStats();
309
+ applyControlsAndRender();
303
310
  } catch (err) {
304
311
  // Even on error, drop the empty-state overlay so the failure message
305
312
  // shows alone instead of stacking on top of "No memories yet".
@@ -308,6 +315,35 @@
308
315
  }
309
316
  }
310
317
 
318
+ // Sprint 43 T1 — re-derive state.nodes/state.edges from the raw API result
319
+ // and the four user controls, then drive the existing render pipeline.
320
+ // Called after every fetch and after any control change.
321
+ function applyControlsAndRender() {
322
+ const raw = { nodes: state.rawNodes || [], edges: state.rawEdges || [] };
323
+ let visible;
324
+ if (GC) {
325
+ visible = GC.applyControls(raw.nodes, raw.edges, state.controls);
326
+ } else {
327
+ visible = { nodes: raw.nodes.slice(), edges: raw.edges.slice() };
328
+ }
329
+ state.nodes = visible.nodes;
330
+ state.edges = visible.edges;
331
+ renderFilters();
332
+ renderGraph();
333
+ updateStats();
334
+ updateControlStat(raw.nodes.length, visible.nodes.length);
335
+ }
336
+
337
+ function updateControlStat(total, visible) {
338
+ const el = document.getElementById('ctlVisibleStat');
339
+ if (!el) return;
340
+ if (total === visible) {
341
+ el.textContent = '';
342
+ } else {
343
+ el.textContent = `${visible} of ${total} visible`;
344
+ }
345
+ }
346
+
311
347
  // -------- Render --------------------------------------------------------
312
348
 
313
349
  function setLoading(msg) {
@@ -417,6 +453,48 @@
417
453
  svg().setAttribute('height', state.height);
418
454
  }
419
455
 
456
+ // Sprint 43 T1 — apply layout-specific forces to the simulation.
457
+ // force — forceCenter at midpoint (default).
458
+ // hierarchical — forceY from BFS levels over directional edges (supersedes /
459
+ // caused_by / blocks). Roots float top, leaves drift down.
460
+ // radial — forceRadial centered at midpoint, radius from inverse
461
+ // degree (high degree → near center).
462
+ function applyLayoutForces(sim, nodes, links) {
463
+ const cx = state.width / 2;
464
+ const cy = state.height / 2;
465
+ const layout = (state.controls && state.controls.layout) || 'force';
466
+
467
+ sim.force('center', null);
468
+ sim.force('hier-y', null);
469
+ sim.force('hier-x', null);
470
+ sim.force('radial', null);
471
+
472
+ if (layout === 'hierarchical' && GC) {
473
+ const levels = GC.computeHierarchyLevels(state.nodes, state.edges);
474
+ let maxLevel = 0;
475
+ for (const v of levels.values()) if (v > maxLevel) maxLevel = v;
476
+ const denom = Math.max(1, maxLevel);
477
+ const top = state.height * 0.10;
478
+ const bottom = state.height * 0.90;
479
+ sim.force('hier-y', window.d3.forceY((d) => {
480
+ const lvl = levels.get(d.id) || 0;
481
+ return top + (lvl / denom) * (bottom - top);
482
+ }).strength(0.65));
483
+ sim.force('hier-x', window.d3.forceX(cx).strength(0.04));
484
+ return;
485
+ }
486
+
487
+ if (layout === 'radial' && GC) {
488
+ const deg = GC.computeDegrees(state.nodes, state.edges);
489
+ const rMax = Math.min(state.width, state.height) * 0.42;
490
+ const radiusFn = GC.radialRadiusFn(deg, rMax);
491
+ sim.force('radial', window.d3.forceRadial(radiusFn, cx, cy).strength(0.55));
492
+ return;
493
+ }
494
+
495
+ sim.force('center', window.d3.forceCenter(cx, cy));
496
+ }
497
+
420
498
  function renderGraph() {
421
499
  if (!window.d3) {
422
500
  setLoading('D3.js failed to load (CDN blocked?)');
@@ -441,10 +519,11 @@
441
519
  const sim = window.d3.forceSimulation(nodes)
442
520
  .force('link', window.d3.forceLink(links).id((d) => d.id).distance((l) => 60 + (1 - (l.weight ?? 0.5)) * 40))
443
521
  .force('charge', window.d3.forceManyBody().strength(-260))
444
- .force('center', window.d3.forceCenter(state.width / 2, state.height / 2))
445
522
  .force('collide', window.d3.forceCollide().radius((d) => nodeRadius(d) + 4))
446
523
  .alphaDecay(0.02);
447
524
 
525
+ applyLayoutForces(sim, nodes, links);
526
+
448
527
  state.sim = sim;
449
528
 
450
529
  const rootSel = window.d3.select(root());
@@ -742,6 +821,41 @@
742
821
  });
743
822
  $('graphFit').addEventListener('click', () => fitToView());
744
823
 
824
+ // Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
825
+ // wire change handlers that mutate state.controls + URL + re-render from
826
+ // the cached raw fetch so toggling is fast (no API round-trip).
827
+ const ctlHide = $('ctlHideIsolated');
828
+ const ctlMin = $('ctlMinDegree');
829
+ const ctlWin = $('ctlWindow');
830
+ const ctlLay = $('ctlLayout');
831
+ if (ctlHide) ctlHide.checked = !!state.controls.hideIsolated;
832
+ if (ctlMin) ctlMin.value = String(state.controls.minDegree || 0);
833
+ if (ctlWin) ctlWin.value = state.controls.window || 'all';
834
+ if (ctlLay) ctlLay.value = state.controls.layout || 'force';
835
+
836
+ function onControlChange() {
837
+ state.controls = (GC ? GC.normalizeControls(state.controls) : state.controls);
838
+ writeUrlState();
839
+ if ((state.rawNodes || []).length === 0) return;
840
+ applyControlsAndRender();
841
+ }
842
+ if (ctlHide) ctlHide.addEventListener('change', () => {
843
+ state.controls.hideIsolated = !!ctlHide.checked;
844
+ onControlChange();
845
+ });
846
+ if (ctlMin) ctlMin.addEventListener('change', () => {
847
+ state.controls.minDegree = parseInt(ctlMin.value, 10) || 0;
848
+ onControlChange();
849
+ });
850
+ if (ctlWin) ctlWin.addEventListener('change', () => {
851
+ state.controls.window = ctlWin.value;
852
+ onControlChange();
853
+ });
854
+ if (ctlLay) ctlLay.addEventListener('change', () => {
855
+ state.controls.layout = ctlLay.value;
856
+ onControlChange();
857
+ });
858
+
745
859
  $('gdClose').addEventListener('click', closeDrawer);
746
860
  $('gdExpand').addEventListener('click', () => {
747
861
  if (!state.selectedNodeId) return;
@@ -763,7 +877,9 @@
763
877
  window.addEventListener('resize', () => {
764
878
  sizeStage();
765
879
  if (state.sim) {
766
- state.sim.force('center', window.d3.forceCenter(state.width / 2, state.height / 2));
880
+ // Re-apply layout-specific forces with the new dimensions; this
881
+ // handles all three layouts (force / hierarchical / radial).
882
+ applyLayoutForces(state.sim, null, null);
767
883
  state.sim.alpha(0.3).restart();
768
884
  }
769
885
  });
@@ -62,6 +62,7 @@
62
62
  <button id="btn-config">config</button>
63
63
  <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
64
64
  <button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
65
+ <button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
65
66
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
66
67
  <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://termdeck-docs.vercel.app','_blank','noopener')">help</button>
67
68
  </div>
@@ -3567,6 +3567,61 @@
3567
3567
  background: rgba(255, 255, 255, 0.08);
3568
3568
  }
3569
3569
 
3570
+ /* Sprint 43 T1 — second toolbar row: hide-isolated / min-degree / window
3571
+ / layout selectors. Mirrors .graph-filters spacing but shifts the
3572
+ background a notch darker so the two rows are visually distinguishable
3573
+ and the edge-type chips above feel like their own band. */
3574
+ .graph-filters-row2 {
3575
+ gap: 14px;
3576
+ padding: 6px 16px;
3577
+ background: rgba(0, 0, 0, 0.18);
3578
+ border-bottom: 1px solid var(--tg-border);
3579
+ }
3580
+ .graph-control {
3581
+ display: inline-flex;
3582
+ align-items: center;
3583
+ gap: 6px;
3584
+ font-size: 11px;
3585
+ font-family: var(--tg-mono);
3586
+ color: var(--tg-text-dim);
3587
+ }
3588
+ .graph-control select {
3589
+ background: var(--tg-bg);
3590
+ color: var(--tg-text);
3591
+ border: 1px solid var(--tg-border);
3592
+ border-radius: var(--tg-radius-sm);
3593
+ padding: 3px 6px;
3594
+ font-family: var(--tg-mono);
3595
+ font-size: 11px;
3596
+ cursor: pointer;
3597
+ }
3598
+ .graph-control select:focus {
3599
+ outline: none;
3600
+ border-color: var(--tg-accent);
3601
+ }
3602
+ .graph-control-toggle {
3603
+ cursor: pointer;
3604
+ user-select: none;
3605
+ }
3606
+ .graph-control-toggle input[type="checkbox"] {
3607
+ accent-color: var(--tg-accent);
3608
+ cursor: pointer;
3609
+ }
3610
+ .graph-control-stat {
3611
+ margin-left: auto;
3612
+ font-size: 10px;
3613
+ color: var(--tg-text-dim);
3614
+ font-family: var(--tg-mono);
3615
+ padding: 2px 8px;
3616
+ border: 1px solid transparent;
3617
+ border-radius: 999px;
3618
+ }
3619
+ .graph-control-stat:not(:empty) {
3620
+ background: rgba(122, 162, 247, 0.10);
3621
+ border-color: rgba(122, 162, 247, 0.35);
3622
+ color: var(--tg-text);
3623
+ }
3624
+
3570
3625
  .graph-stage {
3571
3626
  position: relative;
3572
3627
  flex: 1;
@@ -0,0 +1,158 @@
1
+ // Claude Code adapter — Sprint 44 T3
2
+ //
3
+ // First adapter in the AGENT_ADAPTERS registry (see ./index.js). Lifts the
4
+ // claude-code logic that previously lived as hardcoded branches in
5
+ // packages/server/src/session.js. Behavior is bit-for-bit identical to the
6
+ // pre-Sprint-44 inline path: same regexes, same status strings, same
7
+ // transcript-parser cut-offs. Sprint 45 adds Codex / Gemini / Grok adapters
8
+ // alongside this one; Sprint 46 wires per-lane agent assignment in 4+1.
9
+ //
10
+ // Contract (memorialization doc § 4 + lane brief T3):
11
+ // {
12
+ // name: string, // adapter id used in registry
13
+ // sessionType: string, // session.meta.type produced
14
+ // matches: (cmd) => boolean, // command-string detection
15
+ // spawn: { binary, defaultArgs, env },
16
+ // patterns: { prompt, thinking, editing, tool, idle, error },
17
+ // patternNames: { error: string }, // diag-event label preservation
18
+ // statusFor: (data) => { status, statusDetail } | null,
19
+ // parseTranscript:(raw) => Memory[], // for memory-session-end hook
20
+ // bootPromptTemplate: (lane, sprint) => string,
21
+ // costBand: 'free' | 'pay-per-token' | 'subscription',
22
+ // }
23
+ //
24
+ // `statusFor` returns null when no pattern matches — preserves the original
25
+ // "no change" semantics for the claude-code switch case. Caller leaves
26
+ // `meta.status` and `meta.statusDetail` untouched on null.
27
+
28
+ // ──────────────────────────────────────────────────────────────────────────
29
+ // Patterns — verbatim regexes lifted from session.js so the adapter and the
30
+ // shim remain reference-equal. Don't redeclare these elsewhere; import from
31
+ // the adapter so future tweaks land in one place.
32
+ // ──────────────────────────────────────────────────────────────────────────
33
+
34
+ const PROMPT = /^[>❯]\s/m;
35
+ const THINKING = /\b(thinking|Thinking)\b/;
36
+ const EDITING = /^(Edit|Create|Update|Delete)\s/m;
37
+ const EDITING_DETAIL = /^(Edit|Create|Update|Delete)\s+(.+)$/m;
38
+ const TOOL = /^⏺\s/m;
39
+ const IDLE = /^>\s*$/m;
40
+
41
+ // errorLineStart from session.js — line-anchored variant for claude-code
42
+ // sessions whose tool output (grep results, test logs, file dumps) routinely
43
+ // mentions "Error" mid-line without representing an actual failure.
44
+ // Sprint 40 T2 added mixed-case `Fatal` + the special-cased `npm ERR!` shape.
45
+ 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;
46
+
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+ // statusFor — replaces the `case 'claude-code':` block of _updateStatus.
49
+ // Order matters: thinking → editing → tool → idle. First match wins, exactly
50
+ // as the original switch did with cascading `else if`s.
51
+ // ──────────────────────────────────────────────────────────────────────────
52
+
53
+ function statusFor(data) {
54
+ if (THINKING.test(data)) {
55
+ return { status: 'thinking', statusDetail: 'Claude is reasoning...' };
56
+ }
57
+ if (EDITING.test(data)) {
58
+ const match = data.match(EDITING_DETAIL);
59
+ return {
60
+ status: 'editing',
61
+ statusDetail: match ? `${match[1]} ${match[2]}` : 'Editing files',
62
+ };
63
+ }
64
+ if (TOOL.test(data)) {
65
+ return { status: 'active', statusDetail: 'Using tools' };
66
+ }
67
+ if (IDLE.test(data)) {
68
+ return { status: 'idle', statusDetail: 'Waiting for input' };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // ──────────────────────────────────────────────────────────────────────────
74
+ // parseTranscript — Claude Code JSONL format, lifted from
75
+ // packages/stack-installer/assets/hooks/memory-session-end.js:88-102.
76
+ // Emits records of shape { role: 'user'|'assistant', content: string }
77
+ // truncated to 400 chars per message. The hook itself remains the consumer
78
+ // in Sprint 44; Sprint 45 T4 wires it to read from this adapter so other
79
+ // agents can plug in their own format parsers.
80
+ // ──────────────────────────────────────────────────────────────────────────
81
+
82
+ function parseTranscript(raw) {
83
+ if (typeof raw !== 'string' || raw.length === 0) return [];
84
+ const lines = raw.split('\n').filter(Boolean);
85
+ const messages = [];
86
+ for (const line of lines) {
87
+ let msg;
88
+ try { msg = JSON.parse(line); } catch (_) { continue; }
89
+ const role = msg && msg.message && msg.message.role;
90
+ if (role !== 'user' && role !== 'assistant') continue;
91
+ const content = msg.message.content;
92
+ let text = '';
93
+ if (typeof content === 'string') {
94
+ text = content;
95
+ } else if (Array.isArray(content)) {
96
+ text = content
97
+ .filter((c) => c && c.type === 'text')
98
+ .map((c) => c.text)
99
+ .join(' ');
100
+ }
101
+ if (text) messages.push({ role, content: text.slice(0, 400) });
102
+ }
103
+ return messages;
104
+ }
105
+
106
+ // ──────────────────────────────────────────────────────────────────────────
107
+ // bootPromptTemplate — minimal scaffold matching the global-CLAUDE.md 4+1
108
+ // boot block. Sprint 46 T2 will refine per-agent prompts; this is the
109
+ // placeholder so the adapter contract is complete in Sprint 44.
110
+ // ──────────────────────────────────────────────────────────────────────────
111
+
112
+ function bootPromptTemplate(lane = {}, sprint = {}) {
113
+ const tn = lane.id || 'T?';
114
+ const sprintNum = sprint.number || '?';
115
+ const sprintName = sprint.name || 'unnamed';
116
+ const project = (lane.project || sprint.project || 'termdeck');
117
+ const briefing = lane.briefingPath || `docs/sprint-${sprintNum}-${sprintName}/${tn}-<lane>.md`;
118
+ return [
119
+ `You are ${tn} in Sprint ${sprintNum} (${sprintName}). Boot sequence:`,
120
+ `1. memory_recall(project="${project}", query="<topic>")`,
121
+ `2. memory_recall(query="<broader topic>")`,
122
+ `3. Read ~/.claude/CLAUDE.md and ./CLAUDE.md`,
123
+ `4. Read docs/sprint-${sprintNum}-${sprintName}/PLANNING.md`,
124
+ `5. Read docs/sprint-${sprintNum}-${sprintName}/STATUS.md`,
125
+ `6. Read ${briefing}`,
126
+ '',
127
+ 'Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in STATUS.md.',
128
+ "Don't bump versions, don't touch CHANGELOG, don't commit.",
129
+ ].join('\n');
130
+ }
131
+
132
+ const claudeAdapter = {
133
+ name: 'claude',
134
+ sessionType: 'claude-code',
135
+ matches: (cmd) => typeof cmd === 'string' && /claude/i.test(cmd),
136
+ spawn: {
137
+ binary: 'claude',
138
+ defaultArgs: [],
139
+ env: {},
140
+ },
141
+ patterns: {
142
+ prompt: PROMPT,
143
+ thinking: THINKING,
144
+ editing: EDITING,
145
+ tool: TOOL,
146
+ idle: IDLE,
147
+ error: ERROR,
148
+ },
149
+ patternNames: {
150
+ error: 'errorLineStart',
151
+ },
152
+ statusFor,
153
+ parseTranscript,
154
+ bootPromptTemplate,
155
+ costBand: 'pay-per-token',
156
+ };
157
+
158
+ module.exports = claudeAdapter;
@@ -0,0 +1,55 @@
1
+ // Agent adapter registry — Sprint 44 T3
2
+ //
3
+ // Single source of truth for per-agent terminal behavior. Each adapter
4
+ // implements the contract documented in ./claude.js (and the memorialization
5
+ // doc § 4) — type detection, status patterns, transcript parsing, boot prompt
6
+ // templating, cost band. session.js consults this registry when analyzing
7
+ // PTY output so adding a new agent (Codex / Gemini / Grok in Sprint 45) is a
8
+ // new file in this directory + one entry in `AGENT_ADAPTERS` below — no
9
+ // switch statements to extend.
10
+ //
11
+ // Sprint 44 lands the Claude adapter only. The other agents stay on the
12
+ // in-file shim path in session.js until Sprint 45 T1-T3 ship their adapters
13
+ // and Sprint 45 T4 wires the launcher UI through the same registry.
14
+
15
+ const claude = require('./claude');
16
+
17
+ // Keyed by adapter name (NOT session.meta.type — adapters expose their own
18
+ // `sessionType` field for that mapping). Order is iteration order for the
19
+ // detect loop in session.js, so list more-specific adapters before less.
20
+ const AGENT_ADAPTERS = {
21
+ claude,
22
+ };
23
+
24
+ // Convenience accessor — returns the adapter whose `sessionType` matches the
25
+ // session.meta.type value, or undefined if no adapter has claimed that type.
26
+ // session.js calls this in the hot path; keep it cheap.
27
+ function getAdapterForSessionType(type) {
28
+ if (!type) return undefined;
29
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
30
+ if (adapter.sessionType === type) return adapter;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ // Convenience accessor — first adapter whose prompt regex matches `data` or
36
+ // whose `matches(command)` returns true. Returns undefined if no adapter
37
+ // claims the session, leaving the caller to fall back to legacy detection
38
+ // (gemini / python-server / shell). session.js calls this from `_detectType`.
39
+ function detectAdapter(data, command) {
40
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
41
+ const promptHit = adapter.patterns
42
+ && adapter.patterns.prompt
43
+ && typeof data === 'string'
44
+ && adapter.patterns.prompt.test(data);
45
+ const cmdHit = typeof adapter.matches === 'function' && adapter.matches(command);
46
+ if (promptHit || cmdHit) return adapter;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ module.exports = {
52
+ AGENT_ADAPTERS,
53
+ getAdapterForSessionType,
54
+ detectAdapter,
55
+ };
@@ -2,12 +2,50 @@
2
2
 
3
3
  const path = require('path');
4
4
  const os = require('os');
5
+ const fs = require('fs');
6
+
7
+ // Sprint 43 T2: load a numbered migration .sql file from the repo-root
8
+ // `migrations/` directory if present, falling back to an inline string for
9
+ // packaged npm installs where the `files` allowlist in package.json does not
10
+ // ship `migrations/`. Authoritative source-of-truth is the .sql file; the
11
+ // fallback is kept in lockstep when the schema changes.
12
+ function loadMigrationSql(name, fallback) {
13
+ const candidates = [
14
+ path.join(__dirname, '..', '..', '..', 'migrations', name),
15
+ path.join(__dirname, '..', '..', '..', '..', 'migrations', name),
16
+ ];
17
+ for (const candidate of candidates) {
18
+ try {
19
+ const sql = fs.readFileSync(candidate, 'utf8');
20
+ if (sql && sql.trim().length) return sql;
21
+ } catch (_e) { /* try next */ }
22
+ }
23
+ return fallback;
24
+ }
25
+
26
+ const FLASHBACK_EVENTS_INLINE_SQL = `
27
+ CREATE TABLE IF NOT EXISTS flashback_events (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ fired_at TEXT NOT NULL,
30
+ session_id TEXT NOT NULL,
31
+ project TEXT,
32
+ error_text TEXT NOT NULL,
33
+ hits_count INTEGER NOT NULL DEFAULT 0,
34
+ top_hit_id TEXT,
35
+ top_hit_score REAL,
36
+ dismissed_at TEXT,
37
+ clicked_through INTEGER NOT NULL DEFAULT 0
38
+ );
39
+ CREATE INDEX IF NOT EXISTS flashback_events_fired_at_idx
40
+ ON flashback_events(fired_at DESC);
41
+ CREATE INDEX IF NOT EXISTS flashback_events_session_idx
42
+ ON flashback_events(session_id);
43
+ `;
5
44
 
6
45
  function initDatabase(Database) {
7
46
  const dbPath = path.join(os.homedir(), '.termdeck', 'termdeck.db');
8
47
 
9
48
  // Ensure directory exists
10
- const fs = require('fs');
11
49
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
12
50
 
13
51
  const db = new Database(dbPath);
@@ -115,6 +153,16 @@ function initDatabase(Database) {
115
153
  console.warn('[db] projects.default_theme drop migration failed:', err.message);
116
154
  }
117
155
 
156
+ // Sprint 43 T2: flashback_events durable audit table. Schema lives in
157
+ // migrations/001_flashback_events.sql (repo-root, source-of-truth) with
158
+ // an inline fallback for packaged installs. Idempotent CREATE so this
159
+ // replays safely on every server start.
160
+ try {
161
+ db.exec(loadMigrationSql('001_flashback_events.sql', FLASHBACK_EVENTS_INLINE_SQL));
162
+ } catch (err) {
163
+ console.warn('[db] flashback_events migration failed:', err.message);
164
+ }
165
+
118
166
  return db;
119
167
  }
120
168