@bli-cockpit/telemetry-core 0.1.33 → 0.1.35
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/collector-heartbeat.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/memory-daemon-endpoint.d.ts +114 -0
- package/dist/memory-daemon-endpoint.js +124 -0
- package/dist/memory-hook-stats.d.ts +18 -0
- package/dist/memory-hook-stats.js +50 -2
- package/dist/memory-install-receipt.d.ts +3 -0
- package/dist/memory-install-receipt.js +22 -0
- package/package.json +1 -1
|
@@ -105,6 +105,9 @@ export declare const CollectorHeartbeatSchema: z.ZodObject<{
|
|
|
105
105
|
hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
|
|
106
106
|
hook_printed_24h: z.ZodOptional<z.ZodNumber>;
|
|
107
107
|
hook_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
108
|
+
hook_via_daemon_24h: z.ZodOptional<z.ZodNumber>;
|
|
109
|
+
hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
|
|
110
|
+
hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
|
|
108
111
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
109
112
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
|
110
113
|
schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
|
|
|
9
9
|
export * from "./evidence-upload.js";
|
|
10
10
|
export * from "./ingest-dto.js";
|
|
11
11
|
export * from "./local-config.js";
|
|
12
|
+
export * from "./memory-daemon-endpoint.js";
|
|
12
13
|
export * from "./memory-hook-stats.js";
|
|
13
14
|
export * from "./memory-hook-performance.js";
|
|
14
15
|
export * from "./memory-install-receipt.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./evidence-reconcile.js";
|
|
|
9
9
|
export * from "./evidence-upload.js";
|
|
10
10
|
export * from "./ingest-dto.js";
|
|
11
11
|
export * from "./local-config.js";
|
|
12
|
+
export * from "./memory-daemon-endpoint.js";
|
|
12
13
|
export * from "./memory-hook-stats.js";
|
|
13
14
|
export * from "./memory-hook-performance.js";
|
|
14
15
|
export * from "./memory-install-receipt.js";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE RESIDENT MEMORY DAEMON LISTENS (BLI-3884).
|
|
3
|
+
*
|
|
4
|
+
* `@bli-cockpit/memory-mcp` RUNS the daemon (`bli-memory-mcp daemon`) and its
|
|
5
|
+
* hooks CALL it; `@bli-cockpit/local-collector` only ever ASKS whether it is
|
|
6
|
+
* answering (`cockpit doctor`'s `memory-daemon` row, `cockpit memory status`).
|
|
7
|
+
* The two packages are published separately and never import each other, so
|
|
8
|
+
* the address, the protocol words and the four numbers that govern the
|
|
9
|
+
* handshake live here, in the one package they both depend on — the same rule
|
|
10
|
+
* `memory-hook-stats.ts` next door was written under, and for the same reason:
|
|
11
|
+
* two copies of a wire contract kept in step by comment is the BLI-2541
|
|
12
|
+
* failure.
|
|
13
|
+
*
|
|
14
|
+
* ## The address
|
|
15
|
+
*
|
|
16
|
+
* macOS and Linux: a unix socket in the collector's own state directory,
|
|
17
|
+
* created 0600 inside a 0700 directory, so the endpoint is the OS user's and
|
|
18
|
+
* nobody else's. Windows: a named pipe, `\\.\pipe\bli-memory-<user>` — a
|
|
19
|
+
* Windows pipe has no directory and no mode bits, so the USER NAME is what
|
|
20
|
+
* makes it per-user, and the daemon never puts anything on the wire that a
|
|
21
|
+
* caller has not already got (see the protocol below).
|
|
22
|
+
*
|
|
23
|
+
* ## What may never travel on it
|
|
24
|
+
*
|
|
25
|
+
* The device token. The hook sends `{event, payload, cwd}` and nothing else;
|
|
26
|
+
* the daemon reads the machine's own session file itself. A protocol that
|
|
27
|
+
* carried the credential would put it in every hook process on the machine
|
|
28
|
+
* for no gain — the daemon is already running as the same user that can read
|
|
29
|
+
* the session file.
|
|
30
|
+
*/
|
|
31
|
+
/** The wire vocabulary's version, sent on every request and answer. */
|
|
32
|
+
export declare const MEMORY_DAEMON_PROTOCOL_VERSION = "memory-daemon.v1";
|
|
33
|
+
/** The socket file, in the collector's state directory. One spelling, here. */
|
|
34
|
+
export declare const MEMORY_DAEMON_SOCKET_FILE_NAME = "memory-daemon.sock";
|
|
35
|
+
/**
|
|
36
|
+
* The stamp a hook writes when it spawns a daemon, beside the socket. It is
|
|
37
|
+
* the rate limit: a machine where the daemon cannot start must not spawn one
|
|
38
|
+
* per prompt, so a hook that finds no daemon looks here first and only tries
|
|
39
|
+
* again an hour later.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MEMORY_DAEMON_SPAWN_STAMP_FILE_NAME = "memory-daemon-spawn.json";
|
|
42
|
+
/** Windows named pipes live in one flat namespace; the user makes it per-user. */
|
|
43
|
+
export declare const MEMORY_DAEMON_PIPE_PREFIX = "\\\\.\\pipe\\bli-memory-";
|
|
44
|
+
/**
|
|
45
|
+
* How long a hook may wait to CONNECT before giving up on the daemon.
|
|
46
|
+
*
|
|
47
|
+
* A connect to a live local socket is sub-millisecond. This budget is not
|
|
48
|
+
* sized for the connect; it is sized so that a daemon which is wedged,
|
|
49
|
+
* half-dead or being replaced costs a person 50 ms and then gets out of the
|
|
50
|
+
* way. The whole call still sits inside the hook's own `HOOK_BUDGETS`
|
|
51
|
+
* deadline, which was started before the connect.
|
|
52
|
+
*/
|
|
53
|
+
export declare const MEMORY_DAEMON_CONNECT_TIMEOUT_MS = 50;
|
|
54
|
+
/** At most one spawn attempt per hour per machine. */
|
|
55
|
+
export declare const MEMORY_DAEMON_SPAWN_INTERVAL_MS = 3600000;
|
|
56
|
+
/** The daemon exits after this long with no request. Two hours. */
|
|
57
|
+
export declare const MEMORY_DAEMON_IDLE_EXIT_MS = 7200000;
|
|
58
|
+
/**
|
|
59
|
+
* How long a unix socket PATH may be, in bytes, before the platform refuses to
|
|
60
|
+
* bind it. `sun_path` is 104 on macOS/BSD and 108 on Linux; 100 is the safe
|
|
61
|
+
* floor under both. A real machine is nowhere near it — `/Users/<name>/.local/
|
|
62
|
+
* state/bli-cockpit/memory-daemon.sock` is about 60 characters — but a deeply
|
|
63
|
+
* nested HOME (a container, a temp directory in a test) is, and `bind` answers
|
|
64
|
+
* EINVAL, which reads as a bug rather than as a limit. The endpoint says so by
|
|
65
|
+
* name instead, and every hook simply goes the direct way.
|
|
66
|
+
*/
|
|
67
|
+
export declare const MEMORY_DAEMON_UNIX_PATH_MAX = 100;
|
|
68
|
+
/**
|
|
69
|
+
* The largest single line either side will read. A prompt is capped at 1,000
|
|
70
|
+
* characters before it ever reaches the door, and a recall block is a few KB;
|
|
71
|
+
* 1 MB is far above both and bounds a peer that never sends a newline.
|
|
72
|
+
*/
|
|
73
|
+
export declare const MEMORY_DAEMON_MAX_LINE_BYTES = 1048576;
|
|
74
|
+
export interface MemoryDaemonEndpoint {
|
|
75
|
+
/** Which kind of endpoint this platform gets. */
|
|
76
|
+
kind: "unix" | "pipe";
|
|
77
|
+
/** What `net.connect` / `server.listen` is handed. */
|
|
78
|
+
address: string;
|
|
79
|
+
/** The spawn rate-limit stamp. Always a real file, on both platforms. */
|
|
80
|
+
stampFile: string;
|
|
81
|
+
/** The directory both live in (the pipe's stamp lives here too). */
|
|
82
|
+
stateDir: string;
|
|
83
|
+
/**
|
|
84
|
+
* True when a unix socket at this address would exceed `sun_path`. Nothing
|
|
85
|
+
* is invented in that case — no second address, no temp directory that the
|
|
86
|
+
* daemon and the probe could disagree about; the daemon refuses by name and
|
|
87
|
+
* every hook keeps working on the direct path.
|
|
88
|
+
*/
|
|
89
|
+
addressTooLong: boolean;
|
|
90
|
+
}
|
|
91
|
+
export interface MemoryDaemonEndpointOptions {
|
|
92
|
+
homeDir: string;
|
|
93
|
+
/** Defaults to this process's platform. Injected by the tests. */
|
|
94
|
+
platform?: NodeJS.Platform;
|
|
95
|
+
/** The OS user name. Only Windows uses it; ignored elsewhere. */
|
|
96
|
+
username?: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The one place the daemon's address is computed. Both packages call this;
|
|
100
|
+
* nobody joins a socket path by hand.
|
|
101
|
+
*/
|
|
102
|
+
export declare function memoryDaemonEndpoint(options: MemoryDaemonEndpointOptions): MemoryDaemonEndpoint;
|
|
103
|
+
/**
|
|
104
|
+
* A Windows user name folded into something a pipe name may hold.
|
|
105
|
+
*
|
|
106
|
+
* Real ones carry spaces, dots and a `DOMAIN\user` prefix, and a pipe name is
|
|
107
|
+
* a path segment — an unfiltered name would either fail to bind or, worse,
|
|
108
|
+
* bind somewhere unintended. Everything outside `[A-Za-z0-9_-]` becomes `-`,
|
|
109
|
+
* the whole thing is lower-cased so two spellings of one account do not get
|
|
110
|
+
* two daemons, and a name that reduces to nothing falls back to `user`, which
|
|
111
|
+
* is still per-user in practice because the pipe is created by that user's
|
|
112
|
+
* own session.
|
|
113
|
+
*/
|
|
114
|
+
export declare function pipeUserSegment(username: string | undefined): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE RESIDENT MEMORY DAEMON LISTENS (BLI-3884).
|
|
3
|
+
*
|
|
4
|
+
* `@bli-cockpit/memory-mcp` RUNS the daemon (`bli-memory-mcp daemon`) and its
|
|
5
|
+
* hooks CALL it; `@bli-cockpit/local-collector` only ever ASKS whether it is
|
|
6
|
+
* answering (`cockpit doctor`'s `memory-daemon` row, `cockpit memory status`).
|
|
7
|
+
* The two packages are published separately and never import each other, so
|
|
8
|
+
* the address, the protocol words and the four numbers that govern the
|
|
9
|
+
* handshake live here, in the one package they both depend on — the same rule
|
|
10
|
+
* `memory-hook-stats.ts` next door was written under, and for the same reason:
|
|
11
|
+
* two copies of a wire contract kept in step by comment is the BLI-2541
|
|
12
|
+
* failure.
|
|
13
|
+
*
|
|
14
|
+
* ## The address
|
|
15
|
+
*
|
|
16
|
+
* macOS and Linux: a unix socket in the collector's own state directory,
|
|
17
|
+
* created 0600 inside a 0700 directory, so the endpoint is the OS user's and
|
|
18
|
+
* nobody else's. Windows: a named pipe, `\\.\pipe\bli-memory-<user>` — a
|
|
19
|
+
* Windows pipe has no directory and no mode bits, so the USER NAME is what
|
|
20
|
+
* makes it per-user, and the daemon never puts anything on the wire that a
|
|
21
|
+
* caller has not already got (see the protocol below).
|
|
22
|
+
*
|
|
23
|
+
* ## What may never travel on it
|
|
24
|
+
*
|
|
25
|
+
* The device token. The hook sends `{event, payload, cwd}` and nothing else;
|
|
26
|
+
* the daemon reads the machine's own session file itself. A protocol that
|
|
27
|
+
* carried the credential would put it in every hook process on the machine
|
|
28
|
+
* for no gain — the daemon is already running as the same user that can read
|
|
29
|
+
* the session file.
|
|
30
|
+
*/
|
|
31
|
+
import { getUserLocalCockpitPaths } from "./paths.js";
|
|
32
|
+
/** The wire vocabulary's version, sent on every request and answer. */
|
|
33
|
+
export const MEMORY_DAEMON_PROTOCOL_VERSION = "memory-daemon.v1";
|
|
34
|
+
/** The socket file, in the collector's state directory. One spelling, here. */
|
|
35
|
+
export const MEMORY_DAEMON_SOCKET_FILE_NAME = "memory-daemon.sock";
|
|
36
|
+
/**
|
|
37
|
+
* The stamp a hook writes when it spawns a daemon, beside the socket. It is
|
|
38
|
+
* the rate limit: a machine where the daemon cannot start must not spawn one
|
|
39
|
+
* per prompt, so a hook that finds no daemon looks here first and only tries
|
|
40
|
+
* again an hour later.
|
|
41
|
+
*/
|
|
42
|
+
export const MEMORY_DAEMON_SPAWN_STAMP_FILE_NAME = "memory-daemon-spawn.json";
|
|
43
|
+
/** Windows named pipes live in one flat namespace; the user makes it per-user. */
|
|
44
|
+
export const MEMORY_DAEMON_PIPE_PREFIX = "\\\\.\\pipe\\bli-memory-";
|
|
45
|
+
/**
|
|
46
|
+
* How long a hook may wait to CONNECT before giving up on the daemon.
|
|
47
|
+
*
|
|
48
|
+
* A connect to a live local socket is sub-millisecond. This budget is not
|
|
49
|
+
* sized for the connect; it is sized so that a daemon which is wedged,
|
|
50
|
+
* half-dead or being replaced costs a person 50 ms and then gets out of the
|
|
51
|
+
* way. The whole call still sits inside the hook's own `HOOK_BUDGETS`
|
|
52
|
+
* deadline, which was started before the connect.
|
|
53
|
+
*/
|
|
54
|
+
export const MEMORY_DAEMON_CONNECT_TIMEOUT_MS = 50;
|
|
55
|
+
/** At most one spawn attempt per hour per machine. */
|
|
56
|
+
export const MEMORY_DAEMON_SPAWN_INTERVAL_MS = 3_600_000;
|
|
57
|
+
/** The daemon exits after this long with no request. Two hours. */
|
|
58
|
+
export const MEMORY_DAEMON_IDLE_EXIT_MS = 7_200_000;
|
|
59
|
+
/**
|
|
60
|
+
* How long a unix socket PATH may be, in bytes, before the platform refuses to
|
|
61
|
+
* bind it. `sun_path` is 104 on macOS/BSD and 108 on Linux; 100 is the safe
|
|
62
|
+
* floor under both. A real machine is nowhere near it — `/Users/<name>/.local/
|
|
63
|
+
* state/bli-cockpit/memory-daemon.sock` is about 60 characters — but a deeply
|
|
64
|
+
* nested HOME (a container, a temp directory in a test) is, and `bind` answers
|
|
65
|
+
* EINVAL, which reads as a bug rather than as a limit. The endpoint says so by
|
|
66
|
+
* name instead, and every hook simply goes the direct way.
|
|
67
|
+
*/
|
|
68
|
+
export const MEMORY_DAEMON_UNIX_PATH_MAX = 100;
|
|
69
|
+
/**
|
|
70
|
+
* The largest single line either side will read. A prompt is capped at 1,000
|
|
71
|
+
* characters before it ever reaches the door, and a recall block is a few KB;
|
|
72
|
+
* 1 MB is far above both and bounds a peer that never sends a newline.
|
|
73
|
+
*/
|
|
74
|
+
export const MEMORY_DAEMON_MAX_LINE_BYTES = 1_048_576;
|
|
75
|
+
/**
|
|
76
|
+
* The one place the daemon's address is computed. Both packages call this;
|
|
77
|
+
* nobody joins a socket path by hand.
|
|
78
|
+
*/
|
|
79
|
+
export function memoryDaemonEndpoint(options) {
|
|
80
|
+
const paths = getUserLocalCockpitPaths(options.homeDir);
|
|
81
|
+
const platform = options.platform ?? process.platform;
|
|
82
|
+
const separator = paths.state_dir.includes("\\") ? "\\" : "/";
|
|
83
|
+
const stampFile = `${paths.state_dir}${separator}${MEMORY_DAEMON_SPAWN_STAMP_FILE_NAME}`;
|
|
84
|
+
if (platform === "win32") {
|
|
85
|
+
return {
|
|
86
|
+
kind: "pipe",
|
|
87
|
+
address: `${MEMORY_DAEMON_PIPE_PREFIX}${pipeUserSegment(options.username)}`,
|
|
88
|
+
stampFile,
|
|
89
|
+
stateDir: paths.state_dir,
|
|
90
|
+
// A named pipe is not a filesystem path and has no length problem.
|
|
91
|
+
addressTooLong: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const address = `${paths.state_dir}${separator}${MEMORY_DAEMON_SOCKET_FILE_NAME}`;
|
|
95
|
+
return {
|
|
96
|
+
kind: "unix",
|
|
97
|
+
address,
|
|
98
|
+
stampFile,
|
|
99
|
+
stateDir: paths.state_dir,
|
|
100
|
+
// Bytes, not characters, and no `Buffer`: this module is imported by the
|
|
101
|
+
// dashboard as well as by two CLIs.
|
|
102
|
+
addressTooLong: new TextEncoder().encode(address).length > MEMORY_DAEMON_UNIX_PATH_MAX,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A Windows user name folded into something a pipe name may hold.
|
|
107
|
+
*
|
|
108
|
+
* Real ones carry spaces, dots and a `DOMAIN\user` prefix, and a pipe name is
|
|
109
|
+
* a path segment — an unfiltered name would either fail to bind or, worse,
|
|
110
|
+
* bind somewhere unintended. Everything outside `[A-Za-z0-9_-]` becomes `-`,
|
|
111
|
+
* the whole thing is lower-cased so two spellings of one account do not get
|
|
112
|
+
* two daemons, and a name that reduces to nothing falls back to `user`, which
|
|
113
|
+
* is still per-user in practice because the pipe is created by that user's
|
|
114
|
+
* own session.
|
|
115
|
+
*/
|
|
116
|
+
export function pipeUserSegment(username) {
|
|
117
|
+
const folded = (username ?? "")
|
|
118
|
+
.trim()
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/[^a-z0-9_-]+/gu, "-")
|
|
121
|
+
.replace(/^-+|-+$/gu, "")
|
|
122
|
+
.slice(0, 48);
|
|
123
|
+
return folded.length > 0 ? folded : "user";
|
|
124
|
+
}
|
|
@@ -45,6 +45,9 @@ export declare const MemoryHookCountsSchema: z.ZodObject<{
|
|
|
45
45
|
timeouts: z.ZodNumber;
|
|
46
46
|
failed: z.ZodNumber;
|
|
47
47
|
skipped: z.ZodNumber;
|
|
48
|
+
via_daemon: z.ZodOptional<z.ZodNumber>;
|
|
49
|
+
via_direct: z.ZodOptional<z.ZodNumber>;
|
|
50
|
+
skipped_trivial: z.ZodOptional<z.ZodNumber>;
|
|
48
51
|
}, z.core.$strict>;
|
|
49
52
|
export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
|
|
50
53
|
/** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
|
|
@@ -62,6 +65,9 @@ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
|
|
|
62
65
|
timeouts: z.ZodNumber;
|
|
63
66
|
failed: z.ZodNumber;
|
|
64
67
|
skipped: z.ZodNumber;
|
|
68
|
+
via_daemon: z.ZodOptional<z.ZodNumber>;
|
|
69
|
+
via_direct: z.ZodOptional<z.ZodNumber>;
|
|
70
|
+
skipped_trivial: z.ZodOptional<z.ZodNumber>;
|
|
65
71
|
}, z.core.$strict>>>;
|
|
66
72
|
}, z.core.$strict>;
|
|
67
73
|
export type MemoryHookStatsFile = z.infer<typeof MemoryHookStatsFileSchema>;
|
|
@@ -74,6 +80,18 @@ export interface MemoryHookWindow {
|
|
|
74
80
|
printed: number;
|
|
75
81
|
timeouts: number;
|
|
76
82
|
failed: number;
|
|
83
|
+
/**
|
|
84
|
+
* BLI-3884. How many of those runs the resident daemon answered, and how
|
|
85
|
+
* many took the original direct path. Summed only over buckets that CARRY
|
|
86
|
+
* the field, so a window spanning an upgrade reports what was measured
|
|
87
|
+
* rather than crediting the old rows to `direct`.
|
|
88
|
+
*/
|
|
89
|
+
viaDaemon: number;
|
|
90
|
+
viaDirect: number;
|
|
91
|
+
/** True when at least one bucket in the window named a route at all. */
|
|
92
|
+
viaMeasured: boolean;
|
|
93
|
+
/** Trivial prompts the hook declined to search for (BLI-3881). A subset of skips. */
|
|
94
|
+
skippedTrivial: number;
|
|
77
95
|
/** Buckets that were inside the window and had something in them. */
|
|
78
96
|
hours: number;
|
|
79
97
|
}
|
|
@@ -46,6 +46,36 @@ export const MemoryHookCountsSchema = z
|
|
|
46
46
|
timeouts: z.number().int().min(0),
|
|
47
47
|
failed: z.number().int().min(0),
|
|
48
48
|
skipped: z.number().int().min(0),
|
|
49
|
+
/**
|
|
50
|
+
* WHICH ROUTE ANSWERED (BLI-3884). `via_daemon` counts runs the resident
|
|
51
|
+
* local daemon answered; `via_direct` counts runs that went the original
|
|
52
|
+
* way — a fresh process, a cold connection, the whole gate.
|
|
53
|
+
*
|
|
54
|
+
* Both are OPTIONAL and both are absent on a machine older than the
|
|
55
|
+
* daemon, which must read as "not measured" and never as "no daemon
|
|
56
|
+
* ever answered". They do not have to sum to `runs`: a run that never
|
|
57
|
+
* reached either route (no payload, no session) has no route to name,
|
|
58
|
+
* and is counted in `skipped` alone.
|
|
59
|
+
*/
|
|
60
|
+
via_daemon: z.number().int().min(0).optional(),
|
|
61
|
+
via_direct: z.number().int().min(0).optional(),
|
|
62
|
+
/**
|
|
63
|
+
* BLI-3881 — the SUBSET of `skipped` the hook declined to search for.
|
|
64
|
+
*
|
|
65
|
+
* "ok", "continue", "y", a slash command: a turn whose whole text carries
|
|
66
|
+
* no subject to recall against. Searching one spends a person's whole
|
|
67
|
+
* budget on a query that can only return noise, so the hook returns early
|
|
68
|
+
* and this counts how often — because a hook that stops doing something
|
|
69
|
+
* and says nothing is indistinguishable from a hook that broke.
|
|
70
|
+
*
|
|
71
|
+
* OPTIONAL, and it must stay optional: a file written by a memory-mcp
|
|
72
|
+
* older than this ticket has no such key, and `MemoryHookCountsSchema` is
|
|
73
|
+
* strict, so a required field would make every existing machine's history
|
|
74
|
+
* `hook_stats_unrecognised_shape` overnight. It is counted BESIDE
|
|
75
|
+
* `skipped`, never instead of it, so `runs = printed + empty + timeouts +
|
|
76
|
+
* failed + skipped` still holds.
|
|
77
|
+
*/
|
|
78
|
+
skipped_trivial: z.number().int().min(0).optional(),
|
|
49
79
|
})
|
|
50
80
|
.strict();
|
|
51
81
|
/** `YYYY-MM-DDTHH` in UTC — the bucket key, and the reason the window is exact. */
|
|
@@ -69,7 +99,7 @@ export function memoryHookHourBucket(at) {
|
|
|
69
99
|
return at.toISOString().slice(0, 13);
|
|
70
100
|
}
|
|
71
101
|
export function emptyMemoryHookCounts() {
|
|
72
|
-
return { runs: 0, printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 };
|
|
102
|
+
return { runs: 0, printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0, skipped_trivial: 0 };
|
|
73
103
|
}
|
|
74
104
|
/**
|
|
75
105
|
* Sum one event's buckets over the last `hours` hours, ending at `now`.
|
|
@@ -83,7 +113,17 @@ export function summariseMemoryHookWindow(file, event, options) {
|
|
|
83
113
|
const hours = options.hours ?? 24;
|
|
84
114
|
const earliest = memoryHookHourBucket(new Date(options.now.getTime() - (hours - 1) * 3_600_000));
|
|
85
115
|
const latest = memoryHookHourBucket(options.now);
|
|
86
|
-
const total = {
|
|
116
|
+
const total = {
|
|
117
|
+
runs: 0,
|
|
118
|
+
printed: 0,
|
|
119
|
+
timeouts: 0,
|
|
120
|
+
failed: 0,
|
|
121
|
+
viaDaemon: 0,
|
|
122
|
+
viaDirect: 0,
|
|
123
|
+
viaMeasured: false,
|
|
124
|
+
skippedTrivial: 0,
|
|
125
|
+
hours: 0,
|
|
126
|
+
};
|
|
87
127
|
for (const [bucket, events] of Object.entries(file.buckets)) {
|
|
88
128
|
// Lexical order is chronological for this key, which is the only reason a
|
|
89
129
|
// string comparison is allowed to stand in for a date one here.
|
|
@@ -96,6 +136,14 @@ export function summariseMemoryHookWindow(file, event, options) {
|
|
|
96
136
|
total.printed += counts.printed;
|
|
97
137
|
total.timeouts += counts.timeouts;
|
|
98
138
|
total.failed += counts.failed;
|
|
139
|
+
if (counts.via_daemon !== undefined || counts.via_direct !== undefined) {
|
|
140
|
+
total.viaMeasured = true;
|
|
141
|
+
total.viaDaemon += counts.via_daemon ?? 0;
|
|
142
|
+
total.viaDirect += counts.via_direct ?? 0;
|
|
143
|
+
}
|
|
144
|
+
// Absent means an older writer never counted one, which sums as zero and
|
|
145
|
+
// is honest: that machine genuinely skipped none, because it could not.
|
|
146
|
+
total.skippedTrivial += counts.skipped_trivial ?? 0;
|
|
99
147
|
total.hours += 1;
|
|
100
148
|
}
|
|
101
149
|
return total;
|
|
@@ -113,6 +113,9 @@ export declare const MemoryInstallReceiptSchema: z.ZodObject<{
|
|
|
113
113
|
hook_timeouts_24h: z.ZodOptional<z.ZodNumber>;
|
|
114
114
|
hook_printed_24h: z.ZodOptional<z.ZodNumber>;
|
|
115
115
|
hook_failed_24h: z.ZodOptional<z.ZodNumber>;
|
|
116
|
+
hook_via_daemon_24h: z.ZodOptional<z.ZodNumber>;
|
|
117
|
+
hook_via_direct_24h: z.ZodOptional<z.ZodNumber>;
|
|
118
|
+
hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
|
|
116
119
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
117
120
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
|
118
121
|
schema_version: z.ZodLiteral<"memory-hook-performance.v1">;
|
|
@@ -132,6 +132,28 @@ export const MemoryInstallReceiptSchema = z
|
|
|
132
132
|
hook_timeouts_24h: z.number().int().min(0).optional(),
|
|
133
133
|
hook_printed_24h: z.number().int().min(0).optional(),
|
|
134
134
|
hook_failed_24h: z.number().int().min(0).optional(),
|
|
135
|
+
/**
|
|
136
|
+
* WHICH ROUTE ANSWERED THE RECALL (BLI-3884).
|
|
137
|
+
*
|
|
138
|
+
* `hook_via_daemon_24h` counts prompt hooks the machine's resident local
|
|
139
|
+
* daemon answered from a warm connection; `hook_via_direct_24h` counts
|
|
140
|
+
* the ones that spawned, connected and proved the token themselves. Both
|
|
141
|
+
* optional, and BOTH ABSENT on a machine older than the daemon — which
|
|
142
|
+
* reads as "not measured", never as "the daemon answered nothing". They
|
|
143
|
+
* do not have to sum to `hook_runs_24h`: a run that never reached either
|
|
144
|
+
* route (no payload, no session) names no route at all.
|
|
145
|
+
*/
|
|
146
|
+
hook_via_daemon_24h: z.number().int().min(0).optional(),
|
|
147
|
+
hook_via_direct_24h: z.number().int().min(0).optional(),
|
|
148
|
+
/**
|
|
149
|
+
* BLI-3881: runs the hook DECLINED to search for — "ok", "continue", a
|
|
150
|
+
* slash command. It is a subset of the skips inside `hook_runs_24h`, and
|
|
151
|
+
* it exists because a hook that quietly stops searching looks exactly like
|
|
152
|
+
* a hook that broke. Optional like the four above: absent means the
|
|
153
|
+
* machine is on a memory-mcp older than the rule, never that it skipped
|
|
154
|
+
* none.
|
|
155
|
+
*/
|
|
156
|
+
hook_skipped_trivial_24h: z.number().int().min(0).optional(),
|
|
135
157
|
/** Named when the counts are absent because the file could not be read. */
|
|
136
158
|
hook_stats_reason: ReasonLabelSchema.optional(),
|
|
137
159
|
/** Observed ordinary invocations, not legacy floors or a full host trace. */
|