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.
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. 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
|
@@ -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) {
|
|
@@ -510,12 +526,19 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
|
510
526
|
}
|
|
511
527
|
|
|
512
528
|
if (!diffResult.stdout) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|