monomind 2.6.1 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "monomind",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "description": "Open-source CLI extension for Claude Code. Adds an MCP server with a codebase knowledge graph, persistent memory, multi-agent coordination, and reusable slash commands. MIT licensed, runs locally, no data leaves your machine.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -62,8 +62,8 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
65
- "@monoes/monobrowse": "^1.0.3",
66
- "@monoes/monograph": "^1.5.0",
65
+ "@monoes/monobrowse": "^1.0.6",
66
+ "@monoes/monograph": "^1.5.1",
67
67
  "@noble/ed25519": "^2.1.0",
68
68
  "mammoth": "^1.12.0",
69
69
  "pdf-parse": "^2.4.5",
@@ -72,7 +72,22 @@ if (isMCPMode) {
72
72
  // (or a slow trickle without newlines) cannot OOM-kill the process.
73
73
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
74
74
  let buffer = '';
75
+ // Tracks handleMessage() calls still in flight when stdin closes — without
76
+ // this, 'end' calling process.exit(0) unconditionally could kill the process
77
+ // mid-handler and silently drop the response a client is still waiting on
78
+ // (issue #39).
79
+ let inFlight = 0;
80
+ let stdinEnded = false;
75
81
  process.stdin.setEncoding('utf8');
82
+ // process.stdout.write() to a pipe is NOT guaranteed synchronous — for a
83
+ // large payload (e.g. a full tools/list response) the OS write can still be
84
+ // in flight when the call returns. console.log() doesn't expose that, so
85
+ // calling process.exit() right after it can truncate/drop the very response
86
+ // being protected. Wait for the actual flush callback before allowing exit.
87
+ function writeLine(str) {
88
+ return new Promise((resolve) => process.stdout.write(str + '\n', resolve));
89
+ }
90
+
76
91
  process.stdin.on('data', async (chunk) => {
77
92
  buffer += chunk;
78
93
  if (buffer.length > MAX_BUFFER_BYTES) {
@@ -81,38 +96,51 @@ if (isMCPMode) {
81
96
  }
82
97
  let lines = buffer.split('\n');
83
98
  buffer = lines.pop() || '';
99
+ const toProcess = lines.filter((line) => line.trim());
100
+ // Reserve the WHOLE batch synchronously, before any `await` yields control —
101
+ // otherwise 'end' can fire in the gap between two lines of the same chunk
102
+ // (inFlight transiently reads 0 after line 1 finishes but before line 2's
103
+ // own increment runs), exiting mid-batch and dropping the rest silently.
104
+ inFlight += toProcess.length;
84
105
 
85
- for (const line of lines) {
86
- if (line.trim()) {
87
- let parsed;
88
- try {
89
- parsed = JSON.parse(line);
90
- } catch {
91
- console.log(JSON.stringify({
92
- jsonrpc: '2.0',
93
- id: null,
94
- error: { code: -32700, message: 'Parse error' },
95
- }));
96
- continue;
97
- }
98
- try {
99
- const response = await handleMessage(parsed);
100
- if (response) {
101
- console.log(JSON.stringify(response));
102
- }
103
- } catch (error) {
104
- console.log(JSON.stringify({
105
- jsonrpc: '2.0',
106
- id: parsed.id ?? null,
107
- error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
108
- }));
106
+ for (const line of toProcess) {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(line);
110
+ } catch {
111
+ await writeLine(JSON.stringify({
112
+ jsonrpc: '2.0',
113
+ id: null,
114
+ error: { code: -32700, message: 'Parse error' },
115
+ }));
116
+ inFlight--;
117
+ if (stdinEnded && inFlight === 0) process.exit(0);
118
+ continue;
119
+ }
120
+ try {
121
+ const response = await handleMessage(parsed);
122
+ if (response) {
123
+ await writeLine(JSON.stringify(response));
109
124
  }
125
+ } catch (error) {
126
+ await writeLine(JSON.stringify({
127
+ jsonrpc: '2.0',
128
+ id: parsed.id ?? null,
129
+ error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
130
+ }));
131
+ } finally {
132
+ inFlight--;
133
+ if (stdinEnded && inFlight === 0) process.exit(0);
110
134
  }
111
135
  }
112
136
  });
113
137
 
114
138
  process.stdin.on('end', () => {
115
- process.exit(0);
139
+ stdinEnded = true;
140
+ if (inFlight === 0) { process.exit(0); return; }
141
+ // Bounded safety net: don't hang forever if a handler is genuinely stuck.
142
+ const EXIT_GRACE_MS = 30000;
143
+ setTimeout(() => process.exit(0), EXIT_GRACE_MS).unref();
116
144
  });
117
145
 
118
146
  async function handleMessage(message) {
@@ -49,7 +49,15 @@ export function generateMCPConfig(options) {
49
49
  MONOMIND_TOPOLOGY: options.runtime.topology,
50
50
  MONOMIND_MAX_AGENTS: String(options.runtime.maxAgents),
51
51
  MONOMIND_MEMORY_BACKEND: options.runtime.memoryBackend,
52
- }, { autoStart: config.autoStart });
52
+ }
53
+ // No `autoStart` here: Claude Code's .mcp.json schema for stdio servers
54
+ // is only command/args/env — `autoStart` isn't a field it reads, and
55
+ // nothing in monomind reads it back from this file either (it's a
56
+ // separate, monomind-internal setting stored in .monomind/config.yaml).
57
+ // Writing it here was pure dead-end noise in a file whose schema we
58
+ // don't own — worth removing even though Claude Code likely just
59
+ // ignores unknown fields, since "likely ignores" isn't "verified inert".
60
+ );
53
61
  }
54
62
  // Monograph knowledge graph — built into monomind MCP server since v1.8.0.
55
63
  // Available as mcp__monomind__monograph_build, monograph_query, monograph_suggest, monograph_health.
@@ -5,6 +5,18 @@
5
5
  * All monograph_* tools are backed by @monoes/monograph package.
6
6
  */
7
7
  import type { MCPTool } from './types.js';
8
+ /**
9
+ * BM25 ranks purely on literal keyword overlap, so a doc/concept node whose
10
+ * prose happens to mention task-related words (e.g. a report mentioning
11
+ * "retry" constants) can outrank the actual code symbol the task is really
12
+ * about, which is conceptually related but lexically different (issue #38).
13
+ * Prefer code-symbol hits (Function/Class/Method/...) over doc-type hits
14
+ * (Document/Concept/Section/...) when there are any, falling back to the
15
+ * full hit list so a genuinely doc-only task still gets results.
16
+ */
17
+ export declare function preferSymbolHits<T extends {
18
+ label: string;
19
+ }>(hits: T[], symbolLabels: ReadonlySet<string>): T[];
8
20
  /**
9
21
  * Full tool list regardless of gating — used by the graphify compat shims,
10
22
  * which must resolve targets (e.g. monograph_community) even when the
@@ -493,6 +493,19 @@ const monographSurprisesTool = {
493
493
  },
494
494
  };
495
495
  // ── monograph_suggest ─────────────────────────────────────────────────────────
496
+ /**
497
+ * BM25 ranks purely on literal keyword overlap, so a doc/concept node whose
498
+ * prose happens to mention task-related words (e.g. a report mentioning
499
+ * "retry" constants) can outrank the actual code symbol the task is really
500
+ * about, which is conceptually related but lexically different (issue #38).
501
+ * Prefer code-symbol hits (Function/Class/Method/...) over doc-type hits
502
+ * (Document/Concept/Section/...) when there are any, falling back to the
503
+ * full hit list so a genuinely doc-only task still gets results.
504
+ */
505
+ export function preferSymbolHits(hits, symbolLabels) {
506
+ const symbolHits = hits.filter(h => symbolLabels.has(h.label));
507
+ return symbolHits.length > 0 ? symbolHits : hits;
508
+ }
496
509
  const monographSuggestTool = {
497
510
  name: 'monograph_suggest',
498
511
  description: 'Get graph-topology-derived questions to explore the codebase. Pass task= to score by task relevance via BM25/FTS5.',
@@ -554,7 +567,9 @@ const monographSuggestTool = {
554
567
  let hitIds = [];
555
568
  if (task) {
556
569
  const hits = await hybridQuery(db, task, { limit: 20 });
557
- hitIds = [...new Set(hits.map(h => h.id))];
570
+ const { SYMBOL_NODE_LABELS } = await import('@monoes/monograph');
571
+ const relevantHits = preferSymbolHits(hits, SYMBOL_NODE_LABELS);
572
+ hitIds = [...new Set(relevantHits.map(h => h.id))];
558
573
  if (hitIds.length === 0) {
559
574
  return text('No suggestions for this task. Run monograph_build first or try a different query.' + stalenessAnnotation);
560
575
  }
@@ -118,7 +118,7 @@ export class OrgDaemon {
118
118
  const runtime = { mailbox, policy, status: 'running', done: Promise.resolve() };
119
119
  const sessionOpts = {
120
120
  org: name, role, bus, policy, mailbox, cwd, def,
121
- maxTurns: def.run_config.max_turns_per_message,
121
+ maxTurns: role.max_turns_per_message ?? def.run_config.max_turns_per_message,
122
122
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
123
123
  askHuman: (r, question) => this.askHuman(name, r, question),
124
124
  onComplete: role.id === bossRole.id
@@ -97,6 +97,10 @@ export declare const RoleSchema: z.ZodObject<{
97
97
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
98
98
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
99
99
  }, z.ZodTypeAny, "passthrough">>>;
100
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
101
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
102
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
103
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
100
104
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
101
105
  id: z.ZodString;
102
106
  title: z.ZodDefault<z.ZodString>;
@@ -154,6 +158,10 @@ export declare const RoleSchema: z.ZodObject<{
154
158
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
155
159
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
156
160
  }, z.ZodTypeAny, "passthrough">>>;
161
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
162
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
163
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
164
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
157
165
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
158
166
  id: z.ZodString;
159
167
  title: z.ZodDefault<z.ZodString>;
@@ -211,6 +219,10 @@ export declare const RoleSchema: z.ZodObject<{
211
219
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
212
220
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
213
221
  }, z.ZodTypeAny, "passthrough">>>;
222
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
223
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
224
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
225
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
214
226
  }, z.ZodTypeAny, "passthrough">>;
215
227
  export declare const OrgDefSchema: z.ZodObject<{
216
228
  name: z.ZodString;
@@ -305,6 +317,10 @@ export declare const OrgDefSchema: z.ZodObject<{
305
317
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
306
318
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
307
319
  }, z.ZodTypeAny, "passthrough">>>;
320
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
321
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
322
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
323
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
308
324
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
309
325
  id: z.ZodString;
310
326
  title: z.ZodDefault<z.ZodString>;
@@ -362,6 +378,10 @@ export declare const OrgDefSchema: z.ZodObject<{
362
378
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
363
379
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
364
380
  }, z.ZodTypeAny, "passthrough">>>;
381
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
382
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
383
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
384
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
365
385
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
366
386
  id: z.ZodString;
367
387
  title: z.ZodDefault<z.ZodString>;
@@ -419,6 +439,10 @@ export declare const OrgDefSchema: z.ZodObject<{
419
439
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
420
440
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
421
441
  }, z.ZodTypeAny, "passthrough">>>;
442
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
443
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
444
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
445
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
422
446
  }, z.ZodTypeAny, "passthrough">>, "many">;
423
447
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
424
448
  name: z.ZodString;
@@ -513,6 +537,10 @@ export declare const OrgDefSchema: z.ZodObject<{
513
537
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
514
538
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
515
539
  }, z.ZodTypeAny, "passthrough">>>;
540
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
541
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
542
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
543
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
516
544
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
517
545
  id: z.ZodString;
518
546
  title: z.ZodDefault<z.ZodString>;
@@ -570,6 +598,10 @@ export declare const OrgDefSchema: z.ZodObject<{
570
598
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
571
599
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
572
600
  }, z.ZodTypeAny, "passthrough">>>;
601
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
602
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
603
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
604
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
573
605
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
574
606
  id: z.ZodString;
575
607
  title: z.ZodDefault<z.ZodString>;
@@ -627,6 +659,10 @@ export declare const OrgDefSchema: z.ZodObject<{
627
659
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
628
660
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
629
661
  }, z.ZodTypeAny, "passthrough">>>;
662
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
663
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
664
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
665
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
630
666
  }, z.ZodTypeAny, "passthrough">>, "many">;
631
667
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
632
668
  name: z.ZodString;
@@ -721,6 +757,10 @@ export declare const OrgDefSchema: z.ZodObject<{
721
757
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
722
758
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
723
759
  }, z.ZodTypeAny, "passthrough">>>;
760
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
761
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
762
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
763
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
724
764
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
725
765
  id: z.ZodString;
726
766
  title: z.ZodDefault<z.ZodString>;
@@ -778,6 +818,10 @@ export declare const OrgDefSchema: z.ZodObject<{
778
818
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
779
819
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
780
820
  }, z.ZodTypeAny, "passthrough">>>;
821
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
822
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
823
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
824
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
781
825
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
782
826
  id: z.ZodString;
783
827
  title: z.ZodDefault<z.ZodString>;
@@ -835,6 +879,10 @@ export declare const OrgDefSchema: z.ZodObject<{
835
879
  webAllow: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
836
880
  maxTokens: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
837
881
  }, z.ZodTypeAny, "passthrough">>>;
882
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
883
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
884
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
885
+ max_turns_per_message: z.ZodOptional<z.ZodNumber>;
838
886
  }, z.ZodTypeAny, "passthrough">>, "many">;
839
887
  }, z.ZodTypeAny, "passthrough">>;
840
888
  export type OrgDef = z.infer<typeof OrgDefSchema>;
@@ -32,6 +32,10 @@ export const RoleSchema = z.object({
32
32
  }).partial().optional(),
33
33
  provider: ProviderSchema.optional(),
34
34
  policy: RolePolicySchema.optional(),
35
+ /** Per-role override of run_config.max_turns_per_message — roles that legitimately
36
+ * need many more turns per message (e.g. a developer doing sequential build/fix/verify
37
+ * cycles) than others (e.g. docs, pm) shouldn't be forced onto one global budget. */
38
+ max_turns_per_message: z.number().int().positive().optional(),
35
39
  }).passthrough();
36
40
  export const OrgDefSchema = z.object({
37
41
  name: z.string().min(1),
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@monoes/monomindcli",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "type": "module",
5
- "description": "CLI engine for Monomind \u2014 an open-source MCP server that extends Claude Code with a codebase knowledge graph (tree-sitter + SQLite), persistent memory, multi-agent task coordination, and session hooks. MIT licensed, fully local.",
5
+ "description": "CLI engine for Monomind an open-source MCP server that extends Claude Code with a codebase knowledge graph (tree-sitter + SQLite), persistent memory, multi-agent task coordination, and session hooks. MIT licensed, fully local.",
6
6
  "main": "dist/src/index.js",
7
7
  "types": "dist/src/index.d.ts",
8
8
  "sideEffects": false,
@@ -95,9 +95,9 @@
95
95
  },
96
96
  "dependencies": {
97
97
  "@anthropic-ai/claude-agent-sdk": "^0.3.207",
98
- "@monoes/monobrowse": "^1.0.3",
98
+ "@monoes/monobrowse": "^1.0.6",
99
99
  "@monoes/monodesign": "^1.2.0",
100
- "@monoes/monograph": "^1.5.0",
100
+ "@monoes/monograph": "^1.5.1",
101
101
  "@noble/ed25519": "^2.1.0",
102
102
  "mammoth": "^1.12.0",
103
103
  "pdf-parse": "^2.4.5",
@@ -107,9 +107,9 @@
107
107
  },
108
108
  "optionalDependencies": {
109
109
  "@huggingface/transformers": "^3.8.1",
110
- "@monoes/memory": "^1.0.10",
111
110
  "@monoes/hooks": "^1.0.0",
112
111
  "@monoes/mcp": "^1.0.0",
112
+ "@monoes/memory": "^1.0.10",
113
113
  "@monoes/routing": "^1.0.0",
114
114
  "monofence-ai": "*",
115
115
  "sql.js": "^1.14.1"
@@ -30,17 +30,20 @@ function parseArticles(text, org) {
30
30
  const [, slug, title] = m;
31
31
  let heroImage = null;
32
32
  let supportingImages = [];
33
+ let diagrams = [];
33
34
  const bodyLines = [];
34
35
  for (const line of lines.slice(1)) {
35
36
  const heroMatch = line.match(/^HeroImage:\s*(.+?)\s*$/i);
36
37
  const suppMatch = line.match(/^SupportingImages:\s*(.+?)\s*$/i);
38
+ const diagMatch = line.match(/^Diagrams:\s*(.+?)\s*$/i);
37
39
  if (heroMatch) { heroImage = heroMatch[1]; continue; }
38
40
  if (suppMatch) { supportingImages = suppMatch[1].split(',').map((s) => s.trim()).filter(Boolean); continue; }
41
+ if (diagMatch) { diagrams = diagMatch[1].split(',').map((s) => s.trim()).filter(Boolean); continue; }
39
42
  bodyLines.push(line);
40
43
  }
41
44
  const body = bodyLines.join('\n').trim();
42
45
  if (!body) continue;
43
- articles.push({ org, slug: slug.trim(), title: title.trim(), heroImage, supportingImages, body });
46
+ articles.push({ org, slug: slug.trim(), title: title.trim(), heroImage, supportingImages, diagrams, body });
44
47
  }
45
48
  return articles;
46
49
  }
@@ -80,15 +83,25 @@ function escapeHtml(s) {
80
83
  return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
81
84
  }
82
85
 
83
- function mdToHtml(md) {
84
- // Minimal renderer: paragraphs, headings, code fences, bold/italic, links, tables.
85
- return escapeHtml(md)
86
+ function mdToHtml(md, copiedSet) {
87
+ // Minimal renderer: paragraphs, headings, code fences, bold/italic, inline images, tables.
88
+ const figures = [];
89
+ const placeholder = (i) => `[[[FIGURE-${i}]]]`;
90
+ const withPlaceholders = md.replace(/!\[(.*?)\]\((?:\.\/)?(.*?)\)/g, (_, alt, src) => {
91
+ const idx = figures.length;
92
+ const visible = copiedSet.has(src);
93
+ figures.push(visible ? `<figure class="inline-diagram"><img src="./${escapeHtml(src)}" alt="${escapeHtml(alt)}"><figcaption>${escapeHtml(alt)}</figcaption></figure>` : '');
94
+ return placeholder(idx);
95
+ });
96
+ let html = escapeHtml(withPlaceholders)
86
97
  .replace(/^### (.+)$/gm, '<h4>$1</h4>')
87
98
  .replace(/^## (.+)$/gm, '<h3>$1</h3>')
88
99
  .replace(/^# (.+)$/gm, '<h2>$1</h2>')
89
100
  .replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`)
90
101
  .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
91
102
  .replace(/\n\n/g, '</p><p>');
103
+ figures.forEach((fig, i) => { html = html.replace(placeholder(i), fig); });
104
+ return html;
92
105
  }
93
106
 
94
107
  function render() {
@@ -121,7 +134,7 @@ function render() {
121
134
  // copy every referenced image (article hero/supporting + post images) alongside html/md
122
135
  const copied = new Set();
123
136
  const allImageNames = new Set([
124
- ...articles.flatMap((a) => [a.heroImage, ...a.supportingImages].filter(Boolean)),
137
+ ...articles.flatMap((a) => [a.heroImage, ...a.supportingImages, ...a.diagrams].filter(Boolean)),
125
138
  ...posts.map((p) => p.image).filter(Boolean),
126
139
  ]);
127
140
  for (const name of allImageNames) {
@@ -172,12 +185,14 @@ function render() {
172
185
  function articleCard(a) {
173
186
  const img = a.heroImage && copied.has(a.heroImage) ? `<img class="hero" src="./${escapeHtml(a.heroImage)}" alt="">` : '';
174
187
  const supp = a.supportingImages.filter((n) => copied.has(n)).map((n) => `<img class="supporting" src="./${escapeHtml(n)}" alt="">`).join('');
188
+ const missingDiagrams = a.diagrams.filter((n) => !copied.has(n));
175
189
  return `<div class="article">
176
190
  <div class="article-slug">${escapeHtml(a.slug)}</div>
177
191
  <h3 class="article-title">${escapeHtml(a.title)}</h3>
178
192
  ${img}
179
193
  ${supp ? `<div class="supporting-row">${supp}</div>` : ''}
180
- <div class="article-body"><p>${mdToHtml(a.body)}</p></div>
194
+ <div class="article-body"><p>${mdToHtml(a.body, copied)}</p></div>
195
+ ${missingDiagrams.length ? `<div class="missing-note">Missing diagram file(s) referenced but not found: ${missingDiagrams.map(escapeHtml).join(', ')}</div>` : ''}
181
196
  </div>`;
182
197
  }
183
198
 
@@ -211,6 +226,11 @@ main { max-width:860px; margin:0 auto; padding:0 1.5rem 4rem; }
211
226
  .supporting-row img { width:100%; border-radius:8px; }
212
227
  .article-body { font-size:.92rem; color:var(--text); }
213
228
  .article-body p { white-space:pre-wrap; }
229
+ .article-body h2, .article-body h3, .article-body h4 { white-space:normal; }
230
+ .inline-diagram { margin:1.2rem 0; }
231
+ .inline-diagram img { width:100%; border-radius:8px; background:var(--espresso); }
232
+ .inline-diagram figcaption { font-size:.78rem; color:var(--muted); margin-top:.3rem; text-align:center; }
233
+ .missing-note { margin-top:1rem; padding:.6rem .8rem; border:1px dashed var(--border); border-radius:8px; font-size:.78rem; color:var(--muted); }
214
234
  .card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:1.1rem 1.3rem; margin-bottom:1rem; }
215
235
  .channel { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--accent); margin-bottom:.6rem; }
216
236
  .based-on { font-weight:400; text-transform:none; letter-spacing:0; color:var(--muted); font-style:italic; }
@@ -231,7 +251,10 @@ ${!articles.length && !posts.length ? '<div class="empty">Nothing finalized yet
231
251
  </body></html>`;
232
252
  fs.writeFileSync(path.join(OUT_DIR, 'content-calendar.html'), html);
233
253
 
234
- console.log(`Wrote ${articles.length} article(s), ${posts.length} post(s), ${copied.size} image(s) -> ${path.relative(ROOT, OUT_DIR)}`);
254
+ const totalDiagrams = articles.reduce((n, a) => n + a.diagrams.length, 0);
255
+ const underDiagrammed = articles.filter((a) => a.diagrams.length < 2).map((a) => a.slug);
256
+ console.log(`Wrote ${articles.length} article(s), ${posts.length} post(s), ${copied.size} image(s) (incl. ${totalDiagrams} diagram(s)) -> ${path.relative(ROOT, OUT_DIR)}`);
257
+ if (underDiagrammed.length) console.log(`Note: fewer than 2 diagrams on: ${underDiagrammed.join(', ')}`);
235
258
  }
236
259
 
237
260
  render();