clementine-agent 1.0.40 → 1.0.42
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 +32 -1
- package/dist/agent/mcp-bridge.d.ts +15 -0
- package/dist/agent/mcp-bridge.js +54 -0
- package/dist/tools/admin-tools.js +113 -0
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -694,10 +694,29 @@ export class PersonalAssistant {
|
|
|
694
694
|
/** Capture MCP server status from system init messages. */
|
|
695
695
|
captureMcpStatus(message) {
|
|
696
696
|
const sysMsg = message;
|
|
697
|
-
if (sysMsg.subtype
|
|
697
|
+
if (sysMsg.subtype !== 'init')
|
|
698
|
+
return;
|
|
699
|
+
if (sysMsg.mcp_servers) {
|
|
698
700
|
this._lastMcpStatus = sysMsg.mcp_servers;
|
|
699
701
|
this._lastMcpStatusTime = new Date().toISOString();
|
|
700
702
|
}
|
|
703
|
+
// Auto-register Claude Desktop integrations from the authoritative tool
|
|
704
|
+
// list the SDK reports on init. Previously the claude-integrations file
|
|
705
|
+
// was written reactively — only after the agent successfully called an
|
|
706
|
+
// integration tool — which meant a freshly-connected Google Drive or
|
|
707
|
+
// Gmail was invisible until used. Now every session-init rewrites the
|
|
708
|
+
// list to match reality.
|
|
709
|
+
if (Array.isArray(sysMsg.tools) && sysMsg.tools.length > 0 && _mcpBridge) {
|
|
710
|
+
try {
|
|
711
|
+
const { added, updated } = _mcpBridge.registerClaudeIntegrationsFromToolList(sysMsg.tools);
|
|
712
|
+
if (added.length > 0 || updated.length > 0) {
|
|
713
|
+
logger.info({ added, updated }, 'Registered Claude Desktop integrations from SDK tool inventory');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
logger.debug({ err }, 'Integration auto-registration failed (non-fatal)');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
701
720
|
}
|
|
702
721
|
setUnleashedCompleteCallback(cb) {
|
|
703
722
|
this.onUnleashedComplete = cb;
|
|
@@ -1033,6 +1052,14 @@ Obsidian vault with YAML frontmatter, [[wikilinks]], #tags.
|
|
|
1033
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.
|
|
1034
1053
|
Save important facts immediately; a background agent also extracts after each exchange.
|
|
1035
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
|
+
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 automatically via Claude Desktop — never claim one is "not available" without trying the tool call first.
|
|
1062
|
+
|
|
1036
1063
|
## Context Window Management
|
|
1037
1064
|
|
|
1038
1065
|
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.
|
|
@@ -1335,6 +1362,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1335
1362
|
mcpTool('workspace_config'),
|
|
1336
1363
|
mcpTool('workspace_list'),
|
|
1337
1364
|
mcpTool('workspace_info'),
|
|
1365
|
+
// Env file self-management (owner-DM gated inside the tool)
|
|
1366
|
+
mcpTool('env_set'),
|
|
1367
|
+
mcpTool('env_list'),
|
|
1368
|
+
mcpTool('env_unset'),
|
|
1338
1369
|
mcpTool('self_restart'),
|
|
1339
1370
|
mcpTool('cron_list'),
|
|
1340
1371
|
mcpTool('add_cron_job'),
|
|
@@ -54,6 +54,21 @@ export declare function loadClaudeIntegrations(): Record<string, ClaudeIntegrati
|
|
|
54
54
|
export declare function recordClaudeIntegrationUse(toolName: string): void;
|
|
55
55
|
/** Get all discovered Claude Desktop integrations as a list. */
|
|
56
56
|
export declare function getClaudeIntegrations(): ClaudeIntegration[];
|
|
57
|
+
/**
|
|
58
|
+
* Register every integration found in a tool inventory. The SDK's system
|
|
59
|
+
* init message (subtype='init') includes a `tools: string[]` with the full
|
|
60
|
+
* set of tools the agent actually has access to this session — including
|
|
61
|
+
* every mcp__claude_ai_* tool Claude Desktop is surfacing. Walking that
|
|
62
|
+
* list on init gives us the authoritative, up-to-date integration set
|
|
63
|
+
* without waiting for the agent to blindly try each one.
|
|
64
|
+
*
|
|
65
|
+
* Idempotent: if an entry already exists, we merge new tool names into it
|
|
66
|
+
* and bump `connected = true` without touching firstSeen/lastUsed.
|
|
67
|
+
*/
|
|
68
|
+
export declare function registerClaudeIntegrationsFromToolList(tools: string[]): {
|
|
69
|
+
added: string[];
|
|
70
|
+
updated: string[];
|
|
71
|
+
};
|
|
57
72
|
/**
|
|
58
73
|
* Bootstrap integrations from the audit log.
|
|
59
74
|
* Call once on startup to seed the integrations file from historical data.
|
package/dist/agent/mcp-bridge.js
CHANGED
|
@@ -384,6 +384,60 @@ export function recordClaudeIntegrationUse(toolName) {
|
|
|
384
384
|
export function getClaudeIntegrations() {
|
|
385
385
|
return Object.values(loadClaudeIntegrations());
|
|
386
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Register every integration found in a tool inventory. The SDK's system
|
|
389
|
+
* init message (subtype='init') includes a `tools: string[]` with the full
|
|
390
|
+
* set of tools the agent actually has access to this session — including
|
|
391
|
+
* every mcp__claude_ai_* tool Claude Desktop is surfacing. Walking that
|
|
392
|
+
* list on init gives us the authoritative, up-to-date integration set
|
|
393
|
+
* without waiting for the agent to blindly try each one.
|
|
394
|
+
*
|
|
395
|
+
* Idempotent: if an entry already exists, we merge new tool names into it
|
|
396
|
+
* and bump `connected = true` without touching firstSeen/lastUsed.
|
|
397
|
+
*/
|
|
398
|
+
export function registerClaudeIntegrationsFromToolList(tools) {
|
|
399
|
+
const added = [];
|
|
400
|
+
const updated = [];
|
|
401
|
+
if (!Array.isArray(tools) || tools.length === 0)
|
|
402
|
+
return { added, updated };
|
|
403
|
+
const integrations = loadClaudeIntegrations();
|
|
404
|
+
const now = new Date().toISOString();
|
|
405
|
+
let dirty = false;
|
|
406
|
+
for (const toolName of tools) {
|
|
407
|
+
const parsed = parseClaudeDesktopTool(toolName);
|
|
408
|
+
if (!parsed)
|
|
409
|
+
continue;
|
|
410
|
+
const existing = integrations[parsed.integration];
|
|
411
|
+
if (existing) {
|
|
412
|
+
if (!existing.tools.includes(parsed.tool)) {
|
|
413
|
+
existing.tools.push(parsed.tool);
|
|
414
|
+
existing.tools.sort();
|
|
415
|
+
dirty = true;
|
|
416
|
+
}
|
|
417
|
+
if (!existing.connected) {
|
|
418
|
+
existing.connected = true;
|
|
419
|
+
dirty = true;
|
|
420
|
+
}
|
|
421
|
+
if (!updated.includes(parsed.integration))
|
|
422
|
+
updated.push(parsed.integration);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
integrations[parsed.integration] = {
|
|
426
|
+
name: parsed.integration,
|
|
427
|
+
label: INTEGRATION_LABELS[parsed.integration] ?? parsed.integration.replace(/_/g, ' '),
|
|
428
|
+
tools: [parsed.tool],
|
|
429
|
+
firstSeen: now,
|
|
430
|
+
lastUsed: now,
|
|
431
|
+
connected: true,
|
|
432
|
+
};
|
|
433
|
+
added.push(parsed.integration);
|
|
434
|
+
dirty = true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (dirty)
|
|
438
|
+
saveClaudeIntegrations(integrations);
|
|
439
|
+
return { added, updated };
|
|
440
|
+
}
|
|
387
441
|
/**
|
|
388
442
|
* Bootstrap integrations from the audit log.
|
|
389
443
|
* Call once on startup to seed the integrations file from historical data.
|
|
@@ -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,64 @@ 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
|
+
});
|
|
60
173
|
// ── Workspace Tools ─────────────────────────────────────────────────────
|
|
61
174
|
/** Common developer directories to auto-scan (relative to home). */
|
|
62
175
|
const DEFAULT_WORKSPACE_CANDIDATES = [
|