@bli-cockpit/cli 0.2.292 → 0.2.293

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 (40) hide show
  1. package/dist/adapters/claude-attribution-discovery.js +22 -0
  2. package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
  3. package/dist/adapters/claude-attribution.js +5 -2
  4. package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
  5. package/dist/adapters/codex-attribution.js +30 -12
  6. package/dist/adapters/codex-segment-discovery.js +66 -0
  7. package/dist/adapters/codex-session-revision.js +139 -0
  8. package/dist/adapters/codex-session-signals.js +2 -2
  9. package/dist/adapters/pi-session-facts.js +28 -0
  10. package/dist/adapters/pi-session-reader.js +132 -0
  11. package/dist/adapters/raw-evidence-codex-reader.js +17 -7
  12. package/dist/adapters/raw-evidence-transcript-body.js +3 -0
  13. package/dist/adapters/raw-evidence-transcript-file.js +27 -4
  14. package/dist/adapters/raw-evidence-transcript-stream.js +33 -1
  15. package/dist/collection-scope.js +2 -0
  16. package/dist/commands/backfill-checkpoint.js +3 -1
  17. package/dist/commands/backfill-history-scan.js +38 -0
  18. package/dist/commands/backfill-issues.js +8 -20
  19. package/dist/commands/backfill-scan.js +18 -40
  20. package/dist/commands/backfill-upload.js +1 -0
  21. package/dist/commands/codex-revision-facts.js +64 -0
  22. package/dist/commands/local-args-notes-targets.js +35 -0
  23. package/dist/commands/local-args-tower-notes.js +7 -18
  24. package/dist/commands/local-help-commands-tower.js +1 -1
  25. package/dist/commands/local-help-notes.js +2 -1
  26. package/dist/commands/notes-writes.js +24 -0
  27. package/dist/commands/notes.js +3 -2
  28. package/dist/commands/public-root.js +1 -1
  29. package/dist/commands/session-sync-record.js +3 -1
  30. package/dist/commands/session-sync-scan.js +14 -6
  31. package/dist/commands/session-sync-shelf.js +2 -0
  32. package/dist/commands/session-sync-upload.js +1 -0
  33. package/dist/commands/sources-discovery.js +63 -14
  34. package/dist/commands/sources-reconciliation.js +7 -5
  35. package/dist/commands/sources-verdict.js +3 -0
  36. package/dist/cursors/raw-evidence-cursor.js +1 -0
  37. package/dist/image-description-session.js +4 -2
  38. package/dist/native-session-roots.js +31 -0
  39. package/dist/t3-native-homes.js +64 -0
  40. package/package.json +4 -4
@@ -56,11 +56,27 @@ export async function collectOneEvidenceFile(collection, options) {
56
56
  }
57
57
  // BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
58
58
  // did not move underneath it; only then is the result remembered.
59
- const stamp = await statSourceForMemo(options.filePath);
59
+ const stamp = options.sourceProvider ? null : await statSourceForMemo(options.filePath);
60
60
  const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
61
61
  if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
62
62
  return true;
63
- const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
63
+ let read;
64
+ try {
65
+ read = options.sourceProvider
66
+ ? { status: "ok", ...await options.sourceProvider() }
67
+ : await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
68
+ }
69
+ catch (error) {
70
+ if (error instanceof TranscriptTooLargeError) {
71
+ markCapApplied(collection, options.kind, "max_file_bytes");
72
+ return skip("file_too_large");
73
+ }
74
+ const reason = error instanceof TranscriptReadError ? `file_read_failed:${error.stage}` : "file_read_failed";
75
+ console.error("[raw-evidence] revision source unavailable", JSON.stringify({ reason, ...describeError(error) }));
76
+ return skip(reason);
77
+ }
78
+ if (read.status === "ok" && options.maxFileBytes && read.byteLength > options.maxFileBytes)
79
+ read = { status: "too_large" };
64
80
  if (read.status === "read_failed")
65
81
  return skip("file_read_failed");
66
82
  if (read.status === "too_large") {
@@ -80,8 +96,15 @@ export async function collectOneEvidenceFile(collection, options) {
80
96
  }
81
97
  catch (error) {
82
98
  // BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
83
- if (error instanceof TranscriptReadError)
84
- return skip("file_read_failed");
99
+ if (error instanceof TranscriptReadError) {
100
+ const deferred = error.stage === "composite_staging_declined" && error.underlying instanceof Error ? error.underlying.message : null;
101
+ if (deferred && /^(deferred_(byte|object)_budget|staging_deferred:[a-z_]+)$/.test(deferred)) {
102
+ markBudgetCapApplied(collection, deferred);
103
+ return skip(deferred);
104
+ }
105
+ const detail = options.sourceProvider ? `:${error.stage}` : "";
106
+ return skip(`file_read_failed${detail}`);
107
+ }
85
108
  if (error instanceof TranscriptTooLargeError) {
86
109
  markCapApplied(collection, options.kind, "max_file_bytes");
87
110
  return skip("file_too_large");
@@ -12,6 +12,8 @@
12
12
  * read gives (`file_read_failed`, `file_too_large`), never a shorter file.
13
13
  */
14
14
  import fs from "node:fs/promises";
15
+ import { copyLinesStream } from "../slim/keep-line-copy.js";
16
+ import { openStagedOutput } from "./staged-output.js";
15
17
  import { modeDropsImages } from "@bli-cockpit/telemetry-core";
16
18
  import { describeError } from "../health-detail.js";
17
19
  import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
@@ -32,11 +34,13 @@ export function volatilityOf(mode, prepared) {
32
34
  }
33
35
  export async function streamTranscriptBody(session, agent, file) {
34
36
  if (!session?.prepareTranscriptFile || !agent) {
37
+ if (file.openSource)
38
+ return streamCompositeWithoutImages(file);
35
39
  console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
36
40
  return null;
37
41
  }
38
42
  try {
39
- const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
43
+ const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, ...(file.openSource ? { openSource: file.openSource } : {}), source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
40
44
  const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
41
45
  console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, mode: session.mode, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile, staged_bytes: prepared.staged?.byteLength ?? null }));
42
46
  // BLI-23344: with a spool the text is not read back; the sanitizer reads the spool line by line.
@@ -45,12 +49,18 @@ export async function streamTranscriptBody(session, agent, file) {
45
49
  catch (error) {
46
50
  if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
47
51
  throw error;
52
+ if (file.openSource) {
53
+ console.error(TAG + " composite revision remains pending", JSON.stringify({ reason: "composite_prepare_failed", kind: file.kind, ...describeError(error) }));
54
+ throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
55
+ }
48
56
  console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
49
57
  return null;
50
58
  }
51
59
  }
52
60
  /** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
53
61
  export async function readTranscriptWhole(file) {
62
+ if (file.openSource)
63
+ throw new TranscriptReadError("composite_buffering_refused", new Error("composite_buffering_refused"));
54
64
  let bytes;
55
65
  try {
56
66
  bytes = await fs.readFile(file.filePath);
@@ -62,4 +72,26 @@ export async function readTranscriptWhole(file) {
62
72
  if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
63
73
  throw new TranscriptTooLargeError(file.maxFileBytes);
64
74
  return bytes;
75
+ }
76
+ /** Keep mode when image preparation is unavailable: same spool/mask/ack, no representative read or whole buffer. */
77
+ async function streamCompositeWithoutImages(file) {
78
+ if (!file.stageDir)
79
+ throw new TranscriptReadError("composite_staging_unavailable", new Error("composite_staging_unavailable"));
80
+ const source = await file.openSource();
81
+ let writer;
82
+ try {
83
+ writer = await openStagedOutput(file.stageDir);
84
+ await copyLinesStream(source.lines(), writer);
85
+ await source.verifyUnchanged();
86
+ const staged = await writer.finish();
87
+ console.error(TAG + " composite revision kept without image preparation", JSON.stringify({ reason: "composite_images_unavailable_kept", kind: file.kind, byte_size: source.length }));
88
+ return { held: { text: null, exactBytes: null, staged }, volatile: true, pendingImages: null, readBytes: source.length };
89
+ }
90
+ catch (error) {
91
+ await writer?.abandon();
92
+ throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
93
+ }
94
+ finally {
95
+ await source.close();
96
+ }
65
97
  }
@@ -146,6 +146,8 @@ export function cwdInsideRoots(cwd, roots) {
146
146
  * it cannot be proven inside, so it is withheld.
147
147
  */
148
148
  export function sessionInsideRoots(result, roots) {
149
+ if (result.local_cwds)
150
+ return result.local_cwds.length > 0 && result.local_cwds.every(cwd => cwdInsideRoots(cwd, roots) || Boolean(result.worktree && cwdInsideRoots(cwd, [result.worktree.repo_root])));
149
151
  if (cwdInsideRoots(result.local_cwd, roots))
150
152
  return true;
151
153
  return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
@@ -37,7 +37,8 @@ export async function recordBackfillDurableSessionPointers(options) {
37
37
  // delivery, so a real pointer replaces it and the mark clears.
38
38
  // BLI-11119: an entry the cursor itself knows is `stubbed` counts too.
39
39
  const replacesStub = Boolean(prior?.stub_reoffer || prior?.stubbed) && prior?.uploaded_object_key !== objectKey;
40
- if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
40
+ const replacesRevision = candidate.source === "codex" && prior?.uploaded_source_revision !== candidate.content_hash_sha256;
41
+ if (!objectKey || (prior?.uploaded_object_key && !replacesStub && !replacesRevision))
41
42
  continue;
42
43
  if (replacesStub && !isRedactionStubObjectKey(objectKey))
43
44
  stubsReplaced += 1;
@@ -61,6 +62,7 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
61
62
  const entry = {
62
63
  ...prior,
63
64
  file_hash_sha256: candidate.content_hash_sha256,
65
+ uploaded_source_revision: isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
64
66
  file_mtime_ms: candidate.session_file_mtime_ms,
65
67
  byte_size: candidate.byte_size,
66
68
  // The whole file was delivered, so the incremental reader starts at its end.
@@ -0,0 +1,38 @@
1
+ /** Keep complete-history rescans separate from candidate selection and cursor policy. */
2
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
3
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
4
+ /**
5
+ * The adapter's own limit is a memory guard, not permission to silently leave
6
+ * history undiscovered. When it fires, re-scan the exact discovered population
7
+ * so a user-facing `--max-files` cap can resume from a truthful ordering.
8
+ */
9
+ export async function scanCodexHistory(request, sessionsDirs, scanLimit) {
10
+ const scan = (limit) => scanAndAttributeCodexSessions({
11
+ sessionsDirs,
12
+ worktrees: request.worktrees,
13
+ now: request.now,
14
+ sinceMinutes: request.window.since_minutes,
15
+ limit,
16
+ collectionRoots: request.collectionRoots,
17
+ });
18
+ const firstPass = await scan(scanLimit);
19
+ if (!firstPass.session_limit_applied)
20
+ return firstPass;
21
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
22
+ }
23
+ /** Same rule for the Claude store: a capped first pass is rescanned in full. */
24
+ export async function scanClaudeHistory(request, projectsDirs, scanLimit) {
25
+ const scan = (limit) => scanAndAttributeClaudeSessions({
26
+ projectsDir: projectsDirs[0],
27
+ projectsDirs,
28
+ worktrees: request.worktrees,
29
+ now: request.now,
30
+ sinceMinutes: request.window.since_minutes,
31
+ limit,
32
+ collectionRoots: request.collectionRoots,
33
+ });
34
+ const firstPass = await scan(scanLimit);
35
+ if (!firstPass.session_limit_applied)
36
+ return firstPass;
37
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
38
+ }
@@ -29,36 +29,33 @@ import { candidateCursorKey } from "./backfill-candidates.js";
29
29
  export async function backfillScanIssues(options) {
30
30
  const issues = [];
31
31
  if (options.codexAttribution) {
32
- await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
32
+ await addCodexStoreScanIssues(issues, options.codexAttribution);
33
33
  }
34
34
  if (options.claudeAttribution) {
35
- await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
35
+ await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.candidates);
36
36
  }
37
37
  addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
38
38
  sortScanIssuesByPriority(issues);
39
39
  return issues;
40
40
  }
41
41
  /** What the archived Codex store could not tell us — every one of these hides history. */
42
- async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
42
+ async function addCodexStoreScanIssues(issues, codexAttribution) {
43
43
  countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
44
44
  ? Math.max(1, codexAttribution.discovered_file_count -
45
45
  codexAttribution.scanned_file_count)
46
46
  : 0, "global");
47
- // A session directory that simply does not exist on this machine is not a
48
- // read failure; subtract those before reporting one.
49
- const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
50
- countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
47
+ // The reader already excludes ENOENT. Subtracting absent default roots
48
+ // here would erase a failure in another configured home or T3 discovery.
49
+ countScanIssue(issues, "codex_directory_read_failed", codexAttribution.directory_read_failed_count, "global");
51
50
  countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
52
51
  }
53
52
  /** The same census for the Claude store, which also has sidecars to account for. */
54
- async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
53
+ async function addClaudeStoreScanIssues(issues, claudeAttribution, candidates) {
55
54
  countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
56
55
  ? Math.max(1, claudeAttribution.discovered_session_count -
57
56
  claudeAttribution.scanned_session_count)
58
57
  : 0, "global");
59
- const projectsDirMissing = await isMissingPath(claudeProjectsDir);
60
- countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
61
- (projectsDirMissing ? 1 : 0)), "global");
58
+ countScanIssue(issues, "claude_project_dir_read_failed", claudeAttribution.project_dir_read_failed_count, "global");
62
59
  countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
63
60
  countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
64
61
  countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
@@ -151,15 +148,6 @@ async function countUnreadableClaudeSidecarDirs(candidates) {
151
148
  }
152
149
  return unreadable;
153
150
  }
154
- async function isMissingPath(filePath) {
155
- try {
156
- await fs.stat(filePath);
157
- return false;
158
- }
159
- catch (error) {
160
- return isMissingFsError(error);
161
- }
162
- }
163
151
  function isMissingFsError(error) {
164
152
  if (!error || typeof error !== "object")
165
153
  return false;
@@ -1,3 +1,5 @@
1
+ import { scanCodexHistory, scanClaudeHistory } from "./backfill-history-scan.js";
2
+ import { nativeSessionRoots } from "../native-session-roots.js";
1
3
  /**
2
4
  * SCAN: read both archived session stores and turn them into the candidates
3
5
  * this run will consider, then census what could not be accounted for.
@@ -9,9 +11,6 @@
9
11
  * on what was missed before what merely arrived.
10
12
  */
11
13
  import os from "node:os";
12
- import path from "node:path";
13
- import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
14
- import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
15
14
  import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
15
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
17
16
  import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window.js";
@@ -136,10 +135,15 @@ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
136
135
  */
137
136
  async function scanBackfillSessions(options) {
138
137
  const scanLimit = options.command.maxFiles ?? 10_000;
139
- const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
140
- const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
138
+ const { codex: codexSessionDirs, claudeProjects: claudeProjectsDir, claudeProjectDirs, discoveryFailed } = nativeSessionRoots(options.homeDir);
141
139
  const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
142
- const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit) : null;
140
+ const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectDirs, scanLimit) : null;
141
+ if (discoveryFailed) {
142
+ if (codexScanned)
143
+ codexScanned.directory_read_failed_count += 1;
144
+ if (claudeScanned)
145
+ claudeScanned.project_dir_read_failed_count += 1;
146
+ }
143
147
  // BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
144
148
  const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
145
149
  const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
@@ -155,6 +159,13 @@ async function scanBackfillSessions(options) {
155
159
  sinceMinutes: options.window.since_minutes,
156
160
  });
157
161
  const liveWindowMs = liveWindowMsFor(options.command);
162
+ const durableCodex = await readRawEvidenceCursor(getCollectorRuntimePaths(options.homeDir));
163
+ for (const c of scanned) {
164
+ const prior = durableCodex.sessions[c.session_id];
165
+ if (c.source === "codex" && (c.segment_paths || prior?.uploaded_source_revision) &&
166
+ prior?.uploaded_source_revision !== c.content_hash_sha256)
167
+ options.pointerlessTerminalSessions.codex.add(c.session_id);
168
+ }
158
169
  const selection = selectBackfillCandidates({
159
170
  scanned,
160
171
  cursor: options.cursor,
@@ -188,40 +199,6 @@ async function scanBackfillSessions(options) {
188
199
  live_window_ms: liveWindowMs,
189
200
  };
190
201
  }
191
- /**
192
- * The adapter's own limit is a memory guard, not permission to silently leave
193
- * history undiscovered. When it fires, re-scan the exact discovered population
194
- * so a user-facing `--max-files` cap can resume from a truthful ordering.
195
- */
196
- async function scanCodexHistory(request, sessionsDirs, scanLimit) {
197
- const scan = (limit) => scanAndAttributeCodexSessions({
198
- sessionsDirs,
199
- worktrees: request.worktrees,
200
- now: request.now,
201
- sinceMinutes: request.window.since_minutes,
202
- limit,
203
- collectionRoots: request.collectionRoots,
204
- });
205
- const firstPass = await scan(scanLimit);
206
- if (!firstPass.session_limit_applied)
207
- return firstPass;
208
- return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
209
- }
210
- /** Same rule for the Claude store: a capped first pass is rescanned in full. */
211
- async function scanClaudeHistory(request, projectsDir, scanLimit) {
212
- const scan = (limit) => scanAndAttributeClaudeSessions({
213
- projectsDir,
214
- worktrees: request.worktrees,
215
- now: request.now,
216
- sinceMinutes: request.window.since_minutes,
217
- limit,
218
- collectionRoots: request.collectionRoots,
219
- });
220
- const firstPass = await scan(scanLimit);
221
- if (!firstPass.session_limit_applied)
222
- return firstPass;
223
- return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
224
- }
225
202
  /**
226
203
  * Which scanned sessions this run will actually work on. A session is in play
227
204
  * if the cursor has not passed it, or if it is a terminal session that never
@@ -291,6 +268,7 @@ function normalizeCodexCandidate(result) {
291
268
  return {
292
269
  source: "codex",
293
270
  session_id: result.codex_session_id,
271
+ ...(result.segment_paths ? { segment_paths: result.segment_paths } : {}),
294
272
  session_id_source: result.session_id_source,
295
273
  file_path: result.file_path,
296
274
  state: result.state,
@@ -231,6 +231,7 @@ function backfillBatchSyncOptions(options) {
231
231
  .map((candidate) => ({
232
232
  local_path: candidate.file_path,
233
233
  codex_session_id: candidate.session_id,
234
+ ...(candidate.segment_paths ? { segment_paths: candidate.segment_paths, revision_hash: candidate.content_hash_sha256 ?? undefined, revision_bytes: candidate.byte_size } : {}),
234
235
  })),
235
236
  codexAttributionScan: options.codexAttribution ?? undefined,
236
237
  claudeSessionFiles: options.batch.candidates
@@ -0,0 +1,64 @@
1
+ /** Compare a composite revision with the SAME line/slim/redaction rules as upload. No raw bundle or network. */
2
+ import { createHash } from "node:crypto";
3
+ import { findEmbeddedImages, modeDropsImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
4
+ import { imageStateKey } from "../image-description-state.js";
5
+ import { openCodexSessionRevision } from "../adapters/codex-session-revision.js";
6
+ import { createSlimmer } from "../slim/slim-transcript.js";
7
+ import { scanLines, decideLinePlan, writeLinePlan } from "../adapters/raw-evidence-sanitize-lines.js";
8
+ export function codexRevisionFacts(paths, mode, describedKeys, slimAware) {
9
+ const form = async (drop) => {
10
+ const source = await openCodexSessionRevision(paths);
11
+ try {
12
+ const lines = { async *lines() {
13
+ const slimmer = createSlimmer("codex");
14
+ for await (const line of source.lines()) {
15
+ if (line.start === source.length)
16
+ continue;
17
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(line.bytes);
18
+ if (drop) {
19
+ const images = findEmbeddedImages(text);
20
+ text = replaceImagesWithMarkers(text, images, image => describedKeys.has(imageStateKey(image.sha256))).text;
21
+ text = slimmer.slimLine(text);
22
+ }
23
+ yield text + "\n";
24
+ }
25
+ } };
26
+ const options = { redactedFields: ["codex_jsonl.body"], secretLikeFileName: false };
27
+ const plan = decideLinePlan(await scanLines(lines, options), options);
28
+ if (plan.kind === "whole_mask" || plan.kind === "guard_line")
29
+ return null;
30
+ const hash = createHash("sha256");
31
+ let byte_size = 0;
32
+ const write = async (text) => { hash.update(text); byte_size += Buffer.byteLength(text); };
33
+ if (plan.kind === "clean") {
34
+ for await (const line of lines.lines())
35
+ await write(line);
36
+ }
37
+ else
38
+ await writeLinePlan(lines, plan, options, write);
39
+ await source.verifyUnchanged();
40
+ return { sha256: hash.digest("hex"), byte_size };
41
+ }
42
+ finally {
43
+ await source.close();
44
+ }
45
+ };
46
+ let full, slim;
47
+ return {
48
+ slimAware,
49
+ async rawSha256() { const source = await openCodexSessionRevision(paths); try {
50
+ return source.sha256;
51
+ }
52
+ finally {
53
+ await source.close();
54
+ } },
55
+ wholeForm: () => full ??= form(false),
56
+ sanitized: () => modeDropsImages(mode) ? slim ??= form(true) : full ??= form(false),
57
+ async slimSplit() {
58
+ if (!slimAware)
59
+ return null;
60
+ const [a, b] = await Promise.all([full ??= form(false), slim ??= form(true)]);
61
+ return a && b && a.sha256 !== b.sha256 ? { full: a, slim: b } : null;
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The `--to` readers of `cockpit notes`: `move --to "<shelf>"` and, since BLI-24437,
3
+ * `correct <id> --from "<misheard>" --to "<right>" [--transcript]`. Sibling of
4
+ * `local-args-tower-notes.ts`, which calls both; split out so that file stays a table of contents.
5
+ */
6
+ import { optionalNonEmpty } from "./local-arg-values.js";
7
+ export function readNotesShelfTarget(action, values) {
8
+ const to = optionalNonEmpty(values.flags.get("--to"));
9
+ const clearShelf = values.booleans.has("--clear-shelf");
10
+ if (action !== "move")
11
+ return { to, clearShelf };
12
+ if (to && clearShelf) {
13
+ throw new Error("notes move accepts either --to or --clear-shelf, not both.");
14
+ }
15
+ if (!to && !clearShelf && !values.flags.get("--folder")) {
16
+ throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
17
+ }
18
+ return { to, clearShelf };
19
+ }
20
+ /** BLI-24437: `correct` needs both terms; `--from` and `--transcript` mean nothing on any other verb. */
21
+ export function readNotesCorrection(action, values) {
22
+ const from = optionalNonEmpty(values.flags.get("--from"));
23
+ const transcript = values.booleans.has("--transcript");
24
+ if (action !== "correct") {
25
+ if (from !== undefined)
26
+ throw new Error("--from belongs to `cockpit notes correct <note-id> --from <term> --to <term>`.");
27
+ if (transcript)
28
+ throw new Error("--transcript belongs to `cockpit notes correct <note-id> --from <term> --to <term> --transcript`.");
29
+ return {};
30
+ }
31
+ if (!from || !optionalNonEmpty(values.flags.get("--to"))) {
32
+ throw new Error('notes correct needs --from "<misheard term>" and --to "<right term>".');
33
+ }
34
+ return { from, ...(transcript ? { transcript: true } : {}) };
35
+ }
@@ -7,6 +7,7 @@
7
7
  * address they have always had.
8
8
  */
9
9
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
10
+ import { readNotesCorrection, readNotesShelfTarget } from "./local-args-notes-targets.js";
10
11
  /**
11
12
  * The notetakers `cockpit notes connect` will actually seal a key for
12
13
  * (BLI-4391). It mirrors `CONNECTABLE_NOTES_PROVIDERS` in the dashboard, and
@@ -34,7 +35,7 @@ const NOTES_ACTIONS = new Set([
34
35
  "upload",
35
36
  "paste",
36
37
  "share",
37
- "unshare",
38
+ "unshare", "correct",
38
39
  "summarize",
39
40
  "move",
40
41
  "place",
@@ -61,7 +62,7 @@ const NOTES_ACTIONS = new Set([
61
62
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
62
63
  "show",
63
64
  "share",
64
- "unshare",
65
+ "unshare", "correct",
65
66
  "summarize",
66
67
  "move",
67
68
  "place",
@@ -82,7 +83,7 @@ const NOTES_ALLOWED_FLAGS = [
82
83
  "--file",
83
84
  "--name",
84
85
  "--exclude",
85
- "--to",
86
+ "--to", "--from", "--transcript", // BLI-24437: `correct <id> --from <term> --to <term> [--transcript]`
86
87
  "--folder",
87
88
  "--clear-shelf",
88
89
  "--apply",
@@ -122,7 +123,7 @@ const NOTES_VALUE_FLAGS = [
122
123
  "--file",
123
124
  "--name",
124
125
  "--exclude",
125
- "--to",
126
+ "--to", "--from",
126
127
  "--folder",
127
128
  "--series",
128
129
  "--kind",
@@ -180,6 +181,7 @@ export function parseNotesArgs(args) {
180
181
  exclude: optionalNonEmpty(values.flags.get("--exclude")),
181
182
  ...(to ? { to } : {}),
182
183
  ...(clearShelf ? { clearShelf } : {}),
184
+ ...readNotesCorrection(action, values),
183
185
  // BLI-4058. `place` says where a note belongs; only `--apply` moves it.
184
186
  ...(values.booleans.has("--apply") ? { apply: true } : {}),
185
187
  series: optionalNonEmpty(values.flags.get("--series")),
@@ -201,7 +203,7 @@ function readNotesAction(first) {
201
203
  // almost always wants — same reasoning as bare `cockpit` running convergence.
202
204
  const action = (first === undefined ? "list" : first);
203
205
  if (!NOTES_ACTIONS.has(action)) {
204
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
206
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, correct, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
205
207
  }
206
208
  return action;
207
209
  }
@@ -368,19 +370,6 @@ function readNotesPaths(action, rest) {
368
370
  }
369
371
  return undefined;
370
372
  }
371
- function readNotesShelfTarget(action, values) {
372
- const to = optionalNonEmpty(values.flags.get("--to"));
373
- const clearShelf = values.booleans.has("--clear-shelf");
374
- if (action !== "move")
375
- return { to, clearShelf };
376
- if (to && clearShelf) {
377
- throw new Error("notes move accepts either --to or --clear-shelf, not both.");
378
- }
379
- if (!to && !clearShelf && !values.flags.get("--folder")) {
380
- throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
381
- }
382
- return { to, clearShelf };
383
- }
384
373
  function readNotesMonth(action, values) {
385
374
  const month = optionalNonEmpty(values.flags.get("--month"));
386
375
  if (month === undefined)
@@ -112,7 +112,7 @@ export const TOWER_COMMAND_HELP = [
112
112
  "dm <email> [<email>...] — with several addresses, opens (or re-opens) the group message with exactly those people plus you; the same set always resolves to the same channel. channel <channel> [--json] — the channel's details: name, description, kind, archived, audience (a public channel is everyone in the org, not a member count), source workspace label (where it came from; it grants no access), whether you can manage it, and whether you hid it. channel <channel> --rename <name> | --describe <text> | --archive | --unarchive — changes it (one update; --rename and --describe can go together). Only the person who made the channel, its owners and its moderators can; anyone else is refused channel_manage_refused, a name in use channel_name_taken, a direct message dm_not_editable. Archiving keeps every message and link and stops new posts (channel_archived); unarchive restores posting. members <channel> [--json] — who is in a private channel or a direct message; a public channel says everyone in the org can read it. members <channel> --add <email> | --remove <email> — private channels only, by the same people who can manage it; a removed person's next read is refused. A direct message keeps the people it started with (dm_not_editable): start a new group with `msg dm a@x b@y`. hide <channel> / unhide <channel> — drops a PUBLIC channel from your own sidebar, or puts it back; it changes nothing for anyone else and nothing about reading, search, mentions or notifications. A private channel or direct message is refused hide_not_public.",
113
113
  "read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
114
114
  "unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
115
- "send <channel> [--thread <id>] [--request-id <uuid>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`. Every send carries a request id (minted when you give none); retry a send whose answer was lost with the SAME --request-id and the same text and it posts once. The same id with different text is refused request_id_reused, not applied as an edit. send <channel> --file <id> [--file <id>...] — attaches up to 10 ready files (from `msg file upload`) to the message. The text is optional with a file; it is read from stdin only when something is piped in. file upload <channel> <path|-> [--name <name>] — uploads a file of 1 byte to 25 MB straight to storage (reserve, PUT, finalize) and prints its id and state; `-` reads stdin and needs --name. A file is ready, not sent: attach it with `msg send <channel> --file <id>`. file show <id> — the descriptor (state reserved, ready, attached, cancelled or abandoned). file download <id> [--out <path>] — saves the bytes (60-second signed link; never overwrites). file cancel <id> — ends an unattached file and removes its bytes. file list <channel> — your own unsent uploads there, with coverage. A file is readable only by readers of its channel, and only once attached to a live message. Refusals keep the door's reason: file_too_large, file_not_found_or_unreadable, file_not_uploaded, file_not_ready, file_already_attached, file_wrong_channel, storage_unreachable.",
115
+ "send <channel> [--thread <id>] [--request-id <uuid>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`. Every send carries a request id (minted when you give none); retry a send whose answer was lost with the SAME --request-id and the same text and it posts once. The same id with different text is refused request_id_reused, not applied as an edit. send <channel> --file <id> [--file <id>...] — attaches up to 10 ready files (from `msg file upload`) to the message. The text is optional with a file; it is read from stdin only when something is piped in. file upload <channel> <path|-> [--name <name>] — uploads a file of 1 byte to 25 MB straight to storage (reserve, PUT, finalize) and prints its id and state; `-` reads stdin and needs --name. A file is ready, not sent: attach it with `msg send <channel> --file <id>`. file show <id> — the descriptor (state reserved, ready, attached, cancelled or abandoned). file download <id> [--out <path>] — saves the bytes (60-second signed link; never overwrites). file cancel <id> — ends an unattached file and removes its bytes. file list <channel> — your own unsent uploads there, with coverage. A file is readable only by readers of its channel, and only once attached to a live message. Refusals keep the door's reason: file_too_large, file_not_found_or_unreadable, file_not_uploaded, file_not_ready, file_already_attached, file_wrong_channel, storage_unreachable (no answer: try again), storage_refused (storage said no: retrying will not help; the sentence names the step, HTTP status and class).",
116
116
  "edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying. draft [list|show|save|discard|restore|send] <channel> [--thread <id>] [--expected-revision <n>] [--request-id <uuid>] — the text you have not sent yet, one draft per channel and thread, the same drafts the browser autosaves. list shows all your unsent drafts (newest first, with coverage); show prints one; save takes the body on stdin (`echo \"half a thought\" | cockpit msg draft save general`) and with --expected-revision refuses draft_revision_conflict, naming the stored revision, instead of overwriting; discard keeps the text (state discarded) and restore brings it back; send posts the stored draft with a fresh request id (--request-id to retry the same send) and its revision, prints the receipt, and marks the draft sent only if it did not change meanwhile. Only you can read or change your drafts.",
117
117
  "thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
118
118
  "show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false. activity [--mentions|--threads] [--unread] [--before <cursor>] [--limit <n>] [--mark-read] — your Mentions (messages that mention you) and Threads (replies in threads you follow: you started it, replied natively or followed it by hand), newest first, with unread counts and coverage; items whose message you can no longer read are left out and counted as dropped. Nothing is marked read by looking; --mark-read marks the unread items just printed, and the web marks one when its message is shown. Activity covers native Tower messages only, so the Slack import adds none. follow <message-id> / unfollow <message-id> — the id is the thread's top-level message; an explicit unfollow wins over a later auto follow, and earlier items stay. react <message-id> <emoji> [--remove] — adds or takes off your reaction (idempotent). Refusals: thread_root_not_found_or_unreadable, not_a_thread_root, reaction_not_found. notify — your message notification settings (the same ones the web header menu and Settings change): the default level (all, mentions or none; mentions is the default and covers direct messages, @mentions and replies in threads you follow), quiet hours and per-channel overrides. notify set --level <all|mentions|none> changes the default; add --channel <id|name> for one channel, with --mute / --unmute, or --clear to remove the override. notify quiet <HH:MM-HH:MM> [--timezone <zone>] sets quiet hours (24-hour, may wrap midnight; the zone defaults to this machine's) and notify quiet --off removes them. notify receipts [--message <id>] [--before <cursor>] [--limit <n>] lists what was delivered, suppressed or blocked and why (never message text): outcome shown means Tower told a browser tab to show a banner; push_accepted means the push service took the phone push, not that a phone showed it. Quiet hours and levels hold back banners and phone pushes, never the message or its unread badge. Refusals: invalid_notification_level, invalid_quiet_hours, invalid_timezone. search [words] [--from <email|me>]... [--in <channel>] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--timezone <IANA>] [--replies|--top-level] [--has-file] [--before <cursor>] [--limit <n>] — message search on your own access, newest first (not ranked; `cockpit search --kind msg` is the ranked bar). Each hit prints its id, channel, author, time and snippet, then `more: --before <cursor>` or `complete`; repeat with the cursor until complete. --from takes an email, a user id or me and repeats; --in takes a channel id or #name (exact after case folding, an ambiguous name is refused); --since and --until are whole local days in --timezone, both inclusive (this machine's zone when you give none). The words may be left out when a filter is set, so `msg search --has-file` finds attachment-only messages. Inline operators work in the words as in the web box: from:<email|me> in:#name after:<day> before:<day> (exclusive) on:<day> has:file is:reply is:root. Recognised but unsupported operators (has:link, has:pin, has:reaction, is:saved, is:dm, to:, with:, during:) are refused unsupported_search_operator, naming the operator; a bad day, zone, person or channel is refused invalid_search_filter. Messages imported from Slack carry no file links, so has:file does not find them. Deleted messages, channels you cannot read and files you cannot read never appear.",
@@ -3,7 +3,7 @@ export const NOTES_HELP = [
3
3
  [
4
4
  "notes",
5
5
  [
6
- "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status|review [<note-id>] [--submit|--accept-all]] [flags]",
6
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|correct <id> --from <term> --to <term>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status|review [<note-id>] [--submit|--accept-all]] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
9
  "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording; the recorder stops by itself after 8 hours (under the 1 GB upload limit). `stop` uploads the whole original recording, silence included, and Tower writes a note with Speaker 1, Speaker 2... lines, or says no speech was detected (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording and every session waiting for upload (`needs_upload`); `stop <session-id>` uploads a cancelled, crashed or auto-stopped session. The BLI Notetaker app uploads waiting sessions by itself. The recording also stays on this machine.",
@@ -24,6 +24,7 @@ export const NOTES_HELP = [
24
24
  "share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
25
25
  "summarize <id> — write a new summary (decisions, action items, key points) where every claim cites the speaker, timestamp and segment id (s12) it came from; claims the transcript does not support are dropped. Takes up to a minute; earlier summaries are kept. `show <id>` prints the newest one.",
26
26
  "unshare <id> — take it back. Never asks: it only ever narrows who can read.",
27
+ "correct <id> --from \"<misheard>\" --to \"<right>\" [--transcript] — fix a misheard term: every whole-word mention in the note's notes and summary in one write, the previous text kept. The transcript stays as recorded unless you pass --transcript, and then each fix is marked beside the words as heard. Owner or admin only. Then `cockpit glossary add` makes future transcripts and incoming notes spell it right.",
27
28
  "move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
28
29
  "place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
29
30
  "",
@@ -223,6 +223,30 @@ export async function shareNote(command, door) {
223
223
  sayUpload(door, body);
224
224
  return 0;
225
225
  }
226
+ /**
227
+ * `cockpit notes correct <note-id> --from <term> --to <term> [--transcript]` (BLI-24437): one call to
228
+ * `POST /api/notes/correct`, the door the browser, MCP and JARVIS use. The server counts; this prints its sentences.
229
+ * A refusal, including "nothing_to_correct" (no mention found, nothing changed), prints the server's sentence and exits 1.
230
+ */
231
+ export async function correctNote(command, door) {
232
+ const scope = command.transcript ? "notes_and_transcript" : "notes";
233
+ const answer = await ask(door, {
234
+ path: "/api/notes/correct",
235
+ method: "POST",
236
+ label: "notes correct",
237
+ timeoutMs: READ_DEADLINE_MS,
238
+ body: { note_id: command.noteId, from: command.from, to: command.to, scope },
239
+ });
240
+ if (!answer.ok) {
241
+ return fail(door, answer.reason, answer.detail);
242
+ }
243
+ const body = answer.body;
244
+ writeLine(door.io.stderr, `${TAG} corrected ${JSON.stringify({ note_id: command.noteId ?? null, scope, notes: body.counts?.notes ?? null, transcript: body.counts?.transcript ?? null, summary: body.counts?.summary ?? null })}`);
245
+ if (door.json)
246
+ return emit(door, body);
247
+ sayUpload(door, body);
248
+ return 0;
249
+ }
226
250
  /** A summary run is two model calls over a long transcript: give it room (BLI-20882). */
227
251
  const SUMMARIZE_DEADLINE_MS = 330_000;
228
252
  /**