hippocamp 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -2
- package/README.md +85 -49
- package/package.json +2 -1
- package/scripts/eval-qa.cjs +371 -0
- package/scripts/hippocamp-mcp.cjs +1 -1
- package/scripts/hippocamp-memory.cjs +112 -11
- package/scripts/hippocamp.cjs +47 -1
- package/scripts/install-claude.cjs +7 -5
- package/scripts/install-codex.cjs +7 -5
- package/scripts/install-common.cjs +91 -0
- package/scripts/install-grok.cjs +7 -5
- package/skills/hippocamp-memory/SKILL.md +1 -0
- package/test/install-common.test.cjs +112 -0
- package/test/memory-efficiency.test.cjs +152 -0
- package/test/sync-memory.test.cjs +67 -0
|
@@ -28,6 +28,11 @@ const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
|
|
|
28
28
|
const SEARCH_FUZZY_DISTANCE = 0.2;
|
|
29
29
|
const SEARCH_INDEX_CACHE_LIMIT = 8;
|
|
30
30
|
const SEARCH_EXCERPT_MAX_CHARS = 1200;
|
|
31
|
+
// Below this share of a question's rare-word weight, the best match is about something else.
|
|
32
|
+
const SEARCH_MIN_RARE_WORD_COVERAGE = 0.3;
|
|
33
|
+
const QUESTION_STOPWORDS = new Set(
|
|
34
|
+
"the and for with does did our are was were what which when where why how who can this that from have has had should would could there their them they you your use used about into after before over then than any all".split(" "),
|
|
35
|
+
);
|
|
31
36
|
const EVENT_LOCK_RETRY_MS = 10;
|
|
32
37
|
const EVENT_LOCK_STALE_MS = 10_000;
|
|
33
38
|
const searchIndexCache = new Map();
|
|
@@ -434,6 +439,17 @@ async function getGitRepoRoot(startPath) {
|
|
|
434
439
|
return result.stdout;
|
|
435
440
|
}
|
|
436
441
|
|
|
442
|
+
// Resolve symlinks in a path whose last parts may not exist yet, so it compares with
|
|
443
|
+
// Git's top level, which Git always reports resolved (macOS /tmp is /private/tmp).
|
|
444
|
+
function realpathExisting(targetPath) {
|
|
445
|
+
try {
|
|
446
|
+
return fsSync.realpathSync(targetPath);
|
|
447
|
+
} catch {
|
|
448
|
+
const parent = path.dirname(targetPath);
|
|
449
|
+
return parent === targetPath ? targetPath : path.join(realpathExisting(parent), path.basename(targetPath));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
437
453
|
async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
438
454
|
const repoCandidate = getGlobalRepoRoot();
|
|
439
455
|
const repoRoot = await getGitRepoRoot(repoCandidate);
|
|
@@ -450,7 +466,14 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
|
450
466
|
|
|
451
467
|
const repoRelativePaths = paths.map((item) => {
|
|
452
468
|
const relativePath =
|
|
453
|
-
path
|
|
469
|
+
path
|
|
470
|
+
.relative(
|
|
471
|
+
realpathExisting(repoRoot),
|
|
472
|
+
// Recheck after resolving: a symlink inside the Lagoon must not reach another scope.
|
|
473
|
+
assertPathInScope(realpathExisting(scopeRoot), realpathExisting(assertPathInScope(scopeRoot, item))),
|
|
474
|
+
)
|
|
475
|
+
.split(path.sep)
|
|
476
|
+
.join(path.posix.sep) || ".";
|
|
454
477
|
|
|
455
478
|
if (relativePath === ".") {
|
|
456
479
|
throw new Error("path must point to a memory file or directory inside the Lagoon root.");
|
|
@@ -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
|
-
|
|
934
|
-
|
|
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(
|
|
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
|
-
|
|
1347
|
-
|
|
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) {
|
package/scripts/hippocamp.cjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
4
5
|
const path = require("node:path");
|
|
5
6
|
|
|
7
|
+
// One installer per agent CLI; `install` runs the ones whose CLI is on PATH.
|
|
8
|
+
const AGENTS = [
|
|
9
|
+
["claude", "install-claude.cjs"],
|
|
10
|
+
["codex", "install-codex.cjs"],
|
|
11
|
+
["grok", "install-grok.cjs"],
|
|
12
|
+
];
|
|
13
|
+
|
|
6
14
|
const COMMANDS = {
|
|
7
15
|
dream: "hippocamp-dream.cjs",
|
|
8
16
|
mcp: "hippocamp-mcp.cjs",
|
|
@@ -18,6 +26,8 @@ function printHelp() {
|
|
|
18
26
|
console.log(`Hippocamp
|
|
19
27
|
|
|
20
28
|
Usage:
|
|
29
|
+
hippocamp install
|
|
30
|
+
hippocamp upgrade
|
|
21
31
|
hippocamp dream
|
|
22
32
|
hippocamp mcp
|
|
23
33
|
hippocamp install-codex
|
|
@@ -28,6 +38,8 @@ Usage:
|
|
|
28
38
|
hippocamp upgrade-grok
|
|
29
39
|
|
|
30
40
|
Commands:
|
|
41
|
+
install Install into every agent found: Claude Code, Codex, Grok Build
|
|
42
|
+
upgrade Same as install; run with npx hippocamp@latest
|
|
31
43
|
dream Run offline Dream memory compaction
|
|
32
44
|
mcp Run the local MCP server
|
|
33
45
|
install-codex Install the Hippocamp skill and MCP server into Codex
|
|
@@ -39,6 +51,35 @@ Commands:
|
|
|
39
51
|
`);
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
// Platform-neutral PATH lookup, so `install` also finds agents on Windows.
|
|
55
|
+
function onPath(cli) {
|
|
56
|
+
const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD").split(";") : [""];
|
|
57
|
+
|
|
58
|
+
return String(process.env.PATH || "")
|
|
59
|
+
.split(path.delimiter)
|
|
60
|
+
.some((dir) => extensions.some((extension) => fs.existsSync(path.join(dir, cli + extension.toLowerCase())) || fs.existsSync(path.join(dir, cli + extension))));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function installAll(args) {
|
|
64
|
+
const found = AGENTS.filter(([cli]) => onPath(cli));
|
|
65
|
+
|
|
66
|
+
if (!found.length) {
|
|
67
|
+
console.error("No supported agent CLI found on PATH: claude, codex, or grok. Install one, then rerun.");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Install every agent found, even when one fails, then report the failures.
|
|
72
|
+
const failed = found.filter(([cli, script]) => {
|
|
73
|
+
console.error(`Installing Hippocamp into ${cli}...`);
|
|
74
|
+
return spawnSync(process.execPath, [path.join(__dirname, script), ...args], { stdio: "inherit" }).status !== 0;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (failed.length) {
|
|
78
|
+
console.error(`Install failed for: ${failed.map(([cli]) => cli).join(", ")}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
function main() {
|
|
43
84
|
const [command, ...args] = process.argv.slice(2);
|
|
44
85
|
|
|
@@ -47,6 +88,11 @@ function main() {
|
|
|
47
88
|
return;
|
|
48
89
|
}
|
|
49
90
|
|
|
91
|
+
if (command === "install" || command === "upgrade") {
|
|
92
|
+
installAll(args);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
const script = COMMANDS[command];
|
|
51
97
|
|
|
52
98
|
if (!script) {
|
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
|
|
@@ -204,7 +205,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
const
|
|
208
|
+
const command = mcpServerCommand(repoRoot);
|
|
208
209
|
const added = await runClaude([
|
|
209
210
|
"mcp",
|
|
210
211
|
"add",
|
|
@@ -218,8 +219,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
218
219
|
"--env",
|
|
219
220
|
"HIPPOCAMP_AGENT=claude",
|
|
220
221
|
"--",
|
|
221
|
-
|
|
222
|
-
scriptPath,
|
|
222
|
+
...command,
|
|
223
223
|
]);
|
|
224
224
|
|
|
225
225
|
if (!added.ok) {
|
|
@@ -228,8 +228,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
228
228
|
|
|
229
229
|
return {
|
|
230
230
|
name: serverName,
|
|
231
|
-
command:
|
|
232
|
-
scriptPath,
|
|
231
|
+
command: command.join(" "),
|
|
233
232
|
globalRoot,
|
|
234
233
|
summary: added.stdout || added.stderr,
|
|
235
234
|
};
|
|
@@ -249,6 +248,7 @@ async function main() {
|
|
|
249
248
|
repoRoot,
|
|
250
249
|
});
|
|
251
250
|
|
|
251
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
252
252
|
const server = await ensureServerInstalled({
|
|
253
253
|
repoRoot,
|
|
254
254
|
globalRoot: options.globalRoot,
|
|
@@ -263,12 +263,14 @@ async function main() {
|
|
|
263
263
|
claudeMdPath,
|
|
264
264
|
claudeSkillPath,
|
|
265
265
|
server,
|
|
266
|
+
lagoon,
|
|
266
267
|
note: "Restart Claude Code to load the installed MCP server, Hippocamp skill, and refreshed CLAUDE.md instructions.",
|
|
267
268
|
},
|
|
268
269
|
null,
|
|
269
270
|
2,
|
|
270
271
|
),
|
|
271
272
|
);
|
|
273
|
+
printHints(lagoon);
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
main().catch((error) => {
|
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const CODEX_MANAGED_START = "<!-- hippocamp:managed:start -->";
|
|
@@ -174,7 +175,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
const
|
|
178
|
+
const command = mcpServerCommand(repoRoot);
|
|
178
179
|
const added = await runCodex([
|
|
179
180
|
"mcp",
|
|
180
181
|
"add",
|
|
@@ -184,8 +185,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
184
185
|
"--env",
|
|
185
186
|
"HIPPOCAMP_AGENT=codex",
|
|
186
187
|
"--",
|
|
187
|
-
|
|
188
|
-
scriptPath,
|
|
188
|
+
...command,
|
|
189
189
|
]);
|
|
190
190
|
|
|
191
191
|
if (!added.ok) {
|
|
@@ -194,8 +194,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
194
194
|
|
|
195
195
|
return {
|
|
196
196
|
name: serverName,
|
|
197
|
-
command:
|
|
198
|
-
scriptPath,
|
|
197
|
+
command: command.join(" "),
|
|
199
198
|
globalRoot,
|
|
200
199
|
summary: added.stdout || added.stderr,
|
|
201
200
|
};
|
|
@@ -215,6 +214,7 @@ async function main() {
|
|
|
215
214
|
codexHome: options.codexHome,
|
|
216
215
|
});
|
|
217
216
|
|
|
217
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
218
218
|
const server = await ensureServerInstalled({
|
|
219
219
|
repoRoot,
|
|
220
220
|
globalRoot: options.globalRoot,
|
|
@@ -229,12 +229,14 @@ async function main() {
|
|
|
229
229
|
skillPath,
|
|
230
230
|
codexInstructionsPath,
|
|
231
231
|
server,
|
|
232
|
+
lagoon,
|
|
232
233
|
note: "Restart Codex to load the installed skill, MCP server, and refreshed AGENTS.md instructions.",
|
|
233
234
|
},
|
|
234
235
|
null,
|
|
235
236
|
2,
|
|
236
237
|
),
|
|
237
238
|
);
|
|
239
|
+
printHints(lagoon);
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
main().catch((error) => {
|
|
@@ -0,0 +1,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 };
|
package/scripts/install-grok.cjs
CHANGED
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
|
+
const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const GROK_MANAGED_START = "<!-- hippocamp:managed:start -->";
|
|
@@ -190,7 +191,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
const
|
|
194
|
+
const command = mcpServerCommand(repoRoot);
|
|
194
195
|
const added = await runGrok([
|
|
195
196
|
"mcp",
|
|
196
197
|
"add",
|
|
@@ -200,8 +201,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
200
201
|
"-e",
|
|
201
202
|
"HIPPOCAMP_AGENT=grok",
|
|
202
203
|
"--",
|
|
203
|
-
|
|
204
|
-
scriptPath,
|
|
204
|
+
...command,
|
|
205
205
|
]);
|
|
206
206
|
|
|
207
207
|
if (!added.ok) {
|
|
@@ -210,8 +210,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
|
|
|
210
210
|
|
|
211
211
|
return {
|
|
212
212
|
name: serverName,
|
|
213
|
-
command:
|
|
214
|
-
scriptPath,
|
|
213
|
+
command: command.join(" "),
|
|
215
214
|
globalRoot,
|
|
216
215
|
agent: "grok",
|
|
217
216
|
summary: added.stdout || added.stderr,
|
|
@@ -232,6 +231,7 @@ async function main() {
|
|
|
232
231
|
grokHome: options.grokHome,
|
|
233
232
|
});
|
|
234
233
|
|
|
234
|
+
const lagoon = await prepareLagoon(options.globalRoot);
|
|
235
235
|
const server = await ensureServerInstalled({
|
|
236
236
|
repoRoot,
|
|
237
237
|
globalRoot: options.globalRoot,
|
|
@@ -246,12 +246,14 @@ async function main() {
|
|
|
246
246
|
skillPath,
|
|
247
247
|
grokInstructionsPath,
|
|
248
248
|
server,
|
|
249
|
+
lagoon,
|
|
249
250
|
note: "Restart Grok Build (or start a new session) to load the installed skill, MCP server, and home rules.",
|
|
250
251
|
},
|
|
251
252
|
null,
|
|
252
253
|
2,
|
|
253
254
|
),
|
|
254
255
|
);
|
|
256
|
+
printHints(lagoon);
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
main().catch((error) => {
|
|
@@ -25,6 +25,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
|
|
|
25
25
|
1. At the start of every top-level coding task, call `wake_up` before inspecting files, planning, or editing.
|
|
26
26
|
2. Read the wake-up output before searching.
|
|
27
27
|
3. Use `search_memory` as the normal path for task-specific recall after wake-up.
|
|
28
|
+
Keep the default 5 results for a single fact. Pass `maxResults` 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if the results look incomplete.
|
|
28
29
|
4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
|
|
29
30
|
5. During work, use `append_event` only for meaningful milestones.
|
|
30
31
|
6. When appending events, pass a `title` and 1-8 concise `cues` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
|
|
@@ -0,0 +1,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
|
+
});
|