cc-brain 0.1.2 β†’ 0.1.4

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <h1 align="center">🧠 cc-brain</h1>
2
+ <h1 align="center">cc-brain</h1>
3
3
  <p align="center">
4
4
  <strong>Persistent memory for Claude Code</strong><br>
5
5
  <em>Remember context across sessions</em>
@@ -14,9 +14,10 @@
14
14
  </p>
15
15
 
16
16
  <p align="center">
17
- <a href="#installation">Installation</a> β€’
18
- <a href="#how-it-works">How It Works</a> β€’
19
- <a href="#commands">Commands</a> β€’
17
+ <a href="#installation">Installation</a> -
18
+ <a href="#how-it-works">How It Works</a> -
19
+ <a href="#architecture">Architecture</a> -
20
+ <a href="#commands">Commands</a> -
20
21
  <a href="#cli">CLI</a>
21
22
  </p>
22
23
 
@@ -24,7 +25,7 @@
24
25
 
25
26
  ## The Problem
26
27
 
27
- Claude Code sessions are **ephemeral**. When context fills up or you start a new session, everything is forgotten. Your preferences, project decisions, debugging history β€” gone.
28
+ Claude Code sessions are **ephemeral**. When context fills up or you start a new session, everything is forgotten. Your preferences, project decisions, debugging history -- gone.
28
29
 
29
30
  ## The Solution
30
31
 
@@ -70,7 +71,7 @@ claude plugins add cc-brain
70
71
  └── projects/{id}/
71
72
  β”œβ”€β”€ context.md # Current project state
72
73
  └── archive/ # Session history
73
- └── 2025-01-31.md
74
+ └── 2025-01-31-143052.md
74
75
  ```
75
76
 
76
77
  ### Memory Tiers
@@ -85,17 +86,65 @@ claude plugins add cc-brain
85
86
 
86
87
  ```
87
88
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
88
- β”‚ Session Start │────▢│ Brain Loaded │────▢│ You Work... β”‚
89
+ β”‚ Session Start │────>β”‚ Brain Loaded │────>β”‚ You Work... β”‚
89
90
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
90
91
  β”‚
91
92
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
92
- β”‚ Next Session │◀────│ Brain Saved β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
93
+ β”‚ Next Session β”‚<────│ Brain Saved β”‚<β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
93
94
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
94
95
  (before compaction)
95
96
  ```
96
97
 
97
98
  ---
98
99
 
100
+ ## Architecture
101
+
102
+ ```
103
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
104
+ β”‚ Claude Code β”‚
105
+ β”‚ β”‚
106
+ β”‚ SessionStart hook ──> loader.js ──> XML output β”‚
107
+ β”‚ β”‚ β”‚
108
+ β”‚ β”œβ”€β”€ T1: <user-profile> β”‚
109
+ β”‚ β”œβ”€β”€ T1: <preferences> β”‚
110
+ β”‚ β”œβ”€β”€ T2: <project id="..."> β”‚
111
+ β”‚ └── T3: <archive hint /> β”‚
112
+ β”‚ β”‚
113
+ β”‚ PreCompact hook ──> saver.js ──> atomic writes β”‚
114
+ β”‚ β”‚ β”‚
115
+ β”‚ β”œβ”€β”€ validates input shape β”‚
116
+ β”‚ β”œβ”€β”€ enforces line limits β”‚
117
+ β”‚ β”œβ”€β”€ warns at 80% capacity β”‚
118
+ β”‚ └── safeWriteFileSync() β”‚
119
+ β”‚ β”‚
120
+ β”‚ /recall skill ──> recall.js ──> scored results β”‚
121
+ β”‚ β”‚ β”‚
122
+ β”‚ β”œβ”€β”€ regex with safe fallback β”‚
123
+ β”‚ β”œβ”€β”€ header match scoring β”‚
124
+ β”‚ └── TTY-aware color output β”‚
125
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
126
+ β”‚
127
+ v
128
+ ~/.claude/brain/ (persistent)
129
+ ```
130
+
131
+ ### Data Flow
132
+
133
+ 1. **SessionStart** - `loader.js` loads T1 + T2 into XML-tagged sections, auto-prunes archives older than 90 days
134
+ 2. **PreCompact** - Agent analyzes session, calls `saver.js` with structured JSON payload
135
+ 3. **Manual** - `/save` skill triggers the saver, `/recall` searches the archive
136
+ 4. **Archive** - Each save creates `YYYY-MM-DD-HHMMSS.md` (one file per session, no collisions)
137
+
138
+ ### Design Decisions
139
+
140
+ - **Atomic writes** - All file writes use temp file + rename to prevent corruption
141
+ - **Cross-runtime** - Works in both Node (>=18) and Bun via `isMainModule()` helper
142
+ - **Hook preservation** - Install/uninstall detect cc-brain hooks by content matching, never overwrite user's other hooks
143
+ - **XML output** - Loader wraps content in semantic tags (`<user-profile>`, `<preferences>`, `<project>`) for reliable Claude parsing
144
+ - **Input validation** - Saver checks shape, key names, and types before writing
145
+
146
+ ---
147
+
99
148
  ## Commands
100
149
 
101
150
  Use these skills in Claude Code:
@@ -112,14 +161,15 @@ Use these skills in Claude Code:
112
161
 
113
162
  ```bash
114
163
  # Setup
115
- cc-brain install # Install hooks
116
- cc-brain uninstall # Remove hooks
164
+ cc-brain install # Install hooks (merges with existing)
165
+ cc-brain uninstall # Remove hooks (preserves user hooks)
117
166
  cc-brain uninstall --purge # Remove everything
118
167
 
119
168
  # Search & Archive
120
- cc-brain recall "query" # Search archive
169
+ cc-brain recall "query" # Search archive (scored results)
170
+ cc-brain recall "query" --context # Show surrounding lines
121
171
  cc-brain archive list # List entries
122
- cc-brain archive stats # Show statistics
172
+ cc-brain archive stats # Statistics (avg size, time span)
123
173
  cc-brain archive prune --keep 20
124
174
 
125
175
  # Project Identity
@@ -127,11 +177,52 @@ cc-brain project-id --init # Create stable .brain-id
127
177
 
128
178
  # Manual Save
129
179
  cc-brain save --dry-run --json '{"t2": {"focus": "testing"}}'
130
- cc-brain save --json '{"t2": {"focus": "testing"}}'
180
+ cc-brain save --json '{"t3": "Added search functionality"}'
131
181
  ```
132
182
 
133
183
  ---
134
184
 
185
+ ## Project Structure
186
+
187
+ ```
188
+ src/
189
+ utils.js Shared utilities (safeWriteFileSync, isMainModule)
190
+ loader.js Loads T1+T2 into XML context, auto-prunes archive
191
+ saver.js Structured saver with validation, limits, atomic writes
192
+ recall.js Scored archive search with safe regex and color detection
193
+ archive.js Archive management (list, prune, stats)
194
+ project-id.js Stable project identity (.brain-id)
195
+ bin/
196
+ cc-brain.js CLI entry point with fast runtime detection
197
+ hooks/
198
+ hooks.json Hook configuration (SessionStart, PreCompact)
199
+ skills/
200
+ save.md /save skill
201
+ recall.md /recall skill
202
+ brain.md /brain skill
203
+ scripts/
204
+ install.js Install hooks (merges, preserves existing)
205
+ uninstall.js Remove hooks (filters cc-brain only)
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Features
211
+
212
+ - **Persistent memory** across sessions and compactions
213
+ - **Structured saving** with JSON validation and dry-run preview
214
+ - **Input validation** with shape checking, key allowlist, type enforcement
215
+ - **Capacity warnings** at 80% of line limits before rejecting
216
+ - **Atomic file writes** via temp + rename to prevent corruption
217
+ - **Scored search** with regex safe fallback and header-weighted ranking
218
+ - **Smart color output** that detects TTY and respects NO_COLOR
219
+ - **Auto-prune** removes archive entries older than 90 days
220
+ - **Hook-safe install** that merges without clobbering user config
221
+ - **Cross-runtime** support for Node (>=18) and Bun
222
+ - **Stable project identity** via `.brain-id` that survives renames
223
+
224
+ ---
225
+
135
226
  ## Project Identity
136
227
 
137
228
  By default, projects are identified by directory name. For stable identity that survives renames:
@@ -162,4 +253,4 @@ cc-brain uninstall --purge # Remove everything
162
253
 
163
254
  ## License
164
255
 
165
- MIT Β© [tripzcodes](https://github.com/tripzcodes)
256
+ MIT - [tripzcodes](https://github.com/tripzcodes)
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
- try {
21
- execSync('bun --version', { stdio: 'ignore' });
20
+ if (process.versions.bun || process.env.BUN_INSTALL) {
22
21
  runtime = 'bun';
23
- } catch {
24
- // bun not available, use node
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
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "cc-brain",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Persistent memory system for Claude Code - remembers context across sessions",
5
5
  "type": "module",
6
6
  "bin": {
7
- "cc-brain": "./bin/cc-brain.js"
7
+ "cc-brain": "bin/cc-brain.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-brain",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Persistent memory system for Claude Code",
5
5
  "skills": [
6
6
  "skills/save.md",
@@ -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 PROJECT_ROOT = join(import.meta.dirname, '..');
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,14 +70,45 @@ 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 (preserve existing hooks, add ours)
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}`);
78
95
 
96
+ // --- Install Skills to ~/.claude/skills/ ---
97
+ const SKILLS_DIR = join(CLAUDE_DIR, 'skills');
98
+ const skillNames = ['save', 'recall', 'brain'];
99
+
100
+ for (const name of skillNames) {
101
+ const skillDir = join(SKILLS_DIR, name);
102
+ mkdirSync(skillDir, { recursive: true });
103
+ copyFileSync(
104
+ join(PROJECT_ROOT, 'skills', `${name}.md`),
105
+ join(skillDir, 'SKILL.md')
106
+ );
107
+ }
108
+
109
+ console.log(`\nInstalled skills to: ${SKILLS_DIR}`);
110
+ console.log(' /save, /recall, /brain');
111
+
79
112
  console.log(`
80
113
  cc-brain installed!
81
114
 
@@ -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
- const settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
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
- delete settings.hooks.SessionStart;
28
- delete settings.hooks.PreCompact;
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
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
36
- console.log(`Removed hooks from: ${SETTINGS_PATH}`);
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
  }
@@ -41,6 +69,18 @@ if (existsSync(SETTINGS_PATH)) {
41
69
  console.log('No settings.json found');
42
70
  }
43
71
 
72
+ // --- Remove Skills from ~/.claude/skills/ ---
73
+ const SKILLS_DIR = join(CLAUDE_DIR, 'skills');
74
+ const skillNames = ['save', 'recall', 'brain'];
75
+
76
+ for (const name of skillNames) {
77
+ const skillDir = join(SKILLS_DIR, name);
78
+ if (existsSync(skillDir)) {
79
+ rmSync(skillDir, { recursive: true, force: true });
80
+ console.log(`Removed: ${skillDir}`);
81
+ }
82
+ }
83
+
44
84
  // Optionally remove brain data
45
85
  if (purge) {
46
86
  if (existsSync(BRAIN_DIR)) {
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
- 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)');
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
- 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]
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
- console.error(`Unknown command: ${command}`);
196
- process.exit(1);
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
- // Ignore errors during auto-prune
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
- 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);
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
- 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.]`);
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(process.env.HOME || process.env.USERPROFILE, '.claude', 'brain');
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.main) {
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 = new RegExp(query, 'gi');
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 match count (most relevant first), then by date
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.matchCount !== a.matchCount) return b.matchCount - a.matchCount;
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
- // Highlight the match
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.main) {
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, writeFileSync, mkdirSync } from 'fs';
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
- // Find and replace section, or append
90
- const sectionRegex = new RegExp(`## ${section}[\\s\\S]*?(?=\\n## |$)`, 'g');
91
- if (sectionRegex.test(result)) {
92
- result = result.replace(sectionRegex, newContent + '\n\n');
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 date = new Date().toISOString().split('T')[0];
230
- const archivePath = join(PATHS.t3_dir, `${date}.md`);
231
- const existing = readFile(archivePath);
232
- const updated = existing
233
- ? existing + '\n---\n\n' + generateT3Content(input.t3)
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: existing,
285
+ before: null,
240
286
  after: updated,
241
287
  lines: countLines(updated),
242
- isNew: !existing
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
- console.log(`β”‚ Status: UPDATE`);
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
- // Show content preview (first 10 lines)
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 > 10) {
269
- console.log(`β”‚ ... (${change.lines - 10} more 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
- writeFileSync(change.path, change.after);
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.main) {
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
+ }