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,81 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { getEmbedding, cosineSimilarity } = require('./embeddings');
5
+
6
+ // Weights for relevance scoring
7
+ const WEIGHTS = {
8
+ directFileMatch: 1.0,
9
+ parentDirMatch: 0.7,
10
+ semanticSimilarity: 0.5,
11
+ tagOverlap: 0.3,
12
+ };
13
+
14
+ /**
15
+ * Score an entry against a query context.
16
+ * @param {object} entry - The lore entry
17
+ * @param {object} context - { filepath, queryText, queryEmbedding, tags }
18
+ * @returns {number} score 0–1+
19
+ */
20
+ function scoreEntry(entry, context) {
21
+ let score = 0;
22
+
23
+ // Direct file match
24
+ if (context.filepath && entry.files && entry.files.length > 0) {
25
+ const normalizedQuery = context.filepath.replace(/^\.\//, '');
26
+ for (const f of entry.files) {
27
+ const normalizedFile = f.replace(/^\.\//, '');
28
+ if (normalizedFile === normalizedQuery) {
29
+ score += WEIGHTS.directFileMatch;
30
+ break;
31
+ }
32
+ }
33
+ }
34
+
35
+ // Parent dir match
36
+ if (context.filepath && entry.files && entry.files.length > 0) {
37
+ const queryDir = path.dirname(context.filepath.replace(/^\.\//, ''));
38
+ for (const f of entry.files) {
39
+ const fileDir = path.dirname(f.replace(/^\.\//, ''));
40
+ if (fileDir === queryDir && queryDir !== '.') {
41
+ score += WEIGHTS.parentDirMatch;
42
+ break;
43
+ }
44
+ }
45
+ }
46
+
47
+ // Semantic similarity (if query embedding and entry embedding available)
48
+ if (context.queryEmbedding) {
49
+ const entryVec = getEmbedding(entry.id);
50
+ if (entryVec) {
51
+ const sim = cosineSimilarity(context.queryEmbedding, entryVec);
52
+ score += sim * WEIGHTS.semanticSimilarity;
53
+ }
54
+ }
55
+
56
+ // Tag overlap
57
+ if (context.tags && entry.tags && context.tags.length > 0 && entry.tags.length > 0) {
58
+ const queryTagSet = new Set(context.tags.map(t => t.toLowerCase()));
59
+ const matches = entry.tags.filter(t => queryTagSet.has(t.toLowerCase())).length;
60
+ const overlap = matches / Math.max(queryTagSet.size, entry.tags.length);
61
+ score += overlap * WEIGHTS.tagOverlap;
62
+ }
63
+
64
+ return score;
65
+ }
66
+
67
+ /**
68
+ * Rank entries by relevance to a context.
69
+ * @param {object[]} entries
70
+ * @param {object} context
71
+ * @returns {object[]} sorted entries with .score attached
72
+ */
73
+ function rankEntries(entries, context) {
74
+ return entries
75
+ .map(entry => ({ entry, score: scoreEntry(entry, context) }))
76
+ .filter(({ score }) => score > 0)
77
+ .sort((a, b) => b.score - a.score)
78
+ .map(({ entry, score }) => Object.assign({}, entry, { _score: score }));
79
+ }
80
+
81
+ module.exports = { scoreEntry, rankEntries };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const fs = require('fs-extra');
5
+ const path = require('path');
6
+ const { LORE_DIR, readIndex } = require('./index');
7
+ const { readEntry } = require('./entries');
8
+ const { checkStaleness } = require('./stale');
9
+ const { readConfig } = require('./config');
10
+
11
+ const SCORE_PATH = () => path.join(LORE_DIR, 'score.json');
12
+
13
+ /**
14
+ * Get directories that have >5 commits in the last 90 days.
15
+ * @returns {string[]} relative dir paths
16
+ */
17
+ function getActiveModules() {
18
+ try {
19
+ const output = execSync(
20
+ 'git log --since="90 days ago" --name-only --pretty=format:""',
21
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
22
+ );
23
+ const dirCounts = {};
24
+ for (const line of output.split('\n')) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed || trimmed.startsWith('warning:')) continue;
27
+ const dir = path.dirname(trimmed);
28
+ if (dir === '.') continue;
29
+ dirCounts[dir] = (dirCounts[dir] || 0) + 1;
30
+ }
31
+ return Object.entries(dirCounts)
32
+ .filter(([, count]) => count > 5)
33
+ .map(([dir]) => dir);
34
+ } catch (e) {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Get all directories that have at least one Lore entry linked.
41
+ */
42
+ function getModulesWithEntries(index) {
43
+ const dirs = new Set();
44
+ for (const entryPath of Object.values(index.entries)) {
45
+ const entry = readEntry(entryPath);
46
+ if (!entry) continue;
47
+ for (const file of (entry.files || [])) {
48
+ const dir = path.dirname(file.replace(/^\.\//, '').replace(/\/$/, ''));
49
+ if (dir && dir !== '.') dirs.add(dir);
50
+ }
51
+ }
52
+ return dirs;
53
+ }
54
+
55
+ function calcCoverage(activeModules, modulesWithEntries) {
56
+ if (activeModules.length === 0) return 100;
57
+ const covered = activeModules.filter(m => modulesWithEntries.has(m)).length;
58
+ return Math.round((covered / activeModules.length) * 100);
59
+ }
60
+
61
+ function calcFreshness(index) {
62
+ let deduction = 0;
63
+ const now = new Date();
64
+ for (const entryPath of Object.values(index.entries)) {
65
+ const entry = readEntry(entryPath);
66
+ if (!entry) continue;
67
+
68
+ // File-based staleness
69
+ const staleFiles = checkStaleness(entry);
70
+ for (const { daysAgo } of staleFiles) {
71
+ deduction += Math.min(40, daysAgo / 2);
72
+ }
73
+
74
+ // Age-based staleness for entries with no linked files (older than 60 days)
75
+ if ((!entry.files || entry.files.length === 0) && entry.date) {
76
+ const ageDays = (now - new Date(entry.date)) / (1000 * 60 * 60 * 24);
77
+ if (ageDays > 60) {
78
+ deduction += Math.min(10, (ageDays - 60) / 6);
79
+ }
80
+ }
81
+ }
82
+ return Math.max(0, Math.round(100 - deduction));
83
+ }
84
+
85
+ function calcDepth(index) {
86
+ const counts = { decision: 0, invariant: 0, graveyard: 0, gotcha: 0 };
87
+ for (const entryPath of Object.values(index.entries)) {
88
+ const entry = readEntry(entryPath);
89
+ if (!entry) continue;
90
+ if (counts[entry.type] !== undefined) counts[entry.type]++;
91
+ }
92
+ // invariants and gotchas worth 1.5x
93
+ const weighted =
94
+ counts.decision + counts.graveyard + (counts.invariant * 1.5) + (counts.gotcha * 1.5);
95
+ const maxReasonable = 20;
96
+ return Math.min(100, Math.round((weighted / maxReasonable) * 100));
97
+ }
98
+
99
+ /**
100
+ * Compute the full Lore Score result.
101
+ * @returns {object}
102
+ */
103
+ function computeScore() {
104
+ const index = readIndex();
105
+ const config = readConfig();
106
+ const weights = config.scoringWeights || { coverage: 0.4, freshness: 0.35, depth: 0.25 };
107
+
108
+ const activeModules = getActiveModules();
109
+ const modulesWithEntries = getModulesWithEntries(index);
110
+
111
+ // Commit count per dir for ranking unlogged modules
112
+ const commitCounts = {};
113
+ try {
114
+ const output = execSync(
115
+ 'git log --since="90 days ago" --name-only --pretty=format:""',
116
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
117
+ );
118
+ for (const line of output.split('\n')) {
119
+ const trimmed = line.trim();
120
+ if (!trimmed || trimmed.startsWith('warning:')) continue;
121
+ const dir = path.dirname(trimmed);
122
+ if (dir !== '.') commitCounts[dir] = (commitCounts[dir] || 0) + 1;
123
+ }
124
+ } catch (e) {}
125
+
126
+ const topUnlogged = activeModules
127
+ .filter(m => !modulesWithEntries.has(m))
128
+ .sort((a, b) => (commitCounts[b] || 0) - (commitCounts[a] || 0))
129
+ .slice(0, 3)
130
+ .map(m => ({ module: m, commits: commitCounts[m] || 0 }));
131
+
132
+ const coverage = calcCoverage(activeModules, modulesWithEntries);
133
+ const freshness = calcFreshness(index);
134
+ const depth = calcDepth(index);
135
+ const score = Math.round(
136
+ coverage * weights.coverage +
137
+ freshness * weights.freshness +
138
+ depth * weights.depth
139
+ );
140
+
141
+ return {
142
+ score,
143
+ coverage,
144
+ freshness,
145
+ depth,
146
+ activeModules: activeModules.length,
147
+ coveredModules: activeModules.filter(m => modulesWithEntries.has(m)).length,
148
+ topUnlogged,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Append today's score to history, save to .lore/score.json.
154
+ * @param {object} result
155
+ * @returns {object[]} full history
156
+ */
157
+ function saveScore(result) {
158
+ const scorePath = SCORE_PATH();
159
+ let data = { history: [] };
160
+ if (fs.existsSync(scorePath)) {
161
+ try { data = fs.readJsonSync(scorePath); } catch (e) {}
162
+ }
163
+ const today = new Date().toISOString().split('T')[0];
164
+ data.history = (data.history || []).filter(h => h.date !== today);
165
+ data.history.push({
166
+ date: today,
167
+ score: result.score,
168
+ coverage: result.coverage,
169
+ freshness: result.freshness,
170
+ depth: result.depth,
171
+ });
172
+ data.history = data.history.slice(-90);
173
+ fs.ensureDirSync(LORE_DIR);
174
+ fs.writeJsonSync(scorePath, data, { spaces: 2 });
175
+ return data.history;
176
+ }
177
+
178
+ /**
179
+ * Load score history.
180
+ * @returns {object[]}
181
+ */
182
+ function loadHistory() {
183
+ const scorePath = SCORE_PATH();
184
+ if (!fs.existsSync(scorePath)) return [];
185
+ try { return fs.readJsonSync(scorePath).history || []; } catch (e) { return []; }
186
+ }
187
+
188
+ module.exports = { computeScore, saveScore, loadHistory, getActiveModules };
@@ -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
+ function getSessionPath() {
8
+ return path.join(LORE_DIR, 'sessions', 'last.json');
9
+ }
10
+
11
+ function updateLastSession() {
12
+ const sessionPath = getSessionPath();
13
+ fs.ensureDirSync(path.dirname(sessionPath));
14
+ const data = { lastActive: new Date().toISOString() };
15
+ fs.writeJsonSync(sessionPath, data, { spaces: 2 });
16
+ }
17
+
18
+ function getDaysSinceLastSession() {
19
+ const sessionPath = getSessionPath();
20
+ if (!fs.existsSync(sessionPath)) return null;
21
+ try {
22
+ const data = fs.readJsonSync(sessionPath);
23
+ if (!data.lastActive) return null;
24
+ const last = new Date(data.lastActive);
25
+ const now = new Date();
26
+ const diffMs = now - last;
27
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
28
+ } catch (e) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Watch for conversation activity in .lore/sessions/.
35
+ * Calls onNewSession callback when a new Claude Code session is detected.
36
+ * This is a simple file-watch-based implementation.
37
+ */
38
+ function watchConversations(onNewSession) {
39
+ const sessionsDir = path.join(LORE_DIR, 'sessions');
40
+ fs.ensureDirSync(sessionsDir);
41
+
42
+ const watcher = fs.watch(sessionsDir, { persistent: false }, (event, filename) => {
43
+ if (filename && filename.endsWith('.json')) {
44
+ onNewSession(filename);
45
+ }
46
+ });
47
+
48
+ return watcher;
49
+ }
50
+
51
+ module.exports = { updateLastSession, getDaysSinceLastSession, watchConversations };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+
5
+ function checkStaleness(entry) {
6
+ const results = [];
7
+ const entryDate = new Date(entry.date);
8
+
9
+ for (const filepath of entry.files) {
10
+ try {
11
+ if (!fs.existsSync(filepath)) continue;
12
+ const stat = fs.statSync(filepath);
13
+ const mtime = stat.mtime;
14
+
15
+ if (mtime > entryDate) {
16
+ const daysAgo = Math.floor((Date.now() - mtime.getTime()) / (1000 * 60 * 60 * 24));
17
+ results.push({ isStale: true, filepath, mtime, daysAgo });
18
+ }
19
+ } catch (e) {
20
+ // Skip files that can't be stat'd
21
+ }
22
+ }
23
+
24
+ return results;
25
+ }
26
+
27
+ module.exports = { checkStaleness };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { Server } = require('@modelcontextprotocol/sdk/server');
4
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
+ const {
6
+ ListToolsRequestSchema,
7
+ CallToolRequestSchema,
8
+ } = require('@modelcontextprotocol/sdk/types.js');
9
+
10
+ const why = require('./tools/why');
11
+ const search = require('./tools/search');
12
+ const log = require('./tools/log');
13
+ const stale = require('./tools/stale');
14
+ const overview = require('./tools/overview');
15
+ const drafts = require('./tools/drafts');
16
+
17
+ const TOOLS = [why, search, log, stale, overview, drafts];
18
+
19
+ async function startServer() {
20
+ const server = new Server(
21
+ { name: 'lore', version: '0.3.0' },
22
+ { capabilities: { tools: {} } }
23
+ );
24
+
25
+ // List tools handler
26
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
27
+ tools: TOOLS.map(t => t.toolDefinition),
28
+ }));
29
+
30
+ // Call tool handler
31
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
32
+ const { name, arguments: args } = request.params;
33
+ const tool = TOOLS.find(t => t.toolDefinition.name === name);
34
+
35
+ if (!tool) {
36
+ return {
37
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
38
+ isError: true,
39
+ };
40
+ }
41
+
42
+ return tool.handler(args || {});
43
+ });
44
+
45
+ const transport = new StdioServerTransport();
46
+ await server.connect(transport);
47
+
48
+ // Keep process alive
49
+ process.stdin.resume();
50
+ }
51
+
52
+ module.exports = { startServer };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const { listDrafts, getDraftCount } = require('../../lib/drafts');
4
+
5
+ const toolDefinition = {
6
+ name: 'lore_drafts',
7
+ description: 'Returns the count and summary of pending Lore drafts — automatically captured signals (file deletions, comment mining, commit messages, repeated edits) that have not yet been reviewed. Use this to surface the draft queue to the developer.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {},
11
+ required: [],
12
+ },
13
+ };
14
+
15
+ async function handler() {
16
+ try {
17
+ const count = getDraftCount();
18
+
19
+ if (count === 0) {
20
+ return {
21
+ content: [{ type: 'text', text: 'No pending Lore drafts. Your knowledge base is up to date.' }],
22
+ };
23
+ }
24
+
25
+ const drafts = listDrafts();
26
+ const lines = [
27
+ `You have ${count} unreviewed Lore draft${count === 1 ? '' : 's'} — run \`lore drafts\` to review.\n`,
28
+ ];
29
+
30
+ for (const draft of drafts.slice(0, 10)) {
31
+ const confidence = Math.round((draft.confidence || 0) * 100);
32
+ const files = (draft.files || []).slice(0, 2).join(', ');
33
+ lines.push(`• [${(draft.suggestedType || 'decision').toUpperCase()}] ${draft.suggestedTitle || draft.draftId}`);
34
+ if (files) lines.push(` Files: ${files}`);
35
+ lines.push(` Confidence: ${confidence}% | Evidence: ${(draft.evidence || '').slice(0, 80)}`);
36
+ lines.push('');
37
+ }
38
+
39
+ if (drafts.length > 10) {
40
+ lines.push(` … and ${drafts.length - 10} more. Run \`lore drafts\` to see all.`);
41
+ }
42
+
43
+ return {
44
+ content: [{ type: 'text', text: lines.join('\n') }],
45
+ };
46
+ } catch (e) {
47
+ return {
48
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
49
+ isError: true,
50
+ };
51
+ }
52
+ }
53
+
54
+ module.exports = { toolDefinition, handler };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { readIndex, writeIndex, addEntryToIndex } = require('../../lib/index');
4
+ const { generateId, writeEntry } = require('../../lib/entries');
5
+
6
+ const toolDefinition = {
7
+ name: 'lore_log',
8
+ description: 'Create a new Lore entry to record an architectural decision, invariant, gotcha, or graveyard item. Use this when you make a significant technical decision that future developers (or AI) should know about.',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ type: {
13
+ type: 'string',
14
+ enum: ['decision', 'invariant', 'gotcha', 'graveyard'],
15
+ description: 'Type of entry to create',
16
+ },
17
+ title: {
18
+ type: 'string',
19
+ description: 'Short, descriptive title for the decision or note',
20
+ },
21
+ context: {
22
+ type: 'string',
23
+ description: 'Full explanation: what was decided, why, what problem it solves',
24
+ },
25
+ files: {
26
+ type: 'array',
27
+ items: { type: 'string' },
28
+ description: 'Files or directories this entry relates to (relative paths)',
29
+ },
30
+ tags: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: 'Optional tags for categorization',
34
+ },
35
+ alternatives: {
36
+ type: 'array',
37
+ items: { type: 'string' },
38
+ description: 'Alternative approaches that were considered',
39
+ },
40
+ tradeoffs: {
41
+ type: 'string',
42
+ description: 'Known tradeoffs or downsides of this decision',
43
+ },
44
+ },
45
+ required: ['type', 'title', 'context'],
46
+ },
47
+ };
48
+
49
+ async function handler(args) {
50
+ const { type, title, context, files = [], tags = [], alternatives = [], tradeoffs = '' } = args;
51
+
52
+ try {
53
+ const id = generateId(type, title);
54
+ const entry = {
55
+ id,
56
+ type,
57
+ title,
58
+ context,
59
+ files,
60
+ tags,
61
+ alternatives,
62
+ tradeoffs,
63
+ date: new Date().toISOString(),
64
+ };
65
+
66
+ writeEntry(entry);
67
+
68
+ const index = readIndex();
69
+ addEntryToIndex(index, entry);
70
+ writeIndex(index);
71
+
72
+ // Auto-embed if possible
73
+ try {
74
+ const { generateEmbedding, storeEmbedding } = require('../../lib/embeddings');
75
+ const text = [title, context, ...alternatives, tradeoffs, ...tags].join(' ');
76
+ const vector = await generateEmbedding(text);
77
+ storeEmbedding(id, vector);
78
+ } catch (e) {
79
+ // Ollama not available — skip embedding silently
80
+ }
81
+
82
+ return {
83
+ content: [{ type: 'text', text: `Created ${type}: ${title} (${id})` }],
84
+ };
85
+ } catch (e) {
86
+ return {
87
+ content: [{ type: 'text', text: `Error creating entry: ${e.message}` }],
88
+ isError: true,
89
+ };
90
+ }
91
+ }
92
+
93
+ module.exports = { toolDefinition, handler };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ const { readIndex } = require('../../lib/index');
4
+ const { readEntry } = require('../../lib/entries');
5
+ const { checkStaleness } = require('../../lib/stale');
6
+ const { readConfig } = require('../../lib/config');
7
+ const { getDaysSinceLastSession, updateLastSession } = require('../../lib/sessions');
8
+ const { getDraftCount } = require('../../lib/drafts');
9
+ const { loadHistory } = require('../../lib/scorer');
10
+
11
+ const toolDefinition = {
12
+ name: 'lore_overview',
13
+ description: "Get a high-level summary of this project's Lore knowledge base. Returns key decisions, invariants, gotchas, stale entries, Lore Score, and pending drafts. Call this at the start of a session.",
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ include_stale: {
18
+ type: 'boolean',
19
+ description: 'Include stale entry warnings (default: true)',
20
+ },
21
+ },
22
+ required: [],
23
+ },
24
+ };
25
+
26
+ async function handler(args) {
27
+ const includeStale = args.include_stale !== false;
28
+
29
+ try {
30
+ const config = readConfig();
31
+ const index = readIndex();
32
+ const daysSince = getDaysSinceLastSession();
33
+ updateLastSession();
34
+
35
+ const byType = { decision: [], invariant: [], gotcha: [], graveyard: [] };
36
+ const staleItems = [];
37
+
38
+ for (const entryPath of Object.values(index.entries)) {
39
+ const entry = readEntry(entryPath);
40
+ if (!entry) continue;
41
+ if (byType[entry.type]) byType[entry.type].push(entry);
42
+ if (includeStale) {
43
+ const staleFiles = checkStaleness(entry);
44
+ if (staleFiles.length > 0) staleItems.push({ entry, staleFiles });
45
+ }
46
+ }
47
+
48
+ const lines = [];
49
+ const projectName = config.project || 'this project';
50
+
51
+ if (daysSince !== null && daysSince >= 3) {
52
+ lines.push(`# Welcome back to ${projectName}`);
53
+ lines.push(`_(You've been away for ${daysSince} day${daysSince === 1 ? '' : 's'})_`);
54
+ lines.push('');
55
+ } else {
56
+ lines.push(`# Lore Overview — ${projectName}`);
57
+ lines.push('');
58
+ }
59
+
60
+ // Lore Score one-liner
61
+ const history = loadHistory();
62
+ if (history.length > 0) {
63
+ const latest = history[history.length - 1];
64
+ lines.push(`**Memory health: ${latest.score}/100**`);
65
+ lines.push('');
66
+ }
67
+
68
+ // Pending drafts
69
+ const draftCount = getDraftCount();
70
+ if (draftCount > 0) {
71
+ lines.push(`**${draftCount} unreviewed draft${draftCount === 1 ? '' : 's'} — run \`lore drafts\` to review**`);
72
+ lines.push('');
73
+ }
74
+
75
+ // Decisions
76
+ if (byType.decision.length > 0) {
77
+ lines.push(`## Architectural Decisions (${byType.decision.length})`);
78
+ for (const e of byType.decision.slice(0, 5)) {
79
+ lines.push(`• **${e.title}**`);
80
+ if (e.context) {
81
+ const summary = e.context.split('\n')[0].slice(0, 100);
82
+ lines.push(` ${summary}${e.context.length > 100 ? '…' : ''}`);
83
+ }
84
+ }
85
+ if (byType.decision.length > 5) {
86
+ lines.push(` _(and ${byType.decision.length - 5} more — use lore_search to explore)_`);
87
+ }
88
+ lines.push('');
89
+ }
90
+
91
+ // Invariants
92
+ if (byType.invariant.length > 0) {
93
+ lines.push(`## Invariants — Never Break These (${byType.invariant.length})`);
94
+ for (const e of byType.invariant) {
95
+ lines.push(`• **${e.title}**`);
96
+ if (e.context) {
97
+ lines.push(` ${e.context.split('\n')[0].slice(0, 100)}`);
98
+ }
99
+ }
100
+ lines.push('');
101
+ }
102
+
103
+ // Gotchas
104
+ if (byType.gotcha.length > 0) {
105
+ lines.push(`## Gotchas (${byType.gotcha.length})`);
106
+ for (const e of byType.gotcha.slice(0, 3)) {
107
+ lines.push(`• **${e.title}**`);
108
+ }
109
+ if (byType.gotcha.length > 3) {
110
+ lines.push(` _(and ${byType.gotcha.length - 3} more)_`);
111
+ }
112
+ lines.push('');
113
+ }
114
+
115
+ // Stale
116
+ if (includeStale && staleItems.length > 0) {
117
+ lines.push(`## ⚠️ Stale Entries (${staleItems.length})`);
118
+ for (const { entry, staleFiles } of staleItems.slice(0, 5)) {
119
+ lines.push(`• **${entry.title}** (${entry.id})`);
120
+ for (const s of staleFiles) {
121
+ const daysText = s.daysAgo === 0 ? 'today' : `${s.daysAgo}d ago`;
122
+ lines.push(` ${s.filepath} changed ${daysText}`);
123
+ }
124
+ }
125
+ lines.push('');
126
+ }
127
+
128
+ const total = Object.values(byType).reduce((sum, arr) => sum + arr.length, 0);
129
+ lines.push('---');
130
+ lines.push(`_Total: ${total} entries | Use lore_why <filepath> for file-specific context_`);
131
+
132
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
133
+ } catch (e) {
134
+ return {
135
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
136
+ isError: true,
137
+ };
138
+ }
139
+ }
140
+
141
+ module.exports = { toolDefinition, handler };