wyrm-mcp 7.2.0 → 7.2.2

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 (156) hide show
  1. package/LICENSE +26 -667
  2. package/NOTICE +14 -33
  3. package/dist/activation.d.ts.map +1 -1
  4. package/dist/activation.js +1 -44
  5. package/dist/activation.js.map +1 -1
  6. package/dist/agent-daemon.js +4 -281
  7. package/dist/agent-loop.js +7 -332
  8. package/dist/analytics.js +13 -236
  9. package/dist/attribution.js +1 -49
  10. package/dist/audit.js +2 -457
  11. package/dist/auto-capture.js +3 -138
  12. package/dist/auto-orchestrator.js +1 -325
  13. package/dist/autoconfig.js +39 -840
  14. package/dist/buddy-runner.js +1 -109
  15. package/dist/buddy.js +14 -564
  16. package/dist/build-flags.js +1 -17
  17. package/dist/capabilities.js +3 -183
  18. package/dist/capture.js +1 -56
  19. package/dist/causality.js +6 -107
  20. package/dist/cli.js +20 -281
  21. package/dist/cloud/cli.js +5 -541
  22. package/dist/cloud/client.js +1 -221
  23. package/dist/cloud/crypto.js +1 -85
  24. package/dist/cloud/machine-id.js +2 -113
  25. package/dist/cloud/recovery.js +1 -60
  26. package/dist/cloud/sync-engine.js +7 -543
  27. package/dist/cloud-backup.js +5 -579
  28. package/dist/cloud-profile.js +1 -138
  29. package/dist/cloud-sync-entrypoint.js +1 -47
  30. package/dist/cloud-sync.js +2 -309
  31. package/dist/constellation.js +12 -168
  32. package/dist/context-build-budgeted.js +4 -144
  33. package/dist/context-ranking.js +1 -69
  34. package/dist/crypto.js +1 -179
  35. package/dist/daemon-write-endpoint.js +1 -290
  36. package/dist/daemon-writer.js +2 -406
  37. package/dist/database.js +43 -1110
  38. package/dist/deprecations.js +2 -162
  39. package/dist/design.js +13 -141
  40. package/dist/event-replication.js +1 -112
  41. package/dist/events-sse.js +7 -43
  42. package/dist/events.js +6 -238
  43. package/dist/failure-patterns.js +42 -659
  44. package/dist/federation.js +12 -236
  45. package/dist/goals.js +13 -101
  46. package/dist/golden.js +3 -355
  47. package/dist/handlers/agent.js +4 -165
  48. package/dist/handlers/alias-adapters.js +1 -129
  49. package/dist/handlers/aliases.js +1 -171
  50. package/dist/handlers/audit.js +1 -87
  51. package/dist/handlers/boundary.js +1 -221
  52. package/dist/handlers/capture.js +73 -1109
  53. package/dist/handlers/causality.js +7 -114
  54. package/dist/handlers/cloud.js +85 -382
  55. package/dist/handlers/companion.js +28 -459
  56. package/dist/handlers/datalake.js +7 -187
  57. package/dist/handlers/dispatch-context.js +0 -22
  58. package/dist/handlers/entity.js +25 -256
  59. package/dist/handlers/events.js +16 -335
  60. package/dist/handlers/failure.js +13 -340
  61. package/dist/handlers/goals.js +4 -296
  62. package/dist/handlers/intelligence.js +126 -674
  63. package/dist/handlers/invoicing.js +1 -70
  64. package/dist/handlers/mcpclient.js +6 -137
  65. package/dist/handlers/orchestration.js +40 -125
  66. package/dist/handlers/output-schemas.js +1 -24
  67. package/dist/handlers/presence.js +3 -99
  68. package/dist/handlers/project.js +28 -182
  69. package/dist/handlers/prompts.js +6 -157
  70. package/dist/handlers/quest.js +4 -224
  71. package/dist/handlers/recall.js +11 -218
  72. package/dist/handlers/registry.js +1 -167
  73. package/dist/handlers/resources.js +1 -288
  74. package/dist/handlers/review.js +11 -74
  75. package/dist/handlers/run.js +17 -487
  76. package/dist/handlers/search.js +15 -326
  77. package/dist/handlers/session.js +28 -615
  78. package/dist/handlers/share.js +8 -184
  79. package/dist/handlers/shims.js +1 -464
  80. package/dist/handlers/skill.js +67 -449
  81. package/dist/handlers/survivors.js +1 -120
  82. package/dist/handlers/symbols.js +8 -109
  83. package/dist/handlers/syncops.js +4 -302
  84. package/dist/handlers/types.js +1 -27
  85. package/dist/harvest.js +5 -191
  86. package/dist/hours.js +7 -156
  87. package/dist/http-auth.js +3 -321
  88. package/dist/http-fast.js +21 -1137
  89. package/dist/icons.js +1 -47
  90. package/dist/index.js +2 -924
  91. package/dist/indexer.js +4 -145
  92. package/dist/intelligence.js +31 -261
  93. package/dist/internal-dispatch.js +3 -212
  94. package/dist/keyset.js +1 -110
  95. package/dist/knowledge-graph.js +12 -176
  96. package/dist/license.d.ts +11 -0
  97. package/dist/license.d.ts.map +1 -1
  98. package/dist/license.js +2 -414
  99. package/dist/license.js.map +1 -1
  100. package/dist/logger.js +2 -199
  101. package/dist/maintenance.js +2 -148
  102. package/dist/mcp-client.js +6 -262
  103. package/dist/memory-artifacts.js +30 -449
  104. package/dist/migrate-prompt.js +2 -124
  105. package/dist/migrations.js +40 -655
  106. package/dist/performance.js +1 -228
  107. package/dist/presence.js +11 -140
  108. package/dist/priority-embed.js +5 -164
  109. package/dist/providers/embedding-provider.js +1 -196
  110. package/dist/readonly-gate.js +1 -29
  111. package/dist/rehydration.js +9 -157
  112. package/dist/reindex.js +1 -88
  113. package/dist/render-target.js +21 -514
  114. package/dist/render.js +4 -280
  115. package/dist/repl-guard.js +1 -173
  116. package/dist/replication-daemon-entrypoint.js +1 -31
  117. package/dist/replication-daemon.js +2 -262
  118. package/dist/resilience.js +1 -591
  119. package/dist/reverse-bridge.js +5 -360
  120. package/dist/security.js +1 -244
  121. package/dist/session-seen.js +3 -51
  122. package/dist/setup.js +1 -260
  123. package/dist/skill-author.js +5 -168
  124. package/dist/spec-kit.js +1 -191
  125. package/dist/sqlite-busy.js +1 -154
  126. package/dist/statusline.js +11 -315
  127. package/dist/sub-agent.js +13 -262
  128. package/dist/summarizer.js +13 -139
  129. package/dist/symbols.js +7 -283
  130. package/dist/sync.js +5 -359
  131. package/dist/tasks-dispatch.js +1 -84
  132. package/dist/tasks.js +1 -282
  133. package/dist/token-budget.js +1 -143
  134. package/dist/tool-analytics.js +7 -129
  135. package/dist/tool-annotations.js +1 -365
  136. package/dist/tool-manifest-v2.json +1 -1
  137. package/dist/tool-manifest.json +1 -1
  138. package/dist/tool-profiles.js +1 -75
  139. package/dist/trace-harvest.js +6 -244
  140. package/dist/types.js +1 -30
  141. package/dist/ui-dashboard.js +41 -50
  142. package/dist/ulid.js +1 -81
  143. package/dist/validate.js +1 -129
  144. package/dist/vault.js +1 -534
  145. package/dist/vectors.js +3 -184
  146. package/dist/version-check.js +4 -136
  147. package/dist/visibility.js +19 -155
  148. package/dist/wyrm-cli.js +98 -2451
  149. package/dist/wyrm-cli.js.map +1 -1
  150. package/dist/wyrm-guard.js +14 -424
  151. package/dist/wyrm-loop.js +3 -150
  152. package/dist/wyrm-manifest.json +1 -1
  153. package/dist/wyrm-statusline-daemon.js +1 -11
  154. package/dist/wyrm-statusline.js +4 -56
  155. package/dist/wyrm-ui.js +9 -77
  156. package/package.json +4 -2
package/dist/http-fast.js CHANGED
@@ -1,1196 +1,80 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * 󱅝 Wyrm Fast API
4
- * Optimized for AI consumption - minimal latency, compact responses
5
- *
6
- * Copyright (c) 2025 Ghost Protocol LLC. All rights reserved.
7
- * This is proprietary software. Unauthorized use, modification,
8
- * or distribution is strictly prohibited.
9
- */
10
- import http from 'http';
11
- import { WyrmDB } from './database.js';
12
- import { cache, estimateTokens, truncateToTokens, timed } from './performance.js';
13
- import { authMiddleware, getAuthStatus, getSecurityHeaders } from './http-auth.js';
14
- import { WyrmLogger } from './logger.js';
15
- import { sanitizeFtsQuery, validateProjectPath } from './security.js';
16
- import { readFileSync as _readAsset, existsSync as _assetExists, readdirSync as _readdir, realpathSync as _realpath } from 'fs';
17
- import { homedir as _homedir } from 'os';
18
- import { dirname as _assetDir, join as _assetJoin } from 'path';
19
- import { fileURLToPath as _assetUrl } from 'url';
20
- // The Ghost Protocol silver dragon — served to the /ui dashboard as the brand mark.
21
- const DRAGON_SVG_PATH = _assetJoin(_assetDir(_assetUrl(import.meta.url)), '..', 'ui', 'dragon-mark.svg');
22
- let _dragonSvgCache = null;
23
- import { getUIDashboardHTML } from './ui-dashboard.js';
24
- import { verifyLicense } from './license.js';
25
- import { MemoryArtifacts } from './memory-artifacts.js';
26
- import { GroundTruths, computeStaleness } from './intelligence.js';
27
- import { FailurePatterns } from './failure-patterns.js';
28
- import { handleDaemonWrite } from './daemon-write-endpoint.js';
29
- import { retryableWriteCause, busyErrorBody } from './sqlite-busy.js';
30
- import { sseFrame, resolveStartCursor, SSE_KEEPALIVE } from './events-sse.js';
31
- import { readonlyBlocks } from './readonly-gate.js';
32
- import { resolveActorEnvelope, runWithActor } from './handlers/boundary.js';
33
- const PORT = parseInt(process.env.WYRM_PORT || '3333') || 3333; // || 3333 guards NaN
34
- // Read-only / public-view mode. When set, a single dispatcher-level gate (below)
35
- // 403s every mutating request plus the off-box egress reads, so the dashboard is
36
- // safe to expose beyond localhost. Pair it with WYRM_UI_READONLY=1 whenever the
37
- // server is bound to a non-loopback host. The client hides controls to match.
38
- const READONLY = process.env.WYRM_UI_READONLY === '1';
39
- const MAX_BODY_SIZE = 512 * 1024; // 512KB - smaller for fast API
40
- const MAX_BATCH_OPS = 500;
41
- const MAX_SSE_STREAMS = parseInt(process.env.WYRM_MAX_SSE_STREAMS || '64', 10) || 64; // DoS guard: cap concurrent SSE
42
- const MAX_SSE_PER_IP = parseInt(process.env.WYRM_MAX_SSE_PER_IP || '8', 10) || 8; // per-client SSE cap (one client can't starve the rest)
43
- let activeStreams = 0;
44
- const sseByIp = new Map();
45
- // Honor WYRM_DB_PATH (consistent with the CLI) so the HTTP server / dashboard
46
- // can be pointed at a specific Wyrm home; falls back to the default ~/.wyrm/wyrm.db.
47
- //
48
- // v7 F2 (T012): the DB open is LAZY. `new WyrmDB(...)` at module top-level
49
- // meant ANY import of this module (wyrm-cli `serve`, tests, tooling)
50
- // synchronously opened the database and ran migrations as a side effect of
51
- // the import itself — which is why events-sse.ts and the actor-envelope tests
52
- // had to avoid importing this file at all. First touch now happens on the
53
- // first request that needs it (or on an explicit account switch). The holder
54
- // stays re-pointable for the dashboard account switcher; the domain instances
55
- // below are keyed to the live connection and reset on switch.
56
- let _db = null;
57
- let _artifacts = null;
58
- let _truths = null;
59
- let _failures = null;
60
- function getDb() {
61
- if (!_db)
62
- _db = new WyrmDB(process.env.WYRM_DB_PATH);
63
- return _db;
64
- }
65
- function getArtifacts() {
66
- if (!_artifacts)
67
- _artifacts = new MemoryArtifacts(getDb().getDatabase());
68
- return _artifacts;
69
- }
70
- function getTruths() {
71
- if (!_truths)
72
- _truths = new GroundTruths(getDb().getDatabase());
73
- return _truths;
74
- }
75
- function getFailures() {
76
- if (!_failures)
77
- _failures = new FailurePatterns(getDb().getDatabase());
78
- return _failures;
79
- }
80
- /** Re-point the server at another Wyrm home (dashboard account switcher).
81
- * Domain instances are reset so they can never hold a closed connection —
82
- * the old module-level `const artifacts/truths` kept pointing at the ORIGINAL
83
- * DB after a switch (latent stale-handle bug, fixed by the lazy refactor). */
84
- function switchDb(next) {
85
- const old = _db;
86
- _db = next;
87
- _artifacts = null;
88
- _truths = null;
89
- _failures = null;
90
- try {
91
- old?.close();
92
- }
93
- catch { /* old connection may already be gone */ }
94
- }
95
- /**
96
- * Discover the Wyrm "homes" on this machine (the default ~/.wyrm plus any
97
- * ~/.wyrm-* dirs that hold a wyrm.db), for the dashboard account switcher.
98
- * Deduped by real path so a ~/.wyrm symlink doesn't double-list its target.
99
- * Each home reports the account + tier from its own license (or cloud login).
100
- */
101
- function discoverHomes() {
102
- const out = [];
103
- const root = _homedir();
104
- const seen = new Set();
105
- let entries = [];
106
- try {
107
- entries = _readdir(root);
108
- }
109
- catch {
110
- return out;
111
- }
112
- // Process the real `.wyrm-*` homes before the bare `.wyrm` symlink so the real
113
- // dir name wins the label and the symlink dedups against it.
114
- entries.sort((a, b) => b.localeCompare(a));
115
- for (const e of entries) {
116
- if (e !== '.wyrm' && !e.startsWith('.wyrm-'))
117
- continue;
118
- let real;
119
- try {
120
- real = _realpath(_assetJoin(root, e));
121
- }
122
- catch {
123
- continue;
124
- }
125
- if (seen.has(real))
126
- continue;
127
- const dbPath = _assetJoin(real, 'wyrm.db');
128
- if (!_assetExists(dbPath))
129
- continue;
130
- seen.add(real);
131
- let account = 'Local', tier = 'free';
132
- try {
133
- const licPath = _assetJoin(real, 'license.json');
134
- if (_assetExists(licPath)) {
135
- const signed = JSON.parse(_readAsset(licPath, 'utf-8'));
136
- const v = verifyLicense(signed);
137
- account = (signed.license && signed.license.issued_to) || account;
138
- tier = v.valid ? v.tier : 'free';
139
- }
140
- else {
141
- const cloudPath = _assetJoin(real, 'cloud.json');
142
- if (_assetExists(cloudPath)) {
143
- const c = JSON.parse(_readAsset(cloudPath, 'utf-8'));
144
- account = c.email || account;
145
- }
146
- }
147
- }
148
- catch { /* unreadable license/cloud: leave defaults */ }
149
- const base = e.replace(/^\.wyrm-?/, '') || 'default';
150
- out.push({ name: base, dbPath, account, tier });
151
- }
152
- return out;
153
- }
154
- const logger = new WyrmLogger({ level: 'info' });
155
- // Clamp a query-string `?l=N` integer into [1, max] with a default fallback.
156
- // Hardens against `?l=-1` (SQLite treats negative LIMIT as "no limit" → unbounded
157
- // streaming → DoS) and `?l=abc` (NaN → SQLite reads 0 → silent empty result).
158
- function clampLimit(raw, fallback, max) {
159
- const n = Number.parseInt(String(raw ?? ''), 10);
160
- if (!Number.isFinite(n) || n < 1)
161
- return fallback;
162
- return Math.min(n, max);
163
- }
164
- // Resolve a project reference that may be a path (preferred) or a name.
165
- // Used by the Live Memory event endpoints, whose public param is `?project=`.
166
- function resolveProjectRef(ref) {
167
- const key = ref == null ? '' : String(ref);
168
- if (!key)
169
- return undefined;
170
- return getDb().getProject(key) || getDb().getProjectByName(key);
171
- }
172
- // Minimal JSON response with security headers
173
- function send(res, req, data, status = 200) {
174
- const json = JSON.stringify(data);
175
- const securityHeaders = getSecurityHeaders(req);
176
- res.writeHead(status, {
177
- 'Content-Type': 'application/json',
178
- 'Content-Length': Buffer.byteLength(json),
179
- 'X-Tokens': String(estimateTokens(data)),
180
- ...securityHeaders
181
- });
182
- res.end(json);
183
- }
184
- // HTML response for the browser dashboard
185
- function sendHtml(res, req, html) {
186
- const securityHeaders = getSecurityHeaders(req);
187
- res.writeHead(200, {
188
- ...securityHeaders,
189
- 'Content-Type': 'text/html; charset=utf-8',
190
- 'Content-Length': Buffer.byteLength(html),
191
- 'Cache-Control': 'no-store',
192
- });
193
- res.end(html);
194
- }
195
- // Fast body parser with size limit
196
- function body(req) {
197
- return new Promise((resolve, reject) => {
198
- if (req.method === 'GET') {
199
- resolve({});
200
- return;
201
- }
202
- let data = '';
203
- let size = 0;
204
- req.on('data', (chunk) => {
205
- size += chunk.length;
206
- if (size > MAX_BODY_SIZE) {
207
- req.destroy();
208
- reject(new Error('Request body too large'));
209
- return;
210
- }
211
- data += chunk;
212
- });
213
- req.on('end', () => {
214
- try {
215
- resolve(data ? JSON.parse(data) : {});
216
- }
217
- catch {
218
- reject(new Error('Invalid JSON'));
219
- }
220
- });
221
- req.on('error', reject);
222
- });
223
- }
224
- const routes = {
225
- // Quick context for AI - most used endpoint
226
- 'GET /c': (args) => {
227
- const cacheKey = `ctx:${args.p || 'global'}`;
228
- const cached = cache.get(cacheKey);
229
- if (cached)
230
- return cached;
231
- const projectPath = args.p;
232
- let result;
233
- if (projectPath) {
234
- const project = getDb().getProject(projectPath);
235
- if (!project)
236
- return { e: 'not found' };
237
- const quests = getDb().getPendingQuests(project.id);
238
- const sessions = getDb().getRecentSessions(project.id, 3);
239
- result = {
240
- n: project.name,
241
- s: project.stack,
242
- q: quests.length,
243
- qt: quests.slice(0, 5).map(q => q.title),
244
- r: sessions[0]?.summary || sessions[0]?.notes?.slice(0, 200) || null
245
- };
246
- }
247
- else {
248
- const projects = getDb().getAllProjects(20);
249
- const quests = getDb().getAllPendingQuests();
250
- result = {
251
- p: projects.map(p => ({ n: p.name, s: p.stack, q: getDb().getPendingQuests(p.id).length })),
252
- tq: quests.length
253
- };
254
- }
255
- cache.set(cacheKey, result);
256
- return result;
257
- },
258
- // List projects - compact
259
- 'GET /p': () => {
260
- const cached = cache.get('projects');
261
- if (cached)
262
- return cached;
263
- const projects = getDb().getAllProjects(50);
264
- const result = projects.map(p => ({
265
- i: p.id,
266
- n: p.name,
267
- p: p.path,
268
- s: p.stack
269
- }));
270
- cache.set('projects', result);
271
- return result;
272
- },
273
- // Scan for projects
274
- 'POST /scan': (args) => {
275
- const dir = (args.d || args.directory || process.env.HOME + '/Git Projects');
276
- const resolved = validateProjectPath(dir);
277
- if (!resolved)
278
- return { e: 'path not allowed' };
279
- cache.invalidate();
280
- const projects = getDb().scanForProjects(resolved, true);
281
- return { found: projects.length };
282
- },
283
- // Get quests
284
- 'GET /q': (args) => {
285
- const projectPath = args.p;
286
- if (projectPath) {
287
- const project = getDb().getProject(projectPath);
288
- if (!project)
289
- return { e: 'not found' };
290
- return getDb().getPendingQuests(project.id).map(q => ({
291
- i: q.id,
292
- t: q.title,
293
- p: q.priority[0]
294
- }));
295
- }
296
- return getDb().getAllPendingQuests().slice(0, 20).map(q => ({
297
- i: q.id,
298
- t: q.title,
299
- p: q.priority[0]
300
- }));
301
- },
302
- // Add quest
303
- 'POST /q': (args) => {
304
- const projectPath = args.p;
305
- const project = getDb().getProject(projectPath);
306
- if (!project)
307
- return { e: 'not found' };
308
- cache.invalidate('ctx');
309
- const quest = getDb().addQuest(project.id, args.t, args.d, args.pr || 'medium');
310
- return { i: quest.id };
311
- },
312
- // Complete quest
313
- 'POST /qc': (args) => {
314
- const id = typeof args.i === 'number' ? args.i : parseInt(String(args.i), 10);
315
- if (!id || id < 1)
316
- return { e: 'id required' };
317
- cache.invalidate('ctx');
318
- getDb().updateQuest(id, 'completed');
319
- return { ok: 1 };
320
- },
321
- // Start/get session
322
- 'POST /s': (args) => {
323
- const projectPath = args.p;
324
- let project = getDb().getProject(projectPath);
325
- if (!project) {
326
- getDb().scanForProjects(projectPath, false);
327
- project = getDb().getProject(projectPath);
328
- }
329
- if (!project)
330
- return { e: 'not found' };
331
- let session = getDb().getTodaySession(project.id);
332
- if (!session) {
333
- session = getDb().createSession(project.id, {
334
- objectives: args.o || '',
335
- notes: ''
336
- });
337
- }
338
- cache.invalidate('ctx');
339
- return { i: session.id, d: session.date };
340
- },
341
- // Update session
342
- 'POST /su': (args) => {
343
- const id = args.i;
344
- cache.invalidate('ctx');
345
- getDb().updateSession(id, {
346
- notes: args.n,
347
- summary: args.s,
348
- completed: args.c
349
- });
350
- return { ok: 1 };
351
- },
352
- // Set context
353
- 'POST /x': (args) => {
354
- const projectPath = args.p;
355
- const project = getDb().getProject(projectPath);
356
- if (!project)
357
- return { e: 'not found' };
358
- getDb().setContext(project.id, args.k, args.v);
359
- cache.invalidate('ctx');
360
- return { ok: 1 };
361
- },
362
- // Get context
363
- 'GET /x': (args) => {
364
- const projectPath = args.p;
365
- const project = getDb().getProject(projectPath);
366
- if (!project)
367
- return { e: 'not found' };
368
- return getDb().getAllContext(project.id);
369
- },
370
- // Global context
371
- 'POST /g': (args) => {
372
- const k = args.k;
373
- const v = args.v;
374
- if (!k || typeof k !== 'string')
375
- return { e: 'k (key) required' };
376
- if (v === undefined || v === null)
377
- return { e: 'v (value) required' };
378
- getDb().setGlobalContext(k, String(v));
379
- cache.invalidate();
380
- return { ok: 1 };
381
- },
382
- 'GET /g': () => getDb().getAllGlobalContext(),
383
- // Search
384
- 'GET /s': (args) => {
385
- const q = args.q;
386
- if (!q)
387
- return { e: 'query required' };
388
- const sanitized = sanitizeFtsQuery(q);
389
- const results = {
390
- q: getDb().searchQuests(sanitized).slice(0, 5).map(x => ({ i: x.id, t: x.title })),
391
- s: getDb().searchSessions(sanitized).slice(0, 3).map(x => ({ i: x.id, d: x.date })),
392
- p: getDb().searchProjects(sanitized).slice(0, 3).map(x => ({ n: x.name, p: x.path }))
393
- };
394
- return results;
395
- },
396
- // Data lake - insert
397
- 'POST /d': (args) => {
398
- const projectPath = args.p;
399
- const project = getDb().getProject(projectPath);
400
- if (!project)
401
- return { e: 'not found' };
402
- const dp = getDb().insertData(project.id, args.c, args.k, args.v, args.m);
403
- return { i: dp.id };
404
- },
405
- // Data lake - query
406
- 'GET /d': (args) => {
407
- const projectPath = args.p;
408
- const project = getDb().getProject(projectPath);
409
- if (!project)
410
- return { e: 'not found' };
411
- const limit = clampLimit(args.l, 20, 100);
412
- return getDb().queryData(project.id, args.c, limit).map(d => ({
413
- k: d.key,
414
- v: truncateToTokens(d.value, 100)
415
- }));
416
- },
417
- // Data lake - server-side aggregation for payload-effectiveness records.
418
- // Built for PhantomDragon Fleet Intelligence Phase 2.1 — replaces the
419
- // client-side aggregation in `phantom_dragon_ai/intel.py:payload_effectiveness`
420
- // which doesn't scale once an org accumulates thousands of records.
421
- //
422
- // Records are JSON blobs in `data_lake.value` with shape:
423
- // { scanner, payload_hash, stack_key, hit, ts, ... }
424
- //
425
- // Aggregates into per-(scanner, payload_hash) hit-rate, optionally
426
- // filtered by stack_key and/or scanner. Sorted by hit rate descending.
427
- // Compact wire format: [{s, ph, sk, h, t, r}] — s=scanner,
428
- // ph=payload_hash, sk=stack_key, h=hits, t=total, r=rate.
429
- 'GET /d/agg': (args) => {
430
- const projectPath = args.p;
431
- const project = getDb().getProject(projectPath);
432
- if (!project)
433
- return { e: 'not found' };
434
- const category = args.c;
435
- if (!category)
436
- return { e: 'c (category) required' };
437
- const stackKey = args.stack_key;
438
- const scannerFilter = args.scanner;
439
- // Cap higher than /d because aggregation needs the full corpus to
440
- // produce stable rates. Bounded so a malicious request can't OOM us.
441
- const limit = clampLimit(args.l, 1000, 10000);
442
- const agg = {};
443
- for (const row of getDb().queryData(project.id, category, limit)) {
444
- let rec;
445
- try {
446
- rec = JSON.parse(row.value);
447
- }
448
- catch {
449
- continue;
450
- }
451
- if (stackKey && rec.stack_key !== stackKey)
452
- continue;
453
- if (scannerFilter && rec.scanner !== scannerFilter)
454
- continue;
455
- const scanner = String(rec.scanner ?? '');
456
- const payloadHash = String(rec.payload_hash ?? '');
457
- if (!scanner || !payloadHash)
458
- continue;
459
- const key = `${scanner}:${payloadHash}`;
460
- const entry = agg[key] ?? {
461
- s: scanner,
462
- ph: payloadHash,
463
- sk: String(rec.stack_key ?? ''),
464
- h: 0,
465
- t: 0,
466
- };
467
- entry.t += 1;
468
- if (rec.hit)
469
- entry.h += 1;
470
- agg[key] = entry;
471
- }
472
- return Object.values(agg)
473
- .map(e => ({ ...e, r: e.t > 0 ? e.h / e.t : 0 }))
474
- .sort((a, b) => b.r - a.r || b.h - a.h);
475
- },
476
- // Batch operations
477
- 'POST /batch': (args) => {
478
- const ops = args.ops;
479
- if (!ops || !Array.isArray(ops))
480
- return { e: 'ops required' };
481
- if (ops.length > MAX_BATCH_OPS)
482
- return { e: `max ${MAX_BATCH_OPS} ops per batch` };
483
- const start = performance.now();
484
- const results = ops.map(op => {
485
- const key = `${op.m} ${op.r}`;
486
- const handler = routes[key];
487
- if (!handler)
488
- return { e: 'unknown' };
489
- try {
490
- return { d: handler(op.a || {}) };
491
- }
492
- catch {
493
- return { e: 'op failed' };
494
- }
495
- });
496
- return { r: results, ms: Math.round(performance.now() - start) };
497
- },
498
- // ==================== v7 F2 (T012) — daemon-as-writer ====================
499
- // The single internal write endpoint for WYRM_DAEMON_WRITES=1 clients
500
- // (src/daemon-writer.ts). CLOSED op allowlist + boundary validation live in
501
- // daemon-write-endpoint.ts (unit-tested without booting this server, the
502
- // readonly-gate.ts pattern). Security posture (Article VII): behind
503
- // authMiddleware like every route (Bearer token required when auth is on),
504
- // POST → auto-blocked by the read-only gate, loopback bind by default, and
505
- // the Wyrm-Actor envelope is already live around this handler so daemon-side
506
- // writes stamp the CALLER's agent_id/run_id.
507
- // Wire contract by commit proof: a deterministic SQLITE_CONSTRAINT rollback
508
- // returns the rejected {e} shape (the client fails direct and surfaces the
509
- // real error); SQLITE_BUSY propagates to the dispatcher catch below → the
510
- // structured 503 (proven no-commit); any OTHER throw stays a generic 500,
511
- // which the client must treat as commit-state-UNKNOWN — never widen 5xx
512
- // mapping here without proving rollback.
513
- 'POST /write': (args) => handleDaemonWrite({ db: getDb(), artifacts: getArtifacts(), truths: getTruths(), failures: getFailures() }, args),
514
- // ==================== v4.0.0 — LSP-facing helpers ====================
515
- // These power the wyrm-lsp Language Server. Keep responses compact.
516
- // GET /syms?q=<symbol>&limit=N — workspace-wide symbol lookup
517
- 'GET /syms': (args) => {
518
- const q = String(args.q ?? '').slice(0, 200);
519
- if (!q)
520
- return [];
521
- const limit = clampLimit(args.limit, 20, 200);
522
- const dbRaw = getDb().getDatabase();
523
- try {
524
- return dbRaw.prepare(`
2
+ import Q from"http";import{WyrmDB as F}from"./database.js";import{cache as E,estimateTokens as X,truncateToTokens as K,timed as H}from"./performance.js";import{authMiddleware as z,getAuthStatus as D,getSecurityHeaders as L}from"./http-auth.js";import{WyrmLogger as V}from"./logger.js";import{sanitizeFtsQuery as W,validateProjectPath as Z}from"./security.js";import{readFileSync as R,existsSync as y,readdirSync as tt,realpathSync as G}from"fs";import{homedir as et}from"os";import{dirname as U,join as _}from"path";import{fileURLToPath as rt}from"url";const x=_(U(rt(import.meta.url)),"..","ui","dragon-mark.svg");let v=null;import{getUIDashboardHTML as nt}from"./ui-dashboard.js";import{verifyLicense as B}from"./license.js";import{MemoryArtifacts as st}from"./memory-artifacts.js";import{GroundTruths as ot,computeStaleness as at}from"./intelligence.js";import{FailurePatterns as ct}from"./failure-patterns.js";import{handleDaemonWrite as it}from"./daemon-write-endpoint.js";import{retryableWriteCause as ut,busyErrorBody as lt}from"./sqlite-busy.js";import{sseFrame as dt,resolveStartCursor as Y,SSE_KEEPALIVE as mt}from"./events-sse.js";import{readonlyBlocks as pt}from"./readonly-gate.js";import{resolveActorEnvelope as ft,runWithActor as Et}from"./handlers/boundary.js";const C=parseInt(process.env.WYRM_PORT||"3333")||3333,T=process.env.WYRM_UI_READONLY==="1",ht=512*1024,b=500,St=parseInt(process.env.WYRM_MAX_SSE_STREAMS||"64",10)||64,_t=parseInt(process.env.WYRM_MAX_SSE_PER_IP||"8",10)||8;let N=0;const O=new Map;let g=null,j=null,M=null,I=null;function o(){return g||(g=new F(process.env.WYRM_DB_PATH)),g}function yt(){return j||(j=new st(o().getDatabase())),j}function Tt(){return M||(M=new ot(o().getDatabase())),M}function Ot(){return I||(I=new ct(o().getDatabase())),I}function gt(t){const e=g;g=t,j=null,M=null,I=null;try{e?.close()}catch{}}function $(){const t=[],e=et(),r=new Set;let n=[];try{n=tt(e)}catch{return t}n.sort((s,a)=>a.localeCompare(s));for(const s of n){if(s!==".wyrm"&&!s.startsWith(".wyrm-"))continue;let a;try{a=G(_(e,s))}catch{continue}if(r.has(a))continue;const u=_(a,"wyrm.db");if(!y(u))continue;r.add(a);let c="Local",i="free";try{const m=_(a,"license.json");if(y(m)){const d=JSON.parse(R(m,"utf-8")),p=B(d);c=d.license&&d.license.issued_to||c,i=p.valid?p.tier:"free"}else{const d=_(a,"cloud.json");y(d)&&(c=JSON.parse(R(d,"utf-8")).email||c)}}catch{}const l=s.replace(/^\.wyrm-?/,"")||"default";t.push({name:l,dbPath:u,account:c,tier:i})}return t}const A=new V({level:"info"});function S(t,e,r){const n=Number.parseInt(String(t??""),10);return!Number.isFinite(n)||n<1?e:Math.min(n,r)}function P(t){const e=t==null?"":String(t);if(e)return o().getProject(e)||o().getProjectByName(e)}function h(t,e,r,n=200){const s=JSON.stringify(r),a=L(e);t.writeHead(n,{"Content-Type":"application/json","Content-Length":Buffer.byteLength(s),"X-Tokens":String(X(r)),...a}),t.end(s)}function Rt(t,e,r){const n=L(e);t.writeHead(200,{...n,"Content-Type":"text/html; charset=utf-8","Content-Length":Buffer.byteLength(r),"Cache-Control":"no-store"}),t.end(r)}function vt(t){return new Promise((e,r)=>{if(t.method==="GET"){e({});return}let n="",s=0;t.on("data",a=>{if(s+=a.length,s>ht){t.destroy(),r(new Error("Request body too large"));return}n+=a}),t.on("end",()=>{try{e(n?JSON.parse(n):{})}catch{r(new Error("Invalid JSON"))}}),t.on("error",r)})}const J={"GET /c":t=>{const e=`ctx:${t.p||"global"}`,r=E.get(e);if(r)return r;const n=t.p;let s;if(n){const a=o().getProject(n);if(!a)return{e:"not found"};const u=o().getPendingQuests(a.id),c=o().getRecentSessions(a.id,3);s={n:a.name,s:a.stack,q:u.length,qt:u.slice(0,5).map(i=>i.title),r:c[0]?.summary||c[0]?.notes?.slice(0,200)||null}}else{const a=o().getAllProjects(20),u=o().getAllPendingQuests();s={p:a.map(c=>({n:c.name,s:c.stack,q:o().getPendingQuests(c.id).length})),tq:u.length}}return E.set(e,s),s},"GET /p":()=>{const t=E.get("projects");if(t)return t;const r=o().getAllProjects(50).map(n=>({i:n.id,n:n.name,p:n.path,s:n.stack}));return E.set("projects",r),r},"POST /scan":t=>{const e=t.d||t.directory||process.env.HOME+"/Git Projects",r=Z(e);return r?(E.invalidate(),{found:o().scanForProjects(r,!0).length}):{e:"path not allowed"}},"GET /q":t=>{const e=t.p;if(e){const r=o().getProject(e);return r?o().getPendingQuests(r.id).map(n=>({i:n.id,t:n.title,p:n.priority[0]})):{e:"not found"}}return o().getAllPendingQuests().slice(0,20).map(r=>({i:r.id,t:r.title,p:r.priority[0]}))},"POST /q":t=>{const e=t.p,r=o().getProject(e);return r?(E.invalidate("ctx"),{i:o().addQuest(r.id,t.t,t.d,t.pr||"medium").id}):{e:"not found"}},"POST /qc":t=>{const e=typeof t.i=="number"?t.i:parseInt(String(t.i),10);return!e||e<1?{e:"id required"}:(E.invalidate("ctx"),o().updateQuest(e,"completed"),{ok:1})},"POST /s":t=>{const e=t.p;let r=o().getProject(e);if(r||(o().scanForProjects(e,!1),r=o().getProject(e)),!r)return{e:"not found"};let n=o().getTodaySession(r.id);return n||(n=o().createSession(r.id,{objectives:t.o||"",notes:""})),E.invalidate("ctx"),{i:n.id,d:n.date}},"POST /su":t=>{const e=t.i;return E.invalidate("ctx"),o().updateSession(e,{notes:t.n,summary:t.s,completed:t.c}),{ok:1}},"POST /x":t=>{const e=t.p,r=o().getProject(e);return r?(o().setContext(r.id,t.k,t.v),E.invalidate("ctx"),{ok:1}):{e:"not found"}},"GET /x":t=>{const e=t.p,r=o().getProject(e);return r?o().getAllContext(r.id):{e:"not found"}},"POST /g":t=>{const e=t.k,r=t.v;return!e||typeof e!="string"?{e:"k (key) required"}:r==null?{e:"v (value) required"}:(o().setGlobalContext(e,String(r)),E.invalidate(),{ok:1})},"GET /g":()=>o().getAllGlobalContext(),"GET /s":t=>{const e=t.q;if(!e)return{e:"query required"};const r=W(e);return{q:o().searchQuests(r).slice(0,5).map(s=>({i:s.id,t:s.title})),s:o().searchSessions(r).slice(0,3).map(s=>({i:s.id,d:s.date})),p:o().searchProjects(r).slice(0,3).map(s=>({n:s.name,p:s.path}))}},"POST /d":t=>{const e=t.p,r=o().getProject(e);return r?{i:o().insertData(r.id,t.c,t.k,t.v,t.m).id}:{e:"not found"}},"GET /d":t=>{const e=t.p,r=o().getProject(e);if(!r)return{e:"not found"};const n=S(t.l,20,100);return o().queryData(r.id,t.c,n).map(s=>({k:s.key,v:K(s.value,100)}))},"GET /d/agg":t=>{const e=t.p,r=o().getProject(e);if(!r)return{e:"not found"};const n=t.c;if(!n)return{e:"c (category) required"};const s=t.stack_key,a=t.scanner,u=S(t.l,1e3,1e4),c={};for(const i of o().queryData(r.id,n,u)){let l;try{l=JSON.parse(i.value)}catch{continue}if(s&&l.stack_key!==s||a&&l.scanner!==a)continue;const m=String(l.scanner??""),d=String(l.payload_hash??"");if(!m||!d)continue;const p=`${m}:${d}`,f=c[p]??{s:m,ph:d,sk:String(l.stack_key??""),h:0,t:0};f.t+=1,l.hit&&(f.h+=1),c[p]=f}return Object.values(c).map(i=>({...i,r:i.t>0?i.h/i.t:0})).sort((i,l)=>l.r-i.r||l.h-i.h)},"POST /batch":t=>{const e=t.ops;if(!e||!Array.isArray(e))return{e:"ops required"};if(e.length>b)return{e:`max ${b} ops per batch`};const r=performance.now();return{r:e.map(s=>{const a=`${s.m} ${s.r}`,u=J[a];if(!u)return{e:"unknown"};try{return{d:u(s.a||{})}}catch{return{e:"op failed"}}}),ms:Math.round(performance.now()-r)}},"POST /write":t=>it({db:o(),artifacts:yt(),truths:Tt(),failures:Ot()},t),"GET /syms":t=>{const e=String(t.q??"").slice(0,200);if(!e)return[];const r=S(t.limit,20,200),n=o().getDatabase();try{return n.prepare(`
525
3
  SELECT symbol, kind, language, file_path, line, signature, project_id
526
4
  FROM symbol_index
527
5
  WHERE symbol LIKE ?
528
6
  ORDER BY project_id, file_path, line
529
7
  LIMIT ?
530
- `).all(`%${q}%`, limit);
531
- }
532
- catch {
533
- return [];
534
- }
535
- },
536
- // GET /failures?target=<file-or-symbol>&limit=N — unresolved failures
537
- 'GET /failures': (args) => {
538
- const target = String(args.target ?? '').slice(0, 200);
539
- const limit = clampLimit(args.limit, 10, 100);
540
- const dbRaw = getDb().getDatabase();
541
- try {
542
- // Match by exact target or LIKE substring (LSP hover passes a symbol;
543
- // diagnostics pass a file path — both work).
544
- return dbRaw.prepare(`
8
+ `).all(`%${e}%`,r)}catch{return[]}},"GET /failures":t=>{const e=String(t.target??"").slice(0,200),r=S(t.limit,10,100),n=o().getDatabase();try{return n.prepare(`
545
9
  SELECT id, scope, target, description, why_failed, occurrences,
546
10
  severity, last_seen
547
11
  FROM failure_patterns
548
12
  WHERE resolved = 0 AND (target = ? OR target LIKE ?)
549
13
  ORDER BY occurrences DESC, last_seen DESC
550
14
  LIMIT ?
551
- `).all(target, `%${target}%`, limit);
552
- }
553
- catch {
554
- return [];
555
- }
556
- },
557
- // GET /truths?q=<query>&limit=N — FTS or LIKE over ground truths
558
- 'GET /truths': (args) => {
559
- const q = String(args.q ?? '').slice(0, 200);
560
- const limit = clampLimit(args.limit, 5, 50);
561
- if (!q)
562
- return [];
563
- const dbRaw = getDb().getDatabase();
564
- try {
565
- // Conservative LIKE — keep predictable for LSP hover latency.
566
- return dbRaw.prepare(`
15
+ `).all(e,`%${e}%`,r)}catch{return[]}},"GET /truths":t=>{const e=String(t.q??"").slice(0,200),r=S(t.limit,5,50);if(!e)return[];const n=o().getDatabase();try{return n.prepare(`
567
16
  SELECT category, key, value, rationale, confidence, ttl_days
568
17
  FROM ground_truths
569
18
  WHERE is_current = 1
570
19
  AND (key LIKE ? OR value LIKE ? OR rationale LIKE ?)
571
20
  ORDER BY confidence DESC
572
21
  LIMIT ?
573
- `).all(`%${q}%`, `%${q}%`, `%${q}%`, limit);
574
- }
575
- catch {
576
- return [];
577
- }
578
- },
579
- // GET /audit?limit=N[&kind=...] — recent audit entries
580
- 'GET /audit': (args) => {
581
- const limit = clampLimit(args.limit, 50, 500);
582
- const kind = args.kind;
583
- const dbRaw = getDb().getDatabase();
584
- try {
585
- if (kind) {
586
- return dbRaw.prepare('SELECT * FROM audit_log WHERE event_kind = ? ORDER BY id DESC LIMIT ?').all(kind, limit);
587
- }
588
- return dbRaw.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT ?').all(limit);
589
- }
590
- catch {
591
- return [];
592
- }
593
- },
594
- // GET /sync/push?since=<iso>&limit=N — collect shared rows for push
595
- 'GET /sync/push': (args) => {
596
- const since = args.since;
597
- const limit = clampLimit(args.limit, 500, 5000);
598
- const dbRaw = getDb().getDatabase();
599
- const out = [];
600
- const cursor = since ?? '1970-01-01';
601
- const tables = [
602
- ['session', 'sessions', 'created_at'],
603
- ['quest', 'quests', 'created_at'],
604
- ['truth', 'ground_truths', 'updated_at'],
605
- ['artifact', 'memory_artifacts', 'last_accessed_at'],
606
- ['edge', 'decision_edges', 'created_at'],
607
- ];
608
- // [grove isolation] This route is the team-Wyrm pull surface; it must honour
609
- // the same grove gate as Federation.collectForPush, or a private grove leaks
610
- // here even though the TS push path blocks it. Only a 'team' grove federates.
611
- const groveGate = (() => {
612
- try {
613
- const has = dbRaw.prepare(`PRAGMA table_info(projects)`).all()
614
- .some((c) => c.name === 'sync_policy');
615
- return has
616
- ? `AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy = 'team'))`
617
- : ``;
618
- }
619
- catch {
620
- return ``;
621
- }
622
- })();
623
- for (const [kind, table, tsCol] of tables) {
624
- if (out.length >= limit)
625
- break;
626
- try {
627
- const rows = dbRaw.prepare(`SELECT * FROM ${table}
628
- WHERE is_shared = 1 ${groveGate} AND ${tsCol} > ?
629
- ORDER BY ${tsCol} ASC
630
- LIMIT ?`).all(cursor, Math.max(1, limit - out.length));
631
- for (const r of rows) {
632
- out.push({
633
- kind, id: r.id, project_id: r.project_id ?? null,
634
- updated_at: r[tsCol] ?? null,
635
- payload: r,
636
- });
637
- }
638
- }
639
- catch { /* table may not have is_shared on pre-v4 DBs */ }
640
- }
641
- return { rows: out, count: out.length, cursor: new Date().toISOString() };
642
- },
643
- // ── Live Memory (v6.4) — event stream JSON pull ───────────────────────────
644
- // The SSE counterpart `GET /events/stream` is special-cased in the server
645
- // handler (long-lived response, can't fit this synchronous route table).
646
- // `?project=` accepts a path or name; `?since=` is a cursor; `?limit=` 1-1000.
647
- 'GET /events': (args) => {
648
- if (!getDb().liveMemoryEnabled())
649
- return { e: 'live memory disabled', disabled: 1 };
650
- const project = resolveProjectRef(args.project ?? args.p);
651
- if (!project)
652
- return { e: 'not found' };
653
- const since = resolveStartCursor(undefined, args.since);
654
- const limit = clampLimit(args.limit ?? args.l, 100, 1000);
655
- // `shared=1` gates the read to is_shared events only — what a REMOTE device
656
- // is allowed to pull (private events never leave the box). Omit for the
657
- // same-device local UI/watcher, which may see everything.
658
- const sharedOnly = args.shared === '1' || args.shared === 'true';
659
- const events = sharedOnly
660
- ? getDb().eventsForPush(project.id, since, limit)
661
- : getDb().eventsSince(project.id, since, limit);
662
- const cursor = events.length ? events[events.length - 1].cursor : since;
663
- return { project: project.name, cursor, count: events.length, events };
664
- },
665
- // Live Memory replication INGEST (Phase 3). A peer POSTs its shared events
666
- // here; we INSERT-OR-IGNORE (dedup on origin identity) and echo-suppress our
667
- // own device. Bearer-gated by the existing HTTP auth. Body:
668
- // { project: <path|name>, events: RemoteEvent[] } (or a single `event`).
669
- 'POST /events': (args) => {
670
- if (!getDb().liveMemoryEnabled())
671
- return { e: 'live memory disabled', disabled: 1 };
672
- const project = resolveProjectRef(args.project ?? args.p);
673
- if (!project)
674
- return { e: 'not found' };
675
- const incoming = (Array.isArray(args.events) ? args.events
676
- : (args.event ? [args.event] : []));
677
- if (incoming.length === 0)
678
- return { e: 'events[] required' };
679
- if (incoming.length > MAX_BATCH_OPS)
680
- return { e: `max ${MAX_BATCH_OPS} events per request` };
681
- let inserted = 0, duplicate = 0, echo = 0;
682
- for (const ev of incoming) {
683
- const r = getDb().ingestRemoteEvent(project.id, ev);
684
- if (r === 'inserted')
685
- inserted++;
686
- else if (r === 'echo')
687
- echo++;
688
- else
689
- duplicate++;
690
- }
691
- if (inserted > 0)
692
- cache.invalidate('ctx');
693
- return { project: project.name, inserted, duplicate, echo };
694
- },
695
- // Stats
696
- 'GET /stats': () => {
697
- const { result, ms } = timed(() => getDb().getStats());
698
- return { ...result, ms, cache: cache.stats() };
699
- },
700
- // Health check (unauthenticated)
701
- 'GET /health': () => ({ ok: 1, ts: Date.now() }),
702
- // Auth status
703
- 'GET /auth/status': () => {
704
- const status = getAuthStatus();
705
- return {
706
- auth: status.requireAuth ? 1 : 0,
707
- origins: status.allowedOrigins.length
708
- };
709
- },
710
- // ── UI Dashboard API ──────────────────────────────────────────────────────
711
- // Aggregate stats for the Overview tab
712
- 'GET /ui/stats': () => {
713
- const rawDb = getDb().getDatabase();
714
- const projects = rawDb.prepare('SELECT COUNT(*) as c FROM projects').get().c;
715
- const sessions = rawDb.prepare('SELECT COUNT(*) as c FROM sessions').get().c;
716
- const activeQ = rawDb.prepare("SELECT COUNT(*) as c FROM quests WHERE status IN ('pending','in_progress')").get().c;
717
- const dataPoints = rawDb.prepare('SELECT COUNT(*) as c FROM data_lake').get().c;
718
- const arts = rawDb.prepare('SELECT COUNT(*) as c FROM memory_artifacts WHERE needs_review = 0').get().c;
719
- const reviewQ = rawDb.prepare('SELECT COUNT(*) as c FROM memory_artifacts WHERE needs_review = 1').get().c;
720
- const groundT = rawDb.prepare('SELECT COUNT(*) as c FROM ground_truths WHERE is_current = 1').get().c;
721
- const recentSess = rawDb.prepare(`
22
+ `).all(`%${e}%`,`%${e}%`,`%${e}%`,r)}catch{return[]}},"GET /audit":t=>{const e=S(t.limit,50,500),r=t.kind,n=o().getDatabase();try{return r?n.prepare("SELECT * FROM audit_log WHERE event_kind = ? ORDER BY id DESC LIMIT ?").all(r,e):n.prepare("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?").all(e)}catch{return[]}},"GET /sync/push":t=>{const e=t.since,r=S(t.limit,500,5e3),n=o().getDatabase(),s=[],a=e??"1970-01-01",u=[["session","sessions","created_at"],["quest","quests","created_at"],["truth","ground_truths","updated_at"],["artifact","memory_artifacts","last_accessed_at"],["edge","decision_edges","created_at"]],c=(()=>{try{return n.prepare("PRAGMA table_info(projects)").all().some(l=>l.name==="sync_policy")?"AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy = 'team'))":""}catch{return""}})();for(const[i,l,m]of u){if(s.length>=r)break;try{const d=n.prepare(`SELECT * FROM ${l}
23
+ WHERE is_shared = 1 ${c} AND ${m} > ?
24
+ ORDER BY ${m} ASC
25
+ LIMIT ?`).all(a,Math.max(1,r-s.length));for(const p of d)s.push({kind:i,id:p.id,project_id:p.project_id??null,updated_at:p[m]??null,payload:p})}catch{}}return{rows:s,count:s.length,cursor:new Date().toISOString()}},"GET /events":t=>{if(!o().liveMemoryEnabled())return{e:"live memory disabled",disabled:1};const e=P(t.project??t.p);if(!e)return{e:"not found"};const r=Y(void 0,t.since),n=S(t.limit??t.l,100,1e3),a=t.shared==="1"||t.shared==="true"?o().eventsForPush(e.id,r,n):o().eventsSince(e.id,r,n),u=a.length?a[a.length-1].cursor:r;return{project:e.name,cursor:u,count:a.length,events:a}},"POST /events":t=>{if(!o().liveMemoryEnabled())return{e:"live memory disabled",disabled:1};const e=P(t.project??t.p);if(!e)return{e:"not found"};const r=Array.isArray(t.events)?t.events:t.event?[t.event]:[];if(r.length===0)return{e:"events[] required"};if(r.length>b)return{e:`max ${b} events per request`};let n=0,s=0,a=0;for(const u of r){const c=o().ingestRemoteEvent(e.id,u);c==="inserted"?n++:c==="echo"?a++:s++}return n>0&&E.invalidate("ctx"),{project:e.name,inserted:n,duplicate:s,echo:a}},"GET /stats":()=>{const{result:t,ms:e}=H(()=>o().getStats());return{...t,ms:e,cache:E.stats()}},"GET /health":()=>({ok:1,ts:Date.now()}),"GET /auth/status":()=>{const t=D();return{auth:t.requireAuth?1:0,origins:t.allowedOrigins.length}},"GET /ui/stats":()=>{const t=o().getDatabase(),e=t.prepare("SELECT COUNT(*) as c FROM projects").get().c,r=t.prepare("SELECT COUNT(*) as c FROM sessions").get().c,n=t.prepare("SELECT COUNT(*) as c FROM quests WHERE status IN ('pending','in_progress')").get().c,s=t.prepare("SELECT COUNT(*) as c FROM data_lake").get().c,a=t.prepare("SELECT COUNT(*) as c FROM memory_artifacts WHERE needs_review = 0").get().c,u=t.prepare("SELECT COUNT(*) as c FROM memory_artifacts WHERE needs_review = 1").get().c,c=t.prepare("SELECT COUNT(*) as c FROM ground_truths WHERE is_current = 1").get().c,i=t.prepare(`
722
26
  SELECT date, summary, objectives FROM sessions
723
27
  ORDER BY created_at DESC LIMIT 8
724
- `).all();
725
- return {
726
- projects, sessions, active_quests: activeQ, data_points: dataPoints,
727
- artifacts: arts, review_queue: reviewQ, truths: groundT,
728
- recent_sessions: recentSess
729
- };
730
- },
731
- // Paginated memory artifacts list with optional kind/search filters
732
- 'GET /ui/memories': (args) => {
733
- const rawDb = getDb().getDatabase();
734
- const page = Math.max(1, parseInt(String(args.page || '1'), 10));
735
- const limit = Math.min(100, Math.max(1, parseInt(String(args.limit || '20'), 10)));
736
- const kind = args.kind ? String(args.kind) : '';
737
- const search = args.search ? String(args.search) : '';
738
- const offset = (page - 1) * limit;
739
- let query;
740
- let countQuery;
741
- let params;
742
- let countParams;
743
- if (search) {
744
- const sanitized = sanitizeFtsQuery(search);
745
- if (kind) {
746
- query = `
28
+ `).all();return{projects:e,sessions:r,active_quests:n,data_points:s,artifacts:a,review_queue:u,truths:c,recent_sessions:i}},"GET /ui/memories":t=>{const e=o().getDatabase(),r=Math.max(1,parseInt(String(t.page||"1"),10)),n=Math.min(100,Math.max(1,parseInt(String(t.limit||"20"),10))),s=t.kind?String(t.kind):"",a=t.search?String(t.search):"",u=(r-1)*n;let c,i,l,m;if(a){const f=W(a);s?(c=`
747
29
  SELECT ma.*, p.name AS project_name
748
30
  FROM memory_artifacts ma
749
31
  JOIN memory_artifacts_fts fts ON fts.rowid = ma.id
750
32
  LEFT JOIN projects p ON ma.project_id = p.id
751
33
  WHERE memory_artifacts_fts MATCH ? AND ma.kind = ?
752
34
  AND ma.supersedes_id IS NULL
753
- ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`;
754
- countQuery = `
35
+ ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`,i=`
755
36
  SELECT COUNT(*) as c FROM memory_artifacts ma
756
37
  JOIN memory_artifacts_fts fts ON fts.rowid = ma.id
757
38
  WHERE memory_artifacts_fts MATCH ? AND ma.kind = ?
758
- AND ma.supersedes_id IS NULL`;
759
- params = [sanitized, kind, limit, offset];
760
- countParams = [sanitized, kind];
761
- }
762
- else {
763
- query = `
39
+ AND ma.supersedes_id IS NULL`,l=[f,s,n,u],m=[f,s]):(c=`
764
40
  SELECT ma.*, p.name AS project_name
765
41
  FROM memory_artifacts ma
766
42
  JOIN memory_artifacts_fts fts ON fts.rowid = ma.id
767
43
  LEFT JOIN projects p ON ma.project_id = p.id
768
44
  WHERE memory_artifacts_fts MATCH ? AND ma.supersedes_id IS NULL
769
- ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`;
770
- countQuery = `
45
+ ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`,i=`
771
46
  SELECT COUNT(*) as c FROM memory_artifacts ma
772
47
  JOIN memory_artifacts_fts fts ON fts.rowid = ma.id
773
- WHERE memory_artifacts_fts MATCH ? AND ma.supersedes_id IS NULL`;
774
- params = [sanitized, limit, offset];
775
- countParams = [sanitized];
776
- }
777
- }
778
- else if (kind) {
779
- query = `
48
+ WHERE memory_artifacts_fts MATCH ? AND ma.supersedes_id IS NULL`,l=[f,n,u],m=[f])}else s?(c=`
780
49
  SELECT ma.*, p.name AS project_name
781
50
  FROM memory_artifacts ma
782
51
  LEFT JOIN projects p ON ma.project_id = p.id
783
52
  WHERE ma.kind = ? AND ma.supersedes_id IS NULL
784
- ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`;
785
- countQuery = `SELECT COUNT(*) as c FROM memory_artifacts WHERE kind = ? AND supersedes_id IS NULL`;
786
- params = [kind, limit, offset];
787
- countParams = [kind];
788
- }
789
- else {
790
- query = `
53
+ ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`,i="SELECT COUNT(*) as c FROM memory_artifacts WHERE kind = ? AND supersedes_id IS NULL",l=[s,n,u],m=[s]):(c=`
791
54
  SELECT ma.*, p.name AS project_name
792
55
  FROM memory_artifacts ma
793
56
  LEFT JOIN projects p ON ma.project_id = p.id
794
57
  WHERE ma.supersedes_id IS NULL
795
- ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`;
796
- countQuery = `SELECT COUNT(*) as c FROM memory_artifacts WHERE supersedes_id IS NULL`;
797
- params = [limit, offset];
798
- countParams = [];
799
- }
800
- const items = rawDb.prepare(query).all(...params);
801
- const total = rawDb.prepare(countQuery).get(...countParams).c;
802
- return { items, total, page, limit };
803
- },
804
- // Quests grouped by status (kanban data)
805
- 'GET /ui/quests': () => {
806
- const rawDb = getDb().getDatabase();
807
- const rows = rawDb.prepare(`
58
+ ORDER BY ma.confidence DESC LIMIT ? OFFSET ?`,i="SELECT COUNT(*) as c FROM memory_artifacts WHERE supersedes_id IS NULL",l=[n,u],m=[]);const d=e.prepare(c).all(...l),p=e.prepare(i).get(...m).c;return{items:d,total:p,page:r,limit:n}},"GET /ui/quests":()=>{const e=o().getDatabase().prepare(`
808
59
  SELECT q.id, q.title, q.status, q.priority, p.name AS project_name
809
60
  FROM quests q
810
61
  LEFT JOIN projects p ON q.project_id = p.id
811
62
  ORDER BY q.created_at DESC
812
- `).all();
813
- const grouped = { pending: [], in_progress: [], completed: [], abandoned: [] };
814
- for (const row of rows) {
815
- if (grouped[row.status])
816
- grouped[row.status].push(row);
817
- }
818
- return grouped;
819
- },
820
- // Paginated ground truths with staleness scores
821
- 'GET /ui/truths': (args) => {
822
- const rawDb = getDb().getDatabase();
823
- const page = Math.max(1, parseInt(String(args.page || '1'), 10));
824
- const limit = Math.min(100, Math.max(1, parseInt(String(args.limit || '20'), 10)));
825
- const offset = (page - 1) * limit;
826
- const rows = rawDb.prepare(`
63
+ `).all(),r={pending:[],in_progress:[],completed:[],abandoned:[]};for(const n of e)r[n.status]&&r[n.status].push(n);return r},"GET /ui/truths":t=>{const e=o().getDatabase(),r=Math.max(1,parseInt(String(t.page||"1"),10)),n=Math.min(100,Math.max(1,parseInt(String(t.limit||"20"),10))),s=(r-1)*n,a=e.prepare(`
827
64
  SELECT gt.*, p.name AS project_name
828
65
  FROM ground_truths gt
829
66
  LEFT JOIN projects p ON gt.project_id = p.id
830
67
  WHERE gt.is_current = 1
831
68
  ORDER BY gt.updated_at DESC LIMIT ? OFFSET ?
832
- `).all(limit, offset);
833
- const total = rawDb.prepare('SELECT COUNT(*) as c FROM ground_truths WHERE is_current = 1').get().c;
834
- const items = rows.map(row => ({ ...row, staleness: computeStaleness(row) }));
835
- return { items, total, page, limit };
836
- },
837
- // Memory artifacts flagged for review
838
- 'GET /ui/review': () => {
839
- const rawDb = getDb().getDatabase();
840
- const items = rawDb.prepare(`
69
+ `).all(n,s),u=e.prepare("SELECT COUNT(*) as c FROM ground_truths WHERE is_current = 1").get().c;return{items:a.map(i=>({...i,staleness:at(i)})),total:u,page:r,limit:n}},"GET /ui/review":()=>({items:o().getDatabase().prepare(`
841
70
  SELECT ma.*, p.name AS project_name
842
71
  FROM memory_artifacts ma
843
72
  LEFT JOIN projects p ON ma.project_id = p.id
844
73
  WHERE ma.needs_review = 1
845
74
  ORDER BY ma.created_at DESC
846
- `).all();
847
- return { items };
848
- },
849
- // Active account + license tier (the dashboard identity badge). Reads the
850
- // license from the SAME home dir as the active DB so the badge always matches
851
- // the data on screen, even when an operator runs multiple Wyrm homes.
852
- 'GET /ui/account': () => {
853
- const home = _assetDir(getDb().getDatabasePath());
854
- try {
855
- const licPath = _assetJoin(home, 'license.json');
856
- if (_assetExists(licPath)) {
857
- const signed = JSON.parse(_readAsset(licPath, 'utf-8'));
858
- const v = verifyLicense(signed);
859
- return {
860
- account: (signed.license && signed.license.issued_to) || 'unknown',
861
- tier: v.valid ? v.tier : 'free',
862
- valid: !!v.valid,
863
- expires: v.expiresAt || null,
864
- readonly: READONLY,
865
- };
866
- }
867
- }
868
- catch { /* fall through to the free/cloud fallback */ }
869
- try {
870
- const cloudPath = _assetJoin(home, 'cloud.json');
871
- if (_assetExists(cloudPath)) {
872
- const c = JSON.parse(_readAsset(cloudPath, 'utf-8'));
873
- return { account: c.email || 'Local', tier: 'free', valid: false, expires: null, readonly: READONLY };
874
- }
875
- }
876
- catch { /* ignore */ }
877
- return { account: 'Local', tier: 'free', valid: false, expires: null, readonly: READONLY };
878
- },
879
- // Value/Impact metrics: what Wyrm has actually done (for the Impact tab).
880
- 'GET /ui/impact': () => {
881
- const rawDb = getDb().getDatabase();
882
- const n = (sql) => { try {
883
- return (rawDb.prepare(sql).get()?.n) || 0;
884
- }
885
- catch {
886
- return 0;
887
- } };
888
- return {
889
- failures_blocked: n('SELECT COALESCE(SUM(occurrences),0) AS n FROM failure_patterns'),
890
- tokens_saved: n('SELECT COALESCE(SUM(estimated_tokens),0) AS n FROM token_savings_log'),
891
- memories: n('SELECT COUNT(*) AS n FROM memory_artifacts WHERE supersedes_id IS NULL'),
892
- memories_week: n("SELECT COUNT(*) AS n FROM memory_artifacts WHERE created_at >= datetime('now','-7 days')"),
893
- truths: n('SELECT COUNT(*) AS n FROM ground_truths WHERE is_current = 1'),
894
- sessions: n('SELECT COUNT(*) AS n FROM sessions'),
895
- quests_completed: n("SELECT COUNT(*) AS n FROM quests WHERE status = 'completed'"),
896
- skills: n('SELECT COUNT(*) AS n FROM skills'),
897
- projects: n('SELECT COUNT(*) AS n FROM projects'),
898
- };
899
- },
900
- // Full detail for one memory artifact (the click-through modal).
901
- 'GET /ui/memory': (args) => {
902
- const rawDb = getDb().getDatabase();
903
- const id = parseInt(String((args && args.id) || '0'), 10);
904
- if (!id)
905
- return { item: null };
906
- try {
907
- const item = rawDb.prepare(`SELECT ma.*, p.name AS project_name
75
+ `).all()}),"GET /ui/account":()=>{const t=U(o().getDatabasePath());try{const e=_(t,"license.json");if(y(e)){const r=JSON.parse(R(e,"utf-8")),n=B(r);return{account:r.license&&r.license.issued_to||"unknown",tier:n.valid?n.tier:"free",valid:!!n.valid,expires:n.expiresAt||null,readonly:T}}}catch{}try{const e=_(t,"cloud.json");if(y(e))return{account:JSON.parse(R(e,"utf-8")).email||"Local",tier:"free",valid:!1,expires:null,readonly:T}}catch{}return{account:"Local",tier:"free",valid:!1,expires:null,readonly:T}},"GET /ui/impact":()=>{const t=o().getDatabase(),e=r=>{try{return t.prepare(r).get()?.n||0}catch{return 0}};return{failures_blocked:e("SELECT COALESCE(SUM(occurrences),0) AS n FROM failure_patterns"),tokens_saved:e("SELECT COALESCE(SUM(estimated_tokens),0) AS n FROM token_savings_log"),memories:e("SELECT COUNT(*) AS n FROM memory_artifacts WHERE supersedes_id IS NULL"),memories_week:e("SELECT COUNT(*) AS n FROM memory_artifacts WHERE created_at >= datetime('now','-7 days')"),truths:e("SELECT COUNT(*) AS n FROM ground_truths WHERE is_current = 1"),sessions:e("SELECT COUNT(*) AS n FROM sessions"),quests_completed:e("SELECT COUNT(*) AS n FROM quests WHERE status = 'completed'"),skills:e("SELECT COUNT(*) AS n FROM skills"),projects:e("SELECT COUNT(*) AS n FROM projects")}},"GET /ui/memory":t=>{const e=o().getDatabase(),r=parseInt(String(t&&t.id||"0"),10);if(!r)return{item:null};try{return{item:e.prepare(`SELECT ma.*, p.name AS project_name
908
76
  FROM memory_artifacts ma LEFT JOIN projects p ON ma.project_id = p.id
909
- WHERE ma.id = ?`).get(id);
910
- return { item: item || null };
911
- }
912
- catch {
913
- return { item: null };
914
- }
915
- },
916
- // Registered skills, for the Skills tab (grouped client-side by category).
917
- 'GET /ui/skills': () => {
918
- const rawDb = getDb().getDatabase();
919
- try {
920
- const items = rawDb.prepare(`SELECT name, description, category, tier, usage_count, is_active
921
- FROM skills ORDER BY category COLLATE NOCASE, name COLLATE NOCASE`).all();
922
- return { items, total: items.length };
923
- }
924
- catch {
925
- return { items: [], total: 0 }; // skills table may not exist on an old DB
926
- }
927
- },
928
- // List the Wyrm homes/accounts on this machine (the switcher dropdown).
929
- 'GET /ui/homes': () => {
930
- const homes = discoverHomes();
931
- let active = getDb().getDatabasePath();
932
- try {
933
- active = _realpath(active);
934
- }
935
- catch { /* keep as-is */ }
936
- return { homes: homes.map(h => ({ ...h, active: h.dbPath === active })) };
937
- },
938
- // Switch the dashboard to another home (re-points the server's DB connection).
939
- // Only homes returned by discoverHomes() are accepted, so no arbitrary path.
940
- 'POST /ui/switch': (args) => {
941
- const target = String((args && args.path) || '');
942
- const match = discoverHomes().find(h => h.dbPath === target);
943
- if (!match)
944
- return { ok: false, error: 'unknown home' };
945
- try {
946
- switchDb(new WyrmDB(match.dbPath));
947
- return { ok: true, account: match.account, tier: match.tier };
948
- }
949
- catch (e) {
950
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
951
- }
952
- }
953
- };
954
- // ── Live Memory (v6.4 Phase 2) — SSE event stream ────────────────────────────
955
- // Long-lived `text/event-stream` response: bypasses the JSON route table and is
956
- // dispatched directly from the server handler. Catches up the backlog since the
957
- // caller's cursor, then tails new events (1s poll — better-sqlite3 has no change
958
- // feed). Resumes from `Last-Event-ID` on reconnect; 25s keep-alive comments.
959
- function handleEventStream(req, res, args) {
960
- if (!getDb().liveMemoryEnabled()) {
961
- send(res, req, { e: 'live memory disabled', disabled: 1 }, 503);
962
- return;
963
- }
964
- const project = resolveProjectRef(args.project ?? args.p);
965
- if (!project) {
966
- send(res, req, { e: 'not found' }, 404);
967
- return;
968
- }
969
- // DoS guard: cap concurrent SSE connections globally AND per-IP (each holds a
970
- // poll timer + DB query/sec) so one client can't starve live memory for everyone.
971
- const sseIp = req.socket.remoteAddress || 'unknown';
972
- if (activeStreams >= MAX_SSE_STREAMS) {
973
- send(res, req, { e: 'too many concurrent streams', retry_after: 5 }, 503);
974
- return;
975
- }
976
- if ((sseByIp.get(sseIp) ?? 0) >= MAX_SSE_PER_IP) {
977
- send(res, req, { e: 'too many concurrent streams from this client', retry_after: 5 }, 503);
978
- return;
979
- }
980
- activeStreams++;
981
- sseByIp.set(sseIp, (sseByIp.get(sseIp) ?? 0) + 1);
982
- let cursor = resolveStartCursor(req.headers['last-event-id'], args.since);
983
- // SECURITY: a REMOTE (non-loopback) subscriber may ONLY ever receive shareable
984
- // events — private (is_shared=0) events never leave the box, no matter what the
985
- // client asks for. The same-device local UI/watcher (loopback) may opt into all.
986
- const sseIsLoopback = sseIp === '127.0.0.1' || sseIp === '::1' || sseIp === '::ffff:127.0.0.1';
987
- const sharedOnly = !sseIsLoopback || args.shared === '1' || args.shared === 'true';
988
- res.writeHead(200, {
989
- ...getSecurityHeaders(req),
990
- 'Content-Type': 'text/event-stream; charset=utf-8',
991
- 'Cache-Control': 'no-cache, no-transform',
992
- 'Connection': 'keep-alive',
993
- 'X-Accel-Buffering': 'no', // defeat reverse-proxy buffering (nginx etc.)
994
- });
995
- res.write('retry: 3000\n\n'); // advise client reconnect backoff
996
- let poll;
997
- let ka;
998
- let cleaned = false;
999
- const cleanup = () => {
1000
- if (cleaned)
1001
- return; // idempotent — fires from req/res close+error, decrement exactly once
1002
- cleaned = true;
1003
- if (poll)
1004
- clearInterval(poll);
1005
- if (ka)
1006
- clearInterval(ka);
1007
- activeStreams--;
1008
- const n = (sseByIp.get(sseIp) ?? 1) - 1;
1009
- if (n <= 0)
1010
- sseByIp.delete(sseIp);
1011
- else
1012
- sseByIp.set(sseIp, n);
1013
- };
1014
- const flush = () => {
1015
- if (res.writableEnded) {
1016
- cleanup();
1017
- return;
1018
- }
1019
- try {
1020
- // eventsSince is failure-isolated at the DB layer; on a transient error
1021
- // we simply skip this tick and retry on the next.
1022
- const batch = sharedOnly
1023
- ? getDb().eventsForPush(project.id, cursor, 200)
1024
- : getDb().eventsSince(project.id, cursor, 200);
1025
- for (const ev of batch) {
1026
- cursor = ev.cursor;
1027
- res.write(sseFrame(ev));
1028
- }
1029
- }
1030
- catch { /* retry next tick */ }
1031
- };
1032
- flush(); // immediate backlog catch-up since `cursor`
1033
- poll = setInterval(flush, 1000);
1034
- ka = setInterval(() => { try {
1035
- res.write(SSE_KEEPALIVE);
1036
- }
1037
- catch { /* socket gone */ } }, 25000);
1038
- // Clear timers + free the slot on EVERY disconnect path (abrupt resets fire
1039
- // res 'error'/'close' but not always req 'close').
1040
- req.on('close', cleanup);
1041
- res.on('close', cleanup);
1042
- res.on('error', cleanup);
1043
- }
1044
- // Server with authentication
1045
- const server = http.createServer(async (req, res) => {
1046
- // Apply auth middleware - handles CORS preflight and auth validation
1047
- const authResult = authMiddleware(req, res);
1048
- if (authResult.error)
1049
- return;
1050
- try {
1051
- const url = new URL(req.url || '/', `http://localhost:${PORT}`);
1052
- // ── Read-only / public-view gate (single chokepoint) ──────────────────────
1053
- // Sits ABOVE every channel: the routes map, the regex-matched review
1054
- // approve/reject (NOT in the routes map), the SSE stream, and the /batch
1055
- // fan-out. The predicate lives in readonly-gate.ts (pure + unit-tested);
1056
- // gating by HTTP method there catches all current AND future write routes, so
1057
- // a new write route can't silently slip the gate: the lesson the grove PR taught us.
1058
- if (READONLY && readonlyBlocks(req.method || 'GET', url.pathname)) {
1059
- send(res, req, { e: 'read-only mode', readonly: 1 }, 403);
1060
- return;
1061
- }
1062
- let args;
1063
- try {
1064
- args = await body(req);
1065
- }
1066
- catch (parseErr) {
1067
- const msg = parseErr.message;
1068
- if (msg === 'Invalid JSON') {
1069
- send(res, req, { e: 'Invalid JSON body' }, 400);
1070
- return;
1071
- }
1072
- throw parseErr;
1073
- }
1074
- // Add query params
1075
- url.searchParams.forEach((v, k) => args[k] = v);
1076
- // Special case: browser dashboard — returns HTML, not JSON
1077
- // Brand asset: the silver dragon mark for the dashboard logo.
1078
- if (req.method === 'GET' && url.pathname === '/dragon-mark.svg') {
1079
- try {
1080
- if (!_dragonSvgCache && _assetExists(DRAGON_SVG_PATH))
1081
- _dragonSvgCache = _readAsset(DRAGON_SVG_PATH);
1082
- if (_dragonSvgCache) {
1083
- res.writeHead(200, { ...getSecurityHeaders(req), 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400', 'Content-Length': _dragonSvgCache.length });
1084
- res.end(_dragonSvgCache);
1085
- return;
1086
- }
1087
- }
1088
- catch { /* fall through */ }
1089
- send(res, req, { e: 'not found' }, 404);
1090
- return;
1091
- }
1092
- if (req.method === 'GET' && url.pathname === '/ui') {
1093
- sendHtml(res, req, getUIDashboardHTML());
1094
- return;
1095
- }
1096
- // Special case: Live Memory SSE — long-lived text/event-stream, not JSON
1097
- if (req.method === 'GET' && url.pathname === '/events/stream') {
1098
- handleEventStream(req, res, args);
1099
- return;
1100
- }
1101
- // Dynamic UI review routes: POST /ui/review/:id/approve|reject
1102
- const reviewMatch = url.pathname.match(/^\/ui\/review\/(\d+)\/(approve|reject)$/);
1103
- if (req.method === 'POST' && reviewMatch) {
1104
- const id = parseInt(reviewMatch[1], 10);
1105
- const action = reviewMatch[2];
1106
- const rawDb = getDb().getDatabase();
1107
- if (action === 'approve') {
1108
- rawDb.prepare('UPDATE memory_artifacts SET needs_review = 0 WHERE id = ?').run(id);
1109
- }
1110
- else {
1111
- rawDb.prepare('DELETE FROM memory_artifacts WHERE id = ?').run(id);
1112
- }
1113
- send(res, req, { ok: true });
1114
- return;
1115
- }
1116
- const key = `${req.method} ${url.pathname}`;
1117
- const handler = routes[key];
1118
- if (!handler) {
1119
- if (url.pathname === '/') {
1120
- send(res, req, {
1121
- wyrm: '3.0',
1122
- auth: getAuthStatus().requireAuth ? 'required' : 'disabled',
1123
- tip: 'GET /c for quick context'
1124
- });
1125
- return;
1126
- }
1127
- send(res, req, { e: 'not found' }, 404);
1128
- return;
1129
- }
1130
- // v7 F2 (T009): `Wyrm-Actor: agent_id[;run_id]` — the HTTP analogue of MCP
1131
- // _meta['wyrm/actor']. Parsed/validated/length-capped at the boundary
1132
- // (Article VII: a malformed header is ignored whole, never partially
1133
- // honored); the route handler runs inside the envelope's ALS context so
1134
- // every attributed write it reaches stamps agent_id/run_id. Without the
1135
- // header the env level (WYRM_AGENT_ID/WYRM_RUN_ID) still applies; with
1136
- // nothing known, columns stay NULL (reads as actor='legacy').
1137
- const actorEnvelope = resolveActorEnvelope({ header: req.headers['wyrm-actor'] });
1138
- const { result, ms } = timed(() => runWithActor(actorEnvelope, () => handler(args)));
1139
- res.setHeader('X-Time-Ms', String(ms));
1140
- send(res, req, result);
1141
- }
1142
- catch (err) {
1143
- // v7 F2 (T011/T012 + review fix): the retryable write family (SQLITE_BUSY
1144
- // and a tripped circuit breaker — both classified by retryableWriteCause)
1145
- // gets the same structured BUSY/RETRY body as the MCP dispatcher, as
1146
- // HTTP 503 + Retry-After. A daemon-writer client treats the 503 as a
1147
- // PROVEN no-commit (the write threw before committing) and fails direct;
1148
- // other HTTP consumers get a machine-readable retryable signal.
1149
- const busyCause = retryableWriteCause(err);
1150
- if (busyCause !== null) {
1151
- const body = busyErrorBody('http', busyCause);
1152
- res.setHeader('Retry-After', String(Math.ceil(body.error.retry_after_ms / 1000)));
1153
- send(res, req, body, 503);
1154
- return;
1155
- }
1156
- logger.error('Fast API request failed', {
1157
- path: req.url,
1158
- error: err.message
1159
- });
1160
- send(res, req, { e: 'Internal server error' }, 500);
1161
- }
1162
- });
1163
- // Graceful shutdown — checkpoint WAL and close DB cleanly.
1164
- // `_db?.` (not getDb()): never lazily OPEN a database just to close it.
1165
- const shutdown = () => {
1166
- server.close(() => {
1167
- try {
1168
- _db?.close();
1169
- }
1170
- catch { /* ignore */ }
1171
- process.exit(0);
1172
- });
1173
- };
1174
- process.on('SIGINT', shutdown);
1175
- process.on('SIGTERM', shutdown);
1176
- // Only auto-listen when run directly as a binary, not when imported by wyrm-cli (`wyrm serve`)
1177
- import { fileURLToPath } from 'url';
1178
- const __filename = fileURLToPath(import.meta.url);
1179
- if (process.argv[1] === __filename) {
1180
- const BIND_HOST = process.env.WYRM_BIND_HOST || '127.0.0.1';
1181
- server.listen(PORT, BIND_HOST, () => {
1182
- logger.info('Wyrm Fast API started', { port: PORT, host: BIND_HOST });
1183
- console.log(`󱅝 Wyrm Fast API on ${BIND_HOST}:${PORT}`);
1184
- console.log(` Auth: ${getAuthStatus().requireAuth ? 'required' : 'disabled'}`);
1185
- if (READONLY)
1186
- console.log(' Read-only: writes + off-box egress blocked (safe to expose)');
1187
- if (BIND_HOST !== '127.0.0.1' && BIND_HOST !== '::1' && BIND_HOST !== 'localhost') {
1188
- logger.warn('Wyrm Fast API bound to a NON-loopback host — reachable beyond localhost; ensure auth is required', { host: BIND_HOST });
1189
- console.log(` ⚠ bound to ${BIND_HOST}: reachable beyond localhost — make sure auth is required`);
1190
- if (!READONLY)
1191
- console.log(' ⚠ not in read-only mode; set WYRM_UI_READONLY=1 for a public bind');
1192
- }
1193
- });
1194
- }
1195
- export { server };
1196
- //# sourceMappingURL=http-fast.js.map
77
+ WHERE ma.id = ?`).get(r)||null}}catch{return{item:null}}},"GET /ui/skills":()=>{const t=o().getDatabase();try{const e=t.prepare(`SELECT name, description, category, tier, usage_count, is_active
78
+ FROM skills ORDER BY category COLLATE NOCASE, name COLLATE NOCASE`).all();return{items:e,total:e.length}}catch{return{items:[],total:0}}},"GET /ui/homes":()=>{const t=$();let e=o().getDatabasePath();try{e=G(e)}catch{}return{homes:t.map(r=>({...r,active:r.dbPath===e}))}},"POST /ui/switch":t=>{const e=String(t&&t.path||""),r=$().find(n=>n.dbPath===e);if(!r)return{ok:!1,error:"unknown home"};try{return gt(new F(r.dbPath)),{ok:!0,account:r.account,tier:r.tier}}catch(n){return{ok:!1,error:n instanceof Error?n.message:String(n)}}}};function Lt(t,e,r){if(!o().liveMemoryEnabled()){h(e,t,{e:"live memory disabled",disabled:1},503);return}const n=P(r.project??r.p);if(!n){h(e,t,{e:"not found"},404);return}const s=t.socket.remoteAddress||"unknown";if(N>=St){h(e,t,{e:"too many concurrent streams",retry_after:5},503);return}if((O.get(s)??0)>=_t){h(e,t,{e:"too many concurrent streams from this client",retry_after:5},503);return}N++,O.set(s,(O.get(s)??0)+1);let a=Y(t.headers["last-event-id"],r.since);const c=!(s==="127.0.0.1"||s==="::1"||s==="::ffff:127.0.0.1")||r.shared==="1"||r.shared==="true";e.writeHead(200,{...L(t),"Content-Type":"text/event-stream; charset=utf-8","Cache-Control":"no-cache, no-transform",Connection:"keep-alive","X-Accel-Buffering":"no"}),e.write(`retry: 3000
79
+
80
+ `);let i,l,m=!1;const d=()=>{if(m)return;m=!0,i&&clearInterval(i),l&&clearInterval(l),N--;const f=(O.get(s)??1)-1;f<=0?O.delete(s):O.set(s,f)},p=()=>{if(e.writableEnded){d();return}try{const f=c?o().eventsForPush(n.id,a,200):o().eventsSince(n.id,a,200);for(const k of f)a=k.cursor,e.write(dt(k))}catch{}};p(),i=setInterval(p,1e3),l=setInterval(()=>{try{e.write(mt)}catch{}},25e3),t.on("close",d),e.on("close",d),e.on("error",d)}const w=Q.createServer(async(t,e)=>{if(!z(t,e).error)try{const n=new URL(t.url||"/",`http://localhost:${C}`);if(T&&pt(t.method||"GET",n.pathname)){h(e,t,{e:"read-only mode",readonly:1},403);return}let s;try{s=await vt(t)}catch(d){if(d.message==="Invalid JSON"){h(e,t,{e:"Invalid JSON body"},400);return}throw d}if(n.searchParams.forEach((d,p)=>s[p]=d),t.method==="GET"&&n.pathname==="/dragon-mark.svg"){try{if(!v&&y(x)&&(v=R(x)),v){e.writeHead(200,{...L(t),"Content-Type":"image/svg+xml","Cache-Control":"public, max-age=86400","Content-Length":v.length}),e.end(v);return}}catch{}h(e,t,{e:"not found"},404);return}if(t.method==="GET"&&n.pathname==="/ui"){Rt(e,t,nt());return}if(t.method==="GET"&&n.pathname==="/events/stream"){Lt(t,e,s);return}const a=n.pathname.match(/^\/ui\/review\/(\d+)\/(approve|reject)$/);if(t.method==="POST"&&a){const d=parseInt(a[1],10),p=a[2],f=o().getDatabase();p==="approve"?f.prepare("UPDATE memory_artifacts SET needs_review = 0 WHERE id = ?").run(d):f.prepare("DELETE FROM memory_artifacts WHERE id = ?").run(d),h(e,t,{ok:!0});return}const u=`${t.method} ${n.pathname}`,c=J[u];if(!c){if(n.pathname==="/"){h(e,t,{wyrm:"3.0",auth:D().requireAuth?"required":"disabled",tip:"GET /c for quick context"});return}h(e,t,{e:"not found"},404);return}const i=ft({header:t.headers["wyrm-actor"]}),{result:l,ms:m}=H(()=>Et(i,()=>c(s)));e.setHeader("X-Time-Ms",String(m)),h(e,t,l)}catch(n){const s=ut(n);if(s!==null){const a=lt("http",s);e.setHeader("Retry-After",String(Math.ceil(a.error.retry_after_ms/1e3))),h(e,t,a,503);return}A.error("Fast API request failed",{path:t.url,error:n.message}),h(e,t,{e:"Internal server error"},500)}}),q=()=>{w.close(()=>{try{g?.close()}catch{}process.exit(0)})};process.on("SIGINT",q),process.on("SIGTERM",q);import{fileURLToPath as Ct}from"url";const bt=Ct(import.meta.url);if(process.argv[1]===bt){const t=process.env.WYRM_BIND_HOST||"127.0.0.1";w.listen(C,t,()=>{A.info("Wyrm Fast API started",{port:C,host:t}),console.log(`\u{F115D} Wyrm Fast API on ${t}:${C}`),console.log(` Auth: ${D().requireAuth?"required":"disabled"}`),T&&console.log(" Read-only: writes + off-box egress blocked (safe to expose)"),t!=="127.0.0.1"&&t!=="::1"&&t!=="localhost"&&(A.warn("Wyrm Fast API bound to a NON-loopback host \u2014 reachable beyond localhost; ensure auth is required",{host:t}),console.log(` \u26A0 bound to ${t}: reachable beyond localhost \u2014 make sure auth is required`),T||console.log(" \u26A0 not in read-only mode; set WYRM_UI_READONLY=1 for a public bind"))})}export{w as server};