negotium 0.2.18 → 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.
@@ -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,
@@ -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,
@@ -484,6 +492,126 @@ function trigramSimilarity(left: string, right: string): number {
484
492
  return (2 * overlap) / (a.size + b.size);
485
493
  }
486
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
+
487
615
  function parseWikiIndex(path: string): WikiIndexCandidate[] {
488
616
  let text: string;
489
617
  try {
@@ -848,13 +976,53 @@ function wikiQuery(args: Record<string, unknown>): CallToolResult {
848
976
  return score;
849
977
  }
850
978
 
851
- if (kind === "all" || kind === "article") scan(runtime().articlesDir, "articles");
852
- // Skills are node-local runtime knowledge, not canonical workspace memory.
853
- // Keep the legacy all-in-one server compatible while ensuring the explicit
854
- // wiki surface cannot read a node's skill library.
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.
855
1024
  if (kind === "all" && runtime().surface === "all") scan(runtime().skillsDir, "skills");
856
1025
  if (kind === "all" || kind === "topic") scan(runtime().topicsDir, "topic");
857
- if (kind === "all" || kind === "summary") scan(runtime().summariesDir, "summaries");
858
1026
 
859
1027
  const merged = new Map<string, WikiSearchResult>();
860
1028
  for (const { candidate, keyScore, score } of indexCandidates) {
@@ -1333,15 +1501,12 @@ function skillSave(args: Record<string, unknown>): CallToolResult {
1333
1501
  };
1334
1502
  }
1335
1503
 
1336
- function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
1337
- const rawTopic = safeExt(args, ["topic", "topicName"], "");
1338
- const content: string = safeExt(args, ["content", "text", "body"], "");
1339
-
1340
- if (!rawTopic) return { content: [{ type: "text", text: "Missing topic." }] };
1341
- if (!content) return { content: [{ type: "text", text: "Missing content." }] };
1342
-
1343
- const now = new Date();
1344
- 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 } {
1345
1510
  const topicId = runtime().topicId;
1346
1511
  const fileSlug = wikiBriefStorageKey(rawTopic, topicId);
1347
1512
 
@@ -1365,9 +1530,9 @@ function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
1365
1530
  }
1366
1531
 
1367
1532
  // Record the latest summary. The fresh brief is written authoritatively by
1368
- // save_topic_brief (called after this step). We only backfill brief_md here so
1369
- // a summary-only write — e.g. the no-substance path, which skips
1370
- // save_topic_brief — can't create a title-key row with an empty brief_md that
1533
+ // wiki_write(kind="topic") (called after this step). We only backfill brief_md
1534
+ // here so a summary-only write — e.g. the no-substance path, which skips the
1535
+ // brief — can't create a title-key row with an empty brief_md that
1371
1536
  // shadows an existing brief (see resolveTopicBrief precedence). Backfill order:
1372
1537
  // an existing brief file, else a legacy id-keyed row migrated forward. We
1373
1538
  // never overwrite an existing title brief_md with empty.
@@ -1395,25 +1560,14 @@ function saveWikiEntry(args: Record<string, unknown>): CallToolResult {
1395
1560
  }
1396
1561
  }
1397
1562
 
1398
- return {
1399
- content: [
1400
- {
1401
- type: "text",
1402
- text:
1403
- `Saved summary: summaries/${summaryName}` +
1404
- (sqliteUpdated ? "\nSQLite latest-summary also updated." : ""),
1405
- },
1406
- ],
1407
- };
1563
+ return { slug: summaryName.replace(/\.md$/i, ""), sqliteUpdated };
1408
1564
  }
1409
1565
 
1410
- function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
1411
- const rawTopic = safeExt(args, ["topic", "topicName"], "");
1412
- const content: string = safeExt(args, ["content", "brief", "text", "body"], "");
1413
-
1414
- if (!rawTopic) return { content: [{ type: "text", text: "Missing topic." }] };
1415
- if (!content) return { content: [{ type: "text", text: "Missing content." }] };
1416
-
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 } {
1417
1571
  const topicId = runtime().topicId;
1418
1572
  const fileSlug = wikiBriefStorageKey(rawTopic, topicId);
1419
1573
 
@@ -1425,7 +1579,7 @@ function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
1425
1579
 
1426
1580
  // Mirror the brief into SQLite so it is injected at the next session start.
1427
1581
  // Partial upsert: only brief_md is touched, leaving latest_summary_md and
1428
- // summary_date (written by save_wiki_entry) intact.
1582
+ // summary_date (written by the summary path) intact.
1429
1583
  const setTopicBrief = runtime().host.setTopicBrief;
1430
1584
  let sqliteUpdated = false;
1431
1585
  if (topicId && setTopicBrief) {
@@ -1437,35 +1591,279 @@ function saveTopicBrief(args: Record<string, unknown>): CallToolResult {
1437
1591
  }
1438
1592
  }
1439
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;
1440
1711
  return {
1441
1712
  content: [
1442
1713
  {
1443
1714
  type: "text",
1444
1715
  text:
1445
- `Saved brief: topic/${fileSlug}.md (${existed ? "updated" : "created"})` +
1446
- (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." : ""),
1447
1718
  },
1448
1719
  ],
1449
1720
  };
1450
1721
  }
1451
1722
 
1452
- function indexUpsert(args: Record<string, unknown>): CallToolResult {
1453
- const slug = safeExt(args, ["slug", "id"], "");
1454
- const desc = safeExt(args, ["description", "desc", "text"], "");
1455
- const kind: string = safeExt(args, ["kind", "type"], "article");
1456
- const section = safeExt<string | undefined>(args, ["section", "category"], undefined);
1457
- const date = safeExt<string | undefined>(args, ["date", "created"], undefined);
1723
+ /** Collect the article and summary documents currently on disk. */
1724
+ function collectIndexableDocuments(): WikiSearchIndexSource[] {
1725
+ const sources: WikiSearchIndexSource[] = [];
1726
+ function add(kind: IndexedWikiKind, key: string, path: string): void {
1727
+ try {
1728
+ const stat = statSync(path);
1729
+ if (!stat.isFile()) return;
1730
+ sources.push({ kind, key, path, mtimeMs: stat.mtimeMs, size: stat.size });
1731
+ } catch {
1732
+ // A file that vanished mid-walk is simply absent from this pass.
1733
+ }
1734
+ }
1735
+ for (const [root, kind, allowSubdirectories] of [
1736
+ [runtime().articlesDir, "article", true],
1737
+ [runtime().summariesDir, "summary", false],
1738
+ ] as const) {
1739
+ let entries: Dirent<string>[];
1740
+ try {
1741
+ entries = readdirSync(root, { withFileTypes: true });
1742
+ } catch {
1743
+ continue;
1744
+ }
1745
+ for (const entry of entries) {
1746
+ if (entry.isFile() && entry.name.endsWith(".md")) {
1747
+ add(kind, entry.name.replace(/\.md$/i, ""), join(root, entry.name));
1748
+ continue;
1749
+ }
1750
+ if (!entry.isDirectory() || !allowSubdirectories) continue;
1751
+ let nested: Dirent<string>[];
1752
+ try {
1753
+ nested = readdirSync(join(root, entry.name), { withFileTypes: true });
1754
+ } catch {
1755
+ continue;
1756
+ }
1757
+ for (const file of nested) {
1758
+ if (!file.isFile() || !file.name.endsWith(".md")) continue;
1759
+ add(
1760
+ kind,
1761
+ `${entry.name}/${file.name.replace(/\.md$/i, "")}`,
1762
+ join(root, entry.name, file.name),
1763
+ );
1764
+ }
1765
+ }
1766
+ }
1767
+ return sources;
1768
+ }
1769
+
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
+ }
1458
1800
 
1459
- if (!slug) return { content: [{ type: "text", text: "Missing slug." }] };
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
+ }
1460
1823
 
1461
- const today = new Date().toISOString().slice(0, 10);
1462
- const dateStr = date ?? today;
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
+ }
1463
1849
 
1464
- const indexPath = (() => {
1465
- if (kind === "topic") return resolve(runtime().wikiDir, "topic-index.md");
1466
- if (kind === "skill") return resolve(runtime().wikiDir, "skill-index.md");
1467
- return resolve(runtime().wikiDir, "article-index.md");
1468
- })();
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);
1469
1867
 
1470
1868
  ensureDir(dirname(indexPath));
1471
1869
  const releaseLock = acquireFileLock(indexPath);
@@ -1479,34 +1877,11 @@ function indexUpsert(args: Record<string, unknown>): CallToolResult {
1479
1877
  }
1480
1878
 
1481
1879
  const lines = index.split("\n");
1482
- const link = (() => {
1483
- switch (kind) {
1484
- case "summary":
1485
- return `- [[summaries/${slug}]] ${desc} (${dateStr})`;
1486
- case "topic":
1487
- return `- [[topic/${slug}]] ${desc} (${dateStr})`;
1488
- case "skill":
1489
- return `- [[skills/${slug}]] ${desc} (${dateStr})`;
1490
- default:
1491
- return `- [[articles/${slug}]] ${desc} (${dateStr})`;
1492
- }
1493
- })();
1494
-
1880
+ const target = `${WIKI_INDEX_NAMESPACES[kind]}/${slug}`;
1881
+ const link = `- [[${target}]] ${description} (${date})`;
1495
1882
  // Find every existing entry for this canonical target. Historical versions
1496
1883
  // appended duplicates because they searched for [[slug]] while writing
1497
1884
  // [[topic/slug]].
1498
- const target = (() => {
1499
- switch (kind) {
1500
- case "summary":
1501
- return `summaries/${slug}`;
1502
- case "topic":
1503
- return `topic/${slug}`;
1504
- case "skill":
1505
- return `skills/${slug}`;
1506
- default:
1507
- return `articles/${slug}`;
1508
- }
1509
- })();
1510
1885
  const slugPattern = `[[${target}]]`;
1511
1886
  let replaced = false;
1512
1887
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -1551,12 +1926,85 @@ function indexUpsert(args: Record<string, unknown>): CallToolResult {
1551
1926
 
1552
1927
  atomicWriteFile(indexPath, lines.join("\n"));
1553
1928
 
1554
- return { content: [{ type: "text", text: `Index updated: ${link}` }] };
1929
+ return link;
1555
1930
  } finally {
1556
1931
  releaseLock();
1557
1932
  }
1558
1933
  }
1559
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
+
1560
2008
  // --- MCP Tool definitions -------------------------------------------------
1561
2009
 
1562
2010
  const WIKI_TOOLS: Tool[] = [
@@ -1641,36 +2089,49 @@ const WIKI_TOOLS: Tool[] = [
1641
2089
  },
1642
2090
  },
1643
2091
  {
1644
- name: "save_wiki_entry",
1645
- description: "Save a session summary directly to wiki/summaries/.",
1646
- inputSchema: {
1647
- type: "object",
1648
- properties: {
1649
- topic: { type: "string", description: "Topic name of the session" },
1650
- content: { type: "string", description: "Session summary in markdown" },
1651
- },
1652
- required: ["topic", "content"],
1653
- },
1654
- },
1655
- {
1656
- name: "save_topic_brief",
2092
+ name: "wiki_write",
1657
2093
  description:
1658
- "Write the accumulated persona/topic brief to wiki/topic/<topic>.md and mirror it into SQLite for next-session injection. Call this AFTER save_wiki_entry (archive → summary → brief order).",
2094
+ "Write a wiki document and its catalog row together. The only way to create a summary, article, or topic brief: summaries go to wiki/summaries/ + summary-index.md, articles to wiki/articles/ + article-index.md, briefs to wiki/topic/ + topic-index.md (also mirrored into SQLite for next-session injection). Never write wiki files by any other means.",
1659
2095
  inputSchema: {
1660
2096
  type: "object",
1661
2097
  properties: {
1662
- topic: { type: "string", description: "Topic name of the session" },
1663
- content: {
2098
+ kind: {
2099
+ type: "string",
2100
+ enum: ["summary", "article", "topic"],
2101
+ description: "Which document to write",
2102
+ },
2103
+ content: { type: "string", description: "Document body in markdown" },
2104
+ description: {
1664
2105
  type: "string",
1665
- description: "Full accumulated topic brief in markdown (persona layer + recent work)",
2106
+ description: "Single-line catalog description; required so the index stays useful",
1666
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: {
2117
+ type: "string",
2118
+ description: "H2 catalog section; required for kind=article",
2119
+ },
2120
+ date: { type: "string", description: "Override entry date YYYY-MM-DD (default: today)" },
1667
2121
  },
1668
- required: ["topic", "content"],
2122
+ required: ["kind", "content", "description"],
1669
2123
  },
1670
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
+ },
1671
2131
  {
1672
2132
  name: "index_upsert",
1673
- description: "Upsert an entry in wiki/article-index.md, topic-index.md, or skill-index.md.",
2133
+ description:
2134
+ "Correct the description or section of an entry that already exists. Cannot create an entry — use wiki_write for that.",
1674
2135
  inputSchema: {
1675
2136
  type: "object",
1676
2137
  properties: {
@@ -1790,8 +2251,8 @@ export function createWikiMcpServer(context: WikiMcpContext, host: WikiMcpHost):
1790
2251
  wiki_last_conversation: wikiLastConversation,
1791
2252
  skill_query: skillQuery,
1792
2253
  skill_save: skillSave,
1793
- save_wiki_entry: saveWikiEntry,
1794
- save_topic_brief: saveTopicBrief,
2254
+ wiki_write: wikiWrite,
2255
+ wiki_reindex: wikiReindex,
1795
2256
  index_upsert: indexUpsert,
1796
2257
  };
1797
2258
  const handler = tools.some((tool) => tool.name === name) ? handlers[name] : undefined;