hippocamp 0.2.5 → 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.
@@ -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
  });