memorix 1.9.4 → 1.9.5

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 (60) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +6 -5
  3. package/README.zh-CN.md +6 -5
  4. package/dist/cli/index.js +890 -325
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +251 -104
  7. package/dist/maintenance-runner.js +144 -113
  8. package/dist/media-audio-runner.js +57 -26
  9. package/dist/media-video-runner.js +57 -26
  10. package/dist/memcode-runtime/CHANGELOG.md +33 -1
  11. package/dist/sdk.d.ts +1 -0
  12. package/dist/sdk.js +1012 -842
  13. package/dist/types.d.ts +1 -1
  14. package/dist/vector-backfill-runner.js +57 -26
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
  16. package/docs/API_REFERENCE.md +3 -1
  17. package/docs/CONFIGURATION.md +15 -1
  18. package/docs/DOCKER.md +23 -3
  19. package/docs/INTEGRATIONS.md +1 -1
  20. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +26 -1
  21. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
  22. package/docs/SETUP.md +55 -0
  23. package/package.json +2 -2
  24. package/plugins/antigravity/memorix/plugin.json +1 -1
  25. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  26. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  27. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  28. package/plugins/copilot/memorix/plugin.json +1 -1
  29. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  30. package/plugins/hermes/memorix/plugin.yaml +1 -1
  31. package/plugins/omp/memorix/package.json +1 -1
  32. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  33. package/plugins/pi/memorix/package.json +1 -1
  34. package/server.json +2 -2
  35. package/src/cli/command-guide.ts +2 -2
  36. package/src/cli/commands/agent-integrations.ts +57 -0
  37. package/src/cli/commands/background.ts +11 -2
  38. package/src/cli/commands/serve-http.ts +193 -42
  39. package/src/cli/commands/setup.ts +77 -10
  40. package/src/cli/commands/sync-store.ts +47 -12
  41. package/src/cli/commands/sync.ts +26 -0
  42. package/src/dashboard/server.ts +10 -12
  43. package/src/integrations/registry.ts +1 -1
  44. package/src/memory/graph.ts +10 -8
  45. package/src/memory/session.ts +1 -1
  46. package/src/sdk.ts +24 -0
  47. package/src/server/mcp-binding-store.ts +14 -3
  48. package/src/server.ts +2 -2
  49. package/src/store/file-lock.ts +28 -3
  50. package/src/store/graph-store.ts +178 -46
  51. package/src/store/persistence.ts +14 -9
  52. package/src/store/sqlite-db.ts +6 -3
  53. package/src/store/sqlite-store.ts +32 -27
  54. package/src/sync/engine.ts +9 -7
  55. package/src/sync/namespace.ts +23 -0
  56. package/src/sync/policy.ts +19 -4
  57. package/src/sync/store-port.ts +43 -13
  58. package/src/sync/types.ts +2 -0
  59. package/src/types.ts +1 -1
  60. package/src/workspace/mcp-adapters/grok.ts +32 -0
@@ -168,6 +168,8 @@ function worstStatus(statuses: AgentIntegrationStatus[]): AgentIntegrationStatus
168
168
  }
169
169
 
170
170
  function isMcpConfigAgent(agent: AgentName): agent is McpConfigAgent {
171
+ // Grok has a setup-only HTTP adapter. Doctor/repair skip it so a default
172
+ // host-owned Grok MCP is not reported as missing.
171
173
  return agent !== 'pi' && agent !== 'grok';
172
174
  }
173
175
 
@@ -773,7 +775,62 @@ async function inspectCodexPluginMcp(): Promise<AgentMcpCheck | null> {
773
775
  }
774
776
  }
775
777
 
778
+ async function inspectOptionalGrokMcp(scope: AgentIntegrationScope): Promise<AgentIntegrationEntry['mcp']> {
779
+ if (scope === 'project' || scope === 'local') {
780
+ return { status: 'skipped', issues: ['mcp-host-owned'], checks: [] };
781
+ }
782
+
783
+ const adapter = getMcpAdapter('grok');
784
+ const configPath = adapter.getConfigPath();
785
+ if (!existsSync(configPath)) {
786
+ return {
787
+ status: 'skipped',
788
+ issues: ['mcp-host-owned'],
789
+ checks: [{
790
+ scope: 'global',
791
+ path: configPath,
792
+ exists: false,
793
+ status: 'skipped',
794
+ issues: ['mcp-host-owned'],
795
+ }],
796
+ };
797
+ }
798
+
799
+ const content = await readFile(configPath, 'utf-8');
800
+ const server = adapter.parse(content).find((entry) => entry.name === 'memorix');
801
+ if (!server) {
802
+ return {
803
+ status: 'skipped',
804
+ issues: ['mcp-host-owned'],
805
+ checks: [{
806
+ scope: 'global',
807
+ path: configPath,
808
+ exists: true,
809
+ status: 'skipped',
810
+ issues: ['mcp-host-owned'],
811
+ }],
812
+ };
813
+ }
814
+
815
+ const issues: string[] = [];
816
+ if (!server.url && looksLikeStaleMemorixCommand(server)) issues.push('stale-command-path');
817
+ if (!server.url && !isRecommendedStdioServer(server)) issues.push('nonstandard-mcp-command');
818
+ return {
819
+ status: issues.length > 0 ? 'repairable' : 'ok',
820
+ issues,
821
+ checks: [{
822
+ scope: 'global',
823
+ path: configPath,
824
+ exists: true,
825
+ status: issues.length > 0 ? 'repairable' : 'ok',
826
+ issues,
827
+ server: sanitizeServer(server),
828
+ }],
829
+ };
830
+ }
831
+
776
832
  async function inspectMcp(agent: AgentName, projectRoot: string, scope: AgentIntegrationScope): Promise<AgentIntegrationEntry['mcp']> {
833
+ if (agent === 'grok') return inspectOptionalGrokMcp(scope);
777
834
  if (!isMcpConfigAgent(agent)) {
778
835
  return { status: 'skipped', issues: ['mcp-managed-by-package'], checks: [] };
779
836
  }
@@ -570,8 +570,17 @@ async function doStartUnlocked(port: number): Promise<void> {
570
570
  if (healthy) {
571
571
  process.stderr.write('[OK] Control plane is running and healthy.\n');
572
572
  } else {
573
- process.stderr.write('[WARN] Health check timed out — service may still be initializing.\n');
574
- process.stderr.write(' Check later: memorix background status\n');
573
+ process.stderr.write('[ERROR] Health check timed out; refusing to leave an unverified background state.\n');
574
+ if (isProcessRunning(pid)) {
575
+ killProcess(pid);
576
+ for (let i = 0; i < 10 && isProcessRunning(pid); i++) {
577
+ await new Promise(r => setTimeout(r, 250));
578
+ }
579
+ }
580
+ clearState();
581
+ try { fs.unlinkSync(getMemorixDir() + '/background.ready'); } catch { /* best effort */ }
582
+ process.exitCode = 1;
583
+ return;
575
584
  }
576
585
 
577
586
  const footer = [
@@ -22,6 +22,7 @@
22
22
  import { defineCommand } from 'citty';
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
24
  import { getHeapStatistics } from 'node:v8';
25
+ import { timingSafeEqual } from 'node:crypto';
25
26
  import type { ObservationStore } from '../../store/obs-store.js';
26
27
  import { resolveToolProfile } from '../../server/tool-profile.js';
27
28
  import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
@@ -39,6 +40,9 @@ import { mcpFileUriToPath } from '../mcp-root-path.js';
39
40
  */
40
41
  export const DEFAULT_SESSION_TIMEOUT_MS = 12 * 60 * 60 * 1000;
41
42
  export const EXPIRED_SESSION_TTL_MS = 10 * 60 * 1000;
43
+ export const DEFAULT_HTTP_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
44
+ export const MAX_HTTP_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
45
+ export const MIN_HTTP_BODY_LIMIT_BYTES = 64 * 1024;
42
46
  /** Pinned SDK protocol accepted internally while the HTTP boundary speaks the current stateless contract. */
43
47
  export const MCP_SDK_COMPAT_PROTOCOL_VERSION = '2025-11-25';
44
48
 
@@ -56,6 +60,101 @@ export function parseSessionTimeoutMs(raw: string | undefined): number {
56
60
  return Math.floor(parsed);
57
61
  }
58
62
 
63
+ export function parseHttpBodyLimit(raw: string | undefined): number {
64
+ const value = raw?.trim();
65
+ if (!value) return DEFAULT_HTTP_BODY_LIMIT_BYTES;
66
+ const parsed = Number(value);
67
+ if (!Number.isSafeInteger(parsed) || parsed < MIN_HTTP_BODY_LIMIT_BYTES) {
68
+ return DEFAULT_HTTP_BODY_LIMIT_BYTES;
69
+ }
70
+ return Math.min(parsed, MAX_HTTP_BODY_LIMIT_BYTES);
71
+ }
72
+
73
+ export function isLoopbackHost(host: string): boolean {
74
+ const normalized = host.trim().toLowerCase();
75
+ return normalized === 'localhost'
76
+ || normalized === '127.0.0.1'
77
+ || normalized === '::1'
78
+ || normalized === '[::1]';
79
+ }
80
+
81
+ export function validateHttpBindSecurity(
82
+ host: string,
83
+ env: NodeJS.ProcessEnv = process.env,
84
+ ): { requiresAuth: boolean; explicitlyUnauthenticated: boolean } {
85
+ const hasToken = Boolean(env.MEMORIX_HTTP_AUTH_TOKEN?.trim());
86
+ const explicitlyUnauthenticated = env.MEMORIX_HTTP_ALLOW_UNAUTHENTICATED_BIND === '1';
87
+ if (!isLoopbackHost(host) && !hasToken && !explicitlyUnauthenticated) {
88
+ throw new Error(
89
+ `[memorix] Refusing unauthenticated non-loopback HTTP bind on ${host}. `
90
+ + 'Set MEMORIX_HTTP_AUTH_TOKEN or explicitly set MEMORIX_HTTP_ALLOW_UNAUTHENTICATED_BIND=1.',
91
+ );
92
+ }
93
+ return { requiresAuth: hasToken, explicitlyUnauthenticated };
94
+ }
95
+
96
+ function isAuthorizedHttpRequest(req: IncomingMessage, token: string): boolean {
97
+ const header = req.headers.authorization;
98
+ if (!header) return false;
99
+ const match = /^Bearer\s+(.+)$/iu.exec(header);
100
+ if (!match) return false;
101
+ const provided = Buffer.from(match[1], 'utf8');
102
+ const expected = Buffer.from(token, 'utf8');
103
+ return provided.length === expected.length && timingSafeEqual(provided, expected);
104
+ }
105
+
106
+ export class HttpPayloadTooLargeError extends Error {
107
+ readonly statusCode = 413;
108
+
109
+ constructor(limitBytes: number) {
110
+ super(`Request body exceeds the ${limitBytes} byte limit.`);
111
+ this.name = 'HttpPayloadTooLargeError';
112
+ }
113
+ }
114
+
115
+ export function parseJsonBody(req: IncomingMessage, limitBytes: number): Promise<unknown> {
116
+ return new Promise((resolve, reject) => {
117
+ const contentLength = Number(req.headers['content-length']);
118
+ if (Number.isSafeInteger(contentLength) && contentLength > limitBytes) {
119
+ req.resume();
120
+ reject(new HttpPayloadTooLargeError(limitBytes));
121
+ return;
122
+ }
123
+
124
+ const chunks: Buffer[] = [];
125
+ let size = 0;
126
+ let settled = false;
127
+ const fail = (error: unknown) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ reject(error);
131
+ };
132
+
133
+ req.on('data', (chunk: Buffer) => {
134
+ if (settled) return;
135
+ size += chunk.length;
136
+ if (size > limitBytes) {
137
+ req.removeAllListeners('data');
138
+ req.resume();
139
+ fail(new HttpPayloadTooLargeError(limitBytes));
140
+ return;
141
+ }
142
+ chunks.push(chunk);
143
+ });
144
+ req.on('end', () => {
145
+ if (settled) return;
146
+ settled = true;
147
+ try {
148
+ const body = Buffer.concat(chunks).toString('utf-8');
149
+ resolve(body ? JSON.parse(body) : undefined);
150
+ } catch (error) {
151
+ reject(error);
152
+ }
153
+ });
154
+ req.on('error', fail);
155
+ });
156
+ }
157
+
59
158
  export interface ServeHttpConfiguredDefaults {
60
159
  port?: number;
61
160
  transport?: 'stdio' | 'http';
@@ -146,6 +245,14 @@ export default defineCommand({
146
245
  if (resolved.transportNotice) console.error(resolved.transportNotice);
147
246
  const port = resolved.port;
148
247
  const host = args.host || '127.0.0.1';
248
+ const bindSecurity = validateHttpBindSecurity(host);
249
+ const httpAuthToken = process.env.MEMORIX_HTTP_AUTH_TOKEN?.trim() || '';
250
+ if (bindSecurity.explicitlyUnauthenticated && !isLoopbackHost(host)) {
251
+ console.error(`[memorix] WARNING: HTTP control plane is unauthenticated on ${host}; keep it behind a private network or authenticated proxy.`);
252
+ }
253
+ if (bindSecurity.requiresAuth) {
254
+ console.error('[memorix] HTTP control plane authentication is enabled (Bearer token).');
255
+ }
149
256
  const toolProfile = resolveToolProfile({ explicit: args.mode, envValue: process.env.MEMORIX_MODE, fallback: 'team' });
150
257
 
151
258
  // Priority: explicit --cwd arg > MEMORIX_PROJECT_ROOT env > process.cwd().
@@ -176,6 +283,7 @@ export default defineCommand({
176
283
  console.error(`[memorix] HTTP transport starting on ${host}:${port}`);
177
284
  console.error(`[memorix] Project root: ${projectRoot}`);
178
285
  const sessionTimeoutMs = parseSessionTimeoutMs(process.env.MEMORIX_SESSION_TIMEOUT_MS);
286
+ const httpBodyLimitBytes = parseHttpBodyLimit(process.env.MEMORIX_HTTP_MAX_BODY_BYTES);
179
287
  console.error(
180
288
  sessionTimeoutMs === 0
181
289
  ? '[memorix] HTTP session idle timeout: disabled'
@@ -283,25 +391,6 @@ export default defineCommand({
283
391
  res.end(message);
284
392
  }
285
393
 
286
- /**
287
- * Parse JSON body from IncomingMessage
288
- */
289
- function parseBody(req: IncomingMessage): Promise<unknown> {
290
- return new Promise((resolve, reject) => {
291
- const chunks: Buffer[] = [];
292
- req.on('data', (chunk: Buffer) => chunks.push(chunk));
293
- req.on('end', () => {
294
- try {
295
- const body = Buffer.concat(chunks).toString('utf-8');
296
- resolve(body ? JSON.parse(body) : undefined);
297
- } catch (err) {
298
- reject(err);
299
- }
300
- });
301
- req.on('error', reject);
302
- });
303
- }
304
-
305
394
  /**
306
395
  * Patch ServerResponse for MCP compatibility with strict HTTP clients (rmcp/reqwest).
307
396
  *
@@ -442,7 +531,7 @@ export default defineCommand({
442
531
  // No Access-Control-Allow-Origin at all for disallowed origins —
443
532
  // browser will block the response (fail-closed).
444
533
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
445
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Project-Handle, Mcp-Project-Root, Mcp-Stateless, Last-Event-Id');
534
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, Mcp-Project-Handle, Mcp-Project-Root, Mcp-Stateless, Last-Event-Id');
446
535
  res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, Mcp-Project-Handle, Mcp-Protocol-Version, Mcp-Method, Mcp-Name');
447
536
  res.setHeader('Mcp-Protocol-Version', MCP_SDK_COMPAT_PROTOCOL_VERSION);
448
537
  res.setHeader('Mcp-Method', req.method || 'UNKNOWN');
@@ -454,7 +543,7 @@ export default defineCommand({
454
543
  */
455
544
  async function handlePost(req: IncomingMessage, res: ServerResponse) {
456
545
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
457
- const body = await parseBody(req);
546
+ const body = await parseJsonBody(req, httpBodyLimitBytes);
458
547
 
459
548
  // Keep the pre-2026 startup probe compatible. Older MCP clients sent a
460
549
  // claim-less server/discover before initialize and expect a plain JSON
@@ -861,7 +950,7 @@ export default defineCommand({
861
950
  sendJson({ error: 'Maintenance actions require POST.' }, 405);
862
951
  return;
863
952
  }
864
- const bodyValue = await parseBody(req);
953
+ const bodyValue = await parseJsonBody(req, Math.min(httpBodyLimitBytes, 1024 * 1024));
865
954
  const body = bodyValue && typeof bodyValue === 'object' && !Array.isArray(bodyValue)
866
955
  ? bodyValue as Record<string, unknown>
867
956
  : {};
@@ -1023,9 +1112,9 @@ export default defineCommand({
1023
1112
 
1024
1113
  if (apiPath === '/stats') {
1025
1114
  const { projectId: statsProjectId, dataDir: statsDataDir } = await resolveRequestProject(url);
1026
- const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1027
- await initGraphStore(statsDataDir);
1028
- const graph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1115
+ const { initGraphStore } = await import('../../store/graph-store.js');
1116
+ const gStore = await initGraphStore(statsDataDir, statsProjectId);
1117
+ const graph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
1029
1118
 
1030
1119
  const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active');
1031
1120
  const feedback = await loadProjectFeedback(statsDataDir, statsProjectId);
@@ -1210,9 +1299,9 @@ export default defineCommand({
1210
1299
 
1211
1300
  if (apiPath === '/graph') {
1212
1301
  const { projectId: graphProjectId, dataDir: graphDataDir } = await resolveRequestProject(url);
1213
- const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1214
- await initGraphStore(graphDataDir);
1215
- const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1302
+ const { initGraphStore } = await import('../../store/graph-store.js');
1303
+ const gStore = await initGraphStore(graphDataDir, graphProjectId);
1304
+ const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
1216
1305
 
1217
1306
  const allObs = await loadDashboardProjectObservations(graphDataDir, graphProjectId, 'active');
1218
1307
  const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
@@ -1298,15 +1387,15 @@ export default defineCommand({
1298
1387
  const { projectId: kgProjectId, dataDir: kgDataDir } = await resolveRequestProject(url);
1299
1388
  const { generateKnowledgeGraph } = await import('../../wiki/knowledge-graph.js');
1300
1389
  const { initMiniSkillStore, getMiniSkillStore } = await import('../../store/mini-skill-store.js');
1301
- const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1390
+ const { initGraphStore } = await import('../../store/graph-store.js');
1302
1391
 
1303
1392
  await initMiniSkillStore(kgDataDir);
1304
- await initGraphStore(kgDataDir);
1393
+ const gStore = await initGraphStore(kgDataDir, kgProjectId);
1305
1394
 
1306
1395
  const allObs = await loadDashboardProjectObservations(kgDataDir, kgProjectId, 'active');
1307
1396
  const skills = await getMiniSkillStore().loadByProject(kgProjectId);
1308
1397
 
1309
- const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1398
+ const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
1310
1399
  const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
1311
1400
 
1312
1401
  const graph = generateKnowledgeGraph({
@@ -1634,9 +1723,8 @@ export default defineCommand({
1634
1723
  await store.remove(obsId);
1635
1724
  // Sync: clean up graph entity references
1636
1725
  try {
1637
- const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1638
- await initGraphStore(delDataDir);
1639
- const gStore = getGraphStore();
1726
+ const { initGraphStore } = await import('../../store/graph-store.js');
1727
+ const gStore = await initGraphStore(delDataDir, delProjectId);
1640
1728
  const prefix = `[#${obsId}] `;
1641
1729
  const deletions: { entityName: string; observations: string[] }[] = [];
1642
1730
  for (const entity of gStore.loadEntities()) {
@@ -1653,9 +1741,9 @@ export default defineCommand({
1653
1741
  // GET /api/export — export observations as JSON
1654
1742
  if (apiPath === '/export') {
1655
1743
  const { projectId: expProjectId, projectName: expProjectName, dataDir: expDataDir } = await resolveRequestProject(url);
1656
- const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1657
- await initGraphStore(expDataDir);
1658
- const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1744
+ const { initGraphStore } = await import('../../store/graph-store.js');
1745
+ const gStore = await initGraphStore(expDataDir, expProjectId);
1746
+ const fullGraph = { entities: gStore.loadEntities(), relations: gStore.loadRelations() };
1659
1747
  const observations = await loadDashboardProjectObservations(expDataDir, expProjectId, 'active');
1660
1748
  const expStore = await getDashboardObservationStore(expDataDir);
1661
1749
  const nextId = await expStore.loadIdCounter();
@@ -1694,7 +1782,8 @@ export default defineCommand({
1694
1782
  }, 503);
1695
1783
  return;
1696
1784
  }
1697
- sendJson({ error: err instanceof Error ? err.message : 'Unknown error' }, 500);
1785
+ const status = err instanceof HttpPayloadTooLargeError ? err.statusCode : 500;
1786
+ sendJson({ error: err instanceof Error ? err.message : 'Unknown error' }, status);
1698
1787
  }
1699
1788
  }
1700
1789
 
@@ -1711,6 +1800,15 @@ export default defineCommand({
1711
1800
 
1712
1801
  const url = new URL(req.url || '/', `http://localhost:${port}`);
1713
1802
 
1803
+ if (bindSecurity.requiresAuth && url.pathname !== '/health' && !isAuthorizedHttpRequest(req, httpAuthToken)) {
1804
+ res.writeHead(401, {
1805
+ 'Content-Type': 'application/json',
1806
+ 'WWW-Authenticate': 'Bearer',
1807
+ });
1808
+ res.end(JSON.stringify({ error: 'Authentication required' }));
1809
+ return;
1810
+ }
1811
+
1714
1812
  // Lightweight health check — responds immediately, no heavy init required.
1715
1813
  // This is the readiness signal for background start / status.
1716
1814
  if (url.pathname === '/health') {
@@ -1792,10 +1890,15 @@ export default defineCommand({
1792
1890
  } catch (err) {
1793
1891
  console.error('[memorix] HTTP handler error:', err);
1794
1892
  if (!res.headersSent) {
1795
- res.writeHead(500, { 'Content-Type': 'application/json' });
1893
+ const status = err instanceof HttpPayloadTooLargeError ? err.statusCode : 500;
1894
+ const message = err instanceof Error ? err.message : 'Internal server error';
1895
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1796
1896
  res.end(JSON.stringify({
1797
1897
  jsonrpc: '2.0',
1798
- error: { code: -32603, message: 'Internal server error' },
1898
+ error: {
1899
+ code: status === 413 ? -32013 : -32603,
1900
+ message: status === 413 ? message : 'Internal server error',
1901
+ },
1799
1902
  id: null,
1800
1903
  }));
1801
1904
  }
@@ -1819,7 +1922,13 @@ export default defineCommand({
1819
1922
  httpServer.keepAliveTimeout = 60_000;
1820
1923
  httpServer.headersTimeout = 65_000;
1821
1924
 
1822
- httpServer.listen(port, host, () => {
1925
+ const listenPromise = new Promise<void>((resolve, reject) => {
1926
+ const onError = (error: Error) => {
1927
+ if (typeof (httpServer as any).off === 'function') httpServer.off('listening', onListening);
1928
+ reject(error);
1929
+ };
1930
+ const onListening = () => {
1931
+ if (typeof (httpServer as any).off === 'function') httpServer.off('error', onError);
1823
1932
  httpReadyAt = new Date().toISOString();
1824
1933
  // Write readiness file — background start polls this for out-of-band readiness detection
1825
1934
  try {
@@ -1841,7 +1950,29 @@ export default defineCommand({
1841
1950
  // Fire-and-forget: background update check after server is fully started.
1842
1951
  // Default is notify-only. All output goes to stderr only. Failures never affect the server.
1843
1952
  import('../update-checker.js').then(m => m.checkForUpdates()).catch(() => {});
1953
+ resolve();
1954
+ };
1955
+ if (typeof (httpServer as any).once === 'function') {
1956
+ httpServer.once('error', onError);
1957
+ httpServer.once('listening', onListening);
1958
+ httpServer.listen(port, host);
1959
+ } else {
1960
+ // Lightweight test doubles may only implement listen(callback).
1961
+ httpServer.listen(port, host, onListening);
1962
+ }
1844
1963
  });
1964
+ try {
1965
+ await listenPromise;
1966
+ } catch (error) {
1967
+ console.error(`[memorix] HTTP server failed to listen on ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
1968
+ for (const store of dashboardObservationStores.values()) {
1969
+ try { store.close(); } catch { /* best-effort */ }
1970
+ }
1971
+ dashboardObservationStores.clear();
1972
+ const { closeAllDatabases } = await import('../../store/sqlite-db.js');
1973
+ closeAllDatabases();
1974
+ throw error;
1975
+ }
1845
1976
 
1846
1977
  // Session timeout GC — close sessions idle past the configured threshold.
1847
1978
  // MEMORIX_SESSION_TIMEOUT_MS lets operators work around HTTP clients that do
@@ -1851,6 +1982,7 @@ export default defineCommand({
1851
1982
  maybeProbeSummary(); // Flush suppressed probe count periodically
1852
1983
  const now = Date.now();
1853
1984
  forgetExpiredSessions(now);
1985
+ statelessBindingStore.cleanupExpired(now);
1854
1986
  if (SESSION_TIMEOUT_MS === 0) return;
1855
1987
  for (const [sid, state] of sessions) {
1856
1988
  const lastActive = sessionLastActivity.get(sid) ?? 0;
@@ -1866,7 +1998,10 @@ export default defineCommand({
1866
1998
  gcInterval.unref(); // Don't prevent process exit
1867
1999
 
1868
2000
  // Graceful shutdown
2001
+ let shutdownStarted = false;
1869
2002
  const shutdown = async () => {
2003
+ if (shutdownStarted) return;
2004
+ shutdownStarted = true;
1870
2005
  if (suppressedProbeCount > 0) {
1871
2006
  console.error(`[memorix] ${suppressedProbeCount} probe connection(s) suppressed during this session`);
1872
2007
  }
@@ -1891,7 +2026,17 @@ export default defineCommand({
1891
2026
  console.error(`[memorix] Error closing session ${sid}:`, err);
1892
2027
  }
1893
2028
  }
1894
- httpServer.close();
2029
+ clearInterval(gcInterval);
2030
+ clearInterval(heartbeatInterval);
2031
+ await new Promise<void>((resolve) => {
2032
+ if (!httpServer.listening) {
2033
+ resolve();
2034
+ return;
2035
+ }
2036
+ httpServer.close(() => resolve());
2037
+ });
2038
+ const { closeAllDatabases } = await import('../../store/sqlite-db.js');
2039
+ closeAllDatabases();
1895
2040
  process.exit(0);
1896
2041
  };
1897
2042
 
@@ -1943,6 +2088,12 @@ export default defineCommand({
1943
2088
  /** @internal */
1944
2089
  export const _testing = {
1945
2090
  DEFAULT_SESSION_TIMEOUT_MS,
2091
+ DEFAULT_HTTP_BODY_LIMIT_BYTES,
2092
+ MAX_HTTP_BODY_LIMIT_BYTES,
2093
+ MIN_HTTP_BODY_LIMIT_BYTES,
1946
2094
  parseSessionTimeoutMs,
2095
+ parseHttpBodyLimit,
2096
+ isLoopbackHost,
2097
+ validateHttpBindSecurity,
1947
2098
  parseTcpPortOrReport,
1948
2099
  };