@bli-cockpit/telemetry-core 0.1.34 → 0.1.36
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 +2 -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-experience.d.ts +37 -0
- package/dist/memory-experience.js +85 -0
- package/dist/memory-hook-stats.d.ts +14 -0
- package/dist/memory-hook-stats.js +21 -0
- package/dist/memory-install-receipt.d.ts +2 -0
- package/dist/memory-install-receipt.js +13 -0
- package/package.json +5 -1
|
@@ -105,6 +105,8 @@ 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>;
|
|
108
110
|
hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
|
|
109
111
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
110
112
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type ExperienceStore = "bli" | "supermemory" | "both";
|
|
2
|
+
export type ExperienceVerdict = "win" | "loss" | "noise";
|
|
3
|
+
export interface MemoryExperience {
|
|
4
|
+
id: string;
|
|
5
|
+
store: ExperienceStore;
|
|
6
|
+
verdict: ExperienceVerdict;
|
|
7
|
+
reason: string;
|
|
8
|
+
created_at: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ExperienceReceipt extends MemoryExperience {
|
|
11
|
+
shipped: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type ExperienceSender = (entry: MemoryExperience) => Promise<{
|
|
14
|
+
ok: boolean;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function validateExperience(store: string, verdict: string, reason: string): void;
|
|
18
|
+
export declare function experienceLine(entry: MemoryExperience, agent: string, project: string): string;
|
|
19
|
+
export declare function appendExperience(input: {
|
|
20
|
+
store: ExperienceStore;
|
|
21
|
+
verdict: ExperienceVerdict;
|
|
22
|
+
reason: string;
|
|
23
|
+
}, options: {
|
|
24
|
+
homeDir?: string;
|
|
25
|
+
agent: string;
|
|
26
|
+
project: string;
|
|
27
|
+
}): Promise<ExperienceReceipt>;
|
|
28
|
+
export declare function shipExperience(entry: ExperienceReceipt, send: ExperienceSender, homeDir?: string): Promise<{
|
|
29
|
+
shipped: boolean;
|
|
30
|
+
reason: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Bounded work, no throwing into collection. UUIDs make concurrent drains idempotent. */
|
|
33
|
+
export declare function drainExperiences(send: ExperienceSender, homeDir?: string): Promise<{
|
|
34
|
+
attempted: number;
|
|
35
|
+
shipped: number;
|
|
36
|
+
reason: string;
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Append-only experience receipts shared by the CLI and MCP. BLI-3893. */
|
|
2
|
+
import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export function validateExperience(store, verdict, reason) {
|
|
7
|
+
if (!["bli", "supermemory", "both"].includes(store))
|
|
8
|
+
throw new Error("store must be bli, supermemory or both");
|
|
9
|
+
if (!["win", "loss", "noise"].includes(verdict))
|
|
10
|
+
throw new Error("verdict must be win, loss or noise");
|
|
11
|
+
if (!reason.trim() || [...reason].length > 500 || /[\r\n\x00-\x1f\x7f]/u.test(reason)) {
|
|
12
|
+
throw new Error("reason must be one nonempty line of at most 500 characters; opinions only, no prompts, memory bodies or secrets");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const markers = { bli: "[bli]", supermemory: "◪", both: "[bli]+◪" };
|
|
16
|
+
export function experienceLine(entry, agent, project) {
|
|
17
|
+
const stamp = new Date(Date.parse(entry.created_at) + 7 * 3600000).toISOString().slice(0, 16).replace("T", " ");
|
|
18
|
+
const label = (value) => value.replace(/[\r\n·]/gu, " ");
|
|
19
|
+
return `- ${stamp} ICT · ${label(agent)} · ${label(project)} · ${entry.store} ${markers[entry.store]} · ${entry.verdict}: ${entry.reason}`;
|
|
20
|
+
}
|
|
21
|
+
function paths(home) {
|
|
22
|
+
return { log: path.join(home, ".codex", "AGENT-EXPERIENCE.md"), queue: path.join(home, ".codex", "agent-experience-outbox") };
|
|
23
|
+
}
|
|
24
|
+
async function saveReceipt(home, entry) {
|
|
25
|
+
const target = path.join(paths(home).queue, `${entry.id}.json`);
|
|
26
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
27
|
+
await writeFile(temp, JSON.stringify(entry), { mode: 0o600 });
|
|
28
|
+
await rename(temp, target);
|
|
29
|
+
}
|
|
30
|
+
export async function appendExperience(input, options) {
|
|
31
|
+
validateExperience(input.store, input.verdict, input.reason);
|
|
32
|
+
const home = options.homeDir ?? homedir();
|
|
33
|
+
const entry = { ...input, id: randomUUID(), created_at: new Date().toISOString(), shipped: false };
|
|
34
|
+
await mkdir(paths(home).queue, { recursive: true });
|
|
35
|
+
// Persist the retry before the human receipt so a crash never loses delivery.
|
|
36
|
+
await saveReceipt(home, entry);
|
|
37
|
+
await appendFile(paths(home).log, `${experienceLine(entry, options.agent, options.project)}\n`, { mode: 0o600 });
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
export async function shipExperience(entry, send, homeDir = homedir()) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await send(entry);
|
|
43
|
+
if (!result.ok)
|
|
44
|
+
return { shipped: false, reason: result.reason ?? "experience_refused" };
|
|
45
|
+
await saveReceipt(homeDir, { ...entry, shipped: true });
|
|
46
|
+
return { shipped: true, reason: "accepted" };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { shipped: false, reason: "experience_delivery_failed" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Bounded work, no throwing into collection. UUIDs make concurrent drains idempotent. */
|
|
53
|
+
export async function drainExperiences(send, homeDir = homedir()) {
|
|
54
|
+
let attempted = 0, shipped = 0;
|
|
55
|
+
try {
|
|
56
|
+
let files;
|
|
57
|
+
try {
|
|
58
|
+
files = await readdir(paths(homeDir).queue);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === "ENOENT")
|
|
62
|
+
return { attempted, shipped, reason: "empty" };
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
for (const file of files.sort()) {
|
|
66
|
+
if (!/^[0-9a-f-]{36}\.json$/u.test(file))
|
|
67
|
+
continue;
|
|
68
|
+
const entry = JSON.parse(await readFile(path.join(paths(homeDir).queue, file), "utf8"));
|
|
69
|
+
if (entry.shipped)
|
|
70
|
+
continue;
|
|
71
|
+
validateExperience(entry.store, entry.verdict, entry.reason);
|
|
72
|
+
attempted++;
|
|
73
|
+
const result = await shipExperience(entry, send, homeDir);
|
|
74
|
+
if (!result.shipped)
|
|
75
|
+
return { attempted, shipped, reason: result.reason };
|
|
76
|
+
shipped++;
|
|
77
|
+
if (attempted >= 20)
|
|
78
|
+
return { attempted, shipped, reason: "batch_cap" };
|
|
79
|
+
}
|
|
80
|
+
return { attempted, shipped, reason: "exhausted" };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { attempted, shipped, reason: "experience_outbox_unreadable" };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -45,6 +45,8 @@ 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>;
|
|
48
50
|
skipped_trivial: z.ZodOptional<z.ZodNumber>;
|
|
49
51
|
}, z.core.$strict>;
|
|
50
52
|
export type MemoryHookCounts = z.infer<typeof MemoryHookCountsSchema>;
|
|
@@ -63,6 +65,8 @@ export declare const MemoryHookStatsFileSchema: z.ZodObject<{
|
|
|
63
65
|
timeouts: z.ZodNumber;
|
|
64
66
|
failed: z.ZodNumber;
|
|
65
67
|
skipped: z.ZodNumber;
|
|
68
|
+
via_daemon: z.ZodOptional<z.ZodNumber>;
|
|
69
|
+
via_direct: z.ZodOptional<z.ZodNumber>;
|
|
66
70
|
skipped_trivial: z.ZodOptional<z.ZodNumber>;
|
|
67
71
|
}, z.core.$strict>>>;
|
|
68
72
|
}, z.core.$strict>;
|
|
@@ -76,6 +80,16 @@ export interface MemoryHookWindow {
|
|
|
76
80
|
printed: number;
|
|
77
81
|
timeouts: number;
|
|
78
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;
|
|
79
93
|
/** Trivial prompts the hook declined to search for (BLI-3881). A subset of skips. */
|
|
80
94
|
skippedTrivial: number;
|
|
81
95
|
/** Buckets that were inside the window and had something in them. */
|
|
@@ -46,6 +46,19 @@ 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(),
|
|
49
62
|
/**
|
|
50
63
|
* BLI-3881 — the SUBSET of `skipped` the hook declined to search for.
|
|
51
64
|
*
|
|
@@ -105,6 +118,9 @@ export function summariseMemoryHookWindow(file, event, options) {
|
|
|
105
118
|
printed: 0,
|
|
106
119
|
timeouts: 0,
|
|
107
120
|
failed: 0,
|
|
121
|
+
viaDaemon: 0,
|
|
122
|
+
viaDirect: 0,
|
|
123
|
+
viaMeasured: false,
|
|
108
124
|
skippedTrivial: 0,
|
|
109
125
|
hours: 0,
|
|
110
126
|
};
|
|
@@ -120,6 +136,11 @@ export function summariseMemoryHookWindow(file, event, options) {
|
|
|
120
136
|
total.printed += counts.printed;
|
|
121
137
|
total.timeouts += counts.timeouts;
|
|
122
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
|
+
}
|
|
123
144
|
// Absent means an older writer never counted one, which sums as zero and
|
|
124
145
|
// is honest: that machine genuinely skipped none, because it could not.
|
|
125
146
|
total.skippedTrivial += counts.skipped_trivial ?? 0;
|
|
@@ -113,6 +113,8 @@ 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>;
|
|
116
118
|
hook_skipped_trivial_24h: z.ZodOptional<z.ZodNumber>;
|
|
117
119
|
hook_stats_reason: z.ZodOptional<z.ZodString>;
|
|
118
120
|
hook_performance: z.ZodOptional<z.ZodObject<{
|
|
@@ -132,6 +132,19 @@ 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(),
|
|
135
148
|
/**
|
|
136
149
|
* BLI-3881: runs the hook DECLINED to search for — "ok", "continue", a
|
|
137
150
|
* slash command. It is a subset of the skips inside `hook_runs_24h`, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/telemetry-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.36",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
".": {
|
|
14
14
|
"types": "./dist/index.d.ts",
|
|
15
15
|
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./memory-experience": {
|
|
18
|
+
"types": "./dist/memory-experience.d.ts",
|
|
19
|
+
"import": "./dist/memory-experience.js"
|
|
16
20
|
}
|
|
17
21
|
},
|
|
18
22
|
"publishConfig": {
|