lore-memory 0.2.0 → 0.4.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
@@ -8,8 +8,11 @@ const program = new Command();
8
8
  program
9
9
  .name('lore')
10
10
  .description('Persistent project memory for developers')
11
- .version('0.1.0')
11
+ .version(require('../package.json').version)
12
12
  .action(async () => {
13
+ // Only launch the interactive menu if strictly NO arguments were provided
14
+ if (process.argv.length !== 2) return;
15
+
13
16
  const inquirer = require('inquirer');
14
17
  const chalk = require('chalk');
15
18
  const { execSync } = require('child_process');
@@ -36,6 +39,8 @@ program
36
39
  { name: 'šŸ‘€ Review pending drafts (lore drafts)', value: 'drafts' },
37
40
  { name: 'šŸ“Š View project health (lore score)', value: 'score' },
38
41
  { name: 'šŸ” Search knowledge base (lore search)', value: 'search' },
42
+ { name: 'šŸŖ„ Generate AI Prompt (lore prompt)', value: 'prompt' },
43
+ { name: '🌐 Open Local Web Dashboard (lore ui)', value: 'ui' },
39
44
  { name: 'āš™ļø Start background watcher (lore watch --daemon)', value: 'watch --daemon' },
40
45
  new inquirer.Separator(),
41
46
  { name: 'ā“ Show Help', value: 'help' },
@@ -48,6 +53,18 @@ program
48
53
  process.exit(0);
49
54
  } else if (action === 'help') {
50
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
+ }
51
68
  } else {
52
69
  console.log();
53
70
  try {
@@ -131,7 +148,7 @@ program
131
148
  .action(require('../src/commands/stale'));
132
149
 
133
150
  program
134
- .command('search <query>')
151
+ .command('search [query...]')
135
152
  .description('Search all entries for a keyword')
136
153
  .action(require('../src/commands/search'));
137
154
 
@@ -192,4 +209,17 @@ program
192
209
  .option('--build', 'Rebuild the full graph from source')
193
210
  .action(require('../src/commands/graph'));
194
211
 
212
+ program
213
+ .command('ui')
214
+ .description('Start the local Lore web dashboard')
215
+ .option('-p, --port <port>', 'Port to run the UI server on', '3333')
216
+ .action(require('../src/commands/ui'));
217
+
218
+ program
219
+ .command('prompt [query...]')
220
+ .description('Generate a perfectly formatted LLM context prompt from project memory')
221
+ .option('-t, --threshold <number>', 'Relevance threshold (0.0 to 1.0)', '0.4')
222
+ .option('-l, --limit <number>', 'Max number of entries to include', '10')
223
+ .action(require('../src/commands/prompt'));
224
+
195
225
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lore-memory",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {
@@ -43,11 +43,13 @@
43
43
  "chalk": "^4",
44
44
  "chokidar": "^3.6.0",
45
45
  "commander": "^11",
46
+ "express": "^5.2.1",
46
47
  "fs-extra": "^11",
47
48
  "glob": "^10.5.0",
48
49
  "inquirer": "^8",
49
50
  "js-yaml": "^4",
50
51
  "natural": "^6.12.0",
51
- "ollama": "^0.6.3"
52
+ "ollama": "^0.6.3",
53
+ "open": "^11.0.0"
52
54
  }
53
55
  }
@@ -67,9 +67,26 @@ async function init() {
67
67
  const hookDir = path.join('.git', 'hooks');
68
68
  if (fs.existsSync(hookDir)) {
69
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'));
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
+ }
73
90
  } else {
74
91
  console.log(chalk.yellow('⚠ Not a git repo — hook not installed'));
75
92
  }
@@ -95,7 +112,7 @@ async function init() {
95
112
  console.log(chalk.dim('\n Scanning codebase for lore-worthy comments...'));
96
113
  try {
97
114
  const { mineDirectory } = require('../watcher/comments');
98
- const count = mineDirectory(process.cwd(), process.cwd());
115
+ const count = await mineDirectory(process.cwd(), process.cwd());
99
116
  if (count > 0) {
100
117
  console.log(chalk.cyan(` Found ${count} draft entr${count === 1 ? 'y' : 'ies'} — run: lore drafts`));
101
118
  }
@@ -6,7 +6,7 @@ const fs = require('fs-extra');
6
6
  const { mineFile, mineDirectory } = require('../watcher/comments');
7
7
  const { requireInit } = require('../lib/guard');
8
8
 
9
- function mine(targetPath) {
9
+ async function mine(targetPath) {
10
10
  requireInit();
11
11
  const projectRoot = process.cwd();
12
12
  const target = targetPath || '.';
@@ -18,10 +18,11 @@ function mine(targetPath) {
18
18
 
19
19
  if (stat.isDirectory()) {
20
20
  console.log(chalk.cyan(`šŸ“– Mining comments in ${target} ...`));
21
- count = mineDirectory(abs, projectRoot);
21
+ count = await mineDirectory(abs, projectRoot);
22
22
  } else {
23
23
  console.log(chalk.cyan(`šŸ“– Mining comments in ${target} ...`));
24
- count = mineFile(abs, projectRoot).length;
24
+ const drafts = await mineFile(abs, projectRoot);
25
+ count = drafts.length;
25
26
  }
26
27
 
27
28
  if (count === 0) {
@@ -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 summary = e.context.split('\n')[0].slice(0, 80);
62
- console.log(chalk.dim(` ${summary}${e.context.length > 80 ? '…' : ''}`));
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;
@@ -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(query) {
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 = [];
@@ -12,7 +12,7 @@ function stale() {
12
12
  const index = readIndex();
13
13
  let found = false;
14
14
 
15
- for (const [id, entryPath] of Object.entries(index.entries)) {
15
+ for (const entryPath of Object.values(index.entries)) {
16
16
  const entry = readEntry(entryPath);
17
17
  if (!entry) continue;
18
18
 
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+ const { requireInit } = require('../lib/guard');
7
+ const { readIndex, LORE_DIR } = require('../lib/index');
8
+ const { readEntry } = require('../lib/entries');
9
+ const { computeScore } = require('../lib/scorer');
10
+ const { listDrafts, acceptDraft, deleteDraft } = require('../lib/drafts');
11
+
12
+ // Only load 'open' dynamically to avoid overhead on other CLI commands if not needed
13
+ async function openBrowser(url) {
14
+ const open = (await import('open')).default;
15
+ await open(url);
16
+ }
17
+
18
+ function ui(options) {
19
+ requireInit();
20
+
21
+ const app = express();
22
+ const PORT = options.port || 3333;
23
+
24
+ app.use(express.json());
25
+
26
+ // CORS for local dev
27
+ app.use((req, res, next) => {
28
+ res.header('Access-Control-Allow-Origin', '*');
29
+ res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
30
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
31
+ next();
32
+ });
33
+
34
+ // API Endpoints
35
+
36
+ app.get('/api/stats', (req, res) => {
37
+ try {
38
+ const scoreData = computeScore();
39
+ const drafts = listDrafts();
40
+
41
+ const index = readIndex();
42
+ const counts = { decision: 0, invariant: 0, graveyard: 0, gotcha: 0 };
43
+ for (const entryPath of Object.values(index.entries)) {
44
+ const entry = readEntry(entryPath);
45
+ if (entry && counts[entry.type] !== undefined) counts[entry.type]++;
46
+ }
47
+
48
+ res.json({
49
+ score: scoreData,
50
+ counts,
51
+ draftCount: drafts.length,
52
+ totalEntries: Object.keys(index.entries).length
53
+ });
54
+ } catch (e) {
55
+ res.status(500).json({ error: e.message });
56
+ }
57
+ });
58
+
59
+ app.get('/api/entries', (req, res) => {
60
+ try {
61
+ const index = readIndex();
62
+ const entries = [];
63
+ for (const entryPath of Object.values(index.entries)) {
64
+ const entry = readEntry(entryPath);
65
+ if (entry) entries.push(entry);
66
+ }
67
+ // Sort newest first
68
+ entries.sort((a, b) => new Date(b.date) - new Date(a.date));
69
+ res.json(entries);
70
+ } catch (e) {
71
+ res.status(500).json({ error: e.message });
72
+ }
73
+ });
74
+
75
+ app.get('/api/drafts', (req, res) => {
76
+ try {
77
+ const drafts = listDrafts();
78
+ res.json(drafts);
79
+ } catch (e) {
80
+ res.status(500).json({ error: e.message });
81
+ }
82
+ });
83
+
84
+ app.post('/api/drafts/:id/accept', (req, res) => {
85
+ try {
86
+ const entry = acceptDraft(req.params.id);
87
+ res.json({ success: true, entry });
88
+ } catch (e) {
89
+ res.status(500).json({ error: e.message });
90
+ }
91
+ });
92
+
93
+ app.delete('/api/drafts/:id', (req, res) => {
94
+ try {
95
+ deleteDraft(req.params.id);
96
+ res.json({ success: true });
97
+ } catch (e) {
98
+ res.status(500).json({ error: e.message });
99
+ }
100
+ });
101
+
102
+ app.get('/api/graph', (req, res) => {
103
+ try {
104
+ const { loadGraph, saveGraph } = require('../lib/graph');
105
+ let g = loadGraph();
106
+
107
+ if (Object.keys(g.imports).length === 0) {
108
+ const { buildFullGraph } = require('../watcher/graph');
109
+ g = buildFullGraph(process.cwd());
110
+ saveGraph(g);
111
+ }
112
+
113
+ const nodesSet = new Set();
114
+ const edges = [];
115
+ for (const [file, deps] of Object.entries(g.imports)) {
116
+ nodesSet.add(file);
117
+ for (const dep of deps) {
118
+ nodesSet.add(dep);
119
+ edges.push({ from: file, to: dep });
120
+ }
121
+ }
122
+
123
+ const nodes = Array.from(nodesSet).map(id => ({ id, label: id }));
124
+ res.json({ nodes, edges });
125
+ } catch (e) {
126
+ res.status(500).json({ error: e.message });
127
+ }
128
+ });
129
+
130
+ // Handle unmapped API routes with 404 JSON (instead of serving index.html)
131
+ app.use('/api', (req, res) => {
132
+ res.status(404).json({ error: 'API route not found' });
133
+ });
134
+
135
+ // Serve the frontend application
136
+ const uiPath = path.join(__dirname, '..', 'ui', 'public');
137
+ app.use(express.static(uiPath));
138
+
139
+ // Catch-all to serve index.html for SPA routing
140
+ app.use((req, res) => {
141
+ res.sendFile(path.join(uiPath, 'index.html'));
142
+ });
143
+
144
+ const server = app.listen(PORT, () => {
145
+ const url = `http://localhost:${PORT}`;
146
+ console.log(chalk.green(`\nšŸš€ Lore UI Dashboard running at ${chalk.bold(url)}\n`));
147
+ console.log(chalk.cyan(` Press Ctrl+C to stop the server.`));
148
+
149
+ // Use native exec to open browser to avoid ESM import issues with 'open'
150
+ const { exec } = require('child_process');
151
+ const startPath = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
152
+ exec(`${startPath} ${url}`, (err) => {
153
+ if (err) {
154
+ console.log(chalk.dim(` (Could not open browser automatically. Please visit ${url} manually)`));
155
+ }
156
+ });
157
+ });
158
+
159
+ server.on('error', (e) => {
160
+ if (e.code === 'EADDRINUSE') {
161
+ console.error(chalk.red(`\nPort ${PORT} is already in use by another process.`));
162
+ console.error(chalk.yellow(`Use 'lore ui --port <number>' to specify a different port.\n`));
163
+ process.exit(1);
164
+ } else {
165
+ console.error(chalk.red(`\nFailed to start server: ${e.message}\n`));
166
+ process.exit(1);
167
+ }
168
+ });
169
+ }
170
+
171
+ module.exports = ui;
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
- module.exports = { printEntry };
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 };
@@ -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(/^\.\//, ''));
@@ -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 {