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/connectors/github.js +37 -1
- package/bin/content-store.js +127 -38
- package/bin/digest.js +118 -37
- package/bin/distill.js +355 -0
- package/bin/memory-compare.js +171 -0
- package/bin/memory-report.sh +41 -0
- package/bin/slack-bot.js +2 -1
- package/bin/storage/sqlite-backend.js +44 -2
- package/bin/team-context.js +106 -39
- package/package.json +1 -1
- package/plugin/scripts/session-end.sh +1 -1
- package/plugin/skills/init-folder/SKILL.md +93 -0
- package/plugin/skills/init-team/SKILL.md +8 -0
- package/plugin/skills/session-protocol/SKILL.md +2 -0
- package/specializations/claude-code/CLAUDE.md-global-fragment.md +6 -2
- package/specializations/claude-code/CLAUDE.md-repo-fragment.md +2 -0
- package/specializations/claude-code/commands/init-folder.md +91 -0
- package/specializations/claude-code/commands/init-team.md +8 -0
- package/specializations/claude-code/hooks/session-end.sh +1 -1
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
|
@@ -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,
|
|
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, @
|
|
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));
|