lore-memory 0.4.0 → 0.5.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/bin/lore.js CHANGED
@@ -164,8 +164,9 @@ program
164
164
 
165
165
  program
166
166
  .command('serve')
167
- .description('Start the Lore MCP server (stdio) for use with Claude Code')
167
+ .description('Start the Lore MCP server (stdio) and UI dashboard for use with Claude Code')
168
168
  .option('-q, --quiet', 'Suppress startup messages (use when piped into MCP client)')
169
+ .option('-p, --port <port>', 'Port for the UI dashboard', '3333')
169
170
  .action(require('../src/commands/serve'));
170
171
 
171
172
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lore-memory",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Persistent project memory for developers. Captures decisions, invariants, gotchas, and graveyard entries — automatically and manually — and injects them into AI coding sessions.",
5
5
  "main": "bin/lore.js",
6
6
  "bin": {
@@ -6,20 +6,6 @@ const yaml = require('js-yaml');
6
6
  const chalk = require('chalk');
7
7
  const { LORE_DIR, emptyIndex } = require('../lib/index');
8
8
 
9
- const HOOK_CONTENT = `#!/bin/bash
10
- LINECOUNT=$(git diff HEAD~1 --shortstat 2>/dev/null | grep -o '[0-9]* insertion' | grep -o '[0-9]*' || echo 0)
11
- if [ "\${LINECOUNT:-0}" -gt 50 ]; then
12
- # Only prompt if running in an interactive terminal
13
- if [ -t 1 ] || [ -t 0 ]; then
14
- echo "📖 Lore: Significant change detected. Log it? (y/n)"
15
- read -r answer </dev/tty
16
- if [ "$answer" = "y" ]; then
17
- lore log </dev/tty
18
- fi
19
- fi
20
- fi
21
- `;
22
-
23
9
  async function init() {
24
10
  try {
25
11
  const dirs = ['decisions', 'invariants', 'graveyard', 'gotchas', 'modules', 'sessions', 'drafts'];
@@ -44,6 +30,7 @@ async function init() {
44
30
  },
45
31
  mcp: {
46
32
  tokenBudget: 4000,
33
+ confirmEntries: true,
47
34
  },
48
35
  watchMode: false,
49
36
  watchIgnore: ['node_modules', 'dist', '.git', 'coverage'],
@@ -64,33 +51,6 @@ async function init() {
64
51
  );
65
52
  }
66
53
 
67
- const hookDir = path.join('.git', 'hooks');
68
- if (fs.existsSync(hookDir)) {
69
- const hookPath = path.join(hookDir, 'post-commit');
70
- let writeHook = true;
71
-
72
- if (fs.existsSync(hookPath)) {
73
- const existingHook = await fs.readFile(hookPath, 'utf8');
74
- if (existingHook.includes('Lore: Significant change detected')) {
75
- writeHook = false;
76
- console.log(chalk.dim('✓ Git post-commit hook already installed'));
77
- } else {
78
- // Append to existing hook
79
- await fs.appendFile(hookPath, '\n' + HOOK_CONTENT.replace('#!/bin/bash\n', ''));
80
- console.log(chalk.green('✓ Lore appended to existing Git post-commit hook'));
81
- writeHook = false;
82
- }
83
- }
84
-
85
- if (writeHook) {
86
- await fs.writeFile(hookPath, HOOK_CONTENT);
87
- await fs.chmod(hookPath, '0755');
88
- console.log(chalk.green('✓ Git post-commit hook installed'));
89
- }
90
- } else {
91
- console.log(chalk.yellow('⚠ Not a git repo — hook not installed'));
92
- }
93
-
94
54
  console.log(chalk.green(`✓ Lore initialized at ${LORE_DIR}/`));
95
55
  console.log(chalk.cyan(' Run: lore mine . to scan codebase for lore-worthy comments'));
96
56
  console.log(chalk.cyan(' Run: lore log to create your first entry manually'));
@@ -4,7 +4,7 @@ const chalk = require('chalk');
4
4
  const inquirer = require('inquirer');
5
5
  const { readIndex, writeIndex, addEntryToIndex } = require('../lib/index');
6
6
  const { requireInit } = require('../lib/guard');
7
- const { writeEntry, generateId } = require('../lib/entries');
7
+ const { writeEntry, generateId, findDuplicate } = require('../lib/entries');
8
8
  const { getRecentFiles } = require('../lib/git');
9
9
 
10
10
  async function log(options) {
@@ -103,6 +103,32 @@ async function log(options) {
103
103
  process.exit(1);
104
104
  }
105
105
 
106
+ // Deduplication check
107
+ const duplicate = findDuplicate(index, type, title);
108
+ if (duplicate) {
109
+ const matchLabel = duplicate.match === 'exact' ? 'Exact duplicate' : 'Similar entry';
110
+ console.log(chalk.yellow(`\n⚠ ${matchLabel} found: ${chalk.bold(duplicate.entry.id)}`));
111
+ console.log(chalk.dim(` Title: "${duplicate.entry.title}"\n`));
112
+
113
+ // In inline mode (flags), just warn and exit
114
+ if (options.type && options.title && options.context) {
115
+ console.log(chalk.yellow('Skipping to avoid duplicate. Use a different title or edit the existing entry.'));
116
+ return;
117
+ }
118
+
119
+ // In interactive mode, ask user
120
+ const { proceed } = await inquirer.prompt([{
121
+ type: 'confirm',
122
+ name: 'proceed',
123
+ message: 'Create entry anyway?',
124
+ default: false,
125
+ }]);
126
+ if (!proceed) {
127
+ console.log(chalk.dim('Aborted.'));
128
+ return;
129
+ }
130
+ }
131
+
106
132
  const id = generateId(type, title);
107
133
  const entry = {
108
134
  id,
@@ -2,16 +2,36 @@
2
2
 
3
3
  const chalk = require('chalk');
4
4
  const { requireInit } = require('../lib/guard');
5
+ const { getServerStatus } = require('../mcp/status');
5
6
 
6
7
  async function serve(options) {
7
8
  requireInit();
9
+
10
+ // Prevent double-start
11
+ const existing = getServerStatus();
12
+ if (existing) {
13
+ process.stderr.write(chalk.yellow(`\n⚠ Lore MCP server is already running (PID: ${existing.pid}, started: ${existing.startedAt})\n`));
14
+ process.stderr.write(chalk.dim(` Tools: ${existing.tools.join(', ')}\n\n`));
15
+ process.exit(1);
16
+ }
17
+
8
18
  try {
19
+ // Start the MCP server (communicates via stdio)
9
20
  const { startServer } = require('../mcp/server');
10
21
  if (!options.quiet) {
11
22
  process.stderr.write(chalk.green('📖 Lore MCP server starting on stdio\n'));
12
23
  process.stderr.write(chalk.cyan(' Add to Claude Code settings: { "command": "lore serve", "args": [] }\n'));
13
24
  }
14
25
  await startServer();
26
+
27
+ // Also start the UI dashboard in the same process
28
+ const uiPort = options.port || 3333;
29
+ try {
30
+ const { startDashboard } = require('./ui');
31
+ startDashboard(uiPort);
32
+ } catch (e) {
33
+ process.stderr.write(chalk.yellow(`⚠ Could not start UI dashboard: ${e.message}\n`));
34
+ }
15
35
  } catch (e) {
16
36
  process.stderr.write(chalk.red(`Failed to start server: ${e.message}\n`));
17
37
  process.exit(1);
@@ -19,3 +39,4 @@ async function serve(options) {
19
39
  }
20
40
 
21
41
  module.exports = serve;
42
+
@@ -8,6 +8,7 @@ const { readIndex, LORE_DIR } = require('../lib/index');
8
8
  const { readEntry } = require('../lib/entries');
9
9
  const { computeScore } = require('../lib/scorer');
10
10
  const { listDrafts, acceptDraft, deleteDraft } = require('../lib/drafts');
11
+ const { getServerStatus } = require('../mcp/status');
11
12
 
12
13
  // Only load 'open' dynamically to avoid overhead on other CLI commands if not needed
13
14
  async function openBrowser(url) {
@@ -15,11 +16,9 @@ async function openBrowser(url) {
15
16
  await open(url);
16
17
  }
17
18
 
18
- function ui(options) {
19
- requireInit();
20
-
19
+ function createApp(portNum) {
21
20
  const app = express();
22
- const PORT = options.port || 3333;
21
+ const PORT = portNum || 3333;
23
22
 
24
23
  app.use(express.json());
25
24
 
@@ -56,6 +55,18 @@ function ui(options) {
56
55
  }
57
56
  });
58
57
 
58
+ app.get('/api/mcp-status', (req, res) => {
59
+ try {
60
+ const status = getServerStatus();
61
+ res.json({
62
+ active: !!status,
63
+ ...(status || {}),
64
+ });
65
+ } catch (e) {
66
+ res.json({ active: false });
67
+ }
68
+ });
69
+
59
70
  app.get('/api/entries', (req, res) => {
60
71
  try {
61
72
  const index = readIndex();
@@ -166,6 +177,23 @@ function ui(options) {
166
177
  process.exit(1);
167
178
  }
168
179
  });
180
+
181
+ return server;
182
+ }
183
+
184
+ /**
185
+ * Start just the dashboard Express server (called from serve.js).
186
+ * @param {number} port
187
+ * @returns {object} Express server instance
188
+ */
189
+ function startDashboard(port) {
190
+ return createApp(port || 3333);
191
+ }
192
+
193
+ function ui(options) {
194
+ requireInit();
195
+ createApp(options.port || 3333);
169
196
  }
170
197
 
171
198
  module.exports = ui;
199
+ module.exports.startDashboard = startDashboard;
package/src/lib/config.js CHANGED
@@ -14,6 +14,7 @@ const DEFAULTS = {
14
14
  },
15
15
  mcp: {
16
16
  tokenBudget: 4000,
17
+ confirmEntries: true,
17
18
  },
18
19
  };
19
20
 
package/src/lib/drafts.js CHANGED
@@ -44,41 +44,82 @@ function listDrafts() {
44
44
  }
45
45
 
46
46
  /**
47
- * Promote a draft to a real Lore entry.
47
+ * Promote a draft to a real Lore entry, or apply it as an update to an existing entry.
48
48
  * @param {string} draftId
49
- * @returns {object} the created entry
49
+ * @returns {object} the created or updated entry
50
50
  */
51
51
  function acceptDraft(draftId) {
52
52
  const draftPath = path.join(DRAFTS_DIR(), `${draftId}.json`);
53
53
  const draft = fs.readJsonSync(draftPath);
54
54
 
55
- const type = draft.suggestedType || 'decision';
56
- const title = draft.suggestedTitle || 'Untitled';
57
- const id = generateId(type, title);
58
-
59
- const entry = {
60
- id,
61
- type,
62
- title,
63
- context: draft.evidence || '',
64
- files: draft.files || [],
65
- tags: draft.tags || [],
66
- alternatives: [],
67
- tradeoffs: '',
68
- date: new Date().toISOString(),
69
- };
70
-
71
- writeEntry(entry);
72
- const index = readIndex();
73
- addEntryToIndex(index, entry);
74
- writeIndex(index);
75
-
76
- // Auto-embed if Ollama available
77
- try {
78
- const { generateEmbedding, storeEmbedding } = require('./embeddings');
79
- const text = [title, entry.context, ...entry.tags].join(' ');
80
- generateEmbedding(text).then(vec => storeEmbedding(id, vec)).catch(() => {});
81
- } catch (e) {}
55
+ let entry;
56
+
57
+ if (draft.updatesEntryId) {
58
+ // This draft is an update to an existing entry — modify in place
59
+ const index = readIndex();
60
+ const existingPath = index.entries[draft.updatesEntryId];
61
+ if (!existingPath) {
62
+ throw new Error(`Original entry "${draft.updatesEntryId}" not found. It may have been deleted.`);
63
+ }
64
+
65
+ const { readEntry: readExisting } = require('./entries');
66
+ entry = readExisting(existingPath);
67
+ if (!entry) {
68
+ throw new Error(`Failed to read original entry "${draft.updatesEntryId}".`);
69
+ }
70
+
71
+ // Apply updates from draft — only override fields that have meaningful values
72
+ if (draft.suggestedTitle && draft.suggestedTitle !== entry.title) entry.title = draft.suggestedTitle;
73
+ if (draft.evidence && !draft.evidence.startsWith('[UPDATE to ')) entry.context = draft.evidence;
74
+ else if (draft.evidence) {
75
+ // Strip the "[UPDATE to xxx] " prefix from the context
76
+ entry.context = draft.evidence.replace(/^\[UPDATE to [^\]]+\]\s*/, '');
77
+ }
78
+ if (draft.files && draft.files.length > 0) entry.files = draft.files;
79
+ if (draft.tags && draft.tags.length > 0) entry.tags = draft.tags;
80
+ if (draft.alternatives && draft.alternatives.length > 0) entry.alternatives = draft.alternatives;
81
+ if (draft.tradeoffs) entry.tradeoffs = draft.tradeoffs;
82
+ entry.lastUpdated = new Date().toISOString();
83
+
84
+ writeEntry(entry);
85
+ writeIndex(index);
86
+
87
+ // Re-embed
88
+ try {
89
+ const { generateEmbedding, storeEmbedding } = require('./embeddings');
90
+ const text = [entry.title, entry.context, ...(entry.alternatives || []), entry.tradeoffs || '', ...(entry.tags || [])].join(' ');
91
+ generateEmbedding(text).then(vec => storeEmbedding(entry.id, vec)).catch(() => {});
92
+ } catch (e) {}
93
+ } else {
94
+ // Standard draft → new entry
95
+ const type = draft.suggestedType || 'decision';
96
+ const title = draft.suggestedTitle || 'Untitled';
97
+ const id = generateId(type, title);
98
+
99
+ entry = {
100
+ id,
101
+ type,
102
+ title,
103
+ context: draft.evidence || '',
104
+ files: draft.files || [],
105
+ tags: draft.tags || [],
106
+ alternatives: draft.alternatives || [],
107
+ tradeoffs: draft.tradeoffs || '',
108
+ date: new Date().toISOString(),
109
+ };
110
+
111
+ writeEntry(entry);
112
+ const index = readIndex();
113
+ addEntryToIndex(index, entry);
114
+ writeIndex(index);
115
+
116
+ // Auto-embed if Ollama available
117
+ try {
118
+ const { generateEmbedding, storeEmbedding } = require('./embeddings');
119
+ const text = [title, entry.context, ...entry.alternatives, entry.tradeoffs, ...entry.tags].join(' ');
120
+ generateEmbedding(text).then(vec => storeEmbedding(id, vec)).catch(() => {});
121
+ } catch (e) {}
122
+ }
82
123
 
83
124
  fs.removeSync(draftPath);
84
125
  return entry;
@@ -50,10 +50,71 @@ function readAllEntries(index) {
50
50
  return entries;
51
51
  }
52
52
 
53
+ /**
54
+ * Check if a similar entry or draft already exists.
55
+ * @param {object} index - The lore index
56
+ * @param {string} type - Entry type
57
+ * @param {string} title - Entry title
58
+ * @returns {{ match: 'exact'|'fuzzy', entry: object, source: 'entry'|'draft' }|null}
59
+ */
60
+ function findDuplicate(index, type, title) {
61
+ const normalizedTitle = title.toLowerCase().trim();
62
+ const titleWords = new Set(normalizedTitle.split(/\s+/).filter(w => w.length > 2));
63
+
64
+ function checkTitle(candidateTitle) {
65
+ const candidate = (candidateTitle || '').toLowerCase().trim();
66
+
67
+ // Exact match (case-insensitive)
68
+ if (candidate === normalizedTitle) return 'exact';
69
+
70
+ // Fuzzy match: ≥60% word overlap
71
+ if (titleWords.size > 0) {
72
+ const candidateWords = new Set(candidate.split(/\s+/).filter(w => w.length > 2));
73
+ if (candidateWords.size === 0) return null;
74
+
75
+ let overlap = 0;
76
+ for (const w of titleWords) {
77
+ if (candidateWords.has(w)) overlap++;
78
+ }
79
+
80
+ const similarity = overlap / Math.max(titleWords.size, candidateWords.size);
81
+ if (similarity >= 0.6) return 'fuzzy';
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ // Check approved entries
88
+ for (const entryPath of Object.values(index.entries)) {
89
+ const entry = readEntry(entryPath);
90
+ if (!entry || entry.type !== type) continue;
91
+
92
+ const match = checkTitle(entry.title);
93
+ if (match) return { match, entry, source: 'entry' };
94
+ }
95
+
96
+ // Check pending drafts
97
+ try {
98
+ const { listDrafts } = require('./drafts');
99
+ const drafts = listDrafts();
100
+ for (const draft of drafts) {
101
+ if (draft.suggestedType !== type) continue;
102
+
103
+ const match = checkTitle(draft.suggestedTitle);
104
+ if (match) return { match, entry: { id: draft.draftId, title: draft.suggestedTitle, type: draft.suggestedType }, source: 'draft' };
105
+ }
106
+ } catch (e) {
107
+ // drafts module not available — skip
108
+ }
109
+
110
+ return null;
111
+ }
112
+
53
113
  module.exports = {
54
114
  getEntryPath,
55
115
  readEntry,
56
116
  writeEntry,
57
117
  generateId,
58
118
  readAllEntries,
119
+ findDuplicate,
59
120
  };
package/src/mcp/server.js CHANGED
@@ -10,15 +10,17 @@ const {
10
10
  const why = require('./tools/why');
11
11
  const search = require('./tools/search');
12
12
  const log = require('./tools/log');
13
+ const update = require('./tools/update');
13
14
  const stale = require('./tools/stale');
14
15
  const overview = require('./tools/overview');
15
16
  const drafts = require('./tools/drafts');
17
+ const { announceStartup } = require('./status');
16
18
 
17
- const TOOLS = [why, search, log, stale, overview, drafts];
19
+ const TOOLS = [why, search, log, update, stale, overview, drafts];
18
20
 
19
21
  async function startServer() {
20
22
  const server = new Server(
21
- { name: 'lore', version: '0.3.0' },
23
+ { name: 'lore', version: '0.4.0' },
22
24
  { capabilities: { tools: {} } }
23
25
  );
24
26
 
@@ -45,8 +47,12 @@ async function startServer() {
45
47
  const transport = new StdioServerTransport();
46
48
  await server.connect(transport);
47
49
 
50
+ announceStartup(TOOLS);
51
+
48
52
  // Keep process alive
49
53
  process.stdin.resume();
50
54
  }
51
55
 
52
56
  module.exports = { startServer };
57
+
58
+
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+
6
+ const LORE_DIR = path.join(process.cwd(), '.lore');
7
+ const PID_FILE = path.join(LORE_DIR, 'mcp.pid');
8
+
9
+ /**
10
+ * Write a status file so other tools can detect if the MCP server is running.
11
+ */
12
+ function writePidFile(tools) {
13
+ try {
14
+ fs.ensureDirSync(LORE_DIR);
15
+ fs.writeJsonSync(PID_FILE, {
16
+ pid: process.pid,
17
+ startedAt: new Date().toISOString(),
18
+ tools: tools.map(t => t.toolDefinition.name),
19
+ }, { spaces: 2 });
20
+ } catch (e) {
21
+ // Non-fatal
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Remove the status file on shutdown.
27
+ */
28
+ function removePidFile() {
29
+ try { fs.removeSync(PID_FILE); } catch (e) {}
30
+ }
31
+
32
+ /**
33
+ * Print startup banner to stderr (safe — MCP only reads stdout).
34
+ * Register cleanup handlers.
35
+ */
36
+ function announceStartup(tools) {
37
+ writePidFile(tools);
38
+
39
+ const toolNames = tools.map(t => t.toolDefinition.name).join(', ');
40
+ process.stderr.write(`\n📖 Lore MCP server active (PID: ${process.pid})\n`);
41
+ process.stderr.write(` ${tools.length} tools available: ${toolNames}\n`);
42
+ process.stderr.write(` Serving project: ${path.basename(process.cwd())}\n\n`);
43
+
44
+ process.on('SIGINT', () => { removePidFile(); process.exit(0); });
45
+ process.on('SIGTERM', () => { removePidFile(); process.exit(0); });
46
+ process.on('exit', removePidFile);
47
+ }
48
+
49
+ /**
50
+ * Check if the MCP server is currently running.
51
+ * @returns {{ pid: number, startedAt: string, tools: string[] }|null}
52
+ */
53
+ function getServerStatus() {
54
+ try {
55
+ if (!fs.existsSync(PID_FILE)) return null;
56
+ const info = fs.readJsonSync(PID_FILE);
57
+
58
+ // Verify the process is actually alive
59
+ try {
60
+ process.kill(info.pid, 0); // Signal 0 = just check if process exists
61
+ return info;
62
+ } catch (e) {
63
+ // Process is dead, clean up stale PID file
64
+ removePidFile();
65
+ return null;
66
+ }
67
+ } catch (e) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ module.exports = { announceStartup, getServerStatus };
@@ -1,11 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  const { readIndex, writeIndex, addEntryToIndex } = require('../../lib/index');
4
- const { generateId, writeEntry } = require('../../lib/entries');
4
+ const { generateId, writeEntry, findDuplicate } = require('../../lib/entries');
5
+ const { readConfig } = require('../../lib/config');
6
+ const { saveDraft } = require('../../lib/drafts');
5
7
 
6
8
  const toolDefinition = {
7
9
  name: 'lore_log',
8
- description: 'Create a new Lore entry to record an architectural decision, invariant, gotcha, or graveyard item. Use this when you make a significant technical decision that future developers (or AI) should know about.',
10
+ description: 'Create a new Lore entry to record an architectural decision, invariant, gotcha, or graveyard item. Use this when you make a significant technical decision that future developers (or AI) should know about. Note: entries may be saved as drafts pending human review depending on project configuration.',
9
11
  inputSchema: {
10
12
  type: 'object',
11
13
  properties: {
@@ -50,6 +52,40 @@ async function handler(args) {
50
52
  const { type, title, context, files = [], tags = [], alternatives = [], tradeoffs = '' } = args;
51
53
 
52
54
  try {
55
+ const index = readIndex();
56
+
57
+ // Deduplication check
58
+ const duplicate = findDuplicate(index, type, title);
59
+ if (duplicate) {
60
+ const matchLabel = duplicate.match === 'exact' ? 'Exact duplicate' : 'Similar entry';
61
+ return {
62
+ content: [{ type: 'text', text: `${matchLabel} already exists: "${duplicate.entry.title}" (${duplicate.entry.id}). No new entry created.` }],
63
+ };
64
+ }
65
+
66
+ const config = readConfig();
67
+ const requireConfirmation = config.mcp && config.mcp.confirmEntries !== false;
68
+
69
+ if (requireConfirmation) {
70
+ // Route to drafts for human review
71
+ const draftId = saveDraft({
72
+ suggestedType: type,
73
+ suggestedTitle: title,
74
+ evidence: context,
75
+ files,
76
+ tags,
77
+ alternatives,
78
+ tradeoffs,
79
+ confidence: 0.9,
80
+ source: 'mcp-log',
81
+ });
82
+
83
+ return {
84
+ content: [{ type: 'text', text: `Draft created for human review: "${title}" (${draftId}). The developer can approve it with: lore drafts` }],
85
+ };
86
+ }
87
+
88
+ // Direct entry creation (confirmEntries is explicitly false)
53
89
  const id = generateId(type, title);
54
90
  const entry = {
55
91
  id,
@@ -64,8 +100,6 @@ async function handler(args) {
64
100
  };
65
101
 
66
102
  writeEntry(entry);
67
-
68
- const index = readIndex();
69
103
  addEntryToIndex(index, entry);
70
104
  writeIndex(index);
71
105
 
@@ -91,3 +125,4 @@ async function handler(args) {
91
125
  }
92
126
 
93
127
  module.exports = { toolDefinition, handler };
128
+
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const { readIndex, writeIndex } = require('../../lib/index');
4
+ const { readEntry, writeEntry } = require('../../lib/entries');
5
+ const { saveDraft } = require('../../lib/drafts');
6
+ const { readConfig } = require('../../lib/config');
7
+
8
+ const toolDefinition = {
9
+ name: 'lore_update',
10
+ description: 'Update an existing Lore entry. Use this when a previous decision, invariant, gotcha, or graveyard item needs to be revised because the context has changed. Updates may be saved as drafts pending human review depending on project configuration.',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ id: {
15
+ type: 'string',
16
+ description: 'The ID of the existing entry to update (e.g. "decision-use-postgres-1709876543")',
17
+ },
18
+ context: {
19
+ type: 'string',
20
+ description: 'Updated context/explanation. If provided, replaces the existing context.',
21
+ },
22
+ title: {
23
+ type: 'string',
24
+ description: 'Updated title. If provided, replaces the existing title.',
25
+ },
26
+ files: {
27
+ type: 'array',
28
+ items: { type: 'string' },
29
+ description: 'Updated list of related files. If provided, replaces the existing file list.',
30
+ },
31
+ tags: {
32
+ type: 'array',
33
+ items: { type: 'string' },
34
+ description: 'Updated tags. If provided, replaces existing tags.',
35
+ },
36
+ alternatives: {
37
+ type: 'array',
38
+ items: { type: 'string' },
39
+ description: 'Updated alternatives considered.',
40
+ },
41
+ tradeoffs: {
42
+ type: 'string',
43
+ description: 'Updated tradeoffs.',
44
+ },
45
+ },
46
+ required: ['id'],
47
+ },
48
+ };
49
+
50
+ async function handler(args) {
51
+ const { id, context, title, files, tags, alternatives, tradeoffs } = args;
52
+
53
+ try {
54
+ const index = readIndex();
55
+ const entryPath = index.entries[id];
56
+
57
+ if (!entryPath) {
58
+ return {
59
+ content: [{ type: 'text', text: `Entry not found: "${id}". Use lore_search to find the correct entry ID.` }],
60
+ isError: true,
61
+ };
62
+ }
63
+
64
+ const entry = readEntry(entryPath);
65
+ if (!entry) {
66
+ return {
67
+ content: [{ type: 'text', text: `Failed to read entry: "${id}".` }],
68
+ isError: true,
69
+ };
70
+ }
71
+
72
+ const config = readConfig();
73
+ const requireConfirmation = config.mcp && config.mcp.confirmEntries !== false;
74
+
75
+ // Build a summary of what's changing
76
+ const changes = [];
77
+ if (title !== undefined && title !== entry.title) changes.push(`title: "${entry.title}" → "${title}"`);
78
+ if (context !== undefined && context !== entry.context) changes.push('context updated');
79
+ if (files !== undefined) changes.push(`files: [${files.join(', ')}]`);
80
+ if (tags !== undefined) changes.push(`tags: [${tags.join(', ')}]`);
81
+ if (alternatives !== undefined) changes.push(`alternatives: [${alternatives.join(', ')}]`);
82
+ if (tradeoffs !== undefined) changes.push('tradeoffs updated');
83
+
84
+ if (changes.length === 0) {
85
+ return {
86
+ content: [{ type: 'text', text: `No changes provided for entry "${id}".` }],
87
+ };
88
+ }
89
+
90
+ if (requireConfirmation) {
91
+ // Route update through drafts for human review
92
+ const draftId = saveDraft({
93
+ suggestedType: entry.type,
94
+ suggestedTitle: title || entry.title,
95
+ evidence: `[UPDATE to ${id}] ${context || entry.context}`,
96
+ files: files || entry.files,
97
+ tags: tags || entry.tags,
98
+ alternatives: alternatives || entry.alternatives,
99
+ tradeoffs: tradeoffs !== undefined ? tradeoffs : entry.tradeoffs,
100
+ confidence: 0.95,
101
+ source: 'mcp-update',
102
+ updatesEntryId: id,
103
+ });
104
+
105
+ return {
106
+ content: [{ type: 'text', text: `Update drafted for human review (${draftId}). Changes: ${changes.join('; ')}. The developer can approve it with: lore drafts` }],
107
+ };
108
+ }
109
+
110
+ // Direct update (confirmEntries is explicitly false)
111
+ if (title !== undefined) entry.title = title;
112
+ if (context !== undefined) entry.context = context;
113
+ if (files !== undefined) entry.files = files;
114
+ if (tags !== undefined) entry.tags = tags;
115
+ if (alternatives !== undefined) entry.alternatives = alternatives;
116
+ if (tradeoffs !== undefined) entry.tradeoffs = tradeoffs;
117
+ entry.lastUpdated = new Date().toISOString();
118
+
119
+ writeEntry(entry);
120
+ writeIndex(index);
121
+
122
+ // Re-embed
123
+ try {
124
+ const { generateEmbedding, storeEmbedding } = require('../../lib/embeddings');
125
+ const text = [entry.title, entry.context, ...(entry.alternatives || []), entry.tradeoffs || '', ...(entry.tags || [])].join(' ');
126
+ const vector = await generateEmbedding(text);
127
+ storeEmbedding(id, vector);
128
+ } catch (e) {
129
+ // Ollama not available — skip
130
+ }
131
+
132
+ return {
133
+ content: [{ type: 'text', text: `Updated entry "${id}". Changes: ${changes.join('; ')}` }],
134
+ };
135
+ } catch (e) {
136
+ return {
137
+ content: [{ type: 'text', text: `Error updating entry: ${e.message}` }],
138
+ isError: true,
139
+ };
140
+ }
141
+ }
142
+
143
+ module.exports = { toolDefinition, handler };