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 +2 -2
- package/docs/how-it-works.md +4 -4
- package/docs/setup.md +1 -1
- package/package.json +1 -1
- package/scripts/hippocamp-memory.cjs +85 -2
- package/scripts/install-common.cjs +30 -1
- package/test/install-common.test.cjs +95 -0
- package/test/memory-efficiency.test.cjs +54 -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.
|
|
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
|
@@ -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 ||
|
|
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
|
-
|
|
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
|
+
});
|