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