hebbian 0.1.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/LICENSE +21 -0
- package/README.md +201 -0
- package/bin/hebbian.js +199 -0
- package/lib/constants.js +83 -0
- package/lib/decay.js +100 -0
- package/lib/dedup.js +66 -0
- package/lib/emit.js +376 -0
- package/lib/fire.js +59 -0
- package/lib/grow.js +98 -0
- package/lib/init.js +89 -0
- package/lib/rollback.js +34 -0
- package/lib/scanner.js +227 -0
- package/lib/signal.js +68 -0
- package/lib/similarity.js +62 -0
- package/lib/snapshot.js +46 -0
- package/lib/subsumption.js +77 -0
- package/lib/watch.js +79 -0
- package/package.json +43 -0
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// hebbian — Brain Filesystem Scanner
|
|
2
|
+
//
|
|
3
|
+
// Walks the brain directory tree and builds a structured representation.
|
|
4
|
+
// Folders = neurons. Files = firing traces. Paths = sentences.
|
|
5
|
+
//
|
|
6
|
+
// Scan order follows subsumption priority (P0 brainstem → P6 prefrontal).
|
|
7
|
+
// Only recognizes the 7 canonical region names at the top level.
|
|
8
|
+
|
|
9
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, relative, sep } from 'node:path';
|
|
11
|
+
import { REGIONS, REGION_PRIORITY, MAX_DEPTH } from './constants.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} Neuron
|
|
15
|
+
* @property {string} name - Folder name (leaf)
|
|
16
|
+
* @property {string} path - Relative path from region root
|
|
17
|
+
* @property {string} fullPath - Absolute path on disk
|
|
18
|
+
* @property {number} counter - Excitatory counter (from N.neuron)
|
|
19
|
+
* @property {number} contra - Inhibitory counter (from N.contra)
|
|
20
|
+
* @property {number} dopamine - Reward counter (from dopamineN.neuron)
|
|
21
|
+
* @property {number} intensity - Net activation: counter - contra + dopamine
|
|
22
|
+
* @property {number} polarity - Net / total, range -1.0 to +1.0
|
|
23
|
+
* @property {boolean} hasBomb - bomb.neuron exists (circuit breaker)
|
|
24
|
+
* @property {boolean} hasMemory - memoryN.neuron exists
|
|
25
|
+
* @property {boolean} isDormant - *.dormant file exists
|
|
26
|
+
* @property {number} depth - Depth within region
|
|
27
|
+
* @property {Date} modTime - Most recent .neuron file modification time
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} Region
|
|
32
|
+
* @property {string} name
|
|
33
|
+
* @property {number} priority
|
|
34
|
+
* @property {string} path - Absolute path to region directory
|
|
35
|
+
* @property {Neuron[]} neurons
|
|
36
|
+
* @property {string[]} axons - Cross-region connections
|
|
37
|
+
* @property {boolean} hasBomb - Any neuron in region has bomb
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} Brain
|
|
42
|
+
* @property {string} root
|
|
43
|
+
* @property {Region[]} regions
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan a brain directory and return all regions with their neurons.
|
|
48
|
+
* @param {string} brainRoot - Absolute path to brain root directory
|
|
49
|
+
* @returns {Brain}
|
|
50
|
+
*/
|
|
51
|
+
export function scanBrain(brainRoot) {
|
|
52
|
+
/** @type {Region[]} */
|
|
53
|
+
const regions = [];
|
|
54
|
+
|
|
55
|
+
for (const regionName of REGIONS) {
|
|
56
|
+
const regionPath = join(brainRoot, regionName);
|
|
57
|
+
if (!existsSync(regionPath)) {
|
|
58
|
+
regions.push({
|
|
59
|
+
name: regionName,
|
|
60
|
+
priority: REGION_PRIORITY[regionName],
|
|
61
|
+
path: regionPath,
|
|
62
|
+
neurons: [],
|
|
63
|
+
axons: [],
|
|
64
|
+
hasBomb: false,
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const neurons = walkRegion(regionPath, regionPath, 0);
|
|
70
|
+
const axons = readAxons(regionPath);
|
|
71
|
+
const hasBomb = neurons.some((n) => n.hasBomb);
|
|
72
|
+
|
|
73
|
+
regions.push({
|
|
74
|
+
name: regionName,
|
|
75
|
+
priority: REGION_PRIORITY[regionName],
|
|
76
|
+
path: regionPath,
|
|
77
|
+
neurons,
|
|
78
|
+
axons,
|
|
79
|
+
hasBomb,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { root: brainRoot, regions };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recursively walk a region directory and collect neurons.
|
|
88
|
+
* A neuron is any directory that contains at least one trace file
|
|
89
|
+
* (*.neuron, *.contra, *.dormant, bomb.neuron).
|
|
90
|
+
*
|
|
91
|
+
* @param {string} dir - Current directory to scan
|
|
92
|
+
* @param {string} regionRoot - Root of the region (for relative paths)
|
|
93
|
+
* @param {number} depth - Current recursion depth
|
|
94
|
+
* @returns {Neuron[]}
|
|
95
|
+
*/
|
|
96
|
+
function walkRegion(dir, regionRoot, depth) {
|
|
97
|
+
if (depth > MAX_DEPTH) return [];
|
|
98
|
+
|
|
99
|
+
/** @type {Neuron[]} */
|
|
100
|
+
const neurons = [];
|
|
101
|
+
let entries;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Parse trace files in this directory
|
|
110
|
+
let counter = 0;
|
|
111
|
+
let contra = 0;
|
|
112
|
+
let dopamine = 0;
|
|
113
|
+
let hasBomb = false;
|
|
114
|
+
let hasMemory = false;
|
|
115
|
+
let isDormant = false;
|
|
116
|
+
let modTime = new Date(0);
|
|
117
|
+
let hasTraceFile = false;
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.name.startsWith('_')) continue;
|
|
121
|
+
|
|
122
|
+
if (entry.isFile()) {
|
|
123
|
+
const name = entry.name;
|
|
124
|
+
|
|
125
|
+
// N.neuron — excitatory counter
|
|
126
|
+
if (name.endsWith('.neuron') && !name.startsWith('dopamine') && !name.startsWith('memory') && name !== 'bomb.neuron') {
|
|
127
|
+
const n = parseInt(name, 10);
|
|
128
|
+
if (!isNaN(n) && n > counter) {
|
|
129
|
+
counter = n;
|
|
130
|
+
hasTraceFile = true;
|
|
131
|
+
try {
|
|
132
|
+
const st = statSync(join(dir, name));
|
|
133
|
+
if (st.mtime > modTime) modTime = st.mtime;
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// N.contra — inhibitory counter
|
|
139
|
+
if (name.endsWith('.contra')) {
|
|
140
|
+
const n = parseInt(name, 10);
|
|
141
|
+
if (!isNaN(n) && n > contra) {
|
|
142
|
+
contra = n;
|
|
143
|
+
hasTraceFile = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// dopamineN.neuron — reward signal
|
|
148
|
+
if (name.startsWith('dopamine') && name.endsWith('.neuron')) {
|
|
149
|
+
const n = parseInt(name.replace('dopamine', ''), 10);
|
|
150
|
+
if (!isNaN(n) && n > dopamine) {
|
|
151
|
+
dopamine = n;
|
|
152
|
+
hasTraceFile = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// bomb.neuron — circuit breaker
|
|
157
|
+
if (name === 'bomb.neuron') {
|
|
158
|
+
hasBomb = true;
|
|
159
|
+
hasTraceFile = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// memoryN.neuron — memory signal
|
|
163
|
+
if (name.startsWith('memory') && name.endsWith('.neuron')) {
|
|
164
|
+
hasMemory = true;
|
|
165
|
+
hasTraceFile = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// *.dormant — dormancy marker
|
|
169
|
+
if (name.endsWith('.dormant')) {
|
|
170
|
+
isDormant = true;
|
|
171
|
+
hasTraceFile = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If this directory has trace files, it's a neuron
|
|
177
|
+
if (hasTraceFile) {
|
|
178
|
+
const relPath = relative(regionRoot, dir) || '.';
|
|
179
|
+
const folderName = dir.split(sep).pop() || '';
|
|
180
|
+
const total = counter + contra + dopamine;
|
|
181
|
+
const intensity = counter - contra + dopamine;
|
|
182
|
+
const polarity = total > 0 ? intensity / total : 0;
|
|
183
|
+
|
|
184
|
+
neurons.push({
|
|
185
|
+
name: folderName,
|
|
186
|
+
path: relPath,
|
|
187
|
+
fullPath: dir,
|
|
188
|
+
counter,
|
|
189
|
+
contra,
|
|
190
|
+
dopamine,
|
|
191
|
+
intensity,
|
|
192
|
+
polarity: Math.round(polarity * 100) / 100,
|
|
193
|
+
hasBomb,
|
|
194
|
+
hasMemory,
|
|
195
|
+
isDormant,
|
|
196
|
+
depth,
|
|
197
|
+
modTime,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Recurse into subdirectories
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
204
|
+
if (entry.isDirectory()) {
|
|
205
|
+
const subNeurons = walkRegion(join(dir, entry.name), regionRoot, depth + 1);
|
|
206
|
+
neurons.push(...subNeurons);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return neurons;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read .axon file from a region directory.
|
|
215
|
+
* @param {string} regionPath
|
|
216
|
+
* @returns {string[]}
|
|
217
|
+
*/
|
|
218
|
+
function readAxons(regionPath) {
|
|
219
|
+
const axonPath = join(regionPath, '.axon');
|
|
220
|
+
if (!existsSync(axonPath)) return [];
|
|
221
|
+
try {
|
|
222
|
+
const content = readFileSync(axonPath, 'utf8').trim();
|
|
223
|
+
return content.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
224
|
+
} catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
package/lib/signal.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// hebbian — Signal Neuron (dopamine / bomb / memory)
|
|
2
|
+
//
|
|
3
|
+
// Signals are additional trace files placed in a neuron directory:
|
|
4
|
+
// dopamineN.neuron — reward signal (positive reinforcement)
|
|
5
|
+
// bomb.neuron — circuit breaker (halts downstream regions)
|
|
6
|
+
// memoryN.neuron — memory marker (episodic recording)
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { SIGNAL_TYPES } from './constants.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add a signal to a neuron.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} brainRoot - Absolute path to brain root
|
|
16
|
+
* @param {string} neuronPath - Relative path
|
|
17
|
+
* @param {string} signalType - One of: dopamine, bomb, memory
|
|
18
|
+
* @throws {Error} If signal type is invalid or neuron doesn't exist
|
|
19
|
+
*/
|
|
20
|
+
export function signalNeuron(brainRoot, neuronPath, signalType) {
|
|
21
|
+
if (!SIGNAL_TYPES.includes(signalType)) {
|
|
22
|
+
throw new Error(`Invalid signal type: ${signalType}. Valid: ${SIGNAL_TYPES.join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fullPath = join(brainRoot, neuronPath);
|
|
26
|
+
if (!existsSync(fullPath)) {
|
|
27
|
+
throw new Error(`Neuron not found: ${neuronPath}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
switch (signalType) {
|
|
31
|
+
case 'bomb': {
|
|
32
|
+
writeFileSync(join(fullPath, 'bomb.neuron'), '', 'utf8');
|
|
33
|
+
console.log(`\u{1F4A3} bomb planted: ${neuronPath}`);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case 'dopamine': {
|
|
37
|
+
const level = getNextSignalLevel(fullPath, 'dopamine');
|
|
38
|
+
writeFileSync(join(fullPath, `dopamine${level}.neuron`), '', 'utf8');
|
|
39
|
+
console.log(`\u{1F7E2} dopamine +${level}: ${neuronPath}`);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'memory': {
|
|
43
|
+
const level = getNextSignalLevel(fullPath, 'memory');
|
|
44
|
+
writeFileSync(join(fullPath, `memory${level}.neuron`), '', 'utf8');
|
|
45
|
+
console.log(`\u{1F4BE} memory +${level}: ${neuronPath}`);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the next signal level (current max + 1).
|
|
53
|
+
* @param {string} dir
|
|
54
|
+
* @param {string} prefix - "dopamine" or "memory"
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
function getNextSignalLevel(dir, prefix) {
|
|
58
|
+
let max = 0;
|
|
59
|
+
try {
|
|
60
|
+
for (const entry of readdirSync(dir)) {
|
|
61
|
+
if (entry.startsWith(prefix) && entry.endsWith('.neuron')) {
|
|
62
|
+
const n = parseInt(entry.replace(prefix, ''), 10);
|
|
63
|
+
if (!isNaN(n) && n > max) max = n;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
return max + 1;
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// hebbian — Tokenizer, Stemmer, Jaccard Similarity
|
|
2
|
+
//
|
|
3
|
+
// Used by growNeuron() to detect similar existing neurons and merge
|
|
4
|
+
// instead of duplicating. Implements synaptic consolidation.
|
|
5
|
+
//
|
|
6
|
+
// "Neurons that fire together, wire together." — if two folder names
|
|
7
|
+
// express the same concept, fire the existing one instead of creating a duplicate.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tokenize a neuron name into stemmed words.
|
|
11
|
+
* Splits on underscores, hyphens, spaces, and CamelCase boundaries.
|
|
12
|
+
* @param {string} name
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function tokenize(name) {
|
|
16
|
+
return name
|
|
17
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2') // camelCase → snake
|
|
18
|
+
.replace(/[_\-\s]+/g, ' ') // normalize separators
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.split(' ')
|
|
21
|
+
.map(stem)
|
|
22
|
+
.filter((t) => t.length > 1); // drop single chars
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Simple suffix stemmer — removes common English suffixes.
|
|
27
|
+
* Not a full Porter stemmer, but sufficient for Jaccard comparison.
|
|
28
|
+
* @param {string} word
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function stem(word) {
|
|
32
|
+
const suffixes = ['ing', 'tion', 'sion', 'ness', 'ment', 'able', 'ible', 'ful', 'less', 'ous', 'ive', 'ity', 'ies', 'ed', 'er', 'es', 'ly', 'al', 'en'];
|
|
33
|
+
for (const suffix of suffixes) {
|
|
34
|
+
if (word.length > suffix.length + 2 && word.endsWith(suffix)) {
|
|
35
|
+
return word.slice(0, -suffix.length);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return word;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compute Jaccard similarity between two token sets.
|
|
43
|
+
* |A ∩ B| / |A ∪ B|
|
|
44
|
+
* @param {string[]} a
|
|
45
|
+
* @param {string[]} b
|
|
46
|
+
* @returns {number} 0.0 to 1.0
|
|
47
|
+
*/
|
|
48
|
+
export function jaccardSimilarity(a, b) {
|
|
49
|
+
if (a.length === 0 && b.length === 0) return 1.0;
|
|
50
|
+
if (a.length === 0 || b.length === 0) return 0.0;
|
|
51
|
+
|
|
52
|
+
const setA = new Set(a);
|
|
53
|
+
const setB = new Set(b);
|
|
54
|
+
let intersection = 0;
|
|
55
|
+
|
|
56
|
+
for (const item of setA) {
|
|
57
|
+
if (setB.has(item)) intersection++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const union = new Set([...setA, ...setB]).size;
|
|
61
|
+
return intersection / union;
|
|
62
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// hebbian — Git Snapshot
|
|
2
|
+
//
|
|
3
|
+
// Creates a git commit of the current brain state for audit trail.
|
|
4
|
+
// Only commits if there are changes.
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a git snapshot of the brain directory.
|
|
12
|
+
* @param {string} brainRoot
|
|
13
|
+
* @returns {boolean} Whether a commit was created
|
|
14
|
+
*/
|
|
15
|
+
export function gitSnapshot(brainRoot) {
|
|
16
|
+
// Check if brain is inside a git repo
|
|
17
|
+
if (!existsSync(join(brainRoot, '.git'))) {
|
|
18
|
+
// Try parent directories
|
|
19
|
+
try {
|
|
20
|
+
execSync('git rev-parse --is-inside-work-tree', { cwd: brainRoot, stdio: 'pipe' });
|
|
21
|
+
} catch {
|
|
22
|
+
console.log('\u{26A0}\uFE0F Not a git repository. Run `git init` in the brain directory first.');
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Check for changes
|
|
29
|
+
const status = execSync('git status --porcelain .', { cwd: brainRoot, encoding: 'utf8' });
|
|
30
|
+
if (!status.trim()) {
|
|
31
|
+
console.log('\u{2705} No changes to snapshot.');
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Stage and commit
|
|
36
|
+
execSync('git add .', { cwd: brainRoot, stdio: 'pipe' });
|
|
37
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
38
|
+
execSync(`git commit -m "hebbian snapshot ${ts}"`, { cwd: brainRoot, stdio: 'pipe' });
|
|
39
|
+
|
|
40
|
+
console.log(`\u{1F4F8} snapshot: committed brain state at ${ts}`);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`\u{274C} snapshot failed: ${err.message}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// hebbian — Subsumption Cascade Engine
|
|
2
|
+
//
|
|
3
|
+
// Implements Brooks' subsumption architecture:
|
|
4
|
+
// - Lower priority (P0) always suppresses higher priority (P6)
|
|
5
|
+
// - bomb.neuron in any region halts all downstream regions
|
|
6
|
+
// - Dormant neurons are excluded from activation counts
|
|
7
|
+
//
|
|
8
|
+
// Cascade flow:
|
|
9
|
+
// P0 brainstem → P1 limbic → P2 hippocampus → P3 sensors → P4 cortex → P5 ego → P6 prefrontal
|
|
10
|
+
// If bomb at P(n), all P(n+1)→P6 are BLOCKED.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {import('./scanner.js').Brain} Brain
|
|
14
|
+
* @typedef {import('./scanner.js').Region} Region
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} SubsumptionResult
|
|
19
|
+
* @property {Region[]} activeRegions - Regions that passed the cascade
|
|
20
|
+
* @property {Region[]} blockedRegions - Regions blocked by upstream bomb
|
|
21
|
+
* @property {string} bombSource - Name of the region that triggered the bomb ("" if none)
|
|
22
|
+
* @property {number} firedNeurons - Count of non-dormant neurons in active regions
|
|
23
|
+
* @property {number} totalNeurons - Count of all neurons across all regions
|
|
24
|
+
* @property {number} totalCounter - Sum of all counters in active, non-dormant neurons
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run the subsumption cascade on a scanned brain.
|
|
29
|
+
*
|
|
30
|
+
* @param {Brain} brain
|
|
31
|
+
* @returns {SubsumptionResult}
|
|
32
|
+
*/
|
|
33
|
+
export function runSubsumption(brain) {
|
|
34
|
+
/** @type {Region[]} */
|
|
35
|
+
const activeRegions = [];
|
|
36
|
+
/** @type {Region[]} */
|
|
37
|
+
const blockedRegions = [];
|
|
38
|
+
let bombSource = '';
|
|
39
|
+
let firedNeurons = 0;
|
|
40
|
+
let totalNeurons = 0;
|
|
41
|
+
let totalCounter = 0;
|
|
42
|
+
let blocked = false;
|
|
43
|
+
|
|
44
|
+
for (const region of brain.regions) {
|
|
45
|
+
totalNeurons += region.neurons.length;
|
|
46
|
+
|
|
47
|
+
if (blocked) {
|
|
48
|
+
blockedRegions.push(region);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (region.hasBomb) {
|
|
53
|
+
bombSource = region.name;
|
|
54
|
+
blockedRegions.push(region);
|
|
55
|
+
blocked = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
activeRegions.push(region);
|
|
60
|
+
|
|
61
|
+
for (const neuron of region.neurons) {
|
|
62
|
+
if (!neuron.isDormant) {
|
|
63
|
+
firedNeurons++;
|
|
64
|
+
totalCounter += neuron.counter;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
activeRegions,
|
|
71
|
+
blockedRegions,
|
|
72
|
+
bombSource,
|
|
73
|
+
firedNeurons,
|
|
74
|
+
totalNeurons,
|
|
75
|
+
totalCounter,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// hebbian — Watch Mode
|
|
2
|
+
//
|
|
3
|
+
// Watches the brain directory for filesystem changes and auto-recompiles
|
|
4
|
+
// all tiers when changes are detected. Uses hash-based change detection
|
|
5
|
+
// to avoid redundant writes.
|
|
6
|
+
|
|
7
|
+
import { watch } from 'node:fs';
|
|
8
|
+
import { scanBrain } from './scanner.js';
|
|
9
|
+
import { runSubsumption } from './subsumption.js';
|
|
10
|
+
import { writeAllTiers } from './emit.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Start watching a brain directory for changes.
|
|
14
|
+
* Auto-recompiles tiers when neurons are added/modified/removed.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} brainRoot - Absolute path to brain root
|
|
17
|
+
*/
|
|
18
|
+
export async function startWatch(brainRoot) {
|
|
19
|
+
let lastHash = '';
|
|
20
|
+
let debounceTimer = null;
|
|
21
|
+
|
|
22
|
+
/** Scan brain and recompile if state changed. */
|
|
23
|
+
function recompile() {
|
|
24
|
+
const brain = scanBrain(brainRoot);
|
|
25
|
+
const result = runSubsumption(brain);
|
|
26
|
+
const hash = computeHash(result);
|
|
27
|
+
|
|
28
|
+
if (hash === lastHash) return;
|
|
29
|
+
lastHash = hash;
|
|
30
|
+
|
|
31
|
+
writeAllTiers(brainRoot, result, brain);
|
|
32
|
+
const ts = new Date().toLocaleTimeString();
|
|
33
|
+
console.log(`[${ts}] \u{1F504} recompiled — ${result.firedNeurons} neurons, activation ${result.totalCounter}${result.bombSource ? ` \u{1F4A3} BOMB: ${result.bombSource}` : ''}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Initial compilation
|
|
37
|
+
recompile();
|
|
38
|
+
console.log(`\u{1F440} watching: ${brainRoot}`);
|
|
39
|
+
console.log(' Press Ctrl+C to stop.\n');
|
|
40
|
+
|
|
41
|
+
// Watch for filesystem changes
|
|
42
|
+
try {
|
|
43
|
+
const watcher = watch(brainRoot, { recursive: true }, (eventType, filename) => {
|
|
44
|
+
if (!filename) return;
|
|
45
|
+
// Ignore _index.md and _rules.md (our own output)
|
|
46
|
+
if (filename.endsWith('_index.md') || filename.endsWith('_rules.md')) return;
|
|
47
|
+
// Ignore hidden/internal
|
|
48
|
+
if (filename.startsWith('.') || filename.includes('/_') || filename.includes('\\_')) return;
|
|
49
|
+
|
|
50
|
+
// Debounce: wait 200ms after last change before recompiling
|
|
51
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
52
|
+
debounceTimer = setTimeout(recompile, 200);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Keep process alive
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
process.on('SIGINT', () => {
|
|
58
|
+
watcher.close();
|
|
59
|
+
console.log('\n\u{1F44B} watch stopped.');
|
|
60
|
+
resolve(undefined);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') {
|
|
65
|
+
console.error('Recursive fs.watch not supported on this platform. Use Node.js >= 22.');
|
|
66
|
+
} else {
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute a simple hash from the subsumption result for change detection.
|
|
74
|
+
* @param {import('./subsumption.js').SubsumptionResult} result
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function computeHash(result) {
|
|
78
|
+
return `${result.firedNeurons}:${result.totalCounter}:${result.bombSource}:${result.activeRegions.length}`;
|
|
79
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hebbian",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Folder-as-neuron brain for any AI agent. mkdir replaces system prompts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hebbian": "./bin/hebbian.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
17
|
+
"lint": "biome check .",
|
|
18
|
+
"lint:fix": "biome check --fix ."
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.0.0"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"brain",
|
|
27
|
+
"neuron",
|
|
28
|
+
"filesystem",
|
|
29
|
+
"memory",
|
|
30
|
+
"prompt",
|
|
31
|
+
"rules",
|
|
32
|
+
"cli",
|
|
33
|
+
"hebbian",
|
|
34
|
+
"subsumption"
|
|
35
|
+
],
|
|
36
|
+
"author": "",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/IISweetHeartII/hebbian"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/IISweetHeartII/hebbian#readme"
|
|
43
|
+
}
|