cc-brain 0.1.1 → 0.1.3
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/cc-brain.js +9 -5
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install.js +22 -5
- package/scripts/uninstall.js +34 -6
- package/src/archive.js +45 -36
- package/src/loader.js +25 -12
- package/src/project-id.js +4 -2
- package/src/recall.js +45 -11
- package/src/saver.js +70 -24
- package/src/utils.js +54 -0
package/bin/cc-brain.js
CHANGED
|
@@ -15,13 +15,17 @@ const ROOT = join(__dirname, '..');
|
|
|
15
15
|
const command = process.argv[2];
|
|
16
16
|
const args = process.argv.slice(3);
|
|
17
17
|
|
|
18
|
-
// Check for bun, fallback to node
|
|
18
|
+
// Check for bun, fallback to node (fast path avoids spawning a process)
|
|
19
19
|
let runtime = 'node';
|
|
20
|
-
|
|
21
|
-
execSync('bun --version', { stdio: 'ignore' });
|
|
20
|
+
if (process.versions.bun || process.env.BUN_INSTALL) {
|
|
22
21
|
runtime = 'bun';
|
|
23
|
-
}
|
|
24
|
-
|
|
22
|
+
} else {
|
|
23
|
+
try {
|
|
24
|
+
execSync('bun --version', { stdio: 'ignore', timeout: 2000 });
|
|
25
|
+
runtime = 'bun';
|
|
26
|
+
} catch {
|
|
27
|
+
// bun not available, use node
|
|
28
|
+
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const commands = {
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/scripts/install.js
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
11
12
|
|
|
12
13
|
const HOME = homedir();
|
|
13
14
|
const CLAUDE_DIR = join(HOME, '.claude');
|
|
14
15
|
const BRAIN_DIR = join(CLAUDE_DIR, 'brain');
|
|
15
|
-
const
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const PROJECT_ROOT = join(__dirname, '..');
|
|
16
18
|
|
|
17
19
|
console.log('Installing cc-brain...\n');
|
|
18
20
|
|
|
@@ -68,10 +70,25 @@ const hooks = JSON.parse(readFileSync(join(PROJECT_ROOT, 'hooks', 'hooks.json'),
|
|
|
68
70
|
const loaderPath = join(PROJECT_ROOT, 'src', 'loader.js').replace(/\\/g, '/');
|
|
69
71
|
hooks.SessionStart[0].hooks[0].command = `bun "${loaderPath}"`;
|
|
70
72
|
|
|
71
|
-
// Merge hooks
|
|
73
|
+
// Merge hooks — preserve user's other hooks, replace/append ours
|
|
74
|
+
function isCcBrainHook(entry) {
|
|
75
|
+
if (!entry || !entry.hooks) return false;
|
|
76
|
+
return entry.hooks.some(h =>
|
|
77
|
+
(h.command && h.command.includes('loader.js')) ||
|
|
78
|
+
(h.prompt && h.prompt.includes('structured saver'))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mergeHookArray(existing, ours) {
|
|
83
|
+
if (!existing) return ours;
|
|
84
|
+
// Filter out old cc-brain hooks, then append ours
|
|
85
|
+
const filtered = existing.filter(entry => !isCcBrainHook(entry));
|
|
86
|
+
return [...filtered, ...ours];
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
settings.hooks = settings.hooks || {};
|
|
73
|
-
settings.hooks.SessionStart = hooks.SessionStart;
|
|
74
|
-
settings.hooks.PreCompact = hooks.PreCompact;
|
|
90
|
+
settings.hooks.SessionStart = mergeHookArray(settings.hooks.SessionStart, hooks.SessionStart);
|
|
91
|
+
settings.hooks.PreCompact = mergeHookArray(settings.hooks.PreCompact, hooks.PreCompact);
|
|
75
92
|
|
|
76
93
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
77
94
|
console.log(`\nUpdated: ${settingsPath}`);
|
package/scripts/uninstall.js
CHANGED
|
@@ -19,21 +19,49 @@ const purge = process.argv.includes('--purge');
|
|
|
19
19
|
|
|
20
20
|
console.log('Uninstalling cc-brain...\n');
|
|
21
21
|
|
|
22
|
-
// Remove hooks from settings.json
|
|
22
|
+
// Remove cc-brain hooks from settings.json (preserves user's other hooks)
|
|
23
|
+
function isCcBrainHook(entry) {
|
|
24
|
+
if (!entry || !entry.hooks) return false;
|
|
25
|
+
return entry.hooks.some(h =>
|
|
26
|
+
(h.command && h.command.includes('loader.js')) ||
|
|
27
|
+
(h.prompt && h.prompt.includes('structured saver'))
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
if (existsSync(SETTINGS_PATH)) {
|
|
24
|
-
|
|
32
|
+
let settings;
|
|
33
|
+
try {
|
|
34
|
+
settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`Error: Could not parse ${SETTINGS_PATH}`);
|
|
37
|
+
console.error(` ${e.message}`);
|
|
38
|
+
console.error('Fix the file manually or delete it, then re-run.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
25
41
|
|
|
26
42
|
if (settings.hooks) {
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
let removed = false;
|
|
44
|
+
|
|
45
|
+
for (const event of ['SessionStart', 'PreCompact']) {
|
|
46
|
+
if (Array.isArray(settings.hooks[event])) {
|
|
47
|
+
const before = settings.hooks[event].length;
|
|
48
|
+
settings.hooks[event] = settings.hooks[event].filter(entry => !isCcBrainHook(entry));
|
|
49
|
+
if (settings.hooks[event].length < before) removed = true;
|
|
50
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
29
53
|
|
|
30
54
|
// Clean up empty hooks object
|
|
31
55
|
if (Object.keys(settings.hooks).length === 0) {
|
|
32
56
|
delete settings.hooks;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
|
|
36
|
-
|
|
59
|
+
if (removed) {
|
|
60
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
61
|
+
console.log(`Removed cc-brain hooks from: ${SETTINGS_PATH}`);
|
|
62
|
+
} else {
|
|
63
|
+
console.log('No cc-brain hooks found in settings.json');
|
|
64
|
+
}
|
|
37
65
|
} else {
|
|
38
66
|
console.log('No hooks found in settings.json');
|
|
39
67
|
}
|
package/src/archive.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, readdirSync, statSync, unlinkSync, readFileSync } from 'fs'
|
|
|
16
16
|
import { join } from 'path';
|
|
17
17
|
import { homedir } from 'os';
|
|
18
18
|
import { getProjectId } from './project-id.js';
|
|
19
|
+
import { isMainModule } from './utils.js';
|
|
19
20
|
|
|
20
21
|
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
21
22
|
const PROJECT_ID = getProjectId();
|
|
@@ -70,11 +71,17 @@ function showStats() {
|
|
|
70
71
|
const oldest = entries[entries.length - 1];
|
|
71
72
|
const newest = entries[0];
|
|
72
73
|
|
|
74
|
+
const avgSize = totalSize / entries.length;
|
|
75
|
+
const spanMs = newest.date - oldest.date;
|
|
76
|
+
const spanDays = Math.round(spanMs / (1000 * 60 * 60 * 24));
|
|
77
|
+
|
|
73
78
|
console.log(`Archive Statistics`);
|
|
74
79
|
console.log(`──────────────────`);
|
|
75
80
|
console.log(`Location: ${ARCHIVE_DIR}`);
|
|
76
81
|
console.log(`Entries: ${entries.length}`);
|
|
77
82
|
console.log(`Total: ${(totalSize / 1024).toFixed(1)}kb`);
|
|
83
|
+
console.log(`Average: ${(avgSize / 1024).toFixed(1)}kb per entry`);
|
|
84
|
+
console.log(`Span: ${spanDays} days`);
|
|
78
85
|
console.log(`Oldest: ${oldest.date.toISOString().split('T')[0]} (${oldest.name})`);
|
|
79
86
|
console.log(`Newest: ${newest.date.toISOString().split('T')[0]} (${newest.name})`);
|
|
80
87
|
}
|
|
@@ -144,41 +151,42 @@ function autoPrune(days = 90, silent = false) {
|
|
|
144
151
|
}
|
|
145
152
|
|
|
146
153
|
// CLI
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
154
|
+
if (isMainModule(import.meta.url)) {
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
const command = args[0];
|
|
157
|
+
|
|
158
|
+
if (command === 'list') {
|
|
159
|
+
listArchive();
|
|
160
|
+
} else if (command === 'stats') {
|
|
161
|
+
showStats();
|
|
162
|
+
} else if (command === 'prune') {
|
|
163
|
+
const keepIdx = args.indexOf('--keep');
|
|
164
|
+
const olderIdx = args.indexOf('--older-than');
|
|
165
|
+
|
|
166
|
+
if (keepIdx !== -1) {
|
|
167
|
+
const keep = parseInt(args[keepIdx + 1], 10);
|
|
168
|
+
if (isNaN(keep) || keep <= 0) {
|
|
169
|
+
console.error('Error: --keep requires a positive number');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
pruneByCount(keep);
|
|
173
|
+
} else if (olderIdx !== -1) {
|
|
174
|
+
const ageStr = args[olderIdx + 1] || '';
|
|
175
|
+
if (!/^\d+d?$/.test(ageStr)) {
|
|
176
|
+
console.error('Error: --older-than requires format like "90" or "90d"');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const days = parseInt(ageStr, 10);
|
|
180
|
+
pruneByAge(days);
|
|
181
|
+
} else {
|
|
182
|
+
console.error('Error: prune requires --keep <n> or --older-than <days>');
|
|
170
183
|
process.exit(1);
|
|
171
184
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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]
|
|
185
|
+
} else if (command === 'auto-prune') {
|
|
186
|
+
const days = parseInt(args[1], 10) || 90;
|
|
187
|
+
autoPrune(days);
|
|
188
|
+
} else if (command === '--help' || command === '-h' || !command) {
|
|
189
|
+
console.log(`Usage: bun src/archive.js <command> [options]
|
|
182
190
|
|
|
183
191
|
Commands:
|
|
184
192
|
list List all archive entries
|
|
@@ -191,9 +199,10 @@ Examples:
|
|
|
191
199
|
bun src/archive.js list
|
|
192
200
|
bun src/archive.js prune --keep 20
|
|
193
201
|
bun src/archive.js prune --older-than 90d`);
|
|
194
|
-
} else {
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
} else {
|
|
203
|
+
console.error(`Unknown command: ${command}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
197
206
|
}
|
|
198
207
|
|
|
199
208
|
// Export for use as module
|
package/src/loader.js
CHANGED
|
@@ -49,6 +49,8 @@ function readIfExists(path, limit = null) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function ensureProjectDir() {
|
|
52
|
+
if (!PROJECT_ID) return;
|
|
53
|
+
|
|
52
54
|
const archiveDir = join(PROJECT_PATH, 'archive');
|
|
53
55
|
|
|
54
56
|
if (!existsSync(PROJECT_PATH)) {
|
|
@@ -81,7 +83,7 @@ function autoPruneArchive() {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
} catch (e) {
|
|
84
|
-
|
|
86
|
+
console.error(`[cc-brain] Auto-prune warning: ${e.message}`);
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
return deleted;
|
|
@@ -108,34 +110,45 @@ function loadBrain() {
|
|
|
108
110
|
|
|
109
111
|
const user = readIfExists(join(BRAIN_DIR, 'user.md'), LIMITS.user);
|
|
110
112
|
if (user && user.trim()) {
|
|
113
|
+
parts.push('<user-profile>');
|
|
111
114
|
parts.push(user);
|
|
115
|
+
parts.push('</user-profile>');
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
const prefs = readIfExists(join(BRAIN_DIR, 'preferences.md'), LIMITS.preferences);
|
|
115
119
|
if (prefs && prefs.trim()) {
|
|
120
|
+
parts.push('<preferences>');
|
|
116
121
|
parts.push(prefs);
|
|
122
|
+
parts.push('</preferences>');
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
// ═══════════════════════════════════════════
|
|
120
126
|
// TIER 2: Project context (current project)
|
|
121
127
|
// ═══════════════════════════════════════════
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
if (!PROJECT_ID) {
|
|
130
|
+
parts.push('<!-- T2 skipped: no project ID -->');
|
|
131
|
+
} else {
|
|
132
|
+
const projectContext = join(PROJECT_PATH, 'context.md');
|
|
133
|
+
const context = readIfExists(projectContext, LIMITS.context);
|
|
134
|
+
if (context && context.trim()) {
|
|
135
|
+
parts.push(`<project id="${PROJECT_ID}">`);
|
|
136
|
+
parts.push(context);
|
|
137
|
+
parts.push('</project>');
|
|
138
|
+
}
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
// ═══════════════════════════════════════════
|
|
131
142
|
// TIER 3: Archive (NOT loaded, just noted)
|
|
132
143
|
// ═══════════════════════════════════════════
|
|
133
144
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
if (PROJECT_ID) {
|
|
146
|
+
const archiveDir = join(PROJECT_PATH, 'archive');
|
|
147
|
+
if (existsSync(archiveDir)) {
|
|
148
|
+
const archiveFiles = readdirSync(archiveDir).filter(f => f.endsWith('.md'));
|
|
149
|
+
if (archiveFiles.length > 0) {
|
|
150
|
+
parts.push(`<archive entries="${archiveFiles.length}" hint="Use /recall to search" />`);
|
|
151
|
+
}
|
|
139
152
|
}
|
|
140
153
|
}
|
|
141
154
|
|
|
@@ -147,7 +160,7 @@ function loadBrain() {
|
|
|
147
160
|
const brain = loadBrain();
|
|
148
161
|
|
|
149
162
|
// Only output if there's actual content
|
|
150
|
-
if (brain.replace(/<\/?brain>/g, '').replace(/\[.*?\]/g, '').trim()) {
|
|
163
|
+
if (brain.replace(/<\/?brain>/g, '').replace(/<[^>]+\/>/g, '').replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').replace(/\[.*?\]/g, '').replace(/<!--.*?-->/g, '').trim()) {
|
|
151
164
|
console.log(brain);
|
|
152
165
|
console.log('\n---');
|
|
153
166
|
console.log('Above is your persistent memory. Use /save to update, /recall to search archive.');
|
package/src/project-id.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { randomUUID } from 'crypto';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
import { isMainModule } from './utils.js';
|
|
20
22
|
|
|
21
23
|
const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
22
24
|
const BRAIN_ID_FILE = join(PROJECT_DIR, '.brain-id');
|
|
@@ -47,12 +49,12 @@ function initBrainId() {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function getProjectBrainPath() {
|
|
50
|
-
const brainDir = process.env.CC_BRAIN_DIR || join(
|
|
52
|
+
const brainDir = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
51
53
|
return join(brainDir, 'projects', getProjectId());
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
// CLI (only when run directly)
|
|
55
|
-
if (import.meta.
|
|
57
|
+
if (isMainModule(import.meta.url)) {
|
|
56
58
|
const args = process.argv.slice(2);
|
|
57
59
|
|
|
58
60
|
if (args.includes('--init')) {
|
package/src/recall.js
CHANGED
|
@@ -14,15 +14,28 @@ import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { homedir } from 'os';
|
|
16
16
|
import { getProjectId } from './project-id.js';
|
|
17
|
+
import { isMainModule } from './utils.js';
|
|
17
18
|
|
|
18
19
|
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
19
20
|
const PROJECT_ID = getProjectId();
|
|
20
21
|
const ARCHIVE_DIR = join(BRAIN_DIR, 'projects', PROJECT_ID, 'archive');
|
|
21
22
|
const CONTEXT_DIR = join(BRAIN_DIR, 'projects', PROJECT_ID);
|
|
22
23
|
|
|
24
|
+
function escapeRegex(str) {
|
|
25
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildRegex(query) {
|
|
29
|
+
try {
|
|
30
|
+
return new RegExp(query, 'i');
|
|
31
|
+
} catch {
|
|
32
|
+
return new RegExp(escapeRegex(query), 'i');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
function searchArchive(query, options = {}) {
|
|
24
37
|
const results = [];
|
|
25
|
-
const regex =
|
|
38
|
+
const regex = buildRegex(query);
|
|
26
39
|
|
|
27
40
|
// Search archive files
|
|
28
41
|
if (existsSync(ARCHIVE_DIR)) {
|
|
@@ -62,12 +75,19 @@ function searchArchive(query, options = {}) {
|
|
|
62
75
|
const dateMatch = file.match(/(\d{4}-\d{2}-\d{2})/);
|
|
63
76
|
const date = dateMatch ? dateMatch[1] : 'unknown';
|
|
64
77
|
|
|
78
|
+
// Score: header matches (#) get bonus weight
|
|
79
|
+
let score = matches.length;
|
|
80
|
+
for (const m of matches) {
|
|
81
|
+
if (/^#{1,6}\s/.test(m.text)) score += 2;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
results.push({
|
|
66
85
|
file,
|
|
67
86
|
date,
|
|
68
87
|
path,
|
|
69
88
|
matches,
|
|
70
|
-
matchCount: matches.length
|
|
89
|
+
matchCount: matches.length,
|
|
90
|
+
score
|
|
71
91
|
});
|
|
72
92
|
}
|
|
73
93
|
}
|
|
@@ -90,27 +110,45 @@ function searchArchive(query, options = {}) {
|
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
if (matches.length > 0) {
|
|
113
|
+
let score = matches.length;
|
|
114
|
+
for (const m of matches) {
|
|
115
|
+
if (/^#{1,6}\s/.test(m.text)) score += 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
results.unshift({
|
|
94
119
|
file: 'context.md',
|
|
95
120
|
date: 'current',
|
|
96
121
|
path: contextPath,
|
|
97
122
|
matches,
|
|
98
|
-
matchCount: matches.length
|
|
123
|
+
matchCount: matches.length,
|
|
124
|
+
score
|
|
99
125
|
});
|
|
100
126
|
}
|
|
101
127
|
}
|
|
102
128
|
|
|
103
|
-
// Sort by
|
|
129
|
+
// Sort by score (most relevant first), then by date
|
|
104
130
|
results.sort((a, b) => {
|
|
105
131
|
if (a.date === 'current') return -1;
|
|
106
132
|
if (b.date === 'current') return 1;
|
|
107
|
-
if (b.
|
|
133
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
108
134
|
return b.date.localeCompare(a.date);
|
|
109
135
|
});
|
|
110
136
|
|
|
111
137
|
return results;
|
|
112
138
|
}
|
|
113
139
|
|
|
140
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
141
|
+
|
|
142
|
+
function highlight(text, query) {
|
|
143
|
+
if (!useColor) return text;
|
|
144
|
+
try {
|
|
145
|
+
const highlightRegex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
|
146
|
+
return text.replace(highlightRegex, '\x1b[33m$1\x1b[0m');
|
|
147
|
+
} catch {
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
114
152
|
function formatResults(results, query) {
|
|
115
153
|
if (results.length === 0) {
|
|
116
154
|
console.log(`No results found for: "${query}"`);
|
|
@@ -124,11 +162,7 @@ function formatResults(results, query) {
|
|
|
124
162
|
console.log(`── ${result.file} (${result.date}) ──`);
|
|
125
163
|
|
|
126
164
|
for (const match of result.matches) {
|
|
127
|
-
|
|
128
|
-
const highlighted = match.text.replace(
|
|
129
|
-
new RegExp(`(${query})`, 'gi'),
|
|
130
|
-
'\x1b[33m$1\x1b[0m'
|
|
131
|
-
);
|
|
165
|
+
const highlighted = highlight(match.text, query);
|
|
132
166
|
console.log(` L${match.line}: ${highlighted}`);
|
|
133
167
|
|
|
134
168
|
if (match.context && match.context.length > 0) {
|
|
@@ -142,7 +176,7 @@ function formatResults(results, query) {
|
|
|
142
176
|
}
|
|
143
177
|
|
|
144
178
|
// CLI
|
|
145
|
-
if (import.meta.
|
|
179
|
+
if (isMainModule(import.meta.url)) {
|
|
146
180
|
const args = process.argv.slice(2);
|
|
147
181
|
|
|
148
182
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
package/src/saver.js
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* }
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { existsSync, readFileSync,
|
|
21
|
+
import { existsSync, readFileSync, mkdirSync } from 'fs';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import { homedir } from 'os';
|
|
24
24
|
import { getProjectId } from './project-id.js';
|
|
25
|
+
import { safeWriteFileSync, isMainModule } from './utils.js';
|
|
25
26
|
|
|
26
27
|
const BRAIN_DIR = process.env.CC_BRAIN_DIR || join(homedir(), '.claude', 'brain');
|
|
27
28
|
const PROJECT_ID = getProjectId();
|
|
@@ -71,9 +72,11 @@ function formatSection(title, data) {
|
|
|
71
72
|
return lines.join('\n');
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function escapeRegex(str) {
|
|
76
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
function mergeContent(existing, updates, template) {
|
|
75
|
-
// For now, replace sections that are updated
|
|
76
|
-
// Future: smarter merging
|
|
77
80
|
if (!existing) {
|
|
78
81
|
existing = template || '';
|
|
79
82
|
}
|
|
@@ -81,15 +84,17 @@ function mergeContent(existing, updates, template) {
|
|
|
81
84
|
let result = existing;
|
|
82
85
|
|
|
83
86
|
for (const [section, content] of Object.entries(updates)) {
|
|
84
|
-
const sectionHeader = `## ${section}`;
|
|
85
87
|
const newContent = formatSection(section, content);
|
|
86
88
|
|
|
87
89
|
if (!newContent) continue;
|
|
88
90
|
|
|
89
|
-
//
|
|
90
|
-
const sectionRegex = new RegExp(
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Anchor to line start with m flag, escape section name for regex safety
|
|
92
|
+
const sectionRegex = new RegExp(
|
|
93
|
+
`^## ${escapeRegex(section)}[\\s\\S]*?(?=\\n## |$)`, 'm'
|
|
94
|
+
);
|
|
95
|
+
const replaced = result.replace(sectionRegex, newContent + '\n\n');
|
|
96
|
+
if (replaced !== result) {
|
|
97
|
+
result = replaced;
|
|
93
98
|
} else {
|
|
94
99
|
result = result.trim() + '\n\n' + newContent + '\n';
|
|
95
100
|
}
|
|
@@ -163,10 +168,43 @@ ${summary}
|
|
|
163
168
|
`;
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
const VALID_KEYS = new Set(['t1_user', 't1_prefs', 't2', 't3']);
|
|
172
|
+
|
|
173
|
+
function validateInputShape(input) {
|
|
174
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
175
|
+
return ['Input must be a JSON object'];
|
|
176
|
+
}
|
|
177
|
+
const errors = [];
|
|
178
|
+
for (const key of Object.keys(input)) {
|
|
179
|
+
if (!VALID_KEYS.has(key)) {
|
|
180
|
+
errors.push(`Unknown key: "${key}" (valid: ${[...VALID_KEYS].join(', ')})`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (input.t1_user !== undefined && (typeof input.t1_user !== 'object' || Array.isArray(input.t1_user))) {
|
|
184
|
+
errors.push('t1_user must be an object');
|
|
185
|
+
}
|
|
186
|
+
if (input.t1_prefs !== undefined && (typeof input.t1_prefs !== 'object' || Array.isArray(input.t1_prefs))) {
|
|
187
|
+
errors.push('t1_prefs must be an object');
|
|
188
|
+
}
|
|
189
|
+
if (input.t2 !== undefined && (typeof input.t2 !== 'object' || Array.isArray(input.t2))) {
|
|
190
|
+
errors.push('t2 must be an object');
|
|
191
|
+
}
|
|
192
|
+
if (input.t3 !== undefined && typeof input.t3 !== 'string') {
|
|
193
|
+
errors.push('t3 must be a string');
|
|
194
|
+
}
|
|
195
|
+
return errors;
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
function validateAndPrepare(input, dryRun = false) {
|
|
167
199
|
const changes = [];
|
|
168
200
|
const errors = [];
|
|
169
201
|
|
|
202
|
+
// Validate input shape
|
|
203
|
+
const shapeErrors = validateInputShape(input);
|
|
204
|
+
if (shapeErrors.length > 0) {
|
|
205
|
+
return { changes: [], errors: shapeErrors };
|
|
206
|
+
}
|
|
207
|
+
|
|
170
208
|
// T1 User
|
|
171
209
|
if (input.t1_user) {
|
|
172
210
|
const existing = readFile(PATHS.t1_user);
|
|
@@ -176,6 +214,9 @@ function validateAndPrepare(input, dryRun = false) {
|
|
|
176
214
|
if (lines > LIMITS.t1_user) {
|
|
177
215
|
errors.push(`t1_user exceeds limit: ${lines}/${LIMITS.t1_user} lines`);
|
|
178
216
|
} else {
|
|
217
|
+
if (lines > LIMITS.t1_user * 0.8) {
|
|
218
|
+
console.warn(`Warning: t1_user at ${lines}/${LIMITS.t1_user} lines (${Math.round(lines / LIMITS.t1_user * 100)}%)`);
|
|
219
|
+
}
|
|
179
220
|
changes.push({
|
|
180
221
|
tier: 't1_user',
|
|
181
222
|
path: PATHS.t1_user,
|
|
@@ -195,6 +236,9 @@ function validateAndPrepare(input, dryRun = false) {
|
|
|
195
236
|
if (lines > LIMITS.t1_prefs) {
|
|
196
237
|
errors.push(`t1_prefs exceeds limit: ${lines}/${LIMITS.t1_prefs} lines`);
|
|
197
238
|
} else {
|
|
239
|
+
if (lines > LIMITS.t1_prefs * 0.8) {
|
|
240
|
+
console.warn(`Warning: t1_prefs at ${lines}/${LIMITS.t1_prefs} lines (${Math.round(lines / LIMITS.t1_prefs * 100)}%)`);
|
|
241
|
+
}
|
|
198
242
|
changes.push({
|
|
199
243
|
tier: 't1_prefs',
|
|
200
244
|
path: PATHS.t1_prefs,
|
|
@@ -214,6 +258,9 @@ function validateAndPrepare(input, dryRun = false) {
|
|
|
214
258
|
if (lines > LIMITS.t2) {
|
|
215
259
|
errors.push(`t2 exceeds limit: ${lines}/${LIMITS.t2} lines`);
|
|
216
260
|
} else {
|
|
261
|
+
if (lines > LIMITS.t2 * 0.8) {
|
|
262
|
+
console.warn(`Warning: t2 at ${lines}/${LIMITS.t2} lines (${Math.round(lines / LIMITS.t2 * 100)}%)`);
|
|
263
|
+
}
|
|
217
264
|
changes.push({
|
|
218
265
|
tier: 't2',
|
|
219
266
|
path: PATHS.t2,
|
|
@@ -224,22 +271,21 @@ function validateAndPrepare(input, dryRun = false) {
|
|
|
224
271
|
}
|
|
225
272
|
}
|
|
226
273
|
|
|
227
|
-
// T3 Archive
|
|
274
|
+
// T3 Archive (each session gets its own file)
|
|
228
275
|
if (input.t3) {
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
: generateT3Content(input.t3);
|
|
276
|
+
const now = new Date();
|
|
277
|
+
const date = now.toISOString().split('T')[0];
|
|
278
|
+
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
|
|
279
|
+
const archivePath = join(PATHS.t3_dir, `${date}-${time}.md`);
|
|
280
|
+
const updated = generateT3Content(input.t3);
|
|
235
281
|
|
|
236
282
|
changes.push({
|
|
237
283
|
tier: 't3',
|
|
238
284
|
path: archivePath,
|
|
239
|
-
before:
|
|
285
|
+
before: null,
|
|
240
286
|
after: updated,
|
|
241
287
|
lines: countLines(updated),
|
|
242
|
-
isNew:
|
|
288
|
+
isNew: true
|
|
243
289
|
});
|
|
244
290
|
}
|
|
245
291
|
|
|
@@ -255,18 +301,18 @@ function showDiff(change) {
|
|
|
255
301
|
} else if (!change.before) {
|
|
256
302
|
console.log(`│ Status: CREATE`);
|
|
257
303
|
} else {
|
|
258
|
-
|
|
304
|
+
const beforeLines = countLines(change.before);
|
|
305
|
+
console.log(`│ Status: UPDATE (${beforeLines} → ${change.lines} lines)`);
|
|
259
306
|
}
|
|
260
307
|
|
|
261
308
|
console.log('├──────────────────────────────');
|
|
262
309
|
|
|
263
|
-
|
|
264
|
-
const preview = change.after.split('\n').slice(0, 10);
|
|
310
|
+
const preview = change.after.split('\n').slice(0, 15);
|
|
265
311
|
for (const line of preview) {
|
|
266
312
|
console.log(`│ ${line}`);
|
|
267
313
|
}
|
|
268
|
-
if (change.lines >
|
|
269
|
-
console.log(`│ ... (${change.lines -
|
|
314
|
+
if (change.lines > 15) {
|
|
315
|
+
console.log(`│ ... (${change.lines - 15} more lines)`);
|
|
270
316
|
}
|
|
271
317
|
|
|
272
318
|
console.log('└──────────────────────────────');
|
|
@@ -280,13 +326,13 @@ function applyChanges(changes) {
|
|
|
280
326
|
mkdirSync(dir, { recursive: true });
|
|
281
327
|
}
|
|
282
328
|
|
|
283
|
-
|
|
329
|
+
safeWriteFileSync(change.path, change.after);
|
|
284
330
|
console.log(`Saved: ${change.tier} → ${change.path}`);
|
|
285
331
|
}
|
|
286
332
|
}
|
|
287
333
|
|
|
288
334
|
// CLI
|
|
289
|
-
if (import.meta.
|
|
335
|
+
if (isMainModule(import.meta.url)) {
|
|
290
336
|
const args = process.argv.slice(2);
|
|
291
337
|
|
|
292
338
|
if (args.includes('--help') || args.includes('-h')) {
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync, renameSync, unlinkSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Atomic file write: write to temp file then rename.
|
|
7
|
+
* Handles Windows limitation where rename fails if target exists.
|
|
8
|
+
*/
|
|
9
|
+
export function safeWriteFileSync(filePath, content) {
|
|
10
|
+
const resolved = resolve(filePath);
|
|
11
|
+
const tmp = `${resolved}.tmp.${process.pid}`;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(tmp, content);
|
|
15
|
+
|
|
16
|
+
// On Windows, renameSync fails if target exists — remove first
|
|
17
|
+
if (process.platform === 'win32' && existsSync(resolved)) {
|
|
18
|
+
unlinkSync(resolved);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
renameSync(tmp, resolved);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// Clean up temp file on failure
|
|
24
|
+
try { unlinkSync(tmp); } catch {}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cross-runtime check for whether a module is the main entry point.
|
|
31
|
+
* Works in both Node and Bun.
|
|
32
|
+
*/
|
|
33
|
+
export function isMainModule(importMetaUrl) {
|
|
34
|
+
// Bun sets import.meta.main
|
|
35
|
+
if (typeof globalThis.Bun !== 'undefined') {
|
|
36
|
+
// When called from the main module in Bun, we compare resolved paths
|
|
37
|
+
try {
|
|
38
|
+
const modulePath = fileURLToPath(importMetaUrl);
|
|
39
|
+
const mainPath = process.argv[1];
|
|
40
|
+
return resolve(modulePath) === resolve(mainPath);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Node: compare resolved file paths
|
|
47
|
+
try {
|
|
48
|
+
const modulePath = fileURLToPath(importMetaUrl);
|
|
49
|
+
const mainPath = process.argv[1];
|
|
50
|
+
return resolve(modulePath) === resolve(mainPath);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|