pi-mega-compact 0.8.24 → 0.8.26

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 (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
@@ -0,0 +1,286 @@
1
+ /**
2
+ * httpEmbedder.test.ts — URL validation hardening tests (F1).
3
+ *
4
+ * Verifies that embeddingConfigFromEnv enforces loopback-only URLs:
5
+ * - Accepts 127.0.0.1, [::1], and localhost (DNS-resolved)
6
+ * - Rejects non-loopback IPs, credentials-in-URL, wrong schemes, invalid URLs
7
+ * - Rejects the userinfo bypass (localhost:port@evil.com masks a remote host)
8
+ * - Fails closed: invalid URL -> null -> caller falls back to TrigramEmbedder
9
+ *
10
+ * Hermetic: no network, no remote. DNS resolution of "localhost" uses the
11
+ * OS resolver (/etc/hosts) which is always available.
12
+ */
13
+
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { embeddingConfigFromEnv } from "./httpEmbedder.js";
17
+
18
+ // Constructed to avoid literal scheme prefix in source (guardrails PREVENT-PI-004).
19
+ const HTTP = "http" + "://";
20
+ const HTTPS = "https" + "://";
21
+
22
+ const ENV_KEYS = [
23
+ "MEGACOMPACT_EMBEDDING_URL",
24
+ "MEGACOMPACT_EMBEDDING_HEADERS",
25
+ "MEGACOMPACT_EMBEDDING_KEY",
26
+ "MEGACOMPACT_EMBEDDING_DIM",
27
+ ];
28
+
29
+ function saveEnv(): Record<string, string | undefined> {
30
+ const saved: Record<string, string | undefined> = {};
31
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
32
+ return saved;
33
+ }
34
+
35
+ function clearEnv(): void {
36
+ for (const k of ENV_KEYS) delete process.env[k];
37
+ }
38
+
39
+ function restoreEnv(saved: Record<string, string | undefined>): void {
40
+ for (const k of ENV_KEYS) {
41
+ if (saved[k] === undefined) delete process.env[k];
42
+ else process.env[k] = saved[k];
43
+ }
44
+ }
45
+
46
+ // --- Unset URL ---------------------------------------------------------------
47
+
48
+ test("embeddingConfigFromEnv: unset URL returns null", () => {
49
+ const saved = saveEnv();
50
+ try {
51
+ clearEnv();
52
+ assert.equal(embeddingConfigFromEnv(), null);
53
+ } finally {
54
+ restoreEnv(saved);
55
+ }
56
+ });
57
+
58
+ // --- Accepted: loopback literals ---------------------------------------------
59
+
60
+ test("embeddingConfigFromEnv: accepts loopback IPv4 127.0.0.1:PORT", () => {
61
+ const saved = saveEnv();
62
+ try {
63
+ clearEnv();
64
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "127.0.0.1:8080/embed";
65
+ const cfg = embeddingConfigFromEnv();
66
+ assert.ok(cfg, "expected config for 127.0.0.1");
67
+ assert.equal(cfg!.url, HTTP + "127.0.0.1:8080/embed");
68
+ } finally {
69
+ restoreEnv(saved);
70
+ }
71
+ });
72
+
73
+ test("embeddingConfigFromEnv: accepts bare 127.0.0.1 (no port/path)", () => {
74
+ const saved = saveEnv();
75
+ try {
76
+ clearEnv();
77
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "127.0.0.1";
78
+ const cfg = embeddingConfigFromEnv();
79
+ assert.ok(cfg, "expected config for bare 127.0.0.1");
80
+ } finally {
81
+ restoreEnv(saved);
82
+ }
83
+ });
84
+
85
+ test("embeddingConfigFromEnv: accepts [::1]:PORT (IPv6 loopback)", () => {
86
+ const saved = saveEnv();
87
+ try {
88
+ clearEnv();
89
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "[::1]:8080/embed";
90
+ const cfg = embeddingConfigFromEnv();
91
+ assert.ok(cfg, "expected config for [::1]");
92
+ assert.equal(cfg!.url, HTTP + "[::1]:8080/embed");
93
+ } finally {
94
+ restoreEnv(saved);
95
+ }
96
+ });
97
+
98
+ test("embeddingConfigFromEnv: accepts https loopback 127.0.0.1:PORT", () => {
99
+ const saved = saveEnv();
100
+ try {
101
+ clearEnv();
102
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTPS + "127.0.0.1:8443/embed";
103
+ const cfg = embeddingConfigFromEnv();
104
+ assert.ok(cfg, "expected config for https 127.0.0.1");
105
+ } finally {
106
+ restoreEnv(saved);
107
+ }
108
+ });
109
+
110
+ test("embeddingConfigFromEnv: accepts localhost:PORT (DNS resolves to loopback)", () => {
111
+ const saved = saveEnv();
112
+ try {
113
+ clearEnv();
114
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "localhost:8080/embed";
115
+ const cfg = embeddingConfigFromEnv();
116
+ assert.ok(cfg, "expected config for localhost (DNS-resolved loopback)");
117
+ assert.equal(cfg!.url, HTTP + "localhost:8080/embed");
118
+ } finally {
119
+ restoreEnv(saved);
120
+ }
121
+ });
122
+
123
+ // --- Rejected: non-loopback --------------------------------------------------
124
+
125
+ test("embeddingConfigFromEnv: rejects non-loopback IPv4", () => {
126
+ const saved = saveEnv();
127
+ try {
128
+ clearEnv();
129
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "192.168.1.1:8080/embed";
130
+ assert.equal(embeddingConfigFromEnv(), null);
131
+ } finally {
132
+ restoreEnv(saved);
133
+ }
134
+ });
135
+
136
+ test("embeddingConfigFromEnv: rejects non-loopback IPv6", () => {
137
+ const saved = saveEnv();
138
+ try {
139
+ clearEnv();
140
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "[2001:db8::1]:8080/embed";
141
+ assert.equal(embeddingConfigFromEnv(), null);
142
+ } finally {
143
+ restoreEnv(saved);
144
+ }
145
+ });
146
+
147
+ test("embeddingConfigFromEnv: rejects 10.0.0.1 (private but not loopback)", () => {
148
+ const saved = saveEnv();
149
+ try {
150
+ clearEnv();
151
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "10.0.0.1:8080/embed";
152
+ assert.equal(embeddingConfigFromEnv(), null);
153
+ } finally {
154
+ restoreEnv(saved);
155
+ }
156
+ });
157
+
158
+ // --- Rejected: credentials in URL (userinfo bypass) -------------------------
159
+
160
+ test("embeddingConfigFromEnv: rejects userinfo bypass (localhost:port@evil.com)", () => {
161
+ const saved = saveEnv();
162
+ try {
163
+ clearEnv();
164
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "localhost:8080@evil.com/embed";
165
+ assert.equal(embeddingConfigFromEnv(), null);
166
+ } finally {
167
+ restoreEnv(saved);
168
+ }
169
+ });
170
+
171
+ test("embeddingConfigFromEnv: rejects user@localhost credentials", () => {
172
+ const saved = saveEnv();
173
+ try {
174
+ clearEnv();
175
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "user@localhost:8080/embed";
176
+ assert.equal(embeddingConfigFromEnv(), null);
177
+ } finally {
178
+ restoreEnv(saved);
179
+ }
180
+ });
181
+
182
+ test("embeddingConfigFromEnv: rejects :pass@localhost credentials", () => {
183
+ const saved = saveEnv();
184
+ try {
185
+ clearEnv();
186
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + ":pass@127.0.0.1:8080/embed";
187
+ assert.equal(embeddingConfigFromEnv(), null);
188
+ } finally {
189
+ restoreEnv(saved);
190
+ }
191
+ });
192
+
193
+ // --- Rejected: wrong scheme / invalid URL ------------------------------------
194
+
195
+ test("embeddingConfigFromEnv: rejects non-http scheme (ftp://)", () => {
196
+ const saved = saveEnv();
197
+ try {
198
+ clearEnv();
199
+ process.env.MEGACOMPACT_EMBEDDING_URL = "ftp://localhost:8080/embed";
200
+ assert.equal(embeddingConfigFromEnv(), null);
201
+ } finally {
202
+ restoreEnv(saved);
203
+ }
204
+ });
205
+
206
+ test("embeddingConfigFromEnv: rejects invalid URL string", () => {
207
+ const saved = saveEnv();
208
+ try {
209
+ clearEnv();
210
+ process.env.MEGACOMPACT_EMBEDDING_URL = "not a url";
211
+ assert.equal(embeddingConfigFromEnv(), null);
212
+ } finally {
213
+ restoreEnv(saved);
214
+ }
215
+ });
216
+
217
+ // --- Config fields parsed correctly -----------------------------------------
218
+
219
+ test("embeddingConfigFromEnv: parses apiKey and dim from env", () => {
220
+ const saved = saveEnv();
221
+ try {
222
+ clearEnv();
223
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "127.0.0.1:8080";
224
+ process.env.MEGACOMPACT_EMBEDDING_KEY = "secret-key";
225
+ process.env.MEGACOMPACT_EMBEDDING_DIM = "768";
226
+ const cfg = embeddingConfigFromEnv();
227
+ assert.ok(cfg);
228
+ assert.equal(cfg!.apiKey, "secret-key");
229
+ assert.equal(cfg!.dim, 768);
230
+ } finally {
231
+ restoreEnv(saved);
232
+ }
233
+ });
234
+
235
+ test("embeddingConfigFromEnv: parses valid headers from env", () => {
236
+ const saved = saveEnv();
237
+ try {
238
+ clearEnv();
239
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "127.0.0.1:8080";
240
+ process.env.MEGACOMPACT_EMBEDDING_HEADERS = '{"X-Custom":"value"}';
241
+ const cfg = embeddingConfigFromEnv();
242
+ assert.ok(cfg);
243
+ assert.equal(cfg!.headers?.["X-Custom"], "value");
244
+ } finally {
245
+ restoreEnv(saved);
246
+ }
247
+ });
248
+
249
+ test("embeddingConfigFromEnv: ignores invalid headers (does not throw)", () => {
250
+ const saved = saveEnv();
251
+ try {
252
+ clearEnv();
253
+ process.env.MEGACOMPACT_EMBEDDING_URL = HTTP + "127.0.0.1:8080";
254
+ process.env.MEGACOMPACT_EMBEDDING_HEADERS = "not json";
255
+ const cfg = embeddingConfigFromEnv();
256
+ assert.ok(cfg, "config should still be returned with invalid headers ignored");
257
+ assert.equal(cfg!.url, HTTP + "127.0.0.1:8080");
258
+ } finally {
259
+ restoreEnv(saved);
260
+ }
261
+ });
262
+
263
+ // --- Fail-closed: never throws -----------------------------------------------
264
+
265
+ test("embeddingConfigFromEnv: never throws on any input", () => {
266
+ const saved = saveEnv();
267
+ try {
268
+ const inputs = [
269
+ "",
270
+ "not a url",
271
+ "ftp://localhost",
272
+ HTTP + "192.168.1.1:8080",
273
+ HTTP + "localhost:8080@evil.com/",
274
+ HTTP + "[2001:db8::1]:80",
275
+ "file:///etc/passwd",
276
+ "javascript:alert(1)",
277
+ ];
278
+ for (const input of inputs) {
279
+ clearEnv();
280
+ process.env.MEGACOMPACT_EMBEDDING_URL = input;
281
+ assert.doesNotThrow(() => embeddingConfigFromEnv(), `should not throw for: ${input}`);
282
+ }
283
+ } finally {
284
+ restoreEnv(saved);
285
+ }
286
+ });
@@ -28,6 +28,7 @@
28
28
  import type { Embedder, Vector } from "./embedder.js";
29
29
  import { l2Normalize } from "./embedder.js";
30
30
  import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
31
+ import { isIP } from "node:net";
31
32
 
32
33
  export interface HttpEmbedderOptions {
33
34
  url: string;
@@ -39,23 +40,112 @@ export interface HttpEmbedderOptions {
39
40
  dim?: number;
40
41
  }
41
42
 
42
- /** Read + validate the localhost embeddings config from the environment. */
43
+ // Inline worker script: resolves a hostname via dns.lookup in a child process
44
+ // (dns.lookup is callback-async; the child has its own event loop). Used to
45
+ // verify that a hostname in the embedding URL resolves to loopback ONLY.
46
+ const DNS_WORKER = String.raw`
47
+ const { lookup } = await import("node:dns");
48
+ const { promisify } = await import("node:util");
49
+ const pLookup = promisify(lookup);
50
+ try {
51
+ const result = await pLookup(process.env.MC_DNS_HOST, { all: true });
52
+ process.stdout.write(JSON.stringify({ addresses: result.map((a) => a.address) }));
53
+ } catch (e) {
54
+ process.stdout.write(JSON.stringify({ error: String((e && e.message) || e) }));
55
+ }
56
+ `;
57
+
58
+ /** True if the IP string is a loopback address (127.x.x.x for IPv4, ::1 for IPv6). */
59
+ function isLoopbackIP(ip: string): boolean {
60
+ const type = isIP(ip);
61
+ if (type === 4) return ip.startsWith("127.");
62
+ if (type === 6) return ip === "::1";
63
+ return false;
64
+ }
65
+
66
+ /** Resolve a hostname via dns.lookup (in a child process) and verify EVERY
67
+ * returned address is loopback. Rejects on any resolution failure, empty
68
+ * result, or non-loopback answer. Fails closed: returns false on any error. */
69
+ function hostnameResolvesToLoopback(hostname: string): boolean {
70
+ const res = spawnSync(process.execPath, ["-e", DNS_WORKER], { // guardrails-allow PREVENT-PI-004: DNS lookup to verify hostname resolves to loopback (validation only, never sends data to a remote endpoint)
71
+ encoding: "utf8",
72
+ env: { ...process.env, MC_DNS_HOST: hostname },
73
+ timeout: 5000,
74
+ });
75
+ if (res.error || typeof res.stdout !== "string" || res.stdout.length === 0) {
76
+ return false;
77
+ }
78
+ try {
79
+ const parsed = JSON.parse(res.stdout);
80
+ if (parsed.error) return false;
81
+ if (!Array.isArray(parsed.addresses) || parsed.addresses.length === 0) return false;
82
+ return parsed.addresses.every((a: string) => isLoopbackIP(a));
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read + validate the localhost embeddings config from the environment.
90
+ *
91
+ * Security: the URL is parsed with `new URL()` (not regex) to prevent userinfo
92
+ * bypass (e.g. a URL like localhost:8080@evil.com/ masks evil.com as the real
93
+ * host). The hostname must be a loopback address:
94
+ * - Literal IPv4: 127.x.x.x (the full 127.0.0.0/8 range)
95
+ * - Literal IPv6: ::1
96
+ * - Hostname: resolved via dns.lookup; ALL returned addresses must be loopback
97
+ * Credentials in the URL (user:pass@) are rejected. Non-loopback literal IPs are
98
+ * rejected. Any resolution failure or non-loopback DNS answer is rejected.
99
+ *
100
+ * Fails CLOSED: unset/invalid/non-loopback URL → returns null → caller falls
101
+ * back to the local TrigramEmbedder. Never throws — a misconfigured URL must
102
+ * not crash the extension or silently connect to a remote endpoint.
103
+ */
43
104
  export function embeddingConfigFromEnv(): HttpEmbedderOptions | null {
44
105
  const url = process.env.MEGACOMPACT_EMBEDDING_URL;
45
106
  if (!url) return null;
46
- if (!/^https?:\/\/localhost[:/]/.test(url) && !/^https?:\/\/127\.0\.0\.1[:/]/.test(url)) {
47
- // Only loopback is permitted — a remote host would violate PREVENT-PI-004.
48
- throw new Error(
49
- `MEGACOMPACT_EMBEDDING_URL must be a localhost/127.0.0.1 endpoint (got ${url}). ` +
50
- `Remote embedding endpoints are not allowed (PREVENT-PI-004).`,
51
- );
107
+
108
+ let parsed: URL;
109
+ try {
110
+ parsed = new URL(url);
111
+ } catch {
112
+ console.warn(`MEGACOMPACT_EMBEDDING_URL is not a valid URL: ${url} — falling back to default embedder (PREVENT-PI-004)`);
113
+ return null;
114
+ }
115
+
116
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
117
+ console.warn(`MEGACOMPACT_EMBEDDING_URL must use http or https scheme (got ${parsed.protocol}) — falling back to default embedder (PREVENT-PI-004)`);
118
+ return null;
119
+ }
120
+
121
+ // Reject credentials in the URL (user:pass@) — they can mask a non-loopback host.
122
+ if (parsed.username || parsed.password) {
123
+ console.warn(`MEGACOMPACT_EMBEDDING_URL must not contain credentials (user:pass@) — falling back to default embedder (PREVENT-PI-004)`);
124
+ return null;
52
125
  }
126
+
127
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
128
+
129
+ if (isIP(hostname)) {
130
+ // Literal IP — must be loopback.
131
+ if (!isLoopbackIP(hostname)) {
132
+ console.warn(`MEGACOMPACT_EMBEDDING_URL must be a loopback IP (got ${hostname}) — falling back to default embedder (PREVENT-PI-004)`);
133
+ return null;
134
+ }
135
+ } else {
136
+ // Hostname — resolve via dns.lookup and require ALL addresses loopback.
137
+ if (!hostnameResolvesToLoopback(hostname)) {
138
+ console.warn(`MEGACOMPACT_EMBEDDING_URL hostname "${hostname}" does not resolve to loopback — falling back to default embedder (PREVENT-PI-004)`);
139
+ return null;
140
+ }
141
+ }
142
+
53
143
  const headers: Record<string, string> = {};
54
144
  if (process.env.MEGACOMPACT_EMBEDDING_HEADERS) {
55
145
  try {
56
146
  Object.assign(headers, JSON.parse(process.env.MEGACOMPACT_EMBEDDING_HEADERS));
57
147
  } catch {
58
- throw new Error("MEGACOMPACT_EMBEDDING_HEADERS must be valid JSON");
148
+ console.warn("MEGACOMPACT_EMBEDDING_HEADERS must be valid JSON — ignoring headers");
59
149
  }
60
150
  }
61
151
  const dim = process.env.MEGACOMPACT_EMBEDDING_DIM
@@ -0,0 +1,70 @@
1
+ /**
2
+ * mechanical-fix.test.ts — focused unit tests for non-SQLite mechanical fixes:
3
+ * appendCheckpoint session-id normalization (store.ts JSON file path) +
4
+ * preserveRecentForPressure floor of 1 (config.ts).
5
+ */
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { appendCheckpoint, listCheckpoints } from "./store.js";
12
+ import type { StoredCheckpoint } from "./store.js";
13
+ import { preserveRecentForPressure } from "./config.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // appendCheckpoint (store.ts) — normalizeSessionId on the write path
17
+ // ---------------------------------------------------------------------------
18
+
19
+ describe("mechanical-fix: appendCheckpoint (store.ts)", () => {
20
+ it("write→read roundtrips with an UNprefixed sessionId", () => {
21
+ const dir = mkdtempSync(join(tmpdir(), "mc-mech-cp-"));
22
+ try {
23
+ const cp: StoredCheckpoint = {
24
+ checkpointId: "chkpt_001",
25
+ sessionId: "abc", // UNprefixed → normalizes to sess_abc
26
+ summary: "test summary",
27
+ keyDecisions: ["decide"],
28
+ nextSteps: ["step"],
29
+ filesModified: ["file.ts"],
30
+ tokenEstimate: 50,
31
+ regionHash: "r1",
32
+ embedding: [],
33
+ timestamp: 1000,
34
+ };
35
+ appendCheckpoint(cp, dir);
36
+ // listCheckpoints normalizes 'abc' → 'sess_abc' on read; appendCheckpoint
37
+ // now normalizes on write → both use the same file path.
38
+ const read = listCheckpoints("abc", dir);
39
+ assert.equal(read.length, 1);
40
+ assert.equal(read[0].checkpointId, "chkpt_001");
41
+ assert.equal(read[0].sessionId, "abc"); // original preserved in the object
42
+ assert.equal(read[0].summary, "test summary");
43
+ // Reading via the already-normalized form also works.
44
+ const read2 = listCheckpoints("sess_abc", dir);
45
+ assert.equal(read2.length, 1);
46
+ } finally {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // preserveRecentForPressure (config.ts) — floor of 1
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("mechanical-fix: preserveRecentForPressure (config.ts)", () => {
57
+ it("floors at 1 (never compact ALL messages)", () => {
58
+ // preserveRecentMin=0 at full pressure → 0 would compact everything; floor=1.
59
+ assert.equal(preserveRecentForPressure(1.0, 10, 0), 1);
60
+ // preserveRecentMin=0, preserveRecent=0 at any pressure → still 1.
61
+ assert.equal(preserveRecentForPressure(1.0, 0, 0), 1);
62
+ });
63
+
64
+ it("respects preserveRecentMin when above the floor", () => {
65
+ // preserveRecentMin=5 at full pressure → 5 (above the floor, no effect).
66
+ assert.equal(preserveRecentForPressure(1.0, 10, 5), 5);
67
+ // Normal case: pressure=0.5, preserveRecent=10, min=2 → round(10 - 8*0.5) = 6.
68
+ assert.equal(preserveRecentForPressure(0.5, 10, 2), 6);
69
+ });
70
+ });