hippocamp 0.2.10 → 0.2.12

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/AGENTS.md CHANGED
@@ -48,7 +48,7 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
48
48
  - Global memory lives under `HIPPOCAMP_GLOBAL_ROOT/`.
49
49
  - Project memory lives under `HIPPOCAMP_GLOBAL_ROOT/projects/<slug>/`.
50
50
  - The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory. An existing folder with nothing but `.git` has no project; project scope then resolves to the global root.
51
- - Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json` keyword indexes.
51
+ - Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json` keyword indexes. The one exception is the installer's keyword-link format upgrade of older events.
52
52
  - Curated files such as `current_state.md` and `open_threads.md` use explicit file writes.
53
53
  - MCP `append_event` requires a title and 1-8 keywords; MCP `write_memory_file` rejects paths under `events/`; an explicit `projectRoot` must be a directory inside a Git repository.
54
54
  - `append_event` auto-stamps `Agent:` / `Session:` provenance when resolvable (installer env, MCP client name, or process session id). No manual user config.
@@ -85,4 +85,4 @@ When changing code, use the smallest verification that proves the change:
85
85
  - The cloud/API version is intentionally deferred.
86
86
  - Do not reintroduce Next.js, GitHub API token auth, or GitHub App auth for the local MVP.
87
87
  - Keep Dream minimal: scheduled updates and coherent compaction, two curated files plus bounded indexed evidence, one PR per project.
88
- - The local install story should stay: `npx hippocamp@latest install`, a local Lagoon repo created if missing, normal Git auth. Installers never create remotes or push.
88
+ - The local install story should stay: `npx hippocamp@latest install`, a local Lagoon repo created if missing, normal Git auth. Installers never create remotes or push; their only event rewrite is one local keyword-link format upgrade of older events, the exception to the append-only event invariant.
@@ -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. Install and upgrade convert clear keyword bullets in older events, including legacy `Cues:` sections, to this format and commit the change in the Lagoon; the next memory sync pushes it. A file is rewritten only if search reads exactly the same keywords from it afterwards.
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/docs/setup.md CHANGED
@@ -16,7 +16,7 @@ npx hippocamp@latest install-codex
16
16
  npx hippocamp@latest install-grok
17
17
  ```
18
18
 
19
- Upgrade with the same command: `npx hippocamp@latest upgrade`. Installers default to `~/.lagoon` as the memory repo; pass `--global-root /absolute/path/to/lagoon` to use another clone.
19
+ Upgrade with the same command: `npx hippocamp@latest upgrade`. When older events need it, an upgrade also brings them to the current keyword format, as one local Lagoon commit. Event files with uncommitted changes are left for a later upgrade. Restart your agents afterwards to load the new version. Installers default to `~/.lagoon` as the memory repo; pass `--global-root /absolute/path/to/lagoon` to use another clone.
20
20
 
21
21
  Installed through npx, the installer copies the package to `~/.hippocamp/<version>/` and registers that copy, so the server starts without npx or network and keeps working if npm cleans its cache. From a source checkout, `npm install` then `npm run install:claude` (or `install:codex`, `install:grok`) registers the checkout directly.
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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) {
@@ -742,6 +758,72 @@ function parseEventBlocks(content) {
742
758
  });
743
759
  }
744
760
 
761
+ // Older events used a "Cues:" header, inline comma lists, or plain bullets. Rewrite them in the
762
+ // format append_event writes now: a "Keywords:" header and one [[wiki link]] bullet per keyword.
763
+ // Only clear keyword bullets change: short, no spaces or backticks, before the first other line.
764
+ function linkKeywordsInEventContent(content) {
765
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
766
+ const lines = content.split(/\r?\n/);
767
+ const output = [];
768
+ let inKeywords = false;
769
+
770
+ for (const line of lines) {
771
+ const header = line.match(/^(\s*)(?:Keywords|Cues):\s*(.*?)\s*$/i);
772
+
773
+ if (header) {
774
+ const inline = normalizeKeywordList(splitInlineKeywordValues(header[2]));
775
+ output.push(`${header[1]}Keywords:`, ...inline.map((keyword) => `${header[1]}- [[${keyword}]]`));
776
+ inKeywords = true;
777
+ continue;
778
+ }
779
+
780
+ const bullet = inKeywords && line.match(/^(\s*-\s+)(.+?)\s*$/);
781
+
782
+ if (!bullet) {
783
+ inKeywords = false;
784
+ output.push(line);
785
+ continue;
786
+ }
787
+
788
+ const value = bullet[2];
789
+ const keyword = normalizeKeyword(value);
790
+ const plain = !value.startsWith("[[") && value.length <= 60 && !/[\s`]/.test(value);
791
+ output.push(plain && keyword ? `${bullet[1]}[[${keyword}]]` : line);
792
+ }
793
+
794
+ const linked = output.join(eol);
795
+ const sameKeywords = (text) => JSON.stringify(parseEventBlocks(text).map((event) => event.keywords));
796
+
797
+ // Never change what search reads: keep the file as it is if any keyword would differ.
798
+ return linked !== content && sameKeywords(linked) === sameKeywords(content) ? linked : content;
799
+ }
800
+
801
+ // Link keywords in every event file under the Lagoon root, except paths `skip` returns true for.
802
+ // Reads every file before writing any. Returns the changed paths, relative to the root.
803
+ async function linkEventKeywords(globalRoot, skip = () => false) {
804
+ const rewrites = [];
805
+
806
+ for (const relativePath of await walkMarkdownFiles(globalRoot)) {
807
+ if (!/(?:^|\/)events\/\d{4}-\d{2}-\d{2}\.md$/.test(relativePath) || skip(relativePath)) {
808
+ continue;
809
+ }
810
+
811
+ const absolutePath = path.join(globalRoot, relativePath);
812
+ const content = await fs.readFile(absolutePath, "utf8");
813
+ const linked = linkKeywordsInEventContent(content);
814
+
815
+ if (linked !== content) {
816
+ rewrites.push({ relativePath, absolutePath, linked });
817
+ }
818
+ }
819
+
820
+ for (const rewrite of rewrites) {
821
+ await fs.writeFile(rewrite.absolutePath, rewrite.linked);
822
+ }
823
+
824
+ return rewrites.map((rewrite) => rewrite.relativePath);
825
+ }
826
+
745
827
  function buildEventIndex(markdownContent, markdownPath) {
746
828
  return {
747
829
  version: EVENT_INDEX_VERSION,
@@ -1627,6 +1709,7 @@ module.exports = {
1627
1709
  readMemoryFile,
1628
1710
  writeMemoryFile,
1629
1711
  appendEvent,
1712
+ linkEventKeywords,
1630
1713
  listDirectoryEntries,
1631
1714
  searchMemory,
1632
1715
  readRecentEvents,
@@ -4,6 +4,7 @@ const os = require("node:os");
4
4
  const path = require("node:path");
5
5
  const { execFile } = require("node:child_process");
6
6
  const { promisify } = require("node:util");
7
+ const { linkEventKeywords } = require("./hippocamp-memory.cjs");
7
8
 
8
9
  const execFileAsync = promisify(execFile);
9
10
 
@@ -43,6 +44,7 @@ async function git(args, cwd) {
43
44
 
44
45
  // Make sure the Lagoon memory repo exists, and say what is still missing for sync.
45
46
  // It never creates a remote or pushes: those reach outside this machine.
47
+ // It may commit a local format upgrade of older events.
46
48
  async function prepareLagoon(globalRoot) {
47
49
  const hints = [];
48
50
  let created = false;
@@ -75,6 +77,33 @@ async function prepareLagoon(globalRoot) {
75
77
  hints.push('Set a Git identity so memory can be committed: git config --global user.name "Your Name" && git config --global user.email you@example.com');
76
78
  }
77
79
 
80
+ // Upgrades bring older events to the current keyword format: [[wiki links]] for graph views.
81
+ // Best effort, committed locally only; the next memory sync pushes it. Files with uncommitted
82
+ // or untracked changes are left alone, and a failed commit is undone so the next install retries.
83
+ let linked = [];
84
+
85
+ try {
86
+ const changes = await Promise.all([
87
+ git(["ls-files", "-z", "--modified", "--others", "--exclude-standard"], globalRoot),
88
+ git(["diff", "--cached", "--name-only", "-z"], globalRoot),
89
+ ]);
90
+ const known = changes.every((change) => change.ok);
91
+ const dirty = new Set(changes.flatMap((change) => change.stdout.split("\0").filter(Boolean)));
92
+ linked = await linkEventKeywords(globalRoot, (relativePath) => !known || dirty.has(relativePath));
93
+
94
+ if (linked.length) {
95
+ const commit = await git(["commit", "-m", "hippocamp: link event keywords", "--", ...linked], globalRoot);
96
+
97
+ if (!commit.ok) {
98
+ await git(["checkout", "--", ...linked], globalRoot);
99
+ linked = [];
100
+ hints.push("Older event keywords were not linked because Git could not commit. Run the upgrade again once Git is set up.");
101
+ }
102
+ }
103
+ } catch (error) {
104
+ hints.push(`Older event keywords were not linked: ${error.message}`);
105
+ }
106
+
78
107
  const remote = await git(["remote", "get-url", "origin"], globalRoot);
79
108
  let push = "no_remote";
80
109
 
@@ -88,7 +117,7 @@ async function prepareLagoon(globalRoot) {
88
117
  }
89
118
  }
90
119
 
91
- return { root: globalRoot, created, remote: remote.ok ? remote.stdout : null, push, hints };
120
+ return { root: globalRoot, created, remote: remote.ok ? remote.stdout : null, push, linkedEvents: linked.length, hints };
92
121
  }
93
122
 
94
123
  function printHints(lagoon) {
@@ -127,3 +127,98 @@ test("prepareLagoon gives an empty folder inside another repo its own repo", asy
127
127
  assert.equal(result.created, true);
128
128
  assert.equal(fsSync.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: nested }).toString().trim()), fsSync.realpathSync(nested));
129
129
  });
130
+
131
+ test("prepareLagoon links older event keywords once and commits them locally", async (t) => {
132
+ useGitIdentity(t);
133
+ const lagoon = path.join(await tempDir(t, "link-keywords"), "lagoon");
134
+ const eventPath = path.join(lagoon, "projects", "demo", "events", "2026-05-01.md");
135
+ const notePath = path.join(lagoon, "projects", "demo", "notes.md");
136
+ const oldEvent = [
137
+ "# 2026-05-01",
138
+ "",
139
+ "## 09:00 - Inline cues",
140
+ "Cues: Mem0 removal, bare MCP",
141
+ "",
142
+ "## 10:00 - Bullet cues",
143
+ "Shipped it.",
144
+ "",
145
+ "Cues:",
146
+ "- pr-12",
147
+ "- [[already-linked]]",
148
+ "- `npm test` passed",
149
+ "",
150
+ "- a content bullet",
151
+ "",
152
+ ].join("\n");
153
+
154
+ await fs.mkdir(path.dirname(eventPath), { recursive: true });
155
+ await fs.writeFile(eventPath, oldEvent);
156
+ await fs.writeFile(notePath, "Cues: left, alone\n");
157
+ execFileSync("git", ["init", "-b", "main", lagoon]);
158
+ execFileSync("git", ["add", "."], { cwd: lagoon });
159
+ execFileSync("git", ["commit", "-m", "init"], { cwd: lagoon });
160
+
161
+ const first = await prepareLagoon(lagoon);
162
+ const linked = await fs.readFile(eventPath, "utf8");
163
+
164
+ assert.equal(first.linkedEvents, 1);
165
+ assert.match(linked, /## 09:00 - Inline cues\nKeywords:\n- \[\[mem0-removal\]\]\n- \[\[bare-mcp\]\]\n/);
166
+ assert.match(linked, /Keywords:\n- \[\[pr-12\]\]\n- \[\[already-linked\]\]\n- `npm test` passed\n\n- a content bullet\n/);
167
+ assert.equal(await fs.readFile(notePath, "utf8"), "Cues: left, alone\n");
168
+ assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: lagoon, encoding: "utf8" }), "");
169
+ assert.equal(execFileSync("git", ["log", "-1", "--format=%s"], { cwd: lagoon, encoding: "utf8" }).trim(), "hippocamp: link event keywords");
170
+
171
+ const second = await prepareLagoon(lagoon);
172
+
173
+ assert.equal(second.linkedEvents, 0);
174
+ assert.equal(execFileSync("git", ["rev-list", "--count", "HEAD"], { cwd: lagoon, encoding: "utf8" }).trim(), "2");
175
+ });
176
+
177
+ test("prepareLagoon leaves dirty event files alone and links CRLF events without duplicates", async (t) => {
178
+ useGitIdentity(t);
179
+ const lagoon = path.join(await tempDir(t, "link-dirty"), "lagoon");
180
+ const events = path.join(lagoon, "events");
181
+ const crlfPath = path.join(events, "2026-05-01.md");
182
+ const dirtyPath = path.join(events, "2026-05-02.md");
183
+ const untrackedPath = path.join(events, "2026-05-03.md");
184
+
185
+ await fs.mkdir(events, { recursive: true });
186
+ await fs.writeFile(crlfPath, "# 2026-05-01\r\n\r\n## 09:00 - CRLF\r\nCues: bare MCP, Bare MCP\r\n");
187
+ await fs.writeFile(dirtyPath, "## 09:00 - Dirty\nCues:\n- pr-1\n");
188
+ execFileSync("git", ["init", "-b", "main", lagoon]);
189
+ execFileSync("git", ["add", "."], { cwd: lagoon });
190
+ execFileSync("git", ["commit", "-m", "init"], { cwd: lagoon });
191
+ await fs.appendFile(dirtyPath, "- user-edit\n");
192
+ await fs.writeFile(untrackedPath, "## 09:00 - Untracked\nCues:\n- pr-2\n");
193
+
194
+ const result = await prepareLagoon(lagoon);
195
+
196
+ assert.equal(result.linkedEvents, 1);
197
+ assert.equal(await fs.readFile(crlfPath, "utf8"), "# 2026-05-01\r\n\r\n## 09:00 - CRLF\r\nKeywords:\r\n- [[bare-mcp]]\r\n");
198
+ assert.equal(await fs.readFile(dirtyPath, "utf8"), "## 09:00 - Dirty\nCues:\n- pr-1\n- user-edit\n");
199
+ assert.equal(await fs.readFile(untrackedPath, "utf8"), "## 09:00 - Untracked\nCues:\n- pr-2\n");
200
+ assert.equal(execFileSync("git", ["show", "--name-only", "--format=", "HEAD"], { cwd: lagoon, encoding: "utf8" }).trim(), "events/2026-05-01.md");
201
+ });
202
+
203
+ test("prepareLagoon undoes linked keywords when Git cannot commit them", async (t) => {
204
+ useGitIdentity(t);
205
+ const lagoon = path.join(await tempDir(t, "link-no-commit"), "lagoon");
206
+ const eventPath = path.join(lagoon, "events", "2026-05-01.md");
207
+ const oldEvent = "## 09:00 - Old\nCues:\n- pr-1\n";
208
+
209
+ await fs.mkdir(path.dirname(eventPath), { recursive: true });
210
+ await fs.writeFile(eventPath, oldEvent);
211
+ execFileSync("git", ["init", "-b", "main", lagoon]);
212
+ execFileSync("git", ["add", "."], { cwd: lagoon });
213
+ execFileSync("git", ["commit", "-m", "init"], { cwd: lagoon });
214
+ const hook = path.join(lagoon, ".git", "hooks", "pre-commit");
215
+ await fs.writeFile(hook, "#!/bin/sh\nexit 1\n");
216
+ await fs.chmod(hook, 0o755);
217
+
218
+ const result = await prepareLagoon(lagoon);
219
+
220
+ assert.equal(result.linkedEvents, 0);
221
+ assert.equal(await fs.readFile(eventPath, "utf8"), oldEvent);
222
+ assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: lagoon, encoding: "utf8" }), "");
223
+ assert.ok(result.hints.some((hint) => /could not commit/.test(hint)));
224
+ });
@@ -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
+ });