wicked-brain 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
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": [
@@ -18,6 +18,7 @@ import { renderViewerHtml } from "../lib/viewer-page.mjs";
18
18
  import { walkBrainContent, purgeBrainContent } from "../lib/brain-walker.mjs";
19
19
  import { runOnboardWiki } from "../lib/onboard-wiki.mjs";
20
20
  import { readFile as readFileAsync } from "node:fs/promises";
21
+ import { makeGraphActions } from "../lib/codegraph-actions.mjs";
21
22
 
22
23
  // Parse args
23
24
  const args = argv.slice(2);
@@ -112,6 +113,7 @@ try {
112
113
 
113
114
  // LSP client — pass source path so language servers are rooted at the project, not the brain dir
114
115
  const lsp = new LspClient(brainPath, db, sourcePath);
116
+ const graphActions = makeGraphActions({ sourcePath, brainPath });
115
117
 
116
118
  // Auto-memorize subscriber handle (set after bus init in server.listen callback)
117
119
  let memorySubscriber = null;
@@ -246,6 +248,8 @@ const actions = {
246
248
  "lsp-call-hierarchy-in": (p) => lsp.callHierarchyIn(p),
247
249
  "lsp-call-hierarchy-out": (p) => lsp.callHierarchyOut(p),
248
250
  "lsp-diagnostics": (p) => lsp.diagnostics(p),
251
+ // Graph (codegraph) actions
252
+ ...graphActions,
249
253
  reonboard: async () => {
250
254
  // Detect mode + stamp the CLAUDE.md/AGENTS.md pointer, then rebuild the
251
255
  // search index from whatever content is on disk in this brain. Does NOT
@@ -318,6 +322,8 @@ const WRITE_ACTIONS = new Set([
318
322
  // DLQ replay/drop mutate the bus DB; list is read-only.
319
323
  "dlq_replay",
320
324
  "dlq_drop",
325
+ // Graph rebuild is a write — shells out to codegraph CLI.
326
+ "graph-index",
321
327
  ]);
322
328
 
323
329
  // HTTP server
@@ -0,0 +1,34 @@
1
+ import Database from "better-sqlite3";
2
+ import { CodegraphClient } from "./codegraph-client.mjs";
3
+ import { runIndex, staleness, dbPath } from "./codegraph-index.mjs";
4
+ import { runExtractors } from "./codegraph-extract.mjs";
5
+
6
+ /**
7
+ * Build the graph-* action handlers bound to a source repo. A fresh client per
8
+ * call keeps the readonly DB handle short-lived and always reopens after a rebuild.
9
+ */
10
+ export function makeGraphActions({ sourcePath, brainPath } = {}) {
11
+ const withClient = (fn) => {
12
+ const c = new CodegraphClient(sourcePath);
13
+ try { return fn(c); } finally { c.close(); }
14
+ };
15
+ return {
16
+ "graph-blast-radius": (p = {}) => withClient((c) => c.blastRadius({ node: p.node, maxDepth: p.maxDepth })),
17
+ "graph-callers": (p = {}) => withClient((c) => c.callers({ node: p.node })),
18
+ "graph-lineage": (p = {}) => withClient((c) => c.lineage({ node: p.node, maxDepth: p.maxDepth })),
19
+ "graph-index": async () => {
20
+ const r = await runIndex(sourcePath, { brainPath, sourcePath });
21
+ if (!r.ok) {
22
+ return { ...r, staleness: staleness(sourcePath) };
23
+ }
24
+ const db = new Database(dbPath(sourcePath));
25
+ let injected;
26
+ try {
27
+ injected = await runExtractors({ db, sourcePath });
28
+ } finally {
29
+ db.close();
30
+ }
31
+ return { ...r, injected, staleness: staleness(sourcePath) };
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync } from "node:fs";
2
+ import Database from "better-sqlite3";
3
+ import { dbPath, staleness } from "./codegraph-index.mjs";
4
+
5
+ // Pinned by Task 1 (docs/codegraph-contract.md): edges are stored
6
+ // consumer->producer, edge(source=dependent, target=dependency). So dependents
7
+ // of X are rows WHERE target=X (collect source); dependencies are the inverse.
8
+ const DEPENDENTS_BY = "target";
9
+ const DEPENDENCIES_BY = DEPENDENTS_BY === "target" ? "source" : "target";
10
+
11
+ export class CodegraphClient {
12
+ #sourcePath;
13
+ #db = null;
14
+
15
+ constructor(sourcePath) { this.#sourcePath = sourcePath; }
16
+
17
+ #open() {
18
+ if (this.#db) return this.#db;
19
+ const p = dbPath(this.#sourcePath);
20
+ if (!existsSync(p)) return null;
21
+ this.#db = new Database(p, { readonly: true });
22
+ return this.#db;
23
+ }
24
+
25
+ close() { if (this.#db) { this.#db.close(); this.#db = null; } }
26
+
27
+ #nodeRows(ids) {
28
+ if (ids.length === 0) return [];
29
+ const ph = ids.map(() => "?").join(",");
30
+ return this.#db.prepare(
31
+ `SELECT id, kind, name, file_path, start_line, end_line FROM nodes WHERE id IN (${ph})`
32
+ ).all(...ids);
33
+ }
34
+
35
+ // BFS over edges. matchCol = the column matched against the frontier;
36
+ // collectCol = the column collected as the next frontier.
37
+ #walk(start, { matchCol, collectCol, maxDepth }) {
38
+ const stmt = this.#db.prepare(
39
+ `SELECT DISTINCT ${collectCol} AS next FROM edges WHERE ${matchCol} = ?`);
40
+ const seen = new Set();
41
+ let frontier = [start];
42
+ let depth = 0;
43
+ while (frontier.length && depth < maxDepth) {
44
+ const nextFrontier = [];
45
+ for (const node of frontier) {
46
+ for (const row of stmt.all(node)) {
47
+ if (row.next && row.next !== start && !seen.has(row.next)) {
48
+ seen.add(row.next);
49
+ nextFrontier.push(row.next);
50
+ }
51
+ }
52
+ }
53
+ frontier = nextFrontier;
54
+ depth += 1;
55
+ }
56
+ return [...seen];
57
+ }
58
+
59
+ #unavailable() {
60
+ return { engine: "unavailable",
61
+ reason: `no graph at ${dbPath(this.#sourcePath)} — run graph-index`,
62
+ staleness: staleness(this.#sourcePath) };
63
+ }
64
+
65
+ /** Transitive dependents — "what breaks if I change X". */
66
+ blastRadius({ node, maxDepth = 25 }) {
67
+ if (!this.#open()) return this.#unavailable();
68
+ const ids = this.#walk(node, { matchCol: DEPENDENTS_BY, collectCol: DEPENDENCIES_BY, maxDepth });
69
+ return { node, dependents: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
70
+ }
71
+
72
+ /** Direct dependents only (depth 1). */
73
+ callers({ node }) {
74
+ if (!this.#open()) return this.#unavailable();
75
+ const ids = this.#walk(node, { matchCol: DEPENDENTS_BY, collectCol: DEPENDENCIES_BY, maxDepth: 1 });
76
+ return { node, callers: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
77
+ }
78
+
79
+ /** Transitive dependencies — downstream lineage. */
80
+ lineage({ node, maxDepth = 25 }) {
81
+ if (!this.#open()) return this.#unavailable();
82
+ const ids = this.#walk(node, { matchCol: DEPENDENCIES_BY, collectCol: DEPENDENTS_BY, maxDepth });
83
+ return { node, dependencies: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
84
+ }
85
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * codegraph-extract.mjs — extractor registry (builtins + drop-ins).
3
+ *
4
+ * Ships three built-in extractors and discovers per-repo drop-in extractors
5
+ * under <sourcePath>/.codegraph-extractors/*.mjs, each exporting `extract`.
6
+ *
7
+ * Fail-open per extractor: one throwing must not abort the rest.
8
+ *
9
+ * NOTE on drop-in imports: this imports and runs code from the target repo —
10
+ * by design (the repo provides trusted extractors), same trust level as
11
+ * running its tests.
12
+ */
13
+
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { extract as busExtract } from "./codegraph-extractors/bus.mjs";
18
+ import { extract as dispatchExtract } from "./codegraph-extractors/dispatch.mjs";
19
+ import { extract as capabilityExtract } from "./codegraph-extractors/capability.mjs";
20
+ import { ensureFileNode, ensureVirtualNode } from "./codegraph-nodes.mjs";
21
+
22
+ const BUILTINS = [
23
+ { label: "bus", extract: busExtract },
24
+ { label: "dispatch", extract: dispatchExtract },
25
+ { label: "capability", extract: capabilityExtract },
26
+ ];
27
+
28
+ /**
29
+ * Discover drop-in extractors under <sourcePath>/.codegraph-extractors/*.mjs.
30
+ * Each file must export a function `extract`. Sorted for deterministic ordering.
31
+ * Broken files (import error or missing export) are silently skipped.
32
+ *
33
+ * @param {string} sourcePath
34
+ * @returns {Promise<Array<{label:string, extract:Function}>>}
35
+ */
36
+ export async function discoverDropins(sourcePath) {
37
+ const dir = join(sourcePath, ".codegraph-extractors");
38
+ if (!existsSync(dir)) return [];
39
+
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir)
43
+ .filter((f) => f.endsWith(".mjs"))
44
+ .sort();
45
+ } catch {
46
+ return [];
47
+ }
48
+
49
+ const dropins = [];
50
+ for (const filename of entries) {
51
+ const fullpath = join(dir, filename);
52
+ try {
53
+ const mod = await import(pathToFileURL(fullpath).href);
54
+ if (typeof mod.extract === "function") {
55
+ dropins.push({ label: `dropin:${filename}`, extract: mod.extract });
56
+ }
57
+ } catch {
58
+ // Broken drop-in — skip, not fatal
59
+ }
60
+ }
61
+ return dropins;
62
+ }
63
+
64
+ /**
65
+ * Run all extractors (builtins + drop-ins) against the open read-write db.
66
+ * Fail-open per extractor: one throwing must not abort the rest.
67
+ *
68
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
69
+ * @returns {Promise<Record<string, object> & { total_injected_edges: number, dropins: string[] }>}
70
+ */
71
+ export async function runExtractors({ db, sourcePath }) {
72
+ const dropins = await discoverDropins(sourcePath);
73
+ const dropin_labels = dropins.map((d) => d.label);
74
+ const all = [...BUILTINS, ...dropins];
75
+
76
+ const nodes = { ensureFileNode, ensureVirtualNode };
77
+
78
+ const out = {};
79
+ let total = 0;
80
+
81
+ for (const ext of all) {
82
+ try {
83
+ const counts = await ext.extract({ db, sourcePath, nodes });
84
+ out[ext.label] = counts;
85
+ total += counts.edges_added || 0;
86
+ } catch (e) {
87
+ out[ext.label] = { error: String((e && e.message) || e) };
88
+ }
89
+ }
90
+
91
+ return { ...out, total_injected_edges: total, dropins: dropin_labels };
92
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * codegraph-extractors/bus.mjs — wicked-bus producer→consumer injected edges.
3
+ *
4
+ * Port of scripts/codegraph/inject_edges.py with the edge direction CORRECTED:
5
+ *
6
+ * Garden's inject_edges.py inserts (source=producer, target=consumer).
7
+ * The contract doc resolves the ambiguity: edges are stored
8
+ * source=dependent→target=dependency, and DEPENDENTS_BY="target". So
9
+ * blast-radius(X) = WHERE target=X (collect source). For the consumer to
10
+ * surface as a dependent of the producer, the edge must be:
11
+ *
12
+ * source = consumer (dependent — breaks when producer's event changes)
13
+ * target = producer (dependency — the thing being changed)
14
+ *
15
+ * This is the corrected direction. Garden's version inserts the opposite and
16
+ * is a latent bug (blast-radius of the producer would NOT surface the consumer).
17
+ *
18
+ * Algorithm:
19
+ * 1. DELETE edges WHERE provenance='injected:bus' (idempotent)
20
+ * 2. Read <sourcePath>/scripts/_bus_consumers.json
21
+ * 3. Grep <sourcePath>/scripts/**\/*.py for event-string literals
22
+ * 4. For each consumer: confirm node exists; for each producer: confirm node
23
+ * exists; INSERT edge (source=consumer, target=producer)
24
+ */
25
+
26
+ import { readFileSync, readdirSync, statSync } from "node:fs";
27
+ import { join, relative, sep } from "node:path";
28
+
29
+ const EVENT_RE = /["']((?:wicked|wg)\.[a-z0-9_]+(?:\.[a-z0-9_]+)+)["']/g;
30
+ const INJECTED_PROVENANCE = "injected:bus";
31
+
32
+ /**
33
+ * Read and parse _bus_consumers.json. Returns [] on missing or malformed file.
34
+ * @param {string} sourcePath
35
+ * @returns {{ event_filter: string, module: string }[]}
36
+ */
37
+ function readConsumers(sourcePath) {
38
+ const p = join(sourcePath, "scripts", "_bus_consumers.json");
39
+ try {
40
+ const data = JSON.parse(readFileSync(p, "utf8"));
41
+ const consumers = data?.consumers;
42
+ if (!Array.isArray(consumers)) return [];
43
+ return consumers.filter((c) => c?.event_filter && c?.module);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Recursively collect all .py files under dir, skipping __pycache__ dirs
51
+ * and files whose basename ends with _bus_consumers.py.
52
+ * @param {string} dir
53
+ * @returns {string[]} absolute file paths
54
+ */
55
+ function collectPyFiles(dir) {
56
+ const results = [];
57
+ let entries;
58
+ try { entries = readdirSync(dir); } catch { return results; }
59
+ for (const entry of entries) {
60
+ const full = join(dir, entry);
61
+ let st;
62
+ try { st = statSync(full); } catch { continue; }
63
+ if (st.isDirectory()) {
64
+ if (entry === "__pycache__") continue;
65
+ results.push(...collectPyFiles(full));
66
+ } else if (entry.endsWith(".py") && !entry.endsWith("_bus_consumers.py")) {
67
+ results.push(full);
68
+ }
69
+ }
70
+ return results;
71
+ }
72
+
73
+ /**
74
+ * Build event→Set<producerRelpath> map by grepping scripts/**\/*.py.
75
+ * @param {string} sourcePath
76
+ * @param {Set<string>} events
77
+ * @returns {Map<string, Set<string>>}
78
+ */
79
+ function buildProducerMap(sourcePath, events) {
80
+ /** @type {Map<string, Set<string>>} */
81
+ const map = new Map();
82
+ for (const ev of events) map.set(ev, new Set());
83
+
84
+ const scriptsDir = join(sourcePath, "scripts");
85
+ const pyFiles = collectPyFiles(scriptsDir);
86
+
87
+ for (const absPath of pyFiles) {
88
+ let text;
89
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
90
+ // posix relpath relative to sourcePath
91
+ const rel = relative(sourcePath, absPath).split(sep).join("/");
92
+ // reset lastIndex between files
93
+ EVENT_RE.lastIndex = 0;
94
+ let m;
95
+ while ((m = EVENT_RE.exec(text)) !== null) {
96
+ const ev = m[1];
97
+ if (map.has(ev)) {
98
+ map.get(ev).add(rel);
99
+ }
100
+ // reset not needed between matches in same string, but be safe
101
+ }
102
+ }
103
+ return map;
104
+ }
105
+
106
+ /**
107
+ * Confirm a file node exists in the graph; return the node id or null.
108
+ * @param {import("better-sqlite3").Database} db
109
+ * @param {string} relpath
110
+ * @returns {string|null}
111
+ */
112
+ function fileNodeId(db, relpath) {
113
+ const id = `file:${relpath}`;
114
+ const row = db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(id);
115
+ return row ? id : null;
116
+ }
117
+
118
+ /**
119
+ * Extract wicked-bus producer→consumer injected edges into the codegraph DB.
120
+ *
121
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
122
+ * @returns {{ edges_added: number, skipped: number, consumers: number }}
123
+ */
124
+ export function extract({ db, sourcePath }) {
125
+ // 1. Idempotent: clear prior bus edges
126
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
127
+
128
+ // 2. Load consumer registry
129
+ const consumers = readConsumers(sourcePath);
130
+ if (consumers.length === 0) {
131
+ return { edges_added: 0, skipped: 0, consumers: 0 };
132
+ }
133
+
134
+ // 3. Grep for producers
135
+ const events = new Set(consumers.map((c) => c.event_filter));
136
+ const producerMap = buildProducerMap(sourcePath, events);
137
+
138
+ // 4. Insert edges
139
+ const insertEdge = db.prepare(
140
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
141
+ );
142
+
143
+ let edges_added = 0;
144
+ let skipped = 0;
145
+
146
+ for (const { event_filter: ev, module: consumerMod } of consumers) {
147
+ // Confirm consumer node exists
148
+ const consumerNodeId = fileNodeId(db, consumerMod);
149
+ if (!consumerNodeId) {
150
+ skipped++;
151
+ continue;
152
+ }
153
+
154
+ const producers = producerMap.get(ev) ?? new Set();
155
+ for (const prodRelpath of [...producers].sort()) {
156
+ // Don't self-link
157
+ if (prodRelpath === consumerMod) continue;
158
+
159
+ // Confirm producer node exists
160
+ const producerNodeId = fileNodeId(db, prodRelpath);
161
+ if (!producerNodeId) {
162
+ skipped++;
163
+ continue;
164
+ }
165
+
166
+ // DIRECTION: source=consumer (dependent), target=producer (dependency)
167
+ // blastRadius(producer) = WHERE target=producer → source=consumer ✓
168
+ insertEdge.run(
169
+ consumerNodeId, // source = consumer (dependent)
170
+ producerNodeId, // target = producer (dependency)
171
+ "references",
172
+ JSON.stringify({ injected: "bus", event: ev }),
173
+ INJECTED_PROVENANCE
174
+ );
175
+ edges_added++;
176
+ }
177
+ }
178
+
179
+ return { edges_added, skipped, consumers: consumers.length };
180
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * codegraph-extractors/capability.mjs — agent→capability injected edges.
3
+ *
4
+ * An agent declares needed capabilities via a `tool-capabilities:` YAML frontmatter
5
+ * block list. This extractor reads those declarations and creates synthetic
6
+ * `capability:<name>` nodes plus edges from the agent file to each capability.
7
+ *
8
+ * Edge direction: source=agent (dependent) → target=capability (dependency).
9
+ * DEPENDENTS_BY="target": blastRadius(capability) = WHERE target=capability → source=agent ✓
10
+ *
11
+ * Frontmatter-only port of wicked-garden's inject_capability_edges.py.
12
+ * No capability registry is imported — brain stays dependency-free.
13
+ *
14
+ * This extractor OWNS the capability nodes:
15
+ * - DELETE edges WHERE provenance='injected:capability'
16
+ * - DELETE nodes WHERE kind='capability'
17
+ * then re-inserts cleanly on each run (fully idempotent).
18
+ */
19
+
20
+ import { readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { join, relative, sep } from "node:path";
22
+ import { ensureFileNode, ensureVirtualNode } from "../codegraph-nodes.mjs";
23
+
24
+ const INJECTED_PROVENANCE = "injected:capability";
25
+
26
+ // Matches the tool-capabilities: YAML block at the start or in frontmatter.
27
+ // Captures the multi-line list body (lines starting with optional whitespace + "- item").
28
+ const CAPS_BLOCK_RE = /(?:^|\n)tool-capabilities:\s*\n((?:[ \t]+-[ \t]*[a-z0-9_-]+[ \t]*\n?)+)/;
29
+
30
+ // Matches individual list items: " - capability-name"
31
+ const ITEM_RE = /-[ \t]*([a-z0-9_-]+)/g;
32
+
33
+ /**
34
+ * Extract the YAML frontmatter block (between leading --- fences) from text.
35
+ * Returns the frontmatter string, or the full text if no fences found.
36
+ * @param {string} text
37
+ * @returns {string}
38
+ */
39
+ function extractFrontmatter(text) {
40
+ const match = text.match(/^---\s*\n([\s\S]*?)\n---/);
41
+ return match ? match[1] : "";
42
+ }
43
+
44
+ /**
45
+ * Parse tool-capabilities list from frontmatter text.
46
+ * Returns a Set of capability names (deduped).
47
+ * @param {string} frontmatter
48
+ * @returns {Set<string>}
49
+ */
50
+ function parseCapabilities(frontmatter) {
51
+ const caps = new Set();
52
+ const blockMatch = CAPS_BLOCK_RE.exec(frontmatter);
53
+ if (!blockMatch) return caps;
54
+
55
+ const block = blockMatch[1];
56
+ ITEM_RE.lastIndex = 0;
57
+ let m;
58
+ while ((m = ITEM_RE.exec(block)) !== null) {
59
+ caps.add(m[1]);
60
+ }
61
+ return caps;
62
+ }
63
+
64
+ /**
65
+ * Recursively collect all .md files under dir.
66
+ * @param {string} dir
67
+ * @returns {string[]} absolute file paths
68
+ */
69
+ function collectMdFiles(dir) {
70
+ const results = [];
71
+ let entries;
72
+ try { entries = readdirSync(dir); } catch { return results; }
73
+ for (const entry of entries) {
74
+ const full = join(dir, entry);
75
+ let st;
76
+ try { st = statSync(full); } catch { continue; }
77
+ if (st.isDirectory()) {
78
+ results.push(...collectMdFiles(full));
79
+ } else if (entry.endsWith(".md")) {
80
+ results.push(full);
81
+ }
82
+ }
83
+ return results;
84
+ }
85
+
86
+ /**
87
+ * Extract agent→capability injected edges into the codegraph DB.
88
+ *
89
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
90
+ * @returns {{ edges_added: number, capabilities: number }}
91
+ */
92
+ export function extract({ db, sourcePath }) {
93
+ // 1. Idempotent: clear prior capability edges and owned nodes
94
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
95
+ db.prepare("DELETE FROM nodes WHERE kind = 'capability'").run();
96
+
97
+ const insertEdge = db.prepare(
98
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
99
+ );
100
+
101
+ let edges_added = 0;
102
+ const distinctCaps = new Set();
103
+
104
+ // 2. Scan agents/**/*.md
105
+ const agentsDir = join(sourcePath, "agents");
106
+ const agentFiles = collectMdFiles(agentsDir);
107
+
108
+ for (const absPath of agentFiles) {
109
+ let text;
110
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
111
+
112
+ // Posix relpath relative to sourcePath
113
+ const relpath = relative(sourcePath, absPath).split(sep).join("/");
114
+
115
+ // 3. Parse frontmatter capabilities (deduped per agent)
116
+ const frontmatter = extractFrontmatter(text);
117
+ const caps = parseCapabilities(frontmatter);
118
+ if (caps.size === 0) continue;
119
+
120
+ const src = ensureFileNode(db, relpath);
121
+
122
+ for (const cap of caps) {
123
+ // Ensure the capability virtual node exists
124
+ const tgt = ensureVirtualNode(db, `capability:${cap}`, "capability", cap);
125
+
126
+ insertEdge.run(
127
+ src,
128
+ tgt,
129
+ "references",
130
+ JSON.stringify({ injected: "capability", capability: cap }),
131
+ INJECTED_PROVENANCE
132
+ );
133
+ edges_added++;
134
+ distinctCaps.add(cap);
135
+ }
136
+ }
137
+
138
+ return { edges_added, capabilities: distinctCaps.size };
139
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * codegraph-extractors/dispatch.mjs — command→agent injected edges.
3
+ *
4
+ * A slash command dispatches to a subagent via a `subagent_type: <plugin>:<domain>:<name>`
5
+ * string (in Task(subagent_type="...") or YAML frontmatter). The command file never
6
+ * references the agent file — grep/static can't link them. This extractor injects
7
+ * those edges so blast-radius traversal can surface the dispatching commands when
8
+ * an agent changes.
9
+ *
10
+ * Edge direction: source=command (dependent) → target=agent (dependency).
11
+ * DEPENDENTS_BY="target": blastRadius(agent) = WHERE target=agent → source=command ✓
12
+ *
13
+ * Port of wicked-garden's inject_dispatch_edges.py. Direction is already correct
14
+ * in the garden version for our blast-radius convention (no reversal needed, unlike bus).
15
+ */
16
+
17
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
18
+ import { join, relative, sep } from "node:path";
19
+ import { ensureFileNode } from "../codegraph-nodes.mjs";
20
+
21
+ const INJECTED_PROVENANCE = "injected:dispatch";
22
+
23
+ // Matches subagent_type: "plugin:domain:name" or subagent_type="plugin:domain:name"
24
+ // (with or without quotes, colon or equals separator)
25
+ const SUBAGENT_RE = /subagent_type\s*[:=]\s*["']?([a-z0-9_-]+:[a-z0-9_-]+:[a-z0-9_-]+)["']?/g;
26
+
27
+ /**
28
+ * Recursively collect all .md files under dir.
29
+ * @param {string} dir
30
+ * @returns {string[]} absolute file paths
31
+ */
32
+ function collectMdFiles(dir) {
33
+ const results = [];
34
+ let entries;
35
+ try { entries = readdirSync(dir); } catch { return results; }
36
+ for (const entry of entries) {
37
+ const full = join(dir, entry);
38
+ let st;
39
+ try { st = statSync(full); } catch { continue; }
40
+ if (st.isDirectory()) {
41
+ results.push(...collectMdFiles(full));
42
+ } else if (entry.endsWith(".md")) {
43
+ results.push(full);
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * Given a handle `plugin:domain:name`, resolve to the agent relpath
51
+ * `agents/<domain>/<name>.md`. Returns the relpath if the file exists, else null.
52
+ * @param {string} sourcePath
53
+ * @param {string} handle e.g. "wicked-garden:d:my-agent"
54
+ * @returns {string|null}
55
+ */
56
+ function resolveHandle(sourcePath, handle) {
57
+ const parts = handle.split(":");
58
+ if (parts.length !== 3) return null;
59
+ const [, domain, name] = parts;
60
+ const relpath = `agents/${domain}/${name}.md`;
61
+ if (existsSync(join(sourcePath, relpath))) return relpath;
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Extract command→agent dispatch injected edges into the codegraph DB.
67
+ *
68
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
69
+ * @returns {{ edges_added: number, dispatches: number }}
70
+ */
71
+ export function extract({ db, sourcePath }) {
72
+ // 1. Idempotent: clear prior dispatch edges
73
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
74
+
75
+ const insertEdge = db.prepare(
76
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
77
+ );
78
+
79
+ let edges_added = 0;
80
+ let dispatches = 0;
81
+
82
+ // 2. Scan commands/**/*.md
83
+ const commandsDir = join(sourcePath, "commands");
84
+ const commandFiles = collectMdFiles(commandsDir);
85
+
86
+ for (const absPath of commandFiles) {
87
+ let text;
88
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
89
+
90
+ // Posix relpath relative to sourcePath
91
+ const relpath = relative(sourcePath, absPath).split(sep).join("/");
92
+
93
+ // 3. Find distinct handles in this file
94
+ SUBAGENT_RE.lastIndex = 0;
95
+ const handles = new Set();
96
+ let m;
97
+ while ((m = SUBAGENT_RE.exec(text)) !== null) {
98
+ handles.add(m[1]);
99
+ }
100
+
101
+ for (const handle of handles) {
102
+ // 4. Resolve handle to agent file
103
+ const agentRelpath = resolveHandle(sourcePath, handle);
104
+ if (!agentRelpath) continue; // not an agent file, or file doesn't exist
105
+
106
+ const src = ensureFileNode(db, relpath);
107
+ const tgt = ensureFileNode(db, agentRelpath);
108
+
109
+ insertEdge.run(
110
+ src,
111
+ tgt,
112
+ "references",
113
+ JSON.stringify({ injected: "dispatch", subagent_type: handle }),
114
+ INJECTED_PROVENANCE
115
+ );
116
+ edges_added++;
117
+ dispatches++;
118
+ }
119
+ }
120
+
121
+ return { edges_added, dispatches };
122
+ }
@@ -0,0 +1,56 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { resolveCodegraph } from "./codegraph-resolver.mjs";
5
+
6
+ export function dbPath(sourcePath) {
7
+ return join(sourcePath, ".codegraph", "codegraph.db");
8
+ }
9
+
10
+ /**
11
+ * How far the graph has drifted from HEAD. Fail-open: errors report
12
+ * present-but-unknown, never throw.
13
+ * @returns {{present:boolean, stale:boolean|null, commits_behind:number|null, indexed_at:string|null}}
14
+ */
15
+ export function staleness(sourcePath) {
16
+ const db = dbPath(sourcePath);
17
+ if (!existsSync(db)) {
18
+ return { present: false, stale: null, commits_behind: null, indexed_at: null };
19
+ }
20
+ try {
21
+ const iso = new Date(statSync(db).mtimeMs).toISOString();
22
+ const out = execFileSync("git",
23
+ ["-C", sourcePath, "rev-list", "--count", `--since=${iso}`, "HEAD"],
24
+ { encoding: "utf-8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
25
+ const behind = out ? parseInt(out, 10) : 0;
26
+ return { present: true, stale: behind > 0, commits_behind: behind, indexed_at: iso };
27
+ } catch {
28
+ return { present: true, stale: null, commits_behind: null, indexed_at: null };
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Build/refresh the graph by shelling codegraph. `init` bootstraps .codegraph
34
+ * AND indexes; `index` refreshes an existing graph (fails if .codegraph is
35
+ * absent) — so we pick based on db presence (pinned in docs/codegraph-contract.md).
36
+ * Resolves nothing -> {ok:false}. Never throws. `_spawn` is injectable for tests.
37
+ * @returns {Promise<{ok:boolean, subcommand?:string, error?:string}>}
38
+ */
39
+ export function runIndex(sourcePath, opts = {}, _spawn = spawn) {
40
+ return new Promise((resolve) => {
41
+ const argv = resolveCodegraph({ ...opts, sourcePath });
42
+ if (!argv) { resolve({ ok: false, error: "codegraph not resolvable" }); return; }
43
+ const [cmd, ...prefix] = argv;
44
+ const sub = existsSync(dbPath(sourcePath)) ? "index" : "init";
45
+ // stdout is "ignore", not "pipe": codegraph prints a progress banner and on a
46
+ // large repo an undrained stdout pipe can fill and deadlock the child. stderr
47
+ // stays piped so we can surface the failure message.
48
+ const proc = _spawn(cmd, [...prefix, sub, "."], { cwd: sourcePath, stdio: ["ignore", "ignore", "pipe"] });
49
+ let stderr = "";
50
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
51
+ proc.on("error", (e) => resolve({ ok: false, error: e.message }));
52
+ proc.on("close", (code) =>
53
+ resolve(code === 0 ? { ok: true, subcommand: sub }
54
+ : { ok: false, error: stderr.trim() || `exit ${code}` }));
55
+ });
56
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * codegraph-nodes.mjs — self-noding helpers for injected-edge extractors.
3
+ *
4
+ * codegraph indexes *code* (tree-sitter parsed). Injected edges to/from .md
5
+ * files or virtual capability nodes need their endpoints to exist in the nodes
6
+ * table first. These helpers create them idempotently via INSERT OR IGNORE, so
7
+ * real code files codegraph already indexed are never overwritten.
8
+ *
9
+ * Port of scripts/codegraph/_graph_nodes.py, targeting the exact schema in
10
+ * docs/codegraph-contract.md (all NOT NULL columns populated).
11
+ */
12
+
13
+ // Populates every NOT NULL column the codegraph `nodes` schema requires.
14
+ // Columns with defaults (is_exported etc.) are omitted — SQLite fills them.
15
+ const INSERT_NODE =
16
+ "INSERT OR IGNORE INTO nodes " +
17
+ "(id, kind, name, qualified_name, file_path, language, " +
18
+ " start_line, end_line, start_column, end_column, updated_at) " +
19
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)";
20
+
21
+ function nowMs() {
22
+ return Date.now();
23
+ }
24
+
25
+ /**
26
+ * Ensure a `file:<relpath>` node exists in the graph; return its id.
27
+ *
28
+ * For .md command/agent files that codegraph skipped, this creates a synthetic
29
+ * file node so dispatch/capability edges can anchor. For a real source file
30
+ * codegraph already indexed, INSERT OR IGNORE is a no-op (real node preserved).
31
+ *
32
+ * @param {import("better-sqlite3").Database} db - read-write Database
33
+ * @param {string} relpath - POSIX-relative path from repo root
34
+ * @param {string} [language] - language tag (default "markdown")
35
+ * @returns {string} the node id `file:<relpath>`
36
+ */
37
+ export function ensureFileNode(db, relpath, language = "markdown") {
38
+ const id = `file:${relpath}`;
39
+ const name = relpath.split("/").pop();
40
+ db.prepare(INSERT_NODE).run(id, "file", name, relpath, relpath, language, 1, 1, nowMs());
41
+ return id;
42
+ }
43
+
44
+ /**
45
+ * Ensure a synthetic non-file node (e.g. `capability:<name>`) exists; return id.
46
+ *
47
+ * Populates every NOT NULL column the schema demands — the original capability
48
+ * insert set only id/kind/name/file_path and would violate NOT NULL constraints.
49
+ *
50
+ * @param {import("better-sqlite3").Database} db - read-write Database
51
+ * @param {string} id - node id (e.g. "capability:foo")
52
+ * @param {string} kind - node kind (e.g. "capability")
53
+ * @param {string} name - human-readable name
54
+ * @param {string|null} [filePath] - optional file_path; falls back to id
55
+ * @returns {string} the node id
56
+ */
57
+ export function ensureVirtualNode(db, id, kind, name, filePath = null) {
58
+ db.prepare(INSERT_NODE).run(
59
+ id, kind, name, name, filePath ?? id, "virtual",
60
+ 0, 0, nowMs()
61
+ );
62
+ return id;
63
+ }
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { platform } from "node:os";
5
+
6
+ const PACKAGE = "@colbymchenry/codegraph";
7
+
8
+ function argvFor(target) {
9
+ // A .mjs/.js path is a script -> invoke via node; else run directly.
10
+ return /\.(mjs|js)$/.test(target) ? ["node", target] : [target];
11
+ }
12
+
13
+ function whichDefault(command) {
14
+ try {
15
+ const cmd = platform() === "win32" ? "where" : "which";
16
+ const out = execFileSync(cmd, [command], { encoding: "utf-8", timeout: 5000,
17
+ stdio: ["pipe", "pipe", "pipe"] });
18
+ return out.trim().split("\n")[0] || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function configBin(brainPath) {
25
+ if (!brainPath) return null;
26
+ try {
27
+ const cfg = JSON.parse(readFileSync(join(brainPath, "_meta", "codegraph.json"), "utf-8"));
28
+ return typeof cfg.bin === "string" && cfg.bin.trim() ? cfg.bin.trim() : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolve the argv prefix that invokes codegraph, or null.
36
+ * Ladder: WICKED_CODEGRAPH_BIN (set-but-empty = kill switch) -> brain
37
+ * _meta/codegraph.json {bin} -> PATH -> source node_modules/.bin -> `npx`.
38
+ */
39
+ export function resolveCodegraph(opts = {}) {
40
+ const { env = process.env, brainPath, sourcePath, which = whichDefault,
41
+ allowNpx = true } = opts;
42
+
43
+ if (Object.prototype.hasOwnProperty.call(env, "WICKED_CODEGRAPH_BIN")) {
44
+ const v = (env.WICKED_CODEGRAPH_BIN || "").trim();
45
+ return v ? argvFor(v) : null; // empty == kill switch
46
+ }
47
+ const cfg = configBin(brainPath);
48
+ if (cfg) return argvFor(cfg);
49
+
50
+ const onPath = which("codegraph");
51
+ if (onPath) return [onPath];
52
+
53
+ if (sourcePath) {
54
+ const local = join(sourcePath, "node_modules", ".bin", "codegraph");
55
+ if (existsSync(local)) return [local];
56
+ }
57
+ if (allowNpx && which("npx")) return ["npx", "-y", PACKAGE];
58
+ return null;
59
+ }
60
+
61
+ /** True iff a CONCRETE install resolves (not the npx last resort). */
62
+ export function codegraphAvailable(opts = {}) {
63
+ return resolveCodegraph({ ...opts, allowNpx: false }) !== null;
64
+ }
@@ -13,6 +13,12 @@ import { join } from "node:path";
13
13
  import { getBusDb, isBusAvailable, emitEvent } from "./bus.mjs";
14
14
  import { promoteFact } from "./memory-promoter.mjs";
15
15
 
16
+ // Subscriber identity on the bus. Used both to register the subscription and to
17
+ // locate its cursor for the TTL self-heal — keep them in one place so the two
18
+ // can't drift.
19
+ const PLUGIN = "wicked-brain";
20
+ const FACT_FILTER = "wicked.fact.extracted";
21
+
16
22
  /**
17
23
  * Start the auto-memorize subscriber.
18
24
  * Returns the subscription handle (with .stop()) or null if the bus is unavailable.
@@ -38,10 +44,22 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
38
44
 
39
45
  const memoryDir = join(brainPath, "memory");
40
46
 
47
+ // Self-heal a cursor stranded behind the bus TTL window (e.g. after a long
48
+ // server outage). The subscriber RESUMES its existing cursor, so cursor_init
49
+ // "latest" does not recover a stale one — poll() would throw WB-003 every
50
+ // cycle and auto-memorize would stall until manually reset. Advance it to the
51
+ // latest event before subscribing.
52
+ const healed = fastForwardStaleCursor(busDb, PLUGIN, FACT_FILTER);
53
+ if (healed) {
54
+ console.error(
55
+ `[memory-subscriber] cursor was behind the TTL window; repositioned ${healed.from} -> ${healed.to} to replay survivors`,
56
+ );
57
+ }
58
+
41
59
  const sub = subscribe({
42
60
  db: busDb,
43
- plugin: "wicked-brain",
44
- filter: "wicked.fact.extracted",
61
+ plugin: PLUGIN,
62
+ filter: FACT_FILTER,
45
63
  cursor_init: "latest",
46
64
  pollIntervalMs: 5000,
47
65
  maxRetries: 3,
@@ -84,6 +102,63 @@ export async function startMemorySubscriber({ brainPath, brainId, db }) {
84
102
  return sub;
85
103
  }
86
104
 
105
+ /**
106
+ * Fast-forward a subscriber cursor that has fallen behind the bus TTL window.
107
+ *
108
+ * After a long server outage the durable cursor can sit below the oldest
109
+ * surviving event; wicked-bus poll() then throws WB-003 ("cursor behind the TTL
110
+ * window") every cycle. The subscriber resumes its existing cursor (cursor_init
111
+ * only applies on first registration), so it never recovers on its own. This
112
+ * mirrors poll()'s WB-003 check and, when behind, repositions the cursor to just
113
+ * before the oldest surviving event so the subscriber still replays everything
114
+ * left in the bus (at-least-once) instead of discarding the survivors.
115
+ *
116
+ * No-op when the cursor is current, when there are no events, or when no cursor
117
+ * exists yet (a fresh subscriber initializes at "latest" anyway). Never throws —
118
+ * a self-heal failure must not block server startup.
119
+ *
120
+ * @param {import('better-sqlite3').Database} busDb
121
+ * @param {string} plugin
122
+ * @param {string} filter event_type_filter the subscriber registered with
123
+ * @returns {{from:number,to:number}|null} the adjustment made, or null for no-op
124
+ */
125
+ export function fastForwardStaleCursor(busDb, plugin, filter) {
126
+ try {
127
+ const bounds = busDb
128
+ .prepare("SELECT MIN(event_id) AS min_id FROM events")
129
+ .get();
130
+ if (!bounds || bounds.min_id == null) return null; // no events to be behind of
131
+
132
+ const row = busDb
133
+ .prepare(
134
+ `SELECT c.cursor_id AS cursor_id, c.last_event_id AS last_event_id
135
+ FROM subscriptions s
136
+ INNER JOIN cursors c ON c.subscription_id = s.subscription_id
137
+ WHERE s.plugin = ? AND s.role = 'subscriber'
138
+ AND s.event_type_filter = ?
139
+ AND s.deregistered_at IS NULL AND c.deregistered_at IS NULL
140
+ ORDER BY s.registered_at DESC
141
+ LIMIT 1`,
142
+ )
143
+ .get(plugin, filter);
144
+ if (!row) return null; // no existing cursor — fresh subscribe inits at "latest"
145
+
146
+ // Mirror wicked-bus poll(): WB-003 fires when last_event_id < oldest - 1.
147
+ // Reposition to oldest-1 (not latest) so the subscriber replays every event
148
+ // that survived the sweep instead of discarding the backlog.
149
+ const target = bounds.min_id - 1;
150
+ if (row.last_event_id < target) {
151
+ busDb
152
+ .prepare("UPDATE cursors SET last_event_id = ? WHERE cursor_id = ?")
153
+ .run(target, row.cursor_id);
154
+ return { from: row.last_event_id, to: target };
155
+ }
156
+ return null;
157
+ } catch {
158
+ return null; // never block startup on the self-heal
159
+ }
160
+ }
161
+
87
162
  /**
88
163
  * Render a memory descriptor as a markdown file with YAML-ish frontmatter.
89
164
  * Minimal serializer — no YAML lib. Matches the format used by wicked-brain:memory.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: wicked-brain:graph
3
+ description: |
4
+ Code-relationship graph queries — blast radius, callers, and lineage — backed
5
+ by a codegraph static graph the brain owns. Answers "what breaks if I change X",
6
+ "who calls X", and "what does X depend on" across the whole repo, including
7
+ relationships a grep or single-file LSP lookup cannot see.
8
+
9
+ Use when: "blast radius", "what breaks if I change", "impact of changing X",
10
+ "who depends on X", "what depends on X", "lineage", "what does X depend on",
11
+ "architecture map", "code relationship graph".
12
+ ---
13
+
14
+ # wicked-brain:graph
15
+
16
+ Relationship-graph intelligence over a codegraph-built SQLite graph. Distinct from
17
+ `wicked-brain:lsp` (live, single-symbol definitions / references / hover /
18
+ diagnostics) — this is the whole-repo relationship graph and the home of
19
+ blast-radius / lineage.
20
+
21
+ ## Cross-Platform Notes
22
+
23
+ This skill uses `npx wicked-brain-call` for all server interaction. The CLI works
24
+ on macOS, Linux, and Windows; it discovers the brain, auto-starts the server, and
25
+ writes a per-call audit record under `{brain}/calls/`.
26
+
27
+ ## Queries
28
+
29
+ - `graph-index` — build/refresh the graph (`codegraph init`/`index`). Run once per
30
+ repo, then on demand when a result reports it is stale.
31
+ - `graph-blast-radius {node}` — transitive **dependents** of `node` ("what breaks
32
+ if I change it").
33
+ - `graph-callers {node}` — direct dependents only (depth 1).
34
+ - `graph-lineage {node}` — transitive **dependencies** (what `node` depends on,
35
+ downstream).
36
+
37
+ `node` ids follow codegraph's convention — e.g. `file:src/app.py`, or a symbol id
38
+ like `function:<hash>` (use `qualified_name` from a search/symbols lookup to find
39
+ the id). Every result carries a `staleness` stamp (`commits_behind`, `indexed_at`);
40
+ when `stale` is true, re-run `graph-index`.
41
+
42
+ ## Freshness
43
+
44
+ Lazy by design — the graph is **never** auto-rebuilt by a file watcher (that path
45
+ is a known CPU-runaway hazard). Results tell you when they are behind HEAD; rebuild
46
+ explicitly with `graph-index` (or wire the optional commit hook). If codegraph is
47
+ not installed, queries return `engine: "unavailable"` rather than a misleading
48
+ empty graph.
49
+
50
+ ## Engine
51
+
52
+ Backed by the `@colbymchenry/codegraph` CLI (resolved at runtime via
53
+ `WICKED_CODEGRAPH_BIN` → brain config → PATH → `npx`). The brain reads codegraph's
54
+ SQLite graph directly; it shells the CLI only to (re)build.
@@ -6,14 +6,18 @@ description: |
6
6
  language servers when missing.
7
7
 
8
8
  Use when: "where is X defined", "who uses X", "what type is X",
9
- "list symbols in", "find symbol", "who calls X", "blast radius",
10
- "architecture map", "code diagnostics", "lsp health".
9
+ "list symbols in", "find symbol", "who calls X",
10
+ "code diagnostics", "lsp health".
11
11
  ---
12
12
 
13
13
  # wicked-brain:lsp
14
14
 
15
15
  Universal code intelligence for any CLI/IDE via the brain's LSP client layer.
16
16
 
17
+ > For whole-repo **relationship** queries — blast radius, lineage, architecture
18
+ > map — use `wicked-brain:graph` (codegraph-backed). LSP here is live,
19
+ > single-symbol intelligence (definitions, references, hover, diagnostics).
20
+
17
21
  ## Cross-Platform Notes
18
22
 
19
23
  This skill uses `npx wicked-brain-call` for all server interaction. The CLI