hippocamp 0.1.1 → 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) {
@@ -99,6 +123,15 @@ function findRepositoryRootSync(startPath) {
99
123
  return path.basename(commonDir) === ".git" ? path.dirname(commonDir) : gitRoot;
100
124
  }
101
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
+
102
135
  function slugifyProjectName(value) {
103
136
  const slug = value
104
137
  .toLowerCase()
@@ -249,6 +282,124 @@ function formatCuesSection(cues) {
249
282
  return ["Cues:", ...normalizedCues.map((cue) => `- ${cue}`)].join("\n");
250
283
  }
251
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
+
252
403
  async function ensureParentDirectory(filePath) {
253
404
  await fs.mkdir(path.dirname(filePath), { recursive: true });
254
405
  }
@@ -536,10 +687,13 @@ function parseEventBlocks(content) {
536
687
  const blockLines = lines.slice(startIndex, endIndex);
537
688
  const heading = parseEventHeading(blockLines[0]);
538
689
  const bodyLines = blockLines.slice(1);
690
+ const attribution = extractAttributionFromEventLines(bodyLines);
539
691
 
540
692
  return {
541
693
  id: heading.id,
542
694
  heading: heading.heading,
695
+ agent: attribution.agent,
696
+ session: attribution.session,
543
697
  cues: extractCuesFromEventLines(bodyLines),
544
698
  content: blockLines.join("\n").trim(),
545
699
  body: bodyLines.join("\n").trim(),
@@ -552,11 +706,23 @@ function buildEventIndex(markdownContent, markdownPath) {
552
706
  return {
553
707
  version: EVENT_INDEX_VERSION,
554
708
  path: path.posix.basename(markdownPath),
555
- events: parseEventBlocks(markdownContent).map((event) => ({
556
- id: event.id,
557
- heading: event.heading,
558
- cues: event.cues,
559
- })),
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
+ }),
560
726
  };
561
727
  }
562
728
 
@@ -695,31 +861,71 @@ function scoreSearchValues(queryInfo, values) {
695
861
  return Math.max(phraseScore, exactScore, fuzzyScore);
696
862
  }
697
863
 
698
- function createFallbackSnippet(content) {
699
- const compact = content.replace(/\s+/g, " ").trim();
700
- 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;
701
894
  }
702
895
 
703
- function createSearchSnippet(content, query) {
704
- const haystack = content.toLowerCase();
705
- const needle = query.toLowerCase();
706
- const index = haystack.indexOf(needle);
896
+ function createSearchExcerpt(content, queryInfo) {
897
+ const trimmed = content.trim();
707
898
 
708
- if (index < 0) {
899
+ if (!trimmed) {
709
900
  return null;
710
901
  }
711
902
 
712
- const radius = 80;
713
- const start = Math.max(0, index - radius);
714
- const end = Math.min(content.length, index + query.length + radius);
715
- const prefix = start > 0 ? "..." : "";
716
- const suffix = end < content.length ? "..." : "";
717
- const snippet = content
718
- .slice(start, end)
719
- .replace(/\s+/g, " ")
720
- .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
+ });
924
+
925
+ index.addAll(blocks.map((text, position) => ({ position, text })));
721
926
 
722
- return `${prefix}${snippet}${suffix}`;
927
+ const [best] = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE });
928
+ return best ? blocks[best.id] : null;
723
929
  }
724
930
 
725
931
  async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
@@ -737,6 +943,11 @@ async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
737
943
 
738
944
  async function writeMemoryFile({ scope, path: relativePath, content, projectRoot, sync = true }) {
739
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
+
740
951
  const nextContent = ensureTrailingNewline(requireNonEmptyString(content, "content"));
741
952
 
742
953
  await ensureParentDirectory(target.absolutePath);
@@ -774,13 +985,19 @@ async function appendEvent({
774
985
  title,
775
986
  date = new Date().toISOString().slice(0, 10),
776
987
  timestamp = new Date().toISOString(),
988
+ agent,
989
+ session,
990
+ clientName,
777
991
  }) {
778
992
  const body = requireNonEmptyString(content, "content");
993
+ const attribution = resolveWriteAttribution({ agent, session, clientName });
779
994
  const normalizedCues = normalizeCueList(cues);
780
- const eventBody =
995
+ const cuedBody =
781
996
  normalizedCues.length && !hasCuesSection(body)
782
997
  ? `${formatCuesSection(normalizedCues)}\n\n${body.trim()}`
783
998
  : body.trim();
999
+ const attributionSection = formatAttributionSection(attribution);
1000
+ const eventBody = attributionSection ? `${attributionSection}\n\n${cuedBody}` : cuedBody;
784
1001
  const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
785
1002
  const eventBlock = `${heading}\n\n${eventBody}\n`;
786
1003
  const relativePath = `events/${date}.md`;
@@ -807,13 +1024,17 @@ async function appendEvent({
807
1024
  reason: "sync_disabled",
808
1025
  };
809
1026
 
1027
+ const parsed = parseEventBlocks(eventBlock)[0];
1028
+
810
1029
  return {
811
1030
  scope,
812
1031
  root: target.root,
813
1032
  path: target.path,
814
1033
  timestamp,
815
1034
  title: title?.trim() || null,
816
- cues: parseEventBlocks(eventBlock)[0]?.cues || [],
1035
+ agent: parsed?.agent || attribution.agent,
1036
+ session: parsed?.session || attribution.session,
1037
+ cues: parsed?.cues || [],
817
1038
  indexPath,
818
1039
  sync: syncResult,
819
1040
  };
@@ -854,32 +1075,8 @@ async function listEventFiles(root) {
854
1075
  };
855
1076
  }
856
1077
 
857
- function scoreEventEntry(queryInfo, event) {
858
- const cueScore = scoreSearchValues(queryInfo, event.cues || []);
859
- const headingScore = scoreSearchValues(queryInfo, [event.heading || "", event.id || ""]);
860
- const score = cueScore * 100 + headingScore * 60;
861
- const match = cueScore >= headingScore ? "cues" : "heading";
862
-
863
- return {
864
- score,
865
- match,
866
- };
867
- }
868
-
869
- async function getEventBlockById(markdownPath, eventId) {
870
- const content = await readFileIfExists(markdownPath);
871
-
872
- if (content === null) {
873
- return null;
874
- }
875
-
876
- return parseEventBlocks(content).find((event) => event.id === eventId) || null;
877
- }
878
-
879
- async function searchEventIndexes({ scope, root, queryInfo }) {
880
- const eventFiles = await listEventFiles(root);
881
- const indexedMarkdownFiles = new Set();
882
- const candidates = [];
1078
+ async function collectEventDocuments({ scope, eventFiles }) {
1079
+ const documents = [];
883
1080
 
884
1081
  for (const indexName of eventFiles.indexes) {
885
1082
  const indexPath = path.join(eventFiles.eventsDir, indexName);
@@ -897,151 +1094,212 @@ async function searchEventIndexes({ scope, root, queryInfo }) {
897
1094
  continue;
898
1095
  }
899
1096
 
900
- 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
+ }
901
1105
 
902
1106
  for (const event of index.events) {
903
- const eventScore = scoreEventEntry(queryInfo, event);
1107
+ const block = blocks.get(event.id);
904
1108
 
905
- if (eventScore.score < EVENT_SEARCH_THRESHOLD) {
1109
+ if (!block) {
906
1110
  continue;
907
1111
  }
908
1112
 
909
- candidates.push({
1113
+ documents.push({
910
1114
  scope,
911
1115
  path: `events/${markdownName}`,
912
- markdownPath: path.join(eventFiles.eventsDir, markdownName),
913
1116
  id: event.id,
914
1117
  heading: event.heading,
915
1118
  cues: normalizeCueList(event.cues),
916
- score: Math.round(eventScore.score),
917
- match: eventScore.match,
1119
+ body: block.body,
918
1120
  });
919
1121
  }
920
1122
  }
921
1123
 
922
- for (const markdownName of eventFiles.markdownFiles) {
923
- if (indexedMarkdownFiles.has(markdownName)) {
924
- continue;
925
- }
926
-
927
- const markdownPath = path.join(eventFiles.eventsDir, markdownName);
928
- const content = await readFileIfExists(markdownPath);
1124
+ return {
1125
+ scannedFiles: eventFiles.indexes.length,
1126
+ documents,
1127
+ };
1128
+ }
929
1129
 
930
- if (content === null) {
931
- continue;
932
- }
1130
+ async function listCuratedMarkdownFiles(scope, root) {
1131
+ const markdownFiles = await walkMarkdownFiles(root);
933
1132
 
934
- for (const event of parseEventBlocks(content)) {
935
- const eventScore = scoreEventEntry(queryInfo, event);
1133
+ return markdownFiles.filter(
1134
+ (relativePath) =>
1135
+ !relativePath.startsWith("events/") && (scope !== "global" || !relativePath.startsWith("projects/")),
1136
+ );
1137
+ }
936
1138
 
937
- if (eventScore.score < EVENT_SEARCH_THRESHOLD) {
938
- continue;
939
- }
1139
+ async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
1140
+ const documents = [];
940
1141
 
941
- candidates.push({
942
- scope,
943
- path: `events/${markdownName}`,
944
- markdownPath,
945
- id: event.id,
946
- heading: event.heading,
947
- cues: event.cues,
948
- score: Math.round(eventScore.score),
949
- match: eventScore.match,
950
- });
951
- }
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
+ });
952
1148
  }
953
1149
 
954
- const results = [];
955
- const sortedCandidates = candidates.sort((left, right) => right.score - left.score).slice(0, 60);
1150
+ return {
1151
+ scannedFiles: documents.length,
1152
+ documents,
1153
+ };
1154
+ }
1155
+
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
+ });
956
1163
 
957
- for (const candidate of sortedCandidates) {
958
- const block = await getEventBlockById(candidate.markdownPath, candidate.id);
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
+ );
959
1172
 
960
- if (!block) {
961
- continue;
962
- }
1173
+ return index;
1174
+ }
963
1175
 
964
- results.push({
965
- scope: candidate.scope,
966
- path: candidate.path,
967
- id: candidate.id,
968
- heading: candidate.heading,
969
- cues: candidate.cues,
970
- score: candidate.score,
971
- match: candidate.match,
972
- snippet: createSearchSnippet(block.content, queryInfo.original) || createFallbackSnippet(block.body),
973
- });
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;
1194
+ }
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
+ };
974
1204
  }
975
1205
 
1206
+ const cueScore = scoreSearchValues(queryInfo, document.cues);
1207
+ const headingScore = scoreSearchValues(queryInfo, [document.heading || "", document.id]);
1208
+
976
1209
  return {
977
- scannedFiles: eventFiles.indexes.length + eventFiles.markdownFiles.length - indexedMarkdownFiles.size,
978
- 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,
979
1218
  };
980
1219
  }
981
1220
 
982
- async function searchMarkdownFiles({ scope, root, queryInfo }) {
983
- const markdownFiles = await walkMarkdownFiles(root);
984
- const scopedFiles =
985
- scope === "global"
986
- ? markdownFiles.filter((relativePath) => !relativePath.startsWith("projects/"))
987
- : markdownFiles;
988
- const results = [];
989
- let scannedFiles = 0;
990
-
991
- for (const relativePath of scopedFiles) {
992
- if (relativePath.startsWith("events/")) {
993
- 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}`);
994
1237
  }
995
1238
 
996
- scannedFiles += 1;
1239
+ sources.push({ scope, root, eventFiles, curatedFiles });
1240
+ }
997
1241
 
998
- const absolutePath = path.join(root, relativePath);
999
- const content = await fs.readFile(absolutePath, "utf8");
1000
- const pathScore = scoreSearchValues(queryInfo, [relativePath]);
1001
- const bodyScore = scoreSearchValues(queryInfo, [content]);
1002
- 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");
1003
1245
 
1004
- if (score < EVENT_SEARCH_THRESHOLD) {
1005
- continue;
1006
- }
1246
+ if (cached?.signature === signatureText) {
1247
+ return cached;
1248
+ }
1007
1249
 
1008
- results.push({
1009
- scope,
1010
- path: relativePath,
1011
- score: Math.round(score),
1012
- match: pathScore >= bodyScore ? "path" : "body",
1013
- snippet: createSearchSnippet(content, queryInfo.original) || createFallbackSnippet(content),
1014
- });
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);
1015
1259
  }
1016
1260
 
1017
- return {
1261
+ const loaded = {
1262
+ signature: signatureText,
1263
+ documents,
1018
1264
  scannedFiles,
1019
- results,
1265
+ index: buildSearchIndex(documents),
1020
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;
1021
1276
  }
1022
1277
 
1023
- async function searchMemory({ query, scope = "both", projectRoot, maxResults = 10 }) {
1278
+ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5 }) {
1024
1279
  const queryInfo = createQueryInfo(query);
1025
- const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 10, 20));
1280
+ const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 5, 20));
1026
1281
  const scopes = scope === "both" ? ["global", "project"] : [scope];
1282
+ const search = await loadSearchIndex(scopes, projectRoot);
1027
1283
  const results = [];
1028
- let scannedFiles = 0;
1029
1284
 
1030
- for (const itemScope of scopes) {
1031
- const root = getScopeRoot(itemScope, projectRoot);
1032
- const eventSearch = await searchEventIndexes({ scope: itemScope, root, queryInfo });
1033
- 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);
1034
1288
 
1035
- scannedFiles += eventSearch.scannedFiles + markdownSearch.scannedFiles;
1036
- results.push(...eventSearch.results, ...markdownSearch.results);
1037
- }
1289
+ if (result) {
1290
+ results.push(result);
1291
+ }
1038
1292
 
1039
- const rankedResults = results.sort((left, right) => right.score - left.score).slice(0, clampedMaxResults);
1293
+ if (results.length >= clampedMaxResults) {
1294
+ break;
1295
+ }
1296
+ }
1297
+ }
1040
1298
 
1041
1299
  return {
1042
1300
  query: queryInfo.original,
1043
- scannedFiles,
1044
- results: rankedResults,
1301
+ scannedFiles: search.scannedFiles,
1302
+ results,
1045
1303
  };
1046
1304
  }
1047
1305
 
@@ -1128,6 +1386,7 @@ module.exports = {
1128
1386
  getGlobalRoot,
1129
1387
  getProjectRoot,
1130
1388
  getProjectSlug,
1389
+ isProjectRepository,
1131
1390
  getScopeRoot,
1132
1391
  readMemoryFile,
1133
1392
  writeMemoryFile,
@@ -1137,4 +1396,6 @@ module.exports = {
1137
1396
  syncMemory,
1138
1397
  getDefaultSyncPaths,
1139
1398
  wakeUp,
1399
+ resolveWriteAttribution,
1400
+ PROCESS_SESSION_ID,
1140
1401
  };