hippocamp 0.1.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/.env.example +7 -0
- package/AGENTS.md +74 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/assets/brand/hippocamp-mascot.png +0 -0
- package/assets/github-actions/hippocamp-dream.yml +136 -0
- package/package.json +43 -0
- package/scripts/hippocamp-dream.cjs +626 -0
- package/scripts/hippocamp-mcp.cjs +196 -0
- package/scripts/hippocamp-memory.cjs +1110 -0
- package/scripts/hippocamp.cjs +66 -0
- package/scripts/install-claude.cjs +273 -0
- package/scripts/install-codex.cjs +238 -0
- package/skills/hippocamp-memory/SKILL.md +77 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
const fsSync = require("node:fs");
|
|
2
|
+
const fs = require("node:fs/promises");
|
|
3
|
+
const { execFile } = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const { promisify } = require("node:util");
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
const DEFAULT_GLOBAL_FILES = [
|
|
11
|
+
"identity.md",
|
|
12
|
+
"how_i_work.md",
|
|
13
|
+
"preferences.md",
|
|
14
|
+
"open_loops.md",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PROJECT_FILES = [
|
|
18
|
+
"project.md",
|
|
19
|
+
"current_state.md",
|
|
20
|
+
"open_threads.md",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const EVENT_INDEX_VERSION = 1;
|
|
24
|
+
const EVENT_SEARCH_THRESHOLD = 20;
|
|
25
|
+
|
|
26
|
+
function expandHomePath(value) {
|
|
27
|
+
if (!value) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (value === "~") {
|
|
32
|
+
return os.homedir();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (value.startsWith("~/")) {
|
|
36
|
+
return path.join(os.homedir(), value.slice(2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getGlobalRepoRoot() {
|
|
43
|
+
return path.resolve(expandHomePath(process.env.HIPPOCAMP_GLOBAL_ROOT || "~/.lagoon"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getGlobalRoot() {
|
|
47
|
+
return getGlobalRepoRoot();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getProjectRoot(projectRoot) {
|
|
51
|
+
return path.resolve(expandHomePath(projectRoot || process.env.HIPPOCAMP_PROJECT_ROOT || process.cwd()));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findGitRootSync(startPath) {
|
|
55
|
+
let current = path.resolve(startPath);
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
if (fsSync.existsSync(path.join(current, ".git"))) {
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parent = path.dirname(current);
|
|
63
|
+
|
|
64
|
+
if (parent === current) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function slugifyProjectName(value) {
|
|
73
|
+
const slug = value
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
76
|
+
.replace(/^-+|-+$/g, "");
|
|
77
|
+
|
|
78
|
+
return slug || "project";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getProjectSlug(projectRoot) {
|
|
82
|
+
const resolvedProjectRoot = getProjectRoot(projectRoot);
|
|
83
|
+
const gitRoot = findGitRootSync(resolvedProjectRoot);
|
|
84
|
+
return slugifyProjectName(path.basename(gitRoot || resolvedProjectRoot));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getScopeRoot(scope, projectRoot) {
|
|
88
|
+
if (scope === "global") {
|
|
89
|
+
return getGlobalRoot();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (scope === "project") {
|
|
93
|
+
return path.join(getGlobalRoot(), "projects", getProjectSlug(projectRoot));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new Error(`Unsupported scope: ${scope}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireNonEmptyString(value, field) {
|
|
100
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
101
|
+
throw new Error(`${field} is required.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeRelativePath(value) {
|
|
108
|
+
const candidate = requireNonEmptyString(value, "path");
|
|
109
|
+
const normalized = path.posix.normalize(candidate.replace(/^\/+/, ""));
|
|
110
|
+
|
|
111
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
112
|
+
throw new Error("path must be a safe relative path.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (normalized.split("/").includes(".git")) {
|
|
116
|
+
throw new Error("path must not target git internals.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveScopedPath(scope, relativePath, projectRoot) {
|
|
123
|
+
const root = getScopeRoot(scope, projectRoot);
|
|
124
|
+
const normalizedPath = normalizeRelativePath(relativePath);
|
|
125
|
+
const absolutePath = path.resolve(root, normalizedPath);
|
|
126
|
+
const allowedPrefix = `${root}${path.sep}`;
|
|
127
|
+
|
|
128
|
+
if (absolutePath !== root && !absolutePath.startsWith(allowedPrefix)) {
|
|
129
|
+
throw new Error("path escapes the memory root.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
root,
|
|
134
|
+
path: normalizedPath,
|
|
135
|
+
absolutePath,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assertPathInScope(scopeRoot, targetPath) {
|
|
140
|
+
const absolutePath = path.resolve(targetPath);
|
|
141
|
+
const allowedPrefix = `${scopeRoot}${path.sep}`;
|
|
142
|
+
|
|
143
|
+
if (absolutePath !== scopeRoot && !absolutePath.startsWith(allowedPrefix)) {
|
|
144
|
+
throw new Error("path escapes the memory root.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return absolutePath;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readFileIfExists(filePath) {
|
|
151
|
+
try {
|
|
152
|
+
return await fs.readFile(filePath, "utf8");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function statIfExists(filePath) {
|
|
163
|
+
try {
|
|
164
|
+
return await fs.stat(filePath);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ensureTrailingNewline(content) {
|
|
175
|
+
return content.endsWith("\n") ? content : `${content}\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeSearchText(value) {
|
|
179
|
+
return String(value || "")
|
|
180
|
+
.normalize("NFKD")
|
|
181
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
184
|
+
.trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function tokenizeSearchText(value) {
|
|
188
|
+
const normalized = normalizeSearchText(value);
|
|
189
|
+
return normalized ? normalized.split(/\s+/).filter(Boolean) : [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeCue(value) {
|
|
193
|
+
return tokenizeSearchText(value).join("-");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeCueList(cues) {
|
|
197
|
+
const values = Array.isArray(cues) ? cues : [];
|
|
198
|
+
return [...new Set(values.map(normalizeCue).filter(Boolean))];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function splitInlineCueValues(value) {
|
|
202
|
+
return String(value || "")
|
|
203
|
+
.split(",")
|
|
204
|
+
.map((item) => item.trim())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function hasCuesSection(content) {
|
|
209
|
+
return content.split(/\r?\n/).some((line) => /^Cues:\s*/i.test(line.trim()));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formatCuesSection(cues) {
|
|
213
|
+
const normalizedCues = normalizeCueList(cues);
|
|
214
|
+
|
|
215
|
+
if (!normalizedCues.length) {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return ["Cues:", ...normalizedCues.map((cue) => `- ${cue}`)].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function ensureParentDirectory(filePath) {
|
|
223
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runGit(args, cwd) {
|
|
227
|
+
try {
|
|
228
|
+
const result = await execFileAsync("git", args, { cwd });
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
stdout: result.stdout.trim(),
|
|
232
|
+
stderr: result.stderr.trim(),
|
|
233
|
+
};
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
stdout: error.stdout?.trim() || "",
|
|
238
|
+
stderr: error.stderr?.trim() || error.message,
|
|
239
|
+
code: error.code,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function getGitRepoRoot(startPath) {
|
|
245
|
+
const result = await runGit(["rev-parse", "--show-toplevel"], startPath);
|
|
246
|
+
|
|
247
|
+
if (!result.ok || !result.stdout) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result.stdout;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function syncMemory({ scope, projectRoot, paths, message }) {
|
|
255
|
+
const repoCandidate = getGlobalRepoRoot();
|
|
256
|
+
const repoRoot = await getGitRepoRoot(repoCandidate);
|
|
257
|
+
const scopeRoot = getScopeRoot(scope, projectRoot);
|
|
258
|
+
|
|
259
|
+
if (!repoRoot) {
|
|
260
|
+
return {
|
|
261
|
+
attempted: false,
|
|
262
|
+
skipped: true,
|
|
263
|
+
reason: "not_git_repo",
|
|
264
|
+
repoRoot: repoCandidate,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const repoRelativePaths = paths.map((item) => {
|
|
269
|
+
const relativePath =
|
|
270
|
+
path.relative(repoRoot, assertPathInScope(scopeRoot, item)).split(path.sep).join(path.posix.sep) || ".";
|
|
271
|
+
|
|
272
|
+
if (relativePath === ".") {
|
|
273
|
+
throw new Error("path must point to a memory file or directory inside the Lagoon root.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (relativePath.split("/").includes(".git")) {
|
|
277
|
+
throw new Error("path must not target git internals.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return relativePath;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const addResult = await runGit(["add", "--", ...repoRelativePaths], repoRoot);
|
|
284
|
+
|
|
285
|
+
if (!addResult.ok) {
|
|
286
|
+
throw new Error(addResult.stderr || "git add failed.");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const diffResult = await runGit(["diff", "--cached", "--name-only", "--", ...repoRelativePaths], repoRoot);
|
|
290
|
+
|
|
291
|
+
if (!diffResult.ok) {
|
|
292
|
+
throw new Error(diffResult.stderr || "git diff failed.");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!diffResult.stdout) {
|
|
296
|
+
return {
|
|
297
|
+
attempted: true,
|
|
298
|
+
skipped: true,
|
|
299
|
+
reason: "no_changes_to_commit",
|
|
300
|
+
repoRoot,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const commitResult = await runGit(["commit", "-m", message, "--", ...repoRelativePaths], repoRoot);
|
|
305
|
+
|
|
306
|
+
if (!commitResult.ok) {
|
|
307
|
+
throw new Error(commitResult.stderr || "git commit failed.");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const upstreamResult = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot);
|
|
311
|
+
|
|
312
|
+
if (!upstreamResult.ok) {
|
|
313
|
+
return {
|
|
314
|
+
attempted: true,
|
|
315
|
+
committed: true,
|
|
316
|
+
pushed: false,
|
|
317
|
+
reason: "no_upstream",
|
|
318
|
+
repoRoot,
|
|
319
|
+
commitSummary: commitResult.stdout,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let pushResult = await runGit(["push"], repoRoot);
|
|
324
|
+
|
|
325
|
+
if (!pushResult.ok) {
|
|
326
|
+
const pullResult = await runGit(["pull", "--rebase", "--autostash"], repoRoot);
|
|
327
|
+
|
|
328
|
+
if (!pullResult.ok) {
|
|
329
|
+
return {
|
|
330
|
+
attempted: true,
|
|
331
|
+
committed: true,
|
|
332
|
+
pushed: false,
|
|
333
|
+
reason: "pull_rebase_failed",
|
|
334
|
+
repoRoot,
|
|
335
|
+
commitSummary: commitResult.stdout,
|
|
336
|
+
pushError: pushResult.stderr,
|
|
337
|
+
pullError: pullResult.stderr,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
pushResult = await runGit(["push"], repoRoot);
|
|
342
|
+
|
|
343
|
+
if (!pushResult.ok) {
|
|
344
|
+
return {
|
|
345
|
+
attempted: true,
|
|
346
|
+
committed: true,
|
|
347
|
+
pushed: false,
|
|
348
|
+
reason: "push_failed",
|
|
349
|
+
repoRoot,
|
|
350
|
+
commitSummary: commitResult.stdout,
|
|
351
|
+
pushError: pushResult.stderr,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
attempted: true,
|
|
358
|
+
committed: true,
|
|
359
|
+
pushed: true,
|
|
360
|
+
repoRoot,
|
|
361
|
+
commitSummary: commitResult.stdout,
|
|
362
|
+
pushSummary: pushResult.stdout || pushResult.stderr,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function getDefaultSyncPaths(scope, projectRoot) {
|
|
367
|
+
const scopeRoot = getScopeRoot(scope, projectRoot);
|
|
368
|
+
const defaultNames =
|
|
369
|
+
scope === "project" ? [...DEFAULT_PROJECT_FILES, "events"] : [...DEFAULT_GLOBAL_FILES, "events", "projects"];
|
|
370
|
+
const paths = [];
|
|
371
|
+
|
|
372
|
+
for (const name of defaultNames) {
|
|
373
|
+
const targetPath = path.join(scopeRoot, name);
|
|
374
|
+
|
|
375
|
+
if (await statIfExists(targetPath)) {
|
|
376
|
+
paths.push(targetPath);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return paths;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function listDirectoryEntries(scope, relativePath, projectRoot) {
|
|
384
|
+
const root = getScopeRoot(scope, projectRoot);
|
|
385
|
+
const targetPath =
|
|
386
|
+
relativePath && relativePath !== "."
|
|
387
|
+
? resolveScopedPath(scope, relativePath, projectRoot).absolutePath
|
|
388
|
+
: root;
|
|
389
|
+
|
|
390
|
+
const directoryStats = await statIfExists(targetPath);
|
|
391
|
+
|
|
392
|
+
if (!directoryStats) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!directoryStats.isDirectory()) {
|
|
397
|
+
throw new Error("path must point to a directory.");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
401
|
+
const relativeBase =
|
|
402
|
+
targetPath === root ? "" : path.relative(root, targetPath).split(path.sep).join(path.posix.sep);
|
|
403
|
+
|
|
404
|
+
return entries
|
|
405
|
+
.filter((entry) => entry.name !== ".git")
|
|
406
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
407
|
+
.map((entry) => ({
|
|
408
|
+
name: entry.name,
|
|
409
|
+
path: relativeBase ? path.posix.join(relativeBase, entry.name) : entry.name,
|
|
410
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function walkMarkdownFiles(rootPath, currentPath = "") {
|
|
415
|
+
const absolutePath = currentPath ? path.join(rootPath, currentPath) : rootPath;
|
|
416
|
+
const stats = await statIfExists(absolutePath);
|
|
417
|
+
|
|
418
|
+
if (!stats || !stats.isDirectory()) {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
|
423
|
+
const results = [];
|
|
424
|
+
|
|
425
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
426
|
+
if (entry.name === ".git") {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const entryPath = currentPath ? path.posix.join(currentPath, entry.name) : entry.name;
|
|
431
|
+
|
|
432
|
+
if (entry.isDirectory()) {
|
|
433
|
+
results.push(...(await walkMarkdownFiles(rootPath, entryPath)));
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
438
|
+
results.push(entryPath);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return results;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseEventHeading(line) {
|
|
446
|
+
const heading = line.replace(/^##\s+/, "").trim();
|
|
447
|
+
const match = heading.match(/^(\S+)(?:\s+[—-]\s+(.+))?$/);
|
|
448
|
+
|
|
449
|
+
if (!match) {
|
|
450
|
+
return {
|
|
451
|
+
id: normalizeCue(heading) || heading,
|
|
452
|
+
heading,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
id: match[1],
|
|
458
|
+
heading: match[2]?.trim() || heading,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function extractCuesFromEventLines(lines) {
|
|
463
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
464
|
+
const match = lines[index].trim().match(/^Cues:\s*(.*)$/i);
|
|
465
|
+
|
|
466
|
+
if (!match) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const cues = splitInlineCueValues(match[1]);
|
|
471
|
+
|
|
472
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
473
|
+
const line = lines[cursor];
|
|
474
|
+
const bullet = line.match(/^\s*-\s+(.+?)\s*$/);
|
|
475
|
+
|
|
476
|
+
if (bullet) {
|
|
477
|
+
cues.push(bullet[1]);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!line.trim()) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return normalizeCueList(cues);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseEventBlocks(content) {
|
|
495
|
+
const lines = content.split(/\r?\n/);
|
|
496
|
+
const headingIndexes = [];
|
|
497
|
+
|
|
498
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
499
|
+
if (/^##\s+/.test(lines[index])) {
|
|
500
|
+
headingIndexes.push(index);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return headingIndexes.map((startIndex, index) => {
|
|
505
|
+
const endIndex = headingIndexes[index + 1] ?? lines.length;
|
|
506
|
+
const blockLines = lines.slice(startIndex, endIndex);
|
|
507
|
+
const heading = parseEventHeading(blockLines[0]);
|
|
508
|
+
const bodyLines = blockLines.slice(1);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
id: heading.id,
|
|
512
|
+
heading: heading.heading,
|
|
513
|
+
cues: extractCuesFromEventLines(bodyLines),
|
|
514
|
+
content: blockLines.join("\n").trim(),
|
|
515
|
+
body: bodyLines.join("\n").trim(),
|
|
516
|
+
startLine: startIndex + 1,
|
|
517
|
+
};
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function buildEventIndex(markdownContent, markdownPath) {
|
|
522
|
+
return {
|
|
523
|
+
version: EVENT_INDEX_VERSION,
|
|
524
|
+
path: path.posix.basename(markdownPath),
|
|
525
|
+
events: parseEventBlocks(markdownContent).map((event) => ({
|
|
526
|
+
id: event.id,
|
|
527
|
+
heading: event.heading,
|
|
528
|
+
cues: event.cues,
|
|
529
|
+
})),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function writeEventIndex(markdownPath, markdownRelativePath) {
|
|
534
|
+
const content = (await readFileIfExists(markdownPath)) || "";
|
|
535
|
+
const indexPath = markdownPath.replace(/\.md$/, ".index.json");
|
|
536
|
+
const index = buildEventIndex(content, markdownRelativePath);
|
|
537
|
+
|
|
538
|
+
await fs.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
|
|
539
|
+
|
|
540
|
+
return indexPath;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function createQueryInfo(query) {
|
|
544
|
+
const original = requireNonEmptyString(query, "query");
|
|
545
|
+
const tokens = tokenizeSearchText(original);
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
original,
|
|
549
|
+
normalized: tokens.join(" "),
|
|
550
|
+
cue: tokens.join("-"),
|
|
551
|
+
tokens,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function stripPlural(value) {
|
|
556
|
+
return value.length > 3 && value.endsWith("s") ? value.slice(0, -1) : value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function editDistance(left, right) {
|
|
560
|
+
if (left === right) {
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (!left.length) {
|
|
565
|
+
return right.length;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!right.length) {
|
|
569
|
+
return left.length;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
573
|
+
|
|
574
|
+
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
|
|
575
|
+
const current = [leftIndex + 1];
|
|
576
|
+
|
|
577
|
+
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
|
|
578
|
+
const insertion = current[rightIndex] + 1;
|
|
579
|
+
const deletion = previous[rightIndex + 1] + 1;
|
|
580
|
+
const substitution = previous[rightIndex] + (left[leftIndex] === right[rightIndex] ? 0 : 1);
|
|
581
|
+
current.push(Math.min(insertion, deletion, substitution));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
previous = current;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return previous[right.length];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function tokenSimilarity(left, right) {
|
|
591
|
+
if (!left || !right) {
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const normalizedLeft = stripPlural(left);
|
|
596
|
+
const normalizedRight = stripPlural(right);
|
|
597
|
+
|
|
598
|
+
if (normalizedLeft === normalizedRight) {
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (
|
|
603
|
+
(normalizedLeft.length >= 4 && normalizedRight.includes(normalizedLeft)) ||
|
|
604
|
+
(normalizedRight.length >= 4 && normalizedLeft.includes(normalizedRight))
|
|
605
|
+
) {
|
|
606
|
+
return 0.9;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const maxLength = Math.max(normalizedLeft.length, normalizedRight.length);
|
|
610
|
+
|
|
611
|
+
if (maxLength < 4) {
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return 1 - editDistance(normalizedLeft, normalizedRight) / maxLength;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function scoreSearchValues(queryInfo, values) {
|
|
619
|
+
const normalizedValues = values
|
|
620
|
+
.map((value) => normalizeSearchText(value))
|
|
621
|
+
.filter(Boolean);
|
|
622
|
+
|
|
623
|
+
if (!queryInfo.tokens.length || !normalizedValues.length) {
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const phraseScore = normalizedValues.reduce((bestScore, value) => {
|
|
628
|
+
if (value === queryInfo.normalized || value.replace(/\s+/g, "-") === queryInfo.cue) {
|
|
629
|
+
return Math.max(bestScore, 1);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (queryInfo.normalized && value.includes(queryInfo.normalized)) {
|
|
633
|
+
return Math.max(bestScore, 0.92);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (queryInfo.normalized && queryInfo.normalized.includes(value)) {
|
|
637
|
+
return Math.max(bestScore, 0.82);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return bestScore;
|
|
641
|
+
}, 0);
|
|
642
|
+
|
|
643
|
+
const valueTokens = [...new Set(normalizedValues.flatMap((value) => value.split(/\s+/)))];
|
|
644
|
+
let exactMatches = 0;
|
|
645
|
+
let fuzzyMatches = 0;
|
|
646
|
+
|
|
647
|
+
for (const queryToken of queryInfo.tokens) {
|
|
648
|
+
const bestTokenScore = valueTokens.reduce(
|
|
649
|
+
(bestScore, valueToken) => Math.max(bestScore, tokenSimilarity(queryToken, valueToken)),
|
|
650
|
+
0,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
if (bestTokenScore >= 1) {
|
|
654
|
+
exactMatches += 1;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (bestTokenScore >= 0.72) {
|
|
658
|
+
fuzzyMatches += 1;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const exactScore = (exactMatches / queryInfo.tokens.length) * 0.86;
|
|
663
|
+
const fuzzyScore = (fuzzyMatches / queryInfo.tokens.length) * 0.7;
|
|
664
|
+
|
|
665
|
+
return Math.max(phraseScore, exactScore, fuzzyScore);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function createFallbackSnippet(content) {
|
|
669
|
+
const compact = content.replace(/\s+/g, " ").trim();
|
|
670
|
+
return compact.length > 220 ? `${compact.slice(0, 217)}...` : compact;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function createSearchSnippet(content, query) {
|
|
674
|
+
const haystack = content.toLowerCase();
|
|
675
|
+
const needle = query.toLowerCase();
|
|
676
|
+
const index = haystack.indexOf(needle);
|
|
677
|
+
|
|
678
|
+
if (index < 0) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
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();
|
|
691
|
+
|
|
692
|
+
return `${prefix}${snippet}${suffix}`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
|
|
696
|
+
const target = resolveScopedPath(scope, relativePath, projectRoot);
|
|
697
|
+
const content = await readFileIfExists(target.absolutePath);
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
scope,
|
|
701
|
+
root: target.root,
|
|
702
|
+
path: target.path,
|
|
703
|
+
found: content !== null,
|
|
704
|
+
content,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function writeMemoryFile({ scope, path: relativePath, content, projectRoot, sync = true }) {
|
|
709
|
+
const target = resolveScopedPath(scope, relativePath, projectRoot);
|
|
710
|
+
const nextContent = ensureTrailingNewline(requireNonEmptyString(content, "content"));
|
|
711
|
+
|
|
712
|
+
await ensureParentDirectory(target.absolutePath);
|
|
713
|
+
await fs.writeFile(target.absolutePath, nextContent, "utf8");
|
|
714
|
+
|
|
715
|
+
const syncResult = sync
|
|
716
|
+
? await syncMemory({
|
|
717
|
+
scope,
|
|
718
|
+
projectRoot,
|
|
719
|
+
paths: [target.absolutePath],
|
|
720
|
+
message: `hippocamp: update ${scope} memory ${target.path}`,
|
|
721
|
+
})
|
|
722
|
+
: {
|
|
723
|
+
attempted: false,
|
|
724
|
+
skipped: true,
|
|
725
|
+
reason: "sync_disabled",
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
scope,
|
|
730
|
+
root: target.root,
|
|
731
|
+
path: target.path,
|
|
732
|
+
absolutePath: target.absolutePath,
|
|
733
|
+
bytes: Buffer.byteLength(nextContent, "utf8"),
|
|
734
|
+
sync: syncResult,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function appendEvent({
|
|
739
|
+
scope = "project",
|
|
740
|
+
content,
|
|
741
|
+
cues,
|
|
742
|
+
projectRoot,
|
|
743
|
+
sync = true,
|
|
744
|
+
title,
|
|
745
|
+
date = new Date().toISOString().slice(0, 10),
|
|
746
|
+
timestamp = new Date().toISOString(),
|
|
747
|
+
}) {
|
|
748
|
+
const body = requireNonEmptyString(content, "content");
|
|
749
|
+
const normalizedCues = normalizeCueList(cues);
|
|
750
|
+
const eventBody =
|
|
751
|
+
normalizedCues.length && !hasCuesSection(body)
|
|
752
|
+
? `${formatCuesSection(normalizedCues)}\n\n${body.trim()}`
|
|
753
|
+
: body.trim();
|
|
754
|
+
const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
|
|
755
|
+
const eventBlock = `${heading}\n\n${eventBody}\n`;
|
|
756
|
+
const relativePath = `events/${date}.md`;
|
|
757
|
+
const target = resolveScopedPath(scope, relativePath, projectRoot);
|
|
758
|
+
const existing = await readFileIfExists(target.absolutePath);
|
|
759
|
+
const nextContent = existing?.trim()
|
|
760
|
+
? `${existing.trimEnd()}\n\n${eventBlock.trimEnd()}\n`
|
|
761
|
+
: `# Events — ${date}\n\n${eventBlock.trimEnd()}\n`;
|
|
762
|
+
|
|
763
|
+
await ensureParentDirectory(target.absolutePath);
|
|
764
|
+
await fs.writeFile(target.absolutePath, nextContent, "utf8");
|
|
765
|
+
const indexPath = await writeEventIndex(target.absolutePath, target.path);
|
|
766
|
+
|
|
767
|
+
const syncResult = sync
|
|
768
|
+
? await syncMemory({
|
|
769
|
+
scope,
|
|
770
|
+
projectRoot,
|
|
771
|
+
paths: [target.absolutePath, indexPath],
|
|
772
|
+
message: `hippocamp: append ${scope} event ${date}`,
|
|
773
|
+
})
|
|
774
|
+
: {
|
|
775
|
+
attempted: false,
|
|
776
|
+
skipped: true,
|
|
777
|
+
reason: "sync_disabled",
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
scope,
|
|
782
|
+
root: target.root,
|
|
783
|
+
path: target.path,
|
|
784
|
+
timestamp,
|
|
785
|
+
title: title?.trim() || null,
|
|
786
|
+
cues: parseEventBlocks(eventBlock)[0]?.cues || [],
|
|
787
|
+
indexPath,
|
|
788
|
+
sync: syncResult,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function readJsonFileIfExists(filePath) {
|
|
793
|
+
const content = await readFileIfExists(filePath);
|
|
794
|
+
|
|
795
|
+
if (content === null) {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return JSON.parse(content);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function listEventFiles(root) {
|
|
803
|
+
const eventsDir = path.join(root, "events");
|
|
804
|
+
const stats = await statIfExists(eventsDir);
|
|
805
|
+
|
|
806
|
+
if (!stats || !stats.isDirectory()) {
|
|
807
|
+
return {
|
|
808
|
+
eventsDir,
|
|
809
|
+
indexes: [],
|
|
810
|
+
markdownFiles: [],
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const entries = await fs.readdir(eventsDir, { withFileTypes: true });
|
|
815
|
+
const files = entries
|
|
816
|
+
.filter((entry) => entry.isFile())
|
|
817
|
+
.map((entry) => entry.name)
|
|
818
|
+
.sort((left, right) => right.localeCompare(left));
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
eventsDir,
|
|
822
|
+
indexes: files.filter((name) => name.endsWith(".index.json")),
|
|
823
|
+
markdownFiles: files.filter((name) => name.endsWith(".md")),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
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 = [];
|
|
853
|
+
|
|
854
|
+
for (const indexName of eventFiles.indexes) {
|
|
855
|
+
const indexPath = path.join(eventFiles.eventsDir, indexName);
|
|
856
|
+
let index;
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
index = await readJsonFileIfExists(indexPath);
|
|
860
|
+
} catch {
|
|
861
|
+
index = null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const markdownName = typeof index?.path === "string" ? index.path : indexName.replace(/\.index\.json$/, ".md");
|
|
865
|
+
|
|
866
|
+
if (!index || !Array.isArray(index.events)) {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
indexedMarkdownFiles.add(markdownName);
|
|
871
|
+
|
|
872
|
+
for (const event of index.events) {
|
|
873
|
+
const eventScore = scoreEventEntry(queryInfo, event);
|
|
874
|
+
|
|
875
|
+
if (eventScore.score < EVENT_SEARCH_THRESHOLD) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
candidates.push({
|
|
880
|
+
scope,
|
|
881
|
+
path: `events/${markdownName}`,
|
|
882
|
+
markdownPath: path.join(eventFiles.eventsDir, markdownName),
|
|
883
|
+
id: event.id,
|
|
884
|
+
heading: event.heading,
|
|
885
|
+
cues: normalizeCueList(event.cues),
|
|
886
|
+
score: Math.round(eventScore.score),
|
|
887
|
+
match: eventScore.match,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
for (const markdownName of eventFiles.markdownFiles) {
|
|
893
|
+
if (indexedMarkdownFiles.has(markdownName)) {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const markdownPath = path.join(eventFiles.eventsDir, markdownName);
|
|
898
|
+
const content = await readFileIfExists(markdownPath);
|
|
899
|
+
|
|
900
|
+
if (content === null) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
for (const event of parseEventBlocks(content)) {
|
|
905
|
+
const eventScore = scoreEventEntry(queryInfo, event);
|
|
906
|
+
|
|
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
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const results = [];
|
|
925
|
+
const sortedCandidates = candidates.sort((left, right) => right.score - left.score).slice(0, 60);
|
|
926
|
+
|
|
927
|
+
for (const candidate of sortedCandidates) {
|
|
928
|
+
const block = await getEventBlockById(candidate.markdownPath, candidate.id);
|
|
929
|
+
|
|
930
|
+
if (!block) {
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
|
|
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
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return {
|
|
947
|
+
scannedFiles: eventFiles.indexes.length + eventFiles.markdownFiles.length - indexedMarkdownFiles.size,
|
|
948
|
+
results,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
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;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
scannedFiles += 1;
|
|
967
|
+
|
|
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;
|
|
973
|
+
|
|
974
|
+
if (score < EVENT_SEARCH_THRESHOLD) {
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
|
|
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
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
scannedFiles,
|
|
989
|
+
results,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function searchMemory({ query, scope = "both", projectRoot, maxResults = 10 }) {
|
|
994
|
+
const queryInfo = createQueryInfo(query);
|
|
995
|
+
const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 10, 20));
|
|
996
|
+
const scopes = scope === "both" ? ["global", "project"] : [scope];
|
|
997
|
+
const results = [];
|
|
998
|
+
let scannedFiles = 0;
|
|
999
|
+
|
|
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 });
|
|
1004
|
+
|
|
1005
|
+
scannedFiles += eventSearch.scannedFiles + markdownSearch.scannedFiles;
|
|
1006
|
+
results.push(...eventSearch.results, ...markdownSearch.results);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const rankedResults = results.sort((left, right) => right.score - left.score).slice(0, clampedMaxResults);
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
query: queryInfo.original,
|
|
1013
|
+
scannedFiles,
|
|
1014
|
+
results: rankedResults,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function wakeUp({ projectRoot } = {}) {
|
|
1019
|
+
const globalRoot = getScopeRoot("global", projectRoot);
|
|
1020
|
+
const resolvedProjectRoot = getProjectRoot(projectRoot);
|
|
1021
|
+
const projectSlug = getProjectSlug(projectRoot);
|
|
1022
|
+
const projectMemoryRoot = getScopeRoot("project", projectRoot);
|
|
1023
|
+
const globalFiles = [];
|
|
1024
|
+
const projectFiles = [];
|
|
1025
|
+
const missing = [];
|
|
1026
|
+
|
|
1027
|
+
for (const relativePath of DEFAULT_GLOBAL_FILES) {
|
|
1028
|
+
const file = await readMemoryFile({ scope: "global", path: relativePath, projectRoot });
|
|
1029
|
+
|
|
1030
|
+
if (!file.found) {
|
|
1031
|
+
missing.push(`global:${relativePath}`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
globalFiles.push({
|
|
1036
|
+
path: relativePath,
|
|
1037
|
+
content: file.content,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
for (const relativePath of DEFAULT_PROJECT_FILES) {
|
|
1042
|
+
const file = await readMemoryFile({ scope: "project", path: relativePath, projectRoot });
|
|
1043
|
+
|
|
1044
|
+
if (!file.found) {
|
|
1045
|
+
missing.push(`project:${relativePath}`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
projectFiles.push({
|
|
1050
|
+
path: relativePath,
|
|
1051
|
+
content: file.content,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const sections = [
|
|
1056
|
+
"# Hippocamp Wake-Up",
|
|
1057
|
+
"",
|
|
1058
|
+
`Global root: ${globalRoot}`,
|
|
1059
|
+
`Project root: ${resolvedProjectRoot}`,
|
|
1060
|
+
`Project slug: ${projectSlug}`,
|
|
1061
|
+
`Project memory root: ${projectMemoryRoot}`,
|
|
1062
|
+
];
|
|
1063
|
+
|
|
1064
|
+
if (globalFiles.length) {
|
|
1065
|
+
sections.push("", "## Global Memory");
|
|
1066
|
+
|
|
1067
|
+
for (const file of globalFiles) {
|
|
1068
|
+
sections.push("", `### ${file.path}`, "", file.content.trimEnd());
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (projectFiles.length) {
|
|
1073
|
+
sections.push("", "## Project Memory");
|
|
1074
|
+
|
|
1075
|
+
for (const file of projectFiles) {
|
|
1076
|
+
sections.push("", `### ${file.path}`, "", file.content.trimEnd());
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (missing.length) {
|
|
1081
|
+
sections.push("", "## Missing Files", "", ...missing.map((item) => `- ${item}`));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
globalRoot,
|
|
1086
|
+
projectRoot: resolvedProjectRoot,
|
|
1087
|
+
projectSlug,
|
|
1088
|
+
projectMemoryRoot,
|
|
1089
|
+
missing,
|
|
1090
|
+
globalFiles: globalFiles.map((file) => file.path),
|
|
1091
|
+
projectFiles: projectFiles.map((file) => file.path),
|
|
1092
|
+
text: `${sections.join("\n").trim()}\n`,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
module.exports = {
|
|
1097
|
+
getGlobalRepoRoot,
|
|
1098
|
+
getGlobalRoot,
|
|
1099
|
+
getProjectRoot,
|
|
1100
|
+
getProjectSlug,
|
|
1101
|
+
getScopeRoot,
|
|
1102
|
+
readMemoryFile,
|
|
1103
|
+
writeMemoryFile,
|
|
1104
|
+
appendEvent,
|
|
1105
|
+
listDirectoryEntries,
|
|
1106
|
+
searchMemory,
|
|
1107
|
+
syncMemory,
|
|
1108
|
+
getDefaultSyncPaths,
|
|
1109
|
+
wakeUp,
|
|
1110
|
+
};
|