hippocamp 0.2.6 → 0.2.7

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.
@@ -4,6 +4,7 @@ on:
4
4
  release:
5
5
  types:
6
6
  - published
7
+ workflow_dispatch:
7
8
 
8
9
  permissions:
9
10
  contents: read
@@ -11,6 +12,7 @@ permissions:
11
12
 
12
13
  jobs:
13
14
  publish:
15
+ if: github.event_name == 'release'
14
16
  runs-on: ubuntu-latest
15
17
  steps:
16
18
  - uses: actions/checkout@v6
@@ -25,3 +27,26 @@ jobs:
25
27
  - name: Verify release tag matches package version
26
28
  run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")"
27
29
  - run: npm publish
30
+
31
+ github-packages:
32
+ # Mirror of the npm package on GitHub Packages, so the repository lists it.
33
+ # npmjs.com stays the install path; GitHub's npm registry needs a token to install.
34
+ if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
35
+ runs-on: ubuntu-latest
36
+ permissions:
37
+ contents: read
38
+ packages: write
39
+ steps:
40
+ - uses: actions/checkout@v6
41
+ - uses: actions/setup-node@v6
42
+ with:
43
+ node-version: "24"
44
+ registry-url: "https://npm.pkg.github.com"
45
+ package-manager-cache: false
46
+ - name: Scope the package name for GitHub Packages
47
+ run: |
48
+ npm pkg set name="@${GITHUB_REPOSITORY_OWNER}/hippocamp"
49
+ npm pkg set repository.url="https://github.com/${GITHUB_REPOSITORY}"
50
+ - run: npm publish
51
+ env:
52
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/AGENTS.md CHANGED
@@ -47,15 +47,15 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
47
47
  - Sync paths must stay under the selected Lagoon memory root.
48
48
  - Global memory lives under `HIPPOCAMP_GLOBAL_ROOT/`.
49
49
  - Project memory lives under `HIPPOCAMP_GLOBAL_ROOT/projects/<slug>/`.
50
- - The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory.
51
- - Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json` cue indexes.
50
+ - The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory. An existing folder with nothing but `.git` has no project; project scope then resolves to the global root.
51
+ - Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json` keyword indexes.
52
52
  - Curated files such as `current_state.md` and `open_threads.md` use explicit file writes.
53
- - MCP `append_event` requires a title and 1-8 cues; MCP `write_memory_file` rejects paths under `events/`; an explicit `projectRoot` must be a directory inside a Git repository.
53
+ - MCP `append_event` requires a title and 1-8 keywords; MCP `write_memory_file` rejects paths under `events/`; an explicit `projectRoot` must be a directory inside a Git repository.
54
54
  - `append_event` auto-stamps `Agent:` / `Session:` provenance when resolvable (installer env, MCP client name, or process session id). No manual user config.
55
55
  - Default sync is automatic through the local Lagoon repo.
56
56
  - Local MCP mode must not require `GITHUB_TOKEN`, GitHub App auth, or a cloud service.
57
57
  - Memory must not duplicate facts already tracked by GitHub commits, PRs, issues, reviews, or CI. Store references plus the missing rationale, preference, assumption, or follow-up context instead.
58
- - Dream compaction sends `current_state.md`, `open_threads.md`, and bounded cue-indexed event evidence for open-thread decisions. It must not dump full event logs, cue indexes, or raw GitHub-owned facts into the model prompt.
58
+ - Dream compaction sends `current_state.md`, `open_threads.md`, and bounded keyword-indexed event evidence for open-thread decisions. It must not dump full event logs, keyword indexes, or raw GitHub-owned facts into the model prompt.
59
59
  - Dream must compact coherent Markdown snapshots and fail without writing when the snapshot cannot fit. It must not crop wake-up files.
60
60
  - The Dream GitHub Actions workflow is a Lagoon repo template. It should remain schedule-only and create PRs rather than pushing directly to `main`. Auto-merge is allowed only for repositories named `lagoon` after exact changed-file validation.
61
61
  - The Railway Dream deployment is optional infrastructure. It must exit after each run, keep credentials in environment variables, and preserve the same PR policy.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Hippocamp
2
2
 
3
- [![npm](https://img.shields.io/npm/v/hippocamp)](https://www.npmjs.com/package/hippocamp) [![CI](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml/badge.svg)](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml)
3
+ [![npm](https://img.shields.io/npm/v/hippocamp)](https://www.npmjs.com/package/hippocamp) [![npm downloads](https://img.shields.io/npm/dm/hippocamp)](https://www.npmjs.com/package/hippocamp) [![CI](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml/badge.svg)](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml)
4
4
 
5
5
  <p align="center">
6
6
  <img src="./assets/brand/hippocamp-mascot.png" alt="Hippocamp mascot" width="220" />
@@ -30,6 +30,18 @@ A replay of a real session. Neither prompt mentions Hippocamp.
30
30
  - **Files you own.** Plain Markdown, readable without any UI, synced across machines with your normal Git credentials.
31
31
  - **Nothing to sign up for.** No vendor that can change terms or shut down.
32
32
 
33
+ ## Compared with other memory tools
34
+
35
+ | | **Hippocamp** | [Mem0](https://docs.mem0.ai) | [Zep](https://www.getzep.com) | [Graphiti](https://github.com/getzep/graphiti) | [Letta Code](https://github.com/letta-ai/letta-code) | [Basic Memory](https://github.com/basicmachines-co/basic-memory) | Claude Code / Codex built-in |
36
+ | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
37
+ | Agent agnostic (MCP server) | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
38
+ | Plain files you own | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
39
+ | Auditable history | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
40
+ | No extra account, API key, or model | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
41
+ | Syncs through your own Git | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
42
+ | Always free | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
43
+ | Open source | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
44
+
33
45
  ## Results
34
46
 
35
47
  Reproducible with the scripts in this repo.
@@ -144,8 +144,8 @@ jobs:
144
144
  "",
145
145
  "Policy:",
146
146
  "- touched only `current_state.md` and `open_threads.md`",
147
- "- used bounded cue-indexed event evidence for thread decisions",
148
- "- did not modify append-only events or cue indexes",
147
+ "- used bounded keyword-indexed event evidence for thread decisions",
148
+ "- did not modify append-only events or keyword indexes",
149
149
  "- moved historical detail out of wake-up context",
150
150
  ];
151
151
  console.log(lines.join("\n"));
@@ -153,8 +153,8 @@ const lines = [
153
153
  "",
154
154
  "Policy:",
155
155
  "- touched only `current_state.md` and `open_threads.md`",
156
- "- used bounded cue-indexed event evidence for thread decisions",
157
- "- did not modify append-only events or cue indexes",
156
+ "- used bounded keyword-indexed event evidence for thread decisions",
157
+ "- did not modify append-only events or keyword indexes",
158
158
  "- moved historical detail out of wake-up context",
159
159
  ];
160
160
  console.log(lines.join("\n"));
package/docs/dream.md CHANGED
@@ -9,7 +9,7 @@ projects/<project-slug>/current_state.md
9
9
  projects/<project-slug>/open_threads.md
10
10
  ```
11
11
 
12
- It does not dump event logs into the model prompt, does not rewrite append-only events, and does not run during normal MCP wake-up. When a project is over the threshold, Dream builds a capped thread evidence pack by searching cue-indexed project events for `open_threads.md` bullets. The evidence budget follows `--target-chars`, so the same size target controls both the desired output and the supporting context.
12
+ It does not dump event logs into the model prompt, does not rewrite append-only events, and does not run during normal MCP wake-up. When a project is over the threshold, Dream builds a capped thread evidence pack by searching keyword-indexed project events for `open_threads.md` bullets. The evidence budget follows `--target-chars`, so the same size target controls both the desired output and the supporting context.
13
13
 
14
14
  Dream uses an Eve-style compaction loop. It rewrites the complete curated snapshot, measures the result, and compacts that coherent result again when it is still over the target. It never crops a wake-up file. After three passes, it fails without writing either file if the snapshot still does not fit.
15
15
 
@@ -67,18 +67,18 @@ Typical agent flow:
67
67
  1. Call `wake_up` at the start of a top-level task.
68
68
  2. Read the returned global and project memory.
69
69
  3. Use `search_memory` for task-specific recall after wake-up.
70
- 4. Use `append_event` for meaningful milestones, with a title and 1-8 concise cues.
70
+ 4. Use `append_event` for meaningful milestones, with a title and 1-8 concise keywords.
71
71
  5. Update curated files like `current_state.md` and `open_threads.md` before finishing.
72
72
 
73
73
  Writes sync by default. If sync fails or is skipped, call `sync_memory`.
74
74
 
75
- Event writes update a sibling `events/YYYY-MM-DD.index.json` file. The Markdown event remains the canonical memory; the sidecar is rebuildable. Search ranks indexed events and curated files with BM25 and typo tolerance ([MiniSearch](https://github.com/lucaong/minisearch)), weighting cues above titles and titles above bodies. It returns small coherent evidence blocks from indexed events. If no indexed result is strong enough, it returns no event result instead of scanning whole event logs.
75
+ Event writes update a sibling `events/YYYY-MM-DD.index.json` file. The Markdown event remains the canonical memory; the sidecar is rebuildable. Search ranks indexed events and curated files with BM25 and typo tolerance ([MiniSearch](https://github.com/lucaong/minisearch)), weighting keywords above titles and titles above bodies. It returns small coherent evidence blocks from indexed events; an event found only by its keywords or title returns its first paragraphs. If no indexed result is strong enough, it returns no event result instead of scanning whole event logs.
76
76
 
77
77
  ## Memory Rules
78
78
 
79
79
  - Keep memory concise.
80
80
  - Prefer curated summaries over raw event history.
81
- - Give events a short `Cues:` section; cues weigh the most in search ranking.
81
+ - Give events a short `Keywords:` section; keywords weigh the most in search ranking. Older events with a `Cues:` section still search the same way.
82
82
  - Do not duplicate GitHub-owned facts such as commits, PRs, issues, reviews, or CI results.
83
83
  - Store artifact references plus the missing rationale, preference, assumption, or follow-up context.
84
84
  - Use project scope for project-specific state.
@@ -92,7 +92,7 @@ Example event content:
92
92
  Agent: claude
93
93
  Session: mcp-a1b2c3d4
94
94
 
95
- Cues:
95
+ Keywords:
96
96
  - local-first
97
97
  - token-free
98
98
  - install-story
package/docs/setup.md CHANGED
@@ -57,6 +57,7 @@ Environment variables are optional for local use:
57
57
  - `HIPPOCAMP_GLOBAL_ROOT`: local Lagoon clone path. Default: `~/.lagoon`
58
58
  - `HIPPOCAMP_PROJECT_ROOT`: project root used to infer the current project slug. Default: current working directory
59
59
  - Git worktrees, including Conductor workspaces, resolve to the main repository name so every workspace shares the same project memory.
60
+ - An empty folder (nothing but `.git`, like a fresh agent session folder) has no project: project memory falls back to global memory, so these sessions start clean and stay searchable from everywhere.
60
61
  - `MANIFEST_BASE_URL`: OpenAI-compatible base URL used by `hippocamp dream --write`
61
62
  - `MANIFEST_API_KEY`: API key used by `hippocamp dream --write`
62
63
  - `HIPPOCAMP_DREAM_MODEL`: model used by Dream. Default: `auto`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -61,8 +61,9 @@ async function run(options) {
61
61
  }
62
62
 
63
63
  const { cases } = JSON.parse(await fs.readFile(options.cases, "utf8"));
64
- // searchMemory derives the project slug from the folder name, so a bare temp
65
- // folder per slug reads that project's memory without touching any real repo.
64
+ // searchMemory derives the project slug from the folder name, so a temp path per
65
+ // slug reads that project's memory without touching any real repo. The folder is
66
+ // not created: an existing empty folder has no project and reads global memory.
66
67
  const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-lagoon-"));
67
68
  const answerable = { count: 0, hit: 0, mrr: 0, chars: 0, topScores: [] };
68
69
  const empty = { count: 0, correct: 0, topScores: [] };
@@ -74,7 +75,6 @@ async function run(options) {
74
75
  }
75
76
 
76
77
  const projectRoot = path.join(sandboxRoot, item.project);
77
- await fs.mkdir(projectRoot, { recursive: true });
78
78
 
79
79
  const search = await memory.searchMemory({
80
80
  query: item.query,
@@ -272,7 +272,7 @@ async function buildThreadEvidence(report, { budgetChars }) {
272
272
  const item = {
273
273
  path: match.path,
274
274
  heading: match.heading,
275
- cues: match.cues || [],
275
+ keywords: match.keywords || [],
276
276
  score: match.score,
277
277
  snippet: match.snippet,
278
278
  };
@@ -348,7 +348,7 @@ function createPrompt({ report, targetChars, threadEvidence }) {
348
348
  report.files["open_threads.md"].content.trim() || "# Open Threads",
349
349
  "",
350
350
  "## Thread Evidence",
351
- "Evidence is retrieved from cue-indexed project events. It is intentionally small and may be incomplete.",
351
+ "Evidence is retrieved from keyword-indexed project events. It is intentionally small and may be incomplete.",
352
352
  `Evidence budget: ${threadEvidence.evidenceChars}/${threadEvidence.evidenceBudgetChars} chars`,
353
353
  JSON.stringify(threadEvidence.items, null, 2),
354
354
  ].join("\n"),
@@ -114,23 +114,23 @@ async function main() {
114
114
  "append_event",
115
115
  {
116
116
  description:
117
- "Append a dated event entry under events/YYYY-MM-DD.md and update the sibling cue index. Requires a title and 1-8 short cues for recall. Automatically stamps Agent/Session provenance when available. Store references to GitHub artifacts instead of copying their content.",
117
+ "Append a dated event entry under events/YYYY-MM-DD.md and update the sibling keyword index. Requires a title and 1-8 short keywords for recall. Automatically stamps Agent/Session provenance when available. Store references to GitHub artifacts instead of copying their content.",
118
118
  inputSchema: {
119
119
  scope: z.enum(["global", "project"]).default("project"),
120
120
  title: z.string().trim().min(1),
121
- cues: z.array(z.string().trim().min(1)).min(1).max(8),
121
+ keywords: z.array(z.string().trim().min(1)).min(1).max(8),
122
122
  content: z.string(),
123
123
  sync: z.boolean().optional(),
124
124
  projectRoot: projectRootSchema,
125
125
  },
126
126
  },
127
- async ({ scope, title, cues, content, sync, projectRoot }) => {
127
+ async ({ scope, title, keywords, content, sync, projectRoot }) => {
128
128
  const clientVersion = server.server?.getClientVersion?.();
129
129
  return toTextResult(
130
130
  await memory.appendEvent({
131
131
  scope,
132
132
  title,
133
- cues,
133
+ keywords,
134
134
  content,
135
135
  projectRoot,
136
136
  sync,
@@ -162,7 +162,7 @@ async function main() {
162
162
  "search_memory",
163
163
  {
164
164
  description:
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.",
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 keyword 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"),
@@ -24,7 +24,7 @@ const DEFAULT_PROJECT_FILES = [
24
24
 
25
25
  const EVENT_INDEX_VERSION = 1;
26
26
  const SEARCH_CANDIDATE_LIMIT = 60;
27
- const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
27
+ const SEARCH_FIELD_BOOST = { keywords: 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;
@@ -148,10 +148,20 @@ function slugifyProjectName(value) {
148
148
  return slug || "project";
149
149
  }
150
150
 
151
+ // An existing folder with nothing but .git (a fresh session or scratch folder) is not a project.
152
+ function isEmptyFolder(folder) {
153
+ try {
154
+ return fsSync.readdirSync(folder).every((name) => name === ".git");
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // Null when the folder is empty: project memory then falls back to global memory.
151
161
  function getProjectSlug(projectRoot) {
152
162
  const resolvedProjectRoot = getProjectRoot(projectRoot);
153
- const repositoryRoot = findRepositoryRootSync(resolvedProjectRoot);
154
- return slugifyProjectName(path.basename(repositoryRoot || resolvedProjectRoot));
163
+ const folder = findRepositoryRootSync(resolvedProjectRoot) || resolvedProjectRoot;
164
+ return isEmptyFolder(folder) ? null : slugifyProjectName(path.basename(folder));
155
165
  }
156
166
 
157
167
  function getScopeRoot(scope, projectRoot) {
@@ -160,7 +170,8 @@ function getScopeRoot(scope, projectRoot) {
160
170
  }
161
171
 
162
172
  if (scope === "project") {
163
- return path.join(getGlobalRoot(), "projects", getProjectSlug(projectRoot));
173
+ const slug = getProjectSlug(projectRoot);
174
+ return slug ? path.join(getGlobalRoot(), "projects", slug) : getGlobalRoot();
164
175
  }
165
176
 
166
177
  throw new Error(`Unsupported scope: ${scope}`);
@@ -259,34 +270,34 @@ function tokenizeSearchText(value) {
259
270
  return normalized ? normalized.split(/\s+/).filter(Boolean) : [];
260
271
  }
261
272
 
262
- function normalizeCue(value) {
273
+ function normalizeKeyword(value) {
263
274
  return tokenizeSearchText(value).join("-");
264
275
  }
265
276
 
266
- function normalizeCueList(cues) {
267
- const values = Array.isArray(cues) ? cues : [];
268
- return [...new Set(values.map(normalizeCue).filter(Boolean))];
277
+ function normalizeKeywordList(keywords) {
278
+ const values = Array.isArray(keywords) ? keywords : [];
279
+ return [...new Set(values.map(normalizeKeyword).filter(Boolean))];
269
280
  }
270
281
 
271
- function splitInlineCueValues(value) {
282
+ function splitInlineKeywordValues(value) {
272
283
  return String(value || "")
273
284
  .split(",")
274
285
  .map((item) => item.trim())
275
286
  .filter(Boolean);
276
287
  }
277
288
 
278
- function hasCuesSection(content) {
279
- return content.split(/\r?\n/).some((line) => /^Cues:\s*/i.test(line.trim()));
289
+ function hasKeywordsSection(content) {
290
+ return content.split(/\r?\n/).some((line) => /^(?:Keywords|Cues):\s*/i.test(line.trim()));
280
291
  }
281
292
 
282
- function formatCuesSection(cues) {
283
- const normalizedCues = normalizeCueList(cues);
293
+ function formatKeywordsSection(keywords) {
294
+ const normalizedKeywords = normalizeKeywordList(keywords);
284
295
 
285
- if (!normalizedCues.length) {
296
+ if (!normalizedKeywords.length) {
286
297
  return "";
287
298
  }
288
299
 
289
- return ["Cues:", ...normalizedCues.map((cue) => `- ${cue}`)].join("\n");
300
+ return ["Keywords:", ...normalizedKeywords.map((keyword) => `- ${keyword}`)].join("\n");
290
301
  }
291
302
 
292
303
  function normalizeAgentName(value) {
@@ -313,7 +324,7 @@ function normalizeAgentName(value) {
313
324
  return AGENT_NAME_ALIASES[firstToken];
314
325
  }
315
326
 
316
- const slug = normalizeCue(compact);
327
+ const slug = normalizeKeyword(compact);
317
328
 
318
329
  return slug || null;
319
330
  }
@@ -397,7 +408,7 @@ function extractAttributionFromEventLines(lines) {
397
408
  continue;
398
409
  }
399
410
 
400
- // Provenance lines sit at the top of the body; stop at cues or free text.
411
+ // Provenance lines sit at the top of the body; stop at keywords or free text.
401
412
  break;
402
413
  }
403
414
 
@@ -654,7 +665,7 @@ function parseEventHeading(line) {
654
665
 
655
666
  if (!match) {
656
667
  return {
657
- id: normalizeCue(heading) || heading,
668
+ id: normalizeKeyword(heading) || heading,
658
669
  heading,
659
670
  };
660
671
  }
@@ -665,22 +676,22 @@ function parseEventHeading(line) {
665
676
  };
666
677
  }
667
678
 
668
- function extractCuesFromEventLines(lines) {
679
+ function extractKeywordsFromEventLines(lines) {
669
680
  for (let index = 0; index < lines.length; index += 1) {
670
- const match = lines[index].trim().match(/^Cues:\s*(.*)$/i);
681
+ const match = lines[index].trim().match(/^(?:Keywords|Cues):\s*(.*)$/i);
671
682
 
672
683
  if (!match) {
673
684
  continue;
674
685
  }
675
686
 
676
- const cues = splitInlineCueValues(match[1]);
687
+ const keywords = splitInlineKeywordValues(match[1]);
677
688
 
678
689
  for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
679
690
  const line = lines[cursor];
680
691
  const bullet = line.match(/^\s*-\s+(.+?)\s*$/);
681
692
 
682
693
  if (bullet) {
683
- cues.push(bullet[1]);
694
+ keywords.push(bullet[1]);
684
695
  continue;
685
696
  }
686
697
 
@@ -691,7 +702,7 @@ function extractCuesFromEventLines(lines) {
691
702
  break;
692
703
  }
693
704
 
694
- return normalizeCueList(cues);
705
+ return normalizeKeywordList(keywords);
695
706
  }
696
707
 
697
708
  return [];
@@ -719,7 +730,7 @@ function parseEventBlocks(content) {
719
730
  heading: heading.heading,
720
731
  agent: attribution.agent,
721
732
  session: attribution.session,
722
- cues: extractCuesFromEventLines(bodyLines),
733
+ keywords: extractKeywordsFromEventLines(bodyLines),
723
734
  content: blockLines.join("\n").trim(),
724
735
  body: bodyLines.join("\n").trim(),
725
736
  startLine: startIndex + 1,
@@ -735,7 +746,7 @@ function buildEventIndex(markdownContent, markdownPath) {
735
746
  const entry = {
736
747
  id: event.id,
737
748
  heading: event.heading,
738
- cues: event.cues,
749
+ keywords: event.keywords,
739
750
  };
740
751
 
741
752
  if (event.agent) {
@@ -768,7 +779,7 @@ function createQueryInfo(query) {
768
779
  return {
769
780
  original,
770
781
  normalized: tokens.join(" "),
771
- cue: tokens.join("-"),
782
+ keyword: tokens.join("-"),
772
783
  tokens,
773
784
  datePrefixes: queryDatePrefixes(original),
774
785
  };
@@ -847,7 +858,7 @@ function scoreSearchValues(queryInfo, values) {
847
858
  }
848
859
 
849
860
  const phraseScore = normalizedValues.reduce((bestScore, value) => {
850
- if (value === queryInfo.normalized || value.replace(/\s+/g, "-") === queryInfo.cue) {
861
+ if (value === queryInfo.normalized || value.replace(/\s+/g, "-") === queryInfo.keyword) {
851
862
  return Math.max(bestScore, 1);
852
863
  }
853
864
 
@@ -919,7 +930,7 @@ function splitOversizedBlock(block) {
919
930
  return chunks;
920
931
  }
921
932
 
922
- function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
933
+ function createSearchExcerpt(content, queryInfo) {
923
934
  const trimmed = content.trim();
924
935
 
925
936
  if (!trimmed) {
@@ -934,7 +945,7 @@ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
934
945
  .split(/\n\s*\n+/)
935
946
  .map((block) => block.trim())
936
947
  .filter(Boolean)
937
- .filter((block) => !/^(?:Agent:|Session:|Cues:)\s*/i.test(block))
948
+ .filter((block) => !/^(?:Agent:|Session:|Keywords:|Cues:)\s*/i.test(block))
938
949
  .flatMap(splitOversizedBlock);
939
950
 
940
951
  if (!blocks.length) {
@@ -956,8 +967,8 @@ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
956
967
  // Keep the best-matching paragraphs that fit the excerpt budget, in file order.
957
968
  const hits = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE }).map((hit) => hit.id);
958
969
 
959
- // An event chosen by its date may share no words with the query; then lead with its first paragraphs.
960
- for (const id of hits.length || !leadWhenUnmatched ? hits : blocks.map((_, position) => position)) {
970
+ // An event chosen by its date, keywords, or title may share no words with its paragraphs; then lead with the first ones.
971
+ for (const id of hits.length ? hits : blocks.map((_, position) => position)) {
961
972
  const added = blocks[id].length + (picked.length ? 2 : 0);
962
973
 
963
974
  if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
@@ -988,7 +999,7 @@ async function writeMemoryFile({ scope, path: relativePath, content, projectRoot
988
999
  const target = resolveScopedPath(scope, relativePath, projectRoot);
989
1000
 
990
1001
  if (target.path === "events" || target.path.startsWith("events/")) {
991
- throw new Error("events/ is append-only. Use append_event with a title and cues instead.");
1002
+ throw new Error("events/ is append-only. Use append_event with a title and keywords instead.");
992
1003
  }
993
1004
 
994
1005
  const nextContent = ensureTrailingNewline(requireNonEmptyString(content, "content"));
@@ -1055,7 +1066,7 @@ async function withEventFileLock(filePath, action) {
1055
1066
  async function appendEvent({
1056
1067
  scope = "project",
1057
1068
  content,
1058
- cues,
1069
+ keywords,
1059
1070
  projectRoot,
1060
1071
  sync = true,
1061
1072
  title,
@@ -1067,13 +1078,13 @@ async function appendEvent({
1067
1078
  }) {
1068
1079
  const body = requireNonEmptyString(content, "content");
1069
1080
  const attribution = resolveWriteAttribution({ agent, session, clientName });
1070
- const normalizedCues = normalizeCueList(cues);
1071
- const cuedBody =
1072
- normalizedCues.length && !hasCuesSection(body)
1073
- ? `${formatCuesSection(normalizedCues)}\n\n${body.trim()}`
1081
+ const normalizedKeywords = normalizeKeywordList(keywords);
1082
+ const bodyWithKeywords =
1083
+ normalizedKeywords.length && !hasKeywordsSection(body)
1084
+ ? `${formatKeywordsSection(normalizedKeywords)}\n\n${body.trim()}`
1074
1085
  : body.trim();
1075
1086
  const attributionSection = formatAttributionSection(attribution);
1076
- const eventBody = attributionSection ? `${attributionSection}\n\n${cuedBody}` : cuedBody;
1087
+ const eventBody = attributionSection ? `${attributionSection}\n\n${bodyWithKeywords}` : bodyWithKeywords;
1077
1088
  const relativePath = `events/${date}.md`;
1078
1089
  const target = resolveScopedPath(scope, relativePath, projectRoot);
1079
1090
 
@@ -1122,7 +1133,7 @@ async function appendEvent({
1122
1133
  title: title?.trim() || null,
1123
1134
  agent: parsed?.agent || attribution.agent,
1124
1135
  session: parsed?.session || attribution.session,
1125
- cues: parsed?.cues || [],
1136
+ keywords: parsed?.keywords || [],
1126
1137
  indexPath,
1127
1138
  sync: syncResult,
1128
1139
  };
@@ -1203,7 +1214,7 @@ async function collectEventDocuments({ scope, eventFiles }) {
1203
1214
  path: `events/${markdownName}`,
1204
1215
  id: event.id,
1205
1216
  heading: event.heading,
1206
- cues: normalizeCueList(event.cues),
1217
+ keywords: normalizeKeywordList(event.keywords || event.cues),
1207
1218
  body: block.body,
1208
1219
  });
1209
1220
  }
@@ -1281,7 +1292,7 @@ function queryDatePrefixes(query, now = new Date()) {
1281
1292
 
1282
1293
  function buildSearchIndex(documents) {
1283
1294
  const index = new MiniSearch({
1284
- fields: ["cues", "heading", "body", "date"],
1295
+ fields: ["keywords", "heading", "body", "date"],
1285
1296
  idField: "position",
1286
1297
  tokenize: tokenizeSearchText,
1287
1298
  processTerm: (term) => term,
@@ -1290,7 +1301,7 @@ function buildSearchIndex(documents) {
1290
1301
  index.addAll(
1291
1302
  documents.map((document, position) => ({
1292
1303
  position,
1293
- cues: (document.cues || []).join(" "),
1304
+ keywords: (document.keywords || []).join(" "),
1294
1305
  heading: document.heading || document.path,
1295
1306
  body: document.body,
1296
1307
  date: eventDateText(document.id),
@@ -1316,14 +1327,22 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
1316
1327
  .map((hit) => ({
1317
1328
  document: documents[hit.id],
1318
1329
  score: Math.round(hit.score * 100) / 100,
1319
- queryTerms: hit.queryTerms,
1330
+ terms: hit.terms,
1320
1331
  }));
1321
1332
  }
1322
1333
 
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 }) {
1334
+ // Same word in another form: a plural or longer form ("deploy", "deployments") or the same stem
1335
+ // ("creating", "creation"). A typo-tolerant hit on a different word ("instinct", "instance",
1336
+ // "content", "context") is not.
1337
+ function sameWord(left, right) {
1338
+ const stem = (word) => word.replace(/(?:ments?|ings?|ions?|ers?|ed|es|s)$/, "");
1339
+ return tokenSimilarity(left, right) >= 0.9 || (stem(left).length >= 4 && stem(left) === stem(right));
1340
+ }
1341
+
1342
+ // Share of the question's rare-word weight (IDF) that the best of the returned matches contains.
1343
+ // A question whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even
1344
+ // when common words ("backend", "deploy") match, so search can return nothing instead of noise.
1345
+ function rareWordCoverage(queryInfo, candidates, { index, documents }) {
1327
1346
  // Month names only rank (see eventDateText); they say when, not what.
1328
1347
  const words = [
1329
1348
  ...new Set(
@@ -1335,15 +1354,19 @@ function rareWordCoverage(queryInfo, best, { index, documents }) {
1335
1354
  return 1;
1336
1355
  }
1337
1356
 
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);
1357
+ const weights = new Map(
1358
+ words.map((word) => [word, Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5))]),
1359
+ );
1360
+ const total = words.reduce((sum, word) => sum + weights.get(word), 0);
1361
+ const coverage = ({ terms }) =>
1362
+ words.filter((word) => terms.some((term) => sameWord(word, term))).reduce((sum, word) => sum + weights.get(word), 0) /
1363
+ total;
1341
1364
 
1342
- return words.filter((word) => matched.has(word)).reduce((sum, word) => sum + weight(word), 0) / total;
1365
+ return Math.max(0, ...candidates.map(coverage));
1343
1366
  }
1344
1367
 
1345
1368
  function toSearchResult(queryInfo, { document, score }) {
1346
- const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
1369
+ const snippet = createSearchExcerpt(document.body, queryInfo);
1347
1370
 
1348
1371
  if (!snippet) {
1349
1372
  return null;
@@ -1359,7 +1382,7 @@ function toSearchResult(queryInfo, { document, score }) {
1359
1382
  };
1360
1383
  }
1361
1384
 
1362
- const cueScore = scoreSearchValues(queryInfo, document.cues);
1385
+ const keywordScore = scoreSearchValues(queryInfo, document.keywords);
1363
1386
  const headingScore = scoreSearchValues(queryInfo, [document.heading || "", document.id]);
1364
1387
 
1365
1388
  return {
@@ -1367,9 +1390,9 @@ function toSearchResult(queryInfo, { document, score }) {
1367
1390
  path: document.path,
1368
1391
  id: document.id,
1369
1392
  heading: document.heading,
1370
- cues: document.cues,
1393
+ keywords: document.keywords,
1371
1394
  score,
1372
- match: cueScore > 0 && cueScore >= headingScore ? "cues" : headingScore > 0 ? "heading" : "body",
1395
+ match: keywordScore > 0 && keywordScore >= headingScore ? "keywords" : headingScore > 0 ? "heading" : "body",
1373
1396
  snippet,
1374
1397
  };
1375
1398
  }
@@ -1434,7 +1457,11 @@ async function loadSearchIndex(scopes, projectRoot) {
1434
1457
  async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5 }) {
1435
1458
  const queryInfo = createQueryInfo(query);
1436
1459
  const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 5, 20));
1437
- const scopes = scope === "both" ? ["global", "project"] : [scope];
1460
+ const hasProject = getProjectSlug(projectRoot) !== null;
1461
+ // Without a project, project scope is global memory; search it once, as global.
1462
+ const scopes = [
1463
+ ...new Set((scope === "both" ? ["global", "project"] : [scope]).map((name) => (name === "project" && !hasProject ? "global" : name))),
1464
+ ];
1438
1465
  const search = await loadSearchIndex(scopes, projectRoot);
1439
1466
  const results = [];
1440
1467
 
@@ -1442,7 +1469,7 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
1442
1469
  // Dated questions skip the check: their date words describe time, not the topic.
1443
1470
  const related =
1444
1471
  queryInfo.datePrefixes.length > 0 ||
1445
- rareWordCoverage(queryInfo, candidates[0], search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
1472
+ rareWordCoverage(queryInfo, candidates.slice(0, clampedMaxResults), search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
1446
1473
 
1447
1474
  if (related) {
1448
1475
  for (const candidate of candidates) {
@@ -1488,7 +1515,7 @@ async function wakeUp({ projectRoot } = {}) {
1488
1515
  });
1489
1516
  }
1490
1517
 
1491
- for (const relativePath of DEFAULT_PROJECT_FILES) {
1518
+ for (const relativePath of projectSlug ? DEFAULT_PROJECT_FILES : []) {
1492
1519
  const file = await readMemoryFile({ scope: "project", path: relativePath, projectRoot });
1493
1520
 
1494
1521
  if (!file.found) {
@@ -1507,7 +1534,7 @@ async function wakeUp({ projectRoot } = {}) {
1507
1534
  "",
1508
1535
  `Global root: ${globalRoot}`,
1509
1536
  `Project root: ${resolvedProjectRoot}`,
1510
- `Project slug: ${projectSlug}`,
1537
+ `Project slug: ${projectSlug || "none (empty folder; project memory is global memory)"}`,
1511
1538
  `Project memory root: ${projectMemoryRoot}`,
1512
1539
  ];
1513
1540
 
@@ -113,7 +113,7 @@ function buildClaudeManagedBlock() {
113
113
  "- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
114
114
  "- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
115
115
  "- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
116
- "- 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`.",
116
+ "- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
117
117
  "- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
118
118
  "- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
119
119
  "- If the user asks to upgrade Hippocamp for Claude Code, run `npx hippocamp@latest upgrade-claude`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:claude`.",
@@ -121,7 +121,7 @@ function buildCodexManagedBlock() {
121
121
  "- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
122
122
  "- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
123
123
  "- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
124
- "- 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`.",
124
+ "- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
125
125
  "- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
126
126
  "- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
127
127
  "- If the user asks to upgrade Hippocamp for Codex, run `npx hippocamp@latest upgrade-codex`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:codex`.",
@@ -122,7 +122,7 @@ function buildGrokManagedBlock() {
122
122
  "- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
123
123
  "- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
124
124
  "- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
125
- "- 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`.",
125
+ "- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
126
126
  "- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
127
127
  "- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
128
128
  "- If the user asks to upgrade Hippocamp for Grok Build, run `npx hippocamp@latest upgrade-grok`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:grok`.",
@@ -17,7 +17,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
17
17
 
18
18
  - Global memory lives at the root of the local Lagoon clone pointed to by `HIPPOCAMP_GLOBAL_ROOT`.
19
19
  - Project memory lives under `projects/<slug>/` inside that same Lagoon clone.
20
- - The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory.
20
+ - The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory. An empty folder (nothing but `.git`) has no project and uses global memory.
21
21
  - The Hippocamp MCP server exposes the tools that read and write those locations.
22
22
 
23
23
  ## Default Workflow
@@ -28,7 +28,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
28
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.
29
29
  4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
30
30
  5. During work, use `append_event` only for meaningful milestones.
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`.
31
+ 6. When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
32
32
  7. `write_memory_file` and `append_event` sync by default.
33
33
  8. If a sync is skipped or fails, call `sync_memory` explicitly.
34
34
  9. At the end of the task, if project state actually changed, use `write_memory_file` to update curated files such as:
@@ -44,7 +44,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
44
44
  - Prefer curated summaries over raw event history.
45
45
  - Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.
46
46
  - For small tasks, `append_event` is enough; skip curated file rewrites.
47
- - Use event cues as short recall handles; `search_memory` ranks indexed events by cues, title, and body with typo tolerance, and cues weigh the most.
47
+ - Use event keywords as short recall handles; `search_memory` ranks indexed events by keywords, title, and body with typo tolerance, and keywords weigh the most.
48
48
  - Search returns bounded coherent evidence and does not scan unindexed event logs as a fallback.
49
49
  - Keep curated files short and legible.
50
50
  - Rewrite `current_state.md` as a short snapshot of what is true now. Do not prepend dated entries to it; milestone detail belongs in `append_event` only.
@@ -41,26 +41,26 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
41
41
  assert.equal(eventWrite.isError, true);
42
42
  assert.match(resultText(eventWrite), /append-only/);
43
43
 
44
- const noCues = await client.callTool({
44
+ const noKeywords = await client.callTool({
45
45
  name: "append_event",
46
- arguments: { title: "Missing cues", content: "Unsearchable.", sync: false },
46
+ arguments: { title: "Missing keywords", content: "Unsearchable.", sync: false },
47
47
  });
48
- assert.equal(noCues.isError, true);
48
+ assert.equal(noKeywords.isError, true);
49
49
 
50
- const tooManyCues = await client.callTool({
50
+ const tooManyKeywords = await client.callTool({
51
51
  name: "append_event",
52
52
  arguments: {
53
- title: "Too many cues",
54
- cues: Array.from({ length: 9 }, (_, index) => `cue-${index}`),
53
+ title: "Too many keywords",
54
+ keywords: Array.from({ length: 9 }, (_, index) => `keyword-${index}`),
55
55
  content: "Too broad.",
56
56
  sync: false,
57
57
  },
58
58
  });
59
- assert.equal(tooManyCues.isError, true);
59
+ assert.equal(tooManyKeywords.isError, true);
60
60
 
61
61
  const noTitle = await client.callTool({
62
62
  name: "append_event",
63
- arguments: { title: " ", cues: ["untitled"], content: "No heading.", sync: false },
63
+ arguments: { title: " ", keywords: ["untitled"], content: "No heading.", sync: false },
64
64
  });
65
65
  assert.equal(noTitle.isError, true);
66
66
 
@@ -73,8 +73,8 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
73
73
 
74
74
  const valid = await client.callTool({
75
75
  name: "append_event",
76
- arguments: { title: "Valid event", cues: ["valid-cue"], content: "Indexed.", projectRoot, sync: false },
76
+ arguments: { title: "Valid event", keywords: ["valid-keyword"], content: "Indexed.", projectRoot, sync: false },
77
77
  });
78
78
  assert.notEqual(valid.isError, true);
79
- assert.deepEqual(JSON.parse(resultText(valid)).cues, ["valid-cue"]);
79
+ assert.deepEqual(JSON.parse(resultText(valid)).keywords, ["valid-keyword"]);
80
80
  });
@@ -76,7 +76,6 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
76
76
  const projectRoot = path.join(tempRoot, "search-project");
77
77
 
78
78
  process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
79
- await fs.mkdir(projectRoot, { recursive: true });
80
79
  t.after(async () => {
81
80
  if (originalRoot === undefined) {
82
81
  delete process.env.HIPPOCAMP_GLOBAL_ROOT;
@@ -88,7 +87,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
88
87
 
89
88
  await memory.appendEvent({
90
89
  content: "Decision:\nUse indexed search only.",
91
- cues: ["strict-index"],
90
+ keywords: ["strict-index"],
92
91
  date: "2026-09-21",
93
92
  projectRoot,
94
93
  scope: "project",
@@ -109,7 +108,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
109
108
 
110
109
  await memory.appendEvent({
111
110
  content: `Decision:\n${"x".repeat(1300)}`,
112
- cues: ["oversized-metadata"],
111
+ keywords: ["oversized-metadata"],
113
112
  date: "2026-09-21",
114
113
  projectRoot,
115
114
  scope: "project",
@@ -124,7 +123,9 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
124
123
  scope: "project",
125
124
  });
126
125
 
127
- assert.deepEqual(oversized.results, []);
126
+ // A keyword-only match still returns whole paragraphs; the oversized run is left out, never cropped.
127
+ assert.equal(oversized.results[0]?.heading, "Large metadata-only match");
128
+ assert.equal(oversized.results[0].snippet, "Decision:");
128
129
 
129
130
  const projectMemoryRoot = path.join(lagoonRoot, "projects", "search-project");
130
131
  await fs.writeFile(
@@ -145,7 +146,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
145
146
  const eventsRoot = path.join(projectMemoryRoot, "events");
146
147
  await fs.writeFile(
147
148
  path.join(eventsRoot, "2026-09-20.md"),
148
- "# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\nCues:\n- quasar-velvet\n\nShould not be scanned.\n",
149
+ "# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\nKeywords:\n- quasar-velvet\n\nShould not be scanned.\n",
149
150
  "utf8",
150
151
  );
151
152
 
@@ -174,11 +175,11 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
174
175
  await fs.rm(tempRoot, { recursive: true, force: true });
175
176
  });
176
177
 
177
- for (const [title, cues, content] of [
178
+ for (const [title, keywords, content] of [
178
179
  ["Deploy note", ["deploy"], "The zanzibar migration needs a rollback plan."],
179
180
  ["Unrelated note", ["billing"], "Invoices are generated monthly."],
180
181
  ]) {
181
- await memory.appendEvent({ content, cues, projectRoot, scope: "project", sync: false, title });
182
+ await memory.appendEvent({ content, keywords, projectRoot, scope: "project", sync: false, title });
182
183
  }
183
184
 
184
185
  const search = await memory.searchMemory({ projectRoot, query: "zanzibr rollback", scope: "project" });
@@ -188,6 +189,42 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
188
189
  assert.equal(search.results[0].match, "body");
189
190
  });
190
191
 
192
+ test("search still matches events written with legacy Cues sections and indexes", async (t) => {
193
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
194
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-legacy-cues-"));
195
+ const projectRoot = path.join(tempRoot, "legacy-project");
196
+ const eventsRoot = path.join(tempRoot, "lagoon", "projects", "legacy-project", "events");
197
+
198
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
199
+ await fs.mkdir(eventsRoot, { recursive: true });
200
+ t.after(async () => {
201
+ if (originalRoot === undefined) {
202
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
203
+ } else {
204
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
205
+ }
206
+ await fs.rm(tempRoot, { recursive: true, force: true });
207
+ });
208
+
209
+ const id = "2026-09-01T09:00:00.000Z";
210
+ await fs.writeFile(
211
+ path.join(eventsRoot, "2026-09-01.md"),
212
+ `# Events — 2026-09-01\n\n## ${id} — Old note\n\nCues:\n- quasar-velvet\n\nOld body.\n`,
213
+ "utf8",
214
+ );
215
+ await fs.writeFile(
216
+ path.join(eventsRoot, "2026-09-01.index.json"),
217
+ JSON.stringify({ version: 1, path: "2026-09-01.md", events: [{ id, heading: "Old note", cues: ["quasar-velvet"] }] }),
218
+ "utf8",
219
+ );
220
+
221
+ const search = await memory.searchMemory({ projectRoot, query: "quasar velvet", scope: "project" });
222
+
223
+ assert.equal(search.results.length, 1);
224
+ assert.deepEqual(search.results[0].keywords, ["quasar-velvet"]);
225
+ assert.equal(search.results[0].match, "keywords");
226
+ });
227
+
191
228
  test("search returns whole sentences from an oversized matching paragraph", async (t) => {
192
229
  const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
193
230
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-long-paragraph-"));
@@ -207,7 +244,7 @@ test("search returns whole sentences from an oversized matching paragraph", asyn
207
244
  const filler = "The weekly sync covered routine updates. ".repeat(40);
208
245
  await memory.appendEvent({
209
246
  content: `${filler}The kestrel database moved to eu-west-3. ${filler}`,
210
- cues: ["infra"],
247
+ keywords: ["infra"],
211
248
  projectRoot,
212
249
  scope: "project",
213
250
  sync: false,
@@ -246,7 +283,7 @@ test("search returns every matching paragraph that fits the excerpt budget", asy
246
283
  ];
247
284
  await memory.appendEvent({
248
285
  content: paragraphs.join("\n\n"),
249
- cues: ["infra"],
286
+ keywords: ["infra"],
250
287
  projectRoot,
251
288
  scope: "project",
252
289
  sync: false,
@@ -282,7 +319,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
282
319
  const timestamp = "2026-10-01T09:00:00.000Z";
283
320
  const first = await memory.appendEvent({
284
321
  content: "The zanzibar migration needs a rollback plan.",
285
- cues: ["deploy"],
322
+ keywords: ["deploy"],
286
323
  projectRoot,
287
324
  scope: "project",
288
325
  sync: false,
@@ -291,7 +328,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
291
328
  });
292
329
  const second = await memory.appendEvent({
293
330
  content: "Invoices are generated monthly.",
294
- cues: ["billing"],
331
+ keywords: ["billing"],
295
332
  projectRoot,
296
333
  scope: "project",
297
334
  sync: false,
@@ -329,7 +366,7 @@ test("parallel appends keep every event", async (t) => {
329
366
  Array.from({ length: 20 }, (_, index) =>
330
367
  memory.appendEvent({
331
368
  content: `Parallel event ${index}.`,
332
- cues: ["parallel"],
369
+ keywords: ["parallel"],
333
370
  date: "2026-10-01",
334
371
  projectRoot,
335
372
  scope: "project",
@@ -366,7 +403,7 @@ test("search favors events from a month named in the query", async (t) => {
366
403
  for (const timestamp of ["2026-05-02T08:00:00.000Z", "2026-08-05T10:00:00.000Z", "2026-09-01T09:00:00.000Z"]) {
367
404
  await memory.appendEvent({
368
405
  content: "Published the Android build to Google Play production.",
369
- cues: ["android-release"],
406
+ keywords: ["android-release"],
370
407
  date: timestamp.slice(0, 10),
371
408
  projectRoot,
372
409
  scope: "project",
@@ -407,7 +444,7 @@ test("search favors events from a relative date named in the query", async (t) =
407
444
  await memory.appendEvent({
408
445
  // The old event matches the words better, so only the date words can rank the others first.
409
446
  content: key === "older" ? "Deploy key rotation: rotated the deploy key again." : "Rotated the key for staging.",
410
- cues: ["deploy-key"],
447
+ keywords: ["deploy-key"],
411
448
  date: timestamp.slice(0, 10),
412
449
  projectRoot,
413
450
  scope: "project",
@@ -448,7 +485,7 @@ test("search returns a snippet for a long event matched only by its date", async
448
485
 
449
486
  await memory.appendEvent({
450
487
  content: Array(30).fill("The weekly sync covered routine updates.").join("\n\n"),
451
- cues: ["sync"],
488
+ keywords: ["sync"],
452
489
  date: "2026-08-05",
453
490
  projectRoot,
454
491
  scope: "project",
@@ -485,7 +522,7 @@ test("search returns nothing when no memory is about the question", async (t) =>
485
522
  ["Deploy rollback", "A failed backend deploy rolls back to the previous revision."],
486
523
  ["Billing note", "Invoices are generated monthly."],
487
524
  ]) {
488
- await memory.appendEvent({ content, cues: ["ops"], projectRoot, scope: "project", sync: false, title });
525
+ await memory.appendEvent({ content, keywords: ["ops"], projectRoot, scope: "project", sync: false, title });
489
526
  }
490
527
 
491
528
  const unrelated = await memory.searchMemory({ projectRoot, query: "which kubernetes namespace does the backend deploy to", scope: "project" });
@@ -498,3 +535,130 @@ test("search returns nothing when no memory is about the question", async (t) =>
498
535
  // No event is from August; the month name must not count as a missing topic word.
499
536
  assert.ok(monthOnly.results.length > 0);
500
537
  });
538
+
539
+ test("search keeps an event matched only by a keyword its long body never repeats", async (t) => {
540
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
541
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-keyword-only-"));
542
+ const projectRoot = path.join(tempRoot, "keyword-only-project");
543
+
544
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
545
+ await fs.mkdir(projectRoot, { recursive: true });
546
+ t.after(async () => {
547
+ if (originalRoot === undefined) {
548
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
549
+ } else {
550
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
551
+ }
552
+ await fs.rm(tempRoot, { recursive: true, force: true });
553
+ });
554
+
555
+ const content = [
556
+ "Opened three hardening PRs after the security audit.",
557
+ "The backend PR rotates the cron secret and checks request signatures on every scheduled job. ".repeat(8),
558
+ "The website PR updates the content studio dependencies and removes an unused preview route. ".repeat(8),
559
+ ].join("\n\n");
560
+
561
+ assert.ok(content.length > 1200);
562
+ await memory.appendEvent({
563
+ content,
564
+ keywords: ["security-audit", "strava-oauth"],
565
+ projectRoot,
566
+ scope: "project",
567
+ sync: false,
568
+ title: "Security audit opened hardening PRs",
569
+ });
570
+
571
+ const search = await memory.searchMemory({ projectRoot, query: "Strava bug", scope: "project" });
572
+
573
+ assert.equal(search.results[0]?.heading, "Security audit opened hardening PRs");
574
+ assert.match(search.results[0].snippet, /^Opened three hardening PRs/);
575
+ });
576
+
577
+ test("search returns nothing when a rare word only resembles memory words", async (t) => {
578
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
579
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-lookalike-"));
580
+ const projectRoot = path.join(tempRoot, "lookalike-project");
581
+
582
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
583
+ await fs.mkdir(projectRoot, { recursive: true });
584
+ t.after(async () => {
585
+ if (originalRoot === undefined) {
586
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
587
+ } else {
588
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
589
+ }
590
+ await fs.rm(tempRoot, { recursive: true, force: true });
591
+ });
592
+
593
+ await memory.appendEvent({
594
+ content: "Each worker instance reads the instruct prompt and its content before a sync.",
595
+ keywords: ["health-sync"],
596
+ projectRoot,
597
+ scope: "project",
598
+ sync: false,
599
+ title: "Apple Health sync banner hardening",
600
+ });
601
+
602
+ // "instinct" is one typo away from "instance" and "instruct" but means something else.
603
+ const lookalike = await memory.searchMemory({ projectRoot, query: "Instinct", scope: "project" });
604
+ const shared = await memory.searchMemory({ projectRoot, query: "context", scope: "project" });
605
+ const plural = await memory.searchMemory({ projectRoot, query: "worker instances", scope: "project" });
606
+
607
+ assert.deepEqual(lookalike.results, []);
608
+ assert.deepEqual(shared.results, []);
609
+ assert.equal(plural.results[0]?.heading, "Apple Health sync banner hardening");
610
+ });
611
+
612
+ test("an empty folder has no project and uses global memory", async (t) => {
613
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
614
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-empty-folder-"));
615
+ const lagoonRoot = path.join(tempRoot, "lagoon");
616
+ const emptyRoot = path.join(tempRoot, "billowy-glove");
617
+ const codeRoot = path.join(tempRoot, "job-search");
618
+
619
+ process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
620
+ // Only .git, like a fresh session folder some agents create.
621
+ await fs.mkdir(path.join(emptyRoot, ".git"), { recursive: true });
622
+ await fs.mkdir(codeRoot, { recursive: true });
623
+ await fs.writeFile(path.join(codeRoot, "notes.md"), "Job search notes.\n", "utf8");
624
+ t.after(async () => {
625
+ if (originalRoot === undefined) {
626
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
627
+ } else {
628
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
629
+ }
630
+ await fs.rm(tempRoot, { recursive: true, force: true });
631
+ });
632
+
633
+ const wake = await memory.wakeUp({ projectRoot: emptyRoot });
634
+ const event = await memory.appendEvent({
635
+ content: "Created the applying-to-jobs skill.",
636
+ keywords: ["applying-to-jobs"],
637
+ projectRoot: emptyRoot,
638
+ scope: "project",
639
+ sync: false,
640
+ title: "Job application skill created",
641
+ });
642
+ await memory.appendEvent({
643
+ content: "Created the applying-to-jobs tracker in Notion.",
644
+ keywords: ["applying-to-jobs-tracker"],
645
+ projectRoot: codeRoot,
646
+ scope: "project",
647
+ sync: false,
648
+ title: "Job tracker created",
649
+ });
650
+ const fromEmpty = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs skill" });
651
+ const projectScope = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs", scope: "project" });
652
+ const fromProject = await memory.searchMemory({ projectRoot: codeRoot, query: "applying to jobs skill" });
653
+
654
+ assert.equal(wake.projectSlug, null);
655
+ assert.equal(wake.projectMemoryRoot, lagoonRoot);
656
+ assert.equal(path.dirname(path.dirname(event.indexPath)), lagoonRoot);
657
+ // Global memory is searched once, and other projects stay out of it.
658
+ assert.deepEqual(fromEmpty.results.map((result) => result.heading), ["Job application skill created"]);
659
+ assert.deepEqual(projectScope.results.map((result) => result.heading), ["Job application skill created"]);
660
+ assert.equal(fromProject.results[0]?.heading, "Job application skill created");
661
+ assert.equal(memory.getProjectSlug(codeRoot), "job-search");
662
+ // A folder that does not exist keeps its name, as Dream's synthetic roots do.
663
+ assert.equal(memory.getProjectSlug(path.join(tempRoot, "missing")), "missing");
664
+ });
@@ -29,7 +29,6 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
29
29
  });
30
30
 
31
31
  await fs.mkdir(realLagoon);
32
- await fs.mkdir(projectRoot);
33
32
  await fs.symlink(realLagoon, linkedLagoon);
34
33
  git(["init", "-q", "-b", "main"]);
35
34
  git(["commit", "-q", "--allow-empty", "-m", "init"]);
@@ -45,7 +44,7 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
45
44
 
46
45
  const result = await memory.appendEvent({
47
46
  content: "Use advisory locks.",
48
- cues: ["locks"],
47
+ keywords: ["locks"],
49
48
  projectRoot,
50
49
  scope: "project",
51
50
  title: "Queue decision",
@@ -56,12 +55,11 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
56
55
 
57
56
  // A symlink inside the Lagoon pointing at another project must not be synced as this one.
58
57
  const otherProject = path.join(tempRoot, "other-project");
59
- await fs.mkdir(otherProject);
60
58
  await fs.mkdir(path.join(realLagoon, "projects", "other-project"), { recursive: true });
61
59
  await fs.symlink(path.join(realLagoon, "projects", "linked-project", "events"), path.join(realLagoon, "projects", "other-project", "events"));
62
60
 
63
61
  await assert.rejects(
64
- memory.appendEvent({ content: "Cross write.", cues: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
62
+ memory.appendEvent({ content: "Cross write.", keywords: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
65
63
  /escapes the memory root/,
66
64
  );
67
65
  });