@bli-cockpit/cli 0.2.149 → 0.2.154

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 (46) hide show
  1. package/dist/adapters/local-sources.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +1 -0
  3. package/dist/adapters/raw-evidence-facts.js +1 -0
  4. package/dist/adapters/raw-evidence-manifest.js +5 -0
  5. package/dist/adapters/raw-evidence-sanitize.js +82 -20
  6. package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
  7. package/dist/adapters/raw-evidence-transcript-file.js +10 -0
  8. package/dist/agent-rules.js +3 -3
  9. package/dist/commands/agent-door.js +3 -0
  10. package/dist/commands/backfill-lock-wait.js +49 -0
  11. package/dist/commands/backfill-outside-roots.js +97 -0
  12. package/dist/commands/backfill-reasons.js +17 -0
  13. package/dist/commands/backfill-scan.js +8 -3
  14. package/dist/commands/backfill-scope.js +14 -1
  15. package/dist/commands/backfill-session-report.js +3 -0
  16. package/dist/commands/backfill-upload.js +6 -0
  17. package/dist/commands/backfill.js +11 -5
  18. package/dist/commands/cal-calendars-write.js +107 -0
  19. package/dist/commands/cal.js +29 -110
  20. package/dist/commands/docs.js +20 -4
  21. package/dist/commands/doctor-budget.js +69 -0
  22. package/dist/commands/doctor-lock-wait.js +12 -3
  23. package/dist/commands/doctor-pipeline-verdicts.js +2 -0
  24. package/dist/commands/doctor-pipeline.js +5 -2
  25. package/dist/commands/doctor.js +26 -4
  26. package/dist/commands/issue-directory.js +7 -0
  27. package/dist/commands/issue.js +23 -4
  28. package/dist/commands/local-arg-values.js +9 -0
  29. package/dist/commands/local-args-collector-setup.js +4 -2
  30. package/dist/commands/local-args-collector-work.js +9 -1
  31. package/dist/commands/local-help-commands.js +4 -1
  32. package/dist/commands/local-help.js +1 -1
  33. package/dist/commands/local.js +14 -0
  34. package/dist/commands/mail.js +20 -4
  35. package/dist/commands/memory-store.js +27 -10
  36. package/dist/commands/msg.js +25 -5
  37. package/dist/commands/public-root.js +1 -1
  38. package/dist/commands/read-cache.js +115 -0
  39. package/dist/commands/settings.js +17 -2
  40. package/dist/commands/tower-command.js +4 -0
  41. package/dist/cursors/raw-evidence-cursor.js +14 -0
  42. package/dist/tower-client.js +31 -3
  43. package/dist/upload-envelope-build.js +9 -0
  44. package/dist/upload-sync.js +10 -22
  45. package/dist/warm-tower-fetch.js +83 -0
  46. package/package.json +5 -5
@@ -105,6 +105,15 @@ export function optionalPositiveInteger(value, flag) {
105
105
  }
106
106
  return parsed;
107
107
  }
108
+ export function optionalNonNegativeInteger(value, flag) {
109
+ if (value === undefined)
110
+ return undefined;
111
+ const parsed = Number(value);
112
+ if (!Number.isInteger(parsed) || parsed < 0) {
113
+ throw new Error(`${flag} must be a whole number of 0 or more.`);
114
+ }
115
+ return parsed;
116
+ }
108
117
  export function optionalConfidence(value, flag) {
109
118
  if (value === undefined)
110
119
  return undefined;
@@ -142,8 +142,10 @@ export function parseDoctorArgs(alias, args) {
142
142
  return {
143
143
  kind: "doctor",
144
144
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
- backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget") ?? 900,
146
- lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
145
+ // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
+ // and `--json` without either flag runs under one bounded budget.
147
+ backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget"),
148
+ lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait"),
147
149
  alias,
148
150
  homeDir: optionalNonEmpty(values.flags.get("--home")),
149
151
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
@@ -8,7 +8,7 @@
8
8
  * defaults, same refusal sentences.
9
9
  */
10
10
  import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
11
- import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
11
+ import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalNonNegativeInteger, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
12
12
  export function parseStartArgs(args) {
13
13
  const values = parseNamedArgs(args, {
14
14
  allowedFlags: [
@@ -134,6 +134,7 @@ export function parseBackfillArgs(args) {
134
134
  "--max-files",
135
135
  "--max-depth",
136
136
  "--max-repos",
137
+ "--lock-wait",
137
138
  "--yes",
138
139
  "--json",
139
140
  ],
@@ -146,6 +147,7 @@ export function parseBackfillArgs(args) {
146
147
  "--max-files",
147
148
  "--max-depth",
148
149
  "--max-repos",
150
+ "--lock-wait",
149
151
  ],
150
152
  });
151
153
  assertNoPositionals(values.positionals, "backfill");
@@ -169,7 +171,13 @@ export function parseBackfillArgs(args) {
169
171
  maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
170
172
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
171
173
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
174
+ ...lockWaitMs(values.flags.get("--lock-wait")),
172
175
  yes: values.booleans.has("--yes"),
173
176
  json: values.booleans.has("--json"),
174
177
  };
178
+ }
179
+ /** `--lock-wait <seconds>` (0 = try once); absent means the 10-minute default (BLI-11091). */
180
+ function lockWaitMs(value) {
181
+ const seconds = optionalNonNegativeInteger(value, "--lock-wait");
182
+ return seconds === undefined ? {} : { syncLockWaitMs: seconds * 1000 };
175
183
  }
@@ -95,6 +95,7 @@ export function localSubcommandHelp(command) {
95
95
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
96
96
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
97
97
  "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
98
+ "--json without either flag answers within 45 seconds; a repair that does not fit reads doctor_budget_spent.",
98
99
  "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
99
100
  "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
100
101
  ],
@@ -267,7 +268,7 @@ export function localSubcommandHelp(command) {
267
268
  [
268
269
  "backfill",
269
270
  [
270
- "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
271
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
271
272
  "",
272
273
  "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
273
274
  "Omit --source to scan both sources; there is no --source all literal.",
@@ -276,6 +277,8 @@ export function localSubcommandHelp(command) {
276
277
  "Repo discovery scans 3 folder levels and up to 50 repos by default.",
277
278
  "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
278
279
  "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
280
+ "Sessions outside every saved root upload too, labelled outside; only an explicit opt-out withholds a session.",
281
+ "When the scheduled sync holds the collection lock, backfill waits up to --lock-wait seconds (default 600) and exits 1 with sync_lock_held if it never frees.",
279
282
  ],
280
283
  ],
281
284
  [
@@ -109,7 +109,7 @@ export function localCommandHelp(command) {
109
109
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
110
110
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
111
111
  " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
112
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
112
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
113
113
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
114
114
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
115
115
  " cockpit serve [--port <port>] [--workspace <path>]",
@@ -65,6 +65,20 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
65
65
  argv = argv.filter((token) => token !== "--quiet");
66
66
  process.env.COCKPIT_QUIET = "1";
67
67
  }
68
+ // BLI-10746: `--fresh`/`--allow-stale` are global toggles for the
69
+ // stale-while-revalidate read cache, same reason and same shape as
70
+ // `--quiet` above — stripped here so none of the seven cached read
71
+ // commands' own `allowedFlags` needs to learn them, and mirrored onto
72
+ // env vars `read-cache.ts` reads. One `cockpit` process makes at most one
73
+ // cacheable request, so a process-wide toggle is exactly a per-request one.
74
+ if (argv.includes("--fresh")) {
75
+ argv = argv.filter((token) => token !== "--fresh");
76
+ process.env.COCKPIT_READ_FRESH = "1";
77
+ }
78
+ if (argv.includes("--allow-stale")) {
79
+ argv = argv.filter((token) => token !== "--allow-stale");
80
+ process.env.COCKPIT_READ_ALLOW_STALE = "1";
81
+ }
68
82
  if (isLocalHelpRequest(argv)) {
69
83
  writeLine(io.stdout, localCommandHelp(argv[0]));
70
84
  return 0;
@@ -24,6 +24,7 @@ import { writeFile } from "node:fs/promises";
24
24
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
25
25
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
26
  import { readNoteFile } from "./notes-file.js";
27
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
27
28
  import { towerRequest } from "../tower-client.js";
28
29
  const TAG = "[mail cli]";
29
30
  const READ_DEADLINE_MS = 30_000;
@@ -151,17 +152,32 @@ async function inbox(command, door) {
151
152
  if (command.label)
152
153
  query.set("label", command.label);
153
154
  const suffix = query.toString();
154
- const answer = await askAgentDoor(door, {
155
- path: `/api/mail/inbox${suffix ? `?${suffix}` : ""}`,
156
- method: "GET",
155
+ const inboxPath = `/api/mail/inbox${suffix ? `?${suffix}` : ""}`;
156
+ const answer = await readThroughCache({
157
+ io: door.io,
158
+ homeDir: door.homeDir,
159
+ deviceToken: door.deviceToken,
160
+ surface: "mail",
157
161
  label: "mail inbox",
158
- timeoutMs: READ_DEADLINE_MS,
162
+ path: inboxPath,
163
+ json: door.json,
164
+ ask: () => askAgentDoor(door, {
165
+ path: inboxPath,
166
+ method: "GET",
167
+ label: "mail inbox",
168
+ timeoutMs: READ_DEADLINE_MS,
169
+ // BLI-10746: route through the resident memory daemon's warm connection.
170
+ warmRoute: true,
171
+ }),
159
172
  });
160
173
  if (!answer.ok)
161
174
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
162
175
  const body = answer.body;
163
176
  if (door.json)
164
177
  return emitAgentDoor(door, { ok: true, ...body });
178
+ if (answer.fromCache) {
179
+ writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
180
+ }
165
181
  const messages = body.messages ?? [];
166
182
  if (messages.length === 0) {
167
183
  writeLine(door.io.stdout, (body.accounts ?? []).length === 0 ? "No mailboxes attached yet." : "No mail matches that.");
@@ -46,6 +46,7 @@ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.
46
46
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
47
47
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
48
48
  import { runMemoryConsolidation } from "./memory-consolidation.js";
49
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
49
50
  import { runMemoryRequeue } from "./memory-requeue.js";
50
51
  import { readNoteFile } from "./notes-file.js";
51
52
  export { runMemoryVerbList } from "./memory-verb-list.js";
@@ -142,17 +143,30 @@ function containerTagsFor(command) {
142
143
  }
143
144
  async function searchMemory(command, door) {
144
145
  const containerTags = containerTagsFor(command);
145
- const answer = await askAgentDoor(door, {
146
- path: "/api/memory/search",
147
- method: "POST",
146
+ const requestBody = {
147
+ query: command.query,
148
+ containerTag: containerTags,
149
+ ...(command.limit === undefined ? {} : { limit: command.limit }),
150
+ includeProfile: command.includeRecent !== false,
151
+ };
152
+ const answer = await readThroughCache({
153
+ io: door.io,
154
+ homeDir: door.homeDir,
155
+ deviceToken: door.deviceToken,
156
+ surface: "memory",
148
157
  label: "memory search",
149
- timeoutMs: SEARCH_DEADLINE_MS,
150
- body: {
151
- query: command.query,
152
- containerTag: containerTags,
153
- ...(command.limit === undefined ? {} : { limit: command.limit }),
154
- includeProfile: command.includeRecent !== false,
155
- },
158
+ path: "/api/memory/search",
159
+ requestBody,
160
+ json: door.json,
161
+ ask: () => askAgentDoor(door, {
162
+ path: "/api/memory/search",
163
+ method: "POST",
164
+ label: "memory search",
165
+ timeoutMs: SEARCH_DEADLINE_MS,
166
+ // BLI-10746: the one POST this repo treats as a warm-routable read.
167
+ warmRoute: true,
168
+ body: requestBody,
169
+ }),
156
170
  });
157
171
  if (!answer.ok)
158
172
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
@@ -170,6 +184,9 @@ async function searchMemory(command, door) {
170
184
  })}`);
171
185
  if (door.json)
172
186
  return emitAgentDoor(door, answer.body);
187
+ if (answer.fromCache) {
188
+ writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
189
+ }
173
190
  if (results.length === 0 && body.degraded) {
174
191
  // BLI-10448: an empty answer from a search that lost a channel is NOT the
175
192
  // record being silent. `semantic_timed_out` is the store answering on
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
20
21
  const TAG = "[msg cli]";
21
22
  const READ_DEADLINE_MS = 30_000;
22
23
  const WRITE_DEADLINE_MS = 30_000;
@@ -39,15 +40,31 @@ export async function runMsg(command, io) {
39
40
  }
40
41
  }
41
42
  async function fetchChannels(door) {
42
- const answer = await askAgentDoor(door, {
43
- path: "/api/msg/channels",
44
- method: "GET",
43
+ const answer = await readThroughCache({
44
+ io: door.io,
45
+ homeDir: door.homeDir,
46
+ deviceToken: door.deviceToken,
47
+ surface: "msg",
45
48
  label: "msg channels",
46
- timeoutMs: READ_DEADLINE_MS,
49
+ path: "/api/msg/channels",
50
+ json: door.json,
51
+ ask: () => askAgentDoor(door, {
52
+ path: "/api/msg/channels",
53
+ method: "GET",
54
+ label: "msg channels",
55
+ timeoutMs: READ_DEADLINE_MS,
56
+ // BLI-10746: route through the resident memory daemon's warm connection.
57
+ warmRoute: true,
58
+ }),
47
59
  });
48
60
  if (!answer.ok)
49
61
  return { ok: false, reason: answer.reason, detail: answer.detail };
50
- return { ok: true, channels: answer.body.channels ?? [] };
62
+ return {
63
+ ok: true,
64
+ channels: answer.body.channels ?? [],
65
+ fromCache: answer.fromCache,
66
+ ageMs: answer.ageMs,
67
+ };
51
68
  }
52
69
  async function listChannels(door) {
53
70
  const fetched = await fetchChannels(door);
@@ -55,6 +72,9 @@ async function listChannels(door) {
55
72
  return failAgentDoor(door, TAG, fetched.reason, fetched.detail);
56
73
  if (door.json)
57
74
  return emitAgentDoor(door, { ok: true, channels: fetched.channels });
75
+ if (fetched.fromCache) {
76
+ writeLine(door.io.stderr, `(${cacheAgeLabel(fetched.ageMs ?? 0)}, refreshing in the background)`);
77
+ }
58
78
  if (fetched.channels.length === 0) {
59
79
  writeLine(door.io.stdout, "No channels.");
60
80
  return 0;
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.149");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.154");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * STALE-WHILE-REVALIDATE FOR THE SEVEN READ COMMANDS BLI-10746 MEASURED
3
+ * (`work list`, `docs list`, `cal today`, `mail inbox`, `msg channels`,
4
+ * `settings personal`, `memory search`).
5
+ *
6
+ * Text mode: a cache hit prints INSTANTLY (its age travels with it), then a
7
+ * background refresh is spawned so the next call is live — never blocking
8
+ * this process on a network round trip it already has a recent answer for.
9
+ * `--json` (an agent, not a person) trusts cache only under
10
+ * `JSON_FRESH_BOUND_MS`, unless `--allow-stale` says otherwise; older than
11
+ * that runs the live request inline, same as a miss. `--fresh` skips the
12
+ * read entirely — both flags are process-wide (BLI-10450's `--quiet`
13
+ * pattern), stripped from argv and mirrored onto `COCKPIT_READ_FRESH` /
14
+ * `COCKPIT_READ_ALLOW_STALE` by `local.ts`, so none of these seven parsers'
15
+ * own `allowedFlags` needed to learn a new flag.
16
+ *
17
+ * **The background refresh is a whole second `cockpit` process, detached.**
18
+ * Not a lighter internal-only command: it re-runs THIS SAME argv with
19
+ * `--fresh` appended, `stdio: "ignore"`, unref'd. That is deliberately the
20
+ * simplest correct thing — it reuses every bit of behaviour the live path
21
+ * already has (auth, rendering, the warm route) with zero drift risk, at
22
+ * the cost of a process this ticket's "abundant compute" call already
23
+ * accepts (`docs/architecture/repo-map.md`'s standing orders). The PARENT
24
+ * never waits on it, so the terminal returns control the instant the cached
25
+ * answer is printed — a detached child is the only shape that lets a write
26
+ * to disk keep happening after THIS process has already exited (an
27
+ * unref'd but still-open socket dies with the process; a separate process
28
+ * does not).
29
+ *
30
+ * **Any write invalidates.** `tower-client.ts`'s `towerRequest` calls
31
+ * `invalidateReadCacheSurface` (from `@bli-cockpit/telemetry-core`'s
32
+ * `./node-read-cache`, the same module this reads and writes through) on
33
+ * every successful non-GET response whose path names one of the seven
34
+ * surfaces — coarse (the whole surface, not just the one changed row) and
35
+ * correct: a stale row is never possible, only one avoidable extra request.
36
+ */
37
+ import { spawn } from "node:child_process";
38
+ import { readCacheEntryPath, readCacheKey, readReadCacheEntry, writeReadCacheEntryAtomic, } from "@bli-cockpit/telemetry-core/node-read-cache";
39
+ import { writeLine } from "./cli-io.js";
40
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
41
+ /** An agent's `--json` read trusts cache only this fresh, unless `--allow-stale`. */
42
+ export const JSON_FRESH_BOUND_MS = 30_000;
43
+ /** Never spawns a second refresh for an answer this recently written — avoids a pile-up under rapid repeated calls. */
44
+ export const MIN_REFRESH_INTERVAL_MS = 3_000;
45
+ /** `"cached 3s ago"` / `"cached 4m ago"` — the one phrasing every cached read command prints. */
46
+ export function cacheAgeLabel(ageMs) {
47
+ const seconds = Math.max(0, Math.round(ageMs / 1000));
48
+ if (seconds < 60)
49
+ return `cached ${seconds}s ago`;
50
+ const minutes = Math.round(seconds / 60);
51
+ return `cached ${minutes}m ago`;
52
+ }
53
+ export async function readThroughCache(options) {
54
+ const log = (line) => writeLine(options.io.stderr, line);
55
+ const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
56
+ const key = readCacheKey(options.label, options.path, options.requestBody);
57
+ const entryPath = readCacheEntryPath(stateDir, options.deviceToken, options.surface, key);
58
+ const fresh = options.io.env["COCKPIT_READ_FRESH"] === "1";
59
+ const allowStale = options.io.env["COCKPIT_READ_ALLOW_STALE"] === "1";
60
+ if (fresh) {
61
+ log(`[read-cache] bypassed ${JSON.stringify({ surface: options.surface, label: options.label, reason: "fresh_flag" })}`);
62
+ }
63
+ else {
64
+ const cached = readReadCacheEntry(entryPath);
65
+ if (cached) {
66
+ const ageMs = Date.now() - cached.storedAt;
67
+ const trusted = !options.json || ageMs < JSON_FRESH_BOUND_MS || allowStale;
68
+ if (trusted) {
69
+ log(`[read-cache] served ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs, json: options.json })}`);
70
+ if (ageMs > MIN_REFRESH_INTERVAL_MS) {
71
+ spawnBackgroundRefresh(options.io, log, options.spawnImpl);
72
+ }
73
+ else {
74
+ log(`[read-cache] refresh skipped ${JSON.stringify({ reason: "too_recent", age_ms: ageMs })}`);
75
+ }
76
+ return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs };
77
+ }
78
+ log(`[read-cache] too stale for --json ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs })}`);
79
+ }
80
+ else {
81
+ log(`[read-cache] miss ${JSON.stringify({ surface: options.surface, label: options.label })}`);
82
+ }
83
+ }
84
+ const live = await options.ask();
85
+ if (live.ok) {
86
+ writeReadCacheEntryAtomic(entryPath, { status: live.httpStatus, body: live.body });
87
+ log(`[read-cache] refreshed ${JSON.stringify({ surface: options.surface, label: options.label })}`);
88
+ }
89
+ return { ...live, fromCache: false, ageMs: null };
90
+ }
91
+ /**
92
+ * Re-runs THIS process's own argv with `--fresh` appended, detached and
93
+ * discarded. Never awaited, never throws — a refresh that could not be
94
+ * spawned just means the next call finds the same cache age it would have
95
+ * found anyway; it is never this call's failure to report.
96
+ */
97
+ export function spawnBackgroundRefresh(io, log, spawnImpl = spawn) {
98
+ try {
99
+ const args = process.argv
100
+ .slice(2)
101
+ .filter((token) => token !== "--fresh" && token !== "--allow-stale");
102
+ args.push("--fresh");
103
+ const child = spawnImpl(process.execPath, [process.argv[1] ?? "", ...args], {
104
+ detached: true,
105
+ stdio: "ignore",
106
+ windowsHide: true,
107
+ env: io.env,
108
+ });
109
+ child.unref();
110
+ log(`[read-cache] background refresh spawned ${JSON.stringify({ argv_len: args.length })}`);
111
+ }
112
+ catch (error) {
113
+ log(`[read-cache] background refresh failed to spawn ${JSON.stringify({ reason: error instanceof Error ? error.name : "unknown" })}`);
114
+ }
115
+ }
@@ -20,6 +20,7 @@
20
20
  * being written to stdout, stderr, or an argument vector.
21
21
  */
22
22
  import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
23
+ import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
23
24
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
24
25
  import { showOverview } from "./settings-overview.js";
25
26
  import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, } from "./settings-render.js";
@@ -54,12 +55,26 @@ export async function runSettings(command, io) {
54
55
  }
55
56
  // ── personal ──────────────────────────────────────────────────────────
56
57
  async function showPersonal(command, tower, io) {
57
- const result = await callTower(tower, {
58
- path: "/api/settings/personal",
58
+ const result = await readThroughCache({
59
+ io,
60
+ homeDir: tower.homeDir,
61
+ deviceToken: tower.deviceToken,
62
+ surface: "settings",
59
63
  label: "settings personal",
64
+ path: "/api/settings/personal",
65
+ json: command.json,
66
+ ask: () => callTower(tower, {
67
+ path: "/api/settings/personal",
68
+ label: "settings personal",
69
+ // BLI-10746: route through the resident memory daemon's warm connection.
70
+ warmRoute: true,
71
+ }),
60
72
  });
61
73
  if (!result.ok)
62
74
  return writeCommandFailure(io, command.json, result);
75
+ if (result.fromCache && !command.json) {
76
+ writeLine(io.stderr, `(${cacheAgeLabel(result.ageMs ?? 0)}, refreshing in the background)`);
77
+ }
63
78
  return writeBody(io, command.json, result.body, () => renderPersonal(asRecord(result.body)));
64
79
  }
65
80
  async function setPersonal(command, tower, io) {
@@ -33,6 +33,8 @@ export async function openTower(commandName, command, io) {
33
33
  deviceToken: session.device_token,
34
34
  fetch: io.fetch,
35
35
  log: (line) => writeLine(io.stderr, line),
36
+ io,
37
+ homeDir: command.homeDir,
36
38
  };
37
39
  }
38
40
  export async function callTower(context, options) {
@@ -45,6 +47,8 @@ export async function callTower(context, options) {
45
47
  ...(options.body === undefined ? {} : { body: options.body }),
46
48
  timeoutMs: options.timeoutMs ?? REQUEST_DEADLINE_MS,
47
49
  label: options.label,
50
+ ...(options.warmRoute === undefined ? {} : { warmRoute: options.warmRoute }),
51
+ ...(context.homeDir === undefined ? {} : { homeDir: context.homeDir }),
48
52
  log: context.log,
49
53
  });
50
54
  if (result.ok)
@@ -165,10 +165,14 @@ function parseObjectEntry(value) {
165
165
  const committedAt = optionalString(record["committed_at"]);
166
166
  if (!objectKey || !committedAt)
167
167
  return null;
168
+ const sourceKey = optionalString(record["source_key"]);
168
169
  return {
169
170
  object_key: objectKey,
170
171
  byte_size: optionalNumber(record["byte_size"]),
171
172
  committed_at: committedAt,
173
+ ...(sourceKey
174
+ ? { source_key: sourceKey, checkpoints: parseCheckpoints(record["checkpoints"]) }
175
+ : {}),
172
176
  };
173
177
  }
174
178
  function parseSessionEntry(value) {
@@ -193,6 +197,16 @@ function parseSessionEntry(value) {
193
197
  last_seen_at: lastSeenAt,
194
198
  };
195
199
  }
200
+ function parseCheckpoints(value) {
201
+ if (!value || typeof value !== "object")
202
+ return null;
203
+ const out = {};
204
+ for (const [offset, hash] of Object.entries(value)) {
205
+ if (/^\d+$/.test(offset) && typeof hash === "string")
206
+ out[offset] = hash;
207
+ }
208
+ return out;
209
+ }
196
210
  function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
197
211
  return path.join(paths.cursors_dir, filename);
198
212
  }
@@ -23,9 +23,11 @@
23
23
  * `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
24
24
  * the upload path and the conversation path answer to the same rules.
25
25
  */
26
+ import { invalidateReadCacheSurface, readCacheSurfaceForPath, } from "@bli-cockpit/telemetry-core/node-read-cache";
26
27
  import { errorMessage } from "./commands/cli-io.js";
27
28
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "./local-state.js";
28
29
  import { normalizeDashboardUrl, readResponseJson, responseErrorMessage, } from "./upload-http.js";
30
+ import { isWarmRoutableRead, tryWarmTowerFetch } from "./warm-tower-fetch.js";
29
31
  const defaultLog = (line) => {
30
32
  console.error(line);
31
33
  };
@@ -77,6 +79,7 @@ export async function towerRequest(options) {
77
79
  const startedAt = Date.now();
78
80
  const url = `${normalizeDashboardUrl(options.dashboardUrl)}${options.path}`;
79
81
  const isForm = typeof FormData !== "undefined" && options.body instanceof FormData;
82
+ const method = options.method ?? "POST";
80
83
  const headers = {
81
84
  ...(options.headers ?? {}),
82
85
  authorization: `Bearer ${options.deviceToken}`,
@@ -85,16 +88,26 @@ export async function towerRequest(options) {
85
88
  // content-type by hand would strip it and the server would parse nothing.
86
89
  if (options.body !== undefined && !isForm)
87
90
  headers["content-type"] = "application/json";
91
+ const body = options.body === undefined || isForm ? undefined : JSON.stringify(options.body);
88
92
  const init = {
89
- method: options.method ?? "POST",
93
+ method,
90
94
  headers,
91
95
  ...(options.body === undefined
92
96
  ? {}
93
- : { body: (isForm ? options.body : JSON.stringify(options.body)) }),
97
+ : { body: (isForm ? options.body : body) }),
94
98
  ...(timeoutSignal(options.timeoutMs) ?? {}),
95
99
  };
96
100
  try {
97
- const response = await options.fetch(url, init);
101
+ // BLI-10746: a read (GET, or the one POST read this repo has —
102
+ // `memory search`) skips the cold TCP+TLS connect by asking the resident
103
+ // memory daemon to replay it on its own warm dispatcher. `isForm` never
104
+ // holds for a read this repo makes, but the check is here so a future
105
+ // read that DOES carry a form body never gets silently mis-proxied.
106
+ const warmEligible = options.warmRoute === true && !isForm && isWarmRoutableRead(method, options.label);
107
+ const warmResponse = warmEligible
108
+ ? await tryWarmTowerFetch(url, { method, headers, body }, log, { homeDir: options.homeDir })
109
+ : null;
110
+ const response = warmResponse ?? (await options.fetch(url, init));
98
111
  const respondedMs = Date.now() - startedAt;
99
112
  // The success branch always decided something (BLI-10450 kept the
100
113
  // "log the success branch too" rule intact) — it is just that under
@@ -111,6 +124,21 @@ export async function towerRequest(options) {
111
124
  elapsed_ms: respondedMs,
112
125
  })}`);
113
126
  }
127
+ // BLI-10746: "any write through the CLI or MCP invalidates the affected
128
+ // cache". A successful (2xx) write to a covered surface throws away
129
+ // every cached read for that surface — coarse, but a stale row is never
130
+ // possible this way, only one avoidable extra request.
131
+ if (method !== "GET" && response.ok) {
132
+ const surface = readCacheSurfaceForPath(options.path);
133
+ if (surface) {
134
+ const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
135
+ const invalidated = invalidateReadCacheSurface(stateDir, options.deviceToken, surface);
136
+ log(`[tower client] read cache invalidated ${JSON.stringify({
137
+ surface,
138
+ ...(invalidated.ok ? { removed: invalidated.removed } : { reason: invalidated.reason }),
139
+ })}`);
140
+ }
141
+ }
114
142
  return { ok: true, response, startedAt, respondedMs };
115
143
  }
116
144
  catch (error) {
@@ -14,6 +14,7 @@
14
14
  * `upload-session-reports.ts`, which builds its own provenance without a
15
15
  * full envelope.
16
16
  */
17
+ import { priorSnapshotsFromCursor, } from "./adapters/raw-evidence-snapshot-supersede.js";
17
18
  import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
18
19
  import path from "node:path";
19
20
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
@@ -120,6 +121,10 @@ export async function buildLocalAmbientEnvelope(options = {}) {
120
121
  operatorId: session.operator_id,
121
122
  workContextId: uploadContext.work_context_id,
122
123
  }),
124
+ rawEvidencePriorSnapshots: priorSnapshotsFromCursor(transcriptCursorObjects(options.cursorObjects), (objectKey) => rawEvidenceObjectKeyBelongsToWorkContext(objectKey, {
125
+ operatorId: session.operator_id,
126
+ workContextId: uploadContext.work_context_id,
127
+ })),
123
128
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
124
129
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
125
130
  rawEvidenceBudget: options.rawEvidenceBudget,
@@ -188,6 +193,10 @@ function reusableContentHashes(options, context) {
188
193
  .filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
189
194
  .map(([hash]) => hash));
190
195
  }
196
+ /** Cursor entries that remember a transcript source (BLI-11088); the rest are ignored. */
197
+ function transcriptCursorObjects(objects) {
198
+ return Object.fromEntries(Object.entries(objects ?? {}).filter((pair) => "source_key" in pair[1] && Boolean(pair[1].source_key)));
199
+ }
191
200
  export function makeUploadWorkContext(options) {
192
201
  const provenance = makeCollectorProvenance({
193
202
  context: options.activeContext,