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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-brain",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Persistent memory system for Claude Code - remembers context across sessions",
5
5
  "type": "module",
6
6
  "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,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 (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}`);
@@ -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
  }
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
+ }