wicked-brain 0.6.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Digital brain as skills for AI coding CLIs — no vector DB, no embeddings, no infrastructure",
6
6
  "keywords": [
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "better-sqlite3": "^12.0.0",
35
- "wicked-bus": "^1.0.0"
35
+ "wicked-bus": "^1.1.0"
36
36
  },
37
37
  "files": [
38
38
  "install.mjs",
@@ -7,6 +7,7 @@ import { FileWatcher } from "../lib/file-watcher.mjs";
7
7
  import { SqliteSearch } from "../lib/sqlite-search.mjs";
8
8
  import { LspClient } from "../lib/lsp-client.mjs";
9
9
  import { emitEvent, waitForBus } from "../lib/bus.mjs";
10
+ import { startMemorySubscriber } from "../lib/memory-subscriber.mjs";
10
11
 
11
12
  // Parse args
12
13
  const args = argv.slice(2);
@@ -74,11 +75,17 @@ try {
74
75
  // LSP client — pass source path so language servers are rooted at the project, not the brain dir
75
76
  const lsp = new LspClient(brainPath, db, sourcePath);
76
77
 
78
+ // Auto-memorize subscriber handle (set after bus init in server.listen callback)
79
+ let memorySubscriber = null;
80
+
77
81
  // Graceful shutdown
78
82
  async function shutdown() {
79
83
  console.log("Shutting down...");
80
84
  try { unlinkSync(pidPath); } catch {}
81
85
  watcher.stop();
86
+ if (memorySubscriber) {
87
+ try { await memorySubscriber.stop(); } catch {}
88
+ }
82
89
  await lsp.shutdown();
83
90
  db.close();
84
91
  exit(0);
@@ -238,8 +245,15 @@ try {
238
245
  server.listen(port, async () => {
239
246
  console.log(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})`);
240
247
  watcher.start();
241
- await waitForBus();
248
+ const busReady = await waitForBus();
242
249
  emitEvent("wicked.server.started", "brain.system", {
243
250
  brain_id: brainId, port, pid,
244
251
  });
252
+ if (busReady) {
253
+ try {
254
+ memorySubscriber = await startMemorySubscriber({ brainPath, brainId, db });
255
+ } catch (err) {
256
+ console.error(`[memory-subscriber] failed to start: ${err.message}`);
257
+ }
258
+ }
245
259
  });
@@ -65,6 +65,16 @@ export function busAvailable() {
65
65
  return available;
66
66
  }
67
67
 
68
+ /** Alias for busAvailable() — preferred name going forward. */
69
+ export function isBusAvailable() {
70
+ return available;
71
+ }
72
+
73
+ /** Returns the open bus DB handle, or null if unavailable. Reuses the connection opened at init. */
74
+ export function getBusDb() {
75
+ return available ? busDb : null;
76
+ }
77
+
68
78
  /**
69
79
  * Wait for bus initialization to complete.
70
80
  * Only needed if you must know availability before the first emit.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Promotion policy for auto-memorizing wicked.fact.extracted bus events.
3
+ *
4
+ * Pure function — no I/O, no side effects. Returns either a memory descriptor
5
+ * (frontmatter + content + safeName + contentHash) or a skip reason.
6
+ *
7
+ * @module lib/memory-promoter
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+
12
+ const IMPORTANCE_BY_TYPE = { decision: 7, discovery: 4 };
13
+ const TTL_BY_TYPE = { decision: null, discovery: 14 };
14
+ const ALLOWED_TYPES = new Set(["decision", "discovery"]);
15
+ const MIN_CONTENT_LENGTH = 15;
16
+ const MAX_TAGS = 15;
17
+
18
+ /** Stable normalization for content hashing — collapses whitespace, lowercases. */
19
+ export function computeContentHash(content) {
20
+ const normalized = String(content || "").trim().replace(/\s+/g, " ").toLowerCase();
21
+ return createHash("sha256").update(normalized).digest("hex");
22
+ }
23
+
24
+ /** Slugify text for filenames: lowercase, alnum + dashes, collapsed, trimmed, capped. */
25
+ export function slugify(text, maxLen = 60) {
26
+ const slug = String(text || "")
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, "-")
29
+ .replace(/-+/g, "-")
30
+ .replace(/^-|-$/g, "");
31
+ return slug.slice(0, maxLen).replace(/-+$/g, "");
32
+ }
33
+
34
+ function tierFromImportance(importance) {
35
+ if (importance >= 7) return "semantic";
36
+ if (importance >= 4) return "episodic";
37
+ return "working";
38
+ }
39
+
40
+ /**
41
+ * Apply the auto-memorize promotion policy to a bus event.
42
+ * @param {object} event
43
+ * @returns {{memory: object|null, skip: boolean, reason?: string}}
44
+ */
45
+ export function promoteFact(event) {
46
+ if (!event || event.event_type !== "wicked.fact.extracted") {
47
+ return { memory: null, skip: true, reason: "wrong event_type" };
48
+ }
49
+ const payload = event.payload || {};
50
+ const type = payload.type;
51
+ if (!ALLOWED_TYPES.has(type)) {
52
+ return { memory: null, skip: true, reason: `type ${type} not auto-promoted` };
53
+ }
54
+ const content = typeof payload.content === "string" ? payload.content : null;
55
+ if (!content) {
56
+ return { memory: null, skip: true, reason: "missing payload.content" };
57
+ }
58
+ const trimmed = content.trim();
59
+ if (trimmed.length < MIN_CONTENT_LENGTH) {
60
+ return { memory: null, skip: true, reason: "content too short" };
61
+ }
62
+
63
+ const importance = IMPORTANCE_BY_TYPE[type];
64
+ const tier = tierFromImportance(importance);
65
+ const ttlDays = TTL_BY_TYPE[type];
66
+ const contentHash = computeContentHash(trimmed);
67
+
68
+ const sourceDomain = event.domain || "unknown";
69
+ const source = `bus:${sourceDomain}`;
70
+
71
+ const emittedAt = event.emitted_at || Date.now();
72
+ const sessionOrigin = payload.session_id || new Date(emittedAt).toISOString();
73
+
74
+ const entityList = Array.isArray(payload.entities) ? payload.entities.map(String) : [];
75
+ // Tags = entities + the type label, deduped, capped at MAX_TAGS
76
+ const tagSet = new Set();
77
+ for (const e of entityList) {
78
+ if (e) tagSet.add(e);
79
+ if (tagSet.size >= MAX_TAGS) break;
80
+ }
81
+ if (tagSet.size < MAX_TAGS) tagSet.add(type);
82
+ const contains = Array.from(tagSet).slice(0, MAX_TAGS);
83
+
84
+ const frontmatter = {
85
+ type,
86
+ tier,
87
+ confidence: 0.3,
88
+ importance,
89
+ content_hash: contentHash,
90
+ source,
91
+ ttl_days: ttlDays,
92
+ session_origin: sessionOrigin,
93
+ contains,
94
+ entities: { systems: entityList, people: [] },
95
+ indexed_at: new Date().toISOString(),
96
+ };
97
+
98
+ const slugBase = slugify(trimmed.slice(0, 60));
99
+ const safeName = `${slugBase || "memory"}-${contentHash.slice(0, 8)}.md`;
100
+
101
+ return {
102
+ memory: { frontmatter, content: trimmed, safeName, contentHash },
103
+ skip: false,
104
+ };
105
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Auto-memorize subscriber: bridges wicked-bus fact events into brain memories.
3
+ *
4
+ * Subscribes to `wicked.fact.extracted` via wicked-bus durable cursors,
5
+ * runs each event through the promoter policy, dedups by content hash,
6
+ * and writes a memory file. The brain file watcher picks it up and indexes it.
7
+ *
8
+ * @module lib/memory-subscriber
9
+ */
10
+
11
+ import { writeFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { getBusDb, isBusAvailable, emitEvent } from "./bus.mjs";
14
+ import { promoteFact } from "./memory-promoter.mjs";
15
+
16
+ /**
17
+ * Start the auto-memorize subscriber.
18
+ * Returns the subscription handle (with .stop()) or null if the bus is unavailable.
19
+ * Dynamic-imports wicked-bus so the server still loads when the package is absent
20
+ * (matches the graceful-degradation pattern used by bus.mjs).
21
+ *
22
+ * @param {object} opts
23
+ * @param {string} opts.brainPath absolute brain directory
24
+ * @param {string} opts.brainId
25
+ * @param {object} opts.db brain SqliteSearch instance (for findByContentHash)
26
+ */
27
+ export async function startMemorySubscriber({ brainPath, brainId, db }) {
28
+ if (!isBusAvailable()) return null;
29
+ const busDb = getBusDb();
30
+ if (!busDb) return null;
31
+
32
+ let subscribe;
33
+ try {
34
+ ({ subscribe } = await import("wicked-bus"));
35
+ } catch {
36
+ return null;
37
+ }
38
+
39
+ const memoryDir = join(brainPath, "memory");
40
+
41
+ const sub = subscribe({
42
+ db: busDb,
43
+ plugin: "wicked-brain",
44
+ filter: "wicked.fact.extracted",
45
+ cursor_init: "latest",
46
+ pollIntervalMs: 15000,
47
+ maxRetries: 3,
48
+ backoffMs: [1000, 5000, 30000],
49
+ handler: async (event) => {
50
+ const result = promoteFact(event);
51
+ if (result.skip) return;
52
+
53
+ // Dedup by stable content_hash
54
+ const existing = db.findByContentHash(result.memory.contentHash);
55
+ if (existing) return;
56
+
57
+ const filePath = join(memoryDir, result.memory.safeName);
58
+ if (existsSync(filePath)) return; // filename collision — skip
59
+
60
+ const fileContent = renderMemoryFile(result.memory);
61
+ writeFileSync(filePath, fileContent, "utf-8");
62
+
63
+ emitEvent("wicked.memory.stored", "brain.memory", {
64
+ path: `memory/${result.memory.safeName}`,
65
+ type: result.memory.frontmatter.type,
66
+ tier: result.memory.frontmatter.tier,
67
+ source: result.memory.frontmatter.source,
68
+ brain_id: brainId,
69
+ });
70
+ },
71
+ onError: (err, event) => {
72
+ console.error(`[memory-subscriber] handler error on event ${event?.event_id}: ${err.message}`);
73
+ },
74
+ onDeadLetter: (event, reason) => {
75
+ console.error(`[memory-subscriber] dead-lettered event ${event?.event_id}: ${reason}`);
76
+ emitEvent("wicked.memory.dead_lettered", "brain.memory", {
77
+ event_id: event?.event_id,
78
+ reason,
79
+ brain_id: brainId,
80
+ });
81
+ },
82
+ });
83
+
84
+ return sub;
85
+ }
86
+
87
+ /**
88
+ * Render a memory descriptor as a markdown file with YAML-ish frontmatter.
89
+ * Minimal serializer — no YAML lib. Matches the format used by wicked-brain:memory.
90
+ */
91
+ export function renderMemoryFile(memory) {
92
+ const fm = memory.frontmatter;
93
+ const lines = ["---"];
94
+ for (const [key, value] of Object.entries(fm)) {
95
+ if (value === null) { lines.push(`${key}: null`); continue; }
96
+ if (Array.isArray(value)) {
97
+ lines.push(`${key}:`);
98
+ for (const item of value) lines.push(` - ${item}`);
99
+ continue;
100
+ }
101
+ if (typeof value === "object") {
102
+ lines.push(`${key}:`);
103
+ for (const [k, v] of Object.entries(value)) {
104
+ if (Array.isArray(v)) {
105
+ lines.push(` ${k}: [${v.map(x => JSON.stringify(x)).join(", ")}]`);
106
+ } else {
107
+ lines.push(` ${k}: ${JSON.stringify(v)}`);
108
+ }
109
+ }
110
+ continue;
111
+ }
112
+ if (typeof value === "string") { lines.push(`${key}: ${JSON.stringify(value)}`); continue; }
113
+ lines.push(`${key}: ${value}`);
114
+ }
115
+ lines.push("---", "", memory.content, "");
116
+ return lines.join("\n");
117
+ }
@@ -59,7 +59,8 @@ export class SqliteSearch {
59
59
  content TEXT NOT NULL,
60
60
  frontmatter TEXT,
61
61
  brain_id TEXT NOT NULL,
62
- indexed_at INTEGER NOT NULL
62
+ indexed_at INTEGER NOT NULL,
63
+ content_hash TEXT
63
64
  );
64
65
 
65
66
  CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
@@ -153,11 +154,29 @@ export class SqliteSearch {
153
154
  currentVersion = 2;
154
155
  }
155
156
 
157
+ // Migration 3: add content_hash column + index for memory dedup
158
+ if (currentVersion < 3) {
159
+ try { this.#db.prepare(`SELECT content_hash FROM documents LIMIT 0`).get(); } catch {
160
+ this.#db.exec(`ALTER TABLE documents ADD COLUMN content_hash TEXT`);
161
+ }
162
+ this.#db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_content_hash ON documents(content_hash)`);
163
+ currentVersion = 3;
164
+ }
165
+
156
166
  // Persist the current version
157
167
  this.#db.exec(`DELETE FROM _schema_version`);
158
168
  this.#db.prepare(`INSERT INTO _schema_version (version) VALUES (?)`).run(currentVersion);
159
169
  }
160
170
 
171
+ /** Extract a single field value from a raw frontmatter string (no YAML dep). */
172
+ #extractFrontmatterField(frontmatterStr, fieldName) {
173
+ if (!frontmatterStr) return null;
174
+ const re = new RegExp(`^${fieldName}:\\s*(.+)$`, "m");
175
+ const m = frontmatterStr.match(re);
176
+ if (!m) return null;
177
+ return m[1].trim().replace(/^["']|["']$/g, "");
178
+ }
179
+
161
180
  /** Returns the current schema version number. */
162
181
  schemaVersion() {
163
182
  try {
@@ -180,16 +199,18 @@ export class SqliteSearch {
180
199
  const frontmatter = doc.frontmatter ?? SqliteSearch.#extractFrontmatter(content);
181
200
  const brainId = this.#brainId;
182
201
  const indexedAt = Date.now();
202
+ const contentHash = this.#extractFrontmatterField(frontmatter, "content_hash");
183
203
 
184
204
  const upsertDoc = this.#db.prepare(`
185
- INSERT INTO documents (id, path, content, frontmatter, brain_id, indexed_at)
186
- VALUES (?, ?, ?, ?, ?, ?)
205
+ INSERT INTO documents (id, path, content, frontmatter, brain_id, indexed_at, content_hash)
206
+ VALUES (?, ?, ?, ?, ?, ?, ?)
187
207
  ON CONFLICT(id) DO UPDATE SET
188
208
  path = excluded.path,
189
209
  content = excluded.content,
190
210
  frontmatter = excluded.frontmatter,
191
211
  brain_id = excluded.brain_id,
192
- indexed_at = excluded.indexed_at
212
+ indexed_at = excluded.indexed_at,
213
+ content_hash = excluded.content_hash
193
214
  `);
194
215
 
195
216
  const deleteFts = this.#db.prepare(`DELETE FROM documents_fts WHERE id = ?`);
@@ -205,7 +226,7 @@ export class SqliteSearch {
205
226
  `);
206
227
 
207
228
  const run = this.#db.transaction(() => {
208
- upsertDoc.run(id, path, content, frontmatter, brainId, indexedAt);
229
+ upsertDoc.run(id, path, content, frontmatter, brainId, indexedAt, contentHash);
209
230
  deleteFts.run(id);
210
231
  insertFts.run(id, path, content, brainId);
211
232
  deleteLinks.run(id);
@@ -783,6 +804,18 @@ export class SqliteSearch {
783
804
  `).all(...sinceParams, limit);
784
805
  }
785
806
 
807
+ /**
808
+ * Find the first document with a matching content_hash, or null.
809
+ * Used by the auto-memorize subscriber for dedup.
810
+ */
811
+ findByContentHash(hash) {
812
+ if (!hash) return null;
813
+ const row = this.#db.prepare(`
814
+ SELECT id, path FROM documents WHERE content_hash = ? LIMIT 1
815
+ `).get(hash);
816
+ return row || null;
817
+ }
818
+
786
819
  close() {
787
820
  this.#db.close();
788
821
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [