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.
@@ -28,6 +28,11 @@ const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
28
28
  const SEARCH_FUZZY_DISTANCE = 0.2;
29
29
  const SEARCH_INDEX_CACHE_LIMIT = 8;
30
30
  const SEARCH_EXCERPT_MAX_CHARS = 1200;
31
+ // Below this share of a question's rare-word weight, the best match is about something else.
32
+ const SEARCH_MIN_RARE_WORD_COVERAGE = 0.3;
33
+ const QUESTION_STOPWORDS = new Set(
34
+ "the and for with does did our are was were what which when where why how who can this that from have has had should would could there their them they you your use used about into after before over then than any all".split(" "),
35
+ );
31
36
  const EVENT_LOCK_RETRY_MS = 10;
32
37
  const EVENT_LOCK_STALE_MS = 10_000;
33
38
  const searchIndexCache = new Map();
@@ -434,6 +439,17 @@ async function getGitRepoRoot(startPath) {
434
439
  return result.stdout;
435
440
  }
436
441
 
442
+ // Resolve symlinks in a path whose last parts may not exist yet, so it compares with
443
+ // Git's top level, which Git always reports resolved (macOS /tmp is /private/tmp).
444
+ function realpathExisting(targetPath) {
445
+ try {
446
+ return fsSync.realpathSync(targetPath);
447
+ } catch {
448
+ const parent = path.dirname(targetPath);
449
+ return parent === targetPath ? targetPath : path.join(realpathExisting(parent), path.basename(targetPath));
450
+ }
451
+ }
452
+
437
453
  async function syncMemory({ scope, projectRoot, paths, message }) {
438
454
  const repoCandidate = getGlobalRepoRoot();
439
455
  const repoRoot = await getGitRepoRoot(repoCandidate);
@@ -450,7 +466,14 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
450
466
 
451
467
  const repoRelativePaths = paths.map((item) => {
452
468
  const relativePath =
453
- path.relative(repoRoot, assertPathInScope(scopeRoot, item)).split(path.sep).join(path.posix.sep) || ".";
469
+ path
470
+ .relative(
471
+ realpathExisting(repoRoot),
472
+ // Recheck after resolving: a symlink inside the Lagoon must not reach another scope.
473
+ assertPathInScope(realpathExisting(scopeRoot), realpathExisting(assertPathInScope(scopeRoot, item))),
474
+ )
475
+ .split(path.sep)
476
+ .join(path.posix.sep) || ".";
454
477
 
455
478
  if (relativePath === ".") {
456
479
  throw new Error("path must point to a memory file or directory inside the Lagoon root.");
@@ -747,6 +770,7 @@ function createQueryInfo(query) {
747
770
  normalized: tokens.join(" "),
748
771
  cue: tokens.join("-"),
749
772
  tokens,
773
+ datePrefixes: queryDatePrefixes(original),
750
774
  };
751
775
  }
752
776
 
@@ -895,7 +919,7 @@ function splitOversizedBlock(block) {
895
919
  return chunks;
896
920
  }
897
921
 
898
- function createSearchExcerpt(content, queryInfo) {
922
+ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
899
923
  const trimmed = content.trim();
900
924
 
901
925
  if (!trimmed) {
@@ -930,14 +954,17 @@ function createSearchExcerpt(content, queryInfo) {
930
954
  let length = 0;
931
955
 
932
956
  // Keep the best-matching paragraphs that fit the excerpt budget, in file order.
933
- for (const hit of index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE })) {
934
- const added = blocks[hit.id].length + (picked.length ? 2 : 0);
957
+ const hits = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE }).map((hit) => hit.id);
958
+
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)) {
961
+ const added = blocks[id].length + (picked.length ? 2 : 0);
935
962
 
936
963
  if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
937
964
  continue;
938
965
  }
939
966
 
940
- picked.push(hit.id);
967
+ picked.push(id);
941
968
  length += added;
942
969
  }
943
970
 
@@ -1214,9 +1241,47 @@ async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
1214
1241
  };
1215
1242
  }
1216
1243
 
1244
+ const MONTH_NAMES = "january february march april may june july august september october november december".split(" ");
1245
+
1246
+ // Search word for a UTC day ("2026-08-05" -> "d20260805") or month ("2026-08" -> "august").
1247
+ // A day is one exact word, so it never matches a neighbor day by typo tolerance.
1248
+ function dateTerm(prefix) {
1249
+ return prefix.length === 10 ? `d${prefix.replace(/-/g, "")}` : MONTH_NAMES[Number(prefix.slice(5, 7)) - 1];
1250
+ }
1251
+
1252
+ // Month and day words of an event's timestamp id.
1253
+ function eventDateText(id) {
1254
+ return /^\d{4}-\d{2}-\d{2}T/.test(id || "") ? `${dateTerm(id.slice(0, 7))} ${dateTerm(id.slice(0, 10))}` : "";
1255
+ }
1256
+
1257
+ // Dates a query asks for: YYYY-MM-DD dates and relative phrases, as UTC day
1258
+ // ("2026-08-05") or month ("2026-08") prefixes of event ids. Weeks start on Monday.
1259
+ function queryDatePrefixes(query, now = new Date()) {
1260
+ const text = query.toLowerCase();
1261
+ const prefixes = [...text.matchAll(/\b\d{4}-\d{2}-\d{2}\b/g)].map((match) => match[0]);
1262
+ const utc = (months, days) =>
1263
+ new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + months, days)).toISOString();
1264
+ const addDays = (from, to) => {
1265
+ for (let offset = from; offset <= to; offset += 1) {
1266
+ prefixes.push(utc(0, now.getUTCDate() + offset).slice(0, 10));
1267
+ }
1268
+ };
1269
+ const addMonth = (offset) => prefixes.push(utc(offset, 1).slice(0, 7));
1270
+ const monday = -((now.getUTCDay() + 6) % 7);
1271
+
1272
+ if (/\btoday\b/.test(text)) addDays(0, 0);
1273
+ if (/\byesterday\b/.test(text)) addDays(-1, -1);
1274
+ if (/\bthis week\b/.test(text)) addDays(monday, 0);
1275
+ if (/\blast week\b/.test(text)) addDays(monday - 7, monday - 1);
1276
+ if (/\bthis month\b/.test(text)) addMonth(0);
1277
+ if (/\blast month\b/.test(text)) addMonth(-1);
1278
+
1279
+ return prefixes;
1280
+ }
1281
+
1217
1282
  function buildSearchIndex(documents) {
1218
1283
  const index = new MiniSearch({
1219
- fields: ["cues", "heading", "body"],
1284
+ fields: ["cues", "heading", "body", "date"],
1220
1285
  idField: "position",
1221
1286
  tokenize: tokenizeSearchText,
1222
1287
  processTerm: (term) => term,
@@ -1228,6 +1293,7 @@ function buildSearchIndex(documents) {
1228
1293
  cues: (document.cues || []).join(" "),
1229
1294
  heading: document.heading || document.path,
1230
1295
  body: document.body,
1296
+ date: eventDateText(document.id),
1231
1297
  })),
1232
1298
  );
1233
1299
 
@@ -1235,20 +1301,49 @@ function buildSearchIndex(documents) {
1235
1301
  }
1236
1302
 
1237
1303
  function rankSearchDocuments(queryInfo, { index, documents }) {
1304
+ const { datePrefixes } = queryInfo;
1305
+
1306
+ // A date the query asks for filters events to those dates. Undated files always pass.
1238
1307
  return index
1239
- .search(queryInfo.normalized, {
1308
+ .search([queryInfo.normalized, ...datePrefixes.map(dateTerm)].join(" "), {
1240
1309
  boost: SEARCH_FIELD_BOOST,
1241
- fuzzy: SEARCH_FUZZY_DISTANCE,
1310
+ fuzzy: (term) => (/^d\d{8}$/.test(term) ? 0 : SEARCH_FUZZY_DISTANCE),
1311
+ filter: datePrefixes.length
1312
+ ? (hit) => !documents[hit.id].id || datePrefixes.some((prefix) => documents[hit.id].id.startsWith(prefix))
1313
+ : undefined,
1242
1314
  })
1243
1315
  .slice(0, SEARCH_CANDIDATE_LIMIT)
1244
1316
  .map((hit) => ({
1245
1317
  document: documents[hit.id],
1246
1318
  score: Math.round(hit.score * 100) / 100,
1319
+ queryTerms: hit.queryTerms,
1247
1320
  }));
1248
1321
  }
1249
1322
 
1323
+ // Share of the question's rare-word weight (IDF) that the best match contains. A question
1324
+ // whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even when
1325
+ // common words ("backend", "deploy") match, so search can return nothing instead of noise.
1326
+ function rareWordCoverage(queryInfo, best, { index, documents }) {
1327
+ // Month names only rank (see eventDateText); they say when, not what.
1328
+ const words = [
1329
+ ...new Set(
1330
+ queryInfo.tokens.filter((token) => token.length > 2 && !QUESTION_STOPWORDS.has(token) && !MONTH_NAMES.includes(token)),
1331
+ ),
1332
+ ];
1333
+
1334
+ if (!words.length) {
1335
+ return 1;
1336
+ }
1337
+
1338
+ const weight = (word) => Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5));
1339
+ const matched = new Set(best?.queryTerms || []);
1340
+ const total = words.reduce((sum, word) => sum + weight(word), 0);
1341
+
1342
+ return words.filter((word) => matched.has(word)).reduce((sum, word) => sum + weight(word), 0) / total;
1343
+ }
1344
+
1250
1345
  function toSearchResult(queryInfo, { document, score }) {
1251
- const snippet = createSearchExcerpt(document.body, queryInfo);
1346
+ const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
1252
1347
 
1253
1348
  if (!snippet) {
1254
1349
  return null;
@@ -1343,8 +1438,14 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
1343
1438
  const search = await loadSearchIndex(scopes, projectRoot);
1344
1439
  const results = [];
1345
1440
 
1346
- if (queryInfo.tokens.length && search.documents.length) {
1347
- for (const candidate of rankSearchDocuments(queryInfo, search)) {
1441
+ const candidates = queryInfo.tokens.length && search.documents.length ? rankSearchDocuments(queryInfo, search) : [];
1442
+ // Dated questions skip the check: their date words describe time, not the topic.
1443
+ const related =
1444
+ queryInfo.datePrefixes.length > 0 ||
1445
+ rareWordCoverage(queryInfo, candidates[0], search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
1446
+
1447
+ if (related) {
1448
+ for (const candidate of candidates) {
1348
1449
  const result = toSearchResult(queryInfo, candidate);
1349
1450
 
1350
1451
  if (result) {
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const { spawn, spawnSync } = require("node:child_process");
4
5
  const path = require("node:path");
5
6
 
7
+ // One installer per agent CLI; `install` runs the ones whose CLI is on PATH.
8
+ const AGENTS = [
9
+ ["claude", "install-claude.cjs"],
10
+ ["codex", "install-codex.cjs"],
11
+ ["grok", "install-grok.cjs"],
12
+ ];
13
+
6
14
  const COMMANDS = {
7
15
  dream: "hippocamp-dream.cjs",
8
16
  mcp: "hippocamp-mcp.cjs",
@@ -18,6 +26,8 @@ function printHelp() {
18
26
  console.log(`Hippocamp
19
27
 
20
28
  Usage:
29
+ hippocamp install
30
+ hippocamp upgrade
21
31
  hippocamp dream
22
32
  hippocamp mcp
23
33
  hippocamp install-codex
@@ -28,6 +38,8 @@ Usage:
28
38
  hippocamp upgrade-grok
29
39
 
30
40
  Commands:
41
+ install Install into every agent found: Claude Code, Codex, Grok Build
42
+ upgrade Same as install; run with npx hippocamp@latest
31
43
  dream Run offline Dream memory compaction
32
44
  mcp Run the local MCP server
33
45
  install-codex Install the Hippocamp skill and MCP server into Codex
@@ -39,6 +51,35 @@ Commands:
39
51
  `);
40
52
  }
41
53
 
54
+ // Platform-neutral PATH lookup, so `install` also finds agents on Windows.
55
+ function onPath(cli) {
56
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD").split(";") : [""];
57
+
58
+ return String(process.env.PATH || "")
59
+ .split(path.delimiter)
60
+ .some((dir) => extensions.some((extension) => fs.existsSync(path.join(dir, cli + extension.toLowerCase())) || fs.existsSync(path.join(dir, cli + extension))));
61
+ }
62
+
63
+ function installAll(args) {
64
+ const found = AGENTS.filter(([cli]) => onPath(cli));
65
+
66
+ if (!found.length) {
67
+ console.error("No supported agent CLI found on PATH: claude, codex, or grok. Install one, then rerun.");
68
+ process.exit(1);
69
+ }
70
+
71
+ // Install every agent found, even when one fails, then report the failures.
72
+ const failed = found.filter(([cli, script]) => {
73
+ console.error(`Installing Hippocamp into ${cli}...`);
74
+ return spawnSync(process.execPath, [path.join(__dirname, script), ...args], { stdio: "inherit" }).status !== 0;
75
+ });
76
+
77
+ if (failed.length) {
78
+ console.error(`Install failed for: ${failed.map(([cli]) => cli).join(", ")}`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+
42
83
  function main() {
43
84
  const [command, ...args] = process.argv.slice(2);
44
85
 
@@ -47,6 +88,11 @@ function main() {
47
88
  return;
48
89
  }
49
90
 
91
+ if (command === "install" || command === "upgrade") {
92
+ installAll(args);
93
+ return;
94
+ }
95
+
50
96
  const script = COMMANDS[command];
51
97
 
52
98
  if (!script) {
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
 
@@ -204,7 +205,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
204
205
  }
205
206
  }
206
207
 
207
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
208
+ const command = mcpServerCommand(repoRoot);
208
209
  const added = await runClaude([
209
210
  "mcp",
210
211
  "add",
@@ -218,8 +219,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
218
219
  "--env",
219
220
  "HIPPOCAMP_AGENT=claude",
220
221
  "--",
221
- process.execPath,
222
- scriptPath,
222
+ ...command,
223
223
  ]);
224
224
 
225
225
  if (!added.ok) {
@@ -228,8 +228,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
228
228
 
229
229
  return {
230
230
  name: serverName,
231
- command: process.execPath,
232
- scriptPath,
231
+ command: command.join(" "),
233
232
  globalRoot,
234
233
  summary: added.stdout || added.stderr,
235
234
  };
@@ -249,6 +248,7 @@ async function main() {
249
248
  repoRoot,
250
249
  });
251
250
 
251
+ const lagoon = await prepareLagoon(options.globalRoot);
252
252
  const server = await ensureServerInstalled({
253
253
  repoRoot,
254
254
  globalRoot: options.globalRoot,
@@ -263,12 +263,14 @@ async function main() {
263
263
  claudeMdPath,
264
264
  claudeSkillPath,
265
265
  server,
266
+ lagoon,
266
267
  note: "Restart Claude Code to load the installed MCP server, Hippocamp skill, and refreshed CLAUDE.md instructions.",
267
268
  },
268
269
  null,
269
270
  2,
270
271
  ),
271
272
  );
273
+ printHints(lagoon);
272
274
  }
273
275
 
274
276
  main().catch((error) => {
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const CODEX_MANAGED_START = "<!-- hippocamp:managed:start -->";
@@ -174,7 +175,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
174
175
  }
175
176
  }
176
177
 
177
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
178
+ const command = mcpServerCommand(repoRoot);
178
179
  const added = await runCodex([
179
180
  "mcp",
180
181
  "add",
@@ -184,8 +185,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
184
185
  "--env",
185
186
  "HIPPOCAMP_AGENT=codex",
186
187
  "--",
187
- process.execPath,
188
- scriptPath,
188
+ ...command,
189
189
  ]);
190
190
 
191
191
  if (!added.ok) {
@@ -194,8 +194,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
194
194
 
195
195
  return {
196
196
  name: serverName,
197
- command: process.execPath,
198
- scriptPath,
197
+ command: command.join(" "),
199
198
  globalRoot,
200
199
  summary: added.stdout || added.stderr,
201
200
  };
@@ -215,6 +214,7 @@ async function main() {
215
214
  codexHome: options.codexHome,
216
215
  });
217
216
 
217
+ const lagoon = await prepareLagoon(options.globalRoot);
218
218
  const server = await ensureServerInstalled({
219
219
  repoRoot,
220
220
  globalRoot: options.globalRoot,
@@ -229,12 +229,14 @@ async function main() {
229
229
  skillPath,
230
230
  codexInstructionsPath,
231
231
  server,
232
+ lagoon,
232
233
  note: "Restart Codex to load the installed skill, MCP server, and refreshed AGENTS.md instructions.",
233
234
  },
234
235
  null,
235
236
  2,
236
237
  ),
237
238
  );
239
+ printHints(lagoon);
238
240
  }
239
241
 
240
242
  main().catch((error) => {
@@ -0,0 +1,91 @@
1
+ const fsSync = require("node:fs");
2
+ const fs = require("node:fs/promises");
3
+ const path = require("node:path");
4
+ const { execFile } = require("node:child_process");
5
+ const { promisify } = require("node:util");
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ // How an agent starts the MCP server. Run through npx, the package lives in npm's cache,
10
+ // which npm may clean; a pinned npx command downloads it again instead of breaking.
11
+ function mcpServerCommand(repoRoot) {
12
+ if (!repoRoot.split(path.sep).includes("_npx")) {
13
+ return [process.execPath, path.join(repoRoot, "scripts", "hippocamp-mcp.cjs")];
14
+ }
15
+
16
+ const { version } = require(path.join(repoRoot, "package.json"));
17
+ const npx = path.join(path.dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx");
18
+
19
+ return [fsSync.existsSync(npx) ? npx : path.basename(npx), "-y", "--prefer-offline", `hippocamp@${version}`, "mcp"];
20
+ }
21
+
22
+ async function git(args, cwd) {
23
+ try {
24
+ const { stdout } = await execFileAsync("git", args, {
25
+ cwd,
26
+ timeout: 20_000,
27
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
28
+ });
29
+ return { ok: true, stdout: stdout.trim() };
30
+ } catch (error) {
31
+ return { ok: false, stdout: String(error.stdout || "").trim() };
32
+ }
33
+ }
34
+
35
+ // Make sure the Lagoon memory repo exists, and say what is still missing for sync.
36
+ // It never creates a remote or pushes: those reach outside this machine.
37
+ async function prepareLagoon(globalRoot) {
38
+ const hints = [];
39
+ let created = false;
40
+ const existed = fsSync.existsSync(globalRoot);
41
+ const empty = !existed || (await fs.readdir(globalRoot)).length === 0;
42
+
43
+ await fs.mkdir(globalRoot, { recursive: true });
44
+
45
+ // The Lagoon must be its own repo: a folder inside another repo would commit memory there.
46
+ const topLevel = await git(["rev-parse", "--show-toplevel"], globalRoot);
47
+ const ownRepo = topLevel.ok && fsSync.realpathSync(topLevel.stdout) === fsSync.realpathSync(globalRoot);
48
+
49
+ if (!ownRepo) {
50
+ if (!empty) {
51
+ hints.push(`${globalRoot} is not its own Git repo. Point --global-root at your Lagoon clone, or run: git -C "${globalRoot}" init`);
52
+ return { root: globalRoot, created: false, remote: null, push: "not_git", hints };
53
+ }
54
+
55
+ if (!(await git(["init", "-b", "main"], globalRoot)).ok) {
56
+ throw new Error(`git init failed in ${globalRoot}. Check that Git is installed and the folder is writable.`);
57
+ }
58
+
59
+ await git(["commit", "--allow-empty", "-m", "hippocamp: create lagoon"], globalRoot);
60
+ created = true;
61
+ }
62
+
63
+ const identity = await Promise.all(["user.name", "user.email"].map((key) => git(["config", key], globalRoot)));
64
+
65
+ if (identity.some((value) => !value.stdout)) {
66
+ hints.push('Set a Git identity so memory can be committed: git config --global user.name "Your Name" && git config --global user.email you@example.com');
67
+ }
68
+
69
+ const remote = await git(["remote", "get-url", "origin"], globalRoot);
70
+ let push = "no_remote";
71
+
72
+ if (!remote.ok) {
73
+ hints.push(`Memory is local only. For a private backup and other machines: gh repo create lagoon --private --source "${globalRoot}" --remote origin --push`);
74
+ } else if ((await git(["rev-parse", "--verify", "HEAD"], globalRoot)).ok) {
75
+ push = (await git(["push", "--dry-run"], globalRoot)).ok ? "ok" : "failed";
76
+
77
+ if (push === "failed") {
78
+ hints.push(`Git push from ${globalRoot} failed. Set up auth, then check with git push --dry-run: gh auth login && gh auth setup-git`);
79
+ }
80
+ }
81
+
82
+ return { root: globalRoot, created, remote: remote.ok ? remote.stdout : null, push, hints };
83
+ }
84
+
85
+ function printHints(lagoon) {
86
+ for (const hint of lagoon.hints) {
87
+ console.error(`Next: ${hint}`);
88
+ }
89
+ }
90
+
91
+ module.exports = { mcpServerCommand, prepareLagoon, printHints };
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const GROK_MANAGED_START = "<!-- hippocamp:managed:start -->";
@@ -190,7 +191,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
190
191
  }
191
192
  }
192
193
 
193
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
194
+ const command = mcpServerCommand(repoRoot);
194
195
  const added = await runGrok([
195
196
  "mcp",
196
197
  "add",
@@ -200,8 +201,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
200
201
  "-e",
201
202
  "HIPPOCAMP_AGENT=grok",
202
203
  "--",
203
- process.execPath,
204
- scriptPath,
204
+ ...command,
205
205
  ]);
206
206
 
207
207
  if (!added.ok) {
@@ -210,8 +210,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
210
210
 
211
211
  return {
212
212
  name: serverName,
213
- command: process.execPath,
214
- scriptPath,
213
+ command: command.join(" "),
215
214
  globalRoot,
216
215
  agent: "grok",
217
216
  summary: added.stdout || added.stderr,
@@ -232,6 +231,7 @@ async function main() {
232
231
  grokHome: options.grokHome,
233
232
  });
234
233
 
234
+ const lagoon = await prepareLagoon(options.globalRoot);
235
235
  const server = await ensureServerInstalled({
236
236
  repoRoot,
237
237
  globalRoot: options.globalRoot,
@@ -246,12 +246,14 @@ async function main() {
246
246
  skillPath,
247
247
  grokInstructionsPath,
248
248
  server,
249
+ lagoon,
249
250
  note: "Restart Grok Build (or start a new session) to load the installed skill, MCP server, and home rules.",
250
251
  },
251
252
  null,
252
253
  2,
253
254
  ),
254
255
  );
256
+ printHints(lagoon);
255
257
  }
256
258
 
257
259
  main().catch((error) => {
@@ -25,6 +25,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
25
25
  1. At the start of every top-level coding task, call `wake_up` before inspecting files, planning, or editing.
26
26
  2. Read the wake-up output before searching.
27
27
  3. Use `search_memory` as the normal path for task-specific recall after wake-up.
28
+ Keep the default 5 results for a single fact. Pass `maxResults` 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if the results look incomplete.
28
29
  4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
29
30
  5. During work, use `append_event` only for meaningful milestones.
30
31
  6. When appending events, pass a `title` and 1-8 concise `cues` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
@@ -0,0 +1,112 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { execFileSync } = require("node:child_process");
4
+ const fsSync = require("node:fs");
5
+ const fs = require("node:fs/promises");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const { mcpServerCommand, prepareLagoon } = require("../scripts/install-common.cjs");
9
+
10
+ // CI runners have no Git identity; commits in these tests need one.
11
+ const gitEnv = {
12
+ GIT_AUTHOR_NAME: "Test",
13
+ GIT_AUTHOR_EMAIL: "test@example.com",
14
+ GIT_COMMITTER_NAME: "Test",
15
+ GIT_COMMITTER_EMAIL: "test@example.com",
16
+ };
17
+
18
+ // Scoped to each test that needs a Git identity, then restored.
19
+ function useGitIdentity(t) {
20
+ const saved = Object.fromEntries(Object.keys(gitEnv).map((key) => [key, process.env[key]]));
21
+ Object.assign(process.env, gitEnv);
22
+ t.after(() => {
23
+ for (const [key, value] of Object.entries(saved)) {
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ } else {
27
+ process.env[key] = value;
28
+ }
29
+ }
30
+ });
31
+ }
32
+
33
+ async function tempDir(t, name) {
34
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), `hippocamp-${name}-`));
35
+ t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 5 }));
36
+ return root;
37
+ }
38
+
39
+ test("mcpServerCommand pins npx installs and keeps source checkouts direct", async (t) => {
40
+ const root = await tempDir(t, "npx");
41
+ const npxRoot = path.join(root, "_npx", "abc", "node_modules", "hippocamp");
42
+ await fs.mkdir(npxRoot, { recursive: true });
43
+ await fs.writeFile(path.join(npxRoot, "package.json"), '{ "version": "9.9.9" }');
44
+
45
+ assert.deepEqual(mcpServerCommand(npxRoot).slice(1), ["-y", "--prefer-offline", "hippocamp@9.9.9", "mcp"]);
46
+ const source = path.join(root, "src", "hippocamp");
47
+ assert.deepEqual(mcpServerCommand(source), [process.execPath, path.join(source, "scripts", "hippocamp-mcp.cjs")]);
48
+ });
49
+
50
+ test("prepareLagoon creates a missing Lagoon repo and says it is local only", async (t) => {
51
+ useGitIdentity(t);
52
+ const lagoon = path.join(await tempDir(t, "lagoon"), "lagoon");
53
+ const result = await prepareLagoon(lagoon);
54
+
55
+ assert.equal(result.created, true);
56
+ assert.equal(result.push, "no_remote");
57
+ assert.match(result.hints.join("\n"), /gh repo create lagoon --private/);
58
+ assert.equal(execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: lagoon }).toString().trim(), "true");
59
+ });
60
+
61
+ test("prepareLagoon leaves a non-empty folder that is not a repo alone", async (t) => {
62
+ const folder = await tempDir(t, "notes");
63
+ await fs.writeFile(path.join(folder, "notes.md"), "mine\n");
64
+ const result = await prepareLagoon(folder);
65
+
66
+ assert.equal(result.push, "not_git");
67
+ await assert.rejects(fs.stat(path.join(folder, ".git")));
68
+ });
69
+
70
+ test("prepareLagoon checks push to an existing remote", async (t) => {
71
+ useGitIdentity(t);
72
+ const root = await tempDir(t, "remote");
73
+ const remote = path.join(root, "remote.git");
74
+ const lagoon = path.join(root, "lagoon");
75
+ execFileSync("git", ["init", "--bare", "-b", "main", remote]);
76
+ execFileSync("git", ["init", "-b", "main", lagoon]);
77
+ execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: lagoon });
78
+ execFileSync("git", ["remote", "add", "origin", remote], { cwd: lagoon });
79
+ execFileSync("git", ["push", "-u", "origin", "main"], { cwd: lagoon, stdio: "ignore" });
80
+
81
+ const result = await prepareLagoon(lagoon);
82
+
83
+ assert.equal(result.created, false);
84
+ assert.equal(result.remote, remote);
85
+ assert.equal(result.push, "ok");
86
+ });
87
+
88
+ test("prepareLagoon refuses a folder inside another repo", async (t) => {
89
+ useGitIdentity(t);
90
+ const parent = await tempDir(t, "parent");
91
+ execFileSync("git", ["init", "-b", "main", parent]);
92
+ const nested = path.join(parent, "notes");
93
+ await fs.mkdir(nested);
94
+ await fs.writeFile(path.join(nested, "a.md"), "x\n");
95
+
96
+ const result = await prepareLagoon(nested);
97
+
98
+ assert.equal(result.push, "not_git");
99
+ await assert.rejects(fs.stat(path.join(nested, ".git")));
100
+ });
101
+
102
+ test("prepareLagoon gives an empty folder inside another repo its own repo", async (t) => {
103
+ useGitIdentity(t);
104
+ const parent = await tempDir(t, "parent-empty");
105
+ execFileSync("git", ["init", "-b", "main", parent]);
106
+ const nested = path.join(parent, "lagoon");
107
+
108
+ const result = await prepareLagoon(nested);
109
+
110
+ assert.equal(result.created, true);
111
+ assert.equal(fsSync.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: nested }).toString().trim()), fsSync.realpathSync(nested));
112
+ });