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