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,993 @@
1
+ /**
2
+ * server/index.mjs — the local Deliberate workbench daemon.
3
+ *
4
+ * A small `node:http` server (no external deps) that serves the web app and a
5
+ * handful of file-backed endpoints over the current project's vault. It is a
6
+ * *workbench*, not the pipeline: it reads and edits the case records on disk and
7
+ * reports git status — running the funnel is the skill/CLI's job, in-harness.
8
+ *
9
+ * Endpoints (all JSON unless noted):
10
+ * GET /api/state → { project, cases:[…attention], isGit }
11
+ * GET /api/record?id=<id> → { id,num,title,file,exists,text,idea,isGit }
12
+ * POST /api/record → { ok, updated } (body: { id, text })
13
+ * POST /api/create → { ok, path } (body: { path } — create a new .md file)
14
+ * POST /api/mkdir → { ok, path } (body: { path } — create a new folder)
15
+ * POST /api/move → { ok, from, path } (body: { src, dest } — move file/folder into dest folder; dest:'' = root)
16
+ * POST /api/rename → { ok, from, path } (body: { path, name } — rename a file/folder in place)
17
+ * POST /api/config → { ok } (body: { patch } — merge project config keys)
18
+ * DELETE /api/file → { ok, path } (body: { path } — remove a file or folder)
19
+ * GET /api/diff?id=<id> → { isGit, tracked, diff, base, work } (analysis.md vs HEAD)
20
+ * POST /api/comment → { ok, commentId, file } (reader selection + comment on a file)
21
+ * GET /api/address → { comments:[…], count } (agent drains open comments, each w/ file)
22
+ * POST /api/resolve → { ok } (agent marks a comment resolved by id)
23
+ * GET /api/comments?file=<path>→ { comments } (a file's comments)
24
+ * GET /api/comments-summary → { counts:{rel:n}, total } (per-file OPEN comment counts, read-only)
25
+ * DELETE /api/comment → { ok } (reader deletes their comment by id)
26
+ * GET /api/events → SSE stream (comment / resolve / delete / addressing push)
27
+ * GET /api/asset?path=<p> → a vault image (binary, typed; for Markdown `![](…)` images)
28
+ * GET /api/browse?path=<d> → { path, parent, home, entries:[{name,path}] } (folder picker: dirs only)
29
+ * GET /icon.png → the app icon (favicon / PWA)
30
+ * GET /manifest.webmanifest → PWA manifest
31
+ * GET * → the web app (src/ui/index.html)
32
+ */
33
+ import { createServer } from 'node:http';
34
+ import { readFile } from 'node:fs/promises';
35
+ import { execFile } from 'node:child_process';
36
+ import { promisify } from 'node:util';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname, join, extname, resolve, relative, sep, basename } from 'node:path';
39
+ import { homedir } from 'node:os';
40
+ import { readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, existsSync, statSync, watch, renameSync } from 'node:fs';
41
+ import { ensureGitignore } from '../gitignore.mjs';
42
+ import { configureTelemetry, refreshTelemetry, emit, flushTelemetry, submitTelemetryBatch, shutdownTelemetry, telemetryMode } from '../telemetry.mjs';
43
+ import { submitFeedback } from '../feedback.mjs';
44
+ import { getConsent, setConsent, ensureInstallId } from '../identity.mjs';
45
+
46
+ // The app is a GENERIC markdown editor: it imports no product engine. The host (e.g. the
47
+ // `deliberate` package) injects an `engine` object into startServer with the vault store
48
+ // factory (openVault), project resolvers (currentProject / setCurrentProject /
49
+ // resolveProject), the declarative document-kind registry (KINDS), and a logger
50
+ // (info / error / installCrashHandlers). Everything product-specific arrives through it; the
51
+ // app knows only these injected capabilities. `E` is set once per process by startServer,
52
+ // before any route runs; `info`/`error` are rebound from it.
53
+ let E = null;
54
+ let info = () => {}, error = () => {};
55
+
56
+ const here = dirname(fileURLToPath(import.meta.url));
57
+ // Image types the daemon serves for Markdown images (GET /api/asset). Extension-gated so the
58
+ // asset endpoint can never be used to read arbitrary (e.g. `.md`, `.json`, `.env`) files.
59
+ const IMG_TYPES = {
60
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
61
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif', '.bmp': 'image/bmp', '.ico': 'image/x-icon',
62
+ };
63
+ // The app version + a capability flag. `GET /api/state` returns these so the UI
64
+ // (always read fresh from disk) can detect it's being served by a STALE `serve` process
65
+ // that predates the current routes — the "new UI + old in-memory routes" version skew.
66
+ // An out-of-date server omits `bridge`/returns an older `version`, and the UI warns.
67
+ const PKG = (() => { try { return JSON.parse(readFileSync(join(here, '../../package.json'), 'utf8')); } catch { return {}; } })();
68
+ const APP_VERSION = String(PKG.version || '0');
69
+ // The app's own, stable identity. Branch A: the app is ALWAYS Sonorance across every vault — a
70
+ // plugin (e.g. Deliberate) surfaces per-project via kinds/inbox, it never renames the app. So
71
+ // identity + the canonical command hints are app-owned here, not read from the (per-vault) engine.
72
+ const SONORANCE = { name: 'Sonorance', serve: 'sonorance serve', address: '/sonorance address' };
73
+ const ex = promisify(execFile);
74
+ // Editor state (open tabs / active tab / Explorer) lives in `.sonorance/local/state.json`.
75
+ const readState = (store, pid) => store.getState(pid) || {};
76
+ const writeState = (store, pid, key, value) => store.setState(pid, key, value);
77
+ const writeStateMany = (store, pid, patch) => store.setStateMany(pid, patch);
78
+ const json = (res, code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
79
+ // Read + parse a JSON request body defensively: malformed JSON yields `{}` (routes then return
80
+ // their own validation error, never a 500), and the body is capped so a runaway/oversized POST
81
+ // can't buffer unbounded memory.
82
+ const MAX_BODY = 16 * 1024 * 1024;
83
+ const readBody = (req) => new Promise(r => {
84
+ let b = '', size = 0, tooBig = false;
85
+ req.on('data', c => { size += c.length; if (size > MAX_BODY){ tooBig = true; req.destroy(); return; } b += c; });
86
+ req.on('end', () => { if (tooBig) return r({}); try { r(b ? JSON.parse(b) : {}); } catch { r({}); } });
87
+ req.on('error', () => r({}));
88
+ });
89
+
90
+ // ---- git (read-only) ----------------------------------------------------------
91
+ // The vault may or may not be a git repo (in-repo projects ride the user's git;
92
+ // standalone folders don't). Every git call is best-effort and never throws.
93
+ async function git(root, args) {
94
+ try { const { stdout } = await ex('git', ['-C', root, ...args], { maxBuffer: 16 * 1024 * 1024 }); return { ok: true, out: stdout }; }
95
+ catch (e) { return { ok: false, out: e.stdout || '', err: e.stderr || e.message, code: e.code }; }
96
+ }
97
+ async function isGitRepo(root) {
98
+ if (!root) return false;
99
+ const r = await git(root, ['rev-parse', '--is-inside-work-tree']);
100
+ return r.ok && r.out.trim() === 'true';
101
+ }
102
+ // The working-tree diff of one file vs. the last commit. Tracked files use plain
103
+ // `git diff`; an untracked-but-present record is diffed against /dev/null so the
104
+ // whole new file reads as an addition. Empty string = no changes.
105
+ async function fileDiff(root, file) {
106
+ const tracked = (await git(root, ['ls-files', '--error-unmatch', '--', file])).ok;
107
+ if (tracked) return { tracked: true, diff: (await git(root, ['diff', '--no-color', '--', file])).out };
108
+ const r = await git(root, ['diff', '--no-color', '--no-index', '--', '/dev/null', file]); // exits 1 when differing
109
+ return { tracked: false, diff: r.out };
110
+ }
111
+ // The committed (HEAD) and current working-tree contents of one file, for the workbench's
112
+ // rendered unified diff (which reconstructs the change as marked-up Markdown, not a raw patch).
113
+ // `base` is the last-committed text ('' for an untracked/new file); `work` is what's on disk now
114
+ // ('' for a file deleted since the last commit). `file` may be repo-relative (plain files) OR an
115
+ // absolute path (a plugin's record store returns absolute case paths) — both are normalised to a
116
+ // repo-relative, forward-slashed spec so `git show HEAD:<rel>` and the disk read resolve. Both
117
+ // best-effort — never throw.
118
+ async function fileContents(root, file) {
119
+ const abs = resolve(root, file);
120
+ const rel = relative(root, abs).split(sep).join('/');
121
+ const head = await git(root, ['show', `HEAD:${rel}`]);
122
+ const base = head.ok ? head.out : '';
123
+ let work = '';
124
+ try { work = readFileSync(abs, 'utf8'); } catch { work = ''; }
125
+ return { base, work };
126
+ }
127
+ // The current branch name (or a short SHA when the HEAD is detached / on a fresh repo).
128
+ async function gitBranch(root) {
129
+ const r = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
130
+ const name = r.ok ? r.out.trim() : '';
131
+ if (name && name !== 'HEAD') return name;
132
+ const sha = await git(root, ['rev-parse', '--short', 'HEAD']);
133
+ return sha.ok && sha.out.trim() ? `detached @ ${sha.out.trim()}` : (name || 'no branch');
134
+ }
135
+ // Every path with uncommitted changes (staged + working-tree + untracked), project-root
136
+ // relative (matching the Explorer's tree paths). `status` is the porcelain XY code, trimmed
137
+ // (M/A/D/??/…). Confined to the repo the vault rides; empty on a clean tree or non-repo.
138
+ // `--untracked-files=all` lists every new file individually — without it git collapses a
139
+ // brand-new directory to just its name (e.g. a fresh case folder), so newly added records
140
+ // never surface in the Explorer's diff mode.
141
+ async function gitChanged(root) {
142
+ const r = await git(root, ['status', '--porcelain', '--no-renames', '--untracked-files=all', '-z']);
143
+ if (!r.ok) return [];
144
+ const out = [];
145
+ for (const entry of r.out.split('\0')) {
146
+ if (!entry) continue;
147
+ const status = entry.slice(0, 2).trim() || '?';
148
+ const path = entry.slice(3);
149
+ if (path) out.push({ path, status });
150
+ }
151
+ return out;
152
+ }
153
+ // Discard ALL uncommitted changes in one file (destructive): a tracked file is restored
154
+ // from HEAD (staged + working tree); an untracked new file is deleted. `rel` is confined to
155
+ // the vault by the caller. Returns { ok } — never throws.
156
+ async function discardFile(root, rel) {
157
+ const tracked = (await git(root, ['ls-files', '--error-unmatch', '--', rel])).ok;
158
+ if (tracked) {
159
+ let r = await git(root, ['restore', '--staged', '--worktree', '--source=HEAD', '--', rel]);
160
+ if (!r.ok) r = await git(root, ['checkout', 'HEAD', '--', rel]); // older git without `restore`
161
+ return { ok: r.ok, err: r.err };
162
+ }
163
+ try { rmSync(join(root, rel), { force: true }); return { ok: true }; } // untracked → remove the new file
164
+ catch (e) { return { ok: false, err: e.message }; }
165
+ }
166
+ // The first `# ` heading text of a markdown file (its title) — read cheaply from the head
167
+ // of the file. Used to make the Explorer search match a record's H1, not just its filename.
168
+ // A tiny mtime-keyed read cache: recompute a per-file value (its H1, its `type:`) only when
169
+ // the file actually changed on disk. Tree builds + comment batches hit the same files
170
+ // repeatedly (every poll / FS event), so this turns "read every file every time" into a cheap
171
+ // stat. Self-bounds by clearing when it grows large (a vault has few files; belt-and-suspenders).
172
+ function cachedByMtime(cache, abs, compute) {
173
+ let mtimeMs;
174
+ try { mtimeMs = statSync(abs).mtimeMs; } catch { return compute(); } // unstatable → best-effort compute
175
+ const hit = cache.get(abs);
176
+ if (hit && hit.mtimeMs === mtimeMs) return hit.value;
177
+ const value = compute();
178
+ if (cache.size > 5000) cache.clear();
179
+ cache.set(abs, { mtimeMs, value });
180
+ return value;
181
+ }
182
+ const _h1Cache = new Map();
183
+ const _typeCache = new Map();
184
+ function fileH1(abs) {
185
+ return cachedByMtime(_h1Cache, abs, () => {
186
+ try {
187
+ const head = readFileSync(abs, 'utf8').slice(0, 4000);
188
+ const body = head.replace(/^---\n[\s\S]*?\n---\n?/, ''); // skip YAML frontmatter
189
+ const m = body.match(/^#\s+(.+?)\s*$/m);
190
+ return m ? m[1].trim() : '';
191
+ } catch { return ''; }
192
+ });
193
+ }
194
+
195
+ // ---- the comment bridge: in-record annotations + agent resolution ------------
196
+ //
197
+ // The workbench reader selects any span of ANY file in the project (a case's analysis.md,
198
+ // a brief's brief.md, or any file) and leaves a comment — a question or a change request,
199
+ // GitHub-PR-review style. Comments are persisted as durable OPEN entries in ONE
200
+ // project-level file (`deliberate/comments.jsonl`), each carrying the annotated file's
201
+ // path. The user then runs `/deliberate address` in their agent, which fetches the open
202
+ // comments (batch, via `GET /api/address`), answers them in its own interface / edits the
203
+ // referenced file, and resolves each (`POST /api/resolve`). Resolutions/deletions are
204
+ // pushed to the browser over SSE (`GET /api/events`) so comments update live. All state is
205
+ // the durable comments.jsonl the vault writes plus the live SSE set — no database.
206
+ function makeBridge() {
207
+ return { sse: new Set(), treeCache: null, treeDirty: true, watcher: null, watchFails: 0, rewatch: null };
208
+ }
209
+ function sseBroadcast(bridge, obj) {
210
+ const line = `data: ${JSON.stringify(obj)}\n\n`;
211
+ for (const res of bridge.sse) { try { res.write(line); } catch { /* dropped client */ } }
212
+ }
213
+ // The frontmatter `type:` of a markdown file (cheap head read) — used to attach a kind's
214
+ // per-type `addressing` guidance to its comments. Empty when absent/unreadable.
215
+ function fileType(abs) {
216
+ return cachedByMtime(_typeCache, abs, () => {
217
+ try {
218
+ const head = readFileSync(abs, 'utf8').slice(0, 2000);
219
+ const fm = head.match(/^---\n([\s\S]*?)\n---/);
220
+ if (!fm) return '';
221
+ const m = fm[1].match(/^type:\s*(.+?)\s*$/m);
222
+ return m ? m[1].replace(/^["']|["']$/g, '').trim() : '';
223
+ } catch { return ''; }
224
+ });
225
+ }
226
+ // Every OPEN comment in the project, oldest first — the batch an agent drains when the user
227
+ // runs the address skill. Each carries the `file` to edit and, when the file is a registered
228
+ // document `type`, that kind's optional `addressing` guidance (host special treatment — e.g.
229
+ // "keep the sibling one-pager consistent"). Generic files get none.
230
+ function openComments(store, project) {
231
+ const pid = project.id;
232
+ const byType = new Map((E.KINDS || []).filter(k => k.addressing).map(k => [k.type, k.addressing]));
233
+ const noteFor = (rel) => {
234
+ if (!byType.size || !rel) return undefined;
235
+ const t = fileType(join(project.dir, rel));
236
+ return t ? byType.get(t) : undefined;
237
+ };
238
+ return store.allComments(pid)
239
+ .filter(c => c.status === 'open')
240
+ .map(c => { const o = { id: c.id, file: c.file, anchor: c.anchor, body: c.body, ts: c.ts }; const g = noteFor(c.file); if (g) o.addressing = g; return o; })
241
+ .sort((a, b) => (a.ts || 0) - (b.ts || 0));
242
+ }
243
+
244
+ // Accept the internal hash id (what the app passes) OR a per-project #num, so the
245
+ // endpoints are also usable by hand (`?id=3`) and from tests.
246
+ function resolveCaseId(store, pid, ref) {
247
+ if (ref == null) return null;
248
+ if (store.getCase(ref)) return String(ref);
249
+ const s = store.listCases(pid).find(x => String(x.num) === String(ref));
250
+ return s ? s.id : String(ref);
251
+ }
252
+ // Same, for briefs (project-scoped landscape reports): a hash id or a per-project #num.
253
+ function resolveBriefId(store, pid, ref) {
254
+ if (ref == null) return null;
255
+ if (store.getBrief(ref)) return String(ref);
256
+ const b = store.listBriefs(pid).find(x => String(x.num) === String(ref));
257
+ return b ? b.id : String(ref);
258
+ }
259
+ // Same, for date-keyed product readouts.
260
+ function resolveReadoutId(store, pid, ref) {
261
+ if (ref == null) return null;
262
+ if (store.getReadout(ref)) return String(ref);
263
+ return String(ref);
264
+ }
265
+ // Same, for matchups (single-competitor head-to-heads): a hash id or the competitor slug
266
+ // (the folder name — a natural human handle, since matchups are keyed by rival, not date).
267
+ function resolveMatchupId(store, pid, ref) {
268
+ if (ref == null) return null;
269
+ if (store.getMatchup(ref)) return String(ref);
270
+ const m = store.listMatchups(pid).find(x => String(x.slug) === String(ref));
271
+ return m ? m.id : String(ref);
272
+ }
273
+
274
+ // ---- the vault's markdown file tree (the sidebar's Explorer) ------------------
275
+ // Walk the project's `deliberate/` folder into a tree of ONLY markdown files (and the
276
+ // folders that contain them, transitively). Case records (`analysis.md`) and briefs
277
+ // (`brief.md`) are tagged with their kind + id so the client routes a click to the
278
+ // right tab and labels the leaf by metadata (case title / brief period) rather than the
279
+ // bare filename; every other `.md` is a plain `file` the client opens read-only. Hidden
280
+ // dirs (`.config`) are skipped. Paths are project-root-relative POSIX, matching the
281
+ // comment store's `relFile` key.
282
+ // The content root the tree walks. The host may confine it to a subfolder (Deliberate walks
283
+ // `<repo>/deliberate`); a standalone/generic host serves the whole folder. `base` is the
284
+ // relative prefix the paths start with (must match the store's relFile output). Default:
285
+ // the project folder itself, with no prefix — a plain folder of Markdown.
286
+ // Directories never shown in the Explorer tree: git internals and dependency trees. Hidden
287
+ // CONTENT dirs (dot-dirs like `.github`, and the committed `.sonorance/` — which holds the
288
+ // project's hand-editable `sources.md`) ARE shown, so machine state that carries user-facing
289
+ // Markdown surfaces normally. (The FS watcher ignores a WIDER set — see WATCH_SKIP.)
290
+ const TREE_SKIP = new Set(['.git', 'node_modules']);
291
+ // Directories the FS watcher ignores. Broader than TREE_SKIP: `.sonorance/` churns constantly
292
+ // (config + comments + editor state rewrites), and an unfiltered watch on it caused a
293
+ // self-sustaining refresh loop that froze the UI — so it's shown in the tree but never watched.
294
+ const WATCH_SKIP = new Set(['.git', '.sonorance', 'node_modules']);
295
+ function contentRoot(project) {
296
+ const c = E.contentDir ? E.contentDir(project) : null;
297
+ return c && c.dir ? { dir: c.dir, base: c.base || '' } : { dir: project.dir, base: '' };
298
+ }
299
+ function buildTree(store, project) {
300
+ const pid = project.id;
301
+ const { dir: root, base } = contentRoot(project);
302
+ const caseByRel = new Map(), briefByRel = new Map(), readoutByRel = new Map(), matchupByRel = new Map();
303
+ for (const s of store.listCases(pid)) { const f = store.recordFile(s.id); const rel = f && store.relFile(pid, f); if (rel) caseByRel.set(rel, s.id); }
304
+ for (const b of store.listBriefs(pid)) { const f = store.briefFilePath(b.id); const rel = f && store.relFile(pid, f); if (rel) briefByRel.set(rel, b.id); }
305
+ for (const r of store.listReadouts(pid)) { const f = store.readoutFilePath(r.id); const rel = f && store.relFile(pid, f); if (rel) readoutByRel.set(rel, r.id); }
306
+ for (const m of store.listMatchups(pid)) { const f = store.matchupFilePath(m.id); const rel = f && store.relFile(pid, f); if (rel) matchupByRel.set(rel, m.id); }
307
+ const walk = (absDir, relDir) => {
308
+ let entries = [];
309
+ try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return []; }
310
+ const nodes = [];
311
+ for (const e of entries) {
312
+ // Hidden CONTENT folders (dot-dirs like `.github`, and the committed `.sonorance/` that
313
+ // holds `sources.md`) ARE shown; only git internals and dependency trees are skipped.
314
+ if (TREE_SKIP.has(e.name)) continue;
315
+ const abs = join(absDir, e.name);
316
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
317
+ if (e.isDirectory()) {
318
+ // A folder is shown ONLY if it lies on a path from the root to a Markdown file — i.e. it
319
+ // has at least one `.md` descendant. Fileless folders are pruned (they'd be dead ends in a
320
+ // Markdown workspace). Because `.md` leaves are the only files added and fileless dirs are
321
+ // dropped recursively, a non-empty `children` guarantees a Markdown descendant, so the
322
+ // presence check is just `children.length`.
323
+ const children = walk(abs, rel);
324
+ if (children.length) nodes.push({ name: e.name, path: rel, type: 'dir', children });
325
+ } else if (e.isFile() && /\.md$/i.test(e.name)) {
326
+ const kind = caseByRel.has(rel) ? 'case' : briefByRel.has(rel) ? 'brief' : readoutByRel.has(rel) ? 'readout' : matchupByRel.has(rel) ? 'matchup' : 'file';
327
+ const id = kind === 'case' ? caseByRel.get(rel) : kind === 'brief' ? briefByRel.get(rel) : kind === 'readout' ? readoutByRel.get(rel) : kind === 'matchup' ? matchupByRel.get(rel) : null;
328
+ // Carry the file's H1 (its title) so the Explorer search matches content, not just
329
+ // the filename/path (a record's analysis.md filename is never meaningful).
330
+ nodes.push({ name: e.name, path: rel, type: 'file', kind, id, h1: fileH1(abs) });
331
+ }
332
+ }
333
+ // Folders first, then files; each alphabetical.
334
+ nodes.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
335
+ return nodes;
336
+ };
337
+ return walk(root, base);
338
+ }
339
+
340
+
341
+ function buildRoutes(store, bridge) {
342
+ // Per-request project resolution: an explicit `?project=<slug>` (the URL's /p/<slug>) wins,
343
+ // so multiple projects can be open in separate browser tabs at once; absent/unknown falls
344
+ // back to the last-active project (the registry's `current`).
345
+ const cur = (b) => b?.project ? E.resolveProject(store, b.project) : E.currentProject(store);
346
+ // Mark a project last-active (so `/` and a fresh boot default there). Only WRITE actions call
347
+ // this — reads/polls must not, or two open tabs would fight over the pointer.
348
+ const touch = (p) => { try { if (p && p.id) E.setCurrentProject(store, p.id); } catch { /* best-effort */ } };
349
+ const moveTelemetryTo = (p) => {
350
+ // Drain with the OLD resource before changing vault identity/policy. Do not requeue a failed
351
+ // old-vault send into the new context; the local audit remains the durable record.
352
+ flushTelemetry({ requeue: false }).catch(() => {});
353
+ configureTelemetry({
354
+ surface: 'ui',
355
+ product: (E.KINDS && E.KINDS.length) ? 'deliberate' : 'sonorance',
356
+ version: APP_VERSION,
357
+ vaultDir: p?.dir || null,
358
+ });
359
+ };
360
+ return {
361
+ 'GET /api/state': async (b) => {
362
+ const p = cur(b);
363
+ const isGit = await isGitRepo(p?.dir);
364
+ const cases = p ? store.listCases(p.id).map(s => ({
365
+ id: s.id, title: s.title, state: s.state, score: s.score,
366
+ updated_at: s.updated_at, last_stage_at: s.last_stage_at,
367
+ })) : [];
368
+ // Editor state (open tabs / active tab / Explorer) lives in `.sonorance/local/state.json` — the
369
+ // `readState` helper; `getConfig` is a fallback for an older host that predates the split.
370
+ const st = p ? readState(store, p.id) : {};
371
+ const briefs = p ? store.listBriefs(p.id).map(b => ({
372
+ id: b.id, period_start: b.period_start, period_end: b.period_end,
373
+ created_at: b.created_at, updated_at: b.updated_at,
374
+ })) : [];
375
+ const readouts = p ? store.listReadouts(p.id).map(r => ({
376
+ id: r.id, period_start: r.period_start, period_end: r.period_end,
377
+ created_at: r.created_at, updated_at: r.updated_at,
378
+ })) : [];
379
+ const matchups = p ? store.listMatchups(p.id).map(m => ({
380
+ id: m.id, competitor: m.competitor, slug: m.slug, as_of: m.as_of,
381
+ created_at: m.created_at, updated_at: m.updated_at,
382
+ })) : [];
383
+ return {
384
+ project: p ? { id: p.id, name: p.name, dir: p.dir } : null,
385
+ projects: store.listProjects().map(x => ({ id: x.id, name: x.name, dir: x.dir, exists: x.exists !== false })),
386
+ cases, briefs, readouts, matchups, isGit,
387
+ kinds: E.KINDS, // the declarative document-kind registry (drives the inbox)
388
+ brand: SONORANCE, // app-owned identity + canonical hints (never per-vault)
389
+ version: APP_VERSION, // for the UI's stale-server (version-skew) check
390
+ telemetry: { enabled: telemetryMode() !== 'off' }, // drives the Settings privacy toggle
391
+ bridge: true, // capability flag: this server has the comment bridge
392
+ tabs: Array.isArray(st.tabs) ? st.tabs : [], // persisted open tabs (in state.json)
393
+ activeTab: st.activeTab || null, // persisted active tab {kind,id} — restored per project on switch
394
+ explorer: st.explorer || null, // persisted Explorer collapse/expand state {mode, ex[]}
395
+ };
396
+ },
397
+ // Switch which registered project the workbench is serving (project selector).
398
+ 'POST /api/switch': (b) => {
399
+ const target = E.resolveProject(store, b.id);
400
+ if (target) {
401
+ E.setCurrentProject(store, target.id);
402
+ bridge.treeDirty = true; bridge.treeCache = null; // new project → rebuild + re-arm the watcher
403
+ const np = E.currentProject(store);
404
+ if (np && bridge.rewatch) { try { bridge.rewatch(contentRoot(np).dir); } catch { /* polling covers it */ } }
405
+ moveTelemetryTo(np);
406
+ emit('vault.switched', {});
407
+ }
408
+ return { ok: !!target };
409
+ },
410
+ // Open a folder as a vault and switch to it. `dir` must be an existing folder; opening registers
411
+ // it (creating
412
+ // its `.sonorance/` marker) and makes it current. Only supported when the host engine
413
+ // exposes a registry (`openVaultFolder`); the single-project hosts return `unsupported`.
414
+ 'POST /api/open': (b) => {
415
+ const dir = b && b.dir ? String(b.dir).trim() : '';
416
+ if (!dir) return { error: 'no folder given' };
417
+ if (typeof E.openVaultFolder !== 'function') return { error: 'unsupported' };
418
+ if (!existsSync(dir)) return { error: 'not found' };
419
+ let p; try { p = E.openVaultFolder(store, dir); } catch (e) { return { error: e.message }; }
420
+ if (!p) return { error: 'could not open folder' };
421
+ bridge.treeDirty = true; bridge.treeCache = null;
422
+ if (bridge.rewatch) { try { bridge.rewatch(contentRoot(p).dir); } catch { /* polling covers it */ } }
423
+ moveTelemetryTo(p);
424
+ emit('vault.switched', {});
425
+ return { ok: true, project: { id: p.id, name: p.name, dir: p.dir } };
426
+ },
427
+ // Browse the local filesystem for a folder to open as a vault — powers the "Open folder…"
428
+ // picker (a browser can't return an absolute path from a native dialog, so the local daemon
429
+ // lists directories and the UI drills in). Read-only, directories only (never file contents);
430
+ // hidden dot-folders are skipped. `path` defaults to the home directory. Symlinked folders
431
+ // are followed (statSync). This is a local daemon the user runs, with expected FS access.
432
+ 'GET /api/browse': (b) => {
433
+ let dir;
434
+ try { dir = resolve(b && b.path ? String(b.path) : homedir()); } catch { dir = homedir(); }
435
+ let st; try { st = statSync(dir); } catch { return { error: 'not found' }; }
436
+ if (!st.isDirectory()) return { error: 'not a folder' };
437
+ let entries = [];
438
+ try {
439
+ entries = readdirSync(dir)
440
+ .filter(n => !n.startsWith('.'))
441
+ .map(n => join(dir, n))
442
+ .filter(p => { try { return statSync(p).isDirectory(); } catch { return false; } }) // follows symlinked dirs; skips unreadable
443
+ .map(p => ({ name: basename(p), path: p }))
444
+ .sort((a, c) => a.name.localeCompare(c.name, undefined, { numeric: true, sensitivity: 'base' }));
445
+ } catch { return { error: 'unreadable', path: dir }; }
446
+ const parent = dirname(dir);
447
+ return { path: dir, parent: parent !== dir ? parent : null, name: basename(dir) || dir, home: homedir(), entries };
448
+ },
449
+ // Forget a registered vault (the switcher's Remove for a missing folder). Never deletes the
450
+ // folder or its `.sonorance/` — only drops it from the registry. Host without a registry → no-op ok.
451
+ 'POST /api/remove': (b) => {
452
+ if (typeof E.removeProject !== 'function') return { ok: false };
453
+ E.removeProject(store, b.id);
454
+ bridge.treeDirty = true; bridge.treeCache = null;
455
+ return { ok: true };
456
+ },
457
+ // Persist the set of open tabs (and which one is active) for the current project.
458
+ // Editor state lives in `.sonorance/local/state.json` (regenerable; not needed to open a vault),
459
+ // so switching projects restores that project's own tab set and last-active tab.
460
+ 'POST /api/tabs': (b) => {
461
+ const p = cur(b); if (!p) return { error: 'no project' };
462
+ writeState(store, p.id, 'tabs', Array.isArray(b.tabs) ? b.tabs : []);
463
+ if ('active' in b) writeState(store, p.id, 'activeTab', b.active || null);
464
+ return { ok: true };
465
+ },
466
+ // Generic editor-state write: merge a `patch` object of key→value into the project's
467
+ // `.sonorance/local/state.json`. The client buffers rapid changes (e.g. Explorer collapse/expand)
468
+ // in memory and flushes them here periodically, so high-frequency UI state is persisted
469
+ // without a disk write per interaction. Each key is stored as-is.
470
+ 'POST /api/config': (b) => {
471
+ const p = cur(b); if (!p) return { error: 'no project' };
472
+ const patch = (b && b.patch && typeof b.patch === 'object' && !Array.isArray(b.patch)) ? b.patch : null;
473
+ if (!patch) return { error: 'no patch' };
474
+ writeStateMany(store, p.id, patch);
475
+ return { ok: true };
476
+ },
477
+ 'GET /api/record': async (b) => {
478
+ const p = cur(b); if (!p) return { error: 'no project' };
479
+ const id = resolveCaseId(store, p.id, b.id);
480
+ const rec = store.readRecord(id);
481
+ if (!rec) return { error: 'unknown case' };
482
+ const pr = store.prototypeRef(id);
483
+ const proto = pr ? { exists: !!pr.exists, external: !!pr.external, url: pr.external ? pr.url : null } : null;
484
+ // Hand the client the project-root-relative file — the same key the comment store uses.
485
+ return { ...rec, file: store.relFile(p.id, rec.file) || rec.file, isGit: await isGitRepo(p.dir), proto };
486
+ },
487
+ 'POST /api/record': (b) => {
488
+ const p = cur(b); if (!p) return { error: 'no project' };
489
+ const ok = store.writeRecord(resolveCaseId(store, p.id, b.id), String(b.text ?? ''));
490
+ if (ok) touch(p);
491
+ return ok ? { ok: true, updated: Date.now() } : { error: 'unknown case' };
492
+ },
493
+ // A brief's raw record (read-only in the workbench). Briefs are project-scoped
494
+ // landscape reports, viewed like a case record but not edited through stages.
495
+ 'GET /api/brief': async (b) => {
496
+ const p = cur(b); if (!p) return { error: 'no project' };
497
+ const id = resolveBriefId(store, p.id, b.id);
498
+ const rec = store.readBriefRecord(id);
499
+ if (!rec) return { error: 'unknown brief' };
500
+ return { ...rec, file: store.relFile(p.id, rec.file) || rec.file, isGit: await isGitRepo(p.dir) };
501
+ },
502
+ // A product readout's raw, read-only record.
503
+ 'GET /api/readout': async (b) => {
504
+ const p = cur(b); if (!p) return { error: 'no project' };
505
+ const id = resolveReadoutId(store, p.id, b.id);
506
+ const rec = store.readReadoutRecord(id);
507
+ if (!rec) return { error: 'unknown readout' };
508
+ return { ...rec, file: store.relFile(p.id, rec.file) || rec.file, isGit: await isGitRepo(p.dir) };
509
+ },
510
+ // A matchup's raw record (read-only in the workbench). Matchups are project-scoped
511
+ // single-competitor head-to-heads, viewed like a brief but keyed by rival (refresh-in-place).
512
+ 'GET /api/matchup': async (b) => {
513
+ const p = cur(b); if (!p) return { error: 'no project' };
514
+ const id = resolveMatchupId(store, p.id, b.id);
515
+ const rec = store.readMatchupRecord(id);
516
+ if (!rec) return { error: 'unknown matchup' };
517
+ return { ...rec, file: store.relFile(p.id, rec.file) || rec.file, isGit: await isGitRepo(p.dir) };
518
+ },
519
+ // The vault's markdown file tree — the sidebar Explorer's data source (full-tree view).
520
+ // `base` is the content root's project-relative prefix (empty for the generic app; e.g.
521
+ // `deliberate` for a host that confines content to a subfolder), so the UI's "New file"
522
+ // at the root creates inside the content root, not outside where the tree can't show it.
523
+ // Cached per project: rebuilt only when the FS watcher (or a structural write) marks it
524
+ // dirty, so the client's periodic poll is a cheap cache hit when nothing changed.
525
+ 'GET /api/tree': (b) => {
526
+ const p = cur(b); if (!p) return { tree: [], base: '', inventory: { cases: 0, briefs: 0, readouts: 0, matchups: 0 } };
527
+ const c = bridge.treeCache;
528
+ if (c && c.pid === p.id && !bridge.treeDirty) return c.resp;
529
+ const resp = {
530
+ tree: buildTree(store, p),
531
+ base: contentRoot(p).base || '',
532
+ inventory: {
533
+ cases: store.listCases(p.id).length,
534
+ briefs: store.listBriefs(p.id).length,
535
+ readouts: store.listReadouts(p.id).length,
536
+ matchups: store.listMatchups(p.id).length,
537
+ },
538
+ };
539
+ bridge.treeCache = { pid: p.id, resp }; bridge.treeDirty = false;
540
+ return resp;
541
+ },
542
+ // Read a markdown file by its project-relative path. The path is normalized + confined
543
+ // to the folder via relFile; only `.md` is served. The generic app edits any markdown
544
+ // file this way; Deliberate opens context files (e.g. product.md) with it too.
545
+ 'GET /api/file': async (b) => {
546
+ const p = cur(b); if (!p) return { error: 'no project' };
547
+ const rel = store.relFile(p.id, b.path || '');
548
+ if (!rel || !/\.md$/i.test(rel)) return { error: 'bad path' };
549
+ let text = '';
550
+ try { text = readFileSync(join(p.dir, rel), 'utf8'); } catch { return { error: 'not found' }; }
551
+ return { path: rel, text, isGit: await isGitRepo(p.dir) };
552
+ },
553
+ // Write a markdown file by its project-relative path (the generic editor's save). Confined
554
+ // to the folder via relFile; only `.md` is writable. Autosaved from the live editor.
555
+ 'POST /api/file': (b) => {
556
+ const p = cur(b); if (!p) return { error: 'no project' };
557
+ const rel = store.relFile(p.id, b.path || '');
558
+ if (!rel || !/\.md$/i.test(rel)) return { error: 'bad path' };
559
+ try { writeFileSync(join(p.dir, rel), String(b.text ?? '')); touch(p); return { ok: true, updated: Date.now() }; }
560
+ catch (e) { return { error: e.message }; }
561
+ },
562
+ // Create a NEW markdown file at a project-relative path (Explorer "New file"). Confined to
563
+ // the folder via relFile; only `.md`. Parent folders are created as needed. Never clobbers
564
+ // an existing file (`wx`) — creating over one returns `exists` so the UI can warn.
565
+ 'POST /api/create': (b) => {
566
+ const p = cur(b); if (!p) return { error: 'no project' };
567
+ const rel = store.relFile(p.id, b.path || '');
568
+ if (!rel || !/\.md$/i.test(rel)) return { error: 'bad path' };
569
+ const abs = join(p.dir, rel);
570
+ if (existsSync(abs)) return { error: 'exists' };
571
+ try { mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, String(b.text ?? ''), { flag: 'wx' }); }
572
+ catch (e) { return e.code === 'EEXIST' ? { error: 'exists' } : { error: e.message }; }
573
+ bridge.treeDirty = true; // structural change → next /api/tree rebuilds now
574
+ touch(p);
575
+ info(`create: ${rel}`);
576
+ return { ok: true, path: rel };
577
+ },
578
+ // Remove a file OR a folder (recursively) from the Explorer — destructive (the UI confirms
579
+ // first). `path` is the project-relative file/folder; confined to the vault via relFile.
580
+ // Guarded so it can never target the vault root or the hidden `.sonorance/` state dir.
581
+ 'DELETE /api/file': (b) => {
582
+ const p = cur(b); if (!p) return { error: 'no project' };
583
+ const rel = store.relFile(p.id, b.path || '');
584
+ if (!rel || rel === '.' || /(^|\/)\.[^/]/.test(rel)) return { error: 'bad path' };
585
+ const abs = join(p.dir, rel);
586
+ try {
587
+ if (!existsSync(abs)) return { error: 'not found' };
588
+ rmSync(abs, { recursive: statSync(abs).isDirectory(), force: true });
589
+ } catch (e) { return { error: e.message }; }
590
+ bridge.treeDirty = true; // structural change → rebuild the tree next fetch
591
+ touch(p);
592
+ info(`delete: ${rel}`);
593
+ return { ok: true, path: rel };
594
+ },
595
+ // Create a new (empty) folder at a project-relative path (Explorer "New folder"). Confined to
596
+ // the vault via relFile; can't target the root or a hidden machine dir. `recursive` so nested
597
+ // paths work; never clobbers an existing entry.
598
+ 'POST /api/mkdir': (b) => {
599
+ const p = cur(b); if (!p) return { error: 'no project' };
600
+ const rel = store.relFile(p.id, b.path || '');
601
+ if (!rel || rel === '.' || /(^|\/)\.[^/]/.test(rel)) return { error: 'bad path' };
602
+ const abs = join(p.dir, rel);
603
+ if (existsSync(abs)) return { error: 'exists' };
604
+ try { mkdirSync(abs, { recursive: true }); } catch (e) { return { error: e.message }; }
605
+ bridge.treeDirty = true; touch(p);
606
+ info(`mkdir: ${rel}`);
607
+ return { ok: true, path: rel };
608
+ },
609
+ // Move a file OR a folder (with its whole subtree) into another folder — or the vault root
610
+ // (`dest: ''`). Both ends are confined to the vault via relFile. Guards: can't move a folder
611
+ // into itself or its own subtree, can't clobber an existing entry, no hidden machine dirs.
612
+ // Returns the new project-relative path so the UI can re-point open tabs.
613
+ 'POST /api/move': (b) => {
614
+ const p = cur(b); if (!p) return { error: 'no project' };
615
+ const srcRel = store.relFile(p.id, b.src || '');
616
+ if (!srcRel || srcRel === '.' || /(^|\/)\.[^/]/.test(srcRel)) return { error: 'bad path' };
617
+ const destDirRel = (b.dest == null || b.dest === '') ? '' : store.relFile(p.id, b.dest);
618
+ if (destDirRel == null || /(^|\/)\.[^/]/.test(destDirRel)) return { error: 'bad path' };
619
+ const baseName = srcRel.split('/').pop();
620
+ const targetRel = destDirRel ? `${destDirRel}/${baseName}` : baseName;
621
+ if (targetRel === srcRel) return { ok: true, path: srcRel }; // already there → no-op
622
+ if (targetRel === srcRel || targetRel.startsWith(srcRel + '/')) return { error: 'into-self' };
623
+ const srcAbs = join(p.dir, srcRel), destAbs = join(p.dir, targetRel);
624
+ if (!existsSync(srcAbs)) return { error: 'not found' };
625
+ if (existsSync(destAbs)) return { error: 'exists' };
626
+ try { mkdirSync(dirname(destAbs), { recursive: true }); renameSync(srcAbs, destAbs); }
627
+ catch (e) { return { error: e.message }; }
628
+ bridge.treeDirty = true; touch(p);
629
+ info(`move: ${srcRel} → ${targetRel}`);
630
+ return { ok: true, from: srcRel, path: targetRel };
631
+ },
632
+ // Rename a file or folder in place (same parent dir, new basename) — the Explorer's "Rename…".
633
+ // `name` is a bare basename (no slashes): renaming never moves the entry between folders. A
634
+ // file keeps its extension when the new name omits one (so `notes.md` → `ideas` stays `.md`
635
+ // and doesn't vanish from the markdown tree); folders take the name verbatim.
636
+ 'POST /api/rename': (b) => {
637
+ const p = cur(b); if (!p) return { error: 'no project' };
638
+ const srcRel = store.relFile(p.id, b.path || '');
639
+ if (!srcRel || srcRel === '.' || /(^|\/)\.[^/]/.test(srcRel)) return { error: 'bad path' };
640
+ let name = String(b.name || '').trim();
641
+ if (!name || name === '.' || name === '..' || /[/\\]/.test(name) || name.startsWith('.')) return { error: 'bad name' };
642
+ const srcAbs = join(p.dir, srcRel);
643
+ if (!existsSync(srcAbs)) return { error: 'not found' };
644
+ if (!statSync(srcAbs).isDirectory()) { const ext = extname(srcRel); if (ext && !extname(name)) name += ext; }
645
+ const parent = srcRel.includes('/') ? srcRel.slice(0, srcRel.lastIndexOf('/')) : '';
646
+ const targetRel = parent ? `${parent}/${name}` : name;
647
+ if (targetRel === srcRel) return { ok: true, from: srcRel, path: srcRel }; // unchanged → no-op
648
+ const destAbs = join(p.dir, targetRel);
649
+ if (existsSync(destAbs)) return { error: 'exists' };
650
+ try { renameSync(srcAbs, destAbs); }
651
+ catch (e) { return { error: e.message }; }
652
+ bridge.treeDirty = true; touch(p);
653
+ info(`rename: ${srcRel} → ${targetRel}`);
654
+ return { ok: true, from: srcRel, path: targetRel };
655
+ },
656
+ 'GET /api/diff': async (b) => {
657
+ const p = cur(b); if (!p) return { isGit: false, diff: '' };
658
+ const isGit = await isGitRepo(p.dir);
659
+ if (!isGit) return { isGit: false, diff: '' };
660
+ // Diff any file by its project-relative path (Explorer diff mode), else a case's record by id.
661
+ const file = b.path ? store.relFile(p.id, b.path) : store.recordFile(resolveCaseId(store, p.id, b.id));
662
+ if (!file) return { isGit, tracked: false, diff: '' };
663
+ return { isGit, ...(await fileDiff(p.dir, file)), ...(await fileContents(p.dir, file)) };
664
+ },
665
+ // Git status for the Explorer's diff mode: the current branch + every path with
666
+ // uncommitted changes (staged/working/untracked), project-root relative.
667
+ 'GET /api/git': async (b) => {
668
+ const p = cur(b); if (!p) return { isGit: false, branch: '', changed: [] };
669
+ const isGit = await isGitRepo(p.dir);
670
+ if (!isGit) return { isGit: false, branch: '', changed: [] };
671
+ return { isGit, branch: await gitBranch(p.dir), changed: await gitChanged(p.dir) };
672
+ },
673
+ // Discard ALL uncommitted changes in one file (destructive — the UI confirms first):
674
+ // a tracked file is restored from HEAD, an untracked new file is deleted. `path` is the
675
+ // project-relative file; it's confined to the vault via relFile before touching disk.
676
+ 'POST /api/discard': async (b) => {
677
+ const p = cur(b); if (!p) return { error: 'no project' };
678
+ if (!(await isGitRepo(p.dir))) return { error: 'not a git repo' };
679
+ const rel = store.relFile(p.id, b.path || '');
680
+ if (!rel) return { error: 'bad path' };
681
+ const r = await discardFile(p.dir, rel);
682
+ if (!r.ok) return { error: r.err || 'discard failed' };
683
+ bridge.treeDirty = true; // a discard can add/remove a file → refresh the tree
684
+ touch(p);
685
+ info(`discard: ${rel}`);
686
+ return { ok: true, path: rel };
687
+ },
688
+
689
+ // ---- the comment bridge (in-record annotations) ----------------------------
690
+ // The reader's selection + comment on a file. Persist a durable OPEN comment (the
691
+ // source of truth) and push it to any open browsers over SSE. `file` is the annotated
692
+ // file's path (the UI passes the record's file); it's stored project-root-relative.
693
+ 'POST /api/comment': (b) => {
694
+ const p = cur(b); if (!p) return { error: 'no project' };
695
+ const file = String(b.file ?? '');
696
+ if (!file) return { error: 'no file' };
697
+ const anchor = { quote: String(b.quote ?? b.anchor?.quote ?? '').slice(0, 2000), heading: String(b.heading ?? b.anchor?.heading ?? '') };
698
+ const line = Number(b.line ?? b.anchor?.line);
699
+ const endLine = Number(b.endLine ?? b.anchor?.endLine);
700
+ // Source-LINE-RANGE anchor (robust to duplicate headings / edited-away quote text). The quote
701
+ // is kept only as a secondary hint for precise inline highlighting.
702
+ if (Number.isInteger(line) && line >= 0){ anchor.line = line; anchor.endLine = Number.isInteger(endLine) && endLine >= line ? endLine : line; }
703
+ const body = String(b.body ?? b.message ?? '').slice(0, 4000);
704
+ if (!body.trim()) return { error: 'empty comment' };
705
+ const comment = store.addComment(p.id, file, { anchor, body, author: 'user' });
706
+ if (!comment) return { error: 'invalid file' }; // escaped the project, or unresolvable
707
+ sseBroadcast(bridge, { type: 'comment', file: comment.file, comment });
708
+ info(`comment: ${comment.file} comment ${comment.id}`);
709
+ emit('comment.created', {});
710
+ return { ok: true, commentId: comment.id, file: comment.file };
711
+ },
712
+ // Every OPEN comment across the project — the batch the agent drains on
713
+ // `/deliberate address`. Announces to browsers that an agent is working (a transient
714
+ // "addressing" indicator) so the user sees their comments are being acted on.
715
+ 'GET /api/address': (b) => {
716
+ const p = cur(b); if (!p) return { comments: [], count: 0 };
717
+ const comments = openComments(store, p);
718
+ // Announce the agent is working — but at most once per 10s, so repeated drains/polls don't
719
+ // spam the browser's transient "addressing" toast.
720
+ if (comments.length) {
721
+ const now = Date.now();
722
+ if (now - (bridge.lastAddressing || 0) > 10_000) { bridge.lastAddressing = now; sseBroadcast(bridge, { type: 'addressing' }); emit('address.drained', { count: comments.length }); }
723
+ }
724
+ return { comments, count: comments.length };
725
+ },
726
+ // The agent marks a comment resolved (optionally noting what it did / that it edited
727
+ // the file). Persist it and push the resolution so the browser drops the comment.
728
+ 'POST /api/resolve': (b) => {
729
+ const p = cur(b); if (!p) return { error: 'no project' };
730
+ const commentId = String(b.commentId ?? b.id ?? '');
731
+ const patch = { status: 'resolved', revised: !!b.revised, session: b.session || 'agent', resolved_at: Date.now() };
732
+ if (b.note != null) patch.note = String(b.note).slice(0, 4000);
733
+ const hit = store.resolveComment(p.id, commentId, patch);
734
+ if (!hit) return { error: 'unknown comment' };
735
+ sseBroadcast(bridge, { type: 'resolve', file: hit.file, commentId, revised: patch.revised });
736
+ emit('comment.resolved', { revised: patch.revised });
737
+ return { ok: true };
738
+ },
739
+ // Every persisted comment for a file (the browser loads these when a record opens
740
+ // and renders the OPEN ones inline).
741
+ 'GET /api/comments': (b) => {
742
+ const p = cur(b); if (!p) return { error: 'no project' };
743
+ return { comments: store.comments(p.id, String(b.file ?? '')) };
744
+ },
745
+ // Per-file OPEN comment counts across the whole project (read-only; NO addressing
746
+ // side-effect, unlike /api/address). Drives the Explorer's comment filter + the per-file
747
+ // and per-folder comment markers. `file` keys are project-root-relative (match the tree).
748
+ 'GET /api/comments-summary': (b) => {
749
+ const p = cur(b); if (!p) return { counts: {}, total: 0 };
750
+ const counts = {};
751
+ for (const c of store.allComments(p.id)) { if (c.status === 'open' && c.file) counts[c.file] = (counts[c.file] || 0) + 1; }
752
+ return { counts, total: Object.values(counts).reduce((a, n) => a + n, 0) };
753
+ },
754
+ // The reader edits their own comment's text (a patch that keeps status/anchor). Broadcasts a
755
+ // `comment` update so open browsers reflect the new body.
756
+ 'POST /api/comment-edit': (b) => {
757
+ const p = cur(b); if (!p) return { error: 'no project' };
758
+ const commentId = String(b.commentId ?? b.id ?? '');
759
+ const body = String(b.body ?? '').slice(0, 4000);
760
+ if (!body.trim()) return { error: 'empty comment' };
761
+ const hit = store.resolveComment(p.id, commentId, { body }); // generic patch (body only — status/anchor unchanged)
762
+ if (!hit) return { error: 'unknown comment' };
763
+ sseBroadcast(bridge, { type: 'comment', file: hit.file, comment: hit });
764
+ return { ok: true, comment: hit };
765
+ },
766
+ // The reader deletes their own comment (distinct from the agent resolving it): remove
767
+ // it from the record and push the deletion so it drops from every open browser.
768
+ 'DELETE /api/comment': (b) => {
769
+ const p = cur(b); if (!p) return { error: 'no project' };
770
+ const commentId = String(b.commentId ?? b.comment ?? '');
771
+ const hit = store.deleteComment(p.id, commentId);
772
+ if (!hit) return { error: 'unknown comment' };
773
+ sseBroadcast(bridge, { type: 'delete', file: hit.file, commentId });
774
+ return { ok: true };
775
+ },
776
+
777
+ // ---- telemetry + feedback (content-free product analytics; OTLP under the hood) ------------
778
+ // The UI's telemetry sink. The browser can't reach the OTLP endpoint (no direct network, no
779
+ // secrets), so it batches its content-free events + aggregate rollups and POSTs them here; the
780
+ // server re-emits each already-bounded browser batch through an isolated client scoped to the
781
+ // request's vault, so concurrent project tabs cannot share identity, policy, or retry state.
782
+ 'POST /api/telemetry': async (b) => {
783
+ const p = cur(b);
784
+ await submitTelemetryBatch({
785
+ events: Array.isArray(b.events) ? b.events.slice(0, 100) : [],
786
+ counters: (b.counters && typeof b.counters === 'object') ? b.counters : {},
787
+ gauges: (b.gauges && typeof b.gauges === 'object') ? b.gauges : {},
788
+ histograms: (b.histograms && typeof b.histograms === 'object') ? b.histograms : {},
789
+ }, {
790
+ surface: 'ui',
791
+ product: (E.KINDS && E.KINDS.length) ? 'deliberate' : 'sonorance',
792
+ version: APP_VERSION,
793
+ vaultDir: p?.dir || null,
794
+ });
795
+ return { ok: true };
796
+ },
797
+ // Explicit, user-authored feedback from the UI panel. Always accepted (independent of the
798
+ // telemetry switch): it is a deliberate submission, mirrored locally first, then sent as an
799
+ // OTLP log record carrying only the fields the user typed (bug diagnostics are scrubbed).
800
+ 'POST /api/feedback': async (b) => {
801
+ const product = (E.KINDS && E.KINDS.length) ? 'deliberate' : 'sonorance';
802
+ const p = cur(b);
803
+ try {
804
+ const r = await submitFeedback(b || {}, { surface: 'ui', product, version: APP_VERSION, vaultDir: p?.dir });
805
+ return { ok: true, id: r.id, needs_framing: r.needs_framing };
806
+ } catch (e) { return { error: e.message }; }
807
+ },
808
+ // The UI Settings toggle writes the opt-out choice here, so the CLI
809
+ // and UI resolve the SAME ~/.sonorance consent. Re-resolves the running client immediately.
810
+ 'POST /api/consent': (b) => {
811
+ const enabled = !!(b && (b.enabled === true || b.enabled === 'true' || b.enabled === 1 || b.enabled === '1'));
812
+ const was = telemetryMode() !== 'off';
813
+ if (enabled && !was) { setConsent(true); refreshTelemetry(); emit('telemetry.enabled', {}); }
814
+ else if (!enabled && was) { emit('telemetry.disabled', {}); setConsent(false); refreshTelemetry(); }
815
+ else setConsent(enabled);
816
+ return { ok: true, enabled: telemetryMode() !== 'off' };
817
+ },
818
+ };
819
+ }
820
+
821
+ const manifestJson = () => {
822
+ const name = SONORANCE.name;
823
+ return JSON.stringify({
824
+ name, short_name: name, display: 'standalone',
825
+ background_color: '#0d0b09', theme_color: '#0d0b09', start_url: '/',
826
+ icons: [{ src: '/icon.png', sizes: '512x512', type: 'image/png' }],
827
+ });
828
+ };
829
+
830
+ /**
831
+ * startServer — boot the local app daemon.
832
+ * @param {object} o
833
+ * @param {object} o.engine REQUIRED host injection: { openVault, currentProject,
834
+ * setCurrentProject, resolveProject, KINDS,
835
+ * info, error, installCrashHandlers } — the app imports no product engine itself.
836
+ * @param {number} [o.port] TCP port; 0 lets the OS pick a free one. Env: DELIBERATE_PORT.
837
+ * @param {boolean} [o.unref] detach the socket from the event loop (tests).
838
+ * @returns {Promise<{ server, port, store }>}
839
+ */
840
+ export async function startServer({ engine, port = process.env.DELIBERATE_PORT || 7777, unref } = {}) {
841
+ if (!engine || typeof engine.openVault !== 'function')
842
+ throw new Error('app startServer: an `engine` must be injected by the host (e.g. the deliberate package).');
843
+ E = engine;
844
+ if (typeof E.info === 'function') info = E.info;
845
+ if (typeof E.error === 'function') error = E.error;
846
+ E.installCrashHandlers?.();
847
+ const store = E.openVault();
848
+ const bridge = makeBridge();
849
+ const routes = buildRoutes(store, bridge);
850
+ const server = createServer(async (req, res) => {
851
+ const url = new URL(req.url, 'http://x');
852
+ const base = '/' + url.pathname.split('/').filter(Boolean).slice(0, 2).join('/');
853
+ const route = routes[`${req.method} ${base}`];
854
+ if (route) {
855
+ try { const body = { ...(await readBody(req)), ...Object.fromEntries(url.searchParams) }; return json(res, 200, await route(body) || {}); }
856
+ catch (e) { error(`${req.method} ${url.pathname}`, e); return json(res, 500, { error: e.message }); }
857
+ }
858
+ // SSE: server→browser push for the comment bridge (comment created, comment
859
+ // resolved, agent addressing). Kept open; a comment heartbeat prevents idle drops.
860
+ if (req.method === 'GET' && url.pathname === '/api/events') {
861
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });
862
+ res.write(': ok\n\n');
863
+ bridge.sse.add(res);
864
+ const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch { /* ignore */ } }, 30_000); hb.unref?.();
865
+ req.on('close', () => { clearInterval(hb); bridge.sse.delete(res); });
866
+ return;
867
+ }
868
+ if (req.method === 'GET' && url.pathname === '/api/proto') {
869
+ const ps = url.searchParams.get('project');
870
+ const p = (ps && E.resolveProject(store, ps)) || E.currentProject(store);
871
+ const id = p ? resolveCaseId(store, p.id, url.searchParams.get('id')) : null;
872
+ // Optional surface (a primary-surface slug) selects `prototype/<surface>/index.html`;
873
+ // sanitized to a slug so it can never escape the case's prototype folder.
874
+ const surface = (url.searchParams.get('surface') || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
875
+ const pr = id ? store.prototypeRef(id, surface) : null;
876
+ if (pr && pr.external) { res.writeHead(302, { location: pr.url }); return res.end(); }
877
+ if (!pr || !pr.exists) { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('No prototype for this case yet.'); }
878
+ try { const html = await readFile(pr.path); res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }); return res.end(html); }
879
+ catch { res.writeHead(404); return res.end('no prototype'); }
880
+ }
881
+ if (req.method === 'GET' && url.pathname === '/api/asset') {
882
+ // Serve a Markdown image referenced from a record/file. `path` is project-relative and
883
+ // confined to the vault via relFile (no `..` escape); only known image types are served,
884
+ // read straight off disk so images work offline with no external fetch.
885
+ const ps = url.searchParams.get('project');
886
+ const p = (ps && E.resolveProject(store, ps)) || E.currentProject(store);
887
+ const rel = p ? store.relFile(p.id, url.searchParams.get('path') || '') : null;
888
+ if (!p || !rel) { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('no asset'); }
889
+ const type = IMG_TYPES[extname(rel).toLowerCase()];
890
+ if (!type) { res.writeHead(415, { 'content-type': 'text/plain' }); return res.end('unsupported asset type'); }
891
+ try { const buf = await readFile(join(p.dir, rel)); res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); return res.end(buf); }
892
+ catch { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('not found'); }
893
+ }
894
+ if (req.method === 'GET' && url.pathname === '/manifest.webmanifest') { res.writeHead(200, { 'content-type': 'application/manifest+json' }); return res.end(manifestJson()); }
895
+ if (req.method === 'GET' && url.pathname === '/icon.png') {
896
+ try { const png = await readFile(join(here, '../../build/icon.png')); res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'max-age=86400' }); return res.end(png); }
897
+ catch { res.writeHead(404); return res.end('no icon'); }
898
+ }
899
+ if (req.method === 'GET' && !url.pathname.startsWith('/api')) { res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }); return res.end(await readFile(join(here, '../ui/index.html'))); }
900
+ res.writeHead(404); res.end('not found');
901
+ });
902
+ const boundPort = await new Promise((resolve) => server.listen(port, () => resolve(server.address().port)));
903
+ if (unref || process.env.DELIBERATE_TEST) server.unref();
904
+ // Serving a folder initiates the project there (it creates `.sonorance/`), so keep that
905
+ // machine state out of git the moment we boot — same as `init` does. Only touches an existing
906
+ // .gitignore; idempotent. Entries are host-supplied (generic: `.sonorance/`; Deliberate adds
907
+ // any hidden `deliberate/` subfolder).
908
+ const project = E.currentProject(store) || store.getProject?.();
909
+ try { if (project?.dir) ensureGitignore(project.dir, E.gitignoreEntries?.(project) ?? []); } catch { /* non-fatal */ }
910
+ // Telemetry: the server IS the `ui` surface. Configure the shared client with this vault (so the
911
+ // committed team kill-switch + project_key resolve) and the product flavor (deliberate when the
912
+ // injected engine ships document kinds; the generic editor is plain sonorance). Content-free;
913
+ // never blocks a request. Emitting is a no-op when the user has opted out (mode 'off').
914
+ try {
915
+ const product = (E.KINDS && E.KINDS.length) ? 'deliberate' : 'sonorance';
916
+ const freshInstall = ensureInstallId().fresh; // capture BEFORE configure (which mints the id)
917
+ configureTelemetry({ surface: 'ui', product, version: APP_VERSION, vaultDir: project?.dir || null });
918
+ if (freshInstall) emit('install.created', {});
919
+ emit('session.start', {});
920
+ emit('serve.started', {});
921
+ } catch { /* telemetry must never break serve */ }
922
+ // Watch the vault's content root and push an `fs` event to open browsers whenever files
923
+ // appear/disappear/change on disk, so the Explorer's file tree + git diff mode auto-refresh
924
+ // (a new case folder shows up; a deleted file drops out) without a manual reload, and mark the
925
+ // server-side tree cache dirty. The watcher FOLLOWS the current project (re-armed on switch),
926
+ // SELF-HEALS on error (bounded retries), and is best-effort — an unsupported platform simply
927
+ // falls back to the client's polling. Coalesced (250ms) so a burst of writes is one refresh;
928
+ // unref'd so it never keeps the process (or a test) alive.
929
+ const watchRoot = (root) => {
930
+ try { bridge.watcher?.close(); } catch { /* ignore */ }
931
+ bridge.watcher = null;
932
+ if (!root || !existsSync(root)) return;
933
+ let fsTimer = null;
934
+ const onChange = (_evt, filename) => {
935
+ // Ignore VCS/app-state churn (`.git/` index refreshes, the app's own `.sonorance/`
936
+ // config+comments, `node_modules/`) — the WATCH_SKIP set (wider than TREE_SKIP: `.sonorance/`
937
+ // is shown in the tree but never watched). These are not content, and watching them caused a
938
+ // self-sustaining refresh loop: the client's periodic refresh runs `git status`, which
939
+ // rewrites `.git/index`; an unfiltered watcher fired `fs` → the client refetched tree + ran
940
+ // `git status` again → … ~2.5×/s forever, which froze the UI.
941
+ if (filename && String(filename).split(/[\\/]/).some(seg => WATCH_SKIP.has(seg))) return;
942
+ bridge.watchFails = 0; // a live event → the watcher is healthy
943
+ if (fsTimer) return;
944
+ fsTimer = setTimeout(() => { fsTimer = null; bridge.treeDirty = true; sseBroadcast(bridge, { type: 'fs' }); }, 250);
945
+ fsTimer.unref?.();
946
+ };
947
+ try {
948
+ const w = watch(root, { recursive: true }, onChange);
949
+ w.unref?.();
950
+ w.on('error', () => {
951
+ try { w.close(); } catch { /* ignore */ }
952
+ // Only the CURRENT watcher re-arms. An already-replaced (orphaned) watcher that emits a
953
+ // late error must NOT schedule a rewatch — that would tear down the healthy live watcher
954
+ // and spawn another, and repeated orphan errors could thrash/accumulate watchers.
955
+ if (bridge.watcher !== w) return;
956
+ bridge.watcher = null;
957
+ if ((bridge.watchFails = (bridge.watchFails || 0) + 1) <= 5) { const t = setTimeout(() => watchRoot(root), 1000); t.unref?.(); }
958
+ });
959
+ bridge.watcher = w;
960
+ } catch { /* unsupported → client polling covers it */ }
961
+ };
962
+ bridge.rewatch = watchRoot; // /api/switch re-arms it for the new project
963
+ bridge.treeDirty = true; // force a fresh build on the first /api/tree
964
+ try { watchRoot(contentRoot(project).dir); } catch { /* non-fatal: client polling is the fallback */ }
965
+ // Record where we're listening so the CLI's `address`/`resolve` clients reach THIS server
966
+ // (via the engine-provided discovery path). Cleared best-effort on exit.
967
+ const info_path = E.serveInfoPath?.(project);
968
+ if (info_path) {
969
+ try {
970
+ mkdirSync(dirname(info_path), { recursive: true });
971
+ writeFileSync(info_path, JSON.stringify({ port: boundPort, pid: process.pid, ts: Date.now() }));
972
+ const cleanup = () => { try { const cur = JSON.parse(readFileSync(info_path, 'utf8')); if (cur.pid === process.pid) rmSync(info_path, { force: true }); } catch { /* ignore */ } };
973
+ process.on('exit', cleanup);
974
+ bridge.cleanup = cleanup;
975
+ } catch { /* non-fatal */ }
976
+ }
977
+ // One place to tear down on a signal: drop the serve pointer AND flush telemetry (a final,
978
+ // time-boxed OTLP send of the session's remaining events/rollups) before exiting. Best-effort;
979
+ // the local JSONL is the durable record if the flush can't complete in time.
980
+ const onSignal = async () => {
981
+ try { bridge.cleanup?.(); } catch { /* ignore */ }
982
+ try { await shutdownTelemetry(); } catch { /* ignore */ }
983
+ process.exit(0);
984
+ };
985
+ process.once('SIGINT', onSignal);
986
+ process.once('SIGTERM', onSignal);
987
+ info(`${SONORANCE.name} → http://localhost:${boundPort}`);
988
+ return { server, port: boundPort, store };
989
+ }
990
+
991
+ // This is a LIBRARY: the host (the `deliberate` package) imports `startServer` and injects
992
+ // its engine. There is no run-directly path — `node src/server/index.mjs` has no engine to
993
+ // inject, so it intentionally does nothing.