hippocamp 0.2.2 → 0.2.3
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/package.json
CHANGED
|
@@ -162,7 +162,7 @@ async function main() {
|
|
|
162
162
|
"search_memory",
|
|
163
163
|
{
|
|
164
164
|
description:
|
|
165
|
-
"Search task-specific memory. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions.",
|
|
165
|
+
"Search task-specific memory. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions. A month name in the query favors events from that month. A YYYY-MM-DD date, today, yesterday, this/last week, or this/last month keeps only events from those dates (UTC); undated files still match.",
|
|
166
166
|
inputSchema: {
|
|
167
167
|
query: z.string(),
|
|
168
168
|
scope: z.enum(["global", "project", "both"]).default("both"),
|
|
@@ -747,6 +747,7 @@ function createQueryInfo(query) {
|
|
|
747
747
|
normalized: tokens.join(" "),
|
|
748
748
|
cue: tokens.join("-"),
|
|
749
749
|
tokens,
|
|
750
|
+
datePrefixes: queryDatePrefixes(original),
|
|
750
751
|
};
|
|
751
752
|
}
|
|
752
753
|
|
|
@@ -895,7 +896,7 @@ function splitOversizedBlock(block) {
|
|
|
895
896
|
return chunks;
|
|
896
897
|
}
|
|
897
898
|
|
|
898
|
-
function createSearchExcerpt(content, queryInfo) {
|
|
899
|
+
function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
|
|
899
900
|
const trimmed = content.trim();
|
|
900
901
|
|
|
901
902
|
if (!trimmed) {
|
|
@@ -930,14 +931,17 @@ function createSearchExcerpt(content, queryInfo) {
|
|
|
930
931
|
let length = 0;
|
|
931
932
|
|
|
932
933
|
// Keep the best-matching paragraphs that fit the excerpt budget, in file order.
|
|
933
|
-
|
|
934
|
-
|
|
934
|
+
const hits = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE }).map((hit) => hit.id);
|
|
935
|
+
|
|
936
|
+
// An event chosen by its date may share no words with the query; then lead with its first paragraphs.
|
|
937
|
+
for (const id of hits.length || !leadWhenUnmatched ? hits : blocks.map((_, position) => position)) {
|
|
938
|
+
const added = blocks[id].length + (picked.length ? 2 : 0);
|
|
935
939
|
|
|
936
940
|
if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
|
|
937
941
|
continue;
|
|
938
942
|
}
|
|
939
943
|
|
|
940
|
-
picked.push(
|
|
944
|
+
picked.push(id);
|
|
941
945
|
length += added;
|
|
942
946
|
}
|
|
943
947
|
|
|
@@ -1214,9 +1218,47 @@ async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
|
|
|
1214
1218
|
};
|
|
1215
1219
|
}
|
|
1216
1220
|
|
|
1221
|
+
const MONTH_NAMES = "january february march april may june july august september october november december".split(" ");
|
|
1222
|
+
|
|
1223
|
+
// Search word for a UTC day ("2026-08-05" -> "d20260805") or month ("2026-08" -> "august").
|
|
1224
|
+
// A day is one exact word, so it never matches a neighbor day by typo tolerance.
|
|
1225
|
+
function dateTerm(prefix) {
|
|
1226
|
+
return prefix.length === 10 ? `d${prefix.replace(/-/g, "")}` : MONTH_NAMES[Number(prefix.slice(5, 7)) - 1];
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Month and day words of an event's timestamp id.
|
|
1230
|
+
function eventDateText(id) {
|
|
1231
|
+
return /^\d{4}-\d{2}-\d{2}T/.test(id || "") ? `${dateTerm(id.slice(0, 7))} ${dateTerm(id.slice(0, 10))}` : "";
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Dates a query asks for: YYYY-MM-DD dates and relative phrases, as UTC day
|
|
1235
|
+
// ("2026-08-05") or month ("2026-08") prefixes of event ids. Weeks start on Monday.
|
|
1236
|
+
function queryDatePrefixes(query, now = new Date()) {
|
|
1237
|
+
const text = query.toLowerCase();
|
|
1238
|
+
const prefixes = [...text.matchAll(/\b\d{4}-\d{2}-\d{2}\b/g)].map((match) => match[0]);
|
|
1239
|
+
const utc = (months, days) =>
|
|
1240
|
+
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + months, days)).toISOString();
|
|
1241
|
+
const addDays = (from, to) => {
|
|
1242
|
+
for (let offset = from; offset <= to; offset += 1) {
|
|
1243
|
+
prefixes.push(utc(0, now.getUTCDate() + offset).slice(0, 10));
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
const addMonth = (offset) => prefixes.push(utc(offset, 1).slice(0, 7));
|
|
1247
|
+
const monday = -((now.getUTCDay() + 6) % 7);
|
|
1248
|
+
|
|
1249
|
+
if (/\btoday\b/.test(text)) addDays(0, 0);
|
|
1250
|
+
if (/\byesterday\b/.test(text)) addDays(-1, -1);
|
|
1251
|
+
if (/\bthis week\b/.test(text)) addDays(monday, 0);
|
|
1252
|
+
if (/\blast week\b/.test(text)) addDays(monday - 7, monday - 1);
|
|
1253
|
+
if (/\bthis month\b/.test(text)) addMonth(0);
|
|
1254
|
+
if (/\blast month\b/.test(text)) addMonth(-1);
|
|
1255
|
+
|
|
1256
|
+
return prefixes;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1217
1259
|
function buildSearchIndex(documents) {
|
|
1218
1260
|
const index = new MiniSearch({
|
|
1219
|
-
fields: ["cues", "heading", "body"],
|
|
1261
|
+
fields: ["cues", "heading", "body", "date"],
|
|
1220
1262
|
idField: "position",
|
|
1221
1263
|
tokenize: tokenizeSearchText,
|
|
1222
1264
|
processTerm: (term) => term,
|
|
@@ -1228,6 +1270,7 @@ function buildSearchIndex(documents) {
|
|
|
1228
1270
|
cues: (document.cues || []).join(" "),
|
|
1229
1271
|
heading: document.heading || document.path,
|
|
1230
1272
|
body: document.body,
|
|
1273
|
+
date: eventDateText(document.id),
|
|
1231
1274
|
})),
|
|
1232
1275
|
);
|
|
1233
1276
|
|
|
@@ -1235,10 +1278,16 @@ function buildSearchIndex(documents) {
|
|
|
1235
1278
|
}
|
|
1236
1279
|
|
|
1237
1280
|
function rankSearchDocuments(queryInfo, { index, documents }) {
|
|
1281
|
+
const { datePrefixes } = queryInfo;
|
|
1282
|
+
|
|
1283
|
+
// A date the query asks for filters events to those dates. Undated files always pass.
|
|
1238
1284
|
return index
|
|
1239
|
-
.search(queryInfo.normalized, {
|
|
1285
|
+
.search([queryInfo.normalized, ...datePrefixes.map(dateTerm)].join(" "), {
|
|
1240
1286
|
boost: SEARCH_FIELD_BOOST,
|
|
1241
|
-
fuzzy: SEARCH_FUZZY_DISTANCE,
|
|
1287
|
+
fuzzy: (term) => (/^d\d{8}$/.test(term) ? 0 : SEARCH_FUZZY_DISTANCE),
|
|
1288
|
+
filter: datePrefixes.length
|
|
1289
|
+
? (hit) => !documents[hit.id].id || datePrefixes.some((prefix) => documents[hit.id].id.startsWith(prefix))
|
|
1290
|
+
: undefined,
|
|
1242
1291
|
})
|
|
1243
1292
|
.slice(0, SEARCH_CANDIDATE_LIMIT)
|
|
1244
1293
|
.map((hit) => ({
|
|
@@ -1248,7 +1297,7 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
|
|
|
1248
1297
|
}
|
|
1249
1298
|
|
|
1250
1299
|
function toSearchResult(queryInfo, { document, score }) {
|
|
1251
|
-
const snippet = createSearchExcerpt(document.body, queryInfo);
|
|
1300
|
+
const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
|
|
1252
1301
|
|
|
1253
1302
|
if (!snippet) {
|
|
1254
1303
|
return null;
|
|
@@ -346,3 +346,120 @@ test("parallel appends keep every event", async (t) => {
|
|
|
346
346
|
assert.equal(new Set(ids).size, 20);
|
|
347
347
|
assert.equal(new Set(results.map((result) => result.timestamp)).size, 20);
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
test("search favors events from a month named in the query", async (t) => {
|
|
351
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
352
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-month-search-"));
|
|
353
|
+
const projectRoot = path.join(tempRoot, "month-project");
|
|
354
|
+
|
|
355
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
356
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
357
|
+
t.after(async () => {
|
|
358
|
+
if (originalRoot === undefined) {
|
|
359
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
360
|
+
} else {
|
|
361
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
362
|
+
}
|
|
363
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
for (const timestamp of ["2026-05-02T08:00:00.000Z", "2026-08-05T10:00:00.000Z", "2026-09-01T09:00:00.000Z"]) {
|
|
367
|
+
await memory.appendEvent({
|
|
368
|
+
content: "Published the Android build to Google Play production.",
|
|
369
|
+
cues: ["android-release"],
|
|
370
|
+
date: timestamp.slice(0, 10),
|
|
371
|
+
projectRoot,
|
|
372
|
+
scope: "project",
|
|
373
|
+
sync: false,
|
|
374
|
+
timestamp,
|
|
375
|
+
title: "Android release",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const byMonth = await memory.searchMemory({ projectRoot, query: "android build in august", scope: "project" });
|
|
380
|
+
const byDay = await memory.searchMemory({ projectRoot, query: "android build 2026-05-02", scope: "project" });
|
|
381
|
+
|
|
382
|
+
assert.equal(byMonth.results[0].id, "2026-08-05T10:00:00.000Z");
|
|
383
|
+
assert.equal(byDay.results[0].id, "2026-05-02T08:00:00.000Z");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("search favors events from a relative date named in the query", async (t) => {
|
|
387
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
388
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-relative-date-"));
|
|
389
|
+
const projectRoot = path.join(tempRoot, "relative-project");
|
|
390
|
+
|
|
391
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
392
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
393
|
+
t.after(async () => {
|
|
394
|
+
if (originalRoot === undefined) {
|
|
395
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
396
|
+
} else {
|
|
397
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
398
|
+
}
|
|
399
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Seven days ago is always in last calendar week; one day ago is yesterday.
|
|
403
|
+
const daysAgo = (days) => new Date(Date.now() - days * 86_400_000).toISOString();
|
|
404
|
+
const timestamps = { yesterday: daysAgo(1), lastWeek: daysAgo(7), older: daysAgo(30) };
|
|
405
|
+
|
|
406
|
+
for (const [key, timestamp] of Object.entries(timestamps)) {
|
|
407
|
+
await memory.appendEvent({
|
|
408
|
+
// The old event matches the words better, so only the date words can rank the others first.
|
|
409
|
+
content: key === "older" ? "Deploy key rotation: rotated the deploy key again." : "Rotated the key for staging.",
|
|
410
|
+
cues: ["deploy-key"],
|
|
411
|
+
date: timestamp.slice(0, 10),
|
|
412
|
+
projectRoot,
|
|
413
|
+
scope: "project",
|
|
414
|
+
sync: false,
|
|
415
|
+
timestamp,
|
|
416
|
+
title: "Deploy key rotation",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const ids = async (phrase) =>
|
|
421
|
+
(await memory.searchMemory({ projectRoot, query: `deploy key rotation ${phrase}`, scope: "project" })).results.map(
|
|
422
|
+
(result) => result.id,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
assert.deepEqual(await ids("yesterday"), [timestamps.yesterday]);
|
|
426
|
+
// On a Monday, yesterday is also in last week.
|
|
427
|
+
const lastWeek = await ids("last week");
|
|
428
|
+
assert.ok(lastWeek.includes(timestamps.lastWeek));
|
|
429
|
+
assert.ok(!lastWeek.includes(timestamps.older));
|
|
430
|
+
assert.deepEqual(await ids("today"), []);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("search returns a snippet for a long event matched only by its date", async (t) => {
|
|
434
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
435
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-date-only-"));
|
|
436
|
+
const projectRoot = path.join(tempRoot, "date-only-project");
|
|
437
|
+
|
|
438
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
439
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
440
|
+
t.after(async () => {
|
|
441
|
+
if (originalRoot === undefined) {
|
|
442
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
443
|
+
} else {
|
|
444
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
445
|
+
}
|
|
446
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await memory.appendEvent({
|
|
450
|
+
content: Array(30).fill("The weekly sync covered routine updates.").join("\n\n"),
|
|
451
|
+
cues: ["sync"],
|
|
452
|
+
date: "2026-08-05",
|
|
453
|
+
projectRoot,
|
|
454
|
+
scope: "project",
|
|
455
|
+
sync: false,
|
|
456
|
+
timestamp: "2026-08-05T10:00:00.000Z",
|
|
457
|
+
title: "Weekly sync",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const search = await memory.searchMemory({ projectRoot, query: "2026-08-05", scope: "project" });
|
|
461
|
+
|
|
462
|
+
assert.equal(search.results.length, 1);
|
|
463
|
+
assert.match(search.results[0].snippet, /^The weekly sync covered routine updates\./);
|
|
464
|
+
assert.ok(search.results[0].snippet.length <= 1200);
|
|
465
|
+
});
|