@bli-cockpit/cli 0.2.293 → 0.2.295

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/adapters/codex-attribution.js +28 -7
  2. package/dist/adapters/codex-segment-discovery.js +12 -3
  3. package/dist/adapters/codex-session-revision.js +58 -3
  4. package/dist/adapters/local-sources.js +1 -0
  5. package/dist/adapters/opencode-store.js +201 -0
  6. package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
  7. package/dist/adapters/raw-evidence-codex-reader.js +25 -3
  8. package/dist/adapters/raw-evidence-transcript-body.js +7 -2
  9. package/dist/adapters/raw-evidence-transcript-file.js +19 -0
  10. package/dist/adapters/raw-evidence-transcript-stream.js +6 -3
  11. package/dist/adapters/raw-evidence.js +13 -0
  12. package/dist/adapters/t3-projection-source.js +96 -0
  13. package/dist/commands/backfill-checkpoint.js +1 -1
  14. package/dist/commands/backfill-issues.js +5 -0
  15. package/dist/commands/backfill-output.js +12 -0
  16. package/dist/commands/backfill-report.js +3 -3
  17. package/dist/commands/backfill-scan.js +2 -0
  18. package/dist/commands/backfill-scope.js +2 -0
  19. package/dist/commands/backfill-upload.js +3 -2
  20. package/dist/commands/backfill.js +35 -3
  21. package/dist/commands/codex-revision-facts.js +3 -3
  22. package/dist/commands/collection-report.js +12 -0
  23. package/dist/commands/local-args-collector-status.js +2 -2
  24. package/dist/commands/local-args-collector-work.js +2 -2
  25. package/dist/commands/local-help-commands-tower.js +1 -1
  26. package/dist/commands/local-help.js +3 -3
  27. package/dist/commands/opencode-sync.js +163 -0
  28. package/dist/commands/pi-sync.js +167 -0
  29. package/dist/commands/public-root.js +1 -1
  30. package/dist/commands/session-sync-failures.js +8 -0
  31. package/dist/commands/session-sync-record.js +1 -1
  32. package/dist/commands/session-sync-upload.js +2 -1
  33. package/dist/commands/session-sync.js +21 -1
  34. package/dist/commands/sessions.js +44 -8
  35. package/dist/commands/sources-check.js +56 -47
  36. package/dist/commands/sources-discovery.js +56 -4
  37. package/dist/commands/sources-verdict.js +3 -3
  38. package/dist/local-state-status.js +33 -1
  39. package/dist/opencode-inventory.js +203 -0
  40. package/dist/opencode-policy.js +29 -0
  41. package/dist/pi-inventory.js +238 -0
  42. package/dist/pi-policy.js +29 -0
  43. package/dist/slim/slim-transcript.js +3 -2
  44. package/dist/source-opt-out.js +3 -1
  45. package/dist/spool/local-spool-parse.js +1 -1
  46. package/dist/state-dir-audit.js +3 -0
  47. package/dist/sync-health-class.js +4 -0
  48. package/dist/upload-envelope-build.js +1 -0
  49. package/dist/upload-evidence-delivery-summary.js +10 -0
  50. package/dist/upload-session-reports-wire.js +15 -8
  51. package/package.json +4 -4
@@ -7,6 +7,7 @@ import { existsSync } from "node:fs";
7
7
  import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import { describeError } from "../health-detail.js";
10
+ import { TranscriptReadError } from "./transcript-text-stream.js";
10
11
  import { extractCodexSessionSignals, } from "./codex-session-signals.js";
11
12
  import { readCodexMetadataMemoized } from "./codex-attribution-memoized-reads.js";
12
13
  import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
@@ -48,6 +49,9 @@ export async function scanAndAttributeCodexSessions(options) {
48
49
  for (const file of files) {
49
50
  results.push(await attributeOneSession(file, options.worktrees, options.collectionRoots ?? [], options.readMemo));
50
51
  }
52
+ if (retained.projectionFailureReason)
53
+ for (const result of results)
54
+ result.t3_projection_incomplete = true;
51
55
  // Collector stdout is `--json`, so the line goes to stderr; counts only (BLI-23623).
52
56
  console.error("[hands-on] codex scan", JSON.stringify({ client: "codex", ...summarizeHandsOn(results.map((r) => r.session_facts)) }));
53
57
  return {
@@ -60,6 +64,7 @@ export async function scanAndAttributeCodexSessions(options) {
60
64
  max_file_bytes: 0,
61
65
  directory_read_failed_count: discovery.directoryReadFailedCount,
62
66
  stat_failed_count: discovery.statFailedCount + retained.readFailed,
67
+ ...(retained.projectionFailureReason ? { projection_failure_reason: retained.projectionFailureReason } : {}),
63
68
  secret_path_skipped_count: discovery.secretPathSkippedCount,
64
69
  counts: {
65
70
  attributed: results.filter((entry) => entry.state === "attributed").length,
@@ -172,18 +177,34 @@ function dedupePaths(values) {
172
177
  return out;
173
178
  }
174
179
  async function attributeOneSession(file, worktrees, collectionRoots, readMemo) {
175
- const base = { ...createSessionBase(file), ...(file.segmentPaths ? { segment_paths: file.segmentPaths } : {}) };
180
+ const base = { ...createSessionBase(file), ...(file.segmentPaths ? { segment_paths: file.segmentPaths } : {}), ...(file.t3Projection ? { t3_projection: true } : {}) };
181
+ const readComposite = async (includeT3) => {
182
+ const source = await openCodexSessionRevision(file.segmentPaths, { t3Projection: includeT3 });
183
+ try {
184
+ base.local_cwds = source.segmentCwds;
185
+ const metadata = await readCodexMetadataSignals(file.file, pinnedBytes(source));
186
+ await source.verifyUnchanged();
187
+ return metadata;
188
+ }
189
+ finally {
190
+ await source.close();
191
+ }
192
+ };
176
193
  let read;
177
194
  try {
178
195
  if (file.segmentPaths) {
179
- const source = await openCodexSessionRevision(file.segmentPaths);
180
196
  try {
181
- base.local_cwds = source.segmentCwds;
182
- read = await readCodexMetadataSignals(file.file, pinnedBytes(source));
183
- await source.verifyUnchanged();
197
+ read = await readComposite(file.t3Projection === true);
184
198
  }
185
- finally {
186
- await source.close();
199
+ catch (error) {
200
+ if (!file.t3Projection || !(error instanceof TranscriptReadError) || !error.stage.startsWith("t3_"))
201
+ throw error;
202
+ // Available native content still flows; the missing projection remains a
203
+ // named retry and cannot promote a partial snapshot as the full revision.
204
+ base.t3_projection = false;
205
+ base.t3_projection_incomplete = true;
206
+ base.t3_projection_failure_reason = error.stage;
207
+ read = await readComposite(false);
187
208
  }
188
209
  }
189
210
  else
@@ -2,6 +2,7 @@
2
2
  import { createReadStream } from "node:fs";
3
3
  import readline from "node:readline";
4
4
  import path from "node:path";
5
+ import { t3CodexProjectionSessionIds } from "./t3-projection-source.js";
5
6
  import { sanitizeSessionId, sessionIdFromFileName, shortHash } from "./attribution-core.js";
6
7
  export async function codexFileIdentity(file) {
7
8
  const stream = createReadStream(file, { encoding: "utf8" });
@@ -55,12 +56,20 @@ export async function retainedCodexSegments(files, seeds) {
55
56
  readFailed++;
56
57
  }
57
58
  }
58
- const sessions = [...wanted.values()].map(group => {
59
+ let projectionIds = new Set(), projectionFailureReason;
60
+ try {
61
+ projectionIds = t3CodexProjectionSessionIds();
62
+ }
63
+ catch (error) {
64
+ projectionFailureReason = error instanceof Error ? error.message : "t3_projection_unreadable";
65
+ }
66
+ const sessions = [...wanted.entries()].map(([id, group]) => {
59
67
  // A deterministic representative keeps existing cursor/source-path identity.
60
68
  const representative = [...group].sort((a, b) => a.file.localeCompare(b.file))[0];
61
69
  const mtimeMs = Math.max(...group.map(f => f.mtimeMs));
62
70
  return { ...representative, mtimeMs, byteSize: group.reduce((n, f) => n + f.byteSize, 0),
63
- ...(group.length > 1 ? { segmentPaths: group.map(f => f.file).sort() } : {}) };
71
+ ...(projectionIds.has(id) ? { t3Projection: true } : {}),
72
+ ...(group.length > 1 || projectionIds.has(id) ? { segmentPaths: group.map(f => f.file).sort() } : {}) };
64
73
  });
65
- return { sessions, readFailed, selectedFiles: [...wanted.values()].reduce((n, f) => n + f.length, 0) };
74
+ return { sessions, readFailed, selectedFiles: [...wanted.values()].reduce((n, f) => n + f.length, 0), ...(projectionFailureReason ? { projectionFailureReason } : {}) };
66
75
  }
@@ -10,8 +10,10 @@
10
10
  */
11
11
  import { createHash } from "node:crypto";
12
12
  import fs from "node:fs/promises";
13
+ import { createNativeT3MessageIndex } from "@bli-cockpit/telemetry-core";
14
+ import { readT3CodexProjection, t3ProjectionRevision } from "./t3-projection-source.js";
13
15
  import { openPinnedTranscript } from "./pinned-transcript.js";
14
- import { TranscriptNotStreamable, TranscriptTooLargeError } from "./transcript-text-stream.js";
16
+ import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
15
17
  const newline = Buffer.from("\n");
16
18
  const hashOf = (bytes) => createHash("sha256").update(bytes).digest("hex");
17
19
  const stampOf = async (file) => {
@@ -38,6 +40,8 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
38
40
  const close = async () => { await Promise.all(segments.map(s => s.source.close())); };
39
41
  try {
40
42
  let nativeBytes = 0;
43
+ const nativeMessages = createNativeT3MessageIndex();
44
+ const nativeIds = new Set();
41
45
  for (const filePath of [...new Set(filePaths)]) {
42
46
  const stamp = await stampOf(filePath);
43
47
  const source = await openPinnedTranscript(filePath, { maxFileBytes: options.maxFileBytes });
@@ -54,6 +58,10 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
54
58
  // Timestamp metadata only. Record bodies never leave this process.
55
59
  try {
56
60
  const r = JSON.parse(line.bytes.toString("utf8"));
61
+ if (options.t3Projection)
62
+ nativeMessages.observe(r, hashOf(line.bytes));
63
+ if (r.type === "session_meta" && typeof r.payload?.id === "string")
64
+ nativeIds.add(r.payload.id);
57
65
  if ((r.type === "session_meta" || r.type === "turn_context") && typeof r.payload?.cwd === "string")
58
66
  segment.cwds.add(r.payload.cwd);
59
67
  const time = r.type === "session_meta" ? NaN : Date.parse(r.timestamp ?? "");
@@ -62,7 +70,11 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
62
70
  segment.lastTimestamp = Math.max(segment.lastTimestamp, time);
63
71
  }
64
72
  }
65
- catch { /* Broken/partial lines stay verbatim in the logical revision. */ }
73
+ catch (error) {
74
+ if (error instanceof Error && error.message === "t3_native_identity_index_capped")
75
+ throw error;
76
+ /* Broken/partial lines stay verbatim in the logical revision. */
77
+ }
66
78
  }
67
79
  segment.sha256 = hash.digest("hex");
68
80
  segment.terminalNewline = source.length > 0 && (await source.read(source.length - 1, 1))[0] === 10;
@@ -78,10 +90,27 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
78
90
  } }) + "\n");
79
91
  length += s.header.length + s.source.length + (s.terminalNewline || s.source.length === 0 ? 0 : 1);
80
92
  }
93
+ // Projection bytes are bounded, pinned in memory, and use the SAME governed
94
+ // staging/redaction stream. No native transcript aggregate or unmanaged raw copy.
95
+ const nativeId = options.t3Projection && nativeIds.size === 1 ? [...nativeIds][0] : null;
96
+ const projection = nativeId ? readT3CodexProjection(nativeId, options.projectionHomeDir) : [];
97
+ const projectionRevision = t3ProjectionRevision(projection);
98
+ const supplementalCwds = projection.flatMap(message => nativeMessages.turnCwds(message));
99
+ const extra = projection.map(message => {
100
+ const nativeCopy = nativeMessages.copyOf(message);
101
+ return Buffer.from(JSON.stringify({ type: "tower_t3_message", timestamp: message.created_at,
102
+ payload: { ...message, ...(nativeCopy ? { native_copy: nativeCopy } : {}) } }) + "\n");
103
+ });
104
+ const supplementalStart = length;
105
+ const extraOffsets = [];
106
+ for (const bytes of extra) {
107
+ extraOffsets.push(length);
108
+ length += bytes.length;
109
+ }
81
110
  if (options.maxFileBytes && length > options.maxFileBytes)
82
111
  throw new TranscriptTooLargeError(options.maxFileBytes);
83
112
  const logical = {
84
- length, sha256: "", segmentCount: segments.length, segmentCwds: segments.flatMap(s => s.cwds.size ? [...s.cwds] : [null]),
113
+ length, sha256: "", segmentCount: segments.length, segmentCwds: [...segments.flatMap(s => s.cwds.size ? [...s.cwds] : [null]), ...supplementalCwds],
85
114
  async *lines() {
86
115
  for (const s of segments) {
87
116
  yield { start: s.start, bytes: s.header.subarray(0, -1) };
@@ -91,6 +120,8 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
91
120
  yield { start: s.start + s.header.length + line.start, bytes: line.bytes };
92
121
  }
93
122
  }
123
+ for (const [i, bytes] of extra.entries())
124
+ yield { start: extraOffsets[i], bytes: bytes.subarray(0, -1) };
94
125
  yield { start: length, bytes: Buffer.alloc(0) };
95
126
  },
96
127
  async read(offset, size) {
@@ -113,6 +144,15 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
113
144
  done += bytes.length;
114
145
  }
115
146
  }
147
+ if (offset + size > supplementalStart)
148
+ for (const [i, bytes] of extra.entries()) {
149
+ const start = extraOffsets[i], from = Math.max(offset, start), to = Math.min(offset + size, start + bytes.length);
150
+ if (to <= from)
151
+ continue;
152
+ const part = bytes.subarray(from - start, to - start);
153
+ part.copy(output, done);
154
+ done += part.length;
155
+ }
116
156
  if (done !== size)
117
157
  throw new TranscriptNotStreamable("composite_range_invalid");
118
158
  return output;
@@ -123,6 +163,19 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
123
163
  if (await stampOf(s.filePath) !== s.stamp || await digest(s.source) !== s.sha256)
124
164
  throw new TranscriptNotStreamable("file_changed_during_pass");
125
165
  }
166
+ if (nativeId) {
167
+ let currentProjection;
168
+ try {
169
+ currentProjection = readT3CodexProjection(nativeId, options.projectionHomeDir);
170
+ }
171
+ catch (error) {
172
+ if (error instanceof Error && error.message.startsWith("t3_"))
173
+ throw new TranscriptReadError(error.message, error);
174
+ throw error;
175
+ }
176
+ if (t3ProjectionRevision(currentProjection) !== projectionRevision)
177
+ throw new TranscriptNotStreamable("composite_revision_changed");
178
+ }
126
179
  },
127
180
  close,
128
181
  };
@@ -134,6 +187,8 @@ export async function openCodexSessionRevision(filePaths, options = {}) {
134
187
  }
135
188
  catch (error) {
136
189
  await close();
190
+ if (error instanceof Error && error.message.startsWith("t3_"))
191
+ throw new TranscriptReadError(error.message, error);
137
192
  throw error;
138
193
  }
139
194
  }
@@ -76,6 +76,7 @@ async function collectConfiguredRawEvidence(context, options) {
76
76
  includeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
77
77
  codexSessionFiles: options.rawEvidenceCodexSessionFiles,
78
78
  codexAttributionScan: options.rawEvidenceCodexAttributionScan,
79
+ nativeSessionFiles: options.rawEvidenceNativeSessionFiles,
79
80
  claudeSessionFiles: options.rawEvidenceClaudeSessionFiles,
80
81
  claudeAttributionScan: options.rawEvidenceClaudeAttributionScan,
81
82
  skipContentHashes: options.rawEvidenceSkipContentHashes,
@@ -0,0 +1,201 @@
1
+ /** Read native OpenCode sessions without changing the live store or losing its WAL. */
2
+ import path from "node:path";
3
+ import { access } from "node:fs/promises";
4
+ import { createHash } from "node:crypto";
5
+ export class OpenCodeReadError extends Error {
6
+ reason;
7
+ constructor(reason) { super(reason); this.reason = reason; }
8
+ }
9
+ /** Resolve only the documented data home; never look in the sibling auth file. */
10
+ export function openCodeDatabasePath(homeDir, xdgDataHome) {
11
+ return path.join(xdgDataHome || path.join(homeDir, ".local", "share"), "opencode", "opencode.db");
12
+ }
13
+ /** An absent store is distinct from an unreadable or incompatible one. */
14
+ export async function openOpenCodeStore(databasePath) {
15
+ try {
16
+ await access(databasePath);
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return null;
21
+ throw new OpenCodeReadError("opencode_store_unreadable");
22
+ }
23
+ let database;
24
+ try {
25
+ // Dynamic import keeps older CLI runtimes usable for their existing sources.
26
+ // They name this source's runtime gap rather than pretending it has no sessions.
27
+ const sqliteModule = "node:sqlite";
28
+ const { DatabaseSync } = await import(sqliteModule);
29
+ database = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 });
30
+ }
31
+ catch (error) {
32
+ const code = error.code;
33
+ throw new OpenCodeReadError(code === "ERR_UNKNOWN_BUILTIN_MODULE" || code === "ERR_MODULE_NOT_FOUND" ? "opencode_sqlite_runtime_unavailable" : "opencode_sqlite_open_failed");
34
+ }
35
+ return new OpenCodeStore(database);
36
+ }
37
+ export class OpenCodeStore {
38
+ database;
39
+ constructor(database) { this.database = database; }
40
+ close() { this.database.close(); }
41
+ /** Keyset inventory uses the same native identities for scheduled, historical and reconciliation reads. */
42
+ list(afterId = "", limit = 100) {
43
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
44
+ throw new OpenCodeReadError("opencode_page_limit_invalid");
45
+ try {
46
+ return this.database.prepare(`select id, directory, parent_id, version, time_created, time_updated
47
+ from session where id > ? order by id limit ?`).all(afterId, limit).map(sessionMetadata);
48
+ }
49
+ catch {
50
+ throw new OpenCodeReadError("opencode_inventory_read_failed");
51
+ }
52
+ }
53
+ /** Stream native records at one WAL snapshot so total history size never becomes a buffered-session limit. */
54
+ *records(sessionId) {
55
+ try {
56
+ this.database.exec("BEGIN");
57
+ }
58
+ catch {
59
+ throw new OpenCodeReadError("opencode_snapshot_begin_failed");
60
+ }
61
+ try {
62
+ const session = this.database.prepare("select * from session where id = ?").get(sessionId);
63
+ if (!session)
64
+ throw new OpenCodeReadError("opencode_session_disappeared");
65
+ yield { type: "opencode_session", info: session, timestamp: new Date(Number(session.time_created)).toISOString() };
66
+ let afterMessage = "";
67
+ let parts = 0;
68
+ while (true) {
69
+ const messages = this.database.prepare("select id from message where session_id = ? and id > ? order by id limit 100").all(sessionId, afterMessage);
70
+ if (!messages.length)
71
+ break;
72
+ for (const message of messages) {
73
+ const raw = this.database.prepare("select * from message where session_id = ? and id = ?").get(sessionId, String(message.id));
74
+ const info = { ...parseNativeObject(raw.data), id: message.id, sessionID: sessionId };
75
+ yield { type: "opencode_message", info, storage: nativeStorageMetadata(raw), timestamp: nativeTimestamp(info.time) };
76
+ let afterPart = "";
77
+ while (true) {
78
+ const batch = this.database.prepare("select id from part where session_id = ? and message_id = ? and id > ? order by id limit 100").all(sessionId, String(message.id), afterPart);
79
+ if (!batch.length)
80
+ break;
81
+ for (const part of batch) {
82
+ const rawPart = this.database.prepare("select * from part where session_id = ? and id = ?").get(sessionId, String(part.id));
83
+ yield { type: "opencode_part", storage: nativeStorageMetadata(rawPart), role: info.role, timestamp: nativeTimestamp(parseNativeObject(rawPart.data).time) ?? nativeTimestamp(info.time), part: { ...parseNativeObject(rawPart.data), id: part.id, messageID: message.id, sessionID: sessionId } };
84
+ afterPart = String(part.id);
85
+ parts += 1;
86
+ }
87
+ }
88
+ afterMessage = String(message.id);
89
+ }
90
+ }
91
+ if (Number(this.database.prepare("select count(*) as count from part where session_id = ?").get(sessionId)?.count) !== parts)
92
+ throw new OpenCodeReadError("opencode_orphan_parts");
93
+ }
94
+ catch (error) {
95
+ if (error instanceof OpenCodeReadError)
96
+ throw error;
97
+ throw new OpenCodeReadError("opencode_snapshot_read_failed");
98
+ }
99
+ finally {
100
+ this.database.exec("ROLLBACK");
101
+ }
102
+ }
103
+ /** A read transaction sees the session, messages and parts at one WAL snapshot. No source files are copied. */
104
+ snapshot(sessionId, maxBytes = Number.MAX_SAFE_INTEGER) {
105
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
106
+ throw new OpenCodeReadError("opencode_byte_limit_invalid");
107
+ try {
108
+ this.database.exec("BEGIN");
109
+ }
110
+ catch {
111
+ throw new OpenCodeReadError("opencode_snapshot_begin_failed");
112
+ }
113
+ try {
114
+ const session = this.database.prepare("select * from session where id = ?").get(sessionId);
115
+ if (!session)
116
+ throw new OpenCodeReadError("opencode_session_disappeared");
117
+ const lines = [JSON.stringify({ type: "opencode_session", info: session }) + "\n"];
118
+ let bytes = Buffer.byteLength(lines[0]);
119
+ let afterId = "";
120
+ let messages = 0;
121
+ let parts = 0;
122
+ while (true) {
123
+ // Bound the bytes selected BEFORE decoding content. Oversized messages remain pending, never truncated.
124
+ const rows = this.database.prepare(`select id, length(cast(data as blob)) as bytes from message
125
+ where session_id = ? and id > ? order by id limit 100`).all(sessionId, afterId);
126
+ if (!rows.length)
127
+ break;
128
+ for (const row of rows) {
129
+ if (bytes + Number(row.bytes) > maxBytes)
130
+ throw new OpenCodeReadError("opencode_session_byte_cap");
131
+ const message = this.database.prepare("select * from message where session_id = ? and id = ?").get(sessionId, String(row.id));
132
+ const info = { ...parseNativeObject(message.data), id: row.id, sessionID: sessionId };
133
+ const nativeParts = [];
134
+ const partStorage = {};
135
+ const priorBytes = bytes;
136
+ let afterPart = "";
137
+ while (true) {
138
+ const batch = this.database.prepare(`select id, length(cast(data as blob)) as bytes from part
139
+ where session_id = ? and message_id = ? and id > ? order by id limit 100`).all(sessionId, String(row.id), afterPart);
140
+ if (!batch.length)
141
+ break;
142
+ for (const part of batch) {
143
+ bytes += Number(part.bytes);
144
+ if (bytes + Number(row.bytes) > maxBytes)
145
+ throw new OpenCodeReadError("opencode_session_byte_cap");
146
+ const native = this.database.prepare("select * from part where session_id = ? and id = ?").get(sessionId, String(part.id));
147
+ partStorage[String(part.id)] = nativeStorageMetadata(native);
148
+ nativeParts.push({ ...parseNativeObject(native.data), id: part.id, sessionID: sessionId, messageID: row.id });
149
+ afterPart = String(part.id);
150
+ parts += 1;
151
+ }
152
+ }
153
+ const line = JSON.stringify({ type: "opencode_message", info, parts: nativeParts, storage: { message: nativeStorageMetadata(message), parts: partStorage } }) + "\n";
154
+ // Account for wrapper/identity bytes too, without counting native parts twice.
155
+ bytes = priorBytes + Buffer.byteLength(line);
156
+ if (bytes > maxBytes)
157
+ throw new OpenCodeReadError("opencode_session_byte_cap");
158
+ lines.push(line);
159
+ messages += 1;
160
+ afterId = String(row.id);
161
+ }
162
+ }
163
+ const storedParts = this.database.prepare("select count(*) as count from part where session_id = ?").get(sessionId);
164
+ if (Number(storedParts?.count) !== parts)
165
+ throw new OpenCodeReadError("opencode_orphan_parts");
166
+ const jsonl = lines.join("");
167
+ return { session: sessionMetadata(session), jsonl, revision: createHash("sha256").update(jsonl).digest("hex"), messages, parts };
168
+ }
169
+ catch (error) {
170
+ if (error instanceof OpenCodeReadError)
171
+ throw error;
172
+ throw new OpenCodeReadError("opencode_snapshot_read_failed");
173
+ }
174
+ finally {
175
+ this.database.exec("ROLLBACK");
176
+ }
177
+ }
178
+ }
179
+ function sessionMetadata(row) {
180
+ if (typeof row.id !== "string" || typeof row.directory !== "string" || typeof row.version !== "string" || !Number.isFinite(row.time_updated)) {
181
+ throw new OpenCodeReadError("opencode_session_schema_unknown");
182
+ }
183
+ return { id: row.id, directory: row.directory, version: row.version, parent_id: typeof row.parent_id === "string" ? row.parent_id : null, updated_at_ms: Number(row.time_updated), created_at_ms: Number(row.time_created ?? row.time_updated) };
184
+ }
185
+ function parseNativeObject(raw) {
186
+ if (typeof raw !== "string")
187
+ throw new OpenCodeReadError("opencode_record_invalid");
188
+ const parsed = JSON.parse(raw);
189
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
190
+ throw new OpenCodeReadError("opencode_record_invalid");
191
+ return parsed;
192
+ }
193
+ function nativeTimestamp(value) {
194
+ const time = value && typeof value === "object" ? value : {};
195
+ const ms = time.created ?? time.start ?? time.end ?? time.completed;
196
+ return typeof ms === "number" && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
197
+ }
198
+ /** Keep physical native row metadata without duplicating the JSON content body. */
199
+ function nativeStorageMetadata(row) {
200
+ return Object.fromEntries(Object.entries(row).filter(([key]) => key !== "data"));
201
+ }
@@ -7,6 +7,11 @@ import { recordScanned, recordSkipCount, recordTruncationCount, } from "./raw-ev
7
7
  export function recordCodexAttributionCompleteness(ledger, scan, selectedPaths) {
8
8
  recordScanned(ledger, "codex_attribution", scan.scanned_file_count);
9
9
  ledger.caps.push(...codexAttributionCaps(scan));
10
+ if (scan.projection_failure_reason)
11
+ recordSkipCount(ledger, "codex_attribution", scan.projection_failure_reason, 1);
12
+ for (const result of scan.results)
13
+ if (result.t3_projection_failure_reason)
14
+ recordSkipCount(ledger, "codex_attribution", result.t3_projection_failure_reason, 1);
10
15
  recordSkipCount(ledger, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
11
16
  recordSkipCount(ledger, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
12
17
  recordSkipCount(ledger, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
@@ -21,6 +21,7 @@ import { isSecretLikePath, safeKeySegment, shortHash, } from "./raw-evidence-key
21
21
  import { recordScanned } from "./raw-evidence-completeness.js";
22
22
  import { collectOneEvidenceFile } from "./raw-evidence-transcript-file.js";
23
23
  import { collectAgentImagesFromTranscript } from "./raw-evidence-image-reader.js";
24
+ import { TranscriptNotStreamable, TranscriptReadError } from "./transcript-text-stream.js";
24
25
  export async function collectCodexJsonlFiles(collection, options) {
25
26
  for (const candidate of await chooseCodexCandidates(collection, options)) {
26
27
  recordScanned(collection, "codex_jsonl");
@@ -38,7 +39,7 @@ async function chooseCodexCandidates(collection, options) {
38
39
  ? options.codexSessionFiles.map((file) => ({
39
40
  filePath: file.local_path,
40
41
  codexSessionId: file.codex_session_id,
41
- segmentPaths: file.segment_paths, revisionHash: file.revision_hash, revisionBytes: file.revision_bytes,
42
+ segmentPaths: file.segment_paths, revisionHash: file.revision_hash, revisionBytes: file.revision_bytes, t3Projection: file.t3_projection,
42
43
  }))
43
44
  : (await walkRecentCodexJsonlFiles(collection, options)).map((filePath) => ({ filePath, codexSessionId: null }));
44
45
  collection.caps.push({
@@ -62,10 +63,31 @@ async function collectOneCodexSession(collection, candidate) {
62
63
  // ceiling just like Claude mains.
63
64
  maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
64
65
  ...(candidate.segmentPaths ? { sourceProvider: async () => {
65
- const byteLength = candidate.revisionBytes ?? (await Promise.all(candidate.segmentPaths.map(p => fs.stat(p)))).reduce((n, s) => n + s.size + 1024, 0);
66
+ const revisionOptions = { t3Projection: candidate.t3Projection, maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, expectedHash: candidate.revisionHash };
67
+ const openSource = () => openCodexSessionRevision(candidate.segmentPaths, revisionOptions);
68
+ let byteLength = candidate.revisionBytes;
69
+ // Direct native callers may omit attribution's observed revision. Pin a
70
+ // complete observation before admission, then reopen only that revision;
71
+ // estimates from native file sizes omit provenance/projection overhead.
72
+ if (byteLength === undefined || !revisionOptions.expectedHash) {
73
+ let source;
74
+ try {
75
+ source = await openSource();
76
+ byteLength = source.length;
77
+ revisionOptions.expectedHash = source.sha256;
78
+ }
79
+ catch (error) {
80
+ if (error instanceof TranscriptNotStreamable)
81
+ throw new TranscriptReadError(error.reason, error);
82
+ throw error;
83
+ }
84
+ finally {
85
+ await source?.close();
86
+ }
87
+ }
66
88
  return { byteLength, source: { bytes: null, stream: {
67
89
  filePath: candidate.filePath, inputBytes: byteLength, maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
68
- openSource: () => openCodexSessionRevision(candidate.segmentPaths, { maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, expectedHash: candidate.revisionHash }),
90
+ openSource,
69
91
  } } };
70
92
  } } : {}),
71
93
  redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
@@ -9,8 +9,9 @@
9
9
  * bytes cannot be rebuilt, and the old path uploaded the original bytes when nothing changed.
10
10
  * The order stays image replacement, slim, redact.
11
11
  */
12
- import { TranscriptReadError } from "./transcript-text-stream.js";
13
12
  import { isUtf8 } from "node:buffer";
13
+ import { openPinnedTranscript } from "./pinned-transcript.js";
14
+ import { TranscriptReadError } from "./transcript-text-stream.js";
14
15
  import { mightHoldEmbeddedImages, modeDropsImages, modeStoresImages } from "@bli-cockpit/telemetry-core";
15
16
  import { describeError } from "../health-detail.js";
16
17
  import { makeCaptureProvenance } from "./common.js";
@@ -18,7 +19,7 @@ import { roomToStreamToStaging } from "./raw-evidence-budget-admission.js";
18
19
  import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
19
20
  /** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
20
21
  export function agentOfKind(kind) {
21
- return kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
22
+ return kind === "pi_jsonl" ? "pi" : kind === "opencode_jsonl" ? "opencode" : kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
22
23
  }
23
24
  /** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
24
25
  export function holdDecodedText(source) {
@@ -55,6 +56,10 @@ function releaseBackingStore(bytes) {
55
56
  * the caller turns into the `file_read_failed` and `file_too_large` skips.
56
57
  */
57
58
  export async function prepareTranscriptBody(collection, file) {
59
+ if ((file.kind === "opencode_jsonl" || file.kind === "pi_jsonl") && file.source.stream) {
60
+ const source = file.source.stream;
61
+ source.openSource ??= () => openPinnedTranscript(source.filePath, { maxFileBytes: source.maxFileBytes });
62
+ }
58
63
  const session = collection.budget.images;
59
64
  const agent = agentOfKind(file.kind);
60
65
  // BLI-23329: `store_and_drop` talks to the upload doors for the images of this transcript, as this sync.
@@ -172,6 +172,23 @@ async function prepareTranscriptForUpload(collection, options, names, source) {
172
172
  await discardStagedOutput(body.held.staged, "mask_threw");
173
173
  throw error;
174
174
  }
175
+ if (options.priorRedaction?.status === "sanitized" && (sanitized.status === "clean" || sanitized.completenessLabel === "secret_content_masked")) {
176
+ const prior = options.priorRedaction;
177
+ const final = sanitized.redaction;
178
+ const rules = new Map(prior.rule_counts.map(rule => [rule.rule_id, { ...rule }]));
179
+ for (const rule of final.rule_counts) {
180
+ const combined = rules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
181
+ combined.match_count += rule.match_count;
182
+ combined.redacted_char_count += rule.redacted_char_count;
183
+ rules.set(rule.rule_id, combined);
184
+ }
185
+ sanitized.redaction = { ...prior, ...final, status: "sanitized", mode: "deterministic_text_replacement",
186
+ applied_by: [...new Set([...prior.applied_by, ...final.applied_by])], rule_counts: [...rules.values()],
187
+ secret_like_match_count: prior.secret_like_match_count + final.secret_like_match_count,
188
+ redacted_fields: [...new Set([...prior.redacted_fields, ...final.redacted_fields])] };
189
+ collection.redacted.splice(redactedBefore);
190
+ collection.redacted.push({ kind: options.kind, label: names.evidenceLabel, redaction: sanitized.redaction, completenessLabel: "secret_content_masked" });
191
+ }
175
192
  // Only a clean file IS the spool. Anything the mask rewrote has new bytes (a new staged file, or the crash stub's), so the spool is of no use.
176
193
  if (body.held.staged && !("staged" in sanitized && sanitized.staged === body.held.staged))
177
194
  await discardStagedOutput(body.held.staged, "redaction_rewrote_the_bytes");
@@ -294,6 +311,8 @@ function logDeliveryBackoffHold(kind, sourceKey) {
294
311
  }
295
312
  /** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
296
313
  function streamMinBytesFor(collection, kind) {
314
+ if ((kind === "opencode_jsonl" || kind === "pi_jsonl"))
315
+ return 1024 * 1024;
297
316
  const session = collection.budget.images;
298
317
  if (!session?.prepareTranscriptFile || !agentOfKind(kind))
299
318
  return null;
@@ -77,9 +77,10 @@ export async function readTranscriptWhole(file) {
77
77
  async function streamCompositeWithoutImages(file) {
78
78
  if (!file.stageDir)
79
79
  throw new TranscriptReadError("composite_staging_unavailable", new Error("composite_staging_unavailable"));
80
- const source = await file.openSource();
80
+ let source;
81
81
  let writer;
82
82
  try {
83
+ source = await file.openSource();
83
84
  writer = await openStagedOutput(file.stageDir);
84
85
  await copyLinesStream(source.lines(), writer);
85
86
  await source.verifyUnchanged();
@@ -88,10 +89,12 @@ async function streamCompositeWithoutImages(file) {
88
89
  return { held: { text: null, exactBytes: null, staged }, volatile: true, pendingImages: null, readBytes: source.length };
89
90
  }
90
91
  catch (error) {
91
- await writer?.abandon();
92
+ await writer?.abandon().catch(() => console.error(TAG + " composite cleanup failed", JSON.stringify({ reason: "composite_staging_cleanup_failed", kind: file.kind })));
93
+ if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
94
+ throw error;
92
95
  throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
93
96
  }
94
97
  finally {
95
- await source.close();
98
+ await source?.close().catch(() => console.error(TAG + " composite source cleanup failed", JSON.stringify({ reason: "file_close_failed", kind: file.kind })));
96
99
  }
97
100
  }
@@ -61,6 +61,10 @@ import { DEFAULT_SESSION_LIMIT, DEFAULT_SINCE_MINUTES, newStagingDir, openCollec
61
61
  import { finishWithEmptyPack, finishWithFailedPack, finishWithPromotedPack, } from "./raw-evidence-facts.js";
62
62
  import { collectCodexJsonlFiles } from "./raw-evidence-codex-reader.js";
63
63
  import { collectClaudeJsonlFiles } from "./raw-evidence-claude-reader.js";
64
+ import { collectOneEvidenceFile } from "./raw-evidence-transcript-file.js";
65
+ import { recordScanned } from "./raw-evidence-completeness.js";
66
+ import { safeKeySegment } from "./raw-evidence-keys.js";
67
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
64
68
  import { collectGitDiffFiles } from "./raw-evidence-git-diff-reader.js";
65
69
  // Re-exported so every consumer keeps importing from `adapters/raw-evidence`.
66
70
  export { RAW_EVIDENCE_BUCKET, RAW_EVIDENCE_RETENTION_MODE, } from "./raw-evidence-manifest.js";
@@ -140,6 +144,15 @@ async function collectEverySource(collection, options, sinceMinutes) {
140
144
  if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
141
145
  await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
142
146
  }
147
+ for (const file of options.nativeSessionFiles ?? []) {
148
+ recordScanned(collection, file.source === "pi" ? "pi_jsonl" : "opencode_jsonl");
149
+ await collectOneEvidenceFile(collection, {
150
+ filePath: file.local_path, kind: file.source === "pi" ? "pi_jsonl" : "opencode_jsonl", sessionId: file.session_id, priorRedaction: file.redaction,
151
+ mediaType: "application/jsonl", maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
152
+ redactedSummary: "Native coding session with retained messages, tools, usage and unknown records.",
153
+ contentAddress: (hash16) => `${file.source}/${safeKeySegment(file.session_id)}/${hash16}.jsonl`,
154
+ });
155
+ }
143
156
  await collectGitDiffFiles(collection, options.repoRoot);
144
157
  }
145
158
  // ---------------------------------------------------------------------------