hippocamp 0.2.8 → 0.2.10

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.
@@ -106,14 +106,14 @@ jobs:
106
106
  --target-chars "$HIPPOCAMP_DREAM_TARGET_CHARS" \
107
107
  > "/tmp/hippocamp-dream-${project}.json"
108
108
 
109
- if git diff --quiet -- "projects/${project}/current_state.md" "projects/${project}/open_threads.md"; then
109
+ git add -- "projects/${project}/current_state.md" "projects/${project}/open_threads.md"
110
+ if git diff --cached --quiet; then
110
111
  echo "No Dream diff for ${project}"
111
112
  git checkout main
112
113
  git reset --hard origin/main
113
114
  continue
114
115
  fi
115
116
 
116
- git add "projects/${project}/current_state.md" "projects/${project}/open_threads.md"
117
117
  git commit -m "Dream update ${project} memory"
118
118
  git push --force-with-lease origin "$branch"
119
119
 
@@ -113,12 +113,12 @@ while IFS= read -r project; do
113
113
  continue
114
114
  fi
115
115
 
116
- if git diff --quiet -- "projects/${project}/current_state.md" "projects/${project}/open_threads.md"; then
116
+ git add -- "projects/${project}/current_state.md" "projects/${project}/open_threads.md"
117
+ if git diff --cached --quiet; then
117
118
  echo "No Dream diff for ${project}"
118
119
  continue
119
120
  fi
120
121
 
121
- git add "projects/${project}/current_state.md" "projects/${project}/open_threads.md"
122
122
  git commit -m "Dream update ${project} memory"
123
123
 
124
124
  if git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1; then
package/docs/dream.md CHANGED
@@ -17,6 +17,8 @@ Recent events use at most half of the evidence budget. The remaining budget supp
17
17
 
18
18
  Dream promotes clear decisions, explicit corrections, and reusable project lessons into `current_state.md`, with source references. It keeps remaining actions in `open_threads.md`, preserves unresolved blockers, and removes completed actions only when evidence supports it. A merged PR does not prove its deployment or follow-up work is done. Uncertain lessons remain in events for normal search.
19
19
 
20
+ Projects with indexed events can start without curated summaries. Dream creates the two files, and the scheduled runners include these new files in the project's PR.
21
+
20
22
  Corrections apply across both files: a shipped feature should not remain an implementation task, while unverified deployment stays open. Extra compaction passes must keep source paths and timestamps for retained knowledge and aim below the size limit to leave room for length estimates. Dream writes the review comment itself; the model does not set review progress.
21
23
 
22
24
  The snapshot may grow within `--target-chars` when new evidence adds useful knowledge. If it exceeds the target, Dream compacts the complete result again. It never crops a wake-up file. After three passes, it fails without writing either file or advancing the event comment if the snapshot still does not fit. A project below the threshold with no new indexed evidence is skipped without a model call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -510,12 +510,19 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
510
510
  }
511
511
 
512
512
  if (!diffResult.stdout) {
513
- return {
514
- attempted: true,
515
- skipped: true,
516
- reason: "no_changes_to_commit",
517
- repoRoot,
518
- };
513
+ // Commits left behind by an earlier failed push still need to go up.
514
+ const aheadResult = await runGit(["rev-list", "--count", "@{u}..HEAD"], repoRoot);
515
+
516
+ if (!aheadResult.ok || aheadResult.stdout === "0") {
517
+ return {
518
+ attempted: true,
519
+ skipped: true,
520
+ reason: "no_changes_to_commit",
521
+ repoRoot,
522
+ };
523
+ }
524
+
525
+ return pushToUpstream(repoRoot, { attempted: true, committed: false, repoRoot });
519
526
  }
520
527
 
521
528
  const commitResult = await runGit(["commit", "-m", message, "--", ...repoRelativePaths], repoRoot);
@@ -524,16 +531,22 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
524
531
  throw new Error(commitResult.stderr || "git commit failed.");
525
532
  }
526
533
 
534
+ return pushToUpstream(repoRoot, {
535
+ attempted: true,
536
+ committed: true,
537
+ repoRoot,
538
+ commitSummary: commitResult.stdout,
539
+ });
540
+ }
541
+
542
+ async function pushToUpstream(repoRoot, result) {
527
543
  const upstreamResult = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot);
528
544
 
529
545
  if (!upstreamResult.ok) {
530
546
  return {
531
- attempted: true,
532
- committed: true,
547
+ ...result,
533
548
  pushed: false,
534
549
  reason: "no_upstream",
535
- repoRoot,
536
- commitSummary: commitResult.stdout,
537
550
  };
538
551
  }
539
552
 
@@ -544,12 +557,9 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
544
557
 
545
558
  if (!pullResult.ok) {
546
559
  return {
547
- attempted: true,
548
- committed: true,
560
+ ...result,
549
561
  pushed: false,
550
562
  reason: "pull_rebase_failed",
551
- repoRoot,
552
- commitSummary: commitResult.stdout,
553
563
  pushError: pushResult.stderr,
554
564
  pullError: pullResult.stderr,
555
565
  };
@@ -559,23 +569,17 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
559
569
 
560
570
  if (!pushResult.ok) {
561
571
  return {
562
- attempted: true,
563
- committed: true,
572
+ ...result,
564
573
  pushed: false,
565
574
  reason: "push_failed",
566
- repoRoot,
567
- commitSummary: commitResult.stdout,
568
575
  pushError: pushResult.stderr,
569
576
  };
570
577
  }
571
578
  }
572
579
 
573
580
  return {
574
- attempted: true,
575
- committed: true,
581
+ ...result,
576
582
  pushed: true,
577
- repoRoot,
578
- commitSummary: commitResult.stdout,
579
583
  pushSummary: pushResult.stdout || pushResult.stderr,
580
584
  };
581
585
  }
@@ -0,0 +1,86 @@
1
+ const assert = require("node:assert/strict");
2
+ const { execFile } = require("node:child_process");
3
+ const fs = require("node:fs/promises");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { promisify } = require("node:util");
7
+ const test = require("node:test");
8
+ const memory = require("../scripts/hippocamp-memory.cjs");
9
+
10
+ const exec = promisify(execFile);
11
+
12
+ test("Dream runner publishes first summaries and skips an unchanged repeat", async (t) => {
13
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-runner-test-"));
14
+ const seed = path.join(root, "seed");
15
+ const remote = path.join(root, "remote.git");
16
+ const bin = path.join(root, "bin");
17
+ const calls = path.join(root, "prs");
18
+ const preload = path.join(root, "model.cjs");
19
+ const previousRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
20
+ t.after(async () => {
21
+ if (previousRoot === undefined) delete process.env.HIPPOCAMP_GLOBAL_ROOT;
22
+ else process.env.HIPPOCAMP_GLOBAL_ROOT = previousRoot;
23
+ await fs.rm(root, { recursive: true, force: true });
24
+ });
25
+ await fs.mkdir(seed);
26
+ await fs.mkdir(bin);
27
+ process.env.HIPPOCAMP_GLOBAL_ROOT = seed;
28
+ await memory.appendEvent({ projectRoot: "/tmp/new-project", scope: "project", sync: false, title: "Confirmed decision", keywords: ["constraint"], content: "Keep production access read-only." });
29
+ const env = {
30
+ ...process.env,
31
+ PATH: `${bin}${path.delimiter}${process.env.PATH}`,
32
+ GIT_CONFIG_GLOBAL: path.join(root, "gitconfig"),
33
+ GIT_CONFIG_NOSYSTEM: "1",
34
+ GITHUB_TOKEN: "fixture",
35
+ LAGOON_REPOSITORY: "fixture/lagoon",
36
+ MANIFEST_API_KEY: "fixture",
37
+ MANIFEST_BASE_URL: "https://model.invalid",
38
+ HIPPOCAMP_DREAM_AUTO_MERGE: "false",
39
+ NODE_OPTIONS: `--require=${preload}`,
40
+ FIXTURE_REMOTE: remote,
41
+ FIXTURE_CALLS: calls,
42
+ };
43
+ await fs.writeFile(env.GIT_CONFIG_GLOBAL, "[user]\nname = Fixture\nemail = fixture@localhost\n");
44
+ await fs.writeFile(preload, `
45
+ global.fetch = async () => {
46
+ const fs = require("node:fs");
47
+ const path = require("node:path");
48
+ fs.writeFileSync(path.join(process.env.HIPPOCAMP_GLOBAL_ROOT, "unrelated.md"), "Do not publish this file");
49
+ return { ok: true, status: 200, text: async () => JSON.stringify({ output_text: JSON.stringify({
50
+ current_state_md: "# Current State\\n\\n- Keep production access read-only.",
51
+ open_threads_md: "# Open Threads\\n\\n- Verify deployment."
52
+ }) }) };
53
+ };
54
+ `);
55
+ await fs.writeFile(path.join(bin, "gh"), `#!${process.execPath}
56
+ const fs = require("node:fs");
57
+ const { execFileSync } = require("node:child_process");
58
+ const args = process.argv.slice(2);
59
+ if (args[0] === "auth") process.exit(0);
60
+ if (args[0] === "repo" && args[1] === "clone") {
61
+ execFileSync("git", ["clone", process.env.FIXTURE_REMOTE, args[3]], { stdio: "inherit" });
62
+ } else if (args[0] === "pr" && args[1] === "list") {
63
+ if (fs.existsSync(process.env.FIXTURE_CALLS)) console.log("1");
64
+ } else if (args[0] === "pr" && args[1] === "create") {
65
+ fs.appendFileSync(process.env.FIXTURE_CALLS, "created\\n");
66
+ } else {
67
+ throw new Error("Unexpected gh operation: " + args.join(" "));
68
+ }
69
+ `, { mode: 0o755 });
70
+ const git = (args, cwd = seed) => exec("git", args, { cwd, env });
71
+ await git(["init", "-b", "main"]);
72
+ await git(["add", "."]);
73
+ await git(["commit", "-m", "Seed indexed evidence without curated files"]);
74
+ const before = (await git(["rev-parse", "HEAD"])).stdout.trim();
75
+ await git(["clone", "--bare", seed, remote]);
76
+ const runner = path.resolve(__dirname, "../assets/railway/run-dream.sh");
77
+ await exec("bash", [runner], { env });
78
+ const changed = (await git(["diff", "--name-only", before, "refs/heads/dream/new-project"], remote)).stdout.trim().split("\n");
79
+ assert.deepEqual(changed, ["projects/new-project/current_state.md", "projects/new-project/open_threads.md"]);
80
+ assert.match((await git(["show", "dream/new-project:projects/new-project/current_state.md"], remote)).stdout, /hippocamp-dream-through:/);
81
+ assert.equal(await fs.readFile(calls, "utf8"), "created\n");
82
+ // Simulate accepting the reviewed PR, then repeat against the same evidence.
83
+ await git(["update-ref", "refs/heads/main", "refs/heads/dream/new-project"], remote);
84
+ await exec("bash", [runner], { env });
85
+ assert.equal(await fs.readFile(calls, "utf8"), "created\n");
86
+ });
@@ -63,3 +63,57 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
63
63
  /escapes the memory root/,
64
64
  );
65
65
  });
66
+
67
+ test("sync pushes commits left behind by an earlier failed push", async (t) => {
68
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
69
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-backlog-"));
70
+ const remote = path.join(tempRoot, "remote.git");
71
+ const lagoon = path.join(tempRoot, "lagoon");
72
+ const projectRoot = path.join(tempRoot, "project");
73
+ const git = (args, cwd = lagoon) => execFileSync("git", args, { cwd }).toString().trim();
74
+
75
+ const identity = { GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" };
76
+ const saved = Object.fromEntries(Object.keys(identity).map((key) => [key, process.env[key]]));
77
+ Object.assign(process.env, identity);
78
+ t.after(() => {
79
+ for (const [key, value] of Object.entries(saved)) {
80
+ if (value === undefined) {
81
+ delete process.env[key];
82
+ } else {
83
+ process.env[key] = value;
84
+ }
85
+ }
86
+ });
87
+
88
+ git(["init", "-q", "--bare", "-b", "main", remote], tempRoot);
89
+ git(["clone", "-q", remote, lagoon], tempRoot);
90
+ git(["commit", "-q", "--allow-empty", "-m", "init"]);
91
+ git(["push", "-q", "-u", "origin", "main"]);
92
+ process.env.HIPPOCAMP_GLOBAL_ROOT = lagoon;
93
+ t.after(async () => {
94
+ if (originalRoot === undefined) {
95
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
96
+ } else {
97
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
98
+ }
99
+ await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 });
100
+ });
101
+
102
+ // Simulate an unreachable remote for the first write.
103
+ git(["remote", "set-url", "origin", path.join(tempRoot, "missing.git")]);
104
+ const write = await memory.appendEvent({ content: "Use HTTPS.", keywords: ["sync"], projectRoot, scope: "project", title: "Remote" });
105
+ assert.equal(write.sync.committed, true);
106
+ assert.equal(write.sync.pushed, false);
107
+
108
+ git(["remote", "set-url", "origin", remote]);
109
+ const result = await memory.syncMemory({
110
+ scope: "project",
111
+ projectRoot,
112
+ paths: await memory.getDefaultSyncPaths("project", projectRoot),
113
+ message: "sync",
114
+ });
115
+
116
+ assert.equal(result.committed, false);
117
+ assert.equal(result.pushed, true);
118
+ assert.equal(git(["rev-parse", "main"], remote), git(["rev-parse", "HEAD"]));
119
+ });