the-campfire 0.1.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.
Files changed (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Recording diagnostics — analyzes session recordings for health issues.
3
+ *
4
+ * Goes beyond companion's disconnection-only analysis to include:
5
+ * - Disconnection patterns and data gaps
6
+ * - Message rate anomalies
7
+ * - Permission response time analysis
8
+ * - Cost tracking per turn
9
+ */
10
+
11
+ import type { Recording } from "../replay.js";
12
+ import type { RecordingEntry } from "../recorder.js";
13
+
14
+ // ─── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ export interface TimelineEvent {
17
+ ts: number;
18
+ event: string;
19
+ channel: "cli" | "browser";
20
+ detail?: string;
21
+ }
22
+
23
+ export interface DataGap {
24
+ startTs: number;
25
+ endTs: number;
26
+ gapMs: number;
27
+ channel: "cli" | "browser";
28
+ messagesBefore: number;
29
+ messagesAfter: number;
30
+ }
31
+
32
+ export interface DisconnectionEvent {
33
+ ts: number;
34
+ channel: "cli" | "browser";
35
+ gapMs: number;
36
+ messagesLostEstimate: number;
37
+ }
38
+
39
+ export interface PermissionTiming {
40
+ requestTs: number;
41
+ responseTs: number | null;
42
+ responseTimeMs: number | null;
43
+ toolName: string;
44
+ }
45
+
46
+ export interface DiagnosticsReport {
47
+ sessionId: string;
48
+ backendType: string;
49
+ totalDuration: number;
50
+ totalMessages: number;
51
+ messageRate: number;
52
+ disconnections: DisconnectionEvent[];
53
+ dataGaps: DataGap[];
54
+ patterns: string[];
55
+ permissionTimings: PermissionTiming[];
56
+ avgPermissionResponseMs: number | null;
57
+ messageTypeDistribution: Record<string, number>;
58
+ }
59
+
60
+ // ─── Constants ──────────────────────────────────────────────────────────────
61
+
62
+ const CLI_GAP_THRESHOLD_MS = 30_000;
63
+ const BROWSER_GAP_THRESHOLD_MS = 15_000;
64
+
65
+ // ─── Diagnostics ────────────────────────────────────────────────────────────
66
+
67
+ export function analyzeRecording(recording: Recording): DiagnosticsReport {
68
+ const entries = recording.entries;
69
+ if (entries.length === 0) {
70
+ return emptyReport(recording);
71
+ }
72
+
73
+ const firstTs = entries[0].ts;
74
+ const lastTs = entries[entries.length - 1].ts;
75
+ const totalDuration = lastTs - firstTs;
76
+
77
+ // Message type distribution
78
+ const typeDistribution: Record<string, number> = {};
79
+ for (const entry of entries) {
80
+ const parsed = safeParse(entry);
81
+ const msgType = parsed?.type as string || entry.ch;
82
+ typeDistribution[msgType] = (typeDistribution[msgType] || 0) + 1;
83
+ }
84
+
85
+ // Data gaps per channel
86
+ const dataGaps = findDataGaps(entries);
87
+
88
+ // Disconnection events
89
+ const disconnections = findDisconnections(entries);
90
+
91
+ // Permission timing analysis
92
+ const permissionTimings = analyzePermissions(entries);
93
+
94
+ // Patterns
95
+ const patterns = detectPatterns(disconnections, dataGaps, entries);
96
+
97
+ // Average permission response time
98
+ const responded = permissionTimings.filter((p) => p.responseTimeMs !== null);
99
+ const avgPermissionResponseMs = responded.length > 0
100
+ ? Math.round(responded.reduce((sum, p) => sum + (p.responseTimeMs || 0), 0) / responded.length)
101
+ : null;
102
+
103
+ return {
104
+ sessionId: recording.header.session_id,
105
+ backendType: recording.header.backend_type || "unknown",
106
+ totalDuration,
107
+ totalMessages: entries.length,
108
+ messageRate: totalDuration > 0 ? Math.round((entries.length / totalDuration) * 60_000) : 0,
109
+ disconnections,
110
+ dataGaps,
111
+ patterns,
112
+ permissionTimings,
113
+ avgPermissionResponseMs,
114
+ messageTypeDistribution: typeDistribution,
115
+ };
116
+ }
117
+
118
+ export function buildTimeline(recording: Recording): TimelineEvent[] {
119
+ const events: TimelineEvent[] = [];
120
+ for (const entry of recording.entries) {
121
+ const parsed = safeParse(entry);
122
+ const msgType = parsed?.type as string || "data";
123
+ events.push({
124
+ ts: entry.ts,
125
+ event: msgType,
126
+ channel: entry.ch,
127
+ detail: entry.dir,
128
+ });
129
+ }
130
+ return events;
131
+ }
132
+
133
+ // ─── Helpers ────────────────────────────────────────────────────────────────
134
+
135
+ function findDataGaps(entries: RecordingEntry[]): DataGap[] {
136
+ const gaps: DataGap[] = [];
137
+ const byChannel = new Map<string, RecordingEntry[]>();
138
+
139
+ for (const e of entries) {
140
+ const list = byChannel.get(e.ch) || [];
141
+ list.push(e);
142
+ byChannel.set(e.ch, list);
143
+ }
144
+
145
+ for (const [ch, channelEntries] of byChannel) {
146
+ const threshold = ch === "cli" ? CLI_GAP_THRESHOLD_MS : BROWSER_GAP_THRESHOLD_MS;
147
+ for (let i = 1; i < channelEntries.length; i++) {
148
+ const gapMs = channelEntries[i].ts - channelEntries[i - 1].ts;
149
+ if (gapMs > threshold) {
150
+ gaps.push({
151
+ startTs: channelEntries[i - 1].ts,
152
+ endTs: channelEntries[i].ts,
153
+ gapMs,
154
+ channel: ch as "cli" | "browser",
155
+ messagesBefore: i,
156
+ messagesAfter: channelEntries.length - i,
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ return gaps.sort((a, b) => a.startTs - b.startTs);
163
+ }
164
+
165
+ function findDisconnections(entries: RecordingEntry[]): DisconnectionEvent[] {
166
+ const disconnections: DisconnectionEvent[] = [];
167
+
168
+ for (const entry of entries) {
169
+ const parsed = safeParse(entry);
170
+ if (!parsed) continue;
171
+ const msgType = parsed.type as string;
172
+ if (msgType === "cli_disconnected" || msgType === "cli_connected") {
173
+ if (msgType === "cli_disconnected") {
174
+ disconnections.push({
175
+ ts: entry.ts,
176
+ channel: entry.ch,
177
+ gapMs: 0,
178
+ messagesLostEstimate: 0,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ return disconnections;
185
+ }
186
+
187
+ function analyzePermissions(entries: RecordingEntry[]): PermissionTiming[] {
188
+ const pending = new Map<string, { ts: number; toolName: string }>();
189
+ const timings: PermissionTiming[] = [];
190
+
191
+ for (const entry of entries) {
192
+ const parsed = safeParse(entry);
193
+ if (!parsed) continue;
194
+ const msgType = parsed.type as string;
195
+
196
+ if (msgType === "permission_request") {
197
+ const req = parsed.request as Record<string, unknown> | undefined;
198
+ const requestId = req?.request_id as string || parsed.request_id as string;
199
+ const toolName = req?.tool_name as string || "unknown";
200
+ if (requestId) pending.set(requestId, { ts: entry.ts, toolName });
201
+ }
202
+
203
+ if (msgType === "permission_response" || msgType === "permission_cancelled") {
204
+ const requestId = parsed.request_id as string;
205
+ const req = pending.get(requestId);
206
+ if (req) {
207
+ timings.push({
208
+ requestTs: req.ts,
209
+ responseTs: entry.ts,
210
+ responseTimeMs: entry.ts - req.ts,
211
+ toolName: req.toolName,
212
+ });
213
+ pending.delete(requestId);
214
+ }
215
+ }
216
+ }
217
+
218
+ // Add unresolved permissions
219
+ for (const [, req] of pending) {
220
+ timings.push({
221
+ requestTs: req.ts,
222
+ responseTs: null,
223
+ responseTimeMs: null,
224
+ toolName: req.toolName,
225
+ });
226
+ }
227
+
228
+ return timings;
229
+ }
230
+
231
+ function detectPatterns(
232
+ disconnections: DisconnectionEvent[],
233
+ gaps: DataGap[],
234
+ entries: RecordingEntry[],
235
+ ): string[] {
236
+ const patterns: string[] = [];
237
+
238
+ if (disconnections.length >= 3) {
239
+ patterns.push(`Frequent disconnections: ${disconnections.length} events detected`);
240
+ }
241
+
242
+ const cliGaps = gaps.filter((g) => g.channel === "cli");
243
+ if (cliGaps.length >= 2) {
244
+ patterns.push(`CLI data gaps: ${cliGaps.length} periods with no CLI messages (>${CLI_GAP_THRESHOLD_MS / 1000}s each)`);
245
+ }
246
+
247
+ const browserGaps = gaps.filter((g) => g.channel === "browser");
248
+ if (browserGaps.length >= 2) {
249
+ patterns.push(`Browser data gaps: ${browserGaps.length} periods with no browser messages`);
250
+ }
251
+
252
+ if (entries.length > 0) {
253
+ const duration = (entries.at(-1)?.ts ?? 0) - entries[0].ts;
254
+ if (duration > 0 && entries.length / duration < 0.001) {
255
+ patterns.push("Very low message rate — possible idle or stalled session");
256
+ }
257
+ }
258
+
259
+ if (patterns.length === 0) {
260
+ patterns.push("No anomalies detected");
261
+ }
262
+
263
+ return patterns;
264
+ }
265
+
266
+ function emptyReport(recording: Recording): DiagnosticsReport {
267
+ return {
268
+ sessionId: recording.header.session_id,
269
+ backendType: recording.header.backend_type || "unknown",
270
+ totalDuration: 0,
271
+ totalMessages: 0,
272
+ messageRate: 0,
273
+ disconnections: [],
274
+ dataGaps: [],
275
+ patterns: ["Empty recording"],
276
+ permissionTimings: [],
277
+ avgPermissionResponseMs: null,
278
+ messageTypeDistribution: {},
279
+ };
280
+ }
281
+
282
+ function safeParse(entry: RecordingEntry): Record<string, unknown> | null {
283
+ try {
284
+ const obj = JSON.parse(entry.raw);
285
+ return typeof obj === "object" && obj !== null ? obj : null;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Recording Hub REST API routes.
3
+ *
4
+ * Provides CRUD for hub recordings, validation, diagnostics,
5
+ * timeline, import/upload, and tag management.
6
+ */
7
+
8
+ import type { Hono } from "hono";
9
+ import type { RouteDeps } from "../routes/route-deps.js";
10
+ import { loadRecording } from "../replay.js";
11
+ import { validateRecording } from "./compat-validator.js";
12
+ import { analyzeRecording, buildTimeline } from "./diagnostics.js";
13
+ import * as hubStore from "./hub-store.js";
14
+
15
+ export function registerHubRoutes(api: Hono, deps: RouteDeps): void {
16
+ const { recorder } = deps;
17
+
18
+ // ─── List hub recordings ────────────────────────────────────────────
19
+ api.get("/hub/recordings", (c) => {
20
+ if (recorder) {
21
+ try {
22
+ hubStore.indexExistingRecordings(recorder);
23
+ } catch {
24
+ // Listing should remain available even if a filesystem scan fails.
25
+ }
26
+ }
27
+ return c.json(hubStore.listHubRecordings());
28
+ });
29
+
30
+ // ─── Get single recording metadata ─────────────────────────────────
31
+ api.get("/hub/recordings/:id", (c) => {
32
+ const meta = hubStore.getHubRecording(c.req.param("id"));
33
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
34
+ return c.json(meta);
35
+ });
36
+
37
+ // ─── Get recording summary (with tool names, permission count) ─────
38
+ api.get("/hub/recordings/:id/summary", (c) => {
39
+ const summary = hubStore.getSummary(c.req.param("id"));
40
+ if (!summary) return c.json({ error: "Recording not found" }, 404);
41
+ return c.json(summary);
42
+ });
43
+
44
+ // ─── Update tags ───────────────────────────────────────────────────
45
+ api.put("/hub/recordings/:id/tags", async (c) => {
46
+ const body = await c.req.json().catch(() => ({}));
47
+ if (!Array.isArray(body.tags)) return c.json({ error: "tags must be an array" }, 400);
48
+ const meta = hubStore.updateTags(c.req.param("id"), body.tags);
49
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
50
+ return c.json(meta);
51
+ });
52
+
53
+ // ─── Delete hub recording (from index only, not the file) ──────────
54
+ api.delete("/hub/recordings/:id", (c) => {
55
+ const deleted = hubStore.deleteHubRecording(c.req.param("id"));
56
+ if (!deleted) return c.json({ error: "Recording not found" }, 404);
57
+ return c.json({ ok: true });
58
+ });
59
+
60
+ // ─── Upload recording (JSONL content) ──────────────────────────────
61
+ api.post("/hub/recordings/upload", async (c) => {
62
+ const contentType = c.req.header("content-type") || "";
63
+ let content: string;
64
+ let filename: string | undefined;
65
+
66
+ if (contentType.includes("application/json")) {
67
+ const body = await c.req.json().catch(() => ({}));
68
+ content = typeof body.content === "string" ? body.content : "";
69
+ filename = typeof body.filename === "string" ? body.filename : undefined;
70
+ } else {
71
+ content = await c.req.text();
72
+ }
73
+
74
+ if (!content.trim()) return c.json({ error: "Empty content" }, 400);
75
+
76
+ try {
77
+ const meta = hubStore.uploadRecording(content, filename);
78
+ return c.json(meta, 201);
79
+ } catch (e: unknown) {
80
+ return c.json({ error: e instanceof Error ? e.message : "Upload failed" }, 400);
81
+ }
82
+ });
83
+
84
+ // ─── Import from existing auto-recording ───────────────────────────
85
+ api.post("/hub/recordings/import", async (c) => {
86
+ const body = await c.req.json().catch(() => ({}));
87
+ const filename = typeof body.filename === "string" ? body.filename : "";
88
+ if (!filename) return c.json({ error: "filename is required" }, 400);
89
+
90
+ const recordings = recorder?.listRecordings() || [];
91
+ const match = recordings.find((r) => r.filename === filename);
92
+ if (!match) return c.json({ error: "Recording file not found" }, 404);
93
+
94
+ try {
95
+ const recDir = process.env.CAMPFIRE_RECORDINGS_DIR || `${process.env.HOME || ""}/.campfire/recordings`;
96
+ const filePath = `${recDir}/${filename}`;
97
+ const meta = hubStore.importRecording(filePath);
98
+ return c.json(meta, 201);
99
+ } catch (e: unknown) {
100
+ return c.json({ error: e instanceof Error ? e.message : "Import failed" }, 400);
101
+ }
102
+ });
103
+
104
+ // ─── Index all existing auto-recordings ────────────────────────────
105
+ api.post("/hub/recordings/index-all", (c) => {
106
+ if (!recorder) return c.json({ error: "Recorder not available" }, 500);
107
+ const count = hubStore.indexExistingRecordings(recorder);
108
+ return c.json({ ok: true, imported: count });
109
+ });
110
+
111
+ // ─── Validate recording (compat check) ─────────────────────────────
112
+ api.get("/hub/recordings/:id/validate", (c) => {
113
+ const meta = hubStore.getHubRecording(c.req.param("id"));
114
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
115
+ try {
116
+ const recording = loadRecording(meta.filePath);
117
+ return c.json(validateRecording(recording));
118
+ } catch (e: unknown) {
119
+ return c.json({ error: e instanceof Error ? e.message : "Validation failed" }, 500);
120
+ }
121
+ });
122
+
123
+ // ─── Diagnostics (health report) ───────────────────────────────────
124
+ api.get("/hub/recordings/:id/diagnostics", (c) => {
125
+ const meta = hubStore.getHubRecording(c.req.param("id"));
126
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
127
+ try {
128
+ const recording = loadRecording(meta.filePath);
129
+ return c.json(analyzeRecording(recording));
130
+ } catch (e: unknown) {
131
+ return c.json({ error: e instanceof Error ? e.message : "Diagnostics failed" }, 500);
132
+ }
133
+ });
134
+
135
+ // ─── Timeline ──────────────────────────────────────────────────────
136
+ api.get("/hub/recordings/:id/timeline", (c) => {
137
+ const meta = hubStore.getHubRecording(c.req.param("id"));
138
+ if (!meta) return c.json({ error: "Recording not found" }, 404);
139
+ try {
140
+ const recording = loadRecording(meta.filePath);
141
+ return c.json(buildTimeline(recording));
142
+ } catch (e: unknown) {
143
+ return c.json({ error: e instanceof Error ? e.message : "Timeline failed" }, 500);
144
+ }
145
+ });
146
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Recording Hub Store — metadata index overlay on existing recordings.
3
+ *
4
+ * Unlike companion (separate ~/.companion/hub/ storage), this extends
5
+ * the existing ~/.campfire/recordings/ with an index.json for metadata,
6
+ * tags, and summaries. No duplicate file storage.
7
+ */
8
+
9
+ import {
10
+ mkdirSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ existsSync,
14
+ } from "node:fs";
15
+ import { join, basename } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { randomUUID } from "node:crypto";
18
+ import { loadRecording, type Recording } from "../replay.js";
19
+ import type { RecorderManager } from "../recorder.js";
20
+
21
+ // ─── Types ──────────────────────────────────────────────────────────────────
22
+
23
+ export interface HubRecordingMeta {
24
+ id: string;
25
+ filename: string;
26
+ filePath: string;
27
+ sessionId: string;
28
+ backendType: string;
29
+ startedAt: number;
30
+ duration: number;
31
+ entryCount: number;
32
+ tags: string[];
33
+ importedAt: number;
34
+ messageTypeSummary: Record<string, number>;
35
+ }
36
+
37
+ export interface HubRecordingSummary extends HubRecordingMeta {
38
+ toolNames: string[];
39
+ permissionCount: number;
40
+ }
41
+
42
+ // ─── Paths ──────────────────────────────────────────────────────────────────
43
+
44
+ const CAMPFIRE_DIR = join(homedir(), ".campfire");
45
+ const RECORDINGS_DIR = process.env.CAMPFIRE_RECORDINGS_DIR || join(CAMPFIRE_DIR, "recordings");
46
+ const INDEX_PATH = join(RECORDINGS_DIR, "hub-index.json");
47
+
48
+ // ─── Store ──────────────────────────────────────────────────────────────────
49
+
50
+ let index: HubRecordingMeta[] = [];
51
+ let loaded = false;
52
+
53
+ function ensureLoaded(): void {
54
+ if (loaded) return;
55
+ try {
56
+ if (existsSync(INDEX_PATH)) {
57
+ const raw = readFileSync(INDEX_PATH, "utf-8");
58
+ index = JSON.parse(raw);
59
+ }
60
+ } catch {
61
+ index = [];
62
+ }
63
+ loaded = true;
64
+ }
65
+
66
+ function persist(): void {
67
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
68
+ writeFileSync(INDEX_PATH, JSON.stringify(index, null, 2), "utf-8");
69
+ }
70
+
71
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
72
+
73
+ export function listHubRecordings(): HubRecordingMeta[] {
74
+ ensureLoaded();
75
+ return [...index].sort((a, b) => b.importedAt - a.importedAt);
76
+ }
77
+
78
+ export function getHubRecording(id: string): HubRecordingMeta | null {
79
+ ensureLoaded();
80
+ return index.find((r) => r.id === id) || null;
81
+ }
82
+
83
+ export function importRecording(filePath: string): HubRecordingMeta {
84
+ const recording = loadRecording(filePath);
85
+ const meta = buildMeta(recording, filePath);
86
+
87
+ ensureLoaded();
88
+ // Deduplicate by filename
89
+ const existing = index.findIndex((r) => r.filename === meta.filename);
90
+ if (existing >= 0) {
91
+ index[existing] = meta;
92
+ } else {
93
+ index.push(meta);
94
+ }
95
+ persist();
96
+ return meta;
97
+ }
98
+
99
+ export function uploadRecording(content: string, originalFilename?: string): HubRecordingMeta {
100
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
101
+ const filename = originalFilename || `uploaded_${Date.now()}_${randomUUID().slice(0, 6)}.jsonl`;
102
+ const destPath = join(RECORDINGS_DIR, filename);
103
+ writeFileSync(destPath, content, "utf-8");
104
+
105
+ const recording = loadRecording(destPath);
106
+ const meta = buildMeta(recording, destPath);
107
+
108
+ ensureLoaded();
109
+ index.push(meta);
110
+ persist();
111
+ return meta;
112
+ }
113
+
114
+ export function updateTags(id: string, tags: string[]): HubRecordingMeta | null {
115
+ ensureLoaded();
116
+ const entry = index.find((r) => r.id === id);
117
+ if (!entry) return null;
118
+ entry.tags = tags;
119
+ persist();
120
+ return entry;
121
+ }
122
+
123
+ export function deleteHubRecording(id: string): boolean {
124
+ ensureLoaded();
125
+ const idx = index.findIndex((r) => r.id === id);
126
+ if (idx < 0) return false;
127
+ index.splice(idx, 1);
128
+ persist();
129
+ return true;
130
+ }
131
+
132
+ function extractToolNames(parsed: Record<string, unknown>, toolNames: Set<string>): void {
133
+ const msg = parsed.message as Record<string, unknown> | undefined;
134
+ const content = msg?.content as Array<Record<string, unknown>> | undefined;
135
+ if (!Array.isArray(content)) return;
136
+ for (const block of content) {
137
+ if (block.type === "tool_use" && typeof block.name === "string") {
138
+ toolNames.add(block.name);
139
+ }
140
+ }
141
+ }
142
+
143
+ function extractSummaryFromRecording(recording: Recording): { toolNames: string[]; permissionCount: number } {
144
+ const toolNames = new Set<string>();
145
+ let permissionCount = 0;
146
+
147
+ for (const entry of recording.entries) {
148
+ try {
149
+ const parsed = JSON.parse(entry.raw) as Record<string, unknown>;
150
+ if (parsed.type === "permission_request") permissionCount++;
151
+ if (parsed.type === "assistant") extractToolNames(parsed, toolNames);
152
+ } catch { /* skip malformed */ }
153
+ }
154
+
155
+ return { toolNames: [...toolNames].sort((a, b) => a.localeCompare(b)), permissionCount };
156
+ }
157
+
158
+ export function getSummary(id: string): HubRecordingSummary | null {
159
+ ensureLoaded();
160
+ const meta = index.find((r) => r.id === id);
161
+ if (!meta) return null;
162
+
163
+ try {
164
+ const recording = loadRecording(meta.filePath);
165
+ const { toolNames, permissionCount } = extractSummaryFromRecording(recording);
166
+ return { ...meta, toolNames, permissionCount };
167
+ } catch {
168
+ return { ...meta, toolNames: [], permissionCount: 0 };
169
+ }
170
+ }
171
+
172
+ /** Import all existing auto-recordings into the hub index. */
173
+ export function indexExistingRecordings(recorder: RecorderManager): number {
174
+ const recordings = recorder.listRecordings();
175
+ let imported = 0;
176
+ ensureLoaded();
177
+ const existingFilenames = new Set(index.map((r) => r.filename));
178
+
179
+ for (const rec of recordings) {
180
+ if (existingFilenames.has(rec.filename)) continue;
181
+ try {
182
+ const filePath = join(RECORDINGS_DIR, rec.filename);
183
+ if (!existsSync(filePath)) continue;
184
+ const recording = loadRecording(filePath);
185
+ const meta = buildMeta(recording, filePath);
186
+ index.push(meta);
187
+ imported++;
188
+ } catch { /* skip corrupt files */ }
189
+ }
190
+
191
+ if (imported > 0) persist();
192
+ return imported;
193
+ }
194
+
195
+ // ─── Helpers ────────────────────────────────────────────────────────────────
196
+
197
+ function buildMeta(recording: Recording, filePath: string): HubRecordingMeta {
198
+ const entries = recording.entries;
199
+ const duration = entries.length > 1 ? entries[entries.length - 1].ts - entries[0].ts : 0;
200
+
201
+ const typeSummary: Record<string, number> = {};
202
+ for (const entry of entries) {
203
+ if (entry.dir !== "out" || entry.ch !== "browser") continue;
204
+ try {
205
+ const parsed = JSON.parse(entry.raw) as Record<string, unknown>;
206
+ const t = typeof parsed.type === "string" ? parsed.type : "unknown";
207
+ typeSummary[t] = (typeSummary[t] || 0) + 1;
208
+ } catch { /* skip */ }
209
+ }
210
+
211
+ return {
212
+ id: randomUUID().slice(0, 12),
213
+ filename: basename(filePath),
214
+ filePath,
215
+ sessionId: recording.header.session_id,
216
+ backendType: recording.header.backend_type || "unknown",
217
+ startedAt: recording.header.started_at,
218
+ duration,
219
+ entryCount: entries.length,
220
+ tags: [],
221
+ importedAt: Date.now(),
222
+ messageTypeSummary: typeSummary,
223
+ };
224
+ }