vektor-slipstream 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cerebellum.js ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * cloak_cerebellum.js
3
+ * Pre-write enforcer — checks every write against known error patterns in
4
+ * Vektor's causal graph. Auto-creates [RESOLVED_BY] edges when a fix matches
5
+ * an open error node. Zero extra tool calls from Claude.
6
+ *
7
+ * Two responsibilities:
8
+ * 1. CHECK — before a write, recall causal error patterns and warn if matched
9
+ * 2. RESOLVE — if new write content matches an error pattern, auto-write
10
+ * [RESOLVED_BY] causal edge to close the loop
11
+ *
12
+ * Architecture: CLOAK layer → Vektor causal graph (MAGMA §3.4)
13
+ * Research: MAGMA arXiv:2601.03236, OpenWolf cerebrum Do-Not-Repeat pattern
14
+ *
15
+ * Key design decision: cerebellum NEVER blocks a write, it only warns.
16
+ * Claude Code must remain in control — cerebellum is advisory, not a gatekeeper.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const crypto = require('crypto');
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Similarity threshold for pattern matching
25
+ // ---------------------------------------------------------------------------
26
+ const SIMILARITY_THRESHOLD = 0.72;
27
+
28
+ // Max error patterns to check per write
29
+ const MAX_PATTERNS_TO_CHECK = 8;
30
+
31
+ // Minimum content length to bother checking — skip tiny edits (console.log,
32
+ // closing braces, single-line comments). Not worth a Vektor round-trip.
33
+ const MIN_CHECK_LENGTH = 50;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // LRU warning cache — prevents slamming Vektor on rapid multi-file writes
37
+ // (MultiEdit, code generation loops, etc.)
38
+ //
39
+ // Key: SHA-1 of content.slice(0,300) — cheap, collision-safe for this use
40
+ // Value: { warnings, cachedAt }
41
+ // TTL: 60 seconds — patterns don't change that fast within a session
42
+ // Max: 100 entries — bounded memory, evicts oldest on overflow
43
+ // ---------------------------------------------------------------------------
44
+ const CACHE_TTL_MS = 60_000;
45
+ const CACHE_MAX = 100;
46
+ const _warningCache = new Map(); // key → { warnings, cachedAt }
47
+
48
+ function _cacheKey(content) {
49
+ return crypto.createHash('sha1').update(content.slice(0, 300)).digest('hex');
50
+ }
51
+
52
+ function _cacheGet(key) {
53
+ const entry = _warningCache.get(key);
54
+ if (!entry) return null;
55
+ if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
56
+ _warningCache.delete(key);
57
+ return null;
58
+ }
59
+ return entry.warnings;
60
+ }
61
+
62
+ function _cacheSet(key, warnings) {
63
+ // Evict oldest entry if at capacity
64
+ if (_warningCache.size >= CACHE_MAX) {
65
+ _warningCache.delete(_warningCache.keys().next().value);
66
+ }
67
+ _warningCache.set(key, { warnings, cachedAt: Date.now() });
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Pattern storage key prefix — namespaces cerebellum nodes in Vektor
72
+ // ---------------------------------------------------------------------------
73
+ const ERROR_PREFIX = '[CLOAK_CEREBELLUM_ERROR]';
74
+ const RESOLVED_PREFIX = '[CLOAK_CEREBELLUM_RESOLVED]';
75
+ const DNR_PREFIX = '[CLOAK_CEREBELLUM_DNR]'; // Do-Not-Repeat
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Core: check a write against known error patterns
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * checkWrite({ content, filePath, memory })
83
+ * Called from the PreToolUse hook before every write.
84
+ * Returns warnings if the write matches known error patterns.
85
+ *
86
+ * @param {string} content - The content being written
87
+ * @param {string} filePath - The file being written to
88
+ * @param {object} memory - Vektor memory instance
89
+ * @returns {object} - { warnings: [], shouldProceed: true }
90
+ */
91
+ async function checkWrite({ content, filePath, memory } = {}) {
92
+ if (!memory) throw new Error('cloak_cerebellum: memory instance is required');
93
+ if (!content) return { warnings: [], shouldProceed: true, cached: false };
94
+
95
+ // Bypass: tiny writes aren't worth a Vektor round-trip
96
+ // (closing braces, console.logs, single-line cosmetic edits)
97
+ if (content.length < MIN_CHECK_LENGTH) {
98
+ return { warnings: [], shouldProceed: true, bypassed: 'too_small' };
99
+ }
100
+
101
+ // Cache hit: same content checked recently — return instantly, no Vektor call
102
+ const key = _cacheKey(content);
103
+ const cached = _cacheGet(key);
104
+ if (cached !== null) {
105
+ return {
106
+ warnings : cached,
107
+ shouldProceed: true,
108
+ hasBlockers : cached.some(w => w.severity === 'block'),
109
+ cached : true,
110
+ };
111
+ }
112
+
113
+ const warnings = [];
114
+
115
+ try {
116
+ // Query causal error graph for patterns similar to this write
117
+ const errorPatterns = await memory.recall(
118
+ `${ERROR_PREFIX} ${content.slice(0, 300)}`,
119
+ { limit: MAX_PATTERNS_TO_CHECK }
120
+ );
121
+
122
+ if (!errorPatterns || errorPatterns.length === 0) {
123
+ return { warnings: [], shouldProceed: true };
124
+ }
125
+
126
+ for (const pattern of errorPatterns) {
127
+ if (pattern.score < SIMILARITY_THRESHOLD) continue;
128
+
129
+ // Parse the stored pattern
130
+ const parsed = parsePatternNode(pattern.content);
131
+ if (!parsed) continue;
132
+
133
+ // Skip if this pattern is already resolved
134
+ if (parsed.resolvedBy) continue;
135
+
136
+ warnings.push({
137
+ patternId : pattern.id,
138
+ description : parsed.description,
139
+ originalFile: parsed.file,
140
+ severity : parsed.severity || 'warn',
141
+ hint : parsed.fix || 'See error pattern for details',
142
+ score : Math.round(pattern.score * 100) / 100,
143
+ });
144
+ }
145
+
146
+ // Also check Do-Not-Repeat rules (always enforced regardless of similarity)
147
+ const dnrRules = await memory.recall(
148
+ `${DNR_PREFIX}`,
149
+ { limit: 5 }
150
+ );
151
+
152
+ if (dnrRules) {
153
+ for (const rule of dnrRules) {
154
+ const parsed = parseDNRNode(rule.content);
155
+ if (!parsed) continue;
156
+
157
+ // Check if the pattern keyword appears in the write content
158
+ if (content.toLowerCase().includes(parsed.keyword.toLowerCase())) {
159
+ warnings.push({
160
+ patternId : rule.id,
161
+ description : `Do-Not-Repeat: ${parsed.rule}`,
162
+ severity : 'block',
163
+ hint : parsed.alternative || 'Avoid this pattern',
164
+ score : 1.0,
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ } catch (err) {
171
+ // Non-fatal — Vektor query failure should not block writes
172
+ console.warn('[cloak_cerebellum] Pattern check failed:', err.message);
173
+ }
174
+
175
+ // Cache the result for 60s — rapid multi-file writes skip Vektor entirely
176
+ _cacheSet(key, warnings);
177
+
178
+ return {
179
+ warnings,
180
+ shouldProceed: true,
181
+ hasBlockers : warnings.some(w => w.severity === 'block'),
182
+ cached : false,
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Core: record a new error pattern into the causal graph
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * recordError({ description, filePath, errorType, fix, severity, memory })
192
+ * Store a new error pattern so future writes can be warned.
193
+ *
194
+ * @param {string} description - Human-readable description of the error
195
+ * @param {string} filePath - File where error occurred
196
+ * @param {string} errorType - e.g. 'null_reference', 'type_mismatch'
197
+ * @param {string} fix - What fixed it (optional)
198
+ * @param {'warn'|'block'} severity
199
+ * @param {object} memory - Vektor memory instance
200
+ * @returns {object} - { id, written }
201
+ */
202
+ async function recordError({ description, filePath, errorType, fix, severity = 'warn', memory } = {}) {
203
+ if (!memory) throw new Error('cloak_cerebellum: memory instance is required');
204
+ if (!description) throw new Error('cloak_cerebellum: description is required');
205
+
206
+ const patternStr = [
207
+ ERROR_PREFIX,
208
+ `error_type:${errorType || 'unknown'}`,
209
+ `file:${filePath || 'unknown'}`,
210
+ `description:${description}`,
211
+ `severity:${severity}`,
212
+ fix ? `fix:${fix}` : null,
213
+ `recorded_at:${new Date().toISOString()}`,
214
+ `resolved:false`,
215
+ ].filter(Boolean).join(' | ');
216
+
217
+ try {
218
+ await memory.remember(patternStr);
219
+ return { written: true, pattern: patternStr };
220
+ } catch (err) {
221
+ console.error('[cloak_cerebellum] Failed to record error:', err.message);
222
+ return { written: false, error: err.message };
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Core: auto-resolve — detect when a write fixes an open error
228
+ // Called from PostToolUse after a successful write.
229
+ // This is the key improvement over OpenWolf — resolution is automatic,
230
+ // not dependent on Claude remembering to call a separate tool.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * autoResolve({ content, filePath, memory })
235
+ * After a successful write, check if it resolves any open error patterns.
236
+ * If yes, write a [RESOLVED_BY] causal edge to close the loop.
237
+ * This feeds the decay weighting in MAGMA's causal graph.
238
+ *
239
+ * @param {string} content - Content that was written
240
+ * @param {string} filePath - File that was written
241
+ * @param {object} memory - Vektor memory instance
242
+ * @returns {object} - { resolved: [], written: number }
243
+ */
244
+ async function autoResolve({ content, filePath, memory } = {}) {
245
+ if (!memory || !filePath) return { resolved: [], written: 0 };
246
+
247
+ const resolved = [];
248
+
249
+ try {
250
+ // Query by FILE PATH not content — avoids the semantic gap where
251
+ // "Null reference on user.id" and "if (user && user.id)" have
252
+ // completely different embeddings despite one fixing the other.
253
+ // Pull all open errors on this file, then let REM cycle verify.
254
+ const candidates = await memory.recall(
255
+ `${ERROR_PREFIX} file:${filePath}`,
256
+ { limit: MAX_PATTERNS_TO_CHECK }
257
+ );
258
+
259
+ if (!candidates || candidates.length === 0) return { resolved: [], written: 0 };
260
+
261
+ for (const candidate of candidates) {
262
+ // File path match is already a strong signal — use lower threshold
263
+ // than content matching. 0.5 avoids completely unrelated file path
264
+ // substring matches while catching real file-based errors.
265
+ if (candidate.score < 0.5) continue;
266
+
267
+ const parsed = parsePatternNode(candidate.content);
268
+ if (!parsed || parsed.resolvedBy) continue; // already resolved
269
+
270
+ // Only auto-resolve if the stored error references this exact file
271
+ if (parsed.file && parsed.file !== filePath &&
272
+ parsed.file !== require('path').basename(filePath)) continue;
273
+
274
+ // Write resolution node — this is the [RESOLVED_BY] causal edge
275
+ const resolutionStr = [
276
+ RESOLVED_PREFIX,
277
+ `resolves_pattern:${candidate.id}`,
278
+ `original_error:${parsed.description}`,
279
+ `resolved_by_file:${filePath || 'unknown'}`,
280
+ `resolved_at:${new Date().toISOString()}`,
281
+ `[RESOLVED_BY]`, // explicit causal edge marker for HippoRAG decay weighting
282
+ ].join(' | ');
283
+
284
+ try {
285
+ await memory.remember(resolutionStr);
286
+ resolved.push({
287
+ patternId : candidate.id,
288
+ description: parsed.description,
289
+ resolution : resolutionStr,
290
+ });
291
+ } catch (err) {
292
+ console.warn('[cloak_cerebellum] Failed to write resolution:', err.message);
293
+ }
294
+ }
295
+
296
+ } catch (err) {
297
+ console.warn('[cloak_cerebellum] Auto-resolve query failed:', err.message);
298
+ }
299
+
300
+ return { resolved, written: resolved.length };
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Do-Not-Repeat rules — project-level conventions
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * addDNRRule({ keyword, rule, alternative, memory })
309
+ * Store a Do-Not-Repeat rule. Checked on every write regardless of similarity.
310
+ *
311
+ * @param {string} keyword - Trigger keyword/pattern to watch for
312
+ * @param {string} rule - Human description of what not to do
313
+ * @param {string} alternative - What to do instead
314
+ * @param {object} memory
315
+ */
316
+ async function addDNRRule({ keyword, rule, alternative, memory } = {}) {
317
+ if (!memory) throw new Error('cloak_cerebellum: memory instance is required');
318
+ if (!keyword) throw new Error('cloak_cerebellum: keyword is required');
319
+ if (!rule) throw new Error('cloak_cerebellum: rule is required');
320
+
321
+ const dnrStr = [
322
+ DNR_PREFIX,
323
+ `keyword:${keyword}`,
324
+ `rule:${rule}`,
325
+ alternative ? `alternative:${alternative}` : null,
326
+ `added_at:${new Date().toISOString()}`,
327
+ ].filter(Boolean).join(' | ');
328
+
329
+ try {
330
+ await memory.remember(dnrStr);
331
+ return { written: true };
332
+ } catch (err) {
333
+ console.error('[cloak_cerebellum] Failed to add DNR rule:', err.message);
334
+ return { written: false, error: err.message };
335
+ }
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Claude Code hook integration
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * handleHookPayload({ payload, memory })
344
+ * Routes Claude Code PreToolUse/PostToolUse hooks to checkWrite / autoResolve.
345
+ */
346
+ async function handleHookPayload({ payload, memory } = {}) {
347
+ if (!memory || !payload) return null;
348
+
349
+ const hookName = payload?.hook_event_name || payload?.event;
350
+ const tool = payload?.tool_name;
351
+ const input = payload?.tool_input;
352
+ const output = payload?.tool_response;
353
+
354
+ const isWrite = ['Write', 'Edit', 'MultiEdit', 'Bash'].includes(tool);
355
+
356
+ switch (hookName) {
357
+ case 'PreToolUse':
358
+ case 'pre_tool_use': {
359
+ if (!isWrite) return null;
360
+
361
+ // Bug fix: MultiEdit sends input.edits = [{ old_string, new_string }]
362
+ // not a flat input.new_string. Concatenate all new_string blocks so
363
+ // cerebellum actually sees the content being written in refactors.
364
+ let content = input?.new_string || input?.content || input?.command || '';
365
+ if (!content && input?.edits && Array.isArray(input.edits)) {
366
+ content = input.edits.map(e => e.new_string || '').filter(Boolean).join('\n');
367
+ }
368
+
369
+ const filePath = input?.file_path || input?.path || '';
370
+ return checkWrite({ content, filePath, memory });
371
+ }
372
+
373
+ case 'PostToolUse':
374
+ case 'post_tool_use': {
375
+ if (!isWrite) return null;
376
+ const success = !output?.error;
377
+ if (!success) return null;
378
+
379
+ // Same MultiEdit fix on the post-write resolve path
380
+ let content = input?.new_string || input?.content || '';
381
+ if (!content && input?.edits && Array.isArray(input.edits)) {
382
+ content = input.edits.map(e => e.new_string || '').filter(Boolean).join('\n');
383
+ }
384
+
385
+ const filePath = input?.file_path || input?.path || '';
386
+ return autoResolve({ content, filePath, memory });
387
+ }
388
+
389
+ default:
390
+ return null;
391
+ }
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Parsers — extract structured data from Vektor node strings
396
+ // ---------------------------------------------------------------------------
397
+ function parsePatternNode(str) {
398
+ if (!str || !str.includes(ERROR_PREFIX)) return null;
399
+ const parts = str.split(' | ');
400
+ const get = key => {
401
+ const part = parts.find(p => p.startsWith(`${key}:`));
402
+ return part ? part.slice(key.length + 1) : null;
403
+ };
404
+ return {
405
+ errorType : get('error_type'),
406
+ file : get('file'),
407
+ description: get('description'),
408
+ severity : get('severity'),
409
+ fix : get('fix'),
410
+ resolvedBy: str.includes(RESOLVED_PREFIX) || get('resolved') === 'true' ? true : null,
411
+ };
412
+ }
413
+
414
+ function parseDNRNode(str) {
415
+ if (!str || !str.includes(DNR_PREFIX)) return null;
416
+ const parts = str.split(' | ');
417
+ const get = key => {
418
+ const part = parts.find(p => p.startsWith(`${key}:`));
419
+ return part ? part.slice(key.length + 1) : null;
420
+ };
421
+ return {
422
+ keyword : get('keyword'),
423
+ rule : get('rule'),
424
+ alternative: get('alternative'),
425
+ };
426
+ }
427
+
428
+ module.exports = {
429
+ checkWrite,
430
+ recordError,
431
+ autoResolve,
432
+ addDNRRule,
433
+ handleHookPayload,
434
+ SIMILARITY_THRESHOLD,
435
+ ERROR_PREFIX,
436
+ RESOLVED_PREFIX,
437
+ DNR_PREFIX,
438
+ };
package/cortex.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * cloak_cortex.js
3
+ * File anatomy scanner — builds a token-aware project index in Vektor's entity graph.
4
+ * Called once on init, then on significant file changes (not per-session).
5
+ *
6
+ * Writes to Vektor as entity nodes:
7
+ * "file:src/server.ts | purpose: Express HTTP server | tokens: ~520 | last_scanned: <iso>"
8
+ *
9
+ * Architecture: CLOAK layer → Vektor entity graph (MAGMA §3.2)
10
+ * Research: MAGMA arXiv:2601.03236, OpenWolf anatomy pattern
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Token estimation (OpenWolf ratios — accurate to ±15%)
20
+ // ---------------------------------------------------------------------------
21
+ const TOKEN_RATIOS = {
22
+ code : 3.5, // .js .ts .py .go .rs etc.
23
+ prose: 4.0, // .md .txt .rst
24
+ mixed: 3.75, // .json .yaml .toml .html
25
+ };
26
+
27
+ const CODE_EXTS = new Set(['.js','.ts','.jsx','.tsx','.py','.go','.rs','.rb','.java','.c','.cpp','.cs','.php','.swift','.kt']);
28
+ const PROSE_EXTS = new Set(['.md','.txt','.rst','.mdx']);
29
+ const SKIP_DIRS = new Set(['node_modules','.git','dist','build','.next','__pycache__','.cache','coverage','.wolf']);
30
+ const SKIP_FILES = new Set(['.DS_Store','package-lock.json','yarn.lock','pnpm-lock.yaml']);
31
+
32
+ function estimateTokens(filePath, bytes) {
33
+ const ext = path.extname(filePath).toLowerCase();
34
+ if (CODE_EXTS.has(ext)) return Math.round(bytes / TOKEN_RATIOS.code);
35
+ if (PROSE_EXTS.has(ext)) return Math.round(bytes / TOKEN_RATIOS.prose);
36
+ return Math.round(bytes / TOKEN_RATIOS.mixed);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // File scanner
41
+ // ---------------------------------------------------------------------------
42
+ function scanDirectory(dirPath, results = [], rootPath = dirPath, visited = new Set()) {
43
+ // Only resolve realpath for symlinks — not for every directory.
44
+ // Running fs.realpathSync on all directories is ~4x slower on large codebases
45
+ // (especially Windows NTFS) and the visited Set grows unnecessarily large.
46
+ // Normal directories cannot create cycles; only symlinks can.
47
+
48
+ let entries;
49
+ try {
50
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
51
+ } catch {
52
+ return results;
53
+ }
54
+
55
+ for (const entry of entries) {
56
+ if (SKIP_FILES.has(entry.name)) continue;
57
+
58
+ const fullPath = path.join(dirPath, entry.name);
59
+ const relPath = path.relative(rootPath, fullPath);
60
+
61
+ if (entry.isSymbolicLink()) {
62
+ // Symlinks only: resolve real path and check for cycles
63
+ let realPath;
64
+ try { realPath = fs.realpathSync(fullPath); } catch { continue; }
65
+
66
+ if (visited.has(realPath)) continue; // cycle detected — skip
67
+
68
+ let stat;
69
+ try { stat = fs.statSync(fullPath); } catch { continue; }
70
+
71
+ if (stat.isDirectory()) {
72
+ if (!SKIP_DIRS.has(entry.name)) {
73
+ visited.add(realPath); // mark before recursing
74
+ scanDirectory(fullPath, results, rootPath, visited);
75
+ }
76
+ } else if (stat.isFile()) {
77
+ const ext = path.extname(entry.name).toLowerCase();
78
+ const tokens = estimateTokens(fullPath, stat.size);
79
+ results.push({ relPath, ext, tokens, mtime: stat.mtimeMs, bytes: stat.size });
80
+ }
81
+ continue;
82
+ }
83
+
84
+ if (entry.isDirectory()) {
85
+ // Normal directory — no realpathSync needed, no cycle possible
86
+ if (!SKIP_DIRS.has(entry.name)) {
87
+ scanDirectory(fullPath, results, rootPath, visited);
88
+ }
89
+ continue;
90
+ }
91
+
92
+ if (!entry.isFile()) continue;
93
+
94
+ let stat;
95
+ try { stat = fs.statSync(fullPath); } catch { continue; }
96
+
97
+ const ext = path.extname(entry.name).toLowerCase();
98
+ const tokens = estimateTokens(fullPath, stat.size);
99
+ const mtime = stat.mtimeMs;
100
+
101
+ results.push({ relPath, ext, tokens, mtime, bytes: stat.size });
102
+ }
103
+
104
+ return results;
105
+ }
106
+
107
+ // isSymlinkDir removed — logic inlined above for clarity
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Build Vektor memory strings for entity graph
111
+ // ---------------------------------------------------------------------------
112
+ function buildEntityString(file, projectName) {
113
+ return [
114
+ `[CLOAK_CORTEX] project:${projectName}`,
115
+ `file:${file.relPath}`,
116
+ `tokens:~${file.tokens}`,
117
+ `size:${file.bytes}b`,
118
+ `last_modified:${new Date(file.mtime).toISOString()}`,
119
+ ].join(' | ');
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Load/save scan cache (.wolf/cortex-cache.json) to avoid rescanning unchanged files
124
+ // ---------------------------------------------------------------------------
125
+ function loadCache(projectPath) {
126
+ const cachePath = path.join(projectPath, '.wolf', 'cortex-cache.json');
127
+ try {
128
+ return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ function saveCache(projectPath, cache) {
135
+ const wolfDir = path.join(projectPath, '.wolf');
136
+ if (!fs.existsSync(wolfDir)) fs.mkdirSync(wolfDir, { recursive: true });
137
+ fs.writeFileSync(
138
+ path.join(wolfDir, 'cortex-cache.json'),
139
+ JSON.stringify(cache, null, 2)
140
+ );
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Main export
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * runCortex({ projectPath, memory, force })
149
+ *
150
+ * @param {string} projectPath - Absolute path to project root
151
+ * @param {object} memory - Vektor memory instance (vektor-slipstream)
152
+ * @param {boolean} force - Re-scan all files even if unchanged
153
+ * @returns {object} - { scanned, skipped, written, totalTokens, anatomy }
154
+ */
155
+ async function runCortex({ projectPath, memory, force = false } = {}) {
156
+ if (!projectPath) throw new Error('cloak_cortex: projectPath is required');
157
+ if (!memory) throw new Error('cloak_cortex: memory instance is required');
158
+
159
+ const projectName = path.basename(projectPath);
160
+ const cache = force ? {} : loadCache(projectPath);
161
+ const files = scanDirectory(projectPath);
162
+
163
+ const stats = { scanned: 0, skipped: 0, written: 0, totalTokens: 0 };
164
+ const anatomy = [];
165
+
166
+ for (const file of files) {
167
+ stats.totalTokens += file.tokens;
168
+ anatomy.push({ path: file.relPath, tokens: file.tokens });
169
+
170
+ // Skip if file hasn't changed since last scan
171
+ const cacheKey = file.relPath;
172
+ if (!force && cache[cacheKey] && cache[cacheKey].mtime === file.mtime) {
173
+ stats.skipped++;
174
+ continue;
175
+ }
176
+
177
+ stats.scanned++;
178
+
179
+ const entityStr = buildEntityString(file, projectName);
180
+
181
+ try {
182
+ await memory.remember(entityStr);
183
+ cache[cacheKey] = { mtime: file.mtime, tokens: file.tokens };
184
+ stats.written++;
185
+ } catch (err) {
186
+ console.error(`[cloak_cortex] Failed to write entity for ${file.relPath}:`, err.message);
187
+ }
188
+ }
189
+
190
+ // Write project-level summary node
191
+ const summaryStr = [
192
+ `[CLOAK_CORTEX_SUMMARY] project:${projectName}`,
193
+ `total_files:${files.length}`,
194
+ `total_tokens:~${stats.totalTokens}`,
195
+ `scanned_at:${new Date().toISOString()}`,
196
+ ].join(' | ');
197
+
198
+ try {
199
+ await memory.remember(summaryStr);
200
+ } catch (err) {
201
+ console.error('[cloak_cortex] Failed to write summary node:', err.message);
202
+ }
203
+
204
+ saveCache(projectPath, cache);
205
+
206
+ return { ...stats, anatomy };
207
+ }
208
+
209
+ /**
210
+ * getAnatomy({ projectPath })
211
+ * Returns the cached anatomy without hitting Vektor — for fast pre-read hints.
212
+ */
213
+ function getAnatomy(projectPath) {
214
+ const cache = loadCache(projectPath);
215
+ return Object.entries(cache).map(([relPath, data]) => ({
216
+ path: relPath,
217
+ tokens: data.tokens,
218
+ })).sort((a, b) => b.tokens - a.tokens);
219
+ }
220
+
221
+ module.exports = { runCortex, getAnatomy, scanDirectory, estimateTokens };