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 +105 -14
- package/bin/cc-brain.js +9 -5
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/scripts/install.js +38 -5
- package/scripts/uninstall.js +46 -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/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<h1 align="center"
|
|
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="#
|
|
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
|
|
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
|
|
89
|
+
β Session Start βββββ>β Brain Loaded βββββ>β You Work... β
|
|
89
90
|
βββββββββββββββββββ βββββββββββββββββββ ββββββββββ¬βββββββββ
|
|
90
91
|
β
|
|
91
92
|
βββββββββββββββββββ βββββββββββββββββββ β
|
|
92
|
-
β Next Session
|
|
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 #
|
|
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 '{"
|
|
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
|
|
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
|
-
|
|
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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-brain",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
7
|
+
"cc-brain": "bin/cc-brain.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
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,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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|