@jbarket/loomai 0.5.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.
- package/CHANGELOG.md +475 -0
- package/LICENSE +661 -0
- package/README.md +702 -0
- package/assets/demo.cast +396 -0
- package/assets/skill/SKILL.md +192 -0
- package/dist/backends/embedding-runtime.d.ts +39 -0
- package/dist/backends/embedding-runtime.js +265 -0
- package/dist/backends/episodes.d.ts +36 -0
- package/dist/backends/episodes.js +126 -0
- package/dist/backends/fastembed.d.ts +18 -0
- package/dist/backends/fastembed.js +95 -0
- package/dist/backends/glob.d.ts +10 -0
- package/dist/backends/glob.js +16 -0
- package/dist/backends/index.d.ts +6 -0
- package/dist/backends/index.js +78 -0
- package/dist/backends/migrations.d.ts +38 -0
- package/dist/backends/migrations.js +157 -0
- package/dist/backends/mmr.d.ts +45 -0
- package/dist/backends/mmr.js +82 -0
- package/dist/backends/proposals.d.ts +65 -0
- package/dist/backends/proposals.js +182 -0
- package/dist/backends/recall-log.d.ts +42 -0
- package/dist/backends/recall-log.js +143 -0
- package/dist/backends/retry.d.ts +29 -0
- package/dist/backends/retry.js +60 -0
- package/dist/backends/salience.d.ts +57 -0
- package/dist/backends/salience.js +186 -0
- package/dist/backends/sqlite-knowledge.d.ts +37 -0
- package/dist/backends/sqlite-knowledge.js +788 -0
- package/dist/backends/sqlite-vec.d.ts +64 -0
- package/dist/backends/sqlite-vec.js +737 -0
- package/dist/backends/ttl.d.ts +24 -0
- package/dist/backends/ttl.js +50 -0
- package/dist/backends/types.d.ts +562 -0
- package/dist/backends/types.js +8 -0
- package/dist/blocks/harness.d.ts +45 -0
- package/dist/blocks/harness.js +142 -0
- package/dist/blocks/model.d.ts +4 -0
- package/dist/blocks/model.js +56 -0
- package/dist/blocks/types.d.ts +40 -0
- package/dist/blocks/types.js +34 -0
- package/dist/categories.d.ts +16 -0
- package/dist/categories.js +29 -0
- package/dist/cli/args.d.ts +21 -0
- package/dist/cli/args.js +47 -0
- package/dist/cli/bootstrap.d.ts +2 -0
- package/dist/cli/bootstrap.js +214 -0
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +206 -0
- package/dist/cli/forget.d.ts +2 -0
- package/dist/cli/forget.js +97 -0
- package/dist/cli/harness.d.ts +2 -0
- package/dist/cli/harness.js +91 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +127 -0
- package/dist/cli/inject.d.ts +2 -0
- package/dist/cli/inject.js +298 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.js +137 -0
- package/dist/cli/io.d.ts +14 -0
- package/dist/cli/io.js +63 -0
- package/dist/cli/knowledge.d.ts +2 -0
- package/dist/cli/knowledge.js +216 -0
- package/dist/cli/memory.d.ts +2 -0
- package/dist/cli/memory.js +608 -0
- package/dist/cli/migrate.d.ts +2 -0
- package/dist/cli/migrate.js +116 -0
- package/dist/cli/recall.d.ts +2 -0
- package/dist/cli/recall.js +91 -0
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +111 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +71 -0
- package/dist/cli/subcommands.d.ts +7 -0
- package/dist/cli/subcommands.js +11 -0
- package/dist/cli/tui/multi-select.d.ts +53 -0
- package/dist/cli/tui/multi-select.js +116 -0
- package/dist/cli/update-identity.d.ts +2 -0
- package/dist/cli/update-identity.js +90 -0
- package/dist/cli/update.d.ts +2 -0
- package/dist/cli/update.js +87 -0
- package/dist/cli/wake.d.ts +2 -0
- package/dist/cli/wake.js +53 -0
- package/dist/clients.d.ts +8 -0
- package/dist/clients.js +63 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +160 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +75 -0
- package/dist/injection/harnesses.d.ts +17 -0
- package/dist/injection/harnesses.js +51 -0
- package/dist/injection/render.d.ts +17 -0
- package/dist/injection/render.js +50 -0
- package/dist/injection/writer.d.ts +25 -0
- package/dist/injection/writer.js +118 -0
- package/dist/install/harnesses.d.ts +26 -0
- package/dist/install/harnesses.js +90 -0
- package/dist/install/names.d.ts +16 -0
- package/dist/install/names.js +36 -0
- package/dist/install/render.d.ts +18 -0
- package/dist/install/render.js +42 -0
- package/dist/install/skill-source.d.ts +1 -0
- package/dist/install/skill-source.js +11 -0
- package/dist/path-safety.d.ts +19 -0
- package/dist/path-safety.js +62 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +571 -0
- package/dist/tools/append-maintenance-log.d.ts +52 -0
- package/dist/tools/append-maintenance-log.js +103 -0
- package/dist/tools/archive.d.ts +2 -0
- package/dist/tools/archive.js +21 -0
- package/dist/tools/bootstrap.d.ts +11 -0
- package/dist/tools/bootstrap.js +161 -0
- package/dist/tools/dossier.d.ts +1 -0
- package/dist/tools/dossier.js +102 -0
- package/dist/tools/eurorack-import.d.ts +47 -0
- package/dist/tools/eurorack-import.js +199 -0
- package/dist/tools/find-similar.d.ts +3 -0
- package/dist/tools/find-similar.js +26 -0
- package/dist/tools/forget.d.ts +6 -0
- package/dist/tools/forget.js +70 -0
- package/dist/tools/harness.d.ts +18 -0
- package/dist/tools/harness.js +54 -0
- package/dist/tools/identity-scaffold.d.ts +22 -0
- package/dist/tools/identity-scaffold.js +142 -0
- package/dist/tools/identity.d.ts +4 -0
- package/dist/tools/identity.js +226 -0
- package/dist/tools/knowledge-archive.d.ts +5 -0
- package/dist/tools/knowledge-archive.js +24 -0
- package/dist/tools/knowledge-history.d.ts +6 -0
- package/dist/tools/knowledge-history.js +55 -0
- package/dist/tools/knowledge-maintain.d.ts +9 -0
- package/dist/tools/knowledge-maintain.js +113 -0
- package/dist/tools/knowledge-merge.d.ts +8 -0
- package/dist/tools/knowledge-merge.js +40 -0
- package/dist/tools/knowledge-move.d.ts +10 -0
- package/dist/tools/knowledge-move.js +48 -0
- package/dist/tools/knowledge-purge.d.ts +5 -0
- package/dist/tools/knowledge-purge.js +20 -0
- package/dist/tools/knowledge-recall.d.ts +20 -0
- package/dist/tools/knowledge-recall.js +142 -0
- package/dist/tools/knowledge-restore.d.ts +4 -0
- package/dist/tools/knowledge-restore.js +24 -0
- package/dist/tools/knowledge-supersede.d.ts +6 -0
- package/dist/tools/knowledge-supersede.js +36 -0
- package/dist/tools/knowledge-verify.d.ts +8 -0
- package/dist/tools/knowledge-verify.js +39 -0
- package/dist/tools/knowledge-write.d.ts +25 -0
- package/dist/tools/knowledge-write.js +87 -0
- package/dist/tools/memory-audit.d.ts +3 -0
- package/dist/tools/memory-audit.js +47 -0
- package/dist/tools/memory-list.d.ts +4 -0
- package/dist/tools/memory-list.js +23 -0
- package/dist/tools/prune.d.ts +4 -0
- package/dist/tools/prune.js +28 -0
- package/dist/tools/recall.d.ts +4 -0
- package/dist/tools/recall.js +24 -0
- package/dist/tools/remember.d.ts +10 -0
- package/dist/tools/remember.js +43 -0
- package/dist/tools/restore.d.ts +2 -0
- package/dist/tools/restore.js +20 -0
- package/dist/tools/update-identity.d.ts +34 -0
- package/dist/tools/update-identity.js +194 -0
- package/dist/tools/update.d.ts +2 -0
- package/dist/tools/update.js +16 -0
- package/dist/transport/guards.d.ts +35 -0
- package/dist/transport/guards.js +107 -0
- package/dist/transport/http-server.d.ts +17 -0
- package/dist/transport/http-server.js +198 -0
- package/dist/transport/mcp-app-hello.d.ts +3 -0
- package/dist/transport/mcp-app-hello.js +66 -0
- package/dist/widgets/display-modes.html +131 -0
- package/dist/widgets/hello.html +138 -0
- package/dist/widgets/proposals.html +131 -0
- package/dist/widgets/top-of-mind.html +131 -0
- package/package.json +73 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loom — MCP Server Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates a McpServer with the core identity and memory tools registered.
|
|
5
|
+
* This is the portable identity layer — no routing, no orchestration,
|
|
6
|
+
* no chat clients. Just the tools that carry a persistent persona across
|
|
7
|
+
* any MCP-compatible runtime.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { assertStackVersionCompatible, assertContextBootable, resolveRepoRoot } from './config.js';
|
|
14
|
+
import { MEMORY_CATEGORIES } from './categories.js';
|
|
15
|
+
import { loadIdentity, resolveClientFromPeer } from './tools/identity.js';
|
|
16
|
+
import { loadDossier } from './tools/dossier.js';
|
|
17
|
+
import { remember } from './tools/remember.js';
|
|
18
|
+
import { recall } from './tools/recall.js';
|
|
19
|
+
import { update } from './tools/update.js';
|
|
20
|
+
import { forget } from './tools/forget.js';
|
|
21
|
+
import { prune } from './tools/prune.js';
|
|
22
|
+
import { memoryList } from './tools/memory-list.js';
|
|
23
|
+
import { tapeForContext } from './backends/episodes.js';
|
|
24
|
+
import { findSimilar } from './tools/find-similar.js';
|
|
25
|
+
import { memoryAudit } from './tools/memory-audit.js';
|
|
26
|
+
import { archive } from './tools/archive.js';
|
|
27
|
+
import { restore } from './tools/restore.js';
|
|
28
|
+
import { propose, listProposals, ratifyProposal, rejectProposal, UnknownProposalError, } from './backends/proposals.js';
|
|
29
|
+
import { updateIdentity } from './tools/update-identity.js';
|
|
30
|
+
import { bootstrap } from './tools/bootstrap.js';
|
|
31
|
+
import { harnessInit, harnessDescribe } from './tools/harness.js';
|
|
32
|
+
import { resolvePeerToHarness, normalizePeer } from './blocks/harness.js';
|
|
33
|
+
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';
|
|
45
|
+
// ─── Server Factory ───────────────────────────────────────────────────────────
|
|
46
|
+
export function createLoomServer(config) {
|
|
47
|
+
const { contextDir } = config;
|
|
48
|
+
// Refuse to boot against a stack this loom build doesn't understand.
|
|
49
|
+
assertStackVersionCompatible(contextDir);
|
|
50
|
+
// Refuse to serve a blank identity from the default fallback path.
|
|
51
|
+
assertContextBootable(contextDir);
|
|
52
|
+
const pkg = JSON.parse(readFileSync(join(resolveRepoRoot(), 'package.json'), 'utf-8'));
|
|
53
|
+
const server = new McpServer({
|
|
54
|
+
name: 'loom',
|
|
55
|
+
version: pkg.version,
|
|
56
|
+
});
|
|
57
|
+
// ─── 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 }) => {
|
|
71
|
+
// Precedence: explicit param > data-driven manifest resolution > the
|
|
72
|
+
// static code-map seed fallback > LOOM_CLIENT (inside loadIdentity).
|
|
73
|
+
const peer = server.server.getClientVersion()?.name;
|
|
74
|
+
// A connected peer always gets its harness OR an onboarding prompt (via its
|
|
75
|
+
// normalized name); LOOM_CLIENT is only the no-peer fallback (in loadIdentity).
|
|
76
|
+
const effectiveClient = client ?? (await resolvePeerToHarness(contextDir, peer)) ?? resolveClientFromPeer(peer) ?? (peer ? normalizePeer(peer) : undefined);
|
|
77
|
+
const result = await loadIdentity(contextDir, project, effectiveClient, model, role);
|
|
78
|
+
return { content: [{ type: 'text', text: result }] };
|
|
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.'),
|
|
93
|
+
}, async ({ project, client, model, role }) => {
|
|
94
|
+
const peer = server.server.getClientVersion()?.name;
|
|
95
|
+
// A connected peer always gets its harness OR an onboarding prompt (via its
|
|
96
|
+
// normalized name); LOOM_CLIENT is only the no-peer fallback (in loadIdentity).
|
|
97
|
+
const effectiveClient = client ?? (await resolvePeerToHarness(contextDir, peer)) ?? resolveClientFromPeer(peer) ?? (peer ? normalizePeer(peer) : undefined);
|
|
98
|
+
const result = await loadDossier(contextDir, project, effectiveClient, model, role);
|
|
99
|
+
return { content: [{ type: 'text', text: result }] };
|
|
100
|
+
});
|
|
101
|
+
// ─── 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 });
|
|
113
|
+
return { content: [{ type: 'text', text: `Memory stored: "${ref.title}" → ${ref.ref}` }] };
|
|
114
|
+
});
|
|
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.'),
|
|
126
|
+
}, async ({ query, category, project, limit, diversity }) => {
|
|
127
|
+
const result = await recall(contextDir, { query, category, project, limit, diversity });
|
|
128
|
+
return { content: [{ type: 'text', text: result }] };
|
|
129
|
+
});
|
|
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 });
|
|
139
|
+
return { content: [{ type: 'text', text: result }] };
|
|
140
|
+
});
|
|
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.'),
|
|
152
|
+
}, async ({ ref, category, title, project, title_pattern, confirm }) => {
|
|
153
|
+
const result = await forget(contextDir, { ref, category, title, project, title_pattern, confirm });
|
|
154
|
+
return { content: [{ type: 'text', text: result }] };
|
|
155
|
+
});
|
|
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)'),
|
|
159
|
+
}, async ({ dry_run, stale_days }) => {
|
|
160
|
+
const result = await prune(contextDir, { dryRun: dry_run, staleDays: stale_days });
|
|
161
|
+
return { content: [{ type: 'text', text: result }] };
|
|
162
|
+
});
|
|
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)'),
|
|
168
|
+
}, async ({ category, project, limit }) => {
|
|
169
|
+
const result = await memoryList(contextDir, { category, project, limit });
|
|
170
|
+
return { content: [{ type: 'text', text: result }] };
|
|
171
|
+
});
|
|
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)'),
|
|
177
|
+
}, async ({ hours }) => {
|
|
178
|
+
const tape = tapeForContext(contextDir, { hours, tokenBudget: 6000 });
|
|
179
|
+
return { content: [{ type: 'text', text: tape ?? `No episodes in the last ${hours ?? 24}h.` }] };
|
|
180
|
+
});
|
|
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)'),
|
|
191
|
+
}, async ({ ref, text, limit, category, project, min_relevance }) => {
|
|
192
|
+
const result = await findSimilar(contextDir, {
|
|
193
|
+
ref, text, limit, category, project, minRelevance: min_relevance,
|
|
194
|
+
});
|
|
195
|
+
return { content: [{ type: 'text', text: result }] };
|
|
196
|
+
});
|
|
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)'),
|
|
204
|
+
}, async ({ stale_days, similarity_threshold, max_duplicates }) => {
|
|
205
|
+
const result = await memoryAudit(contextDir, {
|
|
206
|
+
staleDays: stale_days,
|
|
207
|
+
similarityThreshold: similarity_threshold,
|
|
208
|
+
maxDuplicates: max_duplicates,
|
|
209
|
+
});
|
|
210
|
+
return { content: [{ type: 'text', text: result }] };
|
|
211
|
+
});
|
|
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'),
|
|
220
|
+
}, async ({ ref, category, title, note }) => {
|
|
221
|
+
const result = await archive(contextDir, { ref, category, title, note });
|
|
222
|
+
return { content: [{ type: 'text', text: result }] };
|
|
223
|
+
});
|
|
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'),
|
|
230
|
+
}, async ({ ref, category, title }) => {
|
|
231
|
+
const result = await restore(contextDir, { ref, category, title });
|
|
232
|
+
return { content: [{ type: 'text', text: result }] };
|
|
233
|
+
});
|
|
234
|
+
// ─── Capture-propose queue ────────────────────────────────────────────────────
|
|
235
|
+
// The staging area a background lane drafts memory writes into. A proposal is
|
|
236
|
+
// NOT authored canon: it lives in a separate table, invisible to recall /
|
|
237
|
+
// memory_list / the salience digest / find_similar, and becomes a real memory
|
|
238
|
+
// 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'),
|
|
252
|
+
}, async ({ category, title, content, project, ttl, metadata, source }) => {
|
|
253
|
+
const { id, uuid } = propose(contextDir, { category, title, content, project, ttl, metadata, source });
|
|
254
|
+
return { content: [{ type: 'text', text: `Proposal staged: #${id} "${title}" (${uuid}) — pending ratification` }] };
|
|
255
|
+
});
|
|
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 () => {
|
|
260
|
+
const rows = listProposals(contextDir);
|
|
261
|
+
if (rows.length === 0) {
|
|
262
|
+
return { content: [{ type: 'text', text: 'No pending proposals.' }] };
|
|
263
|
+
}
|
|
264
|
+
const lines = rows.map((r) => {
|
|
265
|
+
const src = r.source ? ` [${r.source}]` : '';
|
|
266
|
+
const body = (r.content ?? '').replace(/\s+/g, ' ').trim();
|
|
267
|
+
const clipped = body.length > 120 ? body.slice(0, 117).trimEnd() + '…' : body;
|
|
268
|
+
return `#${r.id} (${r.category ?? '?'})${src} ${r.title ?? '(untitled)'} — ${clipped}`;
|
|
269
|
+
});
|
|
270
|
+
return { content: [{ type: 'text', text: `${rows.length} pending proposal(s):\n${lines.join('\n')}` }] };
|
|
271
|
+
});
|
|
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'),
|
|
284
|
+
}, async ({ id, title, content, category, project, ttl }) => {
|
|
285
|
+
try {
|
|
286
|
+
const ref = await ratifyProposal(contextDir, id, { title, content, category, project, ttl });
|
|
287
|
+
return { content: [{ type: 'text', text: `Proposal #${id} ratified → ${ref.ref}` }] };
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (err instanceof UnknownProposalError) {
|
|
291
|
+
return { content: [{ type: 'text', text: `Proposal #${id} not found.` }] };
|
|
292
|
+
}
|
|
293
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
294
|
+
return { content: [{ type: 'text', text: `Proposal #${id} refused — ${reason}. It remains pending.` }] };
|
|
295
|
+
}
|
|
296
|
+
});
|
|
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)'),
|
|
300
|
+
}, async ({ id }) => {
|
|
301
|
+
const removed = rejectProposal(contextDir, id);
|
|
302
|
+
return {
|
|
303
|
+
content: [{
|
|
304
|
+
type: 'text',
|
|
305
|
+
text: removed ? `Proposal #${id} rejected and removed.` : `Proposal #${id} not found.`,
|
|
306
|
+
}],
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
// ─── 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'),
|
|
318
|
+
}, async ({ file, section, content, mode }) => {
|
|
319
|
+
const result = await updateIdentity(contextDir, { file, section, content, mode });
|
|
320
|
+
return { content: [{ type: 'text', text: result }] };
|
|
321
|
+
});
|
|
322
|
+
// ─── 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)'),
|
|
336
|
+
}, async ({ user, name, purpose, voice, preferences, clients, force }) => {
|
|
337
|
+
const result = await bootstrap(contextDir, { user, name, purpose, voice, preferences, clients, force });
|
|
338
|
+
return { content: [{ type: 'text', text: result }] };
|
|
339
|
+
});
|
|
340
|
+
// ─── 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.'),
|
|
355
|
+
}, async ({ name, overwrite, target }) => {
|
|
356
|
+
const text = await harnessInit(contextDir, { name, overwrite, target });
|
|
357
|
+
return { content: [{ type: 'text', text }] };
|
|
358
|
+
});
|
|
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").'),
|
|
368
|
+
}, async ({ content, version }) => {
|
|
369
|
+
const peer = server.server.getClientVersion()?.name;
|
|
370
|
+
const text = await harnessDescribe(contextDir, { content, version }, peer);
|
|
371
|
+
return { content: [{ type: 'text', text }] };
|
|
372
|
+
});
|
|
373
|
+
// ─── 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 });
|
|
436
|
+
return { content: [{ type: 'text', text: result }] };
|
|
437
|
+
});
|
|
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
|
+
});
|
|
453
|
+
return { content: [{ type: 'text', text: result }] };
|
|
454
|
+
});
|
|
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 });
|
|
464
|
+
return { content: [{ type: 'text', text: result }] };
|
|
465
|
+
});
|
|
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 });
|
|
472
|
+
return { content: [{ type: 'text', text: result }] };
|
|
473
|
+
});
|
|
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
|
+
});
|
|
491
|
+
return { content: [{ type: 'text', text: result }] };
|
|
492
|
+
});
|
|
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
|
+
});
|
|
510
|
+
return { content: [{ type: 'text', text: result }] };
|
|
511
|
+
});
|
|
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 });
|
|
522
|
+
return { content: [{ type: 'text', text: result }] };
|
|
523
|
+
});
|
|
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 });
|
|
534
|
+
return { content: [{ type: 'text', text: result }] };
|
|
535
|
+
});
|
|
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 });
|
|
551
|
+
return { content: [{ type: 'text', text: result }] };
|
|
552
|
+
});
|
|
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 });
|
|
563
|
+
return { content: [{ type: 'text', text: result }] };
|
|
564
|
+
});
|
|
565
|
+
// MCP-App render probe — env-gated, throwaway. Not part of the tool surface
|
|
566
|
+
// contract; only registered when explicitly validating ui:// rendering.
|
|
567
|
+
if (process.env.LOOM_MCP_APP_HELLO) {
|
|
568
|
+
registerHelloApp(server, pkg.version, contextDir);
|
|
569
|
+
}
|
|
570
|
+
return { server };
|
|
571
|
+
}
|