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
@@ -1,9 +1,68 @@
1
- import{emitEvent as b}from"./events.js";import{sanitizeFtsQuery as L,buildFtsMatchQuery as k}from"./security.js";import{getActor as I}from"./handlers/boundary.js";import{buildPage as T,DEFAULT_PAGE_SIZE as O}from"./keyset.js";import{rerankConfigured as F,rerankCandidates as $}from"./rerank.js";const w=40,H=10,x=.3;class j{db;vectorStore;setVectorStore(t){this.vectorStore=t}constructor(t){this.db=t}indexArtifact(t){if(!this.vectorStore)return;const e=`${t.problem}${t.validated_fix?" "+t.validated_fix:""}`.slice(0,2e3);this.vectorStore.addVector(e,"artifact",t.id,t.project_id).catch(()=>{})}add(t,e){const n=e.tags?.length?e.tags.join(","):null,s=I(),a=this.db.prepare(`
1
+ /**
2
+ * Wyrm Memory Artifacts — Distilled knowledge for AI intelligence amplification.
3
+ *
4
+ * Stores problem-solution records, lessons, patterns, and anti-patterns that
5
+ * help AI models recall what worked in the past and apply it to new tasks.
6
+ *
7
+ * @copyright 2026 Ghost Protocol (Pvt) Ltd.
8
+ * @license Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
9
+ */
10
+ import { emitEvent } from './events.js';
11
+ import { sanitizeFtsQuery, buildFtsMatchQuery } from './security.js';
12
+ import { getActor } from './handlers/boundary.js';
13
+ import { buildPage, DEFAULT_PAGE_SIZE } from './keyset.js';
14
+ import { rerankConfigured, rerankCandidates } from './rerank.js';
15
+ /** Maximum artifacts returned from a recall query (before reranking) */
16
+ const MAX_CANDIDATES = 40;
17
+ /** Maximum artifacts included in a context brief */
18
+ const MAX_BRIEF_ITEMS = 10;
19
+ /** Minimum confidence to include in auto-generated briefs */
20
+ const MIN_BRIEF_CONFIDENCE = 0.3;
21
+ export class MemoryArtifacts {
22
+ db;
23
+ /** Optional dense-vector index — when set, `recallHybrid` fuses FTS + vectors (RRF). */
24
+ vectorStore;
25
+ setVectorStore(vs) { this.vectorStore = vs; }
26
+ constructor(db) {
27
+ this.db = db;
28
+ }
29
+ /** Best-effort dense-vector index of an ACTIVE artifact. Fire-and-forget: embedding
30
+ * is async + may be unavailable (Ollama down) — it must never block or break add(). */
31
+ indexArtifact(a) {
32
+ if (!this.vectorStore)
33
+ return;
34
+ const text = `${a.problem}${a.validated_fix ? ' ' + a.validated_fix : ''}`.slice(0, 2000);
35
+ void this.vectorStore.addVector(text, 'artifact', a.id, a.project_id).catch(() => { });
36
+ }
37
+ // ==================== CRUD ====================
38
+ add(projectId, opts) {
39
+ const tags = opts.tags?.length ? opts.tags.join(',') : null;
40
+ // v7 F2 (T009): stamp the writing actor (NULL outside a fleet context).
41
+ // `created_by` keeps its 6.x display semantics untouched.
42
+ const ambient = getActor();
43
+ const result = this.db.prepare(`
2
44
  INSERT INTO memory_artifacts
3
45
  (project_id, kind, problem, constraints, validated_fix, why_it_worked,
4
46
  outcome, source_session_id, tags, confidence, needs_review, created_by, agent_id, run_id)
5
47
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6
- `).run(t,e.kind,e.problem.trim(),e.constraints?.trim()??null,e.validatedFix?.trim()??null,e.whyItWorked?.trim()??null,e.outcome??"neutral",e.sourceSessionId??null,n,e.confidence??1,e.needsReview??0,e.createdBy??"local",s.agent_id,s.run_id),i=this.get(a.lastInsertRowid);return e.needsReview||(b(this.db,{projectId:t,kind:"capture",refTable:"memory_artifacts",refId:i.id,isShared:!!i.is_shared}),this.indexArtifact(i)),i}get(t){return this.db.prepare("SELECT * FROM memory_artifacts WHERE id = ?").get(t)??null}update(t,e){return this.db.prepare(`
48
+ `).run(projectId, opts.kind, opts.problem.trim(), opts.constraints?.trim() ?? null, opts.validatedFix?.trim() ?? null, opts.whyItWorked?.trim() ?? null, opts.outcome ?? 'neutral', opts.sourceSessionId ?? null, tags, opts.confidence ?? 1.0, opts.needsReview ?? 0, opts.createdBy ?? 'local', ambient.agent_id, ambient.run_id);
49
+ const artifact = this.get(result.lastInsertRowid);
50
+ // Live Memory: emit ONLY for active memories. Review-queue candidates
51
+ // (harvest dumps 1000s with needsReview=1) must not flood the event log.
52
+ if (!opts.needsReview) {
53
+ emitEvent(this.db, {
54
+ projectId, kind: 'capture', refTable: 'memory_artifacts', refId: artifact.id,
55
+ isShared: !!artifact.is_shared,
56
+ });
57
+ this.indexArtifact(artifact); // best-effort dense index for hybrid recall
58
+ }
59
+ return artifact;
60
+ }
61
+ get(id) {
62
+ return this.db.prepare('SELECT * FROM memory_artifacts WHERE id = ?').get(id) ?? null;
63
+ }
64
+ update(id, updates) {
65
+ this.db.prepare(`
7
66
  UPDATE memory_artifacts SET
8
67
  confidence = COALESCE(?, confidence),
9
68
  validated_fix = COALESCE(?, validated_fix),
@@ -12,27 +71,336 @@ import{emitEvent as b}from"./events.js";import{sanitizeFtsQuery as L,buildFtsMat
12
71
  needs_review = COALESCE(?, needs_review),
13
72
  updated_at = datetime('now')
14
73
  WHERE id = ?
15
- `).run(e.confidence??null,e.validated_fix??null,e.why_it_worked??null,e.last_validated_at??null,e.needs_review??null,t),this.get(t)}markSuperseded(t,e){this.db.prepare("UPDATE memory_artifacts SET supersedes_id = ?, updated_at = datetime('now') WHERE id = ?").run(e,t)}recordFeedback(t,e){const n=e?"reuse_success_count":"reuse_failure_count";this.db.prepare(`
74
+ `).run(updates.confidence ?? null, updates.validated_fix ?? null, updates.why_it_worked ?? null, updates.last_validated_at ?? null, updates.needs_review ?? null, id);
75
+ return this.get(id);
76
+ }
77
+ /** Mark an artifact as superseded by a newer/better one. */
78
+ markSuperseded(oldId, newId) {
79
+ this.db.prepare('UPDATE memory_artifacts SET supersedes_id = ?, updated_at = datetime(\'now\') WHERE id = ?').run(newId, oldId);
80
+ }
81
+ /** Record that an artifact was used and whether it helped. */
82
+ recordFeedback(id, success) {
83
+ const col = success ? 'reuse_success_count' : 'reuse_failure_count';
84
+ this.db.prepare(`
16
85
  UPDATE memory_artifacts SET
17
86
  reuse_count = reuse_count + 1,
18
- ${n} = ${n} + 1,
87
+ ${col} = ${col} + 1,
19
88
  confidence = CASE
20
89
  WHEN ? THEN MIN(1.0, confidence + 0.05)
21
90
  ELSE MAX(0.0, confidence - 0.1)
22
91
  END,
23
92
  updated_at = datetime('now')
24
93
  WHERE id = ?
25
- `).run(e?1:0,t)}recall(t,e,n={}){const s=n.limit??10,a=n.minConfidence??0,i=this.searchByFts(t,e,w,n.kind,a),l=this.searchByTags(t,e,w,n.kind,a),r=new Set,m=[];i.forEach((o,d)=>{r.add(o.id),m.push({artifact:o,inFts:!0,inTag:!1,ftsRank:d})});for(const o of l)if(!r.has(o.id))r.add(o.id),m.push({artifact:o,inFts:!1,inTag:!0});else{const d=m.find(N=>N.artifact.id===o.id);d&&(d.inTag=!0)}return m.filter(({artifact:o})=>o.confidence>=a).map(({artifact:o,inFts:d,inTag:N,ftsRank:S})=>{let f;if(d){const g=1-(S??0)/(i.length+1);f=(N?.7:.55)+.45*g}else f=.4;f*=o.confidence,o.outcome==="positive"&&(f*=1.1);const A=(Date.now()-new Date(o.last_validated_at).getTime())/(1e3*60*60*24),C=Math.max(.5,1-A/180);return f*=C,{artifact:o,relevance_score:Math.min(1,f),match_type:d&&N?"both":d?"fts":"tag"}}).sort((o,d)=>d.relevance_score-o.relevance_score).slice(0,s)}async recallHybrid(t,e,n={}){const s=n.limit??10,a=n.minConfidence??0,i=n.rerank===!0&&F(),l=Math.min(Math.max(s*2,20),50),r=i?l:s;if(!this.vectorStore)return this.recall(t,e,n);const m=Math.max(50,r*5),_=this.searchByFts(t,e,m,n.kind,a);let o=[];try{o=(await this.vectorStore.search(e,m,t,["artifact"])).map(p=>({id:p.content_id,s:p.similarity}))}catch{}if(o.length===0)return this.recall(t,e,n);const d=60,N=Number(process.env.WYRM_RERANK_ALPHA),S=Number.isFinite(N)?Math.max(0,Math.min(1,N)):.7,f=new Map;if(process.env.WYRM_RERANK_FUSION==="rrf")_.forEach((u,p)=>f.set(u.id,(f.get(u.id)??0)+1/(d+p+1))),o.forEach((u,p)=>f.set(u.id,(f.get(u.id)??0)+1/(d+p+1)));else{const u=_.length+1;_.forEach((y,M)=>f.set(y.id,(f.get(y.id)??0)+(1-S)*(1-M/u)));const p=o.map(y=>y.s),E=Math.min(...p),v=Math.max(...p),D=v-E;o.forEach(y=>f.set(y.id,(f.get(y.id)??0)+S*(D>1e-9?(y.s-E)/D:1)))}const A=new Set(_.map(u=>u.id)),C=new Set(o.map(u=>u.id)),g=new Map(_.map(u=>[u.id,u])),c=[...f.entries()].sort((u,p)=>p[1]-u[1]),h=c[0]?.[1]??1,R=[];for(const[u,p]of c){if(R.length>=r)break;const E=g.get(u)??this.get(u)??void 0;if(!E||E.confidence<a||E.needs_review===1||E.supersedes_id!=null||n.kind&&E.kind!==n.kind)continue;const v=A.has(u)&&C.has(u)?"hybrid":C.has(u)?"vector":"fts";R.push({artifact:E,relevance_score:Math.min(1,p/h),match_type:v})}return i&&R.length>0?(await this.rerankResults(e,R)).slice(0,s):R}async rerankResults(t,e){const n=e.map(r=>({id:r.artifact.id,text:`${r.artifact.problem}${r.artifact.validated_fix?" "+r.artifact.validated_fix:""}`.slice(0,2e3)})),s=await $(t,n);if(!s)return e;const a=new Map(e.map(r=>[r.artifact.id,r])),i=[],l=s.length;return s.forEach((r,m)=>{const _=a.get(r.id);_&&i.push({..._,relevance_score:l>0?(l-m)/l:_.relevance_score})}),i}async recallHybridGlobal(t,e={}){const n=e.limit??10,s=e.minConfidence??0,a=Math.max(50,n*5),i=this.searchByFtsGlobal(t,a,e.kind,s),l=()=>i.slice(0,n).map((c,h)=>({artifact:c,relevance_score:1-h/(i.length+1),match_type:"fts"}));if(!this.vectorStore)return l();let r=[];try{r=(await this.vectorStore.search(t,a,void 0,["artifact"])).map(h=>({id:h.content_id,s:h.similarity}))}catch{}if(r.length===0)return l();const m=60,_=Number(process.env.WYRM_RERANK_ALPHA),o=Number.isFinite(_)?Math.max(0,Math.min(1,_)):.7,d=new Map;if(process.env.WYRM_RERANK_FUSION==="rrf")i.forEach((c,h)=>d.set(c.id,(d.get(c.id)??0)+1/(m+h+1))),r.forEach((c,h)=>d.set(c.id,(d.get(c.id)??0)+1/(m+h+1)));else{const c=i.length+1;i.forEach((E,v)=>d.set(E.id,(d.get(E.id)??0)+(1-o)*(1-v/c)));const h=r.map(E=>E.s),R=Math.min(...h),u=Math.max(...h),p=u-R;r.forEach(E=>d.set(E.id,(d.get(E.id)??0)+o*(p>1e-9?(E.s-R)/p:1)))}const N=new Set(i.map(c=>c.id)),S=new Set(r.map(c=>c.id)),f=new Map(i.map(c=>[c.id,c])),A=[...d.entries()].sort((c,h)=>h[1]-c[1]),C=A[0]?.[1]??1,g=[];for(const[c,h]of A){if(g.length>=n)break;const R=f.get(c)??this.get(c)??void 0;if(!R||R.confidence<s||R.needs_review===1||R.supersedes_id!=null||e.kind&&R.kind!==e.kind)continue;const u=N.has(c)&&S.has(c)?"hybrid":S.has(c)?"vector":"fts";g.push({artifact:R,relevance_score:Math.min(1,h/C),match_type:u})}return g}searchByFtsGlobal(t,e,n,s=0){if(!this.db)return[];let a="";try{const r=L(t);a=r?k(r):""}catch{return[]}if(!a)return[];const i=n?"AND a.kind = ?":"",l=[a,s];n&&l.push(n),l.push(e);try{return this.db.prepare(`
94
+ `).run(success ? 1 : 0, id);
95
+ }
96
+ // ==================== RETRIEVAL ====================
97
+ /**
98
+ * 2-stage retrieval: FTS candidates → sort by weighted relevance.
99
+ * Excludes superseded artifacts and those below minConfidence.
100
+ */
101
+ recall(projectId, query, opts = {}) {
102
+ const limit = opts.limit ?? 10;
103
+ const minConf = opts.minConfidence ?? 0.0;
104
+ // Stage 1: FTS candidates
105
+ const ftsCandidates = this.searchByFts(projectId, query, MAX_CANDIDATES, opts.kind, minConf);
106
+ // Stage 2: Tag candidates (exact tag matches not covered by FTS)
107
+ const tagCandidates = this.searchByTags(projectId, query, MAX_CANDIDATES, opts.kind, minConf);
108
+ // Merge and dedupe by id
109
+ const seen = new Set();
110
+ const merged = [];
111
+ // ftsCandidates arrive in bm25 order (best first) — keep the rank so the score
112
+ // below preserves it instead of flattening every FTS hit to one value.
113
+ ftsCandidates.forEach((a, i) => {
114
+ seen.add(a.id);
115
+ merged.push({ artifact: a, inFts: true, inTag: false, ftsRank: i });
116
+ });
117
+ for (const a of tagCandidates) {
118
+ if (!seen.has(a.id)) {
119
+ seen.add(a.id);
120
+ merged.push({ artifact: a, inFts: false, inTag: true });
121
+ }
122
+ else {
123
+ const existing = merged.find(m => m.artifact.id === a.id);
124
+ if (existing)
125
+ existing.inTag = true;
126
+ }
127
+ }
128
+ // Score and rerank — also enforce minConf as a safety net
129
+ const results = merged
130
+ .filter(({ artifact }) => artifact.confidence >= minConf)
131
+ .map(({ artifact, inFts, inTag, ftsRank }) => {
132
+ // bm25 rank is the PRIMARY signal: earlier FTS candidates (lower rank) score
133
+ // higher, so the query's relevance ordering survives the merge/re-sort.
134
+ let relevance;
135
+ if (inFts) {
136
+ const rankScore = 1 - (ftsRank ?? 0) / (ftsCandidates.length + 1); // ~1.0 best → ~0 worst
137
+ relevance = (inTag ? 0.7 : 0.55) + 0.45 * rankScore; // 0.55..1.0 for FTS hits
138
+ }
139
+ else {
140
+ relevance = 0.4; // tag-only match
141
+ }
142
+ // Boost by confidence
143
+ relevance *= artifact.confidence;
144
+ // Boost positive outcomes slightly
145
+ if (artifact.outcome === 'positive')
146
+ relevance *= 1.1;
147
+ // Freshness: decay slowly over 180 days
148
+ const daysSinceValidated = (Date.now() - new Date(artifact.last_validated_at).getTime()) / (1000 * 60 * 60 * 24);
149
+ const freshnessScore = Math.max(0.5, 1.0 - daysSinceValidated / 180);
150
+ relevance *= freshnessScore;
151
+ return {
152
+ artifact,
153
+ relevance_score: Math.min(1.0, relevance),
154
+ match_type: (inFts && inTag ? 'both' : inFts ? 'fts' : 'tag'),
155
+ };
156
+ });
157
+ return results.sort((a, b) => b.relevance_score - a.relevance_score).slice(0, limit);
158
+ }
159
+ /**
160
+ * Hybrid recall: FTS (bm25) ⊕ dense vectors (cosine), fused by convex score-blend
161
+ * (default, α=0.7) or RRF (WYRM_RERANK_FUSION=rrf). Gracefully degrades to lexical
162
+ * `recall()` when no vector store is wired or the embedding provider is unavailable.
163
+ * Async (embedding the query is async). Measured on real LoCoMo (nomic-embed-text,
164
+ * local): FTS recall@10 59.9% → hybrid+RRF 72.2% → hybrid+convex recall@5 62.1% / @10 72.7%.
165
+ */
166
+ async recallHybrid(projectId, query, opts = {}) {
167
+ const limit = opts.limit ?? 10;
168
+ const minConf = opts.minConfidence ?? 0.0;
169
+ // Rerank stage (subsystem wyrm-routing-rerank): opt-in AND model-configured.
170
+ // When active, fuse a DEEPER top-K so the cross-encoder has real candidates
171
+ // to reorder, then keep the top-N (limit) after reranking. DEFAULT false +
172
+ // no model ⇒ identical to the historic fusion-only path (no behavior drift).
173
+ const doRerank = opts.rerank === true && rerankConfigured();
174
+ // Feed the cross-encoder a genuinely DEEPER candidate set than `limit`: 2× depth
175
+ // at every limit, floor 20 for small limits, hard cap 50 for latency. (A plain
176
+ // max(limit, min(20, limit*4)) collapsed to `limit` for limit>=20, giving the
177
+ // reranker nothing extra to reorder.)
178
+ const RERANK_K = Math.min(Math.max(limit * 2, 20), 50); // top-K fed to the cross-encoder
179
+ const fuseLimit = doRerank ? RERANK_K : limit;
180
+ if (!this.vectorStore)
181
+ return this.recall(projectId, query, opts); // no vectors → lexical
182
+ // Candidate depth 50 (LoCoMo-grid-tuned): convex fusion tolerates depth where
183
+ // RRF dilutes — raising RRF's depth actually regressed recall@10 in the sweep.
184
+ const CAND = Math.max(50, fuseLimit * 5);
185
+ const ftsCands = this.searchByFts(projectId, query, CAND, opts.kind, minConf);
186
+ let vecScored = [];
187
+ try {
188
+ const hits = await this.vectorStore.search(query, CAND, projectId, ['artifact']);
189
+ vecScored = hits.map((h) => ({ id: h.content_id, s: h.similarity })); // keep cosine for convex fusion
190
+ }
191
+ catch { /* provider down → fall through to FTS-only */ }
192
+ if (vecScored.length === 0)
193
+ return this.recall(projectId, query, opts); // nothing indexed yet
194
+ // Fusion. DEFAULT = convex score-blend: α·(min-max-normalized cosine) +
195
+ // (1-α)·(rank-normalized FTS). Won the LoCoMo grid (recall@5 60.3%→62.1%,
196
+ // recall@10 →72.7% at α=0.7, cand=50) vs RRF. WYRM_RERANK_FUSION=rrf falls back.
197
+ const RRF_K = 60;
198
+ const rawAlpha = Number(process.env.WYRM_RERANK_ALPHA); // dense-vector weight
199
+ const ALPHA = Number.isFinite(rawAlpha) ? Math.max(0, Math.min(1, rawAlpha)) : 0.7; // NaN / out-of-range / "0.7x" → 0.7
200
+ const score = new Map();
201
+ if (process.env.WYRM_RERANK_FUSION === 'rrf') {
202
+ ftsCands.forEach((a, r) => score.set(a.id, (score.get(a.id) ?? 0) + 1 / (RRF_K + r + 1)));
203
+ vecScored.forEach((x, r) => score.set(x.id, (score.get(x.id) ?? 0) + 1 / (RRF_K + r + 1)));
204
+ }
205
+ else {
206
+ const fn = ftsCands.length + 1; // FTS has no bm25 score exposed here → rank-normalize
207
+ ftsCands.forEach((a, r) => score.set(a.id, (score.get(a.id) ?? 0) + (1 - ALPHA) * (1 - r / fn)));
208
+ const ss = vecScored.map((x) => x.s), lo = Math.min(...ss), hi = Math.max(...ss), span = hi - lo;
209
+ vecScored.forEach((x) => score.set(x.id, (score.get(x.id) ?? 0) + ALPHA * (span > 1e-9 ? (x.s - lo) / span : 1)));
210
+ }
211
+ const ftsIds = new Set(ftsCands.map((a) => a.id));
212
+ const vecIds = new Set(vecScored.map((x) => x.id));
213
+ const byId = new Map(ftsCands.map((a) => [a.id, a]));
214
+ const ranked = [...score.entries()].sort((a, b) => b[1] - a[1]);
215
+ const top = ranked[0]?.[1] ?? 1;
216
+ const out = [];
217
+ for (const [id, s] of ranked) {
218
+ // Build up to fuseLimit candidates: == limit on the fusion-only path
219
+ // (unchanged), == the deeper top-K when the rerank stage will reorder.
220
+ if (out.length >= fuseLimit)
221
+ break;
222
+ const art = byId.get(id) ?? this.get(id) ?? undefined;
223
+ if (!art)
224
+ continue;
225
+ // Vector-only hits bypass the FTS WHERE-clause, so enforce the same filters here.
226
+ if (art.confidence < minConf || art.needs_review === 1 || art.supersedes_id != null)
227
+ continue;
228
+ if (opts.kind && art.kind !== opts.kind)
229
+ continue;
230
+ const match_type = ftsIds.has(id) && vecIds.has(id) ? 'hybrid' : vecIds.has(id) ? 'vector' : 'fts';
231
+ out.push({ artifact: art, relevance_score: Math.min(1, s / top), match_type });
232
+ }
233
+ // Optional cross-encoder rerank → top-N. Graceful: rerankCandidates returns
234
+ // null on any reranker error (model unset/endpoint down/timeout/malformed),
235
+ // in which case the fusion order is kept verbatim. Never throws, never hangs.
236
+ if (doRerank && out.length > 0) {
237
+ const reordered = await this.rerankResults(query, out);
238
+ return reordered.slice(0, limit);
239
+ }
240
+ return out;
241
+ }
242
+ /**
243
+ * Apply the optional cross-encoder rerank stage to a fused result set.
244
+ * Re-scores each artifact against the query with bge-reranker-v2-m3 (via
245
+ * rerank.ts) and returns the results best-first. Falls back to the input
246
+ * order (unchanged) on ANY reranker failure — the rerank.ts resilience
247
+ * contract: this is strictly no-worse-than-fusion and never throws/hangs.
248
+ * The returned `relevance_score`s are renormalized to [0,1] best-first so
249
+ * downstream consumers keep their monotone ordering semantics.
250
+ */
251
+ async rerankResults(query, fused) {
252
+ const candidates = fused.map((r) => ({
253
+ id: r.artifact.id,
254
+ // Match the bi-encoder index text (indexArtifact): problem [+ fix].
255
+ text: `${r.artifact.problem}${r.artifact.validated_fix ? ' ' + r.artifact.validated_fix : ''}`.slice(0, 2000),
256
+ }));
257
+ const reranked = await rerankCandidates(query, candidates);
258
+ if (!reranked)
259
+ return fused; // reranker unavailable/errored → fusion order
260
+ const byId = new Map(fused.map((r) => [r.artifact.id, r]));
261
+ const out = [];
262
+ const n = reranked.length;
263
+ reranked.forEach((c, i) => {
264
+ const r = byId.get(c.id);
265
+ if (!r)
266
+ return;
267
+ // Renormalize to a strictly-descending [0,1] score so the rerank order
268
+ // is reflected in relevance_score (the raw cross-encoder scores are
269
+ // unbounded/logit-scale and not comparable to the fusion scale).
270
+ out.push({ ...r, relevance_score: n > 0 ? (n - i) / n : r.relevance_score });
271
+ });
272
+ return out;
273
+ }
274
+ /**
275
+ * GLOBAL hybrid recall — the same convex FTS⊕vector fusion as {@link recallHybrid}
276
+ * but across EVERY project (no project scope), for cross-fleet surfaces that must
277
+ * "think with all of Wyrm" (e.g. the phone launcher's Ember grounding). The lexical
278
+ * leg uses the OR-joined {@link buildFtsMatchQuery} so a natural-language question
279
+ * returns relevant rows instead of the empty set an AND-joined MATCH produces.
280
+ * Degrades to global lexical recall when no vector store is wired or the provider
281
+ * is unavailable. Async (embedding the query is async).
282
+ */
283
+ async recallHybridGlobal(query, opts = {}) {
284
+ const limit = opts.limit ?? 10;
285
+ const minConf = opts.minConfidence ?? 0.0;
286
+ const CAND = Math.max(50, limit * 5);
287
+ const ftsCands = this.searchByFtsGlobal(query, CAND, opts.kind, minConf);
288
+ const lexicalOnly = () => ftsCands.slice(0, limit).map((art, i) => ({
289
+ artifact: art,
290
+ relevance_score: 1 - i / (ftsCands.length + 1),
291
+ match_type: 'fts',
292
+ }));
293
+ if (!this.vectorStore)
294
+ return lexicalOnly();
295
+ let vecScored = [];
296
+ try {
297
+ const hits = await this.vectorStore.search(query, CAND, undefined, ['artifact']); // projectId omitted → global
298
+ vecScored = hits.map((h) => ({ id: h.content_id, s: h.similarity }));
299
+ }
300
+ catch { /* provider down → FTS-only */ }
301
+ if (vecScored.length === 0)
302
+ return lexicalOnly();
303
+ // Convex score-blend — identical fusion/α to recallHybrid, just project-agnostic.
304
+ const RRF_K = 60;
305
+ const rawAlpha = Number(process.env.WYRM_RERANK_ALPHA);
306
+ const ALPHA = Number.isFinite(rawAlpha) ? Math.max(0, Math.min(1, rawAlpha)) : 0.7;
307
+ const score = new Map();
308
+ if (process.env.WYRM_RERANK_FUSION === 'rrf') {
309
+ ftsCands.forEach((a, r) => score.set(a.id, (score.get(a.id) ?? 0) + 1 / (RRF_K + r + 1)));
310
+ vecScored.forEach((x, r) => score.set(x.id, (score.get(x.id) ?? 0) + 1 / (RRF_K + r + 1)));
311
+ }
312
+ else {
313
+ const fn = ftsCands.length + 1;
314
+ ftsCands.forEach((a, r) => score.set(a.id, (score.get(a.id) ?? 0) + (1 - ALPHA) * (1 - r / fn)));
315
+ const ss = vecScored.map((x) => x.s), lo = Math.min(...ss), hi = Math.max(...ss), span = hi - lo;
316
+ vecScored.forEach((x) => score.set(x.id, (score.get(x.id) ?? 0) + ALPHA * (span > 1e-9 ? (x.s - lo) / span : 1)));
317
+ }
318
+ const ftsIds = new Set(ftsCands.map((a) => a.id));
319
+ const vecIds = new Set(vecScored.map((x) => x.id));
320
+ const byId = new Map(ftsCands.map((a) => [a.id, a]));
321
+ const ranked = [...score.entries()].sort((a, b) => b[1] - a[1]);
322
+ const top = ranked[0]?.[1] ?? 1;
323
+ const out = [];
324
+ for (const [id, s] of ranked) {
325
+ if (out.length >= limit)
326
+ break;
327
+ const art = byId.get(id) ?? this.get(id) ?? undefined;
328
+ if (!art)
329
+ continue;
330
+ if (art.confidence < minConf || art.needs_review === 1 || art.supersedes_id != null)
331
+ continue;
332
+ if (opts.kind && art.kind !== opts.kind)
333
+ continue;
334
+ const match_type = ftsIds.has(id) && vecIds.has(id) ? 'hybrid' : vecIds.has(id) ? 'vector' : 'fts';
335
+ out.push({ artifact: art, relevance_score: Math.min(1, s / top), match_type });
336
+ }
337
+ return out;
338
+ }
339
+ /** Global (cross-project) lexical FTS candidate fetch — like {@link searchByFts}
340
+ * without the `project_id` filter. OR-joins terms so conversational queries match. */
341
+ searchByFtsGlobal(query, limit, kind, minConf = 0.0) {
342
+ if (!this.db)
343
+ return [];
344
+ let match = '';
345
+ try {
346
+ const sanitized = sanitizeFtsQuery(query);
347
+ match = sanitized ? buildFtsMatchQuery(sanitized) : '';
348
+ }
349
+ catch {
350
+ return [];
351
+ }
352
+ if (!match)
353
+ return [];
354
+ const kindClause = kind ? 'AND a.kind = ?' : '';
355
+ const params = [match, minConf];
356
+ if (kind)
357
+ params.push(kind);
358
+ params.push(limit);
359
+ try {
360
+ return this.db.prepare(`
26
361
  SELECT a.* FROM memory_artifacts a
27
362
  JOIN memory_artifacts_fts ON a.id = memory_artifacts_fts.rowid
28
363
  WHERE memory_artifacts_fts MATCH ?
29
364
  AND a.confidence >= ?
30
365
  AND a.supersedes_id IS NULL
31
366
  AND a.needs_review = 0
32
- ${i}
367
+ ${kindClause}
33
368
  ORDER BY rank, a.confidence DESC
34
369
  LIMIT ?
35
- `).all(...l)}catch{return[]}}searchByFts(t,e,n,s,a=0){let i="";try{const m=L(e);i=m?k(m):""}catch{return this.listRecent(t,n,s)}if(!i)return this.listRecent(t,n,s);const l=s?"AND a.kind = ?":"",r=[i,t,a];s&&r.push(s),r.push(n);try{return this.db.prepare(`
370
+ `).all(...params);
371
+ }
372
+ catch {
373
+ return [];
374
+ }
375
+ }
376
+ searchByFts(projectId, query, limit, kind, minConf = 0.0) {
377
+ // Build a SAFE FTS5 MATCH query (OR-joined quoted terms). The old code passed
378
+ // the raw natural-language query — punctuation like "?" made FTS5 throw, so EVERY
379
+ // recall silently fell back to listRecent() (recency, not relevance). Use the same
380
+ // helpers the rest of the search surface uses; ORDER BY rank gives bm25 ranking.
381
+ // sanitizeFtsQuery THROWS on an empty-after-sanitization query (e.g. an
382
+ // operator-/punctuation-only input). That must degrade to recency, never
383
+ // throw out of recall()/context_build() — guard it.
384
+ let match = '';
385
+ try {
386
+ const sanitized = sanitizeFtsQuery(query);
387
+ match = sanitized ? buildFtsMatchQuery(sanitized) : '';
388
+ }
389
+ catch {
390
+ return this.listRecent(projectId, limit, kind);
391
+ }
392
+ if (!match)
393
+ return this.listRecent(projectId, limit, kind);
394
+ const kindClause = kind ? 'AND a.kind = ?' : '';
395
+ const params = [match, projectId, minConf];
396
+ if (kind)
397
+ params.push(kind);
398
+ params.push(limit);
399
+ try {
400
+ // NB: reference the FTS table by its FULL NAME in MATCH, not an alias —
401
+ // `<alias> MATCH ?` throws "no such column" in SQLite FTS5, which is what sent
402
+ // every recall into the listRecent() fallback (recency instead of relevance).
403
+ return this.db.prepare(`
36
404
  SELECT a.* FROM memory_artifacts a
37
405
  JOIN memory_artifacts_fts ON a.id = memory_artifacts_fts.rowid
38
406
  WHERE memory_artifacts_fts MATCH ?
@@ -40,53 +408,249 @@ import{emitEvent as b}from"./events.js";import{sanitizeFtsQuery as L,buildFtsMat
40
408
  AND a.confidence >= ?
41
409
  AND a.supersedes_id IS NULL
42
410
  AND a.needs_review = 0
43
- ${l}
411
+ ${kindClause}
44
412
  ORDER BY rank, a.confidence DESC
45
413
  LIMIT ?
46
- `).all(...r)}catch{return this.listRecent(t,n,s)}}searchByTags(t,e,n,s,a=0){const i=e.toLowerCase().split(/\s+/).filter(d=>d.length>2);if(!i.length)return[];const l=d=>d.replace(/[%_\\]/g,"\\$&"),r=i.map(()=>"LOWER(tags) LIKE ? ESCAPE '\\'").join(" OR "),m=i.map(d=>`%${l(d)}%`),_=s?"AND kind = ?":"",o=[t,a,...m];return s&&o.push(s),o.push(n),this.db.prepare(`
414
+ `).all(...params);
415
+ }
416
+ catch {
417
+ return this.listRecent(projectId, limit, kind);
418
+ }
419
+ }
420
+ searchByTags(projectId, query, limit, kind, minConf = 0.0) {
421
+ // Match any word in the query against tags
422
+ const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
423
+ if (!words.length)
424
+ return [];
425
+ // Escape LIKE wildcards in user input to prevent injection
426
+ const escapeLike = (s) => s.replace(/[%_\\]/g, '\\$&');
427
+ const likeClause = words.map(() => "LOWER(tags) LIKE ? ESCAPE '\\'").join(' OR ');
428
+ const likeParams = words.map(w => `%${escapeLike(w)}%`);
429
+ const kindClause = kind ? 'AND kind = ?' : '';
430
+ const params = [projectId, minConf, ...likeParams];
431
+ if (kind)
432
+ params.push(kind);
433
+ params.push(limit);
434
+ return this.db.prepare(`
47
435
  SELECT * FROM memory_artifacts
48
436
  WHERE project_id = ? AND confidence >= ? AND supersedes_id IS NULL
49
437
  AND needs_review = 0
50
- AND (${r})
51
- ${_}
438
+ AND (${likeClause})
439
+ ${kindClause}
52
440
  ORDER BY confidence DESC, reuse_success_count DESC
53
441
  LIMIT ?
54
- `).all(...o)}listRecent(t,e=20,n){const s=n?"AND kind = ?":"",a=[t];return n&&a.push(n),a.push(e),this.db.prepare(`
442
+ `).all(...params);
443
+ }
444
+ listRecent(projectId, limit = 20, kind) {
445
+ const kindClause = kind ? 'AND kind = ?' : '';
446
+ const params = [projectId];
447
+ if (kind)
448
+ params.push(kind);
449
+ params.push(limit);
450
+ return this.db.prepare(`
55
451
  SELECT * FROM memory_artifacts
56
452
  WHERE project_id = ? AND supersedes_id IS NULL AND needs_review = 0
57
- ${s}
453
+ ${kindClause}
58
454
  ORDER BY confidence DESC, created_at DESC
59
455
  LIMIT ?
60
- `).all(...a)}buildContextBrief(t,e,n={}){const s=n.kinds??["pattern","heuristic","reasoning_trace","lesson","anti_pattern"],a=n.maxItems??H,i=n.minConfidence??x,l=this.recall(t,e,{limit:a*2,minConfidence:i}),r=new Map;for(const f of l){if(!s.includes(f.artifact.kind))continue;const A=r.get(f.artifact.kind)??[];A.push(f),r.set(f.artifact.kind,A)}const m={pattern:"\u2705 Proven Patterns",heuristic:"\u{1F4A1} Heuristics",reasoning_trace:"\u{1F9E0} Past Reasoning",lesson:"\u{1F4DA} Lessons Learned",anti_pattern:"\u26A0\uFE0F Anti-Patterns to Avoid"},_=["pattern","heuristic","reasoning_trace","lesson","anti_pattern"],o=[],d=[];let N=0;for(const f of _){if(!s.includes(f))continue;const A=r.get(f)??[];if(!A.length)continue;const C=[];for(const g of A){if(N>=a)break;const c=g.artifact;let h=`**Problem:** ${c.problem}`;c.constraints&&(h+=`
61
- _Constraints:_ ${c.constraints}`),c.validated_fix&&(h+=`
62
- _Solution:_ ${c.validated_fix}`),c.why_it_worked&&(h+=`
63
- _Why it worked:_ ${c.why_it_worked}`),c.outcome==="negative"&&(h+=`
64
- _Note: This approach failed \u2014 avoid it_`),C.push(h),d.push(c.id),N++}C.length&&o.push({heading:m[f],items:C,source:f})}let S="";if(o.length>0){S+=`---
65
- ## \u{F115D} Memory Brief
66
- _Relevant past knowledge from Wyrm:_
67
-
68
- `;for(const f of o){S+=`### ${f.heading}
69
- `;for(const A of f.items)S+=`- ${A}
70
- `;S+=`
71
- `}S+=`---
72
- `}return{sections:o,text:S,sourceIds:d}}getStats(t){const e=this.db.prepare("SELECT COUNT(*) as n FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL").get(t).n,n=this.db.prepare("SELECT COUNT(*) as n FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NOT NULL").get(t).n,s=this.db.prepare("SELECT AVG(confidence) as v FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL").get(t).v??0,a=this.db.prepare("SELECT kind, COUNT(*) as cnt FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL GROUP BY kind").all(t),i={};for(const l of a)i[l.kind]=l.cnt;return{total:e,byKind:i,avgConfidence:Math.round(s*100)/100,supersededCount:n}}listAll(t,e={}){const n=e.kind?"AND kind = ?":"",s=e.includeSuperseded?"":"AND supersedes_id IS NULL",a=[t];return e.kind&&a.push(e.kind),a.push(e.limit??50),this.db.prepare(`
456
+ `).all(...params);
457
+ }
458
+ // ==================== CONTEXT BRIEF ====================
459
+ /**
460
+ * Assemble an optimized memory brief for injection into an AI model's context.
461
+ * Excludes credentials/sensitive content. Groups by kind. Deduplicates.
462
+ * Returns sections with headings + items, plus raw text for direct injection.
463
+ */
464
+ buildContextBrief(projectId, task, opts = {}) {
465
+ const kinds = opts.kinds ?? ['pattern', 'heuristic', 'reasoning_trace', 'lesson', 'anti_pattern'];
466
+ const maxItems = opts.maxItems ?? MAX_BRIEF_ITEMS;
467
+ const minConf = opts.minConfidence ?? MIN_BRIEF_CONFIDENCE;
468
+ // Recall relevant artifacts
469
+ const recalled = this.recall(projectId, task, { limit: maxItems * 2, minConfidence: minConf });
470
+ // Group by kind, pick top items per kind
471
+ const byKind = new Map();
472
+ for (const r of recalled) {
473
+ if (!kinds.includes(r.artifact.kind))
474
+ continue;
475
+ const existing = byKind.get(r.artifact.kind) ?? [];
476
+ existing.push(r);
477
+ byKind.set(r.artifact.kind, existing);
478
+ }
479
+ const KIND_LABELS = {
480
+ pattern: '✅ Proven Patterns',
481
+ heuristic: '💡 Heuristics',
482
+ reasoning_trace: '🧠 Past Reasoning',
483
+ lesson: '📚 Lessons Learned',
484
+ anti_pattern: '⚠️ Anti-Patterns to Avoid',
485
+ };
486
+ // Prioritize: patterns > heuristics > reasoning_traces > lessons > anti_patterns
487
+ const kindOrder = ['pattern', 'heuristic', 'reasoning_trace', 'lesson', 'anti_pattern'];
488
+ const sections = [];
489
+ const sourceIds = [];
490
+ let totalItems = 0;
491
+ for (const kind of kindOrder) {
492
+ if (!kinds.includes(kind))
493
+ continue;
494
+ const items = byKind.get(kind) ?? [];
495
+ if (!items.length)
496
+ continue;
497
+ const sectionItems = [];
498
+ for (const r of items) {
499
+ if (totalItems >= maxItems)
500
+ break;
501
+ const a = r.artifact;
502
+ let line = `**Problem:** ${a.problem}`;
503
+ if (a.constraints)
504
+ line += `\n _Constraints:_ ${a.constraints}`;
505
+ if (a.validated_fix)
506
+ line += `\n _Solution:_ ${a.validated_fix}`;
507
+ if (a.why_it_worked)
508
+ line += `\n _Why it worked:_ ${a.why_it_worked}`;
509
+ if (a.outcome === 'negative')
510
+ line += `\n _Note: This approach failed — avoid it_`;
511
+ sectionItems.push(line);
512
+ sourceIds.push(a.id);
513
+ totalItems++;
514
+ }
515
+ if (sectionItems.length) {
516
+ sections.push({ heading: KIND_LABELS[kind], items: sectionItems, source: kind });
517
+ }
518
+ }
519
+ // Build formatted text
520
+ let text = '';
521
+ if (sections.length > 0) {
522
+ text += '---\n## 󱅝 Memory Brief\n_Relevant past knowledge from Wyrm:_\n\n';
523
+ for (const section of sections) {
524
+ text += `### ${section.heading}\n`;
525
+ for (const item of section.items) {
526
+ text += `- ${item}\n`;
527
+ }
528
+ text += '\n';
529
+ }
530
+ text += '---\n';
531
+ }
532
+ return { sections, text, sourceIds };
533
+ }
534
+ // ==================== STATS ====================
535
+ getStats(projectId) {
536
+ const total = this.db.prepare('SELECT COUNT(*) as n FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL').get(projectId).n;
537
+ const supersededCount = this.db.prepare('SELECT COUNT(*) as n FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NOT NULL').get(projectId).n;
538
+ const avgConfidence = this.db.prepare('SELECT AVG(confidence) as v FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL').get(projectId).v ?? 0;
539
+ const kindRows = this.db.prepare('SELECT kind, COUNT(*) as cnt FROM memory_artifacts WHERE project_id = ? AND supersedes_id IS NULL GROUP BY kind').all(projectId);
540
+ const byKind = {};
541
+ for (const r of kindRows)
542
+ byKind[r.kind] = r.cnt;
543
+ return { total, byKind, avgConfidence: Math.round(avgConfidence * 100) / 100, supersededCount };
544
+ }
545
+ listAll(projectId, opts = {}) {
546
+ const kindClause = opts.kind ? 'AND kind = ?' : '';
547
+ const supersededClause = opts.includeSuperseded ? '' : 'AND supersedes_id IS NULL';
548
+ const params = [projectId];
549
+ if (opts.kind)
550
+ params.push(opts.kind);
551
+ params.push(opts.limit ?? 50);
552
+ return this.db.prepare(`
73
553
  SELECT * FROM memory_artifacts
74
- WHERE project_id = ? ${n} ${s}
554
+ WHERE project_id = ? ${kindClause} ${supersededClause}
75
555
  ORDER BY confidence DESC, created_at DESC
76
556
  LIMIT ?
77
- `).all(...a)}listPage(t,e={pageSize:O}){const n=e.kind?"AND kind = ?":"",s=e.includeSuperseded?"":"AND supersedes_id IS NULL",a="AND needs_review = 0",i=[t];e.kind&&i.push(e.kind);let l="";if(e.after){const _=Number(e.after.sortKey);Number.isFinite(_)&&(l="AND (confidence < ? OR (confidence = ? AND id < ?))",i.push(_,_,e.after.id))}const r=e.pageSize+1;i.push(r);const m=this.db.prepare(`
557
+ `).all(...params);
558
+ }
559
+ /**
560
+ * Keyset-paginated sibling of `listAll` (v7 F4 T035). Orders by
561
+ * `(confidence DESC, id DESC)` — a TOTAL order (id is the unique tiebreak),
562
+ * so a page boundary that falls inside a run of tied-confidence artifacts
563
+ * drops nothing and repeats nothing (the bare-sortKey bug the cloud-sync
564
+ * keyset cursor taught us). The composite cursor `(confidence, id)` is opaque
565
+ * on the wire; a malformed cursor degrades to "first page" upstream.
566
+ *
567
+ * Over-fetches by one (`pageSize + 1`) so the caller can tell "is there more"
568
+ * without a COUNT. `pageSize` is already clamped to `[1, MAX_PAGE_SIZE]` by
569
+ * the keyset helper before it reaches here.
570
+ */
571
+ listPage(projectId, opts = { pageSize: DEFAULT_PAGE_SIZE }) {
572
+ const kindClause = opts.kind ? 'AND kind = ?' : '';
573
+ const supersededClause = opts.includeSuperseded ? '' : 'AND supersedes_id IS NULL';
574
+ // The wyrm://project/{id}/memory resource is advertised as a project's
575
+ // "validated memory artifacts" (handlers/resources.ts), and every other
576
+ // validated read path here enforces needs_review = 0 (searchByTags,
577
+ // listRecent, the FTS recall). Match that contract so the resource never
578
+ // surfaces unreviewed/quarantined rows (security pass #2, finding #2).
579
+ const reviewedClause = 'AND needs_review = 0';
580
+ const params = [projectId];
581
+ if (opts.kind)
582
+ params.push(opts.kind);
583
+ // Keyset predicate for a (confidence DESC, id DESC) total order: resume
584
+ // strictly AFTER the anchor row. `confidence` is bound as a number, the id
585
+ // as the unique tiebreak. Confidence is stored as a REAL so the cursor's
586
+ // string sortKey is parsed back to a float here (the encode round-trips it).
587
+ let keysetClause = '';
588
+ if (opts.after) {
589
+ const afterConf = Number(opts.after.sortKey);
590
+ if (Number.isFinite(afterConf)) {
591
+ keysetClause = 'AND (confidence < ? OR (confidence = ? AND id < ?))';
592
+ params.push(afterConf, afterConf, opts.after.id);
593
+ }
594
+ }
595
+ const fetch = opts.pageSize + 1;
596
+ params.push(fetch);
597
+ const rows = this.db.prepare(`
78
598
  SELECT * FROM memory_artifacts
79
- WHERE project_id = ? ${n} ${s} ${a} ${l}
599
+ WHERE project_id = ? ${kindClause} ${supersededClause} ${reviewedClause} ${keysetClause}
80
600
  ORDER BY confidence DESC, id DESC
81
601
  LIMIT ?
82
- `).all(...i);return T(m,e.pageSize,_=>({sortKey:String(_.confidence),id:_.id}))}pruneStale(t={}){const e=t.minConfidence??.3,n=t.olderThanDays??90,s=t.dryRun??!0,a=t.projectId!=null,i=[e,`-${n} days`];a&&i.push(t.projectId);const l=this.db.prepare(`
602
+ `).all(...params);
603
+ return buildPage(rows, opts.pageSize, (r) => ({ sortKey: String(r.confidence), id: r.id }));
604
+ }
605
+ /**
606
+ * Find (and optionally delete) stale, low-confidence artifacts safe to prune.
607
+ *
608
+ * A candidate must be: strictly below `minConfidence`, untouched for at least
609
+ * `olderThanDays` (or never accessed), already reviewed (needs_review = 0),
610
+ * and not superseding another artifact. When `projectId` is supplied the
611
+ * sweep is scoped to that project via an exact equality bind, so a prune can
612
+ * never reach into another project's rows; omit it for a global prune. The
613
+ * candidate set is capped at 500 per call. When `dryRun` is false the matched
614
+ * rows are deleted and the delete re-asserts needs_review = 0 as a final
615
+ * guard. Returns the candidates plus the number actually deleted.
616
+ */
617
+ pruneStale(opts = {}) {
618
+ const minConfidence = opts.minConfidence ?? 0.3;
619
+ const olderThanDays = opts.olderThanDays ?? 90;
620
+ const dryRun = opts.dryRun ?? true;
621
+ const scoped = opts.projectId != null;
622
+ const params = [minConfidence, `-${olderThanDays} days`];
623
+ if (scoped)
624
+ params.push(opts.projectId);
625
+ const candidates = this.db.prepare(`
83
626
  SELECT id, kind, problem, confidence, last_accessed_at
84
627
  FROM memory_artifacts
85
628
  WHERE confidence < ?
86
629
  AND (last_accessed_at IS NULL OR last_accessed_at < datetime('now', ?))
87
630
  AND needs_review = 0
88
631
  AND supersedes_id IS NULL
89
- ${a?"AND project_id = ?":""}
632
+ ${scoped ? 'AND project_id = ?' : ''}
90
633
  ORDER BY confidence ASC
91
634
  LIMIT 500
92
- `).all(...i);return s||l.length===0?{candidates:l,deleted:0,dryRun:s}:{candidates:l,deleted:this.deleteArtifacts(l.map(r=>r.id)),dryRun:s}}deleteArtifacts(t){if(t.length===0)return 0;const e=t.map(()=>"?").join(",");return this.db.prepare(`DELETE FROM memory_artifacts WHERE id IN (${e}) AND needs_review = 0`).run(...t).changes}}export{j as MemoryArtifacts};
635
+ `).all(...params);
636
+ if (dryRun || candidates.length === 0) {
637
+ return { candidates, deleted: 0, dryRun };
638
+ }
639
+ return { candidates, deleted: this.deleteArtifacts(candidates.map(c => c.id)), dryRun };
640
+ }
641
+ /**
642
+ * Delete artifacts by id, refusing any still flagged needs_review (a final
643
+ * guard so a stale id list can never remove a row that has since been queued
644
+ * for review). Returns the number of rows actually deleted. The CLI uses this
645
+ * to delete exactly the candidate set it showed the operator, so the confirmed
646
+ * set and the deleted set are always identical (no re-select in between).
647
+ */
648
+ deleteArtifacts(ids) {
649
+ if (ids.length === 0)
650
+ return 0;
651
+ const placeholders = ids.map(() => '?').join(',');
652
+ const result = this.db.prepare(`DELETE FROM memory_artifacts WHERE id IN (${placeholders}) AND needs_review = 0`).run(...ids);
653
+ return result.changes;
654
+ }
655
+ }
656
+ //# sourceMappingURL=memory-artifacts.js.map