hippocamp 0.2.9 → 0.2.11

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.
@@ -78,7 +78,7 @@ Event writes update a sibling `events/YYYY-MM-DD.index.json` file. The Markdown
78
78
 
79
79
  - Keep memory concise.
80
80
  - Prefer curated summaries over raw event history.
81
- - Give events a short `Keywords:` section; keywords weigh the most in search ranking. Older events with a `Cues:` section still search the same way.
81
+ - Give events a short `Keywords:` section; keywords weigh the most in search ranking. `append_event` writes each keyword as a `[[keyword]]` wiki link, so a graph view such as Obsidian's groups events by topic. Older events with plain keywords or a `Cues:` section still search the same way.
82
82
  - Do not duplicate GitHub-owned facts such as commits, PRs, issues, reviews, or CI results.
83
83
  - Store artifact references plus the missing rationale, preference, assumption, or follow-up context.
84
84
  - Use project scope for project-specific state.
@@ -93,9 +93,9 @@ Agent: claude
93
93
  Session: mcp-a1b2c3d4
94
94
 
95
95
  Keywords:
96
- - local-first
97
- - token-free
98
- - install-story
96
+ - [[local-first]]
97
+ - [[token-free]]
98
+ - [[install-story]]
99
99
 
100
100
  References:
101
101
  - commit: abc1234
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -79,7 +79,20 @@ function getGlobalRoot() {
79
79
  }
80
80
 
81
81
  function getProjectRoot(projectRoot) {
82
- return path.resolve(expandHomePath(projectRoot || process.env.HIPPOCAMP_PROJECT_ROOT || process.cwd()));
82
+ return path.resolve(expandHomePath(projectRoot || process.env.HIPPOCAMP_PROJECT_ROOT || getWorkingDirectory()));
83
+ }
84
+
85
+ // A long-running MCP server keeps its cwd after the worktree it started in is deleted.
86
+ function getWorkingDirectory() {
87
+ try {
88
+ return process.cwd();
89
+ } catch (error) {
90
+ if (error.code !== "ENOENT") {
91
+ throw error;
92
+ }
93
+
94
+ throw new Error("The working directory no longer exists, likely a deleted worktree. Pass projectRoot.");
95
+ }
83
96
  }
84
97
 
85
98
  function findGitRootSync(startPath) {
@@ -140,7 +153,9 @@ function isProjectRepository(projectRoot) {
140
153
  }
141
154
 
142
155
  function slugifyProjectName(value) {
156
+ // macOS can report the same accented folder name in NFC or NFD form.
143
157
  const slug = value
158
+ .normalize("NFC")
144
159
  .toLowerCase()
145
160
  .replace(/[^a-z0-9._-]+/g, "-")
146
161
  .replace(/^-+|-+$/g, "");
@@ -297,7 +312,8 @@ function formatKeywordsSection(keywords) {
297
312
  return "";
298
313
  }
299
314
 
300
- return ["Keywords:", ...normalizedKeywords.map((keyword) => `- ${keyword}`)].join("\n");
315
+ // Wiki links let Obsidian-style graph views group events by keyword.
316
+ return ["Keywords:", ...normalizedKeywords.map((keyword) => `- [[${keyword}]]`)].join("\n");
301
317
  }
302
318
 
303
319
  function normalizeAgentName(value) {
@@ -510,12 +526,19 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
510
526
  }
511
527
 
512
528
  if (!diffResult.stdout) {
513
- return {
514
- attempted: true,
515
- skipped: true,
516
- reason: "no_changes_to_commit",
517
- repoRoot,
518
- };
529
+ // Commits left behind by an earlier failed push still need to go up.
530
+ const aheadResult = await runGit(["rev-list", "--count", "@{u}..HEAD"], repoRoot);
531
+
532
+ if (!aheadResult.ok || aheadResult.stdout === "0") {
533
+ return {
534
+ attempted: true,
535
+ skipped: true,
536
+ reason: "no_changes_to_commit",
537
+ repoRoot,
538
+ };
539
+ }
540
+
541
+ return pushToUpstream(repoRoot, { attempted: true, committed: false, repoRoot });
519
542
  }
520
543
 
521
544
  const commitResult = await runGit(["commit", "-m", message, "--", ...repoRelativePaths], repoRoot);
@@ -524,16 +547,22 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
524
547
  throw new Error(commitResult.stderr || "git commit failed.");
525
548
  }
526
549
 
550
+ return pushToUpstream(repoRoot, {
551
+ attempted: true,
552
+ committed: true,
553
+ repoRoot,
554
+ commitSummary: commitResult.stdout,
555
+ });
556
+ }
557
+
558
+ async function pushToUpstream(repoRoot, result) {
527
559
  const upstreamResult = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot);
528
560
 
529
561
  if (!upstreamResult.ok) {
530
562
  return {
531
- attempted: true,
532
- committed: true,
563
+ ...result,
533
564
  pushed: false,
534
565
  reason: "no_upstream",
535
- repoRoot,
536
- commitSummary: commitResult.stdout,
537
566
  };
538
567
  }
539
568
 
@@ -544,12 +573,9 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
544
573
 
545
574
  if (!pullResult.ok) {
546
575
  return {
547
- attempted: true,
548
- committed: true,
576
+ ...result,
549
577
  pushed: false,
550
578
  reason: "pull_rebase_failed",
551
- repoRoot,
552
- commitSummary: commitResult.stdout,
553
579
  pushError: pushResult.stderr,
554
580
  pullError: pullResult.stderr,
555
581
  };
@@ -559,23 +585,17 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
559
585
 
560
586
  if (!pushResult.ok) {
561
587
  return {
562
- attempted: true,
563
- committed: true,
588
+ ...result,
564
589
  pushed: false,
565
590
  reason: "push_failed",
566
- repoRoot,
567
- commitSummary: commitResult.stdout,
568
591
  pushError: pushResult.stderr,
569
592
  };
570
593
  }
571
594
  }
572
595
 
573
596
  return {
574
- attempted: true,
575
- committed: true,
597
+ ...result,
576
598
  pushed: true,
577
- repoRoot,
578
- commitSummary: commitResult.stdout,
579
599
  pushSummary: pushResult.stdout || pushResult.stderr,
580
600
  };
581
601
  }
@@ -662,3 +662,57 @@ test("an empty folder has no project and uses global memory", async (t) => {
662
662
  // A folder that does not exist keeps its name, as Dream's synthetic roots do.
663
663
  assert.equal(memory.getProjectSlug(path.join(tempRoot, "missing")), "missing");
664
664
  });
665
+
666
+ test("Project slug is the same for NFC and NFD folder names", () => {
667
+ const name = "Génération IA";
668
+ assert.equal(memory.getProjectSlug(`/tmp/${name.normalize("NFC")}`), "g-n-ration-ia");
669
+ assert.equal(memory.getProjectSlug(`/tmp/${name.normalize("NFD")}`), "g-n-ration-ia");
670
+ });
671
+
672
+ test("A deleted working directory asks for projectRoot", async (t) => {
673
+ const originalCwd = process.cwd();
674
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-deleted-cwd-"));
675
+ t.after(() => process.chdir(originalCwd));
676
+ process.chdir(tempRoot);
677
+ await fs.rm(tempRoot, { recursive: true });
678
+
679
+ assert.throws(() => memory.getProjectRoot(), /Pass projectRoot/);
680
+ });
681
+
682
+ test("event keywords are written as wiki links and still indexed as plain keywords", async (t) => {
683
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
684
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-wiki-links-"));
685
+ const projectRoot = path.join(tempRoot, "wiki-links-project");
686
+
687
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
688
+ await fs.mkdir(projectRoot, { recursive: true });
689
+ t.after(async () => {
690
+ if (originalRoot === undefined) {
691
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
692
+ } else {
693
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
694
+ }
695
+ await fs.rm(tempRoot, { recursive: true, force: true });
696
+ });
697
+
698
+ const event = await memory.appendEvent({
699
+ content: "Graph views group events by keyword.",
700
+ keywords: ["Knowledge Graph", "obsidian"],
701
+ projectRoot,
702
+ scope: "project",
703
+ sync: false,
704
+ timestamp: "2026-10-16T09:00:00.000Z",
705
+ title: "Graph note",
706
+ });
707
+
708
+ const content = await fs.readFile(path.join(event.root, event.path), "utf8");
709
+ const index = JSON.parse(await fs.readFile(event.indexPath, "utf8"));
710
+
711
+ assert.match(content, /Keywords:\n- \[\[knowledge-graph\]\]\n- \[\[obsidian\]\]/);
712
+ assert.match(JSON.stringify(index), /"knowledge-graph"/);
713
+ assert.doesNotMatch(JSON.stringify(index), /\[\[/);
714
+
715
+ const search = await memory.searchMemory({ projectRoot, query: "obsidian", scope: "project" });
716
+
717
+ assert.deepEqual(search.results[0].keywords, ["knowledge-graph", "obsidian"]);
718
+ });
@@ -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
+ });