wayfind 2.0.28 → 2.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/distill.js ADDED
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ const contentStore = require('./content-store');
4
+ const llm = require('./connectors/llm');
5
+
6
+ // ── Tier definitions ────────────────────────────────────────────────────────
7
+
8
+ const TIERS = {
9
+ daily: { minAgeDays: 3, maxAgeDays: 14 },
10
+ weekly: { minAgeDays: 14, maxAgeDays: 60 },
11
+ archive: { minAgeDays: 60, maxAgeDays: Infinity },
12
+ };
13
+
14
+ // ── Helpers ─────────────────────────────────────────────────────────────────
15
+
16
+ function daysAgo(dateStr) {
17
+ const now = new Date();
18
+ const then = new Date(dateStr + 'T00:00:00Z');
19
+ return Math.floor((now - then) / (1000 * 60 * 60 * 24));
20
+ }
21
+
22
+ function today() {
23
+ return new Date().toISOString().split('T')[0];
24
+ }
25
+
26
+ /**
27
+ * Compute Jaccard similarity between two titles (word-level).
28
+ * @param {string} a
29
+ * @param {string} b
30
+ * @returns {number} 0-1
31
+ */
32
+ function titleSimilarity(a, b) {
33
+ const wordsA = new Set((a || '').toLowerCase().split(/\s+/).filter(w => w.length > 2));
34
+ const wordsB = new Set((b || '').toLowerCase().split(/\s+/).filter(w => w.length > 2));
35
+ if (wordsA.size === 0 && wordsB.size === 0) return 1;
36
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
37
+ let intersection = 0;
38
+ for (const w of wordsA) {
39
+ if (wordsB.has(w)) intersection++;
40
+ }
41
+ const union = new Set([...wordsA, ...wordsB]).size;
42
+ return union === 0 ? 0 : intersection / union;
43
+ }
44
+
45
+ // ── Grouping ────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Group entries by (date, repo), then cluster by title similarity within each group.
49
+ * @param {Array<{id: string, entry: Object}>} entries
50
+ * @returns {Array<Array<{id: string, entry: Object}>>} Clusters of related entries
51
+ */
52
+ function groupEntries(entries) {
53
+ // Group by date+repo
54
+ const groups = {};
55
+ for (const item of entries) {
56
+ const key = `${item.entry.date}|${item.entry.repo}`;
57
+ if (!groups[key]) groups[key] = [];
58
+ groups[key].push(item);
59
+ }
60
+
61
+ // Within each group, cluster by title similarity
62
+ const clusters = [];
63
+ for (const items of Object.values(groups)) {
64
+ if (items.length === 1) {
65
+ clusters.push(items);
66
+ continue;
67
+ }
68
+
69
+ const assigned = new Set();
70
+ for (let i = 0; i < items.length; i++) {
71
+ if (assigned.has(i)) continue;
72
+ const cluster = [items[i]];
73
+ assigned.add(i);
74
+ for (let j = i + 1; j < items.length; j++) {
75
+ if (assigned.has(j)) continue;
76
+ if (titleSimilarity(items[i].entry.title, items[j].entry.title) > 0.8) {
77
+ cluster.push(items[j]);
78
+ assigned.add(j);
79
+ }
80
+ }
81
+ clusters.push(cluster);
82
+ }
83
+ }
84
+
85
+ return clusters;
86
+ }
87
+
88
+ // ── Deduplication ───────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Deduplicate a cluster of entries.
92
+ * - Exact content_hash matches: keep highest quality_score
93
+ * - Returns { canonical: [{id, entry}], absorbed: [ids] }
94
+ */
95
+ function deduplicateGroup(cluster) {
96
+ if (cluster.length <= 1) {
97
+ return { canonical: cluster, absorbed: [] };
98
+ }
99
+
100
+ // Group by content hash
101
+ const byHash = {};
102
+ for (const item of cluster) {
103
+ const hash = item.entry.contentHash;
104
+ if (!byHash[hash]) byHash[hash] = [];
105
+ byHash[hash].push(item);
106
+ }
107
+
108
+ const canonical = [];
109
+ const absorbed = [];
110
+
111
+ for (const items of Object.values(byHash)) {
112
+ if (items.length === 1) {
113
+ canonical.push(items[0]);
114
+ continue;
115
+ }
116
+ // Keep the one with highest quality score
117
+ items.sort((a, b) => (b.entry.qualityScore || 0) - (a.entry.qualityScore || 0));
118
+ canonical.push(items[0]);
119
+ for (let i = 1; i < items.length; i++) {
120
+ absorbed.push(items[i].id);
121
+ }
122
+ }
123
+
124
+ return { canonical, absorbed };
125
+ }
126
+
127
+ // ── Merging ─────────────────────────────────────────────────────────────────
128
+
129
+ const MERGE_PROMPTS = {
130
+ daily: `You are merging duplicate decision entries from the same day and repo.
131
+ Remove exact duplicates. Keep all distinct decisions with full reasoning.
132
+ Return a single markdown entry that preserves all unique information.
133
+ Format: Start with the repo and title, then include all distinct decisions with their reasoning.`,
134
+
135
+ weekly: `You are creating a weekly summary for a repo.
136
+ Combine related decisions into a concise per-repo weekly summary.
137
+ Preserve key reasoning and alternatives that were considered.
138
+ Remove redundancy and boilerplate.
139
+ Format: A clean markdown summary organized by topic.`,
140
+
141
+ archive: `You are creating a monthly archive summary.
142
+ Compress multiple entries into a brief summary with key decisions and outcomes only.
143
+ Focus on what was decided and why, not the details of how.
144
+ Format: A compact markdown summary, max 500 words.`,
145
+ };
146
+
147
+ /**
148
+ * Merge 2+ related entries into a single distilled entry via LLM.
149
+ * @param {Array<{id: string, entry: Object}>} entries
150
+ * @param {Object} llmConfig - { provider, model, api_key_env }
151
+ * @param {string} tier - 'daily', 'weekly', or 'archive'
152
+ * @returns {Promise<{content: string, title: string}>}
153
+ */
154
+ async function mergeEntries(entries, llmConfig, tier) {
155
+ const storePath = contentStore.DEFAULT_STORE_PATH;
156
+ const journalDir = contentStore.DEFAULT_JOURNAL_DIR;
157
+ const signalsDir = contentStore.DEFAULT_SIGNALS_DIR;
158
+
159
+ const parts = entries.map(({ id, entry }) => {
160
+ const content = contentStore.getEntryContent(id, { storePath, journalDir, signalsDir });
161
+ return content || `${entry.date} — ${entry.repo} — ${entry.title}`;
162
+ });
163
+
164
+ const systemPrompt = MERGE_PROMPTS[tier] || MERGE_PROMPTS.daily;
165
+ const userContent = parts.join('\n\n---\n\n');
166
+
167
+ const config = {
168
+ provider: llmConfig.provider || 'anthropic',
169
+ model: llmConfig.model || 'claude-haiku-4-5-20251001',
170
+ api_key_env: llmConfig.api_key_env || 'ANTHROPIC_API_KEY',
171
+ max_tokens: 2000,
172
+ };
173
+ const result = await llm.call(config, systemPrompt, userContent);
174
+
175
+ // Extract title from first line or generate one
176
+ const firstEntry = entries[0].entry;
177
+ const title = `[${tier}] ${firstEntry.repo} — ${firstEntry.date}`;
178
+
179
+ return { content: result, title };
180
+ }
181
+
182
+ // ── Main Pipeline ───────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Run the distillation pipeline.
186
+ * @param {Object} options
187
+ * @param {string} [options.tier] - 'daily', 'weekly', 'archive', or 'all'
188
+ * @param {boolean} [options.dryRun] - If true, don't write changes
189
+ * @param {Object} [options.llmConfig] - LLM config for merge operations
190
+ * @param {string} [options.storePath] - Content store path
191
+ * @returns {Promise<Object>} Stats: { grouped, deduped, merged, llmCalls }
192
+ */
193
+ async function distillEntries(options = {}) {
194
+ const tierName = options.tier || 'daily';
195
+ const dryRun = options.dryRun || false;
196
+ const storePath = options.storePath || contentStore.DEFAULT_STORE_PATH;
197
+
198
+ const tiersToRun = tierName === 'all'
199
+ ? ['daily', 'weekly', 'archive']
200
+ : [tierName];
201
+
202
+ const totalStats = { grouped: 0, deduped: 0, merged: 0, llmCalls: 0 };
203
+
204
+ for (const tier of tiersToRun) {
205
+ const tierDef = TIERS[tier];
206
+ if (!tierDef) {
207
+ console.log(`Unknown tier: ${tier}`);
208
+ continue;
209
+ }
210
+
211
+ // Calculate date range for this tier
212
+ const now = new Date();
213
+ const sinceDate = new Date(now);
214
+ sinceDate.setDate(sinceDate.getDate() - tierDef.maxAgeDays);
215
+ const untilDate = new Date(now);
216
+ untilDate.setDate(untilDate.getDate() - tierDef.minAgeDays);
217
+
218
+ const since = sinceDate.toISOString().split('T')[0];
219
+ const until = untilDate.toISOString().split('T')[0];
220
+
221
+ // Query entries eligible for this tier
222
+ const entries = contentStore.queryMetadata({ since, until, storePath });
223
+
224
+ // Filter: only raw entries that haven't been distilled yet
225
+ const eligible = entries.filter(({ entry }) => {
226
+ return (entry.distillTier === 'raw' || !entry.distillTier)
227
+ && !entry.distilledAt
228
+ && !entry.distilledFrom; // not already a distilled entry
229
+ });
230
+
231
+ if (eligible.length === 0) {
232
+ console.log(` ${tier}: no eligible entries`);
233
+ continue;
234
+ }
235
+
236
+ console.log(` ${tier}: ${eligible.length} eligible entries (${since} to ${until})`);
237
+
238
+ // Group and cluster
239
+ const clusters = groupEntries(eligible);
240
+ totalStats.grouped += clusters.length;
241
+
242
+ // Deduplicate within each cluster
243
+ let totalDeduped = 0;
244
+ const mergeableClusters = [];
245
+
246
+ for (const cluster of clusters) {
247
+ const { canonical, absorbed } = deduplicateGroup(cluster);
248
+ totalDeduped += absorbed.length;
249
+
250
+ if (!dryRun && absorbed.length > 0) {
251
+ // Mark absorbed entries
252
+ const backend = contentStore.getBackend(storePath);
253
+ const index = backend.loadIndex();
254
+ for (const absorbedId of absorbed) {
255
+ if (index.entries[absorbedId]) {
256
+ index.entries[absorbedId].distilledAt = Date.now();
257
+ index.entries[absorbedId].distillTier = tier;
258
+ }
259
+ }
260
+ backend.saveIndex(index);
261
+ }
262
+
263
+ // Only merge if there are 2+ canonical entries in the cluster
264
+ if (canonical.length >= 2) {
265
+ mergeableClusters.push(canonical);
266
+ }
267
+ }
268
+
269
+ totalStats.deduped += totalDeduped;
270
+
271
+ if (dryRun) {
272
+ console.log(` Would dedup: ${totalDeduped} entries`);
273
+ console.log(` Would merge: ${mergeableClusters.length} clusters (${mergeableClusters.reduce((s, c) => s + c.length, 0)} entries)`);
274
+ continue;
275
+ }
276
+
277
+ // Merge clusters via LLM
278
+ if (mergeableClusters.length > 0 && options.llmConfig) {
279
+ for (const cluster of mergeableClusters) {
280
+ try {
281
+ const { content, title } = await mergeEntries(cluster, options.llmConfig, tier);
282
+ totalStats.llmCalls++;
283
+
284
+ // Create distilled entry in the content store
285
+ const firstEntry = cluster[0].entry;
286
+ const absorbedIds = cluster.map(c => c.id);
287
+ const id = contentStore.generateEntryId(firstEntry.date, firstEntry.repo, title);
288
+ const hash = contentStore.contentHash(content);
289
+
290
+ const backend = contentStore.getBackend(storePath);
291
+ const index = backend.loadIndex();
292
+
293
+ index.entries[id] = {
294
+ date: firstEntry.date,
295
+ repo: firstEntry.repo,
296
+ title,
297
+ source: 'distilled',
298
+ user: '',
299
+ drifted: false,
300
+ contentHash: hash,
301
+ contentLength: content.length,
302
+ tags: firstEntry.tags || [],
303
+ hasEmbedding: false,
304
+ hasReasoning: true,
305
+ hasAlternatives: false,
306
+ qualityScore: 3, // distilled entries are high quality by definition
307
+ distillTier: tier,
308
+ distilledFrom: absorbedIds,
309
+ distilledAt: Date.now(),
310
+ };
311
+
312
+ // Mark source entries as absorbed
313
+ for (const item of cluster) {
314
+ if (index.entries[item.id]) {
315
+ index.entries[item.id].distilledAt = Date.now();
316
+ index.entries[item.id].distillTier = tier;
317
+ }
318
+ }
319
+
320
+ index.entryCount = Object.keys(index.entries).length;
321
+ backend.saveIndex(index);
322
+ totalStats.merged += cluster.length;
323
+ } catch (err) {
324
+ console.log(` Merge failed for cluster: ${err.message}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ // Log the distillation run
330
+ if (!dryRun) {
331
+ try {
332
+ const backend = contentStore.getBackend(storePath);
333
+ if (backend.db) {
334
+ backend.db.prepare(`
335
+ INSERT INTO distillation_log (run_at, tier, entries_input, entries_output, entries_merged, entries_deduped, llm_calls)
336
+ VALUES (?, ?, ?, ?, ?, ?, ?)
337
+ `).run(Date.now(), tier, eligible.length, eligible.length - totalDeduped - totalStats.merged, totalStats.merged, totalDeduped, totalStats.llmCalls);
338
+ }
339
+ } catch { /* non-fatal */ }
340
+ }
341
+ }
342
+
343
+ return totalStats;
344
+ }
345
+
346
+ // ── Exports ─────────────────────────────────────────────────────────────────
347
+
348
+ module.exports = {
349
+ distillEntries,
350
+ groupEntries,
351
+ deduplicateGroup,
352
+ mergeEntries,
353
+ titleSimilarity,
354
+ TIERS,
355
+ };
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const HOME = process.env.HOME || process.env.USERPROFILE;
6
+
7
+ /**
8
+ * Compare Claude Code auto-memory vs Wayfind global memory.
9
+ * Shows overlap, unique content in each, and freshness.
10
+ */
11
+ function compare() {
12
+ const autoMemoryRoot = path.join(HOME, '.claude', 'projects');
13
+ const wayfindMemory = path.join(HOME, '.claude', 'memory');
14
+
15
+ // Collect auto-memory entries
16
+ const autoEntries = [];
17
+ if (fs.existsSync(autoMemoryRoot)) {
18
+ for (const proj of fs.readdirSync(autoMemoryRoot)) {
19
+ const memDir = path.join(autoMemoryRoot, proj, 'memory');
20
+ if (!fs.existsSync(memDir)) continue;
21
+ for (const file of fs.readdirSync(memDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')) {
22
+ const fp = path.join(memDir, file);
23
+ const content = fs.readFileSync(fp, 'utf8');
24
+ const typeMatch = content.match(/^type:\s*(.+)$/m);
25
+ const descMatch = content.match(/^description:\s*(.+)$/m);
26
+ const stat = fs.statSync(fp);
27
+ autoEntries.push({
28
+ project: proj,
29
+ file,
30
+ type: typeMatch ? typeMatch[1].trim() : 'unknown',
31
+ description: descMatch ? descMatch[1].trim() : '',
32
+ size: content.length,
33
+ modified: stat.mtime,
34
+ daysAgo: Math.floor((Date.now() - stat.mtime) / (1000 * 60 * 60 * 24)),
35
+ });
36
+ }
37
+ }
38
+ }
39
+
40
+ // Collect Wayfind global memory entries
41
+ const wayfindEntries = [];
42
+ if (fs.existsSync(wayfindMemory)) {
43
+ for (const file of fs.readdirSync(wayfindMemory).filter(f => f.endsWith('.md'))) {
44
+ const fp = path.join(wayfindMemory, file);
45
+ const content = fs.readFileSync(fp, 'utf8');
46
+ const stat = fs.statSync(fp);
47
+ wayfindEntries.push({
48
+ file,
49
+ size: content.length,
50
+ modified: stat.mtime,
51
+ daysAgo: Math.floor((Date.now() - stat.mtime) / (1000 * 60 * 60 * 24)),
52
+ });
53
+ }
54
+ }
55
+
56
+ // Journal stats
57
+ const journalDir = path.join(wayfindMemory, 'journal');
58
+ let journalCount = 0;
59
+ let journalSize = 0;
60
+ let latestJournal = null;
61
+ if (fs.existsSync(journalDir)) {
62
+ const journals = fs.readdirSync(journalDir).filter(f => f.endsWith('.md'));
63
+ journalCount = journals.length;
64
+ for (const j of journals) {
65
+ const fp = path.join(journalDir, j);
66
+ journalSize += fs.statSync(fp).size;
67
+ const stat = fs.statSync(fp);
68
+ if (!latestJournal || stat.mtime > latestJournal) latestJournal = stat.mtime;
69
+ }
70
+ }
71
+
72
+ // Report
73
+ console.log('');
74
+ console.log('=== Memory Systems Comparison ===');
75
+ console.log('');
76
+
77
+ console.log('Claude Code Auto-Memory (per-project):');
78
+ const byType = {};
79
+ for (const e of autoEntries) {
80
+ byType[e.type] = (byType[e.type] || 0) + 1;
81
+ }
82
+ console.log(` ${autoEntries.length} entries across ${new Set(autoEntries.map(e => e.project)).size} projects`);
83
+ for (const [type, count] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
84
+ console.log(` ${type}: ${count}`);
85
+ }
86
+ const recentAuto = autoEntries.filter(e => e.daysAgo <= 7);
87
+ const staleAuto = autoEntries.filter(e => e.daysAgo > 30);
88
+ console.log(` Fresh (≤7d): ${recentAuto.length} | Stale (>30d): ${staleAuto.length}`);
89
+ console.log('');
90
+
91
+ console.log('Wayfind Global Memory:');
92
+ console.log(` ${wayfindEntries.length} topic files`);
93
+ console.log(` ${journalCount} journal entries (${Math.round(journalSize / 1024)}KB)`);
94
+ const recentWf = wayfindEntries.filter(e => e.daysAgo <= 7);
95
+ const staleWf = wayfindEntries.filter(e => e.daysAgo > 30);
96
+ console.log(` Fresh (≤7d): ${recentWf.length} | Stale (>30d): ${staleWf.length}`);
97
+ if (latestJournal) {
98
+ const jDaysAgo = Math.floor((Date.now() - latestJournal) / (1000 * 60 * 60 * 24));
99
+ console.log(` Latest journal: ${jDaysAgo}d ago`);
100
+ }
101
+ console.log('');
102
+
103
+ // Overlap detection: look for similar topics
104
+ console.log('Potential Overlap (auto-memory projects with Wayfind topic files on same subject):');
105
+ let overlapCount = 0;
106
+ for (const auto of autoEntries) {
107
+ const keywords = auto.file.replace(/\.md$/, '').replace(/^(feedback|project|reference|user)_/, '').split(/[-_]/).filter(w => w.length > 2);
108
+ for (const wf of wayfindEntries) {
109
+ const wfWords = wf.file.replace(/\.md$/, '').split(/[-_]/).filter(w => w.length > 2);
110
+ const overlap = keywords.filter(k => wfWords.some(w => w.includes(k) || k.includes(w)));
111
+ if (overlap.length >= 2) {
112
+ console.log(` AUTO: ${auto.file} (${auto.project.slice(-30)}) <-> WF: ${wf.file}`);
113
+ overlapCount++;
114
+ }
115
+ }
116
+ }
117
+ if (overlapCount === 0) console.log(' None detected');
118
+ console.log('');
119
+
120
+ // Secret scan across both memory systems
121
+ const SECRET_PATTERNS = [
122
+ /xoxb-[0-9A-Za-z-]+/, // Slack bot token
123
+ /xapp-[0-9A-Za-z-]+/, // Slack app token
124
+ /ghp_[0-9A-Za-z]+/, // GitHub PAT
125
+ /gho_[0-9A-Za-z]+/, // GitHub OAuth
126
+ /sk-[0-9A-Za-z]{20,}/, // OpenAI / generic secret key
127
+ /pat-na1-[0-9a-f-]+/, // HubSpot PAT
128
+ /ntn_[0-9A-Za-z]+/, // Notion token
129
+ /dG9r[0-9A-Za-z+/=]{20,}/, // Base64 "tok:" prefix (Intercom-style)
130
+ /AKIA[0-9A-Z]{16}/, // AWS access key
131
+ /\b[A-Za-z0-9_]{10,}\.[A-Za-z0-9_-]{20,}/, // Azure function key pattern (xxx.yyy)
132
+ ];
133
+
134
+ console.log('Secret Scan:');
135
+ let secretsFound = 0;
136
+ const allFiles = [
137
+ ...autoEntries.map(e => ({
138
+ path: path.join(autoMemoryRoot, e.project, 'memory', e.file),
139
+ label: `auto:${e.project.slice(-25)}/${e.file}`,
140
+ })),
141
+ ...wayfindEntries.map(e => ({
142
+ path: path.join(wayfindMemory, e.file),
143
+ label: `wayfind:${e.file}`,
144
+ })),
145
+ ];
146
+ for (const { path: fp, label } of allFiles) {
147
+ try {
148
+ const content = fs.readFileSync(fp, 'utf8');
149
+ for (const pattern of SECRET_PATTERNS) {
150
+ const match = content.match(pattern);
151
+ if (match) {
152
+ // Skip if it's inside a code example or command template (has $ prefix or is in backtick-quoted env var reference)
153
+ const line = content.split('\n').find(l => l.includes(match[0])) || '';
154
+ if (line.includes('$') && !line.includes('`' + match[0])) continue;
155
+ console.log(` ⚠ ${label} — matches ${pattern.source.slice(0, 20)}...`);
156
+ secretsFound++;
157
+ break;
158
+ }
159
+ }
160
+ } catch { /* skip unreadable */ }
161
+ }
162
+ if (secretsFound === 0) {
163
+ console.log(' Clean — no secrets detected in memory files');
164
+ }
165
+ }
166
+
167
+ if (require.main === module) {
168
+ compare();
169
+ }
170
+
171
+ module.exports = { compare };
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # Daily memory systems comparison report — posts to Slack via bot token.
3
+ # Add to crontab: 43 8 * * * /home/greg/repos/greg/wayfind/bin/memory-report.sh
4
+ #
5
+ # Runs on host (needs access to ~/.claude/projects for auto-memory).
6
+ # Pulls SLACK_BOT_TOKEN from the wayfind container if not set locally.
7
+
8
+ set -euo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ CHANNEL="${SLACK_MEMORY_REPORT_CHANNEL:-C0AHV3UUW67}"
11
+ DATE=$(date +%Y-%m-%d)
12
+ PATH="$HOME/.npm-global/bin:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
13
+
14
+ # Get Slack token from container if not in environment
15
+ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
16
+ SLACK_BOT_TOKEN=$(docker exec wayfind printenv SLACK_BOT_TOKEN 2>/dev/null || true)
17
+ fi
18
+ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
19
+ echo "[$DATE] No SLACK_BOT_TOKEN available — skipping report"
20
+ exit 0
21
+ fi
22
+
23
+ # Generate comparison
24
+ REPORT=$(node "$SCRIPT_DIR/memory-compare.js" 2>&1)
25
+
26
+ # Format for Slack
27
+ PAYLOAD=$(node -e "
28
+ const report = process.argv[1];
29
+ const date = process.argv[2];
30
+ const msg = ':brain: *Daily Memory Systems Report — ' + date + '*\n\`\`\`\n' + report.replace(/=== Memory Systems Comparison ===\n\n/, '') + '\n\`\`\`';
31
+ const body = JSON.stringify({ channel: process.argv[3], text: msg });
32
+ process.stdout.write(body);
33
+ " "$REPORT" "$DATE" "$CHANNEL")
34
+
35
+ # Post to Slack
36
+ curl -s -X POST https://slack.com/api/chat.postMessage \
37
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
38
+ -H "Content-Type: application/json" \
39
+ -d "$PAYLOAD" > /dev/null
40
+
41
+ echo "[$DATE] Memory report posted to Slack"
package/bin/slack-bot.js CHANGED
@@ -639,7 +639,8 @@ async function searchDecisionTrail(query, config) {
639
639
  }
640
640
  }
641
641
 
642
- return results || [];
642
+ // Deduplicate: prefer distilled entries over their raw sources
643
+ return contentStore.deduplicateResults(results || []);
643
644
  }
644
645
 
645
646
  /**
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS decisions (
40
40
  has_embedding INTEGER DEFAULT 0,
41
41
  has_reasoning INTEGER DEFAULT 0,
42
42
  has_alternatives INTEGER DEFAULT 0,
43
+ quality_score INTEGER DEFAULT 0,
44
+ distill_tier TEXT DEFAULT 'raw',
45
+ distilled_from TEXT DEFAULT NULL,
46
+ distilled_at INTEGER DEFAULT NULL,
43
47
  created_at INTEGER,
44
48
  updated_at INTEGER
45
49
  );
@@ -48,6 +52,19 @@ CREATE INDEX IF NOT EXISTS idx_decisions_date ON decisions(date);
48
52
  CREATE INDEX IF NOT EXISTS idx_decisions_repo ON decisions(repo);
49
53
  CREATE INDEX IF NOT EXISTS idx_decisions_source ON decisions(source);
50
54
  CREATE INDEX IF NOT EXISTS idx_decisions_user ON decisions(user);
55
+ CREATE INDEX IF NOT EXISTS idx_decisions_quality ON decisions(quality_score);
56
+ CREATE INDEX IF NOT EXISTS idx_decisions_tier ON decisions(distill_tier);
57
+
58
+ CREATE TABLE IF NOT EXISTS distillation_log (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ run_at INTEGER NOT NULL,
61
+ tier TEXT NOT NULL,
62
+ entries_input INTEGER DEFAULT 0,
63
+ entries_output INTEGER DEFAULT 0,
64
+ entries_merged INTEGER DEFAULT 0,
65
+ entries_deduped INTEGER DEFAULT 0,
66
+ llm_calls INTEGER DEFAULT 0
67
+ );
51
68
 
52
69
  CREATE TABLE IF NOT EXISTS embeddings (
53
70
  id TEXT PRIMARY KEY,
@@ -90,6 +107,10 @@ function entryToRow(id, entry) {
90
107
  has_embedding: entry.hasEmbedding ? 1 : 0,
91
108
  has_reasoning: entry.hasReasoning ? 1 : 0,
92
109
  has_alternatives: entry.hasAlternatives ? 1 : 0,
110
+ quality_score: entry.qualityScore || 0,
111
+ distill_tier: entry.distillTier || 'raw',
112
+ distilled_from: entry.distilledFrom ? JSON.stringify(entry.distilledFrom) : null,
113
+ distilled_at: entry.distilledAt || null,
93
114
  created_at: entry.createdAt || Date.now(),
94
115
  updated_at: Date.now(),
95
116
  };
@@ -109,6 +130,10 @@ function rowToEntry(row) {
109
130
  hasEmbedding: !!row.has_embedding,
110
131
  hasReasoning: !!row.has_reasoning,
111
132
  hasAlternatives: !!row.has_alternatives,
133
+ qualityScore: row.quality_score || 0,
134
+ distillTier: row.distill_tier || 'raw',
135
+ distilledFrom: row.distilled_from ? JSON.parse(row.distilled_from) : null,
136
+ distilledAt: row.distilled_at || null,
112
137
  };
113
138
  }
114
139
 
@@ -137,6 +162,21 @@ class SqliteBackend {
137
162
  if (!existing) {
138
163
  this.db.prepare('INSERT INTO metadata (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
139
164
  }
165
+
166
+ // Migrate existing databases: add new columns if they don't exist
167
+ const cols = this.db.prepare('PRAGMA table_info(decisions)').all().map(c => c.name);
168
+ if (!cols.includes('quality_score')) {
169
+ this.db.exec('ALTER TABLE decisions ADD COLUMN quality_score INTEGER DEFAULT 0');
170
+ }
171
+ if (!cols.includes('distill_tier')) {
172
+ this.db.exec('ALTER TABLE decisions ADD COLUMN distill_tier TEXT DEFAULT \'raw\'');
173
+ }
174
+ if (!cols.includes('distilled_from')) {
175
+ this.db.exec('ALTER TABLE decisions ADD COLUMN distilled_from TEXT DEFAULT NULL');
176
+ }
177
+ if (!cols.includes('distilled_at')) {
178
+ this.db.exec('ALTER TABLE decisions ADD COLUMN distilled_at INTEGER DEFAULT NULL');
179
+ }
140
180
  }
141
181
 
142
182
  close() {
@@ -173,10 +213,12 @@ class SqliteBackend {
173
213
  const stmt = this.db.prepare(`
174
214
  INSERT INTO decisions (id, date, repo, title, source, user, drifted,
175
215
  content_hash, content_length, tags, has_embedding, has_reasoning,
176
- has_alternatives, created_at, updated_at)
216
+ has_alternatives, quality_score, distill_tier, distilled_from, distilled_at,
217
+ created_at, updated_at)
177
218
  VALUES (@id, @date, @repo, @title, @source, @user, @drifted,
178
219
  @content_hash, @content_length, @tags, @has_embedding, @has_reasoning,
179
- @has_alternatives, @created_at, @updated_at)
220
+ @has_alternatives, @quality_score, @distill_tier, @distilled_from, @distilled_at,
221
+ @created_at, @updated_at)
180
222
  `);
181
223
  for (const [id, entry] of Object.entries(entries)) {
182
224
  stmt.run(entryToRow(id, entry));