hippocamp 0.1.0 → 0.2.0

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.
@@ -1,9 +1,11 @@
1
+ const crypto = require("node:crypto");
1
2
  const fsSync = require("node:fs");
2
3
  const fs = require("node:fs/promises");
3
4
  const { execFile } = require("node:child_process");
4
5
  const path = require("node:path");
5
6
  const os = require("node:os");
6
7
  const { promisify } = require("node:util");
8
+ const MiniSearch = require("minisearch");
7
9
 
8
10
  const execFileAsync = promisify(execFile);
9
11
 
@@ -21,7 +23,29 @@ const DEFAULT_PROJECT_FILES = [
21
23
  ];
22
24
 
23
25
  const EVENT_INDEX_VERSION = 1;
24
- const EVENT_SEARCH_THRESHOLD = 20;
26
+ const SEARCH_CANDIDATE_LIMIT = 60;
27
+ const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
28
+ const SEARCH_FUZZY_DISTANCE = 0.2;
29
+ const SEARCH_INDEX_CACHE_LIMIT = 8;
30
+ const SEARCH_EXCERPT_MAX_CHARS = 1200;
31
+ const searchIndexCache = new Map();
32
+ const PROCESS_SESSION_ID = `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
33
+
34
+ const AGENT_NAME_ALIASES = {
35
+ claude: "claude",
36
+ "claude-code": "claude",
37
+ "claude code": "claude",
38
+ anthropic: "claude",
39
+ codex: "codex",
40
+ "openai-codex": "codex",
41
+ cursor: "cursor",
42
+ "cursor-ide": "cursor",
43
+ windsurf: "windsurf",
44
+ grok: "grok",
45
+ "grok-build": "grok",
46
+ vscode: "vscode",
47
+ "visual-studio-code": "vscode",
48
+ };
25
49
 
26
50
  function expandHomePath(value) {
27
51
  if (!value) {
@@ -69,6 +93,45 @@ function findGitRootSync(startPath) {
69
93
  }
70
94
  }
71
95
 
96
+ function findRepositoryRootSync(startPath) {
97
+ const gitRoot = findGitRootSync(startPath);
98
+
99
+ if (!gitRoot) {
100
+ return null;
101
+ }
102
+
103
+ const gitFile = path.join(gitRoot, ".git");
104
+
105
+ if (!fsSync.statSync(gitFile).isFile()) {
106
+ return gitRoot;
107
+ }
108
+
109
+ const match = fsSync.readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
110
+
111
+ if (!match) {
112
+ return gitRoot;
113
+ }
114
+
115
+ const gitDir = path.resolve(gitRoot, match[1]);
116
+ const commonDirFile = path.join(gitDir, "commondir");
117
+
118
+ if (!fsSync.existsSync(commonDirFile)) {
119
+ return gitRoot;
120
+ }
121
+
122
+ const commonDir = path.resolve(gitDir, fsSync.readFileSync(commonDirFile, "utf8").trim());
123
+ return path.basename(commonDir) === ".git" ? path.dirname(commonDir) : gitRoot;
124
+ }
125
+
126
+ function isProjectRepository(projectRoot) {
127
+ const resolvedProjectRoot = getProjectRoot(projectRoot);
128
+ return (
129
+ fsSync.existsSync(resolvedProjectRoot) &&
130
+ fsSync.statSync(resolvedProjectRoot).isDirectory() &&
131
+ findGitRootSync(resolvedProjectRoot) !== null
132
+ );
133
+ }
134
+
72
135
  function slugifyProjectName(value) {
73
136
  const slug = value
74
137
  .toLowerCase()
@@ -80,8 +143,8 @@ function slugifyProjectName(value) {
80
143
 
81
144
  function getProjectSlug(projectRoot) {
82
145
  const resolvedProjectRoot = getProjectRoot(projectRoot);
83
- const gitRoot = findGitRootSync(resolvedProjectRoot);
84
- return slugifyProjectName(path.basename(gitRoot || resolvedProjectRoot));
146
+ const repositoryRoot = findRepositoryRootSync(resolvedProjectRoot);
147
+ return slugifyProjectName(path.basename(repositoryRoot || resolvedProjectRoot));
85
148
  }
86
149
 
87
150
  function getScopeRoot(scope, projectRoot) {
@@ -219,6 +282,124 @@ function formatCuesSection(cues) {
219
282
  return ["Cues:", ...normalizedCues.map((cue) => `- ${cue}`)].join("\n");
220
283
  }
221
284
 
285
+ function normalizeAgentName(value) {
286
+ const raw = String(value || "").trim().toLowerCase();
287
+
288
+ if (!raw) {
289
+ return null;
290
+ }
291
+
292
+ const withoutVersion = raw.replace(/\/.*$/, "").replace(/\s+v?\d+(\.\d+)*$/i, "").trim();
293
+ const compact = withoutVersion.replace(/_/g, "-");
294
+
295
+ if (AGENT_NAME_ALIASES[compact]) {
296
+ return AGENT_NAME_ALIASES[compact];
297
+ }
298
+
299
+ if (AGENT_NAME_ALIASES[withoutVersion]) {
300
+ return AGENT_NAME_ALIASES[withoutVersion];
301
+ }
302
+
303
+ const firstToken = compact.split(/[\s/]+/).filter(Boolean)[0];
304
+
305
+ if (firstToken && AGENT_NAME_ALIASES[firstToken]) {
306
+ return AGENT_NAME_ALIASES[firstToken];
307
+ }
308
+
309
+ const slug = normalizeCue(compact);
310
+
311
+ return slug || null;
312
+ }
313
+
314
+ function detectAgentFromEnvironment() {
315
+ if (process.env.CLAUDE_CODE || process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT) {
316
+ return "claude";
317
+ }
318
+
319
+ if (process.env.CODEX_HOME || process.env.CODEX_CI || process.env.OPENAI_CODEX) {
320
+ return "codex";
321
+ }
322
+
323
+ if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_AGENT || process.env.CURSOR_SESSION_ID) {
324
+ return "cursor";
325
+ }
326
+
327
+ if (process.env.GROK_HOME || process.env.GROK_BUILD || process.env.XAI_GROK) {
328
+ return "grok";
329
+ }
330
+
331
+ return null;
332
+ }
333
+
334
+ function resolveWriteAttribution({ agent, session, clientName } = {}) {
335
+ const resolvedAgent =
336
+ normalizeAgentName(agent) ||
337
+ normalizeAgentName(process.env.HIPPOCAMP_AGENT) ||
338
+ normalizeAgentName(clientName) ||
339
+ detectAgentFromEnvironment();
340
+
341
+ const envSession = String(process.env.HIPPOCAMP_SESSION || "").trim();
342
+ const resolvedSession = String(session || "").trim() || envSession || PROCESS_SESSION_ID;
343
+
344
+ return {
345
+ agent: resolvedAgent || null,
346
+ session: resolvedSession || null,
347
+ };
348
+ }
349
+
350
+ function formatAttributionSection({ agent, session } = {}) {
351
+ const lines = [];
352
+
353
+ if (agent) {
354
+ lines.push(`Agent: ${agent}`);
355
+ }
356
+
357
+ if (session) {
358
+ lines.push(`Session: ${session}`);
359
+ }
360
+
361
+ return lines.join("\n");
362
+ }
363
+
364
+ function extractAttributionFromEventLines(lines) {
365
+ let agent = null;
366
+ let session = null;
367
+
368
+ for (const line of lines) {
369
+ const trimmed = line.trim();
370
+
371
+ if (!trimmed) {
372
+ if (agent || session) {
373
+ break;
374
+ }
375
+
376
+ continue;
377
+ }
378
+
379
+ const agentMatch = trimmed.match(/^Agent:\s*(.+)$/i);
380
+
381
+ if (agentMatch) {
382
+ agent = normalizeAgentName(agentMatch[1]) || agentMatch[1].trim();
383
+ continue;
384
+ }
385
+
386
+ const sessionMatch = trimmed.match(/^Session:\s*(.+)$/i);
387
+
388
+ if (sessionMatch) {
389
+ session = sessionMatch[1].trim();
390
+ continue;
391
+ }
392
+
393
+ // Provenance lines sit at the top of the body; stop at cues or free text.
394
+ break;
395
+ }
396
+
397
+ return {
398
+ agent: agent || null,
399
+ session: session || null,
400
+ };
401
+ }
402
+
222
403
  async function ensureParentDirectory(filePath) {
223
404
  await fs.mkdir(path.dirname(filePath), { recursive: true });
224
405
  }
@@ -506,10 +687,13 @@ function parseEventBlocks(content) {
506
687
  const blockLines = lines.slice(startIndex, endIndex);
507
688
  const heading = parseEventHeading(blockLines[0]);
508
689
  const bodyLines = blockLines.slice(1);
690
+ const attribution = extractAttributionFromEventLines(bodyLines);
509
691
 
510
692
  return {
511
693
  id: heading.id,
512
694
  heading: heading.heading,
695
+ agent: attribution.agent,
696
+ session: attribution.session,
513
697
  cues: extractCuesFromEventLines(bodyLines),
514
698
  content: blockLines.join("\n").trim(),
515
699
  body: bodyLines.join("\n").trim(),
@@ -522,11 +706,23 @@ function buildEventIndex(markdownContent, markdownPath) {
522
706
  return {
523
707
  version: EVENT_INDEX_VERSION,
524
708
  path: path.posix.basename(markdownPath),
525
- events: parseEventBlocks(markdownContent).map((event) => ({
526
- id: event.id,
527
- heading: event.heading,
528
- cues: event.cues,
529
- })),
709
+ events: parseEventBlocks(markdownContent).map((event) => {
710
+ const entry = {
711
+ id: event.id,
712
+ heading: event.heading,
713
+ cues: event.cues,
714
+ };
715
+
716
+ if (event.agent) {
717
+ entry.agent = event.agent;
718
+ }
719
+
720
+ if (event.session) {
721
+ entry.session = event.session;
722
+ }
723
+
724
+ return entry;
725
+ }),
530
726
  };
531
727
  }
532
728
 
@@ -665,31 +861,71 @@ function scoreSearchValues(queryInfo, values) {
665
861
  return Math.max(phraseScore, exactScore, fuzzyScore);
666
862
  }
667
863
 
668
- function createFallbackSnippet(content) {
669
- const compact = content.replace(/\s+/g, " ").trim();
670
- return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact;
864
+ // Split a paragraph over the excerpt limit into whole-sentence chunks so a
865
+ // long matching paragraph is still searchable without cropping mid-sentence.
866
+ function splitOversizedBlock(block) {
867
+ if (block.length <= SEARCH_EXCERPT_MAX_CHARS) {
868
+ return [block];
869
+ }
870
+
871
+ const chunks = [];
872
+ let current = "";
873
+
874
+ for (const sentence of block.split(/(?<=[.!?])\s+|\n+/)) {
875
+ const next = current ? `${current} ${sentence}` : sentence;
876
+
877
+ if (next.length <= SEARCH_EXCERPT_MAX_CHARS) {
878
+ current = next;
879
+ continue;
880
+ }
881
+
882
+ if (current) {
883
+ chunks.push(current);
884
+ }
885
+
886
+ current = sentence.length <= SEARCH_EXCERPT_MAX_CHARS ? sentence : "";
887
+ }
888
+
889
+ if (current) {
890
+ chunks.push(current);
891
+ }
892
+
893
+ return chunks;
671
894
  }
672
895
 
673
- function createSearchSnippet(content, query) {
674
- const haystack = content.toLowerCase();
675
- const needle = query.toLowerCase();
676
- const index = haystack.indexOf(needle);
896
+ function createSearchExcerpt(content, queryInfo) {
897
+ const trimmed = content.trim();
677
898
 
678
- if (index < 0) {
899
+ if (!trimmed) {
679
900
  return null;
680
901
  }
681
902
 
682
- const radius = 80;
683
- const start = Math.max(0, index - radius);
684
- const end = Math.min(content.length, index + query.length + radius);
685
- const prefix = start > 0 ? "..." : "";
686
- const suffix = end < content.length ? "..." : "";
687
- const snippet = content
688
- .slice(start, end)
689
- .replace(/\s+/g, " ")
690
- .trim();
903
+ if (trimmed.length <= SEARCH_EXCERPT_MAX_CHARS) {
904
+ return trimmed;
905
+ }
906
+
907
+ const blocks = trimmed
908
+ .split(/\n\s*\n+/)
909
+ .map((block) => block.trim())
910
+ .filter(Boolean)
911
+ .filter((block) => !/^(?:Agent:|Session:|Cues:)\s*/i.test(block))
912
+ .flatMap(splitOversizedBlock);
913
+
914
+ if (!blocks.length) {
915
+ return null;
916
+ }
917
+
918
+ const index = new MiniSearch({
919
+ fields: ["text"],
920
+ idField: "position",
921
+ tokenize: tokenizeSearchText,
922
+ processTerm: (term) => term,
923
+ });
691
924
 
692
- return `${prefix}${snippet}${suffix}`;
925
+ index.addAll(blocks.map((text, position) => ({ position, text })));
926
+
927
+ const [best] = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE });
928
+ return best ? blocks[best.id] : null;
693
929
  }
694
930
 
695
931
  async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
@@ -707,6 +943,11 @@ async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
707
943
 
708
944
  async function writeMemoryFile({ scope, path: relativePath, content, projectRoot, sync = true }) {
709
945
  const target = resolveScopedPath(scope, relativePath, projectRoot);
946
+
947
+ if (target.path === "events" || target.path.startsWith("events/")) {
948
+ throw new Error("events/ is append-only. Use append_event with a title and cues instead.");
949
+ }
950
+
710
951
  const nextContent = ensureTrailingNewline(requireNonEmptyString(content, "content"));
711
952
 
712
953
  await ensureParentDirectory(target.absolutePath);
@@ -744,13 +985,19 @@ async function appendEvent({
744
985
  title,
745
986
  date = new Date().toISOString().slice(0, 10),
746
987
  timestamp = new Date().toISOString(),
988
+ agent,
989
+ session,
990
+ clientName,
747
991
  }) {
748
992
  const body = requireNonEmptyString(content, "content");
993
+ const attribution = resolveWriteAttribution({ agent, session, clientName });
749
994
  const normalizedCues = normalizeCueList(cues);
750
- const eventBody =
995
+ const cuedBody =
751
996
  normalizedCues.length && !hasCuesSection(body)
752
997
  ? `${formatCuesSection(normalizedCues)}\n\n${body.trim()}`
753
998
  : body.trim();
999
+ const attributionSection = formatAttributionSection(attribution);
1000
+ const eventBody = attributionSection ? `${attributionSection}\n\n${cuedBody}` : cuedBody;
754
1001
  const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
755
1002
  const eventBlock = `${heading}\n\n${eventBody}\n`;
756
1003
  const relativePath = `events/${date}.md`;
@@ -777,13 +1024,17 @@ async function appendEvent({
777
1024
  reason: "sync_disabled",
778
1025
  };
779
1026
 
1027
+ const parsed = parseEventBlocks(eventBlock)[0];
1028
+
780
1029
  return {
781
1030
  scope,
782
1031
  root: target.root,
783
1032
  path: target.path,
784
1033
  timestamp,
785
1034
  title: title?.trim() || null,
786
- cues: parseEventBlocks(eventBlock)[0]?.cues || [],
1035
+ agent: parsed?.agent || attribution.agent,
1036
+ session: parsed?.session || attribution.session,
1037
+ cues: parsed?.cues || [],
787
1038
  indexPath,
788
1039
  sync: syncResult,
789
1040
  };
@@ -824,32 +1075,8 @@ async function listEventFiles(root) {
824
1075
  };
825
1076
  }
826
1077
 
827
- function scoreEventEntry(queryInfo, event) {
828
- const cueScore = scoreSearchValues(queryInfo, event.cues || []);
829
- const headingScore = scoreSearchValues(queryInfo, [event.heading || "", event.id || ""]);
830
- const score = cueScore * 100 + headingScore * 60;
831
- const match = cueScore >= headingScore ? "cues" : "heading";
832
-
833
- return {
834
- score,
835
- match,
836
- };
837
- }
838
-
839
- async function getEventBlockById(markdownPath, eventId) {
840
- const content = await readFileIfExists(markdownPath);
841
-
842
- if (content === null) {
843
- return null;
844
- }
845
-
846
- return parseEventBlocks(content).find((event) => event.id === eventId) || null;
847
- }
848
-
849
- async function searchEventIndexes({ scope, root, queryInfo }) {
850
- const eventFiles = await listEventFiles(root);
851
- const indexedMarkdownFiles = new Set();
852
- const candidates = [];
1078
+ async function collectEventDocuments({ scope, eventFiles }) {
1079
+ const documents = [];
853
1080
 
854
1081
  for (const indexName of eventFiles.indexes) {
855
1082
  const indexPath = path.join(eventFiles.eventsDir, indexName);
@@ -867,151 +1094,212 @@ async function searchEventIndexes({ scope, root, queryInfo }) {
867
1094
  continue;
868
1095
  }
869
1096
 
870
- indexedMarkdownFiles.add(markdownName);
1097
+ const content = await readFileIfExists(path.join(eventFiles.eventsDir, markdownName));
1098
+ const blocks = new Map();
1099
+
1100
+ for (const block of parseEventBlocks(content || "")) {
1101
+ if (!blocks.has(block.id)) {
1102
+ blocks.set(block.id, block);
1103
+ }
1104
+ }
871
1105
 
872
1106
  for (const event of index.events) {
873
- const eventScore = scoreEventEntry(queryInfo, event);
1107
+ const block = blocks.get(event.id);
874
1108
 
875
- if (eventScore.score < EVENT_SEARCH_THRESHOLD) {
1109
+ if (!block) {
876
1110
  continue;
877
1111
  }
878
1112
 
879
- candidates.push({
1113
+ documents.push({
880
1114
  scope,
881
1115
  path: `events/${markdownName}`,
882
- markdownPath: path.join(eventFiles.eventsDir, markdownName),
883
1116
  id: event.id,
884
1117
  heading: event.heading,
885
1118
  cues: normalizeCueList(event.cues),
886
- score: Math.round(eventScore.score),
887
- match: eventScore.match,
1119
+ body: block.body,
888
1120
  });
889
1121
  }
890
1122
  }
891
1123
 
892
- for (const markdownName of eventFiles.markdownFiles) {
893
- if (indexedMarkdownFiles.has(markdownName)) {
894
- continue;
895
- }
1124
+ return {
1125
+ scannedFiles: eventFiles.indexes.length,
1126
+ documents,
1127
+ };
1128
+ }
896
1129
 
897
- const markdownPath = path.join(eventFiles.eventsDir, markdownName);
898
- const content = await readFileIfExists(markdownPath);
1130
+ async function listCuratedMarkdownFiles(scope, root) {
1131
+ const markdownFiles = await walkMarkdownFiles(root);
899
1132
 
900
- if (content === null) {
901
- continue;
902
- }
1133
+ return markdownFiles.filter(
1134
+ (relativePath) =>
1135
+ !relativePath.startsWith("events/") && (scope !== "global" || !relativePath.startsWith("projects/")),
1136
+ );
1137
+ }
903
1138
 
904
- for (const event of parseEventBlocks(content)) {
905
- const eventScore = scoreEventEntry(queryInfo, event);
1139
+ async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
1140
+ const documents = [];
906
1141
 
907
- if (eventScore.score < EVENT_SEARCH_THRESHOLD) {
908
- continue;
909
- }
910
-
911
- candidates.push({
912
- scope,
913
- path: `events/${markdownName}`,
914
- markdownPath,
915
- id: event.id,
916
- heading: event.heading,
917
- cues: event.cues,
918
- score: Math.round(eventScore.score),
919
- match: eventScore.match,
920
- });
921
- }
1142
+ for (const relativePath of curatedFiles) {
1143
+ documents.push({
1144
+ scope,
1145
+ path: relativePath,
1146
+ body: await fs.readFile(path.join(root, relativePath), "utf8"),
1147
+ });
922
1148
  }
923
1149
 
924
- const results = [];
925
- const sortedCandidates = candidates.sort((left, right) => right.score - left.score).slice(0, 60);
1150
+ return {
1151
+ scannedFiles: documents.length,
1152
+ documents,
1153
+ };
1154
+ }
926
1155
 
927
- for (const candidate of sortedCandidates) {
928
- const block = await getEventBlockById(candidate.markdownPath, candidate.id);
1156
+ function buildSearchIndex(documents) {
1157
+ const index = new MiniSearch({
1158
+ fields: ["cues", "heading", "body"],
1159
+ idField: "position",
1160
+ tokenize: tokenizeSearchText,
1161
+ processTerm: (term) => term,
1162
+ });
929
1163
 
930
- if (!block) {
931
- continue;
932
- }
1164
+ index.addAll(
1165
+ documents.map((document, position) => ({
1166
+ position,
1167
+ cues: (document.cues || []).join(" "),
1168
+ heading: document.heading || document.path,
1169
+ body: document.body,
1170
+ })),
1171
+ );
933
1172
 
934
- results.push({
935
- scope: candidate.scope,
936
- path: candidate.path,
937
- id: candidate.id,
938
- heading: candidate.heading,
939
- cues: candidate.cues,
940
- score: candidate.score,
941
- match: candidate.match,
942
- snippet: createSearchSnippet(block.content, queryInfo.original) || createFallbackSnippet(block.body),
943
- });
1173
+ return index;
1174
+ }
1175
+
1176
+ function rankSearchDocuments(queryInfo, { index, documents }) {
1177
+ return index
1178
+ .search(queryInfo.normalized, {
1179
+ boost: SEARCH_FIELD_BOOST,
1180
+ fuzzy: SEARCH_FUZZY_DISTANCE,
1181
+ })
1182
+ .slice(0, SEARCH_CANDIDATE_LIMIT)
1183
+ .map((hit) => ({
1184
+ document: documents[hit.id],
1185
+ score: Math.round(hit.score * 100) / 100,
1186
+ }));
1187
+ }
1188
+
1189
+ function toSearchResult(queryInfo, { document, score }) {
1190
+ const snippet = createSearchExcerpt(document.body, queryInfo);
1191
+
1192
+ if (!snippet) {
1193
+ return null;
944
1194
  }
945
1195
 
1196
+ if (!document.id) {
1197
+ return {
1198
+ scope: document.scope,
1199
+ path: document.path,
1200
+ score,
1201
+ match: scoreSearchValues(queryInfo, [document.path]) > 0 ? "path" : "body",
1202
+ snippet,
1203
+ };
1204
+ }
1205
+
1206
+ const cueScore = scoreSearchValues(queryInfo, document.cues);
1207
+ const headingScore = scoreSearchValues(queryInfo, [document.heading || "", document.id]);
1208
+
946
1209
  return {
947
- scannedFiles: eventFiles.indexes.length + eventFiles.markdownFiles.length - indexedMarkdownFiles.size,
948
- results,
1210
+ scope: document.scope,
1211
+ path: document.path,
1212
+ id: document.id,
1213
+ heading: document.heading,
1214
+ cues: document.cues,
1215
+ score,
1216
+ match: cueScore > 0 && cueScore >= headingScore ? "cues" : headingScore > 0 ? "heading" : "body",
1217
+ snippet,
949
1218
  };
950
1219
  }
951
1220
 
952
- async function searchMarkdownFiles({ scope, root, queryInfo }) {
953
- const markdownFiles = await walkMarkdownFiles(root);
954
- const scopedFiles =
955
- scope === "global"
956
- ? markdownFiles.filter((relativePath) => !relativePath.startsWith("projects/"))
957
- : markdownFiles;
958
- const results = [];
959
- let scannedFiles = 0;
960
-
961
- for (const relativePath of scopedFiles) {
962
- if (relativePath.startsWith("events/")) {
963
- continue;
1221
+ async function loadSearchIndex(scopes, projectRoot) {
1222
+ const sources = [];
1223
+ const signature = [];
1224
+
1225
+ for (const scope of scopes) {
1226
+ const root = getScopeRoot(scope, projectRoot);
1227
+ const eventFiles = await listEventFiles(root);
1228
+ const curatedFiles = await listCuratedMarkdownFiles(scope, root);
1229
+ const filePaths = [
1230
+ ...[...eventFiles.indexes, ...eventFiles.markdownFiles].map((name) => path.join(eventFiles.eventsDir, name)),
1231
+ ...curatedFiles.map((relativePath) => path.join(root, relativePath)),
1232
+ ];
1233
+
1234
+ for (const filePath of filePaths) {
1235
+ const stats = await statIfExists(filePath);
1236
+ signature.push(`${filePath}:${stats?.mtimeMs}:${stats?.size}`);
964
1237
  }
965
1238
 
966
- scannedFiles += 1;
1239
+ sources.push({ scope, root, eventFiles, curatedFiles });
1240
+ }
967
1241
 
968
- const absolutePath = path.join(root, relativePath);
969
- const content = await fs.readFile(absolutePath, "utf8");
970
- const pathScore = scoreSearchValues(queryInfo, [relativePath]);
971
- const bodyScore = scoreSearchValues(queryInfo, [content]);
972
- const score = pathScore * 70 + bodyScore * 40;
1242
+ const cacheKey = sources.map((source) => `${source.scope}:${source.root}`).join("|");
1243
+ const cached = searchIndexCache.get(cacheKey);
1244
+ const signatureText = signature.join("\n");
973
1245
 
974
- if (score < EVENT_SEARCH_THRESHOLD) {
975
- continue;
976
- }
1246
+ if (cached?.signature === signatureText) {
1247
+ return cached;
1248
+ }
977
1249
 
978
- results.push({
979
- scope,
980
- path: relativePath,
981
- score: Math.round(score),
982
- match: pathScore >= bodyScore ? "path" : "body",
983
- snippet: createSearchSnippet(content, queryInfo.original) || createFallbackSnippet(content),
984
- });
1250
+ const documents = [];
1251
+ let scannedFiles = 0;
1252
+
1253
+ for (const source of sources) {
1254
+ const eventDocuments = await collectEventDocuments(source);
1255
+ const markdownDocuments = await collectMarkdownDocuments(source);
1256
+
1257
+ scannedFiles += eventDocuments.scannedFiles + markdownDocuments.scannedFiles;
1258
+ documents.push(...eventDocuments.documents, ...markdownDocuments.documents);
985
1259
  }
986
1260
 
987
- return {
1261
+ const loaded = {
1262
+ signature: signatureText,
1263
+ documents,
988
1264
  scannedFiles,
989
- results,
1265
+ index: buildSearchIndex(documents),
990
1266
  };
1267
+
1268
+ searchIndexCache.delete(cacheKey);
1269
+ searchIndexCache.set(cacheKey, loaded);
1270
+
1271
+ if (searchIndexCache.size > SEARCH_INDEX_CACHE_LIMIT) {
1272
+ searchIndexCache.delete(searchIndexCache.keys().next().value);
1273
+ }
1274
+
1275
+ return loaded;
991
1276
  }
992
1277
 
993
- async function searchMemory({ query, scope = "both", projectRoot, maxResults = 10 }) {
1278
+ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5 }) {
994
1279
  const queryInfo = createQueryInfo(query);
995
- const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 10, 20));
1280
+ const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 5, 20));
996
1281
  const scopes = scope === "both" ? ["global", "project"] : [scope];
1282
+ const search = await loadSearchIndex(scopes, projectRoot);
997
1283
  const results = [];
998
- let scannedFiles = 0;
999
1284
 
1000
- for (const itemScope of scopes) {
1001
- const root = getScopeRoot(itemScope, projectRoot);
1002
- const eventSearch = await searchEventIndexes({ scope: itemScope, root, queryInfo });
1003
- const markdownSearch = await searchMarkdownFiles({ scope: itemScope, root, queryInfo });
1285
+ if (queryInfo.tokens.length && search.documents.length) {
1286
+ for (const candidate of rankSearchDocuments(queryInfo, search)) {
1287
+ const result = toSearchResult(queryInfo, candidate);
1004
1288
 
1005
- scannedFiles += eventSearch.scannedFiles + markdownSearch.scannedFiles;
1006
- results.push(...eventSearch.results, ...markdownSearch.results);
1007
- }
1289
+ if (result) {
1290
+ results.push(result);
1291
+ }
1008
1292
 
1009
- const rankedResults = results.sort((left, right) => right.score - left.score).slice(0, clampedMaxResults);
1293
+ if (results.length >= clampedMaxResults) {
1294
+ break;
1295
+ }
1296
+ }
1297
+ }
1010
1298
 
1011
1299
  return {
1012
1300
  query: queryInfo.original,
1013
- scannedFiles,
1014
- results: rankedResults,
1301
+ scannedFiles: search.scannedFiles,
1302
+ results,
1015
1303
  };
1016
1304
  }
1017
1305
 
@@ -1098,6 +1386,7 @@ module.exports = {
1098
1386
  getGlobalRoot,
1099
1387
  getProjectRoot,
1100
1388
  getProjectSlug,
1389
+ isProjectRepository,
1101
1390
  getScopeRoot,
1102
1391
  readMemoryFile,
1103
1392
  writeMemoryFile,
@@ -1107,4 +1396,6 @@ module.exports = {
1107
1396
  syncMemory,
1108
1397
  getDefaultSyncPaths,
1109
1398
  wakeUp,
1399
+ resolveWriteAttribution,
1400
+ PROCESS_SESSION_ID,
1110
1401
  };