hippocamp 0.2.2 → 0.2.4
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 +1 -1
- package/scripts/hippocamp-memory.cjs +112 -11
- 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 +91 -0
- package/scripts/install-grok.cjs +7 -5
- package/skills/hippocamp-memory/SKILL.md +1 -0
- package/test/install-common.test.cjs +112 -0
- package/test/memory-efficiency.test.cjs +152 -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 Claude Code, Codex, and Grok Build 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 cloud service: it runs on your machine and works the moment it is installed. Your memory never leaves your computer except to a Git remote you choose. 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 Grok 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, local, free. 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 agent starts the server with `npx -y --prefer-offline hippocamp@<version> mcp`, so it 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.4",
|
|
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
|
+
});
|
|
@@ -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.",
|
|
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"),
|