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.
- package/README.md +8 -14
- package/dist/activation.js +59 -1
- package/dist/agent-daemon.js +281 -4
- package/dist/agent-loop.js +332 -7
- package/dist/analytics.js +236 -13
- package/dist/attribution.js +49 -1
- package/dist/audit.js +457 -2
- package/dist/auto-capture.js +138 -3
- package/dist/auto-orchestrator.js +325 -1
- package/dist/autoconfig.d.ts +50 -0
- package/dist/autoconfig.d.ts.map +1 -1
- package/dist/autoconfig.js +1115 -39
- package/dist/autoconfig.js.map +1 -1
- package/dist/buddy-runner.js +109 -1
- package/dist/buddy.js +564 -14
- package/dist/build-flags.js +15 -1
- package/dist/capabilities.js +183 -3
- package/dist/capture.js +56 -1
- package/dist/causality.js +148 -8
- package/dist/cli.js +281 -20
- package/dist/cloud/cli.js +541 -5
- package/dist/cloud/client.js +221 -1
- package/dist/cloud/crypto.js +85 -1
- package/dist/cloud/machine-id.js +113 -2
- package/dist/cloud/recovery.js +60 -1
- package/dist/cloud/sync-engine.js +543 -7
- package/dist/cloud-backup.js +579 -5
- package/dist/cloud-profile.js +138 -1
- package/dist/cloud-sync-entrypoint.js +47 -1
- package/dist/cloud-sync.js +309 -2
- package/dist/connectors/bridge-source.d.ts +46 -0
- package/dist/connectors/bridge-source.d.ts.map +1 -0
- package/dist/connectors/bridge-source.js +77 -0
- package/dist/connectors/bridge-source.js.map +1 -0
- package/dist/connectors/index.d.ts +24 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +69 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/ingest.d.ts +16 -0
- package/dist/connectors/ingest.d.ts.map +1 -0
- package/dist/connectors/ingest.js +116 -0
- package/dist/connectors/ingest.js.map +1 -0
- package/dist/connectors/types.d.ts +99 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +17 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/constellation.js +168 -12
- package/dist/content-signature.js +45 -1
- package/dist/context-build-budgeted.js +144 -4
- package/dist/context-ranking.js +69 -1
- package/dist/crypto.js +179 -1
- package/dist/daemon-write-endpoint.js +290 -1
- package/dist/daemon-writer.js +406 -2
- package/dist/database.js +1278 -53
- package/dist/deprecations.js +162 -2
- package/dist/design.js +141 -13
- package/dist/event-replication.js +112 -1
- package/dist/events-sse.js +43 -7
- package/dist/events.js +238 -6
- package/dist/failure-patterns.d.ts +107 -0
- package/dist/failure-patterns.d.ts.map +1 -1
- package/dist/failure-patterns.js +924 -43
- package/dist/failure-patterns.js.map +1 -1
- package/dist/federation.js +236 -12
- package/dist/goals.js +101 -13
- package/dist/golden.js +355 -3
- package/dist/handlers/agent.js +165 -4
- package/dist/handlers/alias-adapters.js +129 -1
- package/dist/handlers/aliases.js +171 -1
- package/dist/handlers/audit.js +87 -1
- package/dist/handlers/boundary.js +221 -1
- package/dist/handlers/capture.js +1114 -73
- package/dist/handlers/causality.js +119 -9
- package/dist/handlers/cloud.js +382 -85
- package/dist/handlers/companion.js +459 -28
- package/dist/handlers/datalake.js +187 -7
- package/dist/handlers/dispatch-context.js +22 -0
- package/dist/handlers/entity.js +256 -25
- package/dist/handlers/events.js +335 -16
- package/dist/handlers/failure.d.ts.map +1 -1
- package/dist/handlers/failure.js +408 -13
- package/dist/handlers/failure.js.map +1 -1
- package/dist/handlers/goals.js +296 -4
- package/dist/handlers/intelligence.js +681 -126
- package/dist/handlers/invoicing.js +70 -1
- package/dist/handlers/mcpclient.js +137 -6
- package/dist/handlers/orchestration.js +125 -40
- package/dist/handlers/output-schemas.js +24 -1
- package/dist/handlers/presence.js +99 -3
- package/dist/handlers/project.js +182 -28
- package/dist/handlers/prompts.js +157 -6
- package/dist/handlers/quest.js +224 -4
- package/dist/handlers/recall.js +237 -13
- package/dist/handlers/registry.js +167 -1
- package/dist/handlers/resources.js +288 -1
- package/dist/handlers/review.js +74 -11
- package/dist/handlers/run.js +498 -16
- package/dist/handlers/search.js +338 -15
- package/dist/handlers/session.js +643 -31
- package/dist/handlers/share.js +184 -8
- package/dist/handlers/shims.js +464 -1
- package/dist/handlers/skill.js +449 -67
- package/dist/handlers/survivors.js +120 -1
- package/dist/handlers/symbols.js +109 -8
- package/dist/handlers/syncops.js +302 -4
- package/dist/handlers/types.js +27 -1
- package/dist/harvest.js +191 -5
- package/dist/hours.js +156 -7
- package/dist/http-auth.js +321 -3
- package/dist/http-fast.js +1302 -22
- package/dist/icons.js +47 -1
- package/dist/importers.js +268 -1
- package/dist/index.js +840 -2
- package/dist/indexer.js +145 -4
- package/dist/intelligence.js +261 -31
- package/dist/internal-dispatch.js +212 -3
- package/dist/keyset.js +110 -1
- package/dist/knowledge-graph.js +176 -12
- package/dist/license.js +441 -2
- package/dist/logger.js +199 -2
- package/dist/maintenance.js +148 -2
- package/dist/mcp-client.js +262 -6
- package/dist/memory-artifacts.js +596 -32
- package/dist/migrate-prompt.js +124 -2
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +799 -42
- package/dist/migrations.js.map +1 -1
- package/dist/performance.js +228 -1
- package/dist/presence.js +140 -11
- package/dist/priority-embed.js +164 -5
- package/dist/providers/embedding-provider.js +196 -1
- package/dist/readonly-gate.js +29 -1
- package/dist/receipt.js +43 -1
- package/dist/rehydration.js +157 -9
- package/dist/reindex.js +88 -1
- package/dist/render-target.js +544 -21
- package/dist/render.js +280 -4
- package/dist/repl-guard.js +173 -1
- package/dist/replication-daemon-entrypoint.js +31 -1
- package/dist/replication-daemon.js +262 -2
- package/dist/rerank.js +142 -1
- package/dist/resilience.js +591 -1
- package/dist/reverse-bridge.js +360 -5
- package/dist/security.js +244 -1
- package/dist/session-seen.js +51 -3
- package/dist/setup.js +260 -1
- package/dist/skill-author.js +168 -5
- package/dist/spec-kit.js +191 -1
- package/dist/sqlite-busy.js +154 -1
- package/dist/statusline.js +315 -11
- package/dist/sub-agent.js +262 -13
- package/dist/summarizer.js +139 -13
- package/dist/symbols.js +283 -7
- package/dist/sync.js +359 -5
- package/dist/tasks-dispatch.js +84 -1
- package/dist/tasks.js +282 -1
- package/dist/token-budget.js +143 -1
- package/dist/tool-analytics.js +129 -7
- package/dist/tool-annotations.js +365 -1
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +75 -1
- package/dist/trace-harvest.js +244 -6
- package/dist/types.js +30 -1
- package/dist/ui-dashboard.js +50 -41
- package/dist/ulid.js +81 -1
- package/dist/usage-tracker.js +66 -1
- package/dist/validate.js +129 -1
- package/dist/vault.js +534 -1
- package/dist/vector-init.js +67 -1
- package/dist/vectors.js +184 -3
- package/dist/version-check.js +136 -4
- package/dist/visibility.js +155 -19
- package/dist/wyrm-cli.js +2845 -101
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.d.ts.map +1 -1
- package/dist/wyrm-guard.js +475 -14
- package/dist/wyrm-guard.js.map +1 -1
- package/dist/wyrm-loop.js +150 -3
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +11 -1
- package/dist/wyrm-statusline.js +56 -4
- package/dist/wyrm-ui.js +77 -9
- package/package.json +1 -1
package/dist/memory-artifacts.js
CHANGED
|
@@ -1,9 +1,68 @@
|
|
|
1
|
-
|
|
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(
|
|
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(
|
|
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
|
-
${
|
|
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(
|
|
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
|
-
${
|
|
367
|
+
${kindClause}
|
|
33
368
|
ORDER BY rank, a.confidence DESC
|
|
34
369
|
LIMIT ?
|
|
35
|
-
`).all(...
|
|
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
|
-
${
|
|
411
|
+
${kindClause}
|
|
44
412
|
ORDER BY rank, a.confidence DESC
|
|
45
413
|
LIMIT ?
|
|
46
|
-
`).all(...
|
|
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 (${
|
|
51
|
-
${
|
|
438
|
+
AND (${likeClause})
|
|
439
|
+
${kindClause}
|
|
52
440
|
ORDER BY confidence DESC, reuse_success_count DESC
|
|
53
441
|
LIMIT ?
|
|
54
|
-
`).all(...
|
|
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
|
-
${
|
|
453
|
+
${kindClause}
|
|
58
454
|
ORDER BY confidence DESC, created_at DESC
|
|
59
455
|
LIMIT ?
|
|
60
|
-
`).all(...
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 = ? ${
|
|
554
|
+
WHERE project_id = ? ${kindClause} ${supersededClause}
|
|
75
555
|
ORDER BY confidence DESC, created_at DESC
|
|
76
556
|
LIMIT ?
|
|
77
|
-
`).all(...
|
|
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 = ? ${
|
|
599
|
+
WHERE project_id = ? ${kindClause} ${supersededClause} ${reviewedClause} ${keysetClause}
|
|
80
600
|
ORDER BY confidence DESC, id DESC
|
|
81
601
|
LIMIT ?
|
|
82
|
-
`).all(...
|
|
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
|
-
${
|
|
632
|
+
${scoped ? 'AND project_id = ?' : ''}
|
|
90
633
|
ORDER BY confidence ASC
|
|
91
634
|
LIMIT 500
|
|
92
|
-
`).all(...
|
|
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
|