@bli-cockpit/cli 0.2.81 → 0.2.82
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.
|
@@ -31,6 +31,7 @@ import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
|
31
31
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
32
32
|
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
33
33
|
import { readMemoryHookCounts } from "./memory-hook-counts.js";
|
|
34
|
+
import { readMemoryHookPerformance } from "./memory-hook-performance.js";
|
|
34
35
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
35
36
|
/**
|
|
36
37
|
* The label form of one approved root.
|
|
@@ -191,11 +192,16 @@ export async function readHeartbeatMemoryReceipt(options) {
|
|
|
191
192
|
homeDir: options.homeDir ?? os.homedir(),
|
|
192
193
|
...(options.now ? { now: options.now } : {}),
|
|
193
194
|
});
|
|
195
|
+
const performance = await readMemoryHookPerformance({
|
|
196
|
+
homeDir: options.homeDir ?? os.homedir(),
|
|
197
|
+
...(options.now ? { now: options.now } : {}),
|
|
198
|
+
});
|
|
194
199
|
return {
|
|
195
200
|
receipt: {
|
|
196
201
|
...result.receipt,
|
|
197
202
|
...(hooks.counts ?? {}),
|
|
198
203
|
hook_stats_reason: hooks.reason,
|
|
204
|
+
hook_performance: performance,
|
|
199
205
|
},
|
|
200
206
|
reason: result.reason,
|
|
201
207
|
};
|
|
@@ -321,6 +321,9 @@ export function localSubcommandHelp(command) {
|
|
|
321
321
|
" --memory-days N changes the window (1-30) and implies --memory. Searches are",
|
|
322
322
|
" NOT counted — nothing in Tower records one, and the gauge says so rather than",
|
|
323
323
|
" printing a zero you would read as nobody searching.",
|
|
324
|
+
" It also shows ordinary prompt-hook observations: outcome counts, 100 ms",
|
|
325
|
+
" duration bounds, actual vector-cache reuse and named coverage gaps. These",
|
|
326
|
+
" are hook-body timings, not full host wait or retrieval-quality scores.",
|
|
324
327
|
" cockpit ops recompile --person <email|name|id> [--dry-run]",
|
|
325
328
|
" Writes that person's page again, now. Your own page is always yours to",
|
|
326
329
|
" recompile; somebody else's is for the people who read across the team.",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/** Read independent observations; never turn a missing sample into a zero. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { MemoryHookSampleSchema, MemoryHookSamplingLeaseSchema, memoryHookSamplesDirectory, blankHistogram, addDuration, } from "@bli-cockpit/telemetry-core";
|
|
6
|
+
const HOUR_MS = 3_600_000;
|
|
7
|
+
export const HOOK_SAMPLE_RETENTION_HOURS = 48;
|
|
8
|
+
export const HOOK_SAMPLE_READ_LIMIT = 20_000;
|
|
9
|
+
const MAX_SAMPLE_BYTES = 1_024;
|
|
10
|
+
const READ_BUDGET_MS = 1_000;
|
|
11
|
+
const HOUR_DIRECTORY = /^\d{4}-\d{2}-\d{2}T\d{2}$/u;
|
|
12
|
+
const SAMPLE_FILE = /^[a-f0-9-]{36}\.json$/u;
|
|
13
|
+
export async function readMemoryHookPerformance(options) {
|
|
14
|
+
const now = options.now ?? new Date();
|
|
15
|
+
const directory = memoryHookSamplesDirectory(options.homeDir);
|
|
16
|
+
const start = now.getTime() - 24 * HOUR_MS;
|
|
17
|
+
const reasons = new Set();
|
|
18
|
+
const result = {
|
|
19
|
+
schema_version: "memory-hook-performance.v1",
|
|
20
|
+
window_start: new Date(start).toISOString(),
|
|
21
|
+
window_end: now.toISOString(),
|
|
22
|
+
sampling_since: null,
|
|
23
|
+
samples: 0,
|
|
24
|
+
outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
|
|
25
|
+
duration_by_cache: {
|
|
26
|
+
hit: blankHistogram(), miss: blankHistogram(), shared: blankHistogram(), unknown: blankHistogram(),
|
|
27
|
+
},
|
|
28
|
+
producer_versions: [],
|
|
29
|
+
invalid_samples: 0,
|
|
30
|
+
incomplete_samples: 0,
|
|
31
|
+
unreadable_samples: 0,
|
|
32
|
+
capped: false,
|
|
33
|
+
reasons: [],
|
|
34
|
+
};
|
|
35
|
+
const versions = new Map();
|
|
36
|
+
const started = performance.now();
|
|
37
|
+
let filesRead = 0;
|
|
38
|
+
try {
|
|
39
|
+
const lease = await renewLease(directory, now, reasons);
|
|
40
|
+
result.sampling_since = lease.continuous_since;
|
|
41
|
+
if (Date.parse(lease.continuous_since) > start)
|
|
42
|
+
reasons.add("partial_window");
|
|
43
|
+
// Exactly the 25 hour directories touching this rolling 24-hour window.
|
|
44
|
+
// Timestamp filtering below trims both partial boundary hours.
|
|
45
|
+
hours: for (let offset = 0; offset <= 24; offset += 1) {
|
|
46
|
+
const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await fs.opendir(path.join(directory, hour));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT")
|
|
53
|
+
reasons.add("read_failed");
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for await (const entry of entries) {
|
|
57
|
+
if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
|
|
58
|
+
continue;
|
|
59
|
+
if (filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
|
|
60
|
+
performance.now() - started >= (options.readBudgetMs ?? READ_BUDGET_MS)) {
|
|
61
|
+
result.capped = true;
|
|
62
|
+
reasons.add("read_limit");
|
|
63
|
+
break hours;
|
|
64
|
+
}
|
|
65
|
+
filesRead += 1;
|
|
66
|
+
const file = path.join(directory, hour, entry.name);
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
const stat = await fs.stat(file);
|
|
70
|
+
if (stat.size > MAX_SAMPLE_BYTES) {
|
|
71
|
+
result.invalid_samples += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
raw = await fs.readFile(file, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
result.unreadable_samples += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!raw.endsWith("\n")) {
|
|
81
|
+
// Could be a live writer or one killed by the host. Leave it for the
|
|
82
|
+
// next tick, and keep the incomplete observation out of every rate.
|
|
83
|
+
result.incomplete_samples += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
result.invalid_samples += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const sample = MemoryHookSampleSchema.safeParse(parsed);
|
|
95
|
+
if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
|
|
96
|
+
result.invalid_samples += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const at = Date.parse(sample.data.recorded_at);
|
|
100
|
+
if (at < start || at > now.getTime())
|
|
101
|
+
continue;
|
|
102
|
+
const version = sample.data.producer_version;
|
|
103
|
+
if (!versions.has(version) && versions.size >= 16) {
|
|
104
|
+
result.capped = true;
|
|
105
|
+
reasons.add("read_limit");
|
|
106
|
+
break hours;
|
|
107
|
+
}
|
|
108
|
+
result.samples += 1;
|
|
109
|
+
result.outcomes[sample.data.outcome] += 1;
|
|
110
|
+
addDuration(result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
|
|
111
|
+
versions.set(version, (versions.get(version) ?? 0) + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await pruneExpiredHours(directory, now, reasons);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
reasons.add("read_failed");
|
|
118
|
+
}
|
|
119
|
+
if (!result.samples)
|
|
120
|
+
reasons.add("no_samples");
|
|
121
|
+
if (result.invalid_samples)
|
|
122
|
+
reasons.add("invalid_samples");
|
|
123
|
+
if (result.incomplete_samples)
|
|
124
|
+
reasons.add("incomplete_samples");
|
|
125
|
+
if (result.unreadable_samples)
|
|
126
|
+
reasons.add("unreadable_samples");
|
|
127
|
+
result.producer_versions = [...versions].sort(([a], [b]) => a.localeCompare(b))
|
|
128
|
+
.map(([version, samples]) => ({ version, samples }));
|
|
129
|
+
result.reasons = [...reasons];
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
async function renewLease(directory, now, reasons) {
|
|
133
|
+
let continuousSince = now.toISOString();
|
|
134
|
+
try {
|
|
135
|
+
const previous = MemoryHookSamplingLeaseSchema.safeParse(JSON.parse(await fs.readFile(path.join(directory, "lease.json"), "utf8")));
|
|
136
|
+
if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime())
|
|
137
|
+
reasons.add("lease_invalid");
|
|
138
|
+
else if (Date.parse(previous.data.expires_at) <= now.getTime())
|
|
139
|
+
reasons.add("lease_expired");
|
|
140
|
+
else
|
|
141
|
+
continuousSince = previous.data.continuous_since;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
reasons.add(error.code === "ENOENT" ? "lease_absent" : "lease_invalid");
|
|
145
|
+
}
|
|
146
|
+
const lease = {
|
|
147
|
+
schema_version: "memory-hook-sampling-lease.v1",
|
|
148
|
+
continuous_since: continuousSince,
|
|
149
|
+
expires_at: new Date(now.getTime() + HOOK_SAMPLE_RETENTION_HOURS * HOUR_MS).toISOString(),
|
|
150
|
+
};
|
|
151
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
152
|
+
const temporary = path.join(directory, `${randomUUID()}.lease.tmp`);
|
|
153
|
+
await fs.writeFile(temporary, `${JSON.stringify(lease)}\n`, { flag: "wx", mode: 0o600 });
|
|
154
|
+
await fs.rename(temporary, path.join(directory, "lease.json"));
|
|
155
|
+
return lease;
|
|
156
|
+
}
|
|
157
|
+
async function pruneExpiredHours(directory, now, reasons) {
|
|
158
|
+
const oldest = new Date(now.getTime() - HOOK_SAMPLE_RETENTION_HOURS * HOUR_MS).toISOString().slice(0, 13);
|
|
159
|
+
try {
|
|
160
|
+
const entries = await fs.opendir(directory);
|
|
161
|
+
let pruned = 0;
|
|
162
|
+
for await (const entry of entries) {
|
|
163
|
+
if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest)
|
|
164
|
+
continue;
|
|
165
|
+
// Cleanup belongs to the collector and cannot delay a prompt. A bounded
|
|
166
|
+
// number of expired hours is enough to catch up over successive ticks.
|
|
167
|
+
await fs.rm(path.join(directory, entry.name), { recursive: true, force: true });
|
|
168
|
+
if (++pruned >= 8)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
reasons.add("cleanup_failed");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -101,6 +101,8 @@ export function renderMemoryHooks(hooks, dim) {
|
|
|
101
101
|
for (const device of ranked) {
|
|
102
102
|
const line = ` ${device.line ?? `${device.displayName ?? "?"}: (no line)`}`;
|
|
103
103
|
lines.push((device.timeouts ?? 0) > 0 ? line : dim(line));
|
|
104
|
+
if (device.performanceLine)
|
|
105
|
+
lines.push(` ${device.performanceLine}`);
|
|
104
106
|
}
|
|
105
107
|
return lines;
|
|
106
108
|
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.82");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.82",
|
|
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-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.14",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.16",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.33"
|
|
33
33
|
}
|
|
34
34
|
}
|