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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +666 -0
  3. package/bin/lore.js +108 -0
  4. package/package.json +53 -0
  5. package/src/commands/drafts.js +144 -0
  6. package/src/commands/edit.js +30 -0
  7. package/src/commands/embed.js +63 -0
  8. package/src/commands/export.js +76 -0
  9. package/src/commands/graph.js +80 -0
  10. package/src/commands/init.js +110 -0
  11. package/src/commands/log.js +149 -0
  12. package/src/commands/mine.js +38 -0
  13. package/src/commands/onboard.js +112 -0
  14. package/src/commands/score.js +88 -0
  15. package/src/commands/search.js +49 -0
  16. package/src/commands/serve.js +21 -0
  17. package/src/commands/stale.js +41 -0
  18. package/src/commands/status.js +59 -0
  19. package/src/commands/watch.js +67 -0
  20. package/src/commands/why.js +58 -0
  21. package/src/lib/budget.js +57 -0
  22. package/src/lib/config.js +52 -0
  23. package/src/lib/drafts.js +104 -0
  24. package/src/lib/embeddings.js +97 -0
  25. package/src/lib/entries.js +59 -0
  26. package/src/lib/format.js +23 -0
  27. package/src/lib/git.js +18 -0
  28. package/src/lib/graph.js +51 -0
  29. package/src/lib/guard.js +13 -0
  30. package/src/lib/index.js +84 -0
  31. package/src/lib/nlp.js +106 -0
  32. package/src/lib/relevance.js +81 -0
  33. package/src/lib/scorer.js +188 -0
  34. package/src/lib/sessions.js +51 -0
  35. package/src/lib/stale.js +27 -0
  36. package/src/mcp/server.js +52 -0
  37. package/src/mcp/tools/drafts.js +54 -0
  38. package/src/mcp/tools/log.js +93 -0
  39. package/src/mcp/tools/overview.js +141 -0
  40. package/src/mcp/tools/search.js +96 -0
  41. package/src/mcp/tools/stale.js +88 -0
  42. package/src/mcp/tools/why.js +91 -0
  43. package/src/watcher/comments.js +113 -0
  44. package/src/watcher/graph.js +149 -0
  45. package/src/watcher/index.js +134 -0
  46. package/src/watcher/signals.js +217 -0
  47. 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 };
@@ -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 };
@@ -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 };
@@ -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 };