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.
- package/dist/agent-helpers.js +41 -32
- package/dist/agent-helpers.js.map +10 -10
- package/dist/{chunk-5cpdbbeh.js → chunk-fc58cqry.js} +3 -1
- package/dist/{chunk-5cpdbbeh.js.map → chunk-fc58cqry.js.map} +3 -3
- package/dist/hosted-agent.js +4 -2
- package/dist/hosted-agent.js.map +4 -4
- package/dist/main.js +1377 -285
- package/dist/main.js.map +14 -13
- package/dist/mcp-factories.js +1497 -403
- package/dist/mcp-factories.js.map +14 -13
- package/dist/prompts.js +8 -8
- package/dist/prompts.js.map +3 -3
- package/dist/registry.js +3 -3
- package/dist/registry.js.map +2 -2
- package/dist/rollout.js +1 -1
- package/dist/runtime/src/agents/archiver.ts +1 -1
- package/dist/runtime/src/agents/idle-archiver.ts +3 -2
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +31 -15
- package/dist/runtime/src/mcp/wiki-search-index.ts +359 -0
- package/dist/runtime/src/mcp/wiki-server.ts +1358 -155
- package/dist/runtime/src/platform/mcp-config.ts +2 -1
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +73 -48
- package/dist/runtime/src/prompts/builders.ts +17 -34
- package/dist/runtime/src/runtime/turn-runner.ts +14 -17
- 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/version.ts +1 -1
- package/dist/storage.js +12 -6
- package/dist/storage.js.map +3 -3
- package/dist/types/packages/core/src/agents/archiver.d.ts +1 -1
- package/dist/types/packages/core/src/mcp/wiki-search-index.d.ts +59 -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/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/version.d.ts +1 -1
- 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="))
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
327
|
-
const
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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 (
|
|
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 ${
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
//
|
|
687
|
-
// a summary-only write — e.g. the no-substance path, which skips
|
|
688
|
-
//
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
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/${
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
801
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
|
|
953
|
-
|
|
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: "
|
|
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: ["
|
|
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:
|
|
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
|
-
|
|
1082
|
-
|
|
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).
|