exe-watcher 0.2.69 → 0.2.71

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exe-watcher",
3
- "version": "0.2.69",
4
- "description": "The owl watching your AI coding spend — cost, tokens, agents, and projects in your terminal and menu bar",
3
+ "version": "0.2.71",
4
+ "description": "The owl watching your AI coding spend \u2014 cost, tokens, agents, and projects in your terminal and menu bar",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
7
7
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "vendor/odd-core-analytics",
12
13
  "THIRD_PARTY_NOTICES.md",
13
14
  "!dist/**/*.map"
14
15
  ],
@@ -57,7 +58,8 @@
57
58
  "tsup": "^8.4.0",
58
59
  "tsx": "^4.19.0",
59
60
  "typescript": "^5.8.0",
60
- "vitest": "^3.1.0"
61
+ "vitest": "^3.1.0",
62
+ "@odd-core/analytics": "file:vendor/odd-core-analytics"
61
63
  },
62
64
  "publishConfig": {
63
65
  "registry": "https://registry.npmjs.org"
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OddBold LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ {
2
+ "source": "OddCommunity/odd-core-kits/packages/analytics",
3
+ "revision": "3fb6fb095a1f9b1b8601965df58f08521fc38f7f",
4
+ "version": "0.2.0",
5
+ "dirty": false,
6
+ "compiler": "5.9.3",
7
+ "sourceHashes": {
8
+ "package.json": "5163e68c17ac1b0296c6d2a9c556e2847ad0a961d66dc072f9810e587d343ead",
9
+ "README.md": "a6785accc7fafbe5355cc01d9876f47d12d290b15568ff92890f98c258f9eeb6",
10
+ "LICENSE": "7a9af43e5df34f24605f6d7ea7613b0982429ab12d09150a76ed681781bd964f",
11
+ "gateway/server.mjs": "3cacb4903bbbfecc38f2c14152ace487001c3e4e362c3ca07b086b2dc279adf2",
12
+ "src/privacy/index.ts": "fcd05058ad316d87938248edfb4fd7a9a1305c3176201027e602f60957455a2b",
13
+ "src/privacy/client.ts": "facac772a0c4767304f7e0564af387555144ed61800257ab996801be90454ddc",
14
+ "src/privacy/schema.ts": "a5a8ed885d6f7674d9449e66f69f2a4debda937b96a0642d97c8b4d37e5d2dd8"
15
+ },
16
+ "hashes": {
17
+ "README.md": "a6785accc7fafbe5355cc01d9876f47d12d290b15568ff92890f98c258f9eeb6",
18
+ "LICENSE": "7a9af43e5df34f24605f6d7ea7613b0982429ab12d09150a76ed681781bd964f",
19
+ "gateway/server.mjs": "3cacb4903bbbfecc38f2c14152ace487001c3e4e362c3ca07b086b2dc279adf2",
20
+ "dist/privacy/index.js": "f345eebe3947115469d4b84361d17583165c77901388e5afd668bf8442fda561",
21
+ "dist/privacy/index.d.ts": "fcd05058ad316d87938248edfb4fd7a9a1305c3176201027e602f60957455a2b",
22
+ "dist/privacy/client.js": "a476348c2934a331de8f5eea70c2a11ff3610e46ead0b0a9f334f9ef5b894564",
23
+ "dist/privacy/client.d.ts": "cae6e898f34ee1b3c0b04087b1c66b5abd2b9ef4c409aecfe925ee23b3e5c7e8",
24
+ "dist/privacy/schema.js": "e850805166766fd36dc05b71915d0e64a4f64c32fd0b21c3086bb17e778ff510",
25
+ "dist/privacy/schema.d.ts": "babe50be7c11c0ef016cb442cc195d5584bd98dfbf9a7a81ef99a80b5460ebbc",
26
+ "package.json": "ab60af99e145369ff8819a8c8b161658fe1efb7edec7d4b39a44d592e77d12e7"
27
+ }
28
+ }
@@ -0,0 +1,196 @@
1
+ # Shared private PostHog + Sentry integration
2
+
3
+ Use **`@odd-core/analytics/privacy`** for Odd's self-hosted counter/error collector.
4
+ It is separate from the older generic API at `@odd-core/analytics` and from
5
+ `@odd-core/telemetry`; those APIs do not enforce this wire protocol or its defaults.
6
+ The privacy entry point has no runtime dependencies or provider SDKs. Its source
7
+ and the matching Node gateway are MIT licensed. This change does not publish npm
8
+ artifacts or make the containing repository public.
9
+
10
+ ## New app setup
11
+
12
+ 1. Create separate PostHog and Sentry projects for the app. A CLI and a native app
13
+ may use separate Sentry projects/aliases. Reuse the existing **build-my** server.
14
+ 2. Register public aliases, the app slug, and static feature/source allowlists in
15
+ the gateway's private maps (see below). Never copy Hansel's project IDs/aliases.
16
+ 3. Install/link `@odd-core/analytics` through the app's normal dependency process.
17
+ Build it with `tsc -b packages/types packages/analytics` from this repository.
18
+ In this workspace, use `"@odd-core/analytics": "workspace:*"`.
19
+ 4. Supply private storage and a cryptographic UUID provider, then expose **two
20
+ independent opt-in controls**, both initially off. Keys/configuration alone
21
+ never constitute consent. Update the app's privacy policy/store disclosures.
22
+ 5. Hook explicit features, foreground/idle state, real recording completion, and
23
+ handled structural errors to the client. Schedule `flush()` on a bounded timer
24
+ and lifecycle transitions. No timers or error hooks start automatically.
25
+ 6. Verify real local app usage and one harmless handled error in the app's own
26
+ dashboards. Check opt-out, no pre-consent history, retry IDs, and tenant routing
27
+ before the app's normal release process. No paid/cloud build is required here.
28
+
29
+ ```ts
30
+ import { ActiveClock, PrivacyClient } from '@odd-core/analytics/privacy';
31
+
32
+ const analytics = await PrivacyClient.create({
33
+ app: 'watcher',
34
+ usageAlias: 'public-watcher-v1', // provision first; illustrative values
35
+ diagnosticsAlias: 'public-watcher-cli-v1',
36
+ features: ['home', 'menu'], // static developer-owned list, not user text
37
+ storage: privateAppStorage, // getItem/setItem, atomic durable replacement
38
+ randomUUID: () => crypto.randomUUID(),
39
+ // collector defaults to https://data.odd.community
40
+ });
41
+
42
+ // Only after the user accepts the relevant disclosure:
43
+ await analytics.setConsent({ usage: true });
44
+ await analytics.track({ event: 'active' }); // startup/return-to-active, not background polls
45
+ await analytics.track({ event: 'feature', feature: 'menu' });
46
+
47
+ const clock = new ActiveClock();
48
+ // Sample about every 5 seconds using a monotonic clock and actual app state.
49
+ // Skip/reset the clock while consent is off, after consent changes, and on sleep.
50
+ if (analytics.status().usage) {
51
+ const active_ms = clock.sample(performance.now(), isFocused, idleSeconds, isRecording);
52
+ if (active_ms > 0) await analytics.track({ event: 'usage', active_ms });
53
+ } else clock.reset();
54
+
55
+ // Recording apps only: duration from the actual completed recording, no audio/text.
56
+ await analytics.track({ event: 'recording', recording_ms: 3700 });
57
+
58
+ // Separate diagnostics consent, followed by a safe structural report.
59
+ await analytics.setConsent({ diagnostics: true });
60
+ await analytics.report({ event: 'error', category: 'ipc_error',
61
+ error_class: 'RangeError', version: '1.0.0', frames: [] });
62
+ await analytics.flush();
63
+
64
+ // On revocation: abort pending requests, erase the channel queue and usage ID.
65
+ await analytics.setConsent({ usage: false });
66
+ clock.reset();
67
+ ```
68
+
69
+ `setConsent`, `track`, and `report` return a boolean: check it when displaying
70
+ settings or troubleshooting persistence. A storage failure disables both channels
71
+ in memory. If a revocation cannot be persisted, the app must keep collection off
72
+ on subsequent launches until it can durably save the choice. One client instance
73
+ owns each app state store; don't share a writable queue across processes/tabs.
74
+ Already accepted server events cannot be recalled by aborting a request.
75
+
76
+ ## Platform adapters
77
+
78
+ | Platform | Adapter responsibilities |
79
+ | --- | --- |
80
+ | Node CLI / Electron main | Owner-only atomic file replacement (directory `0700`, files `0600`), `node:crypto.randomUUID`, explicit lifecycle flush. Renderer requests cross a narrow, validated IPC boundary. Suppress background child CLI polling. |
81
+ | Web | App-scoped storage, Web Crypto UUIDs, visibility/focus/idle sampling. Configure exact CORS origins on the gateway and ingress. Cross-origin cookies are omitted. Browser storage is accessible to same-origin code. |
82
+ | React Native / Expo | Durable app-scoped storage, platform cryptographic UUIDs, foreground state and actual activity/idle signals. Keep Node modules out of the mobile bundle. |
83
+ | Native Swift/Kotlin/Dart | Implement the small protocol below or bridge this client; JavaScript cannot be imported directly into Swift. Reuse the same consent/storage/lifecycle rules and gateway allowlists. |
84
+
85
+ Never install global native crash handlers or send minidumps/raw crash reports as
86
+ part of this kit. Sentry gets explicit sanitized structural errors. There are no
87
+ automatic breadcrumbs, network traces, replay, screenshots, account identification,
88
+ device fingerprints, prompt/transcript/audio collection, or Google analytics.
89
+ DAU counts consenting installations, not authenticated people. Recording minutes
90
+ are **N/A** for apps without a recording feature. Metric days are UTC in the pilot.
91
+ IP addresses necessarily reach the collection network edge; the gateway does not
92
+ forward incoming IP/user-agent/cookies to PostHog or Sentry. Edge logging and
93
+ retention are operator responsibilities; pseudonymous analytics is still data.
94
+
95
+ ## Persistence and transport contract
96
+
97
+ Both queues are capped at **500 events**, expire after **7 days**, and are
98
+ re-sanitized on load. UUID/timestamp are assigned once before retries, preserving
99
+ the backend deduplication key. Each flush sends at most 50 usage and 10 errors,
100
+ with 5s/10s timeouts. Failures retain safe events for the next scheduled attempt.
101
+ Re-enabling usage after revocation creates a fresh random installation UUID.
102
+ The state is bound to collector/app/aliases so a config change cannot reroute an
103
+ old queue. App adapters must not backfill history or generate synthetic activity.
104
+
105
+ `POST /batch/`, JSON content type:
106
+
107
+ ```json
108
+ {"api_key":"public-watcher-v1","batch":[{
109
+ "uuid":"a1c01189-72bf-4f63-b6e6-9e940e87ebc7","timestamp":"2026-10-23T12:00:00.000Z",
110
+ "event":"app_feature","properties":{
111
+ "distinct_id":"a1c01189-72bf-4f63-b6e6-9e940e87ebc8","app":"watcher","feature":"menu"
112
+ }
113
+ }]}
114
+ ```
115
+
116
+ Usage events: `app_active` (no counter), `app_usage` (`active_ms`, integer 1–60000),
117
+ `app_recording` (`recording_ms`, integer 1–86400000), `app_feature` (registered enum).
118
+ `POST /errors/`: same envelope/UUID/timestamp but each row has `diagnostic` instead
119
+ of `event`/`properties`. Diagnostic fields: `event: "error"`, fixed `category`,
120
+ standard JS `error_class` (unknown becomes `Error`), numeric `x.y.z` version, and
121
+ up to 8 registered logical frames with bounded integer line/column numbers.
122
+ Never put a raw exception object, message, stack string, arbitrary properties,
123
+ filesystem path, attachment, user identity, or DSN into either payload.
124
+
125
+ ## Gateway and provisioning
126
+
127
+ The dependency-free gateway is `@odd-core/analytics/gateway` (ESM, Node 20+).
128
+ Run `node node_modules/@odd-core/analytics/gateway/server.mjs` in the private
129
+ deployment. Server-only configuration, supplied as mounted owner-only JSON files:
130
+
131
+ ```json
132
+ // PROJECT_TOKENS_FILE
133
+ {"public-watcher-v1":{"app":"watcher","token":"PRIVATE_POSTHOG_PROJECT_TOKEN","features":["home","menu"]}}
134
+ // SENTRY_PROJECTS_FILE
135
+ {"public-watcher-cli-v1":{"app":"watcher","project_id":7,"public_key":"32_HEX_CHAR_PUBLIC_KEY","platform":"javascript","frames":["app:///main/index.js"]}}
136
+ ```
137
+
138
+ Those aliases/IDs are examples, not provisioned projects. Maps route each alias
139
+ to a single backend project; caller-supplied project IDs, tokens, and properties
140
+ cannot select another tenant. Copy only public aliases into app configuration.
141
+ The server supports per-project `features`/`frames`; omitted lists preserve the
142
+ Hansel pilot defaults. The pilot gateway currently deployed in Hansel predates
143
+ these per-app lists: validate and deploy the shared gateway before using new
144
+ Watcher feature/source enums. Existing Hansel payloads remain compatible.
145
+
146
+ Optional `ALLOWED_ORIGINS` is a JSON array of exact web origins; none by default.
147
+ Unlisted Origin headers are rejected. Permit OPTIONS for only the two intake
148
+ routes at ingress when enabling web apps; do not expose dashboard routes.
149
+ The process binds port 8080; provision private upstreams via `POSTHOG_UPSTREAM`
150
+ (default `http://proxy:80/batch/`) and `SENTRY_UPSTREAM` (`http://sentry-ingress`).
151
+ Standalone mode restricts upstream hostnames to the private service names/local
152
+ host; never configure a hosted provider fallback. Rate/body/connection limits
153
+ remain enabled. `GET /health` does not prove end-to-end ingestion.
154
+
155
+ Existing infrastructure runbooks and provisioning helpers are in
156
+ `/Users/exeai/Hansel/docs/analytics/`: `APP_SETUP.md`, `STATUS.md`, `SENTRY.md`,
157
+ `provision-hansel.py`, `provision-sentry.py`, `create-dashboard.py`,
158
+ `connect-sentry-intake.py`, and `backup/README.md`. Adapt scripts without replacing
159
+ Hansel mappings. Actual credentials stay on the server, outside source control.
160
+ Dashboards: PostHog `https://build-my.tail910c31.ts.net`, Sentry
161
+ `https://build-my.tail910c31.ts.net:8443` (Tailscale + Tailscale DNS).
162
+ Encrypted daily restic backups already use Cloudflare R2, seven-day retention.
163
+
164
+ ## Checks and adoption status
165
+
166
+ `pnpm --filter @odd-core/analytics test` builds the package and runs the privacy,
167
+ wire-contract, project isolation, CORS, and gateway tests without live services.
168
+ `pnpm --filter @odd-core/analytics test:privacy` runs against an existing build.
169
+ The legacy API remains available; adding this entry point does not automatically
170
+ migrate apps, deploy the gateway, create projects, or qualify Swift/mobile builds.
171
+ Hansel's original integration has live dogfood evidence; this shared extraction
172
+ requires each consuming app's normal integration checks before rollout.
173
+
174
+ ## Standard basic analytics (0.2.0)
175
+
176
+ Keep collection off until the person explicitly accepts a clear first-run choice.
177
+ Offer **Share Basic Analytics** and **Not Now**. An unchecked optional diagnostics
178
+ choice can share the same screen, while usage/errors remain independently
179
+ controllable in Settings. Persist a decline; never repeatedly prompt or silently
180
+ turn on a channel after an upgrade. Existing approved choices remain approved.
181
+ Apps must obtain their required platform/privacy permissions before collection.
182
+
183
+ The standard product dataset is consenting-installation DAU/WAU/MAU, foreground
184
+ active duration, real recording duration where applicable, allowlisted feature
185
+ counts/outcomes, numeric app version and coarse platform. `USAGE_DISCLOSURE` and
186
+ `DIAGNOSTICS_DISCLOSURE` provide shared field descriptions. Supply static
187
+ `appVersion: '1.0.48'` and `platform: 'macos'` to PrivacyClient; platform is one of
188
+ macos/windows/linux/ios/android/web. Neither field comes from arbitrary event data.
189
+ Feature results are `{ event: 'result', feature: 'menu', outcome: 'success' }`
190
+ (or `failure`), sent as app_result. No error text accompanies a failure counter.
191
+ The gateway independently validates metadata and results. Deploy that capability
192
+ before enabling result events in clients; old payloads remain compatible.
193
+
194
+ DAU/WAU/MAU use the same per-app random installation ID with UTC day/7-day/30-day
195
+ windows; no account or cross-app ID is necessary. Existing 30-day raw retention
196
+ limits the rolling monthly window. Counts cover consenting installations only.
@@ -0,0 +1,58 @@
1
+ import { PLATFORMS } from './schema';
2
+ export type Consent = {
3
+ usage: boolean;
4
+ diagnostics: boolean;
5
+ };
6
+ export interface PrivateStorage {
7
+ getItem(key: string): Promise<string | null>;
8
+ /** Must replace atomically. Native adapters must use owner-only files/encrypted app storage. */
9
+ setItem(key: string, value: string): Promise<void>;
10
+ }
11
+ export interface PrivacyOptions {
12
+ app: string;
13
+ /** Public gateway aliases, never PostHog admin tokens or a Sentry DSN. */
14
+ usageAlias: string;
15
+ diagnosticsAlias: string;
16
+ features: readonly string[];
17
+ appVersion?: string;
18
+ platform?: typeof PLATFORMS[number];
19
+ /** Static logical source names, registered in the gateway; never filesystem paths. */
20
+ frames?: readonly string[];
21
+ collector?: string;
22
+ storage: PrivateStorage;
23
+ /** Cryptographically random UUID provider (crypto.randomUUID, expo-crypto, etc.). */
24
+ randomUUID: () => string;
25
+ send?: typeof fetch;
26
+ now?: () => number;
27
+ }
28
+ /** No SDK autocapture, user context, content strings, cookies, or provider fallback. */
29
+ export declare class PrivacyClient {
30
+ private readonly options;
31
+ private state;
32
+ private serial;
33
+ private generation;
34
+ private sending;
35
+ private readonly origin;
36
+ private readonly key;
37
+ private readonly now;
38
+ private readonly features;
39
+ private readonly frames;
40
+ private constructor();
41
+ static create(options: PrivacyOptions): Promise<PrivacyClient>;
42
+ private binding;
43
+ private fresh;
44
+ private sanitize;
45
+ private uuid;
46
+ private run;
47
+ private persist;
48
+ status(): Consent;
49
+ /** Call only after the app presents disclosure and records an explicit user choice. */
50
+ setConsent(update: Partial<Consent>): Promise<boolean>;
51
+ track(input: unknown): Promise<boolean>;
52
+ report(input: unknown): Promise<boolean>;
53
+ private enqueue;
54
+ /** One bounded batch per channel; schedule retries in the app's lifecycle. */
55
+ flush(): Promise<void>;
56
+ private flushKind;
57
+ abort(): void;
58
+ }
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PrivacyClient = void 0;
4
+ const schema_1 = require("./schema");
5
+ const WEEK = 7 * 86400000;
6
+ const empty = () => ({ consent: { usage: false, diagnostics: false }, id: '', usage: [], diagnostics: [] });
7
+ /** No SDK autocapture, user context, content strings, cookies, or provider fallback. */
8
+ class PrivacyClient {
9
+ constructor(options) {
10
+ this.options = options;
11
+ this.state = empty();
12
+ this.serial = Promise.resolve();
13
+ this.generation = { usage: 0, diagnostics: 0 };
14
+ this.sending = {};
15
+ this.options = { ...options };
16
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(options.app) ||
17
+ ![options.usageAlias, options.diagnosticsAlias].every(v => /^[a-zA-Z0-9_-]{1,128}$/.test(v)) ||
18
+ options.features.length > 64 || !options.features.every(v => /^[a-z][a-z0-9_]{0,63}$/.test(v)))
19
+ throw new Error('Invalid static app configuration');
20
+ if (options.appVersion !== undefined && !/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(options.appVersion))
21
+ throw new Error('Invalid app version');
22
+ if (options.platform !== undefined && !schema_1.PLATFORMS.includes(options.platform))
23
+ throw new Error('Invalid platform');
24
+ this.features = [...options.features];
25
+ this.frames = [...(options.frames ?? schema_1.DEFAULT_FRAMES)];
26
+ if (this.frames.length > 16 || !this.frames.every(v => /^app:\/\/\/[a-zA-Z0-9_/-]+\.(js|ts|swift|kt|dart)$/.test(v) && !v.includes('..')))
27
+ throw new Error('Invalid logical source configuration');
28
+ this.origin = (0, schema_1.collectorOrigin)(options.collector ?? schema_1.DEFAULT_COLLECTOR);
29
+ this.key = `odd-privacy-v1:${options.app}`;
30
+ this.now = options.now ?? Date.now;
31
+ }
32
+ static async create(options) {
33
+ const client = new PrivacyClient(options);
34
+ try {
35
+ const input = (0, schema_1.record)(JSON.parse(await options.storage.getItem(client.key) ?? 'null'));
36
+ // Binding prevents old queued data being routed to a different project's aliases.
37
+ if (!input || input.binding !== client.binding())
38
+ return client;
39
+ const consent = (0, schema_1.record)(input.consent);
40
+ client.state.consent.diagnostics = consent?.diagnostics === true;
41
+ client.state.consent.usage = consent?.usage === true && typeof input.id === 'string' && schema_1.UUID.test(input.id);
42
+ client.state.id = client.state.consent.usage ? input.id : '';
43
+ for (const kind of ['usage', 'diagnostics']) {
44
+ if (!client.state.consent[kind] || !Array.isArray(input[kind]))
45
+ continue;
46
+ for (const raw of input[kind].slice(-500)) {
47
+ const row = (0, schema_1.record)(raw);
48
+ const metric = client.sanitize(kind, row?.metric);
49
+ if (row && metric && typeof row.uuid === 'string' && schema_1.UUID.test(row.uuid) && client.fresh(row.timestamp)) {
50
+ client.state[kind].push({ uuid: row.uuid, timestamp: new Date(Date.parse(row.timestamp)).toISOString(), metric });
51
+ }
52
+ }
53
+ }
54
+ // Remove expired/malformed fields from disk too, not only from memory.
55
+ await client.persist();
56
+ }
57
+ catch {
58
+ client.state = empty();
59
+ }
60
+ return client;
61
+ }
62
+ binding() { return JSON.stringify([this.origin, this.options.app, this.options.usageAlias, this.options.diagnosticsAlias]); }
63
+ fresh(time) {
64
+ const value = typeof time === 'string' ? Date.parse(time) : NaN;
65
+ return Number.isFinite(value) && value <= this.now() && this.now() - value < WEEK;
66
+ }
67
+ sanitize(kind, input) {
68
+ return kind === 'usage' ? (0, schema_1.sanitizeMetric)(input, this.features) : (0, schema_1.sanitizeDiagnostic)(input, this.frames);
69
+ }
70
+ uuid() {
71
+ const id = this.options.randomUUID();
72
+ if (typeof id !== 'string' || !schema_1.UUID.test(id))
73
+ throw new Error('A cryptographic UUID provider is required');
74
+ return id;
75
+ }
76
+ run(fn) {
77
+ const next = this.serial.then(fn);
78
+ this.serial = next.catch(() => undefined);
79
+ return next;
80
+ }
81
+ async persist() {
82
+ try {
83
+ await this.options.storage.setItem(this.key, JSON.stringify({ binding: this.binding(), ...this.state }));
84
+ return true;
85
+ }
86
+ catch {
87
+ this.state = empty();
88
+ this.abort();
89
+ return false;
90
+ }
91
+ }
92
+ status() { return { ...this.state.consent }; }
93
+ /** Call only after the app presents disclosure and records an explicit user choice. */
94
+ setConsent(update) {
95
+ // Abort immediately, including requests blocked behind a slow storage write.
96
+ for (const kind of ['usage', 'diagnostics'])
97
+ if (update[kind] === false) {
98
+ this.generation[kind]++;
99
+ this.sending[kind]?.abort();
100
+ }
101
+ return this.run(async () => {
102
+ try {
103
+ for (const kind of ['usage', 'diagnostics'])
104
+ if (typeof update[kind] === 'boolean' && this.state.consent[kind] !== update[kind]) {
105
+ this.generation[kind]++;
106
+ this.sending[kind]?.abort();
107
+ this.state.consent[kind] = update[kind];
108
+ this.state[kind] = [];
109
+ if (kind === 'usage')
110
+ this.state.id = update[kind] ? this.uuid() : '';
111
+ }
112
+ return await this.persist();
113
+ }
114
+ catch {
115
+ this.state = empty();
116
+ this.abort();
117
+ return false;
118
+ }
119
+ });
120
+ }
121
+ track(input) { return this.enqueue('usage', input); }
122
+ report(input) { return this.enqueue('diagnostics', input); }
123
+ enqueue(kind, input) {
124
+ // Sanitize now so caller mutation cannot add content while awaiting storage.
125
+ const metric = this.sanitize(kind, input);
126
+ const generation = this.generation[kind];
127
+ const allowed = this.state.consent[kind];
128
+ return this.run(async () => {
129
+ if (!allowed || !this.state.consent[kind] || generation !== this.generation[kind] || !metric)
130
+ return false;
131
+ try {
132
+ this.state[kind] = this.state[kind].filter(row => this.fresh(row.timestamp)).slice(-499);
133
+ this.state[kind].push({ uuid: this.uuid(), timestamp: new Date(this.now()).toISOString(), metric });
134
+ return await this.persist();
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ });
140
+ }
141
+ /** One bounded batch per channel; schedule retries in the app's lifecycle. */
142
+ async flush() { await Promise.all([this.flushKind('usage'), this.flushKind('diagnostics')]); }
143
+ async flushKind(kind) {
144
+ const requestedGeneration = this.generation[kind];
145
+ const pending = await this.run(async () => {
146
+ if (this.sending[kind] || !this.state.consent[kind] || requestedGeneration !== this.generation[kind])
147
+ return null;
148
+ const before = this.state[kind].length;
149
+ this.state[kind] = this.state[kind].filter(row => this.fresh(row.timestamp));
150
+ if (before !== this.state[kind].length && !await this.persist())
151
+ return null;
152
+ const batch = this.state[kind].slice(0, kind === 'usage' ? 50 : 10);
153
+ if (!batch.length)
154
+ return null;
155
+ const controller = new AbortController();
156
+ this.sending[kind] = controller;
157
+ return { batch, controller, generation: this.generation[kind], id: this.state.id };
158
+ });
159
+ if (!pending)
160
+ return;
161
+ const { batch, controller, generation, id } = pending;
162
+ const timer = setTimeout(() => controller.abort(), kind === 'usage' ? 5000 : 10000);
163
+ try {
164
+ if (controller.signal.aborted)
165
+ return;
166
+ const response = await (this.options.send ?? fetch)(`${this.origin}/${kind === 'usage' ? 'batch' : 'errors'}/`, {
167
+ method: 'POST', redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal,
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ api_key: kind === 'usage' ? this.options.usageAlias : this.options.diagnosticsAlias,
170
+ batch: batch.map(row => kind === 'diagnostics' ? { uuid: row.uuid, timestamp: row.timestamp, diagnostic: row.metric } : {
171
+ uuid: row.uuid, timestamp: row.timestamp, event: `app_${row.metric.event}`,
172
+ properties: { distinct_id: id, app: this.options.app, ...row.metric, ...(this.options.appVersion ? { app_version: this.options.appVersion } : {}),
173
+ ...(this.options.platform ? { app_platform: this.options.platform } : {}), $process_person_profile: false, $geoip_disable: true },
174
+ }) }),
175
+ });
176
+ if (response.ok)
177
+ await this.run(async () => {
178
+ if (generation !== this.generation[kind])
179
+ return;
180
+ const sent = new Set(batch.map(row => row.uuid));
181
+ this.state[kind] = this.state[kind].filter(row => !sent.has(row.uuid));
182
+ await this.persist();
183
+ });
184
+ await response.body?.cancel();
185
+ }
186
+ catch { /* Retry the same IDs later; never log payloads or response bodies. */ }
187
+ finally {
188
+ clearTimeout(timer);
189
+ if (this.sending[kind] === controller)
190
+ delete this.sending[kind];
191
+ }
192
+ }
193
+ abort() { this.sending.usage?.abort(); this.sending.diagnostics?.abort(); }
194
+ }
195
+ exports.PrivacyClient = PrivacyClient;
@@ -0,0 +1,2 @@
1
+ export * from './schema';
2
+ export * from './client';
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./schema"), exports);
18
+ __exportStar(require("./client"), exports);
@@ -0,0 +1,46 @@
1
+ /** MIT. Counters and structural errors only; never forward caller property bags. */
2
+ export declare const DEFAULT_COLLECTOR = "https://data.odd.community";
3
+ export declare const HANSEL_FEATURES: readonly ["home", "recordings", "vocabulary", "shortcuts", "dictation"];
4
+ export declare const ERROR_CATEGORIES: readonly ["crash", "unhandled_exception", "render_error", "ipc_error", "ml_error"];
5
+ export declare const DEFAULT_FRAMES: readonly ["app:///main/index.js", "app:///renderer/index.js"];
6
+ export declare const USAGE_DISCLOSURE = "Share active time, recording duration, feature counts and outcomes, app version and platform with our own servers. A random ID unique to this app measures returning installations. Never audio, transcripts, prompts, account details or screen recordings.";
7
+ export declare const DIAGNOSTICS_DISCLOSURE = "Share sanitized error categories, app version and code locations with our own Sentry server. Never error text, account details, recordings or memory dumps.";
8
+ export declare const PLATFORMS: readonly ["macos", "windows", "linux", "ios", "android", "web"];
9
+ export type Metric = {
10
+ event: 'active';
11
+ } | {
12
+ event: 'usage';
13
+ active_ms: number;
14
+ } | {
15
+ event: 'recording';
16
+ recording_ms: number;
17
+ } | {
18
+ event: 'feature';
19
+ feature: string;
20
+ } | {
21
+ event: 'result';
22
+ feature: string;
23
+ outcome: 'success' | 'failure';
24
+ };
25
+ export type Diagnostic = {
26
+ event: 'error';
27
+ category: typeof ERROR_CATEGORIES[number];
28
+ error_class: string;
29
+ version: string;
30
+ frames: {
31
+ filename: string;
32
+ lineno: number;
33
+ colno: number;
34
+ }[];
35
+ };
36
+ export declare const UUID: RegExp;
37
+ export declare function record(input: unknown): Record<string, unknown> | null;
38
+ export declare function sanitizeMetric(input: unknown, features?: readonly string[]): Metric | null;
39
+ export declare function sanitizeDiagnostic(input: unknown, allowedFrames?: readonly string[]): Diagnostic | null;
40
+ export declare function collectorOrigin(input: string): string;
41
+ /** Sample with a monotonic clock; reset at consent/focus lifecycle boundaries. */
42
+ export declare class ActiveClock {
43
+ private previous;
44
+ sample(time: number, focused: boolean, idleSeconds: number, recording?: boolean): number;
45
+ reset(): void;
46
+ }