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/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
+ }
@@ -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
+ }