sonorance 0.1.0-beta.4.1

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.
Files changed (43) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +103 -0
  3. package/build/icon.png +0 -0
  4. package/package.json +86 -0
  5. package/skill/SKILL.md +41 -0
  6. package/skill/scripts/sonorance.mjs +31 -0
  7. package/src/azure-monitor.mjs +221 -0
  8. package/src/cli.mjs +315 -0
  9. package/src/comment-client.mjs +75 -0
  10. package/src/engine-default.mjs +136 -0
  11. package/src/feedback.mjs +151 -0
  12. package/src/gitignore.mjs +27 -0
  13. package/src/grammar.mjs +55 -0
  14. package/src/identity.mjs +126 -0
  15. package/src/otlp.mjs +166 -0
  16. package/src/plugins/deliberate/contribute.mjs +28 -0
  17. package/src/plugins/deliberate/domain.mjs +40 -0
  18. package/src/plugins/deliberate/frontmatter.mjs +85 -0
  19. package/src/plugins/deliberate/gitignore.mjs +21 -0
  20. package/src/plugins/deliberate/kinds.mjs +44 -0
  21. package/src/plugins/deliberate/markdown.mjs +42 -0
  22. package/src/plugins/deliberate/paths.mjs +245 -0
  23. package/src/plugins/deliberate/stages.mjs +27 -0
  24. package/src/plugins/deliberate/vault.mjs +1043 -0
  25. package/src/plugins.mjs +91 -0
  26. package/src/release-config.mjs +2 -0
  27. package/src/scrubber.mjs +64 -0
  28. package/src/server/index.mjs +993 -0
  29. package/src/sources.mjs +80 -0
  30. package/src/telemetry-schema.mjs +187 -0
  31. package/src/telemetry.mjs +390 -0
  32. package/src/ui/active-line.mjs +42 -0
  33. package/src/ui/app.js +3553 -0
  34. package/src/ui/at-mention.mjs +67 -0
  35. package/src/ui/comments-plugin.mjs +107 -0
  36. package/src/ui/diff-plugin.mjs +73 -0
  37. package/src/ui/editor.mjs +210 -0
  38. package/src/ui/index.html +1723 -0
  39. package/src/ui/md.mjs +233 -0
  40. package/src/ui/paste-md.mjs +54 -0
  41. package/src/ui/shell.html +1374 -0
  42. package/src/ui/slash.mjs +122 -0
  43. package/src/vault-registry.mjs +150 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * sources.mjs — parse/serialize the project's categorized grounding sources.
3
+ *
4
+ * `.sonorance/sources.md` is a user-editable Markdown source of truth. Stable starter
5
+ * sections teach the evidence model; custom `##` sections also survive CLI rewrites.
6
+ * Each source remains one list item:
7
+ *
8
+ * - <location> — <description>
9
+ */
10
+
11
+ export const SOURCE_SECTIONS = [
12
+ { id: 'product-strategy', title: 'Product & strategy', note: 'Product briefs, roadmaps, objectives, research plans, and decision context.' },
13
+ { id: 'code-delivery', title: 'Code & delivery', note: 'Codebases, release notes, issue trackers, experiments, incidents, and delivery history.' },
14
+ { id: 'metrics-data', title: 'Metrics & data', note: 'Analytics, warehouse queries, dashboards, billing data, and operational metrics.' },
15
+ { id: 'customer-voice', title: 'Customer voice', note: 'Support, interviews, surveys, reviews, research, and sales conversations.' },
16
+ { id: 'go-to-market', title: 'Go-to-market & public presence', note: 'Websites, documentation, pricing, CRM, campaigns, blogs, and status pages.' },
17
+ { id: 'other', title: 'Other', note: 'Relevant grounding that does not fit the sections above.' },
18
+ ];
19
+
20
+ const slug = (value) => String(value || '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
21
+ const knownById = new Map(SOURCE_SECTIONS.map(s => [s.id, s]));
22
+ const knownByTitle = new Map(SOURCE_SECTIONS.map(s => [slug(s.title), s]));
23
+
24
+ export function normalizeSourceSection(value) {
25
+ const key = slug(value);
26
+ const known = knownById.get(key) || knownByTitle.get(key);
27
+ return known
28
+ ? { section: known.id, sectionTitle: known.title }
29
+ : { section: key || 'other', sectionTitle: String(value || '').trim() || 'Other' };
30
+ }
31
+
32
+ const HEADER = `# Sources
33
+
34
+ Grounding for this project. Add one source per line as \`- <location> — <what it is and why it matters>\`.
35
+ Locations may be URLs, local paths, or git URLs. Edit freely; section headings and list items are picked up on the next run.
36
+ `;
37
+
38
+ function parseItem(item) {
39
+ let location = item, description = '';
40
+ const sep = item.match(/\s+(?:—|–|-{1,2})\s+/);
41
+ if (sep) { location = item.slice(0, sep.index); description = item.slice(sep.index + sep[0].length).trim(); }
42
+ location = location.trim().replace(/^<(.*)>$/, '$1').trim();
43
+ return { location, description };
44
+ }
45
+
46
+ export function parseSources(text) {
47
+ const out = [];
48
+ let current = normalizeSourceSection('other');
49
+ for (const raw of String(text || '').split(/\r?\n/)) {
50
+ const heading = /^\s*##\s+(.+?)\s*$/.exec(raw);
51
+ if (heading) { current = normalizeSourceSection(heading[1]); continue; }
52
+ const item = /^\s*[-*]\s+(.+?)\s*$/.exec(raw);
53
+ if (!item) continue;
54
+ const { location, description } = parseItem(item[1]);
55
+ if (location) out.push({ location, description, ...current });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export function serializeSources(list) {
61
+ const grouped = new Map(SOURCE_SECTIONS.map(s => [s.id, []]));
62
+ const customTitles = new Map();
63
+ for (const source of list || []) {
64
+ if (!source?.location) continue;
65
+ const normalized = normalizeSourceSection(source.sectionTitle || source.section || 'other');
66
+ const section = knownById.has(normalized.section) ? normalized.section : normalized.section || 'other';
67
+ if (!grouped.has(section)) grouped.set(section, []);
68
+ if (!knownById.has(section)) customTitles.set(section, normalized.sectionTitle);
69
+ grouped.get(section).push(source);
70
+ }
71
+ const blocks = SOURCE_SECTIONS.map(section => {
72
+ const lines = grouped.get(section.id).map(s => `- ${s.location}${s.description ? ` — ${s.description}` : ''}`);
73
+ return `## ${section.title}\n\n_${section.note}_` + (lines.length ? `\n\n${lines.join('\n')}` : '');
74
+ });
75
+ for (const [section, title] of customTitles) {
76
+ const lines = grouped.get(section).map(s => `- ${s.location}${s.description ? ` — ${s.description}` : ''}`);
77
+ blocks.push(`## ${title}\n\n${lines.join('\n')}`);
78
+ }
79
+ return `${HEADER}\n${blocks.join('\n\n')}\n`;
80
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * telemetry-schema.mjs — the SINGLE SOURCE OF TRUTH for Sonorance's telemetry contract.
3
+ *
4
+ * Everything the product may ever send is described here, once:
5
+ * - TELEMETRY_EVENTS — the fixed, content-free event allow-list (discrete occurrences).
6
+ * - TELEMETRY_METRICS — the aggregate instruments (high-frequency signals rolled up).
7
+ * - TELEMETRY_RESOURCE — the identity/context attributes that ride EVERY signal.
8
+ * - the allow-listed `props` keys + a value guard that keeps content off the wire.
9
+ *
10
+ * The telemetry client (`telemetry.mjs`) enforces the allow-list + value guard at emit time;
11
+ * schema breaches are dropped rather than sent, and tests keep the contract synchronized.
12
+ *
13
+ * On the wire this maps to OpenTelemetry (OTLP): events → log records, metrics → OTel metrics,
14
+ * a session → a trace, each command/stage → a span (see `otlp.mjs`, implementation spec §2.1).
15
+ * There is NO free-text field: the schema itself is the privacy guarantee (never a prompt,
16
+ * completion, file name, path, title, or body — only enums, numbers, and booleans).
17
+ */
18
+
19
+ // Bumped when the shape changes so a backend/collector can tolerate old clients.
20
+ export const TELEMETRY_SCHEMA_VERSION = 3;
21
+
22
+ // ---- events: the fixed vocabulary (discrete, low-frequency occurrences) --------------------
23
+ // Each entry: [name, description]. Kept sorted by category, then name.
24
+ export const TELEMETRY_EVENTS = [
25
+ // lifecycle
26
+ ['install.created', 'First run on this install — a fresh anonymous install_id was minted.'],
27
+ ['session.start', 'A process/serve session began (the active-user + retention backbone).'],
28
+ ['telemetry.enabled', 'The user turned telemetry on (consent granted).'],
29
+ ['telemetry.disabled', 'The user turned telemetry off (consent withdrawn).'],
30
+ // project
31
+ ['project.init', 'A folder was initialised as a project (sonorance/deliberate init).'],
32
+ ['context.ready', 'The project context was filled in (deliberate init prompt fulfilled).'],
33
+ ['source.added', 'A grounding source was added.'],
34
+ ['source.removed', 'A grounding source was removed.'],
35
+ // case funnel
36
+ ['case.created', 'A case was created.'],
37
+ ['case.stage.completed', 'A case analysis stage advanced {stage}.'],
38
+ ['case.completed', 'A case reached its final analysis stage.'],
39
+ ['case.scored', 'A case was scored by the evaluator {verdict, refreshed, independent}; the first score is the case value moment and independence is a quality guardrail.'],
40
+ ['prototype.gate.shown', 'The prototype gate was shown for a completed case.'],
41
+ ['prototype.built', 'A prototype was produced for a case {surface}.'],
42
+ ['prototype.viewed', 'A prototype was opened/viewed.'],
43
+ ['prototype.approved', 'A prototype was approved.'],
44
+ // other value moments
45
+ ['brief.completed', 'A landscape brief was produced.'],
46
+ ['matchup.completed', 'A competitive matchup was produced {refreshed}.'],
47
+ ['readout.completed', 'A product readout was produced.'],
48
+ // comment bridge
49
+ ['comment.created', 'An in-record comment was created.'],
50
+ ['comment.resolved', 'An in-record comment was resolved {revised}.'],
51
+ ['address.drained', 'A batch of open comments was drained by the agent {count}.'],
52
+ // app / editor
53
+ ['record.opened', 'A semantic record (case/brief/readout/matchup) was opened {kind} — never a raw file read.'],
54
+ ['vault.switched', 'The active vault/project was switched.'],
55
+ ['serve.started', 'The local editor server started.'],
56
+ ['ui.action', 'A named UI affordance was used {area, action, value?} — enums only, never a file name.'],
57
+ ['ui.page.viewed', 'A utility page was viewed {page} — inbox, settings, or feedback.'],
58
+ // generic + errors + feedback
59
+ ['command.run', 'A CLI/skill command ran {name, ok, duration_ms} — the universal seam event.'],
60
+ ['error', 'An error occurred {class, command, stage} — never message content or a stack trace.'],
61
+ ['feedback', 'One user-authored, explicitly-submitted feedback record — the only event that may carry user-consented text.'],
62
+ ];
63
+
64
+ // ---- metrics: aggregate instruments (high-frequency signals; only totals/distribution matter) --
65
+ // Each entry: [name, description]. Flushed as one sparse rollup, not one event per occurrence.
66
+ export const TELEMETRY_METRICS = [
67
+ ['files_opened', 'Counter — raw file opens/reads in the editor (navigation volume; no names/paths).'],
68
+ ['autosaves', 'Counter — autosave/file-write occurrences (no content, no path).'],
69
+ ['searches', 'Counter — in-vault searches run (never the query text).'],
70
+ ['inp_ms', 'Histogram — interaction-to-next-paint / latency distribution (p50/p75/p95).'],
71
+ ['open_tabs', 'Gauge — current open tab count.'],
72
+ ['explorer_files', 'Gauge — current Markdown file count in the Explorer; never names or paths.'],
73
+ ['explorer_folders', 'Gauge — current visible folder count in the Markdown-only Explorer.'],
74
+ ['project_cases', 'Gauge — current Deliberate case count in the anonymous project.'],
75
+ ['project_briefs', 'Gauge — current Deliberate brief count in the anonymous project.'],
76
+ ['project_readouts', 'Gauge — current Deliberate product readout count in the anonymous project.'],
77
+ ['project_matchups', 'Gauge — current Deliberate matchup count in the anonymous project.'],
78
+ ['project_open_comments', 'Gauge — current unresolved comment count in the anonymous project.'],
79
+ ];
80
+
81
+ // ---- resource attributes: identity + coarse context that rides EVERY signal ------------------
82
+ // Each entry: [key, description]. These become OTel resource attributes (feedback carries them too).
83
+ export const TELEMETRY_RESOURCE = [
84
+ ['telemetry_schema', 'Numeric telemetry contract version, so collectors and dashboards can separate incompatible clients.'],
85
+ ['install_id', 'Anonymous, rotatable per-install id (the correlation key ↔ feedback). Pure random, no PII.'],
86
+ ['project_key', 'Anonymous, random per-vault key (committed → team-shared). NOT the folder-name slug.'],
87
+ ['session_id', 'Ephemeral per-process/serve id (maps to the OTLP trace id). Never persisted.'],
88
+ ['product', 'deliberate | sonorance — which product experience emitted the signal.'],
89
+ ['version', 'The product version — rides every signal so version adoption/regressions are queryable.'],
90
+ ['surface', 'cli | ui — the choke point the signal came from (skill usage rides the cli seam).'],
91
+ ['os', 'Coarse OS platform (darwin | linux | win32).'],
92
+ ['os_major', 'Coarse OS major version (a number).'],
93
+ ['node_major', 'Coarse Node.js major version (a number).'],
94
+ ['harness', 'Coarse agent-harness hint (e.g. copilot-cli) | null.'],
95
+ ];
96
+
97
+ // The combined, tagged rows the grammar doc embeds (one flat list; category in the description).
98
+ export const TELEMETRY_ROWS = [
99
+ ...TELEMETRY_EVENTS.map(([n, d]) => [n, `event · ${d}`]),
100
+ ...TELEMETRY_METRICS.map(([n, d]) => [n, `metric · ${d}`]),
101
+ ...TELEMETRY_RESOURCE.map(([n, d]) => [n, `attribute · ${d}`]),
102
+ ];
103
+
104
+ // ---- derived registries + enforcement --------------------------------------------------------
105
+ export const EVENT_NAMES = new Set(TELEMETRY_EVENTS.map(([n]) => n));
106
+ export const METRIC_NAMES = new Set(TELEMETRY_METRICS.map(([n]) => n));
107
+ export const COUNTER_METRIC_NAMES = new Set(['files_opened', 'autosaves', 'searches']);
108
+ export const HISTOGRAM_METRIC_NAMES = new Set(['inp_ms']);
109
+ export const GAUGE_METRIC_NAMES = new Set([
110
+ 'open_tabs', 'explorer_files', 'explorer_folders',
111
+ 'project_cases', 'project_briefs', 'project_readouts', 'project_matchups', 'project_open_comments',
112
+ ]);
113
+ export const RESOURCE_KEYS = new Set(TELEMETRY_RESOURCE.map(([n]) => n));
114
+
115
+ export const isEvent = (name) => EVENT_NAMES.has(name);
116
+ export const isMetric = (name) => METRIC_NAMES.has(name);
117
+ export const isCounterMetric = (name) => COUNTER_METRIC_NAMES.has(name);
118
+ export const isHistogramMetric = (name) => HISTOGRAM_METRIC_NAMES.has(name);
119
+ export const isGaugeMetric = (name) => GAUGE_METRIC_NAMES.has(name);
120
+
121
+ // The ONLY property keys any event may carry — all low-cardinality enums / numbers / bools.
122
+ // Anything not on this list is dropped before the event is buffered (content can never ride a
123
+ // stray key). Kept deliberately small; extend it (and the doc) when a new typed event needs a key.
124
+ export const ALLOWED_PROP_KEYS = new Set([
125
+ 'name', // a command name from the fixed CLI/skill grammar
126
+ 'stage', // a pipeline stage enum (frame | shape | launch | …)
127
+ 'verdict', // go | no-go | … enum
128
+ 'refreshed', // bool (matchup refreshed in place or case re-scored)
129
+ 'independent', // bool (score came from an isolated evaluator, not a same-session fallback)
130
+ 'revised', // bool (comment resolved with an edit)
131
+ 'count', // number
132
+ 'kind', // a record kind (case | brief | readout | matchup) — low cardinality
133
+ 'surface', // a primary-surface slug (cli | api | mcp | …) for prototypes
134
+ 'area', // ui.action area enum
135
+ 'action', // ui.action action enum
136
+ 'value', // ui.action value enum (e.g. theme name)
137
+ 'page', // ui.page.viewed page enum
138
+ 'class', // an error CLASS (constructor name), never the message
139
+ 'command', // the command an error/telemetry occurred under (fixed grammar token)
140
+ 'category', // feedback category enum
141
+ 'rating', // feedback sentiment/rating (number | up | down | excited | null)
142
+ 'ok', // bool
143
+ 'duration_ms', // number
144
+ ]);
145
+
146
+ // Low-cardinality enums for the one named UI-interaction event (`ui.action`). Kept tight so the
147
+ // UI can never smuggle a label/path/free-text through `area`/`action`/`value`.
148
+ export const UI_ACTION_AREAS = new Set(['explorer', 'toc', 'toolbar', 'settings', 'editor']);
149
+ export const UI_ACTION_ACTIONS = new Set([
150
+ 'collapse-all', 'comments', 'create-file', 'create-folder', 'delete-file', 'delete-folder',
151
+ 'diff', 'expand-all', 'feedback-open', 'mode-change', 'move', 'new-record', 'proto-open',
152
+ 'rename', 'search-open', 'theme-change', 'toc-nav', 'vault-switch',
153
+ ]);
154
+ export const UI_ACTION_VALUES = new Set(['system', 'light', 'dark', 'doc', 'diff']);
155
+ export const UI_PAGE_NAMES = new Set(['inbox', 'settings', 'feedback']);
156
+
157
+ // Feedback categories + ratings (also the UI panel's fixed choices).
158
+ export const FEEDBACK_CATEGORIES = new Set(['bug', 'idea', 'praise', 'question', 'other']);
159
+
160
+ // A value is safe to send only if it is a boolean, a finite number, null, or a SHORT enum-like
161
+ // string (no spaces beyond a single word/slug, no quotes/paths/newlines). This is the second
162
+ // guard after the key allow-list: it stops a content-y string riding an allow-listed key.
163
+ const ENUMISH = /^[A-Za-z0-9][A-Za-z0-9._\-]{0,39}$/; // ≤40 chars, slug/enum shape, no spaces
164
+ export function isSafeValue(v) {
165
+ if (v == null) return true;
166
+ if (typeof v === 'boolean') return true;
167
+ if (typeof v === 'number') return Number.isFinite(v);
168
+ if (typeof v === 'string') return ENUMISH.test(v);
169
+ return false;
170
+ }
171
+
172
+ // Keep only allow-listed keys whose values pass the guard. Returns a fresh, content-free object.
173
+ // Unknown keys and unsafe values are dropped silently (defence in depth, not an error path).
174
+ export function sanitizeProps(props) {
175
+ const out = {};
176
+ if (!props || typeof props !== 'object') return out;
177
+ for (const [k, v] of Object.entries(props)) {
178
+ if (!ALLOWED_PROP_KEYS.has(k)) continue;
179
+ if (!isSafeValue(v)) continue;
180
+ if (k === 'area' && !UI_ACTION_AREAS.has(v)) continue;
181
+ if (k === 'action' && !UI_ACTION_ACTIONS.has(v)) continue;
182
+ if (k === 'value' && !UI_ACTION_VALUES.has(v)) continue;
183
+ if (k === 'page' && !UI_PAGE_NAMES.has(v)) continue;
184
+ out[k] = v;
185
+ }
186
+ return out;
187
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * telemetry.mjs — the platform telemetry CLIENT: the client IS the collector.
3
+ *
4
+ * On the user's machine it buffers, enforces the content-free allow-list, batches, and exports
5
+ * over OTLP — never blocking a command or a render (INP/snappiness is a hard priority). Shared by
6
+ * every surface: the `sonorance` CLI and server (and, via the CLI seam, the skills). Design
7
+ * (implementation spec §2):
8
+ *
9
+ * 1. Kill switches at the SOURCE — consent off, DO_NOT_TRACK, CI, SONORANCE_TELEMETRY=off, or a
10
+ * committed `.sonorance/config.json` telemetry:false → emit nothing.
11
+ * 2. Local JSONL FIRST — every event is appended to `~/.sonorance/telemetry.jsonl` (the audit
12
+ * trail + `telemetry show` source) before any network. Files-first: you can read what we send.
13
+ * 3. Allow-list + value guard (telemetry-schema.mjs) — unknown event names and content-y values
14
+ * are dropped, so nothing off-schema can ever reach the wire.
15
+ * 4. Batch-flush, fire-and-forget — flush on a threshold / timer / clean exit with a short
16
+ * timeout; failure is silent (the JSONL keeps the record). High-frequency signals are
17
+ * aggregated into metrics (counters/gauges/histograms), never one event each.
18
+ * 5. Local-test switch — SONORANCE_TELEMETRY=console|debug prints the exact OTLP payloads
19
+ * instead of sending, so the founder can verify the pipeline on a dev machine.
20
+ *
21
+ * On the wire it is OTLP (see otlp.mjs): a session → a trace, events → logs, rollups → metrics.
22
+ */
23
+ import { appendFile, readFile } from 'node:fs/promises';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+ import {
28
+ EVENT_NAMES, isCounterMetric, isHistogramMetric, isGaugeMetric,
29
+ sanitizeProps, TELEMETRY_SCHEMA_VERSION,
30
+ } from './telemetry-schema.mjs';
31
+ import { ensureInstallId, projectKey, ensureProjectKey, sessionId, coarseEnv, getConsent, sonoranceHome } from './identity.mjs';
32
+ import { encodeLogs, encodeMetrics, encodeTraces, traceIdOf, sendOtlp } from './otlp.mjs';
33
+ import { azureConnectionString, azureTrackUrl, encodeEnvelopes, sendAzure, DEFAULT_CONNECTION_STRING } from './azure-monitor.mjs';
34
+
35
+ // Default posture when the user has not yet chosen (transparent opt-out — implementation spec §6,
36
+ // the recommended model). Flip to false here for opt-in; it is the single switch for that policy.
37
+ const DEFAULT_CONSENT_ON = true;
38
+
39
+ // Fixed histogram bounds (ms) for the latency/INP distribution.
40
+ const INP_BOUNDS = [50, 100, 200, 300, 500, 1000, 2000, 5000];
41
+
42
+ const auditPath = () => join(sonoranceHome(), 'telemetry.jsonl');
43
+
44
+ // ---- consent / mode resolution ----------------------------------------------
45
+ // Returns 'off' | 'console' | 'otlp'. Env overrides win (dev/CI + the local-test switch), then the
46
+ // committed team policy, then the user's home-config consent, then the default posture.
47
+ export function resolveMode(vaultDir) {
48
+ const env = String(process.env.SONORANCE_TELEMETRY || '').toLowerCase();
49
+ if (['off', '0', 'false', 'no', 'none'].includes(env)) return 'off';
50
+ if (['console', 'debug', 'print', 'stdout'].includes(env)) return 'console';
51
+ const forcedOn = ['on', '1', 'true', 'otlp'].includes(env);
52
+ if (!forcedOn) {
53
+ if (/^(1|true)$/i.test(String(process.env.DO_NOT_TRACK || ''))) return 'off';
54
+ if (process.env.CI && String(process.env.CI).toLowerCase() !== 'false') return 'off';
55
+ if (vaultDir && committedPolicyOff(vaultDir)) return 'off';
56
+ const consent = getConsent();
57
+ if (consent === false) return 'off';
58
+ if (consent === undefined && !DEFAULT_CONSENT_ON) return 'off';
59
+ }
60
+ return 'otlp';
61
+ }
62
+ // A committed `.sonorance/config.json` `telemetry:false` is a team/enterprise kill switch that
63
+ // overrides an individual's consent (but env `on` can still force it for the founder's own tests).
64
+ function committedPolicyOff(vaultDir) {
65
+ try { return JSON.parse(readFileSync(join(vaultDir, '.sonorance', 'config.json'), 'utf8')).telemetry === false; }
66
+ catch { return false; }
67
+ }
68
+
69
+ // ---- transport resolution ----------------------------------------------------
70
+ // WHERE a live signal goes (mode decides WHETHER; this decides WHERE). Precedence:
71
+ // 1. an explicit Azure connection string (env) → export straight to that App Insights ("Breeze");
72
+ // 2. an explicit OTLP endpoint (env) → OTLP/HTTP to it (a self-hosted collector / any OTLP
73
+ // backend);
74
+ // 3. the official package's release-configured connection string → zero-config Azure export;
75
+ // 4. nothing configured → local-only: the JSONL audit keeps everything, nothing is sent
76
+ // (fail-closed; the app never blindly POSTs to a probably-dead localhost).
77
+ export function resolveTransport() {
78
+ const envCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || process.env.SONORANCE_AZURE_CONNECTION_STRING;
79
+ if (envCs) { const endpoint = azureTrackUrl(envCs); if (endpoint) return { kind: 'azure', endpoint, label: 'Azure Monitor (Application Insights)' }; }
80
+ const otlp = process.env.SONORANCE_OTLP_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
81
+ if (otlp) return { kind: 'otlp', endpoint: otlp.replace(/\/+$/, ''), label: `OTLP → ${otlp}` };
82
+ if (DEFAULT_CONNECTION_STRING) {
83
+ const endpoint = azureTrackUrl(DEFAULT_CONNECTION_STRING);
84
+ if (endpoint) return { kind: 'azure', endpoint, label: 'Azure Monitor (Application Insights, release default)' };
85
+ }
86
+ return { kind: 'none', endpoint: null, label: 'local only (no destination configured)' };
87
+ }
88
+
89
+ // Build the OTLP [signal, payload] jobs from the neutral drained buffers (the vendor-agnostic wire).
90
+ function otlpJobs({ events, spans, counters, gauges, histograms, window, resource }) {
91
+ const jobs = [];
92
+ if (events.length) jobs.push(['logs', encodeLogs(events.map(e => ({ name: e.name, ts: e.ts, attributes: e.props, traceId: traceIdOf(resource.session_id) })), resource)]);
93
+ if (spans.length) jobs.push(['traces', encodeTraces(spans, resource)]);
94
+ if (counters.length || gauges.length || histograms.length) jobs.push(['metrics', encodeMetrics({
95
+ counters: counters.map(c => ({ ...c, attributes: {} })),
96
+ gauges: gauges.map(g => ({ ...g, attributes: {} })),
97
+ histograms,
98
+ }, resource, window)]);
99
+ return jobs;
100
+ }
101
+
102
+ // ---- the client -------------------------------------------------------------
103
+ function createClient() {
104
+ const state = {
105
+ ctx: { surface: 'cli', product: 'sonorance', version: '0', vaultDir: null },
106
+ resource: null,
107
+ mode: 'off',
108
+ events: [], // pending log records: { name, ts, props }
109
+ spans: [], // pending trace spans
110
+ counters: new Map(), // metricName → integer count
111
+ gauges: new Map(), // metricName → latest current value
112
+ histos: new Map(), // metricName → number[]
113
+ windowStart: Date.now(),
114
+ session: null, // the root session span
115
+ flushTimer: null,
116
+ configured: false,
117
+ generation: 0, // increments when the vault context changes
118
+ };
119
+
120
+ const buildResource = () => {
121
+ const { surface, product, version, vaultDir } = state.ctx;
122
+ const { id: install_id } = ensureInstallId();
123
+ // Mint the project_key lazily (only for a real vault) so a random-but-committed key exists.
124
+ const project_key = vaultDir ? (projectKey(vaultDir) || ensureProjectKey(vaultDir)) : null;
125
+ return {
126
+ telemetry_schema: TELEMETRY_SCHEMA_VERSION,
127
+ install_id, project_key, session_id: sessionId(),
128
+ product, version: String(version || '0'), surface,
129
+ ...coarseEnv(),
130
+ };
131
+ };
132
+
133
+ const on = () => state.mode !== 'off';
134
+
135
+ const scheduleFlush = () => {
136
+ if (state.mode === 'off' || state.flushTimer) return;
137
+ state.flushTimer = setTimeout(() => { state.flushTimer = null; flush().catch(() => {}); }, 5000);
138
+ state.flushTimer.unref?.();
139
+ };
140
+
141
+ // Append to the local JSONL audit trail — the offline record you can read. Never awaited on the
142
+ // hot path; failure is ignored (a home-dir write problem must never break a command).
143
+ const audit = (line) => { appendFile(auditPath(), JSON.stringify(line) + '\n').catch(() => {}); };
144
+
145
+ function configure({ surface, product, version, vaultDir } = {}) {
146
+ if (vaultDir !== undefined && vaultDir !== state.ctx.vaultDir) state.generation++;
147
+ if (surface) state.ctx.surface = surface;
148
+ if (product) state.ctx.product = product;
149
+ if (version != null) state.ctx.version = version;
150
+ if (vaultDir !== undefined) state.ctx.vaultDir = vaultDir;
151
+ state.mode = resolveMode(state.ctx.vaultDir);
152
+ state.resource = on() ? buildResource() : null;
153
+ // Open the session trace span once, when first enabled.
154
+ if (on() && !state.session) {
155
+ const traceId = traceIdOf(state.resource.session_id) || randomBytes(16).toString('hex');
156
+ state.session = { name: 'session', traceId, spanId: randomBytes(8).toString('hex'), start: Date.now(), attributes: {} };
157
+ }
158
+ state.configured = true;
159
+ return state.mode;
160
+ }
161
+
162
+ // Re-resolve consent/mode at runtime (after a `telemetry on/off` toggle) without a restart.
163
+ function refresh() {
164
+ const was = on();
165
+ state.mode = resolveMode(state.ctx.vaultDir);
166
+ if (on() && !state.resource) state.resource = buildResource();
167
+ if (!was && on() && !state.session) {
168
+ const traceId = traceIdOf(state.resource.session_id) || randomBytes(16).toString('hex');
169
+ state.session = { name: 'session', traceId, spanId: randomBytes(8).toString('hex'), start: Date.now(), attributes: {} };
170
+ }
171
+ return state.mode;
172
+ }
173
+
174
+ // Emit one discrete event. Off-schema names and content-y props are dropped (never sent).
175
+ function emit(name, props = {}) {
176
+ if (!state.configured) configure();
177
+ if (!on()) return null;
178
+ if (!EVENT_NAMES.has(name)) return null; // not on the allow-list → drop
179
+ const clean = sanitizeProps(props);
180
+ if (name === 'ui.action' && (!clean.area || !clean.action)) return null;
181
+ if (name === 'ui.page.viewed' && !clean.page) return null;
182
+ const event_id = `ev_${randomBytes(8).toString('hex')}`;
183
+ const eventProps = { ...clean, event_id };
184
+ const rec = { type: name, ts: Date.now(), schema: TELEMETRY_SCHEMA_VERSION, event_id, ...state.resource, props: clean };
185
+ audit(rec);
186
+ state.events.push({ name, ts: rec.ts, props: eventProps });
187
+ if (state.events.length >= 50) flush().catch(() => {}); else scheduleFlush();
188
+ return rec;
189
+ }
190
+
191
+ // Aggregate content-free signals: interval counters, current-value gauges, or histogram samples.
192
+ function count(metric, n = 1) {
193
+ if (!state.configured) configure();
194
+ if (!on() || !isCounterMetric(metric)) return;
195
+ state.counters.set(metric, (state.counters.get(metric) || 0) + (Number(n) || 0));
196
+ scheduleFlush();
197
+ }
198
+ function gauge(metric, value) {
199
+ if (!state.configured) configure();
200
+ const v = Number(value);
201
+ if (!on() || !isGaugeMetric(metric) || !Number.isFinite(v) || v < 0) return;
202
+ state.gauges.set(metric, v);
203
+ scheduleFlush();
204
+ }
205
+ function observe(metric, value) {
206
+ if (!state.configured) configure();
207
+ if (!on() || !isHistogramMetric(metric)) return;
208
+ const v = Number(value); if (!Number.isFinite(v)) return;
209
+ if (!state.histos.has(metric)) state.histos.set(metric, []);
210
+ state.histos.get(metric).push(v);
211
+ scheduleFlush();
212
+ }
213
+
214
+ // A command/stage span (a child of the session trace) — the "user journey" tree. Returns a
215
+ // handle whose .end() closes it; a no-op object when telemetry is off.
216
+ function startSpan(name, attributes = {}) {
217
+ if (!state.configured) configure();
218
+ if (!on() || !state.session) return { end() {} };
219
+ const span = {
220
+ name, traceId: state.session.traceId, spanId: randomBytes(8).toString('hex'),
221
+ parentSpanId: state.session.spanId, start: Date.now(), attributes: sanitizeProps(attributes), ok: true,
222
+ };
223
+ return {
224
+ end(extra = {}) {
225
+ span.end = Date.now();
226
+ span.attributes = { ...span.attributes, ...sanitizeProps(extra) };
227
+ if (extra && extra.ok === false) span.ok = false;
228
+ state.spans.push(span);
229
+ scheduleFlush();
230
+ },
231
+ };
232
+ }
233
+
234
+ // Drain the accumulators into the NEUTRAL rollup shape (transport-agnostic), reset the window.
235
+ function drainRaw() {
236
+ const events = state.events.splice(0);
237
+ const spans = state.spans.splice(0);
238
+ const counters = [...state.counters].map(([name, value]) => ({ name, value }));
239
+ const gauges = [...state.gauges].map(([name, value]) => ({ name, value }));
240
+ const histograms = [...state.histos].map(([name, values]) => histogram(name, values));
241
+ const window = { start: state.windowStart, end: Date.now() };
242
+ state.counters.clear(); state.gauges.clear(); state.histos.clear(); state.windowStart = Date.now();
243
+ return { events, spans, counters, gauges, histograms, window };
244
+ }
245
+
246
+ // Flush every non-empty signal over the ACTIVE transport (Azure Monitor by default; OTLP if an
247
+ // OTLP endpoint is set; local-only otherwise). Fire-and-forget: awaited only so callers CAN block
248
+ // on shutdown, but it never throws and a send failure keeps the audit JSONL as the durable record.
249
+ // Returns a small result summary ({ sent, transport, ok?, status?, error? }) for `telemetry test`.
250
+ async function flush({ requeue = true } = {}) {
251
+ if (state.flushTimer) { clearTimeout(state.flushTimer); state.flushTimer = null; }
252
+ const generation = state.generation;
253
+ if (!on()) { state.events = []; state.spans = []; state.counters.clear(); state.gauges.clear(); state.histos.clear(); return { sent: false, transport: 'off' }; }
254
+ const resource = state.resource || buildResource();
255
+ const { events, spans, counters, gauges, histograms, window } = drainRaw();
256
+ if (!events.length && !spans.length && !counters.length && !gauges.length && !histograms.length) return { sent: false, transport: 'idle' };
257
+ const transport = resolveTransport();
258
+
259
+ // The local-test switch: print exactly what WOULD leave over the active transport, send nothing.
260
+ if (state.mode === 'console') {
261
+ if (transport.kind === 'azure') printPayload('azure', encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource));
262
+ else for (const [signal, payload] of otlpJobs({ events, spans, counters, gauges, histograms, window, resource })) printPayload(signal, payload);
263
+ return { sent: false, transport: 'console' };
264
+ }
265
+
266
+ if (transport.kind === 'azure') {
267
+ const r = await sendAzure(encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource), { endpoint: transport.endpoint });
268
+ if (r.ok === false && requeue && state.generation === generation) {
269
+ if (events.length) state.events = [...events.slice(-500), ...state.events];
270
+ for (const g of gauges) if (!state.gauges.has(g.name)) state.gauges.set(g.name, g.value);
271
+ }
272
+ return { sent: true, transport: 'azure', ...r };
273
+ }
274
+ if (transport.kind === 'otlp') {
275
+ const jobs = otlpJobs({ events, spans, counters, gauges, histograms, window, resource });
276
+ const results = await Promise.all(jobs.map(([signal, payload]) => sendOtlp(signal, payload, { endpoint: transport.endpoint })));
277
+ const logResult = results[jobs.findIndex(([signal]) => signal === 'logs')];
278
+ const metricResult = results[jobs.findIndex(([signal]) => signal === 'metrics')];
279
+ if (requeue && state.generation === generation && logResult?.ok === false && events.length) state.events = [...events.slice(-500), ...state.events];
280
+ if (requeue && state.generation === generation && metricResult?.ok === false) {
281
+ for (const g of gauges) if (!state.gauges.has(g.name)) state.gauges.set(g.name, g.value);
282
+ }
283
+ const ok = results.every(r => r && r.ok);
284
+ return { sent: true, transport: 'otlp', ok, status: results[0]?.status };
285
+ }
286
+ // transport 'none' → nothing sent; the JSONL audit already holds everything (local-only).
287
+ return { sent: false, transport: 'none' };
288
+ }
289
+
290
+ // Close the session span + do a final, time-boxed flush. Called by CLIs before exit and by the
291
+ // server on shutdown. Never blocks longer than `timeoutMs`.
292
+ async function shutdown({ timeoutMs = 800 } = {}) {
293
+ if (state.flushTimer) { clearTimeout(state.flushTimer); state.flushTimer = null; }
294
+ if (on() && state.session) { state.session.end = Date.now(); state.spans.push(state.session); state.session = null; }
295
+ await Promise.race([flush(), new Promise(r => setTimeout(r, timeoutMs))]).catch(() => {});
296
+ }
297
+
298
+ // Introspection for `telemetry show/status/test` + tests: the resolved mode, the active transport,
299
+ // the resource, a record count, and the exact payloads that WOULD be sent now (buffers untouched).
300
+ function preview() {
301
+ const resource = state.resource || buildResource();
302
+ const transport = resolveTransport();
303
+ const events = state.events.slice();
304
+ const spans = state.spans.slice();
305
+ const counters = [...state.counters].map(([name, value]) => ({ name, value }));
306
+ const gauges = [...state.gauges].map(([name, value]) => ({ name, value }));
307
+ const histograms = [...state.histos].map(([name, values]) => histogram(name, values));
308
+ const window = { start: state.windowStart, end: Date.now() };
309
+ const count = events.length + spans.length + counters.length + gauges.length + histograms.length;
310
+ const bufs = { events, spans, counters, gauges, histograms, window, resource };
311
+ const payloads = transport.kind === 'azure'
312
+ ? { azure: encodeEnvelopes({ events, spans, counters, gauges, histograms, window }, resource) }
313
+ : Object.fromEntries(otlpJobs(bufs));
314
+ return { mode: state.mode, transport, resource, count, payloads };
315
+ }
316
+
317
+ return {
318
+ configure, refresh, emit, count, gauge, observe, startSpan, flush, shutdown, preview,
319
+ mode: () => state.mode, resource: () => state.resource, ctx: () => state.ctx,
320
+ _state: state,
321
+ };
322
+ }
323
+
324
+ // ---- helpers ----------------------------------------------------------------
325
+ function histogram(name, values) {
326
+ const bounds = INP_BOUNDS;
327
+ const buckets = new Array(bounds.length + 1).fill(0);
328
+ let sum = 0, min = null, max = null;
329
+ for (const v of values) {
330
+ sum += v;
331
+ if (min === null || v < min) min = v;
332
+ if (max === null || v > max) max = v;
333
+ let i = bounds.findIndex(b => v <= b);
334
+ if (i < 0) i = bounds.length;
335
+ buckets[i]++;
336
+ }
337
+ return { name, count: values.length, sum, min, max, bounds, buckets, attributes: {} };
338
+ }
339
+
340
+ function printPayload(signal, payload) {
341
+ // The local-test switch: print exactly what would go over the wire, tagged, to stderr (so it
342
+ // never pollutes a command's stdout / the comment-bridge JSON). `signal` is an OTLP signal name
343
+ // (logs|metrics|traces) or `azure` for the App Insights envelope batch.
344
+ try { process.stderr.write(`[telemetry:${signal}] ${JSON.stringify(payload)}\n`); } catch { /* ignore */ }
345
+ }
346
+
347
+ // ---- process-wide singleton -------------------------------------------------
348
+ let _client = null;
349
+ export function telemetry() { if (!_client) _client = createClient(); return _client; }
350
+
351
+ // Convenience proxies (most callers just want these).
352
+ export const configureTelemetry = (opts) => telemetry().configure(opts);
353
+ export const refreshTelemetry = () => telemetry().refresh();
354
+ export const emit = (name, props) => telemetry().emit(name, props);
355
+ export const count = (metric, n) => telemetry().count(metric, n);
356
+ export const gauge = (metric, value) => telemetry().gauge(metric, value);
357
+ export const observe = (metric, value) => telemetry().observe(metric, value);
358
+ export const startSpan = (name, attrs) => telemetry().startSpan(name, attrs);
359
+ export const flushTelemetry = (opts) => telemetry().flush(opts);
360
+ export const shutdownTelemetry = (opts) => telemetry().shutdown(opts);
361
+ export const telemetryMode = () => telemetry().mode();
362
+ export const telemetryPreview = () => telemetry().preview();
363
+ export { auditPath };
364
+
365
+ // A browser request is already a bounded batch. Process it with an isolated vault-scoped client
366
+ // so concurrent project tabs can never share resource identity, consent policy, or retry state.
367
+ export async function submitTelemetryBatch({ events = [], counters = {}, gauges = {}, histograms = {} } = {}, opts = {}) {
368
+ const client = createClient();
369
+ client.configure(opts);
370
+ for (const event of events) {
371
+ if (event && typeof event.name === 'string') client.emit(event.name, event.props || {});
372
+ }
373
+ for (const [name, value] of Object.entries(counters || {})) client.count(name, value);
374
+ for (const [name, value] of Object.entries(gauges || {})) client.gauge(name, value);
375
+ for (const [name, values] of Object.entries(histograms || {})) {
376
+ if (Array.isArray(values)) for (const value of values) client.observe(name, value);
377
+ }
378
+ return client.flush({ requeue: false });
379
+ }
380
+
381
+ // Read the last `n` lines of the local audit trail (for `sonorance telemetry show`).
382
+ export async function readAudit(n = 20) {
383
+ try {
384
+ const txt = await readFile(auditPath(), 'utf8');
385
+ return txt.trim().split('\n').filter(Boolean).slice(-n).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
386
+ } catch { return []; }
387
+ }
388
+
389
+ // Reset the singleton (tests only).
390
+ export function _resetTelemetryForTest() { _client = null; }