agentlytics 0.2.11 → 0.2.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/server.js CHANGED
@@ -727,6 +727,108 @@ app.get('/api/all-projects', (req, res) => {
727
727
  }
728
728
  });
729
729
 
730
+ // ============================================================
731
+ // GSD endpoints
732
+ // ============================================================
733
+
734
+ app.get('/api/gsd/projects', (req, res) => {
735
+ try {
736
+ res.json(cache.getCachedGSDProjects());
737
+ } catch (err) {
738
+ res.status(500).json({ error: err.message });
739
+ }
740
+ });
741
+
742
+ app.get('/api/gsd/phases', (req, res) => {
743
+ try {
744
+ const { folder } = req.query;
745
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
746
+ res.json(cache.getCachedGSDPhases(folder));
747
+ } catch (err) {
748
+ res.status(500).json({ error: err.message });
749
+ }
750
+ });
751
+
752
+ app.get('/api/gsd/plan', (req, res) => {
753
+ try {
754
+ const { folder, phase } = req.query;
755
+ if (!folder || !phase) return res.status(400).json({ error: 'folder and phase query params required' });
756
+ const gsd = require('./editors/gsd');
757
+ const detail = gsd.getGSDPlanDetail(folder, phase);
758
+ if (!detail) return res.status(404).json({ error: 'Plan not found' });
759
+ res.json(detail);
760
+ } catch (err) {
761
+ res.status(500).json({ error: err.message });
762
+ }
763
+ });
764
+
765
+ app.get('/api/gsd/overview', (req, res) => {
766
+ try {
767
+ res.json(cache.getCachedGSDOverview());
768
+ } catch (err) {
769
+ res.status(500).json({ error: err.message });
770
+ }
771
+ });
772
+
773
+ app.get('/api/gsd/config', (req, res) => {
774
+ try {
775
+ const { folder } = req.query;
776
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
777
+ const configPath = require('path').join(folder, '.planning', 'config.json');
778
+ if (!fs.existsSync(configPath)) return res.json(null);
779
+ res.json(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
780
+ } catch (err) {
781
+ res.status(500).json({ error: err.message });
782
+ }
783
+ });
784
+
785
+ app.get('/api/gsd/phase-tokens', (req, res) => {
786
+ try {
787
+ const { folder } = req.query;
788
+ if (!folder) return res.status(400).json({ error: 'folder query param required' });
789
+ res.json(cache.getGSDPhaseTokens(folder));
790
+ } catch (err) {
791
+ res.status(500).json({ error: err.message });
792
+ }
793
+ });
794
+
795
+ // Generic .planning file reader
796
+ // type: 'state' (project-level STATE.md) | 'research' | 'verification' | 'summary' (phase-level, requires phase param)
797
+ app.get('/api/gsd/file', (req, res) => {
798
+ try {
799
+ const { folder, phase: phaseDir, type } = req.query;
800
+ if (!folder || !type) return res.status(400).json({ error: 'folder and type required' });
801
+
802
+ const planningDir = path.join(folder, '.planning');
803
+ let content = null;
804
+
805
+ if (type === 'state') {
806
+ const filePath = path.join(planningDir, 'STATE.md');
807
+ if (fs.existsSync(filePath)) content = fs.readFileSync(filePath, 'utf-8');
808
+ } else if (phaseDir) {
809
+ const phaseFullDir = path.join(planningDir, 'phases', phaseDir);
810
+ const pattern = type === 'research' ? /RESEARCH\.md$/i
811
+ : type === 'verification' ? /VERIFICATION\.md$/i
812
+ : type === 'summary' ? /SUMMARY\.md$/i
813
+ : null;
814
+ if (pattern && fs.existsSync(phaseFullDir)) {
815
+ const files = fs.readdirSync(phaseFullDir).filter(f => pattern.test(f)).sort();
816
+ if (files.length > 0) {
817
+ const sections = files.map(f => {
818
+ const c = fs.readFileSync(path.join(phaseFullDir, f), 'utf-8');
819
+ return files.length > 1 ? `## ${f}\n\n${c}` : c;
820
+ });
821
+ content = sections.join('\n\n---\n\n');
822
+ }
823
+ }
824
+ }
825
+
826
+ res.json({ content });
827
+ } catch (err) {
828
+ res.status(500).json({ error: err.message });
829
+ }
830
+ });
831
+
730
832
  // SPA fallback
731
833
  app.get('*', (req, res) => {
732
834
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
package/ui/src/App.jsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
3
- import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react'
3
+ import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown, Target } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
6
  import { useLive } from './hooks/useLive'
@@ -21,6 +21,7 @@ import Subscriptions from './pages/Subscriptions'
21
21
  import MCPs from './pages/MCPs'
22
22
  import RelayDashboard from './pages/RelayDashboard'
23
23
  import RelayUserDetail from './pages/RelayUserDetail'
24
+ import GSD from './pages/GSD'
24
25
 
25
26
  function NavDropdown({ icon: Icon, label, items }) {
26
27
  const [open, setOpen] = useState(false)
@@ -154,6 +155,7 @@ export default function App() {
154
155
  { to: '/compare', icon: GitCompare, label: 'Compare' },
155
156
  ]},
156
157
  { to: '/artifacts', icon: Package, label: 'Artifacts' },
158
+ { to: '/gsd', icon: Target, label: 'GSD' },
157
159
  { to: '/mcps', icon: Plug, label: 'MCPs' },
158
160
  { to: '/sql', icon: Database, label: 'SQL' },
159
161
  ]
@@ -284,6 +286,7 @@ export default function App() {
284
286
  <Route path="/artifacts" element={<Artifacts />} />
285
287
  <Route path="/mcps" element={<MCPs />} />
286
288
  <Route path="/sql" element={<SqlViewer />} />
289
+ <Route path="/gsd" element={<GSD />} />
287
290
  <Route path="/settings" element={<Settings />} />
288
291
  </Routes>
289
292
  )}
@@ -1,8 +1,9 @@
1
- import { useState, useEffect, useRef } from 'react'
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { X, Download, Send, Search } from 'lucide-react'
3
3
  import { fetchChat, BASE } from '../lib/api'
4
4
  import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
5
5
  import MessageContent, { ROLE_CONFIG } from './MessageRenderer'
6
+ import TokenTimeline from './TokenTimeline'
6
7
 
7
8
  export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, username }) {
8
9
  const [chat, setChat] = useState(null)
@@ -47,6 +48,17 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
47
48
  setMsgFilter('')
48
49
  }, [chatId])
49
50
 
51
+ const handleScrollToMessage = useCallback((msgIndex) => {
52
+ if (!scrollRef.current) return
53
+ const el = scrollRef.current.querySelector(`[data-msg-index="${msgIndex}"]`)
54
+ if (el) {
55
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
56
+ el.style.outline = '1.5px solid var(--c-accent)'
57
+ el.style.outlineOffset = '2px'
58
+ setTimeout(() => { el.style.outline = 'none' }, 1500)
59
+ }
60
+ }, [])
61
+
50
62
  if (!chatId) return null
51
63
 
52
64
  return (
@@ -107,6 +119,13 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
107
119
  {chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
108
120
  {chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
109
121
  {chat.stats.totalOutputTokens > 0 && <span>{formatNumber(chat.stats.totalOutputTokens)} out</span>}
122
+ {chat.createdAt && chat.lastUpdatedAt && (() => {
123
+ const ms = new Date(chat.lastUpdatedAt).getTime() - new Date(chat.createdAt).getTime()
124
+ if (ms <= 0) return null
125
+ const mins = Math.floor(ms / 60000)
126
+ const label = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`
127
+ return <span>{label}</span>
128
+ })()}
110
129
  {chat.stats.models?.length > 0 && (
111
130
  <span className="ml-auto font-mono truncate" style={{ color: 'var(--c-accent)', opacity: 0.7 }}>
112
131
  {[...new Set(chat.stats.models)].join(', ')}
@@ -115,6 +134,16 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
115
134
  </div>
116
135
  )}
117
136
 
137
+ {/* Token Timeline */}
138
+ {chat && chat.messages.length > 0 && (
139
+ <TokenTimeline
140
+ messages={chat.messages}
141
+ createdAt={chat.createdAt}
142
+ lastUpdatedAt={chat.lastUpdatedAt}
143
+ onScrollToMessage={handleScrollToMessage}
144
+ />
145
+ )}
146
+
118
147
  {/* Search bar */}
119
148
  {chat && chat.messages.length > 0 && (
120
149
  <div className="shrink-0 px-4 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
@@ -148,7 +177,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
148
177
  const cfg = ROLE_CONFIG[msg.role] || ROLE_CONFIG.system
149
178
  const Icon = cfg.icon
150
179
  return (
151
- <div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
180
+ <div key={i} data-msg-index={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg, transition: 'outline 0.3s' }}>
152
181
  <div className="flex items-center gap-1.5 text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>
153
182
  <Icon size={11} />
154
183
  <span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
@@ -0,0 +1,258 @@
1
+ import { useState, useRef, useCallback, useMemo } from 'react'
2
+ import { Activity, ChevronDown, ChevronUp, Clock } from 'lucide-react'
3
+ import { formatNumber } from '../lib/constants'
4
+
5
+ const CHART_H = 56
6
+ const POINT_STEP = 52
7
+ const PAD_X = 20
8
+ const PAD_Y = 6
9
+ const BUCKET_MINUTES = 5
10
+ const BUCKET_MSG_COUNT = 8
11
+
12
+ const AVG_TOKEN_SPEED = 80
13
+
14
+ function formatDuration(ms) {
15
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`
16
+ const mins = Math.floor(ms / 60000)
17
+ if (mins < 60) return `${mins}m`
18
+ const hrs = Math.floor(mins / 60)
19
+ const rem = mins % 60
20
+ return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`
21
+ }
22
+
23
+ function lerp(a, b, t) {
24
+ return a + (b - a) * Math.max(0, Math.min(1, t))
25
+ }
26
+
27
+ function tokenColor(ratio) {
28
+ const r = Math.round(lerp(52, 239, ratio))
29
+ const g = Math.round(lerp(211, 68, ratio))
30
+ const b = Math.round(lerp(153, 68, ratio))
31
+ return `rgb(${r},${g},${b})`
32
+ }
33
+
34
+ function buildTimeBuckets(messages, createdAt, lastUpdatedAt) {
35
+ const start = new Date(createdAt).getTime()
36
+ const end = new Date(lastUpdatedAt).getTime()
37
+ const duration = Math.max(end - start, 1)
38
+ const bucketMs = BUCKET_MINUTES * 60 * 1000
39
+ const bucketCount = Math.max(1, Math.ceil(duration / bucketMs))
40
+
41
+ const buckets = Array.from({ length: bucketCount }, (_, i) => ({
42
+ label: `${i * BUCKET_MINUTES}m`,
43
+ tokens: 0,
44
+ messageIndices: [],
45
+ }))
46
+
47
+ messages.forEach((msg, idx) => {
48
+ const totalTokens = (msg.inputTokens || 0) + (msg.outputTokens || 0)
49
+ const msgTime = start + (idx / Math.max(messages.length - 1, 1)) * duration
50
+ const bucketIdx = Math.min(Math.floor((msgTime - start) / bucketMs), bucketCount - 1)
51
+ buckets[bucketIdx].tokens += totalTokens
52
+ buckets[bucketIdx].messageIndices.push(idx)
53
+ })
54
+
55
+ return buckets
56
+ }
57
+
58
+ function buildSeqBuckets(messages) {
59
+ const bucketCount = Math.max(1, Math.ceil(messages.length / BUCKET_MSG_COUNT))
60
+
61
+ const buckets = Array.from({ length: bucketCount }, () => ({
62
+ label: '',
63
+ tokens: 0,
64
+ messageIndices: [],
65
+ }))
66
+
67
+ messages.forEach((msg, idx) => {
68
+ const chars = typeof msg.content === 'string' ? msg.content.length : 0
69
+ const totalTokens = Math.round(chars / 4)
70
+ const bucketIdx = Math.min(Math.floor(idx / BUCKET_MSG_COUNT), bucketCount - 1)
71
+ buckets[bucketIdx].tokens += totalTokens
72
+ buckets[bucketIdx].messageIndices.push(idx)
73
+ })
74
+
75
+ return buckets
76
+ }
77
+
78
+ export default function TokenTimeline({ messages, createdAt, lastUpdatedAt, onScrollToMessage }) {
79
+ const [open, setOpen] = useState(true)
80
+ const [hoveredIdx, setHoveredIdx] = useState(null)
81
+ const scrollContainerRef = useRef(null)
82
+
83
+ const hasRealTokens = useMemo(() => {
84
+ if (!messages) return false
85
+ return messages.some(m => (m.inputTokens || 0) + (m.outputTokens || 0) > 0)
86
+ }, [messages])
87
+
88
+ const buckets = useMemo(() => {
89
+ if (!messages || messages.length === 0) return []
90
+ if (hasRealTokens) return buildTimeBuckets(messages, createdAt, lastUpdatedAt)
91
+ return buildSeqBuckets(messages)
92
+ }, [messages, createdAt, lastUpdatedAt, hasRealTokens])
93
+
94
+ const maxTokens = useMemo(() => Math.max(...buckets.map(b => b.tokens), 1), [buckets])
95
+
96
+ const totalTokens = useMemo(() => buckets.reduce((s, b) => s + b.tokens, 0), [buckets])
97
+
98
+ const durationLabel = useMemo(() => {
99
+ if (hasRealTokens && createdAt && lastUpdatedAt) {
100
+ const ms = new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
101
+ if (ms > 0) return formatDuration(ms)
102
+ }
103
+ if (totalTokens > 0) {
104
+ const estSeconds = totalTokens / AVG_TOKEN_SPEED
105
+ return '~' + formatDuration(estSeconds * 1000)
106
+ }
107
+ return null
108
+ }, [hasRealTokens, createdAt, lastUpdatedAt, totalTokens])
109
+
110
+ const handlePointClick = useCallback((bucket) => {
111
+ if (bucket.messageIndices.length > 0 && onScrollToMessage) {
112
+ onScrollToMessage(bucket.messageIndices[0])
113
+ }
114
+ }, [onScrollToMessage])
115
+
116
+ if (!messages || messages.length === 0 || totalTokens === 0) return null
117
+
118
+ const svgW = PAD_X * 2 + Math.max((buckets.length - 1) * POINT_STEP, 0)
119
+ const svgH = CHART_H + PAD_Y * 2 + (hasRealTokens ? 16 : 4)
120
+
121
+ const points = buckets.map((b, i) => {
122
+ const x = PAD_X + i * POINT_STEP
123
+ const ratio = b.tokens / maxTokens
124
+ const y = PAD_Y + CHART_H - ratio * CHART_H
125
+ return { x, y, ratio, bucket: b }
126
+ })
127
+
128
+ const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
129
+
130
+ const areaPath = points.length > 0
131
+ ? `${linePath} L ${points[points.length - 1].x} ${PAD_Y + CHART_H} L ${points[0].x} ${PAD_Y + CHART_H} Z`
132
+ : ''
133
+
134
+ return (
135
+ <div className="shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
136
+ {/* Toggle button */}
137
+ <button
138
+ onClick={() => setOpen(o => !o)}
139
+ className="flex items-center gap-2 w-full px-4 py-1.5 text-[11px] transition hover:bg-[var(--c-bg3)]"
140
+ style={{ color: 'var(--c-text2)' }}
141
+ >
142
+ <Activity size={12} style={{ color: 'var(--c-accent)' }} />
143
+ <span className="font-medium">Token Timeline</span>
144
+ <span style={{ color: 'var(--c-text3)' }}>
145
+ ({formatNumber(totalTokens)} tokens{!hasRealTokens ? ', est.' : ''})
146
+ </span>
147
+ {durationLabel && (
148
+ <span className="inline-flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
149
+ <Clock size={10} /> {durationLabel}
150
+ </span>
151
+ )}
152
+ <span className="ml-auto">
153
+ {open ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
154
+ </span>
155
+ </button>
156
+
157
+ {/* Collapsible chart area */}
158
+ {open && (
159
+ <div className="px-2 pb-2 pt-0">
160
+ <div
161
+ ref={scrollContainerRef}
162
+ className="overflow-x-auto scrollbar-thin"
163
+ style={{ scrollBehavior: 'smooth' }}
164
+ >
165
+ <svg
166
+ width={svgW}
167
+ height={svgH}
168
+ style={{ display: 'block', minWidth: svgW }}
169
+ >
170
+ <defs>
171
+ <linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
172
+ <stop offset="0%" stopColor="var(--c-accent)" stopOpacity="0.15" />
173
+ <stop offset="100%" stopColor="var(--c-accent)" stopOpacity="0.02" />
174
+ </linearGradient>
175
+ </defs>
176
+
177
+ {/* Baseline */}
178
+ <line
179
+ x1={PAD_X} y1={PAD_Y + CHART_H}
180
+ x2={PAD_X + (buckets.length - 1) * POINT_STEP} y2={PAD_Y + CHART_H}
181
+ stroke="var(--c-border)" strokeWidth="1"
182
+ />
183
+
184
+ {/* Area fill */}
185
+ {areaPath && <path d={areaPath} fill="url(#tokenAreaGrad)" />}
186
+
187
+ {/* Line */}
188
+ {points.length > 1 && (
189
+ <path d={linePath} fill="none" stroke="var(--c-accent)" strokeWidth="1.5" strokeLinejoin="round" />
190
+ )}
191
+
192
+ {/* Data points + labels */}
193
+ {points.map((p, i) => {
194
+ const isHovered = hoveredIdx === i
195
+ const color = tokenColor(p.ratio)
196
+ return (
197
+ <g
198
+ key={i}
199
+ style={{ cursor: p.bucket.tokens > 0 ? 'pointer' : 'default' }}
200
+ onMouseEnter={() => setHoveredIdx(i)}
201
+ onMouseLeave={() => setHoveredIdx(null)}
202
+ onClick={() => handlePointClick(p.bucket)}
203
+ >
204
+ <circle cx={p.x} cy={p.y} r={12} fill="transparent" />
205
+
206
+ <circle
207
+ cx={p.x} cy={p.y}
208
+ r={isHovered ? 5 : 3}
209
+ fill={p.bucket.tokens > 0 ? color : 'var(--c-border)'}
210
+ stroke={isHovered ? 'var(--c-white)' : 'none'}
211
+ strokeWidth={1.5}
212
+ style={{ transition: 'r 0.15s' }}
213
+ />
214
+
215
+ {isHovered && (
216
+ <line
217
+ x1={p.x} y1={p.y} x2={p.x} y2={PAD_Y + CHART_H}
218
+ stroke="var(--c-text3)" strokeWidth="0.5" strokeDasharray="2,2"
219
+ />
220
+ )}
221
+
222
+ {isHovered && p.bucket.tokens > 0 && (
223
+ <g>
224
+ <rect
225
+ x={p.x - 32} y={p.y - 22}
226
+ width={64} height={16} rx={3}
227
+ fill="var(--c-bg)" stroke="var(--c-border)" strokeWidth="0.5"
228
+ />
229
+ <text
230
+ x={p.x} y={p.y - 11}
231
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
232
+ fill="var(--c-white)"
233
+ >
234
+ {formatNumber(p.bucket.tokens)} tok
235
+ </text>
236
+ </g>
237
+ )}
238
+
239
+ {/* X label — only for time-based buckets */}
240
+ {hasRealTokens && p.bucket.label && (
241
+ <text
242
+ x={p.x} y={PAD_Y + CHART_H + 12}
243
+ textAnchor="middle" fontSize="9" fontFamily="monospace"
244
+ fill="var(--c-text3)"
245
+ >
246
+ {p.bucket.label}
247
+ </text>
248
+ )}
249
+ </g>
250
+ )
251
+ })}
252
+ </svg>
253
+ </div>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
package/ui/src/lib/api.js CHANGED
@@ -234,6 +234,49 @@ export async function fetchMCPs() {
234
234
  return res.json();
235
235
  }
236
236
 
237
+ // ── GSD API ──
238
+
239
+ export async function fetchGSDProjects() {
240
+ const res = await fetch(`${BASE}/api/gsd/projects`);
241
+ return res.json();
242
+ }
243
+
244
+ export async function fetchGSDPhases(folder) {
245
+ const q = new URLSearchParams({ folder });
246
+ const res = await fetch(`${BASE}/api/gsd/phases?${q}`);
247
+ return res.json();
248
+ }
249
+
250
+ export async function fetchGSDPlan(folder, phase) {
251
+ const q = new URLSearchParams({ folder, phase });
252
+ const res = await fetch(`${BASE}/api/gsd/plan?${q}`);
253
+ return res.json();
254
+ }
255
+
256
+ export async function fetchGSDOverview() {
257
+ const res = await fetch(`${BASE}/api/gsd/overview`);
258
+ return res.json();
259
+ }
260
+
261
+ export async function fetchGSDConfig(folder) {
262
+ const q = new URLSearchParams({ folder });
263
+ const res = await fetch(`${BASE}/api/gsd/config?${q}`);
264
+ return res.json();
265
+ }
266
+
267
+ export async function fetchGSDPhaseTokens(folder) {
268
+ const q = new URLSearchParams({ folder });
269
+ const res = await fetch(`${BASE}/api/gsd/phase-tokens?${q}`);
270
+ return res.json();
271
+ }
272
+
273
+ export async function fetchGSDFile(folder, type, phaseDir) {
274
+ const q = new URLSearchParams({ folder, type });
275
+ if (phaseDir) q.set('phase', phaseDir);
276
+ const res = await fetch(`${BASE}/api/gsd/file?${q}`);
277
+ return res.json();
278
+ }
279
+
237
280
  // ── Relay API ──
238
281
 
239
282
  export async function fetchMode() {