clementine-agent 1.0.41 → 1.0.43
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/dist/agent/assistant.js +58 -0
- package/dist/tools/admin-tools.js +189 -0
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -1052,6 +1052,20 @@ Obsidian vault with YAML frontmatter, [[wikilinks]], #tags.
|
|
|
1052
1052
|
**Remembering:** Durable facts → memory_write(action="update_memory"). Daily context → note_take / memory_write(action="append_daily"). New person → note_create. New task → task_add.
|
|
1053
1053
|
Save important facts immediately; a background agent also extracts after each exchange.
|
|
1054
1054
|
|
|
1055
|
+
## Self-Configuration (don't make the owner edit config files)
|
|
1056
|
+
|
|
1057
|
+
When ${owner} gives you an API key, access token, or similar credential in chat, **save it yourself** with \`env_set\`. Same for channel tokens, Salesforce creds, OAuth client IDs — anything that would otherwise live in \`.env\`. \`env_set(key, value)\` writes to \`~/.clementine/.env\` and hot-reloads into \`process.env\` so the next tool call can use it. No daemon restart needed for most cases (restart only if a channel adapter needs to re-auth).
|
|
1058
|
+
|
|
1059
|
+
Use \`env_list\` to show what's configured (values masked) and \`env_unset\` to remove one. All three are owner-DM only — they'll refuse in channel messages or cron runs.
|
|
1060
|
+
|
|
1061
|
+
### Tool schema self-extension
|
|
1062
|
+
|
|
1063
|
+
If a tool call fails with **"not in my function schema" / "tool not allowed" / "unknown tool"** BUT you can see it in the SDK init inventory (e.g. \`mcp__claude_ai_Google_Drive__search_files\`), call \`allow_tool(name)\` to add it to your whitelist. Takes effect on your next query — no restart, no owner intervention. Then try the original task again.
|
|
1064
|
+
|
|
1065
|
+
This is the correct response to "tool not in schema" errors. **Do not dead-end** and tell ${owner} to edit a config file. Use \`list_allowed_tools\` to see what you've already added, \`disallow_tool\` to remove one.
|
|
1066
|
+
|
|
1067
|
+
Don't tell ${owner} "add this to your .env" — just call env_set and report what you saved. For integrations connected at claude.ai (Google Drive, Gmail, Slack, Notion, Linear, etc.), the \`mcp__claude_ai_*\` tools appear in your SDK init inventory; if your first call is refused, call \`allow_tool\` with the exact tool name and retry.
|
|
1068
|
+
|
|
1055
1069
|
## Context Window Management
|
|
1056
1070
|
|
|
1057
1071
|
Delegate data-heavy work (SEO, analytics, bulk API calls for 3+ entities) to sub-agents via the Agent tool. They run in their own context and return summaries. Never pull bulk data directly.
|
|
@@ -1354,6 +1368,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1354
1368
|
mcpTool('workspace_config'),
|
|
1355
1369
|
mcpTool('workspace_list'),
|
|
1356
1370
|
mcpTool('workspace_info'),
|
|
1371
|
+
// Env file self-management (owner-DM gated inside the tool)
|
|
1372
|
+
mcpTool('env_set'),
|
|
1373
|
+
mcpTool('env_list'),
|
|
1374
|
+
mcpTool('env_unset'),
|
|
1375
|
+
// Self-service tool whitelist — Clementine can add tools she discovers
|
|
1376
|
+
// in the SDK init inventory but that aren't in her baseline allowedTools
|
|
1377
|
+
mcpTool('allow_tool'),
|
|
1378
|
+
mcpTool('list_allowed_tools'),
|
|
1379
|
+
mcpTool('disallow_tool'),
|
|
1357
1380
|
mcpTool('self_restart'),
|
|
1358
1381
|
mcpTool('cron_list'),
|
|
1359
1382
|
mcpTool('add_cron_job'),
|
|
@@ -1411,6 +1434,41 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1411
1434
|
}
|
|
1412
1435
|
}
|
|
1413
1436
|
catch { /* non-fatal — dynamic tools are supplementary */ }
|
|
1437
|
+
// Claude Desktop connector tools (mcp__claude_ai_*). These reach the SDK
|
|
1438
|
+
// subprocess via Claude Code's runtime but are NOT added to allowedTools
|
|
1439
|
+
// automatically — which caused the model to see them in the init
|
|
1440
|
+
// inventory but get refused when it tried to call one ("not in my
|
|
1441
|
+
// function schema"). Add every tool from every auto-registered
|
|
1442
|
+
// integration (populated from the SDK init message on prior queries) so
|
|
1443
|
+
// the whitelist matches reality.
|
|
1444
|
+
try {
|
|
1445
|
+
const integrations = _mcpBridge?.getClaudeIntegrations() ?? [];
|
|
1446
|
+
for (const ig of integrations) {
|
|
1447
|
+
for (const tool of ig.tools) {
|
|
1448
|
+
const fullName = `mcp__claude_ai_${ig.name}__${tool}`;
|
|
1449
|
+
if (!allowedTools.includes(fullName))
|
|
1450
|
+
allowedTools.push(fullName);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
catch { /* non-fatal */ }
|
|
1455
|
+
// Self-service extension — Clementine can add tools to her own whitelist
|
|
1456
|
+
// at runtime via the `allow_tool` MCP tool, writing to allowed-tools-
|
|
1457
|
+
// extra.json. This eliminates the "tool not in schema → dead end → tell
|
|
1458
|
+
// owner to edit config" failure pattern. See admin-tools.ts:allow_tool.
|
|
1459
|
+
try {
|
|
1460
|
+
const extraPath = path.join(BASE_DIR, 'allowed-tools-extra.json');
|
|
1461
|
+
if (fs.existsSync(extraPath)) {
|
|
1462
|
+
const extras = JSON.parse(fs.readFileSync(extraPath, 'utf-8'));
|
|
1463
|
+
if (Array.isArray(extras)) {
|
|
1464
|
+
for (const t of extras) {
|
|
1465
|
+
if (typeof t === 'string' && !allowedTools.includes(t))
|
|
1466
|
+
allowedTools.push(t);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
catch { /* non-fatal */ }
|
|
1414
1472
|
// Agent tool whitelist: filter down to only allowed tools
|
|
1415
1473
|
if (profile?.team?.allowedTools?.length) {
|
|
1416
1474
|
const whitelist = new Set(profile.team.allowedTools.flatMap(t => [t, mcpTool(t)]));
|
|
@@ -15,7 +15,62 @@ import path from 'node:path';
|
|
|
15
15
|
import Anthropic from '@anthropic-ai/sdk';
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { BASE_DIR, CRON_FILE, SYSTEM_DIR, env, getStore, logger, textResult, } from './shared.js';
|
|
18
|
+
import { getInteractionSource } from '../agent/hooks.js';
|
|
19
|
+
import { renameSync } from 'node:fs';
|
|
18
20
|
function readEnvFile() { return env; }
|
|
21
|
+
// ── Env file management helpers (tools registered inside registerAdminTools) ──
|
|
22
|
+
const ENV_PATH = path.join(BASE_DIR, '.env');
|
|
23
|
+
/** Parse a .env file into key→value pairs. Preserves order when re-serialized. */
|
|
24
|
+
function parseEnvFile() {
|
|
25
|
+
const map = new Map();
|
|
26
|
+
if (!existsSync(ENV_PATH))
|
|
27
|
+
return map;
|
|
28
|
+
const text = readFileSync(ENV_PATH, 'utf-8');
|
|
29
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
30
|
+
const line = rawLine.trim();
|
|
31
|
+
if (!line || line.startsWith('#'))
|
|
32
|
+
continue;
|
|
33
|
+
const eq = line.indexOf('=');
|
|
34
|
+
if (eq < 0)
|
|
35
|
+
continue;
|
|
36
|
+
const key = line.slice(0, eq).trim();
|
|
37
|
+
let value = line.slice(eq + 1);
|
|
38
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
39
|
+
value = value.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
map.set(key, value);
|
|
42
|
+
}
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
45
|
+
/** Atomic write — write to .tmp then rename. Mode 0o600. */
|
|
46
|
+
function writeEnvFile(map) {
|
|
47
|
+
if (!existsSync(BASE_DIR))
|
|
48
|
+
mkdirSync(BASE_DIR, { recursive: true });
|
|
49
|
+
const lines = [];
|
|
50
|
+
for (const [key, value] of map) {
|
|
51
|
+
const needsQuote = /[\s#'"\\]/.test(value) || value === '';
|
|
52
|
+
lines.push(`${key}=${needsQuote ? `"${value.replace(/"/g, '\\"')}"` : value}`);
|
|
53
|
+
}
|
|
54
|
+
const tmp = ENV_PATH + '.tmp';
|
|
55
|
+
writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
|
|
56
|
+
renameSync(tmp, ENV_PATH);
|
|
57
|
+
}
|
|
58
|
+
/** Mask a secret for safe logging: keep first 4 + last 4, dots in between. */
|
|
59
|
+
function maskSecret(value) {
|
|
60
|
+
if (value.length <= 8)
|
|
61
|
+
return '•'.repeat(Math.max(value.length, 4));
|
|
62
|
+
return value.slice(0, 4) + '…' + value.slice(-4);
|
|
63
|
+
}
|
|
64
|
+
function requireOwnerDm() {
|
|
65
|
+
const source = getInteractionSource();
|
|
66
|
+
if (source !== 'owner-dm') {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
message: `Env writes are restricted to direct owner conversations. Current interaction source: ${source}. Ask the owner to message directly if they want to change credentials.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
19
74
|
export function registerAdminTools(server) {
|
|
20
75
|
// ── 16. set_timer ──────────────────────────────────────────────────────
|
|
21
76
|
const TIMERS_FILE = path.join(BASE_DIR, '.timers.json');
|
|
@@ -57,6 +112,140 @@ export function registerAdminTools(server) {
|
|
|
57
112
|
});
|
|
58
113
|
return textResult(`Timer set. Reminder in ${minutes} minute${minutes !== 1 ? 's' : ''} (~${fireTime}): "${message}"`);
|
|
59
114
|
});
|
|
115
|
+
// ── Env self-configuration (owner-DM only) ────────────────────────────
|
|
116
|
+
server.tool('env_set', 'Save or update an environment variable in ~/.clementine/.env (API keys, tokens, config). Owner-DM only. Changes take effect immediately — the new value becomes available to process.env and to the next tool call. Use this when the owner gives a credential in chat instead of telling them to hand-edit files.', {
|
|
117
|
+
key: z.string().describe('Env var name (uppercase with underscores, e.g. STRIPE_API_KEY)'),
|
|
118
|
+
value: z.string().describe('The value to store. Never echo back to the user; it will be masked in logs.'),
|
|
119
|
+
}, async ({ key, value }) => {
|
|
120
|
+
const gate = requireOwnerDm();
|
|
121
|
+
if (!gate.ok)
|
|
122
|
+
return textResult(gate.message);
|
|
123
|
+
const normalizedKey = key.trim();
|
|
124
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(normalizedKey)) {
|
|
125
|
+
return textResult(`Env var name must be uppercase letters, digits, and underscores only. Got: ${normalizedKey}`);
|
|
126
|
+
}
|
|
127
|
+
if (!value)
|
|
128
|
+
return textResult('Refused: empty value. Use env_unset to remove a key.');
|
|
129
|
+
const map = parseEnvFile();
|
|
130
|
+
const existed = map.has(normalizedKey);
|
|
131
|
+
map.set(normalizedKey, value);
|
|
132
|
+
try {
|
|
133
|
+
writeEnvFile(map);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
|
|
137
|
+
}
|
|
138
|
+
process.env[normalizedKey] = value;
|
|
139
|
+
logger.info({ key: normalizedKey, existed, masked: maskSecret(value) }, 'env_set');
|
|
140
|
+
return textResult(`${existed ? 'Updated' : 'Added'} ${normalizedKey} in ~/.clementine/.env (value: ${maskSecret(value)}). Available to tools immediately; a daemon restart is not required for most cases.`);
|
|
141
|
+
});
|
|
142
|
+
server.tool('env_list', 'List the names of environment variables configured in ~/.clementine/.env. Returns key names only — values are masked. Owner-DM only.', {}, async () => {
|
|
143
|
+
const gate = requireOwnerDm();
|
|
144
|
+
if (!gate.ok)
|
|
145
|
+
return textResult(gate.message);
|
|
146
|
+
const map = parseEnvFile();
|
|
147
|
+
if (map.size === 0)
|
|
148
|
+
return textResult('No env vars configured in ~/.clementine/.env');
|
|
149
|
+
const lines = [...map.entries()].map(([k, v]) => `- ${k} = ${maskSecret(v)}`);
|
|
150
|
+
return textResult(`Configured env vars (${map.size}):\n${lines.join('\n')}`);
|
|
151
|
+
});
|
|
152
|
+
server.tool('env_unset', 'Remove an environment variable from ~/.clementine/.env. Owner-DM only. Also clears from the running process.', {
|
|
153
|
+
key: z.string().describe('Env var name to remove'),
|
|
154
|
+
}, async ({ key }) => {
|
|
155
|
+
const gate = requireOwnerDm();
|
|
156
|
+
if (!gate.ok)
|
|
157
|
+
return textResult(gate.message);
|
|
158
|
+
const normalizedKey = key.trim();
|
|
159
|
+
const map = parseEnvFile();
|
|
160
|
+
if (!map.has(normalizedKey))
|
|
161
|
+
return textResult(`${normalizedKey} is not set in ~/.clementine/.env`);
|
|
162
|
+
map.delete(normalizedKey);
|
|
163
|
+
try {
|
|
164
|
+
writeEnvFile(map);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return textResult(`Failed to write .env: ${String(err).slice(0, 200)}`);
|
|
168
|
+
}
|
|
169
|
+
delete process.env[normalizedKey];
|
|
170
|
+
logger.info({ key: normalizedKey }, 'env_unset');
|
|
171
|
+
return textResult(`Removed ${normalizedKey} from ~/.clementine/.env`);
|
|
172
|
+
});
|
|
173
|
+
// ── Self-service tool whitelist ────────────────────────────────────────
|
|
174
|
+
const ALLOWED_TOOLS_EXTRA = path.join(BASE_DIR, 'allowed-tools-extra.json');
|
|
175
|
+
function readExtraAllowedTools() {
|
|
176
|
+
if (!existsSync(ALLOWED_TOOLS_EXTRA))
|
|
177
|
+
return [];
|
|
178
|
+
try {
|
|
179
|
+
const arr = JSON.parse(readFileSync(ALLOWED_TOOLS_EXTRA, 'utf-8'));
|
|
180
|
+
return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function writeExtraAllowedTools(tools) {
|
|
187
|
+
if (!existsSync(BASE_DIR))
|
|
188
|
+
mkdirSync(BASE_DIR, { recursive: true });
|
|
189
|
+
const unique = [...new Set(tools)].sort();
|
|
190
|
+
writeFileSync(ALLOWED_TOOLS_EXTRA, JSON.stringify(unique, null, 2));
|
|
191
|
+
}
|
|
192
|
+
server.tool('allow_tool', 'Add a tool name to your self-managed allowedTools list. Use when you see a tool in the SDK inventory but get "not in function schema" when you try to call it. Writes to ~/.clementine/allowed-tools-extra.json; takes effect on your NEXT query. Owner-DM only. Common case: Claude Desktop connector tools like mcp__claude_ai_Google_Drive__search_files that appear in the init inventory but aren\'t in your baseline whitelist.', {
|
|
193
|
+
name: z.string().describe('Exact tool name (e.g. "mcp__claude_ai_Google_Drive__search_files")'),
|
|
194
|
+
reason: z.string().optional().describe('Brief note: why you need this tool. For audit trail.'),
|
|
195
|
+
}, async ({ name, reason }) => {
|
|
196
|
+
const gate = requireOwnerDm();
|
|
197
|
+
if (!gate.ok)
|
|
198
|
+
return textResult(gate.message);
|
|
199
|
+
const trimmed = name.trim();
|
|
200
|
+
if (!trimmed)
|
|
201
|
+
return textResult('Refused: empty tool name.');
|
|
202
|
+
// Loose format check — MCP tool names, built-in tool names, or namespaced patterns.
|
|
203
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed.replace(/__[A-Za-z0-9_-]+/g, ''))) {
|
|
204
|
+
return textResult(`Refused: "${trimmed}" doesn't look like a valid tool name. Use a literal name like "mcp__claude_ai_Google_Drive__search_files" or a built-in like "WebFetch".`);
|
|
205
|
+
}
|
|
206
|
+
const current = readExtraAllowedTools();
|
|
207
|
+
if (current.includes(trimmed)) {
|
|
208
|
+
return textResult(`${trimmed} is already in your extra-allowed list. If it's still being refused, it may be disallowed by a higher-priority block (heartbeat/cron disallowed tools, profile tier).`);
|
|
209
|
+
}
|
|
210
|
+
current.push(trimmed);
|
|
211
|
+
try {
|
|
212
|
+
writeExtraAllowedTools(current);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
return textResult(`Failed to persist: ${String(err).slice(0, 200)}`);
|
|
216
|
+
}
|
|
217
|
+
logger.info({ name: trimmed, reason, totalExtras: current.length }, 'allow_tool');
|
|
218
|
+
return textResult(`Added ${trimmed} to ~/.clementine/allowed-tools-extra.json (${current.length} total extras). Active on your next query — no daemon restart needed.${reason ? ` Reason: ${reason}` : ''}`);
|
|
219
|
+
});
|
|
220
|
+
server.tool('list_allowed_tools', 'Show the current self-managed allowedTools extras (tools you added via allow_tool on top of the built-in whitelist). Owner-DM only.', {}, async () => {
|
|
221
|
+
const gate = requireOwnerDm();
|
|
222
|
+
if (!gate.ok)
|
|
223
|
+
return textResult(gate.message);
|
|
224
|
+
const current = readExtraAllowedTools();
|
|
225
|
+
if (current.length === 0)
|
|
226
|
+
return textResult('No extra tools registered. The built-in whitelist is the only source; add entries via `allow_tool` when you encounter "not in function schema" errors.');
|
|
227
|
+
return textResult(`Extra allowed tools (${current.length}):\n${current.map(t => `- ${t}`).join('\n')}\n\nStored in ~/.clementine/allowed-tools-extra.json; merged into allowedTools on every query.`);
|
|
228
|
+
});
|
|
229
|
+
server.tool('disallow_tool', 'Remove a tool from your self-managed allowedTools extras. Takes effect on next query. Owner-DM only.', {
|
|
230
|
+
name: z.string().describe('Exact tool name to remove'),
|
|
231
|
+
}, async ({ name }) => {
|
|
232
|
+
const gate = requireOwnerDm();
|
|
233
|
+
if (!gate.ok)
|
|
234
|
+
return textResult(gate.message);
|
|
235
|
+
const trimmed = name.trim();
|
|
236
|
+
const current = readExtraAllowedTools();
|
|
237
|
+
if (!current.includes(trimmed))
|
|
238
|
+
return textResult(`${trimmed} isn't in the extras list. Nothing to remove.`);
|
|
239
|
+
const next = current.filter(t => t !== trimmed);
|
|
240
|
+
try {
|
|
241
|
+
writeExtraAllowedTools(next);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return textResult(`Failed to persist: ${String(err).slice(0, 200)}`);
|
|
245
|
+
}
|
|
246
|
+
logger.info({ name: trimmed, totalExtras: next.length }, 'disallow_tool');
|
|
247
|
+
return textResult(`Removed ${trimmed} from extras (${next.length} remaining).`);
|
|
248
|
+
});
|
|
60
249
|
// ── Workspace Tools ─────────────────────────────────────────────────────
|
|
61
250
|
/** Common developer directories to auto-scan (relative to home). */
|
|
62
251
|
const DEFAULT_WORKSPACE_CANDIDATES = [
|