lore-memory 0.1.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/LICENSE +21 -0
- package/README.md +666 -0
- package/bin/lore.js +108 -0
- package/package.json +53 -0
- package/src/commands/drafts.js +144 -0
- package/src/commands/edit.js +30 -0
- package/src/commands/embed.js +63 -0
- package/src/commands/export.js +76 -0
- package/src/commands/graph.js +80 -0
- package/src/commands/init.js +110 -0
- package/src/commands/log.js +149 -0
- package/src/commands/mine.js +38 -0
- package/src/commands/onboard.js +112 -0
- package/src/commands/score.js +88 -0
- package/src/commands/search.js +49 -0
- package/src/commands/serve.js +21 -0
- package/src/commands/stale.js +41 -0
- package/src/commands/status.js +59 -0
- package/src/commands/watch.js +67 -0
- package/src/commands/why.js +58 -0
- package/src/lib/budget.js +57 -0
- package/src/lib/config.js +52 -0
- package/src/lib/drafts.js +104 -0
- package/src/lib/embeddings.js +97 -0
- package/src/lib/entries.js +59 -0
- package/src/lib/format.js +23 -0
- package/src/lib/git.js +18 -0
- package/src/lib/graph.js +51 -0
- package/src/lib/guard.js +13 -0
- package/src/lib/index.js +84 -0
- package/src/lib/nlp.js +106 -0
- package/src/lib/relevance.js +81 -0
- package/src/lib/scorer.js +188 -0
- package/src/lib/sessions.js +51 -0
- package/src/lib/stale.js +27 -0
- package/src/mcp/server.js +52 -0
- package/src/mcp/tools/drafts.js +54 -0
- package/src/mcp/tools/log.js +93 -0
- package/src/mcp/tools/overview.js +141 -0
- package/src/mcp/tools/search.js +96 -0
- package/src/mcp/tools/stale.js +88 -0
- package/src/mcp/tools/why.js +91 -0
- package/src/watcher/comments.js +113 -0
- package/src/watcher/graph.js +149 -0
- package/src/watcher/index.js +134 -0
- package/src/watcher/signals.js +217 -0
- package/src/watcher/staleness.js +104 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Rough token estimate: 4 chars per token
|
|
4
|
+
function estimateTokens(text) {
|
|
5
|
+
return Math.ceil((text || '').length / 4);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format a single entry for MCP context injection.
|
|
10
|
+
* @param {object} entry
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function formatEntry(entry) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(`## [${entry.type.toUpperCase()}] ${entry.title}`);
|
|
16
|
+
lines.push(`ID: ${entry.id}`);
|
|
17
|
+
if (entry.files && entry.files.length > 0) {
|
|
18
|
+
lines.push(`Files: ${entry.files.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
21
|
+
lines.push(`Tags: ${entry.tags.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
lines.push('');
|
|
24
|
+
if (entry.context) lines.push(entry.context);
|
|
25
|
+
if (entry.alternatives && entry.alternatives.length > 0) {
|
|
26
|
+
lines.push('');
|
|
27
|
+
lines.push(`Alternatives considered: ${entry.alternatives.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (entry.tradeoffs) {
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push(`Tradeoffs: ${entry.tradeoffs}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Select the highest-scoring entries that fit within a token budget.
|
|
38
|
+
* @param {object[]} rankedEntries - Entries sorted by score descending (from rankEntries)
|
|
39
|
+
* @param {number} budget - Max tokens
|
|
40
|
+
* @returns {string} Formatted context block
|
|
41
|
+
*/
|
|
42
|
+
function enforceBudget(rankedEntries, budget) {
|
|
43
|
+
const sections = [];
|
|
44
|
+
let used = 0;
|
|
45
|
+
|
|
46
|
+
for (const entry of rankedEntries) {
|
|
47
|
+
const text = formatEntry(entry);
|
|
48
|
+
const tokens = estimateTokens(text);
|
|
49
|
+
if (used + tokens > budget) break;
|
|
50
|
+
sections.push(text);
|
|
51
|
+
used += tokens;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return sections.join('\n\n---\n\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { estimateTokens, formatEntry, enforceBudget };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const yaml = require('js-yaml');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { LORE_DIR } = require('./index');
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
version: '1.0',
|
|
10
|
+
staleAfterDays: 30,
|
|
11
|
+
embed: {
|
|
12
|
+
model: 'nomic-embed-text',
|
|
13
|
+
autoEmbed: true,
|
|
14
|
+
},
|
|
15
|
+
mcp: {
|
|
16
|
+
tokenBudget: 4000,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function readConfig() {
|
|
21
|
+
const configPath = path.join(LORE_DIR, 'config.yaml');
|
|
22
|
+
let fileConfig = {};
|
|
23
|
+
try {
|
|
24
|
+
if (fs.existsSync(configPath)) {
|
|
25
|
+
fileConfig = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
|
|
26
|
+
}
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Fall back to defaults on parse error
|
|
29
|
+
}
|
|
30
|
+
return deepMerge(DEFAULTS, fileConfig);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deepMerge(base, override) {
|
|
34
|
+
const result = Object.assign({}, base);
|
|
35
|
+
for (const key of Object.keys(override)) {
|
|
36
|
+
if (
|
|
37
|
+
override[key] !== null &&
|
|
38
|
+
typeof override[key] === 'object' &&
|
|
39
|
+
!Array.isArray(override[key]) &&
|
|
40
|
+
base[key] !== null &&
|
|
41
|
+
typeof base[key] === 'object' &&
|
|
42
|
+
!Array.isArray(base[key])
|
|
43
|
+
) {
|
|
44
|
+
result[key] = deepMerge(base[key], override[key]);
|
|
45
|
+
} else {
|
|
46
|
+
result[key] = override[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { readConfig };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LORE_DIR, readIndex, writeIndex, addEntryToIndex } = require('./index');
|
|
6
|
+
const { generateId, writeEntry } = require('./entries');
|
|
7
|
+
|
|
8
|
+
const DRAFTS_DIR = () => path.join(LORE_DIR, 'drafts');
|
|
9
|
+
|
|
10
|
+
function ensureDraftsDir() {
|
|
11
|
+
fs.ensureDirSync(DRAFTS_DIR());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Save a draft to .lore/drafts/.
|
|
16
|
+
* @param {object} draft
|
|
17
|
+
* @returns {string} draftId
|
|
18
|
+
*/
|
|
19
|
+
function saveDraft(draft) {
|
|
20
|
+
ensureDraftsDir();
|
|
21
|
+
const draftId = draft.draftId || `draft-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
22
|
+
const draftPath = path.join(DRAFTS_DIR(), `${draftId}.json`);
|
|
23
|
+
fs.writeJsonSync(draftPath, { ...draft, draftId, status: 'pending' }, { spaces: 2 });
|
|
24
|
+
return draftId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* List all pending drafts, sorted by confidence descending.
|
|
29
|
+
* @returns {object[]}
|
|
30
|
+
*/
|
|
31
|
+
function listDrafts() {
|
|
32
|
+
const dir = DRAFTS_DIR();
|
|
33
|
+
if (!fs.existsSync(dir)) return [];
|
|
34
|
+
|
|
35
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
36
|
+
const drafts = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
try {
|
|
39
|
+
const draft = fs.readJsonSync(path.join(dir, file));
|
|
40
|
+
if (draft.status === 'pending') drafts.push(draft);
|
|
41
|
+
} catch (e) {}
|
|
42
|
+
}
|
|
43
|
+
return drafts.sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Promote a draft to a real Lore entry.
|
|
48
|
+
* @param {string} draftId
|
|
49
|
+
* @returns {object} the created entry
|
|
50
|
+
*/
|
|
51
|
+
function acceptDraft(draftId) {
|
|
52
|
+
const draftPath = path.join(DRAFTS_DIR(), `${draftId}.json`);
|
|
53
|
+
const draft = fs.readJsonSync(draftPath);
|
|
54
|
+
|
|
55
|
+
const type = draft.suggestedType || 'decision';
|
|
56
|
+
const title = draft.suggestedTitle || 'Untitled';
|
|
57
|
+
const id = generateId(type, title);
|
|
58
|
+
|
|
59
|
+
const entry = {
|
|
60
|
+
id,
|
|
61
|
+
type,
|
|
62
|
+
title,
|
|
63
|
+
context: draft.evidence || '',
|
|
64
|
+
files: draft.files || [],
|
|
65
|
+
tags: draft.tags || [],
|
|
66
|
+
alternatives: [],
|
|
67
|
+
tradeoffs: '',
|
|
68
|
+
date: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
writeEntry(entry);
|
|
72
|
+
const index = readIndex();
|
|
73
|
+
addEntryToIndex(index, entry);
|
|
74
|
+
writeIndex(index);
|
|
75
|
+
|
|
76
|
+
// Auto-embed if Ollama available
|
|
77
|
+
try {
|
|
78
|
+
const { generateEmbedding, storeEmbedding } = require('./embeddings');
|
|
79
|
+
const text = [title, entry.context, ...entry.tags].join(' ');
|
|
80
|
+
generateEmbedding(text).then(vec => storeEmbedding(id, vec)).catch(() => {});
|
|
81
|
+
} catch (e) {}
|
|
82
|
+
|
|
83
|
+
fs.removeSync(draftPath);
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delete a draft permanently.
|
|
89
|
+
* @param {string} draftId
|
|
90
|
+
*/
|
|
91
|
+
function deleteDraft(draftId) {
|
|
92
|
+
const draftPath = path.join(DRAFTS_DIR(), `${draftId}.json`);
|
|
93
|
+
fs.removeSync(draftPath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Count pending drafts.
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function getDraftCount() {
|
|
101
|
+
return listDrafts().length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { saveDraft, listDrafts, acceptDraft, deleteDraft, getDraftCount };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { LORE_DIR } = require('./index');
|
|
5
|
+
const { readConfig } = require('./config');
|
|
6
|
+
|
|
7
|
+
let _db = null;
|
|
8
|
+
|
|
9
|
+
function getDbPath() {
|
|
10
|
+
return path.join(LORE_DIR, 'embeddings.db');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function openDb() {
|
|
14
|
+
if (_db) return _db;
|
|
15
|
+
const Database = require('better-sqlite3');
|
|
16
|
+
_db = new Database(getDbPath());
|
|
17
|
+
_db.exec(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
vector TEXT NOT NULL,
|
|
21
|
+
updated_at TEXT NOT NULL
|
|
22
|
+
)
|
|
23
|
+
`);
|
|
24
|
+
return _db;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function generateEmbedding(text) {
|
|
28
|
+
const { Ollama } = require('ollama');
|
|
29
|
+
const config = readConfig();
|
|
30
|
+
const model = config.embed && config.embed.model ? config.embed.model : 'nomic-embed-text';
|
|
31
|
+
const ollama = new Ollama();
|
|
32
|
+
const response = await ollama.embeddings({ model, prompt: text });
|
|
33
|
+
return response.embedding;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function storeEmbedding(id, vector) {
|
|
37
|
+
const db = openDb();
|
|
38
|
+
const stmt = db.prepare(`
|
|
39
|
+
INSERT OR REPLACE INTO embeddings (id, vector, updated_at)
|
|
40
|
+
VALUES (?, ?, ?)
|
|
41
|
+
`);
|
|
42
|
+
stmt.run(id, JSON.stringify(vector), new Date().toISOString());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getEmbedding(id) {
|
|
46
|
+
const db = openDb();
|
|
47
|
+
const row = db.prepare('SELECT vector FROM embeddings WHERE id = ?').get(id);
|
|
48
|
+
if (!row) return null;
|
|
49
|
+
return JSON.parse(row.vector);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cosineSimilarity(a, b) {
|
|
53
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
54
|
+
let dot = 0, magA = 0, magB = 0;
|
|
55
|
+
for (let i = 0; i < a.length; i++) {
|
|
56
|
+
dot += a[i] * b[i];
|
|
57
|
+
magA += a[i] * a[i];
|
|
58
|
+
magB += b[i] * b[i];
|
|
59
|
+
}
|
|
60
|
+
const mag = Math.sqrt(magA) * Math.sqrt(magB);
|
|
61
|
+
if (mag === 0) return 0;
|
|
62
|
+
return dot / mag;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function findSimilar(queryText, ids, topN = 5) {
|
|
66
|
+
const queryVec = await generateEmbedding(queryText);
|
|
67
|
+
const db = openDb();
|
|
68
|
+
const results = [];
|
|
69
|
+
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const row = db.prepare('SELECT vector FROM embeddings WHERE id = ?').get(id);
|
|
72
|
+
if (!row) continue;
|
|
73
|
+
const vec = JSON.parse(row.vector);
|
|
74
|
+
const score = cosineSimilarity(queryVec, vec);
|
|
75
|
+
results.push({ id, score });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
results.sort((a, b) => b.score - a.score);
|
|
79
|
+
return results.slice(0, topN);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function closeDb() {
|
|
83
|
+
if (_db) {
|
|
84
|
+
_db.close();
|
|
85
|
+
_db = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
openDb,
|
|
91
|
+
generateEmbedding,
|
|
92
|
+
storeEmbedding,
|
|
93
|
+
getEmbedding,
|
|
94
|
+
cosineSimilarity,
|
|
95
|
+
findSimilar,
|
|
96
|
+
closeDb,
|
|
97
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const { LORE_DIR, getTypeDir } = require('./index');
|
|
7
|
+
|
|
8
|
+
function getEntryPath(type, id) {
|
|
9
|
+
return path.join(LORE_DIR, getTypeDir(type), `${id}.json`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readEntry(entryPath) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.readJsonSync(entryPath);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(chalk.red(`Failed to read entry at ${entryPath}: ${e.message}`));
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeEntry(entry) {
|
|
22
|
+
const entryPath = getEntryPath(entry.type, entry.id);
|
|
23
|
+
try {
|
|
24
|
+
fs.writeJsonSync(entryPath, entry, { spaces: 2 });
|
|
25
|
+
return entryPath;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(chalk.red(`Failed to write entry: ${e.message}`));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function generateId(type, title) {
|
|
33
|
+
const words = title
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9\s]/g, '')
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.slice(0, 3)
|
|
39
|
+
.join('-');
|
|
40
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
41
|
+
return `${type}-${words}-${ts}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readAllEntries(index) {
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (const entryPath of Object.values(index.entries)) {
|
|
47
|
+
const entry = readEntry(entryPath);
|
|
48
|
+
if (entry) entries.push(entry);
|
|
49
|
+
}
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
getEntryPath,
|
|
55
|
+
readEntry,
|
|
56
|
+
writeEntry,
|
|
57
|
+
generateId,
|
|
58
|
+
readAllEntries,
|
|
59
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
function printEntry(entry) {
|
|
6
|
+
const typeLabel = `[${entry.type.toUpperCase()}]`;
|
|
7
|
+
console.log(chalk.bold(`${typeLabel} ${entry.title}`) + chalk.gray(` (${entry.date})`));
|
|
8
|
+
console.log(` → ${entry.context}`);
|
|
9
|
+
|
|
10
|
+
if (entry.alternatives && entry.alternatives.length > 0) {
|
|
11
|
+
for (const alt of entry.alternatives) {
|
|
12
|
+
console.log(chalk.yellow(` → Rejected: ${alt}`));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (entry.tradeoffs) {
|
|
17
|
+
console.log(chalk.gray(` → Tradeoffs: ${entry.tradeoffs}`));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { printEntry };
|
package/src/lib/git.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function getRecentFiles(depth = 5) {
|
|
6
|
+
try {
|
|
7
|
+
const output = execSync(`git diff --name-only HEAD~${depth} HEAD`, {
|
|
8
|
+
encoding: 'utf8',
|
|
9
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
10
|
+
});
|
|
11
|
+
return output.trim().split('\n').filter(Boolean);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
// Not a git repo, no commits yet, or not enough history
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { getRecentFiles };
|
package/src/lib/graph.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LORE_DIR } = require('./index');
|
|
6
|
+
|
|
7
|
+
const GRAPH_PATH = () => path.join(LORE_DIR, 'graph.json');
|
|
8
|
+
|
|
9
|
+
function emptyGraph() {
|
|
10
|
+
return {
|
|
11
|
+
imports: {}, // filepath → [filepath]
|
|
12
|
+
importedBy: {}, // filepath → [filepath]
|
|
13
|
+
lastUpdated: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadGraph() {
|
|
18
|
+
const p = GRAPH_PATH();
|
|
19
|
+
if (!fs.existsSync(p)) return emptyGraph();
|
|
20
|
+
try { return fs.readJsonSync(p); } catch (e) { return emptyGraph(); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveGraph(graph) {
|
|
24
|
+
graph.lastUpdated = new Date().toISOString();
|
|
25
|
+
fs.writeJsonSync(GRAPH_PATH(), graph, { spaces: 2 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* For a given filepath, return graph-context entry IDs weighted by relationship.
|
|
30
|
+
* @param {string} filepath Normalized relative path
|
|
31
|
+
* @param {object} graph
|
|
32
|
+
* @param {object} index
|
|
33
|
+
* @returns {{ imports: Array<{file,entryIds}>, importedBy: Array<{file,entryIds}> }}
|
|
34
|
+
*/
|
|
35
|
+
function getGraphContext(filepath, graph, index) {
|
|
36
|
+
const normalized = filepath.replace(/^\.\//, '');
|
|
37
|
+
const result = { imports: [], importedBy: [] };
|
|
38
|
+
|
|
39
|
+
for (const dep of (graph.imports[normalized] || [])) {
|
|
40
|
+
const entryIds = index.files[dep] || [];
|
|
41
|
+
if (entryIds.length > 0) result.imports.push({ file: dep, entryIds });
|
|
42
|
+
}
|
|
43
|
+
for (const dep of (graph.importedBy[normalized] || [])) {
|
|
44
|
+
const entryIds = index.files[dep] || [];
|
|
45
|
+
if (entryIds.length > 0) result.importedBy.push({ file: dep, entryIds });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { loadGraph, saveGraph, emptyGraph, getGraphContext };
|
package/src/lib/guard.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { loreExists } = require('./index');
|
|
5
|
+
|
|
6
|
+
function requireInit() {
|
|
7
|
+
if (!loreExists()) {
|
|
8
|
+
console.error(chalk.red('📖 Run lore init first'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { requireInit };
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
|
|
7
|
+
const LORE_DIR = '.lore';
|
|
8
|
+
const INDEX_PATH = path.join(LORE_DIR, 'index.json');
|
|
9
|
+
|
|
10
|
+
function loreExists() {
|
|
11
|
+
return fs.existsSync(LORE_DIR);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readIndex() {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readJsonSync(INDEX_PATH);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e.code === 'ENOENT') {
|
|
19
|
+
console.error(chalk.red(`📖 index.json not found at ${INDEX_PATH}`));
|
|
20
|
+
} else {
|
|
21
|
+
console.error(chalk.red(`📖 Malformed index.json at ${INDEX_PATH}: ${e.message}`));
|
|
22
|
+
}
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeIndex(index) {
|
|
28
|
+
try {
|
|
29
|
+
index.lastUpdated = new Date().toISOString();
|
|
30
|
+
fs.writeJsonSync(INDEX_PATH, index, { spaces: 2 });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error(chalk.red(`Failed to write index.json: ${e.message}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emptyIndex() {
|
|
38
|
+
return {
|
|
39
|
+
files: {},
|
|
40
|
+
entries: {},
|
|
41
|
+
lastUpdated: new Date().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getTypeDir(type) {
|
|
46
|
+
return type === 'graveyard' ? 'graveyard' : type + 's';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addEntryToIndex(index, entry) {
|
|
50
|
+
const typeDir = getTypeDir(entry.type);
|
|
51
|
+
index.entries[entry.id] = `.lore/${typeDir}/${entry.id}.json`;
|
|
52
|
+
|
|
53
|
+
for (const filepath of entry.files) {
|
|
54
|
+
const normalized = filepath.replace(/^\.\//, '');
|
|
55
|
+
|
|
56
|
+
if (!index.files[normalized]) index.files[normalized] = [];
|
|
57
|
+
if (!index.files[normalized].includes(entry.id)) {
|
|
58
|
+
index.files[normalized].push(entry.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Index immediate parent directory only (ancestor walking happens at lookup time)
|
|
62
|
+
const dir = path.dirname(normalized.replace(/\/$/, ''));
|
|
63
|
+
if (dir && dir !== '.') {
|
|
64
|
+
const dirKey = dir + '/';
|
|
65
|
+
if (!index.files[dirKey]) index.files[dirKey] = [];
|
|
66
|
+
if (!index.files[dirKey].includes(entry.id)) {
|
|
67
|
+
index.files[dirKey].push(entry.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
index.lastUpdated = new Date().toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
LORE_DIR,
|
|
77
|
+
INDEX_PATH,
|
|
78
|
+
loreExists,
|
|
79
|
+
readIndex,
|
|
80
|
+
writeIndex,
|
|
81
|
+
emptyIndex,
|
|
82
|
+
getTypeDir,
|
|
83
|
+
addEntryToIndex,
|
|
84
|
+
};
|
package/src/lib/nlp.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const natural = require('natural');
|
|
4
|
+
|
|
5
|
+
// Type detection patterns
|
|
6
|
+
const TYPE_PATTERNS = [
|
|
7
|
+
{ type: 'invariant', re: /\b(must|always|never|shall|required|mandatory|do not|don't)\b/i, confidence: 0.9 },
|
|
8
|
+
{ type: 'graveyard', re: /\b(tried|abandoned|removed|replaced|deprecated|don'?t use|do not use|we used to)\b/i, confidence: 0.85 },
|
|
9
|
+
{ type: 'gotcha', re: /\b(warning|careful|hack|workaround|footgun|beware|gotcha|pitfall|tricky|careful)\b/i, confidence: 0.8 },
|
|
10
|
+
{ type: 'decision', re: /\b(because|reason|chose|decided|switched|opted|note:|important:|we chose|we use)\b/i, confidence: 0.7 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Trigger phrases for comment scoring
|
|
14
|
+
const TRIGGER_PHRASES = [
|
|
15
|
+
"don't", "never", "always", "because", "warning", "hack",
|
|
16
|
+
"todo: explain", "note:", "important:", "must", "shall",
|
|
17
|
+
"tried", "abandoned", "careful", "workaround", "footgun",
|
|
18
|
+
"beware", "replaced", "deprecated", "we tried", "latency",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const STOP_WORDS = new Set([
|
|
22
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
23
|
+
'being', 'i', 'we', 'it', 'this', 'that', 'to', 'of', 'in',
|
|
24
|
+
'for', 'on', 'with', 'as', 'at', 'by', 'or', 'and', 'but',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect the suggested entry type from a text snippet.
|
|
29
|
+
* @param {string} text
|
|
30
|
+
* @returns {{ type: string, confidence: number }}
|
|
31
|
+
*/
|
|
32
|
+
function detectType(text) {
|
|
33
|
+
for (const { type, re, confidence } of TYPE_PATTERNS) {
|
|
34
|
+
if (re.test(text)) return { type, confidence };
|
|
35
|
+
}
|
|
36
|
+
return { type: 'decision', confidence: 0.4 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract a short title from comment text.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {number} maxWords
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function extractTitle(text, maxWords = 8) {
|
|
46
|
+
// Strip comment markers
|
|
47
|
+
const stripped = text
|
|
48
|
+
.replace(/^[\s\/\*#\-]+/, '')
|
|
49
|
+
.replace(/\*\//g, '')
|
|
50
|
+
.trim();
|
|
51
|
+
|
|
52
|
+
const tokenizer = new natural.WordTokenizer();
|
|
53
|
+
const words = tokenizer.tokenize(stripped) || [];
|
|
54
|
+
const meaningful = words.filter(w => w.length > 2 && !STOP_WORDS.has(w.toLowerCase()));
|
|
55
|
+
|
|
56
|
+
const titleWords = meaningful.slice(0, maxWords);
|
|
57
|
+
if (titleWords.length === 0) return stripped.slice(0, 50);
|
|
58
|
+
|
|
59
|
+
// Title-case
|
|
60
|
+
return titleWords
|
|
61
|
+
.map((w, i) => i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w)
|
|
62
|
+
.join(' ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Slugify text for use in IDs.
|
|
67
|
+
* @param {string} text
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function slugify(text) {
|
|
71
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Score a comment 0–1 for lore-worthiness.
|
|
76
|
+
* @param {string} comment
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
function scoreComment(comment) {
|
|
80
|
+
const lower = comment.toLowerCase();
|
|
81
|
+
let score = 0;
|
|
82
|
+
|
|
83
|
+
// Trigger phrase present
|
|
84
|
+
for (const phrase of TRIGGER_PHRASES) {
|
|
85
|
+
if (lower.includes(phrase)) {
|
|
86
|
+
score += 0.4;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Length bonus
|
|
92
|
+
if (comment.length > 20) score += 0.2;
|
|
93
|
+
if (comment.length > 60) score += 0.2;
|
|
94
|
+
|
|
95
|
+
// Penalty for generic TODO/FIXME without explanation
|
|
96
|
+
if (/\b(todo|fixme)\b/i.test(lower) && !lower.includes('todo: explain')) {
|
|
97
|
+
score -= 0.35;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Bonus for numbers/metrics (performance constraints etc.)
|
|
101
|
+
if (/\d+(ms|mb|kb|s|%|x)/.test(lower)) score += 0.1;
|
|
102
|
+
|
|
103
|
+
return Math.max(0, Math.min(1, score));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { detectType, extractTitle, slugify, scoreComment };
|