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,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 };
|
package/src/lib/stale.js
ADDED
|
@@ -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 };
|