@bli-cockpit/cli 0.2.154 → 0.2.156

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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Asks `POST /api/ambient/evidence/stubs` which of this operator's stored
3
+ * transcripts are only the redaction-crash stub, and groups the answer by agent
4
+ * session (BLI-11102).
5
+ *
6
+ * A stub is a promise to come back, not a delivery: the local cursor recorded
7
+ * an upload, so nothing local would ever offer that session again. The server
8
+ * holds the label, so the server is asked. `cockpit backfill` re-offers every
9
+ * session this returns.
10
+ *
11
+ * Never throws. A door that cannot be asked (an older dashboard answers 404, a
12
+ * network error, a malformed page) returns `unavailable` with the reason, and
13
+ * the backfill runs exactly as it did before. That is also the release gate:
14
+ * the collector only ever sends a `stub_replaced` claim for a stub this door
15
+ * named, so it cannot send one to a server that does not know the rule.
16
+ *
17
+ * Bounded: pages of up to 500, at most `maxPages` of them, walked by the
18
+ * server's keyset cursor. The result says whether the list was exhausted,
19
+ * capped by `maxPages`, or cut short by a failed page after some landed.
20
+ */
21
+ import { EVIDENCE_STUBS_MAX_PAGE, EVIDENCE_STUBS_SCHEMA_VERSION, EvidenceStubsResponseSchema, transcriptSessionFromObjectKey, } from "@bli-cockpit/telemetry-core";
22
+ import { describeError } from "./health-detail.js";
23
+ const STUBS_PAGE_TIMEOUT_MS = 15_000;
24
+ export const STUBS_DEFAULT_MAX_PAGES = 20;
25
+ export function stubSessionKey(source, sessionId) {
26
+ return `${source}:${sessionId}`;
27
+ }
28
+ export async function fetchStubbedSessions(options) {
29
+ const maxPages = options.maxPages ?? STUBS_DEFAULT_MAX_PAGES;
30
+ const sessions = new Map();
31
+ const seen = new Set();
32
+ let stubCount = 0;
33
+ let unkeyed = 0;
34
+ let pages = 0;
35
+ let after = null;
36
+ for (;;) {
37
+ if (pages >= maxPages) {
38
+ return finish("capped", "ok");
39
+ }
40
+ const page = await requestPage({ ...options, after });
41
+ if (!page.ok) {
42
+ return finish(pages === 0 ? "unavailable" : "failed_partial", page.reason);
43
+ }
44
+ pages += 1;
45
+ for (const stub of page.stubs) {
46
+ stubCount += 1;
47
+ if (seen.has(stub.ref_id))
48
+ continue;
49
+ seen.add(stub.ref_id);
50
+ const session = transcriptSessionFromObjectKey(stub.object_key);
51
+ if (!session) {
52
+ unkeyed += 1;
53
+ continue;
54
+ }
55
+ const key = stubSessionKey(session.source, session.session_id);
56
+ sessions.set(key, [...(sessions.get(key) ?? []), stub.raw_evidence_pointer_id]);
57
+ }
58
+ // Advance by what the server returned; a page that names no cursor is the last.
59
+ if (!page.nextAfter)
60
+ return finish("exhausted", "ok");
61
+ after = page.nextAfter;
62
+ }
63
+ function finish(status, reason) {
64
+ const result = {
65
+ status,
66
+ reason,
67
+ pages,
68
+ stub_count: stubCount,
69
+ unique_stub_count: seen.size,
70
+ unkeyed_stub_count: unkeyed,
71
+ sessions,
72
+ };
73
+ console.error("[collector stubs] redaction-crash stubs asked", JSON.stringify({
74
+ reason: status === "unavailable" ? `stub_door_unavailable:${reason}` : `stubs_${status}`,
75
+ pages,
76
+ stub_count: stubCount,
77
+ unique_stub_count: seen.size,
78
+ unkeyed_stub_count: unkeyed,
79
+ session_count: sessions.size,
80
+ ...(status === "failed_partial" ? { failure: reason } : {}),
81
+ }));
82
+ return result;
83
+ }
84
+ }
85
+ async function requestPage(options) {
86
+ const controller = new AbortController();
87
+ const timeout = setTimeout(() => controller.abort(), STUBS_PAGE_TIMEOUT_MS);
88
+ try {
89
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/stubs`, {
90
+ method: "POST",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Authorization: `Bearer ${options.deviceToken}`,
94
+ },
95
+ body: JSON.stringify({
96
+ schema_version: EVIDENCE_STUBS_SCHEMA_VERSION,
97
+ after: options.after,
98
+ limit: EVIDENCE_STUBS_MAX_PAGE,
99
+ }),
100
+ signal: controller.signal,
101
+ });
102
+ if (!response.ok)
103
+ return { ok: false, reason: `http_${response.status}` };
104
+ const parsed = EvidenceStubsResponseSchema.safeParse(await response.json().catch(() => null));
105
+ if (!parsed.success)
106
+ return { ok: false, reason: "invalid_response" };
107
+ return { ok: true, stubs: parsed.data.stubs, nextAfter: parsed.data.next_after };
108
+ }
109
+ catch (error) {
110
+ return { ok: false, reason: describeError(error).error_name };
111
+ }
112
+ finally {
113
+ clearTimeout(timeout);
114
+ }
115
+ }
@@ -37,9 +37,16 @@ import path from "node:path";
37
37
  * of a file operators can read. The tail is capped rather than copied whole, so
38
38
  * the archives cannot themselves become the disk problem — worst case on disk
39
39
  * is (keep + 1) x maxBytes per stream.
40
+ *
41
+ * BLI-11106 tightened the numbers: at most 2 archives and 20 MB of archive per
42
+ * stream (2 x 10 MiB), so both streams together never pass 60 MiB, live logs
43
+ * included. It was 3 x 20 MiB per stream (160 MiB worst case) and the
44
+ * reference Mac held 147 MB of it on 2026-09-30. An archive above the new keep
45
+ * window (`sync.log.3` on every machine that ran the old numbers) is swept on
46
+ * the first tick by `sweepOrphanedLogArchives` below.
40
47
  */
41
- export const DEFAULT_LOG_ROTATION_MAX_BYTES = 20 * 1024 * 1024;
42
- export const DEFAULT_LOG_ROTATION_KEEP = 3;
48
+ export const DEFAULT_LOG_ROTATION_MAX_BYTES = 10 * 1024 * 1024;
49
+ export const DEFAULT_LOG_ROTATION_KEEP = 2;
43
50
  /** The two files launchd writes for the scheduled tick. */
44
51
  export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
45
52
  /**
@@ -129,27 +136,41 @@ async function rotateOne(filePath, maxBytes, keep) {
129
136
  * sentences and an operator is owed the second one.
130
137
  */
131
138
  export async function sweepOrphanedLogArchives(paths, options) {
139
+ const swept = [];
140
+ for (const archive of await orphanedLogArchives(paths, options)) {
141
+ const removed = await fs
142
+ .rm(path.join(paths.state_dir, archive.name), { force: true })
143
+ .then(() => true, () => false);
144
+ if (removed)
145
+ swept.push(archive);
146
+ }
147
+ return swept;
148
+ }
149
+ /**
150
+ * The archives the sweep above WOULD remove, without removing them — what
151
+ * `cockpit clean --dry-run` prints (BLI-11106). Same rule, one reader.
152
+ */
153
+ export async function orphanedLogArchives(paths, options = {
154
+ keep: DEFAULT_LOG_ROTATION_KEEP,
155
+ names: ROTATED_LOG_NAMES,
156
+ }) {
132
157
  const entries = await fs
133
158
  .readdir(paths.state_dir, { withFileTypes: true })
134
159
  .catch(() => []);
135
- const swept = [];
160
+ const found = [];
136
161
  for (const entry of entries) {
137
162
  if (!entry.isFile())
138
163
  continue;
139
164
  const reason = orphanArchiveReason(entry.name, options);
140
165
  if (!reason)
141
166
  continue;
142
- const file = path.join(paths.state_dir, entry.name);
143
167
  const size = await fs
144
- .stat(file)
168
+ .stat(path.join(paths.state_dir, entry.name))
145
169
  .then((info) => info.size)
146
170
  .catch(() => 0);
147
- const removed = await fs.rm(file, { force: true }).then(() => true, () => false);
148
- if (!removed)
149
- continue;
150
- swept.push({ name: entry.name, byte_size: size, reason });
171
+ found.push({ name: entry.name, byte_size: size, reason });
151
172
  }
152
- return swept;
173
+ return found;
153
174
  }
154
175
  /**
155
176
  * Which stream this file belongs to and why it is outside the keep window, or
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `last-prune.json`: when the staging prune last ran and what it freed
3
+ * (BLI-11106).
4
+ *
5
+ * The prune runs on every sync tick, so "did it run?" is answered by a file it
6
+ * overwrites, never by a file that grows. One small JSON object, bounded by
7
+ * construction. `cockpit doctor` and `cockpit status` read it for the
8
+ * footprint line ("last prune: <when>, freed <n> MB") without re-running the
9
+ * prune themselves.
10
+ *
11
+ * It also carries the last time a prune actually freed something, because on
12
+ * a healthy machine the newest run is usually `nothing_to_prune` and "freed
13
+ * 0 MB" on its own cannot tell a working drain from a stuck one.
14
+ *
15
+ * The old once-a-day throttle marker is removed on the first write: it gates
16
+ * nothing any more, and a file that gates nothing is exactly the kind of
17
+ * leftover this ticket exists to stop.
18
+ */
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { describeError } from "./health-detail.js";
22
+ export const LAST_PRUNE_FILE = "last-prune.json";
23
+ /** The retired daily throttle marker; deleted on sight. */
24
+ export const LEGACY_STAGING_PRUNE_MARKER = ".last-staging-prune";
25
+ export async function readLastPruneReceipt(paths) {
26
+ const raw = await fs
27
+ .readFile(path.join(paths.state_dir, LAST_PRUNE_FILE), "utf8")
28
+ .catch(() => null);
29
+ if (!raw)
30
+ return null;
31
+ try {
32
+ const parsed = JSON.parse(raw);
33
+ if (typeof parsed.at !== "string" || typeof parsed.reason !== "string") {
34
+ return null;
35
+ }
36
+ return {
37
+ at: parsed.at,
38
+ reason: parsed.reason,
39
+ freed_bytes: numberOr0(parsed.freed_bytes),
40
+ deleted_files: numberOr0(parsed.deleted_files),
41
+ bytes_scanned: numberOr0(parsed.bytes_scanned),
42
+ bytes_after: numberOr0(parsed.bytes_after),
43
+ last_freed_at: typeof parsed.last_freed_at === "string" ? parsed.last_freed_at : null,
44
+ last_freed_bytes: numberOr0(parsed.last_freed_bytes),
45
+ };
46
+ }
47
+ catch {
48
+ // A torn or hand-edited receipt reads as "never ran"; the next tick
49
+ // overwrites it. Doctor prints "last prune: never" rather than guessing.
50
+ return null;
51
+ }
52
+ }
53
+ /** Never throws: a receipt that cannot be written is a log line, not a failed tick. */
54
+ export async function writeLastPruneReceipt(paths, result, now) {
55
+ try {
56
+ const previous = await readLastPruneReceipt(paths);
57
+ const freed = result.deleted_bytes > 0;
58
+ const receipt = {
59
+ at: now.toISOString(),
60
+ reason: result.reason,
61
+ freed_bytes: result.deleted_bytes,
62
+ deleted_files: result.deleted_files,
63
+ bytes_scanned: result.bytes_before,
64
+ bytes_after: result.bytes_after,
65
+ last_freed_at: freed ? now.toISOString() : (previous?.last_freed_at ?? null),
66
+ last_freed_bytes: freed
67
+ ? result.deleted_bytes
68
+ : (previous?.last_freed_bytes ?? 0),
69
+ };
70
+ await fs.mkdir(paths.state_dir, { recursive: true });
71
+ const file = path.join(paths.state_dir, LAST_PRUNE_FILE);
72
+ const temp = `${file}.${process.pid}.tmp`;
73
+ await fs.writeFile(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
74
+ await fs.rename(temp, file);
75
+ await fs
76
+ .rm(path.join(paths.state_dir, LEGACY_STAGING_PRUNE_MARKER), { force: true })
77
+ .catch(() => undefined);
78
+ }
79
+ catch (error) {
80
+ console.error("[collector prune] could not record the last-prune receipt", JSON.stringify({ reason: "last_prune_write_failed", ...describeError(error) }));
81
+ }
82
+ }
83
+ function numberOr0(value) {
84
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
85
+ ? value
86
+ : 0;
87
+ }
@@ -10,27 +10,33 @@ const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
10
10
  * observed on the fleet is minutes, not hours.
11
11
  */
12
12
  const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
13
- // GC hashes every file in every old-but-kept dir to confirm uploads. Running
14
- // that on each 15-min sync would re-read the same gigabytes ~96x/day on
15
- // machines with unconfirmed evidence, so GC is throttled to once per day.
16
- const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+ /**
14
+ * The retired once-a-day throttle marker (BLI-11106). The GC runs every tick
15
+ * now, and deletes this file on sight so it stops being state nobody reads.
16
+ *
17
+ * Why every tick is affordable: the GC used to hash every file of every old
18
+ * pack to confirm it was uploaded, which re-read gigabytes on a machine with
19
+ * unconfirmed evidence. It now asks the pack's own `manifest.json` first, and a
20
+ * pack whose manifest names ANY hash the ledger has not committed is skipped
21
+ * without reading a payload byte. Only a pack the manifest says is fully
22
+ * committed is hashed, once, on the way to being deleted.
23
+ */
24
+ export const LEGACY_GC_THROTTLE_MARKER = ".last-raw-evidence-gc";
17
25
  export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
18
26
  if (env["COCKPIT_DISABLE_GC"] === "1") {
19
27
  return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
20
28
  }
21
- const throttleMarker = path.join(paths.state_dir, ".last-raw-evidence-gc");
22
- const lastRun = await fs.stat(throttleMarker).catch(() => null);
23
- if (lastRun && now.getTime() - lastRun.mtimeMs < GC_MIN_INTERVAL_MS) {
24
- return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
25
- }
26
- await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
27
- await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
29
+ await fs
30
+ .rm(path.join(paths.state_dir, LEGACY_GC_THROTTLE_MARKER), { force: true })
31
+ .catch(() => undefined);
28
32
  const cursor = await readRawEvidenceCursor(paths);
29
33
  const uploadedHashes = new Set(Object.keys(cursor.objects));
30
34
  const rawEvidenceRoot = path.join(paths.state_dir, "raw-evidence");
31
35
  const entries = await fs.readdir(rawEvidenceRoot, { withFileTypes: true }).catch(() => []);
32
36
  let removedDirs = 0;
33
37
  let freedBytes = 0;
38
+ let scannedDirs = 0;
39
+ let skippedByManifest = 0;
34
40
  for (const entry of entries) {
35
41
  if (!entry.isDirectory() || !entry.name.startsWith("work-"))
36
42
  continue;
@@ -40,6 +46,11 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
40
46
  continue;
41
47
  if (now.getTime() - dirStats.mtimeMs < RAW_EVIDENCE_RETENTION_MS)
42
48
  continue;
49
+ scannedDirs += 1;
50
+ if (await manifestNamesUncommitted(dir, uploadedHashes)) {
51
+ skippedByManifest += 1;
52
+ continue;
53
+ }
43
54
  const inspection = await inspectRawEvidenceDirForGc(dir, uploadedHashes);
44
55
  if (!inspection.deletable)
45
56
  continue;
@@ -50,12 +61,18 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
50
61
  freedBytes += inspection.byteSize;
51
62
  }
52
63
  // Log capping used to live here, and that is why it did not work: this GC
53
- // is throttled to once a day and skipped entirely by COCKPIT_DISABLE_GC,
64
+ // was throttled to once a day and is skipped entirely by COCKPIT_DISABLE_GC,
54
65
  // while sync.log grows ~8 MB an hour on an active machine (measured on the
55
66
  // reference Mac, 2026-09-04: 219,866,695 bytes with the last GC 19 hours
56
- // earlier). A daily truncate-to-zero of a file that reaches ~190 MB a day is
57
- // not a cap, and sync.err.log had no cap at all. One owner now, running in
58
- // the tick preamble: log-rotation.ts (BLI-3553).
67
+ // earlier). One owner now, running in the tick preamble: log-rotation.ts
68
+ // (BLI-3553).
69
+ console.error("[raw-evidence] gc sweep", JSON.stringify({
70
+ reason: removedDirs > 0 ? "gc_removed" : "nothing_to_prune",
71
+ scanned_dirs: scannedDirs,
72
+ skipped_by_manifest: skippedByManifest,
73
+ removed_dirs: removedDirs,
74
+ freed_bytes: freedBytes,
75
+ }));
59
76
  return {
60
77
  skipped: false,
61
78
  removed_dirs: removedDirs,
@@ -241,6 +258,29 @@ async function dirByteSize(dir) {
241
258
  }
242
259
  return total;
243
260
  }
261
+ /**
262
+ * True when the pack's own manifest names a content hash the ledger has not
263
+ * committed, so the pack cannot be deleted and no payload needs reading. A
264
+ * missing or unreadable manifest answers false and falls through to the full
265
+ * hash check, which is the old, conservative path.
266
+ */
267
+ async function manifestNamesUncommitted(dir, uploadedHashes) {
268
+ const manifest = await fs
269
+ .readFile(path.join(dir, "manifest.json"), "utf8")
270
+ .catch(() => null);
271
+ if (!manifest)
272
+ return false;
273
+ try {
274
+ const parsed = JSON.parse(manifest);
275
+ return (parsed.files ?? []).some((file) => typeof file.content_hash_sha256 === "string" &&
276
+ !uploadedHashes.has(file.content_hash_sha256));
277
+ }
278
+ catch {
279
+ // Not silent in effect: an unparseable manifest falls through to the full
280
+ // per-file hash check below, which decides on the bytes themselves.
281
+ return false;
282
+ }
283
+ }
244
284
  async function inspectRawEvidenceDirForGc(dir, uploadedHashes) {
245
285
  const files = await listFiles(dir).catch(() => null);
246
286
  if (!files)
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Everything the collector's state directory holds, sorted into who owns it
3
+ * and what bounds it (BLI-11106).
4
+ *
5
+ * `~/.local/state/bli-cockpit` (Windows: `%USERPROFILE%\.local\state\bli-cockpit`,
6
+ * the same layout with backslashes) is where the collector keeps its state.
7
+ * By 2026-09-30 it also held a gigabyte of study artifacts written by agents
8
+ * running benches and experiments (`memory-bench`, `memory-extract-*`,
9
+ * `chat-model-bench`, …). Those are not the collector's, the collector never
10
+ * reads them, and counting them as its footprint made `cockpit doctor`
11
+ * alarm about bytes no prune could ever explain.
12
+ *
13
+ * The rule: study artifacts belong in the SIBLING root
14
+ * `~/.local/state/bli-cockpit-studies` (same parent, same layout on Windows),
15
+ * see `studiesStateDir`. Existing study folders already under the state dir are
16
+ * recognised by name, left exactly where they are, and not counted. Anything
17
+ * the table below does not recognise is counted as `other`, so a new
18
+ * collector file that nobody registered still shows up in the footprint
19
+ * rather than hiding.
20
+ *
21
+ * Every collector entry carries the one-line reason it is bounded; the
22
+ * runbook (`docs/runbooks/cockpit-launchd-sync.md`, "What the state
23
+ * directory holds") prints the same table. Metadata only: entry names are
24
+ * fixed strings, never a person's path.
25
+ */
26
+ import fs from "node:fs/promises";
27
+ import path from "node:path";
28
+ import { directoryBytes } from "./disk-usage-files.js";
29
+ /** The sibling root where agent-written studies live, never under the collector. */
30
+ export const STUDIES_DIR_SUFFIX = "-studies";
31
+ /**
32
+ * The collector's own entries first (checked before the study prefixes), then
33
+ * the studies. Order matters only where a study prefix could shadow a
34
+ * collector name; none does today, and the test pins that.
35
+ */
36
+ export const STATE_DIR_RULES = [
37
+ { name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; uncommitted evidence is never deleted, only counted" },
38
+ { name: "sync.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
39
+ { name: "sync.err.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
40
+ { name: "spool", match: "exact", class: "spool", bound: "upload queue; each entry is deleted once the ingest door accepts it" },
41
+ { name: "cursors", match: "exact", class: "state", bound: "raw-evidence ledger and reconcile answers capped at 5,000 rows each; backfill cursors one small file each" },
42
+ { name: "raw-evidence-staging.json", match: "exact", class: "state", bound: "staged-object index and delivery attempts capped at 5,000 rows each" },
43
+ { name: "work-contexts", match: "exact", class: "state", bound: "one small file per worktree ever bound with `cockpit start`; overwritten, not pruned (a few KB each)" },
44
+ { name: "active-work-context.json", match: "exact", class: "state", bound: "one file, overwritten" },
45
+ { name: "root-work-scan.json", match: "exact", class: "state", bound: "one file, overwritten by each root scan" },
46
+ { name: "setup-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
47
+ { name: "memory-install-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
48
+ { name: "last-prune.json", match: "exact", class: "state", bound: "one file, overwritten every tick" },
49
+ { name: "last-turn-trace.json", match: "exact", class: "state", bound: "one file, overwritten" },
50
+ { name: "sync.lock", match: "exact", class: "state", bound: "lock file, removed when the tick ends" },
51
+ { name: "backfill.lock", match: "exact", class: "state", bound: "lock file, removed when the backfill ends" },
52
+ { name: ".last-", match: "prefix", class: "state", bound: "24-byte throttle markers for self-update, autostart repair and the memory install" },
53
+ { name: "read-cache", match: "exact", class: "state", bound: "one response file per distinct Tower read, overwritten on refresh" },
54
+ { name: "memory-hook-stats.json", match: "exact", class: "state", bound: "fixed-size histograms, overwritten (BLI Memory hooks)" },
55
+ { name: "memory-hook-samples", match: "exact", class: "state", bound: "hourly content-free timing samples (BLI Memory hooks); not yet pruned, a few MB a week" },
56
+ { name: "memory-recall-seen", match: "exact", class: "state", bound: "one file per agent session, deleted after 7 days (BLI Memory hooks)" },
57
+ { name: "memory-stop-anchors.json", match: "exact", class: "state", bound: "one offset per transcript, overwritten (BLI Memory hooks)" },
58
+ { name: "memory-daemon-spawn.json", match: "exact", class: "state", bound: "one spawn stamp, overwritten (BLI Memory daemon)" },
59
+ { name: "memory-daemon.sock", match: "exact", class: "state", bound: "the daemon's socket, zero bytes" },
60
+ // Studies written by agents and scripts, not by the collector. Counted by
61
+ // name only, never sized, never deleted.
62
+ { name: "memory-bench", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
63
+ { name: "memory-extract-", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
64
+ { name: "memory-questions", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
65
+ { name: "memory-hook-context-sweep", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
66
+ { name: "memory-anchor-probe", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
67
+ { name: "memory-dup-threshold", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
68
+ { name: "memory-judge-effort", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
69
+ { name: "memory-recurrence-backfill", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
70
+ { name: "memory-supersede-from-record", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
71
+ { name: "memory-reconcile-precision", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
72
+ { name: "memory-harvest-dedup", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
73
+ { name: "chat-model-bench", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
74
+ { name: "gpt6-bench-", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
75
+ { name: "manual-study-evidence-vault", match: "prefix", class: "study", bound: "hand-made evidence vault; `cockpit clean --all-committed` removes it once every file is committed" },
76
+ { name: "issue-import", match: "exact", class: "study", bound: "one-off import receipts; belongs in bli-cockpit-studies" },
77
+ { name: "analysis", match: "exact", class: "study", bound: "local harvest-analysis output; belongs in bli-cockpit-studies" },
78
+ { name: "task-type-backlog", match: "prefix", class: "study", bound: "local harvest-analysis output; belongs in bli-cockpit-studies" },
79
+ ];
80
+ /** Which rule an entry name falls under, or null for `other`. */
81
+ export function classifyStateEntry(name) {
82
+ for (const rule of STATE_DIR_RULES) {
83
+ if (rule.match === "exact" ? name === rule.name : name.startsWith(rule.name)) {
84
+ return rule;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * The sibling studies root, on either host family: the state dir's own path
91
+ * with `-studies` appended, so `/Users/x/.local/state/bli-cockpit` becomes
92
+ * `/Users/x/.local/state/bli-cockpit-studies` and
93
+ * `C:\Users\x\.local\state\bli-cockpit` becomes
94
+ * `C:\Users\x\.local\state\bli-cockpit-studies`. No separator is joined, so
95
+ * neither layout can be spelled wrong.
96
+ */
97
+ export function studiesStateDir(paths) {
98
+ return `${paths.state_dir.replace(/[\\/]+$/u, "")}${STUDIES_DIR_SUFFIX}`;
99
+ }
100
+ /**
101
+ * Sizes the `state` and `other` entries of the state directory. Staging,
102
+ * logs and spool are measured by their own readers and skipped here; studies
103
+ * are counted but never walked, because a gigabyte of somebody's bench run is
104
+ * not worth a stat per file on every doctor call.
105
+ */
106
+ export async function readStateDirFootprint(paths) {
107
+ const footprint = {
108
+ state_bytes: 0,
109
+ other_bytes: 0,
110
+ other_entries: 0,
111
+ study_entries: 0,
112
+ };
113
+ const entries = await fs
114
+ .readdir(paths.state_dir, { withFileTypes: true })
115
+ .catch(() => []);
116
+ for (const entry of entries) {
117
+ const rule = classifyStateEntry(entry.name);
118
+ const cls = rule?.class ?? "other";
119
+ if (cls === "staging" || cls === "logs" || cls === "spool")
120
+ continue;
121
+ if (cls === "study") {
122
+ footprint.study_entries += 1;
123
+ continue;
124
+ }
125
+ const full = path.join(paths.state_dir, entry.name);
126
+ const bytes = entry.isDirectory()
127
+ ? await directoryBytes(full)
128
+ : await fs.stat(full).then((info) => info.size, () => 0);
129
+ if (cls === "state") {
130
+ footprint.state_bytes += bytes;
131
+ }
132
+ else {
133
+ footprint.other_bytes += bytes;
134
+ footprint.other_entries += 1;
135
+ }
136
+ }
137
+ return footprint;
138
+ }
@@ -125,6 +125,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
125
125
  operatorId: session.operator_id,
126
126
  workContextId: uploadContext.work_context_id,
127
127
  })),
128
+ rawEvidenceStubReoffers: options.rawEvidenceStubReoffers,
128
129
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
129
130
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
130
131
  rawEvidenceBudget: options.rawEvidenceBudget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.154",
3
+ "version": "0.2.156",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.41",
31
- "@bli-cockpit/mcp": "0.1.75",
32
- "@bli-cockpit/telemetry-core": "0.1.53"
30
+ "@bli-cockpit/memory-mcp": "0.1.42",
31
+ "@bli-cockpit/mcp": "0.1.76",
32
+ "@bli-cockpit/telemetry-core": "0.1.54"
33
33
  }
34
34
  }