wicked-brain 0.5.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 +2 -2
- package/server/bin/wicked-brain-server.mjs +15 -1
- package/server/lib/bus.mjs +10 -0
- package/server/lib/memory-promoter.mjs +105 -0
- package/server/lib/memory-subscriber.mjs +117 -0
- package/server/lib/sqlite-search.mjs +38 -5
- package/server/package.json +1 -1
- package/skills/wicked-brain-memory/SKILL.md +23 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-brain",
|
|
3
|
-
"version": "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.
|
|
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
|
});
|
package/server/lib/bus.mjs
CHANGED
|
@@ -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
|
}
|
package/server/package.json
CHANGED
|
@@ -35,10 +35,12 @@ project root.
|
|
|
35
35
|
|
|
36
36
|
- **mode** (required): `store` or `recall`
|
|
37
37
|
- **content** (store mode): the memory content to store
|
|
38
|
-
- **type** (store mode, optional): `decision`, `pattern
|
|
38
|
+
- **type** (store mode, optional): `decision`, `pattern` (alias: `procedural`), `preference`, `gotcha`, or `discovery`. Auto-detected if omitted. `procedural` is an input alias only — storage frontmatter always writes `type: pattern`.
|
|
39
39
|
- **ttl_days** (store mode, optional): number of days before this memory expires. Defaults by type.
|
|
40
|
+
- **importance** (store mode, optional): `low`, `medium`, or `high`. Overrides the type default. Bands: low = 1-3, medium = 4-6, high = 7-10. Used to derive the initial tier (see Step 2b).
|
|
41
|
+
- **tier** (store mode, optional): explicit tier override (`working`, `episodic`, `semantic`). Takes precedence over `importance`.
|
|
40
42
|
- **query** (recall mode): search term for finding memories
|
|
41
|
-
- **filter_type** (recall mode, optional): filter by memory type
|
|
43
|
+
- **filter_type** (recall mode, optional): filter by memory type. `procedural` is normalized to `pattern`.
|
|
42
44
|
- **filter_tier** (recall mode, optional): filter by tier (`working`, `episodic`, `semantic`)
|
|
43
45
|
|
|
44
46
|
## Store Mode
|
|
@@ -47,11 +49,15 @@ project root.
|
|
|
47
49
|
|
|
48
50
|
If type is not provided, classify the content:
|
|
49
51
|
- Contains "decided", "chose", "will use", "going with" → `decision`
|
|
50
|
-
- Contains "pattern", "always", "tends to", "convention" → `pattern`
|
|
52
|
+
- Contains "pattern", "always", "tends to", "convention" → `pattern` (if the caller explicitly passed `type: procedural`, normalize to `pattern` before continuing)
|
|
51
53
|
- Contains "prefer", "like", "want", "should always" → `preference`
|
|
52
54
|
- Contains "watch out", "careful", "gotcha", "trap", "bug" → `gotcha`
|
|
53
55
|
- Otherwise → `discovery`
|
|
54
56
|
|
|
57
|
+
#### Type aliases
|
|
58
|
+
|
|
59
|
+
- `procedural` → `pattern` (normalize on input; storage always writes `type: pattern`)
|
|
60
|
+
|
|
55
61
|
### Step 2: Apply type defaults
|
|
56
62
|
|
|
57
63
|
| Type | Default importance | Default ttl_days |
|
|
@@ -62,7 +68,14 @@ If type is not provided, classify the content:
|
|
|
62
68
|
| gotcha | 5 | 30 |
|
|
63
69
|
| discovery | 4 | 14 |
|
|
64
70
|
|
|
65
|
-
Agent-provided overrides take precedence.
|
|
71
|
+
Agent-provided overrides take precedence. An explicit `importance` arg overrides the type default.
|
|
72
|
+
|
|
73
|
+
### Step 2b: Resolve initial tier from importance
|
|
74
|
+
|
|
75
|
+
- If `tier` is explicitly passed → use that, skip the rest.
|
|
76
|
+
- Else if `importance` is `high` (or numeric importance >= 7) → `semantic`
|
|
77
|
+
- Else if `importance` is `low` (or numeric importance <= 3) → `working`
|
|
78
|
+
- Else (medium, or 4-6) → `episodic`
|
|
66
79
|
|
|
67
80
|
### Step 3: Generate tags with synonym expansion
|
|
68
81
|
|
|
@@ -88,7 +101,7 @@ Write to `{brain_path}/memory/{safe_name}.md`:
|
|
|
88
101
|
```yaml
|
|
89
102
|
---
|
|
90
103
|
type: {detected or provided type}
|
|
91
|
-
tier:
|
|
104
|
+
tier: {resolved tier from Step 2b}
|
|
92
105
|
confidence: 0.5
|
|
93
106
|
importance: {from type defaults or override}
|
|
94
107
|
ttl_days: {from type defaults or override, null if permanent}
|
|
@@ -112,14 +125,14 @@ indexed_at: "{ISO 8601 timestamp}"
|
|
|
112
125
|
- **episodic**: Specific events or decisions from past sessions. Medium longevity. Use for "we decided X on date Y" or "this happened in project Z".
|
|
113
126
|
- **semantic**: Generalized patterns and facts extracted from experience. Permanent by default. Use for stable conventions, recurring patterns, and distilled knowledge that transcends any single session.
|
|
114
127
|
|
|
115
|
-
New memories
|
|
128
|
+
New memories start at the tier resolved from importance (default `episodic` for medium importance, `working` for low, `semantic` for high). Consolidation (wicked-brain:consolidate) still promotes them across tiers based on access frequency and age.
|
|
116
129
|
|
|
117
130
|
#### Complete example
|
|
118
131
|
|
|
119
132
|
```yaml
|
|
120
133
|
---
|
|
121
134
|
type: decision
|
|
122
|
-
tier:
|
|
135
|
+
tier: semantic
|
|
123
136
|
confidence: 0.9
|
|
124
137
|
importance: 7
|
|
125
138
|
ttl_days: null
|
|
@@ -151,7 +164,7 @@ The server's file watcher will auto-index this file.
|
|
|
151
164
|
Append to `{brain_path}/_meta/log.jsonl`:
|
|
152
165
|
|
|
153
166
|
```json
|
|
154
|
-
{"ts":"{ISO}","op":"memory_store","path":"memory/{safe_name}.md","type":"{type}","tier":"
|
|
167
|
+
{"ts":"{ISO}","op":"memory_store","path":"memory/{safe_name}.md","type":"{type}","tier":"{resolved tier}","author":"agent:memory"}
|
|
155
168
|
```
|
|
156
169
|
|
|
157
170
|
### Step 7: Emit bus event
|
|
@@ -161,7 +174,7 @@ npx wicked-bus emit \
|
|
|
161
174
|
--type "wicked.memory.stored" \
|
|
162
175
|
--domain "wicked-brain" \
|
|
163
176
|
--subdomain "brain.memory" \
|
|
164
|
-
--payload '{"path":"memory/{safe_name}.md","type":"{type}","tier":"
|
|
177
|
+
--payload '{"path":"memory/{safe_name}.md","type":"{type}","tier":"{resolved tier}","brain_id":"{brain_id}"}' 2>/dev/null || true
|
|
165
178
|
```
|
|
166
179
|
|
|
167
180
|
Fire-and-forget — if the bus is not installed, silently skip.
|
|
@@ -187,7 +200,7 @@ consolidation. Use a consistent session_id for the entire conversation.
|
|
|
187
200
|
|
|
188
201
|
### Step 2: Filter results
|
|
189
202
|
|
|
190
|
-
Filter to paths starting with `memory/`. If filter_type or filter_tier provided, read frontmatter and filter accordingly.
|
|
203
|
+
Filter to paths starting with `memory/`. If filter_type or filter_tier provided, read frontmatter and filter accordingly. Normalize `filter_type: procedural` to `pattern` before matching, so it matches memories stored with `type: pattern`.
|
|
191
204
|
|
|
192
205
|
### Step 3: Apply tier weighting
|
|
193
206
|
|