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/README.md +54 -564
- package/bin/lore.js +24 -3
- package/package.json +1 -1
- package/src/commands/init.js +2 -25
- package/src/commands/log.js +27 -1
- package/src/commands/onboard.js +3 -2
- package/src/commands/prompt.js +63 -0
- package/src/commands/search.js +8 -1
- package/src/commands/serve.js +21 -0
- package/src/commands/stale.js +1 -1
- package/src/commands/ui.js +32 -4
- package/src/lib/config.js +1 -0
- package/src/lib/drafts.js +70 -29
- package/src/lib/entries.js +61 -0
- package/src/lib/format.js +45 -2
- package/src/lib/relevance.js +3 -1
- 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/search.js +1 -2
- package/src/mcp/tools/update.js +143 -0
- package/src/watcher/comments.js +13 -2
package/bin/lore.js
CHANGED
|
@@ -8,7 +8,7 @@ const program = new Command();
|
|
|
8
8
|
program
|
|
9
9
|
.name('lore')
|
|
10
10
|
.description('Persistent project memory for developers')
|
|
11
|
-
.version('
|
|
11
|
+
.version(require('../package.json').version)
|
|
12
12
|
.action(async () => {
|
|
13
13
|
// Only launch the interactive menu if strictly NO arguments were provided
|
|
14
14
|
if (process.argv.length !== 2) return;
|
|
@@ -39,6 +39,7 @@ program
|
|
|
39
39
|
{ name: '👀 Review pending drafts (lore drafts)', value: 'drafts' },
|
|
40
40
|
{ name: '📊 View project health (lore score)', value: 'score' },
|
|
41
41
|
{ name: '🔍 Search knowledge base (lore search)', value: 'search' },
|
|
42
|
+
{ name: '🪄 Generate AI Prompt (lore prompt)', value: 'prompt' },
|
|
42
43
|
{ name: '🌐 Open Local Web Dashboard (lore ui)', value: 'ui' },
|
|
43
44
|
{ name: '⚙️ Start background watcher (lore watch --daemon)', value: 'watch --daemon' },
|
|
44
45
|
new inquirer.Separator(),
|
|
@@ -52,6 +53,18 @@ program
|
|
|
52
53
|
process.exit(0);
|
|
53
54
|
} else if (action === 'help') {
|
|
54
55
|
program.outputHelp();
|
|
56
|
+
} else if (action === 'prompt') {
|
|
57
|
+
const { query } = await inquirer.prompt([{
|
|
58
|
+
type: 'input',
|
|
59
|
+
name: 'query',
|
|
60
|
+
message: 'What are you trying to build or refactor? (e.g. "Add a login page")'
|
|
61
|
+
}]);
|
|
62
|
+
console.log();
|
|
63
|
+
if (query.trim()) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`node ${__filename} prompt "${query.replace(/"/g, '\\"')}"`, { stdio: 'inherit' });
|
|
66
|
+
} catch (e) { }
|
|
67
|
+
}
|
|
55
68
|
} else {
|
|
56
69
|
console.log();
|
|
57
70
|
try {
|
|
@@ -135,7 +148,7 @@ program
|
|
|
135
148
|
.action(require('../src/commands/stale'));
|
|
136
149
|
|
|
137
150
|
program
|
|
138
|
-
.command('search
|
|
151
|
+
.command('search [query...]')
|
|
139
152
|
.description('Search all entries for a keyword')
|
|
140
153
|
.action(require('../src/commands/search'));
|
|
141
154
|
|
|
@@ -151,8 +164,9 @@ program
|
|
|
151
164
|
|
|
152
165
|
program
|
|
153
166
|
.command('serve')
|
|
154
|
-
.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')
|
|
155
168
|
.option('-q, --quiet', 'Suppress startup messages (use when piped into MCP client)')
|
|
169
|
+
.option('-p, --port <port>', 'Port for the UI dashboard', '3333')
|
|
156
170
|
.action(require('../src/commands/serve'));
|
|
157
171
|
|
|
158
172
|
program
|
|
@@ -202,4 +216,11 @@ program
|
|
|
202
216
|
.option('-p, --port <port>', 'Port to run the UI server on', '3333')
|
|
203
217
|
.action(require('../src/commands/ui'));
|
|
204
218
|
|
|
219
|
+
program
|
|
220
|
+
.command('prompt [query...]')
|
|
221
|
+
.description('Generate a perfectly formatted LLM context prompt from project memory')
|
|
222
|
+
.option('-t, --threshold <number>', 'Relevance threshold (0.0 to 1.0)', '0.4')
|
|
223
|
+
.option('-l, --limit <number>', 'Max number of entries to include', '10')
|
|
224
|
+
.action(require('../src/commands/prompt'));
|
|
225
|
+
|
|
205
226
|
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lore-memory",
|
|
3
|
-
"version": "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": {
|
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,16 +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
|
-
await fs.writeFile(hookPath, HOOK_CONTENT);
|
|
71
|
-
await fs.chmod(hookPath, '755');
|
|
72
|
-
console.log(chalk.green('✓ Git post-commit hook installed'));
|
|
73
|
-
} else {
|
|
74
|
-
console.log(chalk.yellow('⚠ Not a git repo — hook not installed'));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
54
|
console.log(chalk.green(`✓ Lore initialized at ${LORE_DIR}/`));
|
|
78
55
|
console.log(chalk.cyan(' Run: lore mine . to scan codebase for lore-worthy comments'));
|
|
79
56
|
console.log(chalk.cyan(' Run: lore log to create your first entry manually'));
|
|
@@ -95,7 +72,7 @@ async function init() {
|
|
|
95
72
|
console.log(chalk.dim('\n Scanning codebase for lore-worthy comments...'));
|
|
96
73
|
try {
|
|
97
74
|
const { mineDirectory } = require('../watcher/comments');
|
|
98
|
-
const count = mineDirectory(process.cwd(), process.cwd());
|
|
75
|
+
const count = await mineDirectory(process.cwd(), process.cwd());
|
|
99
76
|
if (count > 0) {
|
|
100
77
|
console.log(chalk.cyan(` Found ${count} draft entr${count === 1 ? 'y' : 'ies'} — run: lore drafts`));
|
|
101
78
|
}
|
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/onboard.js
CHANGED
|
@@ -58,8 +58,9 @@ function onboard(options) {
|
|
|
58
58
|
for (const e of byType.decision.slice(0, 5)) {
|
|
59
59
|
console.log(chalk.white(` • ${e.title}`));
|
|
60
60
|
if (e.context) {
|
|
61
|
-
const
|
|
62
|
-
|
|
61
|
+
const firstLine = e.context.split('\n')[0];
|
|
62
|
+
const summary = firstLine.slice(0, 80);
|
|
63
|
+
console.log(chalk.dim(` ${summary}${firstLine.length > 80 ? '…' : ''}`));
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
if (byType.decision.length > 5) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { requireInit } = require('../lib/guard');
|
|
5
|
+
const { findSimilar } = require('../lib/embeddings');
|
|
6
|
+
const { readIndex } = require('../lib/index');
|
|
7
|
+
const { readEntry } = require('../lib/entries');
|
|
8
|
+
const { formatPromptContext } = require('../lib/format');
|
|
9
|
+
|
|
10
|
+
async function promptCmd(queryArgs, options) {
|
|
11
|
+
requireInit();
|
|
12
|
+
|
|
13
|
+
const queryInfo = Array.isArray(queryArgs) ? queryArgs.join(' ') : (queryArgs || '');
|
|
14
|
+
|
|
15
|
+
if (!queryInfo || queryInfo.trim().length === 0) {
|
|
16
|
+
console.error(chalk.red('\nError: You must provide a query to generate a prompt.'));
|
|
17
|
+
console.log(chalk.yellow('Example: lore prompt "I want to refactor the database"'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const query = queryInfo;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const threshold = parseFloat(options.threshold) || 0.4;
|
|
25
|
+
const limit = parseInt(options.limit, 10) || 10;
|
|
26
|
+
|
|
27
|
+
const index = readIndex();
|
|
28
|
+
const allIds = Object.keys(index.entries);
|
|
29
|
+
|
|
30
|
+
let results = [];
|
|
31
|
+
try {
|
|
32
|
+
const similar = await findSimilar(query, allIds, limit);
|
|
33
|
+
for (const { id, score } of similar) {
|
|
34
|
+
if (score >= threshold) {
|
|
35
|
+
const entry = readEntry(index.entries[id]);
|
|
36
|
+
if (entry) results.push(entry);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(chalk.yellow(`⚠️ Warning: Semantic search failed (${e.message}). Ensure Ollama is running.\nFallback text search will be used.`));
|
|
41
|
+
// Basic text fallback
|
|
42
|
+
const q = query.toLowerCase();
|
|
43
|
+
for (const id of allIds) {
|
|
44
|
+
const entry = readEntry(index.entries[id]);
|
|
45
|
+
if (!entry) continue;
|
|
46
|
+
const searchable = [entry.title, entry.context].join(' ').toLowerCase();
|
|
47
|
+
if (searchable.includes(q)) results.push(entry);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The exact LLM-ready markdown string
|
|
52
|
+
const markdownPrompt = formatPromptContext(query, results);
|
|
53
|
+
|
|
54
|
+
// Print strictly to stdout without extra CLI fluff so it can be piped seamlessly
|
|
55
|
+
console.log(markdownPrompt);
|
|
56
|
+
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error(chalk.red(`\nFailed to generate prompt: ${e.message}\n`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = promptCmd;
|
package/src/commands/search.js
CHANGED
|
@@ -6,9 +6,16 @@ const { readEntry } = require('../lib/entries');
|
|
|
6
6
|
const { printEntry } = require('../lib/format');
|
|
7
7
|
const { requireInit } = require('../lib/guard');
|
|
8
8
|
|
|
9
|
-
function search(
|
|
9
|
+
function search(queryArgs) {
|
|
10
10
|
requireInit();
|
|
11
11
|
try {
|
|
12
|
+
const query = Array.isArray(queryArgs) ? queryArgs.join(' ') : (queryArgs || '');
|
|
13
|
+
|
|
14
|
+
if (!query || query.trim().length === 0) {
|
|
15
|
+
console.error(chalk.red('\nError: You must provide a search query.'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
const index = readIndex();
|
|
13
20
|
const q = query.toLowerCase();
|
|
14
21
|
const matches = [];
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
+
|
package/src/commands/stale.js
CHANGED
|
@@ -12,7 +12,7 @@ function stale() {
|
|
|
12
12
|
const index = readIndex();
|
|
13
13
|
let found = false;
|
|
14
14
|
|
|
15
|
-
for (const
|
|
15
|
+
for (const entryPath of Object.values(index.entries)) {
|
|
16
16
|
const entry = readEntry(entryPath);
|
|
17
17
|
if (!entry) continue;
|
|
18
18
|
|
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) {
|
|
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();
|
|
@@ -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
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/lib/format.js
CHANGED
|
@@ -41,7 +41,7 @@ function printEntry(entry) {
|
|
|
41
41
|
content += `${titleColor(typeLabel)} ${titleColor(entry.title)} ${chalk.gray('(' + entry.date + ')')}\n\n`;
|
|
42
42
|
|
|
43
43
|
// Wrap context text roughly
|
|
44
|
-
const contextWords = entry.context.split(' ');
|
|
44
|
+
const contextWords = (entry.context || '').split(' ');
|
|
45
45
|
let currentLine = '';
|
|
46
46
|
for (const word of contextWords) {
|
|
47
47
|
if (currentLine.length + word.length > 70) {
|
|
@@ -71,4 +71,47 @@ function printEntry(entry) {
|
|
|
71
71
|
drawBox(content.trim(), colorFn, entry.id);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
function formatPromptContext(query, entries) {
|
|
75
|
+
if (!entries || entries.length === 0) {
|
|
76
|
+
return `# Project Context: Lore\nThe user is asking about: "${query}"\n\nNo specific historical rules or decisions were found in the project memory for this topic.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const invariants = entries.filter(e => e.type === 'invariant');
|
|
80
|
+
const gotchas = entries.filter(e => e.type === 'gotcha');
|
|
81
|
+
const decisions = entries.filter(e => e.type === 'decision');
|
|
82
|
+
const graveyard = entries.filter(e => e.type === 'graveyard');
|
|
83
|
+
|
|
84
|
+
let output = `# Project Context: Lore\nThe user is asking: "${query}"\n\nPlease adhere strictly to the following project architectural rules extracted from project memory:\n`;
|
|
85
|
+
|
|
86
|
+
if (invariants.length > 0) {
|
|
87
|
+
output += `\n## Invariants (CRITICAL: DO NOT BREAK)\n`;
|
|
88
|
+
for (const e of invariants) {
|
|
89
|
+
output += `- [${e.id}]: ${(e.context || '').trim().replace(/\n/g, ' ')}\n`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (gotchas.length > 0) {
|
|
94
|
+
output += `\n## Gotchas (WARNING: KNOWN TRAPS)\n`;
|
|
95
|
+
for (const e of gotchas) {
|
|
96
|
+
output += `- [${e.id}]: ${(e.context || '').trim().replace(/\n/g, ' ')}\n`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (decisions.length > 0) {
|
|
101
|
+
output += `\n## Relevant Decisions\n`;
|
|
102
|
+
for (const e of decisions) {
|
|
103
|
+
output += `- [${e.id}]: ${e.title} - ${(e.context || '').trim().replace(/\n/g, ' ')}\n`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (graveyard.length > 0) {
|
|
108
|
+
output += `\n## Graveyard (DO NOT SUGGEST OR USE)\n`;
|
|
109
|
+
for (const e of graveyard) {
|
|
110
|
+
output += `- [${e.id}]: ${(e.context || '').trim().replace(/\n/g, ' ')}\n`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return output.trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { printEntry, formatPromptContext };
|
package/src/lib/relevance.js
CHANGED
|
@@ -21,19 +21,21 @@ function scoreEntry(entry, context) {
|
|
|
21
21
|
let score = 0;
|
|
22
22
|
|
|
23
23
|
// Direct file match
|
|
24
|
+
let exactMatchFound = false;
|
|
24
25
|
if (context.filepath && entry.files && entry.files.length > 0) {
|
|
25
26
|
const normalizedQuery = context.filepath.replace(/^\.\//, '');
|
|
26
27
|
for (const f of entry.files) {
|
|
27
28
|
const normalizedFile = f.replace(/^\.\//, '');
|
|
28
29
|
if (normalizedFile === normalizedQuery) {
|
|
29
30
|
score += WEIGHTS.directFileMatch;
|
|
31
|
+
exactMatchFound = true;
|
|
30
32
|
break;
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Parent dir match
|
|
36
|
-
if (context.filepath && entry.files && entry.files.length > 0) {
|
|
38
|
+
if (!exactMatchFound && context.filepath && entry.files && entry.files.length > 0) {
|
|
37
39
|
const queryDir = path.dirname(context.filepath.replace(/^\.\//, ''));
|
|
38
40
|
for (const f of entry.files) {
|
|
39
41
|
const fileDir = path.dirname(f.replace(/^\.\//, ''));
|