wyrm-mcp 5.2.2 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -58,6 +58,177 @@ import { Goals } from "./goals.js";
58
58
  import { OutboundMcpClient } from "./mcp-client.js";
59
59
  import { AgentLoop } from "./agent-loop.js";
60
60
  import { AgentDaemon } from "./agent-daemon.js";
61
+ import { getUpdateStatus, emitStartupVersionBanner } from "./version-check.js";
62
+ import { getCapabilities, renderCapabilityBriefing } from "./capabilities.js";
63
+ import { spawn as cpSpawn } from "child_process";
64
+ import { makeEstimator, applyBudget, resolveBudget } from "./token-budget.js";
65
+ import { loadWeightsFromEnv, score as rankScore } from "./context-ranking.js";
66
+ import { SessionSeen } from "./session-seen.js";
67
+ import { fileURLToPath as _versionFileURLToPath } from "url";
68
+ import { dirname as _versionDirname, join as _versionJoin } from "path";
69
+ import { readFileSync as _versionReadFile, existsSync as _versionExists } from "fs";
70
+ // Update when adding/removing MCP tools so wyrm_capabilities reports accurately.
71
+ // The ListToolsRequestSchema handler is the single source of truth at runtime —
72
+ // this constant is just for the self-description and the postinstall pitch.
73
+ const WYRM_TOOL_COUNT = 114;
74
+ // Spec 014 — token budgeting subsystem. Lazy-init on first use.
75
+ const tokenEstimator = makeEstimator();
76
+ const rankWeights = loadWeightsFromEnv();
77
+ let _sessionSeen = null;
78
+ function sessionSeen() {
79
+ if (!_sessionSeen)
80
+ _sessionSeen = new SessionSeen(db.getDatabase());
81
+ return _sessionSeen;
82
+ }
83
+ /**
84
+ * Spec 014 budgeted wyrm_context_build path.
85
+ *
86
+ * Renders a stable preamble (ground truths) + dynamic body (memory
87
+ * artifacts, scaffold). Items below the token-budget cutoff elide to
88
+ * one-line stubs the AI can recall via wyrm_recall.
89
+ */
90
+ function runBudgetedContextBuild(opts) {
91
+ // Resolve effective budget per spec 014 D1 precedence: call > env > fallback.
92
+ const budget = resolveBudget({
93
+ callArg: opts.maxTokens,
94
+ envValue: process.env.WYRM_CONTEXT_TOKEN_BUDGET,
95
+ clientName: null, // clientInfo wiring is a follow-up; env override is the lever today
96
+ });
97
+ const seen = opts.sessionId ? sessionSeen().getSeen(opts.sessionId) : new Set();
98
+ // --- Stable preamble (ground truths) ---
99
+ const truthsText = groundTruths.formatForContext(opts.project.id);
100
+ const truthsTokens = tokenEstimator.count(truthsText ?? '');
101
+ // Strict budget mode (D3) elides truths if even the preamble overflows;
102
+ // default behavior is to warn-and-overflow per spec 014.
103
+ let preambleText = truthsText ?? '';
104
+ let preambleEmitted = truthsTokens;
105
+ if (truthsTokens > budget * 0.5 && !opts.strictBudget) {
106
+ process.stderr.write(`[wyrm_context_build] ground truths consume ${truthsTokens} of ${budget}-token budget — body will be heavily elided. Pass strict_budget:true to elide truths too.\n`);
107
+ }
108
+ if (opts.strictBudget && truthsTokens > budget * 0.5) {
109
+ // Strict mode: keep truths brief
110
+ preambleText = (truthsText ?? '').slice(0, budget * 2); // 2 chars/token rough cap on chars
111
+ preambleEmitted = tokenEstimator.count(preambleText);
112
+ }
113
+ // --- Build candidate body items: best scaffold + memory artifacts ---
114
+ const items = [];
115
+ // Scaffold (treated as a single candidate)
116
+ const scaffoldMatch = scaffoldLib.findBest(opts.task, opts.project.id);
117
+ if (scaffoldMatch) {
118
+ const sText = scaffoldLib.formatForContext(scaffoldMatch);
119
+ const sId = scaffoldMatch.scaffold.id;
120
+ items.push({
121
+ item: { kind: 'scaffold', id: sId, title: scaffoldMatch.scaffold.problem_type, body: sText },
122
+ key: `scaffold:${sId}`,
123
+ inlineCost: tokenEstimator.count(sText),
124
+ stubCost: 20,
125
+ // Scaffold is high-relevance by definition; assign generous score.
126
+ score: rankScore({ confidence: 0.9, relevance: 0.9, updatedAt: new Date().toISOString() }, rankWeights),
127
+ });
128
+ }
129
+ // Memory artifacts via recall (which returns id-level results with relevance scores)
130
+ const recalled = memory.recall(opts.project.id, opts.task, {
131
+ limit: 20,
132
+ minConfidence: opts.minConfidence ?? 0.2,
133
+ });
134
+ for (const r of recalled) {
135
+ const a = r.artifact;
136
+ // Skip kind filter mismatches if caller specified one
137
+ if (opts.kinds && opts.kinds.length > 0 && !opts.kinds.includes(a.kind))
138
+ continue;
139
+ const body = [
140
+ `**${a.kind.toUpperCase()}** · ${a.problem}`,
141
+ a.constraints ? `Constraints: ${a.constraints}` : '',
142
+ a.validated_fix ? `Fix: ${a.validated_fix}` : '',
143
+ a.why_it_worked ? `Why: ${a.why_it_worked}` : '',
144
+ ].filter(Boolean).join('\n');
145
+ items.push({
146
+ item: { kind: 'memory', id: a.id, title: a.problem.slice(0, 60), body },
147
+ key: `memory:${a.id}`,
148
+ inlineCost: tokenEstimator.count(body),
149
+ stubCost: 25,
150
+ score: rankScore({
151
+ confidence: a.confidence,
152
+ relevance: r.relevance_score,
153
+ updatedAt: a.updated_at,
154
+ }, rankWeights),
155
+ });
156
+ }
157
+ // --- Apply budget ---
158
+ const result = applyBudget(items, {
159
+ budget,
160
+ alreadySeen: seen,
161
+ reserved: opts.strictBudget ? 0 : preambleEmitted,
162
+ });
163
+ // --- Mark inlined items as seen for this session ---
164
+ if (opts.sessionId) {
165
+ sessionSeen().markBulk(opts.sessionId, result.inline.map((it) => ({ id: it.id, kind: it.kind, mode: 'inline' })));
166
+ sessionSeen().markBulk(opts.sessionId, result.elided.map((e) => ({ id: e.item.id, kind: e.item.kind, mode: 'stub' })));
167
+ }
168
+ // --- Render output ---
169
+ const lines = [];
170
+ lines.push(`🐉 **Context Brief** — "${opts.task}"`);
171
+ lines.push('');
172
+ lines.push('## Ground truths (stable preamble)');
173
+ lines.push('');
174
+ lines.push(preambleText || '_no ground truths set for this project yet_');
175
+ lines.push('');
176
+ lines.push('## Task-relevant memory');
177
+ lines.push('');
178
+ for (const it of result.inline) {
179
+ lines.push(`### [${it.kind}:${it.id}] ${it.title}`);
180
+ lines.push(it.body);
181
+ lines.push('');
182
+ }
183
+ if (result.elided.length > 0) {
184
+ lines.push('## Elided to stubs');
185
+ lines.push('');
186
+ lines.push('_Below the budget cutoff or already seen this session. Recall with `wyrm_recall(id)`:_');
187
+ lines.push('');
188
+ for (const e of result.elided) {
189
+ const tag = e.reason === 'seen' ? 'shown earlier in session' : `~${e.stubCost} tokens`;
190
+ lines.push(`- [${e.item.kind}:${e.item.id}] ${e.item.title} · ${tag}`);
191
+ }
192
+ lines.push('');
193
+ }
194
+ lines.push('---');
195
+ lines.push(`_Budget: ${result.tokensInline}/${budget} tokens inline · ${result.elided.length} stub${result.elided.length === 1 ? '' : 's'} · estimator: ${tokenEstimator.source}_`);
196
+ const text = lines.join('\n');
197
+ // Telemetry — fire-and-forget via the existing tool_call_log surface.
198
+ try {
199
+ toolAnalytics.log({
200
+ tool_name: 'wyrm_context_build',
201
+ project_id: opts.project.id,
202
+ args: {
203
+ budget,
204
+ items_total: items.length,
205
+ items_inline: result.inline.length,
206
+ items_elided: result.elided.length,
207
+ tokens_inline: result.tokensInline,
208
+ tokens_stubs: result.tokensStubs,
209
+ },
210
+ success: true,
211
+ latency_ms: 0,
212
+ });
213
+ }
214
+ catch { /* never fail context_build on telemetry */ }
215
+ return { content: [{ type: 'text', text }] };
216
+ }
217
+ // Resolve our own package.json once so wyrm_check_update knows what version we are.
218
+ const WYRM_PACKAGE_VERSION = (() => {
219
+ try {
220
+ const here = _versionDirname(_versionFileURLToPath(import.meta.url));
221
+ for (const p of [_versionJoin(here, '..', 'package.json'), _versionJoin(here, '..', '..', 'package.json')]) {
222
+ if (_versionExists(p)) {
223
+ const v = JSON.parse(_versionReadFile(p, 'utf-8')).version;
224
+ if (typeof v === 'string')
225
+ return v;
226
+ }
227
+ }
228
+ }
229
+ catch { /* fall through */ }
230
+ return 'unknown';
231
+ })();
61
232
  const db = new WyrmDB();
62
233
  const sync = new WyrmSync(db);
63
234
  const graph = new KnowledgeGraph(db.getDatabase());
@@ -1094,19 +1265,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1094
1265
  },
1095
1266
  {
1096
1267
  name: "wyrm_context_build",
1097
- description: "Assemble an optimized memory brief for the current task — a formatted block of relevant patterns, lessons, reasoning traces, and anti-patterns from past sessions. Inject this at the start of complex tasks to give AI models the right context without bloating the whole conversation.",
1268
+ description: "Assemble an optimized memory brief for the current task — a formatted block of relevant patterns, lessons, reasoning traces, and anti-patterns from past sessions. Inject this at the start of complex tasks to give AI models the right context without bloating the whole conversation. Pass max_tokens for budget-aware delivery (low-score items elide to recallable stubs); omit max_tokens for legacy unbounded behaviour.",
1098
1269
  inputSchema: {
1099
1270
  type: "object",
1100
1271
  properties: {
1101
1272
  projectPath: { type: "string", description: "Project to build context from" },
1102
1273
  task: { type: "string", description: "Describe the current task in detail — the more specific, the better the recall" },
1103
- maxItems: { type: "number", description: "Max knowledge items to include (default: 10, max: 20)" },
1274
+ maxItems: { type: "number", description: "Max knowledge items to include (default: 10, max: 20). Ignored when max_tokens is set." },
1104
1275
  kinds: {
1105
1276
  type: "array",
1106
1277
  items: { type: "string", enum: ["reasoning_trace", "lesson", "pattern", "anti_pattern", "heuristic"] },
1107
1278
  description: "Limit to specific artifact kinds (default: all)",
1108
1279
  },
1109
1280
  minConfidence: { type: "number", description: "Minimum confidence threshold (default: 0.3)" },
1281
+ max_tokens: { type: "number", description: "Spec 014: token budget. Default per model tier auto-detected (Opus 4096 / Sonnet 8192 / Haiku 12288), env WYRM_CONTEXT_TOKEN_BUDGET, fallback 4096. Items below budget cutoff elide to stubs the AI can deref with wyrm_recall." },
1282
+ session_id: { type: "number", description: "Spec 014: session id for already-seen dedup. Items already shown in this session render as one-line references." },
1283
+ strict_budget: { type: "boolean", description: "Spec 014: if true, elide ground truths too when preamble exceeds budget. Default false — ground truths are foundation and never elided unless explicitly requested." },
1110
1284
  },
1111
1285
  required: ["projectPath", "task"],
1112
1286
  },
@@ -1346,6 +1520,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1346
1520
  required: ["project_path"],
1347
1521
  },
1348
1522
  },
1523
+ {
1524
+ name: "wyrm_capabilities",
1525
+ description: "Return Wyrm's self-description: feature inventory, why-it-matters per feature, runtime state (vector provider, encryption, agent), and the canonical pitch. Call this at session start so you know exactly which tool to reach for instead of re-discovering the surface.",
1526
+ inputSchema: {
1527
+ type: "object",
1528
+ properties: {
1529
+ format: { type: "string", enum: ["json", "markdown"], description: "Response shape: 'json' (structured) or 'markdown' (rendered briefing). Default: markdown." },
1530
+ },
1531
+ },
1532
+ },
1533
+ {
1534
+ name: "wyrm_check_update",
1535
+ description: "Check whether a newer wyrm-mcp is available on npm. Cached 24h by default; pass force:true to bypass cache.",
1536
+ inputSchema: {
1537
+ type: "object",
1538
+ properties: {
1539
+ force: { type: "boolean", description: "Bypass the 24-hour cache and re-query the registry." },
1540
+ },
1541
+ },
1542
+ },
1543
+ {
1544
+ name: "wyrm_self_update",
1545
+ description: "Run `npm install -g wyrm-mcp@latest` to upgrade. Returns the install transcript. Requires the operator to have write access to the global npm prefix.",
1546
+ inputSchema: {
1547
+ type: "object",
1548
+ properties: {
1549
+ confirm: { type: "boolean", description: "Must be true to actually run the upgrade (safety guard)." },
1550
+ },
1551
+ required: ["confirm"],
1552
+ },
1553
+ },
1349
1554
  // ============================================================
1350
1555
  // v3.9.0 — Counter-pattern detection (failure_patterns)
1351
1556
  // ============================================================
@@ -2854,10 +3059,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2854
3059
  return { content: [{ type: "text", text: `🐉 Feedback recorded ${icon}\n\n- Artifact #${artifactId}: "${artifact.problem.slice(0, 60)}"\n- New confidence: ${(updated.confidence * 100).toFixed(0)}%\n- Total uses: ${updated.reuse_count} (✅ ${updated.reuse_success_count} / ❌ ${updated.reuse_failure_count})` }] };
2855
3060
  }
2856
3061
  case "wyrm_context_build": {
2857
- const { projectPath: cbPath, task: cbTask, maxItems: cbMax, kinds: cbKinds, minConfidence: cbMinConf } = args;
3062
+ const { projectPath: cbPath, task: cbTask, maxItems: cbMax, kinds: cbKinds, minConfidence: cbMinConf, max_tokens: cbMaxTokens, session_id: cbSessionId, strict_budget: cbStrictBudget, } = args;
2858
3063
  const cbProject = db.getProject(cbPath);
2859
3064
  if (!cbProject)
2860
3065
  return { content: [{ type: "text", text: `Project not found: ${cbPath}` }], isError: true };
3066
+ // Spec 014 budgeted path — only when max_tokens is supplied (backwards compat: criterion 7).
3067
+ // Kill switch: WYRM_DISABLE_TOKEN_BUDGET=1 routes everything to legacy.
3068
+ const wantsBudget = cbMaxTokens != null && !process.env.WYRM_DISABLE_TOKEN_BUDGET;
3069
+ if (wantsBudget) {
3070
+ return runBudgetedContextBuild({
3071
+ project: cbProject,
3072
+ task: cbTask,
3073
+ maxTokens: cbMaxTokens,
3074
+ sessionId: cbSessionId,
3075
+ strictBudget: cbStrictBudget === true,
3076
+ kinds: cbKinds,
3077
+ minConfidence: cbMinConf,
3078
+ });
3079
+ }
2861
3080
  const sections = [];
2862
3081
  // Section 1: Ground truths (~1200 char budget)
2863
3082
  const truthsText = groundTruths.formatForContext(cbProject.id);
@@ -3250,6 +3469,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3250
3469
  db.vacuum();
3251
3470
  text += `- Vacuumed database\n`;
3252
3471
  }
3472
+ // Spec 014: prune session_seen_artifacts older than WYRM_SEEN_TTL_DAYS (default 7).
3473
+ try {
3474
+ const ttlDays = parseInt(process.env.WYRM_SEEN_TTL_DAYS ?? '7', 10);
3475
+ const pruned = sessionSeen().prune(Number.isFinite(ttlDays) && ttlDays > 0 ? ttlDays : 7);
3476
+ if (pruned > 0)
3477
+ text += `- Pruned ${pruned} session_seen_artifacts rows older than ${ttlDays}d\n`;
3478
+ }
3479
+ catch (e) { /* never fail maintenance on this */ }
3253
3480
  db.checkpoint();
3254
3481
  text += `- Checkpointed WAL\n`;
3255
3482
  const stats = db.getStats();
@@ -4068,6 +4295,67 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4068
4295
  }
4069
4296
  return { content: [{ type: "text", text: injText }] };
4070
4297
  }
4298
+ case "wyrm_capabilities": {
4299
+ const { format: capFmt = "markdown" } = args;
4300
+ const report = getCapabilities(db.getDatabase(), WYRM_TOOL_COUNT, {
4301
+ detectVectorProvider: () => {
4302
+ if (process.env.WYRM_VECTOR_PROVIDER === 'openai')
4303
+ return 'openai';
4304
+ return 'ollama';
4305
+ },
4306
+ hasEncryption: () => !!process.env.WYRM_ENCRYPTION_KEY,
4307
+ isFederationEnabled: () => true,
4308
+ isAgentRunning: () => {
4309
+ try {
4310
+ return agentDaemon.status().running;
4311
+ }
4312
+ catch {
4313
+ return null;
4314
+ }
4315
+ },
4316
+ });
4317
+ if (capFmt === 'json') {
4318
+ return { content: [{ type: 'text', text: JSON.stringify(report, null, 2) }] };
4319
+ }
4320
+ return { content: [{ type: 'text', text: renderCapabilityBriefing(report) }] };
4321
+ }
4322
+ case "wyrm_check_update": {
4323
+ const { force = false } = args;
4324
+ const status = await getUpdateStatus(db.getDatabase(), WYRM_PACKAGE_VERSION, { force });
4325
+ let text = `🐉 **Update check**\n\n- Current: ${status.current}\n- Latest: ${status.latest ?? 'unknown (offline?)'}\n- Update available: ${status.updateAvailable ? 'yes' : 'no'}\n- Checked: ${status.checkedAt} (${status.source})`;
4326
+ if (status.updateAvailable) {
4327
+ text += `\n\nRun \`wyrm_self_update\` with \`confirm: true\` to upgrade, or \`npm install -g wyrm-mcp@latest\` from your shell.`;
4328
+ }
4329
+ return { content: [{ type: 'text', text }] };
4330
+ }
4331
+ case "wyrm_self_update": {
4332
+ const { confirm: selfConfirm = false } = args;
4333
+ if (!selfConfirm) {
4334
+ return {
4335
+ content: [{
4336
+ type: 'text',
4337
+ text: '🐉 **Self-update declined**: call again with `confirm: true` to run `npm install -g wyrm-mcp@latest`. Note: requires write access to the global npm prefix.',
4338
+ }],
4339
+ };
4340
+ }
4341
+ const transcript = await new Promise((resolve) => {
4342
+ const proc = cpSpawn('npm', ['install', '-g', 'wyrm-mcp@latest'], { stdio: ['ignore', 'pipe', 'pipe'] });
4343
+ const chunks = [];
4344
+ proc.stdout.on('data', (b) => chunks.push(b.toString()));
4345
+ proc.stderr.on('data', (b) => chunks.push(b.toString()));
4346
+ proc.on('close', (code) => {
4347
+ chunks.push(`\n[exit ${code ?? 'unknown'}]`);
4348
+ resolve(chunks.join(''));
4349
+ });
4350
+ proc.on('error', (err) => resolve(`spawn failed: ${err.message}`));
4351
+ });
4352
+ return {
4353
+ content: [{
4354
+ type: 'text',
4355
+ text: `🐉 **Self-update transcript**\n\n\`\`\`\n${transcript}\n\`\`\`\n\nRestart your MCP client to pick up the new binary.`,
4356
+ }],
4357
+ };
4358
+ }
4071
4359
  // ==================== v3.5.0 TOOLS ====================
4072
4360
  case "wyrm_prune": {
4073
4361
  const { project_id: pruneProjectId, min_confidence: pruneMinConf = 0.3, older_than_days: pruneOlderDays = 90, types: pruneTypes, dry_run: pruneDryRun = true, confirm_ids: pruneConfirmIds, } = args;
@@ -4845,6 +5133,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4845
5133
  async function main() {
4846
5134
  const transport = new StdioServerTransport();
4847
5135
  await server.connect(transport);
5136
+ // Fire-and-forget: ping npm once a day for newer versions. Logs to stderr
5137
+ // so it never interferes with the MCP stdio protocol.
5138
+ emitStartupVersionBanner(db.getDatabase(), WYRM_PACKAGE_VERSION).catch(() => { });
4848
5139
  // Graceful shutdown — flush analytics buffer
4849
5140
  const shutdown = () => {
4850
5141
  try {