lore-memory 0.4.0 → 0.5.1
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 +2 -1
- package/package.json +1 -1
- package/src/commands/init.js +1 -41
- package/src/commands/log.js +27 -1
- package/src/commands/serve.js +23 -0
- package/src/commands/ui.js +60 -20
- package/src/lib/config.js +1 -0
- package/src/lib/drafts.js +70 -29
- package/src/lib/entries.js +61 -0
- package/src/mcp/server.js +8 -2
- package/src/mcp/status.js +72 -0
- package/src/mcp/tools/log.js +39 -4
- package/src/mcp/tools/update.js +143 -0
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.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
package/src/commands/init.js
CHANGED
|
@@ -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'));
|
package/src/commands/log.js
CHANGED
|
@@ -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,
|
package/src/commands/serve.js
CHANGED
|
@@ -2,16 +2,38 @@
|
|
|
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
|
+
// Pass quiet: true to prevent stdout corruption of the MCP protocol stream
|
|
32
|
+
// and prevent the UI from calling process.exit() if the port is in use.
|
|
33
|
+
startDashboard(uiPort, { quiet: true, openBrowser: false });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
process.stderr.write(chalk.yellow(`⚠ Could not start UI dashboard: ${e.message}\n`));
|
|
36
|
+
}
|
|
15
37
|
} catch (e) {
|
|
16
38
|
process.stderr.write(chalk.red(`Failed to start server: ${e.message}\n`));
|
|
17
39
|
process.exit(1);
|
|
@@ -19,3 +41,4 @@ async function serve(options) {
|
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
module.exports = serve;
|
|
44
|
+
|
package/src/commands/ui.js
CHANGED
|
@@ -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
|
|
19
|
-
requireInit();
|
|
20
|
-
|
|
19
|
+
function createApp(portNum, options = {}) {
|
|
21
20
|
const app = express();
|
|
22
|
-
const PORT =
|
|
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();
|
|
@@ -142,30 +153,59 @@ function ui(options) {
|
|
|
142
153
|
});
|
|
143
154
|
|
|
144
155
|
const server = app.listen(PORT, () => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
if (!options.quiet) {
|
|
157
|
+
const url = `http://localhost:${PORT}`;
|
|
158
|
+
console.log(chalk.green(`\n🚀 Lore UI Dashboard running at ${chalk.bold(url)}\n`));
|
|
159
|
+
console.log(chalk.cyan(` Press Ctrl+C to stop the server.`));
|
|
160
|
+
|
|
161
|
+
// Use native exec to open browser to avoid ESM import issues with 'open'
|
|
162
|
+
if (options.openBrowser !== false) {
|
|
163
|
+
const { exec } = require('child_process');
|
|
164
|
+
const startPath = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
165
|
+
exec(`${startPath} ${url}`, (err) => {
|
|
166
|
+
if (err) {
|
|
167
|
+
console.log(chalk.dim(` (Could not open browser automatically. Please visit ${url} manually)`));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
155
170
|
}
|
|
156
|
-
}
|
|
171
|
+
}
|
|
157
172
|
});
|
|
158
173
|
|
|
159
174
|
server.on('error', (e) => {
|
|
160
175
|
if (e.code === 'EADDRINUSE') {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
if (!options.quiet) {
|
|
177
|
+
console.error(chalk.red(`\nPort ${PORT} is already in use by another process.`));
|
|
178
|
+
console.error(chalk.yellow(`Use 'lore ui --port <number>' to specify a different port.\n`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
} else {
|
|
181
|
+
process.stderr.write(chalk.yellow(`\n⚠ Lore UI dashboard port ${PORT} is in use; dashboard disabled for this instance.\n`));
|
|
182
|
+
}
|
|
164
183
|
} else {
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
if (!options.quiet) {
|
|
185
|
+
console.error(chalk.red(`\nFailed to start server: ${e.message}\n`));
|
|
186
|
+
process.exit(1);
|
|
187
|
+
} else {
|
|
188
|
+
process.stderr.write(chalk.red(`\n⚠ Failed to start Lore UI dashboard: ${e.message}\n`));
|
|
189
|
+
}
|
|
167
190
|
}
|
|
168
191
|
});
|
|
192
|
+
|
|
193
|
+
return server;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Start just the dashboard Express server (called from serve.js).
|
|
198
|
+
* @param {number} port
|
|
199
|
+
* @returns {object} Express server instance
|
|
200
|
+
*/
|
|
201
|
+
function startDashboard(port, options = {}) {
|
|
202
|
+
return createApp(port || 3333, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ui(options) {
|
|
206
|
+
requireInit();
|
|
207
|
+
createApp(options.port || 3333, { quiet: false, openBrowser: true });
|
|
169
208
|
}
|
|
170
209
|
|
|
171
210
|
module.exports = ui;
|
|
211
|
+
module.exports.startDashboard = startDashboard;
|
package/src/lib/config.js
CHANGED
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
package/src/lib/entries.js
CHANGED
|
@@ -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.
|
|
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 };
|
package/src/mcp/tools/log.js
CHANGED
|
@@ -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 };
|