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/statusline.js
CHANGED
|
@@ -1,18 +1,322 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Wyrm statusline — daemon + query helpers (6.0 phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Architecture (per spec 018, decision #1):
|
|
5
|
+
*
|
|
6
|
+
* wyrm-statusline (binary)
|
|
7
|
+
* ↓ Unix socket
|
|
8
|
+
* wyrm-statusline-daemon (separate process)
|
|
9
|
+
* ↓ in-process
|
|
10
|
+
* WyrmDB (business-logic layer — same as MCP server uses)
|
|
11
|
+
* ↓
|
|
12
|
+
* SQLite
|
|
13
|
+
*
|
|
14
|
+
* The daemon is auto-spawned by the binary on first call and idle-times-out
|
|
15
|
+
* after IDLE_TIMEOUT_MS. Correctness over speed — direct SQLite reads from
|
|
16
|
+
* the statusline binary would skip visibility filters, encryption-at-rest
|
|
17
|
+
* (future), and audit trail. The MCP server itself doesn't host this; the
|
|
18
|
+
* statusline runs in its own daemon for the same reason a unix command-line
|
|
19
|
+
* tool runs separately from a server: low resource footprint, no MCP
|
|
20
|
+
* client required.
|
|
21
|
+
*
|
|
22
|
+
* Status flags (per spec 018, decision #3):
|
|
23
|
+
* WYRM_STATUSLINE_SHOW_SAVINGS=1 — include "saved" counter (experimental)
|
|
24
|
+
* WYRM_STATUSLINE_PRIVATE=1 — collapse to "<brand> ●●●" for screen-share safety
|
|
25
|
+
*
|
|
26
|
+
* @copyright 2026 Ghost Protocol (Pvt) Ltd.
|
|
27
|
+
* @license Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync, realpathSync } from 'fs';
|
|
30
|
+
import { join as pathJoin } from 'path';
|
|
31
|
+
import { homedir } from 'os';
|
|
32
|
+
import { createServer, createConnection } from 'net';
|
|
33
|
+
import { spawn } from 'child_process';
|
|
34
|
+
import { WyrmDB, resolveDbPath } from './database.js';
|
|
35
|
+
import { ICON } from './icons.js';
|
|
36
|
+
const SOCKET_PATH = pathJoin(homedir(), '.wyrm', 'statusline.sock');
|
|
37
|
+
const PID_PATH = pathJoin(homedir(), '.wyrm', 'statusline.pid');
|
|
38
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
39
|
+
// The brand mark rendered in SILVER (the Ghost Protocol dragon colour). Truecolor
|
|
40
|
+
// #C0C0C0; degrades gracefully on 256-colour terminals. Empty WYRM_BRAND → no mark.
|
|
41
|
+
const SILVER = '\x1b[38;2;192;192;192m';
|
|
42
|
+
const ANSI_RESET = '\x1b[0m';
|
|
43
|
+
const brandMark = ICON.brand ? `${SILVER}${ICON.brand}${ANSI_RESET}` : '';
|
|
44
|
+
/**
|
|
45
|
+
* Look up the active project for a given cwd.
|
|
46
|
+
* Walks up directory tree matching against `projects.path`.
|
|
47
|
+
*/
|
|
48
|
+
function resolveProject(db, cwd) {
|
|
49
|
+
if (!cwd)
|
|
50
|
+
return null;
|
|
51
|
+
let current = cwd;
|
|
52
|
+
for (let i = 0; i < 12; i++) {
|
|
53
|
+
const row = db.prepare('SELECT id, name FROM projects WHERE path = ?').get(current);
|
|
54
|
+
if (row)
|
|
55
|
+
return row;
|
|
56
|
+
const parent = pathJoin(current, '..');
|
|
57
|
+
if (parent === current)
|
|
58
|
+
break;
|
|
59
|
+
current = parent;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function num(db, sql, ...params) {
|
|
64
|
+
try {
|
|
65
|
+
const row = db.prepare(sql).get(...params);
|
|
66
|
+
return row?.n ?? 0;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Compute the statusline payload from the live DB.
|
|
74
|
+
*/
|
|
75
|
+
export function computeStatusline(db, req) {
|
|
76
|
+
// Privacy mode short-circuit — never read project state.
|
|
77
|
+
// Per-call flag takes precedence; falls back to env for daemon-default.
|
|
78
|
+
const privateMode = req.privateMode ?? (process.env.WYRM_STATUSLINE_PRIVATE === '1');
|
|
79
|
+
if (privateMode) {
|
|
80
|
+
return {
|
|
81
|
+
text: `${brandMark} ●●●`,
|
|
82
|
+
raw: { project: null, activeQuests: 0, contextTokensUsed: 0, contextBudget: 0, blockedThisSession: 0, truthCount: 0, tokensSaved: null },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const project = resolveProject(db, req.cwd);
|
|
86
|
+
const projectId = project?.id;
|
|
87
|
+
const showSaved = req.showSavings ?? (process.env.WYRM_STATUSLINE_SHOW_SAVINGS === '1');
|
|
88
|
+
const activeQuests = projectId
|
|
89
|
+
? num(db, `SELECT COUNT(*) AS n FROM quests WHERE project_id = ? AND status = 'pending'`, projectId)
|
|
90
|
+
: 0;
|
|
91
|
+
const truthCount = projectId
|
|
92
|
+
? num(db, `SELECT COUNT(*) AS n FROM ground_truths WHERE project_id = ?`, projectId)
|
|
93
|
+
: 0;
|
|
94
|
+
// Blocked-this-session is approximated as "failure_check hits since the
|
|
95
|
+
// current session's start time." If no session id is provided, falls back
|
|
96
|
+
// to "last 24h" so the statusline isn't blank on first call.
|
|
97
|
+
// v7 F3 (T019) fix, found while writing the renderer fixture tests: both
|
|
98
|
+
// queries referenced columns that have never existed (failure_patterns has
|
|
99
|
+
// `last_seen`, sessions mark their start in `created_at` — there is no
|
|
100
|
+
// last_seen_at/started_at), so num()'s catch silently pinned the blocked
|
|
101
|
+
// counter to 0 and the ✖ segment could never render. Locked by
|
|
102
|
+
// tests/render-fixtures.test.ts.
|
|
103
|
+
let blockedThisSession = 0;
|
|
104
|
+
if (req.sessionId) {
|
|
105
|
+
blockedThisSession = num(db, `SELECT COALESCE(SUM(occurrences), 0) AS n FROM failure_patterns
|
|
106
|
+
WHERE last_seen >= (SELECT created_at FROM sessions WHERE id = ?)`, req.sessionId);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
blockedThisSession = num(db, `SELECT COALESCE(SUM(occurrences), 0) AS n FROM failure_patterns WHERE last_seen >= datetime('now', '-1 day')`);
|
|
110
|
+
}
|
|
111
|
+
// Context-token usage — best-effort from the latest wyrm_context_build call.
|
|
112
|
+
// Falls back to 0 if the log isn't populated yet.
|
|
113
|
+
let contextTokensUsed = 0;
|
|
114
|
+
const contextBudget = 200000; // 200K default; refined by spec 014 budget resolver
|
|
115
|
+
try {
|
|
116
|
+
const row = db.prepare(`
|
|
3
117
|
SELECT estimated_tokens AS n FROM token_savings_log
|
|
4
118
|
WHERE category = 'cached_preamble' AND session_id = ?
|
|
5
119
|
ORDER BY id DESC LIMIT 1
|
|
6
|
-
`).get(
|
|
120
|
+
`).get(req.sessionId ?? -1);
|
|
121
|
+
contextTokensUsed = row?.n ?? 0;
|
|
122
|
+
}
|
|
123
|
+
catch { /* table may not exist on older DBs */ }
|
|
124
|
+
// Tokens-saved counter (opt-in display, default-on tracking per spec).
|
|
125
|
+
let tokensSaved = null;
|
|
126
|
+
if (showSaved) {
|
|
127
|
+
try {
|
|
128
|
+
const row = db.prepare(`
|
|
7
129
|
SELECT COALESCE(SUM(estimated_tokens), 0) AS n FROM token_savings_log
|
|
8
130
|
WHERE session_id = ?
|
|
9
|
-
`).get(
|
|
131
|
+
`).get(req.sessionId ?? -1);
|
|
132
|
+
tokensSaved = row.n;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
tokensSaved = 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const parts = [`${brandMark} Wyrm`];
|
|
139
|
+
if (project)
|
|
140
|
+
parts.push(project.name);
|
|
141
|
+
parts.push(`${activeQuests} ${ICON.questOpen}`);
|
|
142
|
+
if (contextTokensUsed > 0)
|
|
143
|
+
parts.push(`${formatTokens(contextTokensUsed)}/${formatTokens(contextBudget)}`);
|
|
144
|
+
if (tokensSaved !== null && tokensSaved > 0)
|
|
145
|
+
parts.push(`~${formatTokens(tokensSaved)} saved`);
|
|
146
|
+
if (blockedThisSession > 0)
|
|
147
|
+
parts.push(`${ICON.blocked} ${blockedThisSession}`);
|
|
148
|
+
if (truthCount > 0)
|
|
149
|
+
parts.push(`${ICON.truth} ${truthCount}`);
|
|
150
|
+
return {
|
|
151
|
+
text: parts.join(' · '),
|
|
152
|
+
raw: { project: project?.name ?? null, activeQuests, contextTokensUsed, contextBudget, blockedThisSession, truthCount, tokensSaved },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function formatTokens(n) {
|
|
156
|
+
if (n >= 1000)
|
|
157
|
+
return `${Math.round(n / 100) / 10}k`;
|
|
158
|
+
return String(n);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Log a token-savings estimate for the current session.
|
|
162
|
+
* Safe no-op if migration 14 hasn't run yet (older DB).
|
|
163
|
+
*/
|
|
164
|
+
export function logSavings(db, toolName, category, estimatedTokens, sessionId, confidence = 'conservative') {
|
|
165
|
+
try {
|
|
166
|
+
db.prepare(`
|
|
10
167
|
INSERT INTO token_savings_log (session_id, tool_name, category, estimated_tokens, confidence)
|
|
11
168
|
VALUES (?, ?, ?, ?, ?)
|
|
12
|
-
`).run(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
169
|
+
`).run(sessionId ?? null, toolName, category, estimatedTokens, confidence);
|
|
170
|
+
}
|
|
171
|
+
catch { /* migration 14 may not have run; best-effort */ }
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Daemon — runs as a separate process, talks via Unix socket.
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
let idleTimer = null;
|
|
177
|
+
let server = null;
|
|
178
|
+
function resetIdleTimer() {
|
|
179
|
+
if (idleTimer)
|
|
180
|
+
clearTimeout(idleTimer);
|
|
181
|
+
idleTimer = setTimeout(() => {
|
|
182
|
+
try {
|
|
183
|
+
server?.close();
|
|
184
|
+
}
|
|
185
|
+
catch { /* swallow */ }
|
|
186
|
+
cleanup();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}, IDLE_TIMEOUT_MS);
|
|
189
|
+
}
|
|
190
|
+
function cleanup() {
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(SOCKET_PATH);
|
|
193
|
+
}
|
|
194
|
+
catch { /* fine if missing */ }
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(PID_PATH);
|
|
197
|
+
}
|
|
198
|
+
catch { /* fine */ }
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Run the statusline daemon. Listens on SOCKET_PATH, dispatches every
|
|
202
|
+
* incoming JSON request through computeStatusline(). Exits after
|
|
203
|
+
* IDLE_TIMEOUT_MS of no requests.
|
|
204
|
+
*/
|
|
205
|
+
export function runDaemon() {
|
|
206
|
+
const wyrmDir = pathJoin(homedir(), '.wyrm');
|
|
207
|
+
if (!existsSync(wyrmDir))
|
|
208
|
+
mkdirSync(wyrmDir, { recursive: true });
|
|
209
|
+
cleanup(); // clear stale socket if a prior daemon crashed
|
|
210
|
+
const wyrmDB = new WyrmDB(resolveDbPath());
|
|
211
|
+
const db = wyrmDB.getDatabase();
|
|
212
|
+
server = createServer((sock) => {
|
|
213
|
+
let buf = '';
|
|
214
|
+
sock.on('data', (chunk) => {
|
|
215
|
+
buf += chunk.toString();
|
|
216
|
+
const newlineIdx = buf.indexOf('\n');
|
|
217
|
+
if (newlineIdx === -1)
|
|
218
|
+
return;
|
|
219
|
+
const line = buf.slice(0, newlineIdx);
|
|
220
|
+
buf = buf.slice(newlineIdx + 1);
|
|
221
|
+
try {
|
|
222
|
+
const req = JSON.parse(line);
|
|
223
|
+
const resp = computeStatusline(db, req);
|
|
224
|
+
sock.write(`${JSON.stringify(resp)}\n`);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
sock.write(`${JSON.stringify({ text: `${brandMark} Wyrm`, error: String(e) })}\n`);
|
|
228
|
+
}
|
|
229
|
+
sock.end();
|
|
230
|
+
resetIdleTimer();
|
|
231
|
+
});
|
|
232
|
+
sock.on('error', () => { });
|
|
233
|
+
});
|
|
234
|
+
server.on('error', (e) => {
|
|
235
|
+
// EADDRINUSE means another daemon already owns the socket. Do NOT
|
|
236
|
+
// cleanup() — that would delete the working daemon's socket + PID
|
|
237
|
+
// files, killing the live instance. Just exit silently and let the
|
|
238
|
+
// existing daemon serve.
|
|
239
|
+
if (e.code === 'EADDRINUSE') {
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
console.error('wyrm-statusline-daemon: socket error', e);
|
|
243
|
+
cleanup();
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
|
246
|
+
server.listen(SOCKET_PATH, () => {
|
|
247
|
+
try {
|
|
248
|
+
writeFileSync(PID_PATH, String(process.pid));
|
|
249
|
+
}
|
|
250
|
+
catch { /* tolerant */ }
|
|
251
|
+
resetIdleTimer();
|
|
252
|
+
});
|
|
253
|
+
// Daemon exits cleanly on SIGTERM / SIGINT.
|
|
254
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
255
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Client-side: query a running daemon, spawn one if missing.
|
|
259
|
+
* Used by the wyrm-statusline binary.
|
|
260
|
+
*/
|
|
261
|
+
export async function queryDaemon(req) {
|
|
262
|
+
return new Promise((resolve) => {
|
|
263
|
+
const tryConnect = (attempt) => {
|
|
264
|
+
const sock = createConnection({ path: SOCKET_PATH }, () => {
|
|
265
|
+
let buf = '';
|
|
266
|
+
sock.on('data', (chunk) => {
|
|
267
|
+
buf += chunk.toString();
|
|
268
|
+
if (buf.includes('\n')) {
|
|
269
|
+
const line = buf.split('\n')[0];
|
|
270
|
+
try {
|
|
271
|
+
const resp = JSON.parse(line);
|
|
272
|
+
resolve(resp);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
resolve({ text: `${brandMark} Wyrm`, raw: { project: null, activeQuests: 0, contextTokensUsed: 0, contextBudget: 200000, blockedThisSession: 0, truthCount: 0, tokensSaved: null } });
|
|
276
|
+
}
|
|
277
|
+
sock.end();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
sock.write(`${JSON.stringify(req)}\n`);
|
|
281
|
+
});
|
|
282
|
+
sock.on('error', () => {
|
|
283
|
+
if (attempt === 0) {
|
|
284
|
+
// No daemon running — spawn one detached, then retry.
|
|
285
|
+
spawnDaemon();
|
|
286
|
+
setTimeout(() => tryConnect(1), 300);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Daemon didn't come up — return a degraded line rather than fail.
|
|
290
|
+
resolve({
|
|
291
|
+
text: `${brandMark} Wyrm`,
|
|
292
|
+
raw: { project: null, activeQuests: 0, contextTokensUsed: 0, contextBudget: 200000, blockedThisSession: 0, truthCount: 0, tokensSaved: null },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
tryConnect(0);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
function spawnDaemon(daemonPath) {
|
|
301
|
+
// The daemon ships as a sibling of the wyrm-statusline binary inside the
|
|
302
|
+
// package's dist/ directory. When the binary is installed globally, npm
|
|
303
|
+
// creates a symlink in <prefix>/bin → <prefix>/lib/node_modules/.../dist/.
|
|
304
|
+
// process.argv[1] usually resolves to the symlink path, so we follow the
|
|
305
|
+
// symlink with realpath to land in dist/ where the daemon actually lives.
|
|
306
|
+
let target = daemonPath;
|
|
307
|
+
if (!target) {
|
|
308
|
+
let argv1 = process.argv[1] || '';
|
|
309
|
+
try {
|
|
310
|
+
if (argv1)
|
|
311
|
+
argv1 = realpathSync(argv1);
|
|
312
|
+
}
|
|
313
|
+
catch { /* fine — fall through with raw argv1 */ }
|
|
314
|
+
target = pathJoin(pathJoin(argv1, '..'), 'wyrm-statusline-daemon.js');
|
|
315
|
+
}
|
|
316
|
+
const proc = spawn(process.execPath, [target], {
|
|
317
|
+
detached: true,
|
|
318
|
+
stdio: 'ignore',
|
|
319
|
+
});
|
|
320
|
+
proc.unref();
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=statusline.js.map
|
package/dist/sub-agent.js
CHANGED
|
@@ -1,43 +1,292 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent embedding — `wyrm_ask` (Tier 3.10).
|
|
3
|
+
*
|
|
4
|
+
* Wyrm stops being just an MCP server and becomes the agent that knows
|
|
5
|
+
* everything about itself. `wyrm_ask(query)` assembles relevant context
|
|
6
|
+
* from FTS + truths + sessions + symbols + failures, then runs the query
|
|
7
|
+
* through a chat LLM (Ollama auto-detected on localhost:11434, OpenAI
|
|
8
|
+
* fallback if `OPENAI_API_KEY` is set), and returns a grounded answer.
|
|
9
|
+
*
|
|
10
|
+
* If no LLM is reachable (no Ollama, no OpenAI key), Wyrm still returns
|
|
11
|
+
* the assembled context — the user gets the same data, the LLM step is
|
|
12
|
+
* just skipped. Degraded mode is explicit, not silent.
|
|
13
|
+
*
|
|
14
|
+
* Every invocation is logged to `llm_query_log` for cost tracking + answer
|
|
15
|
+
* quality analysis.
|
|
16
|
+
*
|
|
17
|
+
* @copyright 2026 Ghost Protocol (Pvt) Ltd.
|
|
18
|
+
* @license Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
|
|
19
|
+
*/
|
|
20
|
+
import { sanitizeFtsQuery } from './security.js';
|
|
21
|
+
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
|
22
|
+
const DEFAULT_OLLAMA_MODEL = 'llama3.2';
|
|
23
|
+
const DEFAULT_OPENAI_MODEL = 'gpt-4o-mini';
|
|
24
|
+
export class SubAgent {
|
|
25
|
+
db;
|
|
26
|
+
constructor(db) {
|
|
27
|
+
this.db = db;
|
|
28
|
+
}
|
|
29
|
+
/** Assemble the most relevant context pieces for a query. */
|
|
30
|
+
assembleContext(query, projectId, maxChars) {
|
|
31
|
+
const pieces = [];
|
|
32
|
+
let chars = 0;
|
|
33
|
+
const counts = {};
|
|
34
|
+
const push = (kind, text) => {
|
|
35
|
+
const len = text.length;
|
|
36
|
+
if (chars + len > maxChars)
|
|
37
|
+
return false;
|
|
38
|
+
pieces.push({ kind, text });
|
|
39
|
+
chars += len;
|
|
40
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
const ftsQuery = sanitizeFtsQuery(query.slice(0, 200));
|
|
44
|
+
// 1. Current ground truths for the project (most authoritative)
|
|
45
|
+
if (projectId != null) {
|
|
46
|
+
const truths = this.db.prepare(`
|
|
2
47
|
SELECT category, key, value, rationale, confidence
|
|
3
48
|
FROM ground_truths
|
|
4
49
|
WHERE project_id = ? AND is_current = 1
|
|
5
50
|
ORDER BY confidence DESC
|
|
6
51
|
LIMIT 30
|
|
7
|
-
`).all(
|
|
52
|
+
`).all(projectId);
|
|
53
|
+
for (const t of truths) {
|
|
54
|
+
const text = `[truth] ${t.category}.${t.key} = ${t.value}${t.rationale ? ` (${t.rationale})` : ''}`;
|
|
55
|
+
if (!push('truth', text))
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// 2. FTS-matched sessions (recent / relevant work logs)
|
|
60
|
+
if (ftsQuery) {
|
|
61
|
+
try {
|
|
62
|
+
const sessions = this.db.prepare(`
|
|
8
63
|
SELECT s.id, s.date, s.summary, s.objectives, s.completed
|
|
9
64
|
FROM sessions s
|
|
10
65
|
JOIN sessions_fts fts ON fts.rowid = s.id
|
|
11
66
|
WHERE sessions_fts MATCH ?
|
|
12
|
-
${
|
|
67
|
+
${projectId != null ? 'AND s.project_id = ?' : ''}
|
|
13
68
|
ORDER BY s.date DESC
|
|
14
69
|
LIMIT 8
|
|
15
|
-
`).all(...
|
|
70
|
+
`).all(...(projectId != null ? [ftsQuery, projectId] : [ftsQuery]));
|
|
71
|
+
for (const s of sessions) {
|
|
72
|
+
const blob = [s.summary, s.completed, s.objectives].filter(Boolean).join(' ').slice(0, 600);
|
|
73
|
+
if (blob && !push('session', `[session#${s.id} ${s.date}] ${blob}`))
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch { /* FTS table may not exist on very old DBs */ }
|
|
78
|
+
}
|
|
79
|
+
// 3. Memory artifacts (validated patterns) that match
|
|
80
|
+
if (ftsQuery) {
|
|
81
|
+
try {
|
|
82
|
+
const arts = this.db.prepare(`
|
|
16
83
|
SELECT ma.id, ma.problem, ma.validated_fix, ma.why_it_worked
|
|
17
84
|
FROM memory_artifacts ma
|
|
18
85
|
JOIN memory_artifacts_fts fts ON fts.rowid = ma.id
|
|
19
86
|
WHERE memory_artifacts_fts MATCH ?
|
|
20
|
-
${
|
|
87
|
+
${projectId != null ? 'AND ma.project_id = ?' : ''}
|
|
21
88
|
AND (ma.needs_review = 0 OR ma.needs_review IS NULL)
|
|
22
89
|
LIMIT 6
|
|
23
|
-
`).all(...
|
|
90
|
+
`).all(...(projectId != null ? [ftsQuery, projectId] : [ftsQuery]));
|
|
91
|
+
for (const a of arts) {
|
|
92
|
+
const text = `[artifact#${a.id}] ${a.problem} → ${a.validated_fix ?? ''}${a.why_it_worked ? ` (${a.why_it_worked})` : ''}`;
|
|
93
|
+
if (!push('artifact', text.slice(0, 500)))
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* table or FTS may not exist */ }
|
|
98
|
+
}
|
|
99
|
+
// 4. Unresolved failure patterns (counter-examples — "don't try this")
|
|
100
|
+
if (projectId != null) {
|
|
101
|
+
try {
|
|
102
|
+
const fails = this.db.prepare(`
|
|
24
103
|
SELECT id, scope, target, description, why_failed
|
|
25
104
|
FROM failure_patterns
|
|
26
105
|
WHERE resolved = 0 AND (project_id = ? OR project_id IS NULL)
|
|
27
106
|
ORDER BY occurrences DESC, last_seen DESC
|
|
28
107
|
LIMIT 5
|
|
29
|
-
`).all(
|
|
108
|
+
`).all(projectId);
|
|
109
|
+
for (const f of fails) {
|
|
110
|
+
const text = `[failure#${f.id} avoid] ${f.scope}:${f.target} — ${f.description}${f.why_failed ? ` (${f.why_failed})` : ''}`;
|
|
111
|
+
if (!push('failure', text.slice(0, 400)))
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch { /* table may not exist on pre-v3.9 DBs */ }
|
|
116
|
+
}
|
|
117
|
+
// 5. Symbol matches (cross-repo definitions)
|
|
118
|
+
try {
|
|
119
|
+
const symLike = `%${query.replace(/[%_]/g, '').slice(0, 50)}%`;
|
|
120
|
+
const syms = this.db.prepare(`
|
|
30
121
|
SELECT symbol, kind, language, file_path, line, signature
|
|
31
122
|
FROM symbol_index
|
|
32
123
|
WHERE symbol LIKE ?
|
|
33
|
-
${
|
|
124
|
+
${projectId != null ? 'AND project_id = ?' : ''}
|
|
34
125
|
LIMIT 10
|
|
35
|
-
`).all(...
|
|
36
|
-
|
|
37
|
-
|
|
126
|
+
`).all(...(projectId != null ? [symLike, projectId] : [symLike]));
|
|
127
|
+
for (const s of syms) {
|
|
128
|
+
const text = `[symbol ${s.language}:${s.kind}] ${s.symbol} @ ${s.file_path}:${s.line}`;
|
|
129
|
+
if (!push('symbol', text))
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { /* table may not exist on pre-v3.9 DBs */ }
|
|
134
|
+
const summary = Object.entries(counts)
|
|
135
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
136
|
+
.join(',');
|
|
137
|
+
return { pieces, summary, chars };
|
|
138
|
+
}
|
|
139
|
+
/** Run an LLM completion against Ollama with the assembled context.
|
|
140
|
+
* Returns null on connection failure so caller can fall back. */
|
|
141
|
+
async tryOllama(query, context, url, model) {
|
|
142
|
+
const prompt = this.buildPrompt(query, context);
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${url.replace(/\/$/, '')}/api/generate`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({ model, prompt, stream: false }),
|
|
148
|
+
signal: AbortSignal.timeout(60_000),
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
return null;
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
if (!data.response)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
answer: data.response.trim(),
|
|
157
|
+
tokens_in: data.prompt_eval_count,
|
|
158
|
+
tokens_out: data.eval_count,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Run an LLM completion against OpenAI chat API. */
|
|
166
|
+
async tryOpenAI(query, context, apiKey, model) {
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/json',
|
|
172
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
model,
|
|
176
|
+
messages: [
|
|
177
|
+
{ role: 'system', content: 'You are Wyrm, a project-memory assistant. Answer the user\'s question using ONLY the supplied context. If the context is insufficient, say so explicitly. Be terse.' },
|
|
178
|
+
{ role: 'user', content: this.buildPrompt(query, context) },
|
|
179
|
+
],
|
|
180
|
+
temperature: 0.2,
|
|
181
|
+
}),
|
|
182
|
+
signal: AbortSignal.timeout(60_000),
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok)
|
|
185
|
+
return null;
|
|
186
|
+
const data = await res.json();
|
|
187
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
188
|
+
if (!text)
|
|
189
|
+
return null;
|
|
190
|
+
return {
|
|
191
|
+
answer: text,
|
|
192
|
+
tokens_in: data.usage?.prompt_tokens,
|
|
193
|
+
tokens_out: data.usage?.completion_tokens,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
buildPrompt(query, context) {
|
|
201
|
+
return [
|
|
202
|
+
'You are answering a question using the project memory below.',
|
|
203
|
+
'Cite specific items by their bracketed tags (e.g. [truth], [session#42], [failure#7]).',
|
|
204
|
+
'If the context does not contain the answer, say so plainly.',
|
|
205
|
+
'',
|
|
206
|
+
'=== CONTEXT ===',
|
|
207
|
+
context,
|
|
208
|
+
'=== END CONTEXT ===',
|
|
209
|
+
'',
|
|
210
|
+
`Question: ${query}`,
|
|
211
|
+
'',
|
|
212
|
+
'Answer:',
|
|
213
|
+
].join('\n');
|
|
214
|
+
}
|
|
215
|
+
/** Main entry — query → context → LLM → answer. */
|
|
216
|
+
async ask(input) {
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
const maxChars = Math.min(Math.max(2_000, input.max_context_chars ?? 12_000), 50_000);
|
|
219
|
+
const { pieces, summary, chars } = this.assembleContext(input.query, input.project_id, maxChars);
|
|
220
|
+
const contextText = pieces.map(p => p.text).join('\n');
|
|
221
|
+
// Resolve model preference
|
|
222
|
+
const override = input.model_override?.split(':') ?? [];
|
|
223
|
+
const preferKind = override[0]; // 'ollama' | 'openai' | undefined
|
|
224
|
+
const preferModel = override[1];
|
|
225
|
+
const ollamaUrl = input.ollama_url ?? process.env.OLLAMA_URL ?? DEFAULT_OLLAMA_URL;
|
|
226
|
+
const openaiKey = input.openai_api_key ?? process.env.OPENAI_API_KEY;
|
|
227
|
+
let llmResult = null;
|
|
228
|
+
let modelUsed = 'none';
|
|
229
|
+
// Try preferred kind first, fall back to the other.
|
|
230
|
+
const tryOrder = preferKind === 'openai'
|
|
231
|
+
? ['openai', 'ollama']
|
|
232
|
+
: ['ollama', 'openai'];
|
|
233
|
+
for (const kind of tryOrder) {
|
|
234
|
+
if (llmResult)
|
|
235
|
+
break;
|
|
236
|
+
if (kind === 'ollama') {
|
|
237
|
+
const model = preferKind === 'ollama' ? (preferModel || DEFAULT_OLLAMA_MODEL) : DEFAULT_OLLAMA_MODEL;
|
|
238
|
+
const r = await this.tryOllama(input.query, contextText, ollamaUrl, model);
|
|
239
|
+
if (r) {
|
|
240
|
+
llmResult = r;
|
|
241
|
+
modelUsed = `ollama:${model}`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (kind === 'openai' && openaiKey) {
|
|
245
|
+
const model = preferKind === 'openai' ? (preferModel || DEFAULT_OPENAI_MODEL) : DEFAULT_OPENAI_MODEL;
|
|
246
|
+
const r = await this.tryOpenAI(input.query, contextText, openaiKey, model);
|
|
247
|
+
if (r) {
|
|
248
|
+
llmResult = r;
|
|
249
|
+
modelUsed = `openai:${model}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const latency = Date.now() - start;
|
|
254
|
+
const degraded = llmResult === null;
|
|
255
|
+
const answer = llmResult?.answer ?? this.degradedAnswer(input.query, pieces);
|
|
256
|
+
// Log to llm_query_log (fire-and-forget)
|
|
257
|
+
try {
|
|
258
|
+
this.db.prepare(`
|
|
38
259
|
INSERT INTO llm_query_log
|
|
39
260
|
(query, project_id, context_summary, context_chars, response,
|
|
40
261
|
model, tokens_in, tokens_out, latency_ms, error_message)
|
|
41
262
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
42
|
-
`).run(
|
|
43
|
-
|
|
263
|
+
`).run(input.query.slice(0, 1000), input.project_id ?? null, summary, chars, answer.slice(0, 4000), modelUsed, llmResult?.tokens_in ?? null, llmResult?.tokens_out ?? null, latency, degraded ? 'No LLM available — degraded to context-only' : null);
|
|
264
|
+
}
|
|
265
|
+
catch { /* logging never breaks the answer path */ }
|
|
266
|
+
return {
|
|
267
|
+
query: input.query,
|
|
268
|
+
answer,
|
|
269
|
+
model: modelUsed,
|
|
270
|
+
context_summary: summary,
|
|
271
|
+
context_chars: chars,
|
|
272
|
+
latency_ms: latency,
|
|
273
|
+
tokens_in: llmResult?.tokens_in,
|
|
274
|
+
tokens_out: llmResult?.tokens_out,
|
|
275
|
+
degraded,
|
|
276
|
+
error: degraded ? 'No LLM available (no Ollama on localhost:11434 and no OPENAI_API_KEY). Returning raw context.' : undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Fallback when no LLM is reachable — return the assembled context
|
|
280
|
+
* directly so the caller still gets the data, just unprocessed. */
|
|
281
|
+
degradedAnswer(query, pieces) {
|
|
282
|
+
if (pieces.length === 0)
|
|
283
|
+
return `_No matching context found in Wyrm for "${query}". No LLM available to synthesize._`;
|
|
284
|
+
const lines = [];
|
|
285
|
+
lines.push(`_LLM not available — returning ${pieces.length} matched context pieces:_`);
|
|
286
|
+
lines.push('');
|
|
287
|
+
for (const p of pieces)
|
|
288
|
+
lines.push(p.text);
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
//# sourceMappingURL=sub-agent.js.map
|