wyrm-mcp 7.3.2 → 7.3.3

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