kibi-agent-core 0.9.0 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # kibi-agent-core
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d89c1d2: Kibi now sends anonymous usage statistics, so its maintainers can see which operations people use, how long they take and how they fail, including in cloud and ephemeral environments where the local diagnostic log is lost. Only fixed names, statuses, durations, error categories, versions and platform are sent. Code, paths, queries, KB content and repository names never are. Turn it off with `kibi telemetry disable`, `KIBI_TELEMETRY=0` or `DO_NOT_TRACK=1`. It is also off in CI and test runs.
8
+ - kibi-agent-core adds `usage-telemetry`, a shared queue and batch sender for PostHog, with a random install id, hashed session ids and no person profiles.
9
+ - kibi-mcp records one `kibi_operation` event per tool call, sends the queue every minute and on shutdown, and prints a one-time notice to stderr.
10
+ - kibi-cli records one event per command, adds `kibi telemetry status|enable|disable`, and prints the one-time notice.
11
+ - kibi-claude records one `kibi_hook` event per hook decision and sends the queue from the Stop hook.
12
+
13
+ ## 0.9.1
14
+
15
+ ### Patch Changes
16
+
17
+ - 45036a3: Signoff guidance no longer tells agents to attach `latest.html` as a file. Chat apps (the Claude apps, Slack and most others) show an attached HTML file as raw source or a download, so agents now share the page only as a rendered page, such as a Claude Artifact, and otherwise leave the message text as the whole view. Updates the kibi-decisions and kibi-commit-review skills (1.2.1), the `kibi signoff` page note and the stop-hook reminder.
18
+
3
19
  ## 0.9.0
4
20
 
5
21
  ### Minor Changes
@@ -260,7 +260,7 @@ export function claimSignoffReminder(session, workspaceRoot) {
260
260
  export function signoffReminder() {
261
261
  return [
262
262
  "Kibi: this session recorded or changed requirements, and the person has not seen them as a signoff view.",
263
- "Before reporting the work done or asking to commit, run `kibi signoff` and put its output in the reply unchanged; a requirement the agent wrote waits for the person's approval, so ask for it with this view, never in your own words. When replies go through a messaging tool, pass the output there, and attach or publish `.kb/briefs/signoff/latest.html` when the host can show a page (kibi-decisions, resources/signoff-views.md).",
263
+ "Before reporting the work done or asking to commit, run `kibi signoff` and put its output in the reply unchanged; a requirement the agent wrote waits for the person's approval, so ask for it with this view, never in your own words. When replies go through a messaging tool, pass the output there as the message text, and share `.kb/briefs/signoff/latest.html` only as a rendered page (the Artifact tool in Claude apps), never as an attached file, which chat apps show as raw source (kibi-decisions, resources/signoff-views.md).",
264
264
  ].join("\n");
265
265
  }
266
266
  function readSettled(file) {
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./knowledge-index.js";
5
5
  export * from "./path-policy.js";
6
6
  export * from "./snippets.js";
7
7
  export * from "./requirement-candidates.js";
8
+ export * from "./usage-telemetry.js";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export * from "./knowledge-index.js";
5
5
  export * from "./path-policy.js";
6
6
  export * from "./snippets.js";
7
7
  export * from "./requirement-candidates.js";
8
+ export * from "./usage-telemetry.js";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Anonymous usage telemetry shared by the CLI, the MCP server and the host
3
+ * hooks.
4
+ *
5
+ * `.kb/usage.log` stays the detailed, opt-in diagnostic record and never
6
+ * leaves the machine. This module sends a much smaller summary to the Kibi
7
+ * PostHog project so maintainers can see which operations people run, how
8
+ * long they take and how they fail. Events carry only fixed vocabulary
9
+ * (operation and command names, statuses, error categories, path kinds),
10
+ * durations, versions and platform; never paths, queries, entity ids, KB
11
+ * content, repository names or error messages. The install id is random and
12
+ * the host session id is hashed.
13
+ *
14
+ * Events are appended to a per-user queue file and sent in batches, so
15
+ * short-lived processes (hooks, the CLI) do not wait on the network.
16
+ * `KIBI_TELEMETRY=0`, `DO_NOT_TRACK=1`, `kibi telemetry disable`, CI and test
17
+ * runs turn it off.
18
+ */
19
+ /** Public, write-only PostHog project key: it can send events, not read them. */
20
+ export declare const DEFAULT_TELEMETRY_KEY = "phc_A5z8jRhD528abGJiTtPfgu4d4hpbEbfiJr9iUGn9TMsY";
21
+ export declare const DEFAULT_TELEMETRY_HOST = "https://eu.i.posthog.com";
22
+ export declare const TELEMETRY_DOCS_URL = "https://looted.github.io/kibi/guide/telemetry.html";
23
+ type Env = NodeJS.ProcessEnv;
24
+ export type TelemetryPreference = "enabled" | "disabled";
25
+ export type TelemetryConfig = {
26
+ installId?: string;
27
+ preference?: TelemetryPreference;
28
+ noticeShownAt?: string;
29
+ };
30
+ export type TelemetryStatus = {
31
+ enabled: boolean;
32
+ /** Why telemetry is on or off, e.g. `KIBI_TELEMETRY=0` or `default`. */
33
+ reason: string;
34
+ directory: string;
35
+ };
36
+ /** Summary of one Kibi operation run through MCP or the CLI. */
37
+ export type OperationTelemetry = {
38
+ interface: "mcp" | "cli";
39
+ /** Registered MCP tool or CLI command name. */
40
+ operation: string;
41
+ status: "success" | "error";
42
+ durationMs: number;
43
+ /** Fixed diagnostic category such as `tool_timeout`, when known. */
44
+ errorCategory?: string | undefined;
45
+ exitCode?: number | undefined;
46
+ };
47
+ /** Summary of one host hook decision. */
48
+ export type HookTelemetry = {
49
+ hookEvent: string;
50
+ hookAction: string;
51
+ pathKind?: string | undefined;
52
+ kbOperation?: string | undefined;
53
+ kbUsedBefore?: boolean | undefined;
54
+ requirementCount?: number | undefined;
55
+ };
56
+ /** Who is sending: the package, its version and the host agent. */
57
+ export type TelemetrySource = {
58
+ /** npm package name, e.g. `kibi-cli`, `kibi-mcp`, `kibi-claude`. */
59
+ packageName: string;
60
+ packageVersion?: string | null | undefined;
61
+ /** Host agent, e.g. `claude-code`, `cursor`, `codex`, or `unknown`. */
62
+ host?: string | undefined;
63
+ /** Host session id; only its hash is sent. */
64
+ sessionId?: string | null | undefined;
65
+ };
66
+ /** Variables CI providers set; any non-false value marks a CI run. */
67
+ export declare const CI_ENV_KEYS: readonly ["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "TRAVIS", "TF_BUILD", "JENKINS_URL", "TEAMCITY_VERSION", "BITBUCKET_BUILD_NUMBER", "CODEBUILD_BUILD_ID", "DRONE", "APPVEYOR", "SEMAPHORE", "BUILDKITE_BUILD_ID", "CF_BUILD_ID", "HEROKU_TEST_RUN_ID"];
68
+ /** Variables test runners set for the code under test (`NAME=value` must match). */
69
+ export declare const TEST_ENV_KEYS: readonly ["NODE_ENV=test", "JEST_WORKER_ID", "VITEST", "VITEST_WORKER_ID", "NODE_TEST_CONTEXT"];
70
+ /** Per-user directory holding the telemetry config and queue. */
71
+ export declare function telemetryDirectory(env?: Env): string;
72
+ export declare function readTelemetryConfig(env?: Env): TelemetryConfig;
73
+ /**
74
+ * Persist the user's choice. Disabling also deletes anything still queued,
75
+ * so nothing recorded before the choice is sent afterwards.
76
+ */
77
+ export declare function setTelemetryPreference(preference: TelemetryPreference, env?: Env): void;
78
+ /**
79
+ * Whether events are recorded and sent, and why. Explicit opt-outs win over
80
+ * everything. CI and test runs are off even after `kibi telemetry enable`;
81
+ * only an explicit `KIBI_TELEMETRY=1` sends from them.
82
+ */
83
+ export declare function telemetryStatus(env?: Env): TelemetryStatus;
84
+ export declare function telemetryEnabled(env?: Env): boolean;
85
+ /** Stable one-way hash, so a session can be grouped without revealing its id. */
86
+ export declare function hashTelemetryId(value: string): string;
87
+ /**
88
+ * The notice users see before the first event is sent, or null once it was
89
+ * shown. Callers that can reach the user (CLI and MCP stderr) print it.
90
+ */
91
+ export declare function takeTelemetryNotice(env?: Env): string | null;
92
+ export declare function recordOperationTelemetry(source: TelemetrySource, operation: OperationTelemetry, env?: Env): void;
93
+ export declare function recordHookTelemetry(source: TelemetrySource, hook: HookTelemetry, env?: Env): void;
94
+ export declare function clearTelemetryQueue(env?: Env): void;
95
+ export type FlushOptions = {
96
+ /** Send even when the queue is small and recent (process exit, Stop hook). */
97
+ force?: boolean;
98
+ timeoutMs?: number;
99
+ fetchImpl?: typeof fetch;
100
+ };
101
+ /**
102
+ * Send queued events in one batch when a flush is due (or forced). Returns
103
+ * the number of events sent. A failed send puts the events back for the next
104
+ * flush; nothing here throws.
105
+ */
106
+ export declare function flushTelemetry(options?: FlushOptions, env?: Env): Promise<number>;
107
+ export {};
@@ -0,0 +1,473 @@
1
+ // implements REQ-kibi-anonymous-usage-telemetry
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ /**
7
+ * Anonymous usage telemetry shared by the CLI, the MCP server and the host
8
+ * hooks.
9
+ *
10
+ * `.kb/usage.log` stays the detailed, opt-in diagnostic record and never
11
+ * leaves the machine. This module sends a much smaller summary to the Kibi
12
+ * PostHog project so maintainers can see which operations people run, how
13
+ * long they take and how they fail. Events carry only fixed vocabulary
14
+ * (operation and command names, statuses, error categories, path kinds),
15
+ * durations, versions and platform; never paths, queries, entity ids, KB
16
+ * content, repository names or error messages. The install id is random and
17
+ * the host session id is hashed.
18
+ *
19
+ * Events are appended to a per-user queue file and sent in batches, so
20
+ * short-lived processes (hooks, the CLI) do not wait on the network.
21
+ * `KIBI_TELEMETRY=0`, `DO_NOT_TRACK=1`, `kibi telemetry disable`, CI and test
22
+ * runs turn it off.
23
+ */
24
+ // implements REQ-kibi-anonymous-usage-telemetry
25
+ /** Public, write-only PostHog project key: it can send events, not read them. */
26
+ export const DEFAULT_TELEMETRY_KEY = "phc_A5z8jRhD528abGJiTtPfgu4d4hpbEbfiJr9iUGn9TMsY";
27
+ // implements REQ-kibi-anonymous-usage-telemetry
28
+ export const DEFAULT_TELEMETRY_HOST = "https://eu.i.posthog.com";
29
+ // implements REQ-kibi-anonymous-usage-telemetry
30
+ export const TELEMETRY_DOCS_URL = "https://looted.github.io/kibi/guide/telemetry.html";
31
+ const QUEUE_FILE = "telemetry-queue.jsonl";
32
+ const CONFIG_FILE = "telemetry.json";
33
+ const SENDING_SUFFIX = ".sending";
34
+ /** Queue size that makes a flush due regardless of age. */
35
+ const FLUSH_BATCH_EVENTS = 20;
36
+ /** Age of the oldest queued event that makes a flush due. */
37
+ const FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
38
+ /** A claimed batch older than this was left by a process that died. */
39
+ const STALE_CLAIM_MS = 10 * 60 * 1000;
40
+ /** Queued events older than this are dropped instead of sent. */
41
+ const MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
42
+ /** The queue stops growing past this size when sends keep failing. */
43
+ const MAX_QUEUE_BYTES = 1024 * 1024;
44
+ const DEFAULT_FLUSH_TIMEOUT_MS = 1500;
45
+ function flagValue(value) {
46
+ const normalized = value?.trim().toLowerCase();
47
+ if (normalized === undefined || normalized === "")
48
+ return undefined;
49
+ if (["1", "true", "yes", "on"].includes(normalized))
50
+ return true;
51
+ if (["0", "false", "no", "off"].includes(normalized))
52
+ return false;
53
+ return undefined;
54
+ }
55
+ // implements REQ-kibi-anonymous-usage-telemetry
56
+ /** Variables CI providers set; any non-false value marks a CI run. */
57
+ export const CI_ENV_KEYS = [
58
+ "CI",
59
+ "CONTINUOUS_INTEGRATION",
60
+ "GITHUB_ACTIONS",
61
+ "GITLAB_CI",
62
+ "BUILDKITE",
63
+ "CIRCLECI",
64
+ "TRAVIS",
65
+ "TF_BUILD",
66
+ "JENKINS_URL",
67
+ "TEAMCITY_VERSION",
68
+ "BITBUCKET_BUILD_NUMBER",
69
+ "CODEBUILD_BUILD_ID",
70
+ "DRONE",
71
+ "APPVEYOR",
72
+ "SEMAPHORE",
73
+ "BUILDKITE_BUILD_ID",
74
+ "CF_BUILD_ID",
75
+ "HEROKU_TEST_RUN_ID",
76
+ ];
77
+ // implements REQ-kibi-anonymous-usage-telemetry
78
+ /** Variables test runners set for the code under test (`NAME=value` must match). */
79
+ export const TEST_ENV_KEYS = [
80
+ "NODE_ENV=test",
81
+ "JEST_WORKER_ID",
82
+ "VITEST",
83
+ "VITEST_WORKER_ID",
84
+ "NODE_TEST_CONTEXT",
85
+ ];
86
+ function automationName(env, keys) {
87
+ for (const key of keys) {
88
+ const [name, expected] = key.split("=");
89
+ const value = env[name]?.trim();
90
+ if (!value)
91
+ continue;
92
+ if (expected !== undefined) {
93
+ if (value === expected)
94
+ return key;
95
+ continue;
96
+ }
97
+ if (flagValue(value) !== false)
98
+ return name;
99
+ }
100
+ return undefined;
101
+ }
102
+ // implements REQ-kibi-anonymous-usage-telemetry
103
+ /** Per-user directory holding the telemetry config and queue. */
104
+ export function telemetryDirectory(env = process.env) {
105
+ const explicit = env.KIBI_TELEMETRY_DIR?.trim();
106
+ if (explicit)
107
+ return explicit;
108
+ if (process.platform === "win32" && env.LOCALAPPDATA) {
109
+ return path.join(env.LOCALAPPDATA, "kibi");
110
+ }
111
+ const stateHome = env.XDG_STATE_HOME?.trim();
112
+ return path.join(stateHome || path.join(env.HOME || os.homedir(), ".local", "state"), "kibi");
113
+ }
114
+ // implements REQ-kibi-anonymous-usage-telemetry
115
+ export function readTelemetryConfig(env = process.env) {
116
+ try {
117
+ const parsed = JSON.parse(fs.readFileSync(path.join(telemetryDirectory(env), CONFIG_FILE), "utf8"));
118
+ return typeof parsed === "object" && parsed !== null
119
+ ? parsed
120
+ : {};
121
+ }
122
+ catch {
123
+ return {};
124
+ }
125
+ }
126
+ function writeTelemetryConfig(config, env) {
127
+ const directory = telemetryDirectory(env);
128
+ fs.mkdirSync(directory, { recursive: true });
129
+ fs.writeFileSync(path.join(directory, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`, "utf8");
130
+ }
131
+ // implements REQ-kibi-anonymous-usage-telemetry
132
+ /**
133
+ * Persist the user's choice. Disabling also deletes anything still queued,
134
+ * so nothing recorded before the choice is sent afterwards.
135
+ */
136
+ export function setTelemetryPreference(preference, env = process.env) {
137
+ writeTelemetryConfig({ ...readTelemetryConfig(env), preference }, env);
138
+ if (preference === "disabled")
139
+ clearTelemetryQueue(env);
140
+ }
141
+ // implements REQ-kibi-anonymous-usage-telemetry
142
+ /**
143
+ * Whether events are recorded and sent, and why. Explicit opt-outs win over
144
+ * everything. CI and test runs are off even after `kibi telemetry enable`;
145
+ * only an explicit `KIBI_TELEMETRY=1` sends from them.
146
+ */
147
+ export function telemetryStatus(env = process.env) {
148
+ const directory = telemetryDirectory(env);
149
+ const status = (enabled, reason) => ({
150
+ enabled,
151
+ reason,
152
+ directory,
153
+ });
154
+ const explicit = flagValue(env.KIBI_TELEMETRY);
155
+ if (explicit === false)
156
+ return status(false, "KIBI_TELEMETRY=0");
157
+ if (flagValue(env.DO_NOT_TRACK) === true) {
158
+ return status(false, "DO_NOT_TRACK=1");
159
+ }
160
+ const preference = readTelemetryConfig(env).preference;
161
+ if (preference === "disabled") {
162
+ return status(false, "disabled with `kibi telemetry disable`");
163
+ }
164
+ if (telemetryKey(env) === "")
165
+ return status(false, "no telemetry key");
166
+ if (explicit === true)
167
+ return status(true, "KIBI_TELEMETRY=1");
168
+ // Read through a variable: bundlers inline `process.env.NODE_ENV`.
169
+ const processEnv = process.env;
170
+ // CI and test runs are checked in both the given env and the process env,
171
+ // so a suite that passes its own env object cannot leak events either.
172
+ const ciName = automationName(env, CI_ENV_KEYS) ?? automationName(processEnv, CI_ENV_KEYS);
173
+ if (ciName)
174
+ return status(false, `CI environment (${ciName})`);
175
+ const testName = automationName(env, TEST_ENV_KEYS) ??
176
+ automationName(processEnv, TEST_ENV_KEYS);
177
+ if (testName)
178
+ return status(false, `test environment (${testName})`);
179
+ if (preference === "enabled") {
180
+ return status(true, "enabled with `kibi telemetry enable`");
181
+ }
182
+ return status(true, "on by default; anonymous, opt-out");
183
+ }
184
+ // implements REQ-kibi-anonymous-usage-telemetry
185
+ export function telemetryEnabled(env = process.env) {
186
+ return telemetryStatus(env).enabled;
187
+ }
188
+ function telemetryKey(env) {
189
+ const override = env.KIBI_TELEMETRY_KEY;
190
+ return (override === undefined ? DEFAULT_TELEMETRY_KEY : override).trim();
191
+ }
192
+ function telemetryHost(env) {
193
+ return (env.KIBI_TELEMETRY_HOST?.trim() || DEFAULT_TELEMETRY_HOST).replace(/\/+$/, "");
194
+ }
195
+ function installId(env) {
196
+ const config = readTelemetryConfig(env);
197
+ if (typeof config.installId === "string" && config.installId !== "") {
198
+ return config.installId;
199
+ }
200
+ const created = randomUUID();
201
+ try {
202
+ writeTelemetryConfig({ ...config, installId: created }, env);
203
+ }
204
+ catch {
205
+ // An unwritable directory still lets this process send with a fresh id.
206
+ }
207
+ return created;
208
+ }
209
+ // implements REQ-kibi-anonymous-usage-telemetry
210
+ /** Stable one-way hash, so a session can be grouped without revealing its id. */
211
+ export function hashTelemetryId(value) {
212
+ return createHash("sha256")
213
+ .update("kibi-telemetry\0")
214
+ .update(value)
215
+ .digest("hex")
216
+ .slice(0, 16);
217
+ }
218
+ // implements REQ-kibi-anonymous-usage-telemetry
219
+ /**
220
+ * The notice users see before the first event is sent, or null once it was
221
+ * shown. Callers that can reach the user (CLI and MCP stderr) print it.
222
+ */
223
+ export function takeTelemetryNotice(env = process.env) {
224
+ if (!telemetryEnabled(env))
225
+ return null;
226
+ const config = readTelemetryConfig(env);
227
+ if (config.noticeShownAt)
228
+ return null;
229
+ try {
230
+ writeTelemetryConfig({ ...config, noticeShownAt: new Date().toISOString() }, env);
231
+ }
232
+ catch {
233
+ // Without a writable config the notice would repeat on every run.
234
+ return null;
235
+ }
236
+ return [
237
+ "Kibi collects anonymous usage statistics (operation names, durations, error categories and versions) to improve Kibi.",
238
+ "It never sends code, file paths, queries, KB content or repository names.",
239
+ `Turn it off with \`kibi telemetry disable\` or KIBI_TELEMETRY=0. Details: ${TELEMETRY_DOCS_URL}`,
240
+ ].join("\n");
241
+ }
242
+ function runtimeName() {
243
+ return typeof globalThis.Bun === "undefined"
244
+ ? "node"
245
+ : "bun";
246
+ }
247
+ function baseProperties(source, env) {
248
+ const sessionId = source.sessionId?.trim();
249
+ return {
250
+ distinct_id: installId(env),
251
+ $process_person_profile: false,
252
+ $geoip_disable: true,
253
+ $lib: "kibi",
254
+ package: source.packageName,
255
+ package_version: source.packageVersion ?? null,
256
+ host: source.host?.trim() || "unknown",
257
+ session: sessionId ? hashTelemetryId(sessionId) : null,
258
+ os: process.platform,
259
+ arch: process.arch,
260
+ runtime: runtimeName(),
261
+ runtime_version: process.versions.node.split(".")[0] ?? null,
262
+ };
263
+ }
264
+ function queuePath(env) {
265
+ return path.join(telemetryDirectory(env), QUEUE_FILE);
266
+ }
267
+ function appendEvent(event, env) {
268
+ try {
269
+ const file = queuePath(env);
270
+ fs.mkdirSync(path.dirname(file), { recursive: true });
271
+ try {
272
+ if (fs.statSync(file).size > MAX_QUEUE_BYTES)
273
+ return;
274
+ }
275
+ catch {
276
+ // No queue yet.
277
+ }
278
+ fs.appendFileSync(file, `${JSON.stringify(event)}\n`, "utf8");
279
+ }
280
+ catch {
281
+ // Telemetry is best effort and must never disturb the caller.
282
+ }
283
+ }
284
+ // implements REQ-kibi-anonymous-usage-telemetry
285
+ export function recordOperationTelemetry(source, operation, env = process.env) {
286
+ if (!telemetryEnabled(env))
287
+ return;
288
+ appendEvent({
289
+ event: "kibi_operation",
290
+ timestamp: new Date().toISOString(),
291
+ properties: {
292
+ ...baseProperties(source, env),
293
+ interface: operation.interface,
294
+ operation: operation.operation,
295
+ status: operation.status,
296
+ duration_ms: Math.max(0, Math.round(operation.durationMs)),
297
+ error_category: operation.errorCategory ?? null,
298
+ exit_code: operation.exitCode ?? null,
299
+ },
300
+ }, env);
301
+ }
302
+ // implements REQ-kibi-anonymous-usage-telemetry
303
+ export function recordHookTelemetry(source, hook, env = process.env) {
304
+ if (!telemetryEnabled(env))
305
+ return;
306
+ appendEvent({
307
+ event: "kibi_hook",
308
+ timestamp: new Date().toISOString(),
309
+ properties: {
310
+ ...baseProperties(source, env),
311
+ hook_event: hook.hookEvent,
312
+ hook_action: hook.hookAction,
313
+ path_kind: hook.pathKind ?? null,
314
+ kb_operation: hook.kbOperation ?? null,
315
+ kb_used_before: hook.kbUsedBefore ?? null,
316
+ requirement_count: hook.requirementCount ?? null,
317
+ },
318
+ }, env);
319
+ }
320
+ // implements REQ-kibi-anonymous-usage-telemetry
321
+ export function clearTelemetryQueue(env = process.env) {
322
+ const directory = telemetryDirectory(env);
323
+ let names;
324
+ try {
325
+ names = fs.readdirSync(directory);
326
+ }
327
+ catch {
328
+ return;
329
+ }
330
+ for (const name of names) {
331
+ if (name === QUEUE_FILE || name.endsWith(SENDING_SUFFIX)) {
332
+ fs.rmSync(path.join(directory, name), { force: true });
333
+ }
334
+ }
335
+ }
336
+ function parseQueued(text, now) {
337
+ const events = [];
338
+ for (const line of text.split("\n")) {
339
+ if (line.trim() === "")
340
+ continue;
341
+ try {
342
+ const event = JSON.parse(line);
343
+ const age = now - Date.parse(event.timestamp);
344
+ if (Number.isFinite(age) && age <= MAX_EVENT_AGE_MS)
345
+ events.push(event);
346
+ }
347
+ catch {
348
+ // A torn line from a crashed writer is dropped.
349
+ }
350
+ }
351
+ return events;
352
+ }
353
+ function flushDue(file, now) {
354
+ let text;
355
+ try {
356
+ text = fs.readFileSync(file, "utf8");
357
+ }
358
+ catch {
359
+ return false;
360
+ }
361
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
362
+ if (lines.length === 0)
363
+ return false;
364
+ if (lines.length >= FLUSH_BATCH_EVENTS)
365
+ return true;
366
+ try {
367
+ const first = JSON.parse(lines[0] ?? "");
368
+ return now - Date.parse(first.timestamp) >= FLUSH_MAX_AGE_MS;
369
+ }
370
+ catch {
371
+ return true;
372
+ }
373
+ }
374
+ /** Atomically take the queue and any batch a dead process left behind. */
375
+ function claimBatches(directory, now) {
376
+ const claimed = [];
377
+ let names;
378
+ try {
379
+ names = fs.readdirSync(directory);
380
+ }
381
+ catch {
382
+ return claimed;
383
+ }
384
+ for (const name of names) {
385
+ if (!name.endsWith(SENDING_SUFFIX))
386
+ continue;
387
+ const file = path.join(directory, name);
388
+ try {
389
+ if (now - fs.statSync(file).mtimeMs < STALE_CLAIM_MS)
390
+ continue;
391
+ const reclaimed = `${file}.${randomUUID()}${SENDING_SUFFIX}`;
392
+ fs.renameSync(file, reclaimed);
393
+ claimed.push(reclaimed);
394
+ }
395
+ catch {
396
+ // Another process reclaimed it first.
397
+ }
398
+ }
399
+ const queue = path.join(directory, QUEUE_FILE);
400
+ const target = path.join(directory, `${QUEUE_FILE}.${process.pid}.${randomUUID()}${SENDING_SUFFIX}`);
401
+ try {
402
+ fs.renameSync(queue, target);
403
+ claimed.push(target);
404
+ }
405
+ catch {
406
+ // Nothing queued, or another process is sending it.
407
+ }
408
+ return claimed;
409
+ }
410
+ // implements REQ-kibi-anonymous-usage-telemetry
411
+ /**
412
+ * Send queued events in one batch when a flush is due (or forced). Returns
413
+ * the number of events sent. A failed send puts the events back for the next
414
+ * flush; nothing here throws.
415
+ */
416
+ export async function flushTelemetry(options = {}, env = process.env) {
417
+ if (!telemetryEnabled(env))
418
+ return 0;
419
+ const directory = telemetryDirectory(env);
420
+ const now = Date.now();
421
+ if (!options.force && !flushDue(queuePath(env), now))
422
+ return 0;
423
+ const claimed = claimBatches(directory, now);
424
+ if (claimed.length === 0)
425
+ return 0;
426
+ const events = [];
427
+ for (const file of claimed) {
428
+ try {
429
+ events.push(...parseQueued(fs.readFileSync(file, "utf8"), now));
430
+ }
431
+ catch {
432
+ // Unreadable batch: drop it.
433
+ }
434
+ }
435
+ const release = () => {
436
+ for (const file of claimed)
437
+ fs.rmSync(file, { force: true });
438
+ };
439
+ if (events.length === 0) {
440
+ release();
441
+ return 0;
442
+ }
443
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
444
+ const controller = new AbortController();
445
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS);
446
+ timer.unref?.();
447
+ try {
448
+ const response = await fetchImpl(`${telemetryHost(env)}/batch/`, {
449
+ method: "POST",
450
+ headers: { "content-type": "application/json" },
451
+ body: JSON.stringify({
452
+ api_key: telemetryKey(env),
453
+ historical_migration: false,
454
+ batch: events,
455
+ }),
456
+ signal: controller.signal,
457
+ });
458
+ // A 4xx means PostHog will never accept this batch; drop it.
459
+ if (!response.ok && response.status >= 500)
460
+ throw new Error("retry");
461
+ release();
462
+ return response.ok ? events.length : 0;
463
+ }
464
+ catch {
465
+ release();
466
+ for (const event of events)
467
+ appendEvent(event, env);
468
+ return 0;
469
+ }
470
+ finally {
471
+ clearTimeout(timer);
472
+ }
473
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Shared low-latency hook helpers for Kibi agent adapters",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/requirement-candidates.d.ts",
35
35
  "default": "./dist/requirement-candidates.js"
36
36
  },
37
+ "./usage-telemetry": {
38
+ "types": "./dist/usage-telemetry.d.ts",
39
+ "default": "./dist/usage-telemetry.js"
40
+ },
37
41
  "./snippets": {
38
42
  "types": "./dist/snippets.d.ts",
39
43
  "default": "./dist/snippets.js"