lore-memory 0.3.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/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
+
@@ -89,11 +89,10 @@ async function handler(args) {
89
89
  };
90
90
  }
91
91
 
92
- const formatted = matches.map(e => formatEntry(e)).join('\n\n---\n\n');
93
92
  const budgeted = enforceBudget(matches, budget);
94
93
 
95
94
  return {
96
- content: [{ type: 'text', text: budgeted || formatted }],
95
+ content: [{ type: 'text', text: budgeted || `Matches found, but they exceed the ${budget} token budget.` }],
97
96
  };
98
97
  } catch (e) {
99
98
  return {
@@ -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 };
@@ -3,7 +3,9 @@
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const { detectType, extractTitle, scoreComment } = require('../lib/nlp');
6
- const { saveDraft } = require('../lib/drafts');
6
+ const { saveDraft, listDrafts } = require('../lib/drafts');
7
+ const { readIndex } = require('../lib/index');
8
+ const { readEntry } = require('../lib/entries');
7
9
 
8
10
  /**
9
11
  * Extract raw comment strings from source code.
@@ -57,7 +59,11 @@ async function mineFile(absFilePath, projectRoot) {
57
59
  const comments = extractComments(code, absFilePath);
58
60
  const created = [];
59
61
 
60
- // Deduplicate: skip if we have a recent draft from same file with very similar title
62
+ const existingDrafts = listDrafts();
63
+ const index = readIndex();
64
+ const existingEntries = Object.values(index.entries).map(p => readEntry(p)).filter(Boolean);
65
+
66
+ // Deduplicate: skip if we have a recent draft or entry from same file with same title
61
67
  for (const comment of comments) {
62
68
  const score = scoreComment(comment);
63
69
  if (score < 0.5) continue;
@@ -66,6 +72,11 @@ async function mineFile(absFilePath, projectRoot) {
66
72
  const title = extractTitle(comment);
67
73
  if (!title || title.length < 3) continue;
68
74
 
75
+ const isDuplicateDraft = existingDrafts.some(d => d.suggestedTitle === title && (d.files || []).includes(relativePath));
76
+ const isDuplicateEntry = existingEntries.some(e => e.title === title && (e.files || []).includes(relativePath));
77
+
78
+ if (isDuplicateDraft || isDuplicateEntry) continue;
79
+
69
80
  const draft = {
70
81
  draftId: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
71
82
  suggestedType: type,