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.
- package/dist/agent-helpers.js +83 -38
- package/dist/agent-helpers.js.map +13 -13
- package/dist/background-bash.js.map +1 -1
- package/dist/browser-runtime.js.map +1 -1
- package/dist/{chunk-5cpdbbeh.js → chunk-fc58cqry.js} +3 -1
- package/dist/{chunk-5cpdbbeh.js.map → chunk-fc58cqry.js.map} +4 -4
- package/dist/hosted-agent.js +5 -2
- package/dist/hosted-agent.js.map +6 -6
- package/dist/main.js +1228 -486
- package/dist/main.js.map +23 -22
- package/dist/mcp-factories.js +701 -97
- package/dist/mcp-factories.js.map +14 -14
- package/dist/prompts.js +8 -8
- package/dist/prompts.js.map +4 -4
- package/dist/query-runtime.js.map +1 -1
- package/dist/registry.js +3 -3
- package/dist/registry.js.map +2 -2
- package/dist/rollout.js +1 -1
- package/dist/runtime/src/agents/compaction-support.ts +1 -1
- package/dist/runtime/src/agents/idle-archiver.ts +3 -2
- package/dist/runtime/src/agents/maestro-provider.ts +3 -0
- package/dist/runtime/src/index.ts +1 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +18 -1
- package/dist/runtime/src/mcp/wiki-server.ts +801 -59
- package/dist/runtime/src/platform/mcp-config.ts +1 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +16 -6
- package/dist/runtime/src/prompts/builders.ts +17 -34
- package/dist/runtime/src/runtime/turn-event-stream.ts +26 -0
- package/dist/runtime/src/runtime/turn-runner.ts +34 -26
- package/dist/runtime/src/storage/api-topics.ts +13 -4
- package/dist/runtime/src/topics/lifecycle.ts +3 -2
- package/dist/runtime/src/types/api.ts +6 -0
- package/dist/runtime/src/types.ts +6 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js +12 -6
- package/dist/storage.js.map +4 -4
- package/dist/types/packages/core/src/agents/compaction-support.d.ts +1 -0
- package/dist/types/packages/core/src/mcp/wiki-server.d.ts +4 -0
- package/dist/types/packages/core/src/prompts/builders.d.ts +2 -4
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +4 -0
- package/dist/types/packages/core/src/storage/api-topics.d.ts +1 -0
- package/dist/types/packages/core/src/types/api.d.ts +6 -0
- package/dist/types/packages/core/src/types.d.ts +6 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- 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="))
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
327
|
-
const
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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 (
|
|
952
|
+
if (ranked.length === 0) {
|
|
353
953
|
results.push("No matching wiki articles found.");
|
|
354
954
|
} else {
|
|
355
|
-
results.push(`Found ${
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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).
|