@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 +7 -0
- package/dist/adapters/codex-attribution.js +164 -75
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence.js +18 -13
- package/dist/commands/local.js +4 -2
- package/dist/commands/session-sync.js +4 -2
- package/dist/local-state.js +9 -8
- package/dist/onboarding-roots.js +4 -3
- package/dist/root-normalization.js +34 -0
- package/dist/upload.js +2 -1
- package/package.json +1 -1
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,
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
return skippedResult(base, "file_too_large");
|
|
133
|
-
}
|
|
134
|
-
let raw;
|
|
167
|
+
let read;
|
|
135
168
|
try {
|
|
136
|
-
|
|
169
|
+
read = await readCodexMetadataSignals(file.file);
|
|
137
170
|
}
|
|
138
171
|
catch {
|
|
139
172
|
return skippedResult(base, "file_read_failed");
|
|
140
173
|
}
|
|
141
|
-
|
|
142
|
-
base.
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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.
|
|
577
|
-
*
|
|
578
|
-
*
|
|
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
|
-
|
|
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);
|
package/dist/commands/local.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/local-state.js
CHANGED
|
@@ -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 =
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
72
|
+
candidates.push(resolved);
|
|
72
73
|
}
|
|
73
|
-
return
|
|
74
|
+
return normalizeCollectionRoots(candidates);
|
|
74
75
|
}
|
|
75
76
|
export async function pairLocalCollector(options = {}) {
|
|
76
77
|
const homeDir = options.homeDir ?? os.homedir();
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
+
candidates.push(resolved);
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
return
|
|
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
|
-
|
|
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,
|