@bli-cockpit/cli 0.1.25 → 0.1.27

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 CHANGED
@@ -191,6 +191,10 @@ Remote dashboard:
191
191
  for screenshots/images explicitly attached into Codex or Claude sessions;
192
192
  - Codex session attribution records (session id, file hash, attribution state
193
193
  and reason labels, scores) accepted by `/api/ambient/codex-sessions`.
194
+ The local collector scans both active `~/.codex/sessions` files and archived
195
+ `~/.codex/archived_sessions` files, using only `session_meta` and
196
+ `turn_context` metadata for attribution. Transcript bytes are sanitized or
197
+ blocked later by raw evidence collection before upload.
194
198
 
195
199
  New raw evidence object keys are readable from the Storage browser:
196
200
 
@@ -212,6 +216,9 @@ Local attribution preview:
212
216
  cockpit sessions --workspace "$PWD" --json
213
217
  ```
214
218
 
219
+ This preview includes both active and archived Codex sessions in the bounded
220
+ backfill window, plus Claude Code sessions when Claude collection is enabled.
221
+
215
222
  Remote metadata path:
216
223
 
217
224
  1. Query `ambient_evidence_refs` by `operator_user_id`, `received_at`, and
@@ -1,8 +1,11 @@
1
- import { SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
1
+ import { SECRET_FILE_SEGMENT_PATTERN, } from "@bli-cockpit/telemetry-core";
2
+ import { createReadStream } from "node:fs";
3
+ import crypto from "node:crypto";
2
4
  import fs from "node:fs/promises";
3
5
  import path from "node:path";
6
+ import { StringDecoder } from "node:string_decoder";
4
7
  import { normalizeGitOrigin } from "../repo-identity.js";
5
- import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, sha256, shortHash, } from "./attribution-core.js";
8
+ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
6
9
  /**
7
10
  * Deterministic Codex session JSONL -> repo/worktree attribution.
8
11
  *
@@ -18,12 +21,24 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
18
21
  export { sanitizeSessionId, sessionIdFromFileName };
19
22
  export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
20
23
  export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
24
+ export const CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES = 14 * 24 * 60;
25
+ export const CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT = 500;
26
+ // Compatibility export only. Codex attribution streams full files for metadata
27
+ // and no longer rejects sessions by file size; raw evidence collection enforces
28
+ // upload budgets and content guards later.
21
29
  export const CODEX_SESSION_MAX_FILE_BYTES = 10 * 1024 * 1024;
30
+ const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
31
+ export function defaultCodexSessionDirs(homeDir) {
32
+ return [
33
+ path.join(homeDir, ".codex", "sessions"),
34
+ path.join(homeDir, ".codex", "archived_sessions"),
35
+ ];
36
+ }
22
37
  export async function scanAndAttributeCodexSessions(options) {
23
38
  const sinceMinutes = options.sinceMinutes ?? CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES;
24
39
  const limit = options.limit ?? CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT;
25
40
  const cutoffMs = options.now.getTime() - sinceMinutes * 60 * 1000;
26
- const discovery = await discoverCodexJsonlFiles(options.sessionsDir, cutoffMs);
41
+ const discovery = await discoverCodexJsonlFiles(codexSessionDirsFromOptions(options), cutoffMs);
27
42
  const files = discovery.files.slice(0, limit);
28
43
  const results = [];
29
44
  for (const file of files) {
@@ -36,7 +51,7 @@ export async function scanAndAttributeCodexSessions(options) {
36
51
  since_minutes: sinceMinutes,
37
52
  session_limit: limit,
38
53
  session_limit_applied: discovery.files.length > limit,
39
- max_file_bytes: CODEX_SESSION_MAX_FILE_BYTES,
54
+ max_file_bytes: 0,
40
55
  directory_read_failed_count: discovery.directoryReadFailedCount,
41
56
  stat_failed_count: discovery.statFailedCount,
42
57
  secret_path_skipped_count: discovery.secretPathSkippedCount,
@@ -57,7 +72,8 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
57
72
  let directoryReadFailedCount = 0;
58
73
  let statFailedCount = 0;
59
74
  let secretPathSkippedCount = 0;
60
- const stack = [dir];
75
+ const stack = Array.isArray(dir) ? [...dir] : [dir];
76
+ const seenFiles = new Set();
61
77
  while (stack.length > 0) {
62
78
  const current = stack.pop();
63
79
  if (!current)
@@ -100,7 +116,11 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
100
116
  continue;
101
117
  }
102
118
  if (stat.mtimeMs >= cutoffMs) {
103
- out.push({ file: full, mtimeMs: stat.mtimeMs, byteSize: stat.size });
119
+ const dedupeKey = await fs.realpath(full).catch(() => path.resolve(full));
120
+ if (!seenFiles.has(dedupeKey)) {
121
+ seenFiles.add(dedupeKey);
122
+ out.push({ file: full, mtimeMs: stat.mtimeMs, byteSize: stat.size });
123
+ }
104
124
  }
105
125
  }
106
126
  }
@@ -112,6 +132,22 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
112
132
  secretPathSkippedCount,
113
133
  };
114
134
  }
135
+ function codexSessionDirsFromOptions(options) {
136
+ const dirs = options.sessionsDirs ?? (options.sessionsDir ? [options.sessionsDir] : []);
137
+ return dedupePaths(dirs);
138
+ }
139
+ function dedupePaths(values) {
140
+ const seen = new Set();
141
+ const out = [];
142
+ for (const value of values) {
143
+ const resolved = path.resolve(value);
144
+ if (seen.has(resolved))
145
+ continue;
146
+ seen.add(resolved);
147
+ out.push(resolved);
148
+ }
149
+ return out;
150
+ }
115
151
  async function attributeOneSession(file, worktrees) {
116
152
  const fileName = path.basename(file.file);
117
153
  const base = {
@@ -128,23 +164,16 @@ async function attributeOneSession(file, worktrees) {
128
164
  if (isSecretLikePath(fileName)) {
129
165
  return skippedResult(base, "secret_like_file_name");
130
166
  }
131
- if (file.byteSize > CODEX_SESSION_MAX_FILE_BYTES) {
132
- return skippedResult(base, "file_too_large");
133
- }
134
- let raw;
167
+ let read;
135
168
  try {
136
- raw = await fs.readFile(file.file);
169
+ read = await readCodexMetadataSignals(file.file);
137
170
  }
138
171
  catch {
139
172
  return skippedResult(base, "file_read_failed");
140
173
  }
141
- const content = raw.toString("utf8");
142
- base.content_hash_sha256 = sha256(raw);
143
- base.byte_size = raw.byteLength;
144
- if (containsSecretLikeContent(content)) {
145
- return skippedResult(base, "secret_like_content_guard");
146
- }
147
- const signals = extractCodexSessionSignals(content);
174
+ base.content_hash_sha256 = read.contentHashSha256;
175
+ base.byte_size = read.byteSize;
176
+ const signals = read.signals;
148
177
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
149
178
  if (metaSessionId) {
150
179
  base.codex_session_id = metaSessionId;
@@ -174,72 +203,132 @@ async function attributeOneSession(file, worktrees) {
174
203
  }, worktrees);
175
204
  return { ...base, ...outcome };
176
205
  }
206
+ async function readCodexMetadataSignals(filePath) {
207
+ const hash = crypto.createHash("sha256");
208
+ const decoder = new StringDecoder("utf8");
209
+ const state = makeSignalExtractionState();
210
+ const stream = createReadStream(filePath);
211
+ let byteSize = 0;
212
+ let lineBuffer = "";
213
+ let discardingOversizedLine = false;
214
+ for await (const chunk of stream) {
215
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
216
+ byteSize += buffer.byteLength;
217
+ hash.update(buffer);
218
+ const text = decoder.write(buffer);
219
+ const segments = text.split("\n");
220
+ for (let index = 0; index < segments.length; index += 1) {
221
+ const segment = segments[index] ?? "";
222
+ const lineEnded = index < segments.length - 1;
223
+ if (discardingOversizedLine) {
224
+ if (lineEnded)
225
+ discardingOversizedLine = false;
226
+ continue;
227
+ }
228
+ lineBuffer += segment;
229
+ if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
230
+ state.lineCount += 1;
231
+ state.parseErrorCount += 1;
232
+ lineBuffer = "";
233
+ discardingOversizedLine = !lineEnded;
234
+ continue;
235
+ }
236
+ if (lineEnded) {
237
+ absorbCodexSessionLine(state, lineBuffer);
238
+ lineBuffer = "";
239
+ }
240
+ }
241
+ }
242
+ const rest = decoder.end();
243
+ if (rest)
244
+ lineBuffer += rest;
245
+ if (lineBuffer.trim())
246
+ absorbCodexSessionLine(state, lineBuffer);
247
+ return {
248
+ signals: codexSignalsFromState(state),
249
+ contentHashSha256: hash.digest("hex"),
250
+ byteSize,
251
+ };
252
+ }
177
253
  export function extractCodexSessionSignals(content) {
178
- const sessionIds = new Set();
179
- const cwds = new Set();
180
- const workspaceRoots = new Set();
181
- const branches = new Set();
182
- const commitHashes = new Set();
183
- const repositoryUrls = new Set();
184
- let lineCount = 0;
185
- let parseErrorCount = 0;
254
+ const state = makeSignalExtractionState();
186
255
  for (const line of content.split("\n")) {
187
- if (!line.trim())
188
- continue;
189
- lineCount += 1;
190
- let record;
191
- try {
192
- record = JSON.parse(line);
193
- }
194
- catch {
195
- parseErrorCount += 1;
196
- continue;
197
- }
198
- if (!record || typeof record !== "object")
199
- continue;
200
- const type = record.type;
201
- const payload = record.payload;
202
- if (!payload || typeof payload !== "object")
203
- continue;
204
- const payloadRecord = payload;
205
- if (type === "session_meta") {
206
- addString(sessionIds, payloadRecord["id"]);
207
- addString(cwds, payloadRecord["cwd"]);
208
- const git = payloadRecord["git"];
209
- if (git && typeof git === "object") {
210
- const gitRecord = git;
211
- addString(branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
212
- addString(commitHashes, gitRecord["commit_hash"]);
213
- const repositoryUrl = gitRecord["repository_url"];
214
- if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
215
- repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
216
- }
256
+ absorbCodexSessionLine(state, line);
257
+ }
258
+ return codexSignalsFromState(state);
259
+ }
260
+ function makeSignalExtractionState() {
261
+ return {
262
+ sessionIds: new Set(),
263
+ cwds: new Set(),
264
+ workspaceRoots: new Set(),
265
+ branches: new Set(),
266
+ commitHashes: new Set(),
267
+ repositoryUrls: new Set(),
268
+ lineCount: 0,
269
+ parseErrorCount: 0,
270
+ };
271
+ }
272
+ function absorbCodexSessionLine(state, line) {
273
+ if (!line.trim())
274
+ return;
275
+ state.lineCount += 1;
276
+ let record;
277
+ try {
278
+ record = JSON.parse(line);
279
+ }
280
+ catch {
281
+ state.parseErrorCount += 1;
282
+ return;
283
+ }
284
+ if (!record || typeof record !== "object")
285
+ return;
286
+ const type = record.type;
287
+ if (type !== "session_meta" && type !== "turn_context")
288
+ return;
289
+ const payload = record.payload;
290
+ if (!payload || typeof payload !== "object")
291
+ return;
292
+ const payloadRecord = payload;
293
+ if (type === "session_meta") {
294
+ addString(state.sessionIds, payloadRecord["id"]);
295
+ addString(state.cwds, payloadRecord["cwd"]);
296
+ const git = payloadRecord["git"];
297
+ if (git && typeof git === "object") {
298
+ const gitRecord = git;
299
+ addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
300
+ addString(state.commitHashes, gitRecord["commit_hash"]);
301
+ const repositoryUrl = gitRecord["repository_url"];
302
+ if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
303
+ state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
217
304
  }
218
305
  }
219
- else if (type === "turn_context") {
220
- addString(cwds, payloadRecord["cwd"]);
221
- const roots = payloadRecord["workspace_roots"];
222
- if (Array.isArray(roots)) {
223
- for (const root of roots) {
224
- if (typeof root === "string") {
225
- addString(workspaceRoots, root);
226
- }
227
- else if (root && typeof root === "object") {
228
- addString(workspaceRoots, root["path"]);
229
- }
306
+ }
307
+ else if (type === "turn_context") {
308
+ addString(state.cwds, payloadRecord["cwd"]);
309
+ const roots = payloadRecord["workspace_roots"];
310
+ if (Array.isArray(roots)) {
311
+ for (const root of roots) {
312
+ if (typeof root === "string") {
313
+ addString(state.workspaceRoots, root);
314
+ }
315
+ else if (root && typeof root === "object") {
316
+ addString(state.workspaceRoots, root["path"]);
230
317
  }
231
318
  }
232
319
  }
233
320
  }
321
+ }
322
+ function codexSignalsFromState(state) {
234
323
  return {
235
- session_ids: [...sessionIds],
236
- cwds: [...cwds],
237
- workspace_roots: [...workspaceRoots],
238
- branches: [...branches],
239
- commit_hashes: [...commitHashes],
240
- repository_urls: [...repositoryUrls],
241
- line_count: lineCount,
242
- parse_error_count: parseErrorCount,
324
+ session_ids: [...state.sessionIds],
325
+ cwds: [...state.cwds],
326
+ workspace_roots: [...state.workspaceRoots],
327
+ branches: [...state.branches],
328
+ commit_hashes: [...state.commitHashes],
329
+ repository_urls: [...state.repositoryUrls],
330
+ line_count: state.lineCount,
331
+ parse_error_count: state.parseErrorCount,
243
332
  };
244
333
  }
245
334
  function skippedResult(base, reason) {
@@ -25,6 +25,7 @@ export async function runLocalSourceCollectors(options) {
25
25
  stateDir: options.rawEvidenceStateDir,
26
26
  repoRoot: options.repoRoot,
27
27
  sessionsDir: options.rawEvidenceSessionsDir,
28
+ sessionsDirs: options.rawEvidenceSessionsDirs,
28
29
  includeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
29
30
  includeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
30
31
  codexSessionFiles: options.rawEvidenceCodexSessionFiles,
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { makeSourceAdapterIdentity, } from "./common.js";
8
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
+ import { defaultCodexSessionDirs, } from "./codex-attribution.js";
9
10
  const DEFAULT_SINCE_MINUTES = 24 * 60;
10
11
  const DEFAULT_SESSION_LIMIT = 50;
11
12
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
@@ -104,6 +105,7 @@ export async function collectRawEvidencePack(context, options) {
104
105
  await collectCodexJsonlFiles(collection, {
105
106
  codexSessionFiles: options.codexSessionFiles,
106
107
  sessionsDir: options.sessionsDir,
108
+ sessionsDirs: options.sessionsDirs,
107
109
  sinceMinutes,
108
110
  limit: sessionLimit,
109
111
  });
@@ -311,11 +313,6 @@ function recordCodexAttributionCompleteness(collection, scan) {
311
313
  limit: scan.session_limit,
312
314
  observed: scan.discovered_file_count,
313
315
  applied: scan.session_limit_applied,
314
- }, {
315
- source: "codex_attribution",
316
- cap_type: "max_file_bytes",
317
- limit: scan.max_file_bytes,
318
- applied: scan.results.some((result) => result.reason === "file_too_large"),
319
316
  });
320
317
  recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
321
318
  recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
@@ -391,7 +388,10 @@ async function collectCodexJsonlFiles(collection, options) {
391
388
  filePath: file.local_path,
392
389
  codexSessionId: file.codex_session_id,
393
390
  }))
394
- : (await walkJsonlFiles(options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions"), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
391
+ : (await walkJsonlFiles(options.sessionsDirs ??
392
+ (options.sessionsDir
393
+ ? [options.sessionsDir]
394
+ : defaultCodexSessionDirs(os.homedir())), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
395
395
  .map((filePath) => ({ filePath, codexSessionId: null }));
396
396
  collection.caps.push({
397
397
  source: "codex_jsonl",
@@ -573,10 +573,9 @@ async function collectOneAgentImageFile(collection, options) {
573
573
  }
574
574
  /**
575
575
  * Reads, secret-guards, content-addresses, budget-checks, and copies one
576
- * attributed transcript into the pack. The secret guard runs again here even
577
- * though attribution already guarded — three reads per file (attribution,
578
- * collection, server commit) is the accepted defense-in-depth cost; do not
579
- * "optimize" a layer away.
576
+ * attributed transcript into the pack. Attribution only reads metadata records;
577
+ * this collection layer and the server commit layer are the two content guards
578
+ * that decide whether transcript bytes can become durable evidence.
580
579
  */
581
580
  async function collectOneEvidenceFile(collection, options) {
582
581
  const fileName = path.basename(options.filePath);
@@ -1075,7 +1074,8 @@ function makeEvidenceCompleteness(collection, options) {
1075
1074
  }
1076
1075
  async function walkJsonlFiles(dir, cutoffMs) {
1077
1076
  const out = [];
1078
- const stack = [dir];
1077
+ const stack = Array.isArray(dir) ? [...dir] : [dir];
1078
+ const seen = new Set();
1079
1079
  while (stack.length > 0) {
1080
1080
  const current = stack.pop();
1081
1081
  if (!current || isSecretLikePath(current))
@@ -1098,8 +1098,13 @@ async function walkJsonlFiles(dir, cutoffMs) {
1098
1098
  if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
1099
1099
  continue;
1100
1100
  const stat = await fs.stat(full);
1101
- if (stat.mtimeMs >= cutoffMs)
1102
- out.push({ file: full, mtimeMs: stat.mtimeMs });
1101
+ if (stat.mtimeMs >= cutoffMs) {
1102
+ const dedupeKey = await fs.realpath(full).catch(() => path.resolve(full));
1103
+ if (!seen.has(dedupeKey)) {
1104
+ seen.add(dedupeKey);
1105
+ out.push({ file: full, mtimeMs: stat.mtimeMs });
1106
+ }
1107
+ }
1103
1108
  }
1104
1109
  }
1105
1110
  out.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -6,7 +6,7 @@ import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../a
6
6
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
7
7
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
8
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
9
- import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
9
+ import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
10
10
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
11
11
  import { acquireSyncLock } from "../sync-lock.js";
12
12
  import { discoverGitWorktrees } from "../repo-identity.js";
@@ -1143,9 +1143,11 @@ async function runSessions(command, io) {
1143
1143
  const wantClaude = command.source !== "codex";
1144
1144
  const codex = wantCodex
1145
1145
  ? await scanAndAttributeCodexSessions({
1146
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
1146
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
1147
1147
  worktrees,
1148
1148
  now,
1149
+ sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
1150
+ limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
1149
1151
  })
1150
1152
  : null;
1151
1153
  const claude = wantClaude
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
9
9
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
10
- import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
10
+ import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
11
11
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
12
12
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
13
13
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
@@ -30,9 +30,11 @@ export async function runAttributedWorktreeSync(options) {
30
30
  const paths = getCollectorRuntimePaths(options.homeDir);
31
31
  const claudeEnabled = await isClaudeCollectionEnabled(paths);
32
32
  const codexAttribution = await scanAndAttributeCodexSessions({
33
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
33
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
34
34
  worktrees: options.worktrees,
35
35
  now,
36
+ sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
37
+ limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
36
38
  });
37
39
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
38
40
  // days so the first sync captures retroactive history instead of only 24h.
@@ -5,6 +5,7 @@ import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
8
+ import { normalizeCollectionRoots } from "./root-normalization.js";
8
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
9
10
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
11
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
@@ -26,10 +27,10 @@ export async function installLocalCollector(options = {}) {
26
27
  const paths = getCollectorRuntimePaths(homeDir);
27
28
  await ensureRuntimeDirectories(paths);
28
29
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
29
- const defaultRepoPaths = new Set(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? []));
30
- for (const root of repoRoots.length > 0 ? repoRoots : [repoRoot]) {
31
- defaultRepoPaths.add(root);
32
- }
30
+ const defaultRepoPaths = normalizeRepoRoots([
31
+ ...(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? [])),
32
+ ...(repoRoots.length > 0 ? repoRoots : [repoRoot]),
33
+ ]);
33
34
  const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
34
35
  existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
35
36
  ? "remote_durable_opt_in"
@@ -45,7 +46,7 @@ export async function installLocalCollector(options = {}) {
45
46
  defaultDeviceName(),
46
47
  claimed_owner_email: existingConfig?.claimed_owner_email,
47
48
  operator_id: existingConfig?.operator_id,
48
- default_repo_paths: [...defaultRepoPaths],
49
+ default_repo_paths: defaultRepoPaths,
49
50
  raw_evidence_upload: rawEvidenceUpload,
50
51
  session_file_path: paths.session_file,
51
52
  state_dir_path: paths.state_dir,
@@ -62,15 +63,15 @@ function normalizeRepoRoots(repoRoots) {
62
63
  if (!repoRoots)
63
64
  return [];
64
65
  const seen = new Set();
65
- const normalized = [];
66
+ const candidates = [];
66
67
  for (const root of repoRoots) {
67
68
  const resolved = path.resolve(root);
68
69
  if (seen.has(resolved))
69
70
  continue;
70
71
  seen.add(resolved);
71
- normalized.push(resolved);
72
+ candidates.push(resolved);
72
73
  }
73
- return normalized;
74
+ return normalizeCollectionRoots(candidates);
74
75
  }
75
76
  export async function pairLocalCollector(options = {}) {
76
77
  const homeDir = options.homeDir ?? os.homedir();
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { normalizeCollectionRoots } from "./root-normalization.js";
4
5
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
5
6
  export async function resolveOnboardingRoots(options) {
6
7
  const explicitRoots = normalizeRoots(options.explicitRoots ?? []);
@@ -43,7 +44,7 @@ export async function resolveOnboardingRoots(options) {
43
44
  }
44
45
  export function normalizeRoots(roots) {
45
46
  const seen = new Set();
46
- const normalized = [];
47
+ const candidates = [];
47
48
  for (const root of roots) {
48
49
  const trimmed = root.trim();
49
50
  if (!trimmed)
@@ -57,10 +58,10 @@ export function normalizeRoots(roots) {
57
58
  if (seen.has(resolved))
58
59
  continue;
59
60
  seen.add(resolved);
60
- normalized.push(resolved);
61
+ candidates.push(resolved);
61
62
  }
62
63
  }
63
- return normalized;
64
+ return normalizeCollectionRoots(candidates);
64
65
  }
65
66
  function splitRootInput(value) {
66
67
  return value
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ export function collapseAncestorRoots(roots) {
3
+ const collapsed = [];
4
+ for (const root of roots) {
5
+ const resolved = path.resolve(root);
6
+ if (collapsed.some((existing) => containsPath(existing, resolved)))
7
+ continue;
8
+ for (let index = collapsed.length - 1; index >= 0; index -= 1) {
9
+ if (containsPath(resolved, collapsed[index])) {
10
+ collapsed.splice(index, 1);
11
+ }
12
+ }
13
+ collapsed.push(resolved);
14
+ }
15
+ return collapsed;
16
+ }
17
+ export function normalizeCollectionRoots(roots) {
18
+ const collapsed = collapseAncestorRoots(roots);
19
+ if (!collapsed.some(isBliWorkspaceRoot))
20
+ return collapsed;
21
+ return collapsed.filter((root) => !isCodexWorktreePath(root));
22
+ }
23
+ function containsPath(parent, candidate) {
24
+ const relative = path.relative(parent, candidate);
25
+ return (relative === "" ||
26
+ (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)));
27
+ }
28
+ function isBliWorkspaceRoot(root) {
29
+ return path.basename(root) === "BLI";
30
+ }
31
+ function isCodexWorktreePath(root) {
32
+ const parts = root.split(path.sep).filter(Boolean);
33
+ return parts.some((part, index) => part === ".codex" && parts[index + 1] === "worktrees");
34
+ }
package/dist/upload.js CHANGED
@@ -2,6 +2,7 @@ import { AgentImageArtifactReportRequestSchema, EvidenceCompletenessPayloadSchem
2
2
  import path from "node:path";
3
3
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
4
4
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
5
+ import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
5
6
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
6
7
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
7
8
  import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
@@ -57,7 +58,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
57
58
  workContextId: uploadContext.work_context_id,
58
59
  activeWorkContext: activeContext,
59
60
  rawEvidenceStateDir: paths.state_dir,
60
- rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
61
+ rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
61
62
  claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
62
63
  rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
63
64
  rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {