@stackmemoryai/stackmemory 1.10.3 → 1.10.4

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.
@@ -0,0 +1,792 @@
1
+ import { fileURLToPath as __fileURLToPath } from 'url';
2
+ import { dirname as __pathDirname } from 'path';
3
+ const __filename = __fileURLToPath(import.meta.url);
4
+ const __dirname = __pathDirname(__filename);
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ writeFileSync,
9
+ readFileSync,
10
+ readdirSync
11
+ } from "fs";
12
+ import { join } from "path";
13
+ import { logger } from "../monitoring/logger.js";
14
+ class WikiCompiler {
15
+ config;
16
+ dirs;
17
+ constructor(config) {
18
+ this.config = {
19
+ wikiDir: config.wikiDir,
20
+ indexLimit: config.indexLimit ?? 200,
21
+ logLimit: config.logLimit ?? 500
22
+ };
23
+ this.dirs = {
24
+ root: this.config.wikiDir,
25
+ entities: join(this.config.wikiDir, "entities"),
26
+ concepts: join(this.config.wikiDir, "concepts"),
27
+ sources: join(this.config.wikiDir, "sources"),
28
+ synthesis: join(this.config.wikiDir, "synthesis")
29
+ };
30
+ }
31
+ /** Initialize wiki directory structure */
32
+ async initialize() {
33
+ for (const dir of Object.values(this.dirs)) {
34
+ if (!existsSync(dir)) {
35
+ mkdirSync(dir, { recursive: true });
36
+ }
37
+ }
38
+ const indexPath = join(this.dirs.root, "index.md");
39
+ if (!existsSync(indexPath)) {
40
+ writeFileSync(indexPath, this.generateEmptyIndex());
41
+ }
42
+ const logPath = join(this.dirs.root, "log.md");
43
+ if (!existsSync(logPath)) {
44
+ writeFileSync(
45
+ logPath,
46
+ [
47
+ "# Wiki Log",
48
+ "",
49
+ "> Chronological record of wiki operations. Parseable with grep.",
50
+ ""
51
+ ].join("\n")
52
+ );
53
+ }
54
+ logger.info("Wiki compiler initialized", { wikiDir: this.config.wikiDir });
55
+ }
56
+ /**
57
+ * Create the wiki from scratch using all available context.
58
+ * Generates entity pages, concept pages, source digests, and a synthesis overview.
59
+ */
60
+ async create(ctx) {
61
+ const created = [];
62
+ const entitiesByName = this.groupBy(ctx.entities, (e) => e.entity_name);
63
+ for (const [name, states] of entitiesByName) {
64
+ const slug = this.slugify(name);
65
+ const path = `entities/${slug}.md`;
66
+ this.writeArticle(path, this.buildEntityArticle(name, states));
67
+ created.push(path);
68
+ }
69
+ const conceptGroups = this.extractConceptGroups(ctx.anchors);
70
+ for (const [concept, items] of conceptGroups) {
71
+ const slug = this.slugify(concept);
72
+ const path = `concepts/${slug}.md`;
73
+ this.writeArticle(path, this.buildConceptArticle(concept, items));
74
+ created.push(path);
75
+ }
76
+ for (const digest of ctx.digests) {
77
+ const slug = this.slugify(digest.frame_name);
78
+ const path = `sources/${slug}.md`;
79
+ this.writeArticle(path, this.buildSourceArticle(digest));
80
+ created.push(path);
81
+ }
82
+ if (ctx.digests.length > 0 || ctx.entities.length > 0) {
83
+ const overviewPath = "synthesis/overview.md";
84
+ this.writeArticle(
85
+ overviewPath,
86
+ this.buildSynthesisOverview(ctx.digests, ctx.entities, ctx.anchors)
87
+ );
88
+ created.push(overviewPath);
89
+ }
90
+ if (ctx.sessionDigest) {
91
+ const path = `sources/session-${this.slugify(ctx.sessionDigest.period)}.md`;
92
+ this.writeArticle(path, this.buildSessionSource(ctx.sessionDigest));
93
+ created.push(path);
94
+ }
95
+ this.updateIndex();
96
+ this.appendLog(
97
+ `## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] create | full wiki`,
98
+ `Created ${created.length} articles from ${ctx.digests.length} digests, ${ctx.entities.length} entity states, ${ctx.anchors.length} anchors`
99
+ );
100
+ return {
101
+ created,
102
+ updated: [],
103
+ totalArticles: this.countArticles(),
104
+ compiledAt: Date.now()
105
+ };
106
+ }
107
+ /**
108
+ * Incrementally update the wiki with new context since last compile.
109
+ * Updates existing articles or creates new ones as needed.
110
+ */
111
+ async update(ctx) {
112
+ const created = [];
113
+ const updated = [];
114
+ const entitiesByName = this.groupBy(ctx.entities, (e) => e.entity_name);
115
+ for (const [name, states] of entitiesByName) {
116
+ const slug = this.slugify(name);
117
+ const path = `entities/${slug}.md`;
118
+ const filepath = join(this.dirs.root, path);
119
+ if (existsSync(filepath)) {
120
+ const existing = readFileSync(filepath, "utf-8");
121
+ const merged = this.mergeEntityStates(existing, name, states);
122
+ if (merged !== existing) {
123
+ this.writeArticle(path, merged);
124
+ updated.push(path);
125
+ }
126
+ } else {
127
+ this.writeArticle(path, this.buildEntityArticle(name, states));
128
+ created.push(path);
129
+ }
130
+ }
131
+ const conceptGroups = this.extractConceptGroups(ctx.anchors);
132
+ for (const [concept, items] of conceptGroups) {
133
+ const slug = this.slugify(concept);
134
+ const path = `concepts/${slug}.md`;
135
+ const filepath = join(this.dirs.root, path);
136
+ if (existsSync(filepath)) {
137
+ const existing = readFileSync(filepath, "utf-8");
138
+ const merged = this.mergeConceptAnchors(existing, items);
139
+ if (merged !== existing) {
140
+ this.writeArticle(path, merged);
141
+ updated.push(path);
142
+ }
143
+ } else {
144
+ this.writeArticle(path, this.buildConceptArticle(concept, items));
145
+ created.push(path);
146
+ }
147
+ }
148
+ for (const digest of ctx.digests) {
149
+ const slug = this.slugify(digest.frame_name);
150
+ const path = `sources/${slug}.md`;
151
+ const filepath = join(this.dirs.root, path);
152
+ if (!existsSync(filepath)) {
153
+ this.writeArticle(path, this.buildSourceArticle(digest));
154
+ created.push(path);
155
+ }
156
+ }
157
+ if (created.length > 0 || updated.length > 0) {
158
+ const overviewPath = "synthesis/overview.md";
159
+ const allDigests = this.readExistingSources();
160
+ this.writeArticle(
161
+ overviewPath,
162
+ this.buildSynthesisFromExisting(allDigests, ctx.entities, ctx.anchors)
163
+ );
164
+ if (existsSync(join(this.dirs.root, overviewPath))) {
165
+ updated.push(overviewPath);
166
+ } else {
167
+ created.push(overviewPath);
168
+ }
169
+ }
170
+ if (ctx.sessionDigest) {
171
+ const path = `sources/session-${this.slugify(ctx.sessionDigest.period)}.md`;
172
+ this.writeArticle(path, this.buildSessionSource(ctx.sessionDigest));
173
+ const filepath = join(this.dirs.root, path);
174
+ if (existsSync(filepath)) updated.push(path);
175
+ else created.push(path);
176
+ }
177
+ if (created.length > 0 || updated.length > 0) {
178
+ this.updateIndex();
179
+ this.appendLog(
180
+ `## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] update | incremental`,
181
+ `${created.length} created, ${updated.length} updated from ${ctx.digests.length} digests, ${ctx.entities.length} entity states`
182
+ );
183
+ }
184
+ return {
185
+ created,
186
+ updated,
187
+ totalArticles: this.countArticles(),
188
+ compiledAt: Date.now()
189
+ };
190
+ }
191
+ /**
192
+ * Compile a single raw ingest file into a source article.
193
+ */
194
+ async compileRawIngest(filename, content, metadata) {
195
+ const created = [];
196
+ const title = metadata["title"] || filename.replace(".md", "");
197
+ const sourceSlug = this.slugify(title);
198
+ const sourcePath = `sources/${sourceSlug}.md`;
199
+ const lines = [
200
+ "---",
201
+ `title: "${this.escapeYaml(title)}"`,
202
+ `category: source`,
203
+ `source_file: "${filename}"`,
204
+ `created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
205
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
206
+ metadata["source"] ? `url: "${metadata["source"]}"` : "",
207
+ `tags: [source, raw-ingest]`,
208
+ "---",
209
+ "",
210
+ `# ${title}`,
211
+ "",
212
+ metadata["source"] ? `> Source: ${metadata["source"]}` : "",
213
+ "",
214
+ "## Summary",
215
+ "",
216
+ this.extractSummary(content),
217
+ "",
218
+ "## Raw Content",
219
+ "",
220
+ content.slice(0, 2e3),
221
+ content.length > 2e3 ? "\n_...truncated..._" : ""
222
+ ].filter(Boolean);
223
+ this.writeArticle(sourcePath, lines.join("\n"));
224
+ created.push(sourcePath);
225
+ this.updateIndex();
226
+ this.appendLog(
227
+ `## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest | ${title}`,
228
+ `Raw file \`${filename}\` \u2014 1 source article created`
229
+ );
230
+ return {
231
+ created,
232
+ updated: [],
233
+ totalArticles: this.countArticles(),
234
+ compiledAt: Date.now()
235
+ };
236
+ }
237
+ /** Lint the wiki for health issues */
238
+ async lint() {
239
+ const allArticles = this.listAllArticles();
240
+ const orphans = [];
241
+ const brokenLinks = [];
242
+ const stale = [];
243
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1e3;
244
+ const inboundLinks = /* @__PURE__ */ new Map();
245
+ for (const article of allArticles) {
246
+ inboundLinks.set(article, 0);
247
+ }
248
+ for (const article of allArticles) {
249
+ const filepath = join(this.dirs.root, article);
250
+ if (!existsSync(filepath)) continue;
251
+ const content = readFileSync(filepath, "utf-8");
252
+ const links = this.extractWikiLinks(content);
253
+ for (const link of links) {
254
+ if (allArticles.includes(link)) {
255
+ inboundLinks.set(link, (inboundLinks.get(link) || 0) + 1);
256
+ } else {
257
+ brokenLinks.push({ source: article, target: link });
258
+ }
259
+ }
260
+ const updatedMatch = content.match(/updated:\s*(.+)/);
261
+ if (updatedMatch) {
262
+ const updatedAt = new Date((updatedMatch[1] ?? "").trim()).getTime();
263
+ if (updatedAt < thirtyDaysAgo) {
264
+ stale.push(article);
265
+ }
266
+ }
267
+ }
268
+ for (const [article, count] of inboundLinks) {
269
+ if (count === 0 && article !== "index.md" && article !== "log.md") {
270
+ orphans.push(article);
271
+ }
272
+ }
273
+ return { orphans, brokenLinks, stale, totalArticles: allArticles.length };
274
+ }
275
+ /** Search wiki articles by keyword */
276
+ search(query) {
277
+ const results = [];
278
+ const allArticles = this.listAllArticles();
279
+ const queryLower = query.toLowerCase();
280
+ for (const article of allArticles) {
281
+ const filepath = join(this.dirs.root, article);
282
+ if (!existsSync(filepath)) continue;
283
+ const content = readFileSync(filepath, "utf-8").toLowerCase();
284
+ const matches = content.split(queryLower).length - 1;
285
+ if (matches > 0) {
286
+ const titleMatch = content.match(/^#\s+(.+)/m);
287
+ results.push({
288
+ path: article,
289
+ title: titleMatch?.[1] || article,
290
+ matches
291
+ });
292
+ }
293
+ }
294
+ return results.sort((a, b) => b.matches - a.matches);
295
+ }
296
+ /** Get wiki status summary */
297
+ getStatus() {
298
+ const byCategory = {};
299
+ for (const [category, dir] of Object.entries(this.dirs)) {
300
+ if (category === "root") continue;
301
+ if (!existsSync(dir)) {
302
+ byCategory[category] = 0;
303
+ continue;
304
+ }
305
+ byCategory[category] = readdirSync(dir).filter(
306
+ (f) => f.endsWith(".md")
307
+ ).length;
308
+ }
309
+ const logPath = join(this.dirs.root, "log.md");
310
+ let lastCompile = null;
311
+ if (existsSync(logPath)) {
312
+ const logContent = readFileSync(logPath, "utf-8");
313
+ const entries = logContent.match(/^## \[.+$/gm);
314
+ if (entries && entries.length > 0) {
315
+ lastCompile = entries[entries.length - 1] ?? null;
316
+ }
317
+ }
318
+ return { totalArticles: this.countArticles(), byCategory, lastCompile };
319
+ }
320
+ /** Get timestamp of last compile from log.md */
321
+ getLastCompileTime() {
322
+ const logPath = join(this.dirs.root, "log.md");
323
+ if (!existsSync(logPath)) return null;
324
+ const content = readFileSync(logPath, "utf-8");
325
+ const entries = content.match(/^## \[(\d{4}-\d{2}-\d{2})\]/gm);
326
+ if (!entries || entries.length === 0) return null;
327
+ const lastEntry = entries[entries.length - 1] ?? "";
328
+ const dateMatch = lastEntry.match(/\[(\d{4}-\d{2}-\d{2})\]/);
329
+ if (!dateMatch) return null;
330
+ return (/* @__PURE__ */ new Date((dateMatch[1] ?? "") + "T00:00:00Z")).getTime() / 1e3;
331
+ }
332
+ // ── Article builders ──
333
+ buildEntityArticle(name, states) {
334
+ const current = states.filter((s) => s.superseded_at === null);
335
+ const historical = states.filter((s) => s.superseded_at !== null);
336
+ const lines = [
337
+ "---",
338
+ `title: "${this.escapeYaml(name)}"`,
339
+ `category: entity`,
340
+ `created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
341
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
342
+ `tags: [entity]`,
343
+ "---",
344
+ "",
345
+ `# ${name}`,
346
+ ""
347
+ ];
348
+ if (current.length > 0) {
349
+ lines.push("## Current State", "");
350
+ for (const s of current) {
351
+ lines.push(`- **${s.relation}**: ${s.value}`);
352
+ if (s.context) lines.push(` - _Context: ${s.context}_`);
353
+ }
354
+ lines.push("");
355
+ }
356
+ if (historical.length > 0) {
357
+ lines.push("## History", "");
358
+ for (const s of historical.slice(-20)) {
359
+ const from = new Date(s.valid_from * 1e3).toISOString().slice(0, 10);
360
+ const to = s.superseded_at ? new Date(s.superseded_at * 1e3).toISOString().slice(0, 10) : "current";
361
+ lines.push(`- ${from} \u2192 ${to}: **${s.relation}** = ${s.value}`);
362
+ }
363
+ lines.push("");
364
+ }
365
+ return lines.join("\n");
366
+ }
367
+ buildConceptArticle(concept, anchors) {
368
+ const lines = [
369
+ "---",
370
+ `title: "${this.escapeYaml(concept)}"`,
371
+ `category: concept`,
372
+ `created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
373
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
374
+ `tags: [concept, ${concept.toLowerCase().replace(/\s+/g, "-")}]`,
375
+ "---",
376
+ "",
377
+ `# ${concept}`,
378
+ ""
379
+ ];
380
+ const byType = this.groupBy(anchors, (a) => a.type);
381
+ for (const [type, items] of byType) {
382
+ lines.push(`## ${type}s`, "");
383
+ for (const a of items) {
384
+ const date = new Date(a.created_at * 1e3).toISOString().slice(0, 10);
385
+ lines.push(
386
+ `- ${a.text}`,
387
+ ` - _${date} \u2014 from [[sources/${this.slugify(a.frame_name)}]]_`
388
+ );
389
+ }
390
+ lines.push("");
391
+ }
392
+ return lines.join("\n");
393
+ }
394
+ buildSourceArticle(digest) {
395
+ return [
396
+ "---",
397
+ `title: "${this.escapeYaml(digest.frame_name)}"`,
398
+ `category: source`,
399
+ `frame_id: "${digest.frame_id}"`,
400
+ `frame_type: "${digest.frame_type}"`,
401
+ `created: ${new Date(digest.created_at * 1e3).toISOString()}`,
402
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
403
+ `tags: [source, ${digest.frame_type}]`,
404
+ "---",
405
+ "",
406
+ `# ${digest.frame_name}`,
407
+ "",
408
+ `> Frame \`${digest.frame_id.slice(0, 8)}\` | Type: ${digest.frame_type}`,
409
+ "",
410
+ "## Summary",
411
+ "",
412
+ digest.digest_text,
413
+ ""
414
+ ].join("\n");
415
+ }
416
+ buildSessionSource(session) {
417
+ return [
418
+ "---",
419
+ `title: "Session \u2014 ${session.period}"`,
420
+ `category: source`,
421
+ `period: "${session.period}"`,
422
+ `created: ${new Date(session.generatedAt).toISOString()}`,
423
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
424
+ `tags: [source, session]`,
425
+ "---",
426
+ "",
427
+ `# Session \u2014 ${session.period}`,
428
+ "",
429
+ session.content,
430
+ ""
431
+ ].join("\n");
432
+ }
433
+ buildSynthesisOverview(digests, entities, anchors) {
434
+ const uniqueEntities = new Set(entities.map((e) => e.entity_name));
435
+ const decisions = anchors.filter((a) => a.type === "DECISION");
436
+ const risks = anchors.filter((a) => a.type === "RISK");
437
+ const lines = [
438
+ "---",
439
+ `title: "Synthesis Overview"`,
440
+ `category: synthesis`,
441
+ `created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
442
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
443
+ `tags: [synthesis, overview]`,
444
+ "---",
445
+ "",
446
+ "# Synthesis Overview",
447
+ "",
448
+ `> Auto-generated from ${digests.length} frame digests, ${uniqueEntities.size} entities, ${anchors.length} anchors.`,
449
+ ""
450
+ ];
451
+ if (uniqueEntities.size > 0) {
452
+ lines.push("## Key Entities", "");
453
+ for (const name of Array.from(uniqueEntities).slice(0, 30)) {
454
+ lines.push(`- [[entities/${this.slugify(name)}|${name}]]`);
455
+ }
456
+ lines.push("");
457
+ }
458
+ if (decisions.length > 0) {
459
+ lines.push("## Key Decisions", "");
460
+ for (const d of decisions.slice(-20)) {
461
+ const date = new Date(d.created_at * 1e3).toISOString().slice(0, 10);
462
+ lines.push(`- ${d.text} _(${date})_`);
463
+ }
464
+ lines.push("");
465
+ }
466
+ if (risks.length > 0) {
467
+ lines.push("## Open Risks", "");
468
+ for (const r of risks.slice(-10)) {
469
+ lines.push(`- ${r.text}`);
470
+ }
471
+ lines.push("");
472
+ }
473
+ if (digests.length > 0) {
474
+ lines.push("## Recent Activity", "");
475
+ for (const d of digests.slice(-15)) {
476
+ const date = new Date(d.created_at * 1e3).toISOString().slice(0, 10);
477
+ lines.push(
478
+ `- ${date}: [[sources/${this.slugify(d.frame_name)}|${d.frame_name}]] (${d.frame_type})`
479
+ );
480
+ }
481
+ lines.push("");
482
+ }
483
+ return lines.join("\n");
484
+ }
485
+ buildSynthesisFromExisting(existingSources, newEntities, newAnchors) {
486
+ const uniqueEntities = new Set(newEntities.map((e) => e.entity_name));
487
+ if (existsSync(this.dirs.entities)) {
488
+ for (const f of readdirSync(this.dirs.entities)) {
489
+ if (f.endsWith(".md")) {
490
+ uniqueEntities.add(f.replace(".md", "").replace(/-/g, " "));
491
+ }
492
+ }
493
+ }
494
+ const decisions = newAnchors.filter((a) => a.type === "DECISION");
495
+ const lines = [
496
+ "---",
497
+ `title: "Synthesis Overview"`,
498
+ `category: synthesis`,
499
+ `created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
500
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
501
+ `tags: [synthesis, overview]`,
502
+ "---",
503
+ "",
504
+ "# Synthesis Overview",
505
+ "",
506
+ `> ${existingSources.length} source articles, ${uniqueEntities.size} entities tracked.`,
507
+ ""
508
+ ];
509
+ if (uniqueEntities.size > 0) {
510
+ lines.push("## Key Entities", "");
511
+ for (const name of Array.from(uniqueEntities).slice(0, 30)) {
512
+ lines.push(`- [[entities/${this.slugify(name)}|${name}]]`);
513
+ }
514
+ lines.push("");
515
+ }
516
+ if (decisions.length > 0) {
517
+ lines.push("## Recent Decisions", "");
518
+ for (const d of decisions.slice(-10)) {
519
+ lines.push(`- ${d.text}`);
520
+ }
521
+ lines.push("");
522
+ }
523
+ if (existingSources.length > 0) {
524
+ lines.push("## Sources", "");
525
+ for (const s of existingSources.slice(-20)) {
526
+ lines.push(`- [[${s.path.replace(".md", "")}|${s.title}]]`);
527
+ }
528
+ lines.push("");
529
+ }
530
+ return lines.join("\n");
531
+ }
532
+ // ── Merge helpers (for update) ──
533
+ mergeEntityStates(existing, _name, newStates) {
534
+ let content = existing;
535
+ for (const s of newStates) {
536
+ const entry = `- **${s.relation}**: ${s.value}`;
537
+ if (content.includes(s.value)) continue;
538
+ content = content.replace(
539
+ /updated:\s*.+/,
540
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`
541
+ );
542
+ if (s.superseded_at === null) {
543
+ const marker = "## Current State";
544
+ const idx = content.indexOf(marker);
545
+ if (idx !== -1) {
546
+ const afterMarker = content.indexOf("\n\n", idx + marker.length);
547
+ if (afterMarker !== -1) {
548
+ content = content.slice(0, afterMarker) + "\n" + entry + (s.context ? `
549
+ - _Context: ${s.context}_` : "") + content.slice(afterMarker);
550
+ }
551
+ }
552
+ } else {
553
+ const from = new Date(s.valid_from * 1e3).toISOString().slice(0, 10);
554
+ const to = new Date(s.superseded_at * 1e3).toISOString().slice(0, 10);
555
+ const histEntry = `- ${from} \u2192 ${to}: **${s.relation}** = ${s.value}`;
556
+ if (!content.includes("## History")) {
557
+ content = content.trimEnd() + "\n\n## History\n\n" + histEntry + "\n";
558
+ } else {
559
+ content = content.trimEnd() + "\n" + histEntry + "\n";
560
+ }
561
+ }
562
+ }
563
+ return content;
564
+ }
565
+ mergeConceptAnchors(existing, newAnchors) {
566
+ let content = existing;
567
+ for (const a of newAnchors) {
568
+ if (content.includes(a.text)) continue;
569
+ content = content.replace(
570
+ /updated:\s*.+/,
571
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`
572
+ );
573
+ const date = new Date(a.created_at * 1e3).toISOString().slice(0, 10);
574
+ const entry = `- ${a.text}
575
+ - _${date} \u2014 from [[sources/${this.slugify(a.frame_name)}]]_`;
576
+ const sectionHeader = `## ${a.type}s`;
577
+ if (content.includes(sectionHeader)) {
578
+ const idx = content.indexOf(sectionHeader);
579
+ const nextSection = content.indexOf(
580
+ "\n## ",
581
+ idx + sectionHeader.length
582
+ );
583
+ const insertAt = nextSection !== -1 ? nextSection : content.length;
584
+ content = content.slice(0, insertAt).trimEnd() + "\n" + entry + "\n" + content.slice(insertAt);
585
+ } else {
586
+ content = content.trimEnd() + "\n\n" + sectionHeader + "\n\n" + entry + "\n";
587
+ }
588
+ }
589
+ return content;
590
+ }
591
+ // ── Concept extraction ──
592
+ extractConceptGroups(anchors) {
593
+ const groups = /* @__PURE__ */ new Map();
594
+ const conceptMap = {
595
+ DECISION: "Decisions",
596
+ FACT: "Key Facts",
597
+ CONSTRAINT: "Constraints",
598
+ RISK: "Risks",
599
+ TODO: "Action Items",
600
+ INTERFACE_CONTRACT: "Interface Contracts"
601
+ };
602
+ for (const anchor of anchors) {
603
+ const concept = conceptMap[anchor.type] || "Notes";
604
+ const list = groups.get(concept) || [];
605
+ list.push(anchor);
606
+ groups.set(concept, list);
607
+ }
608
+ return groups;
609
+ }
610
+ // ── Reading helpers ──
611
+ readExistingSources() {
612
+ const sources = [];
613
+ if (!existsSync(this.dirs.sources)) return sources;
614
+ for (const f of readdirSync(this.dirs.sources)) {
615
+ if (!f.endsWith(".md")) continue;
616
+ const filepath = join(this.dirs.sources, f);
617
+ const content = readFileSync(filepath, "utf-8");
618
+ const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
619
+ sources.push({
620
+ title: titleMatch?.[1] || f.replace(".md", ""),
621
+ path: `sources/${f}`
622
+ });
623
+ }
624
+ return sources;
625
+ }
626
+ // ── Shared helpers ──
627
+ writeArticle(articlePath, content) {
628
+ const filepath = join(this.dirs.root, articlePath);
629
+ const dir = join(filepath, "..");
630
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
631
+ writeFileSync(filepath, content);
632
+ }
633
+ updateIndex() {
634
+ const articles = this.listAllArticlesWithMeta();
635
+ const grouped = {
636
+ entities: [],
637
+ concepts: [],
638
+ sources: [],
639
+ synthesis: []
640
+ };
641
+ for (const a of articles) {
642
+ const category = a.path.split("/")[0] || "other";
643
+ const group = grouped[category];
644
+ if (group) group.push(a);
645
+ }
646
+ const lines = [
647
+ "---",
648
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
649
+ `total_articles: ${articles.length}`,
650
+ "---",
651
+ "",
652
+ "# Wiki Index",
653
+ "",
654
+ `> ${articles.length} articles. Auto-maintained by StackMemory Wiki Compiler.`,
655
+ ""
656
+ ];
657
+ for (const [category, items] of Object.entries(grouped)) {
658
+ if (items.length === 0) continue;
659
+ lines.push(
660
+ `## ${category.charAt(0).toUpperCase() + category.slice(1)}`,
661
+ ""
662
+ );
663
+ for (const item of items.slice(0, this.config.indexLimit)) {
664
+ const link = item.path.replace(".md", "");
665
+ lines.push(`- [[${link}|${item.title}]] \u2014 ${item.summary}`);
666
+ }
667
+ if (items.length > this.config.indexLimit) {
668
+ lines.push(`_...and ${items.length - this.config.indexLimit} more_`);
669
+ }
670
+ lines.push("");
671
+ }
672
+ writeFileSync(join(this.dirs.root, "index.md"), lines.join("\n"));
673
+ }
674
+ appendLog(heading, detail) {
675
+ const logPath = join(this.dirs.root, "log.md");
676
+ if (!existsSync(logPath)) return;
677
+ let content = readFileSync(logPath, "utf-8");
678
+ content += `
679
+ ${heading}
680
+ ${detail}
681
+ `;
682
+ const entries = content.match(/^## \[.+[\s\S]*?(?=^## \[|\Z)/gm);
683
+ if (entries && entries.length > this.config.logLimit) {
684
+ const header = content.split(/^## \[/m)[0] ?? "";
685
+ const kept = entries.slice(-this.config.logLimit);
686
+ content = header + kept.join("");
687
+ }
688
+ writeFileSync(logPath, content);
689
+ }
690
+ listAllArticles() {
691
+ const articles = [];
692
+ for (const [category, dir] of Object.entries(this.dirs)) {
693
+ if (category === "root") continue;
694
+ if (!existsSync(dir)) continue;
695
+ for (const f of readdirSync(dir)) {
696
+ if (f.endsWith(".md")) articles.push(`${category}/${f}`);
697
+ }
698
+ }
699
+ return articles;
700
+ }
701
+ listAllArticlesWithMeta() {
702
+ const articles = [];
703
+ for (const [category, dir] of Object.entries(this.dirs)) {
704
+ if (category === "root") continue;
705
+ if (!existsSync(dir)) continue;
706
+ for (const f of readdirSync(dir)) {
707
+ if (!f.endsWith(".md")) continue;
708
+ const filepath = join(dir, f);
709
+ const content = readFileSync(filepath, "utf-8");
710
+ const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
711
+ const title = titleMatch?.[1] || f.replace(".md", "");
712
+ const body = content.replace(/^---[\s\S]*?---/, "").trim();
713
+ const firstParagraph = body.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith(">"));
714
+ const summary = firstParagraph?.slice(0, 120) || "";
715
+ articles.push({ path: `${category}/${f}`, title, summary });
716
+ }
717
+ }
718
+ return articles;
719
+ }
720
+ countArticles() {
721
+ let count = 0;
722
+ for (const [category, dir] of Object.entries(this.dirs)) {
723
+ if (category === "root") continue;
724
+ if (!existsSync(dir)) continue;
725
+ count += readdirSync(dir).filter((f) => f.endsWith(".md")).length;
726
+ }
727
+ return count;
728
+ }
729
+ extractWikiLinks(content) {
730
+ const links = [];
731
+ const re = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
732
+ let match;
733
+ while ((match = re.exec(content)) !== null) {
734
+ const target = (match[1] ?? "").trim();
735
+ links.push(target.endsWith(".md") ? target : target + ".md");
736
+ }
737
+ return links;
738
+ }
739
+ extractSummary(content) {
740
+ const body = content.replace(/^---[\s\S]*?---/, "").trim();
741
+ const lines = body.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
742
+ return lines.slice(0, 3).join("\n").slice(0, 500) || "No summary available.";
743
+ }
744
+ slugify(text) {
745
+ return text.toLowerCase().replace(/[^a-z0-9-_\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 60).replace(/-$/, "");
746
+ }
747
+ escapeYaml(s) {
748
+ return s.replace(/"/g, '\\"').replace(/\n/g, " ");
749
+ }
750
+ groupBy(items, keyFn) {
751
+ const map = /* @__PURE__ */ new Map();
752
+ for (const item of items) {
753
+ const key = keyFn(item);
754
+ const list = map.get(key) || [];
755
+ list.push(item);
756
+ map.set(key, list);
757
+ }
758
+ return map;
759
+ }
760
+ generateEmptyIndex() {
761
+ return [
762
+ "---",
763
+ `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
764
+ "total_articles: 0",
765
+ "---",
766
+ "",
767
+ "# Wiki Index",
768
+ "",
769
+ "> No articles yet. Run `stackmemory wiki create` to generate wiki from context.",
770
+ "",
771
+ "## Entities",
772
+ "",
773
+ "_No entity pages yet._",
774
+ "",
775
+ "## Concepts",
776
+ "",
777
+ "_No concept pages yet._",
778
+ "",
779
+ "## Sources",
780
+ "",
781
+ "_No source summaries yet._",
782
+ "",
783
+ "## Synthesis",
784
+ "",
785
+ "_No synthesis articles yet._",
786
+ ""
787
+ ].join("\n");
788
+ }
789
+ }
790
+ export {
791
+ WikiCompiler
792
+ };