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.
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +27 -0
- package/AGENTS.md +13 -5
- package/Dockerfile.railway +15 -0
- package/README.md +64 -9
- package/assets/github-actions/hippocamp-dream.yml +34 -1
- package/assets/railway/run-dream.sh +184 -0
- package/package.json +10 -3
- package/railway.json +12 -0
- package/scripts/eval-retrieval.cjs +276 -0
- package/scripts/hippocamp-dream.cjs +185 -43
- package/scripts/hippocamp-mcp.cjs +35 -14
- package/scripts/hippocamp-memory.cjs +408 -147
- package/scripts/hippocamp.cjs +6 -0
- package/scripts/install-claude.cjs +7 -3
- package/scripts/install-codex.cjs +7 -2
- package/scripts/install-grok.cjs +260 -0
- package/skills/hippocamp-memory/SKILL.md +16 -9
- package/test/mcp-params.test.cjs +80 -0
- package/test/memory-efficiency.test.cjs +223 -0
- package/test/package-paths.test.cjs +30 -0
|
@@ -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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
467
|
+
for (let attempt = 1; attempt <= DREAM_MODEL_MAX_ATTEMPTS; attempt += 1) {
|
|
468
|
+
let response;
|
|
469
|
+
let text;
|
|
425
470
|
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
676
|
+
const compacted = await compactDreamOutput({
|
|
549
677
|
apiKey: options.apiKey,
|
|
550
678
|
baseUrl: options.baseUrl,
|
|
551
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
+
};
|