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.
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +27 -0
- package/AGENTS.md +13 -5
- package/Dockerfile.railway +15 -0
- package/README.md +64 -9
- package/assets/github-actions/hippocamp-dream.yml +34 -1
- package/assets/railway/run-dream.sh +184 -0
- package/package.json +10 -3
- package/railway.json +12 -0
- package/scripts/eval-retrieval.cjs +276 -0
- package/scripts/hippocamp-dream.cjs +185 -43
- package/scripts/hippocamp-mcp.cjs +35 -14
- package/scripts/hippocamp-memory.cjs +408 -147
- package/scripts/hippocamp.cjs +6 -0
- package/scripts/install-claude.cjs +7 -2
- package/scripts/install-codex.cjs +7 -2
- package/scripts/install-grok.cjs +260 -0
- package/skills/hippocamp-memory/SKILL.md +16 -9
- package/test/mcp-params.test.cjs +80 -0
- package/test/memory-efficiency.test.cjs +223 -0
|
@@ -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
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
704
|
-
const
|
|
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 (
|
|
899
|
+
if (!trimmed) {
|
|
709
900
|
return null;
|
|
710
901
|
}
|
|
711
902
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
.
|
|
719
|
-
.
|
|
720
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
858
|
-
const
|
|
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
|
-
|
|
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
|
|
1107
|
+
const block = blocks.get(event.id);
|
|
904
1108
|
|
|
905
|
-
if (
|
|
1109
|
+
if (!block) {
|
|
906
1110
|
continue;
|
|
907
1111
|
}
|
|
908
1112
|
|
|
909
|
-
|
|
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
|
-
|
|
917
|
-
match: eventScore.match,
|
|
1119
|
+
body: block.body,
|
|
918
1120
|
});
|
|
919
1121
|
}
|
|
920
1122
|
}
|
|
921
1123
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
1130
|
+
async function listCuratedMarkdownFiles(scope, root) {
|
|
1131
|
+
const markdownFiles = await walkMarkdownFiles(root);
|
|
933
1132
|
|
|
934
|
-
|
|
935
|
-
|
|
1133
|
+
return markdownFiles.filter(
|
|
1134
|
+
(relativePath) =>
|
|
1135
|
+
!relativePath.startsWith("events/") && (scope !== "global" || !relativePath.startsWith("projects/")),
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
936
1138
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
}
|
|
1139
|
+
async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
|
|
1140
|
+
const documents = [];
|
|
940
1141
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
955
|
-
|
|
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
|
-
|
|
958
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
}
|
|
1173
|
+
return index;
|
|
1174
|
+
}
|
|
963
1175
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
978
|
-
|
|
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
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
1239
|
+
sources.push({ scope, root, eventFiles, curatedFiles });
|
|
1240
|
+
}
|
|
997
1241
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1246
|
+
if (cached?.signature === signatureText) {
|
|
1247
|
+
return cached;
|
|
1248
|
+
}
|
|
1007
1249
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1261
|
+
const loaded = {
|
|
1262
|
+
signature: signatureText,
|
|
1263
|
+
documents,
|
|
1018
1264
|
scannedFiles,
|
|
1019
|
-
|
|
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 =
|
|
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) ||
|
|
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
|
-
|
|
1031
|
-
const
|
|
1032
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1289
|
+
if (result) {
|
|
1290
|
+
results.push(result);
|
|
1291
|
+
}
|
|
1038
1292
|
|
|
1039
|
-
|
|
1293
|
+
if (results.length >= clampedMaxResults) {
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1040
1298
|
|
|
1041
1299
|
return {
|
|
1042
1300
|
query: queryInfo.original,
|
|
1043
|
-
scannedFiles,
|
|
1044
|
-
results
|
|
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
|
};
|