@ijfw/memory-server 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,895 @@
1
+ /**
2
+ * IJFW Dashboard Server -- Wave V1.1I
3
+ * Serves the single-file HTML dashboard + SSE stream from observations.jsonl.
4
+ * Adds cost tracking + savings + memory search endpoints.
5
+ * Credit: ccusage (ryoppippi, MIT), tokscale (junhoyeo, MIT), CodeBurn (AgentSeal, MIT).
6
+ * Zero deps. node:http, node:fs, node:path, node:os, node:url only.
7
+ */
8
+
9
+ import { createServer } from 'node:http';
10
+ import { existsSync, readFileSync, watch, writeFileSync, mkdirSync, readdirSync, statSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { homedir } from 'node:os';
13
+ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ import { buildCostReport, buildBreakdown, buildDailySeries, buildBlockUsage, getSavingsMethodology } from './cost/aggregator.js';
17
+ import { ttlCache } from './lib/cache.js';
18
+ import { getPricesTable } from './cost/pricing.js';
19
+ import { computeValueDelivered } from './cost/savings.js';
20
+ import { listMemoryFiles, listKnownProjects } from './memory/reader.js';
21
+ import { searchMemory } from './memory/search.js';
22
+ import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ // REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
26
+ // Under npm install the package lands in ~/.ijfw/; PWD keeps memory walks in the user's actual project.
27
+ const REPO_ROOT = process.env.IJFW_PROJECT_ROOT
28
+ || process.env.PWD
29
+ || process.cwd();
30
+
31
+ // Read version dynamically from mcp-server/package.json so bumps don't drift.
32
+ const PKG_VERSION = (() => {
33
+ try { return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version; }
34
+ catch { return 'unknown'; }
35
+ })();
36
+ const HTML_PATH = join(__dirname, 'dashboard-client.html');
37
+
38
+ const COST_CACHE_TTL = 30_000; // 30s
39
+
40
+ // Returns mtime of ~/.ijfw/metrics/sessions.jsonl (cross-platform session ledger), or 0 if absent.
41
+ function ijfwLedgerMtime() {
42
+ try {
43
+ return statSync(join(homedir(), '.ijfw', 'metrics', 'sessions.jsonl')).mtimeMs;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ // Build invalidator key: "<latest mtime in ms>:<jsonl file count>:<ledger mtime>" across ~/.claude/projects/
50
+ // Distinguishes empty ('0:0:empty') from walk error ('0:0:err:<code>') to avoid cache collisions.
51
+ // Ledger mtime included so Codex/Gemini session writes bust this cache immediately (W9-H2).
52
+ function claudeProjectsMtimeKey() {
53
+ const projectsDir = join(homedir(), '.claude', 'projects');
54
+ if (!existsSync(projectsDir)) return `0:0:empty:${ijfwLedgerMtime()}`;
55
+ try {
56
+ let latestMtime = 0;
57
+ let fileCount = 0;
58
+ for (const entry of readdirSync(projectsDir)) {
59
+ const projPath = join(projectsDir, entry);
60
+ let pStat;
61
+ try { pStat = statSync(projPath); } catch { continue; }
62
+ if (!pStat.isDirectory()) continue;
63
+ for (const file of readdirSync(projPath)) {
64
+ if (!file.endsWith('.jsonl')) continue;
65
+ fileCount++;
66
+ try {
67
+ const m = statSync(join(projPath, file)).mtimeMs;
68
+ if (m > latestMtime) latestMtime = m;
69
+ } catch {}
70
+ }
71
+ }
72
+ if (fileCount === 0) return `0:0:empty:${ijfwLedgerMtime()}`;
73
+ return `${latestMtime}:${fileCount}:${ijfwLedgerMtime()}`;
74
+ } catch (err) {
75
+ return `0:0:err:${err.code || 'UNKNOWN'}:${ijfwLedgerMtime()}`;
76
+ }
77
+ }
78
+
79
+ // Build invalidator key for memory dirs: "<latestMtime>:<fileCount>"
80
+ // Walks all sources that listMemoryFiles() reads:
81
+ // ~/.ijfw/memory/, ~/.ijfw/sessions/, ~/.ijfw/observations.jsonl, ~/.ijfw/HANDOFF.md,
82
+ // ~/.claude/projects/<slug>/memory/, and <REPO_ROOT>/.ijfw/memory/
83
+ function memoryDirsMtimeKey() {
84
+ let latestMtime = 0;
85
+ let fileCount = 0;
86
+
87
+ function statFile(fp) {
88
+ try {
89
+ const s = statSync(fp);
90
+ fileCount++;
91
+ if (s.mtimeMs > latestMtime) latestMtime = s.mtimeMs;
92
+ } catch {}
93
+ }
94
+
95
+ function walkDir(dir) {
96
+ if (!existsSync(dir)) return;
97
+ try {
98
+ for (const entry of readdirSync(dir)) {
99
+ statFile(join(dir, entry));
100
+ }
101
+ } catch {}
102
+ }
103
+
104
+ walkDir(join(homedir(), '.ijfw', 'memory'));
105
+ walkDir(join(homedir(), '.ijfw', 'sessions'));
106
+ statFile(join(homedir(), '.ijfw', 'observations.jsonl'));
107
+ statFile(join(homedir(), '.ijfw', 'HANDOFF.md'));
108
+
109
+ const projectsDir = join(homedir(), '.claude', 'projects');
110
+ if (existsSync(projectsDir)) {
111
+ try {
112
+ for (const slug of readdirSync(projectsDir)) {
113
+ walkDir(join(projectsDir, slug, 'memory'));
114
+ }
115
+ } catch {}
116
+ }
117
+
118
+ walkDir(join(REPO_ROOT, '.ijfw', 'memory'));
119
+
120
+ // Include cross-platform session ledger so non-Claude session writes bust this cache (W9-H2).
121
+ const ledgerMtime = ijfwLedgerMtime();
122
+ if (ledgerMtime > 0) {
123
+ fileCount++;
124
+ if (ledgerMtime > latestMtime) latestMtime = ledgerMtime;
125
+ }
126
+
127
+ return `${latestMtime}:${fileCount}`;
128
+ }
129
+
130
+ const DEFAULT_PORT = 37891;
131
+ const PORT_WALK_MAX = 10; // walk up to 37891+PORT_WALK_MAX (37900)
132
+ const BACKFILL_DEFAULT = 200;
133
+ const BACKFILL_CAP = 50; // max observations sent on fresh connect (W4.6)
134
+
135
+ // ---------- integer param validator ----------
136
+ // Rejects numeric-prefix garbage like "10xyz" that parseInt accepts (W9-M2).
137
+ // Returns null on invalid input; caller should respond with 400.
138
+ function safeIntegerParam(value, max = Number.MAX_SAFE_INTEGER) {
139
+ if (typeof value !== 'string') return null;
140
+ if (!/^(0|[1-9]\d*)$/.test(value)) return null;
141
+ const n = Number.parseInt(value, 10);
142
+ if (n > max) return null;
143
+ return n;
144
+ }
145
+
146
+ // ---------- localhost guard ----------
147
+ function requireLocalhost(req, res) {
148
+ const addr = req.socket.remoteAddress;
149
+ if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') return true;
150
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
151
+ res.end('403 Forbidden -- localhost only');
152
+ return false;
153
+ }
154
+
155
+ // ---------- simple router ----------
156
+ function route(req, res, routes) {
157
+ const url = new URL(req.url, 'http://localhost');
158
+ const path = url.pathname;
159
+ for (const [pattern, handler] of routes) {
160
+ if (typeof pattern === 'string' ? path === pattern : pattern.test(path)) {
161
+ handler(req, res, url);
162
+ return;
163
+ }
164
+ }
165
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
166
+ res.end('404 Not Found');
167
+ }
168
+
169
+ // ---------- JSONL reader ----------
170
+ function readObservations(ledgerPath) {
171
+ if (!existsSync(ledgerPath)) return [];
172
+ try {
173
+ return readFileSync(ledgerPath, 'utf8')
174
+ .split('\n')
175
+ .filter(Boolean)
176
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
177
+ .filter(Boolean);
178
+ } catch {
179
+ return [];
180
+ }
181
+ }
182
+
183
+ function filterObservations(obs, params) {
184
+ let result = obs;
185
+ const platform = params.get('platform');
186
+ const since = params.get('since');
187
+ const limit = parseInt(params.get('limit') || '200', 10);
188
+
189
+ if (platform) result = result.filter(o => o.platform === platform);
190
+ if (since) result = result.filter(o => o.id > parseInt(since, 10));
191
+ return result.slice(-limit);
192
+ }
193
+
194
+ // ---------- SSE broadcaster ----------
195
+ function makeBroadcaster() {
196
+ const clients = new Set();
197
+ let debounceTimer = null;
198
+ let pendingLines = [];
199
+
200
+ function flush() {
201
+ if (pendingLines.length === 0) return;
202
+ const toSend = pendingLines.slice();
203
+ pendingLines = [];
204
+ for (const res of clients) {
205
+ try {
206
+ for (const { id, data } of toSend) {
207
+ res.write(`id: ${id}\ndata: ${data}\n\n`);
208
+ }
209
+ } catch {
210
+ clients.delete(res);
211
+ }
212
+ }
213
+ }
214
+
215
+ function push(id, jsonLine) {
216
+ pendingLines.push({ id, data: jsonLine });
217
+ clearTimeout(debounceTimer);
218
+ debounceTimer = setTimeout(flush, 50);
219
+ }
220
+
221
+ function add(res) { clients.add(res); }
222
+ function remove(res) { clients.delete(res); }
223
+
224
+ function closeAll() {
225
+ clearTimeout(debounceTimer);
226
+ for (const res of clients) {
227
+ try {
228
+ res.write('event: close\ndata: shutdown\n\n');
229
+ res.end();
230
+ } catch {}
231
+ }
232
+ clients.clear();
233
+ }
234
+
235
+ function size() { return clients.size; }
236
+
237
+ return { push, add, remove, closeAll, size };
238
+ }
239
+
240
+ // ---------- file watcher with tail ----------
241
+ function makeWatcher(ledgerPath, broadcaster) {
242
+ let lastLineCount = 0;
243
+ let watcher = null;
244
+ let pollTimer = null;
245
+
246
+ function tail() {
247
+ if (!existsSync(ledgerPath)) return;
248
+ try {
249
+ const lines = readFileSync(ledgerPath, 'utf8').split('\n').filter(Boolean);
250
+ if (lines.length > lastLineCount) {
251
+ const newLines = lines.slice(lastLineCount);
252
+ for (const line of newLines) {
253
+ try {
254
+ const obj = JSON.parse(line);
255
+ broadcaster.push(obj.id ?? (lastLineCount + 1), line);
256
+ } catch {}
257
+ }
258
+ lastLineCount = lines.length;
259
+ }
260
+ } catch {}
261
+ }
262
+
263
+ // Seed initial count
264
+ if (existsSync(ledgerPath)) {
265
+ try {
266
+ lastLineCount = readFileSync(ledgerPath, 'utf8').split('\n').filter(Boolean).length;
267
+ } catch {}
268
+ }
269
+
270
+ function startWatcher() {
271
+ if (!existsSync(ledgerPath)) return;
272
+ try {
273
+ watcher = watch(ledgerPath, () => tail());
274
+ watcher.on('error', () => { /* poll fallback below covers Windows EPERM */ });
275
+ } catch {
276
+ watcher = null;
277
+ }
278
+ }
279
+
280
+ // 2s poll fallback in case fs.watch is unreliable
281
+ pollTimer = setInterval(tail, 2000);
282
+
283
+ startWatcher();
284
+
285
+ function stop() {
286
+ clearInterval(pollTimer);
287
+ if (watcher) { try { watcher.close(); } catch {} }
288
+ }
289
+
290
+ return { stop, tail };
291
+ }
292
+
293
+ // ---------- backfill SSE ----------
294
+ // offset: explicit index-based resume (takes precedence over lastEventId when set).
295
+ async function backfillSSE(res, ledgerPath, lastEventId, backfillCount, offset = null) {
296
+ if (!existsSync(ledgerPath)) return 0;
297
+ const obs = readObservations(ledgerPath);
298
+
299
+ let start;
300
+ if (offset !== null) {
301
+ // Explicit offset-based resume: slice from that index, cap at BACKFILL_CAP.
302
+ start = Math.min(offset, obs.length);
303
+ } else if (lastEventId) {
304
+ // Resume from after the last seen event by id.
305
+ start = obs.findIndex(o => o.id === lastEventId) + 1;
306
+ } else {
307
+ // Fresh connect: cap at BACKFILL_CAP regardless of caller-requested backfillCount (W4.6).
308
+ const cap = Math.min(backfillCount, BACKFILL_CAP);
309
+ start = Math.max(0, obs.length - cap);
310
+ // Emit sentinel so the client knows history was truncated.
311
+ if (obs.length > cap) {
312
+ try {
313
+ const nextOffset = start + cap;
314
+ const sentinel = JSON.stringify({ showing: cap, total: obs.length, next: nextOffset });
315
+ res.write(`event: history-truncated\ndata: ${sentinel}\n\n`);
316
+ } catch {
317
+ return -1;
318
+ }
319
+ }
320
+ }
321
+
322
+ const toSend = obs.slice(start, start + BACKFILL_CAP);
323
+ for (const o of toSend) {
324
+ try {
325
+ res.write(`id: ${o.id}\ndata: ${JSON.stringify(o)}\n\n`);
326
+ } catch {
327
+ return -1;
328
+ }
329
+ }
330
+ return toSend.length;
331
+ }
332
+
333
+ // ---------- main export ----------
334
+ export async function startServer(options = {}) {
335
+ const {
336
+ ledgerPath = join(homedir(), '.ijfw', 'observations.jsonl'),
337
+ port: preferredPort = DEFAULT_PORT,
338
+ maxPort,
339
+ version = PKG_VERSION,
340
+ } = options;
341
+
342
+ // Walk up to PORT_WALK_MAX ports from preferredPort.
343
+ // When preferredPort is DEFAULT_PORT, this gives the canonical 37891-37900 range.
344
+ const portCeiling = maxPort ?? (preferredPort + PORT_WALK_MAX - 1);
345
+
346
+ const broadcaster = makeBroadcaster();
347
+ const watcher = makeWatcher(ledgerPath, broadcaster);
348
+
349
+ const startTime = Date.now();
350
+
351
+ // Lazily read HTML -- handle both: serving from source and from bundled context.
352
+ let htmlContent = null;
353
+ async function getHtml() {
354
+ if (htmlContent) return htmlContent;
355
+ try {
356
+ htmlContent = await readFile(HTML_PATH, 'utf8');
357
+ } catch {
358
+ htmlContent = '<html><body>Dashboard UI not found. Run from IJFW repo.</body></html>';
359
+ }
360
+ return htmlContent;
361
+ }
362
+
363
+ const routes = [
364
+ ['/', async (req, res) => {
365
+ const html = await getHtml();
366
+ res.writeHead(200, {
367
+ 'Content-Type': 'text/html; charset=utf-8',
368
+ 'Cache-Control': 'no-store',
369
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
370
+ });
371
+ res.end(html);
372
+ }],
373
+
374
+ ['/api/observations', (req, res, url) => {
375
+ const obs = readObservations(ledgerPath);
376
+ const filtered = filterObservations(obs, url.searchParams);
377
+ res.writeHead(200, { 'Content-Type': 'application/json' });
378
+ res.end(JSON.stringify(filtered));
379
+ }],
380
+
381
+ ['/api/summary', (req, res) => {
382
+ const summaryPath = join(dirname(ledgerPath), 'session_summaries.jsonl');
383
+ let summary = null;
384
+ if (existsSync(summaryPath)) {
385
+ try {
386
+ const lines = readFileSync(summaryPath, 'utf8').split('\n').filter(Boolean);
387
+ if (lines.length) summary = JSON.parse(lines[lines.length - 1]);
388
+ } catch {}
389
+ }
390
+ res.writeHead(200, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify(summary));
392
+ }],
393
+
394
+ ['/api/economics', (req, res) => {
395
+ const obs = readObservations(ledgerPath);
396
+ const totalTokens = obs.reduce((s, o) => s + (o.token_cost || 0), 0);
397
+ const workTokens = obs.reduce((s, o) => s + (o.work_tokens || 0), 0);
398
+ res.writeHead(200, { 'Content-Type': 'application/json' });
399
+ res.end(JSON.stringify({ count: obs.length, totalTokens, workTokens }));
400
+ }],
401
+
402
+ ['/api/health', (req, res) => {
403
+ const obs = readObservations(ledgerPath);
404
+ res.writeHead(200, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({
406
+ ok: true,
407
+ status: 'ok',
408
+ version,
409
+ uptime: Math.floor((Date.now() - startTime) / 1000),
410
+ ledgerPath,
411
+ obsCount: obs.length,
412
+ }));
413
+ }],
414
+
415
+ ['/api/memory/file', (req, res, url) => {
416
+ const rawPath = url.searchParams.get('path') || '';
417
+ if (!rawPath) {
418
+ res.writeHead(403, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify({ error: 'outside allowed memory dir' }));
420
+ return;
421
+ }
422
+ const reqPath = resolve(rawPath);
423
+
424
+ // Security: use path.relative() for prefix check -- works on Windows (backslash) and POSIX.
425
+ // If realpathSync throws (path doesn't exist), return 404 rather than 500.
426
+ function canonOrNull(p) {
427
+ try { return realpathSync(p); } catch { return null; }
428
+ }
429
+ function isUnder(allowedRoot, canonChild) {
430
+ const canonRoot = canonOrNull(allowedRoot);
431
+ if (!canonRoot || !canonChild) return false;
432
+ const rel = relative(canonRoot, canonChild);
433
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
434
+ }
435
+
436
+ const canonPath = canonOrNull(reqPath);
437
+ if (!canonPath) {
438
+ res.writeHead(404, { 'Content-Type': 'application/json' });
439
+ res.end(JSON.stringify({ error: 'file not found' }));
440
+ return;
441
+ }
442
+
443
+ const allowed = (
444
+ isUnder(join(homedir(), '.ijfw'), canonPath) ||
445
+ isUnder(join(homedir(), '.claude', 'projects'), canonPath) ||
446
+ isUnder(join(REPO_ROOT, '.ijfw'), canonPath)
447
+ );
448
+ if (!allowed) {
449
+ res.writeHead(403, { 'Content-Type': 'application/json' });
450
+ res.end(JSON.stringify({ error: 'outside allowed memory dir' }));
451
+ return;
452
+ }
453
+ try {
454
+ const body = readFileSync(canonPath, 'utf8');
455
+ res.writeHead(200, { 'Content-Type': 'application/json' });
456
+ res.end(JSON.stringify({ body: body.slice(0, 10000) }));
457
+ } catch (err) {
458
+ res.writeHead(500, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/memory/file' }));
460
+ }
461
+ }],
462
+
463
+ // ---------- cost endpoints ----------
464
+ ['/api/cost/today', (req, res) => {
465
+ try {
466
+ // cache key: fixed 1-day window; invalidates when JSONL count or latest mtime changes
467
+ const result = ttlCache('cost:today', COST_CACHE_TTL, claudeProjectsMtimeKey, () => {
468
+ const obs = readObservations(ledgerPath);
469
+ return buildCostReport(1, obs);
470
+ });
471
+ res.writeHead(200, { 'Content-Type': 'application/json' });
472
+ res.end(JSON.stringify(result));
473
+ } catch (err) {
474
+ res.writeHead(500, { 'Content-Type': 'application/json' });
475
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/cost/today' }));
476
+ }
477
+ }],
478
+
479
+ ['/api/cost/period', (req, res, url) => {
480
+ try {
481
+ const days = parseInt(url.searchParams.get('days') || '7', 10);
482
+ // cache key: per-days-param window; invalidates when JSONL count or latest mtime changes
483
+ const result = ttlCache(`cost:period:${days}`, COST_CACHE_TTL, claudeProjectsMtimeKey, () => {
484
+ const obs = readObservations(ledgerPath);
485
+ return buildCostReport(days, obs);
486
+ });
487
+ res.writeHead(200, { 'Content-Type': 'application/json' });
488
+ res.end(JSON.stringify(result));
489
+ } catch (err) {
490
+ res.writeHead(500, { 'Content-Type': 'application/json' });
491
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/cost/period' }));
492
+ }
493
+ }],
494
+
495
+ ['/api/cost/by', (req, res, url) => {
496
+ try {
497
+ const dim = url.searchParams.get('dim') || 'platform';
498
+ const period = url.searchParams.get('period') || '7d';
499
+ const days = parseInt(period.replace(/\D/g, '') || '7', 10);
500
+ // cache key: per-dim+period breakdown; invalidates when JSONL count or latest mtime changes
501
+ const result = ttlCache(`cost:by:${dim}:${days}`, COST_CACHE_TTL, claudeProjectsMtimeKey, () => {
502
+ const obs = readObservations(ledgerPath);
503
+ return buildBreakdown(dim, days, obs);
504
+ });
505
+ res.writeHead(200, { 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify(result));
507
+ } catch (err) {
508
+ res.writeHead(500, { 'Content-Type': 'application/json' });
509
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/cost/by' }));
510
+ }
511
+ }],
512
+
513
+ ['/api/cost/block', (req, res) => {
514
+ try {
515
+ // cache key: 5-hour rolling window; invalidates when JSONL count or latest mtime changes
516
+ const result = ttlCache('cost:block', COST_CACHE_TTL, claudeProjectsMtimeKey, () => buildBlockUsage());
517
+ res.writeHead(200, { 'Content-Type': 'application/json' });
518
+ res.end(JSON.stringify(result));
519
+ } catch (err) {
520
+ res.writeHead(500, { 'Content-Type': 'application/json' });
521
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/cost/block' }));
522
+ }
523
+ }],
524
+
525
+ ['/api/cost/history', (req, res, url) => {
526
+ try {
527
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
528
+ // cache key: per-days daily series; invalidates when JSONL count or latest mtime changes
529
+ const result = ttlCache(`cost:history:${days}`, COST_CACHE_TTL, claudeProjectsMtimeKey, () => buildDailySeries(days));
530
+ res.writeHead(200, { 'Content-Type': 'application/json' });
531
+ res.end(JSON.stringify(result));
532
+ } catch (err) {
533
+ res.writeHead(500, { 'Content-Type': 'application/json' });
534
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/cost/history' }));
535
+ }
536
+ }],
537
+
538
+ ['/api/prices', (req, res) => {
539
+ try {
540
+ const table = getPricesTable();
541
+ res.writeHead(200, { 'Content-Type': 'application/json' });
542
+ res.end(JSON.stringify(table));
543
+ } catch (err) {
544
+ res.writeHead(500, { 'Content-Type': 'application/json' });
545
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/prices' }));
546
+ }
547
+ }],
548
+
549
+ ['/api/savings/methodology', (req, res) => {
550
+ try {
551
+ const methodology = getSavingsMethodology();
552
+ res.writeHead(200, { 'Content-Type': 'application/json' });
553
+ res.end(JSON.stringify(methodology));
554
+ } catch (err) {
555
+ res.writeHead(500, { 'Content-Type': 'application/json' });
556
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/savings/methodology' }));
557
+ }
558
+ }],
559
+
560
+ // ---------- memory endpoints ----------
561
+ ['/api/memory', (req, res, url) => {
562
+ try {
563
+ const tierFilter = url.searchParams.get('tier') || null;
564
+ const result = ttlCache(`memory:list:${tierFilter}`, COST_CACHE_TTL, memoryDirsMtimeKey, () => {
565
+ const { files, total, root, tiers } = listMemoryFiles(REPO_ROOT, tierFilter);
566
+ const { counts, weekCounts, totalThisWeek } = buildRecallCounts(ledgerPath);
567
+ const enriched = mergeRecallCounts(files, counts, weekCounts);
568
+ return { files: enriched, total, root, tiers, totalRecallsThisWeek: totalThisWeek };
569
+ });
570
+ res.writeHead(200, { 'Content-Type': 'application/json' });
571
+ res.end(JSON.stringify(result));
572
+ } catch (err) {
573
+ res.writeHead(500, { 'Content-Type': 'application/json' });
574
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/memory' }));
575
+ }
576
+ }],
577
+
578
+ ['/api/memory/search', (req, res, url) => {
579
+ try {
580
+ const q = url.searchParams.get('q') || '';
581
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
582
+ const { files } = listMemoryFiles(REPO_ROOT);
583
+ const { counts, weekCounts } = buildRecallCounts(ledgerPath);
584
+ const enriched = mergeRecallCounts(files, counts, weekCounts);
585
+ const results = searchMemory(q, enriched, limit);
586
+ // Merge recall counts into search results
587
+ const resultMap = new Map(enriched.map(f => [f.path, f]));
588
+ const withCounts = results.map(r => ({
589
+ ...r,
590
+ recall_count: resultMap.get(r.path)?.recall_count || 0,
591
+ recall_count_week: resultMap.get(r.path)?.recall_count_week || 0,
592
+ }));
593
+ res.writeHead(200, { 'Content-Type': 'application/json' });
594
+ res.end(JSON.stringify({ results: withCounts, count: withCounts.length }));
595
+ } catch (err) {
596
+ res.writeHead(500, { 'Content-Type': 'application/json' });
597
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/memory/search' }));
598
+ }
599
+ }],
600
+
601
+ ['/api/projects', (req, res) => {
602
+ try {
603
+ const projects = listKnownProjects();
604
+ res.writeHead(200, { 'Content-Type': 'application/json' });
605
+ res.end(JSON.stringify({ projects, total: projects.length }));
606
+ } catch (err) {
607
+ res.writeHead(500, { 'Content-Type': 'application/json' });
608
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/projects' }));
609
+ }
610
+ }],
611
+
612
+ ['/api/memory/recall-stats', (req, res) => {
613
+ try {
614
+ const result = ttlCache('memory:recall-stats', COST_CACHE_TTL, memoryDirsMtimeKey, () => {
615
+ const { files } = listMemoryFiles(REPO_ROOT);
616
+ const { counts, weekCounts, totalThisWeek } = buildRecallCounts(ledgerPath);
617
+ const enriched = mergeRecallCounts(files, counts, weekCounts);
618
+ const top = topRecalled(enriched, 5);
619
+ return { top_recalled: top, total_recalls_this_week: totalThisWeek };
620
+ });
621
+ res.writeHead(200, { 'Content-Type': 'application/json' });
622
+ res.end(JSON.stringify(result));
623
+ } catch (err) {
624
+ res.writeHead(500, { 'Content-Type': 'application/json' });
625
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/memory/recall-stats' }));
626
+ }
627
+ }],
628
+
629
+ // ---------- config endpoints ----------
630
+ ['/api/config', (req, res) => {
631
+ const configPath = join(homedir(), '.ijfw', 'config.json');
632
+ if (req.method === 'POST') {
633
+ let body = '';
634
+ req.on('data', chunk => { body += chunk; if (body.length > 65536) { req.destroy(); return; } });
635
+ req.on('end', () => {
636
+ try {
637
+ const parsed = JSON.parse(body);
638
+ mkdirSync(join(homedir(), '.ijfw'), { recursive: true });
639
+ writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf8');
640
+ res.writeHead(200, { 'Content-Type': 'application/json' });
641
+ res.end(JSON.stringify({ ok: true }));
642
+ } catch (err) {
643
+ res.writeHead(400, { 'Content-Type': 'application/json' });
644
+ res.end(JSON.stringify({ ok: false, error: err.message }));
645
+ }
646
+ });
647
+ return;
648
+ }
649
+ // GET
650
+ const CONFIG_DEFAULTS = { version: 1, subscriptions: {}, accounts: [], prices_pinned_date: '2026-04-16' };
651
+ let config = { ...CONFIG_DEFAULTS };
652
+ if (existsSync(configPath)) {
653
+ try {
654
+ const stored = JSON.parse(readFileSync(configPath, 'utf8'));
655
+ // Merge stored over defaults so new fields appear on first read.
656
+ config = Object.assign({}, CONFIG_DEFAULTS, stored);
657
+ } catch (err) {
658
+ // Corrupt config: rename aside so the user has a recoverable copy AND
659
+ // next read regenerates defaults instead of getting stuck (parity with
660
+ // scripts/dashboard/server.js loadConfig W11-2 fix).
661
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
662
+ try { renameSync(configPath, `${configPath}.corrupt.${stamp}`); }
663
+ catch { /* rename failure is non-fatal; defaults still serve */ }
664
+ process.stderr.write(`[ijfw-mcp] /api/cost/config: ${err.message} -- using defaults; corrupt file preserved as ${configPath}.corrupt.${stamp}\n`);
665
+ }
666
+ }
667
+ res.writeHead(200, { 'Content-Type': 'application/json' });
668
+ res.end(JSON.stringify(config));
669
+ }],
670
+
671
+ ['/api/value-delivered', (req, res, url) => {
672
+ try {
673
+ const platform = url.searchParams.get('platform') || 'claude';
674
+ const days = parseInt(url.searchParams.get('days') || '7', 10);
675
+ const configPath = join(homedir(), '.ijfw', 'config.json');
676
+ let config = {};
677
+ if (existsSync(configPath)) {
678
+ try { config = JSON.parse(readFileSync(configPath, 'utf8')); }
679
+ catch (err) {
680
+ // Corrupt config: rename aside, log, fall through to {} defaults.
681
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
682
+ try { renameSync(configPath, `${configPath}.corrupt.${stamp}`); }
683
+ catch { /* rename failure non-fatal */ }
684
+ process.stderr.write(`[ijfw-mcp] /api/value-delivered: ${err.message} -- using defaults; corrupt file preserved as ${configPath}.corrupt.${stamp}\n`);
685
+ }
686
+ }
687
+ const tierCfg = (config.subscriptions || {})[platform] || null;
688
+ const obs = readObservations(ledgerPath);
689
+ const report = buildCostReport(days, obs);
690
+ // Pass a single synthetic turn with aggregated cost
691
+ const turns = [{ cost_usd: report.cost, platform }];
692
+ const result = computeValueDelivered(tierCfg, turns, days);
693
+ res.writeHead(200, { 'Content-Type': 'application/json' });
694
+ res.end(JSON.stringify(result));
695
+ } catch (err) {
696
+ res.writeHead(500, { 'Content-Type': 'application/json' });
697
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/value-delivered' }));
698
+ }
699
+ }],
700
+
701
+ // ---------- design companion ----------
702
+ ['/design', async (req, res) => {
703
+ const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
704
+ mkdirSync(contentDir, { recursive: true });
705
+ let html = null;
706
+ try {
707
+ const { readdirSync, statSync } = await import('node:fs');
708
+ const files = readdirSync(contentDir)
709
+ .filter(f => f.endsWith('.html'))
710
+ .map(f => ({ name: f, mtime: statSync(join(contentDir, f)).mtimeMs }))
711
+ .sort((a, b) => b.mtime - a.mtime);
712
+ if (files.length > 0) {
713
+ html = readFileSync(join(contentDir, files[0].name), 'utf8');
714
+ }
715
+ } catch {}
716
+ if (!html) {
717
+ html = `<!doctype html><html><body><pre>Design companion active. Push a design with: ijfw design push file.html</pre></body></html>`;
718
+ }
719
+ res.writeHead(200, {
720
+ 'Content-Type': 'text/html; charset=utf-8',
721
+ 'Cache-Control': 'no-store',
722
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
723
+ });
724
+ res.end(html);
725
+ }],
726
+
727
+ ['/design/stream', (req, res) => {
728
+ const contentDir = join(homedir(), '.ijfw', 'design-companion', 'content');
729
+ mkdirSync(contentDir, { recursive: true });
730
+ res.writeHead(200, {
731
+ 'Content-Type': 'text/event-stream',
732
+ 'Cache-Control': 'no-cache',
733
+ 'Connection': 'keep-alive',
734
+ 'X-Accel-Buffering': 'no',
735
+ });
736
+ res.write(': connected\n\n');
737
+
738
+ let debounceTimer = null;
739
+ let watcher = null;
740
+ try {
741
+ watcher = watch(contentDir, () => {
742
+ clearTimeout(debounceTimer);
743
+ debounceTimer = setTimeout(() => {
744
+ try { res.write('event: reload\ndata: reload\n\n'); } catch {}
745
+ }, 50);
746
+ });
747
+ // fs.watch on Windows can emit EPERM asynchronously even when the
748
+ // initial call succeeds. Swallow the error so it doesn't bubble as
749
+ // an uncaughtException after request close (Windows-CI regression).
750
+ watcher.on('error', () => { /* graceful degrade -- poll fallback below */ });
751
+ } catch {}
752
+
753
+ req.on('close', () => {
754
+ clearTimeout(debounceTimer);
755
+ if (watcher) { try { watcher.close(); } catch {} }
756
+ });
757
+ }],
758
+
759
+ ['/stream', async (req, res, url) => {
760
+ // Per W11: pass explicit `max` to safeIntegerParam so absurd values are
761
+ // rejected with 400 rather than silently clamped downstream. Defense-in-depth
762
+ // even though Math.min/slice in backfillSSE would also clamp.
763
+ const OBSERVATION_INDEX_MAX = 1_000_000; // observations.jsonl line index ceiling
764
+
765
+ // Validate lastEventId (from SSE header or query param) -- reject prefix-garbage (W9-M2).
766
+ const rawLastId = req.headers['last-event-id'] || url.searchParams.get('lastEventId') || '';
767
+ let lastId = 0;
768
+ if (rawLastId) {
769
+ const parsed = safeIntegerParam(rawLastId, OBSERVATION_INDEX_MAX);
770
+ if (parsed === null) {
771
+ res.writeHead(400, { 'Content-Type': 'application/json' });
772
+ res.end(JSON.stringify({ error: 'invalid lastEventId' }));
773
+ return;
774
+ }
775
+ lastId = parsed;
776
+ }
777
+
778
+ // Validate ?offset (for explicit resume-from-index) -- reject prefix-garbage (W9-M2).
779
+ const rawOffset = url.searchParams.get('offset');
780
+ let offset = null;
781
+ if (rawOffset !== null) {
782
+ offset = safeIntegerParam(rawOffset, OBSERVATION_INDEX_MAX);
783
+ if (offset === null) {
784
+ res.writeHead(400, { 'Content-Type': 'application/json' });
785
+ res.end(JSON.stringify({ error: 'invalid offset' }));
786
+ return;
787
+ }
788
+ }
789
+
790
+ // Validate ?backfill -- reject prefix-garbage (W9-M2). Cap at BACKFILL_CAP
791
+ // (server-side limit) so callers requesting more get a clear 400.
792
+ const rawBackfill = url.searchParams.get('backfill');
793
+ let backfill = BACKFILL_DEFAULT;
794
+ if (rawBackfill !== null) {
795
+ const parsed = safeIntegerParam(rawBackfill, BACKFILL_CAP);
796
+ if (parsed === null) {
797
+ res.writeHead(400, { 'Content-Type': 'application/json' });
798
+ res.end(JSON.stringify({ error: `invalid backfill (max ${BACKFILL_CAP})` }));
799
+ return;
800
+ }
801
+ backfill = parsed;
802
+ }
803
+
804
+ res.writeHead(200, {
805
+ 'Content-Type': 'text/event-stream',
806
+ 'Cache-Control': 'no-cache',
807
+ 'Connection': 'keep-alive',
808
+ 'X-Accel-Buffering': 'no',
809
+ });
810
+ // Heartbeat comment to keep connection alive
811
+ res.write(': connected\n\n');
812
+
813
+ await backfillSSE(res, ledgerPath, lastId, backfill, offset);
814
+
815
+ broadcaster.add(res);
816
+ req.on('close', () => broadcaster.remove(res));
817
+ }],
818
+ ];
819
+
820
+ return new Promise((resolve, reject) => {
821
+ let port = preferredPort;
822
+
823
+ function tryBind() {
824
+ if (port > portCeiling) {
825
+ reject(new Error(`No free port in range ${preferredPort}-${portCeiling}`));
826
+ return;
827
+ }
828
+
829
+ const server = createServer((req, res) => {
830
+ if (!requireLocalhost(req, res)) return;
831
+ route(req, res, routes);
832
+ });
833
+
834
+ server.once('error', err => {
835
+ if (err.code === 'EADDRINUSE') {
836
+ port++;
837
+ tryBind();
838
+ } else {
839
+ reject(err);
840
+ }
841
+ });
842
+
843
+ server.listen(port, '127.0.0.1', () => {
844
+ function shutdown() {
845
+ watcher.stop();
846
+ broadcaster.closeAll();
847
+ server.close(() => process.exit(0));
848
+ }
849
+ // Increase limit for test environments that start many servers.
850
+ process.setMaxListeners(process.getMaxListeners() + 2);
851
+ process.once('SIGTERM', shutdown);
852
+ process.once('SIGINT', shutdown);
853
+
854
+ // Wrap server.close so tests can clean up watcher + broadcaster without
855
+ // knowing about them directly.
856
+ const originalClose = server.close.bind(server);
857
+ server.close = (cb) => {
858
+ watcher.stop();
859
+ broadcaster.closeAll();
860
+ return originalClose(cb);
861
+ };
862
+ resolve({ port, server, broadcaster, watcher });
863
+ });
864
+ }
865
+
866
+ tryBind();
867
+ });
868
+ }
869
+
870
+ // ---------- daemon entry point ----------
871
+ // When spawned with `--daemon`, starts the server and writes PID + port files.
872
+ if (process.argv.includes('--daemon')) {
873
+ const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
874
+ const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
875
+
876
+ startServer().then(({ port }) => {
877
+ const ijfwDir = dirname(pidFile);
878
+ mkdirSync(ijfwDir, { recursive: true });
879
+ // PID file: plain write (single writer; pid is meaningless mid-write)
880
+ writeFileSync(pidFile, String(process.pid), 'utf8');
881
+ // Port file: atomic write via tmp+rename so readers never see a partial value (W4.2).
882
+ // Cleanup tmp on rename failure so it doesn't leak (W9-M1).
883
+ const portTmp = `${portFile}.tmp.${process.pid}.${Date.now()}`;
884
+ writeFileSync(portTmp, String(port), 'utf8');
885
+ try {
886
+ renameSync(portTmp, portFile);
887
+ } catch (err) {
888
+ try { unlinkSync(portTmp); } catch {}
889
+ throw new Error(`atomic write failed for ${portFile}: ${err.message}`);
890
+ }
891
+ }).catch(err => {
892
+ process.stderr.write('[ijfw-dashboard] ' + err.message + '\n');
893
+ process.exit(1);
894
+ });
895
+ }