negotium 0.2.17 → 0.2.19

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.
Files changed (38) hide show
  1. package/dist/agent-helpers.js +41 -32
  2. package/dist/agent-helpers.js.map +10 -10
  3. package/dist/{chunk-5cpdbbeh.js → chunk-fc58cqry.js} +3 -1
  4. package/dist/{chunk-5cpdbbeh.js.map → chunk-fc58cqry.js.map} +3 -3
  5. package/dist/hosted-agent.js +4 -2
  6. package/dist/hosted-agent.js.map +4 -4
  7. package/dist/main.js +1377 -285
  8. package/dist/main.js.map +14 -13
  9. package/dist/mcp-factories.js +1497 -403
  10. package/dist/mcp-factories.js.map +14 -13
  11. package/dist/prompts.js +8 -8
  12. package/dist/prompts.js.map +3 -3
  13. package/dist/registry.js +3 -3
  14. package/dist/registry.js.map +2 -2
  15. package/dist/rollout.js +1 -1
  16. package/dist/runtime/src/agents/archiver.ts +1 -1
  17. package/dist/runtime/src/agents/idle-archiver.ts +3 -2
  18. package/dist/runtime/src/mcp/canonical-proxy-server.ts +31 -15
  19. package/dist/runtime/src/mcp/wiki-search-index.ts +359 -0
  20. package/dist/runtime/src/mcp/wiki-server.ts +1358 -155
  21. package/dist/runtime/src/platform/mcp-config.ts +2 -1
  22. package/dist/runtime/src/prompts/agents/wiki-archiver.md +73 -48
  23. package/dist/runtime/src/prompts/builders.ts +17 -34
  24. package/dist/runtime/src/runtime/turn-runner.ts +14 -17
  25. package/dist/runtime/src/storage/api-topics.ts +13 -4
  26. package/dist/runtime/src/topics/lifecycle.ts +3 -2
  27. package/dist/runtime/src/types/api.ts +6 -0
  28. package/dist/runtime/src/version.ts +1 -1
  29. package/dist/storage.js +12 -6
  30. package/dist/storage.js.map +3 -3
  31. package/dist/types/packages/core/src/agents/archiver.d.ts +1 -1
  32. package/dist/types/packages/core/src/mcp/wiki-search-index.d.ts +59 -0
  33. package/dist/types/packages/core/src/mcp/wiki-server.d.ts +4 -0
  34. package/dist/types/packages/core/src/prompts/builders.d.ts +2 -4
  35. package/dist/types/packages/core/src/storage/api-topics.d.ts +1 -0
  36. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  37. package/dist/types/packages/core/src/version.d.ts +1 -1
  38. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import {
6
6
  closeSync,
7
+ type Dirent,
7
8
  existsSync,
8
9
  mkdirSync,
9
10
  openSync,
@@ -14,7 +15,7 @@ import {
14
15
  unlinkSync,
15
16
  writeFileSync,
16
17
  } from "node:fs";
17
- import { basename, dirname, join, resolve } from "node:path";
18
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
19
  import { argv, exit } from "node:process";
19
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
21
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -25,6 +26,13 @@ import {
25
26
  type ListToolsResult,
26
27
  type Tool,
27
28
  } from "@modelcontextprotocol/sdk/types.js";
29
+ import {
30
+ type IndexedWikiKind,
31
+ type PreparedWikiSearchDocument,
32
+ resetCorruptWikiSearchIndex,
33
+ WikiSearchIndex,
34
+ type WikiSearchIndexSource,
35
+ } from "#mcp/wiki-search-index";
28
36
  import { SHARED_WIKI_DIR } from "#platform/config";
29
37
  import {
30
38
  wikiBriefStorageKey,
@@ -45,6 +53,7 @@ export interface WikiTopicBrief {
45
53
 
46
54
  export interface WikiMcpHost {
47
55
  wikiRoot: string;
56
+ canReadTopicMemory?(selection: string, userId: string): boolean;
48
57
  getTopicBrief?(topicId: string): WikiTopicBrief | null;
49
58
  resolveTopicBrief?(selection: string, userId: string): WikiTopicBrief | null;
50
59
  setTopicBrief?(
@@ -55,10 +64,13 @@ export interface WikiMcpHost {
55
64
  summaryDate?: string;
56
65
  },
57
66
  ): void;
67
+ adoptTopicMemory?(topicId: string, userId: string, memoryKey: string): boolean;
58
68
  }
59
69
 
60
70
  export interface WikiMcpContext {
61
71
  userId: string;
72
+ /** The room executing the tool, even when topicId points at inherited memory. */
73
+ currentTopicId?: string;
62
74
  topicId?: string;
63
75
  surface?: WikiSurface;
64
76
  }
@@ -91,6 +103,7 @@ export function resolveAccessibleWikiTopicBrief(
91
103
  }
92
104
 
93
105
  interface WikiRuntime extends Required<Pick<WikiMcpContext, "userId" | "surface">> {
106
+ currentTopicId?: string;
94
107
  topicId?: string;
95
108
  host: WikiMcpHost;
96
109
  wikiDir: string;
@@ -111,22 +124,26 @@ function runtime(): WikiRuntime {
111
124
 
112
125
  function parseArgv(): {
113
126
  userId: string;
127
+ currentTopicId?: string;
114
128
  topicId?: string;
115
129
  surface: WikiSurface;
116
130
  } {
117
131
  let userId = "local";
132
+ let currentTopicId: string | undefined;
118
133
  let topicId: string | undefined;
119
134
  let surface: WikiSurface = "all";
120
135
 
121
136
  for (let i = 2; i < argv.length; i++) {
122
137
  const a = argv[i];
123
138
  if (a.startsWith("--user-id=")) userId = a.slice("--user-id=".length);
124
- else if (a.startsWith("--topic-id=")) topicId = a.slice("--topic-id=".length);
139
+ else if (a.startsWith("--current-topic-id=")) {
140
+ currentTopicId = a.slice("--current-topic-id=".length);
141
+ } else if (a.startsWith("--topic-id=")) topicId = a.slice("--topic-id=".length);
125
142
  else if (a === "--surface=wiki") surface = "wiki";
126
143
  else if (a === "--surface=skills") surface = "skills";
127
144
  }
128
145
 
129
- return { userId, topicId, surface };
146
+ return { userId, currentTopicId, topicId, surface };
130
147
  }
131
148
 
132
149
  // --- Helpers ---------------------------------------------------------------
@@ -270,12 +287,521 @@ function slugify(topic: string): string {
270
287
 
271
288
  // --- Tool handlers (file-based) -------------------------------------------
272
289
 
290
+ type WikiQueryKind = "all" | "topic" | "article" | "summary";
291
+
292
+ interface WikiIndexCandidate {
293
+ kind: Exclude<WikiQueryKind, "all">;
294
+ key: string;
295
+ description: string;
296
+ date?: string;
297
+ }
298
+
299
+ type WikiSearchResultKind = Exclude<WikiQueryKind, "all"> | "skill";
300
+
301
+ interface WikiSearchResult {
302
+ kind: WikiSearchResultKind;
303
+ key: string;
304
+ score: number;
305
+ keyScore: number;
306
+ description?: string;
307
+ date?: string;
308
+ path?: string;
309
+ text?: string;
310
+ title?: string;
311
+ }
312
+
313
+ function normalizeSearchText(value: string): string {
314
+ return value
315
+ .toLowerCase()
316
+ .replaceAll(/[^a-z0-9가-힣]+/g, " ")
317
+ .trim()
318
+ .replaceAll(/\s+/g, " ");
319
+ }
320
+
321
+ function compactSearchText(value: string): string {
322
+ return normalizeSearchText(value).replaceAll(" ", "");
323
+ }
324
+
325
+ function trigrams(value: string): Set<string> {
326
+ const compact = compactSearchText(value);
327
+ if (compact.length < 3) return new Set(compact ? [compact] : []);
328
+ const result = new Set<string>();
329
+ for (let i = 0; i <= compact.length - 3; i += 1) result.add(compact.slice(i, i + 3));
330
+ return result;
331
+ }
332
+
333
+ function fuzzySearchScore(query: string, candidate: string, maximum: number): number {
334
+ const compactQuery = compactSearchText(query);
335
+ const compactCandidate = compactSearchText(candidate);
336
+ if (compactQuery.length < 3 || compactCandidate.length < 3) return 0;
337
+ const similarity = trigramSimilarity(compactQuery, compactCandidate);
338
+ const minimumSimilarity = compactQuery.length <= 4 ? 0.6 : 0.45;
339
+ return similarity >= minimumSimilarity ? similarity * maximum : 0;
340
+ }
341
+
342
+ const SEARCH_STOP_WORDS = new Set([
343
+ "a",
344
+ "an",
345
+ "and",
346
+ "are",
347
+ "as",
348
+ "at",
349
+ "be",
350
+ "by",
351
+ "for",
352
+ "from",
353
+ "in",
354
+ "is",
355
+ "it",
356
+ "of",
357
+ "on",
358
+ "or",
359
+ "that",
360
+ "the",
361
+ "to",
362
+ "with",
363
+ ]);
364
+
365
+ const SEARCH_MONTHS = new Map([
366
+ ["january", "01"],
367
+ ["jan", "01"],
368
+ ["1월", "01"],
369
+ ["february", "02"],
370
+ ["feb", "02"],
371
+ ["2월", "02"],
372
+ ["march", "03"],
373
+ ["mar", "03"],
374
+ ["3월", "03"],
375
+ ["april", "04"],
376
+ ["apr", "04"],
377
+ ["4월", "04"],
378
+ ["may", "05"],
379
+ ["5월", "05"],
380
+ ["june", "06"],
381
+ ["jun", "06"],
382
+ ["6월", "06"],
383
+ ["july", "07"],
384
+ ["jul", "07"],
385
+ ["7월", "07"],
386
+ ["august", "08"],
387
+ ["aug", "08"],
388
+ ["8월", "08"],
389
+ ["september", "09"],
390
+ ["sep", "09"],
391
+ ["sept", "09"],
392
+ ["9월", "09"],
393
+ ["october", "10"],
394
+ ["oct", "10"],
395
+ ["10월", "10"],
396
+ ["november", "11"],
397
+ ["nov", "11"],
398
+ ["11월", "11"],
399
+ ["december", "12"],
400
+ ["dec", "12"],
401
+ ["12월", "12"],
402
+ ]);
403
+
404
+ const SEARCH_RECENCY_TERMS = new Set([
405
+ "latest",
406
+ "newest",
407
+ "recent",
408
+ "earliest",
409
+ "oldest",
410
+ "최근",
411
+ "최신",
412
+ "최초",
413
+ "오래된",
414
+ ]);
415
+
416
+ function meaningfulSearchTokens(value: string): string[] {
417
+ return normalizeSearchText(value)
418
+ .split(" ")
419
+ .filter((token) => (token.length >= 2 || /^\d+$/.test(token)) && !SEARCH_STOP_WORDS.has(token));
420
+ }
421
+
422
+ function canonicalSearchToken(token: string): string {
423
+ return token.length > 4 && token.endsWith("s") ? token.slice(0, -1) : token;
424
+ }
425
+
426
+ function canonicalSearchTokens(value: string): string[] {
427
+ return meaningfulSearchTokens(value).map(canonicalSearchToken);
428
+ }
429
+
430
+ function countToken(tokens: string[], token: string): number {
431
+ let count = 0;
432
+ for (const candidate of tokens) if (candidate === token) count += 1;
433
+ return count;
434
+ }
435
+
436
+ function countPhrase(text: string, phrase: string): number {
437
+ if (!phrase) return 0;
438
+ let count = 0;
439
+ let offset = 0;
440
+ let match = text.indexOf(phrase, offset);
441
+ while (match >= 0) {
442
+ count += 1;
443
+ offset = match + phrase.length;
444
+ match = text.indexOf(phrase, offset);
445
+ }
446
+ return count;
447
+ }
448
+
449
+ function summaryTopicIdentity(key: string): string {
450
+ return normalizeSearchText(key.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
451
+ }
452
+
453
+ function scoreSummaryTime(query: string, key: string, title: string): number | null {
454
+ const date = key.match(/^(\d{4})-(\d{2})-(\d{2})/);
455
+ if (!date) return 0;
456
+ const tokens = meaningfulSearchTokens(query);
457
+ const documentDate = `${date[1]}-${date[2]}-${date[3]}`;
458
+ const requestedDateMatch = query.match(/\b(\d{4}) (\d{2}) (\d{2})\b/);
459
+ const requestedDate = requestedDateMatch
460
+ ? `${requestedDateMatch[1]}-${requestedDateMatch[2]}-${requestedDateMatch[3]}`
461
+ : undefined;
462
+ const beforeDate = /\bbefore\b/.test(query);
463
+ const afterDate = /\bafter\b/.test(query);
464
+ if (requestedDate && beforeDate && documentDate >= requestedDate) return null;
465
+ if (requestedDate && afterDate && documentDate <= requestedDate) return null;
466
+ const queryYear = tokens.find((token) => /^\d{4}(?:년)?$/.test(token))?.replace(/년$/, "");
467
+ const queryMonth = tokens.map((token) => SEARCH_MONTHS.get(token)).find(Boolean);
468
+ const hasRecencyTerm = tokens.some((token) => SEARCH_RECENCY_TERMS.has(token));
469
+ const yearMatches = !queryYear || queryYear === date[1];
470
+ const monthMatches = !queryMonth || queryMonth === date[2];
471
+ if ((!queryYear && !queryMonth && !hasRecencyTerm) || !yearMatches || !monthMatches) return 0;
472
+
473
+ const normalizedMetadata = `${normalizeSearchText(key)} ${normalizeSearchText(title)}`;
474
+ const contentTokens = tokens.filter(
475
+ (token) =>
476
+ !/^\d{4}(?:년)?$/.test(token) &&
477
+ !SEARCH_MONTHS.has(token) &&
478
+ !SEARCH_RECENCY_TERMS.has(token),
479
+ );
480
+ const contentMatches = contentTokens.filter((token) => normalizedMetadata.includes(token)).length;
481
+ const relationScore = requestedDate && (beforeDate || afterDate) ? 60 : 0;
482
+ const exactDateScore = requestedDate === documentDate ? 90 : 0;
483
+ return 30 + relationScore + exactDateScore + contentMatches * 15;
484
+ }
485
+
486
+ function trigramSimilarity(left: string, right: string): number {
487
+ const a = trigrams(left);
488
+ const b = trigrams(right);
489
+ if (a.size === 0 || b.size === 0) return 0;
490
+ let overlap = 0;
491
+ for (const gram of a) if (b.has(gram)) overlap += 1;
492
+ return (2 * overlap) / (a.size + b.size);
493
+ }
494
+
495
+ const WIKI_SEARCH_INDEX_FILENAME = ".wiki-search-index.sqlite";
496
+
497
+ /**
498
+ * Open the derived body-search index.
499
+ *
500
+ * The index is a rebuildable cache that is written only by the wiki write path,
501
+ * so a missing or unreadable file is a normal cold state rather than an error:
502
+ * catalog retrieval still works, and `wiki_reindex` refills the bodies.
503
+ */
504
+ function openWikiSearchIndex(): WikiSearchIndex | undefined {
505
+ const path = resolve(runtime().wikiDir, WIKI_SEARCH_INDEX_FILENAME);
506
+ try {
507
+ return new WikiSearchIndex(path);
508
+ } catch (error) {
509
+ if (!/malformed|not a database|schema/i.test(String(error))) return undefined;
510
+ try {
511
+ resetCorruptWikiSearchIndex(path);
512
+ return new WikiSearchIndex(path);
513
+ } catch {
514
+ return undefined;
515
+ }
516
+ }
517
+ }
518
+
519
+ /** Derive the indexed fields for one document body. */
520
+ function prepareWikiSearchDocument(
521
+ kind: IndexedWikiKind,
522
+ key: string,
523
+ text: string,
524
+ ): PreparedWikiSearchDocument {
525
+ const titleLine = text.split("\n").find((line) => /^#+\s*/.test(line)) ?? "";
526
+ const title = titleLine ? titleLine.replace(/^#+\s*/, "") : basename(key);
527
+ return {
528
+ title,
529
+ text,
530
+ tokens: canonicalSearchTokens(`${key} ${title} ${text}`),
531
+ ...(kind === "summary" && /^\d{4}-\d{2}-\d{2}/.test(key)
532
+ ? { date: key.slice(0, 10), family: summaryTopicIdentity(key) }
533
+ : {}),
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Mirror a freshly written document into the derived body index.
539
+ *
540
+ * Returns false when the cache could not be updated. The document and its
541
+ * catalog row are already durable at this point, so a cache miss degrades
542
+ * retrieval to catalog-only rather than failing the write.
543
+ */
544
+ function indexWikiSearchDocument(kind: IndexedWikiKind, key: string, text: string): boolean {
545
+ const root = kind === "article" ? runtime().articlesDir : runtime().summariesDir;
546
+ const path = resolve(root, `${key}.md`);
547
+ const index = openWikiSearchIndex();
548
+ if (!index) return false;
549
+ try {
550
+ const stat = statSync(path);
551
+ index.upsert(
552
+ { kind, key, path, mtimeMs: stat.mtimeMs, size: stat.size },
553
+ prepareWikiSearchDocument(kind, key, text),
554
+ );
555
+ return true;
556
+ } catch {
557
+ return false;
558
+ } finally {
559
+ index.close();
560
+ }
561
+ }
562
+
563
+ type WikiIndexKind = "article" | "summary" | "topic" | "skill";
564
+
565
+ const WIKI_INDEX_FILENAMES: Record<WikiIndexKind, string> = {
566
+ article: "article-index.md",
567
+ summary: "summary-index.md",
568
+ topic: "topic-index.md",
569
+ skill: "skill-index.md",
570
+ };
571
+
572
+ const WIKI_INDEX_NAMESPACES: Record<WikiIndexKind, string> = {
573
+ article: "articles",
574
+ summary: "summaries",
575
+ topic: "topic",
576
+ skill: "skills",
577
+ };
578
+
579
+ /** Resolve the human-readable catalog that owns a given entry kind. */
580
+ function wikiIndexPath(kind: WikiIndexKind): string {
581
+ return resolve(runtime().wikiDir, WIKI_INDEX_FILENAMES[kind]);
582
+ }
583
+
584
+ const MAX_INDEX_DESCRIPTION_LENGTH = 400;
585
+
586
+ /**
587
+ * Reduce a caller-supplied description to a single catalog line.
588
+ *
589
+ * Catalog rows are line-oriented, so an embedded newline would split one entry
590
+ * into an unparsable fragment. Rejecting empty text here is what keeps index
591
+ * quality a schema guarantee rather than a prompt convention.
592
+ */
593
+ function normalizeIndexDescription(raw: string): { description: string } | { error: string } {
594
+ const description = raw.replaceAll(/\s+/g, " ").trim();
595
+ if (!description) return { error: "A non-empty one-line description is required." };
596
+ if (description.length > MAX_INDEX_DESCRIPTION_LENGTH) {
597
+ return {
598
+ error: `Description must be at most ${MAX_INDEX_DESCRIPTION_LENGTH} characters (got ${description.length}).`,
599
+ };
600
+ }
601
+ return { description };
602
+ }
603
+
604
+ /** Reject slugs that would escape their content directory or break a wikilink. */
605
+ function normalizeWikiSlug(raw: string): { slug: string } | { error: string } {
606
+ const slug = raw.trim().replace(/\.md$/i, "").replace(/^\/+/, "");
607
+ if (!slug) return { error: "A non-empty slug is required." };
608
+ if (/[[\]|\\]/.test(slug)) return { error: "Slug must not contain [ ] | or backslashes." };
609
+ if (slug.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
610
+ return { error: "Slug must not contain empty or relative path segments." };
611
+ }
612
+ return { slug };
613
+ }
614
+
615
+ function parseWikiIndex(path: string): WikiIndexCandidate[] {
616
+ let text: string;
617
+ try {
618
+ text = readFileSync(path, "utf-8");
619
+ } catch {
620
+ return [];
621
+ }
622
+ const candidates: WikiIndexCandidate[] = [];
623
+ const entryPattern = /^\s*-\s*\[\[(topic|articles|summaries)\/([^\]]+)\]\]\s*(.*)$/;
624
+ for (const line of text.split("\n")) {
625
+ const match = line.match(entryPattern);
626
+ if (!match) continue;
627
+ const namespace = match[1]!;
628
+ const key = match[2]!.replace(/\.md$/i, "");
629
+ const tail = match[3]!.trim();
630
+ const dateMatch = tail.match(/\((\d{4}-\d{2}-\d{2})(?:[^)]*)\)\s*$/);
631
+ const description = dateMatch ? tail.slice(0, dateMatch.index).trim() : tail;
632
+ candidates.push({
633
+ kind: namespace === "topic" ? "topic" : namespace === "articles" ? "article" : "summary",
634
+ key,
635
+ description,
636
+ ...(dateMatch ? { date: dateMatch[1] } : {}),
637
+ });
638
+ }
639
+ return candidates;
640
+ }
641
+
642
+ function indexCandidateExists(candidate: WikiIndexCandidate): boolean {
643
+ // Topic indexes may point at canonical DB-backed memories rather than local files.
644
+ if (candidate.kind === "topic") return true;
645
+ const root = candidate.kind === "article" ? runtime().articlesDir : runtime().summariesDir;
646
+ const filePath = resolve(root, `${candidate.key}.md`);
647
+ const relativePath = relative(root, filePath);
648
+ if (relativePath.startsWith("..") || relativePath.startsWith("/")) return false;
649
+ try {
650
+ return statSync(filePath).isFile();
651
+ } catch {
652
+ return false;
653
+ }
654
+ }
655
+
656
+ function scoreIndexKey(query: string, key: string): number {
657
+ const normalizedQuery = normalizeSearchText(query);
658
+ const compactQuery = compactSearchText(normalizedQuery);
659
+ const normalizedKey = normalizeSearchText(key);
660
+ const compactKey = compactSearchText(normalizedKey);
661
+ const tokens = meaningfulSearchTokens(normalizedQuery);
662
+ let score = 0;
663
+ if (normalizedKey === normalizedQuery) score += 120;
664
+ else if (compactKey === compactQuery) score += 110;
665
+ else {
666
+ if (
667
+ compactQuery.length >= 2 &&
668
+ (compactKey.startsWith(compactQuery) || compactQuery.startsWith(compactKey))
669
+ ) {
670
+ score += 65;
671
+ }
672
+ if (tokens.length > 0 && compactQuery.length >= 3 && compactKey.includes(compactQuery)) {
673
+ score += 45;
674
+ }
675
+ score += fuzzySearchScore(compactQuery, compactKey, 55);
676
+ }
677
+ for (const token of tokens) {
678
+ if (normalizedKey.includes(token)) score += 10;
679
+ }
680
+ return score;
681
+ }
682
+
683
+ function scoreIndexCandidate(query: string, candidate: WikiIndexCandidate): number {
684
+ const normalizedQuery = normalizeSearchText(query);
685
+ if (!normalizedQuery) return 0;
686
+ const normalizedDescription = normalizeSearchText(candidate.description);
687
+ const tokens = canonicalSearchTokens(normalizedQuery);
688
+ const descriptionTokens = new Set(canonicalSearchTokens(normalizedDescription));
689
+ let score = scoreIndexKey(normalizedQuery, candidate.key);
690
+ if (tokens.length > 0 && normalizedDescription.includes(normalizedQuery)) score += 30;
691
+ let descriptionMatches = 0;
692
+ for (const token of tokens) {
693
+ if (descriptionTokens.has(token)) {
694
+ score += 5;
695
+ descriptionMatches += 1;
696
+ }
697
+ }
698
+ if (tokens.length > 1 && descriptionMatches === tokens.length) score += 25;
699
+ if (candidate.kind === "topic") {
700
+ const requestsCurrent = /\b(?:current|active|ongoing|open)\b|현재|진행 중/.test(
701
+ normalizedQuery,
702
+ );
703
+ const archivedCandidate = /\b(?:archive|archived|historical|closed)\b|보관|종료/.test(
704
+ `${normalizeSearchText(candidate.key)} ${normalizedDescription}`,
705
+ );
706
+ if (requestsCurrent && archivedCandidate) score -= 40;
707
+ }
708
+ return score;
709
+ }
710
+
273
711
  function wikiQuery(args: Record<string, unknown>): CallToolResult {
274
- const question = safeExt(args, ["question", "query", "q", "text"], "");
275
- const query = question.toLowerCase();
712
+ const question = String(safeExt(args, ["question", "query", "q", "text"], "")).trim();
713
+ const requestedKind = String(safeExt(args, ["kind", "type"], "all"));
714
+ const kind: WikiQueryKind =
715
+ requestedKind === "topic" || requestedKind === "article" || requestedKind === "summary"
716
+ ? requestedKind
717
+ : "all";
718
+ const requestedLimit = Number(safeExt(args, ["limit", "maxResults"], 8));
719
+ const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 8, 1), 20);
720
+ if (!question) {
721
+ return {
722
+ content: [{ type: "text", text: "A non-empty wiki query is required." }],
723
+ isError: true,
724
+ };
725
+ }
726
+ const normalizedQuery = normalizeSearchText(question);
727
+ if (!normalizedQuery) {
728
+ return { content: [{ type: "text", text: "No matching wiki articles found." }] };
729
+ }
730
+
731
+ const canReadTopicMemory = runtime().host.canReadTopicMemory;
732
+ const temporalDirection = /\b(?:latest|newest|recent)\b|최근|최신/.test(normalizedQuery)
733
+ ? "newest"
734
+ : /\b(?:earliest|oldest)\b|가장 오래된|최초/.test(normalizedQuery)
735
+ ? "oldest"
736
+ : undefined;
737
+
738
+ const visibleIndexCandidates = [
739
+ ...parseWikiIndex(resolve(runtime().wikiDir, "topic-index.md")),
740
+ ...parseWikiIndex(resolve(runtime().wikiDir, "article-index.md")),
741
+ ]
742
+ .filter((candidate) => kind === "all" || candidate.kind === kind)
743
+ .filter(
744
+ (candidate) =>
745
+ candidate.kind !== "topic" ||
746
+ !canReadTopicMemory ||
747
+ canReadTopicMemory(candidate.key, runtime().userId),
748
+ );
749
+ const hasStaleExactIndexMatch = visibleIndexCandidates.some(
750
+ (candidate) =>
751
+ !indexCandidateExists(candidate) && scoreIndexKey(question, candidate.key) >= 100,
752
+ );
753
+ const indexCandidates = visibleIndexCandidates
754
+ .filter(indexCandidateExists)
755
+ .map((candidate) => ({
756
+ candidate,
757
+ keyScore: scoreIndexKey(question, candidate.key),
758
+ score: scoreIndexCandidate(question, candidate),
759
+ }))
760
+ .filter(({ keyScore, score }) => keyScore >= 25 || score >= 40)
761
+ .sort(
762
+ (left, right) =>
763
+ right.score - left.score || left.candidate.key.localeCompare(right.candidate.key),
764
+ );
765
+
766
+ const strongestIndex = indexCandidates[0];
767
+ if (
768
+ kind === "topic" &&
769
+ strongestIndex &&
770
+ (strongestIndex.keyScore >= 25 || strongestIndex.score >= 40)
771
+ ) {
772
+ const runnerUp = indexCandidates[1];
773
+ const ambiguous =
774
+ strongestIndex.keyScore < 100 &&
775
+ runnerUp !== undefined &&
776
+ runnerUp.score >= strongestIndex.score * 0.9;
777
+ if (ambiguous) {
778
+ const lines = ["Found 2 ambiguous wiki candidates:", ""];
779
+ for (const { candidate, score } of [strongestIndex, runnerUp]) {
780
+ lines.push(
781
+ `- kind: ${candidate.kind}`,
782
+ ` key: ${candidate.key}`,
783
+ ` score: ${score.toFixed(2)}`,
784
+ ` description: ${candidate.description || "(none)"}`,
785
+ ...(candidate.date ? [` date: ${candidate.date}`] : []),
786
+ );
787
+ }
788
+ return { content: [{ type: "text", text: lines.join("\n") }] };
789
+ }
790
+ const lines = ["Found 1 wiki candidate:", ""];
791
+ for (const { candidate, score } of [strongestIndex]) {
792
+ lines.push(
793
+ `- kind: ${candidate.kind}`,
794
+ ` key: ${candidate.key}`,
795
+ ` score: ${score.toFixed(2)}`,
796
+ ` description: ${candidate.description || "(none)"}`,
797
+ ...(candidate.date ? [` date: ${candidate.date}`] : []),
798
+ );
799
+ }
800
+ return { content: [{ type: "text", text: lines.join("\n") }] };
801
+ }
276
802
 
277
803
  const results: string[] = [];
278
- const scored: { score: number; path: string; text: string }[] = [];
804
+ const scored: WikiSearchResult[] = [];
279
805
 
280
806
  function scan(dir: string, label: string): void {
281
807
  ensureDir(dir);
@@ -291,10 +817,15 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
291
817
  const fp = join(sub, f.name);
292
818
  try {
293
819
  const text = readFileSync(fp, "utf-8");
820
+ const key = `${entry.name}/${f.name.replace(/\.md$/i, "")}`;
294
821
  scored.push({
295
- score: scoreMatch(text),
822
+ kind: label === "articles" ? "article" : "skill",
823
+ key,
824
+ keyScore: scoreIndexKey(question, key),
825
+ score: scoreMatch(text, key, label === "articles" ? "article" : "skill"),
296
826
  path: `${label}/${entry.name}/${f.name}`,
297
827
  text,
828
+ title: titleFromDocument(text, key),
298
829
  });
299
830
  } catch {
300
831
  /* skip unreadable */
@@ -305,13 +836,36 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
305
836
  }
306
837
  }
307
838
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
839
+ if (
840
+ label === "topic" &&
841
+ canReadTopicMemory &&
842
+ !canReadTopicMemory(entry.name.replace(/\.md$/i, ""), runtime().userId)
843
+ ) {
844
+ continue;
845
+ }
308
846
  const fp = join(dir, entry.name);
309
847
  try {
310
848
  const text = readFileSync(fp, "utf-8");
849
+ const key = entry.name.replace(/\.md$/i, "");
850
+ const resultKind =
851
+ label === "articles"
852
+ ? "article"
853
+ : label === "topic"
854
+ ? "topic"
855
+ : label === "summaries"
856
+ ? "summary"
857
+ : "skill";
311
858
  scored.push({
312
- score: scoreMatch(text),
859
+ kind: resultKind,
860
+ key,
861
+ keyScore: scoreIndexKey(question, key),
862
+ score: scoreMatch(text, key, resultKind),
313
863
  path: `${label}/${entry.name}`,
314
864
  text,
865
+ title: titleFromDocument(text, key),
866
+ ...(label === "summaries" && /^\d{4}-\d{2}-\d{2}/.test(key)
867
+ ? { date: key.slice(0, 10) }
868
+ : {}),
315
869
  });
316
870
  } catch {
317
871
  /* skip unreadable */
@@ -323,48 +877,275 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
323
877
  }
324
878
  }
325
879
 
326
- function scoreMatch(text: string): number {
327
- const lower = text.toLowerCase();
880
+ function titleFromDocument(text: string, key: string): string {
881
+ const titleLine = text.split("\n").find((line) => /^#+\s*/.test(line));
882
+ return titleLine ? titleLine.replace(/^#+\s*/, "") : basename(key);
883
+ }
884
+
885
+ function scoreMatch(text: string, key: string, resultKind: WikiSearchResultKind): number {
886
+ const titleLine = text.split("\n").find((line) => /^#+\s*/.test(line)) ?? "";
887
+ const title = titleLine.replace(/^#+\s*/, "");
888
+ const normalizedKey = normalizeSearchText(key);
889
+ const normalizedTitle = normalizeSearchText(title);
890
+ const normalizedBody = normalizeSearchText(text.replace(titleLine, ""));
891
+ const compactQuery = compactSearchText(normalizedQuery);
892
+ const compactKey = compactSearchText(normalizedKey);
893
+ const compactTitle = compactSearchText(normalizedTitle);
894
+ const tokens = canonicalSearchTokens(normalizedQuery);
895
+ const keyTokens = new Set(canonicalSearchTokens(normalizedKey));
896
+ const titleTokens = new Set(canonicalSearchTokens(normalizedTitle));
897
+ const bodyTokens = canonicalSearchTokens(normalizedBody);
328
898
  let score = 0;
329
- const words = query.split(/\s+/).filter(Boolean);
330
- for (const w of words) {
331
- if (lower.includes(w)) {
332
- score += w.length >= 3 ? 3 : 1;
333
- // Bonus for title match (first line starts with #)
334
- const firstLine = lower.split("\n")[0];
335
- if (firstLine.startsWith("#") && firstLine.includes(w)) score += 5;
899
+ let strongMetadataEvidence = false;
900
+
901
+ if (normalizedKey === normalizedQuery) {
902
+ score += 120;
903
+ strongMetadataEvidence = true;
904
+ } else if (compactKey === compactQuery) {
905
+ score += 110;
906
+ strongMetadataEvidence = true;
907
+ } else if (
908
+ compactQuery.length >= 2 &&
909
+ (compactKey.startsWith(compactQuery) || compactQuery.startsWith(compactKey))
910
+ ) {
911
+ score += 65;
912
+ strongMetadataEvidence = true;
913
+ }
914
+ if (tokens.length > 0 && compactQuery.length >= 3 && compactKey.includes(compactQuery)) {
915
+ score += 45;
916
+ strongMetadataEvidence = true;
917
+ }
918
+ const fuzzyKeyScore = fuzzySearchScore(compactQuery, compactKey, 55);
919
+ score += fuzzyKeyScore;
920
+ if (fuzzyKeyScore > 0) strongMetadataEvidence = true;
921
+
922
+ if (normalizedTitle === normalizedQuery) {
923
+ score += 115;
924
+ strongMetadataEvidence = true;
925
+ } else if (compactTitle === compactQuery) {
926
+ score += 105;
927
+ strongMetadataEvidence = true;
928
+ } else if (compactQuery.length >= 2 && compactTitle.startsWith(compactQuery)) {
929
+ score += 60;
930
+ strongMetadataEvidence = true;
931
+ }
932
+ const fuzzyTitleScore = fuzzySearchScore(compactQuery, compactTitle, 50);
933
+ score += fuzzyTitleScore;
934
+ if (fuzzyTitleScore > 0) strongMetadataEvidence = true;
935
+ if (resultKind === "summary") {
936
+ const temporalScore = scoreSummaryTime(normalizedQuery, key, title);
937
+ if (temporalScore === null) return 0;
938
+ score += temporalScore;
939
+ if (temporalScore > 0) strongMetadataEvidence = true;
940
+ }
941
+
942
+ const isMultiToken = tokens.length > 1;
943
+ if (tokens.length > 0 && normalizedTitle.includes(normalizedQuery)) {
944
+ score += 35;
945
+ strongMetadataEvidence = true;
946
+ }
947
+ const phraseMatches = isMultiToken ? countPhrase(normalizedBody, normalizedQuery) : 0;
948
+ if (phraseMatches > 0) score += phraseMatches * 12;
949
+ else if (tokens.length === 1 && tokens[0]!.length >= 5 && normalizedBody.includes(tokens[0]!)) {
950
+ score += 16;
951
+ }
952
+
953
+ let bodyMatches = 0;
954
+ let minimumBodyFrequency = Number.POSITIVE_INFINITY;
955
+ for (const token of tokens) {
956
+ if (keyTokens.has(token)) score += 10;
957
+ if (titleTokens.has(token)) score += 12;
958
+ const bodyFrequency = countToken(bodyTokens, token);
959
+ if (bodyFrequency > 0) {
960
+ score += Math.min(bodyFrequency, 3) * 4;
961
+ bodyMatches += 1;
962
+ minimumBodyFrequency = Math.min(minimumBodyFrequency, bodyFrequency);
336
963
  }
337
964
  }
965
+ if (isMultiToken && bodyMatches === tokens.length) {
966
+ score += 8 * Math.min(minimumBodyFrequency, 3);
967
+ }
968
+ const minimumBodyTokens = hasStaleExactIndexMatch ? 3 : 2;
969
+ const partialArticleEvidence =
970
+ resultKind === "article" && bodyMatches >= 3 && bodyMatches / tokens.length >= 0.6;
971
+ const strongBodyEvidence =
972
+ tokens.length >= minimumBodyTokens &&
973
+ ((bodyMatches === tokens.length && (phraseMatches > 0 || minimumBodyFrequency >= 1)) ||
974
+ partialArticleEvidence);
975
+ if (!strongMetadataEvidence && !strongBodyEvidence) return 0;
338
976
  return score;
339
977
  }
340
978
 
341
- scan(runtime().articlesDir, "articles");
342
- // Skills are node-local runtime knowledge, not canonical workspace memory.
343
- // Keep the legacy all-in-one server compatible while ensuring the explicit
344
- // wiki surface cannot read a node's skill library.
345
- if (runtime().surface === "all") scan(runtime().skillsDir, "skills");
346
- scan(runtime().topicsDir, "topic");
347
- scan(runtime().summariesDir, "summaries");
979
+ // Article and summary bodies come from the derived index, which the write path
980
+ // keeps current. Reading every file here is what made retrieval cost grow with
981
+ // the size of the wiki.
982
+ const bodyKinds: IndexedWikiKind[] = [
983
+ ...(kind === "all" || kind === "article" ? (["article"] as const) : []),
984
+ ...(kind === "all" || kind === "summary" ? (["summary"] as const) : []),
985
+ ];
986
+ if (bodyKinds.length > 0) {
987
+ const searchIndex = openWikiSearchIndex();
988
+ if (searchIndex) {
989
+ try {
990
+ const terms = canonicalSearchTokens(normalizedQuery);
991
+ const ids = searchIndex.matchingDocumentIds(bodyKinds, terms);
992
+ // "latest summary" carries no term any document contains, so a purely
993
+ // temporal question is answered from the indexed dates instead.
994
+ if (temporalDirection && bodyKinds.includes("summary")) {
995
+ for (const item of searchIndex.summaryMetadataByDate({
996
+ direction: temporalDirection === "newest" ? "latest" : "oldest",
997
+ limit,
998
+ })) {
999
+ ids.add(item.id);
1000
+ }
1001
+ }
1002
+ for (const document of searchIndex.documents(ids)) {
1003
+ scored.push({
1004
+ kind: document.kind,
1005
+ key: document.key,
1006
+ keyScore: scoreIndexKey(question, document.key),
1007
+ score: scoreMatch(document.text, document.key, document.kind),
1008
+ path: `${document.kind === "article" ? "articles" : "summaries"}/${document.key}.md`,
1009
+ text: document.text,
1010
+ title: document.title,
1011
+ ...(document.date ? { date: document.date } : {}),
1012
+ });
1013
+ }
1014
+ } catch {
1015
+ // A damaged cache must not make catalog retrieval unavailable.
1016
+ } finally {
1017
+ searchIndex.close();
1018
+ }
1019
+ }
1020
+ }
1021
+ // Topic briefs and skills are small, node-local sets that no derived index
1022
+ // covers, so they are still read directly. Skills stay out of the explicit
1023
+ // wiki surface: they are node runtime knowledge, not canonical memory.
1024
+ if (kind === "all" && runtime().surface === "all") scan(runtime().skillsDir, "skills");
1025
+ if (kind === "all" || kind === "topic") scan(runtime().topicsDir, "topic");
1026
+
1027
+ const merged = new Map<string, WikiSearchResult>();
1028
+ for (const { candidate, keyScore, score } of indexCandidates) {
1029
+ merged.set(`${candidate.kind}:${candidate.key}`, {
1030
+ kind: candidate.kind,
1031
+ key: candidate.key,
1032
+ keyScore,
1033
+ score,
1034
+ description: candidate.description,
1035
+ date: candidate.date,
1036
+ });
1037
+ }
1038
+ const documentThreshold = kind === "topic" ? 24 : 16;
1039
+ for (const document of scored) {
1040
+ if (document.score < documentThreshold) continue;
1041
+ const id = `${document.kind}:${document.key}`;
1042
+ const indexed = merged.get(id);
1043
+ merged.set(
1044
+ id,
1045
+ indexed
1046
+ ? { ...indexed, ...document, score: Math.max(indexed.score, document.score) }
1047
+ : document,
1048
+ );
1049
+ }
348
1050
 
349
- scored.sort((a, b) => b.score - a.score);
350
- const top = scored.slice(0, 8);
1051
+ let ranked = [...merged.values()].sort(
1052
+ (left, right) =>
1053
+ right.score - left.score ||
1054
+ (left.kind === "summary" && right.kind === "summary"
1055
+ ? temporalDirection === "oldest"
1056
+ ? (left.date ?? "").localeCompare(right.date ?? "")
1057
+ : (right.date ?? "").localeCompare(left.date ?? "")
1058
+ : 0) ||
1059
+ left.key.localeCompare(right.key),
1060
+ );
351
1061
 
352
- if (top.length === 0) {
1062
+ if (kind === "summary") {
1063
+ const sameTopic = ranked.filter((result) => {
1064
+ const identity = summaryTopicIdentity(result.key);
1065
+ return identity.length >= 3 && normalizedQuery.includes(identity);
1066
+ });
1067
+ if (sameTopic.length > 0) ranked = sameTopic;
1068
+
1069
+ const requestedDateMatch = normalizedQuery.match(/\b(\d{4}) (\d{2}) (\d{2})\b/);
1070
+ const requestedDate = requestedDateMatch
1071
+ ? `${requestedDateMatch[1]}-${requestedDateMatch[2]}-${requestedDateMatch[3]}`
1072
+ : undefined;
1073
+ const beforeDate = /\bbefore\b/.test(normalizedQuery);
1074
+ const afterDate = /\bafter\b/.test(normalizedQuery);
1075
+ if (requestedDate && beforeDate) {
1076
+ ranked = ranked.filter((result) => !result.date || result.date < requestedDate);
1077
+ } else if (requestedDate && afterDate) {
1078
+ ranked = ranked.filter((result) => !result.date || result.date > requestedDate);
1079
+ }
1080
+ ranked.sort((left, right) => {
1081
+ if (beforeDate) {
1082
+ return (right.date ?? "").localeCompare(left.date ?? "") || right.score - left.score;
1083
+ }
1084
+ if (afterDate) {
1085
+ return (left.date ?? "").localeCompare(right.date ?? "") || right.score - left.score;
1086
+ }
1087
+ if (requestedDate) {
1088
+ const dateDifference =
1089
+ Number(right.date === requestedDate) - Number(left.date === requestedDate);
1090
+ if (dateDifference !== 0) return dateDifference;
1091
+ }
1092
+ if (temporalDirection) {
1093
+ const dateDifference =
1094
+ temporalDirection === "oldest"
1095
+ ? (left.date ?? "").localeCompare(right.date ?? "")
1096
+ : (right.date ?? "").localeCompare(left.date ?? "");
1097
+ if (dateDifference !== 0) return dateDifference;
1098
+ }
1099
+ return (
1100
+ right.score - left.score ||
1101
+ (right.date ?? "").localeCompare(left.date ?? "") ||
1102
+ left.key.localeCompare(right.key)
1103
+ );
1104
+ });
1105
+ }
1106
+
1107
+ if (kind === "topic" && ranked[0]) {
1108
+ const runnerUp = ranked[1];
1109
+ const ambiguous =
1110
+ ranked[0].keyScore < 100 && runnerUp !== undefined && runnerUp.score >= ranked[0].score * 0.9;
1111
+ if (ambiguous) {
1112
+ ranked = ranked.slice(0, 2);
1113
+ } else {
1114
+ ranked = ranked.slice(0, 1);
1115
+ }
1116
+ } else {
1117
+ ranked = ranked.slice(0, limit);
1118
+ }
1119
+
1120
+ if (ranked.length === 0) {
353
1121
  results.push("No matching wiki articles found.");
354
1122
  } else {
355
- results.push(`Found ${top.length} matching result(s):\n`);
356
- for (const { path, text } of top) {
357
- // Extract title from first heading
358
- const titleLine = text.split("\n").find((l) => l.startsWith("#"));
359
- const title = titleLine ? titleLine.replace(/^#+\s*/, "") : basename(path, ".md");
360
- // Truncate body to 400 chars
361
- const body = text
362
- .replace(/^---[\s\S]*?---\n?/, "")
363
- .replace(/^#.*$/m, "")
364
- .trim()
365
- .slice(0, 400);
1123
+ results.push(`Found ${ranked.length} matching result(s):\n`);
1124
+ for (const result of ranked) {
1125
+ if (result.kind === "skill") {
1126
+ const body = (result.text ?? "").trim().slice(0, 400);
1127
+ results.push(
1128
+ `## ${result.title ?? result.key}\nPath: ${result.path}\n\n${body}${(result.text?.length ?? 0) > 400 ? "\n...(truncated)" : ""}\n`,
1129
+ );
1130
+ continue;
1131
+ }
366
1132
  results.push(
367
- `## ${title}\nPath: ${path}\n\n${body}${text.length > 400 ? "\n...(truncated)" : ""}\n`,
1133
+ `- kind: ${result.kind}`,
1134
+ ` key: ${result.key}`,
1135
+ ` score: ${result.score.toFixed(2)}`,
1136
+ ...(result.title ? [` title: ${result.title}`] : []),
1137
+ ` description: ${result.description || "(none)"}`,
1138
+ ...(result.date ? [` date: ${result.date}`] : []),
1139
+ ...(result.path ? [` path: ${result.path}`, ` Path: ${result.path}`] : []),
1140
+ ...(result.text
1141
+ ? [
1142
+ ` excerpt: ${result.text
1143
+ .replace(/^---[\s\S]*?---\n?/, "")
1144
+ .replace(/^#.*$/m, "")
1145
+ .trim()
1146
+ .slice(0, 400)}`,
1147
+ ]
1148
+ : []),
368
1149
  );
369
1150
  }
370
1151
  }
@@ -390,40 +1171,109 @@ function wikiTopicBrief(args: Record<string, unknown>): CallToolResult {
390
1171
  }
391
1172
 
392
1173
  const { getTopicBrief, resolveTopicBrief } = runtime().host;
393
- if (runtime().topicId && (getTopicBrief || resolveTopicBrief)) {
1174
+ const hasAuthorizedBridge = Boolean(runtime().topicId && (getTopicBrief || resolveTopicBrief));
1175
+ if (hasAuthorizedBridge) {
394
1176
  try {
395
1177
  const brief = resolveTopicBrief
396
1178
  ? resolveTopicBrief(rawTopic, runtime().userId)
397
1179
  : getTopicBrief!(rawTopic);
398
- if (!brief) {
399
- return {
400
- content: [{ type: "text", text: `No brief found for topic: ${rawTopic}` }],
401
- };
402
- }
403
- const lines = [
404
- `# Topic Brief: ${topicNameFrom(rawTopic)}`,
405
- "",
406
- brief.briefMd || "(empty brief)",
407
- ];
408
- if (brief.latestSummaryMd) {
409
- lines.push("", "## Latest Summary", "", brief.latestSummaryMd);
410
- }
411
- if (brief.summaryDate) {
412
- lines.push("", `Summary date: ${brief.summaryDate}`);
1180
+ if (brief) {
1181
+ const lines = [
1182
+ `# Topic Brief: ${topicNameFrom(rawTopic)}`,
1183
+ "",
1184
+ brief.briefMd || "(empty brief)",
1185
+ ];
1186
+ if (brief.latestSummaryMd) {
1187
+ lines.push("", "## Latest Summary", "", brief.latestSummaryMd);
1188
+ }
1189
+ if (brief.summaryDate) {
1190
+ lines.push("", `Summary date: ${brief.summaryDate}`);
1191
+ }
1192
+ return { content: [{ type: "text", text: lines.join("\n") }] };
413
1193
  }
414
- return { content: [{ type: "text", text: lines.join("\n") }] };
415
1194
  } catch {
416
- return {
417
- content: [{ type: "text", text: `Could not read topic brief for: ${rawTopic}` }],
418
- };
1195
+ // The SQLite mirror is optional; fall through to the canonical file.
419
1196
  }
1197
+ return {
1198
+ content: [{ type: "text", text: `No brief found for topic: ${rawTopic}` }],
1199
+ };
420
1200
  }
421
1201
 
1202
+ const topicKey = wikiSummarySlug(rawTopic.replace(/^topic\//, "").replace(/\.md$/i, ""));
1203
+ const filePath = resolve(runtime().topicsDir, `${topicKey}.md`);
1204
+ try {
1205
+ const content = readFileSync(filePath, "utf-8");
1206
+ return {
1207
+ content: [{ type: "text", text: `# Topic Brief: ${topicKey}\n\n${content}` }],
1208
+ };
1209
+ } catch {
1210
+ // Report the same not-found result for DB- and file-backed lookups.
1211
+ }
422
1212
  return {
423
- content: [{ type: "text", text: `No topic brief found for: ${rawTopic}` }],
1213
+ content: [{ type: "text", text: `No brief found for topic: ${rawTopic}` }],
424
1214
  };
425
1215
  }
426
1216
 
1217
+ function wikiRead(args: Record<string, unknown>): CallToolResult {
1218
+ const rawKind = String(safeExt(args, ["kind", "type"], "article"));
1219
+ const kind = rawKind === "topic" || rawKind === "summary" ? rawKind : "article";
1220
+ const rawKey = String(safeExt(args, ["key", "slug", "topic"], "")).trim();
1221
+ if (!rawKey) {
1222
+ return { content: [{ type: "text", text: "A wiki document key is required." }], isError: true };
1223
+ }
1224
+
1225
+ if (kind === "topic") {
1226
+ const result = wikiTopicBrief({ topic: rawKey });
1227
+ const resultText = result.content
1228
+ .map((entry) => (entry.type === "text" ? entry.text : ""))
1229
+ .join("\n");
1230
+ const adopt = safeExt<boolean>(args, ["adopt", "use", "select"], false);
1231
+ if (!adopt || resultText.startsWith("No brief found for topic")) return result;
1232
+
1233
+ const currentTopicId = runtime().currentTopicId;
1234
+ const memoryKey = wikiSummarySlug(rawKey.replace(/^topic\//, "").replace(/\.md$/i, ""));
1235
+ if (
1236
+ !currentTopicId ||
1237
+ !runtime().host.adoptTopicMemory?.(currentTopicId, runtime().userId, memoryKey)
1238
+ ) {
1239
+ return {
1240
+ content: [
1241
+ ...result.content,
1242
+ { type: "text", text: "\nMemory was read but could not be adopted by this topic." },
1243
+ ],
1244
+ };
1245
+ }
1246
+ return {
1247
+ content: [...result.content, { type: "text", text: `\nAdopted topic memory: ${memoryKey}` }],
1248
+ };
1249
+ }
1250
+
1251
+ if (
1252
+ rawKey.includes("\\") ||
1253
+ rawKey.split("/").some((part) => part === "..") ||
1254
+ rawKey.startsWith("/")
1255
+ ) {
1256
+ return { content: [{ type: "text", text: "Invalid wiki document key." }], isError: true };
1257
+ }
1258
+ const root = kind === "summary" ? runtime().summariesDir : runtime().articlesDir;
1259
+ const key = rawKey
1260
+ .replace(new RegExp(`^(?:${kind === "summary" ? "summaries" : "articles"})/`), "")
1261
+ .replace(/\.md$/i, "");
1262
+ const filePath = resolve(root, `${key}.md`);
1263
+ const relativePath = relative(root, filePath);
1264
+ if (relativePath.startsWith("..") || relativePath.startsWith("/")) {
1265
+ return { content: [{ type: "text", text: "Invalid wiki document key." }], isError: true };
1266
+ }
1267
+ try {
1268
+ const content = readFileSync(filePath, "utf-8");
1269
+ return {
1270
+ content: [{ type: "text", text: `# Wiki ${kind}: ${key}\n\n${content}` }],
1271
+ };
1272
+ } catch {
1273
+ return { content: [{ type: "text", text: `No ${kind} found for: ${rawKey}` }] };
1274
+ }
1275
+ }
1276
+
427
1277
  function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
428
1278
  const rawTopic = safeExt<string | undefined>(
429
1279
  args,
@@ -651,15 +1501,12 @@ function skillSave(args: Record<string, unknown>): CallToolResult {
651
1501
  };
652
1502
  }
653
1503
 
654
- function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
655
- const rawTopic = safeExt(args, ["topic", "topicName"], "");
656
- const content: string = safeExt(args, ["content", "text", "body"], "");
657
-
658
- if (!rawTopic) return { content: [{ type: "text", text: "Missing topic." }] };
659
- if (!content) return { content: [{ type: "text", text: "Missing content." }] };
660
-
661
- const now = new Date();
662
- const dateStr = now.toISOString().slice(0, 10);
1504
+ /** Write a write-once session summary and report the slug the catalog must use. */
1505
+ function writeSummaryDocument(
1506
+ rawTopic: string,
1507
+ content: string,
1508
+ dateStr: string,
1509
+ ): { slug: string; sqliteUpdated: boolean } {
663
1510
  const topicId = runtime().topicId;
664
1511
  const fileSlug = wikiBriefStorageKey(rawTopic, topicId);
665
1512
 
@@ -683,9 +1530,9 @@ function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
683
1530
  }
684
1531
 
685
1532
  // Record the latest summary. The fresh brief is written authoritatively by
686
- // save_topic_brief (called after this step). We only backfill brief_md here so
687
- // a summary-only write — e.g. the no-substance path, which skips
688
- // save_topic_brief — can't create a title-key row with an empty brief_md that
1533
+ // wiki_write(kind="topic") (called after this step). We only backfill brief_md
1534
+ // here so a summary-only write — e.g. the no-substance path, which skips the
1535
+ // brief — can't create a title-key row with an empty brief_md that
689
1536
  // shadows an existing brief (see resolveTopicBrief precedence). Backfill order:
690
1537
  // an existing brief file, else a legacy id-keyed row migrated forward. We
691
1538
  // never overwrite an existing title brief_md with empty.
@@ -713,25 +1560,14 @@ function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
713
1560
  }
714
1561
  }
715
1562
 
716
- return {
717
- content: [
718
- {
719
- type: "text",
720
- text:
721
- `Saved summary: summaries/${summaryName}` +
722
- (sqliteUpdated ? "\nSQLite latest-summary also updated." : ""),
723
- },
724
- ],
725
- };
1563
+ return { slug: summaryName.replace(/\.md$/i, ""), sqliteUpdated };
726
1564
  }
727
1565
 
728
- function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
729
- const rawTopic = safeExt(args, ["topic", "topicName"], "");
730
- const content: string = safeExt(args, ["content", "brief", "text", "body"], "");
731
-
732
- if (!rawTopic) return { content: [{ type: "text", text: "Missing topic." }] };
733
- if (!content) return { content: [{ type: "text", text: "Missing content." }] };
734
-
1566
+ /** Write the accumulated persona brief and mirror it for the next session start. */
1567
+ function writeTopicDocument(
1568
+ rawTopic: string,
1569
+ content: string,
1570
+ ): { slug: string; existed: boolean; sqliteUpdated: boolean } {
735
1571
  const topicId = runtime().topicId;
736
1572
  const fileSlug = wikiBriefStorageKey(rawTopic, topicId);
737
1573
 
@@ -743,7 +1579,7 @@ function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
743
1579
 
744
1580
  // Mirror the brief into SQLite so it is injected at the next session start.
745
1581
  // Partial upsert: only brief_md is touched, leaving latest_summary_md and
746
- // summary_date (written by save_wiki_entry) intact.
1582
+ // summary_date (written by the summary path) intact.
747
1583
  const setTopicBrief = runtime().host.setTopicBrief;
748
1584
  let sqliteUpdated = false;
749
1585
  if (topicId && setTopicBrief) {
@@ -755,35 +1591,279 @@ function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
755
1591
  }
756
1592
  }
757
1593
 
1594
+ return { slug: fileSlug, existed, sqliteUpdated };
1595
+ }
1596
+
1597
+ /** Write a curated concept page. Articles are mergeable, so this overwrites. */
1598
+ function writeArticleDocument(slug: string, content: string): { existed: boolean } {
1599
+ const articlePath = resolve(runtime().articlesDir, `${slug}.md`);
1600
+ ensureDir(dirname(articlePath));
1601
+ const existed = existsSync(articlePath);
1602
+ atomicWriteFile(articlePath, content);
1603
+ return { existed };
1604
+ }
1605
+
1606
+ /**
1607
+ * Write a wiki document and its catalog row in one call.
1608
+ *
1609
+ * This is the only way to create a wiki entry. Pairing the two writes here is
1610
+ * what makes an unindexed document impossible to produce through the tool
1611
+ * surface: a document write that cannot be indexed is reported as a failure so
1612
+ * the caller retries, and the row upsert is idempotent.
1613
+ */
1614
+ function wikiWrite(args: Record<string, unknown>): CallToolResult {
1615
+ const rawKind: string = safeExt(args, ["kind", "type"], "");
1616
+ const content: string = safeExt(args, ["content", "text", "body", "brief"], "");
1617
+ const rawDescription = safeExt(args, ["description", "desc"], "");
1618
+ const rawTopic = safeExt(args, ["topic", "topicName"], "");
1619
+ const rawSlug = safeExt(args, ["slug", "id"], "");
1620
+ const section = safeExt<string | undefined>(args, ["section", "category"], undefined);
1621
+ const rawDate = safeExt<string | undefined>(args, ["date", "created"], undefined);
1622
+
1623
+ if (rawKind !== "summary" && rawKind !== "article" && rawKind !== "topic") {
1624
+ return {
1625
+ content: [{ type: "text", text: 'kind must be one of "summary", "article", "topic".' }],
1626
+ isError: true,
1627
+ };
1628
+ }
1629
+ if (!content.trim()) {
1630
+ return { content: [{ type: "text", text: "Missing content." }], isError: true };
1631
+ }
1632
+ const descriptionResult = normalizeIndexDescription(rawDescription);
1633
+ if ("error" in descriptionResult) {
1634
+ return { content: [{ type: "text", text: descriptionResult.error }], isError: true };
1635
+ }
1636
+ const { description } = descriptionResult;
1637
+ const today = new Date().toISOString().slice(0, 10);
1638
+
1639
+ if (rawKind === "article") {
1640
+ const slugResult = normalizeWikiSlug(rawSlug);
1641
+ if ("error" in slugResult) {
1642
+ return { content: [{ type: "text", text: slugResult.error }], isError: true };
1643
+ }
1644
+ if (!section?.trim()) {
1645
+ return {
1646
+ content: [
1647
+ { type: "text", text: "An article requires a section so the catalog stays navigable." },
1648
+ ],
1649
+ isError: true,
1650
+ };
1651
+ }
1652
+ const { existed } = writeArticleDocument(slugResult.slug, content);
1653
+ const link = indexRowOrPartialWrite("article", {
1654
+ kind: "article",
1655
+ slug: slugResult.slug,
1656
+ description,
1657
+ date: rawDate ?? today,
1658
+ section: section.trim(),
1659
+ });
1660
+ if (typeof link !== "string") return link;
1661
+ const searched = indexWikiSearchDocument("article", slugResult.slug, content);
1662
+ return {
1663
+ content: [
1664
+ {
1665
+ type: "text",
1666
+ text:
1667
+ `Saved article: articles/${slugResult.slug}.md (${existed ? "updated" : "created"})\nIndexed: ${link}` +
1668
+ (searched ? "" : `\n${SEARCH_CACHE_SKIPPED_NOTE}`),
1669
+ },
1670
+ ],
1671
+ };
1672
+ }
1673
+
1674
+ const topic = rawTopic.trim();
1675
+ if (!topic) {
1676
+ return { content: [{ type: "text", text: "Missing topic." }], isError: true };
1677
+ }
1678
+
1679
+ if (rawKind === "summary") {
1680
+ const dateStr = rawDate ?? today;
1681
+ const written = writeSummaryDocument(topic, content, dateStr);
1682
+ const link = indexRowOrPartialWrite("summary", {
1683
+ kind: "summary",
1684
+ slug: written.slug,
1685
+ description,
1686
+ date: dateStr,
1687
+ });
1688
+ if (typeof link !== "string") return link;
1689
+ const searched = indexWikiSearchDocument("summary", written.slug, content);
1690
+ return {
1691
+ content: [
1692
+ {
1693
+ type: "text",
1694
+ text:
1695
+ `Saved summary: summaries/${written.slug}.md\nIndexed: ${link}` +
1696
+ (written.sqliteUpdated ? "\nSQLite latest-summary also updated." : "") +
1697
+ (searched ? "" : `\n${SEARCH_CACHE_SKIPPED_NOTE}`),
1698
+ },
1699
+ ],
1700
+ };
1701
+ }
1702
+
1703
+ const written = writeTopicDocument(topic, content);
1704
+ const link = indexRowOrPartialWrite("topic", {
1705
+ kind: "topic",
1706
+ slug: written.slug,
1707
+ description,
1708
+ date: rawDate ?? today,
1709
+ });
1710
+ if (typeof link !== "string") return link;
758
1711
  return {
759
1712
  content: [
760
1713
  {
761
1714
  type: "text",
762
1715
  text:
763
- `Saved brief: topic/${fileSlug}.md (${existed ? "updated" : "created"})` +
764
- (sqliteUpdated ? "\nSQLite brief also updated." : ""),
1716
+ `Saved brief: topic/${written.slug}.md (${written.existed ? "updated" : "created"})\nIndexed: ${link}` +
1717
+ (written.sqliteUpdated ? "\nSQLite brief also updated." : ""),
765
1718
  },
766
1719
  ],
767
1720
  };
768
1721
  }
769
1722
 
770
- function indexUpsert(args: Record<string, unknown>): CallToolResult {
771
- const slug = safeExt(args, ["slug", "id"], "");
772
- const desc = safeExt(args, ["description", "desc", "text"], "");
773
- const kind: string = safeExt(args, ["kind", "type"], "article");
774
- const section = safeExt<string | undefined>(args, ["section", "category"], undefined);
775
- const date = safeExt<string | undefined>(args, ["date", "created"], undefined);
1723
+ /** Collect the article and summary documents currently on disk. */
1724
+ function collectIndexableDocuments(): WikiSearchIndexSource[] {
1725
+ const sources: WikiSearchIndexSource[] = [];
1726
+ function add(kind: IndexedWikiKind, key: string, path: string): void {
1727
+ try {
1728
+ const stat = statSync(path);
1729
+ if (!stat.isFile()) return;
1730
+ sources.push({ kind, key, path, mtimeMs: stat.mtimeMs, size: stat.size });
1731
+ } catch {
1732
+ // A file that vanished mid-walk is simply absent from this pass.
1733
+ }
1734
+ }
1735
+ for (const [root, kind, allowSubdirectories] of [
1736
+ [runtime().articlesDir, "article", true],
1737
+ [runtime().summariesDir, "summary", false],
1738
+ ] as const) {
1739
+ let entries: Dirent<string>[];
1740
+ try {
1741
+ entries = readdirSync(root, { withFileTypes: true });
1742
+ } catch {
1743
+ continue;
1744
+ }
1745
+ for (const entry of entries) {
1746
+ if (entry.isFile() && entry.name.endsWith(".md")) {
1747
+ add(kind, entry.name.replace(/\.md$/i, ""), join(root, entry.name));
1748
+ continue;
1749
+ }
1750
+ if (!entry.isDirectory() || !allowSubdirectories) continue;
1751
+ let nested: Dirent<string>[];
1752
+ try {
1753
+ nested = readdirSync(join(root, entry.name), { withFileTypes: true });
1754
+ } catch {
1755
+ continue;
1756
+ }
1757
+ for (const file of nested) {
1758
+ if (!file.isFile() || !file.name.endsWith(".md")) continue;
1759
+ add(
1760
+ kind,
1761
+ `${entry.name}/${file.name.replace(/\.md$/i, "")}`,
1762
+ join(root, entry.name, file.name),
1763
+ );
1764
+ }
1765
+ }
1766
+ }
1767
+ return sources;
1768
+ }
776
1769
 
777
- if (!slug) return { content: [{ type: "text", text: "Missing slug." }] };
1770
+ /**
1771
+ * Refill the derived body index and report catalog gaps.
1772
+ *
1773
+ * This is the only place that walks the content directories, and it runs only
1774
+ * when asked. Retrieval never scans, and this command deliberately does not
1775
+ * invent catalog rows: it names the documents that need one so a description is
1776
+ * written by whoever knows what the document is for.
1777
+ */
1778
+ function wikiReindex(): CallToolResult {
1779
+ const documents = collectIndexableDocuments();
1780
+ const index = openWikiSearchIndex();
1781
+ if (!index) {
1782
+ return {
1783
+ content: [{ type: "text", text: "Could not open the wiki search index." }],
1784
+ isError: true,
1785
+ };
1786
+ }
1787
+ let synced: { added: number; updated: number; removed: number };
1788
+ try {
1789
+ synced = index.sync(documents, (source, text) =>
1790
+ prepareWikiSearchDocument(source.kind, source.key, text),
1791
+ );
1792
+ } catch (error) {
1793
+ return {
1794
+ content: [{ type: "text", text: `Failed to rebuild the search index: ${String(error)}` }],
1795
+ isError: true,
1796
+ };
1797
+ } finally {
1798
+ index.close();
1799
+ }
778
1800
 
779
- const today = new Date().toISOString().slice(0, 10);
780
- const dateStr = date ?? today;
1801
+ // Compare the documents on disk against the catalogs. A document with no row
1802
+ // is invisible to retrieval; a row with no document is a harmless tombstone.
1803
+ const lines = [
1804
+ `Search index: ${synced.added} added, ${synced.updated} updated, ${synced.removed} removed (${documents.length} documents).`,
1805
+ ];
1806
+ for (const kind of ["article", "summary"] as const) {
1807
+ const rows = new Set(
1808
+ parseWikiIndex(wikiIndexPath(kind))
1809
+ .filter((candidate) => candidate.kind === kind)
1810
+ .map((candidate) => candidate.key),
1811
+ );
1812
+ const keys = documents.filter((source) => source.kind === kind).map((source) => source.key);
1813
+ const unindexed = keys.filter((key) => !rows.has(key));
1814
+ const tombstones = [...rows].filter((key) => !keys.includes(key));
1815
+ lines.push(
1816
+ `${WIKI_INDEX_FILENAMES[kind]}: ${rows.size} rows for ${keys.length} documents` +
1817
+ (unindexed.length > 0 ? `; missing rows: ${unindexed.join(", ")}` : "") +
1818
+ (tombstones.length > 0 ? `; tombstones: ${tombstones.join(", ")}` : ""),
1819
+ );
1820
+ }
1821
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1822
+ }
781
1823
 
782
- const indexPath = (() => {
783
- if (kind === "topic") return resolve(runtime().wikiDir, "topic-index.md");
784
- if (kind === "skill") return resolve(runtime().wikiDir, "skill-index.md");
785
- return resolve(runtime().wikiDir, "article-index.md");
786
- })();
1824
+ const SEARCH_CACHE_SKIPPED_NOTE =
1825
+ "Body-search cache was not updated; catalog retrieval still works. Run wiki_reindex to refill it.";
1826
+
1827
+ /**
1828
+ * Index a freshly written document, turning an index failure into an explicit
1829
+ * partial-write error naming the document that still needs a catalog row.
1830
+ */
1831
+ function indexRowOrPartialWrite(
1832
+ label: string,
1833
+ options: Parameters<typeof upsertIndexRow>[0],
1834
+ ): string | CallToolResult {
1835
+ try {
1836
+ return upsertIndexRow(options);
1837
+ } catch (error) {
1838
+ return {
1839
+ content: [
1840
+ {
1841
+ type: "text",
1842
+ text: `Wrote the ${label} document but failed to index it (${String(error)}). Retry the same wiki_write call: both writes are idempotent.`,
1843
+ },
1844
+ ],
1845
+ isError: true,
1846
+ };
1847
+ }
1848
+ }
1849
+
1850
+ /**
1851
+ * Insert or update one catalog row in the index that owns `kind`.
1852
+ *
1853
+ * Shared by `wiki_write` (which pairs it with the document write) and
1854
+ * `index_upsert` (which only curates an existing entry). Throwing here is what
1855
+ * lets `wiki_write` report a partial write instead of silently leaving a
1856
+ * document unindexed.
1857
+ */
1858
+ function upsertIndexRow(options: {
1859
+ kind: WikiIndexKind;
1860
+ slug: string;
1861
+ description: string;
1862
+ date: string;
1863
+ section?: string | undefined;
1864
+ }): string {
1865
+ const { kind, slug, description, date, section } = options;
1866
+ const indexPath = wikiIndexPath(kind);
787
1867
 
788
1868
  ensureDir(dirname(indexPath));
789
1869
  const releaseLock = acquireFileLock(indexPath);
@@ -797,34 +1877,11 @@ function indexUpsert(args: Record<string, unknown>): CallToolResult {
797
1877
  }
798
1878
 
799
1879
  const lines = index.split("\n");
800
- const link = (() => {
801
- switch (kind) {
802
- case "summary":
803
- return `- [[summaries/${slug}]] ${desc} (${dateStr})`;
804
- case "topic":
805
- return `- [[topic/${slug}]] ${desc} (${dateStr})`;
806
- case "skill":
807
- return `- [[skills/${slug}]] ${desc} (${dateStr})`;
808
- default:
809
- return `- [[articles/${slug}]] ${desc} (${dateStr})`;
810
- }
811
- })();
812
-
1880
+ const target = `${WIKI_INDEX_NAMESPACES[kind]}/${slug}`;
1881
+ const link = `- [[${target}]] ${description} (${date})`;
813
1882
  // Find every existing entry for this canonical target. Historical versions
814
1883
  // appended duplicates because they searched for [[slug]] while writing
815
1884
  // [[topic/slug]].
816
- const target = (() => {
817
- switch (kind) {
818
- case "summary":
819
- return `summaries/${slug}`;
820
- case "topic":
821
- return `topic/${slug}`;
822
- case "skill":
823
- return `skills/${slug}`;
824
- default:
825
- return `articles/${slug}`;
826
- }
827
- })();
828
1885
  const slugPattern = `[[${target}]]`;
829
1886
  let replaced = false;
830
1887
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -869,18 +1926,91 @@ function indexUpsert(args: Record<string, unknown>): CallToolResult {
869
1926
 
870
1927
  atomicWriteFile(indexPath, lines.join("\n"));
871
1928
 
872
- return { content: [{ type: "text", text: `Index updated: ${link}` }] };
1929
+ return link;
873
1930
  } finally {
874
1931
  releaseLock();
875
1932
  }
876
1933
  }
877
1934
 
1935
+ /** Does the document backing a catalog row exist on disk? */
1936
+ function wikiDocumentExists(kind: WikiIndexKind, slug: string): boolean {
1937
+ const root =
1938
+ kind === "article"
1939
+ ? runtime().articlesDir
1940
+ : kind === "summary"
1941
+ ? runtime().summariesDir
1942
+ : kind === "topic"
1943
+ ? runtime().topicsDir
1944
+ : runtime().skillsDir;
1945
+ const filePath = resolve(root, `${slug}.md`);
1946
+ if (relative(root, filePath).startsWith("..")) return false;
1947
+ try {
1948
+ return statSync(filePath).isFile();
1949
+ } catch {
1950
+ return false;
1951
+ }
1952
+ }
1953
+
1954
+ /**
1955
+ * Curate an existing catalog row.
1956
+ *
1957
+ * This tool deliberately cannot introduce a row for a document that does not
1958
+ * exist: `wiki_write` is the only way to create an entry, so a description fix
1959
+ * can never be the step that invents an unbacked row.
1960
+ */
1961
+ function indexUpsert(args: Record<string, unknown>): CallToolResult {
1962
+ const rawSlug = safeExt(args, ["slug", "id"], "");
1963
+ const rawDescription = safeExt(args, ["description", "desc", "text"], "");
1964
+ const rawKind: string = safeExt(args, ["kind", "type"], "article");
1965
+ const section = safeExt<string | undefined>(args, ["section", "category"], undefined);
1966
+ const date = safeExt<string | undefined>(args, ["date", "created"], undefined);
1967
+
1968
+ if (!(rawKind in WIKI_INDEX_FILENAMES)) {
1969
+ return {
1970
+ content: [{ type: "text", text: `Unsupported kind: ${rawKind}` }],
1971
+ isError: true,
1972
+ };
1973
+ }
1974
+ const kind = rawKind as WikiIndexKind;
1975
+
1976
+ const slugResult = normalizeWikiSlug(rawSlug);
1977
+ if ("error" in slugResult) {
1978
+ return { content: [{ type: "text", text: slugResult.error }], isError: true };
1979
+ }
1980
+ const descriptionResult = normalizeIndexDescription(rawDescription);
1981
+ if ("error" in descriptionResult) {
1982
+ return { content: [{ type: "text", text: descriptionResult.error }], isError: true };
1983
+ }
1984
+
1985
+ // Topic rows may point at a DB-backed brief rather than a local file.
1986
+ if (kind !== "topic" && !wikiDocumentExists(kind, slugResult.slug)) {
1987
+ return {
1988
+ content: [
1989
+ {
1990
+ type: "text",
1991
+ text: `No ${kind} document named ${slugResult.slug}. Use wiki_write to create the document and its catalog row together.`,
1992
+ },
1993
+ ],
1994
+ isError: true,
1995
+ };
1996
+ }
1997
+
1998
+ const link = upsertIndexRow({
1999
+ kind,
2000
+ slug: slugResult.slug,
2001
+ description: descriptionResult.description,
2002
+ date: date ?? new Date().toISOString().slice(0, 10),
2003
+ section,
2004
+ });
2005
+ return { content: [{ type: "text", text: `Index updated: ${link}` }] };
2006
+ }
2007
+
878
2008
  // --- MCP Tool definitions -------------------------------------------------
879
2009
 
880
2010
  const WIKI_TOOLS: Tool[] = [
881
2011
  {
882
2012
  name: "wiki_query",
883
- description: "Search the wiki knowledge base and return relevant articles.",
2013
+ description: "Search topic, article, or summary indexes and return relevant candidates.",
884
2014
  inputSchema: {
885
2015
  type: "object",
886
2016
  properties: {
@@ -892,10 +2022,38 @@ const WIKI_TOOLS: Tool[] = [
892
2022
  type: "string",
893
2023
  description: "Optional topic name to narrow the search",
894
2024
  },
2025
+ kind: {
2026
+ type: "string",
2027
+ enum: ["all", "topic", "article", "summary"],
2028
+ description: "Document kind to search; defaults to all",
2029
+ },
2030
+ limit: {
2031
+ type: "number",
2032
+ minimum: 1,
2033
+ maximum: 20,
2034
+ description: "Maximum number of candidates; defaults to 8",
2035
+ },
895
2036
  },
896
2037
  required: ["question"],
897
2038
  },
898
2039
  },
2040
+ {
2041
+ name: "wiki_read",
2042
+ description:
2043
+ "Read one topic, article, or summary returned by wiki_query. For a clearly matching continuing persona, set adopt=true when reading a topic.",
2044
+ inputSchema: {
2045
+ type: "object",
2046
+ properties: {
2047
+ kind: { type: "string", enum: ["topic", "article", "summary"] },
2048
+ key: { type: "string", description: "Canonical key returned by wiki_query" },
2049
+ adopt: {
2050
+ type: "boolean",
2051
+ description: "For kind=topic, persist this canonical memory key for the current room",
2052
+ },
2053
+ },
2054
+ required: ["kind", "key"],
2055
+ },
2056
+ },
899
2057
  {
900
2058
  name: "wiki_topic_brief",
901
2059
  description: "Get the lightweight topic brief for a specific topic.",
@@ -931,36 +2089,49 @@ const WIKI_TOOLS: Tool[] = [
931
2089
  },
932
2090
  },
933
2091
  {
934
- name: "save_wiki_entry",
935
- description: "Save a session summary directly to wiki/summaries/.",
936
- inputSchema: {
937
- type: "object",
938
- properties: {
939
- topic: { type: "string", description: "Topic name of the session" },
940
- content: { type: "string", description: "Session summary in markdown" },
941
- },
942
- required: ["topic", "content"],
943
- },
944
- },
945
- {
946
- name: "save_topic_brief",
2092
+ name: "wiki_write",
947
2093
  description:
948
- "Write the accumulated persona/topic brief to wiki/topic/<topic>.md and mirror it into SQLite for next-session injection. Call this AFTER save_wiki_entry (archive → summary → brief order).",
2094
+ "Write a wiki document and its catalog row together. The only way to create a summary, article, or topic brief: summaries go to wiki/summaries/ + summary-index.md, articles to wiki/articles/ + article-index.md, briefs to wiki/topic/ + topic-index.md (also mirrored into SQLite for next-session injection). Never write wiki files by any other means.",
949
2095
  inputSchema: {
950
2096
  type: "object",
951
2097
  properties: {
952
- topic: { type: "string", description: "Topic name of the session" },
953
- content: {
2098
+ kind: {
2099
+ type: "string",
2100
+ enum: ["summary", "article", "topic"],
2101
+ description: "Which document to write",
2102
+ },
2103
+ content: { type: "string", description: "Document body in markdown" },
2104
+ description: {
2105
+ type: "string",
2106
+ description: "Single-line catalog description; required so the index stays useful",
2107
+ },
2108
+ topic: {
2109
+ type: "string",
2110
+ description: "Topic name; required for kind=summary and kind=topic",
2111
+ },
2112
+ slug: {
2113
+ type: "string",
2114
+ description: "Article slug in kebab-case; required for kind=article",
2115
+ },
2116
+ section: {
954
2117
  type: "string",
955
- description: "Full accumulated topic brief in markdown (persona layer + recent work)",
2118
+ description: "H2 catalog section; required for kind=article",
956
2119
  },
2120
+ date: { type: "string", description: "Override entry date YYYY-MM-DD (default: today)" },
957
2121
  },
958
- required: ["topic", "content"],
2122
+ required: ["kind", "content", "description"],
959
2123
  },
960
2124
  },
2125
+ {
2126
+ name: "wiki_reindex",
2127
+ description:
2128
+ "Rebuild the derived body-search cache from the documents on disk and report catalog gaps. Run this once after adding documents outside wiki_write; retrieval itself never scans the wiki.",
2129
+ inputSchema: { type: "object", properties: {} },
2130
+ },
961
2131
  {
962
2132
  name: "index_upsert",
963
- description: "Upsert an entry in wiki/article-index.md, topic-index.md, or skill-index.md.",
2133
+ description:
2134
+ "Correct the description or section of an entry that already exists. Cannot create an entry — use wiki_write for that.",
964
2135
  inputSchema: {
965
2136
  type: "object",
966
2137
  properties: {
@@ -1033,6 +2204,7 @@ export function createWikiMcpServer(context: WikiMcpContext, host: WikiMcpHost):
1033
2204
  const wikiDir = resolve(host.wikiRoot);
1034
2205
  const current: WikiRuntime = {
1035
2206
  userId: context.userId,
2207
+ currentTopicId: context.currentTopicId ?? context.topicId,
1036
2208
  topicId: context.topicId,
1037
2209
  surface,
1038
2210
  host,
@@ -1074,12 +2246,13 @@ export function createWikiMcpServer(context: WikiMcpContext, host: WikiMcpHost):
1074
2246
  const { name, arguments: args } = request.params;
1075
2247
  const handlers: Record<string, (a: Record<string, unknown>) => CallToolResult> = {
1076
2248
  wiki_query: wikiQuery,
2249
+ wiki_read: wikiRead,
1077
2250
  wiki_topic_brief: wikiTopicBrief,
1078
2251
  wiki_last_conversation: wikiLastConversation,
1079
2252
  skill_query: skillQuery,
1080
2253
  skill_save: skillSave,
1081
- save_wiki_entry: saveWikiEntry,
1082
- save_topic_brief: saveTopicBrief,
2254
+ wiki_write: wikiWrite,
2255
+ wiki_reindex: wikiReindex,
1083
2256
  index_upsert: indexUpsert,
1084
2257
  };
1085
2258
  const handler = tools.some((tool) => tool.name === name) ? handlers[name] : undefined;
@@ -1098,12 +2271,31 @@ async function main() {
1098
2271
  let bridge: Partial<WikiMcpHost> = {};
1099
2272
  if (context.topicId) {
1100
2273
  try {
1101
- const [briefs, topics] = await Promise.all([
2274
+ const [briefs, topics, topicLifecycle] = await Promise.all([
1102
2275
  import("#storage/api-topic-brief"),
1103
2276
  import("#storage/api-topics"),
2277
+ import("#topics/derive"),
1104
2278
  ]);
1105
2279
  bridge = {
1106
2280
  getTopicBrief: briefs.getTopicBrief,
2281
+ canReadTopicMemory: (selection, userId) => {
2282
+ const normalized = selection
2283
+ .trim()
2284
+ .toLowerCase()
2285
+ .replace(/^topic\//, "")
2286
+ .replace(/\.md$/i, "");
2287
+ return topics.listTopics().some((topic) => {
2288
+ if (topic.visibility === "hidden") return false;
2289
+ if (!topic.participants.some((participant) => participant.userId === userId))
2290
+ return false;
2291
+ return (
2292
+ topic.id === selection ||
2293
+ topic.title.trim().toLowerCase() === normalized ||
2294
+ wikiSummarySlug(topic.title).toLowerCase() === normalized ||
2295
+ topic.memoryKey?.toLowerCase() === normalized
2296
+ );
2297
+ });
2298
+ },
1107
2299
  resolveTopicBrief: (selection, userId) =>
1108
2300
  resolveAccessibleWikiTopicBrief(
1109
2301
  selection,
@@ -1112,6 +2304,17 @@ async function main() {
1112
2304
  briefs.resolveTopicBrief,
1113
2305
  ),
1114
2306
  setTopicBrief: briefs.setTopicBrief,
2307
+ adoptTopicMemory: (topicId, userId, memoryKey) => {
2308
+ const topic = topics.getTopic(topicId);
2309
+ if (
2310
+ !topic?.participants.some(
2311
+ (participant) => participant.userId === userId && participant.role === "owner",
2312
+ )
2313
+ ) {
2314
+ return false;
2315
+ }
2316
+ return topicLifecycle.updateTopic(topicId, { memoryKey });
2317
+ },
1115
2318
  };
1116
2319
  } catch {
1117
2320
  // DB not available — degrade gracefully (file-only).