hippocamp 0.1.1 → 0.2.1

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.
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fsSync = require("node:fs");
4
+ const fs = require("node:fs/promises");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const memory = require("./hippocamp-memory.cjs");
8
+
9
+ const MAX_TOP_K = 20;
10
+ const DEFAULT_DATA = ".context/longmemeval/longmemeval_s_cleaned.json";
11
+ const DATA_URL =
12
+ "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json";
13
+
14
+ function printHelp() {
15
+ console.log(`Hippocamp retrieval eval
16
+
17
+ Retrieval-only LongMemEval-S check. No model or API key is used.
18
+ Each question's sessions are written as memory files in a temporary Lagoon,
19
+ then searchMemory and an in-process BM25 reference retrieve the top K.
20
+
21
+ Usage:
22
+ npm run eval:retrieval -- [--data PATH] [--limit N] [--top-k K]
23
+
24
+ Options:
25
+ --data PATH LongMemEval-S JSON. Default: ${DEFAULT_DATA}
26
+ --limit N Evaluate N questions spread evenly across the set. Default: all answerable questions
27
+ --top-k K Results retrieved per question, at most ${MAX_TOP_K} (the search_memory cap). Default: 5
28
+ --help Show this help
29
+
30
+ Download the data once:
31
+ mkdir -p .context/longmemeval
32
+ curl -L -o ${DEFAULT_DATA} \\
33
+ ${DATA_URL}
34
+ `);
35
+ }
36
+
37
+ function parsePositiveInteger(value, name) {
38
+ const number = Number(value);
39
+
40
+ if (!Number.isInteger(number) || number < 1) {
41
+ throw new Error(`${name} must be a positive integer.`);
42
+ }
43
+
44
+ return number;
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ const options = { data: DEFAULT_DATA, limit: null, topK: 5, help: false };
49
+
50
+ for (let index = 0; index < argv.length; index += 1) {
51
+ const token = argv[index];
52
+
53
+ if (token === "--help" || token === "-h") {
54
+ options.help = true;
55
+ } else if (token === "--data") {
56
+ options.data = argv[++index];
57
+ } else if (token === "--limit") {
58
+ options.limit = parsePositiveInteger(argv[++index], "--limit");
59
+ } else if (token === "--top-k") {
60
+ options.topK = parsePositiveInteger(argv[++index], "--top-k");
61
+
62
+ if (options.topK > MAX_TOP_K) {
63
+ throw new Error(`--top-k must be at most ${MAX_TOP_K}, the search_memory result cap.`);
64
+ }
65
+ } else {
66
+ throw new Error(`Unknown argument: ${token}`);
67
+ }
68
+ }
69
+
70
+ return options;
71
+ }
72
+
73
+ function formatSession(sessionId, date, turns) {
74
+ const lines = [`# Session ${sessionId}`, "", `Date: ${date || "unknown"}`, ""];
75
+
76
+ for (const turn of turns || []) {
77
+ lines.push(`## ${turn.role === "assistant" ? "Assistant" : "User"}`, "", String(turn.content || "").trim(), "");
78
+ }
79
+
80
+ return `${lines.join("\n").trim()}\n`;
81
+ }
82
+
83
+ function tokenize(value) {
84
+ return (
85
+ String(value || "")
86
+ .normalize("NFKD")
87
+ .replace(/[̀-ͯ]/g, "")
88
+ .toLowerCase()
89
+ .match(/[a-z0-9]+/g) || []
90
+ );
91
+ }
92
+
93
+ function rankBm25(query, documents) {
94
+ const tokenized = documents.map((document) => tokenize(document.content));
95
+ const averageLength = tokenized.reduce((sum, tokens) => sum + tokens.length, 0) / Math.max(tokenized.length, 1);
96
+ const documentFrequency = new Map();
97
+
98
+ for (const tokens of tokenized) {
99
+ for (const token of new Set(tokens)) {
100
+ documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
101
+ }
102
+ }
103
+
104
+ const queryTokens = [...new Set(tokenize(query))];
105
+
106
+ return documents
107
+ .map((document, index) => {
108
+ const frequencies = new Map();
109
+
110
+ for (const token of tokenized[index]) {
111
+ frequencies.set(token, (frequencies.get(token) || 0) + 1);
112
+ }
113
+
114
+ let score = 0;
115
+
116
+ for (const token of queryTokens) {
117
+ const frequency = frequencies.get(token) || 0;
118
+
119
+ if (!frequency) {
120
+ continue;
121
+ }
122
+
123
+ const matching = documentFrequency.get(token);
124
+ const idf = Math.log(1 + (documents.length - matching + 0.5) / (matching + 0.5));
125
+ const lengthRatio = tokenized[index].length / Math.max(averageLength, 1);
126
+ score += idf * ((frequency * 2.5) / (frequency + 1.5 * (0.25 + 0.75 * lengthRatio)));
127
+ }
128
+
129
+ return { document, score };
130
+ })
131
+ .sort((left, right) => right.score - left.score || left.document.index - right.document.index)
132
+ .map((item) => item.document);
133
+ }
134
+
135
+ function retrievalMetrics(retrievedIds, answerIds) {
136
+ const expected = new Set(answerIds);
137
+ const hits = retrievedIds.filter((id) => expected.has(id)).length;
138
+ const firstRank = retrievedIds.findIndex((id) => expected.has(id));
139
+
140
+ return {
141
+ recall: hits / expected.size,
142
+ hit: hits ? 1 : 0,
143
+ mrr: firstRank === -1 ? 0 : 1 / (firstRank + 1),
144
+ };
145
+ }
146
+
147
+ function addMetrics(bucket, system, metrics) {
148
+ const total = (bucket[system] ||= { recall: 0, hit: 0, mrr: 0, contextChars: 0, latencyMs: 0, count: 0 });
149
+
150
+ for (const key of ["recall", "hit", "mrr", "contextChars", "latencyMs"]) {
151
+ total[key] += metrics[key];
152
+ }
153
+
154
+ total.count += 1;
155
+ }
156
+
157
+ function formatRow(label, total) {
158
+ const average = (key) => total[key] / total.count;
159
+
160
+ return [
161
+ label.padEnd(12),
162
+ `Recall@K ${(average("recall") * 100).toFixed(1)}%`,
163
+ `Hit@K ${(average("hit") * 100).toFixed(1)}%`,
164
+ `MRR ${average("mrr").toFixed(3)}`,
165
+ `context ${Math.round(average("contextChars"))} chars`,
166
+ `latency ${average("latencyMs").toFixed(0)} ms`,
167
+ ].join(" ");
168
+ }
169
+
170
+ async function run(options) {
171
+ if (!fsSync.existsSync(options.data)) {
172
+ throw new Error(`Missing ${options.data}. Run with --help for the download command.`);
173
+ }
174
+
175
+ const answerable = JSON.parse(await fs.readFile(options.data, "utf8")).filter(
176
+ (item) => !String(item.question_id).endsWith("_abs"),
177
+ );
178
+ const limit = Math.min(options.limit || answerable.length, answerable.length);
179
+ const items = Array.from({ length: limit }, (_, index) => answerable[Math.floor((index * answerable.length) / limit)]);
180
+ const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-retrieval-"));
181
+ const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
182
+ const overall = {};
183
+ const byType = {};
184
+
185
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
186
+
187
+ try {
188
+ for (const [position, item] of items.entries()) {
189
+ const projectRoot = path.join(sandboxRoot, "questions", String(position));
190
+ const documents = [];
191
+ const pathToSessionId = new Map();
192
+
193
+ for (let index = 0; index < item.haystack_sessions.length; index += 1) {
194
+ const sessionId = String(item.haystack_session_ids[index]);
195
+ const relativePath = `sessions/${String(index).padStart(4, "0")}.md`;
196
+ const content = formatSession(sessionId, item.haystack_dates[index], item.haystack_sessions[index]);
197
+
198
+ await memory.writeMemoryFile({ scope: "project", path: relativePath, content, projectRoot, sync: false });
199
+ documents.push({ index, content, sessionId });
200
+ pathToSessionId.set(relativePath, sessionId);
201
+ }
202
+
203
+ const answerIds = item.answer_session_ids.map(String);
204
+ let startedAt = performance.now();
205
+ const search = await memory.searchMemory({
206
+ query: item.question,
207
+ scope: "project",
208
+ projectRoot,
209
+ maxResults: options.topK,
210
+ });
211
+ const hippocamp = {
212
+ ...retrievalMetrics(search.results.map((result) => pathToSessionId.get(result.path)), answerIds),
213
+ latencyMs: performance.now() - startedAt,
214
+ contextChars: search.results.reduce((sum, result) => sum + result.snippet.length, 0),
215
+ };
216
+
217
+ startedAt = performance.now();
218
+ const bm25Documents = rankBm25(item.question, documents).slice(0, options.topK);
219
+ const bm25 = {
220
+ ...retrievalMetrics(bm25Documents.map((document) => document.sessionId), answerIds),
221
+ latencyMs: performance.now() - startedAt,
222
+ contextChars: bm25Documents.reduce((sum, document) => sum + document.content.length, 0),
223
+ };
224
+
225
+ for (const bucket of [overall, (byType[item.question_type] ||= {})]) {
226
+ addMetrics(bucket, "hippocamp", hippocamp);
227
+ addMetrics(bucket, "bm25", bm25);
228
+ }
229
+
230
+ await fs.rm(process.env.HIPPOCAMP_GLOBAL_ROOT, { recursive: true, force: true });
231
+
232
+ if ((position + 1) % 50 === 0) {
233
+ console.error(`progress ${position + 1}/${items.length}`);
234
+ }
235
+ }
236
+ } finally {
237
+ if (previousGlobalRoot === undefined) {
238
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
239
+ } else {
240
+ process.env.HIPPOCAMP_GLOBAL_ROOT = previousGlobalRoot;
241
+ }
242
+
243
+ await fs.rm(sandboxRoot, { recursive: true, force: true });
244
+ }
245
+
246
+ console.log(`LongMemEval-S retrieval, ${items.length} answerable questions, K=${options.topK}`);
247
+ console.log("bm25 returns whole sessions; hippocamp returns bounded snippets.\n");
248
+
249
+ for (const [system, total] of Object.entries(overall)) {
250
+ console.log(formatRow(system, total));
251
+ }
252
+
253
+ for (const [type, systems] of Object.entries(byType)) {
254
+ console.log(`\n${type} (${systems.bm25.count})`);
255
+
256
+ for (const [system, total] of Object.entries(systems)) {
257
+ console.log(` ${formatRow(system, total)}`);
258
+ }
259
+ }
260
+ }
261
+
262
+ async function main() {
263
+ const options = parseArgs(process.argv.slice(2));
264
+
265
+ if (options.help) {
266
+ printHelp();
267
+ return;
268
+ }
269
+
270
+ await run(options);
271
+ }
272
+
273
+ main().catch((error) => {
274
+ console.error(error.message);
275
+ process.exit(1);
276
+ });
@@ -10,6 +10,17 @@ const DEFAULT_THRESHOLD_CHARS = 20000;
10
10
  const DEFAULT_TARGET_CHARS = 15000;
11
11
  const DEFAULT_MODEL = "auto";
12
12
  const TARGET_BYTE_TOLERANCE = 0.02;
13
+ const DREAM_COMPACTION_MAX_PASSES = 3;
14
+ const DREAM_MODEL_MAX_ATTEMPTS = 3;
15
+ const DREAM_MODEL_RETRY_DELAY_MS = 2000;
16
+ // Manifest returns HTTP 200 with a "[🦚 Manifest <code>] ..." banner as the
17
+ // assistant message when the router cannot fulfil a request (model unavailable,
18
+ // overloaded, throttled). It is not JSON, so treat it as a retryable failure.
19
+ const MANIFEST_BANNER_PATTERN = /^\s*\[\s*🦚/u;
20
+ // Manifest surfaces upstream throttling and provider outages as HTTP errors.
21
+ // A 429 is returned both for exhausted subscription quota and for a route that
22
+ // is briefly cooling down, so retry these rather than failing the whole run.
23
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429]);
13
24
 
14
25
  function numberFromEnv(name, fallback) {
15
26
  const value = process.env[name];
@@ -345,6 +356,37 @@ function createPrompt({ report, targetChars, threadEvidence }) {
345
356
  ];
346
357
  }
347
358
 
359
+ function createCompactionRetryPrompt({ output, targetChars }) {
360
+ return [
361
+ {
362
+ role: "system",
363
+ content: [
364
+ "You are Hippocamp Dream, an offline memory curator.",
365
+ "The previous compaction pass was still too large.",
366
+ "Rewrite the complete snapshot again as a shorter, coherent snapshot.",
367
+ "Preserve the goal, important instructions, technical decisions, active state, and open work.",
368
+ "Remove repetition and historical detail before shortening individual facts.",
369
+ "Do not crop text, leave partial bullets, or invent facts.",
370
+ "Return strict JSON only, with keys current_state_md and open_threads_md.",
371
+ "current_state_md must start with '# Current State'.",
372
+ "open_threads_md must start with '# Open Threads'.",
373
+ ].join(" "),
374
+ },
375
+ {
376
+ role: "user",
377
+ content: [
378
+ `Target combined size for current_state.md and open_threads.md: ${targetChars} chars`,
379
+ "",
380
+ "## current_state.md",
381
+ output.currentState,
382
+ "",
383
+ "## open_threads.md",
384
+ output.openThreads,
385
+ ].join("\n"),
386
+ },
387
+ ];
388
+ }
389
+
348
390
  function normalizeBaseUrl(value) {
349
391
  const trimmed = String(value || "").trim().replace(/\/+$/, "");
350
392
 
@@ -398,6 +440,18 @@ function getResponseText(payload) {
398
440
  return "";
399
441
  }
400
442
 
443
+ function isManifestBanner(content) {
444
+ return MANIFEST_BANNER_PATTERN.test(content);
445
+ }
446
+
447
+ function isRetryableStatus(status) {
448
+ return RETRYABLE_HTTP_STATUSES.has(status) || status >= 500;
449
+ }
450
+
451
+ function delay(ms) {
452
+ return new Promise((resolve) => setTimeout(resolve, ms));
453
+ }
454
+
401
455
  async function callDreamModel({ apiKey, baseUrl, messages, model }) {
402
456
  if (!apiKey) {
403
457
  throw new Error("Missing API key. Set MANIFEST_API_KEY or pass --api-key.");
@@ -407,41 +461,90 @@ async function callDreamModel({ apiKey, baseUrl, messages, model }) {
407
461
  throw new Error("This command requires Node.js fetch support.");
408
462
  }
409
463
 
410
- const response = await fetch(`${normalizeBaseUrl(baseUrl)}/responses`, {
411
- method: "POST",
412
- headers: {
413
- Authorization: `Bearer ${apiKey}`,
414
- "Content-Type": "application/json",
415
- },
416
- body: JSON.stringify({
417
- input: createResponsesInput(messages),
418
- model,
419
- store: false,
420
- text: createDreamTextFormat(),
421
- }),
422
- });
464
+ let lastBanner = null;
465
+ const endpoint = `${normalizeBaseUrl(baseUrl)}/responses`;
423
466
 
424
- const text = await response.text();
467
+ for (let attempt = 1; attempt <= DREAM_MODEL_MAX_ATTEMPTS; attempt += 1) {
468
+ let response;
469
+ let text;
425
470
 
426
- if (!response.ok) {
427
- throw new Error(`Dream model request failed (${response.status}): ${text}`);
428
- }
471
+ try {
472
+ response = await fetch(endpoint, {
473
+ method: "POST",
474
+ headers: {
475
+ Authorization: `Bearer ${apiKey}`,
476
+ "Content-Type": "application/json",
477
+ },
478
+ body: JSON.stringify({
479
+ input: createResponsesInput(messages),
480
+ model,
481
+ store: false,
482
+ text: createDreamTextFormat(),
483
+ }),
484
+ });
485
+ text = await response.text();
486
+ } catch (error) {
487
+ if (attempt < DREAM_MODEL_MAX_ATTEMPTS) {
488
+ await delay(DREAM_MODEL_RETRY_DELAY_MS * attempt);
489
+ continue;
490
+ }
429
491
 
430
- const payload = JSON.parse(text);
431
- const content = getResponseText(payload);
492
+ throw new Error(`Dream model request failed after ${DREAM_MODEL_MAX_ATTEMPTS} attempts: ${error.message}`);
493
+ }
494
+
495
+ if (!response.ok) {
496
+ if (isRetryableStatus(response.status) && attempt < DREAM_MODEL_MAX_ATTEMPTS) {
497
+ await delay(DREAM_MODEL_RETRY_DELAY_MS * attempt);
498
+ continue;
499
+ }
500
+
501
+ throw new Error(`Dream model request failed (${response.status}): ${text}`);
502
+ }
503
+
504
+ const payload = JSON.parse(text);
505
+ const content = getResponseText(payload);
506
+
507
+ if (typeof content !== "string" || !content.trim()) {
508
+ throw new Error("Dream model response did not include output text.");
509
+ }
510
+
511
+ if (isManifestBanner(content)) {
512
+ lastBanner = content.trim();
432
513
 
433
- if (typeof content !== "string" || !content.trim()) {
434
- throw new Error("Dream model response did not include output text.");
514
+ if (attempt < DREAM_MODEL_MAX_ATTEMPTS) {
515
+ await delay(DREAM_MODEL_RETRY_DELAY_MS * attempt);
516
+ continue;
517
+ }
518
+
519
+ throw new Error(
520
+ `Dream model returned a Manifest router banner instead of JSON after ${DREAM_MODEL_MAX_ATTEMPTS} attempts: ${lastBanner}`,
521
+ );
522
+ }
523
+
524
+ return content;
435
525
  }
436
526
 
437
- return content;
527
+ // Unreachable: the loop either returns content or throws on the final attempt.
528
+ throw new Error(`Dream model returned a Manifest router banner instead of JSON: ${lastBanner}`);
438
529
  }
439
530
 
440
531
  function parseDreamJson(content) {
441
532
  const trimmed = content.trim();
442
533
  const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
443
534
  const jsonText = match ? match[1].trim() : trimmed;
444
- const parsed = JSON.parse(jsonText);
535
+ let parsed;
536
+
537
+ try {
538
+ parsed = JSON.parse(jsonText);
539
+ } catch (error) {
540
+ const object = jsonText.match(/\{[\s\S]*\}/)?.[0];
541
+
542
+ if (!object) {
543
+ throw error;
544
+ }
545
+
546
+ parsed = JSON.parse(object);
547
+ }
445
548
 
446
549
  if (typeof parsed.current_state_md !== "string" || typeof parsed.open_threads_md !== "string") {
447
550
  throw new Error("Dream response must include current_state_md and open_threads_md strings.");
@@ -461,7 +564,7 @@ function getMaxOutputBytes(targetChars) {
461
564
  return Math.ceil(targetChars * (1 + TARGET_BYTE_TOLERANCE));
462
565
  }
463
566
 
464
- function validateDreamOutput(output, beforeBytes, targetChars) {
567
+ function validateDreamStructure(output) {
465
568
  if (!output.currentState.startsWith("# Current State")) {
466
569
  throw new Error("Dream current_state_md must start with '# Current State'.");
467
570
  }
@@ -469,20 +572,44 @@ function validateDreamOutput(output, beforeBytes, targetChars) {
469
572
  if (!output.openThreads.startsWith("# Open Threads")) {
470
573
  throw new Error("Dream open_threads_md must start with '# Open Threads'.");
471
574
  }
575
+ }
472
576
 
473
- const afterBytes = getDreamOutputBytes(output);
474
-
475
- if (afterBytes >= beforeBytes) {
476
- throw new Error(`Dream output is not smaller than the input (${afterBytes} >= ${beforeBytes} bytes).`);
477
- }
478
-
577
+ async function compactDreamOutput({ apiKey, baseUrl, beforeBytes, model, report, targetChars, threadEvidence }) {
479
578
  const maxBytes = getMaxOutputBytes(targetChars);
579
+ let previousBytes = beforeBytes;
580
+ let previousOutput = null;
581
+
582
+ for (let pass = 1; pass <= DREAM_COMPACTION_MAX_PASSES; pass += 1) {
583
+ const messages = previousOutput
584
+ ? createCompactionRetryPrompt({ output: previousOutput, targetChars })
585
+ : createPrompt({ report, targetChars, threadEvidence });
586
+ const content = await callDreamModel({ apiKey, baseUrl, messages, model });
587
+ const output = parseDreamJson(content);
588
+ validateDreamStructure(output);
589
+
590
+ const afterBytes = getDreamOutputBytes(output);
591
+
592
+ if (afterBytes >= previousBytes) {
593
+ throw new Error(
594
+ `Dream compaction pass ${pass} did not reduce the snapshot (${afterBytes} >= ${previousBytes} bytes).`,
595
+ );
596
+ }
480
597
 
481
- if (afterBytes > maxBytes) {
482
- throw new Error(`Dream output exceeds --target-chars (${afterBytes} > ${maxBytes} bytes).`);
598
+ if (afterBytes <= maxBytes) {
599
+ return {
600
+ afterBytes,
601
+ output,
602
+ passes: pass,
603
+ };
604
+ }
605
+
606
+ previousBytes = afterBytes;
607
+ previousOutput = output;
483
608
  }
484
609
 
485
- return afterBytes;
610
+ throw new Error(
611
+ `Dream output exceeds --target-chars after ${DREAM_COMPACTION_MAX_PASSES} coherent compaction passes (${previousBytes} > ${maxBytes} bytes).`,
612
+ );
486
613
  }
487
614
 
488
615
  async function writeDreamOutput(report, output) {
@@ -496,6 +623,7 @@ function toPublicReport(result) {
496
623
  afterWakeUpChars: result.afterWakeUpChars,
497
624
  beforeBytes: result.beforeBytes,
498
625
  changed: result.changed,
626
+ compactionPasses: result.compactionPasses,
499
627
  context: {
500
628
  evidenceBudgetChars: result.threadEvidence.evidenceBudgetChars,
501
629
  evidenceChars: result.threadEvidence.evidenceChars,
@@ -545,22 +673,24 @@ async function dreamProject(slug, options) {
545
673
  };
546
674
  }
547
675
 
548
- const content = await callDreamModel({
676
+ const compacted = await compactDreamOutput({
549
677
  apiKey: options.apiKey,
550
678
  baseUrl: options.baseUrl,
551
- messages: createPrompt({ report, targetChars: options.targetChars, threadEvidence }),
679
+ beforeBytes,
552
680
  model: options.model,
681
+ report,
682
+ targetChars: options.targetChars,
683
+ threadEvidence,
553
684
  });
554
- const output = parseDreamJson(content);
555
- const afterBytes = validateDreamOutput(output, beforeBytes, options.targetChars);
556
685
 
557
- await writeDreamOutput(report, output);
686
+ await writeDreamOutput(report, compacted.output);
558
687
 
559
688
  return {
560
- afterBytes,
689
+ afterBytes: compacted.afterBytes,
561
690
  afterWakeUpChars: (await memory.wakeUp({ projectRoot: getSyntheticProjectRoot(report.slug) })).text.length,
562
691
  beforeBytes,
563
692
  changed: true,
693
+ compactionPasses: compacted.passes,
564
694
  report,
565
695
  skipped: null,
566
696
  threadEvidence,
@@ -580,6 +710,7 @@ function printText(results) {
580
710
  `threads=${result.context.openThreads}`,
581
711
  `matchedThreads=${result.context.threadsWithEvidence}`,
582
712
  `evidence=${result.context.evidenceItems}`,
713
+ result.compactionPasses ? `passes=${result.compactionPasses}` : null,
583
714
  result.afterBytes ? `after=${result.afterBytes}` : null,
584
715
  result.afterWakeUpChars ? `afterWake=${result.afterWakeUpChars}` : null,
585
716
  ]
@@ -620,7 +751,18 @@ async function main() {
620
751
  printText(results);
621
752
  }
622
753
 
623
- main().catch((error) => {
624
- console.error("Hippocamp Dream failed:", error.message);
625
- process.exit(1);
626
- });
754
+ if (require.main === module) {
755
+ main().catch((error) => {
756
+ console.error("Hippocamp Dream failed:", error.message);
757
+ process.exit(1);
758
+ });
759
+ }
760
+
761
+ module.exports = {
762
+ callDreamModel,
763
+ compactDreamOutput,
764
+ getDreamOutputBytes,
765
+ isManifestBanner,
766
+ isRetryableStatus,
767
+ parseDreamJson,
768
+ };