wicked-brain 0.3.5 → 0.4.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/README.md CHANGED
@@ -33,6 +33,7 @@ Your documents ──> Structured chunks ──> Synthesized wiki ──>
33
33
 
34
34
  - **Chunks** are source-faithful extractions with rich metadata (entities, themes, tags)
35
35
  - **Wiki articles** are LLM-synthesized concepts with `[[backlinks]]` to source chunks
36
+ - **Links carry confidence** — confirmed connections rank higher, contradictions surface for review
36
37
  - **Every claim traces back** to a specific file you can read, edit, or delete
37
38
 
38
39
  The brain is plain markdown on your filesystem. Open it in Obsidian, VS Code, or `cat`. No black box.
@@ -75,6 +76,12 @@ npx wicked-brain
75
76
 
76
77
  That's it. The installer detects your AI CLIs and drops in the skills. First time you use any skill, it walks you through setup.
77
78
 
79
+ To install into a non-standard CLI config path:
80
+
81
+ ```bash
82
+ npx wicked-brain --path=~/alt-configs/.claude
83
+ ```
84
+
78
85
  Or install via [agent-skills-cli](https://github.com/Karanjot786/agent-skills-cli):
79
86
 
80
87
  ```bash
@@ -111,7 +118,7 @@ Every operation uses **progressive loading** — the agent never pulls more than
111
118
  | **Connections** | None (just similarity scores) | Explicit `[[backlinks]]` between concepts |
112
119
  | **Auditability** | Low (why did it retrieve this?) | High (every claim links to a source file) |
113
120
  | **Infrastructure** | Vector DB + embedding pipeline + retrieval service | One SQLite file + markdown |
114
- | **Maintenance** | Re-embed on changes, tune thresholds | Agent self-heals via lint and enhance |
121
+ | **Maintenance** | Re-embed on changes, tune thresholds | Agent self-heals via lint, enhance, and confidence tracking |
115
122
  | **Cost to start** | Embedding API calls for entire corpus | Zero (deterministic chunking is free) |
116
123
  | **Ideal scale** | Millions of documents | 100 - 10,000 high-signal documents |
117
124
 
@@ -119,16 +126,24 @@ Every operation uses **progressive loading** — the agent never pulls more than
119
126
 
120
127
  | Skill | What it does |
121
128
  |---|---|
122
- | `wicked-brain:init` | Set up a new brain (auto-triggers on first use) |
129
+ | `wicked-brain:init` | Set up a new brain creates directory structure, then onboards your project in parallel |
123
130
  | `wicked-brain:ingest` | Add source files — text extracted deterministically, binary docs read via LLM vision |
124
131
  | `wicked-brain:search` | Parallel search across your brain and linked brains |
125
132
  | `wicked-brain:read` | Progressive loading: depth 0 (stats), depth 1 (summary), depth 2 (full content) |
126
133
  | `wicked-brain:query` | Answer questions with source citations |
127
134
  | `wicked-brain:compile` | Synthesize wiki articles from chunks |
128
- | `wicked-brain:lint` | Find broken links, orphan chunks, inconsistencies |
129
- | `wicked-brain:enhance` | Identify and fill knowledge gaps |
130
- | `wicked-brain:status` | Brain health, stats, orientation |
135
+ | `wicked-brain:lint` | Find broken links, orphan chunks, inconsistencies, tag synonyms, low-confidence links; auto-fix where possible |
136
+ | `wicked-brain:enhance` | Identify and fill knowledge gaps with inferred content |
137
+ | `wicked-brain:memory` | Store and recall experiential learnings across sessions (working / episodic / semantic tiers) |
138
+ | `wicked-brain:status` | Brain health, stats, convergence debt detection, contradiction hotspots |
139
+ | `wicked-brain:confirm` | Confirm or contradict a brain link — adjusts confidence score and tracks evidence |
140
+ | `wicked-brain:synonyms` | Manage search synonym mappings; auto-suggest from search misses and tag frequency |
131
141
  | `wicked-brain:server` | Manage the background search server (auto-triggered) |
142
+ | `wicked-brain:configure` | Write brain-aware context into your CLI's config (CLAUDE.md, GEMINI.md, etc.) |
143
+ | `wicked-brain:batch` | Generate scripts for bulk operations — avoids burning context on repetitive tool calls |
144
+ | `wicked-brain:retag` | Backfill synonym-expanded tags across all chunks for better search recall |
145
+ | `wicked-brain:update` | Check npm for updates and reinstall skills across all detected CLIs |
146
+ | `wicked-brain:lsp` | Universal code intelligence via LSP — hover, go-to-definition, diagnostics, completions |
132
147
 
133
148
  ## Multi-Brain Federation
134
149
 
@@ -169,7 +184,7 @@ Modern LLMs read PDF, DOCX, PPTX, and XLSX natively. When you ingest a binary do
169
184
 
170
185
  ## Architecture
171
186
 
172
- **~500 lines of server JavaScript** (SQLite FTS5 + file watcher) + **~900 lines of skill markdown** (agent instructions).
187
+ **~300 lines of server JavaScript** (SQLite FTS5 + file watcher) + **~1,400 lines of skill markdown** (agent instructions).
173
188
 
174
189
  That's the entire system. Compare that to a typical RAG stack:
175
190
 
@@ -182,7 +197,7 @@ Typical RAG: wicked-brain:
182
197
  - Re-ranking model - LLM reasoning
183
198
  - Orchestration layer - Skills (markdown)
184
199
  ───────────────── ─────────────────
185
- ~5,000+ lines, 10+ deps ~1,400 lines, 1 dep
200
+ ~5,000+ lines, 10+ deps ~1,700 lines, 1 dep
186
201
  ```
187
202
 
188
203
  ## Supported CLIs
@@ -194,6 +209,8 @@ Typical RAG: wicked-brain:
194
209
  | GitHub Copilot CLI | Supported |
195
210
  | Cursor | Supported |
196
211
  | Codex | Supported |
212
+ | Kiro | Supported |
213
+ | Antigravity | Supported |
197
214
 
198
215
  Skills use only universally available operations (read files, write files, run shell commands, grep). No CLI-specific features.
199
216
 
package/install.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // wicked-brain installer — detects CLIs and installs skills + agents
3
3
 
4
4
  import { existsSync, mkdirSync, cpSync, readdirSync } from "node:fs";
5
- import { join, resolve } from "node:path";
5
+ import { join, resolve, basename } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { argv } from "node:process";
8
8
  import { fileURLToPath } from "node:url";
@@ -12,56 +12,55 @@ const skillsSource = join(__dirname, "skills");
12
12
  const home = homedir();
13
13
 
14
14
  const CLI_TARGETS = [
15
- { name: "claude", dir: join(home, ".claude", "skills"), agentDir: join(home, ".claude", "agents"), platform: "claude" },
16
- { name: "gemini", dir: join(home, ".gemini", "skills"), agentDir: join(home, ".gemini", "agents"), platform: "gemini" },
17
- { name: "copilot", dir: join(home, ".github", "skills"), agentDir: join(home, ".github", "agents"), platform: "copilot" },
18
- { name: "codex", dir: join(home, ".codex", "skills"), agentDir: join(home, ".codex", "agents"), platform: "codex" },
19
- { name: "cursor", dir: join(home, ".cursor", "skills"), agentDir: join(home, ".cursor", "agents"), platform: "cursor" },
20
- { name: "kiro", dir: join(home, ".kiro", "skills"), agentDir: join(home, ".kiro", "agents"), platform: "kiro" },
21
- { name: "antigravity", dir: join(home, ".antigravity", "skills"), agentDir: join(home, ".antigravity", "rules"), platform: "antigravity" },
15
+ { name: "claude", dir: join(home, ".claude", "skills"), agentDir: join(home, ".claude", "agents"), agentSubdir: "agents", platform: "claude" },
16
+ { name: "gemini", dir: join(home, ".gemini", "skills"), agentDir: join(home, ".gemini", "agents"), agentSubdir: "agents", platform: "gemini" },
17
+ { name: "copilot", dir: join(home, ".github", "skills"), agentDir: join(home, ".github", "agents"), agentSubdir: "agents", platform: "copilot" },
18
+ { name: "codex", dir: join(home, ".codex", "skills"), agentDir: join(home, ".codex", "agents"), agentSubdir: "agents", platform: "codex" },
19
+ { name: "cursor", dir: join(home, ".cursor", "skills"), agentDir: join(home, ".cursor", "agents"), agentSubdir: "agents", platform: "cursor" },
20
+ { name: "kiro", dir: join(home, ".kiro", "skills"), agentDir: join(home, ".kiro", "agents"), agentSubdir: "agents", platform: "kiro" },
21
+ { name: "antigravity", dir: join(home, ".antigravity", "skills"), agentDir: join(home, ".antigravity", "rules"), agentSubdir: "rules", platform: "antigravity" },
22
22
  ];
23
23
 
24
- // Detect which CLIs are installed by checking if parent dir exists
25
- const detected = CLI_TARGETS.filter((t) => {
26
- const parentDir = resolve(t.dir, "..");
27
- return existsSync(parentDir);
28
- });
29
-
30
24
  console.log("wicked-brain installer\n");
31
25
 
32
- if (detected.length === 0) {
33
- console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
34
- console.log("Install skills manually by copying the skills/ directory.");
35
- process.exit(1);
36
- }
37
-
38
- console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
39
-
40
- // Allow filtering via --cli flag or custom --path
41
26
  const args = argv.slice(2);
27
+ const argValue = (a) => a.split("=")[1];
42
28
  const cliArg = args.find((a) => a.startsWith("--cli="));
43
29
  const pathArg = args.find((a) => a.startsWith("--path="));
44
30
 
45
31
  let targets;
46
32
 
47
33
  if (pathArg) {
48
- const rawPath = pathArg.split("=")[1].replace(/^~/, home);
49
- const customPath = resolve(rawPath);
50
- const dirName = customPath.split(/[\\/]/).pop().replace(/^\./, ""); // e.g. ".claude" → "claude"
34
+ const rawPath = argValue(pathArg);
35
+ if (!rawPath) {
36
+ console.error("Error: --path requires a value (e.g. --path=~/.claude)");
37
+ process.exit(1);
38
+ }
39
+ const customPath = resolve(rawPath.replace(/^~/, home));
40
+ // Strip leading dot to match CLI_TARGETS names (e.g. ".claude" → "claude")
41
+ const dirName = basename(customPath).replace(/^\./, "");
51
42
  const knownPlatform = CLI_TARGETS.find((t) => t.name === dirName);
52
- // Use platform's agent subdir name (e.g. antigravity uses "rules"), default to "agents"
53
- const agentSubdirName = knownPlatform
54
- ? knownPlatform.agentDir.split(/[\\/]/).pop()
55
- : "agents";
43
+ const agentSubdir = knownPlatform?.agentSubdir ?? "agents";
56
44
  targets = [{
57
45
  name: dirName,
58
46
  dir: join(customPath, "skills"),
59
- agentDir: join(customPath, agentSubdirName),
47
+ agentDir: join(customPath, agentSubdir),
60
48
  platform: knownPlatform?.platform ?? dirName,
61
49
  }];
62
50
  console.log(`Custom path: ${customPath}\n`);
63
51
  } else {
64
- const cliFilter = cliArg ? cliArg.split("=")[1].split(",") : null;
52
+ // Detect which CLIs are installed by checking if parent dir exists
53
+ const detected = CLI_TARGETS.filter((t) => existsSync(resolve(t.dir, "..")));
54
+
55
+ if (detected.length === 0) {
56
+ console.log("No supported AI CLIs detected. Supported: claude, gemini, copilot, codex, cursor, kiro, antigravity");
57
+ console.log("Install skills manually by copying the skills/ directory.");
58
+ process.exit(1);
59
+ }
60
+
61
+ console.log(`Detected CLIs: ${detected.map((d) => d.name).join(", ")}\n`);
62
+
63
+ const cliFilter = cliArg ? argValue(cliArg).split(",") : null;
65
64
  targets = cliFilter ? detected.filter((d) => cliFilter.includes(d.name)) : detected;
66
65
  }
67
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.3.5",
3
+ "version": "0.4.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": [
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer } from "node:http";
3
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { argv, pid, exit } from "node:process";
6
6
  import { FileWatcher } from "../lib/file-watcher.mjs";
@@ -15,9 +15,27 @@ function getArg(name) {
15
15
  }
16
16
 
17
17
  const brainPath = resolve(getArg("brain") || ".");
18
- const port = parseInt(getArg("port") || "4242", 10);
18
+ const preferredPort = parseInt(getArg("port") || "4242", 10);
19
19
  const configPath = join(brainPath, "brain.json");
20
20
 
21
+ /** Find a free TCP port starting from `start`. */
22
+ function findFreePort(start) {
23
+ return new Promise((resolve, reject) => {
24
+ const tryPort = (p) => {
25
+ const probe = createServer();
26
+ probe.once("error", (err) => {
27
+ if (err.code === "EADDRINUSE") tryPort(p + 1);
28
+ else reject(err);
29
+ });
30
+ probe.once("listening", () => {
31
+ probe.close(() => resolve(p));
32
+ });
33
+ probe.listen(p, "127.0.0.1");
34
+ };
35
+ tryPort(start);
36
+ });
37
+ }
38
+
21
39
  // Read brain config
22
40
  let brainId = "unknown";
23
41
  try {
@@ -66,9 +84,33 @@ const actions = {
66
84
  forward_links: (p) => ({ links: db.forwardLinks(p.id) }),
67
85
  stats: () => db.stats(),
68
86
  candidates: (p) => ({ candidates: db.candidates(p) }),
87
+ symbols: async (p) => {
88
+ // Prefer LSP workspace symbols (structured, language-aware)
89
+ const lspResult = await lsp.workspaceSymbols({ query: p.name || p.query || "" });
90
+ if (lspResult.symbols && lspResult.symbols.length > 0) {
91
+ return {
92
+ results: lspResult.symbols.map(s => ({
93
+ id: `${s.file}::${s.name}`,
94
+ name: s.name,
95
+ type: s.kind,
96
+ file_path: s.file,
97
+ line_start: s.line,
98
+ })),
99
+ source: "lsp",
100
+ };
101
+ }
102
+ // Fall back to FTS-based symbol search
103
+ return { ...db.symbols(p), source: "fts" };
104
+ },
105
+ dependents: (p) => db.dependents(p),
106
+ refs: async (p) => lsp.references(p),
69
107
  access_log: (p) => db.accessLog(p.id),
70
108
  recent_memories: (p) => ({ memories: db.recentMemories(p) }),
71
109
  contradictions: () => ({ links: db.contradictions() }),
110
+ confirm_link: (p) => db.confirmLink(p.source_id, p.target_path, p.verdict),
111
+ link_health: () => db.linkHealth(),
112
+ tag_frequency: () => ({ tags: db.tagFrequency() }),
113
+ search_misses: (p) => ({ misses: db.searchMisses(p) }),
72
114
  // LSP actions
73
115
  "lsp-health": () => lsp.health(),
74
116
  "lsp-symbols": (p) => lsp.symbols(p),
@@ -123,9 +165,10 @@ const server = createServer((req, res) => {
123
165
  });
124
166
 
125
167
  // Read project directories from config
168
+ const metaConfigPath = join(brainPath, "_meta", "config.json");
126
169
  let projects = [];
127
170
  try {
128
- const metaConfig = JSON.parse(readFileSync(join(brainPath, "_meta", "config.json"), "utf-8"));
171
+ const metaConfig = JSON.parse(readFileSync(metaConfigPath, "utf-8"));
129
172
  projects = metaConfig.projects || [];
130
173
  } catch {}
131
174
 
@@ -136,6 +179,18 @@ watcher.onFileChange((relPath, absPath, content, eventType) => {
136
179
  lsp.handleFileChange(relPath, absPath, content, eventType);
137
180
  });
138
181
 
182
+ const port = await findFreePort(preferredPort);
183
+
184
+ // Write actual port back to config so skills can always find the server
185
+ try {
186
+ let metaConfig = {};
187
+ try { metaConfig = JSON.parse(readFileSync(metaConfigPath, "utf-8")); } catch {}
188
+ metaConfig.server_port = port;
189
+ writeFileSync(metaConfigPath, JSON.stringify(metaConfig, null, 2) + "\n");
190
+ } catch (err) {
191
+ console.error(`Warning: could not write port to config: ${err.message}`);
192
+ }
193
+
139
194
  server.listen(port, () => {
140
195
  console.log(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})`);
141
196
  watcher.start();
@@ -2,6 +2,16 @@ import Database from "better-sqlite3";
2
2
  import { parseWikilinks } from "./wikilinks.mjs";
3
3
  import { statSync } from "node:fs";
4
4
 
5
+ /**
6
+ * Extracts body text from a document, stripping YAML frontmatter.
7
+ * Falls back to the raw content if no frontmatter is detected.
8
+ */
9
+ function extractBodyExcerpt(content, maxLen = 300) {
10
+ const match = content.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)/);
11
+ const body = match ? match[1] : content;
12
+ return body.trim().slice(0, maxLen);
13
+ }
14
+
5
15
  function escapeFtsQuery(query) {
6
16
  return query
7
17
  .trim()
@@ -14,6 +24,9 @@ function escapeFtsQuery(query) {
14
24
  /** Weight factor for backlink count in search ranking (PageRank-lite). */
15
25
  const BACKLINK_WEIGHT = 0.5;
16
26
 
27
+ /** Weight factor for average backlink confidence in search ranking. */
28
+ const CONFIDENCE_WEIGHT = 0.3;
29
+
17
30
  /** Weight factor for access count in search ranking. */
18
31
  const SEARCH_ACCESS_WEIGHT = 0.1;
19
32
 
@@ -63,7 +76,9 @@ export class SqliteSearch {
63
76
  target_path TEXT NOT NULL,
64
77
  target_brain TEXT,
65
78
  rel TEXT,
66
- link_text TEXT
79
+ link_text TEXT,
80
+ confidence REAL DEFAULT 0.5,
81
+ evidence_count INTEGER DEFAULT 0
67
82
  );
68
83
 
69
84
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
@@ -76,6 +91,12 @@ export class SqliteSearch {
76
91
  );
77
92
  CREATE INDEX IF NOT EXISTS idx_access_doc ON access_log(doc_id);
78
93
  CREATE INDEX IF NOT EXISTS idx_access_session ON access_log(session_id);
94
+
95
+ CREATE TABLE IF NOT EXISTS search_misses (
96
+ query TEXT NOT NULL,
97
+ searched_at INTEGER NOT NULL,
98
+ session_id TEXT
99
+ );
79
100
  `);
80
101
 
81
102
  this.#migrate();
@@ -114,8 +135,23 @@ export class SqliteSearch {
114
135
  currentVersion = 1;
115
136
  }
116
137
 
117
- // Future migrations go here:
118
- // if (currentVersion < 2) { ... currentVersion = 2; }
138
+ // Migration 2: add confidence + evidence_count to links, add search_misses table
139
+ if (currentVersion < 2) {
140
+ try { this.#db.prepare(`SELECT confidence FROM links LIMIT 0`).get(); } catch {
141
+ this.#db.exec(`ALTER TABLE links ADD COLUMN confidence REAL DEFAULT 0.5`);
142
+ }
143
+ try { this.#db.prepare(`SELECT evidence_count FROM links LIMIT 0`).get(); } catch {
144
+ this.#db.exec(`ALTER TABLE links ADD COLUMN evidence_count INTEGER DEFAULT 0`);
145
+ }
146
+ this.#db.exec(`
147
+ CREATE TABLE IF NOT EXISTS search_misses (
148
+ query TEXT NOT NULL,
149
+ searched_at INTEGER NOT NULL,
150
+ session_id TEXT
151
+ )
152
+ `);
153
+ currentVersion = 2;
154
+ }
119
155
 
120
156
  // Persist the current version
121
157
  this.#db.exec(`DELETE FROM _schema_version`);
@@ -132,8 +168,16 @@ export class SqliteSearch {
132
168
  }
133
169
  }
134
170
 
171
+ /** Extract YAML frontmatter block from content if present. Returns null if none. */
172
+ static #extractFrontmatter(content) {
173
+ const m = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
174
+ return m ? m[1] : null;
175
+ }
176
+
135
177
  index(doc) {
136
- const { id, path, content, frontmatter = null } = doc;
178
+ const { id, path, content } = doc;
179
+ // Auto-extract frontmatter from content when not provided explicitly
180
+ const frontmatter = doc.frontmatter ?? SqliteSearch.#extractFrontmatter(content);
137
181
  const brainId = this.#brainId;
138
182
  const indexedAt = Date.now();
139
183
 
@@ -209,8 +253,10 @@ export class SqliteSearch {
209
253
  d.path,
210
254
  d.brain_id,
211
255
  snippet(documents_fts, 2, '<b>', '</b>', '…', 32) AS snippet,
256
+ SUBSTR(d.content, 1, 1000) AS raw_content,
212
257
  COALESCE(link_count.cnt, 0) AS backlink_count,
213
- COALESCE(ac.cnt, 0) AS access_count
258
+ COALESCE(ac.cnt, 0) AS access_count,
259
+ COALESCE(link_conf.avg_conf, 0.5) AS avg_backlink_confidence
214
260
  FROM documents_fts f
215
261
  JOIN documents d ON d.id = f.id
216
262
  LEFT JOIN (
@@ -218,6 +264,11 @@ export class SqliteSearch {
218
264
  FROM links
219
265
  GROUP BY target_path
220
266
  ) link_count ON d.path = link_count.target_path
267
+ LEFT JOIN (
268
+ SELECT target_path, AVG(confidence) AS avg_conf
269
+ FROM links
270
+ GROUP BY target_path
271
+ ) link_conf ON d.path = link_conf.target_path
221
272
  LEFT JOIN (
222
273
  SELECT doc_id, COUNT(*) AS cnt
223
274
  FROM access_log
@@ -225,10 +276,15 @@ export class SqliteSearch {
225
276
  ) ac ON d.id = ac.doc_id
226
277
  WHERE documents_fts MATCH ?
227
278
  ${sinceClause}
228
- ORDER BY (f.rank - (COALESCE(link_count.cnt, 0) * ${BACKLINK_WEIGHT}) - (COALESCE(ac.cnt, 0) * ${SEARCH_ACCESS_WEIGHT}))
279
+ ORDER BY (f.rank - (COALESCE(link_count.cnt, 0) * ${BACKLINK_WEIGHT}) - (COALESCE(ac.cnt, 0) * ${SEARCH_ACCESS_WEIGHT}) - (COALESCE(link_conf.avg_conf, 0.5) * ${CONFIDENCE_WEIGHT}))
229
280
  LIMIT ? OFFSET ?
230
281
  `)
231
- .all(escaped, ...sinceParams, limit, offset);
282
+ .all(escaped, ...sinceParams, limit, offset)
283
+ .map((row) => {
284
+ const body_excerpt = extractBodyExcerpt(row.raw_content ?? "");
285
+ delete row.raw_content;
286
+ return { ...row, body_excerpt };
287
+ });
232
288
 
233
289
  const countRow = this.#db
234
290
  .prepare(
@@ -241,6 +297,13 @@ export class SqliteSearch {
241
297
 
242
298
  const total_matches = countRow ? countRow.cnt : 0;
243
299
 
300
+ // Log search miss when no results returned
301
+ if (total_matches === 0) {
302
+ this.#db.prepare(
303
+ `INSERT INTO search_misses (query, searched_at, session_id) VALUES (?, ?, ?)`
304
+ ).run(query, Date.now(), session_id ?? null);
305
+ }
306
+
244
307
  // Log access for each returned document if session_id provided
245
308
  if (session_id && rows.length > 0) {
246
309
  const logAccess = this.#db.prepare(
@@ -460,6 +523,222 @@ export class SqliteSearch {
460
523
  .all();
461
524
  }
462
525
 
526
+ /**
527
+ * Confirm or contradict a link, adjusting its confidence score.
528
+ * verdict: "confirm" → confidence += 0.1 (capped at 1.0)
529
+ * verdict: "contradict" → confidence -= 0.2 (floored at 0.0)
530
+ * Returns the updated link row, or null if no matching link was found.
531
+ */
532
+ confirmLink(sourceId, targetPath, verdict) {
533
+ const link = this.#db.prepare(`
534
+ SELECT rowid, confidence, evidence_count
535
+ FROM links
536
+ WHERE source_id = ? AND target_path = ?
537
+ LIMIT 1
538
+ `).get(sourceId, targetPath);
539
+
540
+ if (!link) return null;
541
+
542
+ let newConfidence;
543
+ if (verdict === "confirm") {
544
+ newConfidence = Math.min(link.confidence + 0.1, 1.0);
545
+ } else if (verdict === "contradict") {
546
+ newConfidence = Math.max(link.confidence - 0.2, 0.0);
547
+ } else {
548
+ throw new Error(`Unknown verdict: ${verdict}. Expected "confirm" or "contradict".`);
549
+ }
550
+
551
+ this.#db.prepare(`
552
+ UPDATE links
553
+ SET confidence = ?, evidence_count = evidence_count + 1
554
+ WHERE rowid = ?
555
+ `).run(newConfidence, link.rowid);
556
+
557
+ return this.#db.prepare(`
558
+ SELECT source_id, target_path, confidence, evidence_count
559
+ FROM links
560
+ WHERE rowid = ?
561
+ `).get(link.rowid);
562
+ }
563
+
564
+ /**
565
+ * Returns link health statistics for the lint skill.
566
+ * - broken_links: links where target_path doesn't exist in documents table
567
+ * - low_confidence_links: links where confidence < 0.3
568
+ * - total_links: total link count
569
+ * - avg_confidence: average confidence across all links
570
+ */
571
+ linkHealth() {
572
+ const totalsRow = this.#db.prepare(`
573
+ SELECT COUNT(*) AS total_links, AVG(confidence) AS avg_confidence
574
+ FROM links
575
+ `).get();
576
+
577
+ const brokenRow = this.#db.prepare(`
578
+ SELECT COUNT(*) AS cnt
579
+ FROM links l
580
+ WHERE NOT EXISTS (
581
+ SELECT 1 FROM documents d WHERE d.path = l.target_path
582
+ )
583
+ `).get();
584
+
585
+ const lowConfRow = this.#db.prepare(`
586
+ SELECT COUNT(*) AS cnt
587
+ FROM links
588
+ WHERE confidence < 0.3
589
+ `).get();
590
+
591
+ return {
592
+ total_links: totalsRow.total_links ?? 0,
593
+ avg_confidence: totalsRow.avg_confidence ?? null,
594
+ broken_links: brokenRow.cnt ?? 0,
595
+ low_confidence_links: lowConfRow.cnt ?? 0,
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Returns tag frequency data for synonym detection.
601
+ * Parses frontmatter from all documents and extracts `contains` arrays.
602
+ * Returns [{tag, count}] sorted by count descending.
603
+ */
604
+ tagFrequency() {
605
+ const rows = this.#db.prepare(`
606
+ SELECT frontmatter FROM documents WHERE frontmatter IS NOT NULL
607
+ `).all();
608
+
609
+ const counts = new Map();
610
+
611
+ for (const row of rows) {
612
+ const fm = row.frontmatter;
613
+ // Match: contains: tag1 tag2 tag3 (space-separated inline)
614
+ // or contains: ["tag1","tag2"] (JSON array)
615
+ // or multi-line YAML list (- tag per line)
616
+ // Note: \S required after contains: to avoid matching block-list headers
617
+ const inlineMatch = fm.match(/^contains:[ \t]+(\S.*)$/m);
618
+ if (inlineMatch) {
619
+ const raw = inlineMatch[1].trim();
620
+ let tags = [];
621
+ // Try JSON array first
622
+ if (raw.startsWith("[")) {
623
+ try {
624
+ tags = JSON.parse(raw).map(String);
625
+ } catch {
626
+ tags = raw.replace(/[\[\]"]/g, "").split(/[\s,]+/).filter(Boolean);
627
+ }
628
+ } else {
629
+ tags = raw.split(/\s+/).filter(Boolean);
630
+ }
631
+ for (const tag of tags) {
632
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
633
+ }
634
+ }
635
+
636
+ // Also handle YAML block list: lines starting with " - tag" after "contains:"
637
+ const blockMatch = fm.match(/^contains:\s*\n((?:\s+-\s+.+\n?)+)/m);
638
+ if (!inlineMatch && blockMatch) {
639
+ const listLines = blockMatch[1].match(/^\s+-\s+(.+)$/gm) || [];
640
+ for (const line of listLines) {
641
+ const tag = line.replace(/^\s+-\s+/, "").trim();
642
+ if (tag) counts.set(tag, (counts.get(tag) ?? 0) + 1);
643
+ }
644
+ }
645
+ }
646
+
647
+ return Array.from(counts.entries())
648
+ .map(([tag, count]) => ({ tag, count }))
649
+ .sort((a, b) => b.count - a.count);
650
+ }
651
+
652
+ /**
653
+ * Symbol lookup: FTS search for a symbol name, returning structured results
654
+ * with file path and position extracted from chunk frontmatter.
655
+ * Used as fallback when no LSP server is running.
656
+ */
657
+ symbols({ name, limit = 10 }) {
658
+ const escaped = escapeFtsQuery(name);
659
+ if (!escaped) return { results: [] };
660
+
661
+ const rows = this.#db.prepare(`
662
+ SELECT d.id, d.path, d.frontmatter, d.content
663
+ FROM documents_fts f
664
+ JOIN documents d ON d.id = f.id
665
+ WHERE documents_fts MATCH ?
666
+ ORDER BY rank
667
+ LIMIT ?
668
+ `).all(escaped, limit * 4); // overfetch — we may skip rows with no source_path
669
+
670
+ const seen = new Set();
671
+ const results = [];
672
+
673
+ for (const row of rows) {
674
+ if (results.length >= limit) break;
675
+ const fm = row.frontmatter || SqliteSearch.#extractFrontmatter(row.content) || "";
676
+ const sourcePathMatch = fm.match(/^source_path:\s*(.+)$/m);
677
+ const sourcePath = sourcePathMatch ? sourcePathMatch[1].trim() : null;
678
+ const key = `${sourcePath ?? row.path}::${name}`;
679
+ if (seen.has(key)) continue;
680
+ seen.add(key);
681
+ results.push({
682
+ id: key,
683
+ name,
684
+ type: "unknown",
685
+ file_path: sourcePath,
686
+ chunk_path: row.path,
687
+ line_start: null,
688
+ });
689
+ }
690
+
691
+ return { results };
692
+ }
693
+
694
+ /**
695
+ * Dependent files: find all files (by source_path) that mention the given name.
696
+ * Purely FTS-based — no LSP required.
697
+ */
698
+ dependents({ name, limit = 20 }) {
699
+ const escaped = escapeFtsQuery(name);
700
+ if (!escaped) return { files: [] };
701
+
702
+ const rows = this.#db.prepare(`
703
+ SELECT d.frontmatter, d.content, d.path
704
+ FROM documents_fts f
705
+ JOIN documents d ON d.id = f.id
706
+ WHERE documents_fts MATCH ?
707
+ LIMIT ?
708
+ `).all(escaped, limit * 5);
709
+
710
+ const files = new Map(); // source_path → {file_path, chunk_path}
711
+ for (const row of rows) {
712
+ if (files.size >= limit) break;
713
+ const fm = row.frontmatter || SqliteSearch.#extractFrontmatter(row.content) || "";
714
+ const m = fm.match(/^source_path:\s*(.+)$/m);
715
+ const sourcePath = m ? m[1].trim() : null;
716
+ if (sourcePath && !files.has(sourcePath)) {
717
+ files.set(sourcePath, row.path);
718
+ }
719
+ }
720
+
721
+ return { files: [...files.keys()] };
722
+ }
723
+
724
+ /**
725
+ * Retrieve logged search misses.
726
+ * @param {object} opts
727
+ * @param {number} [opts.limit=50]
728
+ * @param {string} [opts.since] - ISO 8601 timestamp
729
+ */
730
+ searchMisses({ limit = 50, since = null } = {}) {
731
+ const sinceClause = since ? `WHERE searched_at >= ?` : "";
732
+ const sinceParams = since ? [new Date(since).getTime()] : [];
733
+ return this.#db.prepare(`
734
+ SELECT query, searched_at, session_id
735
+ FROM search_misses
736
+ ${sinceClause}
737
+ ORDER BY searched_at DESC
738
+ LIMIT ?
739
+ `).all(...sinceParams, limit);
740
+ }
741
+
463
742
  close() {
464
743
  this.#db.close();
465
744
  }
@@ -7,6 +7,7 @@ const KNOWN_RELS = new Set([
7
7
  "caused-by",
8
8
  "extends",
9
9
  "depends-on",
10
+ "questions",
10
11
  ]);
11
12
 
12
13
  export function parseWikilinks(text) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [
@@ -86,6 +86,26 @@ file, update `authored_at` and `source_hashes`).
86
86
  Read uncovered chunks (frontmatter + body) to understand their content.
87
87
  Group them by topic/concept.
88
88
 
89
+ ## Step 3.5: Identify Cluster Persona
90
+
91
+ For each concept cluster identified in Step 3, analyze the chunk tags and content
92
+ to select the most appropriate synthesis persona:
93
+
94
+ | Chunk tags/content signals | Persona | Focus |
95
+ |---------------------------|---------|-------|
96
+ | architecture, system-design, components, topology | Architect | Emphasize component relationships, trade-offs, scalability |
97
+ | testing, quality, coverage, assertions | QE Specialist | Emphasize test strategies, edge cases, quality metrics |
98
+ | security, auth, encryption, vulnerabilities | Security Analyst | Emphasize threat models, attack surfaces, mitigations |
99
+ | api, endpoints, contracts, integration | API Designer | Emphasize interface contracts, versioning, consumer experience |
100
+ | operations, deployment, monitoring, incidents | SRE | Emphasize reliability, observability, runbooks |
101
+ | data, schema, migration, storage | Data Engineer | Emphasize data integrity, schema evolution, performance |
102
+ | Default (no strong signal) | Generalist | Balanced synthesis across all dimensions |
103
+
104
+ When writing the wiki article in Step 4, adopt this persona's perspective:
105
+ - Frame the article's structure around the persona's priorities
106
+ - Include a "Considerations" section written from the persona's viewpoint
107
+ - Add the persona to the article's frontmatter: `synthesis_persona: {persona_name}`
108
+
89
109
  ## Step 4: Write wiki articles
90
110
 
91
111
  For each concept cluster, write a wiki article to `{brain_path}/wiki/concepts/{concept-name}.md`
@@ -96,6 +116,7 @@ or `{brain_path}/wiki/topics/{topic-name}.md`:
96
116
  authored_by: llm
97
117
  authored_at: {ISO timestamp}
98
118
  confidence: {average of source chunk confidences, rounded to 2 decimals}
119
+ synthesis_persona: generalist
99
120
  source_chunks:
100
121
  - {chunk-path-1}
101
122
  - {chunk-path-2}
@@ -122,6 +143,55 @@ explicit lineage trail so the brain can track how concepts evolve over time.
122
143
  - [[brain-id::cross-brain-concept]] (if applicable)
123
144
  ```
124
145
 
146
+ ## Step 4.5: Consensus Compilation (high-importance clusters only)
147
+
148
+ A cluster is "high-importance" when it has:
149
+ - 5+ source chunks, OR
150
+ - Source chunks with backlink_count >= 3 (check via backlinks API), OR
151
+ - Source chunks spanning 3+ distinct path prefixes (cross-cutting concern)
152
+
153
+ For high-importance clusters, enhance the article with a second perspective:
154
+
155
+ 1. After writing the initial article with the primary persona (Step 4), identify
156
+ a **contrasting** persona that would view the same content differently:
157
+ - Architect ↔ SRE (design vs. operational reality)
158
+ - QE Specialist ↔ Developer (testing rigor vs. implementation pragmatism)
159
+ - Security Analyst ↔ API Designer (lockdown vs. usability)
160
+ - For other combinations, choose the most relevant contrasting view.
161
+
162
+ 2. Re-read the source chunks from the contrasting persona's perspective.
163
+
164
+ 3. Add a "## Alternative Perspectives" section to the article:
165
+ ```
166
+ ## Alternative Perspectives
167
+
168
+ ### {Contrasting Persona} View
169
+
170
+ {2-3 paragraphs examining the topic from the contrasting perspective.
171
+ Highlight where this view agrees with the primary synthesis, and where
172
+ it raises different priorities or concerns.}
173
+
174
+ ### Points of Tension
175
+
176
+ {Bullet list of specific disagreements or trade-offs between the two views.
177
+ Use typed wikilinks where relevant:}
178
+ - [[questions::wiki/concepts/{related-concept}]] — {what the tension is about}
179
+ ```
180
+
181
+ 4. Update the article frontmatter to reflect consensus compilation:
182
+ ```yaml
183
+ synthesis_persona: {primary_persona}
184
+ contrasting_persona: {secondary_persona}
185
+ consensus: true
186
+ ```
187
+
188
+ If the cluster does NOT meet the high-importance threshold, skip this step
189
+ (the single-persona article from Step 4 is sufficient).
190
+
191
+ Note: The `questions` typed link (`[[questions::wiki/concepts/X]]`) is recognized
192
+ by the brain server's wikilink parser alongside existing types (contradicts,
193
+ supersedes, supports, caused-by, extends, depends-on).
194
+
125
195
  ## Step 5: Index new articles
126
196
 
127
197
  For each article written:
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: wicked-brain:confirm
3
+ description: |
4
+ Confirm or contradict a brain link, adjusting its confidence score.
5
+ Increases confidence when a link is confirmed by evidence, decreases it
6
+ when contradicted. Tracks evidence_count for audit purposes.
7
+
8
+ Use when: "confirm this link", "contradict this connection", "adjust link
9
+ confidence", "mark link as confirmed", "this link is wrong".
10
+ ---
11
+
12
+ # wicked-brain:confirm
13
+
14
+ You adjust the confidence score of a brain link based on user feedback.
15
+
16
+ ## Cross-Platform Notes
17
+
18
+ Commands in this skill work on macOS, Linux, and Windows. `curl` is available
19
+ on Windows 10+ and macOS/Linux — it is used here for API calls.
20
+
21
+ For the brain path default:
22
+ - macOS/Linux: ~/.wicked-brain
23
+ - Windows: %USERPROFILE%\.wicked-brain
24
+
25
+ ## Config
26
+
27
+ Read `_meta/config.json` for brain path and server port.
28
+ If it doesn't exist, trigger wicked-brain:init.
29
+
30
+ ## Parameters
31
+
32
+ - **source_id** (required): the ID of the source document that contains the link
33
+ - **target_path** (required): the target path the link points to
34
+ - **verdict** (required): `confirm` or `contradict`
35
+
36
+ ## Process
37
+
38
+ ### Step 1: Validate parameters
39
+
40
+ Ensure `source_id`, `target_path`, and `verdict` are provided.
41
+ `verdict` must be exactly `"confirm"` or `"contradict"`.
42
+
43
+ ### Step 2: Submit the verdict to the server
44
+
45
+ ```bash
46
+ curl -s -X POST http://localhost:{port}/api \
47
+ -H "Content-Type: application/json" \
48
+ -d '{"action":"confirm_link","params":{"source_id":"{source_id}","target_path":"{target_path}","verdict":"{verdict}"}}'
49
+ ```
50
+
51
+ ### Step 3: Report the result
52
+
53
+ If the response contains a `confidence` value, report back to the user:
54
+
55
+ - What the verdict was (`confirmed` or `contradicted`)
56
+ - The updated confidence score (e.g., `0.6`)
57
+ - The evidence_count (how many times this link has been evaluated)
58
+
59
+ Example success response:
60
+ ```
61
+ Link {source_id} → {target_path} {verdict}ed.
62
+ Updated confidence: {confidence} (based on {evidence_count} evaluations)
63
+ ```
64
+
65
+ If the API returns `null` (link not found), report:
66
+ ```
67
+ No link found from {source_id} to {target_path}.
68
+ Use wicked-brain:search to verify the source document ID and target path.
69
+ ```
70
+
71
+ If the API returns an error, report the error message.
72
+
73
+ ### Step 4: Log the action
74
+
75
+ Append an event to `{brain_path}/_meta/log.jsonl`:
76
+
77
+ ```json
78
+ {"ts":"{ISO}","op":"link_{verdict}","source_id":"{source_id}","target_path":"{target_path}","confidence":{new_confidence},"evidence_count":{evidence_count},"author":"agent:confirm"}
79
+ ```
80
+
81
+ Use your Write tool or append via shell:
82
+ - macOS/Linux: `echo '...' >> {brain_path}/_meta/log.jsonl`
83
+ - Windows PowerShell: `Add-Content -Path "{brain_path}\_meta\log.jsonl" -Value '...'`
@@ -25,7 +25,7 @@ For the brain path default:
25
25
 
26
26
  ## Config
27
27
 
28
- Read `_meta/config.json` for brain path and server port.
28
+ Read `{brain_path}/_meta/config.json` for brain path and server port.
29
29
  If it doesn't exist, trigger wicked-brain:init.
30
30
 
31
31
  ## Parameters
@@ -34,6 +34,20 @@ If it doesn't exist, trigger wicked-brain:init.
34
34
 
35
35
  ## Process
36
36
 
37
+ ### Step 0: Ensure server is running
38
+
39
+ Before doing anything else, health-check the server:
40
+
41
+ ```bash
42
+ curl -s -f -X POST http://localhost:{port}/api \
43
+ -H "Content-Type: application/json" \
44
+ -d '{"action":"health","params":{}}'
45
+ ```
46
+
47
+ If this fails (connection refused or non-2xx), invoke `wicked-brain:server` to start it
48
+ before continuing. Re-read `_meta/config.json` after the server starts to get the
49
+ actual port it bound to.
50
+
37
51
  ### Step 1: Assess scope
38
52
 
39
53
  Determine if the source is a single file or a directory.
@@ -169,9 +183,8 @@ Instead, write a batch script and run it. This preserves context and is dramatic
169
183
 
170
184
  ```javascript
171
185
  #!/usr/bin/env node
172
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
186
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, renameSync } from "node:fs";
173
187
  import { join, extname, basename, relative } from "node:path";
174
- import { createHash } from "node:crypto";
175
188
 
176
189
  const BRAIN = "{brain_path}";
177
190
  const PORT = {port};
@@ -273,7 +286,6 @@ async function removeOldChunks(name) {
273
286
  }
274
287
  }
275
288
  // Archive the old directory
276
- const { renameSync } = await import("node:fs");
277
289
  renameSync(chunkDir, `${chunkDir}.archived-${ts}`);
278
290
  console.log(` Archived old chunks: ${name}`);
279
291
  }
@@ -345,17 +357,17 @@ function walk(dir, callback) {
345
357
 
346
358
  console.log(`Ingesting from ${SOURCE_DIR}...`);
347
359
 
360
+ // Collect files first, then process serially so every ingestFile is awaited
361
+ const textFiles = [];
348
362
  walk(SOURCE_DIR, (filePath) => {
349
363
  const ext = extname(filePath).toLowerCase();
350
- if (TEXT_EXT.has(ext)) {
351
- try { ingestFile(filePath); } catch (e) { console.error(` Error: ${filePath}: ${e.message}`); }
352
- } else if (BINARY_EXT.has(ext)) {
353
- binaryFiles.push(filePath);
354
- }
364
+ if (TEXT_EXT.has(ext)) textFiles.push(filePath);
365
+ else if (BINARY_EXT.has(ext)) binaryFiles.push(filePath);
355
366
  });
356
367
 
357
- // Wait for all index operations
358
- await new Promise(r => setTimeout(r, 1000));
368
+ for (const filePath of textFiles) {
369
+ try { await ingestFile(filePath); } catch (e) { console.error(` Error: ${filePath}: ${e.message}`); }
370
+ }
359
371
 
360
372
  console.log(`\nDone: ${totalFiles} files, ${totalChunks} chunks indexed`);
361
373
  if (binaryFiles.length > 0) {
@@ -10,7 +10,7 @@ description: |
10
10
 
11
11
  # wicked-brain:init
12
12
 
13
- You initialize a new digital brain on the filesystem.
13
+ You initialize a new digital brain on the filesystem and get it fully operational.
14
14
 
15
15
  ## Cross-Platform Notes
16
16
 
@@ -38,23 +38,16 @@ Ask these questions (provide defaults):
38
38
  - Default (Windows): `%USERPROFILE%\.wicked-brain`
39
39
  2. "What should this brain be called?" — Default: directory name
40
40
 
41
- ### Step 2: Dispatch onboard agent (fire and continue)
41
+ ### Step 2: Check for existing brain
42
42
 
43
- Immediately dispatch the `wicked-brain-onboard` agent for the current project — don't wait for Steps 3–6 to finish first.
43
+ If `{brain_path}/_meta/config.json` already exists, tell the user:
44
+ "A brain already exists at `{brain_path}`. Do you want to re-initialize it (keeps existing chunks) or pick a different path?"
44
45
 
45
- Pass it:
46
- - `brain_path`: the path confirmed in Step 1
47
- - `project_path`: the current working directory
48
-
49
- **Sequencing rationale:** Onboard starts with a read-only scanning phase (Glob, Grep, Read across the project). That scanning takes meaningful time. Steps 3–6 below are fast — just creating a handful of files and directories. They will complete well before onboard finishes scanning and reaches its write phase (where it needs `brain_path` dirs to exist). So it is safe to fire onboard now and proceed immediately with Steps 3–6; the brain dirs will be in place long before onboard needs them.
50
-
51
- Continue with Steps 3–6 immediately after dispatching.
46
+ Stop and wait for their answer before continuing.
52
47
 
53
48
  ### Step 3: Create directory structure
54
49
 
55
- Use your native Write/mkdir tools to create these directories and files.
56
-
57
- Directories to create (create each with its parent directories):
50
+ Use your native Write tool to create these directories (write a `.gitkeep` placeholder in each):
58
51
  - `{brain_path}/raw`
59
52
  - `{brain_path}/chunks/extracted`
60
53
  - `{brain_path}/chunks/inferred`
@@ -99,21 +92,36 @@ Write to `{brain_path}/_meta/config.json`:
99
92
  }
100
93
  ```
101
94
 
95
+ `server_port: 4242` is the *preferred* port. The server will find a free port starting
96
+ from this value on startup and write the actual port back to this file. You do not
97
+ need to find a free port manually.
98
+
102
99
  ### Step 6: Initialize the event log
103
100
 
104
101
  Use your Write tool to create an empty file at `{brain_path}/_meta/log.jsonl`.
105
102
 
106
- Shell equivalents if needed:
103
+ ### Step 7: Start the server
104
+
105
+ Invoke `wicked-brain:server` to start the server against this brain path.
106
+ The server will pick a free port and write it back to `_meta/config.json`.
107
+
107
108
  ```bash
108
- # macOS/Linux
109
- touch {brain_path}/_meta/log.jsonl
110
- ```
111
- ```powershell
112
- # Windows PowerShell
113
- New-Item -ItemType File -Force -Path "{brain_path}\_meta\log.jsonl"
109
+ npx wicked-brain-server --brain {brain_path} &
114
110
  ```
115
111
 
116
- ### Step 7: Confirm
112
+ Wait for the health check to confirm it's up before continuing.
113
+
114
+ ### Step 8: Ingest the project
115
+
116
+ Invoke `wicked-brain:ingest` with:
117
+ - `brain_path`: `{brain_path}`
118
+ - `source`: the current working directory
119
+
120
+ This indexes the project files so the brain is immediately queryable.
121
+
122
+ ### Step 9: Confirm
117
123
 
118
124
  Tell the user:
119
- "Brain initialized at `{brain_path}`. Onboarding agent is running in the background to index the project."
125
+ "Brain `{name}` is ready at `{brain_path}` {N} files ingested, {M} chunks indexed.
126
+
127
+ Run `/wicked-brain-compile` to synthesize wiki articles from the indexed content."
@@ -80,6 +80,58 @@ For each wiki article with source_hashes in frontmatter:
80
80
  ### Missing frontmatter
81
81
  Check each chunk has required frontmatter fields (source, chunk_id, confidence, indexed_at).
82
82
 
83
+ ### Tag synonym candidates
84
+
85
+ Call the server to get all tag frequencies:
86
+
87
+ ```bash
88
+ curl -s -X POST http://localhost:{port}/api \
89
+ -H "Content-Type: application/json" \
90
+ -d '{"action":"tag_frequency","params":{}}'
91
+ ```
92
+
93
+ The response contains `tags: [{tag, count}]`. Identify potential synonyms:
94
+
95
+ 1. **Substring pairs**: if tag A is a substring of tag B (e.g., "auth" is a substring
96
+ of "authentication"), they may be synonyms. Flag pairs where both appear in the brain.
97
+
98
+ 2. **Edit distance ≤ 2**: tags that differ by at most 2 character insertions, deletions,
99
+ or substitutions (e.g., "authentification" vs "authentication") may be typos or synonyms.
100
+
101
+ For each candidate pair, report as `info` severity with type `synonym_candidate`:
102
+ - **path**: `_meta` (brain-level issue, not file-specific)
103
+ - **message**: `Possible synonym: "{tagA}" ({countA} uses) and "{tagB}" ({countB} uses) — consider merging`
104
+ - **fix**: `Run wicked-brain:retag to consolidate tags`
105
+
106
+ Only report pairs where both tags have at least 1 use.
107
+
108
+ ### Link confidence report
109
+
110
+ Call the server for link health:
111
+
112
+ ```bash
113
+ curl -s -X POST http://localhost:{port}/api \
114
+ -H "Content-Type: application/json" \
115
+ -d '{"action":"link_health","params":{}}'
116
+ ```
117
+
118
+ The response contains:
119
+ - `broken_links`: count of links whose target is not in the index
120
+ - `low_confidence_links`: count of links with confidence < 0.3
121
+ - `total_links`: total link count
122
+ - `avg_confidence`: average confidence across all links
123
+
124
+ Report findings:
125
+
126
+ - If `broken_links > 0`: severity `error`, type `broken_link`:
127
+ `{broken_links} links point to targets not in the index. Use wicked-brain:search to verify targets.`
128
+
129
+ - If `low_confidence_links > 0`: severity `warning`, type `low_confidence`:
130
+ `{low_confidence_links} links have confidence < 0.3. Use wicked-brain:confirm to evaluate them.`
131
+
132
+ - Always include summary stats in the report:
133
+ `Total links: {total_links}, avg confidence: {avg_confidence:.2f}`
134
+
83
135
  ## Pass 2: Semantic analysis
84
136
 
85
137
  Read a sample of chunks and wiki articles. Check:
@@ -37,6 +37,25 @@ If it doesn't exist, trigger wicked-brain:init.
37
37
 
38
38
  ## Process
39
39
 
40
+ ### Step 0: Load synonyms (optional)
41
+
42
+ Check if `{brain_path}/_meta/synonyms.json` exists using the Read tool.
43
+ If it exists, parse it. Format:
44
+ ```json
45
+ {
46
+ "jwt": ["json web token", "auth token"],
47
+ "auth": ["authentication", "authorization"],
48
+ "k8s": ["kubernetes"]
49
+ }
50
+ ```
51
+
52
+ When searching, expand the query: if any word in the query matches a synonym key,
53
+ add the synonym values as additional OR terms.
54
+
55
+ Example: query "jwt validation" → search for "jwt validation" first, then also
56
+ search for "json web token validation" and "auth token validation" if initial
57
+ results are sparse (fewer than 3 results).
58
+
40
59
  ### Step 1: Discover brains to search
41
60
 
42
61
  Use the Read tool on `{brain_path}/brain.json` to get parents and links.
@@ -101,7 +120,21 @@ After all subagents return:
101
120
  3. Sort by score descending
102
121
  4. Tag each result with its brain origin
103
122
 
104
- ### Step 4: Return at requested depth
123
+ ### Step 4: Log search miss (if applicable)
124
+
125
+ If the merged results have 0 matches across all brains, the query is a "search miss."
126
+ Log it so the brain can learn:
127
+ ```bash
128
+ curl -s -X POST http://localhost:{port}/api \
129
+ -H "Content-Type: application/json" \
130
+ -d '{"action":"search_misses","params":{"query":"{original_query}","session_id":"{session_id}"}}'
131
+ ```
132
+
133
+ Note: This logging happens server-side automatically when search returns 0 results.
134
+ The explicit call here is only needed if synonym-expanded searches found results
135
+ but the original query did not.
136
+
137
+ ### Step 5: Return at requested depth
105
138
 
106
139
  **Depth 0 (default):**
107
140
  ```
@@ -78,8 +78,64 @@ Depth 0 plus:
78
78
  - List the top 10 most common tags
79
79
  - Flag any staleness warnings (sources modified after last ingest)
80
80
 
81
+ **Convergence Debt:**
82
+ Detect chunks that are frequently accessed but have never been compiled into wiki articles:
83
+
84
+ ```bash
85
+ curl -s -X POST http://localhost:{port}/api \
86
+ -H "Content-Type: application/json" \
87
+ -d '{"action":"candidates","params":{"mode":"promote","limit":50}}'
88
+ ```
89
+
90
+ For each result where `access_count >= 5` and `session_diversity >= 3`, check whether any wiki article references it. Use the Grep tool on `{brain_path}/wiki/` searching for the chunk path string. If no wiki article references it, flag it as convergence debt:
91
+
92
+ ```
93
+ ⚠ Convergence debt: {path} (accessed {access_count} times across {session_diversity} sessions, no wiki citation)
94
+ ```
95
+
96
+ If any convergence debt exists, suggest running `wicked-brain:compile` to promote high-value chunks.
97
+
98
+ **Contradiction Hotspots:**
99
+ Detect path prefixes that concentrate multiple contradictions:
100
+
101
+ ```bash
102
+ curl -s -X POST http://localhost:{port}/api \
103
+ -H "Content-Type: application/json" \
104
+ -d '{"action":"contradictions","params":{}}'
105
+ ```
106
+
107
+ Group the returned contradiction links by path prefix: take the first two path segments of each linked path (e.g., a path `chunks/extracted/auth/session.md` yields prefix `chunks/extracted/auth/`). If any prefix has 2 or more contradiction links, flag it as a hotspot:
108
+
109
+ ```
110
+ ⚠ Contradiction hotspot: {prefix} ({N} contradictions) — consider dispatching wicked-brain:compile or manual review
111
+ ```
112
+
81
113
  **Depth 2:**
82
114
  Depth 1 plus:
83
115
  - Read `_meta/log.jsonl` fully for recent activity (last 7 days)
84
116
  - List coverage gaps (chunks with no wiki article referencing them)
85
117
  - Full linked brain details
118
+
119
+ **Link Health (Depth 2 only):**
120
+ Check link integrity and surface knowledge gaps using two additional API calls.
121
+
122
+ Get link health:
123
+ ```bash
124
+ curl -s -X POST http://localhost:{port}/api \
125
+ -H "Content-Type: application/json" \
126
+ -d '{"action":"link_health","params":{}}'
127
+ ```
128
+
129
+ Report:
130
+ - Total links checked and number of broken links
131
+ - Links with confidence below 0.5 (low confidence links)
132
+ - Average confidence score across all links
133
+
134
+ Get recent search misses:
135
+ ```bash
136
+ curl -s -X POST http://localhost:{port}/api \
137
+ -H "Content-Type: application/json" \
138
+ -d '{"action":"search_misses","params":{"limit":20}}'
139
+ ```
140
+
141
+ Report the top recurring search miss queries to identify knowledge gaps. If a query appears multiple times, that topic is a strong candidate for ingestion or wiki article creation.
@@ -0,0 +1,133 @@
1
+ ---
2
+ name: wicked-brain:synonyms
3
+ description: |
4
+ Manage the brain's synonym map for search expansion. Add, remove, or review
5
+ synonym mappings. Can also auto-suggest synonyms from search miss data and
6
+ tag frequency analysis.
7
+
8
+ Use when: "add synonym", "manage synonyms", "brain synonyms",
9
+ "why can't I find X", "search isn't finding".
10
+ ---
11
+
12
+ # wicked-brain:synonyms
13
+
14
+ You manage the brain's synonym map for improved search recall.
15
+
16
+ ## Cross-Platform Notes
17
+
18
+ Commands in this skill work on macOS, Linux, and Windows. When a command has
19
+ platform differences, alternatives are shown. Your native tools (Read, Write,
20
+ Grep, Glob) work everywhere — prefer them over shell commands when possible.
21
+
22
+ For the brain path default:
23
+ - macOS/Linux: ~/.wicked-brain
24
+ - Windows: %USERPROFILE%\.wicked-brain
25
+
26
+ ## Config
27
+
28
+ Read `_meta/config.json` for brain path and server port.
29
+ If it doesn't exist, trigger wicked-brain:init.
30
+
31
+ ## Synonym File
32
+
33
+ Location: `{brain_path}/_meta/synonyms.json`
34
+
35
+ Format:
36
+ ```json
37
+ {
38
+ "jwt": ["json web token", "auth token"],
39
+ "auth": ["authentication", "authorization"]
40
+ }
41
+ ```
42
+
43
+ Keys are the short/common form. Values are expansions to try when the key
44
+ appears in a search query. The search skill reads this file before executing
45
+ queries and automatically expands sparse results using these mappings.
46
+
47
+ ## Commands
48
+
49
+ ### Add a synonym
50
+
51
+ Parameters: `term`, `expansions` (comma-separated list)
52
+
53
+ 1. Read `{brain_path}/_meta/synonyms.json` using the Read tool (or start with `{}` if the file does not exist).
54
+ 2. Parse the JSON.
55
+ 3. If the key already exists, merge the new expansions with the existing list (deduplicate).
56
+ 4. If the key is new, add it with the provided expansions as an array.
57
+ 5. Write the updated JSON back using the Write tool.
58
+ 6. Confirm: `Synonym added: "{term}" → [{expansions}]`
59
+
60
+ ### Remove a synonym
61
+
62
+ Parameters: `term`
63
+
64
+ 1. Read `{brain_path}/_meta/synonyms.json` using the Read tool.
65
+ 2. Parse the JSON.
66
+ 3. Delete the key matching `term`. If the key does not exist, report that and stop.
67
+ 4. Write the updated JSON back using the Write tool.
68
+ 5. Confirm: `Synonym removed: "{term}"`
69
+
70
+ ### Review synonyms
71
+
72
+ No parameters.
73
+
74
+ Read `{brain_path}/_meta/synonyms.json` using the Read tool and display the
75
+ current synonym map in a readable table:
76
+
77
+ ```
78
+ Synonym map ({N} entries):
79
+
80
+ jwt → json web token, auth token
81
+ auth → authentication, authorization
82
+ k8s → kubernetes
83
+ ```
84
+
85
+ If the file does not exist, report: "No synonyms defined yet. Use 'add synonym' to create mappings."
86
+
87
+ ### Auto-suggest
88
+
89
+ Analyze search misses and tag frequency to surface candidate synonym mappings
90
+ for user review.
91
+
92
+ **Step 1: Get recent search misses**
93
+
94
+ ```bash
95
+ curl -s -X POST http://localhost:{port}/api \
96
+ -H "Content-Type: application/json" \
97
+ -d '{"action":"search_misses","params":{"limit":50}}'
98
+ ```
99
+
100
+ **Step 2: Get tag frequency**
101
+
102
+ ```bash
103
+ curl -s -X POST http://localhost:{port}/api \
104
+ -H "Content-Type: application/json" \
105
+ -d '{"action":"tag_frequency","params":{}}'
106
+ ```
107
+
108
+ **Step 3: Cross-reference and suggest**
109
+
110
+ For each search miss query:
111
+ - Check whether any word in the query is a substring of an existing tag or vice versa.
112
+ Example: miss query "k8s deploy" — tag "kubernetes" contains "k8s" as a known abbreviation.
113
+ - If a match is found, propose: `"{miss_word}" → ["{tag}"]`
114
+
115
+ For tags that appear to be alternate forms of each other (e.g., "auth" and
116
+ "authentication" both present as tags), suggest consolidating them:
117
+ - `"auth" → ["authentication"]`
118
+
119
+ **Step 4: Present suggestions for approval**
120
+
121
+ List all suggestions in a numbered table before writing anything:
122
+
123
+ ```
124
+ Suggested synonyms (review before applying):
125
+
126
+ 1. "k8s" → ["kubernetes"] (from search miss: "k8s deploy")
127
+ 2. "auth" → ["authentication"] (tag consolidation)
128
+ ```
129
+
130
+ Ask: "Apply all, apply some (specify numbers), or cancel?"
131
+
132
+ Only write to `synonyms.json` after explicit user approval. Merge approved
133
+ suggestions with any existing entries using the same add-synonym logic above.