cc-brain 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/README.md +134 -0
- package/bin/cc-brain.js +85 -0
- package/brain/preferences.md +13 -0
- package/brain/projects/example/archive/.gitkeep +0 -0
- package/brain/projects/example/context.md +16 -0
- package/brain/user.md +13 -0
- package/hooks/hooks.json +27 -0
- package/package.json +45 -0
- package/plugin.json +11 -0
- package/scripts/install.js +96 -0
- package/scripts/uninstall.js +62 -0
- package/skills/brain.md +55 -0
- package/skills/recall.md +44 -0
- package/skills/save.md +73 -0
- package/src/archive.js +200 -0
- package/src/loader.js +154 -0
- package/src/project-id.js +76 -0
- package/src/recall.js +187 -0
- package/src/saver-prompt.md +81 -0
- package/src/saver.js +382 -0
package/src/archive.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Archive Management
|
|
5
|
+
* List, prune, and manage T3 archive entries
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun src/archive.js list # List archive entries
|
|
9
|
+
* bun src/archive.js stats # Show archive statistics
|
|
10
|
+
* bun src/archive.js prune --keep 20 # Keep last 20 entries
|
|
11
|
+
* bun src/archive.js prune --older-than 90d # Delete entries older than 90 days
|
|
12
|
+
* bun src/archive.js auto-prune # Auto-prune (90 days), returns deleted list
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, statSync, unlinkSync, readFileSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { getProjectId } from './project-id.js';
|
|
19
|
+
|
|
20
|
+
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
21
|
+
const PROJECT_ID = getProjectId();
|
|
22
|
+
const ARCHIVE_DIR = join(BRAIN_DIR, 'projects', PROJECT_ID, 'archive');
|
|
23
|
+
|
|
24
|
+
function getArchiveEntries() {
|
|
25
|
+
if (!existsSync(ARCHIVE_DIR)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return readdirSync(ARCHIVE_DIR)
|
|
30
|
+
.filter(f => f.endsWith('.md'))
|
|
31
|
+
.map(f => {
|
|
32
|
+
const path = join(ARCHIVE_DIR, f);
|
|
33
|
+
const stat = statSync(path);
|
|
34
|
+
return {
|
|
35
|
+
name: f,
|
|
36
|
+
path,
|
|
37
|
+
date: stat.mtime,
|
|
38
|
+
size: stat.size
|
|
39
|
+
};
|
|
40
|
+
})
|
|
41
|
+
.sort((a, b) => b.date - a.date); // Newest first
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listArchive() {
|
|
45
|
+
const entries = getArchiveEntries();
|
|
46
|
+
|
|
47
|
+
if (entries.length === 0) {
|
|
48
|
+
console.log('Archive is empty.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`Archive: ${ARCHIVE_DIR}\n`);
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const date = entry.date.toISOString().split('T')[0];
|
|
55
|
+
const size = (entry.size / 1024).toFixed(1) + 'kb';
|
|
56
|
+
console.log(` ${date} ${size.padStart(8)} ${entry.name}`);
|
|
57
|
+
}
|
|
58
|
+
console.log(`\nTotal: ${entries.length} entries`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function showStats() {
|
|
62
|
+
const entries = getArchiveEntries();
|
|
63
|
+
|
|
64
|
+
if (entries.length === 0) {
|
|
65
|
+
console.log('Archive is empty.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const totalSize = entries.reduce((sum, e) => sum + e.size, 0);
|
|
70
|
+
const oldest = entries[entries.length - 1];
|
|
71
|
+
const newest = entries[0];
|
|
72
|
+
|
|
73
|
+
console.log(`Archive Statistics`);
|
|
74
|
+
console.log(`──────────────────`);
|
|
75
|
+
console.log(`Location: ${ARCHIVE_DIR}`);
|
|
76
|
+
console.log(`Entries: ${entries.length}`);
|
|
77
|
+
console.log(`Total: ${(totalSize / 1024).toFixed(1)}kb`);
|
|
78
|
+
console.log(`Oldest: ${oldest.date.toISOString().split('T')[0]} (${oldest.name})`);
|
|
79
|
+
console.log(`Newest: ${newest.date.toISOString().split('T')[0]} (${newest.name})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pruneByCount(keep) {
|
|
83
|
+
const entries = getArchiveEntries();
|
|
84
|
+
const toDelete = entries.slice(keep);
|
|
85
|
+
|
|
86
|
+
if (toDelete.length === 0) {
|
|
87
|
+
console.log(`Nothing to prune (${entries.length} entries, keeping ${keep})`);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const entry of toDelete) {
|
|
92
|
+
unlinkSync(entry.path);
|
|
93
|
+
console.log(`Deleted: ${entry.name}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`\nPruned ${toDelete.length} entries, kept ${keep}`);
|
|
97
|
+
return toDelete.map(e => e.name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pruneByAge(days) {
|
|
101
|
+
const entries = getArchiveEntries();
|
|
102
|
+
const cutoff = new Date();
|
|
103
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
104
|
+
|
|
105
|
+
const toDelete = entries.filter(e => e.date < cutoff);
|
|
106
|
+
|
|
107
|
+
if (toDelete.length === 0) {
|
|
108
|
+
console.log(`Nothing to prune (no entries older than ${days} days)`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const entry of toDelete) {
|
|
113
|
+
unlinkSync(entry.path);
|
|
114
|
+
console.log(`Deleted: ${entry.name}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(`\nPruned ${toDelete.length} entries older than ${days} days`);
|
|
118
|
+
return toDelete.map(e => e.name);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function autoPrune(days = 90, silent = false) {
|
|
122
|
+
const entries = getArchiveEntries();
|
|
123
|
+
const cutoff = new Date();
|
|
124
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
125
|
+
|
|
126
|
+
const toDelete = entries.filter(e => e.date < cutoff);
|
|
127
|
+
|
|
128
|
+
if (toDelete.length === 0) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const entry of toDelete) {
|
|
133
|
+
unlinkSync(entry.path);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!silent) {
|
|
137
|
+
console.log(`Auto-pruned ${toDelete.length} archive entries older than ${days} days:`);
|
|
138
|
+
for (const entry of toDelete) {
|
|
139
|
+
console.log(` - ${entry.name}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return toDelete.map(e => e.name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// CLI
|
|
147
|
+
const args = process.argv.slice(2);
|
|
148
|
+
const command = args[0];
|
|
149
|
+
|
|
150
|
+
if (command === 'list') {
|
|
151
|
+
listArchive();
|
|
152
|
+
} else if (command === 'stats') {
|
|
153
|
+
showStats();
|
|
154
|
+
} else if (command === 'prune') {
|
|
155
|
+
const keepIdx = args.indexOf('--keep');
|
|
156
|
+
const olderIdx = args.indexOf('--older-than');
|
|
157
|
+
|
|
158
|
+
if (keepIdx !== -1) {
|
|
159
|
+
const keep = parseInt(args[keepIdx + 1], 10);
|
|
160
|
+
if (isNaN(keep)) {
|
|
161
|
+
console.error('Error: --keep requires a number');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
pruneByCount(keep);
|
|
165
|
+
} else if (olderIdx !== -1) {
|
|
166
|
+
const ageStr = args[olderIdx + 1];
|
|
167
|
+
const days = parseInt(ageStr, 10);
|
|
168
|
+
if (isNaN(days)) {
|
|
169
|
+
console.error('Error: --older-than requires a number (e.g., 90d)');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
pruneByAge(days);
|
|
173
|
+
} else {
|
|
174
|
+
console.error('Error: prune requires --keep <n> or --older-than <days>');
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
} else if (command === 'auto-prune') {
|
|
178
|
+
const days = parseInt(args[1], 10) || 90;
|
|
179
|
+
autoPrune(days);
|
|
180
|
+
} else if (command === '--help' || command === '-h' || !command) {
|
|
181
|
+
console.log(`Usage: bun src/archive.js <command> [options]
|
|
182
|
+
|
|
183
|
+
Commands:
|
|
184
|
+
list List all archive entries
|
|
185
|
+
stats Show archive statistics
|
|
186
|
+
prune --keep <n> Keep only the last n entries
|
|
187
|
+
prune --older-than <n>d Delete entries older than n days
|
|
188
|
+
auto-prune [days] Auto-prune entries older than days (default: 90)
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
bun src/archive.js list
|
|
192
|
+
bun src/archive.js prune --keep 20
|
|
193
|
+
bun src/archive.js prune --older-than 90d`);
|
|
194
|
+
} else {
|
|
195
|
+
console.error(`Unknown command: ${command}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Export for use as module
|
|
200
|
+
export { getArchiveEntries, autoPrune, ARCHIVE_DIR };
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Brain Loader
|
|
5
|
+
* Runs on SessionStart - injects persistent memory into context
|
|
6
|
+
*
|
|
7
|
+
* TIERS:
|
|
8
|
+
* T1 (always): user.md, preferences.md ~40 lines each
|
|
9
|
+
* T2 (project): projects/{id}/context.md ~120 lines
|
|
10
|
+
* T3 (on-demand): projects/{id}/archive/ never auto-loaded
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Uses .brain-id for stable project identity
|
|
14
|
+
* - Auto-prunes archive entries older than 90 days (with warning)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { homedir } from 'os';
|
|
20
|
+
import { getProjectId, getProjectBrainPath } from './project-id.js';
|
|
21
|
+
|
|
22
|
+
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
23
|
+
const PROJECT_ID = getProjectId();
|
|
24
|
+
const PROJECT_PATH = getProjectBrainPath();
|
|
25
|
+
|
|
26
|
+
// Size limits (lines)
|
|
27
|
+
const LIMITS = {
|
|
28
|
+
user: 40,
|
|
29
|
+
preferences: 40,
|
|
30
|
+
context: 120
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Auto-prune settings
|
|
34
|
+
const AUTO_PRUNE_DAYS = 90;
|
|
35
|
+
|
|
36
|
+
function readIfExists(path, limit = null) {
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
|
|
39
|
+
let content = readFileSync(path, 'utf-8');
|
|
40
|
+
|
|
41
|
+
if (limit) {
|
|
42
|
+
const lines = content.split('\n');
|
|
43
|
+
if (lines.length > limit) {
|
|
44
|
+
content = lines.slice(0, limit).join('\n') + '\n... (truncated)';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureProjectDir() {
|
|
52
|
+
const archiveDir = join(PROJECT_PATH, 'archive');
|
|
53
|
+
|
|
54
|
+
if (!existsSync(PROJECT_PATH)) {
|
|
55
|
+
mkdirSync(PROJECT_PATH, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
if (!existsSync(archiveDir)) {
|
|
58
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function autoPruneArchive() {
|
|
63
|
+
const archiveDir = join(PROJECT_PATH, 'archive');
|
|
64
|
+
if (!existsSync(archiveDir)) return [];
|
|
65
|
+
|
|
66
|
+
const cutoff = new Date();
|
|
67
|
+
cutoff.setDate(cutoff.getDate() - AUTO_PRUNE_DAYS);
|
|
68
|
+
|
|
69
|
+
const deleted = [];
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const files = readdirSync(archiveDir).filter(f => f.endsWith('.md'));
|
|
73
|
+
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const path = join(archiveDir, file);
|
|
76
|
+
const stat = statSync(path);
|
|
77
|
+
|
|
78
|
+
if (stat.mtime < cutoff) {
|
|
79
|
+
unlinkSync(path);
|
|
80
|
+
deleted.push(file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Ignore errors during auto-prune
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return deleted;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadBrain() {
|
|
91
|
+
ensureProjectDir();
|
|
92
|
+
|
|
93
|
+
// Auto-prune old archive entries
|
|
94
|
+
const pruned = autoPruneArchive();
|
|
95
|
+
|
|
96
|
+
const parts = [];
|
|
97
|
+
|
|
98
|
+
parts.push('<brain>');
|
|
99
|
+
|
|
100
|
+
// Show pruned files warning
|
|
101
|
+
if (pruned.length > 0) {
|
|
102
|
+
parts.push(`[Auto-pruned ${pruned.length} archive entries older than ${AUTO_PRUNE_DAYS} days: ${pruned.join(', ')}]\n`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ═══════════════════════════════════════════
|
|
106
|
+
// TIER 1: Always loaded (core understanding)
|
|
107
|
+
// ═══════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
const user = readIfExists(join(BRAIN_DIR, 'user.md'), LIMITS.user);
|
|
110
|
+
if (user && user.trim()) {
|
|
111
|
+
parts.push(user);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const prefs = readIfExists(join(BRAIN_DIR, 'preferences.md'), LIMITS.preferences);
|
|
115
|
+
if (prefs && prefs.trim()) {
|
|
116
|
+
parts.push(prefs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════
|
|
120
|
+
// TIER 2: Project context (current project)
|
|
121
|
+
// ═══════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
const projectContext = join(PROJECT_PATH, 'context.md');
|
|
124
|
+
const context = readIfExists(projectContext, LIMITS.context);
|
|
125
|
+
if (context && context.trim()) {
|
|
126
|
+
parts.push(`## Project: ${PROJECT_ID}\n`);
|
|
127
|
+
parts.push(context);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ═══════════════════════════════════════════
|
|
131
|
+
// TIER 3: Archive (NOT loaded, just noted)
|
|
132
|
+
// ═══════════════════════════════════════════
|
|
133
|
+
|
|
134
|
+
const archiveDir = join(PROJECT_PATH, 'archive');
|
|
135
|
+
if (existsSync(archiveDir)) {
|
|
136
|
+
const archiveFiles = readdirSync(archiveDir).filter(f => f.endsWith('.md'));
|
|
137
|
+
if (archiveFiles.length > 0) {
|
|
138
|
+
parts.push(`\n[Archive: ${archiveFiles.length} entries. Use /recall to search.]`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
parts.push('</brain>');
|
|
143
|
+
|
|
144
|
+
return parts.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const brain = loadBrain();
|
|
148
|
+
|
|
149
|
+
// Only output if there's actual content
|
|
150
|
+
if (brain.replace(/<\/?brain>/g, '').replace(/\[.*?\]/g, '').trim()) {
|
|
151
|
+
console.log(brain);
|
|
152
|
+
console.log('\n---');
|
|
153
|
+
console.log('Above is your persistent memory. Use /save to update, /recall to search archive.');
|
|
154
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project Identity
|
|
5
|
+
* Provides stable project ID that survives directory renames
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. .brain-id file in project root (UUID)
|
|
9
|
+
* 2. Fall back to directory name
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* bun src/project-id.js # Output current project ID
|
|
13
|
+
* bun src/project-id.js --init # Create .brain-id if not exists
|
|
14
|
+
* bun src/project-id.js --path # Output project brain path
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
20
|
+
|
|
21
|
+
const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
22
|
+
const BRAIN_ID_FILE = join(PROJECT_DIR, '.brain-id');
|
|
23
|
+
|
|
24
|
+
function getProjectId() {
|
|
25
|
+
// Check for .brain-id file
|
|
26
|
+
if (existsSync(BRAIN_ID_FILE)) {
|
|
27
|
+
return readFileSync(BRAIN_ID_FILE, 'utf-8').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fall back to directory name
|
|
31
|
+
return PROJECT_DIR.split(/[/\\]/).pop();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function initBrainId() {
|
|
35
|
+
if (existsSync(BRAIN_ID_FILE)) {
|
|
36
|
+
const existing = readFileSync(BRAIN_ID_FILE, 'utf-8').trim();
|
|
37
|
+
console.log(`Already exists: ${BRAIN_ID_FILE}`);
|
|
38
|
+
console.log(`ID: ${existing}`);
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const id = randomUUID();
|
|
43
|
+
writeFileSync(BRAIN_ID_FILE, id + '\n');
|
|
44
|
+
console.log(`Created: ${BRAIN_ID_FILE}`);
|
|
45
|
+
console.log(`ID: ${id}`);
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getProjectBrainPath() {
|
|
50
|
+
const brainDir = process.env.CC_BRAIN_DIR || join(process.env.HOME || process.env.USERPROFILE, '.claude', 'brain');
|
|
51
|
+
return join(brainDir, 'projects', getProjectId());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// CLI (only when run directly)
|
|
55
|
+
if (import.meta.main) {
|
|
56
|
+
const args = process.argv.slice(2);
|
|
57
|
+
|
|
58
|
+
if (args.includes('--init')) {
|
|
59
|
+
initBrainId();
|
|
60
|
+
} else if (args.includes('--path')) {
|
|
61
|
+
console.log(getProjectBrainPath());
|
|
62
|
+
} else if (args.includes('--help') || args.includes('-h')) {
|
|
63
|
+
console.log(`Usage: bun src/project-id.js [options]
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
(none) Output current project ID
|
|
67
|
+
--init Create .brain-id file if not exists
|
|
68
|
+
--path Output full path to project brain directory
|
|
69
|
+
--help Show this help`);
|
|
70
|
+
} else {
|
|
71
|
+
console.log(getProjectId());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Export for use as module
|
|
76
|
+
export { getProjectId, initBrainId, getProjectBrainPath, BRAIN_ID_FILE };
|
package/src/recall.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Archive Search (Recall)
|
|
5
|
+
* Grep-based search through T3 archive
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun src/recall.js "search term" # Search archive for term
|
|
9
|
+
* bun src/recall.js "regex.*pattern" # Regex search
|
|
10
|
+
* bun src/recall.js "term" --context # Show surrounding context
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
import { getProjectId } from './project-id.js';
|
|
17
|
+
|
|
18
|
+
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
19
|
+
const PROJECT_ID = getProjectId();
|
|
20
|
+
const ARCHIVE_DIR = join(BRAIN_DIR, 'projects', PROJECT_ID, 'archive');
|
|
21
|
+
const CONTEXT_DIR = join(BRAIN_DIR, 'projects', PROJECT_ID);
|
|
22
|
+
|
|
23
|
+
function searchArchive(query, options = {}) {
|
|
24
|
+
const results = [];
|
|
25
|
+
const regex = new RegExp(query, 'gi');
|
|
26
|
+
|
|
27
|
+
// Search archive files
|
|
28
|
+
if (existsSync(ARCHIVE_DIR)) {
|
|
29
|
+
const files = readdirSync(ARCHIVE_DIR).filter(f => f.endsWith('.md'));
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const path = join(ARCHIVE_DIR, file);
|
|
33
|
+
const content = readFileSync(path, 'utf-8');
|
|
34
|
+
const lines = content.split('\n');
|
|
35
|
+
|
|
36
|
+
const matches = [];
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
if (regex.test(lines[i])) {
|
|
39
|
+
const match = {
|
|
40
|
+
line: i + 1,
|
|
41
|
+
text: lines[i].trim(),
|
|
42
|
+
context: []
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (options.context) {
|
|
46
|
+
// Add surrounding lines
|
|
47
|
+
const start = Math.max(0, i - 2);
|
|
48
|
+
const end = Math.min(lines.length - 1, i + 2);
|
|
49
|
+
for (let j = start; j <= end; j++) {
|
|
50
|
+
if (j !== i) {
|
|
51
|
+
match.context.push({ line: j + 1, text: lines[j].trim() });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
matches.push(match);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (matches.length > 0) {
|
|
61
|
+
// Extract date from filename (e.g., 2025-01-31.md)
|
|
62
|
+
const dateMatch = file.match(/(\d{4}-\d{2}-\d{2})/);
|
|
63
|
+
const date = dateMatch ? dateMatch[1] : 'unknown';
|
|
64
|
+
|
|
65
|
+
results.push({
|
|
66
|
+
file,
|
|
67
|
+
date,
|
|
68
|
+
path,
|
|
69
|
+
matches,
|
|
70
|
+
matchCount: matches.length
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Also search current context.md
|
|
77
|
+
const contextPath = join(CONTEXT_DIR, 'context.md');
|
|
78
|
+
if (existsSync(contextPath)) {
|
|
79
|
+
const content = readFileSync(contextPath, 'utf-8');
|
|
80
|
+
const lines = content.split('\n');
|
|
81
|
+
|
|
82
|
+
const matches = [];
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
if (regex.test(lines[i])) {
|
|
85
|
+
matches.push({
|
|
86
|
+
line: i + 1,
|
|
87
|
+
text: lines[i].trim()
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (matches.length > 0) {
|
|
93
|
+
results.unshift({
|
|
94
|
+
file: 'context.md',
|
|
95
|
+
date: 'current',
|
|
96
|
+
path: contextPath,
|
|
97
|
+
matches,
|
|
98
|
+
matchCount: matches.length
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sort by match count (most relevant first), then by date
|
|
104
|
+
results.sort((a, b) => {
|
|
105
|
+
if (a.date === 'current') return -1;
|
|
106
|
+
if (b.date === 'current') return 1;
|
|
107
|
+
if (b.matchCount !== a.matchCount) return b.matchCount - a.matchCount;
|
|
108
|
+
return b.date.localeCompare(a.date);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatResults(results, query) {
|
|
115
|
+
if (results.length === 0) {
|
|
116
|
+
console.log(`No results found for: "${query}"`);
|
|
117
|
+
console.log(`\nArchive location: ${ARCHIVE_DIR}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`Found ${results.reduce((sum, r) => sum + r.matchCount, 0)} matches in ${results.length} files for: "${query}"\n`);
|
|
122
|
+
|
|
123
|
+
for (const result of results) {
|
|
124
|
+
console.log(`── ${result.file} (${result.date}) ──`);
|
|
125
|
+
|
|
126
|
+
for (const match of result.matches) {
|
|
127
|
+
// Highlight the match
|
|
128
|
+
const highlighted = match.text.replace(
|
|
129
|
+
new RegExp(`(${query})`, 'gi'),
|
|
130
|
+
'\x1b[33m$1\x1b[0m'
|
|
131
|
+
);
|
|
132
|
+
console.log(` L${match.line}: ${highlighted}`);
|
|
133
|
+
|
|
134
|
+
if (match.context && match.context.length > 0) {
|
|
135
|
+
for (const ctx of match.context) {
|
|
136
|
+
console.log(` ${ctx.line}: ${ctx.text}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.log('');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// CLI
|
|
145
|
+
if (import.meta.main) {
|
|
146
|
+
const args = process.argv.slice(2);
|
|
147
|
+
|
|
148
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
149
|
+
console.log(`Usage: bun src/recall.js <query> [options]
|
|
150
|
+
|
|
151
|
+
Search the brain archive for past context and decisions.
|
|
152
|
+
|
|
153
|
+
Arguments:
|
|
154
|
+
query Search term or regex pattern
|
|
155
|
+
|
|
156
|
+
Options:
|
|
157
|
+
--context Show surrounding lines for each match
|
|
158
|
+
--json Output results as JSON
|
|
159
|
+
--help Show this help
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
bun src/recall.js "authentication"
|
|
163
|
+
bun src/recall.js "why.*choose" --context
|
|
164
|
+
bun src/recall.js "API" --json`);
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const query = args.find(a => !a.startsWith('--'));
|
|
169
|
+
const showContext = args.includes('--context');
|
|
170
|
+
const jsonOutput = args.includes('--json');
|
|
171
|
+
|
|
172
|
+
if (!query) {
|
|
173
|
+
console.error('Error: search query required');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const results = searchArchive(query, { context: showContext });
|
|
178
|
+
|
|
179
|
+
if (jsonOutput) {
|
|
180
|
+
console.log(JSON.stringify(results, null, 2));
|
|
181
|
+
} else {
|
|
182
|
+
formatResults(results, query);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Export for use as module
|
|
187
|
+
export { searchArchive, ARCHIVE_DIR };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Brain Saver
|
|
2
|
+
|
|
3
|
+
Save session context using the structured saver tool.
|
|
4
|
+
|
|
5
|
+
## Memory Tiers
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
~/.claude/brain/
|
|
9
|
+
│
|
|
10
|
+
├── user.md ← T1: Who they are (40 lines max)
|
|
11
|
+
├── preferences.md ← T1: How they code (40 lines max)
|
|
12
|
+
│
|
|
13
|
+
└── projects/{id}/
|
|
14
|
+
├── context.md ← T2: Current state (120 lines max)
|
|
15
|
+
└── archive/ ← T3: History (unlimited)
|
|
16
|
+
└── {date}.md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What to Save
|
|
20
|
+
|
|
21
|
+
**T1 (rare)** - Only genuine new insights:
|
|
22
|
+
- Communication style discoveries
|
|
23
|
+
- Thinking patterns observed
|
|
24
|
+
- Strong preferences revealed
|
|
25
|
+
- Pet peeves encountered
|
|
26
|
+
|
|
27
|
+
**T2 (common)** - Project state:
|
|
28
|
+
- What the project is
|
|
29
|
+
- Current focus/active work
|
|
30
|
+
- Recent decisions + rationale
|
|
31
|
+
- Key files and purposes
|
|
32
|
+
- Blockers
|
|
33
|
+
|
|
34
|
+
**T3 (optional)** - Significant sessions:
|
|
35
|
+
- Major features implemented
|
|
36
|
+
- Important decisions made
|
|
37
|
+
- Problems solved
|
|
38
|
+
|
|
39
|
+
## How to Save
|
|
40
|
+
|
|
41
|
+
Build a JSON payload with only the tiers that have new information:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"t1_user": {
|
|
46
|
+
"Section Name": ["item1", "item2"]
|
|
47
|
+
},
|
|
48
|
+
"t1_prefs": {
|
|
49
|
+
"Section Name": ["preference1"]
|
|
50
|
+
},
|
|
51
|
+
"t2": {
|
|
52
|
+
"what": "One-line description",
|
|
53
|
+
"focus": ["task1", "task2"],
|
|
54
|
+
"decisions": {
|
|
55
|
+
"Decision": "rationale"
|
|
56
|
+
},
|
|
57
|
+
"files": {
|
|
58
|
+
"path/file.js": "purpose"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"t3": "Summary of significant work."
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Preview with dry-run:
|
|
66
|
+
```bash
|
|
67
|
+
bun src/saver.js --dry-run --json '<payload>'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Apply changes:
|
|
71
|
+
```bash
|
|
72
|
+
bun src/saver.js --json '<payload>'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Rules
|
|
76
|
+
|
|
77
|
+
- REPLACE outdated info, don't append endlessly
|
|
78
|
+
- Decisions need rationale (the "why")
|
|
79
|
+
- Keep within size limits
|
|
80
|
+
- Only save what helps future sessions
|
|
81
|
+
- T1 updates should be rare and meaningful
|