hippocamp 0.2.3 → 0.2.5
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 +5 -2
- package/README.md +85 -49
- package/package.json +2 -1
- package/scripts/eval-qa.cjs +371 -0
- package/scripts/hippocamp-mcp.cjs +2 -2
- package/scripts/hippocamp-memory.cjs +55 -3
- package/scripts/hippocamp.cjs +47 -1
- package/scripts/install-claude.cjs +7 -5
- package/scripts/install-codex.cjs +7 -5
- package/scripts/install-common.cjs +100 -0
- package/scripts/install-grok.cjs +7 -5
- package/skills/hippocamp-memory/SKILL.md +1 -0
- package/test/install-common.test.cjs +129 -0
- package/test/memory-efficiency.test.cjs +35 -0
- package/test/sync-memory.test.cjs +67 -0
package/AGENTS.md
CHANGED
|
@@ -22,12 +22,14 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
|
|
|
22
22
|
|
|
23
23
|
## Repository Map
|
|
24
24
|
|
|
25
|
-
- `scripts/hippocamp.cjs`: small CLI wrapper for `dream`, `mcp`, `install-codex`, `install-claude`, and `install-grok`.
|
|
25
|
+
- `scripts/hippocamp.cjs`: small CLI wrapper for `install` (every agent CLI found), `dream`, `mcp`, `install-codex`, `install-claude`, and `install-grok`.
|
|
26
26
|
- `scripts/hippocamp-dream.cjs`: offline Dream CLI for scheduled compaction of curated project wake-up files.
|
|
27
27
|
- `scripts/hippocamp-memory.cjs`: local filesystem-backed Hippocamp memory helpers for MCP use.
|
|
28
28
|
- `scripts/hippocamp-mcp.cjs`: local stdio MCP server for Hippocamp memory tools.
|
|
29
29
|
- `scripts/eval-lagoon.cjs`: read-only retrieval check against real Lagoon cases in `<Lagoon root>/evals/retrieval-cases.json`.
|
|
30
|
+
- `scripts/eval-qa.cjs`: LongMemEval-S answer-accuracy check (reader and grader models) for Hippocamp snippets against the labeled answer sessions.
|
|
30
31
|
- `scripts/eval-retrieval.cjs`: retrieval-only LongMemEval-S check for `searchMemory` against a BM25 reference.
|
|
32
|
+
- `scripts/install-common.cjs`: shared installer helpers: the MCP launch command and Lagoon repo preparation.
|
|
31
33
|
- `scripts/install-claude.cjs`: one-step installer for Claude Code MCP setup and user-level `CLAUDE.md` guidance.
|
|
32
34
|
- `scripts/install-codex.cjs`: one-step installer for the Hippocamp skill and MCP server in Codex.
|
|
33
35
|
- `scripts/install-grok.cjs`: one-step installer for the Hippocamp skill, Grok home rules, and MCP server in Grok Build.
|
|
@@ -74,10 +76,11 @@ When changing code, use the smallest verification that proves the change:
|
|
|
74
76
|
- Run `npm run mcp:smoke` when changing memory reads, root resolution, or wake-up behavior.
|
|
75
77
|
- Run `npm run dream -- --help` when changing Dream CLI arguments.
|
|
76
78
|
- Run `npm run eval:retrieval -- --limit 100` when changing search ranking or snippets; report Recall@5 against the BM25 row and snippet evidence. Also run `npm run eval:lagoon` when a Lagoon is available.
|
|
79
|
+
- Run `npm run eval:qa -- --limit 3 --dry-run` when changing `scripts/eval-qa.cjs`; a full run calls paid models, so run it only when asked.
|
|
77
80
|
|
|
78
81
|
## Notes For Future Agents
|
|
79
82
|
|
|
80
83
|
- The cloud/API version is intentionally deferred.
|
|
81
84
|
- Do not reintroduce Next.js, GitHub API token auth, or GitHub App auth for the local MVP.
|
|
82
85
|
- Keep Dream minimal: scheduled coherent compaction, current/open curated files plus bounded thread evidence, one PR per project.
|
|
83
|
-
- The local install story should stay: install
|
|
86
|
+
- 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.
|
package/README.md
CHANGED
|
@@ -6,13 +6,77 @@
|
|
|
6
6
|
<img src="./assets/brand/hippocamp-mascot.png" alt="Hippocamp mascot" width="220" />
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Agent memory you can `git log`.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Hippocamp gives all your coding agents, like Claude Code and Codex, one shared memory: plain Markdown in a private Git repo you own. Every session starts by waking up from it, and every decision an agent records is a commit you can read, diff, and revert.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Open source (MIT). No account, no API key, no Hippocamp server: it runs on your machine and works the moment it is installed. Your memory lives in a private Git repo. No database, no vector store, nothing to pay for.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
```bash
|
|
16
|
+
npx hippocamp@latest install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This installs Hippocamp into every supported agent CLI it finds, creates `~/.lagoon` as a local Git repo if it does not exist, and tells you how to add a private remote. Restart your agent, and it calls `wake_up` at the start of each task.
|
|
20
|
+
|
|
21
|
+
## Why Not Built-In Memory?
|
|
22
|
+
|
|
23
|
+
Claude Code and Codex now ship their own memory. Use Hippocamp when you want:
|
|
24
|
+
|
|
25
|
+
- **One memory for every agent.** Claude Code, Codex, and your other agents read and write the same repo. Built-in memory stays inside one tool.
|
|
26
|
+
- **History you can audit.** Every memory change is a Git commit with an author, a diff, and a timestamp. Roll back a bad memory like bad code.
|
|
27
|
+
- **Files you own.** Markdown in your own private repo, readable without any UI, portable across machines through your normal Git credentials.
|
|
28
|
+
- **Nothing to sign up for.** Open source and free, running on your machine. No account, no API key, no vendor that can change terms or shut down.
|
|
29
|
+
- **Reviewed compaction.** Optional Dream mode summarizes memory offline and proposes the result as a pull request.
|
|
30
|
+
|
|
31
|
+
## Results
|
|
32
|
+
|
|
33
|
+
Every number below comes from a script in this repo, so you can rerun it. We report where Hippocamp falls short too.
|
|
34
|
+
|
|
35
|
+
### Finding the right memory
|
|
36
|
+
|
|
37
|
+
LongMemEval-S, all 470 answerable questions, top 5 results, no model involved (`npm run eval:retrieval`).
|
|
38
|
+
|
|
39
|
+
| Measure | Hippocamp | BM25 reference |
|
|
40
|
+
| --- | ---: | ---: |
|
|
41
|
+
| Right session in top 5 (Recall@5) | 90.5% | 91.4% |
|
|
42
|
+
| At least one right session in top 5 (Hit@5) | 96.0% | 96.8% |
|
|
43
|
+
| Returned text contains the answer turn | 87.9% | n/a |
|
|
44
|
+
| Text returned per query | 5.9k chars | 65.5k chars |
|
|
45
|
+
| Search latency, cold index | 42 ms | n/a |
|
|
46
|
+
|
|
47
|
+
BM25 returns whole sessions; Hippocamp returns the matching paragraphs of each result.
|
|
48
|
+
|
|
49
|
+
### Answering with it
|
|
50
|
+
|
|
51
|
+
100 LongMemEval-S questions, reader Claude Sonnet 5, grader gpt-4o with the official LongMemEval grading prompts (`npm run eval:qa`).
|
|
52
|
+
|
|
53
|
+
| What the reader gets | Text per question | Answer accuracy |
|
|
54
|
+
| --- | ---: | ---: |
|
|
55
|
+
| Hippocamp, top 5 | 6.7k chars | 71% |
|
|
56
|
+
| Hippocamp, top 10 | 12.9k chars | 77% |
|
|
57
|
+
| The correct sessions, given directly | 28.8k chars | 90% |
|
|
58
|
+
|
|
59
|
+
The same reader reaches 90% with the correct sessions, so the remaining gap is retrieval, not the reader.
|
|
60
|
+
|
|
61
|
+
### Real coding questions
|
|
62
|
+
|
|
63
|
+
53 questions about real work in a personal Lagoon across 13 projects (`npm run eval:lagoon`). This is our own test set, kept in the private Lagoon.
|
|
64
|
+
|
|
65
|
+
| Measure | Result |
|
|
66
|
+
| --- | ---: |
|
|
67
|
+
| Right event in top 5, 43 answerable questions | 42 / 43 |
|
|
68
|
+
| No results for 10 questions no memory is about | 10 / 10 |
|
|
69
|
+
|
|
70
|
+
### Why less text matters
|
|
71
|
+
|
|
72
|
+
Memory results share the agent's context with code, diffs, and tool output, and agents search several times per task. About 6k characters per search is roughly 1.5k tokens; whole sessions would be about 16k. Fewer tokens make each turn cheaper and faster, and keep the relevant lines from being buried. Less text is only useful when the answer is still in it, which is why we report answer accuracy next to text size.
|
|
73
|
+
|
|
74
|
+
### Limits
|
|
75
|
+
|
|
76
|
+
- Recall@5 is just under the BM25 reference, by less than 1 point, with 11 times less text.
|
|
77
|
+
- No embeddings or semantic search: a memory written in other words can be missed. On LongMemEval-S, 9 of 470 questions miss with no word in common with the answer.
|
|
78
|
+
- Answer accuracy is measured on 100 questions, about plus or minus 4 points.
|
|
79
|
+
- Self-reported scores from other memory systems use their own readers and graders, so they are not directly comparable with these.
|
|
16
80
|
|
|
17
81
|
## What It Stores
|
|
18
82
|
|
|
@@ -33,65 +97,28 @@ Git is a practical default for agent memory:
|
|
|
33
97
|
|
|
34
98
|
## Install
|
|
35
99
|
|
|
36
|
-
Clone this repo, install dependencies, then install the MCP server for your agent:
|
|
37
|
-
|
|
38
100
|
```bash
|
|
39
|
-
|
|
40
|
-
npm run install:codex
|
|
101
|
+
npx hippocamp@latest install
|
|
41
102
|
```
|
|
42
103
|
|
|
43
|
-
|
|
104
|
+
To install into one agent only:
|
|
44
105
|
|
|
45
106
|
```bash
|
|
46
|
-
|
|
47
|
-
|
|
107
|
+
npx hippocamp@latest install-claude
|
|
108
|
+
npx hippocamp@latest install-codex
|
|
109
|
+
npx hippocamp@latest install-grok
|
|
48
110
|
```
|
|
49
111
|
|
|
50
|
-
|
|
112
|
+
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.
|
|
51
113
|
|
|
52
|
-
|
|
53
|
-
npm install
|
|
54
|
-
npm run install:grok
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
After publishing, the intended one-line install shape is:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
npx hippocamp install-codex
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Upgrade uses the same install path, so agents can refresh themselves without a separate state model:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
npx hippocamp@latest upgrade-codex
|
|
67
|
-
npx hippocamp@latest upgrade-claude
|
|
68
|
-
npx hippocamp@latest upgrade-grok
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
From a source checkout:
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
git pull --ff-only
|
|
75
|
-
npm install
|
|
76
|
-
npm run upgrade:codex
|
|
77
|
-
npm run upgrade:claude
|
|
78
|
-
npm run upgrade:grok
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
Installers default to `~/.lagoon` as the memory repo. You can override it:
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
npm run install:codex -- --global-root /absolute/path/to/lagoon
|
|
85
|
-
npm run install:claude -- --global-root /absolute/path/to/lagoon
|
|
86
|
-
npm run install:grok -- --global-root /absolute/path/to/lagoon
|
|
87
|
-
```
|
|
114
|
+
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.
|
|
88
115
|
|
|
89
116
|
The Codex installer refreshes `~/.codex/AGENTS.md`. The Claude installer refreshes `~/.claude/CLAUDE.md`. The Grok installer refreshes `~/.grok/rules/hippocamp.md` and registers the MCP server with `HIPPOCAMP_AGENT=grok`.
|
|
90
117
|
Managed instruction blocks tell the agent to call `wake_up` at the start of new top-level coding tasks before repo exploration or edits.
|
|
91
118
|
|
|
92
119
|
## Lagoon Repo
|
|
93
120
|
|
|
94
|
-
Hippocamp
|
|
121
|
+
Hippocamp keeps memory in a local Git repo. The installer creates `~/.lagoon` if it is missing. To use memory you already have on another machine, clone it first:
|
|
95
122
|
|
|
96
123
|
```bash
|
|
97
124
|
git clone git@github.com:YOUR_USER/lagoon.git ~/.lagoon
|
|
@@ -99,6 +126,12 @@ git clone git@github.com:YOUR_USER/lagoon.git ~/.lagoon
|
|
|
99
126
|
|
|
100
127
|
The repo should usually be private. Pushing memory to a private remote keeps it available across machines and agent environments while still using normal Git access controls. Hippocamp does not need a GitHub token for local MCP mode; it uses your normal local Git credentials.
|
|
101
128
|
|
|
129
|
+
Memory works locally without a remote. To back it up privately and share it across machines, create a private remote:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
gh repo create lagoon --private --source ~/.lagoon --remote origin --push
|
|
133
|
+
```
|
|
134
|
+
|
|
102
135
|
If push auth is not configured yet, use your preferred GitHub setup. With GitHub CLI:
|
|
103
136
|
|
|
104
137
|
```bash
|
|
@@ -253,6 +286,7 @@ npm run mcp:smoke
|
|
|
253
286
|
npm run dream
|
|
254
287
|
npm run dream:railway
|
|
255
288
|
npm run eval:lagoon
|
|
289
|
+
npm run eval:qa
|
|
256
290
|
npm run eval:retrieval
|
|
257
291
|
npm run install:codex
|
|
258
292
|
npm run install:claude
|
|
@@ -266,6 +300,8 @@ npm run upgrade:grok
|
|
|
266
300
|
|
|
267
301
|
`npm run eval:lagoon` runs the same kind of check, read-only, against your real Lagoon. It reads cases from `<Lagoon root>/evals/retrieval-cases.json`: a project, a query, and the event ids that answer it. An empty list means no memory should be returned. Keep the cases in Lagoon, not in this repo, because they quote private work.
|
|
268
302
|
|
|
303
|
+
`npm run eval:qa` measures answer accuracy on the same LongMemEval-S data with the official LongMemEval reader and grader prompts. It compares Hippocamp top 5 (`hippocamp`), top 10 (`hippocamp-k10`), and the labeled answer sessions (`oracle`). It calls an OpenAI-compatible endpoint from `MANIFEST_BASE_URL` and `MANIFEST_API_KEY`; set `QA_READER_MODEL` and `QA_GRADER_MODEL` to change models. Results are cached in `.context/qa-results.jsonl`, and `--dry-run` shows the jobs and token estimates without any API call.
|
|
304
|
+
|
|
269
305
|
## Releases
|
|
270
306
|
|
|
271
307
|
Pull requests and pushes to `main` run syntax and MCP smoke checks in GitHub Actions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hippocamp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Local Git-backed memory for AI agents.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"dream": "node scripts/hippocamp-dream.cjs",
|
|
32
32
|
"dream:railway": "bash assets/railway/run-dream.sh",
|
|
33
33
|
"eval:lagoon": "node scripts/eval-lagoon.cjs",
|
|
34
|
+
"eval:qa": "node scripts/eval-qa.cjs",
|
|
34
35
|
"eval:retrieval": "node scripts/eval-retrieval.cjs",
|
|
35
36
|
"install:claude": "node scripts/install-claude.cjs",
|
|
36
37
|
"install:codex": "node scripts/install-codex.cjs",
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fsSync = require("node:fs");
|
|
5
|
+
const fs = require("node:fs/promises");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const memory = require("./hippocamp-memory.cjs");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DATA = ".context/longmemeval/longmemeval_s_cleaned.json";
|
|
11
|
+
const DEFAULT_CACHE = ".context/qa-results.jsonl";
|
|
12
|
+
const DEFAULT_SYSTEMS = "hippocamp,oracle";
|
|
13
|
+
const SYSTEMS = ["hippocamp", "hippocamp-k10", "oracle"];
|
|
14
|
+
const DATA_URL =
|
|
15
|
+
"https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json";
|
|
16
|
+
|
|
17
|
+
// Prompts are verbatim from the official LongMemEval repo (xiaowu0162/LongMemEval):
|
|
18
|
+
// src/generation/run_generation.py for the reader, src/evaluation/evaluate_qa.py for the grader.
|
|
19
|
+
const READER_PROMPT =
|
|
20
|
+
"I will give you several history chats between you and a user. Please answer the question based on the relevant chat history. Answer the question step by step: first extract all the relevant information, and then reason over the information to get the answer.\n\n\nHistory Chats:\n\n{}\n\nCurrent Date: {}\nQuestion: {}\nAnswer (step by step):";
|
|
21
|
+
|
|
22
|
+
const BASE_CHECK =
|
|
23
|
+
"I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response is equivalent to the correct answer or contains all the intermediate steps to get the correct answer, you should also answer yes. If the response only contains a subset of the information required by the answer, answer no. ";
|
|
24
|
+
const GRADER_PROMPTS = {
|
|
25
|
+
"single-session-user": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
26
|
+
"single-session-assistant": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
27
|
+
"multi-session": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
28
|
+
"temporal-reasoning": `${BASE_CHECK}In addition, do not penalize off-by-one errors for the number of days. If the question asks for the number of days/weeks/months, etc., and the model makes off-by-one errors (e.g., predicting 19 days when the answer is 18), the model's response is still correct. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
29
|
+
"knowledge-update":
|
|
30
|
+
"I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response contains some previous information along with an updated answer, the response should be considered as correct as long as the updated answer is the required answer.\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
|
|
31
|
+
"single-session-preference":
|
|
32
|
+
"I will give you a question, a rubric for desired personalized response, and a response from a model. Please answer yes if the response satisfies the desired response. Otherwise, answer no. The model does not need to reflect all the points in the rubric. The response is correct as long as it recalls and utilizes the user's personal information correctly.\n\nQuestion: {}\n\nRubric: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function printHelp() {
|
|
36
|
+
console.log(`Hippocamp QA eval
|
|
37
|
+
|
|
38
|
+
LongMemEval-S answer accuracy. A reader model answers each question from the
|
|
39
|
+
retrieved history, and a grader model checks the answer, both with the official
|
|
40
|
+
LongMemEval prompts. Calls go to an OpenAI-compatible chat completions endpoint.
|
|
41
|
+
|
|
42
|
+
Systems:
|
|
43
|
+
hippocamp searchMemory top 5 snippets
|
|
44
|
+
hippocamp-k10 searchMemory top 10 snippets
|
|
45
|
+
oracle the labeled answer sessions, in full
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
npm run eval:qa -- [--data PATH] [--limit N] [--systems LIST] [--cache PATH] [--dry-run]
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--data PATH LongMemEval-S JSON. Default: ${DEFAULT_DATA}
|
|
52
|
+
--limit N Evaluate N questions spread evenly across the set. Default: all answerable questions
|
|
53
|
+
--systems LIST Comma-separated systems. Default: ${DEFAULT_SYSTEMS}
|
|
54
|
+
--cache PATH JSONL results cache; reruns only pay for missing calls. Default: ${DEFAULT_CACHE}
|
|
55
|
+
--concurrency N Parallel reader and grader calls. Default: 6
|
|
56
|
+
--dry-run Print jobs and prompt token estimates without any API call
|
|
57
|
+
--help Show this help
|
|
58
|
+
|
|
59
|
+
Environment:
|
|
60
|
+
MANIFEST_BASE_URL, MANIFEST_API_KEY OpenAI-compatible endpoint and key
|
|
61
|
+
QA_READER_MODEL Default: anthropic/claude-sonnet-5
|
|
62
|
+
QA_GRADER_MODEL Default: openrouter/openai/gpt-4o
|
|
63
|
+
|
|
64
|
+
Download the data once:
|
|
65
|
+
mkdir -p .context/longmemeval
|
|
66
|
+
curl -L -o ${DEFAULT_DATA} \\
|
|
67
|
+
${DATA_URL}
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parsePositiveInteger(value, name) {
|
|
72
|
+
const number = Number(value);
|
|
73
|
+
|
|
74
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
75
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseArgs(argv) {
|
|
82
|
+
const options = {
|
|
83
|
+
data: DEFAULT_DATA,
|
|
84
|
+
limit: null,
|
|
85
|
+
systems: DEFAULT_SYSTEMS.split(","),
|
|
86
|
+
cache: DEFAULT_CACHE,
|
|
87
|
+
concurrency: 6,
|
|
88
|
+
dryRun: false,
|
|
89
|
+
help: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
93
|
+
const token = argv[index];
|
|
94
|
+
|
|
95
|
+
if (token === "--help" || token === "-h") {
|
|
96
|
+
options.help = true;
|
|
97
|
+
} else if (token === "--data") {
|
|
98
|
+
options.data = argv[++index];
|
|
99
|
+
} else if (token === "--limit") {
|
|
100
|
+
options.limit = parsePositiveInteger(argv[++index], "--limit");
|
|
101
|
+
} else if (token === "--systems") {
|
|
102
|
+
options.systems = String(argv[++index] || "").split(",").filter(Boolean);
|
|
103
|
+
|
|
104
|
+
for (const system of options.systems) {
|
|
105
|
+
if (!SYSTEMS.includes(system)) {
|
|
106
|
+
throw new Error(`Unknown system: ${system}. Use ${SYSTEMS.join(", ")}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} else if (token === "--cache") {
|
|
110
|
+
options.cache = argv[++index];
|
|
111
|
+
} else if (token === "--concurrency") {
|
|
112
|
+
options.concurrency = parsePositiveInteger(argv[++index], "--concurrency");
|
|
113
|
+
} else if (token === "--dry-run") {
|
|
114
|
+
options.dryRun = true;
|
|
115
|
+
} else {
|
|
116
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return options;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fill(template, ...values) {
|
|
124
|
+
return values.reduce((text, value) => text.replace("{}", () => String(value)), template);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatSession(sessionId, date, turns) {
|
|
128
|
+
const lines = [`# Session ${sessionId}`, "", `Date: ${date || "unknown"}`, ""];
|
|
129
|
+
|
|
130
|
+
for (const turn of turns || []) {
|
|
131
|
+
lines.push(`## ${turn.role === "assistant" ? "Assistant" : "User"}`, "", String(turn.content || "").trim(), "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `${lines.join("\n").trim()}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function historyString(chunks) {
|
|
138
|
+
return chunks
|
|
139
|
+
.sort((left, right) => left.date.localeCompare(right.date))
|
|
140
|
+
.map((chunk, index) => `\n### Session ${index + 1}:\nSession Date: ${chunk.date}\nSession Content:\n${chunk.content}\n`)
|
|
141
|
+
.join("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function hippocampChunks(item, projectRoot, maxResults) {
|
|
145
|
+
const pathToIndex = new Map();
|
|
146
|
+
|
|
147
|
+
for (let index = 0; index < item.haystack_sessions.length; index += 1) {
|
|
148
|
+
const relativePath = `sessions/${String(index).padStart(4, "0")}.md`;
|
|
149
|
+
const content = formatSession(item.haystack_session_ids[index], item.haystack_dates[index], item.haystack_sessions[index]);
|
|
150
|
+
|
|
151
|
+
await memory.writeMemoryFile({ scope: "project", path: relativePath, content, projectRoot, sync: false });
|
|
152
|
+
pathToIndex.set(relativePath, index);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const search = await memory.searchMemory({ query: item.question, scope: "project", projectRoot, maxResults });
|
|
156
|
+
|
|
157
|
+
return search.results.map((result) => ({
|
|
158
|
+
date: item.haystack_dates[pathToIndex.get(result.path)],
|
|
159
|
+
content: `\n${result.snippet}`,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function oracleChunks(item) {
|
|
164
|
+
const answerIds = new Set(item.answer_session_ids.map(String));
|
|
165
|
+
|
|
166
|
+
return item.haystack_sessions
|
|
167
|
+
.map((turns, index) => ({ id: String(item.haystack_session_ids[index]), date: item.haystack_dates[index], turns }))
|
|
168
|
+
.filter((session) => answerIds.has(session.id))
|
|
169
|
+
.map((session) => ({
|
|
170
|
+
date: session.date,
|
|
171
|
+
content: `\n${JSON.stringify(session.turns.map(({ role, content }) => ({ role, content })))}`,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Models that rejected temperature (Claude 5 does); the official runs use 0 wherever it is accepted.
|
|
176
|
+
const NO_TEMPERATURE = new Set();
|
|
177
|
+
|
|
178
|
+
async function chat(endpoint, model, content, maxTokens) {
|
|
179
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
180
|
+
try {
|
|
181
|
+
const body = { model, messages: [{ role: "user", content }], max_tokens: maxTokens };
|
|
182
|
+
|
|
183
|
+
if (!NO_TEMPERATURE.has(model)) {
|
|
184
|
+
body.temperature = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { Authorization: `Bearer ${process.env.MANIFEST_API_KEY}`, "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify(body),
|
|
191
|
+
});
|
|
192
|
+
const text = await response.text();
|
|
193
|
+
|
|
194
|
+
// Checks this request, not the set: parallel calls can all be refused before the first one marks the model.
|
|
195
|
+
if (response.status === 400 && /temperature/i.test(text) && "temperature" in body) {
|
|
196
|
+
NO_TEMPERATURE.add(model);
|
|
197
|
+
attempt -= 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw Object.assign(new Error(`HTTP ${response.status}: ${text.slice(0, 300)}`), { status: response.status });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return JSON.parse(text).choices[0].message.content || "";
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const retryable = !error.status || error.status === 429 || error.status >= 500;
|
|
208
|
+
|
|
209
|
+
if (!retryable || attempt >= 5) {
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function percent(rows) {
|
|
219
|
+
return `${((rows.filter((row) => row.correct).length / Math.max(rows.length, 1)) * 100).toFixed(1)}%`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function run(options) {
|
|
223
|
+
if (!fsSync.existsSync(options.data)) {
|
|
224
|
+
throw new Error(`Missing ${options.data}. Run with --help for the download command.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const reader = process.env.QA_READER_MODEL || "anthropic/claude-sonnet-5";
|
|
228
|
+
const grader = process.env.QA_GRADER_MODEL || "openrouter/openai/gpt-4o";
|
|
229
|
+
const baseUrl = String(process.env.MANIFEST_BASE_URL || "").replace(/\/+$/, "");
|
|
230
|
+
const endpoint = `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/chat/completions`;
|
|
231
|
+
// The reader prompt's hash is in the key, so a change to the data or to retrieval never reuses a stale answer.
|
|
232
|
+
const promptHash = (prompt) => crypto.createHash("sha1").update(prompt).digest("hex").slice(0, 12);
|
|
233
|
+
const key = (system, id, hash) => `${system}|${reader}|${grader}|${id}|${hash}`;
|
|
234
|
+
|
|
235
|
+
const answerable = JSON.parse(await fs.readFile(options.data, "utf8")).filter(
|
|
236
|
+
(item) => !String(item.question_id).endsWith("_abs"),
|
|
237
|
+
);
|
|
238
|
+
const limit = Math.min(options.limit || answerable.length, answerable.length);
|
|
239
|
+
const items = Array.from({ length: limit }, (_, index) => answerable[Math.floor((index * answerable.length) / limit)]);
|
|
240
|
+
const done = new Map();
|
|
241
|
+
|
|
242
|
+
if (fsSync.existsSync(options.cache)) {
|
|
243
|
+
for (const line of (await fs.readFile(options.cache, "utf8")).split("\n").filter(Boolean)) {
|
|
244
|
+
const row = JSON.parse(line);
|
|
245
|
+
done.set(`${row.system}|${row.reader}|${row.grader}|${row.id}|${row.promptHash}`, row);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build every prompt first (retrieval is local and sequential), then call models concurrently.
|
|
250
|
+
const jobs = [];
|
|
251
|
+
const current = new Map();
|
|
252
|
+
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-qa-"));
|
|
253
|
+
const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
254
|
+
|
|
255
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
for (const [position, item] of items.entries()) {
|
|
259
|
+
const projectRoot = path.join(sandboxRoot, "questions", String(position));
|
|
260
|
+
|
|
261
|
+
for (const system of options.systems) {
|
|
262
|
+
const chunks =
|
|
263
|
+
system === "oracle" ? oracleChunks(item) : await hippocampChunks(item, projectRoot, system === "hippocamp-k10" ? 10 : 5);
|
|
264
|
+
const prompt = fill(READER_PROMPT, historyString(chunks), item.question_date, item.question);
|
|
265
|
+
const hash = promptHash(prompt);
|
|
266
|
+
|
|
267
|
+
current.set(`${system}|${item.question_id}`, key(system, item.question_id, hash));
|
|
268
|
+
|
|
269
|
+
if (!done.has(key(system, item.question_id, hash))) {
|
|
270
|
+
jobs.push({ system, item, prompt, hash });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await fs.rm(process.env.HIPPOCAMP_GLOBAL_ROOT, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
if (previousGlobalRoot === undefined) {
|
|
278
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
279
|
+
} else {
|
|
280
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = previousGlobalRoot;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await fs.rm(sandboxRoot, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.error(`${items.length} questions, ${jobs.length} new jobs; reader ${reader}, grader ${grader}`);
|
|
287
|
+
|
|
288
|
+
for (const system of options.systems) {
|
|
289
|
+
const chars = jobs.filter((job) => job.system === system).reduce((sum, job) => sum + job.prompt.length, 0);
|
|
290
|
+
console.error(` ${system}: ${Math.round(chars / 4)} reader prompt tokens (estimate)`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (options.dryRun) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (jobs.length && (!baseUrl || !process.env.MANIFEST_API_KEY)) {
|
|
298
|
+
throw new Error("Set MANIFEST_BASE_URL and MANIFEST_API_KEY.");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await fs.mkdir(path.dirname(path.resolve(options.cache)), { recursive: true });
|
|
302
|
+
let next = 0;
|
|
303
|
+
let finished = 0;
|
|
304
|
+
const worker = async () => {
|
|
305
|
+
while (next < jobs.length) {
|
|
306
|
+
const job = jobs[next++];
|
|
307
|
+
const response = await chat(endpoint, reader, job.prompt, 800);
|
|
308
|
+
const verdict = await chat(
|
|
309
|
+
endpoint,
|
|
310
|
+
grader,
|
|
311
|
+
fill(GRADER_PROMPTS[job.item.question_type], job.item.question, job.item.answer, response),
|
|
312
|
+
10,
|
|
313
|
+
);
|
|
314
|
+
const row = {
|
|
315
|
+
system: job.system,
|
|
316
|
+
reader,
|
|
317
|
+
grader,
|
|
318
|
+
id: job.item.question_id,
|
|
319
|
+
type: job.item.question_type,
|
|
320
|
+
// Same check as the official evaluate_qa.py: 'yes' in the lowercased verdict.
|
|
321
|
+
correct: /yes/i.test(verdict),
|
|
322
|
+
promptHash: job.hash,
|
|
323
|
+
contextChars: job.prompt.length,
|
|
324
|
+
response,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await fs.appendFile(options.cache, `${JSON.stringify(row)}\n`);
|
|
328
|
+
done.set(key(row.system, row.id, row.promptHash), row);
|
|
329
|
+
|
|
330
|
+
if (++finished % 20 === 0) {
|
|
331
|
+
console.error(`progress ${finished}/${jobs.length}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
await Promise.all(Array.from({ length: options.concurrency }, worker));
|
|
337
|
+
|
|
338
|
+
console.log(`LongMemEval-S QA, ${items.length} answerable questions, reader ${reader}, grader ${grader}\n`);
|
|
339
|
+
|
|
340
|
+
for (const system of options.systems) {
|
|
341
|
+
const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
|
|
342
|
+
const contextChars = Math.round(rows.reduce((sum, row) => sum + row.contextChars, 0) / Math.max(rows.length, 1));
|
|
343
|
+
console.log(`${system.padEnd(14)} QA ${percent(rows)} (${rows.length} graded, prompt ${contextChars} chars)`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const system of options.systems) {
|
|
347
|
+
const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
|
|
348
|
+
console.log(`\n${system}`);
|
|
349
|
+
|
|
350
|
+
for (const type of [...new Set(rows.map((row) => row.type))].sort()) {
|
|
351
|
+
const byType = rows.filter((row) => row.type === type);
|
|
352
|
+
console.log(` ${type.padEnd(26)} ${percent(byType).padStart(6)} (${byType.length})`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function main() {
|
|
358
|
+
const options = parseArgs(process.argv.slice(2));
|
|
359
|
+
|
|
360
|
+
if (options.help) {
|
|
361
|
+
printHelp();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await run(options);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
main().catch((error) => {
|
|
369
|
+
console.error(error.message);
|
|
370
|
+
process.exit(1);
|
|
371
|
+
});
|
|
@@ -64,7 +64,7 @@ async function main() {
|
|
|
64
64
|
|
|
65
65
|
const server = new McpServer({
|
|
66
66
|
name: "hippocamp",
|
|
67
|
-
version: "
|
|
67
|
+
version: require("../package.json").version,
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
server.registerTool(
|
|
@@ -162,7 +162,7 @@ async function main() {
|
|
|
162
162
|
"search_memory",
|
|
163
163
|
{
|
|
164
164
|
description:
|
|
165
|
-
"Search task-specific memory. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions. A month name in the query favors events from that month. A YYYY-MM-DD date, today, yesterday, this/last week, or this/last month keeps only events from those dates (UTC); undated files still match.",
|
|
165
|
+
"Search task-specific memory. Returns no results when the question's distinctive words appear in no memory, unless the question names a date. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions. A month name in the query favors events from that month. A YYYY-MM-DD date, today, yesterday, this/last week, or this/last month keeps only events from those dates (UTC); undated files still match. Default 5 results suits a single fact; ask for 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if results look incomplete.",
|
|
166
166
|
inputSchema: {
|
|
167
167
|
query: z.string(),
|
|
168
168
|
scope: z.enum(["global", "project", "both"]).default("both"),
|
|
@@ -28,6 +28,11 @@ const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
|
|
|
28
28
|
const SEARCH_FUZZY_DISTANCE = 0.2;
|
|
29
29
|
const SEARCH_INDEX_CACHE_LIMIT = 8;
|
|
30
30
|
const SEARCH_EXCERPT_MAX_CHARS = 1200;
|
|
31
|
+
// Below this share of a question's rare-word weight, the best match is about something else.
|
|
32
|
+
const SEARCH_MIN_RARE_WORD_COVERAGE = 0.3;
|
|
33
|
+
const QUESTION_STOPWORDS = new Set(
|
|
34
|
+
"the and for with does did our are was were what which when where why how who can this that from have has had should would could there their them they you your use used about into after before over then than any all".split(" "),
|
|
35
|
+
);
|
|
31
36
|
const EVENT_LOCK_RETRY_MS = 10;
|
|
32
37
|
const EVENT_LOCK_STALE_MS = 10_000;
|
|
33
38
|
const searchIndexCache = new Map();
|
|
@@ -434,6 +439,17 @@ async function getGitRepoRoot(startPath) {
|
|
|
434
439
|
return result.stdout;
|
|
435
440
|
}
|
|
436
441
|
|
|
442
|
+
// Resolve symlinks in a path whose last parts may not exist yet, so it compares with
|
|
443
|
+
// Git's top level, which Git always reports resolved (macOS /tmp is /private/tmp).
|
|
444
|
+
function realpathExisting(targetPath) {
|
|
445
|
+
try {
|
|
446
|
+
return fsSync.realpathSync(targetPath);
|
|
447
|
+
} catch {
|
|
448
|
+
const parent = path.dirname(targetPath);
|
|
449
|
+
return parent === targetPath ? targetPath : path.join(realpathExisting(parent), path.basename(targetPath));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
437
453
|
async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
438
454
|
const repoCandidate = getGlobalRepoRoot();
|
|
439
455
|
const repoRoot = await getGitRepoRoot(repoCandidate);
|
|
@@ -450,7 +466,14 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
|
450
466
|
|
|
451
467
|
const repoRelativePaths = paths.map((item) => {
|
|
452
468
|
const relativePath =
|
|
453
|
-
path
|
|
469
|
+
path
|
|
470
|
+
.relative(
|
|
471
|
+
realpathExisting(repoRoot),
|
|
472
|
+
// Recheck after resolving: a symlink inside the Lagoon must not reach another scope.
|
|
473
|
+
assertPathInScope(realpathExisting(scopeRoot), realpathExisting(assertPathInScope(scopeRoot, item))),
|
|
474
|
+
)
|
|
475
|
+
.split(path.sep)
|
|
476
|
+
.join(path.posix.sep) || ".";
|
|
454
477
|
|
|
455
478
|
if (relativePath === ".") {
|
|
456
479
|
throw new Error("path must point to a memory file or directory inside the Lagoon root.");
|
|
@@ -1293,9 +1316,32 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
|
|
|
1293
1316
|
.map((hit) => ({
|
|
1294
1317
|
document: documents[hit.id],
|
|
1295
1318
|
score: Math.round(hit.score * 100) / 100,
|
|
1319
|
+
queryTerms: hit.queryTerms,
|
|
1296
1320
|
}));
|
|
1297
1321
|
}
|
|
1298
1322
|
|
|
1323
|
+
// Share of the question's rare-word weight (IDF) that the best match contains. A question
|
|
1324
|
+
// whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even when
|
|
1325
|
+
// common words ("backend", "deploy") match, so search can return nothing instead of noise.
|
|
1326
|
+
function rareWordCoverage(queryInfo, best, { index, documents }) {
|
|
1327
|
+
// Month names only rank (see eventDateText); they say when, not what.
|
|
1328
|
+
const words = [
|
|
1329
|
+
...new Set(
|
|
1330
|
+
queryInfo.tokens.filter((token) => token.length > 2 && !QUESTION_STOPWORDS.has(token) && !MONTH_NAMES.includes(token)),
|
|
1331
|
+
),
|
|
1332
|
+
];
|
|
1333
|
+
|
|
1334
|
+
if (!words.length) {
|
|
1335
|
+
return 1;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const weight = (word) => Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5));
|
|
1339
|
+
const matched = new Set(best?.queryTerms || []);
|
|
1340
|
+
const total = words.reduce((sum, word) => sum + weight(word), 0);
|
|
1341
|
+
|
|
1342
|
+
return words.filter((word) => matched.has(word)).reduce((sum, word) => sum + weight(word), 0) / total;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1299
1345
|
function toSearchResult(queryInfo, { document, score }) {
|
|
1300
1346
|
const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
|
|
1301
1347
|
|
|
@@ -1392,8 +1438,14 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
|
|
|
1392
1438
|
const search = await loadSearchIndex(scopes, projectRoot);
|
|
1393
1439
|
const results = [];
|
|
1394
1440
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1441
|
+
const candidates = queryInfo.tokens.length && search.documents.length ? rankSearchDocuments(queryInfo, search) : [];
|
|
1442
|
+
// Dated questions skip the check: their date words describe time, not the topic.
|
|
1443
|
+
const related =
|
|
1444
|
+
queryInfo.datePrefixes.length > 0 ||
|
|
1445
|
+
rareWordCoverage(queryInfo, candidates[0], search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
|
|
1446
|
+
|
|
1447
|
+
if (related) {
|
|
1448
|
+
for (const candidate of candidates) {
|
|
1397
1449
|
const result = toSearchResult(queryInfo, candidate);
|
|
1398
1450
|
|
|
1399
1451
|
if (result) {
|
package/scripts/hippocamp.cjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
4
5
|
const path = require("node:path");
|
|
5
6
|
|
|
7
|
+
// One installer per agent CLI; `install` runs the ones whose CLI is on PATH.
|
|
8
|
+
const AGENTS = [
|
|
9
|
+
["claude", "install-claude.cjs"],
|
|
10
|
+
["codex", "install-codex.cjs"],
|
|
11
|
+
["grok", "install-grok.cjs"],
|
|
12
|
+
];
|
|
13
|
+
|
|
6
14
|
const COMMANDS = {
|
|
7
15
|
dream: "hippocamp-dream.cjs",
|
|
8
16
|
mcp: "hippocamp-mcp.cjs",
|
|
@@ -18,6 +26,8 @@ function printHelp() {
|
|
|
18
26
|
console.log(`Hippocamp
|
|
19
27
|
|
|
20
28
|
Usage:
|
|
29
|
+
hippocamp install
|
|
30
|
+
hippocamp upgrade
|
|
21
31
|
hippocamp dream
|
|
22
32
|
hippocamp mcp
|
|
23
33
|
hippocamp install-codex
|
|
@@ -28,6 +38,8 @@ Usage:
|
|
|
28
38
|
hippocamp upgrade-grok
|
|
29
39
|
|
|
30
40
|
Commands:
|
|
41
|
+
install Install into every agent found: Claude Code, Codex, Grok Build
|
|
42
|
+
upgrade Same as install; run with npx hippocamp@latest
|
|
31
43
|
dream Run offline Dream memory compaction
|
|
32
44
|
mcp Run the local MCP server
|
|
33
45
|
install-codex Install the Hippocamp skill and MCP server into Codex
|
|
@@ -39,6 +51,35 @@ Commands:
|
|
|
39
51
|
`);
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
// Platform-neutral PATH lookup, so `install` also finds agents on Windows.
|
|
55
|
+
function onPath(cli) {
|
|
56
|
+
const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD").split(";") : [""];
|
|
57
|
+
|
|
58
|
+
return String(process.env.PATH || "")
|
|
59
|
+
.split(path.delimiter)
|
|
60
|
+
.some((dir) => extensions.some((extension) => fs.existsSync(path.join(dir, cli + extension.toLowerCase())) || fs.existsSync(path.join(dir, cli + extension))));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function installAll(args) {
|
|
64
|
+
const found = AGENTS.filter(([cli]) => onPath(cli));
|
|
65
|
+
|
|
66
|
+
if (!found.length) {
|
|
67
|
+
console.error("No supported agent CLI found on PATH: claude, codex, or grok. Install one, then rerun.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Install every agent found, even when one fails, then report the failures.
|
|
72
|
+
const failed = found.filter(([cli, script]) => {
|
|
73
|
+
console.error(`Installing Hippocamp into ${cli}...`);
|
|
74
|
+
return spawnSync(process.execPath, [path.join(__dirname, script), ...args], { stdio: "inherit" }).status !== 0;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (failed.length) {
|
|
78
|
+
console.error(`Install failed for: ${failed.map(([cli]) => cli).join(", ")}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
function main() {
|
|
43
84
|
const [command, ...args] = process.argv.slice(2);
|
|
44
85
|
|
|
@@ -47,6 +88,11 @@ function main() {
|
|
|
47
88
|
return;
|
|
48
89
|
}
|
|
49
90
|
|
|
91
|
+
if (command === "install" || command === "upgrade") {
|
|
92
|
+
installAll(args);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
const script = COMMANDS[command];
|
|
51
97
|
|
|
52
98
|
if (!script) {
|
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
|
|
@@ -204,7 +205,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
const
|
|
208
|
+
const command = mcpServerCommand(repoRoot);
|
|
208
209
|
const added = await runClaude([
|
|
209
210
|
"mcp",
|
|
210
211
|
"add",
|
|
@@ -218,8 +219,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
218
219
|
"--env",
|
|
219
220
|
"HIPPOCAMP_AGENT=claude",
|
|
220
221
|
"--",
|
|
221
|
-
|
|
222
|
-
scriptPath,
|
|
222
|
+
...command,
|
|
223
223
|
]);
|
|
224
224
|
|
|
225
225
|
if (!added.ok) {
|
|
@@ -228,8 +228,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
228
228
|
|
|
229
229
|
return {
|
|
230
230
|
name: serverName,
|
|
231
|
-
command:
|
|
232
|
-
scriptPath,
|
|
231
|
+
command: command.join(" "),
|
|
233
232
|
globalRoot,
|
|
234
233
|
summary: added.stdout || added.stderr,
|
|
235
234
|
};
|
|
@@ -249,6 +248,7 @@ async function main() {
|
|
|
249
248
|
repoRoot,
|
|
250
249
|
});
|
|
251
250
|
|
|
251
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
252
252
|
const server = await ensureServerInstalled({
|
|
253
253
|
repoRoot,
|
|
254
254
|
globalRoot: options.globalRoot,
|
|
@@ -263,12 +263,14 @@ async function main() {
|
|
|
263
263
|
claudeMdPath,
|
|
264
264
|
claudeSkillPath,
|
|
265
265
|
server,
|
|
266
|
+
lagoon,
|
|
266
267
|
note: "Restart Claude Code to load the installed MCP server, Hippocamp skill, and refreshed CLAUDE.md instructions.",
|
|
267
268
|
},
|
|
268
269
|
null,
|
|
269
270
|
2,
|
|
270
271
|
),
|
|
271
272
|
);
|
|
273
|
+
printHints(lagoon);
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
main().catch((error) => {
|
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const CODEX_MANAGED_START = "<!-- hippocamp:managed:start -->";
|
|
@@ -174,7 +175,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
const
|
|
178
|
+
const command = mcpServerCommand(repoRoot);
|
|
178
179
|
const added = await runCodex([
|
|
179
180
|
"mcp",
|
|
180
181
|
"add",
|
|
@@ -184,8 +185,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
184
185
|
"--env",
|
|
185
186
|
"HIPPOCAMP_AGENT=codex",
|
|
186
187
|
"--",
|
|
187
|
-
|
|
188
|
-
scriptPath,
|
|
188
|
+
...command,
|
|
189
189
|
]);
|
|
190
190
|
|
|
191
191
|
if (!added.ok) {
|
|
@@ -194,8 +194,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
194
194
|
|
|
195
195
|
return {
|
|
196
196
|
name: serverName,
|
|
197
|
-
command:
|
|
198
|
-
scriptPath,
|
|
197
|
+
command: command.join(" "),
|
|
199
198
|
globalRoot,
|
|
200
199
|
summary: added.stdout || added.stderr,
|
|
201
200
|
};
|
|
@@ -215,6 +214,7 @@ async function main() {
|
|
|
215
214
|
codexHome: options.codexHome,
|
|
216
215
|
});
|
|
217
216
|
|
|
217
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
218
218
|
const server = await ensureServerInstalled({
|
|
219
219
|
repoRoot,
|
|
220
220
|
globalRoot: options.globalRoot,
|
|
@@ -229,12 +229,14 @@ async function main() {
|
|
|
229
229
|
skillPath,
|
|
230
230
|
codexInstructionsPath,
|
|
231
231
|
server,
|
|
232
|
+
lagoon,
|
|
232
233
|
note: "Restart Codex to load the installed skill, MCP server, and refreshed AGENTS.md instructions.",
|
|
233
234
|
},
|
|
234
235
|
null,
|
|
235
236
|
2,
|
|
236
237
|
),
|
|
237
238
|
);
|
|
239
|
+
printHints(lagoon);
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
main().catch((error) => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const fsSync = require("node:fs");
|
|
2
|
+
const fs = require("node:fs/promises");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { execFile } = require("node:child_process");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// How an agent starts the MCP server. Run through npx, the package sits in npm's cache, which
|
|
11
|
+
// npm may clean. Copy it with its dependencies to ~/.hippocamp/<version> and start it from
|
|
12
|
+
// there: no npx or network at start, so it also works offline.
|
|
13
|
+
function mcpServerCommand(repoRoot, home = os.homedir()) {
|
|
14
|
+
if (!repoRoot.split(path.sep).includes("_npx")) {
|
|
15
|
+
return [process.execPath, path.join(repoRoot, "scripts", "hippocamp-mcp.cjs")];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { version } = require(path.join(repoRoot, "package.json"));
|
|
19
|
+
const target = path.join(home, ".hippocamp", version, "node_modules");
|
|
20
|
+
|
|
21
|
+
// Copy into a staging folder first, so a failed copy never removes a working install.
|
|
22
|
+
const staging = `${target}.staging-${process.pid}`;
|
|
23
|
+
fsSync.rmSync(staging, { recursive: true, force: true });
|
|
24
|
+
fsSync.cpSync(path.dirname(repoRoot), staging, { recursive: true, dereference: true });
|
|
25
|
+
fsSync.rmSync(target, { recursive: true, force: true });
|
|
26
|
+
fsSync.renameSync(staging, target);
|
|
27
|
+
|
|
28
|
+
return [process.execPath, path.join(target, path.basename(repoRoot), "scripts", "hippocamp-mcp.cjs")];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function git(args, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
34
|
+
cwd,
|
|
35
|
+
timeout: 20_000,
|
|
36
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
37
|
+
});
|
|
38
|
+
return { ok: true, stdout: stdout.trim() };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { ok: false, stdout: String(error.stdout || "").trim() };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Make sure the Lagoon memory repo exists, and say what is still missing for sync.
|
|
45
|
+
// It never creates a remote or pushes: those reach outside this machine.
|
|
46
|
+
async function prepareLagoon(globalRoot) {
|
|
47
|
+
const hints = [];
|
|
48
|
+
let created = false;
|
|
49
|
+
const existed = fsSync.existsSync(globalRoot);
|
|
50
|
+
const empty = !existed || (await fs.readdir(globalRoot)).length === 0;
|
|
51
|
+
|
|
52
|
+
await fs.mkdir(globalRoot, { recursive: true });
|
|
53
|
+
|
|
54
|
+
// The Lagoon must be its own repo: a folder inside another repo would commit memory there.
|
|
55
|
+
const topLevel = await git(["rev-parse", "--show-toplevel"], globalRoot);
|
|
56
|
+
const ownRepo = topLevel.ok && fsSync.realpathSync(topLevel.stdout) === fsSync.realpathSync(globalRoot);
|
|
57
|
+
|
|
58
|
+
if (!ownRepo) {
|
|
59
|
+
if (!empty) {
|
|
60
|
+
hints.push(`${globalRoot} is not its own Git repo. Point --global-root at your Lagoon clone, or run: git -C "${globalRoot}" init`);
|
|
61
|
+
return { root: globalRoot, created: false, remote: null, push: "not_git", hints };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!(await git(["init", "-b", "main"], globalRoot)).ok) {
|
|
65
|
+
throw new Error(`git init failed in ${globalRoot}. Check that Git is installed and the folder is writable.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await git(["commit", "--allow-empty", "-m", "hippocamp: create lagoon"], globalRoot);
|
|
69
|
+
created = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const identity = await Promise.all(["user.name", "user.email"].map((key) => git(["config", key], globalRoot)));
|
|
73
|
+
|
|
74
|
+
if (identity.some((value) => !value.stdout)) {
|
|
75
|
+
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
|
+
}
|
|
77
|
+
|
|
78
|
+
const remote = await git(["remote", "get-url", "origin"], globalRoot);
|
|
79
|
+
let push = "no_remote";
|
|
80
|
+
|
|
81
|
+
if (!remote.ok) {
|
|
82
|
+
hints.push(`Memory is local only. For a private backup and other machines: gh repo create lagoon --private --source "${globalRoot}" --remote origin --push`);
|
|
83
|
+
} else if ((await git(["rev-parse", "--verify", "HEAD"], globalRoot)).ok) {
|
|
84
|
+
push = (await git(["push", "--dry-run"], globalRoot)).ok ? "ok" : "failed";
|
|
85
|
+
|
|
86
|
+
if (push === "failed") {
|
|
87
|
+
hints.push(`Git push from ${globalRoot} failed. Set up auth, then check with git push --dry-run: gh auth login && gh auth setup-git`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { root: globalRoot, created, remote: remote.ok ? remote.stdout : null, push, hints };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printHints(lagoon) {
|
|
95
|
+
for (const hint of lagoon.hints) {
|
|
96
|
+
console.error(`Next: ${hint}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { mcpServerCommand, prepareLagoon, printHints };
|
package/scripts/install-grok.cjs
CHANGED
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const GROK_MANAGED_START = "<!-- hippocamp:managed:start -->";
|
|
@@ -190,7 +191,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
const
|
|
194
|
+
const command = mcpServerCommand(repoRoot);
|
|
194
195
|
const added = await runGrok([
|
|
195
196
|
"mcp",
|
|
196
197
|
"add",
|
|
@@ -200,8 +201,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
200
201
|
"-e",
|
|
201
202
|
"HIPPOCAMP_AGENT=grok",
|
|
202
203
|
"--",
|
|
203
|
-
|
|
204
|
-
scriptPath,
|
|
204
|
+
...command,
|
|
205
205
|
]);
|
|
206
206
|
|
|
207
207
|
if (!added.ok) {
|
|
@@ -210,8 +210,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
210
210
|
|
|
211
211
|
return {
|
|
212
212
|
name: serverName,
|
|
213
|
-
command:
|
|
214
|
-
scriptPath,
|
|
213
|
+
command: command.join(" "),
|
|
215
214
|
globalRoot,
|
|
216
215
|
agent: "grok",
|
|
217
216
|
summary: added.stdout || added.stderr,
|
|
@@ -232,6 +231,7 @@ async function main() {
|
|
|
232
231
|
grokHome: options.grokHome,
|
|
233
232
|
});
|
|
234
233
|
|
|
234
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
235
235
|
const server = await ensureServerInstalled({
|
|
236
236
|
repoRoot,
|
|
237
237
|
globalRoot: options.globalRoot,
|
|
@@ -246,12 +246,14 @@ async function main() {
|
|
|
246
246
|
skillPath,
|
|
247
247
|
grokInstructionsPath,
|
|
248
248
|
server,
|
|
249
|
+
lagoon,
|
|
249
250
|
note: "Restart Grok Build (or start a new session) to load the installed skill, MCP server, and home rules.",
|
|
250
251
|
},
|
|
251
252
|
null,
|
|
252
253
|
2,
|
|
253
254
|
),
|
|
254
255
|
);
|
|
256
|
+
printHints(lagoon);
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
main().catch((error) => {
|
|
@@ -25,6 +25,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
|
|
|
25
25
|
1. At the start of every top-level coding task, call `wake_up` before inspecting files, planning, or editing.
|
|
26
26
|
2. Read the wake-up output before searching.
|
|
27
27
|
3. Use `search_memory` as the normal path for task-specific recall after wake-up.
|
|
28
|
+
Keep the default 5 results for a single fact. Pass `maxResults` 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if the results look incomplete.
|
|
28
29
|
4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
|
|
29
30
|
5. During work, use `append_event` only for meaningful milestones.
|
|
30
31
|
6. When appending events, pass a `title` and 1-8 concise `cues` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { execFileSync } = require("node:child_process");
|
|
4
|
+
const fsSync = require("node:fs");
|
|
5
|
+
const fs = require("node:fs/promises");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon } = require("../scripts/install-common.cjs");
|
|
9
|
+
|
|
10
|
+
// CI runners have no Git identity; commits in these tests need one.
|
|
11
|
+
const gitEnv = {
|
|
12
|
+
GIT_AUTHOR_NAME: "Test",
|
|
13
|
+
GIT_AUTHOR_EMAIL: "test@example.com",
|
|
14
|
+
GIT_COMMITTER_NAME: "Test",
|
|
15
|
+
GIT_COMMITTER_EMAIL: "test@example.com",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Scoped to each test that needs a Git identity, then restored.
|
|
19
|
+
function useGitIdentity(t) {
|
|
20
|
+
const saved = Object.fromEntries(Object.keys(gitEnv).map((key) => [key, process.env[key]]));
|
|
21
|
+
Object.assign(process.env, gitEnv);
|
|
22
|
+
t.after(() => {
|
|
23
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
delete process.env[key];
|
|
26
|
+
} else {
|
|
27
|
+
process.env[key] = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function tempDir(t, name) {
|
|
34
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), `hippocamp-${name}-`));
|
|
35
|
+
t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 5 }));
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("mcpServerCommand copies npx installs to a stable folder and keeps source checkouts direct", async (t) => {
|
|
40
|
+
const root = await tempDir(t, "npx");
|
|
41
|
+
const modules = path.join(root, "_npx", "abc", "node_modules");
|
|
42
|
+
const npxRoot = path.join(modules, "hippocamp");
|
|
43
|
+
const home = path.join(root, "home");
|
|
44
|
+
await fs.mkdir(path.join(npxRoot, "scripts"), { recursive: true });
|
|
45
|
+
await fs.mkdir(path.join(modules, "minisearch"), { recursive: true });
|
|
46
|
+
await fs.writeFile(path.join(npxRoot, "package.json"), '{ "version": "9.9.9" }');
|
|
47
|
+
await fs.writeFile(path.join(npxRoot, "scripts", "hippocamp-mcp.cjs"), "// server\n");
|
|
48
|
+
await fs.writeFile(path.join(modules, "minisearch", "index.js"), "// dependency\n");
|
|
49
|
+
|
|
50
|
+
const command = mcpServerCommand(npxRoot, home);
|
|
51
|
+
const stable = path.join(home, ".hippocamp", "9.9.9", "node_modules");
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(command, [process.execPath, path.join(stable, "hippocamp", "scripts", "hippocamp-mcp.cjs")]);
|
|
54
|
+
await fs.stat(command[1]);
|
|
55
|
+
await fs.stat(path.join(stable, "minisearch", "index.js"));
|
|
56
|
+
|
|
57
|
+
// A reinstall of the same version replaces the copy: no stale files, no staging left behind.
|
|
58
|
+
await fs.writeFile(path.join(stable, "stale.js"), "// old\n");
|
|
59
|
+
mcpServerCommand(npxRoot, home);
|
|
60
|
+
await assert.rejects(fs.stat(path.join(stable, "stale.js")));
|
|
61
|
+
assert.deepEqual(await fs.readdir(path.join(home, ".hippocamp", "9.9.9")), ["node_modules"]);
|
|
62
|
+
|
|
63
|
+
const source = path.join(root, "src", "hippocamp");
|
|
64
|
+
assert.deepEqual(mcpServerCommand(source), [process.execPath, path.join(source, "scripts", "hippocamp-mcp.cjs")]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("prepareLagoon creates a missing Lagoon repo and says it is local only", async (t) => {
|
|
68
|
+
useGitIdentity(t);
|
|
69
|
+
const lagoon = path.join(await tempDir(t, "lagoon"), "lagoon");
|
|
70
|
+
const result = await prepareLagoon(lagoon);
|
|
71
|
+
|
|
72
|
+
assert.equal(result.created, true);
|
|
73
|
+
assert.equal(result.push, "no_remote");
|
|
74
|
+
assert.match(result.hints.join("\n"), /gh repo create lagoon --private/);
|
|
75
|
+
assert.equal(execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: lagoon }).toString().trim(), "true");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("prepareLagoon leaves a non-empty folder that is not a repo alone", async (t) => {
|
|
79
|
+
const folder = await tempDir(t, "notes");
|
|
80
|
+
await fs.writeFile(path.join(folder, "notes.md"), "mine\n");
|
|
81
|
+
const result = await prepareLagoon(folder);
|
|
82
|
+
|
|
83
|
+
assert.equal(result.push, "not_git");
|
|
84
|
+
await assert.rejects(fs.stat(path.join(folder, ".git")));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("prepareLagoon checks push to an existing remote", async (t) => {
|
|
88
|
+
useGitIdentity(t);
|
|
89
|
+
const root = await tempDir(t, "remote");
|
|
90
|
+
const remote = path.join(root, "remote.git");
|
|
91
|
+
const lagoon = path.join(root, "lagoon");
|
|
92
|
+
execFileSync("git", ["init", "--bare", "-b", "main", remote]);
|
|
93
|
+
execFileSync("git", ["init", "-b", "main", lagoon]);
|
|
94
|
+
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: lagoon });
|
|
95
|
+
execFileSync("git", ["remote", "add", "origin", remote], { cwd: lagoon });
|
|
96
|
+
execFileSync("git", ["push", "-u", "origin", "main"], { cwd: lagoon, stdio: "ignore" });
|
|
97
|
+
|
|
98
|
+
const result = await prepareLagoon(lagoon);
|
|
99
|
+
|
|
100
|
+
assert.equal(result.created, false);
|
|
101
|
+
assert.equal(result.remote, remote);
|
|
102
|
+
assert.equal(result.push, "ok");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("prepareLagoon refuses a folder inside another repo", async (t) => {
|
|
106
|
+
useGitIdentity(t);
|
|
107
|
+
const parent = await tempDir(t, "parent");
|
|
108
|
+
execFileSync("git", ["init", "-b", "main", parent]);
|
|
109
|
+
const nested = path.join(parent, "notes");
|
|
110
|
+
await fs.mkdir(nested);
|
|
111
|
+
await fs.writeFile(path.join(nested, "a.md"), "x\n");
|
|
112
|
+
|
|
113
|
+
const result = await prepareLagoon(nested);
|
|
114
|
+
|
|
115
|
+
assert.equal(result.push, "not_git");
|
|
116
|
+
await assert.rejects(fs.stat(path.join(nested, ".git")));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("prepareLagoon gives an empty folder inside another repo its own repo", async (t) => {
|
|
120
|
+
useGitIdentity(t);
|
|
121
|
+
const parent = await tempDir(t, "parent-empty");
|
|
122
|
+
execFileSync("git", ["init", "-b", "main", parent]);
|
|
123
|
+
const nested = path.join(parent, "lagoon");
|
|
124
|
+
|
|
125
|
+
const result = await prepareLagoon(nested);
|
|
126
|
+
|
|
127
|
+
assert.equal(result.created, true);
|
|
128
|
+
assert.equal(fsSync.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: nested }).toString().trim()), fsSync.realpathSync(nested));
|
|
129
|
+
});
|
|
@@ -463,3 +463,38 @@ test("search returns a snippet for a long event matched only by its date", async
|
|
|
463
463
|
assert.match(search.results[0].snippet, /^The weekly sync covered routine updates\./);
|
|
464
464
|
assert.ok(search.results[0].snippet.length <= 1200);
|
|
465
465
|
});
|
|
466
|
+
|
|
467
|
+
test("search returns nothing when no memory is about the question", async (t) => {
|
|
468
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
469
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-unrelated-"));
|
|
470
|
+
const projectRoot = path.join(tempRoot, "unrelated-project");
|
|
471
|
+
|
|
472
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
473
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
474
|
+
t.after(async () => {
|
|
475
|
+
if (originalRoot === undefined) {
|
|
476
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
477
|
+
} else {
|
|
478
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
479
|
+
}
|
|
480
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
for (const [title, content] of [
|
|
484
|
+
["Backend deploy", "The backend deploys to Cloud Run after the build passes."],
|
|
485
|
+
["Deploy rollback", "A failed backend deploy rolls back to the previous revision."],
|
|
486
|
+
["Billing note", "Invoices are generated monthly."],
|
|
487
|
+
]) {
|
|
488
|
+
await memory.appendEvent({ content, cues: ["ops"], projectRoot, scope: "project", sync: false, title });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const unrelated = await memory.searchMemory({ projectRoot, query: "which kubernetes namespace does the backend deploy to", scope: "project" });
|
|
492
|
+
const related = await memory.searchMemory({ projectRoot, query: "where does the backend deploy", scope: "project" });
|
|
493
|
+
|
|
494
|
+
const monthOnly = await memory.searchMemory({ projectRoot, query: "deploy in august", scope: "project" });
|
|
495
|
+
|
|
496
|
+
assert.deepEqual(unrelated.results, []);
|
|
497
|
+
assert.equal(related.results[0].heading, "Backend deploy");
|
|
498
|
+
// No event is from August; the month name must not count as a missing topic word.
|
|
499
|
+
assert.ok(monthOnly.results.length > 0);
|
|
500
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { execFileSync } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs/promises");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const memory = require("../scripts/hippocamp-memory.cjs");
|
|
8
|
+
|
|
9
|
+
test("sync commits when the Lagoon path goes through a symlink", async (t) => {
|
|
10
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
11
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-symlink-"));
|
|
12
|
+
const realLagoon = path.join(tempRoot, "real-lagoon");
|
|
13
|
+
const linkedLagoon = path.join(tempRoot, "linked-lagoon");
|
|
14
|
+
const projectRoot = path.join(tempRoot, "linked-project");
|
|
15
|
+
const git = (args) => execFileSync("git", args, { cwd: realLagoon }).toString().trim();
|
|
16
|
+
|
|
17
|
+
// CI runners have no Git identity; set one for this test only.
|
|
18
|
+
const identity = { GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" };
|
|
19
|
+
const saved = Object.fromEntries(Object.keys(identity).map((key) => [key, process.env[key]]));
|
|
20
|
+
Object.assign(process.env, identity);
|
|
21
|
+
t.after(() => {
|
|
22
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
delete process.env[key];
|
|
25
|
+
} else {
|
|
26
|
+
process.env[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await fs.mkdir(realLagoon);
|
|
32
|
+
await fs.mkdir(projectRoot);
|
|
33
|
+
await fs.symlink(realLagoon, linkedLagoon);
|
|
34
|
+
git(["init", "-q", "-b", "main"]);
|
|
35
|
+
git(["commit", "-q", "--allow-empty", "-m", "init"]);
|
|
36
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = linkedLagoon;
|
|
37
|
+
t.after(async () => {
|
|
38
|
+
if (originalRoot === undefined) {
|
|
39
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
40
|
+
} else {
|
|
41
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
42
|
+
}
|
|
43
|
+
await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await memory.appendEvent({
|
|
47
|
+
content: "Use advisory locks.",
|
|
48
|
+
cues: ["locks"],
|
|
49
|
+
projectRoot,
|
|
50
|
+
scope: "project",
|
|
51
|
+
title: "Queue decision",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(result.sync.committed, true);
|
|
55
|
+
assert.match(git(["log", "--format=%s", "-1"]), /append project event/);
|
|
56
|
+
|
|
57
|
+
// A symlink inside the Lagoon pointing at another project must not be synced as this one.
|
|
58
|
+
const otherProject = path.join(tempRoot, "other-project");
|
|
59
|
+
await fs.mkdir(otherProject);
|
|
60
|
+
await fs.mkdir(path.join(realLagoon, "projects", "other-project"), { recursive: true });
|
|
61
|
+
await fs.symlink(path.join(realLagoon, "projects", "linked-project", "events"), path.join(realLagoon, "projects", "other-project", "events"));
|
|
62
|
+
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
memory.appendEvent({ content: "Cross write.", cues: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
|
|
65
|
+
/escapes the memory root/,
|
|
66
|
+
);
|
|
67
|
+
});
|