@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
package/dist/server.js CHANGED
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { McpServer } from '@modelcontextprotocol/server';
12
12
  import { z } from 'zod';
13
- import { assertStackVersionCompatible, assertContextBootable, resolveRepoRoot } from './config.js';
13
+ import { assertStackVersionCompatible, assertContextBootable, resolveRepoRoot, resolveIdentityName } from './config.js';
14
14
  import { MEMORY_CATEGORIES } from './categories.js';
15
15
  import { loadIdentity, resolveClientFromPeer } from './tools/identity.js';
16
16
  import { loadDossier } from './tools/dossier.js';
@@ -25,26 +25,20 @@ import { findSimilar } from './tools/find-similar.js';
25
25
  import { memoryAudit } from './tools/memory-audit.js';
26
26
  import { archive } from './tools/archive.js';
27
27
  import { restore } from './tools/restore.js';
28
+ import { memoryMerge } from './tools/memory-merge.js';
28
29
  import { propose, listProposals, ratifyProposal, rejectProposal, UnknownProposalError, } from './backends/proposals.js';
29
30
  import { updateIdentity } from './tools/update-identity.js';
30
31
  import { bootstrap } from './tools/bootstrap.js';
31
32
  import { harnessInit, harnessDescribe } from './tools/harness.js';
32
33
  import { resolvePeerToHarness, normalizePeer } from './blocks/harness.js';
33
34
  import { registerHelloApp } from './transport/mcp-app-hello.js';
34
- import { knowledgeWrite } from './tools/knowledge-write.js';
35
- import { knowledgeRecall } from './tools/knowledge-recall.js';
36
- import { knowledgeMaintain } from './tools/knowledge-maintain.js';
37
- import { knowledgeArchive } from './tools/knowledge-archive.js';
38
- import { knowledgeRestore } from './tools/knowledge-restore.js';
39
- import { knowledgeSupersede } from './tools/knowledge-supersede.js';
40
- import { knowledgeMove } from './tools/knowledge-move.js';
41
- import { knowledgeMerge } from './tools/knowledge-merge.js';
42
- import { knowledgePurge } from './tools/knowledge-purge.js';
43
- import { knowledgeVerify } from './tools/knowledge-verify.js';
44
- import { knowledgeHistory } from './tools/knowledge-history.js';
35
+ import { registerKnowledgeTools } from './transport/knowledge-tools.js';
36
+ import { memoryHistory } from './tools/memory-history.js';
37
+ import { memoryRevisionRestore } from './tools/memory-revision-restore.js';
38
+ import { personWrite, personVerify, personArchive, personRestore, person, personList, personHistory, personRevisionRestore, } from './tools/person.js';
45
39
  // ─── Server Factory ───────────────────────────────────────────────────────────
46
40
  export function createLoomServer(config) {
47
- const { contextDir } = config;
41
+ const { contextDir, gapsDir } = config;
48
42
  // Refuse to boot against a stack this loom build doesn't understand.
49
43
  assertStackVersionCompatible(contextDir);
50
44
  // Refuse to serve a blank identity from the default fallback path.
@@ -55,41 +49,51 @@ export function createLoomServer(config) {
55
49
  version: pkg.version,
56
50
  });
57
51
  // ─── Identity ───────────────────────────────────────────────────────────────
58
- server.tool('identity', 'Load the persistent identity for this agent. Returns the terminal creed ' +
59
- '(who you are), relevant memories, preferences, and self-model. ' +
60
- 'IMPORTANT: Call this tool FIRST before doing any other work. ' +
61
- 'The identity defines who you are and how you should behave.', {
62
- project: z.string().optional().describe('Project context to load (loads project-specific memories)'),
63
- client: z.string().optional().describe('Runtime client name for tool-prefix context: "claude-code", "gemini-cli", or a custom name with a matching <contextDir>/clients/<name>.md override. ' +
64
- 'Overrides the LOOM_CLIENT environment variable.'),
65
- model: z.string().optional().describe('Model identifier for model-manifest context (e.g. "claude-opus", "gemma4"). ' +
66
- 'Overrides the LOOM_MODEL environment variable.'),
67
- role: z.string().optional().describe('Reflection mode to append as an addendum from roles/<role>.md. When Art is ' +
68
- 'dispatched into a mode ("wonder", "tend", "retro", "consolidate", "identity"), ' +
69
- 'pass it here to load that mode\'s playbook alongside the identity.'),
70
- }, async ({ project, client, model, role }) => {
52
+ server.registerTool('identity', {
53
+ description: 'Load the persistent identity for this agent. Returns the terminal creed ' +
54
+ '(who you are), relevant memories, preferences, and self-model. ' +
55
+ 'IMPORTANT: Call this tool FIRST before doing any other work. ' +
56
+ 'The identity defines who you are and how you should behave.',
57
+ inputSchema: {
58
+ project: z.string().optional().describe('Project context to load (loads project-specific memories)'),
59
+ client: z.string().optional().describe('Runtime client name for tool-prefix context: "claude-code", "gemini-cli", or a custom name with a matching <contextDir>/clients/<name>.md override. ' +
60
+ 'Overrides the LOOM_CLIENT environment variable.'),
61
+ model: z.string().optional().describe('Model identifier for model-manifest context (e.g. "claude-opus", "gemma4"). ' +
62
+ 'Overrides the LOOM_MODEL environment variable.'),
63
+ role: z.string().optional().describe('Reflection mode to append as an addendum from roles/<role>.md. When Art is ' +
64
+ 'dispatched into a mode ("wonder", "tend", "retro", "consolidate", "identity"), ' +
65
+ 'pass it here to load that mode\'s playbook alongside the identity.'),
66
+ people: z.array(z.string()).optional().describe('Slugs of the people this turn is addressed by (the room), e.g. ["patrick-kells"]. ' +
67
+ 'Adds an "# In the room" block with their person cards. Omit for no block.'),
68
+ },
69
+ annotations: { readOnlyHint: true, openWorldHint: false },
70
+ }, async ({ project, client, model, role, people }) => {
71
71
  // Precedence: explicit param > data-driven manifest resolution > the
72
72
  // static code-map seed fallback > LOOM_CLIENT (inside loadIdentity).
73
73
  const peer = server.server.getClientVersion()?.name;
74
74
  // A connected peer always gets its harness OR an onboarding prompt (via its
75
75
  // normalized name); LOOM_CLIENT is only the no-peer fallback (in loadIdentity).
76
76
  const effectiveClient = client ?? (await resolvePeerToHarness(contextDir, peer)) ?? resolveClientFromPeer(peer) ?? (peer ? normalizePeer(peer) : undefined);
77
- const result = await loadIdentity(contextDir, project, effectiveClient, model, role);
77
+ const result = await loadIdentity(contextDir, project, effectiveClient, model, role, people);
78
78
  return { content: [{ type: 'text', text: result }] };
79
79
  });
80
- server.tool('dossier', 'Load Art\'s operating brief for a worker body. Returns Art\'s standards, ' +
81
- 'taste, operating constraints, and how Art wants work done — framed in the ' +
82
- 'third person for agents that are NOT Art but execute tasks on Art\'s behalf. ' +
83
- 'Includes the push-back mandate: workers are expected to refuse bad work and ' +
84
- 'explain why, including requests from Art or Jonathan.', {
85
- project: z.string().optional().describe('Project context to load (loads project-specific brief)'),
86
- client: z.string().optional().describe('Runtime client name for tool-prefix context: "claude-code", "gemini-cli", or a custom name. ' +
87
- 'Overrides the LOOM_CLIENT environment variable.'),
88
- model: z.string().optional().describe('Model identifier for model-manifest context (e.g. "claude-opus", "gemma4"). ' +
89
- 'Overrides the LOOM_MODEL environment variable.'),
90
- role: z.string().optional().describe('Worker role to append as an addendum from roles/<role>.md — the specific job ' +
91
- 'this body does for Art ("code", "review", "architect", "pr", "look", "compose"). ' +
92
- 'Appends the role brief to the dossier.'),
80
+ server.registerTool('dossier', {
81
+ description: 'Load Art\'s operating brief for a worker body. Returns Art\'s standards, ' +
82
+ 'taste, operating constraints, and how Art wants work done — framed in the ' +
83
+ 'third person for agents that are NOT Art but execute tasks on Art\'s behalf. ' +
84
+ 'Includes the push-back mandate: workers are expected to refuse bad work and ' +
85
+ 'explain why, including requests from Art or Jonathan.',
86
+ inputSchema: {
87
+ project: z.string().optional().describe('Project context to load (loads project-specific brief)'),
88
+ client: z.string().optional().describe('Runtime client name for tool-prefix context: "claude-code", "gemini-cli", or a custom name. ' +
89
+ 'Overrides the LOOM_CLIENT environment variable.'),
90
+ model: z.string().optional().describe('Model identifier for model-manifest context (e.g. "claude-opus", "gemma4"). ' +
91
+ 'Overrides the LOOM_MODEL environment variable.'),
92
+ role: z.string().optional().describe('Worker role to append as an addendum from roles/<role>.md — the specific job ' +
93
+ 'this body does for Art ("code", "review", "architect", "pr", "look", "compose"). ' +
94
+ 'Appends the role brief to the dossier.'),
95
+ },
96
+ annotations: { readOnlyHint: true, openWorldHint: false },
93
97
  }, async ({ project, client, model, role }) => {
94
98
  const peer = server.server.getClientVersion()?.name;
95
99
  // A connected peer always gets its harness OR an onboarding prompt (via its
@@ -99,108 +103,167 @@ export function createLoomServer(config) {
99
103
  return { content: [{ type: 'text', text: result }] };
100
104
  });
101
105
  // ─── Memory ─────────────────────────────────────────────────────────────────
102
- server.tool('remember', 'Store an episodic memory that persists across sessions. Use this when you ' +
103
- 'learn something important about the user, a project, or yourself that ' +
104
- 'should be available in future sessions.', {
105
- category: z.enum(MEMORY_CATEGORIES).describe('Memory category: user (about the human), project (about work), self (capability/learning), feedback (corrections/confirmations), reference (external pointers), pursuit (active goal or ongoing creative thread), episode (short-term cross-body tape: where you were / what was said or decided / what shipped / what is open — 48h TTL by default, set metadata.where to your surface e.g. "discord:#general", "voice", "wake:<id>", "lane:tending", "terminal")'),
106
- title: z.string().describe('Short title for the memory'),
107
- content: z.string().describe('The memory content — what you learned, observed, or were told'),
108
- project: z.string().optional().describe('Associated project, if any (omit for global memories)'),
109
- metadata: z.record(z.string(), z.unknown()).optional().describe('Arbitrary key-value metadata'),
110
- ttl: z.string().optional().describe('Time-to-live: "7d", "30d", "24h", "permanent", or omit for no expiration.'),
111
- }, async ({ category, title, content, project, metadata, ttl }) => {
112
- const ref = await remember(contextDir, { category, title, content, project, metadata, ttl });
106
+ server.registerTool('remember', {
107
+ description: 'Store an episodic memory that persists across sessions. Use this when you ' +
108
+ 'learn something important about the user, a project, or yourself that ' +
109
+ 'should be available in future sessions.',
110
+ inputSchema: {
111
+ category: z.enum(MEMORY_CATEGORIES).describe('Memory category: user (about the human), project (about work), self (capability/learning), feedback (corrections/confirmations), reference (external pointers), pursuit (active goal or ongoing creative thread), episode (short-term cross-body tape: where you were / what was said or decided / what shipped / what is open — 48h TTL by default, set metadata.where to your surface e.g. "discord:#general", "voice", "wake:<id>", "lane:tending", "terminal")'),
112
+ title: z.string().describe('Short title for the memory'),
113
+ content: z.string().describe('The memory content — what you learned, observed, or were told'),
114
+ project: z.string().optional().describe('Associated project, if any (omit for global memories)'),
115
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Arbitrary key-value metadata'),
116
+ ttl: z.string().optional().describe('Time-to-live: "7d", "30d", "24h", "permanent", or omit for no expiration.'),
117
+ sourcing: z.enum(['observed', 'relayed', 'inferred', 'system']).optional().describe('Provenance/trust tier — how this memory was produced. ' +
118
+ '"observed": directly witnessed (measured, tested, read from source). ' +
119
+ '"relayed": learned from another person/body (conversation, letter, episode). ' +
120
+ '"inferred": AI-generated reasoning/synthesis — treat with skepticism, not ground truth. ' +
121
+ '"system": auto-written by the harness (episode fallback, lane output). ' +
122
+ 'Shown in recall results and the boot digest so readers calibrate confidence.'),
123
+ provenance: z.string().optional().describe('Free-form origin: surface, body, session, or person. ' +
124
+ 'E.g. "wake:w-74fccc 2026-09-07", "Jonathan, Discord #loom", "lane:letters". ' +
125
+ 'For episodes, prefer metadata.where (already conventional); provenance carries detail.'),
126
+ scope: z.enum(['project', 'global']).optional().describe('Override this memory\'s recall visibility. Omit to take the category default: ' +
127
+ '"user" and "reference" default global, everything else defaults to project-scoped ' +
128
+ 'when a project is set (global when it isn\'t — nothing to scope against). Set this ' +
129
+ 'explicitly to "global" for a fact learned in one project that actually applies ' +
130
+ 'everywhere — a security, git, or tool-workflow practice, not a language/framework/' +
131
+ 'file-layout/code-style/error-handling convention (those stay project-scoped).'),
132
+ },
133
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
134
+ }, async ({ category, title, content, project, metadata, ttl, sourcing, provenance, scope }) => {
135
+ const ref = await remember(contextDir, { category, title, content, project, metadata, ttl, sourcing, provenance, scope });
113
136
  return { content: [{ type: 'text', text: `Memory stored: "${ref.title}" → ${ref.ref}` }] };
114
137
  });
115
- server.tool('recall', 'Retrieve memories relevant to a query or topic. Returns matching memories ' +
116
- 'from the persistent store. Use this when you need context from past sessions. ' +
117
- 'Results are re-ranked for diversity (MMR, λ=0.7 by default) so near-duplicate ' +
118
- 'memories on a well-covered topic don\'t crowd out different ones; the top ' +
119
- 'result is always the most relevant. Pass diversity: 0 for pure relevance order.', {
120
- query: z.string().describe('What to search for — topic, keyword, or question'),
121
- category: z.string().optional().describe('Filter to a specific memory category, or omit for all'),
122
- project: z.string().optional().describe('Filter to a specific project'),
123
- limit: z.number().int().positive().optional().describe('Maximum results to return (default: 10)'),
124
- diversity: z.number().min(0).max(1).optional().describe('MMR diversity 0..1 (default 0.3 = 1−λ). 0 reproduces the plain relevance ranking; ' +
125
- 'higher trades relevance for coverage of distinct memories.'),
138
+ server.registerTool('recall', {
139
+ description: 'Retrieve memories relevant to a query or topic. Returns matching memories ' +
140
+ 'from the persistent store. Use this when you need context from past sessions. ' +
141
+ 'Results are re-ranked for diversity (MMR, λ=0.7 by default) so near-duplicate ' +
142
+ 'memories on a well-covered topic don\'t crowd out different ones; the top ' +
143
+ 'result is always the most relevant. Pass diversity: 0 for pure relevance order.',
144
+ inputSchema: {
145
+ query: z.string().describe('What to search for — topic, keyword, or question'),
146
+ category: z.string().optional().describe('Filter to a specific memory category, or omit for all'),
147
+ project: z.string().optional().describe('Scope to a specific project — pass the project you are currently working in. ' +
148
+ 'Enforces scope discipline (t-337): memories tagged to a different project are ' +
149
+ 'excluded unless their effective scope is global (user/reference by default, or ' +
150
+ 'anything written with scope: "global"). Omit to search without project scoping.'),
151
+ limit: z.number().int().positive().optional().describe('Maximum results to return (default: 10)'),
152
+ diversity: z.number().min(0).max(1).optional().describe('MMR diversity 0..1 (default 0.3 = 1−λ). 0 reproduces the plain relevance ranking; ' +
153
+ 'higher trades relevance for coverage of distinct memories.'),
154
+ },
155
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
126
156
  }, async ({ query, category, project, limit, diversity }) => {
127
157
  const result = await recall(contextDir, { query, category, project, limit, diversity });
128
158
  return { content: [{ type: 'text', text: result }] };
129
159
  });
130
- server.tool('update', 'Update an existing memory. Find by ref (returned from remember) or by ' +
131
- 'category+title. Can replace content, update metadata, or both.', {
132
- ref: z.string().optional().describe('Memory reference (category/filename) from remember'),
133
- category: z.string().optional().describe('Category to search in (used with title)'),
134
- title: z.string().optional().describe('Title of the memory to update (used with category)'),
135
- content: z.string().optional().describe('New content (replaces existing body)'),
136
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata fields to add or update'),
137
- }, async ({ ref, category, title, content, metadata }) => {
138
- const result = await update(contextDir, { ref, category, title, content, metadata });
160
+ server.registerTool('update', {
161
+ description: 'Update an existing memory. Find by ref (returned from remember) or by ' +
162
+ 'category+title. Can replace content, update metadata, or both.',
163
+ inputSchema: {
164
+ ref: z.string().optional().describe('Memory reference (category/filename) from remember'),
165
+ category: z.string().optional().describe('Category to search in (used with title)'),
166
+ title: z.string().optional().describe('Title of the memory to update (used with category)'),
167
+ content: z.string().optional().describe('New content (replaces existing body)'),
168
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata fields to add or update'),
169
+ observation: z.enum(['confirm', 'contradict']).optional().describe('Feedback memories only: record a confirming or contradicting observation. ' +
170
+ '"confirm" raises confidence (+0.05, capped at 0.9), "contradict" lowers it ' +
171
+ '(-0.1, floored at 0.3); either way the evidence count goes up. Ignored on ' +
172
+ 'non-feedback memories. Confidence decays toward neutral the longer it goes ' +
173
+ 'unconfirmed — see the feedback category description on remember.'),
174
+ },
175
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
176
+ }, async ({ ref, category, title, content, metadata, observation }) => {
177
+ const result = await update(contextDir, { ref, category, title, content, metadata, observation });
139
178
  return { content: [{ type: 'text', text: result }] };
140
179
  });
141
- server.tool('forget', 'Remove memories. Single deletion by ref or category+title. ' +
142
- 'Bulk deletion by category and/or project scope — requires confirm: true; ' +
143
- 'without it, returns a dry-run preview of what would be deleted.', {
144
- ref: z.string().optional().describe('Memory reference for single deletion'),
145
- category: z.string().optional().describe('Category (with title for single, alone for bulk)'),
146
- title: z.string().optional().describe('Title of specific memory to forget'),
147
- project: z.string().optional().describe('Delete all memories for this project (bulk)'),
148
- title_pattern: z.string().optional().describe('Glob pattern for bulk title matching. Requires category or project as scope guard.'),
149
- confirm: z.boolean().optional().describe('Safety gate for scope deletions (category alone, project alone, or title_pattern). ' +
150
- 'Must be true to actually delete; omit for a free dry-run preview. ' +
151
- 'Single-target deletions (ref, or category+title) never need it.'),
180
+ server.registerTool('forget', {
181
+ description: 'Remove memories. Single deletion by ref or category+title. ' +
182
+ 'Bulk deletion by category and/or project scope — requires confirm: true; ' +
183
+ 'without it, returns a dry-run preview of what would be deleted.',
184
+ inputSchema: {
185
+ ref: z.string().optional().describe('Memory reference for single deletion'),
186
+ category: z.string().optional().describe('Category (with title for single, alone for bulk)'),
187
+ title: z.string().optional().describe('Title of specific memory to forget'),
188
+ project: z.string().optional().describe('Delete all memories for this project (bulk)'),
189
+ title_pattern: z.string().optional().describe('Glob pattern for bulk title matching. Requires category or project as scope guard.'),
190
+ confirm: z.boolean().optional().describe('Safety gate for scope deletions (category alone, project alone, or title_pattern). ' +
191
+ 'Must be true to actually delete; omit for a free dry-run preview. ' +
192
+ 'Single-target deletions (ref, or category+title) never need it.'),
193
+ },
194
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
152
195
  }, async ({ ref, category, title, project, title_pattern, confirm }) => {
153
196
  const result = await forget(contextDir, { ref, category, title, project, title_pattern, confirm });
154
197
  return { content: [{ type: 'text', text: result }] };
155
198
  });
156
- server.tool('memory_prune', 'Remove expired memories (TTL elapsed). Use dry_run to preview without deleting.', {
157
- dry_run: z.boolean().optional().describe('Preview only — show what would be pruned without deleting (default: false)'),
158
- stale_days: z.number().int().positive().optional().describe('Days since last access to consider a memory stale (default: 30)'),
199
+ server.registerTool('memory_prune', {
200
+ description: 'Remove expired memories (TTL elapsed). Use dry_run to preview without deleting.',
201
+ inputSchema: {
202
+ dry_run: z.boolean().optional().describe('Preview only — show what would be pruned without deleting (default: false)'),
203
+ stale_days: z.number().int().positive().optional().describe('Days since last access to consider a memory stale (default: 30)'),
204
+ },
205
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
159
206
  }, async ({ dry_run, stale_days }) => {
160
207
  const result = await prune(contextDir, { dryRun: dry_run, staleDays: stale_days });
161
208
  return { content: [{ type: 'text', text: result }] };
162
209
  });
163
- server.tool('memory_list', 'Browse memories without semantic search. Lists memories with optional ' +
164
- 'category/project filters. Useful for auditing, maintenance, and discovery.', {
165
- category: z.string().optional().describe('Filter to a specific category'),
166
- project: z.string().optional().describe('Filter to a specific project'),
167
- limit: z.number().int().positive().optional().describe('Maximum results (default: 50)'),
210
+ server.registerTool('memory_list', {
211
+ description: 'Browse memories without semantic search. Lists memories with optional ' +
212
+ 'category/project filters. Useful for auditing, maintenance, and discovery.',
213
+ inputSchema: {
214
+ category: z.string().optional().describe('Filter to a specific category'),
215
+ project: z.string().optional().describe('Filter to a specific project'),
216
+ limit: z.number().int().positive().optional().describe('Maximum results (default: 50)'),
217
+ },
218
+ annotations: { readOnlyHint: true, openWorldHint: false },
168
219
  }, async ({ category, project, limit }) => {
169
220
  const result = await memoryList(contextDir, { category, project, limit });
170
221
  return { content: [{ type: 'text', text: result }] };
171
222
  });
172
- server.tool('episodes', 'The episode tape: what happened across ALL bodies of this identity in the ' +
173
- 'last N hours, time-ordered (oldest first), never ranked. The same block ' +
174
- 'identity injects at boot — call it mid-session to catch up on what other ' +
175
- 'sleeves did since you loaded, or for the nightly pass to index the day.', {
176
- hours: z.number().positive().optional().describe('Look-back window in hours (default 24)'),
223
+ server.registerTool('episodes', {
224
+ description: 'The episode tape: what happened across ALL bodies of this identity in the ' +
225
+ 'last N hours, time-ordered (oldest first), never ranked. The same block ' +
226
+ 'identity injects at boot — call it mid-session to catch up on what other ' +
227
+ 'sleeves did since you loaded, or for the nightly pass to index the day.',
228
+ inputSchema: {
229
+ hours: z.number().positive().optional().describe('Look-back window in hours (default 24)'),
230
+ },
231
+ annotations: { readOnlyHint: true, openWorldHint: false },
177
232
  }, async ({ hours }) => {
178
233
  const tape = tapeForContext(contextDir, { hours, tokenBudget: 6000 });
179
234
  return { content: [{ type: 'text', text: tape ?? `No episodes in the last ${hours ?? 24}h.` }] };
180
235
  });
181
- server.tool('find_similar', 'Surface memories semantically near an existing ref or free-form text. ' +
182
- 'Use during consolidation/dream workflows to find overlap and dedupe ' +
183
- 'candidates. Anchor with `ref` (an existing memory) or `text` (a fresh ' +
184
- 'query). Self is always excluded when `ref` is given.', {
185
- ref: z.string().optional().describe('Anchor on an existing memory ref (excludes self from results)'),
186
- text: z.string().optional().describe('Or anchor on fresh text — embedded on the fly'),
187
- limit: z.number().int().positive().optional().describe('Max neighbours to return (default 10)'),
188
- category: z.string().optional().describe('Restrict candidates to a category'),
189
- project: z.string().optional().describe('Restrict candidates to a project'),
190
- min_relevance: z.number().min(0).max(1).optional().describe('Drop matches below this cosine similarity (0..1)'),
236
+ server.registerTool('find_similar', {
237
+ description: 'Surface memories semantically near an existing ref or free-form text. ' +
238
+ 'Use during consolidation/dream workflows to find overlap and dedupe ' +
239
+ 'candidates. Anchor with `ref` (an existing memory) or `text` (a fresh ' +
240
+ 'query). Self is always excluded when `ref` is given.',
241
+ inputSchema: {
242
+ ref: z.string().optional().describe('Anchor on an existing memory ref (excludes self from results)'),
243
+ text: z.string().optional().describe('Or anchor on fresh text — embedded on the fly'),
244
+ limit: z.number().int().positive().optional().describe('Max neighbours to return (default 10)'),
245
+ category: z.string().optional().describe('Restrict candidates to a category'),
246
+ project: z.string().optional().describe('Restrict candidates to a project'),
247
+ min_relevance: z.number().min(0).max(1).optional().describe('Drop matches below this cosine similarity (0..1)'),
248
+ },
249
+ annotations: { readOnlyHint: true, openWorldHint: false },
191
250
  }, async ({ ref, text, limit, category, project, min_relevance }) => {
192
251
  const result = await findSimilar(contextDir, {
193
252
  ref, text, limit, category, project, minRelevance: min_relevance,
194
253
  });
195
254
  return { content: [{ type: 'text', text: result }] };
196
255
  });
197
- server.tool('memory_audit', 'One-shot health report for the memory store: totals, category breakdown, ' +
198
- 'stale memories (untouched beyond threshold), near-duplicate pairs (above ' +
199
- 'similarity threshold), and expired refs. Read-only — pair with `forget`/' +
200
- '`update` to act on findings.', {
201
- stale_days: z.number().int().positive().optional().describe('Stale threshold in days (default 30)'),
202
- similarity_threshold: z.number().min(0).max(1).optional().describe('Cosine floor for duplicate pairs, 0..1 (default 0.85)'),
203
- max_duplicates: z.number().int().positive().optional().describe('Cap on duplicate pairs returned (default 20)'),
256
+ server.registerTool('memory_audit', {
257
+ description: 'One-shot health report for the memory store: totals, category breakdown, ' +
258
+ 'stale memories (untouched beyond threshold), near-duplicate pairs (above ' +
259
+ 'similarity threshold), and expired refs. Read-only — pair with `forget`/' +
260
+ '`update` to act on findings.',
261
+ inputSchema: {
262
+ stale_days: z.number().int().positive().optional().describe('Stale threshold in days (default 30)'),
263
+ similarity_threshold: z.number().min(0).max(1).optional().describe('Cosine floor for duplicate pairs, 0..1 (default 0.85)'),
264
+ max_duplicates: z.number().int().positive().optional().describe('Cap on duplicate pairs returned (default 20)'),
265
+ },
266
+ annotations: { readOnlyHint: true, openWorldHint: false },
204
267
  }, async ({ stale_days, similarity_threshold, max_duplicates }) => {
205
268
  const result = await memoryAudit(contextDir, {
206
269
  staleDays: stale_days,
@@ -209,54 +272,92 @@ export function createLoomServer(config) {
209
272
  });
210
273
  return { content: [{ type: 'text', text: result }] };
211
274
  });
212
- server.tool('memory_archive', 'Soft-retire a memory: move it to the archive tier with a tombstone instead of ' +
213
- 'deleting it. Archived memories are excluded from recall, list, audit, and ' +
214
- 'find_similar but remain fully recoverable via memory_restore. Use this instead ' +
215
- 'of forget when the memory may need to be recovered or audited later.', {
216
- ref: z.string().optional().describe('Memory reference for single archive'),
217
- category: z.string().optional().describe('Category (used with title)'),
218
- title: z.string().optional().describe('Title of specific memory to archive'),
219
- note: z.string().optional().describe('Tombstone note: why this memory is being retired'),
275
+ server.registerTool('memory_archive', {
276
+ description: 'Soft-retire a memory: move it to the archive tier with a tombstone instead of ' +
277
+ 'deleting it. Archived memories are excluded from recall, list, audit, and ' +
278
+ 'find_similar but remain fully recoverable via memory_restore. Use this instead ' +
279
+ 'of forget when the memory may need to be recovered or audited later.',
280
+ inputSchema: {
281
+ ref: z.string().optional().describe('Memory reference for single archive'),
282
+ category: z.string().optional().describe('Category (used with title)'),
283
+ title: z.string().optional().describe('Title of specific memory to archive'),
284
+ note: z.string().optional().describe('Tombstone note: why this memory is being retired'),
285
+ },
286
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
220
287
  }, async ({ ref, category, title, note }) => {
221
288
  const result = await archive(contextDir, { ref, category, title, note });
222
289
  return { content: [{ type: 'text', text: result }] };
223
290
  });
224
- server.tool('memory_restore', 'Restore a previously archived memory to the active set. Clears the archive ' +
225
- 'flag and tombstone note. The memory becomes visible to recall, list, audit, ' +
226
- 'and find_similar again.', {
227
- ref: z.string().optional().describe('Memory reference to restore'),
228
- category: z.string().optional().describe('Category (used with title)'),
229
- title: z.string().optional().describe('Title of the archived memory to restore'),
291
+ server.registerTool('memory_restore', {
292
+ description: 'Restore a previously archived memory to the active set. Clears the archive ' +
293
+ 'flag and tombstone note. The memory becomes visible to recall, list, audit, ' +
294
+ 'and find_similar again.',
295
+ inputSchema: {
296
+ ref: z.string().optional().describe('Memory reference to restore'),
297
+ category: z.string().optional().describe('Category (used with title)'),
298
+ title: z.string().optional().describe('Title of the archived memory to restore'),
299
+ },
300
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
230
301
  }, async ({ ref, category, title }) => {
231
302
  const result = await restore(contextDir, { ref, category, title });
232
303
  return { content: [{ type: 'text', text: result }] };
233
304
  });
305
+ server.registerTool('memory_merge', {
306
+ description: 'Consolidate 2+ memories into one canonical row. Acts on a memory_audit duplicate ' +
307
+ 'finding: pick the survivor (target_ref) and fold the rest (source_refs) into it. ' +
308
+ 'Sources are archived with a tombstone and a supersession pointer to the target ' +
309
+ '(same mechanism as memory_archive/supersede, N:1 instead of 1:1); the target\'s ' +
310
+ 'times_seen sums in each source\'s count. Unlike forget/archive on a duplicate pair, ' +
311
+ 'this consolidates both sides into one row instead of just discarding one.',
312
+ inputSchema: {
313
+ source_refs: z.array(z.string()).describe('Refs of the losing memories to fold into the target (all must be active)'),
314
+ target_ref: z.string().describe('Ref of the canonical memory that survives the merge (must already exist, active)'),
315
+ note: z.string().optional().describe('Optional note about this merge, stored in supersession tombstones on the losers'),
316
+ hard_delete_losers: z.boolean().optional().describe('Hard-delete losers after archiving/superseding them (default false).'),
317
+ append_loser_bodies: z.boolean().optional().describe('Append loser bodies to the target content under section markers (default false). ' +
318
+ 'Off by default — review loser bodies (returned in the result) before deciding.'),
319
+ },
320
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
321
+ }, async ({ source_refs, target_ref, note, hard_delete_losers, append_loser_bodies }) => {
322
+ const result = await memoryMerge(contextDir, {
323
+ source_refs, target_ref, note, hard_delete_losers, append_loser_bodies,
324
+ });
325
+ return { content: [{ type: 'text', text: result }] };
326
+ });
234
327
  // ─── Capture-propose queue ────────────────────────────────────────────────────
235
328
  // The staging area a background lane drafts memory writes into. A proposal is
236
329
  // NOT authored canon: it lives in a separate table, invisible to recall /
237
330
  // memory_list / the salience digest / find_similar, and becomes a real memory
238
331
  // only via an explicit memory_ratify. Never auto-committed.
239
- server.tool('memory_propose', 'Stage a DRAFT memory in the capture-propose queue for later ratification. ' +
240
- 'A proposal is NOT an authored memory: it is invisible to recall, memory_list, ' +
241
- 'find_similar, and the boot digest until it is ratified via memory_ratify. ' +
242
- 'Use this when a background lane wants to suggest a write without committing it — ' +
243
- 'the human (or Art) reviews and ratifies before it becomes canon. Drafts may be ' +
244
- 'rough; validation runs at ratify time.', {
245
- category: z.enum(MEMORY_CATEGORIES).describe('Memory category: user (about the human), project (about work), self (capability/learning), feedback (corrections/confirmations), reference (external pointers), pursuit (active goal or ongoing creative thread), episode (short-term cross-body tape: where you were / what was said or decided / what shipped / what is open — 48h TTL by default, set metadata.where to your surface e.g. "discord:#general", "voice", "wake:<id>", "lane:tending", "terminal")'),
246
- title: z.string().describe('Short title for the proposed memory'),
247
- content: z.string().describe('The proposed memory content'),
248
- project: z.string().optional().describe('Associated project, if any'),
249
- ttl: z.string().optional().describe('Time-to-live: "7d", "30d", "24h", "permanent", or omit'),
250
- metadata: z.record(z.string(), z.unknown()).optional().describe('Arbitrary key-value metadata'),
251
- source: z.string().optional().describe('Where this proposal came from, e.g. a lane name'),
332
+ server.registerTool('memory_propose', {
333
+ description: 'Stage a DRAFT memory in the capture-propose queue for later ratification. ' +
334
+ 'A proposal is NOT an authored memory: it is invisible to recall, memory_list, ' +
335
+ 'find_similar, and the boot digest until it is ratified via memory_ratify. ' +
336
+ 'Use this when a background lane wants to suggest a write without committing it — ' +
337
+ 'the human (or Art) reviews and ratifies before it becomes canon. Drafts may be ' +
338
+ 'rough; validation runs at ratify time.',
339
+ inputSchema: {
340
+ category: z.enum(MEMORY_CATEGORIES).describe('Memory category: user (about the human), project (about work), self (capability/learning), feedback (corrections/confirmations), reference (external pointers), pursuit (active goal or ongoing creative thread), episode (short-term cross-body tape: where you were / what was said or decided / what shipped / what is open — 48h TTL by default, set metadata.where to your surface e.g. "discord:#general", "voice", "wake:<id>", "lane:tending", "terminal")'),
341
+ title: z.string().describe('Short title for the proposed memory'),
342
+ content: z.string().describe('The proposed memory content'),
343
+ project: z.string().optional().describe('Associated project, if any'),
344
+ ttl: z.string().optional().describe('Time-to-live: "7d", "30d", "24h", "permanent", or omit'),
345
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Arbitrary key-value metadata'),
346
+ source: z.string().optional().describe('Where this proposal came from, e.g. a lane name'),
347
+ },
348
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
252
349
  }, async ({ category, title, content, project, ttl, metadata, source }) => {
253
350
  const { id, uuid } = propose(contextDir, { category, title, content, project, ttl, metadata, source });
254
351
  return { content: [{ type: 'text', text: `Proposal staged: #${id} "${title}" (${uuid}) — pending ratification` }] };
255
352
  });
256
- server.tool('memory_proposals', 'List all pending proposals in the capture-propose queue, newest first. ' +
257
- 'These are DRAFTS awaiting ratification — they are not part of memory and ' +
258
- 'do not appear in recall, memory_list, find_similar, or the boot digest. ' +
259
- 'Ratify one with memory_ratify or discard it with memory_reject.', {}, async () => {
353
+ server.registerTool('memory_proposals', {
354
+ description: 'List all pending proposals in the capture-propose queue, newest first. ' +
355
+ 'These are DRAFTS awaiting ratification — they are not part of memory and ' +
356
+ 'do not appear in recall, memory_list, find_similar, or the boot digest. ' +
357
+ 'Ratify one with memory_ratify or discard it with memory_reject.',
358
+ inputSchema: {},
359
+ annotations: { readOnlyHint: true, openWorldHint: false },
360
+ }, async () => {
260
361
  const rows = listProposals(contextDir);
261
362
  if (rows.length === 0) {
262
363
  return { content: [{ type: 'text', text: 'No pending proposals.' }] };
@@ -269,18 +370,22 @@ export function createLoomServer(config) {
269
370
  });
270
371
  return { content: [{ type: 'text', text: `${rows.length} pending proposal(s):\n${lines.join('\n')}` }] };
271
372
  });
272
- server.tool('memory_ratify', 'Ratify a pending proposal into a REAL memory. Loads the proposal, applies any ' +
273
- 'optional overrides (your edits on accept), and commits it through the same ' +
274
- 'validated write path as remember — so an invalid proposal is refused with its ' +
275
- 'typed reason and stays pending. On success the memory becomes recallable and the ' +
276
- 'proposal is removed from the queue. This is the gate: no proposal becomes canon ' +
277
- 'without it.', {
278
- id: z.number().int().positive().describe('Proposal id (from memory_proposals)'),
279
- title: z.string().optional().describe('Override the proposed title on accept'),
280
- content: z.string().optional().describe('Override the proposed content on accept'),
281
- category: z.enum(MEMORY_CATEGORIES).optional().describe('Override the proposed category on accept'),
282
- project: z.string().optional().describe('Override the proposed project on accept'),
283
- ttl: z.string().optional().describe('Override the proposed TTL on accept'),
373
+ server.registerTool('memory_ratify', {
374
+ description: 'Ratify a pending proposal into a REAL memory. Loads the proposal, applies any ' +
375
+ 'optional overrides (your edits on accept), and commits it through the same ' +
376
+ 'validated write path as remember — so an invalid proposal is refused with its ' +
377
+ 'typed reason and stays pending. On success the memory becomes recallable and the ' +
378
+ 'proposal is removed from the queue. This is the gate: no proposal becomes canon ' +
379
+ 'without it.',
380
+ inputSchema: {
381
+ id: z.number().int().positive().describe('Proposal id (from memory_proposals)'),
382
+ title: z.string().optional().describe('Override the proposed title on accept'),
383
+ content: z.string().optional().describe('Override the proposed content on accept'),
384
+ category: z.enum(MEMORY_CATEGORIES).optional().describe('Override the proposed category on accept'),
385
+ project: z.string().optional().describe('Override the proposed project on accept'),
386
+ ttl: z.string().optional().describe('Override the proposed TTL on accept'),
387
+ },
388
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
284
389
  }, async ({ id, title, content, category, project, ttl }) => {
285
390
  try {
286
391
  const ref = await ratifyProposal(contextDir, id, { title, content, category, project, ttl });
@@ -294,9 +399,13 @@ export function createLoomServer(config) {
294
399
  return { content: [{ type: 'text', text: `Proposal #${id} refused — ${reason}. It remains pending.` }] };
295
400
  }
296
401
  });
297
- server.tool('memory_reject', 'Discard a pending proposal without committing it. Deletes the staging row; ' +
298
- 'no memory is written. Use this for drafts that should not become canon.', {
299
- id: z.number().int().positive().describe('Proposal id (from memory_proposals)'),
402
+ server.registerTool('memory_reject', {
403
+ description: 'Discard a pending proposal without committing it. Deletes the staging row; ' +
404
+ 'no memory is written. Use this for drafts that should not become canon.',
405
+ inputSchema: {
406
+ id: z.number().int().positive().describe('Proposal id (from memory_proposals)'),
407
+ },
408
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
300
409
  }, async ({ id }) => {
301
410
  const removed = rejectProposal(contextDir, id);
302
411
  return {
@@ -307,259 +416,237 @@ export function createLoomServer(config) {
307
416
  };
308
417
  });
309
418
  // ─── Identity Update ────────────────────────────────────────────────────────
310
- server.tool('update_identity', 'Update your self-model or preferences with section-level precision. ' +
311
- 'Targets H2 sections in identity files. Call without section/content to ' +
312
- 'list available sections. IDENTITY.md (the creed) is immutable — only ' +
313
- 'self-model and preferences can be edited.', {
314
- file: z.enum(['self-model', 'preferences']).describe('Which identity file to update: "self-model" or "preferences"'),
315
- section: z.string().optional().describe('H2 section name to target. Omit to list all sections.'),
316
- content: z.string().optional().describe('New content for the section (replaces everything under the H2 header)'),
317
- mode: z.enum(['replace', 'append']).optional().describe('"replace" updates an existing section (default), "append" adds a new section'),
419
+ server.registerTool('update_identity', {
420
+ description: 'Update your self-model or preferences with section-level precision. ' +
421
+ 'Targets H2 sections in identity files. Call without section/content to ' +
422
+ 'list available sections. IDENTITY.md (the creed) is immutable — only ' +
423
+ 'self-model and preferences can be edited.',
424
+ inputSchema: {
425
+ file: z.enum(['self-model', 'preferences']).describe('Which identity file to update: "self-model" or "preferences"'),
426
+ section: z.string().optional().describe('H2 section name to target. Omit to list all sections.'),
427
+ content: z.string().optional().describe('New content for the section (replaces everything under the H2 header)'),
428
+ mode: z.enum(['replace', 'append']).optional().describe('"replace" updates an existing section (default), "append" adds a new section'),
429
+ },
430
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
318
431
  }, async ({ file, section, content, mode }) => {
319
432
  const result = await updateIdentity(contextDir, { file, section, content, mode });
320
433
  return { content: [{ type: 'text', text: result }] };
321
434
  });
322
435
  // ─── Bootstrap ──────────────────────────────────────────────────────────────
323
- server.tool('bootstrap', 'Initialize a new loom identity from scratch. Generates IDENTITY.md, preferences.md, ' +
324
- 'and self-model.md from an onboarding interview, then returns setup instructions for ' +
325
- 'the requested runtimes. The interview is four questions — the user\'s name, the ' +
326
- "agent's name, a one-line purpose, a one-line voice; everything structural (continuity " +
327
- 'model, memory tiers, reflection, honesty) is written by the scaffold, so do not ask ' +
328
- 'for it. Will not overwrite existing files unless force is true.', {
329
- user: z.string().optional().describe("The human this agent works with — their name, not the agent's"),
330
- name: z.string().describe('Name for the agent identity (e.g. "Aria")'),
331
- purpose: z.string().describe('What this agent exists to do — its reason for being, one line'),
332
- voice: z.string().describe('Communication style and personality, one line'),
333
- preferences: z.string().optional().describe('Seed preferences about the user or working style'),
334
- clients: z.array(z.string()).optional().describe('Runtimes to generate setup instructions for: "claude-code", "gemini-cli", or any custom runtime name (uses a generic template)'),
335
- force: z.boolean().optional().describe('Overwrite existing identity files (default: false)'),
436
+ server.registerTool('bootstrap', {
437
+ description: 'Initialize a new loom identity from scratch. Generates IDENTITY.md, preferences.md, ' +
438
+ 'and self-model.md from an onboarding interview, then returns setup instructions for ' +
439
+ 'the requested runtimes. The interview is four questions — the user\'s name, the ' +
440
+ "agent's name, a one-line purpose, a one-line voice; everything structural (continuity " +
441
+ 'model, memory tiers, reflection, honesty) is written by the scaffold, so do not ask ' +
442
+ 'for it. Will not overwrite existing files unless force is true.',
443
+ inputSchema: {
444
+ user: z.string().optional().describe("The human this agent works with — their name, not the agent's"),
445
+ name: z.string().describe('Name for the agent identity (e.g. "Aria")'),
446
+ purpose: z.string().describe('What this agent exists to do — its reason for being, one line'),
447
+ voice: z.string().describe('Communication style and personality, one line'),
448
+ preferences: z.string().optional().describe('Seed preferences about the user or working style'),
449
+ clients: z.array(z.string()).optional().describe('Runtimes to generate setup instructions for: "claude-code", "gemini-cli", or any custom runtime name (uses a generic template)'),
450
+ force: z.boolean().optional().describe('Overwrite existing identity files (default: false)'),
451
+ },
452
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
336
453
  }, async ({ user, name, purpose, voice, preferences, clients, force }) => {
337
454
  const result = await bootstrap(contextDir, { user, name, purpose, voice, preferences, clients, force });
338
455
  return { content: [{ type: 'text', text: result }] };
339
456
  });
340
457
  // ─── Harness manifests ──────────────────────────────────────────────────────
341
- server.tool('harness_init', 'Scaffold a harness manifest at <contextDir>/harnesses/<name>.md from the template ' +
342
- '(see stack spec v1 §4.7). Call this when identity() reports a missing manifest for the ' +
343
- 'current harness. Idempotent: skip-exists by default; overwrite: true replaces.\n\n' +
344
- 'When `target` is supplied, also writes a loom-managed block (bounded by ' +
345
- '<!-- loom:start / loom:end --> markers with an embedded <!-- loom:hash --> line) ' +
346
- 'into that file — typically the project CLAUDE.md. Re-runnable: the block is ' +
347
- 'left unchanged when already present and intact ("no-change"), reinstalled when ' +
348
- 'missing or corrupted ("created" / "updated"). Pass an absolute path or a path ' +
349
- 'relative to the current working directory.', {
350
- name: z.string().describe('Harness name (e.g. "claude-code", "codex", "gemini-cli")'),
351
- overwrite: z.boolean().optional().describe('Replace existing manifest (default: false)'),
352
- target: z.string().optional().describe('Path to the dotfile to inject the managed loom block into (e.g. an absolute path ' +
353
- 'to CLAUDE.md / AGENTS.md / GEMINI.md). When omitted only the harness manifest ' +
354
- 'is scaffolded.'),
458
+ server.registerTool('harness_init', {
459
+ description: 'Scaffold a harness manifest at <contextDir>/harnesses/<name>.md from the template ' +
460
+ '(see stack spec v1 §4.7). Call this when identity() reports a missing manifest for the ' +
461
+ 'current harness. Idempotent: skip-exists by default; overwrite: true replaces.\n\n' +
462
+ 'When `target` is supplied, also writes a loom-managed block (bounded by ' +
463
+ '<!-- loom:start / loom:end --> markers with an embedded <!-- loom:hash --> line) ' +
464
+ 'into that file — typically the project CLAUDE.md. Re-runnable: the block is ' +
465
+ 'left unchanged when already present and intact ("no-change"), reinstalled when ' +
466
+ 'missing or corrupted ("created" / "updated"). Pass an absolute path or a path ' +
467
+ 'relative to the current working directory.',
468
+ inputSchema: {
469
+ name: z.string().describe('Harness name (e.g. "claude-code", "codex", "gemini-cli")'),
470
+ overwrite: z.boolean().optional().describe('Replace existing manifest (default: false)'),
471
+ target: z.string().optional().describe('Path to the dotfile to inject the managed loom block into (e.g. an absolute path ' +
472
+ 'to CLAUDE.md / AGENTS.md / GEMINI.md). When omitted only the harness manifest ' +
473
+ 'is scaffolded.'),
474
+ },
475
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
355
476
  }, async ({ name, overwrite, target }) => {
356
477
  const text = await harnessInit(contextDir, { name, overwrite, target });
357
478
  return { content: [{ type: 'text', text }] };
358
479
  });
359
- server.tool('harness_describe', 'Self-describe the CURRENTLY CONNECTED harness: write its manifest at ' +
360
- '<contextDir>/harnesses/<key>.md (see stack spec v1 §4.7). Call this when ' +
361
- 'identity() reports an onboarding block for an unknown runtime. The target is ' +
362
- 'derived from your own MCP clientInfo.name — you can only describe yourself, ' +
363
- 'not another harness. Re-runnable: overwrites the manifest each time. The body ' +
364
- 'should cover: tool surface / prefixes, sandbox & filesystem, delegation ' +
365
- 'primitive, scheduling, session search, memory layers, and gotchas.', {
366
- content: z.string().describe('The manifest body (markdown). Frontmatter is stamped automatically.'),
367
- version: z.string().optional().describe('Manifest version stamp (default "0.1").'),
480
+ server.registerTool('harness_describe', {
481
+ description: 'Self-describe the CURRENTLY CONNECTED harness: write its manifest at ' +
482
+ '<contextDir>/harnesses/<key>.md (see stack spec v1 §4.7). Call this when ' +
483
+ 'identity() reports an onboarding block for an unknown runtime. The target is ' +
484
+ 'derived from your own MCP clientInfo.name — you can only describe yourself, ' +
485
+ 'not another harness. Re-runnable: overwrites the manifest each time. The body ' +
486
+ 'should cover: tool surface / prefixes, sandbox & filesystem, delegation ' +
487
+ 'primitive, scheduling, session search, memory layers, and gotchas.',
488
+ inputSchema: {
489
+ content: z.string().describe('The manifest body (markdown). Frontmatter is stamped automatically.'),
490
+ version: z.string().optional().describe('Manifest version stamp (default "0.1").'),
491
+ },
492
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
368
493
  }, async ({ content, version }) => {
369
494
  const peer = server.server.getClientVersion()?.name;
370
495
  const text = await harnessDescribe(contextDir, { content, version }, peer);
371
496
  return { content: [{ type: 'text', text }] };
372
497
  });
373
498
  // ─── Knowledge ──────────────────────────────────────────────────────────────
374
- server.tool('knowledge_write', 'Upsert a knowledge page by slug. Two classes:\n' +
375
- ' world/ (default) — facts true independent of us. Domain = "music/gear", "software/loom", etc.\n' +
376
- ' ours/ — Art-created artifacts, revised-in-place (breakbrain density model, homelab design,\n' +
377
- ' wake-chain spec, script templates). Domain starts with "ours/", e.g. "ours/art-ops".\n' +
378
- 'On an existing slug: body REPLACES by default (mode: "append" adds to it instead), ' +
379
- 'title/domain follow the write, citations always appended with exact-duplicate dedup — safe to re-send.\n' +
380
- 'Epistemic gate (§E1):\n' +
381
- ' • conversation-only citations → provisional (both classes).\n' +
382
- ' • any repo citation → internal (ours/ class; repo = git path / commit / live-system probe).\n' +
383
- ' • any web citation, no repo → sourced (world/ default).\n' +
384
- 'World filing test: knowledge must be true independent of Jonathan. For our own artifacts use ours/.', {
385
- title: z.string().describe('Page title — the entity name (e.g. "Mutable Instruments Rings") or artifact name (e.g. "breakbrain density model")'),
386
- domain: z.string().describe('Domain tag. World class: "music/eurorack", "programming/typescript". ' +
387
- 'Ours class: prefix with "ours/" — e.g. "ours/art-ops", "ours/breakbrain", "ours/homelab". ' +
388
- 'Hierarchical string; sub-domains queryable via prefix filter.'),
389
- body: z.string().describe('Synthesized markdown body for the entity or artifact page (max 64 KB)'),
390
- slug: z.string().optional().describe('Entity key for upsert — stable URL-safe identifier. ' +
391
- 'Derived from title if omitted.'),
392
- 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 ' +
393
- 'device, "as of 2026-05" for a topic, or "t-81 / 2026-08-31" for an ours/ artifact. ' +
394
- 'Drives the verification engine: a page is re-verified when this anchor moves or the freshness SLA elapses.'),
395
- mode: z.enum(['replace', 'append']).optional().describe('Body combine mode when the slug already exists: "replace" (default) overwrites the ' +
396
- 'stored body; "append" adds this body after the existing one. Citations are appended ' +
397
- '(deduped) in both modes. Ignored when creating a new page.'),
398
- citations: z.array(z.object({
399
- claim: z.string().describe('The assertion this citation supports'),
400
- source_kind: z.enum(['web', 'loom_memory', 'conversation', 'repo']).describe('web = external URL; loom_memory = opaque memory ref; conversation = session distillation; ' +
401
- 'repo = git repo path / commit / live-system probe (ours/ class)'),
402
- source_locator: z.string().optional().describe('URL, memory ref, session ID, or repo path + commit hash'),
403
- excerpt: z.string().describe('Inline supporting quote or repo excerpt — link-rot insurance (max 4 KB)'),
404
- })).describe('Support citations. At least one required. ' +
405
- 'All-conversation → provisional. Any repo → internal (ours/). Any web → sourced.'),
406
- created_by: z.string().optional().describe('ours/ class: who created or last owned this artifact (e.g. "art", "jonathan"). ' +
407
- 'Preserved across upserts when omitted.'),
408
- version: z.string().optional().describe('ours/ class: artifact version or revision tag (e.g. "v2", "2026-08-31", "t-81"). ' +
409
- 'Preserved across upserts when omitted.'),
410
- }, async ({ title, domain, body, slug, freshness_anchor, mode, citations, created_by, version }) => {
411
- const result = await knowledgeWrite(contextDir, { title, domain, body, slug, freshness_anchor, mode, citations, created_by, version });
412
- return { content: [{ type: 'text', text: result }] };
413
- });
414
- server.tool('knowledge_recall', 'Search the knowledge store with LIKE matching over title, body, and domain, ' +
415
- 'or fetch one page exactly by slug. Never surfaces archived pages. Two detail ' +
416
- 'tiers: "full" returns whole entity pages (the synthesis unit) and stamps ' +
417
- 'last_accessed/hit_count; "index" returns compact slug/domain/snippet entries ' +
418
- 'without stamping. Defaults: full when a query is given, index when browsing ' +
419
- 'without one. Full output is size-guarded — overflow results degrade to index ' +
420
- 'entries; recall by slug to read them. Prefer slug over query when you know ' +
421
- 'the page — token matching can hit cross-references in other pages\' bodies.', {
422
- slug: z.string().optional().describe('Exact-slug lookup — returns that single page in full detail and stamps ' +
423
- 'access. Takes precedence over query/domain/limit.'),
424
- query: z.string().optional().describe('Search terms — matched against title, body, and domain. ' +
425
- 'Omit to browse (returns an index of non-archived pages up to limit).'),
426
- domain: z.string().optional().describe('Filter by domain prefix, inclusive of the exact domain ' +
427
- '(e.g. "music/gear" matches "music/gear" and "music/gear/elektron")'),
428
- limit: z.number().int().positive().optional().describe('Maximum results to return (default: 10)'),
429
- detail: z.enum(['index', 'full']).optional().describe('Output tier override. "index": compact listing, no body, no access stamping. ' +
430
- '"full": whole pages with citations. Default: full with a query, index without.'),
431
- sort_by_verified: z.boolean().optional().describe('Stale-first ordering for the verification engine: verified_at ASC with ' +
432
- 'never-verified pages first. Index entries gain a "verified:" stamp so the ' +
433
- 'SLA filter can run from the listing alone.'),
434
- }, async ({ slug, query, domain, limit, detail, sort_by_verified }) => {
435
- const result = await knowledgeRecall(contextDir, { slug, query, domain, limit, detail, sort_by_verified });
499
+ // The local identity — LOOM_IDENTITY, else the context dir's real name —
500
+ // since this server runs as whichever identity owns contextDir, not only
501
+ // Art. The standalone knowledge-only service (knowledge-http-server.ts)
502
+ // resolves identity per-connection from its per-identity bearer token instead.
503
+ registerKnowledgeTools(server, { contextDir, gapsDir, identity: resolveIdentityName(contextDir), personCards: true });
504
+ server.registerTool('memory_history', {
505
+ description: 'Body-revision history for a memory. update() snapshots the displaced body into ' +
506
+ 'memory_revisions before overwriting it — this tool is the read surface. ' +
507
+ 'Two modes: ref alone lists snapshots (metadata only — id, op, replaced_at, char count); ' +
508
+ 'ref + revision_id reads one snapshot\'s full content. ' +
509
+ 'To put a snapshot back, use memory_revision_restore.',
510
+ inputSchema: {
511
+ ref: z.string().describe('Ref of the memory (category/title slug).'),
512
+ revision_id: z.number().int().positive().optional().describe('Revision to read (from the listing). Omit to list all revisions.'),
513
+ },
514
+ annotations: { readOnlyHint: true, openWorldHint: false },
515
+ }, async ({ ref, revision_id }) => {
516
+ const result = await memoryHistory(contextDir, { ref, revision_id });
436
517
  return { content: [{ type: 'text', text: result }] };
437
518
  });
438
- server.tool('knowledge_maintain', 'Read-only health report for the knowledge store. Three branches: ' +
439
- '(1) expansion candidates — thin body + high hit_count (needs deepening); ' +
440
- '(2) cold pages — not accessed recently (unused or undiscovered); ' +
441
- '(3) misfile audit — provisional sourcing or conversation-only citations ' +
442
- '(world/ class: should be in the memory store instead; ours/ class with internal sourcing are NOT misfiles — they are correct). ' +
443
- 'Pair with knowledge_write to act on findings.', {
444
- expansion_hit_threshold: z.number().int().nonnegative().optional().describe('hit_count floor for expansion candidates (default 3; 0 considers every page)'),
445
- thin_body_threshold: z.number().int().positive().optional().describe('body char ceiling to consider a page thin (default 500)'),
446
- cold_days: z.number().int().positive().optional().describe('Days without access before a page is cold (default 30)'),
447
- }, async ({ expansion_hit_threshold, thin_body_threshold, cold_days }) => {
448
- const result = await knowledgeMaintain(contextDir, {
449
- expansionHitThreshold: expansion_hit_threshold,
450
- thinBodyThreshold: thin_body_threshold,
451
- coldDays: cold_days,
452
- });
519
+ server.registerTool('memory_revision_restore', {
520
+ description: 'Restore a snapshotted memory body back onto the memory. ' +
521
+ 'Snapshots the current body first, so restore is never destructive. ' +
522
+ 'Use memory_history to list revision IDs before calling this.',
523
+ inputSchema: {
524
+ ref: z.string().describe('Ref of the memory (category/title slug).'),
525
+ revision_id: z.number().int().positive().describe('Id of the revision to restore (from memory_history listing).'),
526
+ },
527
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
528
+ }, async ({ ref, revision_id }) => {
529
+ const result = await memoryRevisionRestore(contextDir, { ref, revision_id });
453
530
  return { content: [{ type: 'text', text: result }] };
454
531
  });
455
- server.tool('knowledge_archive', 'Soft-retire a knowledge page: set its status to archived with an optional tombstone note. ' +
456
- 'Archived pages are excluded from knowledge_recall and knowledge_maintain but remain ' +
457
- 'in the database and are fully recoverable via knowledge_restore. ' +
458
- 'Use this instead of deletion when the page may need to be audited or recovered. ' +
459
- 'For deduplication merges, prefer knowledge_supersede which archives and records the relationship.', {
460
- slug: z.string().describe('Slug of the knowledge page to archive'),
461
- note: z.string().optional().describe('Tombstone note: why this page is being retired'),
462
- }, async ({ slug, note }) => {
463
- const result = await knowledgeArchive(contextDir, { slug, note });
532
+ // ─── People ─────────────────────────────────────────────────────────────────
533
+ // Person cards (docs/specs/person-cards.md): the condensed current state of a
534
+ // human the agent works with. Current state, overwritten; revisions keep the
535
+ // past. Descriptive only — never an authorization surface. Schemas are
536
+ // strict: a raw shape strips unknown keys, and a misspelled section on a
537
+ // merge: false write would then silently clear state (PR143 review).
538
+ const cardValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]);
539
+ const slugParam = z.string().describe('Person slug: lowercase [a-z0-9-], e.g. "patrick-kells".');
540
+ server.registerTool('person_write', {
541
+ description: 'Write a person card — the condensed current state of what you know about one human ' +
542
+ '(not a knowledge page, not the memories: those stay the evidence). merge (default true) ' +
543
+ 'merges where/how one level deep (a field passed as null is removed), and replaces name, ' +
544
+ 'summary and may when passed; merge: false makes the card exactly what was passed. ' +
545
+ 'Passing summary says "I re-read the evidence and this is still right" and moves ' +
546
+ 'synthesized_at. Reserved keys (tier, field, projects, scope, trust) are rejected: gates ' +
547
+ 'stay in the harness. Worker bodies must not call this — cards are written by Art.',
548
+ inputSchema: z.object({
549
+ slug: slugParam,
550
+ name: z.string().optional().describe('Display name (required on create and on merge: false), ≤ 80 chars.'),
551
+ summary: z.string().optional().describe('Short synthesized summary, ≤ 1,200 chars.'),
552
+ where: z.record(z.string(), z.union([
553
+ z.object({
554
+ value: cardValue,
555
+ verified_at: z.string().optional(),
556
+ source: z.string().optional(),
557
+ stale_after_days: z.number().int().optional(),
558
+ }).passthrough(),
559
+ z.null(),
560
+ ])).optional().describe('Where the person is in each system: field -> {value, verified_at?, source?, stale_after_days?}. ' +
561
+ 'null removes a field (merge writes only).'),
562
+ how: z.record(z.string(), z.union([cardValue, z.null()])).optional().describe('How to work with them: field -> value (e.g. tz, mode, loop_in). null removes a field.'),
563
+ may: z.array(z.object({ text: z.string(), source: z.string() }).passthrough()).optional().describe('What they were said to be allowed to do, each with its source. Descriptive, not a grant. Replaces the whole list.'),
564
+ merge: z.boolean().optional().describe('Default true. false: the card becomes exactly what was passed.'),
565
+ }).strict(),
566
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
567
+ }, async (input) => {
568
+ const result = await personWrite(contextDir, input);
464
569
  return { content: [{ type: 'text', text: result }] };
465
570
  });
466
- server.tool('knowledge_restore', 'Restore a previously archived knowledge page back to active status. ' +
467
- 'Clears the archive flag and tombstone note. The page becomes visible ' +
468
- 'to knowledge_recall and knowledge_maintain again.', {
469
- slug: z.string().describe('Slug of the archived knowledge page to restore'),
470
- }, async ({ slug }) => {
471
- const result = await knowledgeRestore(contextDir, { slug });
571
+ server.registerTool('person_verify', {
572
+ description: 'Stamp where fields on a person card as checked: sets verified_at (default now) and, if ' +
573
+ 'passed, source. Values are not changed. All-or-nothing: an unknown field rejects the call. ' +
574
+ 'A failed check is recorded by not stamping.',
575
+ inputSchema: z.object({
576
+ slug: slugParam,
577
+ fields: z.array(z.string()).describe('where field names to stamp.'),
578
+ verified_at: z.string().optional().describe('ISO-8601 UTC; default now; not in the future.'),
579
+ source: z.string().optional().describe('What you checked against, e.g. "pond /api/tadpoles".'),
580
+ }).strict(),
581
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
582
+ }, async ({ slug, fields, verified_at, source }) => {
583
+ const result = await personVerify(contextDir, { slug, fields, verified_at, source });
472
584
  return { content: [{ type: 'text', text: result }] };
473
585
  });
474
- server.tool('knowledge_move', 'Re-key or re-domain a knowledge page in place — same row, same uuid, citations and verification history preserved. ' +
475
- 'Three modes: (1) Single-page: provide slug + new_slug and/or new_domain. ' +
476
- 'Slug rename writes a supersessions pointer (old→new) unless leave_pointer=false. ' +
477
- 'If new_slug already exists, the call is rejected — use knowledge_merge instead. ' +
478
- '(2) Batch by slug list: provide slugs array + new_domain to re-home multiple pages atomically. ' +
479
- '(3) Batch by domain prefix: provide from_domain_prefix + to_domain_prefix to re-home a whole subtree in one transaction.', {
480
- slug: z.string().optional().describe('Current slug of the page to move (single-page mode)'),
481
- new_slug: z.string().optional().describe('New slug (re-slug). Collision with an existing page is rejected — use knowledge_merge instead.'),
482
- new_domain: z.string().optional().describe('New domain for the page (single-page re-domain or shared target for batch-by-slugs mode)'),
483
- leave_pointer: z.boolean().optional().describe('Write a supersessions pointer old_slug→new_slug when the slug changes. Default true.'),
484
- 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.'),
485
- from_domain_prefix: z.string().optional().describe('Batch prefix mode: domain prefix to replace (e.g. "gear/elektron"). Requires to_domain_prefix.'),
486
- to_domain_prefix: z.string().optional().describe('Batch prefix mode: replacement domain prefix (e.g. "instruments/elektron").'),
487
- }, async ({ slug, new_slug, new_domain, leave_pointer, slugs, from_domain_prefix, to_domain_prefix }) => {
488
- const result = await knowledgeMove(contextDir, {
489
- slug, new_slug, new_domain, leave_pointer, slugs, from_domain_prefix, to_domain_prefix,
490
- });
586
+ server.registerTool('person_archive', {
587
+ description: 'Soft-retire a person card with a tombstone (who, when, why). An archived card is left ' +
588
+ 'out of identity\'s room block and person_list, and rejects writes until person_restore.',
589
+ inputSchema: z.object({
590
+ slug: slugParam,
591
+ reason: z.string().describe('Why the card is being retired, ≤ 200 chars.'),
592
+ }).strict(),
593
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
594
+ }, async ({ slug, reason }) => {
595
+ const result = await personArchive(contextDir, { slug, reason });
491
596
  return { content: [{ type: 'text', text: result }] };
492
597
  });
493
- server.tool('knowledge_merge', 'Consolidate 2+ knowledge pages into one canonical page. ' +
494
- 'Re-parents all citations from source pages to the target, deduplicating by (claim, source_kind, source_locator, excerpt). ' +
495
- 'Takes MAX(verified_at) across all pages. ' +
496
- 'Losers are superseded: archived with a tombstone and a supersessions pointer to the target. ' +
497
- 'Loser bodies are returned in the result for curator review; set append_loser_bodies=true to concatenate them. ' +
498
- 'Use knowledge_write first if the target body needs updating before merging. ' +
499
- 'Distinct from knowledge_supersede (1:1 pointer, no citation consolidation) — ' +
500
- 'use merge when consolidating data from multiple pages into one.', {
501
- source_slugs: z.array(z.string()).describe('Slugs of the pages to merge into the target (all must exist)'),
502
- target_slug: z.string().describe('Slug of the canonical target page that survives the merge (must already exist)'),
503
- note: z.string().optional().describe('Optional note about this merge, stored in supersession tombstones on the losers'),
504
- 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.'),
505
- 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.'),
506
- }, async ({ source_slugs, target_slug, note, hard_delete_losers, append_loser_bodies }) => {
507
- const result = await knowledgeMerge(contextDir, {
508
- source_slugs, target_slug, note, hard_delete_losers, append_loser_bodies,
509
- });
598
+ server.registerTool('person_restore', {
599
+ description: 'Reactivate an archived person card, fields intact.',
600
+ inputSchema: z.object({ slug: slugParam }).strict(),
601
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
602
+ }, async ({ slug }) => {
603
+ const result = await personRestore(contextDir, { slug });
510
604
  return { content: [{ type: 'text', text: result }] };
511
605
  });
512
- server.tool('knowledge_supersede', 'Mark one knowledge page as superseded by another, then archive the old page. ' +
513
- 'Records the supersession relationship in the supersessions table. ' +
514
- 'This is the dedup-merge primitive: write the canonical page with knowledge_write, ' +
515
- 'then call knowledge_supersede(old_slug=loser, new_slug=canonical). ' +
516
- 'Both pages must already exist. old_slug is archived with a tombstone pointing to new_slug.', {
517
- old_slug: z.string().describe('Slug of the page being retired (the duplicate or loser)'),
518
- new_slug: z.string().describe('Slug of the canonical replacement page (must already exist)'),
519
- note: z.string().optional().describe('Optional note explaining the merge or supersession decision'),
520
- }, async ({ old_slug, new_slug, note }) => {
521
- const result = await knowledgeSupersede(contextDir, { old_slug, new_slug, note });
606
+ server.registerTool('person', {
607
+ description: 'Read one person card: {found, rendered, card, archived, updated, synthesized_at}. ' +
608
+ 'A missing card is {found: false}, not an error. where fields carry a freshness marker: ' +
609
+ '[ok], [unverified], or [STALE <n>d] — never trust a stale one without re-checking.',
610
+ inputSchema: z.object({
611
+ slug: slugParam,
612
+ include_archived: z.boolean().optional().describe('Return an archived card in full (default false).'),
613
+ }).strict(),
614
+ annotations: { readOnlyHint: true, openWorldHint: false },
615
+ }, async ({ slug, include_archived }) => {
616
+ const result = await person(contextDir, { slug, include_archived });
522
617
  return { content: [{ type: 'text', text: result }] };
523
618
  });
524
- server.tool('knowledge_purge', 'Hard-delete one or more archived knowledge pages and cascade their citations. ' +
525
- 'Archive-first guard: rejects any page that is not already archived — call knowledge_archive first. ' +
526
- 'All slugs must be archived; a mixed list (any active) rejects the entire batch with no mutation. ' +
527
- 'confirm: true is required explicitly to prevent accidental irreversible deletes. ' +
528
- 'Supersession pointers in the supersessions table are NOT removed (historical record preserved). ' +
529
- 'Use this to clean up tombstoned cruft after merge/supersede workflows — not for retiring active pages.', {
530
- slugs: z.array(z.string()).describe('Slugs of archived pages to hard-delete. All must have status=archived.'),
531
- confirm: z.literal(true).describe('Must be explicitly true — required safety gate for an irreversible operation.'),
532
- }, async ({ slugs, confirm }) => {
533
- const result = await knowledgePurge(contextDir, { slugs, confirm });
619
+ server.registerTool('person_list', {
620
+ description: 'List person cards with slug, name, archived, updated, synthesized_at, stale and unverified ' +
621
+ 'field counts, and memories_since_synthesis (tagged user memories newer than the summary — ' +
622
+ 'how the consolidation lane finds summaries that have fallen behind).',
623
+ inputSchema: z.object({
624
+ include_archived: z.boolean().optional().describe('Include archived cards (default false).'),
625
+ }).strict(),
626
+ annotations: { readOnlyHint: true, openWorldHint: false },
627
+ }, async ({ include_archived }) => {
628
+ const result = await personList(contextDir, { include_archived });
534
629
  return { content: [{ type: 'text', text: result }] };
535
630
  });
536
- server.tool('knowledge_verify', 'Stamp a knowledge page as verified WITHOUT touching its body — sets verified_at ' +
537
- 'and optionally freshness_anchor. This is the verification engine\'s primitive: ' +
538
- 'use it (never knowledge_write) to record "claims still hold". An optional note ' +
539
- 'appends a dated "## Verification" section to the body (append-only, single-page ' +
540
- 'mode). Batch mode (slugs) stamps many pages with a shared timestamp; archived ' +
541
- 'pages are rejected; a batch with any unknown slug is rejected whole.', {
542
- slug: z.string().optional().describe('Single-page mode: slug of the page to verify.'),
543
- slugs: z.array(z.string()).optional().describe('Batch mode: stamp many pages at once. Mutually exclusive with slug; ' +
544
- 'note and freshness_anchor are not allowed in batch mode.'),
545
- verified_at: z.string().optional().describe('ISO timestamp to stamp. Defaults to now.'),
546
- freshness_anchor: z.string().optional().describe('New freshness anchor (e.g. "Syntakt OS 1.41"). Preserved when omitted. Single-page mode only.'),
547
- note: z.string().optional().describe('Optional verification note — appended to the body as a "## Verification — <date>" ' +
548
- 'section. Never replaces the body. Single-page mode only.'),
549
- }, async ({ slug, slugs, verified_at, freshness_anchor, note }) => {
550
- const result = await knowledgeVerify(contextDir, { slug, slugs, verified_at, freshness_anchor, note });
631
+ server.registerTool('person_history', {
632
+ description: 'Revision history of a person card, newest first: id, op, replaced_at. Every stored change ' +
633
+ 'snapshots the previous card; the newest 10 are kept. Use person_revision_restore to put one back.',
634
+ inputSchema: z.object({ slug: slugParam }).strict(),
635
+ annotations: { readOnlyHint: true, openWorldHint: false },
636
+ }, async ({ slug }) => {
637
+ const result = await personHistory(contextDir, { slug });
551
638
  return { content: [{ type: 'text', text: result }] };
552
639
  });
553
- server.tool('knowledge_history', 'Body-revision history for a knowledge page. Replace-writes snapshot the displaced ' +
554
- 'body into page_revisions (newest kept, capped per page) — this tool is the recovery ' +
555
- 'surface. Three modes: slug alone lists snapshots (metadata only); slug + revision_id ' +
556
- 'reads one snapshot\'s full body; adding restore: true puts that body back on the page ' +
557
- '(the displaced body is snapshotted first, so restore is never destructive).', {
558
- slug: z.string().describe('Slug of the knowledge page.'),
559
- revision_id: z.number().int().positive().optional().describe('Revision to read (from the listing). Combine with restore: true to put it back.'),
560
- restore: z.boolean().optional().describe('Restore the revision\'s body onto the page. Requires revision_id.'),
561
- }, async ({ slug, revision_id, restore }) => {
562
- const result = await knowledgeHistory(contextDir, { slug, revision_id, restore });
640
+ server.registerTool('person_revision_restore', {
641
+ description: 'Put a person card back to a revision from person_history (card and its synthesized_at). ' +
642
+ 'Snapshots the current card first, so it is never destructive. Archive state is unchanged.',
643
+ inputSchema: z.object({
644
+ slug: slugParam,
645
+ revision_id: z.number().int().positive().describe('Revision id from person_history.'),
646
+ }).strict(),
647
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
648
+ }, async ({ slug, revision_id }) => {
649
+ const result = await personRevisionRestore(contextDir, { slug, revision_id });
563
650
  return { content: [{ type: 'text', text: result }] };
564
651
  });
565
652
  // MCP-App render probe — env-gated, throwaway. Not part of the tool surface