@bli-cockpit/cli 0.2.50 → 0.2.52
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.
- package/dist/commands/clean.js +191 -0
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/jarvis.js +101 -9
- package/dist/commands/local-args-collector.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +18 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-contract.js +55 -0
- package/dist/commands/memory-install.js +113 -26
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +95 -2
- package/dist/commands/sync.js +4 -1
- package/dist/disk-prune.js +246 -0
- package/dist/disk-retention.js +157 -0
- package/dist/disk-usage.js +337 -0
- package/dist/log-rotation.js +106 -2
- package/dist/tower-stream.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit clean` — what this machine is holding, why, and what goes.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3619. The tick prunes on its own once a day; this is the door a person
|
|
5
|
+
* opens when a laptop is full now. It prints the same buckets the retention
|
|
6
|
+
* rule reasons in, one line each with its reason, and only then deletes:
|
|
7
|
+
*
|
|
8
|
+
* committed, past the 48 h retry window goes
|
|
9
|
+
* committed, still inside the window kept — unless the cap needs it
|
|
10
|
+
* uncommitted kept, always, and named
|
|
11
|
+
* the ledger cannot say kept, always, and named apart
|
|
12
|
+
*
|
|
13
|
+
* `--dry-run` stops after the printing. `--all-committed` drops the retry
|
|
14
|
+
* window — every committed spare goes now — and is also the only thing that
|
|
15
|
+
* removes a `manual-study-evidence-vault-*` directory, and then only when every
|
|
16
|
+
* object inside it is committed. Nothing here can delete evidence the upload
|
|
17
|
+
* ledger did not vouch for; that line is in `disk-retention.ts` and this
|
|
18
|
+
* command has no way to cross it.
|
|
19
|
+
*/
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { writeLine } from "./cli-io.js";
|
|
24
|
+
import { planFromDisk, runStagingPrune } from "../disk-prune.js";
|
|
25
|
+
import { MANUAL_VAULT_PREFIX, readDiskFootprint, } from "../disk-usage.js";
|
|
26
|
+
import { readRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
27
|
+
import { getCollectorRuntimePaths, } from "../local-state.js";
|
|
28
|
+
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
29
|
+
export async function runClean(command, io) {
|
|
30
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const env = io.env ?? process.env;
|
|
33
|
+
const footprint = await readDiskFootprint(paths, now);
|
|
34
|
+
const plan = await planFromDisk(paths, env, { allCommitted: command.allCommitted }, now);
|
|
35
|
+
const vaults = command.allCommitted
|
|
36
|
+
? await vaultsSafeToRemove(paths, footprint.vaults)
|
|
37
|
+
: [];
|
|
38
|
+
if (!command.json) {
|
|
39
|
+
for (const line of cleanLines(footprint, plan, vaults, command)) {
|
|
40
|
+
writeLine(io.stdout, line);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (command.dryRun) {
|
|
44
|
+
if (command.json)
|
|
45
|
+
writeLine(io.stdout, cleanJson(footprint, plan, vaults, null));
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
const pruned = await runStagingPrune(paths, {
|
|
49
|
+
env,
|
|
50
|
+
now,
|
|
51
|
+
force: true,
|
|
52
|
+
allCommitted: command.allCommitted,
|
|
53
|
+
});
|
|
54
|
+
// The two smaller footprints go with it: a rotation pass sweeps the archives
|
|
55
|
+
// outside the keep window, and each removed vault is named on its own line.
|
|
56
|
+
await rotateCollectorLogsBestEffort(paths);
|
|
57
|
+
const removedVaults = await removeVaults(paths.state_dir, vaults, io);
|
|
58
|
+
if (command.json) {
|
|
59
|
+
writeLine(io.stdout, cleanJson(footprint, plan, vaults, pruned));
|
|
60
|
+
return pruned.status === "fail" ? 1 : 0;
|
|
61
|
+
}
|
|
62
|
+
writeLine(io.stdout, `Freed ${mb(pruned.deleted_bytes)} MB across ${pruned.deleted_files} file(s) and ${pruned.removed_packs} pack(s)${removedVaults > 0 ? `, plus ${removedVaults} evidence vault(s)` : ""}.`);
|
|
63
|
+
if (pruned.cap_blocked_by_uncommitted) {
|
|
64
|
+
writeLine(io.stdout, `Still ${mb(pruned.bytes_after)} MB over a ${mb(pruned.cap_bytes)} MB cap, held by ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for. Run \`cockpit sync\` to try delivering them; nothing here will delete them.`);
|
|
65
|
+
}
|
|
66
|
+
return pruned.status === "fail" ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
/** The bucket table a person reads before anything is deleted. */
|
|
69
|
+
export function cleanLines(footprint, plan, vaults, command) {
|
|
70
|
+
const staging = footprint.staging;
|
|
71
|
+
const lines = [
|
|
72
|
+
command.dryRun ? "Tower clean (dry run)" : "Tower clean",
|
|
73
|
+
` staging ${mb(staging.total_bytes)} MB across ${staging.pack_count} pack(s), ${staging.object_count} file(s)`,
|
|
74
|
+
` going ${mb(plan.deleted_bytes + plan.empty_pack_manifest_bytes)} MB, ${plan.delete.length} file(s) — ${command.allCommitted
|
|
75
|
+
? "every committed copy (--all-committed)"
|
|
76
|
+
: `committed and past the ${Math.round(plan.retention_ms / 3_600_000)} h retry window`}`,
|
|
77
|
+
` kept in window ${mb(plan.kept_in_window.bytes)} MB, ${plan.kept_in_window.count} file(s) — committed, still inside the retry window`,
|
|
78
|
+
` kept uncommitted ${mb(plan.kept_uncommitted.bytes)} MB, ${plan.kept_uncommitted.count} file(s) — never delivered; retention will never delete these`,
|
|
79
|
+
` kept unknown ${mb(plan.kept_unknown.bytes)} MB, ${plan.kept_unknown.count} file(s) — the upload ledger no longer reaches back this far and cannot say`,
|
|
80
|
+
` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))`,
|
|
81
|
+
` spool ${mb(footprint.spool_bytes)} MB`,
|
|
82
|
+
];
|
|
83
|
+
for (const vault of footprint.vaults) {
|
|
84
|
+
const removable = vaults.some((entry) => entry.name === vault.name);
|
|
85
|
+
lines.push(` ${vault.name} ${mb(vault.byte_size)} MB, ${vault.file_count} file(s) — ${removable
|
|
86
|
+
? "every object inside is committed; --all-committed removes it"
|
|
87
|
+
: "a one-off you made by hand; nothing here removes it"}`);
|
|
88
|
+
}
|
|
89
|
+
if (plan.cap_blocked_by_uncommitted) {
|
|
90
|
+
lines.push(` over cap ${mb(plan.bytes_after)} MB against a ${mb(plan.cap_bytes)} MB cap, held by ${plan.cap_blocked_count} object(s) no ledger vouches for (staging_cap_blocked_by_uncommitted)`);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
function cleanJson(footprint, plan, vaults, pruned) {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
staging: {
|
|
97
|
+
pack_count: footprint.staging.pack_count,
|
|
98
|
+
object_count: footprint.staging.object_count,
|
|
99
|
+
total_bytes: footprint.staging.total_bytes,
|
|
100
|
+
committed_bytes: footprint.staging.committed_bytes,
|
|
101
|
+
uncommitted_bytes: footprint.staging.uncommitted_bytes,
|
|
102
|
+
unknown_bytes: footprint.staging.unknown_bytes,
|
|
103
|
+
oldest_uncommitted_at: footprint.staging.oldest_uncommitted_at,
|
|
104
|
+
},
|
|
105
|
+
rotated_log_bytes: footprint.logs.total_bytes,
|
|
106
|
+
spool_bytes: footprint.spool_bytes,
|
|
107
|
+
vaults: footprint.vaults,
|
|
108
|
+
plan: {
|
|
109
|
+
delete_count: plan.delete.length,
|
|
110
|
+
delete_bytes: plan.deleted_bytes + plan.empty_pack_manifest_bytes,
|
|
111
|
+
kept_in_window: plan.kept_in_window,
|
|
112
|
+
kept_uncommitted: plan.kept_uncommitted,
|
|
113
|
+
kept_unknown: plan.kept_unknown,
|
|
114
|
+
cap_bytes: plan.cap_bytes,
|
|
115
|
+
cap_blocked_by_uncommitted: plan.cap_blocked_by_uncommitted,
|
|
116
|
+
},
|
|
117
|
+
removable_vaults: vaults.map((vault) => vault.name),
|
|
118
|
+
pruned,
|
|
119
|
+
}, null, 2);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A hand-made evidence vault may only go when EVERY object inside it is
|
|
123
|
+
* committed. The vault is not a pack — it has no manifest — so each file is
|
|
124
|
+
* hashed and looked up. That is why it is on the explicit `--all-committed`
|
|
125
|
+
* door and never on the tick.
|
|
126
|
+
*/
|
|
127
|
+
async function vaultsSafeToRemove(paths, vaults) {
|
|
128
|
+
if (vaults.length === 0)
|
|
129
|
+
return [];
|
|
130
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
131
|
+
const committed = new Set(Object.keys(cursor.objects));
|
|
132
|
+
const safe = [];
|
|
133
|
+
for (const vault of vaults) {
|
|
134
|
+
const dir = path.join(paths.state_dir, vault.name);
|
|
135
|
+
if (await everyFileCommitted(dir, committed))
|
|
136
|
+
safe.push(vault);
|
|
137
|
+
}
|
|
138
|
+
return safe;
|
|
139
|
+
}
|
|
140
|
+
async function everyFileCommitted(dir, committed) {
|
|
141
|
+
const stack = [dir];
|
|
142
|
+
let sawFile = false;
|
|
143
|
+
while (stack.length > 0) {
|
|
144
|
+
const current = stack.pop();
|
|
145
|
+
if (!current)
|
|
146
|
+
continue;
|
|
147
|
+
const entries = await fs
|
|
148
|
+
.readdir(current, { withFileTypes: true })
|
|
149
|
+
.catch(() => []);
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
const full = path.join(current, entry.name);
|
|
152
|
+
if (entry.isDirectory()) {
|
|
153
|
+
stack.push(full);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!entry.isFile())
|
|
157
|
+
continue;
|
|
158
|
+
sawFile = true;
|
|
159
|
+
const bytes = await fs.readFile(full).catch(() => null);
|
|
160
|
+
if (!bytes)
|
|
161
|
+
return false;
|
|
162
|
+
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
163
|
+
if (!committed.has(digest))
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return sawFile;
|
|
168
|
+
}
|
|
169
|
+
async function removeVaults(stateDir, vaults, io) {
|
|
170
|
+
let removed = 0;
|
|
171
|
+
for (const vault of vaults) {
|
|
172
|
+
if (!vault.name.startsWith(MANUAL_VAULT_PREFIX))
|
|
173
|
+
continue;
|
|
174
|
+
const done = await fs
|
|
175
|
+
.rm(path.join(stateDir, vault.name), { recursive: true, force: true })
|
|
176
|
+
.then(() => true, () => false);
|
|
177
|
+
if (!done)
|
|
178
|
+
continue;
|
|
179
|
+
removed += 1;
|
|
180
|
+
writeLine(io.stderr, `[collector prune] removed an evidence vault ${JSON.stringify({
|
|
181
|
+
reason: "vault_all_committed",
|
|
182
|
+
vault: vault.name,
|
|
183
|
+
byte_size: vault.byte_size,
|
|
184
|
+
file_count: vault.file_count,
|
|
185
|
+
})}`);
|
|
186
|
+
}
|
|
187
|
+
return removed;
|
|
188
|
+
}
|
|
189
|
+
function mb(bytes) {
|
|
190
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
191
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -9,6 +9,9 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSIO
|
|
|
9
9
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
10
10
|
import { detectSecondCockpitInstall } from "../second-install.js";
|
|
11
11
|
import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
|
|
12
|
+
import { runStagingPrune } from "../disk-prune.js";
|
|
13
|
+
import { retentionOptionsFromEnv } from "../disk-retention.js";
|
|
14
|
+
import { readDiskFootprint } from "../disk-usage.js";
|
|
12
15
|
import { runBackfillCommand } from "./backfill.js";
|
|
13
16
|
import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
|
|
14
17
|
import { createInteractiveExecRunner } from "../process-runner.js";
|
|
@@ -100,6 +103,15 @@ function doctorInvariants() {
|
|
|
100
103
|
check: (context) => context.deps.checkGc(context),
|
|
101
104
|
fix: (context, _state) => context.deps.fixGc(context),
|
|
102
105
|
},
|
|
106
|
+
// BLI-3619. Runs after the GC because it answers the question the GC
|
|
107
|
+
// cannot: the GC only removes a whole pack whose every file is committed,
|
|
108
|
+
// so one undelivered file kept 20 GB alive on the reference Mac. This row
|
|
109
|
+
// reads the disk per FILE against the upload ledger and says what is on it.
|
|
110
|
+
{
|
|
111
|
+
id: "disk-bounded",
|
|
112
|
+
check: (context) => context.deps.checkDisk(context),
|
|
113
|
+
fix: (context, _state) => context.deps.fixDisk(context),
|
|
114
|
+
},
|
|
103
115
|
{
|
|
104
116
|
id: "sync-fresh",
|
|
105
117
|
check: (context) => context.deps.checkSync(context),
|
|
@@ -126,6 +138,8 @@ function defaultDoctorDeps(hooks) {
|
|
|
126
138
|
fixBackfill: fixBackfillState,
|
|
127
139
|
checkGc: checkGcState,
|
|
128
140
|
fixGc: fixGcState,
|
|
141
|
+
checkDisk: checkDiskState,
|
|
142
|
+
fixDisk: fixDiskState,
|
|
129
143
|
checkSync: checkSyncState,
|
|
130
144
|
fixSync: fixSyncState,
|
|
131
145
|
};
|
|
@@ -449,6 +463,60 @@ async function fixGcState(context) {
|
|
|
449
463
|
}
|
|
450
464
|
return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
|
|
451
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* BLI-3619: what Cockpit is holding on this machine, in one line.
|
|
468
|
+
*
|
|
469
|
+
* Green means the staging tree fits under the cap. Over the cap is `needs_fix`
|
|
470
|
+
* and the fix is the same prune the tick runs, forced past its daily throttle;
|
|
471
|
+
* if it is still over afterwards, the message names the one command that goes
|
|
472
|
+
* further. Every number here is metadata — byte counts and file counts, never
|
|
473
|
+
* a path.
|
|
474
|
+
*/
|
|
475
|
+
async function checkDiskState(context) {
|
|
476
|
+
const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
|
|
477
|
+
const { capBytes } = retentionOptionsFromEnv(context.io.env);
|
|
478
|
+
const message = diskRowMessage(footprint, capBytes);
|
|
479
|
+
return footprint.staging.total_bytes > capBytes
|
|
480
|
+
? needsFix("disk-bounded", "over_staging_cap", message)
|
|
481
|
+
: ok("disk-bounded", "within_staging_cap", message);
|
|
482
|
+
}
|
|
483
|
+
async function fixDiskState(context) {
|
|
484
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
485
|
+
const pruned = await runStagingPrune(paths, {
|
|
486
|
+
env: context.io.env,
|
|
487
|
+
force: true,
|
|
488
|
+
});
|
|
489
|
+
if (pruned.status === "fail") {
|
|
490
|
+
return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
|
|
491
|
+
}
|
|
492
|
+
if (pruned.status === "skipped") {
|
|
493
|
+
return skipped("disk-bounded", pruned.reason, "cleanup is switched off");
|
|
494
|
+
}
|
|
495
|
+
const footprint = await readDiskFootprint(paths);
|
|
496
|
+
const message = diskRowMessage(footprint, pruned.cap_bytes);
|
|
497
|
+
if (!pruned.cap_blocked_by_uncommitted) {
|
|
498
|
+
return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
|
|
499
|
+
}
|
|
500
|
+
// Deliberately still `ok`: staging over the cap because evidence has not been
|
|
501
|
+
// accepted yet is the collector working, not a machine to repair. The row
|
|
502
|
+
// names the blockage and the one command that goes further.
|
|
503
|
+
return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — run \`cockpit sync\` to deliver them, or \`cockpit clean --all-committed\` to drop every accepted copy now`);
|
|
504
|
+
}
|
|
505
|
+
function diskRowMessage(footprint, capBytes) {
|
|
506
|
+
const staging = footprint.staging;
|
|
507
|
+
const parts = [
|
|
508
|
+
`staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
|
|
509
|
+
`logs ${mib(footprint.logs.total_bytes)} MB`,
|
|
510
|
+
`spool ${mib(footprint.spool_bytes)} MB`,
|
|
511
|
+
];
|
|
512
|
+
for (const vault of footprint.vaults) {
|
|
513
|
+
parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
|
|
514
|
+
}
|
|
515
|
+
return parts.join("; ");
|
|
516
|
+
}
|
|
517
|
+
function mib(bytes) {
|
|
518
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
519
|
+
}
|
|
452
520
|
async function checkSyncState(context) {
|
|
453
521
|
const roots = await doctorRoots(context);
|
|
454
522
|
if (roots.length === 0) {
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -102,7 +102,10 @@ export async function runJarvis(command, io) {
|
|
|
102
102
|
const promptStartedAt = Date.now();
|
|
103
103
|
const oneShotPrompt = await resolveOneShotPrompt(command, io);
|
|
104
104
|
if (oneShotPrompt !== null) {
|
|
105
|
-
|
|
105
|
+
// A one-shot turn has nothing above it — this process printed no earlier
|
|
106
|
+
// answer — so it sends no previous answer and nothing is ever revised. The
|
|
107
|
+
// `sent_previous_answer: false` field on the answered line says so.
|
|
108
|
+
const turn = await sendOneTurn({
|
|
106
109
|
command,
|
|
107
110
|
dashboardUrl,
|
|
108
111
|
deviceToken: session.device_token,
|
|
@@ -114,27 +117,38 @@ export async function runJarvis(command, io) {
|
|
|
114
117
|
promptMs: command.prompt ? null : Date.now() - promptStartedAt,
|
|
115
118
|
},
|
|
116
119
|
}, oneShotPrompt, io);
|
|
120
|
+
return turn.exitCode;
|
|
117
121
|
}
|
|
118
122
|
if (command.json) {
|
|
119
123
|
throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
|
|
120
124
|
}
|
|
121
125
|
writeLine(io.stdout, "JARVIS terminal chat. Type /exit to leave.");
|
|
126
|
+
// BLI-3567: the answer this session last printed, and the question it
|
|
127
|
+
// answered. It is what a correction typed on the next line can rewrite —
|
|
128
|
+
// kept here rather than read back off the ledger, for the same reason the
|
|
129
|
+
// browsers send their own bubble: what has to be revised is what is on the
|
|
130
|
+
// person's screen. A failed turn deliberately does not replace it; a "that
|
|
131
|
+
// did not go through" line is not an answer.
|
|
132
|
+
let previous = null;
|
|
122
133
|
while (true) {
|
|
123
134
|
const prompt = (await readLine(io, "you> ")).trim();
|
|
124
135
|
if (!prompt)
|
|
125
136
|
continue;
|
|
126
137
|
if (prompt === "/exit" || prompt === "/quit")
|
|
127
138
|
return 0;
|
|
128
|
-
const
|
|
139
|
+
const turn = await sendOneTurn({
|
|
129
140
|
command,
|
|
130
141
|
dashboardUrl,
|
|
131
142
|
deviceToken: session.device_token,
|
|
132
143
|
// The typing wait belongs to the person, not to the turn, so no prompt
|
|
133
144
|
// span is reported for an interactive turn.
|
|
134
145
|
boot: { bootMs, sessionMs, promptMs: null },
|
|
146
|
+
previous,
|
|
135
147
|
}, prompt, io);
|
|
136
|
-
if (exitCode !== 0)
|
|
137
|
-
return exitCode;
|
|
148
|
+
if (turn.exitCode !== 0)
|
|
149
|
+
return turn.exitCode;
|
|
150
|
+
if (turn.reply)
|
|
151
|
+
previous = { answer: turn.reply, question: prompt };
|
|
138
152
|
}
|
|
139
153
|
}
|
|
140
154
|
/**
|
|
@@ -242,7 +256,7 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
242
256
|
const read = await readAttachedImage(context.command.imagePath);
|
|
243
257
|
if (!read.ok) {
|
|
244
258
|
writeAttachmentRefusal(context.command, io, read.refusal, context.command.imagePath);
|
|
245
|
-
return 1;
|
|
259
|
+
return { exitCode: 1, reply: null };
|
|
246
260
|
}
|
|
247
261
|
attachment = read;
|
|
248
262
|
}
|
|
@@ -277,12 +291,18 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
277
291
|
// BLI-3484: which day's page this turn is about. Sent verbatim — the
|
|
278
292
|
// dashboard decides what counts as a date and whose day it is.
|
|
279
293
|
date: context.command.date,
|
|
294
|
+
// BLI-3567: the answer this session last printed, so a correction can
|
|
295
|
+
// rewrite the paragraph it contradicts. Absent on a one-shot turn and
|
|
296
|
+
// on the first turn of a session, which is how the dashboard knows
|
|
297
|
+
// there is nothing above to revise.
|
|
298
|
+
previousAnswer: context.previous?.answer,
|
|
299
|
+
previousQuestion: context.previous?.question,
|
|
280
300
|
},
|
|
281
301
|
log,
|
|
282
302
|
});
|
|
283
303
|
if (!requested.ok) {
|
|
284
304
|
writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
|
|
285
|
-
return 1;
|
|
305
|
+
return { exitCode: 1, reply: null };
|
|
286
306
|
}
|
|
287
307
|
// Live trace lines go to a person as they land, never to a `--json`
|
|
288
308
|
// consumer: that contract is exactly one object on stdout, so the events are
|
|
@@ -302,6 +322,17 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
302
322
|
liveTraceLines += 1;
|
|
303
323
|
},
|
|
304
324
|
onToken: (event) => live.token(event),
|
|
325
|
+
// BLI-3567: the paragraph being rewritten says so while it happens. The
|
|
326
|
+
// terminal cannot mark the paragraph itself — it printed it turns ago — so
|
|
327
|
+
// the pending state is one dim line, in the same words the browsers use.
|
|
328
|
+
onRevision: (event) => {
|
|
329
|
+
if (context.command.json)
|
|
330
|
+
return;
|
|
331
|
+
if (event.revision?.status !== "pending")
|
|
332
|
+
return;
|
|
333
|
+
live.interrupt();
|
|
334
|
+
writeLine(io.stdout, dim(` ${ANSWER_UPDATING_LINE}`, colorEnabled(io)));
|
|
335
|
+
},
|
|
305
336
|
onNote: (reason, detail) => {
|
|
306
337
|
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
307
338
|
reason,
|
|
@@ -312,7 +343,7 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
312
343
|
if (!turn.ok) {
|
|
313
344
|
live.abandon();
|
|
314
345
|
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
315
|
-
return 1;
|
|
346
|
+
return { exitCode: 1, reply: null };
|
|
316
347
|
}
|
|
317
348
|
const body = turn.final;
|
|
318
349
|
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
@@ -320,7 +351,7 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
320
351
|
live.abandon();
|
|
321
352
|
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
322
353
|
writeFailure(context.command, io, "turn_failed", reason);
|
|
323
|
-
return 1;
|
|
354
|
+
return { exitCode: 1, reply: null };
|
|
324
355
|
}
|
|
325
356
|
// A streaming server may leave the settled trace out of the final event
|
|
326
357
|
// because it already sent every step live; the activity we collected is that
|
|
@@ -366,6 +397,10 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
366
397
|
// through `writeReply`, which is byte-for-byte what this command printed
|
|
367
398
|
// before, BLI-3570's dim link line included.
|
|
368
399
|
live.settle(body.reply, subject, body.revised === true);
|
|
400
|
+
// BLI-3567: what the correction did to the answer above, printed under
|
|
401
|
+
// this turn's reply because that is the only place a terminal has.
|
|
402
|
+
if (body.revision)
|
|
403
|
+
writeRevision(context.command, io, body.revision);
|
|
369
404
|
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
370
405
|
if (liveTraceLines === 0)
|
|
371
406
|
writeTraceBlock(io, trace);
|
|
@@ -389,6 +424,13 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
389
424
|
// dashboard means the answer landed all at once.
|
|
390
425
|
streamed_chars: turn.draft.length,
|
|
391
426
|
revised: body.revised === true,
|
|
427
|
+
// BLI-3567: whether this turn was told there was an answer above it, and
|
|
428
|
+
// what the correction did to it. `sent_previous_answer: false` is a
|
|
429
|
+
// one-shot turn; a status with no revision at all is a dashboard that
|
|
430
|
+
// predates the step.
|
|
431
|
+
sent_previous_answer: Boolean(context.previous),
|
|
432
|
+
revision: body.revision?.status ?? null,
|
|
433
|
+
revision_reason: body.revision?.reason ?? null,
|
|
392
434
|
// BLI-3582: `elapsed_ms` above is the whole wait as this side felt it;
|
|
393
435
|
// these three say which part of it was the dashboard's prep, which was
|
|
394
436
|
// the model's first token, and how long the socket stayed silent before
|
|
@@ -406,7 +448,7 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
406
448
|
timing: turn.timing,
|
|
407
449
|
}),
|
|
408
450
|
})}`);
|
|
409
|
-
return 0;
|
|
451
|
+
return { exitCode: 0, reply: body.reply };
|
|
410
452
|
}
|
|
411
453
|
/**
|
|
412
454
|
* The terminal's own spans as log fields (BLI-3591).
|
|
@@ -528,6 +570,56 @@ function createLiveAnswer(command, io) {
|
|
|
528
570
|
* not exist.
|
|
529
571
|
*/
|
|
530
572
|
const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
|
|
573
|
+
/**
|
|
574
|
+
* The three lines a correction's rewrite prints (BLI-3567).
|
|
575
|
+
*
|
|
576
|
+
* The browsers replace the paragraph where it stands. A terminal cannot —
|
|
577
|
+
* stdout is a river — so it says the same three things in sequence instead:
|
|
578
|
+
* that a rewrite is under way, what the paragraph now says, and that the
|
|
579
|
+
* printed one above it did not change. The last of those is the honest half:
|
|
580
|
+
* without it a person would have two versions on screen and no idea which one
|
|
581
|
+
* JARVIS believes.
|
|
582
|
+
*
|
|
583
|
+
* `ANSWER_UPDATING_LINE` is the terminal's copy of the browsers'
|
|
584
|
+
* `ANSWER_UPDATING_LABEL` (`apps/dashboard/src/lib/webchat/tool-trace.ts`),
|
|
585
|
+
* word for word. This package cannot import from the dashboard, so it is
|
|
586
|
+
* written out here rather than shared through a dependency that does not exist
|
|
587
|
+
* — the same arrangement `ANSWER_REVISED_LINE` above already has.
|
|
588
|
+
*/
|
|
589
|
+
const ANSWER_UPDATING_LINE = "(updating...)";
|
|
590
|
+
const ANSWER_CORRECTED_LINE = "revised after your correction";
|
|
591
|
+
const ANSWER_CORRECTED_NOTE_LINE = "The paragraph printed above is unchanged — a terminal cannot rewrite what it already " +
|
|
592
|
+
"printed. This is what it says now.";
|
|
593
|
+
const ANSWER_REWRITE_UNVERIFIED_LINE = "Your correction is recorded. I could not verify a rewrite of that paragraph against its " +
|
|
594
|
+
"sources, so it stays as it was.";
|
|
595
|
+
/**
|
|
596
|
+
* Print what a correction did to the answer above, once the turn has settled.
|
|
597
|
+
*
|
|
598
|
+
* `--json` prints nothing: that contract is exactly one object on stdout, and
|
|
599
|
+
* the revision rides it as a field. Every branch either prints or is a
|
|
600
|
+
* deliberate no-op with a stderr line behind it in `sendOneTurn`.
|
|
601
|
+
*/
|
|
602
|
+
function writeRevision(command, io, revision) {
|
|
603
|
+
if (command.json)
|
|
604
|
+
return;
|
|
605
|
+
const styled = colorEnabled(io);
|
|
606
|
+
if (revision.status === "unverified") {
|
|
607
|
+
writeLine(io.stdout, "");
|
|
608
|
+
writeLine(io.stdout, dim(` — ${ANSWER_REWRITE_UNVERIFIED_LINE}`, styled));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (revision.status !== "revised")
|
|
612
|
+
return;
|
|
613
|
+
const paragraphs = Array.isArray(revision.paragraphs) ? revision.paragraphs : [];
|
|
614
|
+
const written = paragraphs.filter((one) => typeof one === "object" && one !== null && typeof one.text === "string");
|
|
615
|
+
if (written.length === 0)
|
|
616
|
+
return;
|
|
617
|
+
writeLine(io.stdout, "");
|
|
618
|
+
writeLine(io.stdout, dim(` — ${ANSWER_CORRECTED_LINE} —`, styled));
|
|
619
|
+
for (const paragraph of written)
|
|
620
|
+
writeLine(io.stdout, paragraph.text);
|
|
621
|
+
writeLine(io.stdout, dim(` ${ANSWER_CORRECTED_NOTE_LINE}`, styled));
|
|
622
|
+
}
|
|
531
623
|
/**
|
|
532
624
|
* The multipart body `/api/jarvis/cli` reads when a file is attached
|
|
533
625
|
* (BLI-3414) — same field names the request handler parses, mirroring the
|
|
@@ -596,6 +596,27 @@ export function parseMemoryArgs(args) {
|
|
|
596
596
|
json: values.booleans.has("--json"),
|
|
597
597
|
};
|
|
598
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* BLI-3619. Deliberately takes no positional: `clean` is one verb, and the two
|
|
601
|
+
* flags say how far it goes. A path is never accepted — the only directory this
|
|
602
|
+
* command may touch is the collector's own state directory.
|
|
603
|
+
*/
|
|
604
|
+
export function parseCleanArgs(args) {
|
|
605
|
+
const values = parseNamedArgs(args, {
|
|
606
|
+
allowedFlags: ["--home", "--dry-run", "--all-committed", "--json"],
|
|
607
|
+
valueFlags: ["--home"],
|
|
608
|
+
});
|
|
609
|
+
if (values.positionals.length > 0) {
|
|
610
|
+
throw new Error("clean takes no arguments; use --dry-run or --all-committed.");
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
kind: "clean",
|
|
614
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
615
|
+
dryRun: values.booleans.has("--dry-run"),
|
|
616
|
+
allCommitted: values.booleans.has("--all-committed"),
|
|
617
|
+
json: values.booleans.has("--json"),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
599
620
|
function parseAgentRulesHost(value) {
|
|
600
621
|
const host = value?.trim().toLowerCase() || "all";
|
|
601
622
|
if (host === "codex" || host === "claude" || host === "all")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
1
|
+
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
2
2
|
import { parseBriefArgs, parseCorrectArgs, parseJarvisArgs, parseModelArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
3
3
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
4
4
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
@@ -82,6 +82,8 @@ export function parseLocalArgs(argv) {
|
|
|
82
82
|
return parseAgentRulesArgs(argv.slice(1));
|
|
83
83
|
case "memory":
|
|
84
84
|
return parseMemoryArgs(argv.slice(1));
|
|
85
|
+
case "clean":
|
|
86
|
+
return parseCleanArgs(argv.slice(1));
|
|
85
87
|
case "release":
|
|
86
88
|
return parseReleaseArgs(argv.slice(1));
|
|
87
89
|
default:
|
|
@@ -39,6 +39,7 @@ export const rootCommandNames = new Set([
|
|
|
39
39
|
"autostart",
|
|
40
40
|
"agent-rules",
|
|
41
41
|
"memory",
|
|
42
|
+
"clean",
|
|
42
43
|
"release",
|
|
43
44
|
]);
|
|
44
45
|
export function localCommandHelp(command) {
|
|
@@ -76,6 +77,7 @@ export function localCommandHelp(command) {
|
|
|
76
77
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
77
78
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
78
79
|
" cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
80
|
+
" cockpit clean [--dry-run] [--all-committed] [--json]",
|
|
79
81
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
80
82
|
"",
|
|
81
83
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
@@ -546,6 +548,22 @@ function localSubcommandHelp(command) {
|
|
|
546
548
|
"Action defaults to `install`. See docs/runbooks/bli-memory-install.md.",
|
|
547
549
|
],
|
|
548
550
|
],
|
|
551
|
+
[
|
|
552
|
+
"clean",
|
|
553
|
+
[
|
|
554
|
+
"Usage: cockpit clean [--dry-run] [--all-committed] [--json]",
|
|
555
|
+
"",
|
|
556
|
+
"Reclaims this machine's disk. Prints what it would delete and why, per bucket,",
|
|
557
|
+
"then deletes: staged evidence Tower has already accepted goes after a 48-hour",
|
|
558
|
+
"retry window, and anything the upload ledger has NOT vouched for is kept,",
|
|
559
|
+
"counted and named — retention never deletes undelivered evidence.",
|
|
560
|
+
"--dry-run stops after the printing. --all-committed drops the retry window,",
|
|
561
|
+
"so every already-accepted copy goes now, and is the only thing that removes a",
|
|
562
|
+
"manual-study-evidence-vault folder (and only when every file in it is accepted).",
|
|
563
|
+
"The scheduled sync does the same prune once a day; this is the door for a",
|
|
564
|
+
"laptop that is full right now.",
|
|
565
|
+
],
|
|
566
|
+
],
|
|
549
567
|
[
|
|
550
568
|
"release",
|
|
551
569
|
[
|
package/dist/commands/local.js
CHANGED
|
@@ -31,6 +31,7 @@ import { runServe } from "./serve.js";
|
|
|
31
31
|
import { runAutostart } from "./autostart-command.js";
|
|
32
32
|
import { runAgentRules } from "./agent-rules-command.js";
|
|
33
33
|
import { runMemoryInstall } from "./memory-install.js";
|
|
34
|
+
import { runClean } from "./clean.js";
|
|
34
35
|
import { parseLocalArgs } from "./local-args.js";
|
|
35
36
|
// `./local.js` is the published entry point for this command surface: the
|
|
36
37
|
// public CLI's generated root, commands/root.ts, doctor.ts and the test suite
|
|
@@ -128,6 +129,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
128
129
|
return await runAgentRules(command, io);
|
|
129
130
|
case "memory":
|
|
130
131
|
return await runMemoryInstall(command, io);
|
|
132
|
+
case "clean":
|
|
133
|
+
return await runClean(command, io);
|
|
131
134
|
case "release":
|
|
132
135
|
return await runRelease(command, io);
|
|
133
136
|
}
|
|
@@ -42,6 +42,13 @@
|
|
|
42
42
|
* env = { COCKPIT_DASHBOARD_URL = "https://…" }
|
|
43
43
|
* ```
|
|
44
44
|
*
|
|
45
|
+
* **The printed `command` is a bare name and is never written as-is.** The bin
|
|
46
|
+
* cannot know where it was installed, and it lives nested inside
|
|
47
|
+
* `@bli-cockpit/cli`'s `node_modules` rather than on PATH — so
|
|
48
|
+
* `withResolvedBinPath` re-points every command at the absolute path this
|
|
49
|
+
* machine resolved before anything is written. Skipping that step registered
|
|
50
|
+
* three hooks that answered `command not found` on every turn.
|
|
51
|
+
*
|
|
45
52
|
* Nothing here touches the filesystem. The halves that do are
|
|
46
53
|
* `memory-install-claude.ts` and `memory-install-codex.ts`.
|
|
47
54
|
*/
|
|
@@ -207,6 +214,54 @@ export function parsePrintedMemoryInstallConfig(stdout) {
|
|
|
207
214
|
permissions_allow: allow ?? [...MEMORY_AUTO_APPROVE_TOOLS],
|
|
208
215
|
};
|
|
209
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Put THIS machine's resolved bin path into a config the bin printed.
|
|
219
|
+
*
|
|
220
|
+
* The printed contract says `command: "bli-memory-mcp"` — a bare name, because
|
|
221
|
+
* the bin has no idea where it was installed. `bli-memory-mcp` is a DEPENDENCY
|
|
222
|
+
* of `@bli-cockpit/cli`, nested inside its `node_modules`, and therefore **not
|
|
223
|
+
* on anybody's PATH**: a host handed the bare name answers `command not found`
|
|
224
|
+
* on every SessionStart, every prompt and every Stop. The installer is the only
|
|
225
|
+
* thing that knows the absolute path, so path qualification happens here and
|
|
226
|
+
* the printed strings are never written verbatim (BLI-3580; found by running
|
|
227
|
+
* the registered hook command in a simulated global install, not by reading
|
|
228
|
+
* the code).
|
|
229
|
+
*
|
|
230
|
+
* What the printed config still owns: which hook EVENTS exist, their
|
|
231
|
+
* subcommands, their timeouts, the server id and the allow-list. What this
|
|
232
|
+
* function owns: where the program is, and the per-platform launch shape.
|
|
233
|
+
*
|
|
234
|
+
* Returns null when a printed hook command cannot be understood well enough to
|
|
235
|
+
* re-point — the caller then falls back to the built-in template, which is
|
|
236
|
+
* always path-qualified. Guessing at a command we cannot parse would write a
|
|
237
|
+
* hook that runs something else.
|
|
238
|
+
*/
|
|
239
|
+
export function withResolvedBinPath(config, options) {
|
|
240
|
+
const quoted = shellQuoteBinPath(options.binPath);
|
|
241
|
+
const hooks = [];
|
|
242
|
+
for (const hook of config.hooks) {
|
|
243
|
+
const tail = hookSubcommandTail(hook.command);
|
|
244
|
+
if (!tail)
|
|
245
|
+
return null;
|
|
246
|
+
hooks.push({ ...hook, command: `${quoted} ${tail}` });
|
|
247
|
+
}
|
|
248
|
+
const entry = memoryMcpServerEntry(options);
|
|
249
|
+
return {
|
|
250
|
+
...config,
|
|
251
|
+
// The bin's own env wins nothing and loses nothing: it prints `{}`, and the
|
|
252
|
+
// dashboard URL is a machine fact the installer holds.
|
|
253
|
+
mcp_server: { ...entry, env: { ...config.mcp_server.env, ...entry.env } },
|
|
254
|
+
hooks,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* `…/bli-memory-mcp hook prompt` → `hook prompt`. The subcommand is the part we
|
|
259
|
+
* keep; everything before it is a path that may be wrong for this machine.
|
|
260
|
+
*/
|
|
261
|
+
function hookSubcommandTail(command) {
|
|
262
|
+
const match = /(^|\s)(hook\s+\S+.*)$/u.exec(command.trim());
|
|
263
|
+
return match?.[2]?.trim() ?? null;
|
|
264
|
+
}
|
|
210
265
|
function normalizeStringArray(value) {
|
|
211
266
|
if (value === undefined)
|
|
212
267
|
return [];
|