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.
- package/LICENSE +174 -0
- package/README.md +103 -0
- package/build/icon.png +0 -0
- package/package.json +86 -0
- package/skill/SKILL.md +41 -0
- package/skill/scripts/sonorance.mjs +31 -0
- package/src/azure-monitor.mjs +221 -0
- package/src/cli.mjs +315 -0
- package/src/comment-client.mjs +75 -0
- package/src/engine-default.mjs +136 -0
- package/src/feedback.mjs +151 -0
- package/src/gitignore.mjs +27 -0
- package/src/grammar.mjs +55 -0
- package/src/identity.mjs +126 -0
- package/src/otlp.mjs +166 -0
- package/src/plugins/deliberate/contribute.mjs +28 -0
- package/src/plugins/deliberate/domain.mjs +40 -0
- package/src/plugins/deliberate/frontmatter.mjs +85 -0
- package/src/plugins/deliberate/gitignore.mjs +21 -0
- package/src/plugins/deliberate/kinds.mjs +44 -0
- package/src/plugins/deliberate/markdown.mjs +42 -0
- package/src/plugins/deliberate/paths.mjs +245 -0
- package/src/plugins/deliberate/stages.mjs +27 -0
- package/src/plugins/deliberate/vault.mjs +1043 -0
- package/src/plugins.mjs +91 -0
- package/src/release-config.mjs +2 -0
- package/src/scrubber.mjs +64 -0
- package/src/server/index.mjs +993 -0
- package/src/sources.mjs +80 -0
- package/src/telemetry-schema.mjs +187 -0
- package/src/telemetry.mjs +390 -0
- package/src/ui/active-line.mjs +42 -0
- package/src/ui/app.js +3553 -0
- package/src/ui/at-mention.mjs +67 -0
- package/src/ui/comments-plugin.mjs +107 -0
- package/src/ui/diff-plugin.mjs +73 -0
- package/src/ui/editor.mjs +210 -0
- package/src/ui/index.html +1723 -0
- package/src/ui/md.mjs +233 -0
- package/src/ui/paste-md.mjs +54 -0
- package/src/ui/shell.html +1374 -0
- package/src/ui/slash.mjs +122 -0
- package/src/vault-registry.mjs +150 -0
package/src/feedback.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feedback.mjs — the platform feedback client (shared by the UI panel and `/deliberate feedback`).
|
|
3
|
+
*
|
|
4
|
+
* Feedback is the one channel that MAY carry user text — because it is explicit and user-authored
|
|
5
|
+
* (they typed it to send you). So, unlike telemetry (which has no free-text field), a feedback
|
|
6
|
+
* record carries the user's verbatim `message`. Design (feedback-ux spec):
|
|
7
|
+
*
|
|
8
|
+
* - The user's `message` is the source of truth — stored EXACTLY as typed, never reworded. JTBD
|
|
9
|
+
* distillation happens founder-side at review, not here.
|
|
10
|
+
* - It travels over OTLP too (the founder's final call): as an OpenTelemetry LOG record named
|
|
11
|
+
* `feedback`, carrying the consented content as attributes, sharing the SAME anonymous
|
|
12
|
+
* `install_id` as telemetry so a bug report joins to "what this install actually did".
|
|
13
|
+
* - It ALWAYS writes a durable local mirror first (`.sonorance/local/feedback.jsonl`) — files-first,
|
|
14
|
+
* and the offline record if the endpoint is unset/unreachable.
|
|
15
|
+
* - It works even when passive telemetry is OFF (the user chose to submit); only the local-test
|
|
16
|
+
* switch (SONORANCE_TELEMETRY=console) diverts it to stdout instead of the wire.
|
|
17
|
+
* - Only the bug `diagnostics` blob is scrubbed (a pasted log/stack); the user's words are not.
|
|
18
|
+
*/
|
|
19
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { FEEDBACK_CATEGORIES, TELEMETRY_SCHEMA_VERSION } from './telemetry-schema.mjs';
|
|
23
|
+
import { ensureInstallId, projectKey, ensureProjectKey, coarseEnv } from './identity.mjs';
|
|
24
|
+
import { sonoranceHome } from './vault-registry.mjs';
|
|
25
|
+
import { scrub } from './scrubber.mjs';
|
|
26
|
+
import { encodeLogs, sendOtlp } from './otlp.mjs';
|
|
27
|
+
import { resolveMode, resolveTransport } from './telemetry.mjs';
|
|
28
|
+
import { encodeFeedbackEnvelope, sendAzure } from './azure-monitor.mjs';
|
|
29
|
+
|
|
30
|
+
const RATINGS = new Set(['up', 'down', 'excited']);
|
|
31
|
+
|
|
32
|
+
// The per-vault feedback mirror (committed-adjacent but machine-local); a home fallback when there
|
|
33
|
+
// is no vault. Content-full by design (it is YOUR copy) — never committed (it lives under local/).
|
|
34
|
+
export const feedbackMirrorPath = (vaultDir) =>
|
|
35
|
+
vaultDir ? join(vaultDir, '.sonorance', 'local', 'feedback.jsonl')
|
|
36
|
+
: join(sonoranceHome(), 'local', 'feedback.jsonl');
|
|
37
|
+
|
|
38
|
+
const clampStr = (v, n) => (typeof v === 'string' ? v.slice(0, n) : '');
|
|
39
|
+
|
|
40
|
+
// Normalise an incoming record into the content-safe, verbatim-first shape (feedback-ux §7).
|
|
41
|
+
export function normalizeFeedback(input = {}, { surface = 'ui', product = 'sonorance', version = '0', vaultDir = null } = {}) {
|
|
42
|
+
const category = FEEDBACK_CATEGORIES.has(input.category) ? input.category : 'other';
|
|
43
|
+
let rating = null;
|
|
44
|
+
if (typeof input.rating === 'number' && input.rating >= 1 && input.rating <= 5) rating = Math.round(input.rating);
|
|
45
|
+
else if (RATINGS.has(input.rating)) rating = input.rating;
|
|
46
|
+
|
|
47
|
+
const install_id = ensureInstallId().id;
|
|
48
|
+
const project_key = vaultDir ? (projectKey(vaultDir) || ensureProjectKey(vaultDir)) : null;
|
|
49
|
+
|
|
50
|
+
const rec = {
|
|
51
|
+
type: 'feedback',
|
|
52
|
+
id: 'fb_' + randomBytes(4).toString('hex'),
|
|
53
|
+
schema: TELEMETRY_SCHEMA_VERSION,
|
|
54
|
+
ts: new Date().toISOString(),
|
|
55
|
+
install_id, project_key,
|
|
56
|
+
surface, product, version: String(version || '0'),
|
|
57
|
+
category, rating,
|
|
58
|
+
message: clampStr(input.message, 8000), // VERBATIM — never reworded
|
|
59
|
+
context: input.context ? clampStr(input.context, 4000) : null, // the user's answer to the one nudge
|
|
60
|
+
context_auto: sanitizeAutoContext(input.context_auto), // coarse, content-free capture context
|
|
61
|
+
agent_context: input.agent_context ? clampStr(input.agent_context, 4000) : null, // agent-drafted, previewed
|
|
62
|
+
solution_hint: input.solution_hint ? clampStr(input.solution_hint, 4000) : null,
|
|
63
|
+
bug: category === 'bug' ? normalizeBug(input.bug) : null,
|
|
64
|
+
priority: input.priority ? clampStr(input.priority, 40) : null,
|
|
65
|
+
needs_framing: !!input.needs_framing,
|
|
66
|
+
contact: input.contact ? clampStr(input.contact, 200) : null, // only if they asked for a reply
|
|
67
|
+
};
|
|
68
|
+
return rec;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The optional coarse capture context: enums only (a command name, a stage, a surface, an error
|
|
72
|
+
// class) — everything else is dropped. Never free text.
|
|
73
|
+
function sanitizeAutoContext(c) {
|
|
74
|
+
if (!c || typeof c !== 'object') return null;
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const k of ['command', 'stage', 'surface', 'error_class']) {
|
|
77
|
+
const v = c[k];
|
|
78
|
+
if (typeof v === 'string' && /^[A-Za-z0-9._-]{1,40}$/.test(v)) out[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return Object.keys(out).length ? out : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A bug's structured fields; the free-text `diagnostics` blob is SCRUBBED (paths/secrets/emails).
|
|
84
|
+
function normalizeBug(bug) {
|
|
85
|
+
if (!bug || typeof bug !== 'object') return null;
|
|
86
|
+
return {
|
|
87
|
+
steps: clampStr(bug.steps, 4000) || null,
|
|
88
|
+
expected: clampStr(bug.expected, 2000) || null,
|
|
89
|
+
actual: clampStr(bug.actual, 2000) || null,
|
|
90
|
+
frequency: bug.frequency ? clampStr(bug.frequency, 40) : null,
|
|
91
|
+
diagnostics: bug.diagnostics ? scrub(bug.diagnostics, { max: 4000 }) : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The OTLP log attributes for a feedback record — the consented content (allowed ONLY here).
|
|
96
|
+
function feedbackLogAttributes(rec) {
|
|
97
|
+
const a = {
|
|
98
|
+
'feedback.id': rec.id,
|
|
99
|
+
'feedback.category': rec.category,
|
|
100
|
+
'feedback.message': rec.message,
|
|
101
|
+
};
|
|
102
|
+
if (rec.rating != null) a['feedback.rating'] = String(rec.rating);
|
|
103
|
+
if (rec.context) a['feedback.context'] = rec.context;
|
|
104
|
+
if (rec.agent_context) a['feedback.agent_context'] = rec.agent_context;
|
|
105
|
+
if (rec.solution_hint) a['feedback.solution_hint'] = rec.solution_hint;
|
|
106
|
+
if (rec.priority) a['feedback.priority'] = rec.priority;
|
|
107
|
+
if (rec.needs_framing) a['feedback.needs_framing'] = true;
|
|
108
|
+
if (rec.contact) a['feedback.contact'] = rec.contact;
|
|
109
|
+
if (rec.context_auto) for (const [k, v] of Object.entries(rec.context_auto)) a[`feedback.ctx.${k}`] = v;
|
|
110
|
+
if (rec.bug) for (const [k, v] of Object.entries(rec.bug)) if (v != null) a[`feedback.bug.${k}`] = v;
|
|
111
|
+
return a;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persist + deliver a feedback record. Always writes the local mirror; then, unless the local-test
|
|
116
|
+
* switch diverts it, exports it as an OTLP log record. Returns `{ id }`. Never throws.
|
|
117
|
+
*/
|
|
118
|
+
export async function submitFeedback(input, opts = {}) {
|
|
119
|
+
const rec = normalizeFeedback(input, opts);
|
|
120
|
+
// 1) durable local mirror first (files-first + offline record).
|
|
121
|
+
const mirror = feedbackMirrorPath(opts.vaultDir);
|
|
122
|
+
try { await mkdir(mirror.replace(/[^/\\]+$/, ''), { recursive: true }); await appendFile(mirror, JSON.stringify(rec) + '\n'); } catch { /* best-effort */ }
|
|
123
|
+
|
|
124
|
+
// 2) Delivery over the ACTIVE transport (feedback works even when passive telemetry is off — the
|
|
125
|
+
// user chose to submit). Azure Monitor by default (a content-full custom event), OTLP if an
|
|
126
|
+
// OTLP endpoint is set, the local-test switch prints it, and local-only writes just the mirror.
|
|
127
|
+
const resource = {
|
|
128
|
+
telemetry_schema: TELEMETRY_SCHEMA_VERSION,
|
|
129
|
+
install_id: rec.install_id, project_key: rec.project_key,
|
|
130
|
+
product: rec.product, version: rec.version, surface: rec.surface,
|
|
131
|
+
...coarseEnv(),
|
|
132
|
+
};
|
|
133
|
+
const attributes = feedbackLogAttributes(rec);
|
|
134
|
+
const mode = resolveMode(opts.vaultDir);
|
|
135
|
+
const transport = resolveTransport();
|
|
136
|
+
if (mode === 'console') {
|
|
137
|
+
try {
|
|
138
|
+
const line = transport.kind === 'azure'
|
|
139
|
+
? `[telemetry:azure] ${JSON.stringify([encodeFeedbackEnvelope(attributes, resource)])}`
|
|
140
|
+
: `[telemetry:logs] ${JSON.stringify(encodeLogs([{ name: 'feedback', ts: Date.parse(rec.ts) || Date.now(), attributes }], resource))}`;
|
|
141
|
+
process.stderr.write(line + '\n');
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
} else if (transport.kind === 'azure') {
|
|
144
|
+
await sendAzure([encodeFeedbackEnvelope(attributes, resource)], { endpoint: transport.endpoint }).catch(() => {});
|
|
145
|
+
} else if (transport.kind === 'otlp') {
|
|
146
|
+
const payload = encodeLogs([{ name: 'feedback', ts: Date.parse(rec.ts) || Date.now(), attributes }], resource);
|
|
147
|
+
await sendOtlp('logs', payload, { endpoint: transport.endpoint }).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
// transport 'none' → the durable local mirror (written above) is the record; nothing is sent.
|
|
150
|
+
return { id: rec.id, ok: true, needs_framing: rec.needs_framing };
|
|
151
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitignore.mjs — keep a project's machine state out of git.
|
|
3
|
+
*
|
|
4
|
+
* `ensureGitignore(root, entries)` adds each entry to `<root>/.gitignore` — but ONLY if that
|
|
5
|
+
* file already exists (we never create one; a project that doesn't use .gitignore is left
|
|
6
|
+
* alone). Idempotent: an entry already present (with or without a trailing slash) is not
|
|
7
|
+
* re-added. Returns the entries actually written.
|
|
8
|
+
*
|
|
9
|
+
* This runs both from the CLI `init` and from `startServer` (whenever the app initiates a
|
|
10
|
+
* project in a folder — i.e. creates its `.sonorance/`), so machine state is ignored no matter
|
|
11
|
+
* how the project first came to be. The entries themselves are supplied by the host engine
|
|
12
|
+
* (`engine.gitignoreEntries(project)`): the generic app ignores `.sonorance/`; Deliberate also
|
|
13
|
+
* ignores any hidden subfolder under `deliberate/`.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export function ensureGitignore(root, entries) {
|
|
19
|
+
const gi = join(root, '.gitignore');
|
|
20
|
+
if (!existsSync(gi)) return [];
|
|
21
|
+
const txt = readFileSync(gi, 'utf8');
|
|
22
|
+
const present = new Set(txt.split(/\r?\n/).map(l => l.trim().replace(/\/+$/, '')).filter(Boolean));
|
|
23
|
+
const missing = (entries || []).filter(e => e && !present.has(e.replace(/\/+$/, '')));
|
|
24
|
+
if (!missing.length) return [];
|
|
25
|
+
writeFileSync(gi, txt + (txt === '' || txt.endsWith('\n') ? '' : '\n') + missing.join('\n') + '\n');
|
|
26
|
+
return missing;
|
|
27
|
+
}
|
package/src/grammar.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grammar.mjs — the SINGLE SOURCE OF TRUTH for Sonorance's command + on-disk surface.
|
|
3
|
+
*
|
|
4
|
+
* Sonorance is the app/platform: a local agent-review workbench over a Markdown folder, and the
|
|
5
|
+
* surface skills (like Deliberate) run on. Three grammars are described here:
|
|
6
|
+
* - SKILL_COMMANDS — the harness-facing `/sonorance` grammar (what a user types).
|
|
7
|
+
* - CLI_COMMANDS — the `sonorance` binary (src/cli.mjs) the skill shells into.
|
|
8
|
+
* - FS_LAYOUT — where Sonorance keeps its platform state on disk (no database).
|
|
9
|
+
*
|
|
10
|
+
* Tests keep this registry, src/cli.mjs, and the shipped skill in lock-step. To add, rename, or
|
|
11
|
+
* remove a command, edit this file and update those surfaces in the same change.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Each entry: [command, one-line description]. `<folder>` defaults to the current directory.
|
|
15
|
+
// Kept sorted alphabetically by command.
|
|
16
|
+
export const SKILL_COMMANDS = [
|
|
17
|
+
['/sonorance address', 'Work through open in-record comments in the running workbench — answer each question here, or edit the referenced file, then resolve it.'],
|
|
18
|
+
['/sonorance open', 'Open the local agent-review workbench over the current folder.'],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Each entry: [invocation, one-line description]. Mirrors the `dispatch` table in src/cli.mjs.
|
|
22
|
+
// Kept sorted alphabetically by invocation.
|
|
23
|
+
export const CLI_COMMANDS = [
|
|
24
|
+
['comment list', 'The open in-record comments from the running editor, as JSON (the comment bridge — the skill parses it).'],
|
|
25
|
+
['comment <commentId> resolve [--note "<text>"] [--revised]', 'Mark a comment resolved back to the editor (--revised if you edited the file; --note records what you did).'],
|
|
26
|
+
['init [<folder>] [--name <name>]', 'Scaffold a Sonorance project — the hidden .sonorance/config.json (identity · settings) + hand-editable .sonorance/sources.md; name = folder.'],
|
|
27
|
+
['install-skill [--here | --project <dir>]', 'Install the /sonorance skill (global, or into a repo’s .github/skills).'],
|
|
28
|
+
['open [<folder>] [--name <name>]', 'Register a folder as a vault in ~/.sonorance and make it current, so it appears in the workbench vault switcher.'],
|
|
29
|
+
['serve [<folder>] [--port <n>] [--open]', 'Start the local agent-review workbench over a folder (default: the current directory); --open launches the browser.'],
|
|
30
|
+
['source [list | add <location> ["<description>"] [--section <section>] | remove <location>]', 'List / manage categorized shared grounding sources (the hand-editable .sonorance/sources.md, readable by every skill on the project).'],
|
|
31
|
+
['telemetry [status | on | off | show | reset-id | test]', 'View or control the anonymous, content-free product telemetry (opt-out): show status + destination, turn it on/off, print the local audit trail, rotate the install id, or test the telemetry pipeline locally (Azure Monitor by default; set SONORANCE_TELEMETRY=console to watch the raw payload).'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// The distinct top-level verbs the CLI must dispatch (first token of each command).
|
|
35
|
+
export const CLI_COMMAND_NAMES = [...new Set(CLI_COMMANDS.map(([c]) => c.split(' ')[0]))];
|
|
36
|
+
|
|
37
|
+
// Each entry: [path, description]. Paths are relative to the folder Sonorance is opened over
|
|
38
|
+
// (the "vault") unless noted. Sonorance is files-first: everything is plain Markdown/JSON.
|
|
39
|
+
// Kept sorted alphabetically by path.
|
|
40
|
+
export const FS_LAYOUT = [
|
|
41
|
+
['.sonorance/', 'Hidden Sonorance platform state at the folder root — shared, cross-skill project configuration. Committed to git.'],
|
|
42
|
+
['.sonorance/config.json', 'Per-vault identity (id, name, created_at) + settings. Committed. Don’t hand-edit.'],
|
|
43
|
+
['.sonorance/local/', 'Machine-local runtime state — gitignored. Disposable/regenerable; never shared across machines.'],
|
|
44
|
+
['.sonorance/local/comments.jsonl', 'In-record comments (annotations on any file) — the store behind the comment bridge (address/resolve).'],
|
|
45
|
+
['.sonorance/local/feedback.jsonl', 'Your local mirror of feedback submitted from this vault — content-full (your own copy), machine-local + gitignored. The durable record even offline.'],
|
|
46
|
+
['.sonorance/local/serve.json', 'The running-editor pointer (port) — written on `serve`, read by `address` / `resolve` to find the live app.'],
|
|
47
|
+
['.sonorance/local/state.json', 'Disposable editor state — open tabs, active tab, Explorer collapse/expand. Regenerable; safe to delete.'],
|
|
48
|
+
['.sonorance/plugins.json', 'Enabled bundled plugin ids — e.g. `{ plugins: [{ id: "deliberate", enabled: true }] }`; contributions compose additively at serve time. Committed.'],
|
|
49
|
+
['.sonorance/sources.md', 'The project’s categorized grounding sources — hand-editable Markdown grouped into product, code, data, customer, go-to-market, and other sections. Committed.'],
|
|
50
|
+
['<folder>/', 'Any folder of Markdown files that Sonorance opens; the folder itself is the project.'],
|
|
51
|
+
['<folder>/**/*.md', 'Your Markdown documents — what Sonorance renders, edits (WYSIWYG, autosaved), and diffs against git.'],
|
|
52
|
+
['~/.sonorance/', 'The global Sonorance home (override with SONORANCE_HOME) — the platform state that spans folders.'],
|
|
53
|
+
['~/.sonorance/config.json', 'The vault registry: { vaults: […absolute folder paths…], current } + the anonymous install_id and telemetry consent — powers the workbench vault switcher (open / switch / remove).'],
|
|
54
|
+
['~/.sonorance/telemetry.jsonl', 'The local, human-readable telemetry audit trail — every content-free event is appended here before any network (files-first: read what we send). `sonorance telemetry show` prints it.'],
|
|
55
|
+
];
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identity.mjs — the anonymous ids that let feedback correlate with telemetry WITHOUT PII.
|
|
3
|
+
*
|
|
4
|
+
* Three opaque, random, PII-free ids (implementation spec §1), built on the identity primitives
|
|
5
|
+
* the platform already has (`~/.sonorance/config.json` home; per-vault `.sonorance/config.json`):
|
|
6
|
+
*
|
|
7
|
+
* - install_id — a random UUID minted once per install, stored in the home config. The
|
|
8
|
+
* correlation key: it rides every telemetry signal AND every feedback record,
|
|
9
|
+
* so the founder can join "this bug reporter" ↔ "what they actually did". The
|
|
10
|
+
* user can rotate it (`sonorance telemetry reset-id`) — a privacy control that
|
|
11
|
+
* deliberately breaks future correlation (the forget-me mechanism).
|
|
12
|
+
* - project_key — a random hex added to the COMMITTED per-vault `.sonorance/config.json`, so a
|
|
13
|
+
* team's clones share it (active-projects + collaboration metrics) while each
|
|
14
|
+
* person's install_id still separates them. Random → safe to commit + send;
|
|
15
|
+
* crucially NOT the vault `id` (which is slug(folderName) and leaks the name).
|
|
16
|
+
* - session_id — a random per-process id, NEVER persisted. Sequences a session's events into a
|
|
17
|
+
* journey/trace, then evaporates.
|
|
18
|
+
*
|
|
19
|
+
* The home config is also where the telemetry preference lives (`telemetry: true|false`);
|
|
20
|
+
* undefined uses the default-on posture. Reads/writes here are merge-safe: the registry owns
|
|
21
|
+
* `vaults`/`current` in the same file, so we never clobber sibling keys.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { release } from 'node:os';
|
|
27
|
+
import { registryConfigPath, sonoranceHome } from './vault-registry.mjs';
|
|
28
|
+
|
|
29
|
+
// ---- generic merge-safe JSON config helpers (atomic write, never throws) ----
|
|
30
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; } };
|
|
31
|
+
const writeJson = (p, obj) => {
|
|
32
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
33
|
+
const tmp = `${p}.tmp-${process.pid}`;
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
35
|
+
renameSync(tmp, p); // atomic: readers see old-or-new whole
|
|
36
|
+
};
|
|
37
|
+
// Read-modify-write ONE key without disturbing the rest of the file (the registry owns others).
|
|
38
|
+
const patchJson = (p, key, value) => { const c = readJson(p); c[key] = value; writeJson(p, c); return c[key]; };
|
|
39
|
+
|
|
40
|
+
const vaultConfig = (vaultDir) => join(vaultDir, '.sonorance', 'config.json');
|
|
41
|
+
|
|
42
|
+
// ---- install_id (home config) ----
|
|
43
|
+
// The stable, anonymous install identifier. Minted once (returns { id, fresh } so the caller can
|
|
44
|
+
// fire `install.created` exactly on first mint). Pure random UUIDv4 — never derived from
|
|
45
|
+
// hostname/MAC/user.
|
|
46
|
+
export function ensureInstallId() {
|
|
47
|
+
const p = registryConfigPath();
|
|
48
|
+
const c = readJson(p);
|
|
49
|
+
if (c.install_id && typeof c.install_id === 'string') return { id: c.install_id, fresh: false };
|
|
50
|
+
const id = randomUUID();
|
|
51
|
+
patchJson(p, 'install_id', id);
|
|
52
|
+
return { id, fresh: true };
|
|
53
|
+
}
|
|
54
|
+
export const installId = () => ensureInstallId().id;
|
|
55
|
+
|
|
56
|
+
// Rotate the install_id — a privacy control (and the local "forget me"): a fresh random id that
|
|
57
|
+
// deliberately breaks correlation with everything sent before. Returns the new id.
|
|
58
|
+
export function resetInstallId() {
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
patchJson(registryConfigPath(), 'install_id', id);
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- consent (home config) ----
|
|
65
|
+
// undefined = use the default-on posture; true/false = an explicit user choice.
|
|
66
|
+
export function getConsent() {
|
|
67
|
+
const v = readJson(registryConfigPath()).telemetry;
|
|
68
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
69
|
+
}
|
|
70
|
+
export function setConsent(on) {
|
|
71
|
+
patchJson(registryConfigPath(), 'telemetry', !!on);
|
|
72
|
+
return !!on;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- project_key (per-vault committed config) ----
|
|
76
|
+
// A random per-vault key added to the COMMITTED `.sonorance/config.json`. Merge-safe: preserves
|
|
77
|
+
// the identity (id/name/created_at) + any other keys the vault config holds. Null if no vaultDir.
|
|
78
|
+
export function ensureProjectKey(vaultDir) {
|
|
79
|
+
if (!vaultDir) return null;
|
|
80
|
+
const p = vaultConfig(vaultDir);
|
|
81
|
+
const c = readJson(p);
|
|
82
|
+
if (c.project_key && typeof c.project_key === 'string') return c.project_key;
|
|
83
|
+
const key = randomBytes(16).toString('hex');
|
|
84
|
+
// Only write if the vault already exists as a project (has identity) OR create the key beside
|
|
85
|
+
// whatever is there — never invent identity here (that is init's job); just add the key.
|
|
86
|
+
patchJson(p, 'project_key', key);
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
// Read-only: the project_key if one has been minted, else null (no write — for hot resolvers).
|
|
90
|
+
export function projectKey(vaultDir) {
|
|
91
|
+
if (!vaultDir) return null;
|
|
92
|
+
const k = readJson(vaultConfig(vaultDir)).project_key;
|
|
93
|
+
return typeof k === 'string' ? k : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- session_id (ephemeral, per process) ----
|
|
97
|
+
// A 32-hex value so it doubles as a valid OTLP trace id (the client strips the `s_` prefix):
|
|
98
|
+
// "the session IS a trace" (implementation spec §2.1). Never persisted.
|
|
99
|
+
let _sessionId = null;
|
|
100
|
+
export function sessionId() {
|
|
101
|
+
if (!_sessionId) _sessionId = 's_' + randomBytes(16).toString('hex');
|
|
102
|
+
return _sessionId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- coarse, content-free environment context ----
|
|
106
|
+
export function coarseEnv() {
|
|
107
|
+
const nodeMajor = Number(String(process.versions.node || '').split('.')[0]) || null;
|
|
108
|
+
const osMajor = Number(String(release() || '').split('.')[0]) || null;
|
|
109
|
+
return {
|
|
110
|
+
os: process.platform, // darwin | linux | win32
|
|
111
|
+
os_major: osMajor,
|
|
112
|
+
node_major: nodeMajor,
|
|
113
|
+
harness: harnessHint(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// A best-effort, coarse hint at the agent harness the CLI runs under — env-var sniffing only,
|
|
117
|
+
// never anything identifying. Extend as new harnesses appear.
|
|
118
|
+
function harnessHint() {
|
|
119
|
+
const e = process.env;
|
|
120
|
+
if (e.COPILOT_AGENT_ID || e.GITHUB_COPILOT_CLI || e.COPILOT_CLI) return 'copilot-cli';
|
|
121
|
+
if (e.CLAUDECODE || e.CLAUDE_CODE) return 'claude-code';
|
|
122
|
+
if (e.CURSOR_TRACE_ID || e.CURSOR) return 'cursor';
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { sonoranceHome };
|
package/src/otlp.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* otlp.mjs — a small, dependency-free OTLP/HTTP+JSON exporter (encoders + sender).
|
|
3
|
+
*
|
|
4
|
+
* Why hand-rolled and not the full `@opentelemetry/sdk-node`: the wire format is what makes
|
|
5
|
+
* telemetry vendor-agnostic ("won't have to migrate off"), and OTLP/HTTP+JSON is a stable,
|
|
6
|
+
* published protocol. Emitting it directly keeps the app dependency-light and zero-build (the
|
|
7
|
+
* house style) while staying 100% OTLP — the SAME bytes any OTLP backend (Azure Monitor, Grafana,
|
|
8
|
+
* Honeycomb) or the official OpenTelemetry Collector ingests. This is the *client exporter*, NOT a
|
|
9
|
+
* collector: the heavy aggregation/redaction/fan-out tier is the off-the-shelf `otelcol`, which
|
|
10
|
+
* this can export to unchanged by pointing the endpoint at it.
|
|
11
|
+
*
|
|
12
|
+
* Three signals are encoded: log records (events + feedback), metrics (aggregate rollups), and
|
|
13
|
+
* spans (a session trace + command spans). The telemetry client owns buffering + the resource;
|
|
14
|
+
* this module is pure: shape in → OTLP JSON out, plus a fire-and-forget POST.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ---- primitives -------------------------------------------------------------
|
|
18
|
+
// OTLP timestamps are uint64 nanoseconds as a STRING. Derive from JS milliseconds.
|
|
19
|
+
export const nanos = (ms = Date.now()) => String(BigInt(Math.round(ms)) * 1000000n);
|
|
20
|
+
|
|
21
|
+
// Encode a flat { key: value } map into OTLP's KeyValue[] (skipping null/undefined). Numbers that
|
|
22
|
+
// are integers use intValue (int64-as-string per spec); non-integers use doubleValue.
|
|
23
|
+
export function toAttributes(obj = {}) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
26
|
+
if (v == null) continue;
|
|
27
|
+
let value;
|
|
28
|
+
if (typeof v === 'boolean') value = { boolValue: v };
|
|
29
|
+
else if (typeof v === 'number') value = Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
|
|
30
|
+
else value = { stringValue: String(v) };
|
|
31
|
+
out.push({ key, value });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SCOPE = { name: 'sonorance', version: '1' };
|
|
37
|
+
// A session_id like `s_<32hex>` → the 32-hex OTLP trace id; anything else → undefined (no link).
|
|
38
|
+
export const traceIdOf = (sessionId) => {
|
|
39
|
+
const hex = String(sessionId || '').replace(/^s_/, '');
|
|
40
|
+
return /^[0-9a-f]{32}$/i.test(hex) ? hex.toLowerCase() : undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---- encoders ---------------------------------------------------------------
|
|
44
|
+
// records: [{ name, ts, attributes, traceId? }] → OTLP ExportLogsServiceRequest.
|
|
45
|
+
export function encodeLogs(records, resourceAttrs) {
|
|
46
|
+
return {
|
|
47
|
+
resourceLogs: [{
|
|
48
|
+
resource: { attributes: toAttributes(resourceAttrs) },
|
|
49
|
+
scopeLogs: [{
|
|
50
|
+
scope: SCOPE,
|
|
51
|
+
logRecords: records.map(r => {
|
|
52
|
+
const rec = {
|
|
53
|
+
timeUnixNano: nanos(r.ts),
|
|
54
|
+
severityNumber: r.name === 'error' ? 17 : 9, // ERROR : INFO
|
|
55
|
+
severityText: r.name === 'error' ? 'ERROR' : 'INFO',
|
|
56
|
+
body: { stringValue: r.name },
|
|
57
|
+
attributes: toAttributes(r.attributes || {}),
|
|
58
|
+
};
|
|
59
|
+
if (r.traceId) rec.traceId = r.traceId;
|
|
60
|
+
return rec;
|
|
61
|
+
}),
|
|
62
|
+
}],
|
|
63
|
+
}],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// metrics: counters + gauges + histograms → OTLP ExportMetricsServiceRequest.
|
|
68
|
+
// Counters/histograms use DELTA temporality; gauges are the latest current-value snapshot.
|
|
69
|
+
export function encodeMetrics({ counters = [], gauges = [], histograms = [] } = {}, resourceAttrs, { start, end } = {}) {
|
|
70
|
+
const startNano = nanos(start), endNano = nanos(end);
|
|
71
|
+
const metrics = [];
|
|
72
|
+
for (const c of counters) {
|
|
73
|
+
metrics.push({
|
|
74
|
+
name: c.name, unit: '1',
|
|
75
|
+
sum: {
|
|
76
|
+
aggregationTemporality: 1, isMonotonic: true,
|
|
77
|
+
dataPoints: [{ asInt: String(Math.round(c.value || 0)), startTimeUnixNano: startNano, timeUnixNano: endNano, attributes: toAttributes(c.attributes || {}) }],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (const g of gauges) {
|
|
82
|
+
metrics.push({
|
|
83
|
+
name: g.name, unit: '1',
|
|
84
|
+
gauge: {
|
|
85
|
+
dataPoints: [{ asInt: String(Math.round(g.value || 0)), timeUnixNano: endNano, attributes: toAttributes(g.attributes || {}) }],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
for (const h of histograms) {
|
|
90
|
+
metrics.push({
|
|
91
|
+
name: h.name, unit: 'ms',
|
|
92
|
+
histogram: {
|
|
93
|
+
aggregationTemporality: 1,
|
|
94
|
+
dataPoints: [{
|
|
95
|
+
count: String(h.count || 0), sum: h.sum || 0,
|
|
96
|
+
explicitBounds: h.bounds || [], bucketCounts: (h.buckets || []).map(String),
|
|
97
|
+
startTimeUnixNano: startNano, timeUnixNano: endNano, attributes: toAttributes(h.attributes || {}),
|
|
98
|
+
}],
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { resourceMetrics: [{ resource: { attributes: toAttributes(resourceAttrs) }, scopeMetrics: [{ scope: SCOPE, metrics }] }] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// spans: [{ name, traceId, spanId, parentSpanId?, start, end, attributes, ok }] → OTLP traces.
|
|
106
|
+
export function encodeTraces(spans, resourceAttrs) {
|
|
107
|
+
return {
|
|
108
|
+
resourceSpans: [{
|
|
109
|
+
resource: { attributes: toAttributes(resourceAttrs) },
|
|
110
|
+
scopeSpans: [{
|
|
111
|
+
scope: SCOPE,
|
|
112
|
+
spans: spans.map(s => {
|
|
113
|
+
const span = {
|
|
114
|
+
traceId: s.traceId, spanId: s.spanId,
|
|
115
|
+
name: s.name, kind: 1, // INTERNAL
|
|
116
|
+
startTimeUnixNano: nanos(s.start), endTimeUnixNano: nanos(s.end ?? s.start),
|
|
117
|
+
attributes: toAttributes(s.attributes || {}),
|
|
118
|
+
status: { code: s.ok === false ? 2 : 0 }, // ERROR : UNSET
|
|
119
|
+
};
|
|
120
|
+
if (s.parentSpanId) span.parentSpanId = s.parentSpanId;
|
|
121
|
+
return span;
|
|
122
|
+
}),
|
|
123
|
+
}],
|
|
124
|
+
}],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- sender -----------------------------------------------------------------
|
|
129
|
+
// Resolve the base OTLP/HTTP endpoint. Honours the standard OTel env vars so the founder can point
|
|
130
|
+
// it at Azure Monitor (or a local collector) with no code change; defaults to the collector's
|
|
131
|
+
// conventional localhost port (4318) for the local-test path.
|
|
132
|
+
export function otlpEndpoint() {
|
|
133
|
+
return process.env.SONORANCE_OTLP_ENDPOINT
|
|
134
|
+
|| process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
135
|
+
|| 'http://localhost:4318';
|
|
136
|
+
}
|
|
137
|
+
// Parse OTEL_EXPORTER_OTLP_HEADERS ("k1=v1,k2=v2") into a headers object.
|
|
138
|
+
export function otlpHeaders() {
|
|
139
|
+
const raw = process.env.SONORANCE_OTLP_HEADERS || process.env.OTEL_EXPORTER_OTLP_HEADERS || '';
|
|
140
|
+
const h = { 'content-type': 'application/json' };
|
|
141
|
+
for (const pair of raw.split(',')) {
|
|
142
|
+
const i = pair.indexOf('='); if (i < 0) continue;
|
|
143
|
+
const k = pair.slice(0, i).trim(); const v = pair.slice(i + 1).trim();
|
|
144
|
+
if (k) h[k] = v;
|
|
145
|
+
}
|
|
146
|
+
return h;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SIGNAL_PATH = { logs: '/v1/logs', metrics: '/v1/metrics', traces: '/v1/traces' };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* POST one signal's OTLP JSON payload. Fire-and-forget by design: resolves `{ ok }` and NEVER
|
|
153
|
+
* throws or blocks the caller for long (a hard timeout aborts a stuck request). Failures are
|
|
154
|
+
* swallowed — the client keeps its local JSONL as the audit/offline trail.
|
|
155
|
+
*/
|
|
156
|
+
export async function sendOtlp(signal, payload, { endpoint = otlpEndpoint(), headers = otlpHeaders(), timeoutMs = 3000 } = {}) {
|
|
157
|
+
const url = endpoint.replace(/\/$/, '') + (SIGNAL_PATH[signal] || `/v1/${signal}`);
|
|
158
|
+
const ac = new AbortController();
|
|
159
|
+
const t = setTimeout(() => ac.abort(), timeoutMs); t.unref?.();
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload), signal: ac.signal });
|
|
162
|
+
return { ok: !!res && res.ok, status: res?.status };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { ok: false, error: e?.name || 'error' };
|
|
165
|
+
} finally { clearTimeout(t); }
|
|
166
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contribute.mjs — the Deliberate PLUGIN's additive contribution to the Sonorance engine.
|
|
3
|
+
*
|
|
4
|
+
* Sonorance is the host/platform; Deliberate is a per-vault plugin bundled inside this package (for
|
|
5
|
+
* extensibility) that LAYERS capabilities on top of the generic engine — it never replaces it. The
|
|
6
|
+
* host builds the default engine for every vault, then, for a vault that enables this plugin (in its
|
|
7
|
+
* `.sonorance/plugins.json`), merges these contributions:
|
|
8
|
+
*
|
|
9
|
+
* - `KINDS` — the document-kind registry (cases, one-pagers, briefs) that drives the
|
|
10
|
+
* inbox sections + per-file badges. The base engine contributes none.
|
|
11
|
+
* - `openVault` — the Deliberate record store (cases/briefs/context/competitors/ecosystem), overlaid
|
|
12
|
+
* on the base store so generic vault/comment/state machinery stays app-owned.
|
|
13
|
+
* - `gitignoreEntries` — also keep any hidden `deliberate/` subfolder out of git.
|
|
14
|
+
*
|
|
15
|
+
* Identity (name/serve/address) is deliberately NOT contributed: the app is always Sonorance.
|
|
16
|
+
*/
|
|
17
|
+
import { KINDS } from './kinds.mjs';
|
|
18
|
+
import { openVault } from './vault.mjs';
|
|
19
|
+
import { vaultIgnoreEntries } from './gitignore.mjs';
|
|
20
|
+
|
|
21
|
+
// The additive contribution the host composes onto the base engine for a Deliberate vault.
|
|
22
|
+
export function contribute() {
|
|
23
|
+
return {
|
|
24
|
+
KINDS,
|
|
25
|
+
openVault,
|
|
26
|
+
gitignoreEntries: (project) => vaultIgnoreEntries(project.dir),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain.mjs — shared vocabulary for the whole engine: the case lifecycle
|
|
3
|
+
* states, per-stage statuses, score thresholds, and the sentinel used to record
|
|
4
|
+
* the "current" project. Centralised here so CLI, server, store and pipeline
|
|
5
|
+
* agree on the same string literals instead of repeating them (and risking typos).
|
|
6
|
+
*
|
|
7
|
+
* Zero dependencies — safe to import from any layer, including the store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Case lifecycle state (cases.state). 'new' is the initial state; while a run is
|
|
11
|
+
// in flight the state holds the name of the stage currently executing; 'done'/'error'
|
|
12
|
+
// are terminal. (There are no manual triage states — a case is managed as a file.)
|
|
13
|
+
export const STATE = Object.freeze({
|
|
14
|
+
NEW: 'new',
|
|
15
|
+
ERROR: 'error',
|
|
16
|
+
DONE: 'done',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Per-stage execution status (stages.status).
|
|
20
|
+
export const STATUS = Object.freeze({
|
|
21
|
+
PENDING: 'pending',
|
|
22
|
+
RUNNING: 'running',
|
|
23
|
+
DONE: 'done',
|
|
24
|
+
ERROR: 'error',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Inbox score colour bands (0–10 scale). A case at/above GOOD is green, at/above
|
|
28
|
+
// OK is amber, below is red. These match the score advance/shelve/reject bands
|
|
29
|
+
// and the UI's `sccls` helper (src/ui/index.html). scoreClass() returns the
|
|
30
|
+
// matching CSS class ('g'|'y'|'r') or null.
|
|
31
|
+
export const SCORE_GOOD = 7;
|
|
32
|
+
export const SCORE_OK = 5;
|
|
33
|
+
export function scoreClass(score) {
|
|
34
|
+
if (score == null) return null;
|
|
35
|
+
return score >= SCORE_GOOD ? 'g' : score >= SCORE_OK ? 'y' : 'r';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The "current project" pointer is stored as a config row under a sentinel
|
|
39
|
+
// project id ('_'), so both the CLI and the daemon resolve the same selection.
|
|
40
|
+
export const CURRENT = Object.freeze({ project: '_', key: 'last' });
|