negotium 0.2.16 → 0.2.18

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 (47) hide show
  1. package/dist/agent-helpers.js +83 -38
  2. package/dist/agent-helpers.js.map +13 -13
  3. package/dist/background-bash.js.map +1 -1
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/{chunk-5cpdbbeh.js → chunk-fc58cqry.js} +3 -1
  6. package/dist/{chunk-5cpdbbeh.js.map → chunk-fc58cqry.js.map} +4 -4
  7. package/dist/hosted-agent.js +5 -2
  8. package/dist/hosted-agent.js.map +6 -6
  9. package/dist/main.js +1228 -486
  10. package/dist/main.js.map +23 -22
  11. package/dist/mcp-factories.js +701 -97
  12. package/dist/mcp-factories.js.map +14 -14
  13. package/dist/prompts.js +8 -8
  14. package/dist/prompts.js.map +4 -4
  15. package/dist/query-runtime.js.map +1 -1
  16. package/dist/registry.js +3 -3
  17. package/dist/registry.js.map +2 -2
  18. package/dist/rollout.js +1 -1
  19. package/dist/runtime/src/agents/compaction-support.ts +1 -1
  20. package/dist/runtime/src/agents/idle-archiver.ts +3 -2
  21. package/dist/runtime/src/agents/maestro-provider.ts +3 -0
  22. package/dist/runtime/src/index.ts +1 -0
  23. package/dist/runtime/src/mcp/canonical-proxy-server.ts +18 -1
  24. package/dist/runtime/src/mcp/wiki-server.ts +801 -59
  25. package/dist/runtime/src/platform/mcp-config.ts +1 -0
  26. package/dist/runtime/src/prompts/agents/wiki-archiver.md +16 -6
  27. package/dist/runtime/src/prompts/builders.ts +17 -34
  28. package/dist/runtime/src/runtime/turn-event-stream.ts +26 -0
  29. package/dist/runtime/src/runtime/turn-runner.ts +34 -26
  30. package/dist/runtime/src/storage/api-topics.ts +13 -4
  31. package/dist/runtime/src/topics/lifecycle.ts +3 -2
  32. package/dist/runtime/src/types/api.ts +6 -0
  33. package/dist/runtime/src/types.ts +6 -0
  34. package/dist/runtime/src/version.ts +1 -1
  35. package/dist/runtime-helpers.js.map +1 -1
  36. package/dist/storage.js +12 -6
  37. package/dist/storage.js.map +4 -4
  38. package/dist/types/packages/core/src/agents/compaction-support.d.ts +1 -0
  39. package/dist/types/packages/core/src/mcp/wiki-server.d.ts +4 -0
  40. package/dist/types/packages/core/src/prompts/builders.d.ts +2 -4
  41. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +4 -0
  42. package/dist/types/packages/core/src/storage/api-topics.d.ts +1 -0
  43. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  44. package/dist/types/packages/core/src/types.d.ts +6 -0
  45. package/dist/types/packages/core/src/version.d.ts +1 -1
  46. package/dist/vault.js.map +1 -1
  47. package/package.json +2 -2
@@ -14,7 +14,7 @@ import {
14
14
  unlinkSync,
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
- import { basename, dirname, join, resolve } from "node:path";
17
+ import { basename, dirname, join, relative, resolve } from "node:path";
18
18
  import { argv, exit } from "node:process";
19
19
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -45,6 +45,7 @@ export interface WikiTopicBrief {
45
45
 
46
46
  export interface WikiMcpHost {
47
47
  wikiRoot: string;
48
+ canReadTopicMemory?(selection: string, userId: string): boolean;
48
49
  getTopicBrief?(topicId: string): WikiTopicBrief | null;
49
50
  resolveTopicBrief?(selection: string, userId: string): WikiTopicBrief | null;
50
51
  setTopicBrief?(
@@ -55,10 +56,13 @@ export interface WikiMcpHost {
55
56
  summaryDate?: string;
56
57
  },
57
58
  ): void;
59
+ adoptTopicMemory?(topicId: string, userId: string, memoryKey: string): boolean;
58
60
  }
59
61
 
60
62
  export interface WikiMcpContext {
61
63
  userId: string;
64
+ /** The room executing the tool, even when topicId points at inherited memory. */
65
+ currentTopicId?: string;
62
66
  topicId?: string;
63
67
  surface?: WikiSurface;
64
68
  }
@@ -91,6 +95,7 @@ export function resolveAccessibleWikiTopicBrief(
91
95
  }
92
96
 
93
97
  interface WikiRuntime extends Required<Pick<WikiMcpContext, "userId" | "surface">> {
98
+ currentTopicId?: string;
94
99
  topicId?: string;
95
100
  host: WikiMcpHost;
96
101
  wikiDir: string;
@@ -111,22 +116,26 @@ function runtime(): WikiRuntime {
111
116
 
112
117
  function parseArgv(): {
113
118
  userId: string;
119
+ currentTopicId?: string;
114
120
  topicId?: string;
115
121
  surface: WikiSurface;
116
122
  } {
117
123
  let userId = "local";
124
+ let currentTopicId: string | undefined;
118
125
  let topicId: string | undefined;
119
126
  let surface: WikiSurface = "all";
120
127
 
121
128
  for (let i = 2; i < argv.length; i++) {
122
129
  const a = argv[i];
123
130
  if (a.startsWith("--user-id=")) userId = a.slice("--user-id=".length);
124
- else if (a.startsWith("--topic-id=")) topicId = a.slice("--topic-id=".length);
131
+ else if (a.startsWith("--current-topic-id=")) {
132
+ currentTopicId = a.slice("--current-topic-id=".length);
133
+ } else if (a.startsWith("--topic-id=")) topicId = a.slice("--topic-id=".length);
125
134
  else if (a === "--surface=wiki") surface = "wiki";
126
135
  else if (a === "--surface=skills") surface = "skills";
127
136
  }
128
137
 
129
- return { userId, topicId, surface };
138
+ return { userId, currentTopicId, topicId, surface };
130
139
  }
131
140
 
132
141
  // --- Helpers ---------------------------------------------------------------
@@ -270,12 +279,401 @@ function slugify(topic: string): string {
270
279
 
271
280
  // --- Tool handlers (file-based) -------------------------------------------
272
281
 
282
+ type WikiQueryKind = "all" | "topic" | "article" | "summary";
283
+
284
+ interface WikiIndexCandidate {
285
+ kind: Exclude<WikiQueryKind, "all">;
286
+ key: string;
287
+ description: string;
288
+ date?: string;
289
+ }
290
+
291
+ type WikiSearchResultKind = Exclude<WikiQueryKind, "all"> | "skill";
292
+
293
+ interface WikiSearchResult {
294
+ kind: WikiSearchResultKind;
295
+ key: string;
296
+ score: number;
297
+ keyScore: number;
298
+ description?: string;
299
+ date?: string;
300
+ path?: string;
301
+ text?: string;
302
+ title?: string;
303
+ }
304
+
305
+ function normalizeSearchText(value: string): string {
306
+ return value
307
+ .toLowerCase()
308
+ .replaceAll(/[^a-z0-9가-힣]+/g, " ")
309
+ .trim()
310
+ .replaceAll(/\s+/g, " ");
311
+ }
312
+
313
+ function compactSearchText(value: string): string {
314
+ return normalizeSearchText(value).replaceAll(" ", "");
315
+ }
316
+
317
+ function trigrams(value: string): Set<string> {
318
+ const compact = compactSearchText(value);
319
+ if (compact.length < 3) return new Set(compact ? [compact] : []);
320
+ const result = new Set<string>();
321
+ for (let i = 0; i <= compact.length - 3; i += 1) result.add(compact.slice(i, i + 3));
322
+ return result;
323
+ }
324
+
325
+ function fuzzySearchScore(query: string, candidate: string, maximum: number): number {
326
+ const compactQuery = compactSearchText(query);
327
+ const compactCandidate = compactSearchText(candidate);
328
+ if (compactQuery.length < 3 || compactCandidate.length < 3) return 0;
329
+ const similarity = trigramSimilarity(compactQuery, compactCandidate);
330
+ const minimumSimilarity = compactQuery.length <= 4 ? 0.6 : 0.45;
331
+ return similarity >= minimumSimilarity ? similarity * maximum : 0;
332
+ }
333
+
334
+ const SEARCH_STOP_WORDS = new Set([
335
+ "a",
336
+ "an",
337
+ "and",
338
+ "are",
339
+ "as",
340
+ "at",
341
+ "be",
342
+ "by",
343
+ "for",
344
+ "from",
345
+ "in",
346
+ "is",
347
+ "it",
348
+ "of",
349
+ "on",
350
+ "or",
351
+ "that",
352
+ "the",
353
+ "to",
354
+ "with",
355
+ ]);
356
+
357
+ const SEARCH_MONTHS = new Map([
358
+ ["january", "01"],
359
+ ["jan", "01"],
360
+ ["1월", "01"],
361
+ ["february", "02"],
362
+ ["feb", "02"],
363
+ ["2월", "02"],
364
+ ["march", "03"],
365
+ ["mar", "03"],
366
+ ["3월", "03"],
367
+ ["april", "04"],
368
+ ["apr", "04"],
369
+ ["4월", "04"],
370
+ ["may", "05"],
371
+ ["5월", "05"],
372
+ ["june", "06"],
373
+ ["jun", "06"],
374
+ ["6월", "06"],
375
+ ["july", "07"],
376
+ ["jul", "07"],
377
+ ["7월", "07"],
378
+ ["august", "08"],
379
+ ["aug", "08"],
380
+ ["8월", "08"],
381
+ ["september", "09"],
382
+ ["sep", "09"],
383
+ ["sept", "09"],
384
+ ["9월", "09"],
385
+ ["october", "10"],
386
+ ["oct", "10"],
387
+ ["10월", "10"],
388
+ ["november", "11"],
389
+ ["nov", "11"],
390
+ ["11월", "11"],
391
+ ["december", "12"],
392
+ ["dec", "12"],
393
+ ["12월", "12"],
394
+ ]);
395
+
396
+ const SEARCH_RECENCY_TERMS = new Set([
397
+ "latest",
398
+ "newest",
399
+ "recent",
400
+ "earliest",
401
+ "oldest",
402
+ "최근",
403
+ "최신",
404
+ "최초",
405
+ "오래된",
406
+ ]);
407
+
408
+ function meaningfulSearchTokens(value: string): string[] {
409
+ return normalizeSearchText(value)
410
+ .split(" ")
411
+ .filter((token) => (token.length >= 2 || /^\d+$/.test(token)) && !SEARCH_STOP_WORDS.has(token));
412
+ }
413
+
414
+ function canonicalSearchToken(token: string): string {
415
+ return token.length > 4 && token.endsWith("s") ? token.slice(0, -1) : token;
416
+ }
417
+
418
+ function canonicalSearchTokens(value: string): string[] {
419
+ return meaningfulSearchTokens(value).map(canonicalSearchToken);
420
+ }
421
+
422
+ function countToken(tokens: string[], token: string): number {
423
+ let count = 0;
424
+ for (const candidate of tokens) if (candidate === token) count += 1;
425
+ return count;
426
+ }
427
+
428
+ function countPhrase(text: string, phrase: string): number {
429
+ if (!phrase) return 0;
430
+ let count = 0;
431
+ let offset = 0;
432
+ let match = text.indexOf(phrase, offset);
433
+ while (match >= 0) {
434
+ count += 1;
435
+ offset = match + phrase.length;
436
+ match = text.indexOf(phrase, offset);
437
+ }
438
+ return count;
439
+ }
440
+
441
+ function summaryTopicIdentity(key: string): string {
442
+ return normalizeSearchText(key.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
443
+ }
444
+
445
+ function scoreSummaryTime(query: string, key: string, title: string): number | null {
446
+ const date = key.match(/^(\d{4})-(\d{2})-(\d{2})/);
447
+ if (!date) return 0;
448
+ const tokens = meaningfulSearchTokens(query);
449
+ const documentDate = `${date[1]}-${date[2]}-${date[3]}`;
450
+ const requestedDateMatch = query.match(/\b(\d{4}) (\d{2}) (\d{2})\b/);
451
+ const requestedDate = requestedDateMatch
452
+ ? `${requestedDateMatch[1]}-${requestedDateMatch[2]}-${requestedDateMatch[3]}`
453
+ : undefined;
454
+ const beforeDate = /\bbefore\b/.test(query);
455
+ const afterDate = /\bafter\b/.test(query);
456
+ if (requestedDate && beforeDate && documentDate >= requestedDate) return null;
457
+ if (requestedDate && afterDate && documentDate <= requestedDate) return null;
458
+ const queryYear = tokens.find((token) => /^\d{4}(?:년)?$/.test(token))?.replace(/년$/, "");
459
+ const queryMonth = tokens.map((token) => SEARCH_MONTHS.get(token)).find(Boolean);
460
+ const hasRecencyTerm = tokens.some((token) => SEARCH_RECENCY_TERMS.has(token));
461
+ const yearMatches = !queryYear || queryYear === date[1];
462
+ const monthMatches = !queryMonth || queryMonth === date[2];
463
+ if ((!queryYear && !queryMonth && !hasRecencyTerm) || !yearMatches || !monthMatches) return 0;
464
+
465
+ const normalizedMetadata = `${normalizeSearchText(key)} ${normalizeSearchText(title)}`;
466
+ const contentTokens = tokens.filter(
467
+ (token) =>
468
+ !/^\d{4}(?:년)?$/.test(token) &&
469
+ !SEARCH_MONTHS.has(token) &&
470
+ !SEARCH_RECENCY_TERMS.has(token),
471
+ );
472
+ const contentMatches = contentTokens.filter((token) => normalizedMetadata.includes(token)).length;
473
+ const relationScore = requestedDate && (beforeDate || afterDate) ? 60 : 0;
474
+ const exactDateScore = requestedDate === documentDate ? 90 : 0;
475
+ return 30 + relationScore + exactDateScore + contentMatches * 15;
476
+ }
477
+
478
+ function trigramSimilarity(left: string, right: string): number {
479
+ const a = trigrams(left);
480
+ const b = trigrams(right);
481
+ if (a.size === 0 || b.size === 0) return 0;
482
+ let overlap = 0;
483
+ for (const gram of a) if (b.has(gram)) overlap += 1;
484
+ return (2 * overlap) / (a.size + b.size);
485
+ }
486
+
487
+ function parseWikiIndex(path: string): WikiIndexCandidate[] {
488
+ let text: string;
489
+ try {
490
+ text = readFileSync(path, "utf-8");
491
+ } catch {
492
+ return [];
493
+ }
494
+ const candidates: WikiIndexCandidate[] = [];
495
+ const entryPattern = /^\s*-\s*\[\[(topic|articles|summaries)\/([^\]]+)\]\]\s*(.*)$/;
496
+ for (const line of text.split("\n")) {
497
+ const match = line.match(entryPattern);
498
+ if (!match) continue;
499
+ const namespace = match[1]!;
500
+ const key = match[2]!.replace(/\.md$/i, "");
501
+ const tail = match[3]!.trim();
502
+ const dateMatch = tail.match(/\((\d{4}-\d{2}-\d{2})(?:[^)]*)\)\s*$/);
503
+ const description = dateMatch ? tail.slice(0, dateMatch.index).trim() : tail;
504
+ candidates.push({
505
+ kind: namespace === "topic" ? "topic" : namespace === "articles" ? "article" : "summary",
506
+ key,
507
+ description,
508
+ ...(dateMatch ? { date: dateMatch[1] } : {}),
509
+ });
510
+ }
511
+ return candidates;
512
+ }
513
+
514
+ function indexCandidateExists(candidate: WikiIndexCandidate): boolean {
515
+ // Topic indexes may point at canonical DB-backed memories rather than local files.
516
+ if (candidate.kind === "topic") return true;
517
+ const root = candidate.kind === "article" ? runtime().articlesDir : runtime().summariesDir;
518
+ const filePath = resolve(root, `${candidate.key}.md`);
519
+ const relativePath = relative(root, filePath);
520
+ if (relativePath.startsWith("..") || relativePath.startsWith("/")) return false;
521
+ try {
522
+ return statSync(filePath).isFile();
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+
528
+ function scoreIndexKey(query: string, key: string): number {
529
+ const normalizedQuery = normalizeSearchText(query);
530
+ const compactQuery = compactSearchText(normalizedQuery);
531
+ const normalizedKey = normalizeSearchText(key);
532
+ const compactKey = compactSearchText(normalizedKey);
533
+ const tokens = meaningfulSearchTokens(normalizedQuery);
534
+ let score = 0;
535
+ if (normalizedKey === normalizedQuery) score += 120;
536
+ else if (compactKey === compactQuery) score += 110;
537
+ else {
538
+ if (
539
+ compactQuery.length >= 2 &&
540
+ (compactKey.startsWith(compactQuery) || compactQuery.startsWith(compactKey))
541
+ ) {
542
+ score += 65;
543
+ }
544
+ if (tokens.length > 0 && compactQuery.length >= 3 && compactKey.includes(compactQuery)) {
545
+ score += 45;
546
+ }
547
+ score += fuzzySearchScore(compactQuery, compactKey, 55);
548
+ }
549
+ for (const token of tokens) {
550
+ if (normalizedKey.includes(token)) score += 10;
551
+ }
552
+ return score;
553
+ }
554
+
555
+ function scoreIndexCandidate(query: string, candidate: WikiIndexCandidate): number {
556
+ const normalizedQuery = normalizeSearchText(query);
557
+ if (!normalizedQuery) return 0;
558
+ const normalizedDescription = normalizeSearchText(candidate.description);
559
+ const tokens = canonicalSearchTokens(normalizedQuery);
560
+ const descriptionTokens = new Set(canonicalSearchTokens(normalizedDescription));
561
+ let score = scoreIndexKey(normalizedQuery, candidate.key);
562
+ if (tokens.length > 0 && normalizedDescription.includes(normalizedQuery)) score += 30;
563
+ let descriptionMatches = 0;
564
+ for (const token of tokens) {
565
+ if (descriptionTokens.has(token)) {
566
+ score += 5;
567
+ descriptionMatches += 1;
568
+ }
569
+ }
570
+ if (tokens.length > 1 && descriptionMatches === tokens.length) score += 25;
571
+ if (candidate.kind === "topic") {
572
+ const requestsCurrent = /\b(?:current|active|ongoing|open)\b|현재|진행 중/.test(
573
+ normalizedQuery,
574
+ );
575
+ const archivedCandidate = /\b(?:archive|archived|historical|closed)\b|보관|종료/.test(
576
+ `${normalizeSearchText(candidate.key)} ${normalizedDescription}`,
577
+ );
578
+ if (requestsCurrent && archivedCandidate) score -= 40;
579
+ }
580
+ return score;
581
+ }
582
+
273
583
  function wikiQuery(args: Record<string, unknown>): CallToolResult {
274
- const question = safeExt(args, ["question", "query", "q", "text"], "");
275
- const query = question.toLowerCase();
584
+ const question = String(safeExt(args, ["question", "query", "q", "text"], "")).trim();
585
+ const requestedKind = String(safeExt(args, ["kind", "type"], "all"));
586
+ const kind: WikiQueryKind =
587
+ requestedKind === "topic" || requestedKind === "article" || requestedKind === "summary"
588
+ ? requestedKind
589
+ : "all";
590
+ const requestedLimit = Number(safeExt(args, ["limit", "maxResults"], 8));
591
+ const limit = Math.min(Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 8, 1), 20);
592
+ if (!question) {
593
+ return {
594
+ content: [{ type: "text", text: "A non-empty wiki query is required." }],
595
+ isError: true,
596
+ };
597
+ }
598
+ const normalizedQuery = normalizeSearchText(question);
599
+ if (!normalizedQuery) {
600
+ return { content: [{ type: "text", text: "No matching wiki articles found." }] };
601
+ }
602
+
603
+ const canReadTopicMemory = runtime().host.canReadTopicMemory;
604
+ const temporalDirection = /\b(?:latest|newest|recent)\b|최근|최신/.test(normalizedQuery)
605
+ ? "newest"
606
+ : /\b(?:earliest|oldest)\b|가장 오래된|최초/.test(normalizedQuery)
607
+ ? "oldest"
608
+ : undefined;
609
+
610
+ const visibleIndexCandidates = [
611
+ ...parseWikiIndex(resolve(runtime().wikiDir, "topic-index.md")),
612
+ ...parseWikiIndex(resolve(runtime().wikiDir, "article-index.md")),
613
+ ]
614
+ .filter((candidate) => kind === "all" || candidate.kind === kind)
615
+ .filter(
616
+ (candidate) =>
617
+ candidate.kind !== "topic" ||
618
+ !canReadTopicMemory ||
619
+ canReadTopicMemory(candidate.key, runtime().userId),
620
+ );
621
+ const hasStaleExactIndexMatch = visibleIndexCandidates.some(
622
+ (candidate) =>
623
+ !indexCandidateExists(candidate) && scoreIndexKey(question, candidate.key) >= 100,
624
+ );
625
+ const indexCandidates = visibleIndexCandidates
626
+ .filter(indexCandidateExists)
627
+ .map((candidate) => ({
628
+ candidate,
629
+ keyScore: scoreIndexKey(question, candidate.key),
630
+ score: scoreIndexCandidate(question, candidate),
631
+ }))
632
+ .filter(({ keyScore, score }) => keyScore >= 25 || score >= 40)
633
+ .sort(
634
+ (left, right) =>
635
+ right.score - left.score || left.candidate.key.localeCompare(right.candidate.key),
636
+ );
637
+
638
+ const strongestIndex = indexCandidates[0];
639
+ if (
640
+ kind === "topic" &&
641
+ strongestIndex &&
642
+ (strongestIndex.keyScore >= 25 || strongestIndex.score >= 40)
643
+ ) {
644
+ const runnerUp = indexCandidates[1];
645
+ const ambiguous =
646
+ strongestIndex.keyScore < 100 &&
647
+ runnerUp !== undefined &&
648
+ runnerUp.score >= strongestIndex.score * 0.9;
649
+ if (ambiguous) {
650
+ const lines = ["Found 2 ambiguous wiki candidates:", ""];
651
+ for (const { candidate, score } of [strongestIndex, runnerUp]) {
652
+ lines.push(
653
+ `- kind: ${candidate.kind}`,
654
+ ` key: ${candidate.key}`,
655
+ ` score: ${score.toFixed(2)}`,
656
+ ` description: ${candidate.description || "(none)"}`,
657
+ ...(candidate.date ? [` date: ${candidate.date}`] : []),
658
+ );
659
+ }
660
+ return { content: [{ type: "text", text: lines.join("\n") }] };
661
+ }
662
+ const lines = ["Found 1 wiki candidate:", ""];
663
+ for (const { candidate, score } of [strongestIndex]) {
664
+ lines.push(
665
+ `- kind: ${candidate.kind}`,
666
+ ` key: ${candidate.key}`,
667
+ ` score: ${score.toFixed(2)}`,
668
+ ` description: ${candidate.description || "(none)"}`,
669
+ ...(candidate.date ? [` date: ${candidate.date}`] : []),
670
+ );
671
+ }
672
+ return { content: [{ type: "text", text: lines.join("\n") }] };
673
+ }
276
674
 
277
675
  const results: string[] = [];
278
- const scored: { score: number; path: string; text: string }[] = [];
676
+ const scored: WikiSearchResult[] = [];
279
677
 
280
678
  function scan(dir: string, label: string): void {
281
679
  ensureDir(dir);
@@ -291,10 +689,15 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
291
689
  const fp = join(sub, f.name);
292
690
  try {
293
691
  const text = readFileSync(fp, "utf-8");
692
+ const key = `${entry.name}/${f.name.replace(/\.md$/i, "")}`;
294
693
  scored.push({
295
- score: scoreMatch(text),
694
+ kind: label === "articles" ? "article" : "skill",
695
+ key,
696
+ keyScore: scoreIndexKey(question, key),
697
+ score: scoreMatch(text, key, label === "articles" ? "article" : "skill"),
296
698
  path: `${label}/${entry.name}/${f.name}`,
297
699
  text,
700
+ title: titleFromDocument(text, key),
298
701
  });
299
702
  } catch {
300
703
  /* skip unreadable */
@@ -305,13 +708,36 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
305
708
  }
306
709
  }
307
710
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
711
+ if (
712
+ label === "topic" &&
713
+ canReadTopicMemory &&
714
+ !canReadTopicMemory(entry.name.replace(/\.md$/i, ""), runtime().userId)
715
+ ) {
716
+ continue;
717
+ }
308
718
  const fp = join(dir, entry.name);
309
719
  try {
310
720
  const text = readFileSync(fp, "utf-8");
721
+ const key = entry.name.replace(/\.md$/i, "");
722
+ const resultKind =
723
+ label === "articles"
724
+ ? "article"
725
+ : label === "topic"
726
+ ? "topic"
727
+ : label === "summaries"
728
+ ? "summary"
729
+ : "skill";
311
730
  scored.push({
312
- score: scoreMatch(text),
731
+ kind: resultKind,
732
+ key,
733
+ keyScore: scoreIndexKey(question, key),
734
+ score: scoreMatch(text, key, resultKind),
313
735
  path: `${label}/${entry.name}`,
314
736
  text,
737
+ title: titleFromDocument(text, key),
738
+ ...(label === "summaries" && /^\d{4}-\d{2}-\d{2}/.test(key)
739
+ ? { date: key.slice(0, 10) }
740
+ : {}),
315
741
  });
316
742
  } catch {
317
743
  /* skip unreadable */
@@ -323,48 +749,235 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
323
749
  }
324
750
  }
325
751
 
326
- function scoreMatch(text: string): number {
327
- const lower = text.toLowerCase();
752
+ function titleFromDocument(text: string, key: string): string {
753
+ const titleLine = text.split("\n").find((line) => /^#+\s*/.test(line));
754
+ return titleLine ? titleLine.replace(/^#+\s*/, "") : basename(key);
755
+ }
756
+
757
+ function scoreMatch(text: string, key: string, resultKind: WikiSearchResultKind): number {
758
+ const titleLine = text.split("\n").find((line) => /^#+\s*/.test(line)) ?? "";
759
+ const title = titleLine.replace(/^#+\s*/, "");
760
+ const normalizedKey = normalizeSearchText(key);
761
+ const normalizedTitle = normalizeSearchText(title);
762
+ const normalizedBody = normalizeSearchText(text.replace(titleLine, ""));
763
+ const compactQuery = compactSearchText(normalizedQuery);
764
+ const compactKey = compactSearchText(normalizedKey);
765
+ const compactTitle = compactSearchText(normalizedTitle);
766
+ const tokens = canonicalSearchTokens(normalizedQuery);
767
+ const keyTokens = new Set(canonicalSearchTokens(normalizedKey));
768
+ const titleTokens = new Set(canonicalSearchTokens(normalizedTitle));
769
+ const bodyTokens = canonicalSearchTokens(normalizedBody);
328
770
  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;
771
+ let strongMetadataEvidence = false;
772
+
773
+ if (normalizedKey === normalizedQuery) {
774
+ score += 120;
775
+ strongMetadataEvidence = true;
776
+ } else if (compactKey === compactQuery) {
777
+ score += 110;
778
+ strongMetadataEvidence = true;
779
+ } else if (
780
+ compactQuery.length >= 2 &&
781
+ (compactKey.startsWith(compactQuery) || compactQuery.startsWith(compactKey))
782
+ ) {
783
+ score += 65;
784
+ strongMetadataEvidence = true;
785
+ }
786
+ if (tokens.length > 0 && compactQuery.length >= 3 && compactKey.includes(compactQuery)) {
787
+ score += 45;
788
+ strongMetadataEvidence = true;
789
+ }
790
+ const fuzzyKeyScore = fuzzySearchScore(compactQuery, compactKey, 55);
791
+ score += fuzzyKeyScore;
792
+ if (fuzzyKeyScore > 0) strongMetadataEvidence = true;
793
+
794
+ if (normalizedTitle === normalizedQuery) {
795
+ score += 115;
796
+ strongMetadataEvidence = true;
797
+ } else if (compactTitle === compactQuery) {
798
+ score += 105;
799
+ strongMetadataEvidence = true;
800
+ } else if (compactQuery.length >= 2 && compactTitle.startsWith(compactQuery)) {
801
+ score += 60;
802
+ strongMetadataEvidence = true;
803
+ }
804
+ const fuzzyTitleScore = fuzzySearchScore(compactQuery, compactTitle, 50);
805
+ score += fuzzyTitleScore;
806
+ if (fuzzyTitleScore > 0) strongMetadataEvidence = true;
807
+ if (resultKind === "summary") {
808
+ const temporalScore = scoreSummaryTime(normalizedQuery, key, title);
809
+ if (temporalScore === null) return 0;
810
+ score += temporalScore;
811
+ if (temporalScore > 0) strongMetadataEvidence = true;
812
+ }
813
+
814
+ const isMultiToken = tokens.length > 1;
815
+ if (tokens.length > 0 && normalizedTitle.includes(normalizedQuery)) {
816
+ score += 35;
817
+ strongMetadataEvidence = true;
818
+ }
819
+ const phraseMatches = isMultiToken ? countPhrase(normalizedBody, normalizedQuery) : 0;
820
+ if (phraseMatches > 0) score += phraseMatches * 12;
821
+ else if (tokens.length === 1 && tokens[0]!.length >= 5 && normalizedBody.includes(tokens[0]!)) {
822
+ score += 16;
823
+ }
824
+
825
+ let bodyMatches = 0;
826
+ let minimumBodyFrequency = Number.POSITIVE_INFINITY;
827
+ for (const token of tokens) {
828
+ if (keyTokens.has(token)) score += 10;
829
+ if (titleTokens.has(token)) score += 12;
830
+ const bodyFrequency = countToken(bodyTokens, token);
831
+ if (bodyFrequency > 0) {
832
+ score += Math.min(bodyFrequency, 3) * 4;
833
+ bodyMatches += 1;
834
+ minimumBodyFrequency = Math.min(minimumBodyFrequency, bodyFrequency);
336
835
  }
337
836
  }
837
+ if (isMultiToken && bodyMatches === tokens.length) {
838
+ score += 8 * Math.min(minimumBodyFrequency, 3);
839
+ }
840
+ const minimumBodyTokens = hasStaleExactIndexMatch ? 3 : 2;
841
+ const partialArticleEvidence =
842
+ resultKind === "article" && bodyMatches >= 3 && bodyMatches / tokens.length >= 0.6;
843
+ const strongBodyEvidence =
844
+ tokens.length >= minimumBodyTokens &&
845
+ ((bodyMatches === tokens.length && (phraseMatches > 0 || minimumBodyFrequency >= 1)) ||
846
+ partialArticleEvidence);
847
+ if (!strongMetadataEvidence && !strongBodyEvidence) return 0;
338
848
  return score;
339
849
  }
340
850
 
341
- scan(runtime().articlesDir, "articles");
851
+ if (kind === "all" || kind === "article") scan(runtime().articlesDir, "articles");
342
852
  // Skills are node-local runtime knowledge, not canonical workspace memory.
343
853
  // Keep the legacy all-in-one server compatible while ensuring the explicit
344
854
  // 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");
855
+ if (kind === "all" && runtime().surface === "all") scan(runtime().skillsDir, "skills");
856
+ if (kind === "all" || kind === "topic") scan(runtime().topicsDir, "topic");
857
+ if (kind === "all" || kind === "summary") scan(runtime().summariesDir, "summaries");
858
+
859
+ const merged = new Map<string, WikiSearchResult>();
860
+ for (const { candidate, keyScore, score } of indexCandidates) {
861
+ merged.set(`${candidate.kind}:${candidate.key}`, {
862
+ kind: candidate.kind,
863
+ key: candidate.key,
864
+ keyScore,
865
+ score,
866
+ description: candidate.description,
867
+ date: candidate.date,
868
+ });
869
+ }
870
+ const documentThreshold = kind === "topic" ? 24 : 16;
871
+ for (const document of scored) {
872
+ if (document.score < documentThreshold) continue;
873
+ const id = `${document.kind}:${document.key}`;
874
+ const indexed = merged.get(id);
875
+ merged.set(
876
+ id,
877
+ indexed
878
+ ? { ...indexed, ...document, score: Math.max(indexed.score, document.score) }
879
+ : document,
880
+ );
881
+ }
882
+
883
+ let ranked = [...merged.values()].sort(
884
+ (left, right) =>
885
+ right.score - left.score ||
886
+ (left.kind === "summary" && right.kind === "summary"
887
+ ? temporalDirection === "oldest"
888
+ ? (left.date ?? "").localeCompare(right.date ?? "")
889
+ : (right.date ?? "").localeCompare(left.date ?? "")
890
+ : 0) ||
891
+ left.key.localeCompare(right.key),
892
+ );
893
+
894
+ if (kind === "summary") {
895
+ const sameTopic = ranked.filter((result) => {
896
+ const identity = summaryTopicIdentity(result.key);
897
+ return identity.length >= 3 && normalizedQuery.includes(identity);
898
+ });
899
+ if (sameTopic.length > 0) ranked = sameTopic;
900
+
901
+ const requestedDateMatch = normalizedQuery.match(/\b(\d{4}) (\d{2}) (\d{2})\b/);
902
+ const requestedDate = requestedDateMatch
903
+ ? `${requestedDateMatch[1]}-${requestedDateMatch[2]}-${requestedDateMatch[3]}`
904
+ : undefined;
905
+ const beforeDate = /\bbefore\b/.test(normalizedQuery);
906
+ const afterDate = /\bafter\b/.test(normalizedQuery);
907
+ if (requestedDate && beforeDate) {
908
+ ranked = ranked.filter((result) => !result.date || result.date < requestedDate);
909
+ } else if (requestedDate && afterDate) {
910
+ ranked = ranked.filter((result) => !result.date || result.date > requestedDate);
911
+ }
912
+ ranked.sort((left, right) => {
913
+ if (beforeDate) {
914
+ return (right.date ?? "").localeCompare(left.date ?? "") || right.score - left.score;
915
+ }
916
+ if (afterDate) {
917
+ return (left.date ?? "").localeCompare(right.date ?? "") || right.score - left.score;
918
+ }
919
+ if (requestedDate) {
920
+ const dateDifference =
921
+ Number(right.date === requestedDate) - Number(left.date === requestedDate);
922
+ if (dateDifference !== 0) return dateDifference;
923
+ }
924
+ if (temporalDirection) {
925
+ const dateDifference =
926
+ temporalDirection === "oldest"
927
+ ? (left.date ?? "").localeCompare(right.date ?? "")
928
+ : (right.date ?? "").localeCompare(left.date ?? "");
929
+ if (dateDifference !== 0) return dateDifference;
930
+ }
931
+ return (
932
+ right.score - left.score ||
933
+ (right.date ?? "").localeCompare(left.date ?? "") ||
934
+ left.key.localeCompare(right.key)
935
+ );
936
+ });
937
+ }
348
938
 
349
- scored.sort((a, b) => b.score - a.score);
350
- const top = scored.slice(0, 8);
939
+ if (kind === "topic" && ranked[0]) {
940
+ const runnerUp = ranked[1];
941
+ const ambiguous =
942
+ ranked[0].keyScore < 100 && runnerUp !== undefined && runnerUp.score >= ranked[0].score * 0.9;
943
+ if (ambiguous) {
944
+ ranked = ranked.slice(0, 2);
945
+ } else {
946
+ ranked = ranked.slice(0, 1);
947
+ }
948
+ } else {
949
+ ranked = ranked.slice(0, limit);
950
+ }
351
951
 
352
- if (top.length === 0) {
952
+ if (ranked.length === 0) {
353
953
  results.push("No matching wiki articles found.");
354
954
  } 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);
955
+ results.push(`Found ${ranked.length} matching result(s):\n`);
956
+ for (const result of ranked) {
957
+ if (result.kind === "skill") {
958
+ const body = (result.text ?? "").trim().slice(0, 400);
959
+ results.push(
960
+ `## ${result.title ?? result.key}\nPath: ${result.path}\n\n${body}${(result.text?.length ?? 0) > 400 ? "\n...(truncated)" : ""}\n`,
961
+ );
962
+ continue;
963
+ }
366
964
  results.push(
367
- `## ${title}\nPath: ${path}\n\n${body}${text.length > 400 ? "\n...(truncated)" : ""}\n`,
965
+ `- kind: ${result.kind}`,
966
+ ` key: ${result.key}`,
967
+ ` score: ${result.score.toFixed(2)}`,
968
+ ...(result.title ? [` title: ${result.title}`] : []),
969
+ ` description: ${result.description || "(none)"}`,
970
+ ...(result.date ? [` date: ${result.date}`] : []),
971
+ ...(result.path ? [` path: ${result.path}`, ` Path: ${result.path}`] : []),
972
+ ...(result.text
973
+ ? [
974
+ ` excerpt: ${result.text
975
+ .replace(/^---[\s\S]*?---\n?/, "")
976
+ .replace(/^#.*$/m, "")
977
+ .trim()
978
+ .slice(0, 400)}`,
979
+ ]
980
+ : []),
368
981
  );
369
982
  }
370
983
  }
@@ -390,40 +1003,109 @@ function wikiTopicBrief(args: Record<string, unknown>): CallToolResult {
390
1003
  }
391
1004
 
392
1005
  const { getTopicBrief, resolveTopicBrief } = runtime().host;
393
- if (runtime().topicId && (getTopicBrief || resolveTopicBrief)) {
1006
+ const hasAuthorizedBridge = Boolean(runtime().topicId && (getTopicBrief || resolveTopicBrief));
1007
+ if (hasAuthorizedBridge) {
394
1008
  try {
395
1009
  const brief = resolveTopicBrief
396
1010
  ? resolveTopicBrief(rawTopic, runtime().userId)
397
1011
  : 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}`);
1012
+ if (brief) {
1013
+ const lines = [
1014
+ `# Topic Brief: ${topicNameFrom(rawTopic)}`,
1015
+ "",
1016
+ brief.briefMd || "(empty brief)",
1017
+ ];
1018
+ if (brief.latestSummaryMd) {
1019
+ lines.push("", "## Latest Summary", "", brief.latestSummaryMd);
1020
+ }
1021
+ if (brief.summaryDate) {
1022
+ lines.push("", `Summary date: ${brief.summaryDate}`);
1023
+ }
1024
+ return { content: [{ type: "text", text: lines.join("\n") }] };
413
1025
  }
414
- return { content: [{ type: "text", text: lines.join("\n") }] };
415
1026
  } catch {
416
- return {
417
- content: [{ type: "text", text: `Could not read topic brief for: ${rawTopic}` }],
418
- };
1027
+ // The SQLite mirror is optional; fall through to the canonical file.
419
1028
  }
1029
+ return {
1030
+ content: [{ type: "text", text: `No brief found for topic: ${rawTopic}` }],
1031
+ };
420
1032
  }
421
1033
 
1034
+ const topicKey = wikiSummarySlug(rawTopic.replace(/^topic\//, "").replace(/\.md$/i, ""));
1035
+ const filePath = resolve(runtime().topicsDir, `${topicKey}.md`);
1036
+ try {
1037
+ const content = readFileSync(filePath, "utf-8");
1038
+ return {
1039
+ content: [{ type: "text", text: `# Topic Brief: ${topicKey}\n\n${content}` }],
1040
+ };
1041
+ } catch {
1042
+ // Report the same not-found result for DB- and file-backed lookups.
1043
+ }
422
1044
  return {
423
- content: [{ type: "text", text: `No topic brief found for: ${rawTopic}` }],
1045
+ content: [{ type: "text", text: `No brief found for topic: ${rawTopic}` }],
424
1046
  };
425
1047
  }
426
1048
 
1049
+ function wikiRead(args: Record<string, unknown>): CallToolResult {
1050
+ const rawKind = String(safeExt(args, ["kind", "type"], "article"));
1051
+ const kind = rawKind === "topic" || rawKind === "summary" ? rawKind : "article";
1052
+ const rawKey = String(safeExt(args, ["key", "slug", "topic"], "")).trim();
1053
+ if (!rawKey) {
1054
+ return { content: [{ type: "text", text: "A wiki document key is required." }], isError: true };
1055
+ }
1056
+
1057
+ if (kind === "topic") {
1058
+ const result = wikiTopicBrief({ topic: rawKey });
1059
+ const resultText = result.content
1060
+ .map((entry) => (entry.type === "text" ? entry.text : ""))
1061
+ .join("\n");
1062
+ const adopt = safeExt<boolean>(args, ["adopt", "use", "select"], false);
1063
+ if (!adopt || resultText.startsWith("No brief found for topic")) return result;
1064
+
1065
+ const currentTopicId = runtime().currentTopicId;
1066
+ const memoryKey = wikiSummarySlug(rawKey.replace(/^topic\//, "").replace(/\.md$/i, ""));
1067
+ if (
1068
+ !currentTopicId ||
1069
+ !runtime().host.adoptTopicMemory?.(currentTopicId, runtime().userId, memoryKey)
1070
+ ) {
1071
+ return {
1072
+ content: [
1073
+ ...result.content,
1074
+ { type: "text", text: "\nMemory was read but could not be adopted by this topic." },
1075
+ ],
1076
+ };
1077
+ }
1078
+ return {
1079
+ content: [...result.content, { type: "text", text: `\nAdopted topic memory: ${memoryKey}` }],
1080
+ };
1081
+ }
1082
+
1083
+ if (
1084
+ rawKey.includes("\\") ||
1085
+ rawKey.split("/").some((part) => part === "..") ||
1086
+ rawKey.startsWith("/")
1087
+ ) {
1088
+ return { content: [{ type: "text", text: "Invalid wiki document key." }], isError: true };
1089
+ }
1090
+ const root = kind === "summary" ? runtime().summariesDir : runtime().articlesDir;
1091
+ const key = rawKey
1092
+ .replace(new RegExp(`^(?:${kind === "summary" ? "summaries" : "articles"})/`), "")
1093
+ .replace(/\.md$/i, "");
1094
+ const filePath = resolve(root, `${key}.md`);
1095
+ const relativePath = relative(root, filePath);
1096
+ if (relativePath.startsWith("..") || relativePath.startsWith("/")) {
1097
+ return { content: [{ type: "text", text: "Invalid wiki document key." }], isError: true };
1098
+ }
1099
+ try {
1100
+ const content = readFileSync(filePath, "utf-8");
1101
+ return {
1102
+ content: [{ type: "text", text: `# Wiki ${kind}: ${key}\n\n${content}` }],
1103
+ };
1104
+ } catch {
1105
+ return { content: [{ type: "text", text: `No ${kind} found for: ${rawKey}` }] };
1106
+ }
1107
+ }
1108
+
427
1109
  function wikiLastConversation(args: Record<string, unknown>): CallToolResult {
428
1110
  const rawTopic = safeExt<string | undefined>(
429
1111
  args,
@@ -880,7 +1562,7 @@ function indexUpsert(args: Record<string, unknown>): CallToolResult {
880
1562
  const WIKI_TOOLS: Tool[] = [
881
1563
  {
882
1564
  name: "wiki_query",
883
- description: "Search the wiki knowledge base and return relevant articles.",
1565
+ description: "Search topic, article, or summary indexes and return relevant candidates.",
884
1566
  inputSchema: {
885
1567
  type: "object",
886
1568
  properties: {
@@ -892,10 +1574,38 @@ const WIKI_TOOLS: Tool[] = [
892
1574
  type: "string",
893
1575
  description: "Optional topic name to narrow the search",
894
1576
  },
1577
+ kind: {
1578
+ type: "string",
1579
+ enum: ["all", "topic", "article", "summary"],
1580
+ description: "Document kind to search; defaults to all",
1581
+ },
1582
+ limit: {
1583
+ type: "number",
1584
+ minimum: 1,
1585
+ maximum: 20,
1586
+ description: "Maximum number of candidates; defaults to 8",
1587
+ },
895
1588
  },
896
1589
  required: ["question"],
897
1590
  },
898
1591
  },
1592
+ {
1593
+ name: "wiki_read",
1594
+ description:
1595
+ "Read one topic, article, or summary returned by wiki_query. For a clearly matching continuing persona, set adopt=true when reading a topic.",
1596
+ inputSchema: {
1597
+ type: "object",
1598
+ properties: {
1599
+ kind: { type: "string", enum: ["topic", "article", "summary"] },
1600
+ key: { type: "string", description: "Canonical key returned by wiki_query" },
1601
+ adopt: {
1602
+ type: "boolean",
1603
+ description: "For kind=topic, persist this canonical memory key for the current room",
1604
+ },
1605
+ },
1606
+ required: ["kind", "key"],
1607
+ },
1608
+ },
899
1609
  {
900
1610
  name: "wiki_topic_brief",
901
1611
  description: "Get the lightweight topic brief for a specific topic.",
@@ -1033,6 +1743,7 @@ export function createWikiMcpServer(context: WikiMcpContext, host: WikiMcpHost):
1033
1743
  const wikiDir = resolve(host.wikiRoot);
1034
1744
  const current: WikiRuntime = {
1035
1745
  userId: context.userId,
1746
+ currentTopicId: context.currentTopicId ?? context.topicId,
1036
1747
  topicId: context.topicId,
1037
1748
  surface,
1038
1749
  host,
@@ -1074,6 +1785,7 @@ export function createWikiMcpServer(context: WikiMcpContext, host: WikiMcpHost):
1074
1785
  const { name, arguments: args } = request.params;
1075
1786
  const handlers: Record<string, (a: Record<string, unknown>) => CallToolResult> = {
1076
1787
  wiki_query: wikiQuery,
1788
+ wiki_read: wikiRead,
1077
1789
  wiki_topic_brief: wikiTopicBrief,
1078
1790
  wiki_last_conversation: wikiLastConversation,
1079
1791
  skill_query: skillQuery,
@@ -1098,12 +1810,31 @@ async function main() {
1098
1810
  let bridge: Partial<WikiMcpHost> = {};
1099
1811
  if (context.topicId) {
1100
1812
  try {
1101
- const [briefs, topics] = await Promise.all([
1813
+ const [briefs, topics, topicLifecycle] = await Promise.all([
1102
1814
  import("#storage/api-topic-brief"),
1103
1815
  import("#storage/api-topics"),
1816
+ import("#topics/derive"),
1104
1817
  ]);
1105
1818
  bridge = {
1106
1819
  getTopicBrief: briefs.getTopicBrief,
1820
+ canReadTopicMemory: (selection, userId) => {
1821
+ const normalized = selection
1822
+ .trim()
1823
+ .toLowerCase()
1824
+ .replace(/^topic\//, "")
1825
+ .replace(/\.md$/i, "");
1826
+ return topics.listTopics().some((topic) => {
1827
+ if (topic.visibility === "hidden") return false;
1828
+ if (!topic.participants.some((participant) => participant.userId === userId))
1829
+ return false;
1830
+ return (
1831
+ topic.id === selection ||
1832
+ topic.title.trim().toLowerCase() === normalized ||
1833
+ wikiSummarySlug(topic.title).toLowerCase() === normalized ||
1834
+ topic.memoryKey?.toLowerCase() === normalized
1835
+ );
1836
+ });
1837
+ },
1107
1838
  resolveTopicBrief: (selection, userId) =>
1108
1839
  resolveAccessibleWikiTopicBrief(
1109
1840
  selection,
@@ -1112,6 +1843,17 @@ async function main() {
1112
1843
  briefs.resolveTopicBrief,
1113
1844
  ),
1114
1845
  setTopicBrief: briefs.setTopicBrief,
1846
+ adoptTopicMemory: (topicId, userId, memoryKey) => {
1847
+ const topic = topics.getTopic(topicId);
1848
+ if (
1849
+ !topic?.participants.some(
1850
+ (participant) => participant.userId === userId && participant.role === "owner",
1851
+ )
1852
+ ) {
1853
+ return false;
1854
+ }
1855
+ return topicLifecycle.updateTopic(topicId, { memoryKey });
1856
+ },
1115
1857
  };
1116
1858
  } catch {
1117
1859
  // DB not available — degrade gracefully (file-only).