wyrm-mcp 7.3.2 → 7.4.0

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