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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-bridge.ts +2322 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, appendFileSync, statSync, unlinkSync, readFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import type { BackendType } from "./session-types.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_LINES = 100_000;
|
|
8
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface RecordingHeader {
|
|
13
|
+
_header: true;
|
|
14
|
+
version: 1;
|
|
15
|
+
session_id: string;
|
|
16
|
+
backend_type: BackendType;
|
|
17
|
+
started_at: number;
|
|
18
|
+
cwd: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RecordingDirection = "in" | "out";
|
|
22
|
+
export type RecordingChannel = "cli" | "browser";
|
|
23
|
+
|
|
24
|
+
export interface RecordingEntry {
|
|
25
|
+
ts: number;
|
|
26
|
+
dir: RecordingDirection;
|
|
27
|
+
raw: string;
|
|
28
|
+
ch: RecordingChannel;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RecordingFileMeta {
|
|
32
|
+
filename: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
backendType: string;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
/** Number of lines in the file (header + entries). */
|
|
37
|
+
lines: number;
|
|
38
|
+
/** Whether the recording contains playable conversation content. */
|
|
39
|
+
hasContent: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── SessionRecorder ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Writes raw messages for a single session to a JSONL file.
|
|
46
|
+
* First line is a header with session metadata; subsequent lines are entries.
|
|
47
|
+
* Tracks its own line count so the manager can enforce the global limit.
|
|
48
|
+
*/
|
|
49
|
+
export class SessionRecorder {
|
|
50
|
+
readonly filePath: string;
|
|
51
|
+
private closed = false;
|
|
52
|
+
/** Number of lines written (1 for the header at construction). */
|
|
53
|
+
lineCount = 1;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
sessionId: string,
|
|
57
|
+
backendType: BackendType,
|
|
58
|
+
cwd: string,
|
|
59
|
+
outputDir: string,
|
|
60
|
+
) {
|
|
61
|
+
const ts = new Date().toISOString().replace(/:/g, "-");
|
|
62
|
+
const suffix = randomBytes(3).toString("hex");
|
|
63
|
+
const filename = `${sessionId}_${backendType}_${ts}_${suffix}.jsonl`;
|
|
64
|
+
this.filePath = join(outputDir, filename);
|
|
65
|
+
|
|
66
|
+
const header: RecordingHeader = {
|
|
67
|
+
_header: true,
|
|
68
|
+
version: 1,
|
|
69
|
+
session_id: sessionId,
|
|
70
|
+
backend_type: backendType,
|
|
71
|
+
started_at: Date.now(),
|
|
72
|
+
cwd,
|
|
73
|
+
};
|
|
74
|
+
appendFileSync(this.filePath, JSON.stringify(header) + "\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
record(dir: RecordingDirection, raw: string, channel: RecordingChannel): void {
|
|
78
|
+
if (this.closed) return;
|
|
79
|
+
const entry: RecordingEntry = {
|
|
80
|
+
ts: Date.now(),
|
|
81
|
+
dir,
|
|
82
|
+
raw,
|
|
83
|
+
ch: channel,
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
87
|
+
this.lineCount++;
|
|
88
|
+
} catch {
|
|
89
|
+
// Never throw — recording must not disrupt normal operation
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close(): void {
|
|
94
|
+
this.closed = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── RecorderManager ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Manages recording for all sessions.
|
|
102
|
+
*
|
|
103
|
+
* Always enabled by default. Disable explicitly with CAMPFIRE_RECORD=0.
|
|
104
|
+
*
|
|
105
|
+
* Automatic rotation: when total lines across all recording files exceed
|
|
106
|
+
* maxLines (default 100 000, override with CAMPFIRE_RECORDINGS_MAX_LINES),
|
|
107
|
+
* the oldest files are deleted until we're back under the limit.
|
|
108
|
+
*/
|
|
109
|
+
export class RecorderManager {
|
|
110
|
+
private globalEnabled: boolean;
|
|
111
|
+
private recordingsDir: string;
|
|
112
|
+
private maxLines: number;
|
|
113
|
+
private perSessionEnabled = new Set<string>();
|
|
114
|
+
private perSessionDisabled = new Set<string>();
|
|
115
|
+
private recorders = new Map<string, SessionRecorder>();
|
|
116
|
+
private dirCreated = false;
|
|
117
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
118
|
+
|
|
119
|
+
constructor(options?: {
|
|
120
|
+
globalEnabled?: boolean;
|
|
121
|
+
recordingsDir?: string;
|
|
122
|
+
maxLines?: number;
|
|
123
|
+
}) {
|
|
124
|
+
this.globalEnabled = options?.globalEnabled ?? RecorderManager.resolveEnabled();
|
|
125
|
+
this.recordingsDir =
|
|
126
|
+
options?.recordingsDir ??
|
|
127
|
+
process.env.CAMPFIRE_RECORDINGS_DIR ??
|
|
128
|
+
join(homedir(), ".campfire", "recordings");
|
|
129
|
+
this.maxLines =
|
|
130
|
+
options?.maxLines ??
|
|
131
|
+
(Number(process.env.CAMPFIRE_RECORDINGS_MAX_LINES) || DEFAULT_MAX_LINES);
|
|
132
|
+
|
|
133
|
+
if (this.globalEnabled) {
|
|
134
|
+
// Run cleanup at startup (async, non-blocking) and periodically
|
|
135
|
+
this.cleanup();
|
|
136
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
137
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Always on unless explicitly disabled with CAMPFIRE_RECORD=0|false.
|
|
143
|
+
*/
|
|
144
|
+
private static resolveEnabled(): boolean {
|
|
145
|
+
const env = process.env.CAMPFIRE_RECORD;
|
|
146
|
+
if (env === "0" || env === "false") return false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
isGloballyEnabled(): boolean {
|
|
151
|
+
return this.globalEnabled;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getRecordingsDir(): string {
|
|
155
|
+
return this.recordingsDir;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getMaxLines(): number {
|
|
159
|
+
return this.maxLines;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
isRecording(sessionId: string): boolean {
|
|
163
|
+
if (this.perSessionDisabled.has(sessionId)) return false;
|
|
164
|
+
return this.globalEnabled || this.perSessionEnabled.has(sessionId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
enableForSession(sessionId: string): void {
|
|
168
|
+
this.perSessionDisabled.delete(sessionId);
|
|
169
|
+
this.perSessionEnabled.add(sessionId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
disableForSession(sessionId: string): void {
|
|
173
|
+
this.perSessionEnabled.delete(sessionId);
|
|
174
|
+
this.perSessionDisabled.add(sessionId);
|
|
175
|
+
this.stopRecording(sessionId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Record a raw message. No-op if recording is disabled for this session.
|
|
180
|
+
* Lazily creates the SessionRecorder on first call.
|
|
181
|
+
*/
|
|
182
|
+
record(
|
|
183
|
+
sessionId: string,
|
|
184
|
+
dir: RecordingDirection,
|
|
185
|
+
raw: string,
|
|
186
|
+
channel: RecordingChannel,
|
|
187
|
+
backendType: BackendType,
|
|
188
|
+
cwd: string,
|
|
189
|
+
): void {
|
|
190
|
+
if (!this.isRecording(sessionId)) return;
|
|
191
|
+
|
|
192
|
+
let recorder = this.recorders.get(sessionId);
|
|
193
|
+
if (!recorder) {
|
|
194
|
+
this.ensureDir();
|
|
195
|
+
recorder = new SessionRecorder(sessionId, backendType, cwd, this.recordingsDir);
|
|
196
|
+
this.recorders.set(sessionId, recorder);
|
|
197
|
+
}
|
|
198
|
+
recorder.record(dir, raw, channel);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
stopRecording(sessionId: string): void {
|
|
202
|
+
const recorder = this.recorders.get(sessionId);
|
|
203
|
+
if (recorder) {
|
|
204
|
+
recorder.close();
|
|
205
|
+
this.recorders.delete(sessionId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getRecordingStatus(sessionId: string): { filePath?: string } {
|
|
210
|
+
const recorder = this.recorders.get(sessionId);
|
|
211
|
+
return recorder ? { filePath: recorder.filePath } : {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
listRecordings(): RecordingFileMeta[] {
|
|
215
|
+
try {
|
|
216
|
+
const files = readdirSync(this.recordingsDir);
|
|
217
|
+
return files
|
|
218
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
219
|
+
.map((filename) => {
|
|
220
|
+
// Format: {sessionId}_{backendType}_{ISO-timestamp}_{suffix}.jsonl
|
|
221
|
+
const withoutExt = filename.replace(/\.jsonl$/, "");
|
|
222
|
+
const firstUnderscore = withoutExt.indexOf("_");
|
|
223
|
+
const secondUnderscore = withoutExt.indexOf("_", firstUnderscore + 1);
|
|
224
|
+
if (firstUnderscore === -1 || secondUnderscore === -1) {
|
|
225
|
+
return { filename, sessionId: "", backendType: "", startedAt: "", lines: 0, hasContent: false };
|
|
226
|
+
}
|
|
227
|
+
const filePath = join(this.recordingsDir, filename);
|
|
228
|
+
const { lines, hasContent } = countFileLinesAndContent(filePath);
|
|
229
|
+
return {
|
|
230
|
+
filename,
|
|
231
|
+
sessionId: withoutExt.substring(0, firstUnderscore),
|
|
232
|
+
backendType: withoutExt.substring(firstUnderscore + 1, secondUnderscore),
|
|
233
|
+
startedAt: withoutExt.substring(secondUnderscore + 1),
|
|
234
|
+
lines,
|
|
235
|
+
hasContent,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
closeAll(): void {
|
|
244
|
+
if (this.cleanupTimer) {
|
|
245
|
+
clearInterval(this.cleanupTimer);
|
|
246
|
+
this.cleanupTimer = null;
|
|
247
|
+
}
|
|
248
|
+
for (const [, recorder] of this.recorders) {
|
|
249
|
+
recorder.close();
|
|
250
|
+
}
|
|
251
|
+
this.recorders.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Delete oldest recording files until total lines are under maxLines.
|
|
256
|
+
* Skips files that belong to active (currently recording) sessions.
|
|
257
|
+
*/
|
|
258
|
+
cleanup(): number {
|
|
259
|
+
try {
|
|
260
|
+
this.ensureDir();
|
|
261
|
+
const files = readdirSync(this.recordingsDir).filter((f) => f.endsWith(".jsonl"));
|
|
262
|
+
if (files.length === 0) return 0;
|
|
263
|
+
|
|
264
|
+
// Build list with line counts and mtime, sorted oldest-first
|
|
265
|
+
const activeFiles = new Set<string>();
|
|
266
|
+
for (const rec of this.recorders.values()) {
|
|
267
|
+
activeFiles.add(rec.filePath);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const entries: { filename: string; path: string; lines: number; mtimeMs: number }[] = [];
|
|
271
|
+
let totalLines = 0;
|
|
272
|
+
|
|
273
|
+
for (const filename of files) {
|
|
274
|
+
const fullPath = join(this.recordingsDir, filename);
|
|
275
|
+
const { lines } = countFileLinesAndContent(fullPath);
|
|
276
|
+
let mtimeMs = 0;
|
|
277
|
+
try {
|
|
278
|
+
mtimeMs = statSync(fullPath).mtimeMs;
|
|
279
|
+
} catch {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
entries.push({ filename, path: fullPath, lines, mtimeMs });
|
|
283
|
+
totalLines += lines;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (totalLines <= this.maxLines) return 0;
|
|
287
|
+
|
|
288
|
+
// Sort oldest first (lowest mtime = oldest)
|
|
289
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
290
|
+
|
|
291
|
+
let deleted = 0;
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (totalLines <= this.maxLines) break;
|
|
294
|
+
// Don't delete files that are actively being written to
|
|
295
|
+
if (activeFiles.has(entry.path)) continue;
|
|
296
|
+
try {
|
|
297
|
+
unlinkSync(entry.path);
|
|
298
|
+
totalLines -= entry.lines;
|
|
299
|
+
deleted++;
|
|
300
|
+
} catch {
|
|
301
|
+
// File may have been removed concurrently
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (deleted > 0) {
|
|
306
|
+
console.log(`[recorder] Cleanup: deleted ${deleted} old recording(s), ${totalLines} lines remaining`);
|
|
307
|
+
}
|
|
308
|
+
return deleted;
|
|
309
|
+
} catch {
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private ensureDir(): void {
|
|
315
|
+
if (this.dirCreated) return;
|
|
316
|
+
mkdirSync(this.recordingsDir, { recursive: true });
|
|
317
|
+
this.dirCreated = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Count newlines and detect playable content in a recording file.
|
|
325
|
+
* Scans the raw buffer for line count AND checks for assistant/user_message
|
|
326
|
+
* type markers to determine if the recording has conversation content.
|
|
327
|
+
* This avoids full JSON parsing for the listing endpoint.
|
|
328
|
+
*/
|
|
329
|
+
function countFileLinesAndContent(path: string): { lines: number; hasContent: boolean } {
|
|
330
|
+
try {
|
|
331
|
+
const buf = readFileSync(path);
|
|
332
|
+
let lines = 0;
|
|
333
|
+
let hasContent = false;
|
|
334
|
+
// In JSONL recordings, the raw field contains escaped JSON, so type markers
|
|
335
|
+
// appear as \"type\":\"assistant\" (backslash-escaped quotes inside the outer JSON string).
|
|
336
|
+
// Check for multiple content types to cover all backends (Claude, Codex, Goose, etc.)
|
|
337
|
+
const contentMarkers = [
|
|
338
|
+
Buffer.from(String.raw`\"type\":\"assistant\"`),
|
|
339
|
+
Buffer.from(String.raw`\"type\":\"user_message\"`),
|
|
340
|
+
Buffer.from(String.raw`\"type\":\"stream_event\"`),
|
|
341
|
+
Buffer.from(String.raw`\"type\":\"result\"`),
|
|
342
|
+
Buffer.from(String.raw`\"type\":\"session_init\"`),
|
|
343
|
+
];
|
|
344
|
+
for (let i = 0; i < buf.length; i++) {
|
|
345
|
+
if (buf[i] === 0x0a) lines++;
|
|
346
|
+
if (!hasContent) {
|
|
347
|
+
for (const marker of contentMarkers) {
|
|
348
|
+
if (i + marker.length <= buf.length && buf.subarray(i, i + marker.length).equals(marker)) {
|
|
349
|
+
hasContent = true;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { lines, hasContent };
|
|
356
|
+
} catch {
|
|
357
|
+
return { lines: 0, hasContent: false };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol compatibility validator for session recordings.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes recorded messages to detect protocol drift — catches when
|
|
5
|
+
* backend CLIs change their message format between versions.
|
|
6
|
+
* Supports ALL 7 backends (not just Claude/Codex like companion).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Recording } from "../replay.js";
|
|
10
|
+
import type { RecordingEntry } from "../recorder.js";
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface ProtocolDiff {
|
|
15
|
+
entryIndex: number;
|
|
16
|
+
messageType: string;
|
|
17
|
+
kind: "missing_field" | "extra_field" | "type_mismatch" | "unexpected_type";
|
|
18
|
+
field?: string;
|
|
19
|
+
details: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ValidationResult {
|
|
23
|
+
compatible: boolean;
|
|
24
|
+
backendType: string;
|
|
25
|
+
totalMessages: number;
|
|
26
|
+
checkedMessages: number;
|
|
27
|
+
diffs: ProtocolDiff[];
|
|
28
|
+
messageTypeBreakdown: Record<string, { count: number; issues: number }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Ignored Fields (timestamps, IDs, costs — vary per session) ─────────────
|
|
32
|
+
|
|
33
|
+
const IGNORED_FIELDS = new Set([
|
|
34
|
+
"ts", "timestamp", "created_at", "updated_at", "session_id",
|
|
35
|
+
"uuid", "id", "request_id", "client_msg_id",
|
|
36
|
+
"duration_ms", "duration_api_ms", "cost_usd", "total_cost_usd",
|
|
37
|
+
"api_tokens", "input_tokens", "output_tokens",
|
|
38
|
+
"cache_read_input_tokens", "cache_creation_input_tokens",
|
|
39
|
+
"seq", "last_seq",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// ─── Expected Shapes per Message Type ───────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// Required fields for known message types (applies to all backends)
|
|
45
|
+
const REQUIRED_FIELDS: Record<string, string[]> = {
|
|
46
|
+
session_init: ["session"],
|
|
47
|
+
assistant: ["message"],
|
|
48
|
+
result: ["data"],
|
|
49
|
+
stream_event: ["event"],
|
|
50
|
+
permission_request: ["request"],
|
|
51
|
+
permission_cancelled: ["request_id"],
|
|
52
|
+
status_change: ["status"],
|
|
53
|
+
cli_connected: [],
|
|
54
|
+
cli_disconnected: [],
|
|
55
|
+
error: ["message"],
|
|
56
|
+
tool_progress: ["tool_use_id", "tool_name"],
|
|
57
|
+
presence_update: ["viewers"],
|
|
58
|
+
role_assigned: ["role", "viewerId"],
|
|
59
|
+
vote_update: ["request_id", "votes"],
|
|
60
|
+
vote_resolved: ["request_id", "result"],
|
|
61
|
+
mcp_status: ["servers"],
|
|
62
|
+
session_name_update: ["name"],
|
|
63
|
+
pr_status_update: ["available"],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ─── Validator ──────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function validateEntry(
|
|
69
|
+
entry: RecordingEntry, index: number,
|
|
70
|
+
diffs: ProtocolDiff[], breakdown: Record<string, { count: number; issues: number }>,
|
|
71
|
+
): void {
|
|
72
|
+
if (entry.dir !== "out" || entry.ch !== "browser") return;
|
|
73
|
+
const parsed = safeParse(entry);
|
|
74
|
+
if (!parsed) return;
|
|
75
|
+
|
|
76
|
+
const msgType = typeof parsed.type === "string" ? parsed.type : "unknown";
|
|
77
|
+
if (!breakdown[msgType]) breakdown[msgType] = { count: 0, issues: 0 };
|
|
78
|
+
breakdown[msgType].count++;
|
|
79
|
+
|
|
80
|
+
const required = REQUIRED_FIELDS[msgType];
|
|
81
|
+
if (!required) {
|
|
82
|
+
diffs.push({ entryIndex: index, messageType: msgType, kind: "unexpected_type", details: `Unknown message type: ${msgType}` });
|
|
83
|
+
breakdown[msgType].issues++;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const field of required) {
|
|
88
|
+
if (!(field in parsed)) {
|
|
89
|
+
diffs.push({ entryIndex: index, messageType: msgType, kind: "missing_field", field, details: `Required field "${field}" missing from ${msgType}` });
|
|
90
|
+
breakdown[msgType].issues++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateRecording(recording: Recording): ValidationResult {
|
|
96
|
+
const breakdown: Record<string, { count: number; issues: number }> = {};
|
|
97
|
+
const diffs: ProtocolDiff[] = [];
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < recording.entries.length; i++) {
|
|
100
|
+
validateEntry(recording.entries[i], i, diffs, breakdown);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const checked = Object.values(breakdown).reduce((sum, b) => sum + b.count, 0);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
compatible: diffs.length === 0,
|
|
107
|
+
backendType: recording.header.backend_type || "unknown",
|
|
108
|
+
totalMessages: recording.entries.length,
|
|
109
|
+
checkedMessages: checked,
|
|
110
|
+
diffs,
|
|
111
|
+
messageTypeBreakdown: breakdown,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function safeParse(entry: RecordingEntry): Record<string, unknown> | null {
|
|
116
|
+
try {
|
|
117
|
+
const obj = JSON.parse(entry.raw);
|
|
118
|
+
return typeof obj === "object" && obj !== null ? obj : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|