@jbarket/loomai 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +97 -0
  3. package/dist/backends/dedup.d.ts +23 -0
  4. package/dist/backends/dedup.js +100 -0
  5. package/dist/backends/embedding-runtime.js +38 -9
  6. package/dist/backends/episodes.js +1 -1
  7. package/dist/backends/fastembed.js +30 -4
  8. package/dist/backends/migrations.js +200 -0
  9. package/dist/backends/mmr.d.ts +8 -0
  10. package/dist/backends/mmr.js +18 -4
  11. package/dist/backends/people.d.ts +192 -0
  12. package/dist/backends/people.js +824 -0
  13. package/dist/backends/proposals.js +1 -1
  14. package/dist/backends/recorder-health.d.ts +34 -0
  15. package/dist/backends/recorder-health.js +117 -0
  16. package/dist/backends/salience.d.ts +39 -0
  17. package/dist/backends/salience.js +52 -4
  18. package/dist/backends/scope.d.ts +56 -0
  19. package/dist/backends/scope.js +75 -0
  20. package/dist/backends/sqlite-knowledge.d.ts +24 -4
  21. package/dist/backends/sqlite-knowledge.js +253 -91
  22. package/dist/backends/sqlite-vec.d.ts +32 -1
  23. package/dist/backends/sqlite-vec.js +394 -75
  24. package/dist/backends/types.d.ts +203 -3
  25. package/dist/categories.d.ts +2 -0
  26. package/dist/categories.js +2 -0
  27. package/dist/cli/args.d.ts +16 -1
  28. package/dist/cli/args.js +45 -1
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +57 -3
  31. package/dist/cli/knowledge.js +4 -1
  32. package/dist/cli/memory.js +2 -2
  33. package/dist/cli/person.d.ts +2 -0
  34. package/dist/cli/person.js +116 -0
  35. package/dist/cli/recall.js +2 -1
  36. package/dist/cli/remember.js +10 -0
  37. package/dist/cli/remote.d.ts +36 -0
  38. package/dist/cli/remote.js +173 -0
  39. package/dist/cli/serve.d.ts +7 -0
  40. package/dist/cli/serve.js +94 -5
  41. package/dist/cli/subcommands.d.ts +1 -1
  42. package/dist/cli/subcommands.js +1 -1
  43. package/dist/cli/update.js +30 -11
  44. package/dist/clients.js +7 -4
  45. package/dist/config.d.ts +14 -0
  46. package/dist/config.js +25 -2
  47. package/dist/index.js +22 -7
  48. package/dist/injection/harnesses.js +9 -3
  49. package/dist/install/harnesses.js +7 -1
  50. package/dist/server.d.ts +8 -1
  51. package/dist/server.js +461 -374
  52. package/dist/tools/dossier.js +5 -1
  53. package/dist/tools/eurorack-import.js +3 -0
  54. package/dist/tools/identity.d.ts +2 -0
  55. package/dist/tools/identity.js +35 -2
  56. package/dist/tools/knowledge-history.d.ts +6 -1
  57. package/dist/tools/knowledge-history.js +26 -8
  58. package/dist/tools/knowledge-maintain.d.ts +5 -0
  59. package/dist/tools/knowledge-maintain.js +92 -5
  60. package/dist/tools/knowledge-recall.d.ts +7 -1
  61. package/dist/tools/knowledge-recall.js +53 -3
  62. package/dist/tools/knowledge-verify.d.ts +11 -1
  63. package/dist/tools/knowledge-verify.js +17 -4
  64. package/dist/tools/knowledge-write.d.ts +8 -1
  65. package/dist/tools/knowledge-write.js +27 -9
  66. package/dist/tools/memory-history.d.ts +5 -0
  67. package/dist/tools/memory-history.js +52 -0
  68. package/dist/tools/memory-list.js +2 -1
  69. package/dist/tools/memory-merge.d.ts +2 -0
  70. package/dist/tools/memory-merge.js +40 -0
  71. package/dist/tools/memory-revision-restore.d.ts +5 -0
  72. package/dist/tools/memory-revision-restore.js +33 -0
  73. package/dist/tools/person.d.ts +32 -0
  74. package/dist/tools/person.js +111 -0
  75. package/dist/tools/prune.js +1 -1
  76. package/dist/tools/recall.js +7 -1
  77. package/dist/tools/remember.js +18 -2
  78. package/dist/tools/update.js +5 -1
  79. package/dist/transport/cli-endpoint.d.ts +13 -0
  80. package/dist/transport/cli-endpoint.js +97 -0
  81. package/dist/transport/guards.d.ts +14 -0
  82. package/dist/transport/guards.js +26 -0
  83. package/dist/transport/http-server.d.ts +28 -1
  84. package/dist/transport/http-server.js +156 -18
  85. package/dist/transport/knowledge-server.d.ts +11 -0
  86. package/dist/transport/knowledge-server.js +30 -0
  87. package/dist/transport/knowledge-tools.d.ts +50 -0
  88. package/dist/transport/knowledge-tools.js +290 -0
  89. package/dist/transport/mcp-app-hello.d.ts +1 -1
  90. package/dist/widgets/display-modes.html +62 -56
  91. package/dist/widgets/hello.html +62 -56
  92. package/dist/widgets/proposals.html +62 -56
  93. package/dist/widgets/top-of-mind.html +60 -54
  94. package/package.json +7 -5
@@ -0,0 +1,290 @@
1
+ import { z } from 'zod';
2
+ import { knowledgeWrite } from '../tools/knowledge-write.js';
3
+ import { knowledgeRecall } from '../tools/knowledge-recall.js';
4
+ import { knowledgeMaintain } from '../tools/knowledge-maintain.js';
5
+ import { knowledgeArchive } from '../tools/knowledge-archive.js';
6
+ import { knowledgeRestore } from '../tools/knowledge-restore.js';
7
+ import { knowledgeSupersede } from '../tools/knowledge-supersede.js';
8
+ import { knowledgeMove } from '../tools/knowledge-move.js';
9
+ import { knowledgeMerge } from '../tools/knowledge-merge.js';
10
+ import { knowledgePurge } from '../tools/knowledge-purge.js';
11
+ import { knowledgeVerify } from '../tools/knowledge-verify.js';
12
+ import { knowledgeHistory } from '../tools/knowledge-history.js';
13
+ /** Registers the full knowledge_* tool surface (11 tools) on `server`. */
14
+ export function registerKnowledgeTools(server, opts) {
15
+ const { contextDir, gapsDir, identity, restrictCreatedByToIdentity = false, personCards = false } = opts;
16
+ server.registerTool('knowledge_write', {
17
+ description: 'Upsert a knowledge page by slug. Two classes:\n' +
18
+ ' world/ (default) — facts true independent of us. Domain = "music/gear", "software/loom", etc.\n' +
19
+ ' ours/ — Art-created artifacts, revised-in-place (breakbrain density model, homelab design,\n' +
20
+ ' wake-chain spec, script templates). Domain starts with "ours/", e.g. "ours/art-ops".\n' +
21
+ 'On an existing slug: body REPLACES by default (mode: "append" adds to it instead), ' +
22
+ 'title/domain follow the write, citations always appended with exact-duplicate dedup — safe to re-send.\n' +
23
+ 'Epistemic gate (§E1):\n' +
24
+ ' • conversation-only citations → provisional (both classes).\n' +
25
+ ' • ours/ domain + any repo citation → internal (ours/ class artifact; repo = git path / commit / live-system probe).\n' +
26
+ ' • any web/repo citation on a non-ours/ domain → sourced (world/ default).\n' +
27
+ ' NOTE: repo citations on world-class pages (domain not starting with "ours/") are treated as sourced, not internal.\n' +
28
+ ' A citation to github.com/someone-else/project is a world source.\n' +
29
+ 'World filing test: knowledge must be true independent of Jonathan. For our own artifacts use ours/.\n' +
30
+ 'Shared surface, not a scratchpad: knowledge is readable by other identities (e.g. Mark) and by ' +
31
+ 'Jonathan, not just you. Write pages as finished synthesis for that audience — no raw inner-monologue ' +
32
+ 'reasoning, no private-to-you deliberation, nothing that assumes only you will ever read it. A ' +
33
+ 'personal reflection or working-out-loud note belongs in `remember`, not here.',
34
+ inputSchema: {
35
+ title: z.string().describe('Page title — the entity name (e.g. "Mutable Instruments Rings") or artifact name (e.g. "breakbrain density model")'),
36
+ domain: z.string().describe('Domain tag. World class: "music/eurorack", "programming/typescript". ' +
37
+ 'Ours class: prefix with "ours/" — e.g. "ours/art-ops", "ours/breakbrain", "ours/homelab". ' +
38
+ 'Hierarchical string; sub-domains queryable via prefix filter.'),
39
+ body: z.string().describe('Synthesized markdown body for the entity or artifact page (max 64 KB)'),
40
+ slug: z.string().optional().describe('Entity key for upsert — stable URL-safe identifier. ' +
41
+ 'Derived from title if omitted.'),
42
+ freshness_anchor: z.string().optional().describe('The version/date the page\'s claims are valid as-of — e.g. "Syntakt OS 1.21" for a ' +
43
+ 'device, "as of 2026-05" for a topic, or "t-81 / 2026-08-31" for an ours/ artifact. ' +
44
+ 'Drives the verification engine: a page is re-verified when this anchor moves or the freshness SLA elapses.'),
45
+ mode: z.enum(['replace', 'append']).optional().describe('Body combine mode when the slug already exists: "replace" (default) overwrites the ' +
46
+ 'stored body; "append" adds this body after the existing one. Citations are appended ' +
47
+ '(deduped) in both modes. Ignored when creating a new page.'),
48
+ citations: z.array(z.object({
49
+ claim: z.string().describe('The assertion this citation supports'),
50
+ source_kind: z.enum(['web', 'loom_memory', 'conversation', 'repo']).describe('web = external URL; loom_memory = opaque memory ref; conversation = session distillation; ' +
51
+ 'repo = git repo path / commit / live-system probe (signals internal only when domain is ours/; ' +
52
+ 'on world-class pages, treated as sourced)'),
53
+ source_locator: z.string().optional().describe('URL, memory ref, session ID, or repo path + commit hash'),
54
+ excerpt: z.string().describe('Inline supporting quote or repo excerpt — link-rot insurance (max 4 KB)'),
55
+ })).describe('Support citations. At least one required. ' +
56
+ 'All-conversation → provisional. ours/ domain + repo → internal. Any web or repo on non-ours/ → sourced.'),
57
+ created_by: z.string().optional().describe('ours/ class: who created or last owned this artifact (e.g. "art", "jonathan"). ' +
58
+ 'Preserved across upserts when omitted; defaults to the calling identity for ours/ pages. ' +
59
+ 'On the knowledge-only service this must equal your own identity — you cannot attribute ' +
60
+ 'a write to a different identity than the one your bearer token resolved to.'),
61
+ version: z.string().optional().describe('ours/ class: artifact version or revision tag (e.g. "v2", "2026-08-31", "t-81"). ' +
62
+ 'Preserved across upserts when omitted.'),
63
+ },
64
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
65
+ }, async ({ title, domain, body, slug, freshness_anchor, mode, citations, created_by, version }) => {
66
+ if (restrictCreatedByToIdentity && created_by !== undefined && created_by !== identity) {
67
+ return {
68
+ content: [{
69
+ type: 'text',
70
+ text: `Error: created_by ("${created_by}") must match your own identity ` +
71
+ `("${identity}") on the knowledge-only service — cross-identity attribution ` +
72
+ `isn't allowed here. Omit created_by to default to your identity.`,
73
+ }],
74
+ };
75
+ }
76
+ const result = await knowledgeWrite(contextDir, { title, domain, body, slug, freshness_anchor, mode, citations, created_by, version }, identity);
77
+ return { content: [{ type: 'text', text: result }] };
78
+ });
79
+ server.registerTool('knowledge_recall', {
80
+ description: 'Search the knowledge store with LIKE matching over title, body, and domain, ' +
81
+ 'or fetch one page exactly by slug. Never surfaces archived pages. Two detail ' +
82
+ 'tiers: "full" returns whole entity pages (the synthesis unit) and stamps ' +
83
+ 'last_accessed/hit_count; "index" returns compact slug/domain/snippet entries ' +
84
+ 'without stamping. Defaults: full when a query is given, index when browsing ' +
85
+ 'without one. Full output is size-guarded — overflow results degrade to index ' +
86
+ 'entries; recall by slug to read them. Prefer slug over query when you know ' +
87
+ 'the page — token matching can hit cross-references in other pages\' bodies.',
88
+ inputSchema: {
89
+ slug: z.string().optional().describe('Exact-slug lookup — returns that single page in full detail and stamps ' +
90
+ 'access. Takes precedence over query/domain/limit.'),
91
+ query: z.string().optional().describe('Search terms — matched against title, body, and domain. ' +
92
+ 'Omit to browse (returns an index of non-archived pages up to limit).'),
93
+ domain: z.string().optional().describe('Filter by domain prefix, inclusive of the exact domain ' +
94
+ '(e.g. "music/gear" matches "music/gear" and "music/gear/elektron")'),
95
+ limit: z.number().int().positive().optional().describe('Maximum results to return (default: 10)'),
96
+ detail: z.enum(['index', 'full']).optional().describe('Output tier override. "index": compact listing, no body, no access stamping. ' +
97
+ '"full": whole pages with citations. Default: full with a query, index without.'),
98
+ sort_by_verified: z.boolean().optional().describe('Stale-first ordering for the verification engine: verified_at ASC with ' +
99
+ 'never-verified pages first. Index entries gain a "verified:" stamp so the ' +
100
+ 'SLA filter can run from the listing alone.'),
101
+ },
102
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
103
+ }, async ({ slug, query, domain, limit, detail, sort_by_verified }) => {
104
+ const result = await knowledgeRecall(contextDir, { slug, query, domain, limit, detail, sort_by_verified }, gapsDir, identity);
105
+ return { content: [{ type: 'text', text: result }] };
106
+ });
107
+ server.registerTool('knowledge_maintain', {
108
+ description: 'Read-only health report for the knowledge store. Four branches: ' +
109
+ '(1) expansion candidates — thin body + high hit_count (needs deepening); ' +
110
+ '(2) cold pages — not accessed recently (unused or undiscovered); ' +
111
+ '(3) misfile audit — provisional sourcing or conversation-only citations ' +
112
+ '(world/ class: should be in the memory store instead — including pages that drifted in as ' +
113
+ 'personal/inner-monologue content rather than shared-ready synthesis, which matters more now ' +
114
+ 'that knowledge is read by other identities and by Jonathan, not just you; ' +
115
+ 'ours/ class with internal sourcing are NOT misfiles — they are correct); ' +
116
+ '(4) person-page check — an active page whose slug or title names a person card (its slug or ' +
117
+ 'exact name, whole words) may belong on the card instead; a report, not enforcement. Only the ' +
118
+ 'full loom server runs (4); the knowledge-only server says it was not run.\n' +
119
+ 'Cold/expansion ranking reads the shared hit_count/last_accessed aggregate (any reader\'s access ' +
120
+ 'keeps a page warm), not a per-identity breakdown — per-reader stats exist (knowledge_access) for ' +
121
+ 'attribution, but splitting cold-ranking by identity would make the list miss pages a *different* ' +
122
+ 'reader keeps alive.\n' +
123
+ 'Pair with knowledge_write to act on findings.',
124
+ inputSchema: {
125
+ expansion_hit_threshold: z.number().int().nonnegative().optional().describe('hit_count floor for expansion candidates (default 3; 0 considers every page)'),
126
+ thin_body_threshold: z.number().int().positive().optional().describe('body char ceiling to consider a page thin (default 500)'),
127
+ cold_days: z.number().int().positive().optional().describe('Days without access before a page is cold (default 30)'),
128
+ },
129
+ annotations: { readOnlyHint: true, openWorldHint: false },
130
+ }, async ({ expansion_hit_threshold, thin_body_threshold, cold_days }) => {
131
+ const result = await knowledgeMaintain(contextDir, {
132
+ expansionHitThreshold: expansion_hit_threshold,
133
+ thinBodyThreshold: thin_body_threshold,
134
+ coldDays: cold_days,
135
+ personCards,
136
+ });
137
+ return { content: [{ type: 'text', text: result }] };
138
+ });
139
+ server.registerTool('knowledge_archive', {
140
+ description: 'Soft-retire a knowledge page: set its status to archived with an optional tombstone note. ' +
141
+ 'Archived pages are excluded from knowledge_recall and knowledge_maintain but remain ' +
142
+ 'in the database and are fully recoverable via knowledge_restore. ' +
143
+ 'Use this instead of deletion when the page may need to be audited or recovered. ' +
144
+ 'For deduplication merges, prefer knowledge_supersede which archives and records the relationship.',
145
+ inputSchema: {
146
+ slug: z.string().describe('Slug of the knowledge page to archive'),
147
+ note: z.string().optional().describe('Tombstone note: why this page is being retired'),
148
+ },
149
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
150
+ }, async ({ slug, note }) => {
151
+ const result = await knowledgeArchive(contextDir, { slug, note });
152
+ return { content: [{ type: 'text', text: result }] };
153
+ });
154
+ server.registerTool('knowledge_restore', {
155
+ description: 'Restore a previously archived knowledge page back to active status. ' +
156
+ 'Clears the archive flag and tombstone note. The page becomes visible ' +
157
+ 'to knowledge_recall and knowledge_maintain again.',
158
+ inputSchema: {
159
+ slug: z.string().describe('Slug of the archived knowledge page to restore'),
160
+ },
161
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
162
+ }, async ({ slug }) => {
163
+ const result = await knowledgeRestore(contextDir, { slug });
164
+ return { content: [{ type: 'text', text: result }] };
165
+ });
166
+ server.registerTool('knowledge_supersede', {
167
+ description: 'Mark one knowledge page as superseded by another, then archive the old page. ' +
168
+ 'Records the supersession relationship in the supersessions table. ' +
169
+ 'This is the dedup-merge primitive: write the canonical page with knowledge_write, ' +
170
+ 'then call knowledge_supersede(old_slug=loser, new_slug=canonical). ' +
171
+ 'Both pages must already exist. old_slug is archived with a tombstone pointing to new_slug.',
172
+ inputSchema: {
173
+ old_slug: z.string().describe('Slug of the page being retired (the duplicate or loser)'),
174
+ new_slug: z.string().describe('Slug of the canonical replacement page (must already exist)'),
175
+ note: z.string().optional().describe('Optional note explaining the merge or supersession decision'),
176
+ },
177
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
178
+ }, async ({ old_slug, new_slug, note }) => {
179
+ const result = await knowledgeSupersede(contextDir, { old_slug, new_slug, note });
180
+ return { content: [{ type: 'text', text: result }] };
181
+ });
182
+ server.registerTool('knowledge_move', {
183
+ description: 'Re-key or re-domain a knowledge page in place — same row, same uuid, citations and verification history preserved. ' +
184
+ 'Three modes: (1) Single-page: provide slug + new_slug and/or new_domain. ' +
185
+ 'Slug rename writes a supersessions pointer (old→new) unless leave_pointer=false. ' +
186
+ 'If new_slug already exists, the call is rejected — use knowledge_merge instead. ' +
187
+ '(2) Batch by slug list: provide slugs array + new_domain to re-home multiple pages atomically. ' +
188
+ '(3) Batch by domain prefix: provide from_domain_prefix + to_domain_prefix to re-home a whole subtree in one transaction.',
189
+ inputSchema: {
190
+ slug: z.string().optional().describe('Current slug of the page to move (single-page mode)'),
191
+ new_slug: z.string().optional().describe('New slug (re-slug). Collision with an existing page is rejected — use knowledge_merge instead.'),
192
+ new_domain: z.string().optional().describe('New domain for the page (single-page re-domain or shared target for batch-by-slugs mode)'),
193
+ leave_pointer: z.boolean().optional().describe('Write a supersessions pointer old_slug→new_slug when the slug changes. Default true.'),
194
+ slugs: z.array(z.string()).optional().describe('Batch mode: list of slugs to re-domain. Requires new_domain. Atomic — rolls back on any missing slug.'),
195
+ from_domain_prefix: z.string().optional().describe('Batch prefix mode: domain prefix to replace (e.g. "gear/elektron"). Requires to_domain_prefix.'),
196
+ to_domain_prefix: z.string().optional().describe('Batch prefix mode: replacement domain prefix (e.g. "instruments/elektron").'),
197
+ },
198
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
199
+ }, async ({ slug, new_slug, new_domain, leave_pointer, slugs, from_domain_prefix, to_domain_prefix }) => {
200
+ const result = await knowledgeMove(contextDir, {
201
+ slug, new_slug, new_domain, leave_pointer, slugs, from_domain_prefix, to_domain_prefix,
202
+ });
203
+ return { content: [{ type: 'text', text: result }] };
204
+ });
205
+ server.registerTool('knowledge_merge', {
206
+ description: 'Consolidate 2+ knowledge pages into one canonical page. ' +
207
+ 'Re-parents all citations from source pages to the target, deduplicating by (claim, source_kind, source_locator, excerpt). ' +
208
+ 'Takes MAX(verified_at) across all pages. ' +
209
+ 'Losers are superseded: archived with a tombstone and a supersessions pointer to the target. ' +
210
+ 'Loser bodies are returned in the result for curator review; set append_loser_bodies=true to concatenate them. ' +
211
+ 'Use knowledge_write first if the target body needs updating before merging. ' +
212
+ 'Distinct from knowledge_supersede (1:1 pointer, no citation consolidation) — ' +
213
+ 'use merge when consolidating data from multiple pages into one.',
214
+ inputSchema: {
215
+ source_slugs: z.array(z.string()).describe('Slugs of the pages to merge into the target (all must exist)'),
216
+ target_slug: z.string().describe('Slug of the canonical target page that survives the merge (must already exist)'),
217
+ note: z.string().optional().describe('Optional note about this merge, stored in supersession tombstones on the losers'),
218
+ hard_delete_losers: z.boolean().optional().describe('Hard-delete losers after archiving them. Losers are archived (supersession pointer written) then DELETEd from the database, cascading their citations.'),
219
+ append_loser_bodies: z.boolean().optional().describe('Append loser page bodies to the target body under section markers (default false). Off by default — curator normally hand-merges body content.'),
220
+ },
221
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
222
+ }, async ({ source_slugs, target_slug, note, hard_delete_losers, append_loser_bodies }) => {
223
+ const result = await knowledgeMerge(contextDir, {
224
+ source_slugs, target_slug, note, hard_delete_losers, append_loser_bodies,
225
+ });
226
+ return { content: [{ type: 'text', text: result }] };
227
+ });
228
+ server.registerTool('knowledge_purge', {
229
+ description: 'Hard-delete one or more archived knowledge pages and cascade their citations. ' +
230
+ 'Archive-first guard: rejects any page that is not already archived — call knowledge_archive first. ' +
231
+ 'All slugs must be archived; a mixed list (any active) rejects the entire batch with no mutation. ' +
232
+ 'confirm: true is required explicitly to prevent accidental irreversible deletes. ' +
233
+ 'Supersession pointers in the supersessions table are NOT removed (historical record preserved). ' +
234
+ 'Use this to clean up tombstoned cruft after merge/supersede workflows — not for retiring active pages.',
235
+ inputSchema: {
236
+ slugs: z.array(z.string()).describe('Slugs of archived pages to hard-delete. All must have status=archived.'),
237
+ confirm: z.literal(true).describe('Must be explicitly true — required safety gate for an irreversible operation.'),
238
+ },
239
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
240
+ }, async ({ slugs, confirm }) => {
241
+ const result = await knowledgePurge(contextDir, { slugs, confirm });
242
+ return { content: [{ type: 'text', text: result }] };
243
+ });
244
+ server.registerTool('knowledge_verify', {
245
+ description: 'Stamp a knowledge page as verified WITHOUT touching its body — sets verified_at ' +
246
+ 'and optionally freshness_anchor, and records an attributed verification (verifier, ' +
247
+ 'outcome, whether citations were checked) — the verifier is always YOUR loom identity, ' +
248
+ 'resolved server-side, never a field you can set. This is the verification engine\'s ' +
249
+ 'primitive: use it (never knowledge_write) to record "claims still hold". An optional ' +
250
+ 'note appends a dated "## Verification" section to the body (append-only, single-page ' +
251
+ 'mode). Batch mode (slugs) stamps many pages with a shared timestamp; archived ' +
252
+ 'pages are rejected; a batch with any unknown slug is rejected whole.',
253
+ inputSchema: {
254
+ slug: z.string().optional().describe('Single-page mode: slug of the page to verify.'),
255
+ slugs: z.array(z.string()).optional().describe('Batch mode: stamp many pages at once. Mutually exclusive with slug; ' +
256
+ 'note and freshness_anchor are not allowed in batch mode.'),
257
+ verified_at: z.string().optional().describe('ISO timestamp to stamp. Defaults to now.'),
258
+ freshness_anchor: z.string().optional().describe('New freshness anchor (e.g. "Syntakt OS 1.41"). Preserved when omitted. Single-page mode only.'),
259
+ note: z.string().optional().describe('Optional verification note — appended to the body as a "## Verification — <date>" ' +
260
+ 'section. Never replaces the body. Single-page mode only.'),
261
+ outcome: z.enum(['confirmed', 'stale', 'corrected']).optional().describe('What this verification found. Defaults to "confirmed" (the claims still hold). ' +
262
+ '"stale" flags it as out of date without fixing it — recorded in the audit trail but ' +
263
+ 'verified_at is NOT advanced, so the page stays at the front of the stale-first queue; ' +
264
+ '"corrected" means you fixed it ' +
265
+ '(pair with a knowledge_write, or the note field, describing the fix).'),
266
+ citation_checked: z.boolean().optional().describe('Did you actually check the page\'s citations against their sources (not just ' +
267
+ 'the body text)? Defaults to false.'),
268
+ },
269
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
270
+ }, async ({ slug, slugs, verified_at, freshness_anchor, note, outcome, citation_checked }) => {
271
+ const result = await knowledgeVerify(contextDir, { slug, slugs, verified_at, freshness_anchor, note, outcome, citation_checked }, identity);
272
+ return { content: [{ type: 'text', text: result }] };
273
+ });
274
+ server.registerTool('knowledge_history', {
275
+ description: 'Body-revision history for a knowledge page. Replace-writes snapshot the displaced ' +
276
+ 'body into page_revisions (newest kept, capped per page) — this tool is the recovery ' +
277
+ 'surface. Three modes: slug alone lists snapshots (metadata only); slug + revision_id ' +
278
+ 'reads one snapshot\'s full body; adding restore: true puts that body back on the page ' +
279
+ '(the displaced body is snapshotted first, so restore is never destructive).',
280
+ inputSchema: {
281
+ slug: z.string().describe('Slug of the knowledge page.'),
282
+ revision_id: z.number().int().positive().optional().describe('Revision to read (from the listing). Combine with restore: true to put it back.'),
283
+ restore: z.boolean().optional().describe('Restore the revision\'s body onto the page. Requires revision_id.'),
284
+ },
285
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
286
+ }, async ({ slug, revision_id, restore }) => {
287
+ const result = await knowledgeHistory(contextDir, { slug, revision_id, restore }, identity);
288
+ return { content: [{ type: 'text', text: result }] };
289
+ });
290
+ }
@@ -1,3 +1,3 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
2
  /** Register the env-gated MCP-App probes + the first real widget. Call only when probing. */
3
3
  export declare function registerHelloApp(server: McpServer, version: string, contextDir: string): void;