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/cli.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli.mjs — the Sonorance CLI. Subcommands:
|
|
4
|
+
* serve [<folder>] [--port <n>] [--open] run the editor over a folder (default: cwd)
|
|
5
|
+
* comment list the open comments from a running editor (as JSON)
|
|
6
|
+
* comment <id> resolve [--note "<t>"] [--revised] mark a comment resolved (back to the editor)
|
|
7
|
+
* source [list | add <location> ["<d>"] | remove <location>] the shared grounding sources
|
|
8
|
+
* install-skill [--project <dir> | --here] install the /sonorance skill into a harness
|
|
9
|
+
*
|
|
10
|
+
* The comment bridge (`comment list` / `comment <id> resolve`) are HTTP clients of the running
|
|
11
|
+
* server; the port is discovered from the `.sonorance/local/serve.json` the server writes on
|
|
12
|
+
* `serve`. Standalone, no host.
|
|
13
|
+
*/
|
|
14
|
+
import { resolve as pathResolve, join, dirname } from 'node:path';
|
|
15
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, cpSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { ensureGitignore } from './gitignore.mjs';
|
|
20
|
+
import { createRegistry } from './vault-registry.mjs';
|
|
21
|
+
import { parseSources, serializeSources } from './sources.mjs';
|
|
22
|
+
import { configureTelemetry, refreshTelemetry, emit, telemetry, telemetryMode, telemetryPreview, readAudit, shutdownTelemetry, flushTelemetry, resolveTransport } from './telemetry.mjs';
|
|
23
|
+
import { installId, ensureInstallId, resetInstallId, getConsent, setConsent } from './identity.mjs';
|
|
24
|
+
import { CommentClientError, fetchCommentBatch, resolveComment } from './comment-client.mjs';
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const VERSION = (() => { try { return String(JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version || '0'); } catch { return '0'; } })();
|
|
28
|
+
const A = process.argv.slice(2);
|
|
29
|
+
const cmd = A[0];
|
|
30
|
+
const rest = A.slice(1);
|
|
31
|
+
const P = (...a) => console.log(...a);
|
|
32
|
+
const opt = (f) => { const i = A.indexOf(f); return i >= 0 ? A[i + 1] : null; };
|
|
33
|
+
// Flags that consume the next token as their value — so it isn't mistaken for a positional.
|
|
34
|
+
const VALUE_FLAGS = new Set(['--name', '--port', '--project', '--note', '--section']);
|
|
35
|
+
// Positional args from `rest`, skipping flags and the values that follow value-flags.
|
|
36
|
+
function positionals() {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let i = 0; i < rest.length; i++) {
|
|
39
|
+
const a = rest[i];
|
|
40
|
+
if (a.startsWith('--')) { if (VALUE_FLAGS.has(a)) i++; continue; }
|
|
41
|
+
out.push(a);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- shared config helpers (the hidden `.sonorance/` platform dir at the folder root) ----
|
|
47
|
+
const sonoranceDir = (root) => join(root, '.sonorance');
|
|
48
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; } };
|
|
49
|
+
|
|
50
|
+
// A recorded server process is only trustworthy if it's still alive: `process.kill(pid, 0)`
|
|
51
|
+
// probes liveness without signalling. Lenient — only a definitively-dead pid (ESRCH) is
|
|
52
|
+
// rejected; a missing/opaque pid is trusted (backward compatible), so a stale serve.json left
|
|
53
|
+
// behind by a crashed server no longer points `address`/`resolve` at a dead port.
|
|
54
|
+
function pidAlive(pid) {
|
|
55
|
+
if (!Number.isInteger(pid)) return true;
|
|
56
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; }
|
|
57
|
+
}
|
|
58
|
+
// Discover the running editor's port: the `.sonorance/local/serve.json` it wrote in the cwd, else --port, else default.
|
|
59
|
+
function discoverPort() {
|
|
60
|
+
try { const j = JSON.parse(readFileSync(join(process.cwd(), '.sonorance', 'local', 'serve.json'), 'utf8')); if (j && j.port && pidAlive(j.pid)) return +j.port; } catch { /* none */ }
|
|
61
|
+
return +opt('--port') || 7777;
|
|
62
|
+
}
|
|
63
|
+
const apiUrl = (p) => `http://localhost:${discoverPort()}${p}`;
|
|
64
|
+
|
|
65
|
+
async function serve() {
|
|
66
|
+
const { startServer } = await import('./server/index.mjs');
|
|
67
|
+
const { resolveEngine } = await import('./plugins.mjs');
|
|
68
|
+
let port, open = false, dir;
|
|
69
|
+
for (let i = 0; i < rest.length; i++) {
|
|
70
|
+
const a = rest[i];
|
|
71
|
+
if (a === '--open') open = true;
|
|
72
|
+
else if (a === '--port') port = +rest[++i];
|
|
73
|
+
else if (!a.startsWith('--') && !dir) dir = a;
|
|
74
|
+
}
|
|
75
|
+
const root = pathResolve(dir || process.cwd());
|
|
76
|
+
const engine = await resolveEngine(root); // load the vault's plugin flavor (or the generic editor)
|
|
77
|
+
const { port: bound } = await startServer(Number.isFinite(port) ? { engine, port } : { engine });
|
|
78
|
+
const u = `http://localhost:${bound}`;
|
|
79
|
+
P(`Sonorance → ${u}`);
|
|
80
|
+
P(` folder: ${root}`);
|
|
81
|
+
if (open) {
|
|
82
|
+
const c = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
83
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', u] : [u];
|
|
84
|
+
try { spawn(c, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
P(' Ctrl-C to stop.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The comment bridge — a machine surface over the running editor:
|
|
90
|
+
// comment list → the open in-record comments as JSON (always JSON; the skill parses it)
|
|
91
|
+
// comment <id> resolve [...] → mark one comment resolved back to the editor
|
|
92
|
+
// The port is auto-discovered from `.sonorance/local/serve.json` — no host, no port flag.
|
|
93
|
+
async function comment() {
|
|
94
|
+
const [a, b] = positionals();
|
|
95
|
+
if (!a || a === 'list') return commentList();
|
|
96
|
+
if (b === 'resolve') return commentResolve(a);
|
|
97
|
+
return P('usage: sonorance comment <list | <id> resolve [--note "<text>"] [--revised]>');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function commentList() {
|
|
101
|
+
const j = await fetchCommentBatch(
|
|
102
|
+
apiUrl('/api/address'),
|
|
103
|
+
`could not reach Sonorance on port ${discoverPort()} — is it running? (sonorance serve)`,
|
|
104
|
+
);
|
|
105
|
+
return P(JSON.stringify(j));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function commentResolve(id) {
|
|
109
|
+
const body = { commentId: id, revised: A.includes('--revised') };
|
|
110
|
+
const note = opt('--note'); if (note) body.note = note;
|
|
111
|
+
await resolveComment(
|
|
112
|
+
apiUrl('/api/resolve'),
|
|
113
|
+
body,
|
|
114
|
+
`could not reach Sonorance on port ${discoverPort()} — is it running? (sonorance serve)`,
|
|
115
|
+
);
|
|
116
|
+
return P(`✓ resolved comment ${id}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `init` — scaffold a Sonorance project in a folder: the hidden `.sonorance/config.json` (the
|
|
120
|
+
// ONE per-vault file — identity + sources + settings). This is the PLATFORM-level init; a skill
|
|
121
|
+
// like Deliberate layers its own `deliberate/…` content on top and its engine pointer (`deliberate init`).
|
|
122
|
+
function init() {
|
|
123
|
+
const dir = pathResolve(positionals()[0] || process.cwd());
|
|
124
|
+
const sd = sonoranceDir(dir);
|
|
125
|
+
mkdirSync(sd, { recursive: true });
|
|
126
|
+
const cfgPath = join(sd, 'config.json');
|
|
127
|
+
const fresh = !readJson(cfgPath).id;
|
|
128
|
+
// register writes identity (id/name/created_at) into .sonorance/config.json (merge-safe) and
|
|
129
|
+
// makes this the current vault in the global ~/.sonorance registry.
|
|
130
|
+
createRegistry().register(dir, { name: opt('--name') || undefined });
|
|
131
|
+
const sourcesPath = join(sd, 'sources.md');
|
|
132
|
+
if (!existsSync(sourcesPath)) writeFileSync(sourcesPath, serializeSources([]));
|
|
133
|
+
// Committed config (identity · sources · plugins) stays in git; only machine-local runtime
|
|
134
|
+
// state under `.sonorance/local/` is ignored.
|
|
135
|
+
const ignored = ensureGitignore(dir, ['.sonorance/local/']);
|
|
136
|
+
P(`${fresh ? '✓ initialized' : '✓ already a'} Sonorance project → ${dir}`);
|
|
137
|
+
P(` config: .sonorance/config.json (identity · settings, committed)`);
|
|
138
|
+
P(` sources: .sonorance/sources.md (grounding, hand-editable, committed)`);
|
|
139
|
+
if (ignored.length) P(` gitignore: added ${ignored.join(', ')}`);
|
|
140
|
+
P(` next → add grounding: sonorance source add <location>`);
|
|
141
|
+
P(` open it: sonorance serve`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `open` — register an existing folder as a vault in the global `~/.sonorance` registry and make
|
|
145
|
+
// it current, so it shows up in the app's vault switcher.
|
|
146
|
+
// Unlike `init` it assumes the folder already exists; it writes `.sonorance/config.json` if absent.
|
|
147
|
+
function openCmd() {
|
|
148
|
+
const dir = pathResolve(positionals()[0] || process.cwd());
|
|
149
|
+
if (!existsSync(dir)) return P(`no such folder: ${dir}`);
|
|
150
|
+
const v = createRegistry().register(dir, { name: opt('--name') || undefined });
|
|
151
|
+
P(`✓ opened vault → ${v.dir}`);
|
|
152
|
+
P(` it's now current — run \`sonorance serve\` here, or switch to it in the app's vault switcher.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// `source` — manage the shared grounding sources, stored in the hand-editable
|
|
156
|
+
// `.sonorance/sources.md`. Platform-level, so every skill running on the project can read them.
|
|
157
|
+
// source list | source add <location> ["<description>"] [--section <section>] | source remove <location>
|
|
158
|
+
function source() {
|
|
159
|
+
const dir = process.cwd();
|
|
160
|
+
const mdPath = join(sonoranceDir(dir), 'sources.md');
|
|
161
|
+
const read = () => { try { return parseSources(readFileSync(mdPath, 'utf8')); } catch { return []; } };
|
|
162
|
+
const write = (list) => { mkdirSync(dirname(mdPath), { recursive: true }); writeFileSync(mdPath, serializeSources(list)); };
|
|
163
|
+
const pos = positionals();
|
|
164
|
+
const sub = pos[0];
|
|
165
|
+
const args = pos.slice(1);
|
|
166
|
+
if (sub === 'add') {
|
|
167
|
+
const location = args[0]; if (!location) return P('usage: sonorance source add <location> ["<description>"] [--section <section>]');
|
|
168
|
+
const list = read();
|
|
169
|
+
if (!list.some(s => s.location === location)) list.push({ location, description: args.slice(1).join(' '), section: opt('--section') || 'other' });
|
|
170
|
+
write(list); return P(`✓ added source ${location}`);
|
|
171
|
+
}
|
|
172
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
173
|
+
const location = args[0]; if (!location) return P('usage: sonorance source remove <location>');
|
|
174
|
+
write(read().filter(s => s.location !== location)); return P(`✓ removed source ${location}`);
|
|
175
|
+
}
|
|
176
|
+
const list = read();
|
|
177
|
+
if (!list.length) return P('no sources — add one with `sonorance source add <location>`');
|
|
178
|
+
let section = null;
|
|
179
|
+
for (const s of list) {
|
|
180
|
+
if (s.sectionTitle !== section) { section = s.sectionTitle; P(`${section}:`); }
|
|
181
|
+
P(`- ${s.location}${s.description ? ` (${s.description})` : ''}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function installSkill() {
|
|
186
|
+
const src = join(here, '..', 'skill');
|
|
187
|
+
if (!existsSync(src)) return P(`skill source not found at ${src}`);
|
|
188
|
+
const projectDir = opt('--project') || (A.includes('--here') ? process.cwd() : (positionals()[0] || null));
|
|
189
|
+
const project = !!projectDir;
|
|
190
|
+
const dest = project ? join(pathResolve(projectDir), '.github', 'skills', 'sonorance') : join(homedir(), '.copilot', 'skills', 'sonorance');
|
|
191
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
192
|
+
cpSync(src, dest, { recursive: true });
|
|
193
|
+
// Bake this checkout's CLI path so the launcher resolves it without a global install.
|
|
194
|
+
const engineFile = fileURLToPath(new URL('./cli.mjs', import.meta.url));
|
|
195
|
+
writeFileSync(join(dest, 'scripts', 'engine.json'), JSON.stringify({ engine: engineFile }, null, 2) + '\n');
|
|
196
|
+
P(`✓ installed the /sonorance skill (${project ? 'project' : 'global'}) → ${dest}`);
|
|
197
|
+
P(` open a folder in your coding agent, run \`sonorance serve\`, leave comments, then /sonorance address`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const dispatch = { init, open: openCmd, source, serve, comment, telemetry: telemetryCmd, 'install-skill': installSkill };
|
|
201
|
+
|
|
202
|
+
// `sonorance telemetry [status|on|off|show|reset-id|test]` — inspect and control the anonymous,
|
|
203
|
+
// content-free product telemetry. This is the standalone/CLI surface for the opt-out switch; the
|
|
204
|
+
// UI has an equivalent settings control, and both resolve the same ~/.sonorance consent.
|
|
205
|
+
async function telemetryCmd() {
|
|
206
|
+
const [sub] = positionals();
|
|
207
|
+
configureTelemetry({ surface: 'cli', product: 'sonorance', version: VERSION, vaultDir: process.cwd() });
|
|
208
|
+
const shortId = () => (installId() || '').slice(0, 8);
|
|
209
|
+
switch (sub || 'status') {
|
|
210
|
+
case 'status': {
|
|
211
|
+
const active = telemetryMode() !== 'off';
|
|
212
|
+
const consent = getConsent();
|
|
213
|
+
const choice = consent === undefined ? 'default on (opt-out)' : (consent ? 'on' : 'off');
|
|
214
|
+
const transport = resolveTransport();
|
|
215
|
+
P(`telemetry: ${active ? 'on' : 'off'} (mode: ${telemetryMode()}, consent: ${choice})`);
|
|
216
|
+
P(` install id: ${shortId()}…`);
|
|
217
|
+
P(` destination: ${transport.label}`);
|
|
218
|
+
P(` audit log: ~/.sonorance/telemetry.jsonl (sonorance telemetry show)`);
|
|
219
|
+
P(` it is anonymous and content-free — never prompts, completions, or file contents.`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'on': {
|
|
223
|
+
setConsent(true);
|
|
224
|
+
refreshTelemetry();
|
|
225
|
+
emit('telemetry.enabled', {});
|
|
226
|
+
await flushTelemetry();
|
|
227
|
+
P('✓ telemetry on — thank you; it helps shape what to build next. Turn it off anytime with `sonorance telemetry off`.');
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'off': {
|
|
231
|
+
emit('telemetry.disabled', {}); // record the opt-out itself, while still on
|
|
232
|
+
await flushTelemetry();
|
|
233
|
+
setConsent(false);
|
|
234
|
+
refreshTelemetry();
|
|
235
|
+
P('✓ telemetry off — nothing further will be sent from this machine.');
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'show': {
|
|
239
|
+
const rows = await readAudit(50);
|
|
240
|
+
if (!rows.length) { P('(no telemetry recorded yet)'); break; }
|
|
241
|
+
P(`last ${rows.length} events (from ~/.sonorance/telemetry.jsonl):`);
|
|
242
|
+
for (const r of rows) {
|
|
243
|
+
const when = typeof r.ts === 'number' ? new Date(r.ts).toISOString() : (r.ts || '');
|
|
244
|
+
P(` ${when} ${r.type || r.name || ''}`);
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'reset-id': {
|
|
249
|
+
const id = resetInstallId();
|
|
250
|
+
P(`✓ rotated install id → ${id.slice(0, 8)}… (prior history is now unlinkable)`);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case 'test': {
|
|
254
|
+
// Local pipeline check: mint a sample event and print (or send) exactly what would leave over
|
|
255
|
+
// the active transport (Azure Monitor by default, OTLP if configured, or local-only).
|
|
256
|
+
emit('session.start', {});
|
|
257
|
+
emit('command.run', { name: 'telemetry-test', ok: true, duration_ms: 0 });
|
|
258
|
+
const pv = telemetryPreview();
|
|
259
|
+
P(`telemetry test — mode: ${telemetryMode()}, destination: ${pv.transport.label}`);
|
|
260
|
+
P(` buffered ${pv.count} record(s). To watch the raw payload locally, set SONORANCE_TELEMETRY=console.`);
|
|
261
|
+
if (telemetryMode() === 'off') {
|
|
262
|
+
P(' telemetry is off — run `sonorance telemetry on` (or set SONORANCE_TELEMETRY=console) first.');
|
|
263
|
+
} else if (telemetryMode() === 'console') {
|
|
264
|
+
await flushTelemetry();
|
|
265
|
+
P(' ✓ printed above in console mode (nothing sent over the network).');
|
|
266
|
+
} else if (pv.transport.kind === 'none') {
|
|
267
|
+
P(' no destination configured — set APPLICATIONINSIGHTS_CONNECTION_STRING (Azure Monitor) or');
|
|
268
|
+
P(' SONORANCE_OTLP_ENDPOINT (a collector / OTLP backend). Everything is still kept locally.');
|
|
269
|
+
} else {
|
|
270
|
+
const res = await flushTelemetry();
|
|
271
|
+
P(res && res.ok
|
|
272
|
+
? ` ✓ reached ${pv.transport.label} (HTTP ${res.status}) — check your destination.`
|
|
273
|
+
: ` ✗ could not reach ${pv.transport.label} (${(res && (res.error || res.status)) || 'unknown'}). Everything is still kept locally.`);
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
P('usage: sonorance telemetry [status | on | off | show | reset-id | test]');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Only dispatch when run as the entry point (`sonorance <cmd>`); importing this module to inspect
|
|
283
|
+
// `dispatch` must have no side effects.
|
|
284
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
285
|
+
const run = dispatch[cmd];
|
|
286
|
+
if (!run) {
|
|
287
|
+
P('usage: sonorance <init|open|serve|source|comment|telemetry|install-skill>');
|
|
288
|
+
} else if (cmd === 'serve' || cmd === 'telemetry') {
|
|
289
|
+
// `serve` is the surface=ui process (the server configures telemetry); `telemetry` is the
|
|
290
|
+
// control surface itself — neither is wrapped as a plain `command.run`.
|
|
291
|
+
await run();
|
|
292
|
+
} else {
|
|
293
|
+
const freshInstall = ensureInstallId().fresh; // capture BEFORE configure (which mints the id)
|
|
294
|
+
configureTelemetry({ surface: 'cli', product: 'sonorance', version: VERSION, vaultDir: process.cwd() });
|
|
295
|
+
if (freshInstall) emit('install.created', {});
|
|
296
|
+
emit('session.start', {});
|
|
297
|
+
const t0 = Date.now();
|
|
298
|
+
let ok = true;
|
|
299
|
+
try {
|
|
300
|
+
await run();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
ok = false;
|
|
303
|
+
try { emit('error', { command: cmd, class: (err && err.constructor && err.constructor.name) || 'Error' }); } catch { /* never let telemetry break the CLI */ }
|
|
304
|
+
if (err instanceof CommentClientError) {
|
|
305
|
+
process.stderr.write(`${err.message}\n`);
|
|
306
|
+
process.exitCode = 1;
|
|
307
|
+
} else {
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
try { emit('command.run', { name: cmd, ok, duration_ms: Date.now() - t0 }); } catch { /* ignore */ }
|
|
312
|
+
await shutdownTelemetry();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class CommentClientError extends Error {}
|
|
2
|
+
|
|
3
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
const isComment = (comment) => {
|
|
5
|
+
if (!isObject(comment)
|
|
6
|
+
|| typeof comment.id !== 'string'
|
|
7
|
+
|| !comment.id.trim()
|
|
8
|
+
|| typeof comment.file !== 'string'
|
|
9
|
+
|| !comment.file.trim()
|
|
10
|
+
|| typeof comment.body !== 'string'
|
|
11
|
+
|| !comment.body.trim()
|
|
12
|
+
|| !isObject(comment.anchor)
|
|
13
|
+
|| typeof comment.anchor.quote !== 'string'
|
|
14
|
+
|| typeof comment.anchor.heading !== 'string'
|
|
15
|
+
|| (comment.addressing !== undefined && typeof comment.addressing !== 'string')
|
|
16
|
+
|| (comment.ts !== undefined && !Number.isFinite(comment.ts))) return false;
|
|
17
|
+
const hasTextAnchor = Boolean(comment.anchor.quote.trim() || comment.anchor.heading.trim());
|
|
18
|
+
if (comment.anchor.line === undefined) return comment.anchor.endLine === undefined && hasTextAnchor;
|
|
19
|
+
return Number.isInteger(comment.anchor.line)
|
|
20
|
+
&& comment.anchor.line >= 0
|
|
21
|
+
&& Number.isInteger(comment.anchor.endLine)
|
|
22
|
+
&& comment.anchor.endLine >= comment.anchor.line;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function responseJson(url, init, operation, unavailableMessage) {
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(url, init);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new CommentClientError(unavailableMessage);
|
|
31
|
+
}
|
|
32
|
+
if (!response.ok) throw new CommentClientError(`${operation} failed: app returned HTTP ${response.status}`);
|
|
33
|
+
try {
|
|
34
|
+
return await response.json();
|
|
35
|
+
} catch {
|
|
36
|
+
throw new CommentClientError(`${operation} failed: app returned an invalid response`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function fetchCommentBatch(url, unavailableMessage) {
|
|
41
|
+
const payload = await responseJson(url, undefined, 'comment list', unavailableMessage);
|
|
42
|
+
if (isObject(payload) && payload.error) throw new CommentClientError(`comment list failed: ${payload.error}`);
|
|
43
|
+
if (!isObject(payload)
|
|
44
|
+
|| !Array.isArray(payload.comments)
|
|
45
|
+
|| !Number.isInteger(payload.count)
|
|
46
|
+
|| payload.count !== payload.comments.length
|
|
47
|
+
|| payload.comments.some(comment => !isComment(comment))) {
|
|
48
|
+
throw new CommentClientError('comment list failed: app returned an invalid response');
|
|
49
|
+
}
|
|
50
|
+
return payload;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function fetchCommentProject(url, unavailableMessage) {
|
|
54
|
+
const payload = await responseJson(url, undefined, 'project check', unavailableMessage);
|
|
55
|
+
if (!isObject(payload)
|
|
56
|
+
|| !isObject(payload.project)
|
|
57
|
+
|| typeof payload.project.id !== 'string'
|
|
58
|
+
|| !payload.project.id.trim()
|
|
59
|
+
|| typeof payload.project.dir !== 'string'
|
|
60
|
+
|| !payload.project.dir.trim()) {
|
|
61
|
+
throw new CommentClientError('project check failed: app is serving a different or invalid project');
|
|
62
|
+
}
|
|
63
|
+
return payload.project;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function resolveComment(url, body, unavailableMessage) {
|
|
67
|
+
const payload = await responseJson(url, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
}, 'resolve', unavailableMessage);
|
|
72
|
+
if (!isObject(payload)) throw new CommentClientError('resolve failed: app returned an invalid response');
|
|
73
|
+
if (payload.ok !== true) throw new CommentClientError(`resolve failed: ${payload.error || 'unknown'}`);
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-default.mjs — the app's built-in GENERIC engine, so it runs standalone as a plain
|
|
3
|
+
* Markdown file viewer/editor over any folder, with no host (no Deliberate) required.
|
|
4
|
+
*
|
|
5
|
+
* A host (e.g. the `deliberate` package) injects a richer engine to light up cases, briefs,
|
|
6
|
+
* scores and the inbox. With this default there are no such concepts: every `.md` under the
|
|
7
|
+
* current vault is just a file the tree lists and the editor edits. It implements the exact
|
|
8
|
+
* surface `startServer` needs — the store methods the routes call plus the project resolvers,
|
|
9
|
+
* funnel constants (empty), the kind registry (empty), and a console logger.
|
|
10
|
+
*
|
|
11
|
+
* Multiple vaults + switching are delegated to the shared `vault-registry`: the registry owns
|
|
12
|
+
* the list of known folders and which is current at
|
|
13
|
+
* `~/.sonorance/config.json`; this engine reads the current vault dynamically, so `/api/switch`
|
|
14
|
+
* and "Open folder…" work without restarting the daemon. Per-vault state (comments, tabs,
|
|
15
|
+
* Explorer) lives in that vault's own `.sonorance/`.
|
|
16
|
+
*
|
|
17
|
+
* Usage: startServer({ engine: makeDefaultEngine(rootDir) })
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { join, dirname, isAbsolute, relative, sep } from 'node:path';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { createRegistry } from './vault-registry.mjs';
|
|
23
|
+
|
|
24
|
+
const now = () => Date.now();
|
|
25
|
+
const randId = () => 'c' + randomBytes(5).toString('hex');
|
|
26
|
+
const ensureDir = (d) => mkdirSync(d, { recursive: true });
|
|
27
|
+
// Write a file atomically (temp file + rename) so a crash mid-write can never leave a
|
|
28
|
+
// truncated/corrupt `.sonorance/` state file — readers always see the old or the new whole.
|
|
29
|
+
const atomicWrite = (path, data) => { const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, data); renameSync(tmp, path); };
|
|
30
|
+
|
|
31
|
+
// A generic, MULTI-VAULT store: comments + per-vault config live in the current vault's hidden
|
|
32
|
+
// `.sonorance/` dir; everything case/brief/stage-shaped returns empty (this host has none of
|
|
33
|
+
// those). The active folder is resolved from the registry on every call, so a project switch
|
|
34
|
+
// (which just moves the registry's `current` pointer) is picked up with no restart.
|
|
35
|
+
function makeStore(registry, root) {
|
|
36
|
+
// THIS store's own current-vault pointer. It lives on the store (not the engine) because the
|
|
37
|
+
// server keeps a single module-global engine `E` but a per-server `store` closure — so routes
|
|
38
|
+
// resolve the right vault via the store even when several servers share one process (tests).
|
|
39
|
+
// Serving a folder makes it current here; switching / opening moves it.
|
|
40
|
+
let currentDir = root ? registry.register(root, { makeCurrent: true }).dir : (registry.current()?.dir || null);
|
|
41
|
+
// The folder a request targets. The server passes the current project's id, so a bare pid
|
|
42
|
+
// resolves via the registry; fall back to THIS store's current dir.
|
|
43
|
+
const dirFor = (pid) => (pid && registry.get(pid)?.dir) || currentDir;
|
|
44
|
+
const stateDir = (dir) => join(dir, '.sonorance', 'local');
|
|
45
|
+
|
|
46
|
+
const relFile = (pid, file) => {
|
|
47
|
+
const root = dirFor(pid); if (!root || !file) return null;
|
|
48
|
+
const abs = isAbsolute(file) ? file : join(root, file);
|
|
49
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
50
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null;
|
|
51
|
+
return rel;
|
|
52
|
+
};
|
|
53
|
+
const commentsPathFor = (pid) => { const d = dirFor(pid); return d ? join(stateDir(d), 'comments.jsonl') : null; };
|
|
54
|
+
const statePathFor = (pid) => { const d = dirFor(pid); return d ? join(stateDir(d), 'state.json') : null; };
|
|
55
|
+
const readComments = (pid) => {
|
|
56
|
+
const p = commentsPathFor(pid); if (!p) return [];
|
|
57
|
+
try { return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); }
|
|
58
|
+
catch { return []; }
|
|
59
|
+
};
|
|
60
|
+
const writeComments = (pid, arr) => { const p = commentsPathFor(pid); if (!p) return; ensureDir(dirname(p)); atomicWrite(p, arr.length ? arr.map(c => JSON.stringify(c)).join('\n') + '\n' : ''); };
|
|
61
|
+
// Editor STATE (open tabs, active tab, Explorer collapse/expand) lives in
|
|
62
|
+
// `.sonorance/local/state.json`, separate from `config.json` (identity). It describes the
|
|
63
|
+
// session, not the project — it's disposable/regenerable and never needed to open a vault.
|
|
64
|
+
const readState = (pid) => { const p = statePathFor(pid); if (!p) return {}; try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; } };
|
|
65
|
+
const writeState = (pid, st) => { const p = statePathFor(pid); if (!p) return; ensureDir(dirname(p)); atomicWrite(p, JSON.stringify(st, null, 2) + '\n'); };
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// ---- projects (delegated to the shared multi-vault registry) ----
|
|
69
|
+
listProjects: () => registry.list().map(v => ({ id: v.id, name: v.name, dir: v.dir, exists: v.exists })),
|
|
70
|
+
getProject: (id) => { const v = id ? registry.get(id) : registry.current(); return v ? { id: v.id, name: v.name, dir: v.dir } : undefined; },
|
|
71
|
+
// ---- editor state (open tabs / active tab / Explorer state), per-vault, in state.json ----
|
|
72
|
+
getState: (pid) => readState(pid),
|
|
73
|
+
setState: (pid, key, value) => { const c = readState(pid); c[key] = value; writeState(pid, c); },
|
|
74
|
+
// Merge many keys in ONE read-modify-write (atomic) — the buffered-flush target so a
|
|
75
|
+
// multi-key patch never does a partial series of rewrites.
|
|
76
|
+
setStateMany: (pid, patch) => { const c = readState(pid); for (const [k, v] of Object.entries(patch || {})) c[k] = v; writeState(pid, c); },
|
|
77
|
+
// ---- cases / briefs / readouts / matchups / stages: none in generic mode ----
|
|
78
|
+
listCases: () => [], listBriefs: () => [], listReadouts: () => [], listMatchups: () => [], listStages: () => [],
|
|
79
|
+
getCase: () => null, getBrief: () => null, getReadout: () => null, getMatchup: () => null,
|
|
80
|
+
recordFile: () => null, briefFilePath: () => null, readoutFilePath: () => null, matchupFilePath: () => null,
|
|
81
|
+
readRecord: () => null, writeRecord: () => false,
|
|
82
|
+
readBriefRecord: () => null, readReadoutRecord: () => null, readMatchupRecord: () => null, prototypeRef: () => null, listPrototypes: () => [],
|
|
83
|
+
// ---- files (path-relative to the current vault root) ----
|
|
84
|
+
relFile,
|
|
85
|
+
// ---- comments (file-anchored, per-vault .sonorance/local/comments.jsonl) ----
|
|
86
|
+
allComments: (pid) => readComments(pid),
|
|
87
|
+
comments: (pid, file) => { const rel = relFile(pid, file); if (!rel) return []; return readComments(pid).filter(c => c.file === rel); },
|
|
88
|
+
addComment: (pid, file, entry) => {
|
|
89
|
+
const rel = relFile(pid, file); if (!rel) return null;
|
|
90
|
+
const rec = { id: randId(), file: rel, status: 'open', ts: now(), ...entry }; rec.file = rel;
|
|
91
|
+
const all = readComments(pid); all.push(rec); writeComments(pid, all); return rec;
|
|
92
|
+
},
|
|
93
|
+
resolveComment: (pid, id, patch) => { const all = readComments(pid); let hit = null; const next = all.map(c => (c.id === id ? (hit = { ...c, ...patch }) : c)); if (!hit) return null; writeComments(pid, next); return hit; },
|
|
94
|
+
deleteComment: (pid, id) => { const all = readComments(pid); const hit = all.find(c => c.id === id); if (!hit) return null; writeComments(pid, all.filter(c => c.id !== id)); return hit; },
|
|
95
|
+
// ---- current-vault pointer (per store; the engine wrapper delegates here) ----
|
|
96
|
+
_current: () => (currentDir ? registry.get(currentDir) : registry.current()),
|
|
97
|
+
_setCurrent: (idOrDir) => { const v = registry.get(idOrDir); if (v) { currentDir = v.dir; registry.setCurrent(v.dir); } return v; },
|
|
98
|
+
_open: (dir, opts = {}) => { const v = registry.register(dir, opts); if (v && opts.makeCurrent !== false) currentDir = v.dir; return v; },
|
|
99
|
+
_remove: (idOrDir) => { registry.remove(idOrDir); return true; },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build a generic engine over `root` (the folder to view/edit). Registering `root` makes it a
|
|
104
|
+
// vault and the current one; the UI can then open/switch to other registered vaults. This is
|
|
105
|
+
// what standalone `serve` injects into startServer.
|
|
106
|
+
export function makeDefaultEngine(root) {
|
|
107
|
+
const registry = createRegistry();
|
|
108
|
+
const store = makeStore(registry, root);
|
|
109
|
+
const toProject = (v) => (v ? { id: v.id, name: v.name, dir: v.dir, exists: v.exists } : null);
|
|
110
|
+
return {
|
|
111
|
+
openVault: () => store,
|
|
112
|
+
// ---- project resolution / switching — delegated to the per-server store ----
|
|
113
|
+
currentProject: (s) => toProject((s || store)._current()),
|
|
114
|
+
setCurrentProject: (s, idOrDir) => { (s || store)._setCurrent(idOrDir); },
|
|
115
|
+
resolveProject: (_s, idOrDir) => toProject(registry.get(idOrDir)),
|
|
116
|
+
// Open a folder as a vault (register + make current) — the UI's "Open folder…" / the
|
|
117
|
+
// `sonorance open` CLI. Returns the project.
|
|
118
|
+
openVaultFolder: (s, dir, opts = {}) => toProject((s || store)._open(dir, opts)),
|
|
119
|
+
// Forget a registered vault (the UI's Remove for a missing folder). Never deletes the folder.
|
|
120
|
+
removeProject: (s, idOrDir) => (s || store)._remove(idOrDir),
|
|
121
|
+
// The generic app serves the WHOLE folder (no `deliberate/` subfolder confinement).
|
|
122
|
+
contentDir: (project) => ({ dir: project.dir, base: '' }),
|
|
123
|
+
// Where the running server records its port for the CLI's `address`/`resolve` clients.
|
|
124
|
+
serveInfoPath: (project) => join(project.dir, '.sonorance', 'local', 'serve.json'),
|
|
125
|
+
// Machine state to keep out of git when the app initiates a project in a folder — only the
|
|
126
|
+
// hidden `.sonorance/local/` (runtime/session state); the committed config (identity,
|
|
127
|
+
// sources, plugins) at `.sonorance/` stays in git.
|
|
128
|
+
gitignoreEntries: () => ['.sonorance/local/'],
|
|
129
|
+
// No document kinds → the inbox shows a generic recent-files view / empty state.
|
|
130
|
+
KINDS: [],
|
|
131
|
+
// Logger.
|
|
132
|
+
info: (...a) => console.log(...a),
|
|
133
|
+
error: (...a) => console.error(...a),
|
|
134
|
+
installCrashHandlers: () => {},
|
|
135
|
+
};
|
|
136
|
+
}
|