hippocamp 0.2.11 → 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 +2 -2
- package/docs/how-it-works.md +1 -1
- package/docs/setup.md +1 -1
- package/package.json +1 -1
- package/scripts/hippocamp-memory.cjs +67 -0
- package/scripts/install-common.cjs +30 -1
- package/test/install-common.test.cjs +95 -0
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.
|
package/docs/how-it-works.md
CHANGED
|
@@ -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. `append_event` writes each keyword as a `[[keyword]]` wiki link, so a graph view such as Obsidian's groups events by topic.
|
|
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.
|
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
|
@@ -758,6 +758,72 @@ function parseEventBlocks(content) {
|
|
|
758
758
|
});
|
|
759
759
|
}
|
|
760
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
|
+
|
|
761
827
|
function buildEventIndex(markdownContent, markdownPath) {
|
|
762
828
|
return {
|
|
763
829
|
version: EVENT_INDEX_VERSION,
|
|
@@ -1643,6 +1709,7 @@ module.exports = {
|
|
|
1643
1709
|
readMemoryFile,
|
|
1644
1710
|
writeMemoryFile,
|
|
1645
1711
|
appendEvent,
|
|
1712
|
+
linkEventKeywords,
|
|
1646
1713
|
listDirectoryEntries,
|
|
1647
1714
|
searchMemory,
|
|
1648
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
|
+
});
|