hippocamp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,626 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fsSync = require("node:fs");
4
+ const fs = require("node:fs/promises");
5
+ const path = require("node:path");
6
+ const memory = require("./hippocamp-memory.cjs");
7
+
8
+ const CURATED_FILES = ["current_state.md", "open_threads.md"];
9
+ const DEFAULT_THRESHOLD_CHARS = 20000;
10
+ const DEFAULT_TARGET_CHARS = 15000;
11
+ const DEFAULT_MODEL = "auto";
12
+ const TARGET_BYTE_TOLERANCE = 0.02;
13
+
14
+ function numberFromEnv(name, fallback) {
15
+ const value = process.env[name];
16
+
17
+ return value ? Number(value) : fallback;
18
+ }
19
+
20
+ function printHelp() {
21
+ console.log(`Hippocamp Dream
22
+
23
+ Offline memory compaction for Lagoon curated project files.
24
+
25
+ Usage:
26
+ hippocamp dream --project SLUG [--dry-run|--write]
27
+ hippocamp dream --all [--dry-run|--write]
28
+
29
+ Options:
30
+ --project SLUG Compact one project under projects/<slug>
31
+ --all Scan all projects and compact those over the threshold
32
+ --threshold-chars N Minimum wake-up size to include. Default: HIPPOCAMP_DREAM_THRESHOLD_CHARS or ${DEFAULT_THRESHOLD_CHARS}
33
+ --target-chars N Target combined size for current_state/open_threads. Default: HIPPOCAMP_DREAM_TARGET_CHARS or ${DEFAULT_TARGET_CHARS}
34
+ --model NAME Responses API model. Default: HIPPOCAMP_DREAM_MODEL or ${DEFAULT_MODEL}
35
+ --base-url URL Responses API base URL. Default: MANIFEST_BASE_URL
36
+ --api-key KEY API key. Default: MANIFEST_API_KEY
37
+ --global-root PATH Lagoon root. Default: HIPPOCAMP_GLOBAL_ROOT or ~/.lagoon
38
+ --json Print machine-readable JSON
39
+ --dry-run Report candidates without calling AI or writing files. Default
40
+ --write Call AI and rewrite only current_state.md/open_threads.md
41
+ `);
42
+ }
43
+
44
+ function parseArgs(argv) {
45
+ const options = {
46
+ all: false,
47
+ apiKey: process.env.MANIFEST_API_KEY || process.env.HIPPOCAMP_DREAM_API_KEY || "",
48
+ baseUrl: process.env.MANIFEST_BASE_URL || process.env.HIPPOCAMP_DREAM_BASE_URL || "",
49
+ dryRun: true,
50
+ globalRoot: process.env.HIPPOCAMP_GLOBAL_ROOT || "",
51
+ json: false,
52
+ model: process.env.HIPPOCAMP_DREAM_MODEL || DEFAULT_MODEL,
53
+ project: "",
54
+ targetChars: numberFromEnv("HIPPOCAMP_DREAM_TARGET_CHARS", DEFAULT_TARGET_CHARS),
55
+ thresholdChars: numberFromEnv("HIPPOCAMP_DREAM_THRESHOLD_CHARS", DEFAULT_THRESHOLD_CHARS),
56
+ write: false,
57
+ };
58
+
59
+ for (let index = 0; index < argv.length; index += 1) {
60
+ const token = argv[index];
61
+
62
+ if (token === "--all") {
63
+ options.all = true;
64
+ continue;
65
+ }
66
+
67
+ if (token === "--api-key") {
68
+ options.apiKey = argv[index + 1] || "";
69
+ index += 1;
70
+ continue;
71
+ }
72
+
73
+ if (token === "--base-url") {
74
+ options.baseUrl = argv[index + 1] || "";
75
+ index += 1;
76
+ continue;
77
+ }
78
+
79
+ if (token === "--dry-run") {
80
+ options.dryRun = true;
81
+ options.write = false;
82
+ continue;
83
+ }
84
+
85
+ if (token === "--global-root") {
86
+ options.globalRoot = argv[index + 1] || "";
87
+ index += 1;
88
+ continue;
89
+ }
90
+
91
+ if (token === "--json") {
92
+ options.json = true;
93
+ continue;
94
+ }
95
+
96
+ if (token === "--model") {
97
+ options.model = argv[index + 1] || "";
98
+ index += 1;
99
+ continue;
100
+ }
101
+
102
+ if (token === "--project") {
103
+ options.project = argv[index + 1] || "";
104
+ index += 1;
105
+ continue;
106
+ }
107
+
108
+ if (token === "--target-chars") {
109
+ options.targetChars = Number(argv[index + 1]);
110
+ index += 1;
111
+ continue;
112
+ }
113
+
114
+ if (token === "--threshold-chars") {
115
+ options.thresholdChars = Number(argv[index + 1]);
116
+ index += 1;
117
+ continue;
118
+ }
119
+
120
+ if (token === "--write") {
121
+ options.dryRun = false;
122
+ options.write = true;
123
+ continue;
124
+ }
125
+
126
+ if (token === "--help" || token === "-h") {
127
+ printHelp();
128
+ process.exit(0);
129
+ }
130
+
131
+ throw new Error(`Unknown argument: ${token}`);
132
+ }
133
+
134
+ if (!options.all && !options.project) {
135
+ throw new Error("Provide --project SLUG or --all.");
136
+ }
137
+
138
+ if (options.all && options.project) {
139
+ throw new Error("Use either --project or --all, not both.");
140
+ }
141
+
142
+ if (!Number.isFinite(options.thresholdChars) || options.thresholdChars < 1) {
143
+ throw new Error("--threshold-chars must be a positive number.");
144
+ }
145
+
146
+ if (!Number.isFinite(options.targetChars) || options.targetChars < 1) {
147
+ throw new Error("--target-chars must be a positive number.");
148
+ }
149
+
150
+ if (options.globalRoot) {
151
+ process.env.HIPPOCAMP_GLOBAL_ROOT = options.globalRoot;
152
+ }
153
+
154
+ return options;
155
+ }
156
+
157
+ function assertSafeProjectSlug(slug) {
158
+ if (!/^[a-z0-9._-]+$/.test(slug) || slug === "." || slug === ".." || slug.includes(".git")) {
159
+ throw new Error(`Unsafe project slug: ${slug}`);
160
+ }
161
+
162
+ return slug;
163
+ }
164
+
165
+ async function readFileIfExists(filePath) {
166
+ try {
167
+ return await fs.readFile(filePath, "utf8");
168
+ } catch (error) {
169
+ if (error && typeof error === "object" && error.code === "ENOENT") {
170
+ return "";
171
+ }
172
+
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async function statSize(filePath) {
178
+ try {
179
+ return (await fs.stat(filePath)).size;
180
+ } catch (error) {
181
+ if (error && typeof error === "object" && error.code === "ENOENT") {
182
+ return 0;
183
+ }
184
+
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async function listProjectSlugs(globalRoot) {
190
+ const projectsRoot = path.join(globalRoot, "projects");
191
+ const entries = await fs.readdir(projectsRoot, { withFileTypes: true }).catch((error) => {
192
+ if (error && typeof error === "object" && error.code === "ENOENT") {
193
+ return [];
194
+ }
195
+
196
+ throw error;
197
+ });
198
+
199
+ return entries
200
+ .filter((entry) => entry.isDirectory())
201
+ .map((entry) => entry.name)
202
+ .filter((name) => /^[a-z0-9._-]+$/.test(name))
203
+ .sort((left, right) => left.localeCompare(right));
204
+ }
205
+
206
+ function getSyntheticProjectRoot(slug) {
207
+ return path.join("/tmp", slug);
208
+ }
209
+
210
+ async function getProjectReport(slug) {
211
+ const safeSlug = assertSafeProjectSlug(slug);
212
+ const projectRoot = getSyntheticProjectRoot(safeSlug);
213
+ const projectMemoryRoot = memory.getScopeRoot("project", projectRoot);
214
+ const files = {};
215
+
216
+ for (const name of CURATED_FILES) {
217
+ const absolutePath = path.join(projectMemoryRoot, name);
218
+ files[name] = {
219
+ absolutePath,
220
+ bytes: await statSize(absolutePath),
221
+ content: await readFileIfExists(absolutePath),
222
+ path: `projects/${safeSlug}/${name}`,
223
+ };
224
+ }
225
+
226
+ const wake = await memory.wakeUp({ projectRoot });
227
+
228
+ return {
229
+ files,
230
+ projectMemoryRoot,
231
+ slug: safeSlug,
232
+ wakeUpChars: wake.text.length,
233
+ };
234
+ }
235
+
236
+ function extractOpenThreads(content) {
237
+ return String(content || "")
238
+ .split(/\r?\n/)
239
+ .map((line) => line.match(/^\s*-\s+(.+?)\s*$/)?.[1]?.trim())
240
+ .filter(Boolean);
241
+ }
242
+
243
+ async function buildThreadEvidence(report, { budgetChars }) {
244
+ const projectRoot = getSyntheticProjectRoot(report.slug);
245
+ const threads = extractOpenThreads(report.files["open_threads.md"].content);
246
+ const items = [];
247
+ let evidenceChars = 0;
248
+
249
+ for (const thread of threads) {
250
+ const search = await memory.searchMemory({
251
+ projectRoot,
252
+ query: thread,
253
+ scope: "project",
254
+ });
255
+ const match = search.results.find((item) => item.path.startsWith("events/"));
256
+
257
+ if (!match) {
258
+ continue;
259
+ }
260
+
261
+ const item = {
262
+ path: match.path,
263
+ heading: match.heading,
264
+ cues: match.cues || [],
265
+ score: match.score,
266
+ snippet: match.snippet,
267
+ };
268
+ const candidate = {
269
+ thread,
270
+ query: thread,
271
+ evidence: [item],
272
+ };
273
+ const candidateChars = Buffer.byteLength(JSON.stringify(candidate), "utf8");
274
+
275
+ if (evidenceChars + candidateChars > budgetChars) {
276
+ break;
277
+ }
278
+
279
+ items.push(candidate);
280
+ evidenceChars += candidateChars;
281
+ }
282
+
283
+ return {
284
+ evidenceBudgetChars: budgetChars,
285
+ evidenceChars,
286
+ openThreadCount: threads.length,
287
+ items,
288
+ };
289
+ }
290
+
291
+ function countEvidenceItems(threadEvidence) {
292
+ return threadEvidence.items.reduce((total, item) => total + item.evidence.length, 0);
293
+ }
294
+
295
+ function createPrompt({ report, targetChars, threadEvidence }) {
296
+ return [
297
+ {
298
+ role: "system",
299
+ content: [
300
+ "You are Hippocamp Dream, an offline memory curator.",
301
+ "Rewrite only the project's curated memory files so future wake_up context stays compact and useful.",
302
+ "Do not add new facts that are not supported by the provided files.",
303
+ "Do not preserve completed historical run logs unless they explain an active state, durable decision, or open follow-up.",
304
+ "Use thread evidence to decide whether open threads are still active, resolved, superseded, or worth promoting into current state.",
305
+ "Remove an open thread only when the current files or evidence clearly show it is closed or obsolete; keep it concise when uncertain.",
306
+ "The evidence pack is capped, so missing evidence never proves that a thread is closed.",
307
+ "The combined output must stay under the requested target size.",
308
+ "Quality bar: every bullet must be useful to a future agent immediately after wake_up.",
309
+ "current_state.md is for durable project state, active constraints, and recent decisions; it is not a backlog or PR inventory.",
310
+ "In current_state.md, use workstream summaries instead of PR lists; include a concrete PR number only for a live blocker, active architecture decision, or release-critical state.",
311
+ "open_threads.md is for next actions and decisions; it is not a mirror of current_state.md.",
312
+ "In open_threads.md, avoid long comma-separated PR lists. Use counts, ranges, or workstream names for merge-ready and monitoring batches; name specific PRs only for next review, blockers, or unusual follow-up.",
313
+ "Do not duplicate the same inventory across both files.",
314
+ "Do not enumerate every historical PR; keep only the most current, actionable, or high-risk items.",
315
+ "Collapse large PR batches into grouped workstreams and name only blockers, exceptions, or representative references.",
316
+ "Treat open_threads.md as an agenda, not an issue tracker.",
317
+ "Group related PR review, merge, monitor, and local-run items by repo or status instead of listing every item separately.",
318
+ "Prefer short bullets. Preserve concrete references when they are still actionable.",
319
+ "Return strict JSON only, with keys current_state_md and open_threads_md.",
320
+ "current_state_md must start with '# Current State'.",
321
+ "open_threads_md must start with '# Open Threads'.",
322
+ ].join(" "),
323
+ },
324
+ {
325
+ role: "user",
326
+ content: [
327
+ `Project: ${report.slug}`,
328
+ `Current wake_up size: ${report.wakeUpChars} chars`,
329
+ `Target combined size for current_state.md and open_threads.md: ${targetChars} chars`,
330
+ "",
331
+ "Rewrite these files from the current content below.",
332
+ "",
333
+ "## current_state.md",
334
+ report.files["current_state.md"].content.trim() || "# Current State",
335
+ "",
336
+ "## open_threads.md",
337
+ report.files["open_threads.md"].content.trim() || "# Open Threads",
338
+ "",
339
+ "## Thread Evidence",
340
+ "Evidence is retrieved from cue-indexed project events. It is intentionally small and may be incomplete.",
341
+ `Evidence budget: ${threadEvidence.evidenceChars}/${threadEvidence.evidenceBudgetChars} chars`,
342
+ JSON.stringify(threadEvidence.items, null, 2),
343
+ ].join("\n"),
344
+ },
345
+ ];
346
+ }
347
+
348
+ function normalizeBaseUrl(value) {
349
+ const trimmed = String(value || "").trim().replace(/\/+$/, "");
350
+
351
+ if (!trimmed) {
352
+ throw new Error("Missing Manifest/OpenAI-compatible base URL. Set MANIFEST_BASE_URL or pass --base-url.");
353
+ }
354
+
355
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
356
+ }
357
+
358
+ function createResponsesInput(messages) {
359
+ return messages.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
360
+ }
361
+
362
+ function createDreamTextFormat() {
363
+ return {
364
+ format: {
365
+ type: "json_schema",
366
+ name: "hippocamp_dream",
367
+ strict: true,
368
+ schema: {
369
+ type: "object",
370
+ additionalProperties: false,
371
+ required: ["current_state_md", "open_threads_md"],
372
+ properties: {
373
+ current_state_md: {
374
+ type: "string",
375
+ },
376
+ open_threads_md: {
377
+ type: "string",
378
+ },
379
+ },
380
+ },
381
+ },
382
+ };
383
+ }
384
+
385
+ function getResponseText(payload) {
386
+ if (typeof payload.output_text === "string" && payload.output_text.trim()) {
387
+ return payload.output_text;
388
+ }
389
+
390
+ for (const item of payload.output || []) {
391
+ for (const content of item.content || []) {
392
+ if (typeof content.text === "string" && content.text.trim()) {
393
+ return content.text;
394
+ }
395
+ }
396
+ }
397
+
398
+ return "";
399
+ }
400
+
401
+ async function callDreamModel({ apiKey, baseUrl, messages, model }) {
402
+ if (!apiKey) {
403
+ throw new Error("Missing API key. Set MANIFEST_API_KEY or pass --api-key.");
404
+ }
405
+
406
+ if (!global.fetch) {
407
+ throw new Error("This command requires Node.js fetch support.");
408
+ }
409
+
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
+ });
423
+
424
+ const text = await response.text();
425
+
426
+ if (!response.ok) {
427
+ throw new Error(`Dream model request failed (${response.status}): ${text}`);
428
+ }
429
+
430
+ const payload = JSON.parse(text);
431
+ const content = getResponseText(payload);
432
+
433
+ if (typeof content !== "string" || !content.trim()) {
434
+ throw new Error("Dream model response did not include output text.");
435
+ }
436
+
437
+ return content;
438
+ }
439
+
440
+ function parseDreamJson(content) {
441
+ const trimmed = content.trim();
442
+ const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
443
+ const jsonText = match ? match[1].trim() : trimmed;
444
+ const parsed = JSON.parse(jsonText);
445
+
446
+ if (typeof parsed.current_state_md !== "string" || typeof parsed.open_threads_md !== "string") {
447
+ throw new Error("Dream response must include current_state_md and open_threads_md strings.");
448
+ }
449
+
450
+ return {
451
+ currentState: parsed.current_state_md.trim(),
452
+ openThreads: parsed.open_threads_md.trim(),
453
+ };
454
+ }
455
+
456
+ function getDreamOutputBytes(output) {
457
+ return Buffer.byteLength(`${output.currentState}\n${output.openThreads}\n`, "utf8");
458
+ }
459
+
460
+ function getMaxOutputBytes(targetChars) {
461
+ return Math.ceil(targetChars * (1 + TARGET_BYTE_TOLERANCE));
462
+ }
463
+
464
+ function validateDreamOutput(output, beforeBytes, targetChars) {
465
+ if (!output.currentState.startsWith("# Current State")) {
466
+ throw new Error("Dream current_state_md must start with '# Current State'.");
467
+ }
468
+
469
+ if (!output.openThreads.startsWith("# Open Threads")) {
470
+ throw new Error("Dream open_threads_md must start with '# Open Threads'.");
471
+ }
472
+
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
+
479
+ const maxBytes = getMaxOutputBytes(targetChars);
480
+
481
+ if (afterBytes > maxBytes) {
482
+ throw new Error(`Dream output exceeds --target-chars (${afterBytes} > ${maxBytes} bytes).`);
483
+ }
484
+
485
+ return afterBytes;
486
+ }
487
+
488
+ async function writeDreamOutput(report, output) {
489
+ await fs.writeFile(report.files["current_state.md"].absolutePath, `${output.currentState}\n`, "utf8");
490
+ await fs.writeFile(report.files["open_threads.md"].absolutePath, `${output.openThreads}\n`, "utf8");
491
+ }
492
+
493
+ function toPublicReport(result) {
494
+ return {
495
+ afterBytes: result.afterBytes,
496
+ afterWakeUpChars: result.afterWakeUpChars,
497
+ beforeBytes: result.beforeBytes,
498
+ changed: result.changed,
499
+ context: {
500
+ evidenceBudgetChars: result.threadEvidence.evidenceBudgetChars,
501
+ evidenceChars: result.threadEvidence.evidenceChars,
502
+ evidenceItems: countEvidenceItems(result.threadEvidence),
503
+ openThreads: result.threadEvidence.openThreadCount,
504
+ threadsWithEvidence: result.threadEvidence.items.length,
505
+ },
506
+ files: {
507
+ "current_state.md": result.report.files["current_state.md"].bytes,
508
+ "open_threads.md": result.report.files["open_threads.md"].bytes,
509
+ },
510
+ project: result.report.slug,
511
+ skipped: result.skipped,
512
+ wakeUpChars: result.report.wakeUpChars,
513
+ };
514
+ }
515
+
516
+ async function dreamProject(slug, options) {
517
+ const report = await getProjectReport(slug);
518
+ const beforeBytes = CURATED_FILES.reduce((total, name) => total + report.files[name].bytes, 0);
519
+ let threadEvidence = {
520
+ evidenceBudgetChars: options.targetChars,
521
+ evidenceChars: 0,
522
+ openThreadCount: extractOpenThreads(report.files["open_threads.md"].content).length,
523
+ items: [],
524
+ };
525
+
526
+ if (report.wakeUpChars < options.thresholdChars) {
527
+ return {
528
+ beforeBytes,
529
+ changed: false,
530
+ report,
531
+ skipped: "below_threshold",
532
+ threadEvidence,
533
+ };
534
+ }
535
+
536
+ threadEvidence = await buildThreadEvidence(report, { budgetChars: options.targetChars });
537
+
538
+ if (!options.write) {
539
+ return {
540
+ beforeBytes,
541
+ changed: false,
542
+ report,
543
+ skipped: "dry_run",
544
+ threadEvidence,
545
+ };
546
+ }
547
+
548
+ const content = await callDreamModel({
549
+ apiKey: options.apiKey,
550
+ baseUrl: options.baseUrl,
551
+ messages: createPrompt({ report, targetChars: options.targetChars, threadEvidence }),
552
+ model: options.model,
553
+ });
554
+ const output = parseDreamJson(content);
555
+ const afterBytes = validateDreamOutput(output, beforeBytes, options.targetChars);
556
+
557
+ await writeDreamOutput(report, output);
558
+
559
+ return {
560
+ afterBytes,
561
+ afterWakeUpChars: (await memory.wakeUp({ projectRoot: getSyntheticProjectRoot(report.slug) })).text.length,
562
+ beforeBytes,
563
+ changed: true,
564
+ report,
565
+ skipped: null,
566
+ threadEvidence,
567
+ };
568
+ }
569
+
570
+ function printText(results) {
571
+ for (const item of results) {
572
+ const result = toPublicReport(item);
573
+ const status = result.changed ? "changed" : `skipped:${result.skipped}`;
574
+ console.log(
575
+ [
576
+ result.project,
577
+ status,
578
+ `wake=${result.wakeUpChars}`,
579
+ `before=${result.beforeBytes}`,
580
+ `threads=${result.context.openThreads}`,
581
+ `matchedThreads=${result.context.threadsWithEvidence}`,
582
+ `evidence=${result.context.evidenceItems}`,
583
+ result.afterBytes ? `after=${result.afterBytes}` : null,
584
+ result.afterWakeUpChars ? `afterWake=${result.afterWakeUpChars}` : null,
585
+ ]
586
+ .filter(Boolean)
587
+ .join("\t"),
588
+ );
589
+ }
590
+ }
591
+
592
+ async function main() {
593
+ const options = parseArgs(process.argv.slice(2));
594
+ const globalRoot = memory.getGlobalRoot();
595
+ const slugs = options.all ? await listProjectSlugs(globalRoot) : [options.project];
596
+ const results = [];
597
+
598
+ if (!fsSync.existsSync(globalRoot)) {
599
+ throw new Error(`Lagoon root does not exist: ${globalRoot}`);
600
+ }
601
+
602
+ for (const slug of slugs) {
603
+ results.push(await dreamProject(slug, options));
604
+ }
605
+
606
+ const payload = {
607
+ changedProjects: results.filter((item) => item.changed).map((item) => item.report.slug),
608
+ dryRun: options.dryRun,
609
+ globalRoot,
610
+ model: options.model,
611
+ results: results.map(toPublicReport),
612
+ thresholdChars: options.thresholdChars,
613
+ };
614
+
615
+ if (options.json) {
616
+ console.log(JSON.stringify(payload, null, 2));
617
+ return;
618
+ }
619
+
620
+ printText(results);
621
+ }
622
+
623
+ main().catch((error) => {
624
+ console.error("Hippocamp Dream failed:", error.message);
625
+ process.exit(1);
626
+ });