@bli-cockpit/cli 0.1.9 → 0.1.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/README.md +25 -14
- package/dist/adapters/agent-image-evidence.js +97 -0
- package/dist/adapters/agent-image-records.js +147 -0
- package/dist/adapters/agent-image-validation.js +88 -0
- package/dist/adapters/raw-evidence.js +145 -16
- package/dist/autostart.js +47 -1
- package/dist/commands/local-args.js +383 -0
- package/dist/commands/local.js +15 -877
- package/dist/commands/public-root.js +3 -0
- package/dist/commands/session-sync.js +504 -0
- package/dist/evidence-upload-client.js +7 -0
- package/dist/local-state.js +1 -1
- package/dist/upload.js +153 -4
- package/package.json +2 -2
|
@@ -22,7 +22,10 @@ function cockpitHelp() {
|
|
|
22
22
|
"Usage:",
|
|
23
23
|
localCommandHelp(),
|
|
24
24
|
"",
|
|
25
|
+
"Install/update: `npm install -g @bli-cockpit/cli@latest`.",
|
|
25
26
|
"Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
|
|
27
|
+
"Already onboarded: run `cockpit sync --repo \"$PWD\" --json`.",
|
|
28
|
+
"Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
|
|
26
29
|
"Manual collector path: `install`, `login`, `start [--ticket <id>]`, `sync`, `status`.",
|
|
27
30
|
].join("\n");
|
|
28
31
|
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Session attribution + ambient sync engine for the local collector. Split out
|
|
2
|
+
// of commands/local.ts so the command runners read as a table of contents; this
|
|
3
|
+
// is the core dual-source (Codex + Claude) scan/attribute/upload/report pass.
|
|
4
|
+
//
|
|
5
|
+
// Behavior-preserving extraction: moved verbatim from local.ts, no logic change.
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
|
|
9
|
+
import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
|
|
10
|
+
import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
11
|
+
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
12
|
+
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
13
|
+
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
14
|
+
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
15
|
+
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
16
|
+
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
19
|
+
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
20
|
+
* every discovered worktree; each worktree syncs with only its own attributed
|
|
21
|
+
* transcripts (codex + claude main + sidecars), and the
|
|
22
|
+
* ambiguous/unattributed/skipped remainder is reported with reason labels and a
|
|
23
|
+
* `source` discriminator instead of being duplicated into every repo or
|
|
24
|
+
* silently dropped. The session row's upload state maps ONLY from the main-file
|
|
25
|
+
* outcome (D3); sidecar outcomes aggregate into CLI counts.
|
|
26
|
+
*/
|
|
27
|
+
export async function runAttributedWorktreeSync(options) {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
30
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
31
|
+
const claudeEnabled = await isClaudeCollectionEnabled(paths);
|
|
32
|
+
const codexAttribution = await scanAndAttributeCodexSessions({
|
|
33
|
+
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
34
|
+
worktrees: options.worktrees,
|
|
35
|
+
now,
|
|
36
|
+
});
|
|
37
|
+
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
38
|
+
// days so the first sync captures retroactive history instead of only 24h.
|
|
39
|
+
const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
40
|
+
const firstRunBackfill = claudeEnabled && !claudeCursorExists;
|
|
41
|
+
const claudeAttribution = claudeEnabled
|
|
42
|
+
? await scanAndAttributeClaudeSessions({
|
|
43
|
+
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
44
|
+
worktrees: options.worktrees,
|
|
45
|
+
now,
|
|
46
|
+
sinceMinutes: firstRunBackfill
|
|
47
|
+
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
48
|
+
: undefined,
|
|
49
|
+
})
|
|
50
|
+
: emptyClaudeScan();
|
|
51
|
+
// Read the Claude cursor up front: damping decisions need prior upload state.
|
|
52
|
+
const claudeCursorBefore = claudeEnabled
|
|
53
|
+
? await readRawEvidenceCursor(paths, {
|
|
54
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
55
|
+
}).catch(() => emptyRawEvidenceCursorState())
|
|
56
|
+
: emptyRawEvidenceCursorState();
|
|
57
|
+
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
58
|
+
// growth_damped count + the skip_main decision).
|
|
59
|
+
const dampedClaudePointers = new Map();
|
|
60
|
+
// sessionId -> prior durable pointer for EVERY already-durable Claude session
|
|
61
|
+
// (superset of damped). A session that was durable before but had no fresh
|
|
62
|
+
// upload this sync (damped, spooled, budget-deferred) reports reused_existing
|
|
63
|
+
// with this pointer instead of not_uploaded, so the store row never flips.
|
|
64
|
+
const claudePriorDurablePointers = new Map();
|
|
65
|
+
for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
|
|
66
|
+
if (entry.uploaded_object_key) {
|
|
67
|
+
claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// One shared budget for the whole sync (D7b is per-sync): a parent-folder
|
|
71
|
+
// sync over many worktrees honors a single byte/object cap rather than N×.
|
|
72
|
+
const rawEvidenceBudget = {
|
|
73
|
+
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
74
|
+
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
75
|
+
};
|
|
76
|
+
const outcomes = [];
|
|
77
|
+
let ok = true;
|
|
78
|
+
for (const worktree of options.worktrees) {
|
|
79
|
+
let context = null;
|
|
80
|
+
if (options.startContexts) {
|
|
81
|
+
context = await startLocalWorkContext({
|
|
82
|
+
homeDir: options.homeDir,
|
|
83
|
+
repoRoot: worktree.repo_root,
|
|
84
|
+
branch: options.branch,
|
|
85
|
+
activeTicketId: options.activeTicketId,
|
|
86
|
+
operatorId: options.operatorId,
|
|
87
|
+
sessionId: options.sessionId,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const claudeSessionFiles = claudeAttribution.results
|
|
91
|
+
.filter((result) => result.state === "attributed" &&
|
|
92
|
+
result.worktree?.worktree_fingerprint ===
|
|
93
|
+
worktree.worktree_fingerprint)
|
|
94
|
+
.map((result) => {
|
|
95
|
+
const damped = !result.main_file_oversized &&
|
|
96
|
+
shouldDampClaudeMain(result, claudeCursorBefore, now);
|
|
97
|
+
if (damped) {
|
|
98
|
+
dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
|
|
99
|
+
?.uploaded_object_key ?? null);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
local_path: result.file_path,
|
|
103
|
+
claude_session_id: result.claude_session_id,
|
|
104
|
+
main_file_oversized: result.main_file_oversized,
|
|
105
|
+
skip_main: damped,
|
|
106
|
+
sidecar_files: result.sidecar_files
|
|
107
|
+
.filter((sidecar) => !sidecar.skipped_reason)
|
|
108
|
+
.map((sidecar) => ({ local_path: sidecar.local_path })),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
const syncOptions = {
|
|
112
|
+
homeDir: options.homeDir,
|
|
113
|
+
repoRoot: worktree.repo_root,
|
|
114
|
+
dashboardUrl: options.dashboardUrl,
|
|
115
|
+
codexSessionFiles: codexAttribution.results
|
|
116
|
+
.filter((result) => result.state === "attributed" &&
|
|
117
|
+
result.worktree?.worktree_fingerprint ===
|
|
118
|
+
worktree.worktree_fingerprint)
|
|
119
|
+
.map((result) => ({
|
|
120
|
+
local_path: result.file_path,
|
|
121
|
+
codex_session_id: result.codex_session_id,
|
|
122
|
+
})),
|
|
123
|
+
claudeSessionFiles,
|
|
124
|
+
rawEvidenceBudget,
|
|
125
|
+
fetch: options.fetchImpl,
|
|
126
|
+
};
|
|
127
|
+
let sync;
|
|
128
|
+
try {
|
|
129
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// A newly cloned repo has no work context yet. Capture is permissive
|
|
133
|
+
// and ticket binding comes later, so start general ambient capture for
|
|
134
|
+
// it instead of blocking every other repo's sync until someone runs
|
|
135
|
+
// `cockpit start` by hand.
|
|
136
|
+
if (error instanceof LocalUploadBlockedError &&
|
|
137
|
+
error.blocker === "missing_context") {
|
|
138
|
+
context = await startLocalWorkContext({
|
|
139
|
+
homeDir: options.homeDir,
|
|
140
|
+
repoRoot: worktree.repo_root,
|
|
141
|
+
branch: options.branch,
|
|
142
|
+
});
|
|
143
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
ok = ok && sync.status === "uploaded";
|
|
150
|
+
outcomes.push({ worktree, context, sync });
|
|
151
|
+
}
|
|
152
|
+
const sessions = buildAgentSessionReport({
|
|
153
|
+
codexResults: codexAttribution.results,
|
|
154
|
+
claudeResults: claudeAttribution.results,
|
|
155
|
+
outcomes,
|
|
156
|
+
now,
|
|
157
|
+
claudePriorDurablePointers,
|
|
158
|
+
});
|
|
159
|
+
// The sessions cursor is an optimization; a broken local state dir must not
|
|
160
|
+
// turn already-completed syncs into a CLI crash.
|
|
161
|
+
let codexStaleCount = 0;
|
|
162
|
+
let claudeStaleCount = 0;
|
|
163
|
+
try {
|
|
164
|
+
const codexCursor = await readRawEvidenceCursor(paths);
|
|
165
|
+
codexStaleCount = recordSourceObservations({
|
|
166
|
+
cursor: codexCursor,
|
|
167
|
+
results: codexAttribution.results,
|
|
168
|
+
sessions,
|
|
169
|
+
source: "codex",
|
|
170
|
+
sessionIdOf: (result) => result.codex_session_id,
|
|
171
|
+
now,
|
|
172
|
+
});
|
|
173
|
+
codexCursor.updated_at = now.toISOString();
|
|
174
|
+
await writeRawEvidenceCursor(paths, codexCursor);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
178
|
+
}
|
|
179
|
+
if (claudeEnabled) {
|
|
180
|
+
try {
|
|
181
|
+
claudeStaleCount = recordSourceObservations({
|
|
182
|
+
cursor: claudeCursorBefore,
|
|
183
|
+
results: claudeAttribution.results,
|
|
184
|
+
sessions,
|
|
185
|
+
source: "claude_code",
|
|
186
|
+
sessionIdOf: (result) => result.claude_session_id,
|
|
187
|
+
now,
|
|
188
|
+
priorCursor: claudeCursorBefore,
|
|
189
|
+
});
|
|
190
|
+
claudeCursorBefore.updated_at = now.toISOString();
|
|
191
|
+
await writeRawEvidenceCursor(paths, claudeCursorBefore, {
|
|
192
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
193
|
+
sessionsOnly: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Best-effort: a broken Claude cursor must not fail the sync.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
201
|
+
const report = firstUploaded
|
|
202
|
+
? await postCodexSessionReport({
|
|
203
|
+
homeDir: options.homeDir,
|
|
204
|
+
repoRoot: firstUploaded.worktree.repo_root,
|
|
205
|
+
dashboardUrl: options.dashboardUrl,
|
|
206
|
+
sessions,
|
|
207
|
+
fetch: options.fetchImpl,
|
|
208
|
+
now,
|
|
209
|
+
})
|
|
210
|
+
: {
|
|
211
|
+
posted: false,
|
|
212
|
+
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
213
|
+
};
|
|
214
|
+
const summary = buildAgentSessionSummary({
|
|
215
|
+
codexAttribution,
|
|
216
|
+
claudeAttribution,
|
|
217
|
+
outcomes,
|
|
218
|
+
codexStaleCount,
|
|
219
|
+
claudeStaleCount,
|
|
220
|
+
firstRunBackfill,
|
|
221
|
+
growthDamped: dampedClaudePointers.size,
|
|
222
|
+
report,
|
|
223
|
+
});
|
|
224
|
+
return { ok, outcomes, codexAttribution, claudeAttribution, summary };
|
|
225
|
+
}
|
|
226
|
+
const ATTRIBUTION_STATE_RANK = {
|
|
227
|
+
attributed: 3,
|
|
228
|
+
ambiguous: 2,
|
|
229
|
+
unattributed: 1,
|
|
230
|
+
skipped: 0,
|
|
231
|
+
};
|
|
232
|
+
function normalizeCodexResult(result) {
|
|
233
|
+
return {
|
|
234
|
+
source: "codex",
|
|
235
|
+
session_id: result.codex_session_id,
|
|
236
|
+
state: result.state,
|
|
237
|
+
reason: result.reason,
|
|
238
|
+
signals: result.signals,
|
|
239
|
+
attribution_score: result.attribution_score,
|
|
240
|
+
path_score: result.path_score,
|
|
241
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
242
|
+
byte_size: result.byte_size,
|
|
243
|
+
session_file_mtime: result.session_file_mtime,
|
|
244
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
245
|
+
worktree: result.worktree,
|
|
246
|
+
cwd_basename: result.cwd_basename,
|
|
247
|
+
cwd_hash: result.cwd_hash,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function normalizeClaudeResult(result) {
|
|
251
|
+
return {
|
|
252
|
+
source: "claude_code",
|
|
253
|
+
session_id: result.claude_session_id,
|
|
254
|
+
state: result.state,
|
|
255
|
+
reason: result.reason,
|
|
256
|
+
signals: result.signals,
|
|
257
|
+
attribution_score: result.attribution_score,
|
|
258
|
+
path_score: result.path_score,
|
|
259
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
260
|
+
byte_size: result.byte_size,
|
|
261
|
+
session_file_mtime: result.session_file_mtime,
|
|
262
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
263
|
+
worktree: result.worktree,
|
|
264
|
+
cwd_basename: result.cwd_basename,
|
|
265
|
+
cwd_hash: result.cwd_hash,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Generalizes the per-session report across sources. Dedupe is per
|
|
270
|
+
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
271
|
+
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
272
|
+
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
273
|
+
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
274
|
+
* carrying their prior durable pointer.
|
|
275
|
+
*/
|
|
276
|
+
function buildAgentSessionReport(options) {
|
|
277
|
+
const normalized = [
|
|
278
|
+
...options.codexResults.map(normalizeCodexResult),
|
|
279
|
+
...options.claudeResults.map(normalizeClaudeResult),
|
|
280
|
+
];
|
|
281
|
+
const bestByKey = new Map();
|
|
282
|
+
for (const result of normalized) {
|
|
283
|
+
const key = `${result.source}:${result.session_id}`;
|
|
284
|
+
const existing = bestByKey.get(key);
|
|
285
|
+
if (!existing ||
|
|
286
|
+
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
287
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
288
|
+
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
289
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
290
|
+
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
291
|
+
bestByKey.set(key, result);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Main-file outcomes only (D3): a sidecar making it must never mark a session
|
|
295
|
+
// uploaded when the main did not.
|
|
296
|
+
const uploadByKey = new Map();
|
|
297
|
+
for (const outcome of options.outcomes) {
|
|
298
|
+
if (outcome.sync.status !== "uploaded")
|
|
299
|
+
continue;
|
|
300
|
+
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
301
|
+
if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
|
|
302
|
+
continue;
|
|
303
|
+
const source = upload.kind === "claude_jsonl"
|
|
304
|
+
? "claude_code"
|
|
305
|
+
: upload.kind === "codex_jsonl"
|
|
306
|
+
? "codex"
|
|
307
|
+
: null;
|
|
308
|
+
if (!source)
|
|
309
|
+
continue; // sidecars and other kinds do not set session state
|
|
310
|
+
uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return [...bestByKey.values()].map((result) => {
|
|
314
|
+
const key = `${result.source}:${result.session_id}`;
|
|
315
|
+
const upload = uploadByKey.get(key);
|
|
316
|
+
// A previously-durable Claude session with no fresh main upload this sync
|
|
317
|
+
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
318
|
+
// pointer rather than not_uploaded, so the store row never flips.
|
|
319
|
+
const priorDurablePointer = result.source === "claude_code"
|
|
320
|
+
? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
321
|
+
: null;
|
|
322
|
+
return {
|
|
323
|
+
codex_session_id: result.session_id,
|
|
324
|
+
source: result.source,
|
|
325
|
+
observed_at: options.now.toISOString(),
|
|
326
|
+
attribution_state: result.state,
|
|
327
|
+
attribution_reason: result.reason,
|
|
328
|
+
attribution_score: result.attribution_score,
|
|
329
|
+
path_score: result.path_score,
|
|
330
|
+
signals: result.signals,
|
|
331
|
+
...(result.content_hash_sha256
|
|
332
|
+
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
333
|
+
: {}),
|
|
334
|
+
session_file_byte_size: result.byte_size,
|
|
335
|
+
session_file_mtime: result.session_file_mtime,
|
|
336
|
+
...(result.worktree
|
|
337
|
+
? {
|
|
338
|
+
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
339
|
+
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
340
|
+
repo_label: result.worktree.repo_label,
|
|
341
|
+
branch: result.worktree.branch,
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
344
|
+
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
345
|
+
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
346
|
+
...(upload
|
|
347
|
+
? {
|
|
348
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
349
|
+
upload_state: upload.upload_state,
|
|
350
|
+
}
|
|
351
|
+
: priorDurablePointer
|
|
352
|
+
? {
|
|
353
|
+
raw_evidence_pointer_id: priorDurablePointer,
|
|
354
|
+
upload_state: "reused_existing",
|
|
355
|
+
}
|
|
356
|
+
: result.state === "attributed"
|
|
357
|
+
? { upload_state: "not_uploaded" }
|
|
358
|
+
: {}),
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Records per-source session observations into its cursor and returns the stale
|
|
364
|
+
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
365
|
+
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
366
|
+
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
367
|
+
*/
|
|
368
|
+
function recordSourceObservations(options) {
|
|
369
|
+
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
370
|
+
const stale = countStaleSessions(options.cursor, seen);
|
|
371
|
+
for (const result of options.results) {
|
|
372
|
+
const sessionId = options.sessionIdOf(result);
|
|
373
|
+
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
374
|
+
session.codex_session_id === sessionId);
|
|
375
|
+
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
376
|
+
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
377
|
+
reported?.upload_state === "reused_existing";
|
|
378
|
+
const prior = options.priorCursor?.sessions[sessionId];
|
|
379
|
+
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
380
|
+
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
381
|
+
// prior durable state — otherwise damping is forfeited forever and the
|
|
382
|
+
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
383
|
+
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
384
|
+
const uploadedObjectKey = durableThisSync
|
|
385
|
+
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
386
|
+
: (prior?.uploaded_object_key ?? null);
|
|
387
|
+
const uploadedAt = uploadedThisSync
|
|
388
|
+
? options.now.toISOString()
|
|
389
|
+
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
390
|
+
const uploadedByteSize = uploadedThisSync
|
|
391
|
+
? result.byte_size
|
|
392
|
+
: (prior?.uploaded_byte_size ??
|
|
393
|
+
(durableThisSync ? result.byte_size : null));
|
|
394
|
+
const entry = {
|
|
395
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
396
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
397
|
+
byte_size: result.byte_size,
|
|
398
|
+
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
399
|
+
// last uploaded size), not the current file size.
|
|
400
|
+
byte_offset: uploadedByteSize ?? 0,
|
|
401
|
+
state: result.state,
|
|
402
|
+
reason: result.reason,
|
|
403
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
404
|
+
uploaded_object_key: uploadedObjectKey,
|
|
405
|
+
uploaded_at: uploadedAt,
|
|
406
|
+
uploaded_byte_size: uploadedByteSize,
|
|
407
|
+
last_seen_at: options.now.toISOString(),
|
|
408
|
+
};
|
|
409
|
+
recordSessionObservation(options.cursor, sessionId, entry);
|
|
410
|
+
}
|
|
411
|
+
return stale;
|
|
412
|
+
}
|
|
413
|
+
function shouldDampClaudeMain(result, cursor, now) {
|
|
414
|
+
const entry = cursor.sessions[result.claude_session_id];
|
|
415
|
+
if (!entry ||
|
|
416
|
+
!entry.uploaded_object_key ||
|
|
417
|
+
!entry.uploaded_at ||
|
|
418
|
+
entry.uploaded_byte_size == null) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const grew = result.byte_size > entry.uploaded_byte_size;
|
|
422
|
+
if (!grew)
|
|
423
|
+
return false; // unchanged content reuses via the object cursor
|
|
424
|
+
const growth = result.byte_size - entry.uploaded_byte_size;
|
|
425
|
+
const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
|
|
426
|
+
return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
|
|
427
|
+
Number.isFinite(ageMs) &&
|
|
428
|
+
ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
|
|
429
|
+
}
|
|
430
|
+
function buildAgentSessionSummary(options) {
|
|
431
|
+
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
432
|
+
const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
|
|
433
|
+
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
434
|
+
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
435
|
+
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
436
|
+
result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
|
|
437
|
+
const codex = {
|
|
438
|
+
scanned: options.codexAttribution.scanned_file_count,
|
|
439
|
+
attributed: options.codexAttribution.counts.attributed,
|
|
440
|
+
ambiguous: options.codexAttribution.counts.ambiguous,
|
|
441
|
+
unattributed: options.codexAttribution.counts.unattributed,
|
|
442
|
+
skipped: options.codexAttribution.counts.skipped,
|
|
443
|
+
stale: options.codexStaleCount,
|
|
444
|
+
};
|
|
445
|
+
const claude = {
|
|
446
|
+
scanned: options.claudeAttribution.scanned_session_count,
|
|
447
|
+
attributed: options.claudeAttribution.counts.attributed,
|
|
448
|
+
ambiguous: options.claudeAttribution.counts.ambiguous,
|
|
449
|
+
unattributed: options.claudeAttribution.counts.unattributed,
|
|
450
|
+
skipped: options.claudeAttribution.counts.skipped,
|
|
451
|
+
stale: options.claudeStaleCount,
|
|
452
|
+
sidecars_collected: sidecarsCollected,
|
|
453
|
+
sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
|
|
454
|
+
upload.upload_state === "reused_existing").length,
|
|
455
|
+
sidecars_skipped: sidecarsSkipped,
|
|
456
|
+
sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
|
|
457
|
+
sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
|
|
458
|
+
mains_oversized: options.claudeAttribution.counts.mains_oversized,
|
|
459
|
+
oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
|
|
460
|
+
project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
|
|
461
|
+
sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
|
|
462
|
+
growth_damped: options.growthDamped,
|
|
463
|
+
first_run_backfill: options.firstRunBackfill,
|
|
464
|
+
};
|
|
465
|
+
return {
|
|
466
|
+
scanned: codex.scanned,
|
|
467
|
+
attributed: codex.attributed,
|
|
468
|
+
ambiguous: codex.ambiguous,
|
|
469
|
+
unattributed: codex.unattributed,
|
|
470
|
+
skipped: codex.skipped,
|
|
471
|
+
stale: codex.stale,
|
|
472
|
+
report_posted: options.report.posted,
|
|
473
|
+
report_reason: options.report.reason,
|
|
474
|
+
codex,
|
|
475
|
+
claude,
|
|
476
|
+
files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
|
|
477
|
+
files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function emptyClaudeScan() {
|
|
481
|
+
return {
|
|
482
|
+
results: [],
|
|
483
|
+
scanned_session_count: 0,
|
|
484
|
+
project_dirs_skipped: 0,
|
|
485
|
+
counts: {
|
|
486
|
+
attributed: 0,
|
|
487
|
+
ambiguous: 0,
|
|
488
|
+
unattributed: 0,
|
|
489
|
+
skipped: 0,
|
|
490
|
+
mains_oversized: 0,
|
|
491
|
+
oversized_lines_skipped: 0,
|
|
492
|
+
sessions_schema_drift: 0,
|
|
493
|
+
sidecars_capped: 0,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async function isClaudeCollectionEnabled(paths) {
|
|
498
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
499
|
+
return config?.collect_claude_jsonl !== false;
|
|
500
|
+
}
|
|
501
|
+
async function fileExists(filePath) {
|
|
502
|
+
const { stat } = await import("node:fs/promises");
|
|
503
|
+
return stat(filePath).then(() => true, () => false);
|
|
504
|
+
}
|
|
@@ -117,6 +117,7 @@ function duplicateOutcome(primary, duplicate) {
|
|
|
117
117
|
pointer: duplicate.pointer,
|
|
118
118
|
codex_session_id: duplicate.codex_session_id ?? null,
|
|
119
119
|
kind: duplicate.kind ?? "unknown",
|
|
120
|
+
artifact_metadata: duplicate.artifact_metadata,
|
|
120
121
|
upload_state: primary.upload_state === "uploaded"
|
|
121
122
|
? "reused_existing"
|
|
122
123
|
: primary.upload_state,
|
|
@@ -132,6 +133,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
|
132
133
|
object_key: objectKey,
|
|
133
134
|
codex_session_id: entry.file.codex_session_id ?? null,
|
|
134
135
|
kind: entry.file.kind ?? "unknown",
|
|
136
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
135
137
|
upload_state: "reused_existing",
|
|
136
138
|
reason: "already_committed",
|
|
137
139
|
uploaded_chunk_count: 0,
|
|
@@ -185,6 +187,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
|
185
187
|
object_key: objectKey,
|
|
186
188
|
codex_session_id: entry.file.codex_session_id ?? null,
|
|
187
189
|
kind: entry.file.kind ?? "unknown",
|
|
190
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
188
191
|
upload_state: "reused_existing",
|
|
189
192
|
reason: "already_committed",
|
|
190
193
|
uploaded_chunk_count: uploadedChunks,
|
|
@@ -195,6 +198,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
|
195
198
|
object_key: objectKey,
|
|
196
199
|
codex_session_id: entry.file.codex_session_id ?? null,
|
|
197
200
|
kind: entry.file.kind ?? "unknown",
|
|
201
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
198
202
|
upload_state: "uploaded",
|
|
199
203
|
reason: null,
|
|
200
204
|
uploaded_chunk_count: uploadedChunks,
|
|
@@ -228,6 +232,7 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
|
|
|
228
232
|
object_key: entry.file.pointer.object_key ?? "",
|
|
229
233
|
codex_session_id: entry.file.codex_session_id ?? null,
|
|
230
234
|
kind: entry.file.kind ?? "unknown",
|
|
235
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
231
236
|
upload_state: "uploaded",
|
|
232
237
|
reason: "legacy_single_shot_upload",
|
|
233
238
|
uploaded_chunk_count: 1,
|
|
@@ -241,6 +246,7 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
|
|
|
241
246
|
object_key: entry.file.pointer.object_key ?? "",
|
|
242
247
|
codex_session_id: entry.file.codex_session_id ?? null,
|
|
243
248
|
kind: entry.file.kind ?? "unknown",
|
|
249
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
244
250
|
upload_state: "reused_existing",
|
|
245
251
|
reason: "legacy_already_exists",
|
|
246
252
|
uploaded_chunk_count: 0,
|
|
@@ -319,6 +325,7 @@ function failedOutcome(file, reason, uploadedChunks = 0) {
|
|
|
319
325
|
object_key: file.pointer.object_key ?? "",
|
|
320
326
|
codex_session_id: file.codex_session_id ?? null,
|
|
321
327
|
kind: file.kind ?? "unknown",
|
|
328
|
+
artifact_metadata: file.artifact_metadata,
|
|
322
329
|
upload_state: "upload_failed",
|
|
323
330
|
reason,
|
|
324
331
|
uploaded_chunk_count: uploadedChunks,
|
package/dist/local-state.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
|
|
7
7
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
8
|
-
export const LOCAL_COLLECTOR_VERSION = "0.1.
|
|
8
|
+
export const LOCAL_COLLECTOR_VERSION = "0.1.10";
|
|
9
9
|
export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
|
|
10
10
|
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
11
11
|
const paths = getUserLocalCockpitPaths(homeDir);
|