hippocamp 0.2.6 → 0.2.7
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/publish.yml +25 -0
- package/AGENTS.md +4 -4
- package/README.md +13 -1
- package/assets/github-actions/hippocamp-dream.yml +2 -2
- package/assets/railway/run-dream.sh +2 -2
- package/docs/dream.md +1 -1
- package/docs/how-it-works.md +4 -4
- package/docs/setup.md +1 -0
- package/package.json +1 -1
- package/scripts/eval-lagoon.cjs +3 -3
- package/scripts/hippocamp-dream.cjs +2 -2
- package/scripts/hippocamp-mcp.cjs +5 -5
- package/scripts/hippocamp-memory.cjs +86 -59
- package/scripts/install-claude.cjs +1 -1
- package/scripts/install-codex.cjs +1 -1
- package/scripts/install-grok.cjs +1 -1
- package/skills/hippocamp-memory/SKILL.md +3 -3
- package/test/mcp-params.test.cjs +10 -10
- package/test/memory-efficiency.test.cjs +180 -16
- package/test/sync-memory.test.cjs +2 -4
|
@@ -4,6 +4,7 @@ on:
|
|
|
4
4
|
release:
|
|
5
5
|
types:
|
|
6
6
|
- published
|
|
7
|
+
workflow_dispatch:
|
|
7
8
|
|
|
8
9
|
permissions:
|
|
9
10
|
contents: read
|
|
@@ -11,6 +12,7 @@ permissions:
|
|
|
11
12
|
|
|
12
13
|
jobs:
|
|
13
14
|
publish:
|
|
15
|
+
if: github.event_name == 'release'
|
|
14
16
|
runs-on: ubuntu-latest
|
|
15
17
|
steps:
|
|
16
18
|
- uses: actions/checkout@v6
|
|
@@ -25,3 +27,26 @@ jobs:
|
|
|
25
27
|
- name: Verify release tag matches package version
|
|
26
28
|
run: test "$GITHUB_REF_NAME" = "v$(node -p "require('./package.json').version")"
|
|
27
29
|
- run: npm publish
|
|
30
|
+
|
|
31
|
+
github-packages:
|
|
32
|
+
# Mirror of the npm package on GitHub Packages, so the repository lists it.
|
|
33
|
+
# npmjs.com stays the install path; GitHub's npm registry needs a token to install.
|
|
34
|
+
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
permissions:
|
|
37
|
+
contents: read
|
|
38
|
+
packages: write
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v6
|
|
41
|
+
- uses: actions/setup-node@v6
|
|
42
|
+
with:
|
|
43
|
+
node-version: "24"
|
|
44
|
+
registry-url: "https://npm.pkg.github.com"
|
|
45
|
+
package-manager-cache: false
|
|
46
|
+
- name: Scope the package name for GitHub Packages
|
|
47
|
+
run: |
|
|
48
|
+
npm pkg set name="@${GITHUB_REPOSITORY_OWNER}/hippocamp"
|
|
49
|
+
npm pkg set repository.url="https://github.com/${GITHUB_REPOSITORY}"
|
|
50
|
+
- run: npm publish
|
|
51
|
+
env:
|
|
52
|
+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/AGENTS.md
CHANGED
|
@@ -47,15 +47,15 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
|
|
|
47
47
|
- Sync paths must stay under the selected Lagoon memory root.
|
|
48
48
|
- Global memory lives under `HIPPOCAMP_GLOBAL_ROOT/`.
|
|
49
49
|
- Project memory lives under `HIPPOCAMP_GLOBAL_ROOT/projects/<slug>/`.
|
|
50
|
-
- The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory.
|
|
51
|
-
- Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json`
|
|
50
|
+
- The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory. An existing folder with nothing but `.git` has no project; project scope then resolves to the global root.
|
|
51
|
+
- Event logs are append-only daily Markdown files under `events/YYYY-MM-DD.md`, with sibling `events/YYYY-MM-DD.index.json` keyword indexes.
|
|
52
52
|
- Curated files such as `current_state.md` and `open_threads.md` use explicit file writes.
|
|
53
|
-
- MCP `append_event` requires a title and 1-8
|
|
53
|
+
- MCP `append_event` requires a title and 1-8 keywords; MCP `write_memory_file` rejects paths under `events/`; an explicit `projectRoot` must be a directory inside a Git repository.
|
|
54
54
|
- `append_event` auto-stamps `Agent:` / `Session:` provenance when resolvable (installer env, MCP client name, or process session id). No manual user config.
|
|
55
55
|
- Default sync is automatic through the local Lagoon repo.
|
|
56
56
|
- Local MCP mode must not require `GITHUB_TOKEN`, GitHub App auth, or a cloud service.
|
|
57
57
|
- Memory must not duplicate facts already tracked by GitHub commits, PRs, issues, reviews, or CI. Store references plus the missing rationale, preference, assumption, or follow-up context instead.
|
|
58
|
-
- Dream compaction sends `current_state.md`, `open_threads.md`, and bounded
|
|
58
|
+
- Dream compaction sends `current_state.md`, `open_threads.md`, and bounded keyword-indexed event evidence for open-thread decisions. It must not dump full event logs, keyword indexes, or raw GitHub-owned facts into the model prompt.
|
|
59
59
|
- Dream must compact coherent Markdown snapshots and fail without writing when the snapshot cannot fit. It must not crop wake-up files.
|
|
60
60
|
- The Dream GitHub Actions workflow is a Lagoon repo template. It should remain schedule-only and create PRs rather than pushing directly to `main`. Auto-merge is allowed only for repositories named `lagoon` after exact changed-file validation.
|
|
61
61
|
- The Railway Dream deployment is optional infrastructure. It must exit after each run, keep credentials in environment variables, and preserve the same PR policy.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Hippocamp
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/hippocamp) [](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml)
|
|
3
|
+
[](https://www.npmjs.com/package/hippocamp) [](https://www.npmjs.com/package/hippocamp) [](https://github.com/guillaumegay13/hippocamp/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<img src="./assets/brand/hippocamp-mascot.png" alt="Hippocamp mascot" width="220" />
|
|
@@ -30,6 +30,18 @@ A replay of a real session. Neither prompt mentions Hippocamp.
|
|
|
30
30
|
- **Files you own.** Plain Markdown, readable without any UI, synced across machines with your normal Git credentials.
|
|
31
31
|
- **Nothing to sign up for.** No vendor that can change terms or shut down.
|
|
32
32
|
|
|
33
|
+
## Compared with other memory tools
|
|
34
|
+
|
|
35
|
+
| | **Hippocamp** | [Mem0](https://docs.mem0.ai) | [Zep](https://www.getzep.com) | [Graphiti](https://github.com/getzep/graphiti) | [Letta Code](https://github.com/letta-ai/letta-code) | [Basic Memory](https://github.com/basicmachines-co/basic-memory) | Claude Code / Codex built-in |
|
|
36
|
+
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
|
37
|
+
| Agent agnostic (MCP server) | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
|
|
38
|
+
| Plain files you own | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
|
39
|
+
| Auditable history | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
|
|
40
|
+
| No extra account, API key, or model | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
|
41
|
+
| Syncs through your own Git | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
|
|
42
|
+
| Always free | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
|
43
|
+
| Open source | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
|
44
|
+
|
|
33
45
|
## Results
|
|
34
46
|
|
|
35
47
|
Reproducible with the scripts in this repo.
|
|
@@ -144,8 +144,8 @@ jobs:
|
|
|
144
144
|
"",
|
|
145
145
|
"Policy:",
|
|
146
146
|
"- touched only `current_state.md` and `open_threads.md`",
|
|
147
|
-
"- used bounded
|
|
148
|
-
"- did not modify append-only events or
|
|
147
|
+
"- used bounded keyword-indexed event evidence for thread decisions",
|
|
148
|
+
"- did not modify append-only events or keyword indexes",
|
|
149
149
|
"- moved historical detail out of wake-up context",
|
|
150
150
|
];
|
|
151
151
|
console.log(lines.join("\n"));
|
|
@@ -153,8 +153,8 @@ const lines = [
|
|
|
153
153
|
"",
|
|
154
154
|
"Policy:",
|
|
155
155
|
"- touched only `current_state.md` and `open_threads.md`",
|
|
156
|
-
"- used bounded
|
|
157
|
-
"- did not modify append-only events or
|
|
156
|
+
"- used bounded keyword-indexed event evidence for thread decisions",
|
|
157
|
+
"- did not modify append-only events or keyword indexes",
|
|
158
158
|
"- moved historical detail out of wake-up context",
|
|
159
159
|
];
|
|
160
160
|
console.log(lines.join("\n"));
|
package/docs/dream.md
CHANGED
|
@@ -9,7 +9,7 @@ projects/<project-slug>/current_state.md
|
|
|
9
9
|
projects/<project-slug>/open_threads.md
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
It does not dump event logs into the model prompt, does not rewrite append-only events, and does not run during normal MCP wake-up. When a project is over the threshold, Dream builds a capped thread evidence pack by searching
|
|
12
|
+
It does not dump event logs into the model prompt, does not rewrite append-only events, and does not run during normal MCP wake-up. When a project is over the threshold, Dream builds a capped thread evidence pack by searching keyword-indexed project events for `open_threads.md` bullets. The evidence budget follows `--target-chars`, so the same size target controls both the desired output and the supporting context.
|
|
13
13
|
|
|
14
14
|
Dream uses an Eve-style compaction loop. It rewrites the complete curated snapshot, measures the result, and compacts that coherent result again when it is still over the target. It never crops a wake-up file. After three passes, it fails without writing either file if the snapshot still does not fit.
|
|
15
15
|
|
package/docs/how-it-works.md
CHANGED
|
@@ -67,18 +67,18 @@ Typical agent flow:
|
|
|
67
67
|
1. Call `wake_up` at the start of a top-level task.
|
|
68
68
|
2. Read the returned global and project memory.
|
|
69
69
|
3. Use `search_memory` for task-specific recall after wake-up.
|
|
70
|
-
4. Use `append_event` for meaningful milestones, with a title and 1-8 concise
|
|
70
|
+
4. Use `append_event` for meaningful milestones, with a title and 1-8 concise keywords.
|
|
71
71
|
5. Update curated files like `current_state.md` and `open_threads.md` before finishing.
|
|
72
72
|
|
|
73
73
|
Writes sync by default. If sync fails or is skipped, call `sync_memory`.
|
|
74
74
|
|
|
75
|
-
Event writes update a sibling `events/YYYY-MM-DD.index.json` file. The Markdown event remains the canonical memory; the sidecar is rebuildable. Search ranks indexed events and curated files with BM25 and typo tolerance ([MiniSearch](https://github.com/lucaong/minisearch)), weighting
|
|
75
|
+
Event writes update a sibling `events/YYYY-MM-DD.index.json` file. The Markdown event remains the canonical memory; the sidecar is rebuildable. Search ranks indexed events and curated files with BM25 and typo tolerance ([MiniSearch](https://github.com/lucaong/minisearch)), weighting keywords above titles and titles above bodies. It returns small coherent evidence blocks from indexed events; an event found only by its keywords or title returns its first paragraphs. If no indexed result is strong enough, it returns no event result instead of scanning whole event logs.
|
|
76
76
|
|
|
77
77
|
## Memory Rules
|
|
78
78
|
|
|
79
79
|
- Keep memory concise.
|
|
80
80
|
- Prefer curated summaries over raw event history.
|
|
81
|
-
- Give events a short `
|
|
81
|
+
- Give events a short `Keywords:` section; keywords weigh the most in search ranking. Older events with a `Cues:` section still search the same way.
|
|
82
82
|
- Do not duplicate GitHub-owned facts such as commits, PRs, issues, reviews, or CI results.
|
|
83
83
|
- Store artifact references plus the missing rationale, preference, assumption, or follow-up context.
|
|
84
84
|
- Use project scope for project-specific state.
|
|
@@ -92,7 +92,7 @@ Example event content:
|
|
|
92
92
|
Agent: claude
|
|
93
93
|
Session: mcp-a1b2c3d4
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Keywords:
|
|
96
96
|
- local-first
|
|
97
97
|
- token-free
|
|
98
98
|
- install-story
|
package/docs/setup.md
CHANGED
|
@@ -57,6 +57,7 @@ Environment variables are optional for local use:
|
|
|
57
57
|
- `HIPPOCAMP_GLOBAL_ROOT`: local Lagoon clone path. Default: `~/.lagoon`
|
|
58
58
|
- `HIPPOCAMP_PROJECT_ROOT`: project root used to infer the current project slug. Default: current working directory
|
|
59
59
|
- Git worktrees, including Conductor workspaces, resolve to the main repository name so every workspace shares the same project memory.
|
|
60
|
+
- An empty folder (nothing but `.git`, like a fresh agent session folder) has no project: project memory falls back to global memory, so these sessions start clean and stay searchable from everywhere.
|
|
60
61
|
- `MANIFEST_BASE_URL`: OpenAI-compatible base URL used by `hippocamp dream --write`
|
|
61
62
|
- `MANIFEST_API_KEY`: API key used by `hippocamp dream --write`
|
|
62
63
|
- `HIPPOCAMP_DREAM_MODEL`: model used by Dream. Default: `auto`
|
package/package.json
CHANGED
package/scripts/eval-lagoon.cjs
CHANGED
|
@@ -61,8 +61,9 @@ async function run(options) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const { cases } = JSON.parse(await fs.readFile(options.cases, "utf8"));
|
|
64
|
-
// searchMemory derives the project slug from the folder name, so a
|
|
65
|
-
//
|
|
64
|
+
// searchMemory derives the project slug from the folder name, so a temp path per
|
|
65
|
+
// slug reads that project's memory without touching any real repo. The folder is
|
|
66
|
+
// not created: an existing empty folder has no project and reads global memory.
|
|
66
67
|
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-lagoon-"));
|
|
67
68
|
const answerable = { count: 0, hit: 0, mrr: 0, chars: 0, topScores: [] };
|
|
68
69
|
const empty = { count: 0, correct: 0, topScores: [] };
|
|
@@ -74,7 +75,6 @@ async function run(options) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
const projectRoot = path.join(sandboxRoot, item.project);
|
|
77
|
-
await fs.mkdir(projectRoot, { recursive: true });
|
|
78
78
|
|
|
79
79
|
const search = await memory.searchMemory({
|
|
80
80
|
query: item.query,
|
|
@@ -272,7 +272,7 @@ async function buildThreadEvidence(report, { budgetChars }) {
|
|
|
272
272
|
const item = {
|
|
273
273
|
path: match.path,
|
|
274
274
|
heading: match.heading,
|
|
275
|
-
|
|
275
|
+
keywords: match.keywords || [],
|
|
276
276
|
score: match.score,
|
|
277
277
|
snippet: match.snippet,
|
|
278
278
|
};
|
|
@@ -348,7 +348,7 @@ function createPrompt({ report, targetChars, threadEvidence }) {
|
|
|
348
348
|
report.files["open_threads.md"].content.trim() || "# Open Threads",
|
|
349
349
|
"",
|
|
350
350
|
"## Thread Evidence",
|
|
351
|
-
"Evidence is retrieved from
|
|
351
|
+
"Evidence is retrieved from keyword-indexed project events. It is intentionally small and may be incomplete.",
|
|
352
352
|
`Evidence budget: ${threadEvidence.evidenceChars}/${threadEvidence.evidenceBudgetChars} chars`,
|
|
353
353
|
JSON.stringify(threadEvidence.items, null, 2),
|
|
354
354
|
].join("\n"),
|
|
@@ -114,23 +114,23 @@ async function main() {
|
|
|
114
114
|
"append_event",
|
|
115
115
|
{
|
|
116
116
|
description:
|
|
117
|
-
"Append a dated event entry under events/YYYY-MM-DD.md and update the sibling
|
|
117
|
+
"Append a dated event entry under events/YYYY-MM-DD.md and update the sibling keyword index. Requires a title and 1-8 short keywords for recall. Automatically stamps Agent/Session provenance when available. Store references to GitHub artifacts instead of copying their content.",
|
|
118
118
|
inputSchema: {
|
|
119
119
|
scope: z.enum(["global", "project"]).default("project"),
|
|
120
120
|
title: z.string().trim().min(1),
|
|
121
|
-
|
|
121
|
+
keywords: z.array(z.string().trim().min(1)).min(1).max(8),
|
|
122
122
|
content: z.string(),
|
|
123
123
|
sync: z.boolean().optional(),
|
|
124
124
|
projectRoot: projectRootSchema,
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
|
-
async ({ scope, title,
|
|
127
|
+
async ({ scope, title, keywords, content, sync, projectRoot }) => {
|
|
128
128
|
const clientVersion = server.server?.getClientVersion?.();
|
|
129
129
|
return toTextResult(
|
|
130
130
|
await memory.appendEvent({
|
|
131
131
|
scope,
|
|
132
132
|
title,
|
|
133
|
-
|
|
133
|
+
keywords,
|
|
134
134
|
content,
|
|
135
135
|
projectRoot,
|
|
136
136
|
sync,
|
|
@@ -162,7 +162,7 @@ async function main() {
|
|
|
162
162
|
"search_memory",
|
|
163
163
|
{
|
|
164
164
|
description:
|
|
165
|
-
"Search task-specific memory. Returns no results when the question's distinctive words appear in no memory, unless the question names a date. Event search uses
|
|
165
|
+
"Search task-specific memory. Returns no results when the question's distinctive words appear in no memory, unless the question names a date. Event search uses keyword indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions. A month name in the query favors events from that month. A YYYY-MM-DD date, today, yesterday, this/last week, or this/last month keeps only events from those dates (UTC); undated files still match. Default 5 results suits a single fact; ask for 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if results look incomplete.",
|
|
166
166
|
inputSchema: {
|
|
167
167
|
query: z.string(),
|
|
168
168
|
scope: z.enum(["global", "project", "both"]).default("both"),
|
|
@@ -24,7 +24,7 @@ const DEFAULT_PROJECT_FILES = [
|
|
|
24
24
|
|
|
25
25
|
const EVENT_INDEX_VERSION = 1;
|
|
26
26
|
const SEARCH_CANDIDATE_LIMIT = 60;
|
|
27
|
-
const SEARCH_FIELD_BOOST = {
|
|
27
|
+
const SEARCH_FIELD_BOOST = { keywords: 3, heading: 2 };
|
|
28
28
|
const SEARCH_FUZZY_DISTANCE = 0.2;
|
|
29
29
|
const SEARCH_INDEX_CACHE_LIMIT = 8;
|
|
30
30
|
const SEARCH_EXCERPT_MAX_CHARS = 1200;
|
|
@@ -148,10 +148,20 @@ function slugifyProjectName(value) {
|
|
|
148
148
|
return slug || "project";
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// An existing folder with nothing but .git (a fresh session or scratch folder) is not a project.
|
|
152
|
+
function isEmptyFolder(folder) {
|
|
153
|
+
try {
|
|
154
|
+
return fsSync.readdirSync(folder).every((name) => name === ".git");
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Null when the folder is empty: project memory then falls back to global memory.
|
|
151
161
|
function getProjectSlug(projectRoot) {
|
|
152
162
|
const resolvedProjectRoot = getProjectRoot(projectRoot);
|
|
153
|
-
const
|
|
154
|
-
return slugifyProjectName(path.basename(
|
|
163
|
+
const folder = findRepositoryRootSync(resolvedProjectRoot) || resolvedProjectRoot;
|
|
164
|
+
return isEmptyFolder(folder) ? null : slugifyProjectName(path.basename(folder));
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
function getScopeRoot(scope, projectRoot) {
|
|
@@ -160,7 +170,8 @@ function getScopeRoot(scope, projectRoot) {
|
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
if (scope === "project") {
|
|
163
|
-
|
|
173
|
+
const slug = getProjectSlug(projectRoot);
|
|
174
|
+
return slug ? path.join(getGlobalRoot(), "projects", slug) : getGlobalRoot();
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
throw new Error(`Unsupported scope: ${scope}`);
|
|
@@ -259,34 +270,34 @@ function tokenizeSearchText(value) {
|
|
|
259
270
|
return normalized ? normalized.split(/\s+/).filter(Boolean) : [];
|
|
260
271
|
}
|
|
261
272
|
|
|
262
|
-
function
|
|
273
|
+
function normalizeKeyword(value) {
|
|
263
274
|
return tokenizeSearchText(value).join("-");
|
|
264
275
|
}
|
|
265
276
|
|
|
266
|
-
function
|
|
267
|
-
const values = Array.isArray(
|
|
268
|
-
return [...new Set(values.map(
|
|
277
|
+
function normalizeKeywordList(keywords) {
|
|
278
|
+
const values = Array.isArray(keywords) ? keywords : [];
|
|
279
|
+
return [...new Set(values.map(normalizeKeyword).filter(Boolean))];
|
|
269
280
|
}
|
|
270
281
|
|
|
271
|
-
function
|
|
282
|
+
function splitInlineKeywordValues(value) {
|
|
272
283
|
return String(value || "")
|
|
273
284
|
.split(",")
|
|
274
285
|
.map((item) => item.trim())
|
|
275
286
|
.filter(Boolean);
|
|
276
287
|
}
|
|
277
288
|
|
|
278
|
-
function
|
|
279
|
-
return content.split(/\r?\n/).some((line) => /^Cues:\s*/i.test(line.trim()));
|
|
289
|
+
function hasKeywordsSection(content) {
|
|
290
|
+
return content.split(/\r?\n/).some((line) => /^(?:Keywords|Cues):\s*/i.test(line.trim()));
|
|
280
291
|
}
|
|
281
292
|
|
|
282
|
-
function
|
|
283
|
-
const
|
|
293
|
+
function formatKeywordsSection(keywords) {
|
|
294
|
+
const normalizedKeywords = normalizeKeywordList(keywords);
|
|
284
295
|
|
|
285
|
-
if (!
|
|
296
|
+
if (!normalizedKeywords.length) {
|
|
286
297
|
return "";
|
|
287
298
|
}
|
|
288
299
|
|
|
289
|
-
return ["
|
|
300
|
+
return ["Keywords:", ...normalizedKeywords.map((keyword) => `- ${keyword}`)].join("\n");
|
|
290
301
|
}
|
|
291
302
|
|
|
292
303
|
function normalizeAgentName(value) {
|
|
@@ -313,7 +324,7 @@ function normalizeAgentName(value) {
|
|
|
313
324
|
return AGENT_NAME_ALIASES[firstToken];
|
|
314
325
|
}
|
|
315
326
|
|
|
316
|
-
const slug =
|
|
327
|
+
const slug = normalizeKeyword(compact);
|
|
317
328
|
|
|
318
329
|
return slug || null;
|
|
319
330
|
}
|
|
@@ -397,7 +408,7 @@ function extractAttributionFromEventLines(lines) {
|
|
|
397
408
|
continue;
|
|
398
409
|
}
|
|
399
410
|
|
|
400
|
-
// Provenance lines sit at the top of the body; stop at
|
|
411
|
+
// Provenance lines sit at the top of the body; stop at keywords or free text.
|
|
401
412
|
break;
|
|
402
413
|
}
|
|
403
414
|
|
|
@@ -654,7 +665,7 @@ function parseEventHeading(line) {
|
|
|
654
665
|
|
|
655
666
|
if (!match) {
|
|
656
667
|
return {
|
|
657
|
-
id:
|
|
668
|
+
id: normalizeKeyword(heading) || heading,
|
|
658
669
|
heading,
|
|
659
670
|
};
|
|
660
671
|
}
|
|
@@ -665,22 +676,22 @@ function parseEventHeading(line) {
|
|
|
665
676
|
};
|
|
666
677
|
}
|
|
667
678
|
|
|
668
|
-
function
|
|
679
|
+
function extractKeywordsFromEventLines(lines) {
|
|
669
680
|
for (let index = 0; index < lines.length; index += 1) {
|
|
670
|
-
const match = lines[index].trim().match(/^Cues:\s*(.*)$/i);
|
|
681
|
+
const match = lines[index].trim().match(/^(?:Keywords|Cues):\s*(.*)$/i);
|
|
671
682
|
|
|
672
683
|
if (!match) {
|
|
673
684
|
continue;
|
|
674
685
|
}
|
|
675
686
|
|
|
676
|
-
const
|
|
687
|
+
const keywords = splitInlineKeywordValues(match[1]);
|
|
677
688
|
|
|
678
689
|
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
679
690
|
const line = lines[cursor];
|
|
680
691
|
const bullet = line.match(/^\s*-\s+(.+?)\s*$/);
|
|
681
692
|
|
|
682
693
|
if (bullet) {
|
|
683
|
-
|
|
694
|
+
keywords.push(bullet[1]);
|
|
684
695
|
continue;
|
|
685
696
|
}
|
|
686
697
|
|
|
@@ -691,7 +702,7 @@ function extractCuesFromEventLines(lines) {
|
|
|
691
702
|
break;
|
|
692
703
|
}
|
|
693
704
|
|
|
694
|
-
return
|
|
705
|
+
return normalizeKeywordList(keywords);
|
|
695
706
|
}
|
|
696
707
|
|
|
697
708
|
return [];
|
|
@@ -719,7 +730,7 @@ function parseEventBlocks(content) {
|
|
|
719
730
|
heading: heading.heading,
|
|
720
731
|
agent: attribution.agent,
|
|
721
732
|
session: attribution.session,
|
|
722
|
-
|
|
733
|
+
keywords: extractKeywordsFromEventLines(bodyLines),
|
|
723
734
|
content: blockLines.join("\n").trim(),
|
|
724
735
|
body: bodyLines.join("\n").trim(),
|
|
725
736
|
startLine: startIndex + 1,
|
|
@@ -735,7 +746,7 @@ function buildEventIndex(markdownContent, markdownPath) {
|
|
|
735
746
|
const entry = {
|
|
736
747
|
id: event.id,
|
|
737
748
|
heading: event.heading,
|
|
738
|
-
|
|
749
|
+
keywords: event.keywords,
|
|
739
750
|
};
|
|
740
751
|
|
|
741
752
|
if (event.agent) {
|
|
@@ -768,7 +779,7 @@ function createQueryInfo(query) {
|
|
|
768
779
|
return {
|
|
769
780
|
original,
|
|
770
781
|
normalized: tokens.join(" "),
|
|
771
|
-
|
|
782
|
+
keyword: tokens.join("-"),
|
|
772
783
|
tokens,
|
|
773
784
|
datePrefixes: queryDatePrefixes(original),
|
|
774
785
|
};
|
|
@@ -847,7 +858,7 @@ function scoreSearchValues(queryInfo, values) {
|
|
|
847
858
|
}
|
|
848
859
|
|
|
849
860
|
const phraseScore = normalizedValues.reduce((bestScore, value) => {
|
|
850
|
-
if (value === queryInfo.normalized || value.replace(/\s+/g, "-") === queryInfo.
|
|
861
|
+
if (value === queryInfo.normalized || value.replace(/\s+/g, "-") === queryInfo.keyword) {
|
|
851
862
|
return Math.max(bestScore, 1);
|
|
852
863
|
}
|
|
853
864
|
|
|
@@ -919,7 +930,7 @@ function splitOversizedBlock(block) {
|
|
|
919
930
|
return chunks;
|
|
920
931
|
}
|
|
921
932
|
|
|
922
|
-
function createSearchExcerpt(content, queryInfo
|
|
933
|
+
function createSearchExcerpt(content, queryInfo) {
|
|
923
934
|
const trimmed = content.trim();
|
|
924
935
|
|
|
925
936
|
if (!trimmed) {
|
|
@@ -934,7 +945,7 @@ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
|
|
|
934
945
|
.split(/\n\s*\n+/)
|
|
935
946
|
.map((block) => block.trim())
|
|
936
947
|
.filter(Boolean)
|
|
937
|
-
.filter((block) => !/^(?:Agent:|Session:|Cues:)\s*/i.test(block))
|
|
948
|
+
.filter((block) => !/^(?:Agent:|Session:|Keywords:|Cues:)\s*/i.test(block))
|
|
938
949
|
.flatMap(splitOversizedBlock);
|
|
939
950
|
|
|
940
951
|
if (!blocks.length) {
|
|
@@ -956,8 +967,8 @@ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
|
|
|
956
967
|
// Keep the best-matching paragraphs that fit the excerpt budget, in file order.
|
|
957
968
|
const hits = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE }).map((hit) => hit.id);
|
|
958
969
|
|
|
959
|
-
// An event chosen by its date may share no words with
|
|
960
|
-
for (const id of hits.length
|
|
970
|
+
// An event chosen by its date, keywords, or title may share no words with its paragraphs; then lead with the first ones.
|
|
971
|
+
for (const id of hits.length ? hits : blocks.map((_, position) => position)) {
|
|
961
972
|
const added = blocks[id].length + (picked.length ? 2 : 0);
|
|
962
973
|
|
|
963
974
|
if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
|
|
@@ -988,7 +999,7 @@ async function writeMemoryFile({ scope, path: relativePath, content, projectRoot
|
|
|
988
999
|
const target = resolveScopedPath(scope, relativePath, projectRoot);
|
|
989
1000
|
|
|
990
1001
|
if (target.path === "events" || target.path.startsWith("events/")) {
|
|
991
|
-
throw new Error("events/ is append-only. Use append_event with a title and
|
|
1002
|
+
throw new Error("events/ is append-only. Use append_event with a title and keywords instead.");
|
|
992
1003
|
}
|
|
993
1004
|
|
|
994
1005
|
const nextContent = ensureTrailingNewline(requireNonEmptyString(content, "content"));
|
|
@@ -1055,7 +1066,7 @@ async function withEventFileLock(filePath, action) {
|
|
|
1055
1066
|
async function appendEvent({
|
|
1056
1067
|
scope = "project",
|
|
1057
1068
|
content,
|
|
1058
|
-
|
|
1069
|
+
keywords,
|
|
1059
1070
|
projectRoot,
|
|
1060
1071
|
sync = true,
|
|
1061
1072
|
title,
|
|
@@ -1067,13 +1078,13 @@ async function appendEvent({
|
|
|
1067
1078
|
}) {
|
|
1068
1079
|
const body = requireNonEmptyString(content, "content");
|
|
1069
1080
|
const attribution = resolveWriteAttribution({ agent, session, clientName });
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
? `${
|
|
1081
|
+
const normalizedKeywords = normalizeKeywordList(keywords);
|
|
1082
|
+
const bodyWithKeywords =
|
|
1083
|
+
normalizedKeywords.length && !hasKeywordsSection(body)
|
|
1084
|
+
? `${formatKeywordsSection(normalizedKeywords)}\n\n${body.trim()}`
|
|
1074
1085
|
: body.trim();
|
|
1075
1086
|
const attributionSection = formatAttributionSection(attribution);
|
|
1076
|
-
const eventBody = attributionSection ? `${attributionSection}\n\n${
|
|
1087
|
+
const eventBody = attributionSection ? `${attributionSection}\n\n${bodyWithKeywords}` : bodyWithKeywords;
|
|
1077
1088
|
const relativePath = `events/${date}.md`;
|
|
1078
1089
|
const target = resolveScopedPath(scope, relativePath, projectRoot);
|
|
1079
1090
|
|
|
@@ -1122,7 +1133,7 @@ async function appendEvent({
|
|
|
1122
1133
|
title: title?.trim() || null,
|
|
1123
1134
|
agent: parsed?.agent || attribution.agent,
|
|
1124
1135
|
session: parsed?.session || attribution.session,
|
|
1125
|
-
|
|
1136
|
+
keywords: parsed?.keywords || [],
|
|
1126
1137
|
indexPath,
|
|
1127
1138
|
sync: syncResult,
|
|
1128
1139
|
};
|
|
@@ -1203,7 +1214,7 @@ async function collectEventDocuments({ scope, eventFiles }) {
|
|
|
1203
1214
|
path: `events/${markdownName}`,
|
|
1204
1215
|
id: event.id,
|
|
1205
1216
|
heading: event.heading,
|
|
1206
|
-
|
|
1217
|
+
keywords: normalizeKeywordList(event.keywords || event.cues),
|
|
1207
1218
|
body: block.body,
|
|
1208
1219
|
});
|
|
1209
1220
|
}
|
|
@@ -1281,7 +1292,7 @@ function queryDatePrefixes(query, now = new Date()) {
|
|
|
1281
1292
|
|
|
1282
1293
|
function buildSearchIndex(documents) {
|
|
1283
1294
|
const index = new MiniSearch({
|
|
1284
|
-
fields: ["
|
|
1295
|
+
fields: ["keywords", "heading", "body", "date"],
|
|
1285
1296
|
idField: "position",
|
|
1286
1297
|
tokenize: tokenizeSearchText,
|
|
1287
1298
|
processTerm: (term) => term,
|
|
@@ -1290,7 +1301,7 @@ function buildSearchIndex(documents) {
|
|
|
1290
1301
|
index.addAll(
|
|
1291
1302
|
documents.map((document, position) => ({
|
|
1292
1303
|
position,
|
|
1293
|
-
|
|
1304
|
+
keywords: (document.keywords || []).join(" "),
|
|
1294
1305
|
heading: document.heading || document.path,
|
|
1295
1306
|
body: document.body,
|
|
1296
1307
|
date: eventDateText(document.id),
|
|
@@ -1316,14 +1327,22 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
|
|
|
1316
1327
|
.map((hit) => ({
|
|
1317
1328
|
document: documents[hit.id],
|
|
1318
1329
|
score: Math.round(hit.score * 100) / 100,
|
|
1319
|
-
|
|
1330
|
+
terms: hit.terms,
|
|
1320
1331
|
}));
|
|
1321
1332
|
}
|
|
1322
1333
|
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
function
|
|
1334
|
+
// Same word in another form: a plural or longer form ("deploy", "deployments") or the same stem
|
|
1335
|
+
// ("creating", "creation"). A typo-tolerant hit on a different word ("instinct", "instance",
|
|
1336
|
+
// "content", "context") is not.
|
|
1337
|
+
function sameWord(left, right) {
|
|
1338
|
+
const stem = (word) => word.replace(/(?:ments?|ings?|ions?|ers?|ed|es|s)$/, "");
|
|
1339
|
+
return tokenSimilarity(left, right) >= 0.9 || (stem(left).length >= 4 && stem(left) === stem(right));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Share of the question's rare-word weight (IDF) that the best of the returned matches contains.
|
|
1343
|
+
// A question whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even
|
|
1344
|
+
// when common words ("backend", "deploy") match, so search can return nothing instead of noise.
|
|
1345
|
+
function rareWordCoverage(queryInfo, candidates, { index, documents }) {
|
|
1327
1346
|
// Month names only rank (see eventDateText); they say when, not what.
|
|
1328
1347
|
const words = [
|
|
1329
1348
|
...new Set(
|
|
@@ -1335,15 +1354,19 @@ function rareWordCoverage(queryInfo, best, { index, documents }) {
|
|
|
1335
1354
|
return 1;
|
|
1336
1355
|
}
|
|
1337
1356
|
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
1357
|
+
const weights = new Map(
|
|
1358
|
+
words.map((word) => [word, Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5))]),
|
|
1359
|
+
);
|
|
1360
|
+
const total = words.reduce((sum, word) => sum + weights.get(word), 0);
|
|
1361
|
+
const coverage = ({ terms }) =>
|
|
1362
|
+
words.filter((word) => terms.some((term) => sameWord(word, term))).reduce((sum, word) => sum + weights.get(word), 0) /
|
|
1363
|
+
total;
|
|
1341
1364
|
|
|
1342
|
-
return
|
|
1365
|
+
return Math.max(0, ...candidates.map(coverage));
|
|
1343
1366
|
}
|
|
1344
1367
|
|
|
1345
1368
|
function toSearchResult(queryInfo, { document, score }) {
|
|
1346
|
-
const snippet = createSearchExcerpt(document.body, queryInfo
|
|
1369
|
+
const snippet = createSearchExcerpt(document.body, queryInfo);
|
|
1347
1370
|
|
|
1348
1371
|
if (!snippet) {
|
|
1349
1372
|
return null;
|
|
@@ -1359,7 +1382,7 @@ function toSearchResult(queryInfo, { document, score }) {
|
|
|
1359
1382
|
};
|
|
1360
1383
|
}
|
|
1361
1384
|
|
|
1362
|
-
const
|
|
1385
|
+
const keywordScore = scoreSearchValues(queryInfo, document.keywords);
|
|
1363
1386
|
const headingScore = scoreSearchValues(queryInfo, [document.heading || "", document.id]);
|
|
1364
1387
|
|
|
1365
1388
|
return {
|
|
@@ -1367,9 +1390,9 @@ function toSearchResult(queryInfo, { document, score }) {
|
|
|
1367
1390
|
path: document.path,
|
|
1368
1391
|
id: document.id,
|
|
1369
1392
|
heading: document.heading,
|
|
1370
|
-
|
|
1393
|
+
keywords: document.keywords,
|
|
1371
1394
|
score,
|
|
1372
|
-
match:
|
|
1395
|
+
match: keywordScore > 0 && keywordScore >= headingScore ? "keywords" : headingScore > 0 ? "heading" : "body",
|
|
1373
1396
|
snippet,
|
|
1374
1397
|
};
|
|
1375
1398
|
}
|
|
@@ -1434,7 +1457,11 @@ async function loadSearchIndex(scopes, projectRoot) {
|
|
|
1434
1457
|
async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5 }) {
|
|
1435
1458
|
const queryInfo = createQueryInfo(query);
|
|
1436
1459
|
const clampedMaxResults = Math.max(1, Math.min(Number(maxResults) || 5, 20));
|
|
1437
|
-
const
|
|
1460
|
+
const hasProject = getProjectSlug(projectRoot) !== null;
|
|
1461
|
+
// Without a project, project scope is global memory; search it once, as global.
|
|
1462
|
+
const scopes = [
|
|
1463
|
+
...new Set((scope === "both" ? ["global", "project"] : [scope]).map((name) => (name === "project" && !hasProject ? "global" : name))),
|
|
1464
|
+
];
|
|
1438
1465
|
const search = await loadSearchIndex(scopes, projectRoot);
|
|
1439
1466
|
const results = [];
|
|
1440
1467
|
|
|
@@ -1442,7 +1469,7 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
|
|
|
1442
1469
|
// Dated questions skip the check: their date words describe time, not the topic.
|
|
1443
1470
|
const related =
|
|
1444
1471
|
queryInfo.datePrefixes.length > 0 ||
|
|
1445
|
-
rareWordCoverage(queryInfo, candidates
|
|
1472
|
+
rareWordCoverage(queryInfo, candidates.slice(0, clampedMaxResults), search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
|
|
1446
1473
|
|
|
1447
1474
|
if (related) {
|
|
1448
1475
|
for (const candidate of candidates) {
|
|
@@ -1488,7 +1515,7 @@ async function wakeUp({ projectRoot } = {}) {
|
|
|
1488
1515
|
});
|
|
1489
1516
|
}
|
|
1490
1517
|
|
|
1491
|
-
for (const relativePath of DEFAULT_PROJECT_FILES) {
|
|
1518
|
+
for (const relativePath of projectSlug ? DEFAULT_PROJECT_FILES : []) {
|
|
1492
1519
|
const file = await readMemoryFile({ scope: "project", path: relativePath, projectRoot });
|
|
1493
1520
|
|
|
1494
1521
|
if (!file.found) {
|
|
@@ -1507,7 +1534,7 @@ async function wakeUp({ projectRoot } = {}) {
|
|
|
1507
1534
|
"",
|
|
1508
1535
|
`Global root: ${globalRoot}`,
|
|
1509
1536
|
`Project root: ${resolvedProjectRoot}`,
|
|
1510
|
-
`Project slug: ${projectSlug}`,
|
|
1537
|
+
`Project slug: ${projectSlug || "none (empty folder; project memory is global memory)"}`,
|
|
1511
1538
|
`Project memory root: ${projectMemoryRoot}`,
|
|
1512
1539
|
];
|
|
1513
1540
|
|
|
@@ -113,7 +113,7 @@ function buildClaudeManagedBlock() {
|
|
|
113
113
|
"- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
|
|
114
114
|
"- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
|
|
115
115
|
"- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
|
|
116
|
-
"- When appending events, pass a `title` and 1-8 concise `
|
|
116
|
+
"- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
|
|
117
117
|
"- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
|
|
118
118
|
"- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
|
|
119
119
|
"- If the user asks to upgrade Hippocamp for Claude Code, run `npx hippocamp@latest upgrade-claude`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:claude`.",
|
|
@@ -121,7 +121,7 @@ function buildCodexManagedBlock() {
|
|
|
121
121
|
"- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
|
|
122
122
|
"- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
|
|
123
123
|
"- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
|
|
124
|
-
"- When appending events, pass a `title` and 1-8 concise `
|
|
124
|
+
"- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
|
|
125
125
|
"- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
|
|
126
126
|
"- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
|
|
127
127
|
"- If the user asks to upgrade Hippocamp for Codex, run `npx hippocamp@latest upgrade-codex`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:codex`.",
|
package/scripts/install-grok.cjs
CHANGED
|
@@ -122,7 +122,7 @@ function buildGrokManagedBlock() {
|
|
|
122
122
|
"- When project state changes, use `append_event` for milestones and update curated files such as `current_state.md` and `open_threads.md` before finishing the task.",
|
|
123
123
|
"- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.",
|
|
124
124
|
"- Update curated files only when project state actually changed. For small tasks, `append_event` is enough.",
|
|
125
|
-
"- When appending events, pass a `title` and 1-8 concise `
|
|
125
|
+
"- When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.",
|
|
126
126
|
"- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
|
|
127
127
|
"- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
|
|
128
128
|
"- If the user asks to upgrade Hippocamp for Grok Build, run `npx hippocamp@latest upgrade-grok`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:grok`.",
|
|
@@ -17,7 +17,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
|
|
|
17
17
|
|
|
18
18
|
- Global memory lives at the root of the local Lagoon clone pointed to by `HIPPOCAMP_GLOBAL_ROOT`.
|
|
19
19
|
- Project memory lives under `projects/<slug>/` inside that same Lagoon clone.
|
|
20
|
-
- The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory.
|
|
20
|
+
- The current project slug is inferred from `HIPPOCAMP_PROJECT_ROOT` or the current working directory. An empty folder (nothing but `.git`) has no project and uses global memory.
|
|
21
21
|
- The Hippocamp MCP server exposes the tools that read and write those locations.
|
|
22
22
|
|
|
23
23
|
## Default Workflow
|
|
@@ -28,7 +28,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
|
|
|
28
28
|
Keep the default 5 results for a single fact. Pass `maxResults` 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if the results look incomplete.
|
|
29
29
|
4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
|
|
30
30
|
5. During work, use `append_event` only for meaningful milestones.
|
|
31
|
-
6. When appending events, pass a `title` and 1-8 concise `
|
|
31
|
+
6. When appending events, pass a `title` and 1-8 concise `keywords` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
|
|
32
32
|
7. `write_memory_file` and `append_event` sync by default.
|
|
33
33
|
8. If a sync is skipped or fails, call `sync_memory` explicitly.
|
|
34
34
|
9. At the end of the task, if project state actually changed, use `write_memory_file` to update curated files such as:
|
|
@@ -44,7 +44,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
|
|
|
44
44
|
- Prefer curated summaries over raw event history.
|
|
45
45
|
- Do not re-read `current_state.md` or `open_threads.md` after `wake_up`; they are already loaded.
|
|
46
46
|
- For small tasks, `append_event` is enough; skip curated file rewrites.
|
|
47
|
-
- Use event
|
|
47
|
+
- Use event keywords as short recall handles; `search_memory` ranks indexed events by keywords, title, and body with typo tolerance, and keywords weigh the most.
|
|
48
48
|
- Search returns bounded coherent evidence and does not scan unindexed event logs as a fallback.
|
|
49
49
|
- Keep curated files short and legible.
|
|
50
50
|
- Rewrite `current_state.md` as a short snapshot of what is true now. Do not prepend dated entries to it; milestone detail belongs in `append_event` only.
|
package/test/mcp-params.test.cjs
CHANGED
|
@@ -41,26 +41,26 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
|
|
|
41
41
|
assert.equal(eventWrite.isError, true);
|
|
42
42
|
assert.match(resultText(eventWrite), /append-only/);
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const noKeywords = await client.callTool({
|
|
45
45
|
name: "append_event",
|
|
46
|
-
arguments: { title: "Missing
|
|
46
|
+
arguments: { title: "Missing keywords", content: "Unsearchable.", sync: false },
|
|
47
47
|
});
|
|
48
|
-
assert.equal(
|
|
48
|
+
assert.equal(noKeywords.isError, true);
|
|
49
49
|
|
|
50
|
-
const
|
|
50
|
+
const tooManyKeywords = await client.callTool({
|
|
51
51
|
name: "append_event",
|
|
52
52
|
arguments: {
|
|
53
|
-
title: "Too many
|
|
54
|
-
|
|
53
|
+
title: "Too many keywords",
|
|
54
|
+
keywords: Array.from({ length: 9 }, (_, index) => `keyword-${index}`),
|
|
55
55
|
content: "Too broad.",
|
|
56
56
|
sync: false,
|
|
57
57
|
},
|
|
58
58
|
});
|
|
59
|
-
assert.equal(
|
|
59
|
+
assert.equal(tooManyKeywords.isError, true);
|
|
60
60
|
|
|
61
61
|
const noTitle = await client.callTool({
|
|
62
62
|
name: "append_event",
|
|
63
|
-
arguments: { title: " ",
|
|
63
|
+
arguments: { title: " ", keywords: ["untitled"], content: "No heading.", sync: false },
|
|
64
64
|
});
|
|
65
65
|
assert.equal(noTitle.isError, true);
|
|
66
66
|
|
|
@@ -73,8 +73,8 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
|
|
|
73
73
|
|
|
74
74
|
const valid = await client.callTool({
|
|
75
75
|
name: "append_event",
|
|
76
|
-
arguments: { title: "Valid event",
|
|
76
|
+
arguments: { title: "Valid event", keywords: ["valid-keyword"], content: "Indexed.", projectRoot, sync: false },
|
|
77
77
|
});
|
|
78
78
|
assert.notEqual(valid.isError, true);
|
|
79
|
-
assert.deepEqual(JSON.parse(resultText(valid)).
|
|
79
|
+
assert.deepEqual(JSON.parse(resultText(valid)).keywords, ["valid-keyword"]);
|
|
80
80
|
});
|
|
@@ -76,7 +76,6 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
|
|
|
76
76
|
const projectRoot = path.join(tempRoot, "search-project");
|
|
77
77
|
|
|
78
78
|
process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
|
|
79
|
-
await fs.mkdir(projectRoot, { recursive: true });
|
|
80
79
|
t.after(async () => {
|
|
81
80
|
if (originalRoot === undefined) {
|
|
82
81
|
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
@@ -88,7 +87,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
|
|
|
88
87
|
|
|
89
88
|
await memory.appendEvent({
|
|
90
89
|
content: "Decision:\nUse indexed search only.",
|
|
91
|
-
|
|
90
|
+
keywords: ["strict-index"],
|
|
92
91
|
date: "2026-09-21",
|
|
93
92
|
projectRoot,
|
|
94
93
|
scope: "project",
|
|
@@ -109,7 +108,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
|
|
|
109
108
|
|
|
110
109
|
await memory.appendEvent({
|
|
111
110
|
content: `Decision:\n${"x".repeat(1300)}`,
|
|
112
|
-
|
|
111
|
+
keywords: ["oversized-metadata"],
|
|
113
112
|
date: "2026-09-21",
|
|
114
113
|
projectRoot,
|
|
115
114
|
scope: "project",
|
|
@@ -124,7 +123,9 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
|
|
|
124
123
|
scope: "project",
|
|
125
124
|
});
|
|
126
125
|
|
|
127
|
-
|
|
126
|
+
// A keyword-only match still returns whole paragraphs; the oversized run is left out, never cropped.
|
|
127
|
+
assert.equal(oversized.results[0]?.heading, "Large metadata-only match");
|
|
128
|
+
assert.equal(oversized.results[0].snippet, "Decision:");
|
|
128
129
|
|
|
129
130
|
const projectMemoryRoot = path.join(lagoonRoot, "projects", "search-project");
|
|
130
131
|
await fs.writeFile(
|
|
@@ -145,7 +146,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
|
|
|
145
146
|
const eventsRoot = path.join(projectMemoryRoot, "events");
|
|
146
147
|
await fs.writeFile(
|
|
147
148
|
path.join(eventsRoot, "2026-09-20.md"),
|
|
148
|
-
"# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\
|
|
149
|
+
"# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\nKeywords:\n- quasar-velvet\n\nShould not be scanned.\n",
|
|
149
150
|
"utf8",
|
|
150
151
|
);
|
|
151
152
|
|
|
@@ -174,11 +175,11 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
|
|
|
174
175
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
175
176
|
});
|
|
176
177
|
|
|
177
|
-
for (const [title,
|
|
178
|
+
for (const [title, keywords, content] of [
|
|
178
179
|
["Deploy note", ["deploy"], "The zanzibar migration needs a rollback plan."],
|
|
179
180
|
["Unrelated note", ["billing"], "Invoices are generated monthly."],
|
|
180
181
|
]) {
|
|
181
|
-
await memory.appendEvent({ content,
|
|
182
|
+
await memory.appendEvent({ content, keywords, projectRoot, scope: "project", sync: false, title });
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
const search = await memory.searchMemory({ projectRoot, query: "zanzibr rollback", scope: "project" });
|
|
@@ -188,6 +189,42 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
|
|
|
188
189
|
assert.equal(search.results[0].match, "body");
|
|
189
190
|
});
|
|
190
191
|
|
|
192
|
+
test("search still matches events written with legacy Cues sections and indexes", async (t) => {
|
|
193
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
194
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-legacy-cues-"));
|
|
195
|
+
const projectRoot = path.join(tempRoot, "legacy-project");
|
|
196
|
+
const eventsRoot = path.join(tempRoot, "lagoon", "projects", "legacy-project", "events");
|
|
197
|
+
|
|
198
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
199
|
+
await fs.mkdir(eventsRoot, { recursive: true });
|
|
200
|
+
t.after(async () => {
|
|
201
|
+
if (originalRoot === undefined) {
|
|
202
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
203
|
+
} else {
|
|
204
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
205
|
+
}
|
|
206
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const id = "2026-09-01T09:00:00.000Z";
|
|
210
|
+
await fs.writeFile(
|
|
211
|
+
path.join(eventsRoot, "2026-09-01.md"),
|
|
212
|
+
`# Events — 2026-09-01\n\n## ${id} — Old note\n\nCues:\n- quasar-velvet\n\nOld body.\n`,
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
await fs.writeFile(
|
|
216
|
+
path.join(eventsRoot, "2026-09-01.index.json"),
|
|
217
|
+
JSON.stringify({ version: 1, path: "2026-09-01.md", events: [{ id, heading: "Old note", cues: ["quasar-velvet"] }] }),
|
|
218
|
+
"utf8",
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const search = await memory.searchMemory({ projectRoot, query: "quasar velvet", scope: "project" });
|
|
222
|
+
|
|
223
|
+
assert.equal(search.results.length, 1);
|
|
224
|
+
assert.deepEqual(search.results[0].keywords, ["quasar-velvet"]);
|
|
225
|
+
assert.equal(search.results[0].match, "keywords");
|
|
226
|
+
});
|
|
227
|
+
|
|
191
228
|
test("search returns whole sentences from an oversized matching paragraph", async (t) => {
|
|
192
229
|
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
193
230
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-long-paragraph-"));
|
|
@@ -207,7 +244,7 @@ test("search returns whole sentences from an oversized matching paragraph", asyn
|
|
|
207
244
|
const filler = "The weekly sync covered routine updates. ".repeat(40);
|
|
208
245
|
await memory.appendEvent({
|
|
209
246
|
content: `${filler}The kestrel database moved to eu-west-3. ${filler}`,
|
|
210
|
-
|
|
247
|
+
keywords: ["infra"],
|
|
211
248
|
projectRoot,
|
|
212
249
|
scope: "project",
|
|
213
250
|
sync: false,
|
|
@@ -246,7 +283,7 @@ test("search returns every matching paragraph that fits the excerpt budget", asy
|
|
|
246
283
|
];
|
|
247
284
|
await memory.appendEvent({
|
|
248
285
|
content: paragraphs.join("\n\n"),
|
|
249
|
-
|
|
286
|
+
keywords: ["infra"],
|
|
250
287
|
projectRoot,
|
|
251
288
|
scope: "project",
|
|
252
289
|
sync: false,
|
|
@@ -282,7 +319,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
|
|
|
282
319
|
const timestamp = "2026-10-01T09:00:00.000Z";
|
|
283
320
|
const first = await memory.appendEvent({
|
|
284
321
|
content: "The zanzibar migration needs a rollback plan.",
|
|
285
|
-
|
|
322
|
+
keywords: ["deploy"],
|
|
286
323
|
projectRoot,
|
|
287
324
|
scope: "project",
|
|
288
325
|
sync: false,
|
|
@@ -291,7 +328,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
|
|
|
291
328
|
});
|
|
292
329
|
const second = await memory.appendEvent({
|
|
293
330
|
content: "Invoices are generated monthly.",
|
|
294
|
-
|
|
331
|
+
keywords: ["billing"],
|
|
295
332
|
projectRoot,
|
|
296
333
|
scope: "project",
|
|
297
334
|
sync: false,
|
|
@@ -329,7 +366,7 @@ test("parallel appends keep every event", async (t) => {
|
|
|
329
366
|
Array.from({ length: 20 }, (_, index) =>
|
|
330
367
|
memory.appendEvent({
|
|
331
368
|
content: `Parallel event ${index}.`,
|
|
332
|
-
|
|
369
|
+
keywords: ["parallel"],
|
|
333
370
|
date: "2026-10-01",
|
|
334
371
|
projectRoot,
|
|
335
372
|
scope: "project",
|
|
@@ -366,7 +403,7 @@ test("search favors events from a month named in the query", async (t) => {
|
|
|
366
403
|
for (const timestamp of ["2026-05-02T08:00:00.000Z", "2026-08-05T10:00:00.000Z", "2026-09-01T09:00:00.000Z"]) {
|
|
367
404
|
await memory.appendEvent({
|
|
368
405
|
content: "Published the Android build to Google Play production.",
|
|
369
|
-
|
|
406
|
+
keywords: ["android-release"],
|
|
370
407
|
date: timestamp.slice(0, 10),
|
|
371
408
|
projectRoot,
|
|
372
409
|
scope: "project",
|
|
@@ -407,7 +444,7 @@ test("search favors events from a relative date named in the query", async (t) =
|
|
|
407
444
|
await memory.appendEvent({
|
|
408
445
|
// The old event matches the words better, so only the date words can rank the others first.
|
|
409
446
|
content: key === "older" ? "Deploy key rotation: rotated the deploy key again." : "Rotated the key for staging.",
|
|
410
|
-
|
|
447
|
+
keywords: ["deploy-key"],
|
|
411
448
|
date: timestamp.slice(0, 10),
|
|
412
449
|
projectRoot,
|
|
413
450
|
scope: "project",
|
|
@@ -448,7 +485,7 @@ test("search returns a snippet for a long event matched only by its date", async
|
|
|
448
485
|
|
|
449
486
|
await memory.appendEvent({
|
|
450
487
|
content: Array(30).fill("The weekly sync covered routine updates.").join("\n\n"),
|
|
451
|
-
|
|
488
|
+
keywords: ["sync"],
|
|
452
489
|
date: "2026-08-05",
|
|
453
490
|
projectRoot,
|
|
454
491
|
scope: "project",
|
|
@@ -485,7 +522,7 @@ test("search returns nothing when no memory is about the question", async (t) =>
|
|
|
485
522
|
["Deploy rollback", "A failed backend deploy rolls back to the previous revision."],
|
|
486
523
|
["Billing note", "Invoices are generated monthly."],
|
|
487
524
|
]) {
|
|
488
|
-
await memory.appendEvent({ content,
|
|
525
|
+
await memory.appendEvent({ content, keywords: ["ops"], projectRoot, scope: "project", sync: false, title });
|
|
489
526
|
}
|
|
490
527
|
|
|
491
528
|
const unrelated = await memory.searchMemory({ projectRoot, query: "which kubernetes namespace does the backend deploy to", scope: "project" });
|
|
@@ -498,3 +535,130 @@ test("search returns nothing when no memory is about the question", async (t) =>
|
|
|
498
535
|
// No event is from August; the month name must not count as a missing topic word.
|
|
499
536
|
assert.ok(monthOnly.results.length > 0);
|
|
500
537
|
});
|
|
538
|
+
|
|
539
|
+
test("search keeps an event matched only by a keyword its long body never repeats", async (t) => {
|
|
540
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
541
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-keyword-only-"));
|
|
542
|
+
const projectRoot = path.join(tempRoot, "keyword-only-project");
|
|
543
|
+
|
|
544
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
545
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
546
|
+
t.after(async () => {
|
|
547
|
+
if (originalRoot === undefined) {
|
|
548
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
549
|
+
} else {
|
|
550
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
551
|
+
}
|
|
552
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const content = [
|
|
556
|
+
"Opened three hardening PRs after the security audit.",
|
|
557
|
+
"The backend PR rotates the cron secret and checks request signatures on every scheduled job. ".repeat(8),
|
|
558
|
+
"The website PR updates the content studio dependencies and removes an unused preview route. ".repeat(8),
|
|
559
|
+
].join("\n\n");
|
|
560
|
+
|
|
561
|
+
assert.ok(content.length > 1200);
|
|
562
|
+
await memory.appendEvent({
|
|
563
|
+
content,
|
|
564
|
+
keywords: ["security-audit", "strava-oauth"],
|
|
565
|
+
projectRoot,
|
|
566
|
+
scope: "project",
|
|
567
|
+
sync: false,
|
|
568
|
+
title: "Security audit opened hardening PRs",
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const search = await memory.searchMemory({ projectRoot, query: "Strava bug", scope: "project" });
|
|
572
|
+
|
|
573
|
+
assert.equal(search.results[0]?.heading, "Security audit opened hardening PRs");
|
|
574
|
+
assert.match(search.results[0].snippet, /^Opened three hardening PRs/);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("search returns nothing when a rare word only resembles memory words", async (t) => {
|
|
578
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
579
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-lookalike-"));
|
|
580
|
+
const projectRoot = path.join(tempRoot, "lookalike-project");
|
|
581
|
+
|
|
582
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
|
|
583
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
584
|
+
t.after(async () => {
|
|
585
|
+
if (originalRoot === undefined) {
|
|
586
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
587
|
+
} else {
|
|
588
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
589
|
+
}
|
|
590
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await memory.appendEvent({
|
|
594
|
+
content: "Each worker instance reads the instruct prompt and its content before a sync.",
|
|
595
|
+
keywords: ["health-sync"],
|
|
596
|
+
projectRoot,
|
|
597
|
+
scope: "project",
|
|
598
|
+
sync: false,
|
|
599
|
+
title: "Apple Health sync banner hardening",
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// "instinct" is one typo away from "instance" and "instruct" but means something else.
|
|
603
|
+
const lookalike = await memory.searchMemory({ projectRoot, query: "Instinct", scope: "project" });
|
|
604
|
+
const shared = await memory.searchMemory({ projectRoot, query: "context", scope: "project" });
|
|
605
|
+
const plural = await memory.searchMemory({ projectRoot, query: "worker instances", scope: "project" });
|
|
606
|
+
|
|
607
|
+
assert.deepEqual(lookalike.results, []);
|
|
608
|
+
assert.deepEqual(shared.results, []);
|
|
609
|
+
assert.equal(plural.results[0]?.heading, "Apple Health sync banner hardening");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("an empty folder has no project and uses global memory", async (t) => {
|
|
613
|
+
const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
614
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-empty-folder-"));
|
|
615
|
+
const lagoonRoot = path.join(tempRoot, "lagoon");
|
|
616
|
+
const emptyRoot = path.join(tempRoot, "billowy-glove");
|
|
617
|
+
const codeRoot = path.join(tempRoot, "job-search");
|
|
618
|
+
|
|
619
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
|
|
620
|
+
// Only .git, like a fresh session folder some agents create.
|
|
621
|
+
await fs.mkdir(path.join(emptyRoot, ".git"), { recursive: true });
|
|
622
|
+
await fs.mkdir(codeRoot, { recursive: true });
|
|
623
|
+
await fs.writeFile(path.join(codeRoot, "notes.md"), "Job search notes.\n", "utf8");
|
|
624
|
+
t.after(async () => {
|
|
625
|
+
if (originalRoot === undefined) {
|
|
626
|
+
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
627
|
+
} else {
|
|
628
|
+
process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
|
|
629
|
+
}
|
|
630
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const wake = await memory.wakeUp({ projectRoot: emptyRoot });
|
|
634
|
+
const event = await memory.appendEvent({
|
|
635
|
+
content: "Created the applying-to-jobs skill.",
|
|
636
|
+
keywords: ["applying-to-jobs"],
|
|
637
|
+
projectRoot: emptyRoot,
|
|
638
|
+
scope: "project",
|
|
639
|
+
sync: false,
|
|
640
|
+
title: "Job application skill created",
|
|
641
|
+
});
|
|
642
|
+
await memory.appendEvent({
|
|
643
|
+
content: "Created the applying-to-jobs tracker in Notion.",
|
|
644
|
+
keywords: ["applying-to-jobs-tracker"],
|
|
645
|
+
projectRoot: codeRoot,
|
|
646
|
+
scope: "project",
|
|
647
|
+
sync: false,
|
|
648
|
+
title: "Job tracker created",
|
|
649
|
+
});
|
|
650
|
+
const fromEmpty = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs skill" });
|
|
651
|
+
const projectScope = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs", scope: "project" });
|
|
652
|
+
const fromProject = await memory.searchMemory({ projectRoot: codeRoot, query: "applying to jobs skill" });
|
|
653
|
+
|
|
654
|
+
assert.equal(wake.projectSlug, null);
|
|
655
|
+
assert.equal(wake.projectMemoryRoot, lagoonRoot);
|
|
656
|
+
assert.equal(path.dirname(path.dirname(event.indexPath)), lagoonRoot);
|
|
657
|
+
// Global memory is searched once, and other projects stay out of it.
|
|
658
|
+
assert.deepEqual(fromEmpty.results.map((result) => result.heading), ["Job application skill created"]);
|
|
659
|
+
assert.deepEqual(projectScope.results.map((result) => result.heading), ["Job application skill created"]);
|
|
660
|
+
assert.equal(fromProject.results[0]?.heading, "Job application skill created");
|
|
661
|
+
assert.equal(memory.getProjectSlug(codeRoot), "job-search");
|
|
662
|
+
// A folder that does not exist keeps its name, as Dream's synthetic roots do.
|
|
663
|
+
assert.equal(memory.getProjectSlug(path.join(tempRoot, "missing")), "missing");
|
|
664
|
+
});
|
|
@@ -29,7 +29,6 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
await fs.mkdir(realLagoon);
|
|
32
|
-
await fs.mkdir(projectRoot);
|
|
33
32
|
await fs.symlink(realLagoon, linkedLagoon);
|
|
34
33
|
git(["init", "-q", "-b", "main"]);
|
|
35
34
|
git(["commit", "-q", "--allow-empty", "-m", "init"]);
|
|
@@ -45,7 +44,7 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
|
|
|
45
44
|
|
|
46
45
|
const result = await memory.appendEvent({
|
|
47
46
|
content: "Use advisory locks.",
|
|
48
|
-
|
|
47
|
+
keywords: ["locks"],
|
|
49
48
|
projectRoot,
|
|
50
49
|
scope: "project",
|
|
51
50
|
title: "Queue decision",
|
|
@@ -56,12 +55,11 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
|
|
|
56
55
|
|
|
57
56
|
// A symlink inside the Lagoon pointing at another project must not be synced as this one.
|
|
58
57
|
const otherProject = path.join(tempRoot, "other-project");
|
|
59
|
-
await fs.mkdir(otherProject);
|
|
60
58
|
await fs.mkdir(path.join(realLagoon, "projects", "other-project"), { recursive: true });
|
|
61
59
|
await fs.symlink(path.join(realLagoon, "projects", "linked-project", "events"), path.join(realLagoon, "projects", "other-project", "events"));
|
|
62
60
|
|
|
63
61
|
await assert.rejects(
|
|
64
|
-
memory.appendEvent({ content: "Cross write.",
|
|
62
|
+
memory.appendEvent({ content: "Cross write.", keywords: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
|
|
65
63
|
/escapes the memory root/,
|
|
66
64
|
);
|
|
67
65
|
});
|