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.
- 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 +65 -9
- package/assets/github-actions/hippocamp-dream.yml +65 -31
- 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 +440 -149
- 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) {
|
|
@@ -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
|
|
84
|
-
return slugifyProjectName(path.basename(
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
674
|
-
const
|
|
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 (
|
|
899
|
+
if (!trimmed) {
|
|
679
900
|
return null;
|
|
680
901
|
}
|
|
681
902
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
.
|
|
689
|
-
.
|
|
690
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
828
|
-
const
|
|
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
|
-
|
|
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
|
|
1107
|
+
const block = blocks.get(event.id);
|
|
874
1108
|
|
|
875
|
-
if (
|
|
1109
|
+
if (!block) {
|
|
876
1110
|
continue;
|
|
877
1111
|
}
|
|
878
1112
|
|
|
879
|
-
|
|
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
|
-
|
|
887
|
-
match: eventScore.match,
|
|
1119
|
+
body: block.body,
|
|
888
1120
|
});
|
|
889
1121
|
}
|
|
890
1122
|
}
|
|
891
1123
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1124
|
+
return {
|
|
1125
|
+
scannedFiles: eventFiles.indexes.length,
|
|
1126
|
+
documents,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
896
1129
|
|
|
897
|
-
|
|
898
|
-
|
|
1130
|
+
async function listCuratedMarkdownFiles(scope, root) {
|
|
1131
|
+
const markdownFiles = await walkMarkdownFiles(root);
|
|
899
1132
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1133
|
+
return markdownFiles.filter(
|
|
1134
|
+
(relativePath) =>
|
|
1135
|
+
!relativePath.startsWith("events/") && (scope !== "global" || !relativePath.startsWith("projects/")),
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
903
1138
|
|
|
904
|
-
|
|
905
|
-
|
|
1139
|
+
async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
|
|
1140
|
+
const documents = [];
|
|
906
1141
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
925
|
-
|
|
1150
|
+
return {
|
|
1151
|
+
scannedFiles: documents.length,
|
|
1152
|
+
documents,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
926
1155
|
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
948
|
-
|
|
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
|
|
953
|
-
const
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
1239
|
+
sources.push({ scope, root, eventFiles, curatedFiles });
|
|
1240
|
+
}
|
|
967
1241
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1246
|
+
if (cached?.signature === signatureText) {
|
|
1247
|
+
return cached;
|
|
1248
|
+
}
|
|
977
1249
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1261
|
+
const loaded = {
|
|
1262
|
+
signature: signatureText,
|
|
1263
|
+
documents,
|
|
988
1264
|
scannedFiles,
|
|
989
|
-
|
|
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 =
|
|
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) ||
|
|
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
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1289
|
+
if (result) {
|
|
1290
|
+
results.push(result);
|
|
1291
|
+
}
|
|
1008
1292
|
|
|
1009
|
-
|
|
1293
|
+
if (results.length >= clampedMaxResults) {
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1010
1298
|
|
|
1011
1299
|
return {
|
|
1012
1300
|
query: queryInfo.original,
|
|
1013
|
-
scannedFiles,
|
|
1014
|
-
results
|
|
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
|
};
|