@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
@@ -164,7 +164,7 @@ upload = {}) {
164
164
  const describedKeys = upload.describedKeys ?? new Set();
165
165
  const slimAware = upload.slimAware ?? modeDropsImages(mode);
166
166
  if (file.segment_paths)
167
- return codexRevisionFacts(file.segment_paths, mode, describedKeys, slimAware);
167
+ return codexRevisionFacts(file.segment_paths, mode, describedKeys, slimAware, file.t3_projection);
168
168
  const redactForUpload = (text, original) => redactTranscriptForm(file, text, original);
169
169
  let split = null;
170
170
  let whole = null;
@@ -186,7 +186,7 @@ upload = {}) {
186
186
  if (!buffer)
187
187
  return null;
188
188
  // Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
189
- const uploadText = uploadFormOfTranscript(buffer.toString("utf8"), file.source === "codex" ? "codex" : "claude_code", mode, describedKeys);
189
+ const uploadText = (file.source === "opencode" || file.source === "pi") ? buffer.toString("utf8") : uploadFormOfTranscript(buffer.toString("utf8"), file.source, mode, describedKeys);
190
190
  return redactForUpload(uploadText, buffer);
191
191
  });
192
192
  return sanitized;
@@ -202,7 +202,7 @@ upload = {}) {
202
202
  if (!buffer)
203
203
  return null;
204
204
  const text = buffer.toString("utf8");
205
- const slimText = uploadFormOfTranscript(text, file.source === "codex" ? "codex" : "claude_code", "describe_and_drop", describedKeys);
205
+ const slimText = uploadFormOfTranscript(text, file.source, "describe_and_drop", describedKeys);
206
206
  // Slimming changes nothing: one form, and `sanitized()` is the answer.
207
207
  if (slimText === text)
208
208
  return null;
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { readStagingInventory } from "./disk-usage.js";
@@ -18,7 +19,38 @@ import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-wo
18
19
  export async function inspectLocalCollectorStatus(options = {}) {
19
20
  const now = options.now ?? new Date();
20
21
  const reading = await readCollectorStatus(options, now);
21
- return collectorStatusFromReading(reading, now);
22
+ const status = collectorStatusFromReading(reading, now);
23
+ try {
24
+ const native = JSON.parse(await readFile(path.join(reading.paths.state_dir, "opencode-collection-status.json"), "utf8"));
25
+ if (native.source === "opencode" && ["complete", "partial", "absent", "withheld"].includes(native.coverage)
26
+ && [native.discovered, native.eligible, native.delivered, native.pending, native.withheld, native.unique].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0)) {
27
+ const at = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? new Date(native.observed_at).toISOString() : "unknown";
28
+ const reasons = Array.isArray(native.reasons) ? native.reasons.filter((reason) => typeof reason === "string" && /^[a-z0-9_:-]{1,100}$/.test(reason)).join(", ") : "";
29
+ status.details.push(`OpenCode: ${native.coverage}; discovered ${native.discovered}, eligible ${native.eligible}, delivered ${native.delivered}, pending ${native.pending}, withheld ${native.withheld}, unique ${native.unique}; as of ${at}${reasons ? ` (${reasons})` : ""}.`);
30
+ }
31
+ else
32
+ status.details.push("OpenCode coverage unavailable: opencode_status_invalid.");
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "ENOENT")
36
+ status.details.push("OpenCode coverage unavailable: opencode_status_unreadable.");
37
+ }
38
+ try {
39
+ const native = JSON.parse(await readFile(path.join(reading.paths.state_dir, "pi-collection-status.json"), "utf8"));
40
+ if (native.source === "pi" && ["complete", "partial", "absent", "withheld"].includes(native.coverage)
41
+ && [native.discovered, native.eligible, native.delivered, native.pending, native.withheld, native.unique].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0)) {
42
+ const at = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? new Date(native.observed_at).toISOString() : "unknown";
43
+ const reasons = Array.isArray(native.reasons) ? native.reasons.filter((reason) => typeof reason === "string" && /^[a-z0-9_:-]{1,100}$/.test(reason)).join(", ") : "";
44
+ status.details.push(`Pi: ${native.coverage}; discovered ${native.discovered}, eligible ${native.eligible}, delivered ${native.delivered}, pending ${native.pending}, withheld ${native.withheld}, unique ${native.unique}; as of ${at}${reasons ? ` (${reasons})` : ""}.`);
45
+ }
46
+ else
47
+ status.details.push("Pi coverage unavailable: pi_status_invalid.");
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT")
51
+ status.details.push("Pi coverage unavailable: pi_status_unreadable.");
52
+ }
53
+ return status;
22
54
  }
23
55
  function collectorStatusFromReading(reading, now) {
24
56
  const { paths, config, session, identity, context, branch } = reading;
@@ -0,0 +1,203 @@
1
+ /** OpenCode's one inventory for sync, history and reconciliation, exported through shared secret guards. */
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { newSessionFactsAccumulator, observeSessionFactsFile, observeSessionFactsRecord, finalizeSessionFacts, nativeObject, openCodeProviderIdentity } from "@bli-cockpit/telemetry-core";
6
+ import { openCodeDatabasePath, openOpenCodeStore, OpenCodeReadError } from "./adapters/opencode-store.js";
7
+ import { openStagingAllowance, roomToStageUpTo, admitToStaging } from "./staging-gate.js";
8
+ import { sanitizeTextEvidenceForUpload } from "./adapters/raw-evidence-sanitize.js";
9
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
10
+ export const OPENCODE_CURSOR_FILENAME = "opencode-raw-evidence.json";
11
+ /** Policy is decided from native directory metadata before exporting any conversation body. */
12
+ export async function readOpenCodeInventory(options) {
13
+ const result = { discovered: 0, sessions: [], eligible: 0, withheld: 0, files: [], shelved: [], failures: [], absent: false };
14
+ let store = null;
15
+ try {
16
+ const databasePath = options.databasePath ?? process.env.OPENCODE_DB ?? openCodeDatabasePath(options.homeDir, process.env.XDG_DATA_HOME);
17
+ store = await openOpenCodeStore(databasePath);
18
+ if (!store) {
19
+ result.absent = true;
20
+ console.error("[opencode inventory] source path absent", JSON.stringify({ source: "opencode", reason: "opencode_source_path_absent" }));
21
+ return result;
22
+ }
23
+ const sessions = [];
24
+ let afterId = "";
25
+ while (true) {
26
+ const page = store.list(afterId);
27
+ if (!page.length)
28
+ break;
29
+ sessions.push(...page.filter(session => options.sinceMs === undefined || session.updated_at_ms >= options.sinceMs));
30
+ afterId = page.at(-1).id;
31
+ }
32
+ result.discovered = sessions.length;
33
+ result.sessions = sessions;
34
+ if (options.metadataOnly) {
35
+ for (const session of sessions) {
36
+ if (!options.include || options.include(session))
37
+ result.eligible += 1;
38
+ else
39
+ result.withheld += 1;
40
+ }
41
+ console.error("[opencode inventory] metadata-only scan complete", JSON.stringify({ discovered: result.discovered, eligible: result.eligible, withheld: result.withheld }));
42
+ return result;
43
+ }
44
+ const owners = { ...await readUsageOwners(options.stateDir, "opencode-usage-tentative.json"), ...await readUsageOwners(options.stateDir, "opencode-usage-owners.json") };
45
+ const allowance = await openStagingAllowance(options.stateDir, process.env);
46
+ for (const session of sessions.sort((a, b) => Number(options.deliveredSessionIds?.has(a.id) ?? false) - Number(options.deliveredSessionIds?.has(b.id) ?? false) || a.created_at_ms - b.created_at_ms || a.id.localeCompare(b.id))) {
47
+ if (options.include && !options.include(session)) {
48
+ result.withheld += 1;
49
+ if (options.countOnly?.(session)) {
50
+ try {
51
+ result.shelved.push(await exportNativeSession(store, session, options.stateDir, owners, false));
52
+ }
53
+ catch (error) {
54
+ result.failures.push({ session_id: session.id, reason: readReason(error) });
55
+ }
56
+ }
57
+ continue;
58
+ }
59
+ result.eligible += 1;
60
+ try {
61
+ result.files.push(await exportNativeSession(store, session, options.stateDir, owners, true, allowance, options.exportDirectory));
62
+ }
63
+ catch (error) {
64
+ result.failures.push({ session_id: session.id, reason: readReason(error) });
65
+ }
66
+ }
67
+ if (!options.readOnlyAccounting)
68
+ await writeJsonFileAtomic(path.join(options.stateDir, "opencode-usage-tentative.json"), owners);
69
+ }
70
+ catch (error) {
71
+ result.failures.push({ session_id: null, reason: readReason(error) });
72
+ }
73
+ finally {
74
+ store?.close();
75
+ }
76
+ console.error("[opencode inventory] scan finished", JSON.stringify({ source: "opencode", discovered: result.discovered, exported: result.files.length, withheld: result.withheld, failed: result.failures.length, reason: result.absent ? "opencode_source_path_absent" : result.failures.length ? "opencode_inventory_partial" : "opencode_inventory_complete" }));
77
+ return result;
78
+ }
79
+ async function exportNativeSession(store, session, stateDir, owners, retainContent = true, allowance, exportDirectory) {
80
+ const directory = exportDirectory ?? path.join(stateDir, "opencode-transcripts");
81
+ if (retainContent)
82
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
83
+ const temporary = path.join(directory, `.export-${crypto.randomUUID()}`);
84
+ const output = retainContent ? await fs.open(temporary, "wx", 0o600) : null;
85
+ const hash = crypto.createHash("sha256");
86
+ const factsAccumulator = newSessionFactsAccumulator("opencode");
87
+ let lines = 0;
88
+ let bytes = 0;
89
+ let message = null;
90
+ let identities = [];
91
+ let redactedRecords = 0;
92
+ const redactionRules = new Map();
93
+ let secretMatches = 0;
94
+ const write = async (record) => {
95
+ const part = record.type === "opencode_part" ? nativeObject(record.part) : {};
96
+ if (part.type === "file" && typeof part.url === "string")
97
+ record = { ...record, attachment_source: {
98
+ representation: part.url.startsWith("data:") ? "inline_data_url" : "reference_only",
99
+ referenced_bytes_read: false,
100
+ reason: part.url.startsWith("data:") ? "native_inline_payload_available" : "external_attachment_bytes_not_read",
101
+ } };
102
+ const masked = sanitizeTextEvidenceForUpload({ text: JSON.stringify(record) + "\n", redactedFields: ["opencode_native_record"] });
103
+ if (masked.status === "redacted" && masked.completenessLabel === "redaction_crashed_stubbed")
104
+ throw new OpenCodeReadError("opencode_redaction_failed");
105
+ if (masked.status === "redacted") {
106
+ redactedRecords += 1;
107
+ secretMatches += masked.redaction.secret_like_match_count;
108
+ for (const rule of masked.redaction.rule_counts) {
109
+ const prior = redactionRules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
110
+ prior.match_count += rule.match_count;
111
+ prior.redacted_char_count += rule.redacted_char_count;
112
+ redactionRules.set(rule.rule_id, prior);
113
+ }
114
+ }
115
+ hash.update(masked.bytes);
116
+ bytes += masked.bytes.length;
117
+ lines += 1;
118
+ try {
119
+ observeSessionFactsRecord(factsAccumulator, JSON.parse(masked.bytes.toString("utf8")));
120
+ }
121
+ catch { /* Masked crash marker remains evidence, not an invented native record. */ }
122
+ if (output) {
123
+ const deferred = roomToStageUpTo(allowance, bytes);
124
+ if (deferred)
125
+ throw new OpenCodeReadError(`opencode_staging_deferred_${deferred}`);
126
+ await output.write(masked.bytes);
127
+ }
128
+ };
129
+ const settleUsage = async () => {
130
+ if (!message || message.role !== "assistant")
131
+ return;
132
+ const nativeIdentity = openCodeProviderIdentity(message, identities);
133
+ const identity = nativeIdentity ? crypto.createHash("sha256").update(nativeIdentity).digest("hex") : null;
134
+ const nativeTokens = nativeObject(message.tokens);
135
+ const hasUsage = !message.error && Number.isFinite(nativeObject(message.time).completed) && ["input", "output", "reasoning"].every((key) => typeof nativeTokens[key] === "number" && Number.isFinite(nativeTokens[key]) && Number(nativeTokens[key]) >= 0) && typeof message.modelID === "string";
136
+ const owner = identity ? owners[identity] : null;
137
+ const status = !identity || !hasUsage ? "unknown" : owner && owner !== session.id ? "copied" : "known";
138
+ if (identity && hasUsage && !owner)
139
+ owners[identity] = session.id;
140
+ await write({ type: "opencode_usage", status, identity, owner_session_id: identity ? owners[identity] : null, model: message.modelID ?? null, provider: message.providerID ?? null, tokens: message.tokens ?? null });
141
+ };
142
+ try {
143
+ for (const record of store.records(session.id)) {
144
+ if (record.type === "opencode_message") {
145
+ await settleUsage();
146
+ message = nativeObject(record.info);
147
+ identities = [];
148
+ }
149
+ if (record.type === "opencode_part") {
150
+ const part = nativeObject(record.part);
151
+ if (part.metadata)
152
+ identities.push({ metadata: Object.fromEntries(Object.entries(nativeObject(part.metadata)).map(([name, value]) => [name, { itemId: nativeObject(value).itemId }])) });
153
+ }
154
+ await write(record);
155
+ }
156
+ await settleUsage();
157
+ await write({ type: "opencode_capture", redacted_records: redactedRecords, accounting_scope: "persistent_native_provider_item_ownership_on_this_machine" });
158
+ await output?.close();
159
+ const revision = hash.digest("hex");
160
+ const localPath = retainContent ? path.join(directory, `${session.id}-${revision}.jsonl`) : "";
161
+ if (retainContent) {
162
+ if (allowance) {
163
+ const deferred = admitToStaging(allowance, bytes, { kind: "opencode_jsonl" });
164
+ if (deferred)
165
+ throw new OpenCodeReadError(`opencode_staging_deferred_${deferred}`);
166
+ }
167
+ await fs.rename(temporary, localPath);
168
+ }
169
+ observeSessionFactsFile(factsAccumulator, { bytes, lines });
170
+ const facts = finalizeSessionFacts(factsAccumulator);
171
+ return { session, local_path: localPath, byte_size: bytes, revision, facts,
172
+ ...(secretMatches ? { redaction: { schema_version: "raw-evidence-redaction.v1", status: "sanitized", mode: "deterministic_text_replacement",
173
+ applied_by: ["local_collector"], rule_counts: [...redactionRules.values()], secret_like_match_count: secretMatches,
174
+ redacted_fields: ["opencode_native_record"], redacted_ranges: [], sanitized_content_hash_sha256: revision, sanitized_byte_size: bytes } } : {}) };
175
+ }
176
+ catch (error) {
177
+ await output?.close().catch(() => undefined);
178
+ if (retainContent)
179
+ await fs.unlink(temporary).catch(() => undefined);
180
+ throw error;
181
+ }
182
+ }
183
+ function readReason(error) { return error instanceof OpenCodeReadError ? error.reason : "opencode_export_failed"; }
184
+ async function readUsageOwners(stateDir, filename) {
185
+ const saved = await readJsonFile(path.join(stateDir, filename)).catch((error) => {
186
+ if (error.code === "ENOENT")
187
+ return {};
188
+ throw new OpenCodeReadError("opencode_accounting_cursor_unreadable");
189
+ });
190
+ if (!saved || typeof saved !== "object" || Array.isArray(saved) || Object.entries(saved).some(([key, owner]) => !/^[a-f0-9]{64}$/.test(key) || typeof owner !== "string" || !/^ses_[A-Za-z0-9]+$/.test(owner)))
191
+ throw new OpenCodeReadError("opencode_accounting_cursor_invalid");
192
+ return saved;
193
+ }
194
+ /** A local usage owner becomes durable only after both evidence and metadata were acknowledged. */
195
+ export async function acknowledgeOpenCodeUsage(stateDir, sessions) {
196
+ const committed = await readUsageOwners(stateDir, "opencode-usage-owners.json");
197
+ const tentative = await readUsageOwners(stateDir, "opencode-usage-tentative.json");
198
+ for (const [identity, owner] of Object.entries(tentative))
199
+ if (sessions.has(owner))
200
+ committed[identity] ??= owner;
201
+ await writeJsonFileAtomic(path.join(stateDir, "opencode-usage-owners.json"), committed);
202
+ console.error("[opencode accounting] acknowledged native identity owners", JSON.stringify({ reason: "durable_native_usage_acknowledged", identities: Object.keys(committed).length, sessions: sessions.size }));
203
+ }
@@ -0,0 +1,29 @@
1
+ /** Apply existing collection choices to native directory metadata before exporting OpenCode evidence. */
2
+ import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
3
+ import { readSourceSwitches } from "./source-opt-out.js";
4
+ import { settleCollectionScope, sessionInsideRoots } from "./collection-scope.js";
5
+ import { decideShelfMode, sessionShelf } from "./commands/session-sync-shelf.js";
6
+ import { scoreSignalsAgainstWorktrees } from "./adapters/attribution-core.js";
7
+ export async function openCodeCollectionPolicy(options) {
8
+ const paths = getCollectorRuntimePaths(options.homeDir);
9
+ const config = await readLocalCollectorConfig(paths).catch((error) => { if (error.code === "ENOENT")
10
+ return null; throw error; });
11
+ const switches = await readSourceSwitches(config, paths, "opencode_inventory", { fetch: options.fetchImpl, dashboardUrl: options.dashboardUrl });
12
+ const shelf = await decideShelfMode({ homeDir: options.homeDir, dashboardUrl: options.dashboardUrl ?? null, fetchImpl: options.fetchImpl ?? globalThis.fetch });
13
+ const scope = await settleCollectionScope(paths.state_dir, config?.collection_scope);
14
+ const roots = options.collectionRoots ?? config?.default_repo_paths ?? [];
15
+ const enabled = switches.opencode && !switches.held && shelf.mode !== "hold";
16
+ const reason = switches.held || shelf.mode === "hold" ? "owner_check_unavailable" : !switches.opencode ? "raw_evidence_upload_disabled" : "opencode_existing_collection_policy";
17
+ const drawer = (session) => {
18
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
19
+ return shelf.mode === "founder" && sessionShelf({ ...attribution, local_cwd: session.directory, session_facts: null }, shelf.rules).shelf === "drawer";
20
+ };
21
+ return { enabled, reason, drawer, include: (session) => {
22
+ if (!enabled)
23
+ return false;
24
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
25
+ if (scope.scope === "roots_only" && !sessionInsideRoots({ ...attribution, local_cwd: session.directory }, roots))
26
+ return false;
27
+ return !drawer(session);
28
+ } };
29
+ }
@@ -0,0 +1,238 @@
1
+ /** Pi's shared inventory keeps native records and fragments inside the existing redacted staging path. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { accountPiUsage, piObject, newSessionFactsAccumulator, observeSessionFactsRecord, observeSessionFactsFile, finalizeSessionFacts } from '@bli-cockpit/telemetry-core';
6
+ import { listPiSessions, piSessionRoots, readPiSession, PiReadError } from './adapters/pi-session-reader.js';
7
+ import { piSessionHeader } from './adapters/pi-session-facts.js';
8
+ import { sanitizeTextEvidenceForUpload } from './adapters/raw-evidence-sanitize.js';
9
+ import { openStagingAllowance, roomToStageUpTo, admitToStaging } from './staging-gate.js';
10
+ export const PI_CURSOR_FILENAME = 'pi-raw-evidence.json';
11
+ /** All consumers share roots and bounded keyset discovery; policy is applied before full body reads. */
12
+ export async function readPiInventory(options) {
13
+ const result = { discovered: 0, sessions: [], eligible: 0, withheld: 0, files: [], shelved: [], failures: [], absent: true };
14
+ const sessions = new Map();
15
+ for (const root of options.sessionRoots ?? piSessionRoots(options.homeDir, { agentDir: process.env.PI_CODING_AGENT_DIR, sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR })) {
16
+ let after = '';
17
+ try {
18
+ while (true) {
19
+ const page = await listPiSessions(root, after, 100);
20
+ if (page.state !== 'missing')
21
+ result.absent = false;
22
+ for (const file of page.files) {
23
+ try {
24
+ const stamp = await fs.stat(file.filePath);
25
+ if (options.sinceMs !== undefined && stamp.mtimeMs < options.sinceMs)
26
+ continue;
27
+ let header = null;
28
+ for await (const event of readPiSession(file.filePath)) {
29
+ if (event.kind === 'record') {
30
+ header = piSessionHeader(event.value);
31
+ break;
32
+ }
33
+ }
34
+ const id = header?.id && /^[A-Za-z0-9._-]{1,80}$/.test(header.id) ? header.id : `pi-file-${crypto.createHash('sha256').update(file.filePath).digest('hex').slice(0, 32)}`;
35
+ const existing = sessions.get(id);
36
+ if (existing) {
37
+ if (!existing.filePaths.includes(file.filePath))
38
+ existing.filePaths.push(file.filePath);
39
+ existing.directories[file.filePath] = header?.cwd ?? "";
40
+ existing.updated_at_ms = Math.max(existing.updated_at_ms, stamp.mtimeMs);
41
+ }
42
+ else
43
+ sessions.set(id, { id, directory: header?.cwd ?? '', parentSession: header?.parentSession ?? null, created_at_ms: stamp.birthtimeMs, updated_at_ms: stamp.mtimeMs, filePaths: [file.filePath], directories: { [file.filePath]: header?.cwd ?? "" } });
44
+ }
45
+ catch (error) {
46
+ result.failures.push({ session_id: null, reason: reason(error) });
47
+ }
48
+ }
49
+ if (page.state !== 'more')
50
+ break;
51
+ after = page.next;
52
+ }
53
+ }
54
+ catch (error) {
55
+ result.failures.push({ session_id: null, reason: reason(error) });
56
+ }
57
+ }
58
+ result.sessions = [...sessions.values()];
59
+ result.discovered = sessions.size;
60
+ for (const session of result.sessions.sort((a, b) => Number(options.deliveredSessionIds?.has(a.id) ?? false) - Number(options.deliveredSessionIds?.has(b.id) ?? false) || a.created_at_ms - b.created_at_ms || a.id.localeCompare(b.id))) {
61
+ const eligiblePaths = session.filePaths.filter(filePath => !options.include || options.include({ ...session, directory: session.directories[filePath] ?? session.directory }));
62
+ const included = eligiblePaths.length > 0;
63
+ if (included)
64
+ result.eligible++;
65
+ else
66
+ result.withheld++;
67
+ if (options.metadataOnly || (!included && !options.countOnly?.(session)))
68
+ continue;
69
+ try {
70
+ (included ? result.files : result.shelved).push(await exportPiSession({ ...session, directory: session.directories[(eligiblePaths[0] ?? session.filePaths[0])] ?? session.directory, filePaths: included ? eligiblePaths : session.filePaths }, options, included));
71
+ }
72
+ catch (error) {
73
+ result.failures.push({ session_id: session.id, reason: reason(error) });
74
+ }
75
+ }
76
+ console.error('[pi inventory] observed native source', JSON.stringify({ discovered: result.discovered, returned: result.sessions.length, unique: sessions.size, exported: result.files.length, failed: result.failures.length, absent: result.absent }));
77
+ return result;
78
+ }
79
+ async function exportPiSession(session, options, retain) {
80
+ const directory = options.exportDirectory ?? path.join(options.stateDir, 'pi-transcripts');
81
+ if (retain) {
82
+ // Recompute the current policy-selected, redacted stream before trusting a retained derivative.
83
+ // This pass writes nothing and never buffers the aggregate session.
84
+ const current = await exportPiSession(session, options, false);
85
+ const candidate = path.join(directory, `${session.id}-${current.revision}.jsonl`);
86
+ if (await verifyRetainedExport(candidate, current.revision, current.byte_size)) {
87
+ console.error('[pi inventory] retained export reused', JSON.stringify({ reason: 'pi_retained_export_verified', bytes: current.byte_size }));
88
+ return { ...current, local_path: candidate };
89
+ }
90
+ }
91
+ if (retain)
92
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
93
+ const temporary = path.join(directory, `.export-${crypto.randomUUID()}`), output = retain ? await fs.open(temporary, 'wx', 0o600) : null;
94
+ const allowance = retain ? await openStagingAllowance(options.stateDir, process.env) : undefined;
95
+ const hash = crypto.createHash('sha256'), acc = newSessionFactsAccumulator('pi'), ledger = new Map();
96
+ const pending = new Set(session.id.startsWith('pi-file-') ? ['pi_session_header_identity_unavailable'] : []), seenRevisions = new Set();
97
+ let bytes = 0, lines = 0, secretMatches = 0;
98
+ const rules = new Map();
99
+ const write = async (text) => {
100
+ const masked = sanitizeTextEvidenceForUpload({ text, redactedFields: ['pi_native_record'] });
101
+ if (masked.status === 'redacted') {
102
+ if (masked.completenessLabel === 'redaction_crashed_stubbed')
103
+ throw new PiReadError('pi_redaction_failed');
104
+ secretMatches += masked.redaction.secret_like_match_count;
105
+ for (const rule of masked.redaction.rule_counts) {
106
+ const prior = rules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
107
+ prior.match_count += rule.match_count;
108
+ prior.redacted_char_count += rule.redacted_char_count;
109
+ rules.set(rule.rule_id, prior);
110
+ }
111
+ }
112
+ hash.update(masked.bytes);
113
+ bytes += masked.bytes.length;
114
+ lines++;
115
+ try {
116
+ observeSessionFactsRecord(acc, JSON.parse(masked.bytes.toString('utf8')));
117
+ }
118
+ catch {
119
+ pending.add('pi_normalization_pending');
120
+ }
121
+ if (output) {
122
+ const deferred = roomToStageUpTo(allowance, bytes);
123
+ if (deferred)
124
+ throw new PiReadError(`pi_staging_deferred_${deferred}`);
125
+ await output.write(masked.bytes);
126
+ }
127
+ };
128
+ try {
129
+ for (const filePath of [...session.filePaths].sort()) {
130
+ // Same-ID divergent files remain separate native segments; no text/title deduplication occurs.
131
+ // Deduplicate only byte-identical copies under the same exact native session ID.
132
+ let observed = null;
133
+ for await (const event of readPiSession(filePath))
134
+ if (event.kind === 'revision')
135
+ observed = event;
136
+ if (observed?.stable && seenRevisions.has(observed.revision))
137
+ continue;
138
+ for await (const event of readPiSession(filePath)) {
139
+ if (event.kind === 'revision') {
140
+ if (!event.stable)
141
+ pending.add('pi_source_changed_during_read');
142
+ seenRevisions.add(event.revision);
143
+ continue;
144
+ }
145
+ if (event.kind === 'partial' || !event.valid) {
146
+ pending.add(event.kind === 'partial' ? 'pi_partial_native_record' : 'pi_malformed_native_record');
147
+ let text, encoding;
148
+ try {
149
+ text = new TextDecoder('utf-8', { fatal: true }).decode(event.raw);
150
+ encoding = 'utf8';
151
+ }
152
+ catch {
153
+ text = event.raw.toString('latin1');
154
+ encoding = 'latin1';
155
+ }
156
+ await write(JSON.stringify({ type: 'pi_raw_fragment', encoding, offset: event.offset, text }) + '\n');
157
+ continue;
158
+ }
159
+ await write(event.raw.toString('utf8'));
160
+ const record = piObject(event.value), message = piObject(record.message);
161
+ if (!['session', 'message', 'model_change', 'thinking_level_change', 'compaction', 'branch_summary', 'custom', 'custom_message', 'label', 'session_info'].includes(String(record.type)))
162
+ pending.add('pi_unknown_native_record');
163
+ if (record.type !== 'session' && typeof record.id !== 'string')
164
+ pending.add('pi_native_entry_identity_missing');
165
+ if (record.type === 'session' && record.version !== 3)
166
+ pending.add('pi_format_version_unverified');
167
+ if (record.type === 'message' && message.role === 'assistant') {
168
+ const decision = accountPiUsage(session.id, record, ledger);
169
+ const identity = decision.identity ? crypto.createHash('sha256').update(decision.identity).digest('hex') : null;
170
+ await write(JSON.stringify({ type: 'pi_usage', status: decision.status === 'known' ? 'known' : decision.status === 'copied' ? 'copied' : 'unknown', identity, model: decision.model, usage: decision.usage, reason: decision.reason }) + '\n');
171
+ }
172
+ if (record.type === 'compaction' || record.type === 'branch_summary' || (message.role !== 'assistant' && message.usage != null))
173
+ await write(JSON.stringify({ type: 'pi_usage', status: 'unknown', reason: 'pi_auxiliary_usage_identity_unverified' }) + '\n');
174
+ }
175
+ }
176
+ await write(JSON.stringify({ type: 'pi_capture', native_id: session.id, native_revisions: [...seenRevisions], pending_reasons: [...pending], accounting_scope: 'pending_operator_evidence_ack' }) + '\n');
177
+ await output?.close();
178
+ const revision = hash.digest('hex'), localPath = retain ? path.join(directory, `${session.id}-${revision}.jsonl`) : '';
179
+ if (retain) {
180
+ const deferred = admitToStaging(allowance, bytes, { kind: 'pi_jsonl' });
181
+ if (deferred)
182
+ throw new PiReadError(`pi_staging_deferred_${deferred}`);
183
+ await fs.rename(temporary, localPath);
184
+ }
185
+ observeSessionFactsFile(acc, { bytes, lines });
186
+ const facts = finalizeSessionFacts(acc);
187
+ return { session, local_path: localPath, byte_size: bytes, revision, facts, pending_reasons: [...pending], ...(secretMatches ? { redaction: { schema_version: 'raw-evidence-redaction.v1', status: 'sanitized', mode: 'deterministic_text_replacement', applied_by: ['local_collector'], rule_counts: [...rules.values()], secret_like_match_count: secretMatches, redacted_fields: ['pi_native_record'], redacted_ranges: [], sanitized_content_hash_sha256: revision, sanitized_byte_size: bytes } } : {}) };
188
+ }
189
+ catch (error) {
190
+ await output?.close().catch(() => undefined);
191
+ if (retain)
192
+ await fs.unlink(temporary).catch(() => undefined);
193
+ throw error;
194
+ }
195
+ }
196
+ function reason(error) { return error instanceof PiReadError ? error.reason : 'pi_inventory_failed'; }
197
+ /** Pi's server claims, not a machine-local ledger, establish acknowledged ownership. */
198
+ export async function acknowledgePiUsage(_stateDir, sessions) { console.error('[pi accounting] metadata acknowledgment received', JSON.stringify({ sessions: sessions.size, reason: 'operator_claims_resolved_by_shared_backend' })); }
199
+ /** A filename is only a candidate: reject links, truncation, corruption and changes during verification. */
200
+ async function verifyRetainedExport(filePath, revision, expectedBytes) {
201
+ let initial;
202
+ try {
203
+ initial = await fs.lstat(filePath);
204
+ }
205
+ catch (error) {
206
+ if (error.code === 'ENOENT')
207
+ return false;
208
+ throw new PiReadError('pi_retained_export_unreadable');
209
+ }
210
+ if (!initial.isFile() || initial.isSymbolicLink() || initial.size !== expectedBytes)
211
+ throw new PiReadError('pi_retained_export_invalid');
212
+ const input = await fs.open(filePath, 'r').catch(() => { throw new PiReadError('pi_retained_export_unreadable'); });
213
+ try {
214
+ const before = await input.stat();
215
+ if (before.dev !== initial.dev || before.ino !== initial.ino || before.size !== expectedBytes)
216
+ throw new PiReadError('pi_retained_export_changed');
217
+ const hash = crypto.createHash('sha256');
218
+ let bytes = 0;
219
+ for await (const chunk of input.createReadStream({ autoClose: false })) {
220
+ hash.update(chunk);
221
+ bytes += chunk.length;
222
+ }
223
+ const after = await input.stat(), named = await fs.lstat(filePath);
224
+ if (named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
225
+ throw new PiReadError('pi_retained_export_changed');
226
+ if (bytes !== expectedBytes || hash.digest('hex') !== revision)
227
+ throw new PiReadError('pi_retained_export_invalid');
228
+ return true;
229
+ }
230
+ catch (error) {
231
+ if (error instanceof PiReadError)
232
+ throw error;
233
+ throw new PiReadError('pi_retained_export_unreadable');
234
+ }
235
+ finally {
236
+ await input.close();
237
+ }
238
+ }
@@ -0,0 +1,29 @@
1
+ /** Apply existing collection choices to native directory metadata before exporting Pi evidence. */
2
+ import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
3
+ import { readSourceSwitches } from "./source-opt-out.js";
4
+ import { settleCollectionScope, sessionInsideRoots } from "./collection-scope.js";
5
+ import { decideShelfMode, sessionShelf } from "./commands/session-sync-shelf.js";
6
+ import { scoreSignalsAgainstWorktrees } from "./adapters/attribution-core.js";
7
+ export async function piCollectionPolicy(options) {
8
+ const paths = getCollectorRuntimePaths(options.homeDir);
9
+ const config = await readLocalCollectorConfig(paths).catch((error) => { if (error.code === "ENOENT")
10
+ return null; throw error; });
11
+ const switches = await readSourceSwitches(config, paths, "pi_inventory", { fetch: options.fetchImpl, dashboardUrl: options.dashboardUrl });
12
+ const shelf = await decideShelfMode({ homeDir: options.homeDir, dashboardUrl: options.dashboardUrl ?? null, fetchImpl: options.fetchImpl ?? globalThis.fetch });
13
+ const scope = await settleCollectionScope(paths.state_dir, config?.collection_scope);
14
+ const roots = options.collectionRoots ?? config?.default_repo_paths ?? [];
15
+ const enabled = switches.pi && !switches.held && shelf.mode !== "hold";
16
+ const reason = switches.held || shelf.mode === "hold" ? "owner_check_unavailable" : !switches.pi ? "raw_evidence_upload_disabled" : "pi_existing_collection_policy";
17
+ const drawer = (session) => {
18
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
19
+ return shelf.mode === "founder" && sessionShelf({ ...attribution, local_cwd: session.directory, session_facts: null }, shelf.rules).shelf === "drawer";
20
+ };
21
+ return { enabled, reason, drawer, include: (session) => {
22
+ if (!enabled)
23
+ return false;
24
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
25
+ if (scope.scope === "roots_only" && !sessionInsideRoots({ ...attribution, local_cwd: session.directory }, roots))
26
+ return false;
27
+ return !drawer(session);
28
+ } };
29
+ }
@@ -36,7 +36,7 @@ import { LineEdits, SeenBlocks, isJsonObject } from "./slim-support.js";
36
36
  * once, which is intended: the stored bytes are no longer what the new rules would produce.
37
37
  * `slim-rules-version.test.ts` fails when a rule changes without this moving.
38
38
  */
39
- export const SLIM_RULES_VERSION = "slim-rules.v2";
39
+ export const SLIM_RULES_VERSION = "slim-rules.v3";
40
40
  /** The text an image marker starts with; a line carrying one is a modified line (an image became a marker). */
41
41
  const IMAGE_MARKER_START = "[image sha256:";
42
42
  export function createSlimmer(kind, rulesForTests) {
@@ -49,8 +49,9 @@ export function createSlimmer(kind, rulesForTests) {
49
49
  const applyRules = rulesForTests ?? ((record, edits) => {
50
50
  if (kind === "claude_code")
51
51
  slimClaudeRecord(record, claude, seen, edits);
52
- else
52
+ else if (kind === "codex")
53
53
  slimCodexRecord(record, codex, seen, edits);
54
+ // OpenCode keeps every native field; only the shared acknowledged-image swap changes its payload.
54
55
  });
55
56
  return {
56
57
  slimLine(line) {
@@ -46,7 +46,7 @@ export function resolveSourceSwitches(config, standing) {
46
46
  const labels = configOptOutLabels(config);
47
47
  const verdict = labels.length > 0 ? optOutVerdict(standing) : null;
48
48
  if (verdict && !verdict.obeyed) {
49
- return { codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
49
+ return { opencode: true, pi: true, codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
50
50
  }
51
51
  const all = config?.raw_evidence_upload === "disabled";
52
52
  const codexReason = config?.collect_codex_jsonl === false
@@ -56,6 +56,8 @@ export function resolveSourceSwitches(config, standing) {
56
56
  ? CLAUDE_OPT_OUT_REASON
57
57
  : all ? ALL_SOURCES_OPT_OUT_REASON : null;
58
58
  return {
59
+ opencode: !all,
60
+ pi: !all,
59
61
  codex: codexReason === null,
60
62
  claude: claudeReason === null,
61
63
  codexReason,
@@ -100,7 +100,7 @@ export function parseMetadataOnlySessionReportRows(value, fieldPath) {
100
100
  return sessions;
101
101
  }
102
102
  function parseRetrySource(value, fieldPath) {
103
- if (value === "codex" || value === "claude_code")
103
+ if (value === "codex" || value === "claude_code" || value === "opencode" || value === "pi")
104
104
  return value;
105
105
  return invalidUploadSpoolState(fieldPath, '"codex" or "claude_code"');
106
106
  }
@@ -37,6 +37,9 @@ export const STUDIES_DIR_SUFFIX = "-studies";
37
37
  * collector name; none does today, and the test pins that.
38
38
  */
39
39
  export const STATE_DIR_RULES = [
40
+ { name: "pi-", match: "prefix", class: "state", bound: "native Pi derivatives and coverage, governed by the existing staging allowance" },
41
+ { name: "opencode-transcripts", match: "exact", class: "state", bound: "redacted native reader derivatives; existing staging allowance and state ceiling govern every write; current derivatives removed after durable evidence and metadata acknowledgments" },
42
+ { name: "opencode-", match: "prefix", class: "state", bound: "native identity cursors and metadata-only coverage receipt; overwritten by collection" },
40
43
  { name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; the staging gate stops new copies at the cap, on a low disk or over the 512 MiB ceiling; uncommitted evidence is never deleted, only counted" },
41
44
  { name: "sync.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
42
45
  { name: "sync.err.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },