hippocamp 0.2.6 → 0.2.8
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/.github/workflows/publish.yml +25 -0
- package/AGENTS.md +5 -5
- package/README.md +15 -3
- package/assets/github-actions/hippocamp-dream.yml +8 -8
- package/assets/railway/run-dream.sh +7 -6
- package/docs/dream.md +12 -4
- package/docs/how-it-works.md +4 -4
- package/docs/setup.md +1 -0
- package/package.json +1 -1
- package/scripts/eval-lagoon.cjs +3 -3
- package/scripts/hippocamp-dream.cjs +125 -28
- package/scripts/hippocamp-mcp.cjs +5 -5
- package/scripts/hippocamp-memory.cjs +130 -59
- package/scripts/install-claude.cjs +1 -1
- package/scripts/install-codex.cjs +1 -1
- package/scripts/install-grok.cjs +1 -1
- package/skills/hippocamp-memory/SKILL.md +4 -3
- package/test/dream.test.cjs +190 -0
- package/test/mcp-params.test.cjs +10 -10
- package/test/memory-efficiency.test.cjs +180 -16
- package/test/sync-memory.test.cjs +2 -4
|
@@ -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 = {
|
|
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
|
|
154
|
-
return slugifyProjectName(path.basename(
|
|
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
|
-
|
|
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
|
|
273
|
+
function normalizeKeyword(value) {
|
|
263
274
|
return tokenizeSearchText(value).join("-");
|
|
264
275
|
}
|
|
265
276
|
|
|
266
|
-
function
|
|
267
|
-
const values = Array.isArray(
|
|
268
|
-
return [...new Set(values.map(
|
|
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
|
|
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
|
|
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
|
|
283
|
-
const
|
|
293
|
+
function formatKeywordsSection(keywords) {
|
|
294
|
+
const normalizedKeywords = normalizeKeywordList(keywords);
|
|
284
295
|
|
|
285
|
-
if (!
|
|
296
|
+
if (!normalizedKeywords.length) {
|
|
286
297
|
return "";
|
|
287
298
|
}
|
|
288
299
|
|
|
289
|
-
return ["
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
960
|
-
for (const id of hits.length
|
|
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
|
|
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
|
-
|
|
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
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
? `${
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
1217
|
+
keywords: normalizeKeywordList(event.keywords || event.cues),
|
|
1207
1218
|
body: block.body,
|
|
1208
1219
|
});
|
|
1209
1220
|
}
|
|
@@ -1215,6 +1226,49 @@ async function collectEventDocuments({ scope, eventFiles }) {
|
|
|
1215
1226
|
};
|
|
1216
1227
|
}
|
|
1217
1228
|
|
|
1229
|
+
// Dream reads a bounded chronological batch from indexes, then opens only the
|
|
1230
|
+
// referenced days. Unindexed logs are never used as a fallback.
|
|
1231
|
+
async function readRecentEvents({ projectRoot, after, before = new Date().toISOString(), maxResults = 20 }) {
|
|
1232
|
+
const root = getScopeRoot("project", projectRoot);
|
|
1233
|
+
const eventFiles = await listEventFiles(root);
|
|
1234
|
+
const candidates = [];
|
|
1235
|
+
|
|
1236
|
+
for (const name of eventFiles.indexes) {
|
|
1237
|
+
if (!/^\d{4}-\d{2}-\d{2}\.index\.json$/.test(name) || name.slice(0, 10) < after.slice(0, 10)) {
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
let index;
|
|
1241
|
+
try {
|
|
1242
|
+
index = await readJsonFileIfExists(path.join(eventFiles.eventsDir, name));
|
|
1243
|
+
} catch {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
for (const event of Array.isArray(index?.events) ? index.events : []) {
|
|
1247
|
+
if (typeof event?.id === "string" && Number.isFinite(Date.parse(event.id)) && event.id > after && event.id <= before) {
|
|
1248
|
+
candidates.push({ id: event.id, path: `events/${name.replace(/\.index\.json$/, ".md")}` });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
candidates.sort((left, right) => left.id.localeCompare(right.id));
|
|
1254
|
+
const days = new Map();
|
|
1255
|
+
const results = [];
|
|
1256
|
+
for (const candidate of candidates) {
|
|
1257
|
+
if (results.length >= maxResults) break;
|
|
1258
|
+
if (!days.has(candidate.path)) {
|
|
1259
|
+
const target = resolveScopedPath("project", candidate.path, projectRoot);
|
|
1260
|
+
const content = await readFileIfExists(target.absolutePath);
|
|
1261
|
+
days.set(candidate.path, new Map(parseEventBlocks(content || "").map((block) => [block.id, block])));
|
|
1262
|
+
}
|
|
1263
|
+
const block = days.get(candidate.path).get(candidate.id);
|
|
1264
|
+
if (!block) continue;
|
|
1265
|
+
const snippet = createSearchExcerpt(block.body, createQueryInfo(block.heading || candidate.id));
|
|
1266
|
+
if (!snippet) continue;
|
|
1267
|
+
results.push({ ...candidate, heading: block.heading, snippet });
|
|
1268
|
+
}
|
|
1269
|
+
return results;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1218
1272
|
async function listCuratedMarkdownFiles(scope, root) {
|
|
1219
1273
|
const markdownFiles = await walkMarkdownFiles(root);
|
|
1220
1274
|
|
|
@@ -1281,7 +1335,7 @@ function queryDatePrefixes(query, now = new Date()) {
|
|
|
1281
1335
|
|
|
1282
1336
|
function buildSearchIndex(documents) {
|
|
1283
1337
|
const index = new MiniSearch({
|
|
1284
|
-
fields: ["
|
|
1338
|
+
fields: ["keywords", "heading", "body", "date"],
|
|
1285
1339
|
idField: "position",
|
|
1286
1340
|
tokenize: tokenizeSearchText,
|
|
1287
1341
|
processTerm: (term) => term,
|
|
@@ -1290,7 +1344,7 @@ function buildSearchIndex(documents) {
|
|
|
1290
1344
|
index.addAll(
|
|
1291
1345
|
documents.map((document, position) => ({
|
|
1292
1346
|
position,
|
|
1293
|
-
|
|
1347
|
+
keywords: (document.keywords || []).join(" "),
|
|
1294
1348
|
heading: document.heading || document.path,
|
|
1295
1349
|
body: document.body,
|
|
1296
1350
|
date: eventDateText(document.id),
|
|
@@ -1316,14 +1370,22 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
|
|
|
1316
1370
|
.map((hit) => ({
|
|
1317
1371
|
document: documents[hit.id],
|
|
1318
1372
|
score: Math.round(hit.score * 100) / 100,
|
|
1319
|
-
|
|
1373
|
+
terms: hit.terms,
|
|
1320
1374
|
}));
|
|
1321
1375
|
}
|
|
1322
1376
|
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
function
|
|
1377
|
+
// Same word in another form: a plural or longer form ("deploy", "deployments") or the same stem
|
|
1378
|
+
// ("creating", "creation"). A typo-tolerant hit on a different word ("instinct", "instance",
|
|
1379
|
+
// "content", "context") is not.
|
|
1380
|
+
function sameWord(left, right) {
|
|
1381
|
+
const stem = (word) => word.replace(/(?:ments?|ings?|ions?|ers?|ed|es|s)$/, "");
|
|
1382
|
+
return tokenSimilarity(left, right) >= 0.9 || (stem(left).length >= 4 && stem(left) === stem(right));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Share of the question's rare-word weight (IDF) that the best of the returned matches contains.
|
|
1386
|
+
// A question whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even
|
|
1387
|
+
// when common words ("backend", "deploy") match, so search can return nothing instead of noise.
|
|
1388
|
+
function rareWordCoverage(queryInfo, candidates, { index, documents }) {
|
|
1327
1389
|
// Month names only rank (see eventDateText); they say when, not what.
|
|
1328
1390
|
const words = [
|
|
1329
1391
|
...new Set(
|
|
@@ -1335,15 +1397,19 @@ function rareWordCoverage(queryInfo, best, { index, documents }) {
|
|
|
1335
1397
|
return 1;
|
|
1336
1398
|
}
|
|
1337
1399
|
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
1400
|
+
const weights = new Map(
|
|
1401
|
+
words.map((word) => [word, Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5))]),
|
|
1402
|
+
);
|
|
1403
|
+
const total = words.reduce((sum, word) => sum + weights.get(word), 0);
|
|
1404
|
+
const coverage = ({ terms }) =>
|
|
1405
|
+
words.filter((word) => terms.some((term) => sameWord(word, term))).reduce((sum, word) => sum + weights.get(word), 0) /
|
|
1406
|
+
total;
|
|
1341
1407
|
|
|
1342
|
-
return
|
|
1408
|
+
return Math.max(0, ...candidates.map(coverage));
|
|
1343
1409
|
}
|
|
1344
1410
|
|
|
1345
1411
|
function toSearchResult(queryInfo, { document, score }) {
|
|
1346
|
-
const snippet = createSearchExcerpt(document.body, queryInfo
|
|
1412
|
+
const snippet = createSearchExcerpt(document.body, queryInfo);
|
|
1347
1413
|
|
|
1348
1414
|
if (!snippet) {
|
|
1349
1415
|
return null;
|
|
@@ -1359,7 +1425,7 @@ function toSearchResult(queryInfo, { document, score }) {
|
|
|
1359
1425
|
};
|
|
1360
1426
|
}
|
|
1361
1427
|
|
|
1362
|
-
const
|
|
1428
|
+
const keywordScore = scoreSearchValues(queryInfo, document.keywords);
|
|
1363
1429
|
const headingScore = scoreSearchValues(queryInfo, [document.heading || "", document.id]);
|
|
1364
1430
|
|
|
1365
1431
|
return {
|
|
@@ -1367,9 +1433,9 @@ function toSearchResult(queryInfo, { document, score }) {
|
|
|
1367
1433
|
path: document.path,
|
|
1368
1434
|
id: document.id,
|
|
1369
1435
|
heading: document.heading,
|
|
1370
|
-
|
|
1436
|
+
keywords: document.keywords,
|
|
1371
1437
|
score,
|
|
1372
|
-
match:
|
|
1438
|
+
match: keywordScore > 0 && keywordScore >= headingScore ? "keywords" : headingScore > 0 ? "heading" : "body",
|
|
1373
1439
|
snippet,
|
|
1374
1440
|
};
|
|
1375
1441
|
}
|
|
@@ -1434,7 +1500,11 @@ async function loadSearchIndex(scopes, projectRoot) {
|
|
|
1434
1500
|
async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5 }) {
|
|
1435
1501
|
const queryInfo = createQueryInfo(query);
|
|
1436
1502
|
const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 5, 20));
|
|
1437
|
-
const
|
|
1503
|
+
const hasProject = getProjectSlug(projectRoot) !== null;
|
|
1504
|
+
// Without a project, project scope is global memory; search it once, as global.
|
|
1505
|
+
const scopes = [
|
|
1506
|
+
...new Set((scope === "both" ? ["global", "project"] : [scope]).map((name) => (name === "project" && !hasProject ? "global" : name))),
|
|
1507
|
+
];
|
|
1438
1508
|
const search = await loadSearchIndex(scopes, projectRoot);
|
|
1439
1509
|
const results = [];
|
|
1440
1510
|
|
|
@@ -1442,7 +1512,7 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
|
|
|
1442
1512
|
// Dated questions skip the check: their date words describe time, not the topic.
|
|
1443
1513
|
const related =
|
|
1444
1514
|
queryInfo.datePrefixes.length > 0 ||
|
|
1445
|
-
rareWordCoverage(queryInfo, candidates
|
|
1515
|
+
rareWordCoverage(queryInfo, candidates.slice(0, clampedMaxResults), search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
|
|
1446
1516
|
|
|
1447
1517
|
if (related) {
|
|
1448
1518
|
for (const candidate of candidates) {
|
|
@@ -1488,7 +1558,7 @@ async function wakeUp({ projectRoot } = {}) {
|
|
|
1488
1558
|
});
|
|
1489
1559
|
}
|
|
1490
1560
|
|
|
1491
|
-
for (const relativePath of DEFAULT_PROJECT_FILES) {
|
|
1561
|
+
for (const relativePath of projectSlug ? DEFAULT_PROJECT_FILES : []) {
|
|
1492
1562
|
const file = await readMemoryFile({ scope: "project", path: relativePath, projectRoot });
|
|
1493
1563
|
|
|
1494
1564
|
if (!file.found) {
|
|
@@ -1507,7 +1577,7 @@ async function wakeUp({ projectRoot } = {}) {
|
|
|
1507
1577
|
"",
|
|
1508
1578
|
`Global root: ${globalRoot}`,
|
|
1509
1579
|
`Project root: ${resolvedProjectRoot}`,
|
|
1510
|
-
`Project slug: ${projectSlug}`,
|
|
1580
|
+
`Project slug: ${projectSlug || "none (empty folder; project memory is global memory)"}`,
|
|
1511
1581
|
`Project memory root: ${projectMemoryRoot}`,
|
|
1512
1582
|
];
|
|
1513
1583
|
|
|
@@ -1555,6 +1625,7 @@ module.exports = {
|
|
|
1555
1625
|
appendEvent,
|
|
1556
1626
|
listDirectoryEntries,
|
|
1557
1627
|
searchMemory,
|
|
1628
|
+
readRecentEvents,
|
|
1558
1629
|
syncMemory,
|
|
1559
1630
|
getDefaultSyncPaths,
|
|
1560
1631
|
wakeUp,
|
|
@@ -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 `
|
|
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 `
|
|
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`.",
|
package/scripts/install-grok.cjs
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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,9 +44,10 @@ 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
|
|
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
|
+
- Preserve any `<!-- hippocamp-dream-through: ... -->` comment in `current_state.md`; Dream uses it to avoid reviewing the same events again.
|
|
50
51
|
- 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.
|
|
51
52
|
- Remove resolved items from `open_threads.md` instead of keeping them.
|
|
52
53
|
- Do not duplicate GitHub-owned facts such as commits, PRs, issues, reviews, or CI results. Store artifact references plus the missing rationale, preference, assumption, or follow-up context.
|