@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.
- package/CHANGELOG.md +114 -1
- package/README.md +97 -0
- package/dist/backends/dedup.d.ts +23 -0
- package/dist/backends/dedup.js +100 -0
- package/dist/backends/embedding-runtime.js +38 -9
- package/dist/backends/episodes.js +1 -1
- package/dist/backends/fastembed.js +30 -4
- package/dist/backends/migrations.js +200 -0
- package/dist/backends/mmr.d.ts +8 -0
- package/dist/backends/mmr.js +18 -4
- package/dist/backends/people.d.ts +192 -0
- package/dist/backends/people.js +824 -0
- package/dist/backends/proposals.js +1 -1
- package/dist/backends/recorder-health.d.ts +34 -0
- package/dist/backends/recorder-health.js +117 -0
- package/dist/backends/salience.d.ts +39 -0
- package/dist/backends/salience.js +52 -4
- package/dist/backends/scope.d.ts +56 -0
- package/dist/backends/scope.js +75 -0
- package/dist/backends/sqlite-knowledge.d.ts +24 -4
- package/dist/backends/sqlite-knowledge.js +253 -91
- package/dist/backends/sqlite-vec.d.ts +32 -1
- package/dist/backends/sqlite-vec.js +394 -75
- package/dist/backends/types.d.ts +203 -3
- package/dist/categories.d.ts +2 -0
- package/dist/categories.js +2 -0
- package/dist/cli/args.d.ts +16 -1
- package/dist/cli/args.js +45 -1
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +57 -3
- package/dist/cli/knowledge.js +4 -1
- package/dist/cli/memory.js +2 -2
- package/dist/cli/person.d.ts +2 -0
- package/dist/cli/person.js +116 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.js +10 -0
- package/dist/cli/remote.d.ts +36 -0
- package/dist/cli/remote.js +173 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +94 -5
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/cli/update.js +30 -11
- package/dist/clients.js +7 -4
- package/dist/config.d.ts +14 -0
- package/dist/config.js +25 -2
- package/dist/index.js +22 -7
- package/dist/injection/harnesses.js +9 -3
- package/dist/install/harnesses.js +7 -1
- package/dist/server.d.ts +8 -1
- package/dist/server.js +461 -374
- package/dist/tools/dossier.js +5 -1
- package/dist/tools/eurorack-import.js +3 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +35 -2
- package/dist/tools/knowledge-history.d.ts +6 -1
- package/dist/tools/knowledge-history.js +26 -8
- package/dist/tools/knowledge-maintain.d.ts +5 -0
- package/dist/tools/knowledge-maintain.js +92 -5
- package/dist/tools/knowledge-recall.d.ts +7 -1
- package/dist/tools/knowledge-recall.js +53 -3
- package/dist/tools/knowledge-verify.d.ts +11 -1
- package/dist/tools/knowledge-verify.js +17 -4
- package/dist/tools/knowledge-write.d.ts +8 -1
- package/dist/tools/knowledge-write.js +27 -9
- package/dist/tools/memory-history.d.ts +5 -0
- package/dist/tools/memory-history.js +52 -0
- package/dist/tools/memory-list.js +2 -1
- package/dist/tools/memory-merge.d.ts +2 -0
- package/dist/tools/memory-merge.js +40 -0
- package/dist/tools/memory-revision-restore.d.ts +5 -0
- package/dist/tools/memory-revision-restore.js +33 -0
- package/dist/tools/person.d.ts +32 -0
- package/dist/tools/person.js +111 -0
- package/dist/tools/prune.js +1 -1
- package/dist/tools/recall.js +7 -1
- package/dist/tools/remember.js +18 -2
- package/dist/tools/update.js +5 -1
- package/dist/transport/cli-endpoint.d.ts +13 -0
- package/dist/transport/cli-endpoint.js +97 -0
- package/dist/transport/guards.d.ts +14 -0
- package/dist/transport/guards.js +26 -0
- package/dist/transport/http-server.d.ts +28 -1
- package/dist/transport/http-server.js +156 -18
- package/dist/transport/knowledge-server.d.ts +11 -0
- package/dist/transport/knowledge-server.js +30 -0
- package/dist/transport/knowledge-tools.d.ts +50 -0
- package/dist/transport/knowledge-tools.js +290 -0
- package/dist/transport/mcp-app-hello.d.ts +1 -1
- package/dist/widgets/display-modes.html +62 -56
- package/dist/widgets/hello.html +62 -56
- package/dist/widgets/proposals.html +62 -56
- package/dist/widgets/top-of-mind.html +60 -54
- 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/
|
|
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 {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
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.
|
|
59
|
-
'
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'
|
|
70
|
-
|
|
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.
|
|
81
|
-
'
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
'
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
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.
|
|
103
|
-
'
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
116
|
-
'
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
131
|
-
'
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
142
|
-
'
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
'
|
|
151
|
-
'
|
|
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.
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
164
|
-
'
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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.
|
|
173
|
-
'
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
182
|
-
'
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
198
|
-
'
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
213
|
-
'
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
225
|
-
'
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
240
|
-
'
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
257
|
-
'
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
273
|
-
'
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
298
|
-
'
|
|
299
|
-
|
|
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.
|
|
311
|
-
'
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
324
|
-
'
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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.
|
|
342
|
-
'
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
'
|
|
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.
|
|
360
|
-
'
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
'
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
'
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
439
|
-
'
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}, async ({
|
|
448
|
-
const result = await
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
475
|
-
'
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
494
|
-
'
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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.
|
|
513
|
-
'
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}, async ({
|
|
533
|
-
const result = await
|
|
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.
|
|
537
|
-
'
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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.
|
|
554
|
-
'
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}, async ({ slug, revision_id
|
|
562
|
-
const result = await
|
|
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
|