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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -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
- for (const hit of index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE })) {
934
- const added = blocks[hit.id].length + (picked.length ? 2 : 0);
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(hit.id);
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
+ });