wicked-brain 0.3.7 → 0.4.2

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.
@@ -117,7 +118,7 @@ Every operation uses **progressive loading** — the agent never pulls more than
117
118
  | **Connections** | None (just similarity scores) | Explicit `[[backlinks]]` between concepts |
118
119
  | **Auditability** | Low (why did it retrieve this?) | High (every claim links to a source file) |
119
120
  | **Infrastructure** | Vector DB + embedding pipeline + retrieval service | One SQLite file + markdown |
120
- | **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 |
121
122
  | **Cost to start** | Embedding API calls for entire corpus | Zero (deterministic chunking is free) |
122
123
  | **Ideal scale** | Millions of documents | 100 - 10,000 high-signal documents |
123
124
 
@@ -125,16 +126,18 @@ Every operation uses **progressive loading** — the agent never pulls more than
125
126
 
126
127
  | Skill | What it does |
127
128
  |---|---|
128
- | `wicked-brain:init` | Set up a new brain — creates directory structure, then onboards your project in parallel |
129
+ | `wicked-brain:init` | Set up a new brain — creates structure, starts the server, and ingests your project in one shot |
129
130
  | `wicked-brain:ingest` | Add source files — text extracted deterministically, binary docs read via LLM vision |
130
131
  | `wicked-brain:search` | Parallel search across your brain and linked brains |
131
132
  | `wicked-brain:read` | Progressive loading: depth 0 (stats), depth 1 (summary), depth 2 (full content) |
132
133
  | `wicked-brain:query` | Answer questions with source citations |
133
134
  | `wicked-brain:compile` | Synthesize wiki articles from chunks |
134
- | `wicked-brain:lint` | Find broken links, orphan chunks, inconsistencies; auto-fix where possible |
135
+ | `wicked-brain:lint` | Find broken links, orphan chunks, inconsistencies, tag synonyms, low-confidence links; auto-fix where possible |
135
136
  | `wicked-brain:enhance` | Identify and fill knowledge gaps with inferred content |
136
137
  | `wicked-brain:memory` | Store and recall experiential learnings across sessions (working / episodic / semantic tiers) |
137
- | `wicked-brain:status` | Brain health, stats, orientation |
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 |
138
141
  | `wicked-brain:server` | Manage the background search server (auto-triggered) |
139
142
  | `wicked-brain:configure` | Write brain-aware context into your CLI's config (CLAUDE.md, GEMINI.md, etc.) |
140
143
  | `wicked-brain:batch` | Generate scripts for bulk operations — avoids burning context on repetitive tool calls |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.3.7",
3
+ "version": "0.4.2",
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,37 @@ 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
+ try {
90
+ const lspResult = await lsp.workspaceSymbols({ query: p.name || p.query || "" });
91
+ if (lspResult.symbols && lspResult.symbols.length > 0) {
92
+ return {
93
+ results: lspResult.symbols.map(s => ({
94
+ id: `${s.file}::${s.name}`,
95
+ name: s.name,
96
+ type: s.kind,
97
+ file_path: s.file,
98
+ line_start: s.line,
99
+ })),
100
+ source: "lsp",
101
+ };
102
+ }
103
+ } catch {
104
+ // LSP unavailable or errored (e.g. no tsconfig.json) — fall through to FTS
105
+ }
106
+ // Fall back to FTS-based symbol search
107
+ return { ...db.symbols(p), source: "fts" };
108
+ },
109
+ dependents: (p) => db.dependents(p),
110
+ refs: async (p) => lsp.references(p),
69
111
  access_log: (p) => db.accessLog(p.id),
70
112
  recent_memories: (p) => ({ memories: db.recentMemories(p) }),
71
113
  contradictions: () => ({ links: db.contradictions() }),
114
+ confirm_link: (p) => db.confirmLink(p.source_id, p.target_path, p.verdict),
115
+ link_health: () => db.linkHealth(),
116
+ tag_frequency: () => ({ tags: db.tagFrequency() }),
117
+ search_misses: (p) => ({ misses: db.searchMisses(p) }),
72
118
  // LSP actions
73
119
  "lsp-health": () => lsp.health(),
74
120
  "lsp-symbols": (p) => lsp.symbols(p),
@@ -123,9 +169,10 @@ const server = createServer((req, res) => {
123
169
  });
124
170
 
125
171
  // Read project directories from config
172
+ const metaConfigPath = join(brainPath, "_meta", "config.json");
126
173
  let projects = [];
127
174
  try {
128
- const metaConfig = JSON.parse(readFileSync(join(brainPath, "_meta", "config.json"), "utf-8"));
175
+ const metaConfig = JSON.parse(readFileSync(metaConfigPath, "utf-8"));
129
176
  projects = metaConfig.projects || [];
130
177
  } catch {}
131
178
 
@@ -136,6 +183,18 @@ watcher.onFileChange((relPath, absPath, content, eventType) => {
136
183
  lsp.handleFileChange(relPath, absPath, content, eventType);
137
184
  });
138
185
 
186
+ const port = await findFreePort(preferredPort);
187
+
188
+ // Write actual port back to config so skills can always find the server
189
+ try {
190
+ let metaConfig = {};
191
+ try { metaConfig = JSON.parse(readFileSync(metaConfigPath, "utf-8")); } catch {}
192
+ metaConfig.server_port = port;
193
+ writeFileSync(metaConfigPath, JSON.stringify(metaConfig, null, 2) + "\n");
194
+ } catch (err) {
195
+ console.error(`Warning: could not write port to config: ${err.message}`);
196
+ }
197
+
139
198
  server.listen(port, () => {
140
199
  console.log(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})`);
141
200
  watcher.start();
@@ -24,6 +24,9 @@ function escapeFtsQuery(query) {
24
24
  /** Weight factor for backlink count in search ranking (PageRank-lite). */
25
25
  const BACKLINK_WEIGHT = 0.5;
26
26
 
27
+ /** Weight factor for average backlink confidence in search ranking. */
28
+ const CONFIDENCE_WEIGHT = 0.3;
29
+
27
30
  /** Weight factor for access count in search ranking. */
28
31
  const SEARCH_ACCESS_WEIGHT = 0.1;
29
32
 
@@ -73,7 +76,9 @@ export class SqliteSearch {
73
76
  target_path TEXT NOT NULL,
74
77
  target_brain TEXT,
75
78
  rel TEXT,
76
- link_text TEXT
79
+ link_text TEXT,
80
+ confidence REAL DEFAULT 0.5,
81
+ evidence_count INTEGER DEFAULT 0
77
82
  );
78
83
 
79
84
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
@@ -86,6 +91,12 @@ export class SqliteSearch {
86
91
  );
87
92
  CREATE INDEX IF NOT EXISTS idx_access_doc ON access_log(doc_id);
88
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
+ );
89
100
  `);
90
101
 
91
102
  this.#migrate();
@@ -124,8 +135,23 @@ export class SqliteSearch {
124
135
  currentVersion = 1;
125
136
  }
126
137
 
127
- // Future migrations go here:
128
- // 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
+ }
129
155
 
130
156
  // Persist the current version
131
157
  this.#db.exec(`DELETE FROM _schema_version`);
@@ -142,8 +168,16 @@ export class SqliteSearch {
142
168
  }
143
169
  }
144
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
+
145
177
  index(doc) {
146
- 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);
147
181
  const brainId = this.#brainId;
148
182
  const indexedAt = Date.now();
149
183
 
@@ -221,7 +255,8 @@ export class SqliteSearch {
221
255
  snippet(documents_fts, 2, '<b>', '</b>', '…', 32) AS snippet,
222
256
  SUBSTR(d.content, 1, 1000) AS raw_content,
223
257
  COALESCE(link_count.cnt, 0) AS backlink_count,
224
- 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
225
260
  FROM documents_fts f
226
261
  JOIN documents d ON d.id = f.id
227
262
  LEFT JOIN (
@@ -229,6 +264,11 @@ export class SqliteSearch {
229
264
  FROM links
230
265
  GROUP BY target_path
231
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
232
272
  LEFT JOIN (
233
273
  SELECT doc_id, COUNT(*) AS cnt
234
274
  FROM access_log
@@ -236,7 +276,7 @@ export class SqliteSearch {
236
276
  ) ac ON d.id = ac.doc_id
237
277
  WHERE documents_fts MATCH ?
238
278
  ${sinceClause}
239
- 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}))
240
280
  LIMIT ? OFFSET ?
241
281
  `)
242
282
  .all(escaped, ...sinceParams, limit, offset)
@@ -257,6 +297,13 @@ export class SqliteSearch {
257
297
 
258
298
  const total_matches = countRow ? countRow.cnt : 0;
259
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
+
260
307
  // Log access for each returned document if session_id provided
261
308
  if (session_id && rows.length > 0) {
262
309
  const logAccess = this.#db.prepare(
@@ -476,6 +523,222 @@ export class SqliteSearch {
476
523
  .all();
477
524
  }
478
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
+
479
742
  close() {
480
743
  this.#db.close();
481
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.7",
3
+ "version": "0.4.2",
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.