@danielmarbach/mnemonic-mcp 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/CHANGELOG.md +37 -0
- package/LICENSE +201 -0
- package/README.md +395 -0
- package/build/config.d.ts +34 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +141 -0
- package/build/config.js.map +1 -0
- package/build/consolidate.d.ts +7 -0
- package/build/consolidate.d.ts.map +1 -0
- package/build/consolidate.js +42 -0
- package/build/consolidate.js.map +1 -0
- package/build/embeddings.d.ts +4 -0
- package/build/embeddings.d.ts.map +1 -0
- package/build/embeddings.js +32 -0
- package/build/embeddings.js.map +1 -0
- package/build/git.d.ts +70 -0
- package/build/git.d.ts.map +1 -0
- package/build/git.js +196 -0
- package/build/git.js.map +1 -0
- package/build/import.d.ts +14 -0
- package/build/import.d.ts.map +1 -0
- package/build/import.js +41 -0
- package/build/import.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +2753 -0
- package/build/index.js.map +1 -0
- package/build/markdown.d.ts +6 -0
- package/build/markdown.d.ts.map +1 -0
- package/build/markdown.js +51 -0
- package/build/markdown.js.map +1 -0
- package/build/migration.d.ts +65 -0
- package/build/migration.d.ts.map +1 -0
- package/build/migration.js +372 -0
- package/build/migration.js.map +1 -0
- package/build/project-introspection.d.ts +5 -0
- package/build/project-introspection.d.ts.map +1 -0
- package/build/project-introspection.js +28 -0
- package/build/project-introspection.js.map +1 -0
- package/build/project-memory-policy.d.ts +17 -0
- package/build/project-memory-policy.d.ts.map +1 -0
- package/build/project-memory-policy.js +16 -0
- package/build/project-memory-policy.js.map +1 -0
- package/build/project.d.ts +32 -0
- package/build/project.d.ts.map +1 -0
- package/build/project.js +125 -0
- package/build/project.js.map +1 -0
- package/build/recall.d.ts +10 -0
- package/build/recall.d.ts.map +1 -0
- package/build/recall.js +18 -0
- package/build/recall.js.map +1 -0
- package/build/storage.d.ts +58 -0
- package/build/storage.d.ts.map +1 -0
- package/build/storage.js +269 -0
- package/build/storage.js.map +1 -0
- package/build/structured-content.d.ts +1818 -0
- package/build/structured-content.d.ts.map +1 -0
- package/build/structured-content.js +267 -0
- package/build/structured-content.js.map +1 -0
- package/build/vault.d.ts +54 -0
- package/build/vault.d.ts.map +1 -0
- package/build/vault.js +144 -0
- package/build/vault.js.map +1 -0
- package/package.json +46 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,2753 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { promises as fs } from "fs";
|
|
8
|
+
import { NOTE_LIFECYCLES } from "./storage.js";
|
|
9
|
+
import { embed, cosineSimilarity, embedModel } from "./embeddings.js";
|
|
10
|
+
import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
|
|
11
|
+
import { selectRecallResults } from "./recall.js";
|
|
12
|
+
import { cleanMarkdown } from "./markdown.js";
|
|
13
|
+
import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
|
|
14
|
+
import { CONSOLIDATION_MODES, PROJECT_POLICY_SCOPES, WRITE_SCOPES, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
|
|
15
|
+
import { classifyTheme, summarizePreview, titleCaseTheme } from "./project-introspection.js";
|
|
16
|
+
import { detectProject, resolveProjectIdentity } from "./project.js";
|
|
17
|
+
import { VaultManager } from "./vault.js";
|
|
18
|
+
import { Migrator } from "./migration.js";
|
|
19
|
+
import { parseMemorySections } from "./import.js";
|
|
20
|
+
import { RememberResultSchema, RecallResultSchema, ListResultSchema, GetResultSchema, UpdateResultSchema, ForgetResultSchema, MoveResultSchema, RelateResultSchema, RecentResultSchema, MemoryGraphResultSchema, ProjectSummaryResultSchema, SyncResultSchema, WhereIsResultSchema, ConsolidateResultSchema, ProjectIdentityResultSchema, MigrationListResultSchema, MigrationExecuteResultSchema, PolicyResultSchema, } from "./structured-content.js";
|
|
21
|
+
// ── CLI Migration Command ─────────────────────────────────────────────────────
|
|
22
|
+
if (process.argv[2] === "migrate") {
|
|
23
|
+
const VAULT_PATH = process.env["VAULT_PATH"]
|
|
24
|
+
? path.resolve(process.env["VAULT_PATH"])
|
|
25
|
+
: path.join(process.env["HOME"] ?? "~", "mnemonic-vault");
|
|
26
|
+
async function runMigrationCli() {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const argv = process.argv.slice(3);
|
|
29
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
30
|
+
console.log(`
|
|
31
|
+
Mnemonic Migration Tool
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
mnemonic migrate [options]
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
--dry-run Show what would change without modifying files (STRONGLY RECOMMENDED)
|
|
38
|
+
--cwd=<path> Limit migration to specific project vault (/path/to/project)
|
|
39
|
+
--list Show available migrations and pending count
|
|
40
|
+
--help Show this help message
|
|
41
|
+
|
|
42
|
+
Workflow:
|
|
43
|
+
1. Always use --dry-run first to see what will change
|
|
44
|
+
2. Review the output carefully
|
|
45
|
+
3. Run without --dry-run to execute and auto-commit
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
# Step 1: See what would change
|
|
49
|
+
mnemonic migrate --dry-run
|
|
50
|
+
|
|
51
|
+
# Step 2: Review, then execute (auto-commits changes)
|
|
52
|
+
mnemonic migrate
|
|
53
|
+
|
|
54
|
+
# For a specific project
|
|
55
|
+
mnemonic migrate --dry-run --cwd=/path/to/project
|
|
56
|
+
mnemonic migrate --cwd=/path/to/project
|
|
57
|
+
`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const dryRun = argv.includes("--dry-run");
|
|
61
|
+
const cwdOption = argv.find(arg => arg.startsWith("--cwd="));
|
|
62
|
+
const targetCwd = cwdOption ? cwdOption.split("=")[1] : undefined;
|
|
63
|
+
const vaultManager = new VaultManager(VAULT_PATH);
|
|
64
|
+
await vaultManager.initMain();
|
|
65
|
+
const migrator = new Migrator(vaultManager);
|
|
66
|
+
if (argv.includes("--list")) {
|
|
67
|
+
const migrations = migrator.listAvailableMigrations();
|
|
68
|
+
console.log("Available migrations:");
|
|
69
|
+
migrations.forEach(m => console.log(` ${m.name}: ${m.description}`));
|
|
70
|
+
console.log("\nVault schema versions:");
|
|
71
|
+
let totalPending = 0;
|
|
72
|
+
for (const vault of vaultManager.allKnownVaults()) {
|
|
73
|
+
const version = await readVaultSchemaVersion(vault.storage.vaultPath);
|
|
74
|
+
const pending = await migrator.getPendingMigrations(version);
|
|
75
|
+
totalPending += pending.length;
|
|
76
|
+
const label = vault.isProject ? "project" : "main";
|
|
77
|
+
console.log(` ${label} (${vault.storage.vaultPath}): ${version} — ${pending.length} pending`);
|
|
78
|
+
}
|
|
79
|
+
if (dryRun && totalPending > 0) {
|
|
80
|
+
console.log("\n💡 Run without --dry-run to execute these migrations");
|
|
81
|
+
console.log(" Changes will be automatically committed and pushed");
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (dryRun) {
|
|
86
|
+
console.log("Running migrations in dry-run mode...");
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log("⚠️ Executing migrations (changes will be committed and pushed)...");
|
|
90
|
+
console.log(" Use --dry-run first if you want to preview changes\n");
|
|
91
|
+
}
|
|
92
|
+
const { migrationResults, vaultsProcessed } = await migrator.runAllPending({ dryRun, cwd: targetCwd });
|
|
93
|
+
for (const [vaultPath, results] of migrationResults) {
|
|
94
|
+
console.log(`\nVault: ${vaultPath}`);
|
|
95
|
+
for (const { migration, result } of results) {
|
|
96
|
+
console.log(` Migration ${migration}:`);
|
|
97
|
+
console.log(` Notes processed: ${result.notesProcessed}`);
|
|
98
|
+
console.log(` Notes modified: ${result.notesModified}`);
|
|
99
|
+
if (!dryRun && result.notesModified > 0) {
|
|
100
|
+
console.log(` Auto-committed: ${result.warnings.length === 0 ? "✓" : "⚠ (see warnings)"}`);
|
|
101
|
+
}
|
|
102
|
+
if (result.errors.length > 0) {
|
|
103
|
+
console.log(` Errors: ${result.errors.length}`);
|
|
104
|
+
result.errors.forEach(e => console.log(` - ${e.noteId}: ${e.error}`));
|
|
105
|
+
}
|
|
106
|
+
if (result.warnings.length > 0) {
|
|
107
|
+
console.log(` Warnings: ${result.warnings.length}`);
|
|
108
|
+
result.warnings.forEach(w => console.log(` - ${w}`));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!dryRun && vaultsProcessed > 0) {
|
|
113
|
+
console.log("\n✓ Migration completed");
|
|
114
|
+
console.log("Changes have been automatically committed and pushed.");
|
|
115
|
+
}
|
|
116
|
+
else if (dryRun) {
|
|
117
|
+
console.log("\n✓ Dry-run completed - no changes made");
|
|
118
|
+
if (vaultsProcessed > 0) {
|
|
119
|
+
console.log("\n💡 Ready to execute? Run: mnemonic migrate");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
runMigrationCli().catch(err => {
|
|
124
|
+
console.error("Migration failed:", err);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
// Wait for async operations to complete
|
|
128
|
+
await new Promise(() => { });
|
|
129
|
+
}
|
|
130
|
+
// ── CLI: import-claude-memory ─────────────────────────────────────────────────
|
|
131
|
+
if (process.argv[2] === "import-claude-memory") {
|
|
132
|
+
const homeDir = process.env["HOME"] ?? process.env["USERPROFILE"] ?? "~";
|
|
133
|
+
const VAULT_PATH = process.env["VAULT_PATH"]
|
|
134
|
+
? path.resolve(process.env["VAULT_PATH"])
|
|
135
|
+
: path.join(homeDir, "mnemonic-vault");
|
|
136
|
+
const CLAUDE_HOME = process.env["CLAUDE_HOME"]
|
|
137
|
+
? path.resolve(process.env["CLAUDE_HOME"])
|
|
138
|
+
: path.join(homeDir, ".claude");
|
|
139
|
+
function makeImportNoteId(title) {
|
|
140
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
141
|
+
const suffix = randomUUID().split("-")[0];
|
|
142
|
+
return slug ? `${slug}-${suffix}` : suffix;
|
|
143
|
+
}
|
|
144
|
+
async function runImportCli() {
|
|
145
|
+
const argv = process.argv.slice(3);
|
|
146
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
147
|
+
console.log(`
|
|
148
|
+
Mnemonic: Import Claude Code Auto-Memory
|
|
149
|
+
|
|
150
|
+
Usage:
|
|
151
|
+
mnemonic import-claude-memory [options]
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
--dry-run Show what would be imported without writing anything
|
|
155
|
+
--cwd=<path> Project path to resolve Claude memory for (default: cwd)
|
|
156
|
+
--claude-home=<p> Claude home directory (default: ~/.claude, or $CLAUDE_HOME)
|
|
157
|
+
--help Show this help message
|
|
158
|
+
|
|
159
|
+
How it works:
|
|
160
|
+
Claude Code stores per-project auto-memory in:
|
|
161
|
+
~/.claude/projects/<encoded-path>/memory/*.md
|
|
162
|
+
|
|
163
|
+
Each ## heading in those files becomes a separate mnemonic note
|
|
164
|
+
tagged with "claude-memory" and "imported". Notes with duplicate
|
|
165
|
+
titles are skipped.
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
mnemonic import-claude-memory --dry-run
|
|
169
|
+
mnemonic import-claude-memory
|
|
170
|
+
mnemonic import-claude-memory --cwd=/path/to/project
|
|
171
|
+
`);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
const dryRun = argv.includes("--dry-run");
|
|
175
|
+
const cwdOption = argv.find(arg => arg.startsWith("--cwd="));
|
|
176
|
+
const targetCwd = cwdOption ? path.resolve(cwdOption.split("=")[1]) : process.cwd();
|
|
177
|
+
const claudeHomeOption = argv.find(arg => arg.startsWith("--claude-home="));
|
|
178
|
+
const claudeHome = claudeHomeOption ? path.resolve(claudeHomeOption.split("=")[1]) : CLAUDE_HOME;
|
|
179
|
+
// Encode the project path the same way Claude Code does:
|
|
180
|
+
// /Users/foo/Projects/bar → -Users-foo-Projects-bar
|
|
181
|
+
// On Windows both \ and / are replaced with -
|
|
182
|
+
const projectDirName = targetCwd.replace(/[/\\]/g, "-");
|
|
183
|
+
const memoryDir = path.join(claudeHome, "projects", projectDirName, "memory");
|
|
184
|
+
try {
|
|
185
|
+
await fs.access(memoryDir);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
console.log(`No Claude memory found for this project.`);
|
|
189
|
+
console.log(`Expected: ${memoryDir}`);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
const files = (await fs.readdir(memoryDir)).filter(f => f.endsWith(".md")).sort();
|
|
193
|
+
if (files.length === 0) {
|
|
194
|
+
console.log("No markdown files found in Claude memory directory.");
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
console.log(`Found ${files.length} file(s) in ${memoryDir}`);
|
|
198
|
+
const vaultManager = new VaultManager(VAULT_PATH);
|
|
199
|
+
await vaultManager.initMain();
|
|
200
|
+
const vault = vaultManager.main;
|
|
201
|
+
const existingNotes = await vault.storage.listNotes();
|
|
202
|
+
const existingTitles = new Set(existingNotes.map(n => n.title.toLowerCase()));
|
|
203
|
+
const now = new Date().toISOString();
|
|
204
|
+
const notesToWrite = [];
|
|
205
|
+
const skipped = [];
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const raw = await fs.readFile(path.join(memoryDir, file), "utf-8");
|
|
208
|
+
const sections = parseMemorySections(raw);
|
|
209
|
+
const sourceTag = file.replace(/\.md$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
210
|
+
for (const section of sections) {
|
|
211
|
+
if (existingTitles.has(section.title.toLowerCase())) {
|
|
212
|
+
skipped.push(section.title);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
notesToWrite.push({
|
|
216
|
+
id: makeImportNoteId(section.title),
|
|
217
|
+
title: section.title,
|
|
218
|
+
content: section.content,
|
|
219
|
+
tags: ["claude-memory", "imported", sourceTag],
|
|
220
|
+
lifecycle: "permanent",
|
|
221
|
+
createdAt: now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
memoryVersion: 1,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (skipped.length > 0) {
|
|
228
|
+
console.log(`\nSkipped (title already exists in vault):`);
|
|
229
|
+
skipped.forEach(t => console.log(` ~ ${t}`));
|
|
230
|
+
}
|
|
231
|
+
if (notesToWrite.length === 0) {
|
|
232
|
+
console.log("\nNothing new to import.");
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
console.log(`\nSections to import (${notesToWrite.length}):`);
|
|
236
|
+
notesToWrite.forEach(n => console.log(` + ${n.title}`));
|
|
237
|
+
if (dryRun) {
|
|
238
|
+
console.log("\n✓ Dry-run complete — no changes written");
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
const filesToCommit = [];
|
|
242
|
+
for (const note of notesToWrite) {
|
|
243
|
+
await vault.storage.writeNote(note);
|
|
244
|
+
filesToCommit.push(`notes/${note.id}.md`);
|
|
245
|
+
}
|
|
246
|
+
const commitMessage = [
|
|
247
|
+
`import: claude-memory (${notesToWrite.length} note${notesToWrite.length === 1 ? "" : "s"})`,
|
|
248
|
+
"",
|
|
249
|
+
`- Notes: ${notesToWrite.length}`,
|
|
250
|
+
`- Source: ${memoryDir}`,
|
|
251
|
+
`- Skipped: ${skipped.length} (already exist)`,
|
|
252
|
+
].join("\n");
|
|
253
|
+
try {
|
|
254
|
+
const importConfigStore = new MnemonicConfigStore(VAULT_PATH);
|
|
255
|
+
await vault.git.commit(commitMessage, filesToCommit);
|
|
256
|
+
const mutationPushMode = (await importConfigStore.load()).mutationPushMode;
|
|
257
|
+
if (mutationPushMode !== "none") {
|
|
258
|
+
await vault.git.push();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error(`\nNotes written but git operation failed: ${err}`);
|
|
263
|
+
}
|
|
264
|
+
console.log(`\n✓ Imported ${notesToWrite.length} note${notesToWrite.length === 1 ? "" : "s"} into main vault`);
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
runImportCli().catch(err => {
|
|
268
|
+
console.error("Import failed:", err);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
});
|
|
271
|
+
// Wait for async operations to complete
|
|
272
|
+
await new Promise(() => { });
|
|
273
|
+
}
|
|
274
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
275
|
+
const VAULT_PATH = process.env["VAULT_PATH"]
|
|
276
|
+
? path.resolve(process.env["VAULT_PATH"])
|
|
277
|
+
: path.join(process.env["HOME"] ?? "~", "mnemonic-vault");
|
|
278
|
+
const DEFAULT_RECALL_LIMIT = 5;
|
|
279
|
+
const DEFAULT_MIN_SIMILARITY = 0.3;
|
|
280
|
+
async function readPackageVersion() {
|
|
281
|
+
const packageJsonPath = path.resolve(import.meta.dirname, "../package.json");
|
|
282
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
|
283
|
+
return packageJson.version ?? "0.1.0";
|
|
284
|
+
}
|
|
285
|
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
286
|
+
const vaultManager = new VaultManager(VAULT_PATH);
|
|
287
|
+
await vaultManager.initMain();
|
|
288
|
+
const configStore = new MnemonicConfigStore(VAULT_PATH);
|
|
289
|
+
const config = await configStore.load();
|
|
290
|
+
const migrator = new Migrator(vaultManager);
|
|
291
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
292
|
+
function slugify(text) {
|
|
293
|
+
return text
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
296
|
+
.replace(/^-+|-+$/g, "")
|
|
297
|
+
.slice(0, 60);
|
|
298
|
+
}
|
|
299
|
+
function makeId(title) {
|
|
300
|
+
const slug = slugify(title);
|
|
301
|
+
const suffix = randomUUID().split("-")[0];
|
|
302
|
+
return slug ? `${slug}-${suffix}` : suffix;
|
|
303
|
+
}
|
|
304
|
+
const projectParam = z
|
|
305
|
+
.string()
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("The working directory of the project (absolute path). " +
|
|
308
|
+
"Pass the cwd of the file/project being worked on. " +
|
|
309
|
+
"Omit for global memories not tied to any project.");
|
|
310
|
+
async function resolveProject(cwd) {
|
|
311
|
+
if (!cwd)
|
|
312
|
+
return undefined;
|
|
313
|
+
return detectProject(cwd, {
|
|
314
|
+
getProjectIdentityOverride: async (projectId) => configStore.getProjectIdentityOverride(projectId),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function resolveProjectIdentityForCwd(cwd) {
|
|
318
|
+
if (!cwd)
|
|
319
|
+
return undefined;
|
|
320
|
+
const identity = await resolveProjectIdentity(cwd, {
|
|
321
|
+
getProjectIdentityOverride: async (projectId) => configStore.getProjectIdentityOverride(projectId),
|
|
322
|
+
});
|
|
323
|
+
return identity ?? undefined;
|
|
324
|
+
}
|
|
325
|
+
async function resolveWriteVault(cwd, scope) {
|
|
326
|
+
if (scope === "project") {
|
|
327
|
+
return cwd
|
|
328
|
+
? (await vaultManager.getOrCreateProjectVault(cwd)) ?? vaultManager.main
|
|
329
|
+
: vaultManager.main;
|
|
330
|
+
}
|
|
331
|
+
return vaultManager.main;
|
|
332
|
+
}
|
|
333
|
+
function describeProject(project) {
|
|
334
|
+
return project ? `project '${project.name}' (${project.id})` : "global";
|
|
335
|
+
}
|
|
336
|
+
function formatProjectIdentityText(identity) {
|
|
337
|
+
const lines = [
|
|
338
|
+
`Project identity:`,
|
|
339
|
+
`- **id:** \`${identity.project.id}\``,
|
|
340
|
+
`- **name:** ${identity.project.name}`,
|
|
341
|
+
`- **source:** ${identity.project.source}`,
|
|
342
|
+
];
|
|
343
|
+
if (identity.project.remoteName) {
|
|
344
|
+
lines.push(`- **remote:** ${identity.project.remoteName}`);
|
|
345
|
+
}
|
|
346
|
+
if (identity.identityOverride) {
|
|
347
|
+
const defaultRemote = identity.defaultProject.remoteName ?? "none";
|
|
348
|
+
const status = identity.identityOverrideApplied ? "applied" : "configured, remote unavailable";
|
|
349
|
+
lines.push(`- **identity override:** ${identity.identityOverride.remoteName} (${status}; default remote: ${defaultRemote})`);
|
|
350
|
+
lines.push(`- **default id:** \`${identity.defaultProject.id}\``);
|
|
351
|
+
}
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
async function getProjectPolicyScope(cwd) {
|
|
355
|
+
const project = await resolveProject(cwd);
|
|
356
|
+
if (!project) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
const policy = await configStore.getProjectPolicy(project.id);
|
|
360
|
+
return policy?.defaultScope;
|
|
361
|
+
}
|
|
362
|
+
function describeLifecycle(lifecycle) {
|
|
363
|
+
return `lifecycle: ${lifecycle}`;
|
|
364
|
+
}
|
|
365
|
+
function formatNote(note, score) {
|
|
366
|
+
const scoreStr = score !== undefined ? ` | similarity: ${score.toFixed(3)}` : "";
|
|
367
|
+
const projectStr = note.project ? ` | project: ${note.projectName ?? note.project}` : " | global";
|
|
368
|
+
const relStr = note.relatedTo && note.relatedTo.length > 0
|
|
369
|
+
? `\n**related:** ${note.relatedTo.map((r) => `\`${r.id}\` (${r.type})`).join(", ")}`
|
|
370
|
+
: "";
|
|
371
|
+
return (`## ${note.title}\n` +
|
|
372
|
+
`**id:** \`${note.id}\`${projectStr}${scoreStr}\n` +
|
|
373
|
+
`**tags:** ${note.tags.join(", ") || "none"} | **${describeLifecycle(note.lifecycle)}** | **updated:** ${note.updatedAt}${relStr}\n\n` +
|
|
374
|
+
note.content);
|
|
375
|
+
}
|
|
376
|
+
// ── Git commit message helpers ────────────────────────────────────────────────
|
|
377
|
+
/**
|
|
378
|
+
* Extract a short human-readable summary from note content.
|
|
379
|
+
* Returns the first sentence or first 100 chars, whichever is shorter.
|
|
380
|
+
*/
|
|
381
|
+
function extractSummary(content, maxLength = 100) {
|
|
382
|
+
// Normalize whitespace
|
|
383
|
+
const normalized = content.replace(/\s+/g, " ").trim();
|
|
384
|
+
// Try to find first sentence (ending with .!? followed by space or end)
|
|
385
|
+
const sentenceMatch = normalized.match(/^[^.!?]+[.!?]/);
|
|
386
|
+
if (sentenceMatch) {
|
|
387
|
+
const sentence = sentenceMatch[0].trim();
|
|
388
|
+
if (sentence.length <= maxLength) {
|
|
389
|
+
return sentence;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Fallback: first maxLength chars
|
|
393
|
+
if (normalized.length <= maxLength) {
|
|
394
|
+
return normalized;
|
|
395
|
+
}
|
|
396
|
+
return normalized.slice(0, maxLength - 3) + "...";
|
|
397
|
+
}
|
|
398
|
+
function formatCommitBody(options) {
|
|
399
|
+
const lines = [];
|
|
400
|
+
// Human-readable summary comes first (like a good commit message)
|
|
401
|
+
if (options.summary) {
|
|
402
|
+
lines.push(options.summary);
|
|
403
|
+
lines.push("");
|
|
404
|
+
}
|
|
405
|
+
// Structured metadata follows
|
|
406
|
+
if (options.noteId && options.noteTitle) {
|
|
407
|
+
lines.push(`- Note: ${options.noteId} (${options.noteTitle})`);
|
|
408
|
+
}
|
|
409
|
+
if (options.noteIds && options.noteIds.length > 0) {
|
|
410
|
+
if (options.noteIds.length === 1 && !options.noteId) {
|
|
411
|
+
lines.push(`- Note: ${options.noteIds[0]}`);
|
|
412
|
+
}
|
|
413
|
+
else if (options.noteIds.length > 1) {
|
|
414
|
+
lines.push(`- Notes: ${options.noteIds.length} notes affected`);
|
|
415
|
+
options.noteIds.forEach((id) => lines.push(` - ${id}`));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (options.count && !options.noteIds) {
|
|
419
|
+
lines.push(`- Count: ${options.count} items`);
|
|
420
|
+
}
|
|
421
|
+
if (options.projectName) {
|
|
422
|
+
lines.push(`- Project: ${options.projectName}`);
|
|
423
|
+
}
|
|
424
|
+
if (options.scope) {
|
|
425
|
+
lines.push(`- Scope: ${options.scope}`);
|
|
426
|
+
}
|
|
427
|
+
if (options.tags && options.tags.length > 0) {
|
|
428
|
+
lines.push(`- Tags: ${options.tags.join(", ")}`);
|
|
429
|
+
}
|
|
430
|
+
if (options.relationship) {
|
|
431
|
+
lines.push(`- Relationship: ${options.relationship.fromId} ${options.relationship.type} ${options.relationship.toId}`);
|
|
432
|
+
}
|
|
433
|
+
if (options.mode) {
|
|
434
|
+
lines.push(`- Mode: ${options.mode}`);
|
|
435
|
+
}
|
|
436
|
+
if (options.description) {
|
|
437
|
+
lines.push("");
|
|
438
|
+
lines.push(options.description);
|
|
439
|
+
}
|
|
440
|
+
return lines.join("\n");
|
|
441
|
+
}
|
|
442
|
+
function formatAskForWriteScope(project) {
|
|
443
|
+
const projectLabel = project ? `${project.name} (${project.id})` : "this context";
|
|
444
|
+
return [
|
|
445
|
+
`Project memory policy for ${projectLabel} is set to always ask.`,
|
|
446
|
+
"Choose where to store this memory and call `remember` again with one of:",
|
|
447
|
+
"- `scope: \"project\"` — shared project vault (`.mnemonic/`)",
|
|
448
|
+
"- `scope: \"global\"` — private main vault with project association",
|
|
449
|
+
].join("\n");
|
|
450
|
+
}
|
|
451
|
+
async function embedMissingNotes(storage, noteIds, force = false) {
|
|
452
|
+
const notes = noteIds
|
|
453
|
+
? (await Promise.all(noteIds.map((id) => storage.readNote(id)))).filter(Boolean)
|
|
454
|
+
: await storage.listNotes();
|
|
455
|
+
let rebuilt = 0;
|
|
456
|
+
const failed = [];
|
|
457
|
+
let index = 0;
|
|
458
|
+
const workerCount = Math.min(config.reindexEmbedConcurrency, Math.max(notes.length, 1));
|
|
459
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
460
|
+
while (true) {
|
|
461
|
+
const note = notes[index++];
|
|
462
|
+
if (!note) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!force) {
|
|
466
|
+
const existing = await storage.readEmbedding(note.id);
|
|
467
|
+
if (existing?.model === embedModel) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const vector = await embed(`${note.title}\n\n${note.content}`);
|
|
473
|
+
await storage.writeEmbedding({
|
|
474
|
+
id: note.id,
|
|
475
|
+
model: embedModel,
|
|
476
|
+
embedding: vector,
|
|
477
|
+
updatedAt: new Date().toISOString(),
|
|
478
|
+
});
|
|
479
|
+
rebuilt++;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
failed.push(note.id);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
await Promise.all(workers);
|
|
487
|
+
failed.sort();
|
|
488
|
+
return { rebuilt, failed };
|
|
489
|
+
}
|
|
490
|
+
async function backfillEmbeddingsAfterSync(storage, label, lines, force = false) {
|
|
491
|
+
const { rebuilt, failed } = await embedMissingNotes(storage, undefined, force);
|
|
492
|
+
if (rebuilt > 0 || failed.length > 0) {
|
|
493
|
+
lines.push(`${label}: embedded ${rebuilt} note(s)${force ? " (force rebuild)." : " (including any missing local embeddings)."}` +
|
|
494
|
+
`${failed.length > 0 ? ` Failed: ${failed.join(", ")}` : ""}`);
|
|
495
|
+
}
|
|
496
|
+
return { embedded: rebuilt, failed };
|
|
497
|
+
}
|
|
498
|
+
async function removeStaleEmbeddings(storage, noteIds) {
|
|
499
|
+
for (const id of noteIds) {
|
|
500
|
+
try {
|
|
501
|
+
await fs.unlink(storage.embeddingPath(id));
|
|
502
|
+
}
|
|
503
|
+
catch { /* already gone */ }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function formatSyncResult(result, label) {
|
|
507
|
+
if (!result.hasRemote)
|
|
508
|
+
return [`${label}: no remote configured — git sync skipped.`];
|
|
509
|
+
const lines = [];
|
|
510
|
+
lines.push(result.pushedCommits > 0
|
|
511
|
+
? `${label}: ↑ pushed ${result.pushedCommits} commit(s).`
|
|
512
|
+
: `${label}: ↑ nothing to push.`);
|
|
513
|
+
if (result.deletedNoteIds.length > 0)
|
|
514
|
+
lines.push(`${label}: ✕ ${result.deletedNoteIds.length} note(s) deleted on remote.`);
|
|
515
|
+
lines.push(result.pulledNoteIds.length > 0
|
|
516
|
+
? `${label}: ↓ ${result.pulledNoteIds.length} note(s) pulled.`
|
|
517
|
+
: `${label}: ↓ no new notes from remote.`);
|
|
518
|
+
return lines;
|
|
519
|
+
}
|
|
520
|
+
function resolveDurability(commit, push) {
|
|
521
|
+
if (push.status === "pushed") {
|
|
522
|
+
return "pushed";
|
|
523
|
+
}
|
|
524
|
+
if (commit.status === "committed") {
|
|
525
|
+
return "committed";
|
|
526
|
+
}
|
|
527
|
+
return "local-only";
|
|
528
|
+
}
|
|
529
|
+
function buildPersistenceStatus(args) {
|
|
530
|
+
return {
|
|
531
|
+
notePath: args.storage.notePath(args.id),
|
|
532
|
+
embeddingPath: args.storage.embeddingPath(args.id),
|
|
533
|
+
embedding: {
|
|
534
|
+
status: args.embedding.status,
|
|
535
|
+
model: embedModel,
|
|
536
|
+
reason: args.embedding.reason,
|
|
537
|
+
},
|
|
538
|
+
git: {
|
|
539
|
+
commit: args.commit.status,
|
|
540
|
+
push: args.push.status,
|
|
541
|
+
commitMessage: args.commitMessage,
|
|
542
|
+
commitBody: args.commitBody,
|
|
543
|
+
commitReason: args.commit.reason,
|
|
544
|
+
pushReason: args.push.reason,
|
|
545
|
+
},
|
|
546
|
+
durability: resolveDurability(args.commit, args.push),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function formatPersistenceSummary(persistence) {
|
|
550
|
+
const parts = [
|
|
551
|
+
`Persistence: embedding ${persistence.embedding.status}`,
|
|
552
|
+
`git ${persistence.durability}`,
|
|
553
|
+
];
|
|
554
|
+
if (persistence.embedding.reason) {
|
|
555
|
+
parts.push(`embedding reason=${persistence.embedding.reason}`);
|
|
556
|
+
}
|
|
557
|
+
return parts.join(" | ");
|
|
558
|
+
}
|
|
559
|
+
async function getMutationPushMode() {
|
|
560
|
+
const latestConfig = await configStore.load();
|
|
561
|
+
return latestConfig.mutationPushMode;
|
|
562
|
+
}
|
|
563
|
+
async function pushAfterMutation(vault) {
|
|
564
|
+
const mutationPushMode = await getMutationPushMode();
|
|
565
|
+
switch (mutationPushMode) {
|
|
566
|
+
case "all":
|
|
567
|
+
return vault.git.pushWithStatus();
|
|
568
|
+
case "main-only":
|
|
569
|
+
return vault.isProject
|
|
570
|
+
? { status: "skipped", reason: "auto-push-disabled" }
|
|
571
|
+
: vault.git.pushWithStatus();
|
|
572
|
+
case "none":
|
|
573
|
+
return { status: "skipped", reason: "auto-push-disabled" };
|
|
574
|
+
default: {
|
|
575
|
+
const _exhaustive = mutationPushMode;
|
|
576
|
+
throw new Error(`Unknown mutation push mode: ${_exhaustive}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function storageLabel(vault) {
|
|
581
|
+
return vault.isProject ? "project-vault" : "main-vault";
|
|
582
|
+
}
|
|
583
|
+
async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any") {
|
|
584
|
+
const project = await resolveProject(cwd);
|
|
585
|
+
const vaults = await vaultManager.searchOrder(cwd);
|
|
586
|
+
let filterProject = undefined;
|
|
587
|
+
if (scope === "project" && project)
|
|
588
|
+
filterProject = project.id;
|
|
589
|
+
else if (scope === "global")
|
|
590
|
+
filterProject = null;
|
|
591
|
+
const seen = new Set();
|
|
592
|
+
const entries = [];
|
|
593
|
+
for (const vault of vaults) {
|
|
594
|
+
const vaultNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
|
|
595
|
+
for (const note of vaultNotes) {
|
|
596
|
+
if (seen.has(note.id)) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (tags && tags.length > 0) {
|
|
600
|
+
const noteTags = new Set(note.tags);
|
|
601
|
+
if (!tags.every((tag) => noteTags.has(tag))) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (storedIn !== "any" && storageLabel(vault) !== storedIn) {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
seen.add(note.id);
|
|
609
|
+
entries.push({ note, vault });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
entries.sort((a, b) => {
|
|
613
|
+
const aRank = project && a.note.project === project.id ? 0 : a.note.project ? 1 : 2;
|
|
614
|
+
const bRank = project && b.note.project === project.id ? 0 : b.note.project ? 1 : 2;
|
|
615
|
+
return aRank - bRank || a.note.title.localeCompare(b.note.title);
|
|
616
|
+
});
|
|
617
|
+
return { project, entries };
|
|
618
|
+
}
|
|
619
|
+
function formatListEntry(entry, options = {}) {
|
|
620
|
+
const { note, vault } = entry;
|
|
621
|
+
const proj = note.project ? `[${note.projectName ?? note.project}]` : "[global]";
|
|
622
|
+
const extras = [];
|
|
623
|
+
if (note.tags.length > 0)
|
|
624
|
+
extras.push(note.tags.join(", "));
|
|
625
|
+
extras.push(`lifecycle=${note.lifecycle}`);
|
|
626
|
+
if (options.includeStorage)
|
|
627
|
+
extras.push(`stored=${storageLabel(vault)}`);
|
|
628
|
+
if (options.includeUpdated)
|
|
629
|
+
extras.push(`updated=${note.updatedAt}`);
|
|
630
|
+
const lines = [`- **${note.title}** \`${note.id}\` ${proj}${extras.length > 0 ? ` — ${extras.join(" | ")}` : ""}`];
|
|
631
|
+
if (options.includeRelations && note.relatedTo && note.relatedTo.length > 0) {
|
|
632
|
+
lines.push(` related: ${note.relatedTo.map((rel) => `${rel.id} (${rel.type})`).join(", ")}`);
|
|
633
|
+
}
|
|
634
|
+
if (options.includePreview) {
|
|
635
|
+
lines.push(` preview: ${summarizePreview(note.content)}`);
|
|
636
|
+
}
|
|
637
|
+
return lines.join("\n");
|
|
638
|
+
}
|
|
639
|
+
async function formatProjectPolicyLine(projectId) {
|
|
640
|
+
if (!projectId) {
|
|
641
|
+
return "Policy: none";
|
|
642
|
+
}
|
|
643
|
+
const policy = await configStore.getProjectPolicy(projectId);
|
|
644
|
+
if (!policy) {
|
|
645
|
+
return "Policy: none (fallback write scope with cwd is project)";
|
|
646
|
+
}
|
|
647
|
+
return `Policy: default write scope ${policy.defaultScope} (updated ${policy.updatedAt})`;
|
|
648
|
+
}
|
|
649
|
+
async function moveNoteBetweenVaults(found, targetVault, noteToWrite) {
|
|
650
|
+
const { note, vault: sourceVault } = found;
|
|
651
|
+
const finalNote = noteToWrite ?? note;
|
|
652
|
+
const embedding = await sourceVault.storage.readEmbedding(note.id);
|
|
653
|
+
await targetVault.storage.writeNote(finalNote);
|
|
654
|
+
if (embedding) {
|
|
655
|
+
await targetVault.storage.writeEmbedding(embedding);
|
|
656
|
+
}
|
|
657
|
+
await sourceVault.storage.deleteNote(note.id);
|
|
658
|
+
const sourceVaultLabel = sourceVault.isProject ? "project-vault" : "main-vault";
|
|
659
|
+
const targetVaultLabel = targetVault.isProject ? "project-vault" : "main-vault";
|
|
660
|
+
const targetCommitBody = formatCommitBody({
|
|
661
|
+
summary: `Moved from ${sourceVaultLabel} to ${targetVaultLabel}`,
|
|
662
|
+
noteId: finalNote.id,
|
|
663
|
+
noteTitle: finalNote.title,
|
|
664
|
+
projectName: finalNote.projectName,
|
|
665
|
+
});
|
|
666
|
+
const targetCommit = await targetVault.git.commitWithStatus(`move: ${finalNote.title}`, [vaultManager.noteRelPath(targetVault, finalNote.id)], targetCommitBody);
|
|
667
|
+
const sourceCommitBody = formatCommitBody({
|
|
668
|
+
summary: `Moved to ${targetVaultLabel}`,
|
|
669
|
+
noteId: finalNote.id,
|
|
670
|
+
noteTitle: finalNote.title,
|
|
671
|
+
projectName: finalNote.projectName,
|
|
672
|
+
});
|
|
673
|
+
await sourceVault.git.commitWithStatus(`move: ${finalNote.title}`, [vaultManager.noteRelPath(sourceVault, finalNote.id)], sourceCommitBody);
|
|
674
|
+
const targetPush = await pushAfterMutation(targetVault);
|
|
675
|
+
if (sourceVault !== targetVault) {
|
|
676
|
+
await pushAfterMutation(sourceVault);
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
note: finalNote,
|
|
680
|
+
persistence: buildPersistenceStatus({
|
|
681
|
+
storage: targetVault.storage,
|
|
682
|
+
id: finalNote.id,
|
|
683
|
+
embedding: embedding ? { status: "written" } : { status: "skipped", reason: "no-source-embedding" },
|
|
684
|
+
commit: targetCommit,
|
|
685
|
+
push: targetPush,
|
|
686
|
+
commitMessage: `move: ${finalNote.title}`,
|
|
687
|
+
commitBody: targetCommitBody,
|
|
688
|
+
}),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
async function removeRelationshipsToNoteIds(noteIds) {
|
|
692
|
+
const vaultChanges = new Map();
|
|
693
|
+
for (const vault of vaultManager.allKnownVaults()) {
|
|
694
|
+
const notes = await vault.storage.listNotes();
|
|
695
|
+
for (const note of notes) {
|
|
696
|
+
const filtered = filterRelationships(note.relatedTo, noteIds);
|
|
697
|
+
if (filtered === note.relatedTo) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
await vault.storage.writeNote({
|
|
701
|
+
...note,
|
|
702
|
+
relatedTo: filtered,
|
|
703
|
+
});
|
|
704
|
+
addVaultChange(vaultChanges, vault, vaultManager.noteRelPath(vault, note.id));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return vaultChanges;
|
|
708
|
+
}
|
|
709
|
+
function addVaultChange(vaultChanges, vault, file) {
|
|
710
|
+
const files = vaultChanges.get(vault) ?? [];
|
|
711
|
+
if (!files.includes(file)) {
|
|
712
|
+
files.push(file);
|
|
713
|
+
vaultChanges.set(vault, files);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
717
|
+
const server = new McpServer({
|
|
718
|
+
name: "mnemonic",
|
|
719
|
+
version: await readPackageVersion(),
|
|
720
|
+
});
|
|
721
|
+
// ── detect_project ────────────────────────────────────────────────────────────
|
|
722
|
+
server.registerTool("detect_project", {
|
|
723
|
+
title: "Detect Project",
|
|
724
|
+
description: "Identify which project a working directory belongs to. " +
|
|
725
|
+
"Returns the stable project id and name. " +
|
|
726
|
+
"Call this to know what project context to pass to other tools.",
|
|
727
|
+
outputSchema: ProjectIdentityResultSchema,
|
|
728
|
+
inputSchema: z.object({
|
|
729
|
+
cwd: z.string().describe("Absolute path to the working directory"),
|
|
730
|
+
}),
|
|
731
|
+
}, async ({ cwd }) => {
|
|
732
|
+
const identity = await resolveProjectIdentityForCwd(cwd);
|
|
733
|
+
const project = identity?.project;
|
|
734
|
+
if (!project || !identity) {
|
|
735
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
736
|
+
}
|
|
737
|
+
const policyLine = await formatProjectPolicyLine(project.id);
|
|
738
|
+
const structuredContent = {
|
|
739
|
+
action: "project_identity_detected",
|
|
740
|
+
project: {
|
|
741
|
+
id: project.id,
|
|
742
|
+
name: project.name,
|
|
743
|
+
source: project.source,
|
|
744
|
+
remoteName: project.remoteName,
|
|
745
|
+
},
|
|
746
|
+
defaultProject: identity.defaultProject ? {
|
|
747
|
+
id: identity.defaultProject.id,
|
|
748
|
+
name: identity.defaultProject.name,
|
|
749
|
+
remoteName: identity.defaultProject.remoteName,
|
|
750
|
+
} : undefined,
|
|
751
|
+
identityOverride: identity.identityOverride,
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
content: [{
|
|
755
|
+
type: "text",
|
|
756
|
+
text: `${formatProjectIdentityText(identity)}\n` +
|
|
757
|
+
`- **${policyLine}**`,
|
|
758
|
+
}],
|
|
759
|
+
structuredContent,
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
// ── get_project_identity ───────────────────────────────────────────────────────
|
|
763
|
+
server.registerTool("get_project_identity", {
|
|
764
|
+
title: "Get Project Identity",
|
|
765
|
+
description: "Show the effective project identity for a working directory, including any configured remote override.",
|
|
766
|
+
inputSchema: z.object({
|
|
767
|
+
cwd: z.string().describe("Absolute path to the project working directory"),
|
|
768
|
+
}),
|
|
769
|
+
outputSchema: ProjectIdentityResultSchema,
|
|
770
|
+
}, async ({ cwd }) => {
|
|
771
|
+
const identity = await resolveProjectIdentityForCwd(cwd);
|
|
772
|
+
if (!identity) {
|
|
773
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
774
|
+
}
|
|
775
|
+
const structuredContent = {
|
|
776
|
+
action: "project_identity_shown",
|
|
777
|
+
project: {
|
|
778
|
+
id: identity.project.id,
|
|
779
|
+
name: identity.project.name,
|
|
780
|
+
source: identity.project.source,
|
|
781
|
+
remoteName: identity.project.remoteName,
|
|
782
|
+
},
|
|
783
|
+
defaultProject: identity.defaultProject ? {
|
|
784
|
+
id: identity.defaultProject.id,
|
|
785
|
+
name: identity.defaultProject.name,
|
|
786
|
+
remoteName: identity.defaultProject.remoteName,
|
|
787
|
+
} : undefined,
|
|
788
|
+
identityOverride: identity.identityOverride,
|
|
789
|
+
};
|
|
790
|
+
return {
|
|
791
|
+
content: [{
|
|
792
|
+
type: "text",
|
|
793
|
+
text: formatProjectIdentityText(identity),
|
|
794
|
+
}],
|
|
795
|
+
structuredContent,
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
// ── set_project_identity ───────────────────────────────────────────────────────
|
|
799
|
+
server.registerTool("set_project_identity", {
|
|
800
|
+
title: "Set Project Identity",
|
|
801
|
+
description: "Override which git remote defines project identity for a repo. Useful for forks that should follow `upstream` instead of `origin`.",
|
|
802
|
+
inputSchema: z.object({
|
|
803
|
+
cwd: z.string().describe("Absolute path to the project working directory"),
|
|
804
|
+
remoteName: z.string().min(1).describe("Git remote name to use as the canonical project identity, such as `upstream`")
|
|
805
|
+
}),
|
|
806
|
+
outputSchema: ProjectIdentityResultSchema,
|
|
807
|
+
}, async ({ cwd, remoteName }) => {
|
|
808
|
+
const defaultIdentity = await resolveProjectIdentity(cwd);
|
|
809
|
+
if (!defaultIdentity) {
|
|
810
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
811
|
+
}
|
|
812
|
+
const defaultProject = defaultIdentity.project;
|
|
813
|
+
if (defaultProject.source !== "git-remote") {
|
|
814
|
+
return {
|
|
815
|
+
content: [{
|
|
816
|
+
type: "text",
|
|
817
|
+
text: `Project identity override requires a git remote. Current source: ${defaultProject.source}`,
|
|
818
|
+
}],
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const now = new Date().toISOString();
|
|
822
|
+
const candidateIdentity = await resolveProjectIdentity(cwd, {
|
|
823
|
+
getProjectIdentityOverride: async () => ({ remoteName, updatedAt: now }),
|
|
824
|
+
});
|
|
825
|
+
if (!candidateIdentity || !candidateIdentity.identityOverrideApplied) {
|
|
826
|
+
return {
|
|
827
|
+
content: [{
|
|
828
|
+
type: "text",
|
|
829
|
+
text: `Could not resolve git remote '${remoteName}' for ${defaultProject.name}.`,
|
|
830
|
+
}],
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
await configStore.setProjectIdentityOverride(defaultProject.id, { remoteName, updatedAt: now });
|
|
834
|
+
const commitBody = formatCommitBody({
|
|
835
|
+
summary: `Use ${remoteName} as canonical project identity`,
|
|
836
|
+
projectName: defaultProject.name,
|
|
837
|
+
description: `Default identity: ${defaultProject.id}\n` +
|
|
838
|
+
`Resolved identity: ${candidateIdentity.project.id}\n` +
|
|
839
|
+
`Remote: ${remoteName}`,
|
|
840
|
+
});
|
|
841
|
+
await vaultManager.main.git.commit(`identity: ${defaultProject.name} use remote ${remoteName}`, ["config.json"], commitBody);
|
|
842
|
+
await pushAfterMutation(vaultManager.main);
|
|
843
|
+
const structuredContent = {
|
|
844
|
+
action: "project_identity_set",
|
|
845
|
+
project: {
|
|
846
|
+
id: candidateIdentity.project.id,
|
|
847
|
+
name: candidateIdentity.project.name,
|
|
848
|
+
source: candidateIdentity.project.source,
|
|
849
|
+
remoteName: candidateIdentity.project.remoteName,
|
|
850
|
+
},
|
|
851
|
+
defaultProject: {
|
|
852
|
+
id: defaultProject.id,
|
|
853
|
+
name: defaultProject.name,
|
|
854
|
+
remoteName: defaultProject.remoteName,
|
|
855
|
+
},
|
|
856
|
+
identityOverride: {
|
|
857
|
+
remoteName,
|
|
858
|
+
updatedAt: now,
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
return {
|
|
862
|
+
content: [{
|
|
863
|
+
type: "text",
|
|
864
|
+
text: `Project identity override set for ${defaultProject.name}: ` +
|
|
865
|
+
`default=\`${defaultProject.id}\`, effective=\`${candidateIdentity.project.id}\`, remote=${remoteName}`,
|
|
866
|
+
}],
|
|
867
|
+
structuredContent,
|
|
868
|
+
};
|
|
869
|
+
});
|
|
870
|
+
// ── list_migrations ───────────────────────────────────────────────────────────
|
|
871
|
+
server.registerTool("list_migrations", {
|
|
872
|
+
title: "List Migrations",
|
|
873
|
+
description: "List available migrations and show which ones are pending for the current schema version",
|
|
874
|
+
inputSchema: z.object({}),
|
|
875
|
+
outputSchema: MigrationListResultSchema,
|
|
876
|
+
}, async () => {
|
|
877
|
+
const available = migrator.listAvailableMigrations();
|
|
878
|
+
const lines = [];
|
|
879
|
+
lines.push("Vault schema versions:");
|
|
880
|
+
let totalPending = 0;
|
|
881
|
+
const vaultsInfo = [];
|
|
882
|
+
for (const vault of vaultManager.allKnownVaults()) {
|
|
883
|
+
const version = await readVaultSchemaVersion(vault.storage.vaultPath);
|
|
884
|
+
const pending = await migrator.getPendingMigrations(version);
|
|
885
|
+
totalPending += pending.length;
|
|
886
|
+
const label = vault.isProject ? "project" : "main";
|
|
887
|
+
lines.push(` ${label} (${vault.storage.vaultPath}): ${version} — ${pending.length} pending`);
|
|
888
|
+
vaultsInfo.push({
|
|
889
|
+
path: vault.storage.vaultPath,
|
|
890
|
+
type: vault.isProject ? "project" : "main",
|
|
891
|
+
version,
|
|
892
|
+
pending: pending.length,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
lines.push("");
|
|
896
|
+
lines.push("Available migrations:");
|
|
897
|
+
for (const migration of available) {
|
|
898
|
+
lines.push(` ${migration.name}`);
|
|
899
|
+
lines.push(` ${migration.description}`);
|
|
900
|
+
}
|
|
901
|
+
lines.push("");
|
|
902
|
+
if (totalPending > 0) {
|
|
903
|
+
lines.push("Run migration with: mnemonic migrate (CLI) or execute_migration (MCP)");
|
|
904
|
+
}
|
|
905
|
+
const structuredContent = {
|
|
906
|
+
action: "migration_list",
|
|
907
|
+
vaults: vaultsInfo,
|
|
908
|
+
available: available.map(m => ({ name: m.name, description: m.description })),
|
|
909
|
+
totalPending,
|
|
910
|
+
};
|
|
911
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
912
|
+
});
|
|
913
|
+
// ── execute_migration ─────────────────────────────────────────────────────────
|
|
914
|
+
server.registerTool("execute_migration", {
|
|
915
|
+
title: "Execute Migration",
|
|
916
|
+
description: "Execute a named migration on vault notes",
|
|
917
|
+
inputSchema: z.object({
|
|
918
|
+
migrationName: z.string().describe("Name of the migration to execute"),
|
|
919
|
+
dryRun: z.boolean().default(true).describe("If true, show what would change without actually modifying notes"),
|
|
920
|
+
backup: z.boolean().default(true).describe("If true, warn about backing up before real migration"),
|
|
921
|
+
cwd: projectParam.optional().describe("Optional: limit to project vault for given working directory"),
|
|
922
|
+
}),
|
|
923
|
+
outputSchema: MigrationExecuteResultSchema,
|
|
924
|
+
}, async ({ migrationName, dryRun, backup, cwd }) => {
|
|
925
|
+
try {
|
|
926
|
+
const { results, vaultsProcessed } = await migrator.runMigration(migrationName, {
|
|
927
|
+
dryRun,
|
|
928
|
+
backup,
|
|
929
|
+
cwd,
|
|
930
|
+
});
|
|
931
|
+
const lines = [];
|
|
932
|
+
lines.push(`Migration: ${migrationName}`);
|
|
933
|
+
lines.push(`Mode: ${dryRun ? "DRY-RUN" : "EXECUTE"}`);
|
|
934
|
+
lines.push(`Vaults processed: ${vaultsProcessed}`);
|
|
935
|
+
lines.push("");
|
|
936
|
+
const vaultResults = [];
|
|
937
|
+
for (const [vaultPath, result] of results) {
|
|
938
|
+
lines.push(`Vault: ${vaultPath}`);
|
|
939
|
+
lines.push(` Notes processed: ${result.notesProcessed}`);
|
|
940
|
+
lines.push(` Notes modified: ${result.notesModified}`);
|
|
941
|
+
const vaultResultErrors = [];
|
|
942
|
+
const vaultResultWarnings = [];
|
|
943
|
+
if (result.errors.length > 0) {
|
|
944
|
+
lines.push(` Errors: ${result.errors.length}`);
|
|
945
|
+
result.errors.forEach(e => lines.push(` - ${e.noteId}: ${e.error}`));
|
|
946
|
+
vaultResultErrors.push(...result.errors.map(e => ({ noteId: e.noteId, error: e.error })));
|
|
947
|
+
}
|
|
948
|
+
if (result.warnings.length > 0) {
|
|
949
|
+
lines.push(` Warnings: ${result.warnings.length}`);
|
|
950
|
+
result.warnings.forEach(w => lines.push(` - ${w}`));
|
|
951
|
+
vaultResultWarnings.push(...result.warnings);
|
|
952
|
+
}
|
|
953
|
+
vaultResults.push({
|
|
954
|
+
path: vaultPath,
|
|
955
|
+
notesProcessed: result.notesProcessed,
|
|
956
|
+
notesModified: result.notesModified,
|
|
957
|
+
errors: vaultResultErrors,
|
|
958
|
+
warnings: vaultResultWarnings,
|
|
959
|
+
});
|
|
960
|
+
lines.push("");
|
|
961
|
+
}
|
|
962
|
+
if (!dryRun) {
|
|
963
|
+
lines.push("Migration executed. Modified vaults were auto-committed and pushed when git was available.");
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
lines.push("✓ Dry-run completed - no changes made");
|
|
967
|
+
}
|
|
968
|
+
const structuredContent = {
|
|
969
|
+
action: "migration_executed",
|
|
970
|
+
migration: migrationName,
|
|
971
|
+
dryRun,
|
|
972
|
+
vaultsProcessed,
|
|
973
|
+
vaultResults,
|
|
974
|
+
};
|
|
975
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
return {
|
|
979
|
+
content: [{
|
|
980
|
+
type: "text",
|
|
981
|
+
text: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
982
|
+
}],
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
// ── remember ──────────────────────────────────────────────────────────────────
|
|
987
|
+
server.registerTool("remember", {
|
|
988
|
+
title: "Remember",
|
|
989
|
+
description: "Store a new memory. `cwd` sets project context. `scope` picks whether the note " +
|
|
990
|
+
"is stored in the shared project vault or the private main vault. When omitted, " +
|
|
991
|
+
"the project's default policy is used before falling back to legacy behavior.",
|
|
992
|
+
inputSchema: z.object({
|
|
993
|
+
title: z.string().describe("Short descriptive title"),
|
|
994
|
+
content: z.string().describe("The content to remember (markdown supported; write summary-first with the key fact or decision near the top)"),
|
|
995
|
+
tags: z.array(z.string()).optional().default([]).describe("Optional tags"),
|
|
996
|
+
lifecycle: z
|
|
997
|
+
.enum(NOTE_LIFECYCLES)
|
|
998
|
+
.optional()
|
|
999
|
+
.describe("Whether the note is temporary working-state scaffolding or durable permanent knowledge"),
|
|
1000
|
+
summary: z.string().optional().describe("Brief summary for git commit message (like a good commit message, describing the change). Not stored in the note."),
|
|
1001
|
+
cwd: projectParam,
|
|
1002
|
+
scope: z
|
|
1003
|
+
.enum(WRITE_SCOPES)
|
|
1004
|
+
.optional()
|
|
1005
|
+
.describe("Where to store the memory: project vault or private global vault"),
|
|
1006
|
+
}),
|
|
1007
|
+
outputSchema: RememberResultSchema,
|
|
1008
|
+
}, async ({ title, content, tags, lifecycle, summary, cwd, scope }) => {
|
|
1009
|
+
const project = await resolveProject(cwd);
|
|
1010
|
+
const cleanedContent = await cleanMarkdown(content);
|
|
1011
|
+
const policyScope = await getProjectPolicyScope(cwd);
|
|
1012
|
+
const writeScope = resolveWriteScope(scope, policyScope, Boolean(project));
|
|
1013
|
+
if (writeScope === "ask") {
|
|
1014
|
+
return { content: [{ type: "text", text: formatAskForWriteScope(project) }], isError: true };
|
|
1015
|
+
}
|
|
1016
|
+
const vault = await resolveWriteVault(cwd, writeScope);
|
|
1017
|
+
const id = makeId(title);
|
|
1018
|
+
const now = new Date().toISOString();
|
|
1019
|
+
const note = {
|
|
1020
|
+
id, title, content: cleanedContent, tags,
|
|
1021
|
+
lifecycle: lifecycle ?? "permanent",
|
|
1022
|
+
project: project?.id,
|
|
1023
|
+
projectName: project?.name,
|
|
1024
|
+
createdAt: now,
|
|
1025
|
+
updatedAt: now,
|
|
1026
|
+
memoryVersion: 1,
|
|
1027
|
+
};
|
|
1028
|
+
await vault.storage.writeNote(note);
|
|
1029
|
+
let embeddingStatus = { status: "written" };
|
|
1030
|
+
try {
|
|
1031
|
+
const vector = await embed(`${title}\n\n${cleanedContent}`);
|
|
1032
|
+
await vault.storage.writeEmbedding({ id, model: embedModel, embedding: vector, updatedAt: now });
|
|
1033
|
+
}
|
|
1034
|
+
catch (err) {
|
|
1035
|
+
embeddingStatus = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
|
1036
|
+
console.error(`[embedding] Skipped for '${id}': ${err}`);
|
|
1037
|
+
}
|
|
1038
|
+
const projectScope = describeProject(project);
|
|
1039
|
+
const commitSummary = summary ?? extractSummary(cleanedContent);
|
|
1040
|
+
const commitBody = formatCommitBody({
|
|
1041
|
+
summary: commitSummary,
|
|
1042
|
+
noteId: id,
|
|
1043
|
+
noteTitle: title,
|
|
1044
|
+
projectName: project?.name,
|
|
1045
|
+
scope: writeScope,
|
|
1046
|
+
tags: tags,
|
|
1047
|
+
});
|
|
1048
|
+
const commitStatus = await vault.git.commitWithStatus(`remember: ${title}`, [vaultManager.noteRelPath(vault, id)], commitBody);
|
|
1049
|
+
const pushStatus = await pushAfterMutation(vault);
|
|
1050
|
+
const persistence = buildPersistenceStatus({
|
|
1051
|
+
storage: vault.storage,
|
|
1052
|
+
id,
|
|
1053
|
+
embedding: embeddingStatus,
|
|
1054
|
+
commit: commitStatus,
|
|
1055
|
+
push: pushStatus,
|
|
1056
|
+
commitMessage: `remember: ${title}`,
|
|
1057
|
+
commitBody,
|
|
1058
|
+
});
|
|
1059
|
+
const vaultLabel = vault.isProject ? " [project vault]" : " [main vault]";
|
|
1060
|
+
const textContent = `Remembered as \`${id}\` [${projectScope}, stored=${writeScope}]${vaultLabel}\n${formatPersistenceSummary(persistence)}`;
|
|
1061
|
+
const structuredContent = {
|
|
1062
|
+
action: "remembered",
|
|
1063
|
+
id,
|
|
1064
|
+
title,
|
|
1065
|
+
project: project ? { id: project.id, name: project.name } : undefined,
|
|
1066
|
+
scope: writeScope,
|
|
1067
|
+
vault: vault.isProject ? "project-vault" : "main-vault",
|
|
1068
|
+
tags: tags || [],
|
|
1069
|
+
lifecycle: note.lifecycle,
|
|
1070
|
+
timestamp: now,
|
|
1071
|
+
persistence,
|
|
1072
|
+
};
|
|
1073
|
+
return {
|
|
1074
|
+
content: [{ type: "text", text: textContent }],
|
|
1075
|
+
structuredContent,
|
|
1076
|
+
};
|
|
1077
|
+
});
|
|
1078
|
+
// ── set_project_memory_policy ─────────────────────────────────────────────────
|
|
1079
|
+
server.registerTool("set_project_memory_policy", {
|
|
1080
|
+
title: "Set Project Memory Policy",
|
|
1081
|
+
description: "Choose the default write scope and consolidation mode for a project. " +
|
|
1082
|
+
"This lets agents avoid asking where to store memories and how to handle consolidation.",
|
|
1083
|
+
inputSchema: z.object({
|
|
1084
|
+
cwd: z.string().describe("Absolute path to the project working directory"),
|
|
1085
|
+
defaultScope: z.enum(PROJECT_POLICY_SCOPES).describe("Default storage location for project-related memories"),
|
|
1086
|
+
consolidationMode: z.enum(CONSOLIDATION_MODES).optional().describe("Default consolidation mode: 'supersedes' preserves history (default), 'delete' removes sources"),
|
|
1087
|
+
}),
|
|
1088
|
+
outputSchema: PolicyResultSchema,
|
|
1089
|
+
}, async ({ cwd, defaultScope, consolidationMode }) => {
|
|
1090
|
+
const project = await resolveProject(cwd);
|
|
1091
|
+
if (!project) {
|
|
1092
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
1093
|
+
}
|
|
1094
|
+
const now = new Date().toISOString();
|
|
1095
|
+
const policy = {
|
|
1096
|
+
projectId: project.id,
|
|
1097
|
+
projectName: project.name,
|
|
1098
|
+
defaultScope,
|
|
1099
|
+
consolidationMode,
|
|
1100
|
+
updatedAt: now,
|
|
1101
|
+
};
|
|
1102
|
+
await configStore.setProjectPolicy(policy);
|
|
1103
|
+
const modeStr = consolidationMode ? `, consolidationMode=${consolidationMode}` : "";
|
|
1104
|
+
const commitBody = formatCommitBody({
|
|
1105
|
+
projectName: project.name,
|
|
1106
|
+
description: `Default scope: ${defaultScope}${modeStr ? `\nConsolidation mode: ${consolidationMode}` : ""}`,
|
|
1107
|
+
});
|
|
1108
|
+
await vaultManager.main.git.commit(`policy: ${project.name} default scope ${defaultScope}`, ["config.json"], commitBody);
|
|
1109
|
+
await pushAfterMutation(vaultManager.main);
|
|
1110
|
+
const structuredContent = {
|
|
1111
|
+
action: "policy_set",
|
|
1112
|
+
project: { id: project.id, name: project.name },
|
|
1113
|
+
defaultScope,
|
|
1114
|
+
consolidationMode,
|
|
1115
|
+
timestamp: now,
|
|
1116
|
+
};
|
|
1117
|
+
return {
|
|
1118
|
+
content: [{
|
|
1119
|
+
type: "text",
|
|
1120
|
+
text: `Project memory policy set for ${project.name}: defaultScope=${defaultScope}${modeStr}`,
|
|
1121
|
+
}],
|
|
1122
|
+
structuredContent,
|
|
1123
|
+
};
|
|
1124
|
+
});
|
|
1125
|
+
// ── get_project_memory_policy ─────────────────────────────────────────────────
|
|
1126
|
+
server.registerTool("get_project_memory_policy", {
|
|
1127
|
+
title: "Get Project Memory Policy",
|
|
1128
|
+
description: "Show the current default write scope for a project, if one exists.",
|
|
1129
|
+
inputSchema: z.object({
|
|
1130
|
+
cwd: z.string().describe("Absolute path to the project working directory"),
|
|
1131
|
+
}),
|
|
1132
|
+
outputSchema: PolicyResultSchema,
|
|
1133
|
+
}, async ({ cwd }) => {
|
|
1134
|
+
const project = await resolveProject(cwd);
|
|
1135
|
+
if (!project) {
|
|
1136
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
1137
|
+
}
|
|
1138
|
+
const policy = await configStore.getProjectPolicy(project.id);
|
|
1139
|
+
if (!policy) {
|
|
1140
|
+
const structuredContent = {
|
|
1141
|
+
action: "policy_shown",
|
|
1142
|
+
project: { id: project.id, name: project.name },
|
|
1143
|
+
};
|
|
1144
|
+
return {
|
|
1145
|
+
content: [{
|
|
1146
|
+
type: "text",
|
|
1147
|
+
text: `No project memory policy set for ${project.name}. Default write behavior remains scope=project when cwd is present.`,
|
|
1148
|
+
}],
|
|
1149
|
+
structuredContent,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const structuredContent = {
|
|
1153
|
+
action: "policy_shown",
|
|
1154
|
+
project: { id: project.id, name: project.name },
|
|
1155
|
+
defaultScope: policy.defaultScope,
|
|
1156
|
+
consolidationMode: policy.consolidationMode,
|
|
1157
|
+
updatedAt: policy.updatedAt,
|
|
1158
|
+
};
|
|
1159
|
+
return {
|
|
1160
|
+
content: [{
|
|
1161
|
+
type: "text",
|
|
1162
|
+
text: `Project memory policy for ${project.name}: defaultScope=${policy.defaultScope} (updated ${policy.updatedAt})`,
|
|
1163
|
+
}],
|
|
1164
|
+
structuredContent,
|
|
1165
|
+
};
|
|
1166
|
+
});
|
|
1167
|
+
// ── recall ────────────────────────────────────────────────────────────────────
|
|
1168
|
+
server.registerTool("recall", {
|
|
1169
|
+
title: "Recall",
|
|
1170
|
+
description: "Semantic search over memories. " +
|
|
1171
|
+
"When `cwd` is provided, searches both the project vault (.mnemonic/) and the " +
|
|
1172
|
+
"main vault — project memories are boosted by +0.15 and shown first. " +
|
|
1173
|
+
"Without `cwd`, searches only the main vault.",
|
|
1174
|
+
inputSchema: z.object({
|
|
1175
|
+
query: z.string().describe("What to search for"),
|
|
1176
|
+
cwd: projectParam,
|
|
1177
|
+
limit: z.number().int().min(1).max(20).optional().default(DEFAULT_RECALL_LIMIT),
|
|
1178
|
+
minSimilarity: z.number().min(0).max(1).optional().default(DEFAULT_MIN_SIMILARITY),
|
|
1179
|
+
tags: z.array(z.string()).optional().describe("Optional tag filter"),
|
|
1180
|
+
scope: z
|
|
1181
|
+
.enum(["project", "global", "all"])
|
|
1182
|
+
.optional()
|
|
1183
|
+
.default("all")
|
|
1184
|
+
.describe("'project' = only project memories, " +
|
|
1185
|
+
"'global' = only unscoped memories, " +
|
|
1186
|
+
"'all' = project-boosted then global (default)"),
|
|
1187
|
+
}),
|
|
1188
|
+
outputSchema: RecallResultSchema,
|
|
1189
|
+
}, async ({ query, cwd, limit, minSimilarity, tags, scope }) => {
|
|
1190
|
+
const project = await resolveProject(cwd);
|
|
1191
|
+
const queryVec = await embed(query);
|
|
1192
|
+
const vaults = await vaultManager.searchOrder(cwd);
|
|
1193
|
+
const scored = [];
|
|
1194
|
+
for (const vault of vaults) {
|
|
1195
|
+
const embeddings = await vault.storage.listEmbeddings();
|
|
1196
|
+
for (const rec of embeddings) {
|
|
1197
|
+
const rawScore = cosineSimilarity(queryVec, rec.embedding);
|
|
1198
|
+
if (rawScore < minSimilarity)
|
|
1199
|
+
continue;
|
|
1200
|
+
const note = await vault.storage.readNote(rec.id);
|
|
1201
|
+
if (!note)
|
|
1202
|
+
continue;
|
|
1203
|
+
if (tags && tags.length > 0) {
|
|
1204
|
+
const noteTags = new Set(note.tags);
|
|
1205
|
+
if (!tags.every((t) => noteTags.has(t)))
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const isProjectNote = note.project !== undefined;
|
|
1209
|
+
const isCurrentProject = project && note.project === project.id;
|
|
1210
|
+
if (scope === "project") {
|
|
1211
|
+
if (!isCurrentProject)
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
else if (scope === "global") {
|
|
1215
|
+
if (isProjectNote)
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const boost = isCurrentProject ? 0.15 : 0;
|
|
1219
|
+
scored.push({ id: rec.id, score: rawScore, boosted: rawScore + boost, vault, isCurrentProject: Boolean(isCurrentProject) });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const top = selectRecallResults(scored, limit, scope);
|
|
1223
|
+
if (top.length === 0) {
|
|
1224
|
+
const structuredContent = { action: "recalled", query, scope: scope || "all", results: [] };
|
|
1225
|
+
return { content: [{ type: "text", text: "No memories found matching that query." }], structuredContent };
|
|
1226
|
+
}
|
|
1227
|
+
const sections = [];
|
|
1228
|
+
for (const { id, score, vault } of top) {
|
|
1229
|
+
const note = await vault.storage.readNote(id);
|
|
1230
|
+
if (note)
|
|
1231
|
+
sections.push(formatNote(note, score));
|
|
1232
|
+
}
|
|
1233
|
+
const header = project
|
|
1234
|
+
? `Recall results for project **${project.name}** (scope: ${scope}):`
|
|
1235
|
+
: `Recall results (global):`;
|
|
1236
|
+
const textContent = `${header}\n\n${sections.join("\n\n---\n\n")}`;
|
|
1237
|
+
// Build structured results array
|
|
1238
|
+
const structuredResults = [];
|
|
1239
|
+
for (const { id, score, vault, boosted } of top) {
|
|
1240
|
+
const note = await vault.storage.readNote(id);
|
|
1241
|
+
if (note) {
|
|
1242
|
+
structuredResults.push({
|
|
1243
|
+
id,
|
|
1244
|
+
title: note.title,
|
|
1245
|
+
score,
|
|
1246
|
+
boosted,
|
|
1247
|
+
project: note.project,
|
|
1248
|
+
projectName: note.projectName,
|
|
1249
|
+
vault: vault.isProject ? "project-vault" : "main-vault",
|
|
1250
|
+
tags: note.tags,
|
|
1251
|
+
lifecycle: note.lifecycle,
|
|
1252
|
+
updatedAt: note.updatedAt,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const structuredContent = {
|
|
1257
|
+
action: "recalled",
|
|
1258
|
+
query,
|
|
1259
|
+
scope: scope || "all",
|
|
1260
|
+
results: structuredResults,
|
|
1261
|
+
};
|
|
1262
|
+
return {
|
|
1263
|
+
content: [{ type: "text", text: textContent }],
|
|
1264
|
+
structuredContent,
|
|
1265
|
+
};
|
|
1266
|
+
});
|
|
1267
|
+
// ── update ────────────────────────────────────────────────────────────────────
|
|
1268
|
+
server.registerTool("update", {
|
|
1269
|
+
title: "Update Memory",
|
|
1270
|
+
description: "Update the content, title, or tags of an existing memory by id. `cwd` helps locate project notes but does not change project metadata.",
|
|
1271
|
+
inputSchema: z.object({
|
|
1272
|
+
id: z.string().describe("Memory id to update"),
|
|
1273
|
+
content: z.string().optional(),
|
|
1274
|
+
title: z.string().optional(),
|
|
1275
|
+
tags: z.array(z.string()).optional(),
|
|
1276
|
+
lifecycle: z
|
|
1277
|
+
.enum(NOTE_LIFECYCLES)
|
|
1278
|
+
.optional()
|
|
1279
|
+
.describe("Set to temporary for working-state notes or permanent for durable knowledge"),
|
|
1280
|
+
summary: z.string().optional().describe("Brief summary of what changed and why (for git commit message). Not stored in the note."),
|
|
1281
|
+
cwd: projectParam,
|
|
1282
|
+
}),
|
|
1283
|
+
outputSchema: UpdateResultSchema,
|
|
1284
|
+
}, async ({ id, content, title, tags, lifecycle, summary, cwd }) => {
|
|
1285
|
+
const found = await vaultManager.findNote(id, cwd);
|
|
1286
|
+
if (!found) {
|
|
1287
|
+
return { content: [{ type: "text", text: `No memory found with id '${id}'` }], isError: true };
|
|
1288
|
+
}
|
|
1289
|
+
const { note, vault } = found;
|
|
1290
|
+
const now = new Date().toISOString();
|
|
1291
|
+
const cleanedContent = content === undefined ? undefined : await cleanMarkdown(content);
|
|
1292
|
+
const updated = {
|
|
1293
|
+
...note,
|
|
1294
|
+
title: title ?? note.title,
|
|
1295
|
+
content: cleanedContent ?? note.content,
|
|
1296
|
+
tags: tags ?? note.tags,
|
|
1297
|
+
lifecycle: lifecycle ?? note.lifecycle,
|
|
1298
|
+
updatedAt: now,
|
|
1299
|
+
};
|
|
1300
|
+
await vault.storage.writeNote(updated);
|
|
1301
|
+
let embeddingStatus = { status: "written" };
|
|
1302
|
+
try {
|
|
1303
|
+
const vector = await embed(`${updated.title}\n\n${updated.content}`);
|
|
1304
|
+
await vault.storage.writeEmbedding({ id, model: embedModel, embedding: vector, updatedAt: now });
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
embeddingStatus = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
|
1308
|
+
console.error(`[embedding] Re-embed failed for '${id}': ${err}`);
|
|
1309
|
+
}
|
|
1310
|
+
// Build change summary (LLM-provided or auto-generated)
|
|
1311
|
+
const changes = [];
|
|
1312
|
+
if (title !== undefined && title !== note.title)
|
|
1313
|
+
changes.push("title");
|
|
1314
|
+
if (content !== undefined)
|
|
1315
|
+
changes.push("content");
|
|
1316
|
+
if (tags !== undefined)
|
|
1317
|
+
changes.push("tags");
|
|
1318
|
+
if (lifecycle !== undefined && lifecycle !== note.lifecycle)
|
|
1319
|
+
changes.push("lifecycle");
|
|
1320
|
+
const changeDesc = changes.length > 0 ? `Updated ${changes.join(", ")}` : "No changes";
|
|
1321
|
+
const commitSummary = summary ?? changeDesc;
|
|
1322
|
+
const commitBody = formatCommitBody({
|
|
1323
|
+
summary: commitSummary,
|
|
1324
|
+
noteId: id,
|
|
1325
|
+
noteTitle: updated.title,
|
|
1326
|
+
projectName: updated.projectName,
|
|
1327
|
+
tags: updated.tags,
|
|
1328
|
+
});
|
|
1329
|
+
const commitStatus = await vault.git.commitWithStatus(`update: ${updated.title}`, [vaultManager.noteRelPath(vault, id)], commitBody);
|
|
1330
|
+
const pushStatus = await pushAfterMutation(vault);
|
|
1331
|
+
const persistence = buildPersistenceStatus({
|
|
1332
|
+
storage: vault.storage,
|
|
1333
|
+
id,
|
|
1334
|
+
embedding: embeddingStatus,
|
|
1335
|
+
commit: commitStatus,
|
|
1336
|
+
push: pushStatus,
|
|
1337
|
+
commitMessage: `update: ${updated.title}`,
|
|
1338
|
+
commitBody,
|
|
1339
|
+
});
|
|
1340
|
+
const structuredContent = {
|
|
1341
|
+
action: "updated",
|
|
1342
|
+
id,
|
|
1343
|
+
title: updated.title,
|
|
1344
|
+
fieldsModified: changes,
|
|
1345
|
+
timestamp: now,
|
|
1346
|
+
project: updated.project,
|
|
1347
|
+
projectName: updated.projectName,
|
|
1348
|
+
lifecycle: updated.lifecycle,
|
|
1349
|
+
persistence,
|
|
1350
|
+
};
|
|
1351
|
+
return { content: [{ type: "text", text: `Updated memory '${id}'\n${formatPersistenceSummary(persistence)}` }], structuredContent };
|
|
1352
|
+
});
|
|
1353
|
+
// ── forget ────────────────────────────────────────────────────────────────────
|
|
1354
|
+
server.registerTool("forget", {
|
|
1355
|
+
title: "Forget",
|
|
1356
|
+
description: "Delete a memory by id. Pass `cwd` when targeting project memories from a fresh project-scoped server.",
|
|
1357
|
+
inputSchema: z.object({
|
|
1358
|
+
id: z.string().describe("Memory id to delete"),
|
|
1359
|
+
cwd: projectParam,
|
|
1360
|
+
}),
|
|
1361
|
+
outputSchema: ForgetResultSchema,
|
|
1362
|
+
}, async ({ id, cwd }) => {
|
|
1363
|
+
const found = await vaultManager.findNote(id, cwd);
|
|
1364
|
+
if (!found) {
|
|
1365
|
+
return { content: [{ type: "text", text: `No memory found with id '${id}'` }], isError: true };
|
|
1366
|
+
}
|
|
1367
|
+
const { note, vault: noteVault } = found;
|
|
1368
|
+
await noteVault.storage.deleteNote(id);
|
|
1369
|
+
// Clean up dangling references grouped by vault so we make one commit per vault
|
|
1370
|
+
const vaultChanges = await removeRelationshipsToNoteIds([id]);
|
|
1371
|
+
// Always include the deleted note's path (git add on a deleted file stages the removal)
|
|
1372
|
+
addVaultChange(vaultChanges, noteVault, vaultManager.noteRelPath(noteVault, id));
|
|
1373
|
+
for (const [v, files] of vaultChanges) {
|
|
1374
|
+
const isPrimaryVault = v === noteVault;
|
|
1375
|
+
const summary = isPrimaryVault ? `Deleted note and cleaned up ${files.length - 1} reference(s)` : "Cleaned up dangling reference";
|
|
1376
|
+
const commitBody = formatCommitBody({
|
|
1377
|
+
summary,
|
|
1378
|
+
noteId: id,
|
|
1379
|
+
noteTitle: note.title,
|
|
1380
|
+
projectName: note.projectName,
|
|
1381
|
+
});
|
|
1382
|
+
await v.git.commit(`forget: ${note.title}`, files, commitBody);
|
|
1383
|
+
await pushAfterMutation(v);
|
|
1384
|
+
}
|
|
1385
|
+
const structuredContent = {
|
|
1386
|
+
action: "forgotten",
|
|
1387
|
+
id,
|
|
1388
|
+
title: note.title,
|
|
1389
|
+
project: note.project,
|
|
1390
|
+
projectName: note.projectName,
|
|
1391
|
+
relationshipsCleaned: vaultChanges.size > 0 ? Array.from(vaultChanges.values()).reduce((sum, files) => sum + files.length - 1, 0) : 0,
|
|
1392
|
+
vaultsModified: Array.from(vaultChanges.keys()).map(v => v.isProject ? "project-vault" : "main-vault"),
|
|
1393
|
+
};
|
|
1394
|
+
return { content: [{ type: "text", text: `Forgotten '${id}' (${note.title})` }], structuredContent };
|
|
1395
|
+
});
|
|
1396
|
+
// ── get ───────────────────────────────────────────────────────────────────────
|
|
1397
|
+
server.registerTool("get", {
|
|
1398
|
+
title: "Get Memory",
|
|
1399
|
+
description: "Fetch one or more notes by exact id. Returns full note content, metadata, and relationships. " +
|
|
1400
|
+
"Pass `cwd` to search the project vault when looking up project notes.",
|
|
1401
|
+
inputSchema: z.object({
|
|
1402
|
+
ids: z.array(z.string()).min(1).describe("One or more memory ids to fetch"),
|
|
1403
|
+
cwd: projectParam,
|
|
1404
|
+
}),
|
|
1405
|
+
outputSchema: GetResultSchema,
|
|
1406
|
+
}, async ({ ids, cwd }) => {
|
|
1407
|
+
const found = [];
|
|
1408
|
+
const notFound = [];
|
|
1409
|
+
for (const id of ids) {
|
|
1410
|
+
const result = await vaultManager.findNote(id, cwd);
|
|
1411
|
+
if (!result) {
|
|
1412
|
+
notFound.push(id);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const { note, vault } = result;
|
|
1416
|
+
found.push({
|
|
1417
|
+
id: note.id,
|
|
1418
|
+
title: note.title,
|
|
1419
|
+
content: note.content,
|
|
1420
|
+
project: note.project,
|
|
1421
|
+
projectName: note.projectName,
|
|
1422
|
+
tags: note.tags,
|
|
1423
|
+
lifecycle: note.lifecycle,
|
|
1424
|
+
relatedTo: note.relatedTo,
|
|
1425
|
+
createdAt: note.createdAt,
|
|
1426
|
+
updatedAt: note.updatedAt,
|
|
1427
|
+
vault: storageLabel(vault),
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
const lines = [];
|
|
1431
|
+
for (const note of found) {
|
|
1432
|
+
lines.push(`## ${note.title} (${note.id})`);
|
|
1433
|
+
lines.push(`project: ${note.projectName ?? note.project ?? "global"} | stored: ${note.vault} | lifecycle: ${note.lifecycle}`);
|
|
1434
|
+
if (note.tags.length > 0)
|
|
1435
|
+
lines.push(`tags: ${note.tags.join(", ")}`);
|
|
1436
|
+
lines.push("");
|
|
1437
|
+
lines.push(note.content);
|
|
1438
|
+
lines.push("");
|
|
1439
|
+
}
|
|
1440
|
+
if (notFound.length > 0) {
|
|
1441
|
+
lines.push(`Not found: ${notFound.join(", ")}`);
|
|
1442
|
+
}
|
|
1443
|
+
const structuredContent = {
|
|
1444
|
+
action: "got",
|
|
1445
|
+
count: found.length,
|
|
1446
|
+
notes: found,
|
|
1447
|
+
notFound,
|
|
1448
|
+
};
|
|
1449
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }], structuredContent };
|
|
1450
|
+
});
|
|
1451
|
+
// ── where_is_memory ───────────────────────────────────────────────────────────
|
|
1452
|
+
server.registerTool("where_is_memory", {
|
|
1453
|
+
title: "Where Is Memory",
|
|
1454
|
+
description: "Show a memory's project association and actual storage location (main vault or project vault). " +
|
|
1455
|
+
"Lightweight alternative to `get` when you only need location metadata, not content. " +
|
|
1456
|
+
"Pass `cwd` to include the project vault when searching.",
|
|
1457
|
+
inputSchema: z.object({
|
|
1458
|
+
id: z.string().describe("Memory id to locate"),
|
|
1459
|
+
cwd: projectParam,
|
|
1460
|
+
}),
|
|
1461
|
+
outputSchema: WhereIsResultSchema,
|
|
1462
|
+
}, async ({ id, cwd }) => {
|
|
1463
|
+
const found = await vaultManager.findNote(id, cwd);
|
|
1464
|
+
if (!found) {
|
|
1465
|
+
return { content: [{ type: "text", text: `No memory found with id '${id}'` }], isError: true };
|
|
1466
|
+
}
|
|
1467
|
+
const { note, vault } = found;
|
|
1468
|
+
const vaultLabel = storageLabel(vault);
|
|
1469
|
+
const projectDisplay = note.projectName && note.project
|
|
1470
|
+
? `${note.projectName} (${note.project})`
|
|
1471
|
+
: note.projectName ?? note.project ?? "global";
|
|
1472
|
+
const relatedCount = note.relatedTo?.length ?? 0;
|
|
1473
|
+
const structuredContent = {
|
|
1474
|
+
action: "located",
|
|
1475
|
+
id: note.id,
|
|
1476
|
+
title: note.title,
|
|
1477
|
+
project: note.project,
|
|
1478
|
+
projectName: note.projectName,
|
|
1479
|
+
vault: vaultLabel,
|
|
1480
|
+
updatedAt: note.updatedAt,
|
|
1481
|
+
relatedCount,
|
|
1482
|
+
};
|
|
1483
|
+
return {
|
|
1484
|
+
content: [{
|
|
1485
|
+
type: "text",
|
|
1486
|
+
text: `'${note.title}' (${id})\nproject: ${projectDisplay} | stored: ${vaultLabel} | updated: ${note.updatedAt} | related: ${relatedCount}`,
|
|
1487
|
+
}],
|
|
1488
|
+
structuredContent,
|
|
1489
|
+
};
|
|
1490
|
+
});
|
|
1491
|
+
// ── list ──────────────────────────────────────────────────────────────────────
|
|
1492
|
+
server.registerTool("list", {
|
|
1493
|
+
title: "List Memories",
|
|
1494
|
+
description: "List stored memories. Pass `cwd` to include the project vault, or omit for main vault only.",
|
|
1495
|
+
inputSchema: z.object({
|
|
1496
|
+
cwd: projectParam,
|
|
1497
|
+
scope: z
|
|
1498
|
+
.enum(["project", "global", "all"])
|
|
1499
|
+
.optional()
|
|
1500
|
+
.default("all")
|
|
1501
|
+
.describe("'project' = only this project, 'global' = only unscoped, 'all' = everything"),
|
|
1502
|
+
storedIn: z
|
|
1503
|
+
.enum(["project-vault", "main-vault", "any"])
|
|
1504
|
+
.optional()
|
|
1505
|
+
.default("any")
|
|
1506
|
+
.describe("Filter by actual storage location instead of project association"),
|
|
1507
|
+
tags: z.array(z.string()).optional().describe("Optional tag filter"),
|
|
1508
|
+
includeRelations: z.boolean().optional().default(false).describe("Include related memory ids/types"),
|
|
1509
|
+
includePreview: z.boolean().optional().default(false).describe("Include a short content preview for each memory"),
|
|
1510
|
+
includeStorage: z.boolean().optional().default(false).describe("Include whether the memory lives in the project vault or main vault"),
|
|
1511
|
+
includeUpdated: z.boolean().optional().default(false).describe("Include the last updated timestamp for each memory"),
|
|
1512
|
+
}),
|
|
1513
|
+
outputSchema: ListResultSchema,
|
|
1514
|
+
}, async ({ cwd, scope, storedIn, tags, includeRelations, includePreview, includeStorage, includeUpdated }) => {
|
|
1515
|
+
const { project, entries } = await collectVisibleNotes(cwd, scope, tags, storedIn);
|
|
1516
|
+
if (entries.length === 0) {
|
|
1517
|
+
const structuredContent = { action: "listed", count: 0, scope: scope || "all", storedIn: storedIn || "any", project: project ? { id: project.id, name: project.name } : undefined, notes: [] };
|
|
1518
|
+
return { content: [{ type: "text", text: "No memories found." }], structuredContent };
|
|
1519
|
+
}
|
|
1520
|
+
const lines = entries.map((entry) => formatListEntry(entry, {
|
|
1521
|
+
includeRelations,
|
|
1522
|
+
includePreview,
|
|
1523
|
+
includeStorage,
|
|
1524
|
+
includeUpdated,
|
|
1525
|
+
}));
|
|
1526
|
+
const header = project && scope !== "global"
|
|
1527
|
+
? `${entries.length} memories (project: ${project.name}, scope: ${scope}, storedIn: ${storedIn}):`
|
|
1528
|
+
: `${entries.length} memories (scope: ${scope}, storedIn: ${storedIn}):`;
|
|
1529
|
+
const textContent = `${header}\n\n${lines.join("\n")}`;
|
|
1530
|
+
const structuredNotes = entries.map(({ note, vault }) => ({
|
|
1531
|
+
id: note.id,
|
|
1532
|
+
title: note.title,
|
|
1533
|
+
project: note.project,
|
|
1534
|
+
projectName: note.projectName,
|
|
1535
|
+
tags: note.tags,
|
|
1536
|
+
lifecycle: note.lifecycle,
|
|
1537
|
+
vault: vault.isProject ? "project-vault" : "main-vault",
|
|
1538
|
+
updatedAt: note.updatedAt,
|
|
1539
|
+
hasRelated: note.relatedTo && note.relatedTo.length > 0,
|
|
1540
|
+
}));
|
|
1541
|
+
const structuredContent = {
|
|
1542
|
+
action: "listed",
|
|
1543
|
+
count: entries.length,
|
|
1544
|
+
scope: scope || "all",
|
|
1545
|
+
storedIn: storedIn || "any",
|
|
1546
|
+
project: project ? { id: project.id, name: project.name } : undefined,
|
|
1547
|
+
notes: structuredNotes,
|
|
1548
|
+
options: {
|
|
1549
|
+
includeRelations,
|
|
1550
|
+
includePreview,
|
|
1551
|
+
includeStorage,
|
|
1552
|
+
includeUpdated,
|
|
1553
|
+
},
|
|
1554
|
+
};
|
|
1555
|
+
return { content: [{ type: "text", text: textContent }], structuredContent };
|
|
1556
|
+
});
|
|
1557
|
+
// ── recent_memories ───────────────────────────────────────────────────────────
|
|
1558
|
+
server.registerTool("recent_memories", {
|
|
1559
|
+
title: "Recent Memories",
|
|
1560
|
+
description: "Show the most recently updated memories for the current project or global vault.",
|
|
1561
|
+
inputSchema: z.object({
|
|
1562
|
+
cwd: projectParam,
|
|
1563
|
+
scope: z.enum(["project", "global", "all"]).optional().default("all"),
|
|
1564
|
+
storedIn: z.enum(["project-vault", "main-vault", "any"]).optional().default("any"),
|
|
1565
|
+
limit: z.number().int().min(1).max(20).optional().default(5),
|
|
1566
|
+
includePreview: z.boolean().optional().default(true),
|
|
1567
|
+
includeStorage: z.boolean().optional().default(true),
|
|
1568
|
+
}),
|
|
1569
|
+
outputSchema: RecentResultSchema,
|
|
1570
|
+
}, async ({ cwd, scope, storedIn, limit, includePreview, includeStorage }) => {
|
|
1571
|
+
const { project, entries } = await collectVisibleNotes(cwd, scope, undefined, storedIn);
|
|
1572
|
+
const recent = [...entries]
|
|
1573
|
+
.sort((a, b) => b.note.updatedAt.localeCompare(a.note.updatedAt))
|
|
1574
|
+
.slice(0, limit);
|
|
1575
|
+
if (recent.length === 0) {
|
|
1576
|
+
const structuredContent = { action: "recent_shown", project: project?.id, projectName: project?.name, count: 0, limit: limit || 5, notes: [] };
|
|
1577
|
+
return { content: [{ type: "text", text: "No memories found." }], structuredContent };
|
|
1578
|
+
}
|
|
1579
|
+
const header = project && scope !== "global"
|
|
1580
|
+
? `Recent memories for ${project.name}:`
|
|
1581
|
+
: "Recent memories:";
|
|
1582
|
+
const lines = recent.map((entry) => formatListEntry(entry, {
|
|
1583
|
+
includePreview,
|
|
1584
|
+
includeStorage,
|
|
1585
|
+
includeUpdated: true,
|
|
1586
|
+
}));
|
|
1587
|
+
const textContent = `${header}\n\n${lines.join("\n")}`;
|
|
1588
|
+
const structuredNotes = recent.map(({ note, vault }) => ({
|
|
1589
|
+
id: note.id,
|
|
1590
|
+
title: note.title,
|
|
1591
|
+
project: note.project,
|
|
1592
|
+
projectName: note.projectName,
|
|
1593
|
+
tags: note.tags,
|
|
1594
|
+
lifecycle: note.lifecycle,
|
|
1595
|
+
vault: vault.isProject ? "project-vault" : "main-vault",
|
|
1596
|
+
updatedAt: note.updatedAt,
|
|
1597
|
+
preview: includePreview && note.content ? note.content.substring(0, 100) + (note.content.length > 100 ? "..." : "") : undefined,
|
|
1598
|
+
}));
|
|
1599
|
+
const structuredContent = {
|
|
1600
|
+
action: "recent_shown",
|
|
1601
|
+
project: project?.id,
|
|
1602
|
+
projectName: project?.name,
|
|
1603
|
+
count: recent.length,
|
|
1604
|
+
limit: limit || 5,
|
|
1605
|
+
notes: structuredNotes,
|
|
1606
|
+
};
|
|
1607
|
+
return { content: [{ type: "text", text: textContent }], structuredContent };
|
|
1608
|
+
});
|
|
1609
|
+
// ── memory_graph ──────────────────────────────────────────────────────────────
|
|
1610
|
+
server.registerTool("memory_graph", {
|
|
1611
|
+
title: "Memory Graph",
|
|
1612
|
+
description: "Show memory relationships for the current project or selected scope as a compact adjacency list.",
|
|
1613
|
+
inputSchema: z.object({
|
|
1614
|
+
cwd: projectParam,
|
|
1615
|
+
scope: z.enum(["project", "global", "all"]).optional().default("all"),
|
|
1616
|
+
storedIn: z.enum(["project-vault", "main-vault", "any"]).optional().default("any"),
|
|
1617
|
+
limit: z.number().int().min(1).max(50).optional().default(25),
|
|
1618
|
+
}),
|
|
1619
|
+
outputSchema: MemoryGraphResultSchema,
|
|
1620
|
+
}, async ({ cwd, scope, storedIn, limit }) => {
|
|
1621
|
+
const { project, entries } = await collectVisibleNotes(cwd, scope, undefined, storedIn);
|
|
1622
|
+
if (entries.length === 0) {
|
|
1623
|
+
const structuredContent = { action: "graph_shown", project: project?.id, projectName: project?.name, nodes: [], limit, truncated: false };
|
|
1624
|
+
return { content: [{ type: "text", text: "No memories found." }], structuredContent };
|
|
1625
|
+
}
|
|
1626
|
+
const visibleIds = new Set(entries.map((entry) => entry.note.id));
|
|
1627
|
+
const lines = entries
|
|
1628
|
+
.filter((entry) => (entry.note.relatedTo?.length ?? 0) > 0)
|
|
1629
|
+
.slice(0, limit)
|
|
1630
|
+
.map((entry) => {
|
|
1631
|
+
const edges = (entry.note.relatedTo ?? [])
|
|
1632
|
+
.filter((rel) => visibleIds.has(rel.id))
|
|
1633
|
+
.map((rel) => `${rel.id} (${rel.type})`);
|
|
1634
|
+
return edges.length > 0 ? `- ${entry.note.id} -> ${edges.join(", ")}` : null;
|
|
1635
|
+
})
|
|
1636
|
+
.filter(Boolean);
|
|
1637
|
+
if (lines.length === 0) {
|
|
1638
|
+
const structuredContent = { action: "graph_shown", project: project?.id, projectName: project?.name, nodes: [], limit, truncated: false };
|
|
1639
|
+
return { content: [{ type: "text", text: "No relationships found for that scope." }], structuredContent };
|
|
1640
|
+
}
|
|
1641
|
+
const header = project && scope !== "global"
|
|
1642
|
+
? `Memory graph for ${project.name}:`
|
|
1643
|
+
: "Memory graph:";
|
|
1644
|
+
const textContent = `${header}\n\n${lines.join("\n")}`;
|
|
1645
|
+
// Build structured graph
|
|
1646
|
+
const structuredNodes = entries
|
|
1647
|
+
.filter((entry) => (entry.note.relatedTo?.length ?? 0) > 0)
|
|
1648
|
+
.slice(0, limit)
|
|
1649
|
+
.map((entry) => {
|
|
1650
|
+
const edges = (entry.note.relatedTo ?? [])
|
|
1651
|
+
.filter((rel) => visibleIds.has(rel.id))
|
|
1652
|
+
.map((rel) => ({ toId: rel.id, type: rel.type }));
|
|
1653
|
+
return {
|
|
1654
|
+
id: entry.note.id,
|
|
1655
|
+
title: entry.note.title,
|
|
1656
|
+
edges: edges.length > 0 ? edges : [],
|
|
1657
|
+
};
|
|
1658
|
+
})
|
|
1659
|
+
.filter((node) => node.edges.length > 0);
|
|
1660
|
+
const structuredContent = {
|
|
1661
|
+
action: "graph_shown",
|
|
1662
|
+
project: project?.id,
|
|
1663
|
+
projectName: project?.name,
|
|
1664
|
+
nodes: structuredNodes,
|
|
1665
|
+
limit,
|
|
1666
|
+
truncated: structuredNodes.length < entries.filter(e => (e.note.relatedTo?.length ?? 0) > 0).length,
|
|
1667
|
+
};
|
|
1668
|
+
return { content: [{ type: "text", text: textContent }], structuredContent };
|
|
1669
|
+
});
|
|
1670
|
+
// ── project_memory_summary ────────────────────────────────────────────────────
|
|
1671
|
+
server.registerTool("project_memory_summary", {
|
|
1672
|
+
title: "Project Memory Summary",
|
|
1673
|
+
description: "Summarize what mnemonic currently knows about a project, including policy, themes, recent changes, and storage layout.",
|
|
1674
|
+
inputSchema: z.object({
|
|
1675
|
+
cwd: z.string().describe("Absolute path to the project working directory"),
|
|
1676
|
+
maxPerTheme: z.number().int().min(1).max(5).optional().default(3),
|
|
1677
|
+
recentLimit: z.number().int().min(1).max(10).optional().default(5),
|
|
1678
|
+
}),
|
|
1679
|
+
outputSchema: ProjectSummaryResultSchema,
|
|
1680
|
+
}, async ({ cwd, maxPerTheme, recentLimit }) => {
|
|
1681
|
+
const { project, entries } = await collectVisibleNotes(cwd, "all");
|
|
1682
|
+
if (!project) {
|
|
1683
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
1684
|
+
}
|
|
1685
|
+
if (entries.length === 0) {
|
|
1686
|
+
const structuredContent = { action: "project_summary_shown", project: { id: project.id, name: project.name }, notes: { total: 0, projectVault: 0, mainVault: 0, privateProject: 0 }, themes: {}, recent: [] };
|
|
1687
|
+
return { content: [{ type: "text", text: `No memories found for project ${project.name}.` }], structuredContent };
|
|
1688
|
+
}
|
|
1689
|
+
const policyLine = await formatProjectPolicyLine(project.id);
|
|
1690
|
+
const themed = new Map();
|
|
1691
|
+
for (const entry of entries) {
|
|
1692
|
+
const theme = classifyTheme(entry.note);
|
|
1693
|
+
const bucket = themed.get(theme) ?? [];
|
|
1694
|
+
bucket.push(entry);
|
|
1695
|
+
themed.set(theme, bucket);
|
|
1696
|
+
}
|
|
1697
|
+
const themeOrder = ["overview", "decisions", "tooling", "bugs", "architecture", "quality", "other"];
|
|
1698
|
+
const projectVaultCount = entries.filter((entry) => entry.vault.isProject).length;
|
|
1699
|
+
const mainVaultCount = entries.length - projectVaultCount;
|
|
1700
|
+
const sections = [];
|
|
1701
|
+
sections.push(`Project summary: **${project.name}**`);
|
|
1702
|
+
sections.push(`- id: \`${project.id}\``);
|
|
1703
|
+
sections.push(`- ${policyLine.replace(/^Policy:\s*/, "policy: ")}`);
|
|
1704
|
+
sections.push(`- memories: ${entries.length} (project-vault: ${projectVaultCount}, main-vault: ${mainVaultCount})`);
|
|
1705
|
+
const mainVaultProjectEntries = entries.filter((entry) => !entry.vault.isProject && entry.note.project === project.id);
|
|
1706
|
+
if (mainVaultProjectEntries.length > 0) {
|
|
1707
|
+
sections.push(`- private project memories: ${mainVaultProjectEntries.length}`);
|
|
1708
|
+
}
|
|
1709
|
+
const themes = [];
|
|
1710
|
+
for (const theme of themeOrder) {
|
|
1711
|
+
const bucket = themed.get(theme);
|
|
1712
|
+
if (!bucket || bucket.length === 0) {
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const top = bucket.slice(0, maxPerTheme);
|
|
1716
|
+
sections.push(`\n${titleCaseTheme(theme)}:`);
|
|
1717
|
+
sections.push(...top.map((entry) => `- ${entry.note.title} (\`${entry.note.id}\`)`));
|
|
1718
|
+
themes.push({
|
|
1719
|
+
name: theme,
|
|
1720
|
+
count: bucket.length,
|
|
1721
|
+
examples: top.map((entry) => entry.note.title),
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
const recent = [...entries]
|
|
1725
|
+
.sort((a, b) => b.note.updatedAt.localeCompare(a.note.updatedAt))
|
|
1726
|
+
.slice(0, recentLimit);
|
|
1727
|
+
sections.push(`\nRecent:`);
|
|
1728
|
+
sections.push(...recent.map((entry) => `- ${entry.note.updatedAt} — ${entry.note.title}`));
|
|
1729
|
+
const themeCounts = {};
|
|
1730
|
+
for (const theme of themes) {
|
|
1731
|
+
themeCounts[theme.name] = theme.count;
|
|
1732
|
+
}
|
|
1733
|
+
const structuredContent = {
|
|
1734
|
+
action: "project_summary_shown",
|
|
1735
|
+
project: { id: project.id, name: project.name },
|
|
1736
|
+
notes: {
|
|
1737
|
+
total: entries.length,
|
|
1738
|
+
projectVault: projectVaultCount,
|
|
1739
|
+
mainVault: mainVaultCount,
|
|
1740
|
+
privateProject: mainVaultProjectEntries.length,
|
|
1741
|
+
},
|
|
1742
|
+
themes: themeCounts,
|
|
1743
|
+
recent: recent.map((entry) => ({
|
|
1744
|
+
id: entry.note.id,
|
|
1745
|
+
title: entry.note.title,
|
|
1746
|
+
updatedAt: entry.note.updatedAt,
|
|
1747
|
+
})),
|
|
1748
|
+
};
|
|
1749
|
+
return { content: [{ type: "text", text: sections.join("\n") }], structuredContent };
|
|
1750
|
+
});
|
|
1751
|
+
// ── sync ──────────────────────────────────────────────────────────────────────
|
|
1752
|
+
server.registerTool("sync", {
|
|
1753
|
+
title: "Sync",
|
|
1754
|
+
description: "Bring the vault to a fully operational state: git sync when a remote exists, " +
|
|
1755
|
+
"plus embedding backfill always. Use `force=true` to rebuild all embeddings. " +
|
|
1756
|
+
"Always syncs the main vault. " +
|
|
1757
|
+
"When `cwd` is provided, also syncs the project vault (.mnemonic/) " +
|
|
1758
|
+
"so you pull in notes added by collaborators.",
|
|
1759
|
+
inputSchema: z.object({
|
|
1760
|
+
cwd: projectParam,
|
|
1761
|
+
force: z.boolean().optional().default(false).describe("Rebuild all embeddings even if current model already has them"),
|
|
1762
|
+
}),
|
|
1763
|
+
outputSchema: SyncResultSchema,
|
|
1764
|
+
}, async ({ cwd, force }) => {
|
|
1765
|
+
const lines = [];
|
|
1766
|
+
const vaultResults = [];
|
|
1767
|
+
// Always sync main vault
|
|
1768
|
+
const mainResult = await vaultManager.main.git.sync();
|
|
1769
|
+
lines.push(...formatSyncResult(mainResult, "main vault"));
|
|
1770
|
+
let mainEmbedded = 0;
|
|
1771
|
+
let mainFailed = [];
|
|
1772
|
+
const mainBackfill = await backfillEmbeddingsAfterSync(vaultManager.main.storage, "main vault", lines, force);
|
|
1773
|
+
mainEmbedded = mainBackfill.embedded;
|
|
1774
|
+
mainFailed = mainBackfill.failed;
|
|
1775
|
+
if (mainResult.deletedNoteIds.length > 0) {
|
|
1776
|
+
await removeStaleEmbeddings(vaultManager.main.storage, mainResult.deletedNoteIds);
|
|
1777
|
+
}
|
|
1778
|
+
vaultResults.push({
|
|
1779
|
+
vault: "main",
|
|
1780
|
+
hasRemote: mainResult.hasRemote,
|
|
1781
|
+
pulled: mainResult.pulledNoteIds.length,
|
|
1782
|
+
deleted: mainResult.deletedNoteIds.length,
|
|
1783
|
+
pushed: mainResult.pushedCommits,
|
|
1784
|
+
embedded: mainEmbedded,
|
|
1785
|
+
failed: mainFailed,
|
|
1786
|
+
});
|
|
1787
|
+
// Optionally sync project vault
|
|
1788
|
+
if (cwd) {
|
|
1789
|
+
const projectVault = await vaultManager.getProjectVaultIfExists(cwd);
|
|
1790
|
+
if (projectVault) {
|
|
1791
|
+
const projectResult = await projectVault.git.sync();
|
|
1792
|
+
lines.push(...formatSyncResult(projectResult, "project vault"));
|
|
1793
|
+
let projEmbedded = 0;
|
|
1794
|
+
let projFailed = [];
|
|
1795
|
+
const projectBackfill = await backfillEmbeddingsAfterSync(projectVault.storage, "project vault", lines, force);
|
|
1796
|
+
projEmbedded = projectBackfill.embedded;
|
|
1797
|
+
projFailed = projectBackfill.failed;
|
|
1798
|
+
if (projectResult.deletedNoteIds.length > 0) {
|
|
1799
|
+
await removeStaleEmbeddings(projectVault.storage, projectResult.deletedNoteIds);
|
|
1800
|
+
}
|
|
1801
|
+
vaultResults.push({
|
|
1802
|
+
vault: "project",
|
|
1803
|
+
hasRemote: projectResult.hasRemote,
|
|
1804
|
+
pulled: projectResult.pulledNoteIds.length,
|
|
1805
|
+
deleted: projectResult.deletedNoteIds.length,
|
|
1806
|
+
pushed: projectResult.pushedCommits,
|
|
1807
|
+
embedded: projEmbedded,
|
|
1808
|
+
failed: projFailed,
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
lines.push("project vault: no .mnemonic/ found — skipped.");
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const structuredContent = {
|
|
1816
|
+
action: "synced",
|
|
1817
|
+
vaults: vaultResults,
|
|
1818
|
+
};
|
|
1819
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
1820
|
+
});
|
|
1821
|
+
// ── move_memory ───────────────────────────────────────────────────────────────
|
|
1822
|
+
server.registerTool("move_memory", {
|
|
1823
|
+
title: "Move Memory",
|
|
1824
|
+
description: "Move a memory between the main vault and the current project's vault without changing its id or project metadata.",
|
|
1825
|
+
inputSchema: z.object({
|
|
1826
|
+
id: z.string().describe("Memory id to move"),
|
|
1827
|
+
target: z.enum(["main-vault", "project-vault"]).describe("Destination storage location"),
|
|
1828
|
+
cwd: projectParam,
|
|
1829
|
+
}),
|
|
1830
|
+
outputSchema: MoveResultSchema,
|
|
1831
|
+
}, async ({ id, target, cwd }) => {
|
|
1832
|
+
const found = await vaultManager.findNote(id, cwd);
|
|
1833
|
+
if (!found) {
|
|
1834
|
+
return { content: [{ type: "text", text: `No memory found with id '${id}'` }], isError: true };
|
|
1835
|
+
}
|
|
1836
|
+
const currentStorage = storageLabel(found.vault);
|
|
1837
|
+
if (currentStorage === target) {
|
|
1838
|
+
return { content: [{ type: "text", text: `Memory '${id}' is already stored in ${target}.` }], isError: true };
|
|
1839
|
+
}
|
|
1840
|
+
let targetVault;
|
|
1841
|
+
let targetProject;
|
|
1842
|
+
if (target === "main-vault") {
|
|
1843
|
+
targetVault = vaultManager.main;
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
if (!cwd) {
|
|
1847
|
+
return {
|
|
1848
|
+
content: [{
|
|
1849
|
+
type: "text",
|
|
1850
|
+
text: "Moving into a project vault requires `cwd` so mnemonic can resolve the destination project.",
|
|
1851
|
+
}],
|
|
1852
|
+
isError: true,
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
const projectVault = await vaultManager.getOrCreateProjectVault(cwd);
|
|
1856
|
+
if (!projectVault) {
|
|
1857
|
+
return { content: [{ type: "text", text: `Could not resolve a project vault for: ${cwd}` }], isError: true };
|
|
1858
|
+
}
|
|
1859
|
+
targetProject = await resolveProject(cwd);
|
|
1860
|
+
if (!targetProject) {
|
|
1861
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
1862
|
+
}
|
|
1863
|
+
targetVault = projectVault;
|
|
1864
|
+
}
|
|
1865
|
+
const existing = await targetVault.storage.readNote(id);
|
|
1866
|
+
if (existing) {
|
|
1867
|
+
return { content: [{ type: "text", text: `Cannot move '${id}' because a note with that id already exists in ${target}.` }], isError: true };
|
|
1868
|
+
}
|
|
1869
|
+
let noteToWrite = found.note;
|
|
1870
|
+
let metadataRewritten = false;
|
|
1871
|
+
if (target === "project-vault" && targetProject) {
|
|
1872
|
+
const rewrittenProject = targetProject.id;
|
|
1873
|
+
const rewrittenProjectName = targetProject.name;
|
|
1874
|
+
metadataRewritten = noteToWrite.project !== rewrittenProject || noteToWrite.projectName !== rewrittenProjectName;
|
|
1875
|
+
noteToWrite = {
|
|
1876
|
+
...noteToWrite,
|
|
1877
|
+
project: rewrittenProject,
|
|
1878
|
+
projectName: rewrittenProjectName,
|
|
1879
|
+
updatedAt: new Date().toISOString(),
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
const moveResult = await moveNoteBetweenVaults(found, targetVault, noteToWrite);
|
|
1883
|
+
const movedNote = moveResult.note;
|
|
1884
|
+
const associationValue = movedNote.projectName && movedNote.project
|
|
1885
|
+
? `${movedNote.projectName} (${movedNote.project})`
|
|
1886
|
+
: movedNote.projectName ?? movedNote.project ?? "global";
|
|
1887
|
+
const structuredContent = {
|
|
1888
|
+
action: "moved",
|
|
1889
|
+
id,
|
|
1890
|
+
fromVault: currentStorage,
|
|
1891
|
+
toVault: target,
|
|
1892
|
+
projectAssociation: associationValue,
|
|
1893
|
+
title: movedNote.title,
|
|
1894
|
+
metadataRewritten,
|
|
1895
|
+
persistence: moveResult.persistence,
|
|
1896
|
+
};
|
|
1897
|
+
const associationText = metadataRewritten
|
|
1898
|
+
? `Project association is now ${associationValue}.`
|
|
1899
|
+
: `Project association remains ${associationValue}.`;
|
|
1900
|
+
return {
|
|
1901
|
+
content: [{
|
|
1902
|
+
type: "text",
|
|
1903
|
+
text: `Moved '${id}' from ${currentStorage} to ${target}. ${associationText}\n${formatPersistenceSummary(moveResult.persistence)}`,
|
|
1904
|
+
}],
|
|
1905
|
+
structuredContent,
|
|
1906
|
+
};
|
|
1907
|
+
});
|
|
1908
|
+
// ── relate ────────────────────────────────────────────────────────────────────
|
|
1909
|
+
const RELATIONSHIP_TYPES = [
|
|
1910
|
+
"related-to",
|
|
1911
|
+
"explains",
|
|
1912
|
+
"example-of",
|
|
1913
|
+
"supersedes",
|
|
1914
|
+
];
|
|
1915
|
+
server.registerTool("relate", {
|
|
1916
|
+
title: "Relate Memories",
|
|
1917
|
+
description: "Create a typed relationship between two memories. " +
|
|
1918
|
+
"By default adds the relationship in both directions. " +
|
|
1919
|
+
"Notes may be in different vaults — each vault gets its own commit. " +
|
|
1920
|
+
"Pass `cwd` to include the current project vault when resolving ids.",
|
|
1921
|
+
inputSchema: z.object({
|
|
1922
|
+
fromId: z.string().describe("The source memory id"),
|
|
1923
|
+
toId: z.string().describe("The target memory id"),
|
|
1924
|
+
type: z.enum(RELATIONSHIP_TYPES).default("related-to"),
|
|
1925
|
+
bidirectional: z.boolean().optional().default(true),
|
|
1926
|
+
cwd: projectParam,
|
|
1927
|
+
}),
|
|
1928
|
+
outputSchema: RelateResultSchema,
|
|
1929
|
+
}, async ({ fromId, toId, type, bidirectional, cwd }) => {
|
|
1930
|
+
const [foundFrom, foundTo] = await Promise.all([
|
|
1931
|
+
vaultManager.findNote(fromId, cwd),
|
|
1932
|
+
vaultManager.findNote(toId, cwd),
|
|
1933
|
+
]);
|
|
1934
|
+
if (!foundFrom)
|
|
1935
|
+
return { content: [{ type: "text", text: `No memory found with id '${fromId}'` }], isError: true };
|
|
1936
|
+
if (!foundTo)
|
|
1937
|
+
return { content: [{ type: "text", text: `No memory found with id '${toId}'` }], isError: true };
|
|
1938
|
+
const { note: fromNote, vault: fromVault } = foundFrom;
|
|
1939
|
+
const { note: toNote, vault: toVault } = foundTo;
|
|
1940
|
+
const now = new Date().toISOString();
|
|
1941
|
+
// Group changes by vault so notes in the same vault share one commit
|
|
1942
|
+
const vaultChanges = new Map();
|
|
1943
|
+
const fromRels = fromNote.relatedTo ?? [];
|
|
1944
|
+
if (!fromRels.some((r) => r.id === toId)) {
|
|
1945
|
+
await fromVault.storage.writeNote({ ...fromNote, relatedTo: [...fromRels, { id: toId, type }], updatedAt: now });
|
|
1946
|
+
const files = vaultChanges.get(fromVault) ?? [];
|
|
1947
|
+
files.push(vaultManager.noteRelPath(fromVault, fromId));
|
|
1948
|
+
vaultChanges.set(fromVault, files);
|
|
1949
|
+
}
|
|
1950
|
+
if (bidirectional) {
|
|
1951
|
+
const toRels = toNote.relatedTo ?? [];
|
|
1952
|
+
if (!toRels.some((r) => r.id === fromId)) {
|
|
1953
|
+
await toVault.storage.writeNote({ ...toNote, relatedTo: [...toRels, { id: fromId, type }], updatedAt: now });
|
|
1954
|
+
const files = vaultChanges.get(toVault) ?? [];
|
|
1955
|
+
files.push(vaultManager.noteRelPath(toVault, toId));
|
|
1956
|
+
vaultChanges.set(toVault, files);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (vaultChanges.size === 0) {
|
|
1960
|
+
return { content: [{ type: "text", text: `Relationship already exists between '${fromId}' and '${toId}'` }], isError: true };
|
|
1961
|
+
}
|
|
1962
|
+
const modifiedNoteIds = [];
|
|
1963
|
+
for (const [vault, files] of vaultChanges) {
|
|
1964
|
+
const isFromVault = vault === fromVault;
|
|
1965
|
+
const thisNote = isFromVault ? fromNote : toNote;
|
|
1966
|
+
const otherNote = isFromVault ? toNote : fromNote;
|
|
1967
|
+
const commitBody = formatCommitBody({
|
|
1968
|
+
noteId: thisNote.id,
|
|
1969
|
+
noteTitle: thisNote.title,
|
|
1970
|
+
projectName: thisNote.projectName,
|
|
1971
|
+
relationship: {
|
|
1972
|
+
fromId: thisNote.id,
|
|
1973
|
+
toId: otherNote.id,
|
|
1974
|
+
type,
|
|
1975
|
+
},
|
|
1976
|
+
});
|
|
1977
|
+
await vault.git.commit(`relate: ${fromNote.title} ↔ ${toNote.title}`, files, commitBody);
|
|
1978
|
+
await pushAfterMutation(vault);
|
|
1979
|
+
modifiedNoteIds.push(...files.map(f => path.basename(f, '.md')));
|
|
1980
|
+
}
|
|
1981
|
+
const dirStr = bidirectional ? "↔" : "→";
|
|
1982
|
+
const structuredContent = {
|
|
1983
|
+
action: "related",
|
|
1984
|
+
fromId,
|
|
1985
|
+
toId,
|
|
1986
|
+
type,
|
|
1987
|
+
bidirectional,
|
|
1988
|
+
notesModified: modifiedNoteIds,
|
|
1989
|
+
};
|
|
1990
|
+
return {
|
|
1991
|
+
content: [{ type: "text", text: `Linked \`${fromId}\` ${dirStr} \`${toId}\` (${type})` }],
|
|
1992
|
+
structuredContent,
|
|
1993
|
+
};
|
|
1994
|
+
});
|
|
1995
|
+
// ── unrelate ──────────────────────────────────────────────────────────────────
|
|
1996
|
+
server.registerTool("unrelate", {
|
|
1997
|
+
title: "Remove Relationship",
|
|
1998
|
+
description: "Remove the relationship between two memories. Pass `cwd` to include the current project vault.",
|
|
1999
|
+
inputSchema: z.object({
|
|
2000
|
+
fromId: z.string().describe("The source memory id"),
|
|
2001
|
+
toId: z.string().describe("The target memory id"),
|
|
2002
|
+
bidirectional: z.boolean().optional().default(true),
|
|
2003
|
+
cwd: projectParam,
|
|
2004
|
+
}),
|
|
2005
|
+
outputSchema: RelateResultSchema,
|
|
2006
|
+
}, async ({ fromId, toId, bidirectional, cwd }) => {
|
|
2007
|
+
const [foundFrom, foundTo] = await Promise.all([
|
|
2008
|
+
vaultManager.findNote(fromId, cwd),
|
|
2009
|
+
vaultManager.findNote(toId, cwd),
|
|
2010
|
+
]);
|
|
2011
|
+
const now = new Date().toISOString();
|
|
2012
|
+
const vaultChanges = new Map();
|
|
2013
|
+
if (foundFrom) {
|
|
2014
|
+
const { note: fromNote, vault: fromVault } = foundFrom;
|
|
2015
|
+
const filtered = (fromNote.relatedTo ?? []).filter((r) => r.id !== toId);
|
|
2016
|
+
if (filtered.length !== (fromNote.relatedTo?.length ?? 0)) {
|
|
2017
|
+
await fromVault.storage.writeNote({ ...fromNote, relatedTo: filtered, updatedAt: now });
|
|
2018
|
+
const files = vaultChanges.get(fromVault) ?? [];
|
|
2019
|
+
files.push(vaultManager.noteRelPath(fromVault, fromId));
|
|
2020
|
+
vaultChanges.set(fromVault, files);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
if (bidirectional && foundTo) {
|
|
2024
|
+
const { note: toNote, vault: toVault } = foundTo;
|
|
2025
|
+
const filtered = (toNote.relatedTo ?? []).filter((r) => r.id !== fromId);
|
|
2026
|
+
if (filtered.length !== (toNote.relatedTo?.length ?? 0)) {
|
|
2027
|
+
await toVault.storage.writeNote({ ...toNote, relatedTo: filtered, updatedAt: now });
|
|
2028
|
+
const files = vaultChanges.get(toVault) ?? [];
|
|
2029
|
+
files.push(vaultManager.noteRelPath(toVault, toId));
|
|
2030
|
+
vaultChanges.set(toVault, files);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (vaultChanges.size === 0) {
|
|
2034
|
+
return { content: [{ type: "text", text: `No relationship found between '${fromId}' and '${toId}'` }], isError: true };
|
|
2035
|
+
}
|
|
2036
|
+
for (const [vault, files] of vaultChanges) {
|
|
2037
|
+
const found = foundFrom?.vault === vault ? foundFrom : foundTo;
|
|
2038
|
+
const commitBody = found
|
|
2039
|
+
? formatCommitBody({
|
|
2040
|
+
noteId: found.note.id,
|
|
2041
|
+
noteTitle: found.note.title,
|
|
2042
|
+
projectName: found.note.projectName,
|
|
2043
|
+
})
|
|
2044
|
+
: undefined;
|
|
2045
|
+
await vault.git.commit(`unrelate: ${fromId} ↔ ${toId}`, files, commitBody);
|
|
2046
|
+
await pushAfterMutation(vault);
|
|
2047
|
+
}
|
|
2048
|
+
const modifiedNoteIds = [];
|
|
2049
|
+
for (const [vault, files] of vaultChanges) {
|
|
2050
|
+
modifiedNoteIds.push(...files.map(f => path.basename(f, '.md')));
|
|
2051
|
+
}
|
|
2052
|
+
const structuredContent = {
|
|
2053
|
+
action: "unrelated",
|
|
2054
|
+
fromId,
|
|
2055
|
+
toId,
|
|
2056
|
+
type: "related-to", // not tracked for unrelate
|
|
2057
|
+
bidirectional,
|
|
2058
|
+
notesModified: modifiedNoteIds,
|
|
2059
|
+
};
|
|
2060
|
+
return { content: [{ type: "text", text: `Removed relationship between \`${fromId}\` and \`${toId}\`` }], structuredContent };
|
|
2061
|
+
});
|
|
2062
|
+
// ── consolidate ───────────────────────────────────────────────────────────────
|
|
2063
|
+
server.registerTool("consolidate", {
|
|
2064
|
+
title: "Consolidate Memories",
|
|
2065
|
+
description: "Analyze memories for consolidation opportunities or execute merges. " +
|
|
2066
|
+
"Strategies that modify data (execute-merge, prune-superseded) require confirmation. " +
|
|
2067
|
+
"Cross-vault: gathers notes from both main and project vaults for the detected project.",
|
|
2068
|
+
inputSchema: z.object({
|
|
2069
|
+
cwd: projectParam,
|
|
2070
|
+
strategy: z
|
|
2071
|
+
.enum([
|
|
2072
|
+
"detect-duplicates",
|
|
2073
|
+
"find-clusters",
|
|
2074
|
+
"suggest-merges",
|
|
2075
|
+
"execute-merge",
|
|
2076
|
+
"prune-superseded",
|
|
2077
|
+
"dry-run",
|
|
2078
|
+
])
|
|
2079
|
+
.describe("Analysis or action to perform"),
|
|
2080
|
+
mode: z
|
|
2081
|
+
.enum(CONSOLIDATION_MODES)
|
|
2082
|
+
.optional()
|
|
2083
|
+
.describe("Override the project's default consolidation mode (supersedes or delete)"),
|
|
2084
|
+
threshold: z
|
|
2085
|
+
.number()
|
|
2086
|
+
.min(0)
|
|
2087
|
+
.max(1)
|
|
2088
|
+
.optional()
|
|
2089
|
+
.default(0.85)
|
|
2090
|
+
.describe("Similarity threshold for detecting duplicates"),
|
|
2091
|
+
mergePlan: z
|
|
2092
|
+
.object({
|
|
2093
|
+
sourceIds: z.array(z.string()).min(2).describe("Notes to merge into a single consolidated note"),
|
|
2094
|
+
targetTitle: z.string().describe("Title for the consolidated note"),
|
|
2095
|
+
content: z.string().optional().describe("Custom body for the consolidated note. When provided, replaces the auto-merged source content. Use this to distil only durable knowledge instead of dumping all source content verbatim."),
|
|
2096
|
+
description: z.string().optional().describe("Optional context explaining the consolidation (stored in note)"),
|
|
2097
|
+
summary: z.string().optional().describe("Brief summary of merge rationale (for git commit message only)"),
|
|
2098
|
+
tags: z.array(z.string()).optional().describe("Tags for the consolidated note (defaults to union of source tags)"),
|
|
2099
|
+
})
|
|
2100
|
+
.optional()
|
|
2101
|
+
.describe("Required for execute-merge strategy"),
|
|
2102
|
+
}),
|
|
2103
|
+
outputSchema: ConsolidateResultSchema,
|
|
2104
|
+
}, async ({ cwd, strategy, mode, threshold, mergePlan }) => {
|
|
2105
|
+
const project = await resolveProject(cwd);
|
|
2106
|
+
if (!project && cwd) {
|
|
2107
|
+
return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
|
|
2108
|
+
}
|
|
2109
|
+
// Gather notes from all vaults (project + main) for this project
|
|
2110
|
+
const { entries } = await collectVisibleNotes(cwd, "all", undefined, "any");
|
|
2111
|
+
const projectNotes = project
|
|
2112
|
+
? entries.filter((e) => e.note.project === project.id)
|
|
2113
|
+
: entries.filter((e) => !e.note.project);
|
|
2114
|
+
if (projectNotes.length === 0) {
|
|
2115
|
+
return { content: [{ type: "text", text: "No memories found to consolidate." }], isError: true };
|
|
2116
|
+
}
|
|
2117
|
+
// Resolve project/default consolidation mode. Temporary-only merges may still
|
|
2118
|
+
// resolve to delete later when a specific source set is known.
|
|
2119
|
+
const policy = project ? await configStore.getProjectPolicy(project.id) : undefined;
|
|
2120
|
+
const defaultConsolidationMode = resolveConsolidationMode(policy);
|
|
2121
|
+
switch (strategy) {
|
|
2122
|
+
case "detect-duplicates":
|
|
2123
|
+
return detectDuplicates(projectNotes, threshold, project);
|
|
2124
|
+
case "find-clusters":
|
|
2125
|
+
return findClusters(projectNotes, project);
|
|
2126
|
+
case "suggest-merges":
|
|
2127
|
+
return suggestMerges(projectNotes, threshold, defaultConsolidationMode, project, mode);
|
|
2128
|
+
case "execute-merge":
|
|
2129
|
+
if (!mergePlan) {
|
|
2130
|
+
return { content: [{ type: "text", text: "execute-merge strategy requires a mergePlan with sourceIds and targetTitle." }], isError: true };
|
|
2131
|
+
}
|
|
2132
|
+
return executeMerge(projectNotes, mergePlan, defaultConsolidationMode, project, cwd, mode);
|
|
2133
|
+
case "prune-superseded":
|
|
2134
|
+
return pruneSuperseded(projectNotes, mode ?? defaultConsolidationMode, project);
|
|
2135
|
+
case "dry-run":
|
|
2136
|
+
return dryRunAll(projectNotes, threshold, defaultConsolidationMode, project, mode);
|
|
2137
|
+
default:
|
|
2138
|
+
return { content: [{ type: "text", text: `Unknown strategy: ${strategy}` }], isError: true };
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
// Consolidate helper functions
|
|
2142
|
+
async function detectDuplicates(entries, threshold, project) {
|
|
2143
|
+
const lines = [];
|
|
2144
|
+
lines.push(`Duplicate detection for ${project?.name ?? "global"} (similarity > ${threshold}):`);
|
|
2145
|
+
lines.push("");
|
|
2146
|
+
const checked = new Set();
|
|
2147
|
+
let foundCount = 0;
|
|
2148
|
+
const duplicates = [];
|
|
2149
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2150
|
+
const entryA = entries[i];
|
|
2151
|
+
if (checked.has(entryA.note.id))
|
|
2152
|
+
continue;
|
|
2153
|
+
const embeddingA = await entryA.vault.storage.readEmbedding(entryA.note.id);
|
|
2154
|
+
if (!embeddingA)
|
|
2155
|
+
continue;
|
|
2156
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
2157
|
+
const entryB = entries[j];
|
|
2158
|
+
if (checked.has(entryB.note.id))
|
|
2159
|
+
continue;
|
|
2160
|
+
const embeddingB = await entryB.vault.storage.readEmbedding(entryB.note.id);
|
|
2161
|
+
if (!embeddingB)
|
|
2162
|
+
continue;
|
|
2163
|
+
const similarity = cosineSimilarity(embeddingA.embedding, embeddingB.embedding);
|
|
2164
|
+
if (similarity >= threshold) {
|
|
2165
|
+
foundCount++;
|
|
2166
|
+
lines.push(`${foundCount}. ${entryA.note.title} (${entryA.note.id})`);
|
|
2167
|
+
lines.push(` └── ${entryB.note.title} (${entryB.note.id})`);
|
|
2168
|
+
lines.push(` Similarity: ${similarity.toFixed(3)}`);
|
|
2169
|
+
lines.push("");
|
|
2170
|
+
checked.add(entryA.note.id);
|
|
2171
|
+
checked.add(entryB.note.id);
|
|
2172
|
+
duplicates.push({
|
|
2173
|
+
noteA: { id: entryA.note.id, title: entryA.note.title },
|
|
2174
|
+
noteB: { id: entryB.note.id, title: entryB.note.title },
|
|
2175
|
+
similarity,
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (foundCount === 0) {
|
|
2181
|
+
lines.push("No duplicates found above the similarity threshold.");
|
|
2182
|
+
}
|
|
2183
|
+
else {
|
|
2184
|
+
lines.push(`Found ${foundCount} potential duplicate pair(s).`);
|
|
2185
|
+
lines.push("Use 'suggest-merges' strategy for actionable recommendations.");
|
|
2186
|
+
}
|
|
2187
|
+
const structuredContent = {
|
|
2188
|
+
action: "consolidated",
|
|
2189
|
+
strategy: "detect-duplicates",
|
|
2190
|
+
project: project?.id,
|
|
2191
|
+
projectName: project?.name,
|
|
2192
|
+
notesProcessed: entries.length,
|
|
2193
|
+
notesModified: 0,
|
|
2194
|
+
};
|
|
2195
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2196
|
+
}
|
|
2197
|
+
function findClusters(entries, project) {
|
|
2198
|
+
const lines = [];
|
|
2199
|
+
lines.push(`Cluster analysis for ${project?.name ?? "global"}:`);
|
|
2200
|
+
lines.push("");
|
|
2201
|
+
// Group by theme
|
|
2202
|
+
const themed = new Map();
|
|
2203
|
+
for (const entry of entries) {
|
|
2204
|
+
const theme = classifyTheme(entry.note);
|
|
2205
|
+
const bucket = themed.get(theme) ?? [];
|
|
2206
|
+
bucket.push(entry);
|
|
2207
|
+
themed.set(theme, bucket);
|
|
2208
|
+
}
|
|
2209
|
+
// Find relationship clusters
|
|
2210
|
+
const idToEntry = new Map(entries.map((e) => [e.note.id, e]));
|
|
2211
|
+
const visited = new Set();
|
|
2212
|
+
const clusters = [];
|
|
2213
|
+
for (const entry of entries) {
|
|
2214
|
+
if (visited.has(entry.note.id))
|
|
2215
|
+
continue;
|
|
2216
|
+
const cluster = [];
|
|
2217
|
+
const queue = [entry];
|
|
2218
|
+
while (queue.length > 0) {
|
|
2219
|
+
const current = queue.shift();
|
|
2220
|
+
if (visited.has(current.note.id))
|
|
2221
|
+
continue;
|
|
2222
|
+
visited.add(current.note.id);
|
|
2223
|
+
cluster.push(current);
|
|
2224
|
+
// Add related notes to queue
|
|
2225
|
+
for (const rel of current.note.relatedTo ?? []) {
|
|
2226
|
+
const related = idToEntry.get(rel.id);
|
|
2227
|
+
if (related && !visited.has(rel.id)) {
|
|
2228
|
+
queue.push(related);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
if (cluster.length > 1) {
|
|
2233
|
+
clusters.push(cluster);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
// Output theme groups
|
|
2237
|
+
const themeGroups = [];
|
|
2238
|
+
lines.push("By Theme:");
|
|
2239
|
+
for (const [theme, bucket] of themed) {
|
|
2240
|
+
if (bucket.length > 1) {
|
|
2241
|
+
lines.push(` ${titleCaseTheme(theme)} (${bucket.length} notes)`);
|
|
2242
|
+
const examples = bucket.slice(0, 3).map((entry) => entry.note.title);
|
|
2243
|
+
for (const entry of bucket.slice(0, 3)) {
|
|
2244
|
+
lines.push(` - ${entry.note.title}`);
|
|
2245
|
+
}
|
|
2246
|
+
if (bucket.length > 3) {
|
|
2247
|
+
lines.push(` ... and ${bucket.length - 3} more`);
|
|
2248
|
+
}
|
|
2249
|
+
themeGroups.push({ name: theme, count: bucket.length, examples });
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// Output relationship clusters
|
|
2253
|
+
const relationshipClusters = [];
|
|
2254
|
+
if (clusters.length > 0) {
|
|
2255
|
+
lines.push("");
|
|
2256
|
+
lines.push("Connected Clusters (via relationships):");
|
|
2257
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
2258
|
+
const cluster = clusters[i];
|
|
2259
|
+
lines.push(` Cluster ${i + 1} (${cluster.length} notes):`);
|
|
2260
|
+
const hub = cluster.reduce((max, e) => (e.note.relatedTo?.length ?? 0) > (max.note.relatedTo?.length ?? 0) ? e : max);
|
|
2261
|
+
lines.push(` Hub: ${hub.note.title}`);
|
|
2262
|
+
const clusterNotes = [];
|
|
2263
|
+
for (const entry of cluster) {
|
|
2264
|
+
if (entry.note.id !== hub.note.id) {
|
|
2265
|
+
lines.push(` - ${entry.note.title}`);
|
|
2266
|
+
clusterNotes.push({ id: entry.note.id, title: entry.note.title });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
relationshipClusters.push({
|
|
2270
|
+
hub: { id: hub.note.id, title: hub.note.title },
|
|
2271
|
+
notes: clusterNotes,
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
const structuredContent = {
|
|
2276
|
+
action: "consolidated",
|
|
2277
|
+
strategy: "find-clusters",
|
|
2278
|
+
project: project?.id,
|
|
2279
|
+
projectName: project?.name,
|
|
2280
|
+
notesProcessed: entries.length,
|
|
2281
|
+
notesModified: 0,
|
|
2282
|
+
};
|
|
2283
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2284
|
+
}
|
|
2285
|
+
async function suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode) {
|
|
2286
|
+
const lines = [];
|
|
2287
|
+
const modeLabel = explicitMode ?? `${defaultConsolidationMode} (project/default; all-temporary merges auto-delete)`;
|
|
2288
|
+
lines.push(`Merge suggestions for ${project?.name ?? "global"} (mode: ${modeLabel}):`);
|
|
2289
|
+
lines.push("");
|
|
2290
|
+
const checked = new Set();
|
|
2291
|
+
let suggestionCount = 0;
|
|
2292
|
+
const suggestions = [];
|
|
2293
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2294
|
+
const entryA = entries[i];
|
|
2295
|
+
if (checked.has(entryA.note.id))
|
|
2296
|
+
continue;
|
|
2297
|
+
const embeddingA = await entryA.vault.storage.readEmbedding(entryA.note.id);
|
|
2298
|
+
if (!embeddingA)
|
|
2299
|
+
continue;
|
|
2300
|
+
const similar = [];
|
|
2301
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
2302
|
+
const entryB = entries[j];
|
|
2303
|
+
if (checked.has(entryB.note.id))
|
|
2304
|
+
continue;
|
|
2305
|
+
const embeddingB = await entryB.vault.storage.readEmbedding(entryB.note.id);
|
|
2306
|
+
if (!embeddingB)
|
|
2307
|
+
continue;
|
|
2308
|
+
const similarity = cosineSimilarity(embeddingA.embedding, embeddingB.embedding);
|
|
2309
|
+
if (similarity >= threshold) {
|
|
2310
|
+
similar.push({ entry: entryB, similarity });
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
if (similar.length > 0) {
|
|
2314
|
+
suggestionCount++;
|
|
2315
|
+
similar.sort((a, b) => b.similarity - a.similarity);
|
|
2316
|
+
const sources = [entryA, ...similar.map((s) => s.entry)];
|
|
2317
|
+
const effectiveMode = resolveEffectiveConsolidationMode(sources.map((source) => source.note), defaultConsolidationMode, explicitMode);
|
|
2318
|
+
lines.push(`${suggestionCount}. MERGE ${sources.length} NOTES`);
|
|
2319
|
+
lines.push(` Into: "${entryA.note.title} (consolidated)"`);
|
|
2320
|
+
lines.push(" Sources:");
|
|
2321
|
+
for (const src of sources) {
|
|
2322
|
+
const simStr = src.note.id === entryA.note.id ? "" : ` (${similar.find((s) => s.entry.note.id === src.note.id)?.similarity.toFixed(3)})`;
|
|
2323
|
+
lines.push(` - ${src.note.title} (${src.note.id})${simStr}`);
|
|
2324
|
+
}
|
|
2325
|
+
const modeDescription = (() => {
|
|
2326
|
+
switch (effectiveMode) {
|
|
2327
|
+
case "supersedes":
|
|
2328
|
+
return "preserves history";
|
|
2329
|
+
case "delete":
|
|
2330
|
+
return "removes sources";
|
|
2331
|
+
default: {
|
|
2332
|
+
const _exhaustive = effectiveMode;
|
|
2333
|
+
return _exhaustive;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
})();
|
|
2337
|
+
lines.push(` Mode: ${effectiveMode} (${modeDescription})`);
|
|
2338
|
+
lines.push(" To execute:");
|
|
2339
|
+
lines.push(` consolidate({ strategy: "execute-merge", mergePlan: {`);
|
|
2340
|
+
lines.push(` sourceIds: [${sources.map((s) => `"${s.note.id}"`).join(", ")}],`);
|
|
2341
|
+
lines.push(` targetTitle: "${entryA.note.title} (consolidated)"`);
|
|
2342
|
+
lines.push(` }})`);
|
|
2343
|
+
lines.push("");
|
|
2344
|
+
suggestions.push({
|
|
2345
|
+
targetTitle: `${entryA.note.title} (consolidated)`,
|
|
2346
|
+
sourceIds: sources.map((s) => s.note.id),
|
|
2347
|
+
similarities: similar.map((s) => ({ id: s.entry.note.id, similarity: s.similarity })),
|
|
2348
|
+
});
|
|
2349
|
+
checked.add(entryA.note.id);
|
|
2350
|
+
for (const s of similar)
|
|
2351
|
+
checked.add(s.entry.note.id);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (suggestionCount === 0) {
|
|
2355
|
+
lines.push("No merge suggestions found. Try lowering the threshold or manual review.");
|
|
2356
|
+
}
|
|
2357
|
+
else {
|
|
2358
|
+
lines.push(`Generated ${suggestionCount} merge suggestion(s). Review carefully before executing.`);
|
|
2359
|
+
}
|
|
2360
|
+
const structuredContent = {
|
|
2361
|
+
action: "consolidated",
|
|
2362
|
+
strategy: "suggest-merges",
|
|
2363
|
+
project: project?.id,
|
|
2364
|
+
projectName: project?.name,
|
|
2365
|
+
notesProcessed: entries.length,
|
|
2366
|
+
notesModified: 0,
|
|
2367
|
+
};
|
|
2368
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2369
|
+
}
|
|
2370
|
+
async function executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, explicitMode) {
|
|
2371
|
+
const sourceIds = normalizeMergePlanSourceIds(mergePlan.sourceIds);
|
|
2372
|
+
const targetTitle = mergePlan.targetTitle.trim();
|
|
2373
|
+
const { content: customContent, description, summary, tags } = mergePlan;
|
|
2374
|
+
if (sourceIds.length < 2) {
|
|
2375
|
+
const structuredContent = {
|
|
2376
|
+
action: "consolidated",
|
|
2377
|
+
strategy: "execute-merge",
|
|
2378
|
+
project: project?.id,
|
|
2379
|
+
projectName: project?.name,
|
|
2380
|
+
notesProcessed: entries.length,
|
|
2381
|
+
notesModified: 0,
|
|
2382
|
+
warnings: ["execute-merge requires at least two distinct sourceIds."],
|
|
2383
|
+
};
|
|
2384
|
+
return { content: [{ type: "text", text: "execute-merge requires at least two distinct sourceIds." }], structuredContent };
|
|
2385
|
+
}
|
|
2386
|
+
if (!targetTitle) {
|
|
2387
|
+
const structuredContent = {
|
|
2388
|
+
action: "consolidated",
|
|
2389
|
+
strategy: "execute-merge",
|
|
2390
|
+
project: project?.id,
|
|
2391
|
+
projectName: project?.name,
|
|
2392
|
+
notesProcessed: entries.length,
|
|
2393
|
+
notesModified: 0,
|
|
2394
|
+
warnings: ["execute-merge requires a non-empty targetTitle."],
|
|
2395
|
+
};
|
|
2396
|
+
return { content: [{ type: "text", text: "execute-merge requires a non-empty targetTitle." }], structuredContent };
|
|
2397
|
+
}
|
|
2398
|
+
// Find all source entries
|
|
2399
|
+
const sourceEntries = [];
|
|
2400
|
+
for (const id of sourceIds) {
|
|
2401
|
+
const entry = entries.find((e) => e.note.id === id);
|
|
2402
|
+
if (!entry) {
|
|
2403
|
+
const structuredContent = {
|
|
2404
|
+
action: "consolidated",
|
|
2405
|
+
strategy: "execute-merge",
|
|
2406
|
+
project: project?.id,
|
|
2407
|
+
projectName: project?.name,
|
|
2408
|
+
notesProcessed: entries.length,
|
|
2409
|
+
notesModified: 0,
|
|
2410
|
+
warnings: [`Source note '${id}' not found.`],
|
|
2411
|
+
};
|
|
2412
|
+
return { content: [{ type: "text", text: `Source note '${id}' not found.` }], structuredContent };
|
|
2413
|
+
}
|
|
2414
|
+
sourceEntries.push(entry);
|
|
2415
|
+
}
|
|
2416
|
+
const consolidationMode = resolveEffectiveConsolidationMode(sourceEntries.map((entry) => entry.note), defaultConsolidationMode, explicitMode);
|
|
2417
|
+
const projectVault = cwd ? await vaultManager.getOrCreateProjectVault(cwd) : null;
|
|
2418
|
+
const targetVault = projectVault ?? vaultManager.main;
|
|
2419
|
+
const now = new Date().toISOString();
|
|
2420
|
+
// Build consolidated content
|
|
2421
|
+
const sections = [];
|
|
2422
|
+
if (customContent) {
|
|
2423
|
+
if (description) {
|
|
2424
|
+
sections.push(description);
|
|
2425
|
+
sections.push("");
|
|
2426
|
+
}
|
|
2427
|
+
sections.push(customContent);
|
|
2428
|
+
}
|
|
2429
|
+
else {
|
|
2430
|
+
if (description) {
|
|
2431
|
+
sections.push(description);
|
|
2432
|
+
sections.push("");
|
|
2433
|
+
}
|
|
2434
|
+
sections.push("## Consolidated from:");
|
|
2435
|
+
for (const entry of sourceEntries) {
|
|
2436
|
+
sections.push(`### ${entry.note.title}`);
|
|
2437
|
+
sections.push(`*Source: \`${entry.note.id}\`*`);
|
|
2438
|
+
sections.push("");
|
|
2439
|
+
sections.push(entry.note.content);
|
|
2440
|
+
sections.push("");
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
// Combine tags (deduplicated)
|
|
2444
|
+
const combinedTags = tags ?? Array.from(new Set(sourceEntries.flatMap((e) => e.note.tags)));
|
|
2445
|
+
// Collect all unique relationships from sources (excluding relationships among sources)
|
|
2446
|
+
const sourceIdsSet = new Set(sourceIds);
|
|
2447
|
+
const allRelationships = mergeRelationshipsFromNotes(sourceEntries.map((entry) => entry.note), sourceIdsSet);
|
|
2448
|
+
// Create consolidated note
|
|
2449
|
+
const targetId = makeId(targetTitle);
|
|
2450
|
+
const consolidatedNote = {
|
|
2451
|
+
id: targetId,
|
|
2452
|
+
title: targetTitle,
|
|
2453
|
+
content: sections.join("\n").trim(),
|
|
2454
|
+
tags: combinedTags,
|
|
2455
|
+
lifecycle: "permanent",
|
|
2456
|
+
project: project?.id,
|
|
2457
|
+
projectName: project?.name,
|
|
2458
|
+
relatedTo: allRelationships,
|
|
2459
|
+
createdAt: now,
|
|
2460
|
+
updatedAt: now,
|
|
2461
|
+
memoryVersion: 1,
|
|
2462
|
+
};
|
|
2463
|
+
// Write consolidated note
|
|
2464
|
+
await targetVault.storage.writeNote(consolidatedNote);
|
|
2465
|
+
let embeddingStatus = { status: "written" };
|
|
2466
|
+
// Generate embedding for consolidated note
|
|
2467
|
+
try {
|
|
2468
|
+
const vector = await embed(`${targetTitle}\n\n${consolidatedNote.content}`);
|
|
2469
|
+
await targetVault.storage.writeEmbedding({
|
|
2470
|
+
id: targetId,
|
|
2471
|
+
model: embedModel,
|
|
2472
|
+
embedding: vector,
|
|
2473
|
+
updatedAt: now,
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
catch (err) {
|
|
2477
|
+
embeddingStatus = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
|
2478
|
+
console.error(`[embedding] Failed for consolidated note '${targetId}': ${err}`);
|
|
2479
|
+
}
|
|
2480
|
+
const vaultChanges = new Map();
|
|
2481
|
+
// Handle sources based on consolidation mode
|
|
2482
|
+
switch (consolidationMode) {
|
|
2483
|
+
case "delete": {
|
|
2484
|
+
// Delete all sources
|
|
2485
|
+
for (const entry of sourceEntries) {
|
|
2486
|
+
await entry.vault.storage.deleteNote(entry.note.id);
|
|
2487
|
+
addVaultChange(vaultChanges, entry.vault, vaultManager.noteRelPath(entry.vault, entry.note.id));
|
|
2488
|
+
}
|
|
2489
|
+
const cleanupChanges = await removeRelationshipsToNoteIds(sourceIds);
|
|
2490
|
+
for (const [vault, files] of cleanupChanges) {
|
|
2491
|
+
for (const file of files) {
|
|
2492
|
+
addVaultChange(vaultChanges, vault, file);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
break;
|
|
2496
|
+
}
|
|
2497
|
+
case "supersedes": {
|
|
2498
|
+
// Mark sources with supersedes relationship
|
|
2499
|
+
for (const entry of sourceEntries) {
|
|
2500
|
+
const updatedRels = [...(entry.note.relatedTo ?? [])];
|
|
2501
|
+
if (!updatedRels.some((r) => r.id === targetId)) {
|
|
2502
|
+
updatedRels.push({ id: targetId, type: "supersedes" });
|
|
2503
|
+
}
|
|
2504
|
+
await entry.vault.storage.writeNote({
|
|
2505
|
+
...entry.note,
|
|
2506
|
+
relatedTo: updatedRels,
|
|
2507
|
+
updatedAt: now,
|
|
2508
|
+
});
|
|
2509
|
+
addVaultChange(vaultChanges, entry.vault, vaultManager.noteRelPath(entry.vault, entry.note.id));
|
|
2510
|
+
}
|
|
2511
|
+
break;
|
|
2512
|
+
}
|
|
2513
|
+
default: {
|
|
2514
|
+
const _exhaustive = consolidationMode;
|
|
2515
|
+
throw new Error(`Unknown consolidation mode: ${_exhaustive}`);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
// Add consolidated note to changes
|
|
2519
|
+
addVaultChange(vaultChanges, targetVault, vaultManager.noteRelPath(targetVault, targetId));
|
|
2520
|
+
// Commit changes per vault
|
|
2521
|
+
let targetCommitStatus = { status: "skipped", reason: "no-changes" };
|
|
2522
|
+
let targetPushStatus = { status: "skipped", reason: "no-remote" };
|
|
2523
|
+
let targetCommitBody;
|
|
2524
|
+
let targetCommitMessage;
|
|
2525
|
+
for (const [vault, files] of vaultChanges) {
|
|
2526
|
+
const isTargetVault = vault === targetVault;
|
|
2527
|
+
// Determine action and summary based on mode
|
|
2528
|
+
let action;
|
|
2529
|
+
let sourceSummary;
|
|
2530
|
+
switch (consolidationMode) {
|
|
2531
|
+
case "delete":
|
|
2532
|
+
action = "consolidate(delete)";
|
|
2533
|
+
sourceSummary = "Deleted as part of consolidation";
|
|
2534
|
+
break;
|
|
2535
|
+
case "supersedes":
|
|
2536
|
+
action = "consolidate(supersedes)";
|
|
2537
|
+
sourceSummary = "Marked as superseded by consolidation";
|
|
2538
|
+
break;
|
|
2539
|
+
default: {
|
|
2540
|
+
const _exhaustive = consolidationMode;
|
|
2541
|
+
throw new Error(`Unknown consolidation mode: ${_exhaustive}`);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
const defaultSummary = `Consolidated ${sourceIds.length} notes into new note`;
|
|
2545
|
+
const commitSummary = isTargetVault ? (summary ?? defaultSummary) : sourceSummary;
|
|
2546
|
+
const commitBody = isTargetVault
|
|
2547
|
+
? formatCommitBody({
|
|
2548
|
+
summary: commitSummary,
|
|
2549
|
+
noteId: targetId,
|
|
2550
|
+
noteTitle: targetTitle,
|
|
2551
|
+
projectName: project?.name,
|
|
2552
|
+
mode: consolidationMode,
|
|
2553
|
+
noteIds: sourceIds,
|
|
2554
|
+
description: `Sources: ${sourceIds.join(", ")}`,
|
|
2555
|
+
})
|
|
2556
|
+
: formatCommitBody({
|
|
2557
|
+
summary: commitSummary,
|
|
2558
|
+
noteIds: files.map((f) => f.replace(/\.mnemonic\/notes\/(.+)\.md$/, "$1").replace(/notes\/(.+)\.md$/, "$1")),
|
|
2559
|
+
});
|
|
2560
|
+
const commitMessage = `${action}: ${targetTitle}`;
|
|
2561
|
+
const commitStatus = await vault.git.commitWithStatus(commitMessage, files, commitBody);
|
|
2562
|
+
const pushStatus = await pushAfterMutation(vault);
|
|
2563
|
+
if (isTargetVault) {
|
|
2564
|
+
targetCommitStatus = commitStatus;
|
|
2565
|
+
targetPushStatus = pushStatus;
|
|
2566
|
+
targetCommitBody = commitBody;
|
|
2567
|
+
targetCommitMessage = commitMessage;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
const persistence = buildPersistenceStatus({
|
|
2571
|
+
storage: targetVault.storage,
|
|
2572
|
+
id: targetId,
|
|
2573
|
+
embedding: embeddingStatus,
|
|
2574
|
+
commit: targetCommitStatus,
|
|
2575
|
+
push: targetPushStatus,
|
|
2576
|
+
commitMessage: targetCommitMessage,
|
|
2577
|
+
commitBody: targetCommitBody,
|
|
2578
|
+
});
|
|
2579
|
+
const lines = [];
|
|
2580
|
+
lines.push(`Consolidated ${sourceIds.length} notes into '${targetId}'`);
|
|
2581
|
+
lines.push(`Mode: ${consolidationMode}`);
|
|
2582
|
+
lines.push(`Stored in: ${targetVault.isProject ? "project-vault" : "main-vault"}`);
|
|
2583
|
+
lines.push(formatPersistenceSummary(persistence));
|
|
2584
|
+
switch (consolidationMode) {
|
|
2585
|
+
case "supersedes":
|
|
2586
|
+
lines.push("Sources preserved with 'supersedes' relationship.");
|
|
2587
|
+
lines.push("Use 'prune-superseded' later to clean up if desired.");
|
|
2588
|
+
break;
|
|
2589
|
+
case "delete":
|
|
2590
|
+
lines.push("Source notes deleted.");
|
|
2591
|
+
break;
|
|
2592
|
+
default: {
|
|
2593
|
+
const _exhaustive = consolidationMode;
|
|
2594
|
+
throw new Error(`Unknown consolidation mode: ${_exhaustive}`);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const structuredContent = {
|
|
2598
|
+
action: "consolidated",
|
|
2599
|
+
strategy: "execute-merge",
|
|
2600
|
+
project: project?.id,
|
|
2601
|
+
projectName: project?.name,
|
|
2602
|
+
notesProcessed: entries.length,
|
|
2603
|
+
notesModified: vaultChanges.size,
|
|
2604
|
+
persistence,
|
|
2605
|
+
};
|
|
2606
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2607
|
+
}
|
|
2608
|
+
async function pruneSuperseded(entries, consolidationMode, project) {
|
|
2609
|
+
if (consolidationMode !== "delete") {
|
|
2610
|
+
const structuredContent = {
|
|
2611
|
+
action: "consolidated",
|
|
2612
|
+
strategy: "prune-superseded",
|
|
2613
|
+
project: project?.id,
|
|
2614
|
+
projectName: project?.name,
|
|
2615
|
+
notesProcessed: entries.length,
|
|
2616
|
+
notesModified: 0,
|
|
2617
|
+
warnings: [`prune-superseded requires consolidationMode="delete". Current mode: ${consolidationMode}.`],
|
|
2618
|
+
};
|
|
2619
|
+
return {
|
|
2620
|
+
content: [{
|
|
2621
|
+
type: "text",
|
|
2622
|
+
text: `prune-superseded requires consolidationMode="delete". Current mode: ${consolidationMode}.\nSet mode explicitly or update project policy.`,
|
|
2623
|
+
}],
|
|
2624
|
+
structuredContent,
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
const lines = [];
|
|
2628
|
+
lines.push(`Pruning superseded notes for ${project?.name ?? "global"}:`);
|
|
2629
|
+
lines.push("");
|
|
2630
|
+
// Find all notes that have a supersedes relationship pointing to them
|
|
2631
|
+
const supersededIds = new Set();
|
|
2632
|
+
const supersededBy = new Map();
|
|
2633
|
+
for (const entry of entries) {
|
|
2634
|
+
for (const rel of entry.note.relatedTo ?? []) {
|
|
2635
|
+
if (rel.type === "supersedes") {
|
|
2636
|
+
supersededIds.add(entry.note.id);
|
|
2637
|
+
supersededBy.set(entry.note.id, rel.id);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
if (supersededIds.size === 0) {
|
|
2642
|
+
lines.push("No superseded notes found.");
|
|
2643
|
+
const structuredContent = {
|
|
2644
|
+
action: "consolidated",
|
|
2645
|
+
strategy: "prune-superseded",
|
|
2646
|
+
project: project?.id,
|
|
2647
|
+
projectName: project?.name,
|
|
2648
|
+
notesProcessed: entries.length,
|
|
2649
|
+
notesModified: 0,
|
|
2650
|
+
};
|
|
2651
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2652
|
+
}
|
|
2653
|
+
lines.push(`Found ${supersededIds.size} superseded note(s) to prune:`);
|
|
2654
|
+
const vaultChanges = new Map();
|
|
2655
|
+
for (const id of supersededIds) {
|
|
2656
|
+
const entry = entries.find((e) => e.note.id === id);
|
|
2657
|
+
if (!entry)
|
|
2658
|
+
continue;
|
|
2659
|
+
const targetId = supersededBy.get(id);
|
|
2660
|
+
lines.push(` - ${entry.note.title} (${id}) -> superseded by ${targetId}`);
|
|
2661
|
+
await entry.vault.storage.deleteNote(id);
|
|
2662
|
+
addVaultChange(vaultChanges, entry.vault, vaultManager.noteRelPath(entry.vault, id));
|
|
2663
|
+
}
|
|
2664
|
+
const cleanupChanges = await removeRelationshipsToNoteIds(Array.from(supersededIds));
|
|
2665
|
+
for (const [vault, files] of cleanupChanges) {
|
|
2666
|
+
for (const file of files) {
|
|
2667
|
+
addVaultChange(vaultChanges, vault, file);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
// Commit changes per vault
|
|
2671
|
+
for (const [vault, files] of vaultChanges) {
|
|
2672
|
+
const prunedIds = files.map((f) => f.replace(/\.mnemonic\/notes\/(.+)\.md$/, "$1").replace(/notes\/(.+)\.md$/, "$1"));
|
|
2673
|
+
const commitBody = formatCommitBody({
|
|
2674
|
+
noteIds: prunedIds,
|
|
2675
|
+
description: `Pruned ${prunedIds.length} superseded note(s)\nNotes: ${prunedIds.join(", ")}`,
|
|
2676
|
+
});
|
|
2677
|
+
await vault.git.commit(`prune: removed ${files.length} superseded note(s)`, files, commitBody);
|
|
2678
|
+
await pushAfterMutation(vault);
|
|
2679
|
+
}
|
|
2680
|
+
lines.push("");
|
|
2681
|
+
lines.push(`Pruned ${supersededIds.size} note(s).`);
|
|
2682
|
+
const structuredContent = {
|
|
2683
|
+
action: "consolidated",
|
|
2684
|
+
strategy: "prune-superseded",
|
|
2685
|
+
project: project?.id,
|
|
2686
|
+
projectName: project?.name,
|
|
2687
|
+
notesProcessed: entries.length,
|
|
2688
|
+
notesModified: vaultChanges.size,
|
|
2689
|
+
};
|
|
2690
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2691
|
+
}
|
|
2692
|
+
async function dryRunAll(entries, threshold, defaultConsolidationMode, project, explicitMode) {
|
|
2693
|
+
const lines = [];
|
|
2694
|
+
lines.push(`Consolidation analysis for ${project?.name ?? "global"}:`);
|
|
2695
|
+
const modeLabel = explicitMode ?? `${defaultConsolidationMode} (project/default; all-temporary merges auto-delete)`;
|
|
2696
|
+
lines.push(`Mode: ${modeLabel} | Threshold: ${threshold}`);
|
|
2697
|
+
lines.push("");
|
|
2698
|
+
// Run all analysis strategies
|
|
2699
|
+
const dupes = await detectDuplicates(entries, threshold, project);
|
|
2700
|
+
lines.push("=== DUPLICATE DETECTION ===");
|
|
2701
|
+
lines.push(dupes.content[0]?.text ?? "No output");
|
|
2702
|
+
lines.push("");
|
|
2703
|
+
const clusters = findClusters(entries, project);
|
|
2704
|
+
lines.push("=== CLUSTER ANALYSIS ===");
|
|
2705
|
+
lines.push(clusters.content[0]?.text ?? "No output");
|
|
2706
|
+
lines.push("");
|
|
2707
|
+
const merges = await suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode);
|
|
2708
|
+
lines.push("=== MERGE SUGGESTIONS ===");
|
|
2709
|
+
lines.push(merges.content[0]?.text ?? "No output");
|
|
2710
|
+
const structuredContent = {
|
|
2711
|
+
action: "consolidated",
|
|
2712
|
+
strategy: "dry-run",
|
|
2713
|
+
project: project?.id,
|
|
2714
|
+
projectName: project?.name,
|
|
2715
|
+
notesProcessed: entries.length,
|
|
2716
|
+
notesModified: 0,
|
|
2717
|
+
};
|
|
2718
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
|
|
2719
|
+
}
|
|
2720
|
+
async function warnAboutPendingMigrationsOnStartup() {
|
|
2721
|
+
let totalPending = 0;
|
|
2722
|
+
const details = [];
|
|
2723
|
+
for (const vault of vaultManager.allKnownVaults()) {
|
|
2724
|
+
const version = await readVaultSchemaVersion(vault.storage.vaultPath);
|
|
2725
|
+
const pending = await migrator.getPendingMigrations(version);
|
|
2726
|
+
if (pending.length === 0) {
|
|
2727
|
+
continue;
|
|
2728
|
+
}
|
|
2729
|
+
totalPending += pending.length;
|
|
2730
|
+
const label = vault.isProject ? "project" : "main";
|
|
2731
|
+
details.push(`${label} (${vault.storage.vaultPath}): ${pending.length} pending from schema ${version}`);
|
|
2732
|
+
}
|
|
2733
|
+
if (totalPending === 0) {
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
console.error(`[mnemonic] ${totalPending} pending migration(s) detected - run "mnemonic migrate --dry-run" to preview`);
|
|
2737
|
+
for (const detail of details) {
|
|
2738
|
+
console.error(`[mnemonic] ${detail}`);
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
// ── start ─────────────────────────────────────────────────────────────────────
|
|
2742
|
+
await warnAboutPendingMigrationsOnStartup();
|
|
2743
|
+
const transport = new StdioServerTransport();
|
|
2744
|
+
async function shutdown() {
|
|
2745
|
+
await server.close();
|
|
2746
|
+
process.exit(0);
|
|
2747
|
+
}
|
|
2748
|
+
process.on("SIGINT", shutdown);
|
|
2749
|
+
process.on("SIGTERM", shutdown);
|
|
2750
|
+
transport.onclose = async () => { await server.close(); };
|
|
2751
|
+
await server.connect(transport);
|
|
2752
|
+
console.error(`[mnemonic] Started. Main vault: ${VAULT_PATH}`);
|
|
2753
|
+
//# sourceMappingURL=index.js.map
|