clementine-agent 1.18.148 → 1.18.150

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.
@@ -26,7 +26,7 @@ import { AgentManager } from './agent-manager.js';
26
26
  * Callers that need exact counts should read `usage.input_tokens` from the
27
27
  * SDK result; this function is for pre-flight planning only.
28
28
  */
29
- export declare function estimateTokens(text: string): number;
29
+ export { estimateTokens } from '../lib/format.js';
30
30
  /** Format a millisecond duration as a human-friendly "X ago" string. */
31
31
  export declare function formatTimeAgo(ms: number): string;
32
32
  export declare function looksLikeOneMillionContextError(value: unknown): boolean;
@@ -165,11 +165,10 @@ function getChannelToolDenyList(channel) {
165
165
  * Callers that need exact counts should read `usage.input_tokens` from the
166
166
  * SDK result; this function is for pre-flight planning only.
167
167
  */
168
- export function estimateTokens(text) {
169
- if (!text)
170
- return 0;
171
- return Math.ceil(text.length / 3.3);
172
- }
168
+ // 1.18.149 — switched to canonical /4 divisor (was /3.3 here for no documented
169
+ // reason; brain/llm-client.ts and gateway/turn-ledger.ts both used /4 to match
170
+ // Anthropic's published rule of thumb). Re-export keeps existing callers working.
171
+ export { estimateTokens } from '../lib/format.js';
173
172
  /**
174
173
  * Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
175
174
  * safely serialized to JSON. Lone surrogates are valid in JS strings but
@@ -2054,13 +2054,29 @@ export class SelfImproveLoop {
2054
2054
  }
2055
2055
  }
2056
2056
  catch { /* non-fatal */ }
2057
- // Identify consistently failed approaches
2057
+ // Identify consistently failed approaches.
2058
+ // 1.18.150 — exact-string Set dedup let near-duplicate hypotheses ("improve
2059
+ // cron prompt clarity" vs "improve cron prompt for clarity") both make it
2060
+ // through and waste two of the five slots. Use the existing tokenize +
2061
+ // jaccard helpers (Set ≥0.85 collapse) to merge near-duplicates while
2062
+ // preserving genuinely distinct framings.
2058
2063
  const failedHypotheses = history
2059
2064
  .filter(e => !e.accepted && e.score < 0.3 && !e.type)
2060
2065
  .map(e => e.hypothesis.slice(0, 80));
2061
2066
  if (failedHypotheses.length > 0) {
2062
2067
  lines.push('\n### Approaches That Scored Poorly (avoid these)');
2063
- for (const h of [...new Set(failedHypotheses)].slice(0, 5)) {
2068
+ const merged = [];
2069
+ const mergedTokens = [];
2070
+ for (const h of failedHypotheses) {
2071
+ const ht = tokenizeForDrift(h);
2072
+ if (mergedTokens.some(t => jaccardSimilarity(t, ht) >= 0.85))
2073
+ continue;
2074
+ merged.push(h);
2075
+ mergedTokens.push(ht);
2076
+ if (merged.length >= 5)
2077
+ break;
2078
+ }
2079
+ for (const h of merged) {
2064
2080
  lines.push(`- "${h}"`);
2065
2081
  }
2066
2082
  }
@@ -28,8 +28,8 @@ export declare function setLLMOverride(fn: LLMCallFn | null): void;
28
28
  export declare function callLLM(prompt: string, opts?: LLMCallOpts): Promise<string>;
29
29
  /** Parse a JSON response defensively (strip code fences, trailing text). */
30
30
  export declare function parseJsonResponse<T = unknown>(raw: string): T | null;
31
- /** Rough token counter 4 chars ≈ 1 token. Good enough for input-truncation. */
32
- export declare function estTokens(text: string): number;
31
+ import { estimateTokens } from '../lib/format.js';
32
+ export declare const estTokens: typeof estimateTokens;
33
33
  /** Truncate text to a token budget (approx). Returns { text, truncated }. */
34
34
  export declare function truncateToTokens(text: string, maxTokens: number): {
35
35
  text: string;
@@ -86,10 +86,10 @@ export function parseJsonResponse(raw) {
86
86
  return null;
87
87
  }
88
88
  }
89
- /** Rough token counter 4 chars 1 token. Good enough for input-truncation. */
90
- export function estTokens(text) {
91
- return Math.ceil(text.length / 4);
92
- }
89
+ // 1.18.149estTokens consolidated into the canonical estimateTokens helper.
90
+ // Re-exported under the legacy name to keep existing callers working.
91
+ import { estimateTokens } from '../lib/format.js';
92
+ export const estTokens = estimateTokens;
93
93
  /** Truncate text to a token budget (approx). Returns { text, truncated }. */
94
94
  export function truncateToTokens(text, maxTokens) {
95
95
  const maxChars = maxTokens * 4;
package/dist/cli/index.js CHANGED
@@ -67,17 +67,8 @@ function getLaunchdPlistPath() {
67
67
  function getSystemdServiceName() {
68
68
  return `${getAssistantName().toLowerCase()}.service`;
69
69
  }
70
- function formatBytes(n) {
71
- if (!Number.isFinite(n) || n < 0)
72
- return '0 B';
73
- if (n < 1024)
74
- return `${n} B`;
75
- if (n < 1024 * 1024)
76
- return `${(n / 1024).toFixed(1)} KB`;
77
- if (n < 1024 * 1024 * 1024)
78
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
79
- return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
80
- }
70
+ // 1.18.149 — formatBytes consolidated to src/lib/format.ts (was 4 inline copies)
71
+ import { formatBytes } from '../lib/format.js';
81
72
  function dirSizeBytes(dir) {
82
73
  if (!existsSync(dir))
83
74
  return 0;
@@ -15,6 +15,7 @@ import { runIngestion } from '../brain/ingestion-pipeline.js';
15
15
  import { detectManifest } from '../brain/format-detector.js';
16
16
  import { runSource, getSource, listSources, upsertSource } from '../brain/source-registry.js';
17
17
  import { getStore } from '../tools/shared.js';
18
+ import { formatBytes } from '../lib/format.js';
18
19
  export async function cmdIngestSeed(inputPath, opts) {
19
20
  const abs = path.resolve(inputPath);
20
21
  if (!existsSync(abs)) {
@@ -145,13 +146,5 @@ function deriveSlug(abs) {
145
146
  const base = path.basename(abs, path.extname(abs));
146
147
  return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'seed';
147
148
  }
148
- function formatBytes(n) {
149
- if (n < 1024)
150
- return `${n} B`;
151
- if (n < 1024 * 1024)
152
- return `${(n / 1024).toFixed(1)} KB`;
153
- if (n < 1024 * 1024 * 1024)
154
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
155
- return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
156
- }
149
+ // 1.18.149 — formatBytes consolidated to src/lib/format.ts
157
150
  //# sourceMappingURL=ingest.js.map
package/dist/config.d.ts CHANGED
@@ -168,7 +168,6 @@ export declare const CHANNEL_SLACK: boolean;
168
168
  export declare const CHANNEL_TELEGRAM: boolean;
169
169
  export declare const CHANNEL_WHATSAPP: boolean;
170
170
  export declare const CHANNEL_WEBHOOK: boolean;
171
- export declare const CHANNEL_OUTLOOK: boolean;
172
171
  export declare const CHANNEL_SALESFORCE: boolean;
173
172
  /**
174
173
  * Validate that explicitly configured secrets actually resolved.
package/dist/config.js CHANGED
@@ -515,7 +515,11 @@ export const CHANNEL_SLACK = Boolean(SLACK_BOT_TOKEN && SLACK_APP_TOKEN);
515
515
  export const CHANNEL_TELEGRAM = Boolean(TELEGRAM_BOT_TOKEN);
516
516
  export const CHANNEL_WHATSAPP = Boolean(TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN && WHATSAPP_OWNER_PHONE);
517
517
  export const CHANNEL_WEBHOOK = WEBHOOK_ENABLED && Boolean(WEBHOOK_SECRET);
518
- export const CHANNEL_OUTLOOK = Boolean(MS_TENANT_ID && MS_CLIENT_ID && MS_CLIENT_SECRET && MS_USER_EMAIL);
518
+ // 1.18.149 CHANNEL_OUTLOOK removed. There's no native Outlook channel
519
+ // startup wired into src/index.ts; users access Outlook via the Composio
520
+ // Outlook toolkit (set up from Settings → Integrations). The flag was
521
+ // only displayed in the startup banner — misleading because it implied
522
+ // an Outlook channel was running when nothing was.
519
523
  export const CHANNEL_SALESFORCE = Boolean(SF_INSTANCE_URL && SF_CLIENT_ID && SF_CLIENT_SECRET);
520
524
  const SECRET_VALIDATIONS = [
521
525
  { key: 'DISCORD_TOKEN', channel: 'Discord' },
@@ -24,7 +24,8 @@ export interface TurnLedgerEntry {
24
24
  errorPreview?: string;
25
25
  durationMs: number;
26
26
  }
27
- export declare function estimateTokensApprox(text: string): number;
27
+ import { estimateTokens } from '../lib/format.js';
28
+ export declare const estimateTokensApprox: typeof estimateTokens;
28
29
  export declare function turnLedgerPath(baseDir?: string): string;
29
30
  export declare function appendTurnLedger(entry: TurnLedgerEntry, baseDir?: string): void;
30
31
  export declare function readRecentTurnLedger(sessionKey: string, limit?: number, baseDir?: string): TurnLedgerEntry[];
@@ -1,9 +1,10 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { BASE_DIR } from '../config.js';
4
- export function estimateTokensApprox(text) {
5
- return Math.ceil(text.length / 4);
6
- }
4
+ // 1.18.149 estimateTokensApprox consolidated into the canonical estimateTokens helper.
5
+ // Re-exported under the legacy name to keep existing callers working.
6
+ import { estimateTokens } from '../lib/format.js';
7
+ export const estimateTokensApprox = estimateTokens;
7
8
  export function turnLedgerPath(baseDir = BASE_DIR) {
8
9
  return path.join(baseDir, 'logs', 'turn-ledger.jsonl');
9
10
  }
package/dist/index.js CHANGED
@@ -254,8 +254,6 @@ function printBanner(channels, profiles, cronJobs, graphEnabled = false) {
254
254
  tags.push('voice');
255
255
  if (config.GOOGLE_API_KEY)
256
256
  tags.push('video');
257
- if (config.CHANNEL_OUTLOOK)
258
- tags.push('outlook');
259
257
  if (graphEnabled)
260
258
  tags.push('graph');
261
259
  if (profiles > 0)
@@ -301,8 +299,6 @@ function printBanner(channels, profiles, cronJobs, graphEnabled = false) {
301
299
  hints.push(['ELEVENLABS_API_KEY', 'voice replies']);
302
300
  if (!config.GOOGLE_API_KEY)
303
301
  hints.push(['GOOGLE_API_KEY', 'video analysis']);
304
- if (!config.CHANNEL_OUTLOOK)
305
- hints.push(['MS_TENANT_ID + MS_CLIENT_ID + MS_CLIENT_SECRET', 'Outlook email & calendar']);
306
302
  if (!graphEnabled)
307
303
  hints.push(['clementine doctor', 'knowledge graph (run to diagnose)']);
308
304
  if (hints.length > 0) {
@@ -83,7 +83,6 @@ export interface CatalogToolkit {
83
83
  * cache (so the next request retries instead of returning stale empty).
84
84
  */
85
85
  export declare function listAllToolkits(): Promise<CatalogToolkit[]>;
86
- export declare function listToolkitMeta(): Promise<Map<string, ToolkitMeta>>;
87
86
  export declare function listToolkitSlugsWithAuthConfig(): Promise<Set<string>>;
88
87
  export declare class ComposioNeedsAuthConfigError extends Error {
89
88
  readonly slug: string;
@@ -81,7 +81,6 @@ export function isComposioEnabled() {
81
81
  export function resetComposioClient() {
82
82
  singleton = null;
83
83
  identityCache.clear();
84
- toolkitMetaCache = null;
85
84
  catalogCache = null;
86
85
  detectedPreferredUserId = null;
87
86
  }
@@ -345,7 +344,6 @@ export async function listConnectedToolkits() {
345
344
  return [];
346
345
  }
347
346
  }
348
- let toolkitMetaCache = null;
349
347
  let catalogCache = null;
350
348
  const CATALOG_TTL_MS = 60 * 60 * 1000; // 1h — catalog drifts very slowly
351
349
  /**
@@ -435,55 +433,6 @@ async function fetchCatalogViaWrapper(composio) {
435
433
  const items = (Array.isArray(resp) ? resp : (resp?.items ?? []));
436
434
  return items.map(normalizeCatalogItem);
437
435
  }
438
- async function fetchAllToolkitMeta() {
439
- const composio = getComposio();
440
- if (!composio)
441
- return new Map();
442
- const out = new Map();
443
- try {
444
- const resp = await composio.toolkits.get({ limit: 500 });
445
- const items = Array.isArray(resp) ? resp : (resp.items ?? []);
446
- for (const it of items) {
447
- out.set(it.slug, {
448
- slug: it.slug,
449
- name: it.name,
450
- logo: it.meta?.logo,
451
- description: it.meta?.description,
452
- toolsCount: it.meta?.toolsCount,
453
- });
454
- }
455
- // Backfill curated toolkits the list endpoint omitted (e.g. MCP-only ones).
456
- await Promise.all(CURATED_TOOLKITS.filter(t => !out.has(t.slug)).map(async (t) => {
457
- try {
458
- const full = (await composio.toolkits.get(t.slug));
459
- out.set(full.slug, {
460
- slug: full.slug,
461
- name: full.name,
462
- logo: full.meta?.logo,
463
- description: full.meta?.description,
464
- toolsCount: full.meta?.toolsCount,
465
- });
466
- }
467
- catch (err) {
468
- logger.debug({ err, slug: t.slug }, 'meta backfill failed');
469
- }
470
- }));
471
- }
472
- catch (err) {
473
- logger.error({ err }, 'fetchAllToolkitMeta failed');
474
- }
475
- return out;
476
- }
477
- export async function listToolkitMeta() {
478
- if (!toolkitMetaCache) {
479
- toolkitMetaCache = fetchAllToolkitMeta().catch(err => {
480
- logger.error({ err }, 'listToolkitMeta failed');
481
- toolkitMetaCache = null;
482
- return new Map();
483
- });
484
- }
485
- return toolkitMetaCache;
486
- }
487
436
  export async function listToolkitSlugsWithAuthConfig() {
488
437
  const composio = getComposio();
489
438
  if (!composio)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared formatting helpers (1.18.149).
3
+ *
4
+ * Consolidates implementations that were previously inlined across the
5
+ * codebase:
6
+ * - formatBytes: 4 inline copies (cli/index.ts, cli/ingest.ts,
7
+ * memory/store.ts, cli/dashboard.ts) with minor variation
8
+ * - estimateTokens: 3 inline copies with INCONSISTENT divisors
9
+ * (brain/llm-client.ts and gateway/turn-ledger.ts used /4 — Anthropic's
10
+ * published rule of thumb — while agent/assistant.ts used /3.3 for
11
+ * no documented reason). Going with /4 as the canonical.
12
+ *
13
+ * Kept tiny + dependency-free so any module can import without pulling
14
+ * in extra weight.
15
+ */
16
+ /**
17
+ * Format a byte count as a human-readable string (1.5 KB, 23 MB, 4.2 GB).
18
+ * Defensive: returns '0 B' for null, undefined, NaN, negative.
19
+ */
20
+ export declare function formatBytes(n: number | null | undefined): string;
21
+ /**
22
+ * Approximate token count for a string. Uses the Anthropic-published
23
+ * heuristic of ~4 characters per token. Good enough for budget planning,
24
+ * input truncation, and pre-flight cost estimates. Callers needing exact
25
+ * counts should read `usage.input_tokens` from the SDK result.
26
+ */
27
+ export declare function estimateTokens(text: string): number;
28
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared formatting helpers (1.18.149).
3
+ *
4
+ * Consolidates implementations that were previously inlined across the
5
+ * codebase:
6
+ * - formatBytes: 4 inline copies (cli/index.ts, cli/ingest.ts,
7
+ * memory/store.ts, cli/dashboard.ts) with minor variation
8
+ * - estimateTokens: 3 inline copies with INCONSISTENT divisors
9
+ * (brain/llm-client.ts and gateway/turn-ledger.ts used /4 — Anthropic's
10
+ * published rule of thumb — while agent/assistant.ts used /3.3 for
11
+ * no documented reason). Going with /4 as the canonical.
12
+ *
13
+ * Kept tiny + dependency-free so any module can import without pulling
14
+ * in extra weight.
15
+ */
16
+ /**
17
+ * Format a byte count as a human-readable string (1.5 KB, 23 MB, 4.2 GB).
18
+ * Defensive: returns '0 B' for null, undefined, NaN, negative.
19
+ */
20
+ export function formatBytes(n) {
21
+ if (n == null || !Number.isFinite(n) || n < 0)
22
+ return '0 B';
23
+ if (n < 1024)
24
+ return `${n} B`;
25
+ if (n < 1024 * 1024)
26
+ return `${(n / 1024).toFixed(1)} KB`;
27
+ if (n < 1024 * 1024 * 1024)
28
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
29
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
30
+ }
31
+ /**
32
+ * Approximate token count for a string. Uses the Anthropic-published
33
+ * heuristic of ~4 characters per token. Good enough for budget planning,
34
+ * input truncation, and pre-flight cost estimates. Callers needing exact
35
+ * counts should read `usage.input_tokens` from the SDK result.
36
+ */
37
+ export function estimateTokens(text) {
38
+ if (!text)
39
+ return 0;
40
+ return Math.ceil(text.length / 4);
41
+ }
42
+ //# sourceMappingURL=format.js.map
@@ -78,7 +78,6 @@ export declare class MemoryStore {
78
78
  private writeQueue;
79
79
  constructor(dbPath: string, vaultDir: string);
80
80
  private static confidenceMultiplier;
81
- private static formatBytes;
82
81
  private static dirSizeBytes;
83
82
  /**
84
83
  * Create the database and schema if needed.
@@ -14,6 +14,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
14
14
  import path from 'node:path';
15
15
  import Database from 'better-sqlite3';
16
16
  import { BASE_DIR } from '../config.js';
17
+ import { formatBytes } from '../lib/format.js';
17
18
  import { temporalDecay } from './search.js';
18
19
  import * as embeddingsModule from './embeddings.js';
19
20
  import { chunkFile } from './chunker.js';
@@ -46,17 +47,7 @@ export class MemoryStore {
46
47
  const conf = Math.max(0, Math.min(1, confidence ?? 1));
47
48
  return conf >= 1 ? 1 : 0.5 + 0.5 * conf;
48
49
  }
49
- static formatBytes(n) {
50
- if (!Number.isFinite(n) || n < 0)
51
- return '0 B';
52
- if (n < 1024)
53
- return `${n} B`;
54
- if (n < 1024 * 1024)
55
- return `${(n / 1024).toFixed(1)} KB`;
56
- if (n < 1024 * 1024 * 1024)
57
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
58
- return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
59
- }
50
+ // 1.18.149 — formatBytes consolidated to src/lib/format.ts
60
51
  static dirSizeBytes(dir) {
61
52
  if (!existsSync(dir))
62
53
  return 0;
@@ -5917,7 +5908,7 @@ export class MemoryStore {
5917
5908
  cacheDir,
5918
5909
  cacheExists: existsSync(cacheDir),
5919
5910
  cacheBytes,
5920
- cacheSize: MemoryStore.formatBytes(cacheBytes),
5911
+ cacheSize: formatBytes(cacheBytes),
5921
5912
  installed: cacheBytes >= 1024 * 1024,
5922
5913
  };
5923
5914
  })(),
@@ -325,7 +325,7 @@ export function registerAdminTools(server) {
325
325
  const unique = [...new Set(tools)].sort();
326
326
  writeFileSync(ALLOWED_TOOLS_EXTRA, JSON.stringify(unique, null, 2));
327
327
  }
328
- 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. If the tool name isn\'t yet in the cached inventory, this auto-refreshes the probe first — covers the case where the owner just added a new claude.ai connector or local MCP server. Owner-DM only.', {
328
+ server.tool('allow_tool', 'Add a tool name to your self-managed allowedTools list (~/.clementine/allowed-tools-extra.json). Use when an SDK-inventory tool returns "not in function schema". Effective on your next query. Auto-refreshes the inventory probe if the tool isn\'t cached. Owner-DM only.', {
329
329
  name: z.string().describe('Exact tool name (e.g. "mcp__claude_ai_Google_Drive__search_files")'),
330
330
  reason: z.string().optional().describe('Brief note: why you need this tool. For audit trail.'),
331
331
  }, async ({ name, reason }) => {
@@ -368,7 +368,7 @@ export function registerAdminTools(server) {
368
368
  logger.info({ name: trimmed, reason, totalExtras: current.length }, 'allow_tool');
369
369
  return textResult(`Added ${trimmed} to ~/.clementine/allowed-tools-extra.json (${current.length} total extras)${refreshNote}. Active on your next query — no daemon restart needed.${reason ? ` Reason: ${reason}` : ''}`);
370
370
  });
371
- server.tool('refresh_tool_inventory', 'Force a fresh probe of the SDK\'s tool inventory, picking up any claude.ai connectors, Composio toolkits, or local MCP servers the owner has added since the last cache refresh. Owner-DM only. Use this when the owner says "I just added X at claude.ai" or when an expected integration isn\'t showing up. Updates ~/.clementine/.tool-inventory.json and syncs claude-integrations.json. Returns a diff of what changed.', {}, async () => {
371
+ server.tool('refresh_tool_inventory', 'Force a fresh probe of the SDK tool inventory to pick up newly added claude.ai connectors, Composio toolkits, or local MCP servers. Updates ~/.clementine/.tool-inventory.json + syncs claude-integrations.json and returns a diff. Owner-DM only.', {}, async () => {
372
372
  const gate = requireOwnerDm();
373
373
  if (!gate.ok)
374
374
  return textResult(gate.message);
@@ -1025,7 +1025,7 @@ export function registerAdminTools(server) {
1025
1025
  return textResult(lines.join('\n\n'));
1026
1026
  });
1027
1027
  // ── Add Cron Job ────────────────────────────────────────────────────────
1028
- server.tool('add_cron_job', 'Add a new scheduled task. BEFORE CALLING THIS TOOL: propose the concrete plan to the user in chat and get explicit approval. The `prompt` you save should be SELF-CONTAINED list the actual recipients, the actual template/content, the actual criteria. AVOID vague references like "recent leads" or "this week\'s items" that the trick will re-derive at fire-time, because re-derivation reads from MEMORY.md which drifts between chat-time agreement and fire-time execution. Good prompt: "Send template `monday-followup` to alice@x.com, bob@y.com, carol@z.com." Bad prompt: "Send follow-up to recent leads." The default `predictable: true` mode runs the trick with ONLY the prompt + explicitly-attached skills/tools no MEMORY.md, no team-comms injection, no runtime skill auto-match. Set `predictable: false` ONLY if the user explicitly wants a dynamic trick that re-resolves data each fire (e.g., "summarize yesterday\'s daily note" where the data legitimately changes).', {
1028
+ server.tool('add_cron_job', 'Add a new scheduled task. Propose the plan in chat and get user approval first. The `prompt` MUST be self-contained: name the actual recipients, template, and criteria vague references ("recent leads", "this week\'s items") drift between chat-time and fire-time. Default `predictable: true` runs with only prompt + pinned skills/tools (no MEMORY.md, no auto-match). Set `predictable: false` only when the user explicitly wants dynamic re-resolution each fire.', {
1029
1029
  name: z.string().describe('Job name (unique identifier)'),
1030
1030
  schedule: z.string().describe('Cron expression (e.g., "0 9 * * 1" for Monday 9 AM)'),
1031
1031
  prompt: z.string().describe('The prompt/instruction for the assistant to execute. SHOULD BE CONCRETE — list actual recipients, criteria, content. Vague prompts re-derive at fire-time and cause "agent agreed in chat but emailed wrong people" failures.'),
@@ -1143,7 +1143,7 @@ export function registerAdminTools(server) {
1143
1143
  return textResult(`Added cron job "${jobName}":\n${details.join('\n')}\n\n${verifyMsg}${goalHint}`);
1144
1144
  });
1145
1145
  // ── Update Cron Job ─────────────────────────────────────────────────────
1146
- server.tool('update_cron_job', 'Update an existing cron job in CRON.md. Partial — only fields you supply change. To CLEAR a capability allowlist (skills/allowed_tools/allowed_mcp_servers/tags), pass an empty array. To clear category, pass an empty string. The daemon auto-reloads on file change. Use preview_cron_job to confirm what will run before the next fire. Flipping `predictable` from true to false changes whether the trick reads MEMORY.md at fire-time — make sure the user understands the tradeoff before you toggle it.', {
1146
+ server.tool('update_cron_job', 'Update an existing cron job in CRON.md. Partial — only fields you supply change. Pass an empty array to clear a capability allowlist (skills/allowed_tools/allowed_mcp_servers/tags); empty string clears category. Daemon auto-reloads. Run preview_cron_job before relying on the change. Flipping `predictable` truefalse makes the trick read MEMORY.md at fire-time — confirm the tradeoff with the user.', {
1147
1147
  name: z.string().describe('Existing job name to update.'),
1148
1148
  schedule: z.string().optional().describe('New cron expression.'),
1149
1149
  prompt: z.string().optional().describe('New prompt.'),
@@ -1279,7 +1279,7 @@ export function registerAdminTools(server) {
1279
1279
  return textResult(`Updated cron job "${jobName}":\n ${changed.join('\n ')}\n\nDaemon will pick up the new definition within ~2s. Use \`preview_cron_job\` to confirm what will actually run.`);
1280
1280
  });
1281
1281
  // ── Preview Cron Job ────────────────────────────────────────────────────
1282
- server.tool('preview_cron_job', 'Show EXACTLY what a cron job ("trick") will send the agent on its next fire without dispatching to the agent. Composes the same context blocks (memory, progress, goals, skills, team, criteria, prompt, how-to-respond) the runner would compose at fire time. Returns the built prompt + resolved skills (full content) + effective tool/MCP allowlists + warnings (missing pins). Use this to sanity-check tricks after configuring them via chat or the dashboard, especially when you want predictable morning behavior.', {
1282
+ server.tool('preview_cron_job', 'Show what a cron job will send the agent on its next fire without dispatching. Composes the same context blocks the runner uses at fire time and returns the built prompt + resolved skills + effective tool/MCP allowlists + warnings. Use to sanity-check tricks after configuration.', {
1283
1283
  name: z.string().describe('Exact name of the cron job to preview (use list_cron_jobs to see available).'),
1284
1284
  }, async ({ name: jobName }) => {
1285
1285
  const [{ parseCronJobs, parseAgentCronJobs }, { buildCronExecutionPlan }, { loadSkillByName }, { discoverMcpServers }] = await Promise.all([
@@ -1582,99 +1582,6 @@ export function registerAdminTools(server) {
1582
1582
  lines.push('', 'ONLY modify files under this path when updating source. Other ~/clementine* directories may be stale checkouts — ignore them.');
1583
1583
  return textResult(lines.join('\n'));
1584
1584
  });
1585
- server.tool('self_update', 'Update Clementine to the latest main-branch code and restart. Runs git pull + npm install (if lockfile changed) + npm run build in the running daemon\'s source dir, then signals SIGUSR1 to restart. Owner-DM only. Use this instead of manually invoking git/npm — it operates on the correct source dir and handles the restart cleanly. Returns immediately; the daemon will be unreachable for ~15s during rebuild+restart.', {
1586
- branch: z.string().optional().describe('Branch to pull (default "main")'),
1587
- }, async ({ branch }) => {
1588
- const gate = requireOwnerDm();
1589
- if (!gate.ok)
1590
- return textResult(gate.message);
1591
- const root = resolvePackageRoot();
1592
- if (!existsSync(path.join(root, '.git'))) {
1593
- return textResult(`Refused: ${root} is not a git clone. This daemon was likely installed via npm — updates should go through \`npm install -g clementine-agent@latest\`, not self_update.`);
1594
- }
1595
- const targetBranch = branch ?? 'main';
1596
- const out = [];
1597
- const runQuiet = (cmd, args, timeout = 120000) => {
1598
- try {
1599
- const res = execSync(`${cmd} ${args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, {
1600
- cwd: root, encoding: 'utf-8', timeout,
1601
- stdio: ['ignore', 'pipe', 'pipe'],
1602
- });
1603
- return { ok: true, output: res };
1604
- }
1605
- catch (e) {
1606
- return { ok: false, output: (e?.stdout ?? '') + '\n' + (e?.stderr ?? '') + '\n' + String(e).slice(0, 200) };
1607
- }
1608
- };
1609
- // 1. Stash local changes so git pull is clean
1610
- const statusProbe = runQuiet('git', ['status', '--porcelain']);
1611
- const hadLocal = statusProbe.ok && statusProbe.output.trim().length > 0;
1612
- let stashed = false;
1613
- if (hadLocal) {
1614
- const stashRes = runQuiet('git', ['stash', 'push', '-u', '-m', `self_update ${new Date().toISOString()}`]);
1615
- if (stashRes.ok) {
1616
- stashed = true;
1617
- out.push('Stashed local changes (restore with `git stash pop` in the source dir if needed).');
1618
- }
1619
- else {
1620
- return textResult(`Refused: couldn't stash local changes in ${root}. ${stashRes.output.slice(0, 200)}`);
1621
- }
1622
- }
1623
- // 2. Checkout target branch if not already there
1624
- const branchProbe = runQuiet('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
1625
- if (branchProbe.ok && branchProbe.output.trim() !== targetBranch) {
1626
- const coRes = runQuiet('git', ['checkout', targetBranch]);
1627
- if (!coRes.ok)
1628
- return textResult(`git checkout ${targetBranch} failed: ${coRes.output.slice(0, 300)}`);
1629
- }
1630
- // 3. Record pre-pull hashes so we know if package-lock changed
1631
- const preLock = existsSync(path.join(root, 'package-lock.json')) ? readFileSync(path.join(root, 'package-lock.json'), 'utf-8').length : 0;
1632
- const preCommit = runQuiet('git', ['rev-parse', 'HEAD']).output.trim();
1633
- // 4. Pull
1634
- const pullRes = runQuiet('git', ['pull', '--ff-only', 'origin', targetBranch], 60000);
1635
- if (!pullRes.ok)
1636
- return textResult(`git pull failed: ${pullRes.output.slice(0, 300)}\n\n${stashed ? 'Local changes are preserved in git stash; inspect with `git stash list`.' : ''}`);
1637
- const postCommit = runQuiet('git', ['rev-parse', 'HEAD']).output.trim();
1638
- if (preCommit === postCommit) {
1639
- out.push(`Already up to date at ${preCommit.slice(0, 7)}.`);
1640
- return textResult(out.join('\n'));
1641
- }
1642
- out.push(`Pulled: ${preCommit.slice(0, 7)} → ${postCommit.slice(0, 7)}`);
1643
- // 5. npm install only if package-lock changed
1644
- const postLock = existsSync(path.join(root, 'package-lock.json')) ? readFileSync(path.join(root, 'package-lock.json'), 'utf-8').length : 0;
1645
- if (postLock !== preLock) {
1646
- out.push('package-lock.json changed — running npm install...');
1647
- const installRes = runQuiet('npm', ['install'], 180000);
1648
- if (!installRes.ok)
1649
- return textResult(`${out.join('\n')}\n\nnpm install failed: ${installRes.output.slice(0, 300)}`);
1650
- out.push(' ok');
1651
- }
1652
- // 6. Build
1653
- out.push('Building...');
1654
- const buildRes = runQuiet('npm', ['run', 'build'], 300000);
1655
- if (!buildRes.ok)
1656
- return textResult(`${out.join('\n')}\n\nBuild failed: ${buildRes.output.slice(0, 300)}`);
1657
- out.push(' ok');
1658
- // 7. Trigger graceful self-restart
1659
- const pidFile = path.join(BASE_DIR, `.${(env['ASSISTANT_NAME'] ?? 'clementine').toLowerCase()}.pid`);
1660
- if (existsSync(pidFile)) {
1661
- const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
1662
- if (!isNaN(pid)) {
1663
- try {
1664
- process.kill(pid, 0);
1665
- process.kill(pid, 'SIGUSR1');
1666
- out.push(`Sent SIGUSR1 to PID ${pid}. I'll be back in ~15s on the new build.`);
1667
- }
1668
- catch {
1669
- out.push('Daemon PID not running — no restart signal sent. New build is on disk; launch manually.');
1670
- }
1671
- }
1672
- }
1673
- else {
1674
- out.push('No PID file found. Build succeeded but restart not triggered — run `clementine launch` manually.');
1675
- }
1676
- return textResult(out.join('\n'));
1677
- });
1678
1585
  server.tool('self_restart', 'Restart the Clementine daemon to pick up code changes. Sends SIGUSR1 to the running process, which triggers a graceful restart. Use self_update instead if you also need to pull/build first.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
1679
1586
  const pidFile = path.join(BASE_DIR, `.${(env['ASSISTANT_NAME'] ?? 'clementine').toLowerCase()}.pid`);
1680
1587
  if (!existsSync(pidFile)) {
@@ -12,7 +12,6 @@ import { z } from 'zod';
12
12
  import { textResult } from './shared.js';
13
13
  import { listAllForBuilder, readWorkflow, saveWorkflow, cronId, workflowId, parseBuilderId, isCronShape, sourceFileForId, } from '../dashboard/builder/serializer.js';
14
14
  import { validateWorkflow } from '../dashboard/builder/validation.js';
15
- import { dryRunWorkflow } from '../dashboard/builder/dry-run.js';
16
15
  import { emitBuilderEvent } from '../dashboard/builder/events.js';
17
16
  import { listSnapshots, restoreSnapshot } from '../dashboard/builder/snapshots.js';
18
17
  import { discoverMcpServers, loadToolInventory } from '../agent/mcp-bridge.js';
@@ -59,30 +58,6 @@ export function registerBuilderTools(server) {
59
58
  return `${i.id}|${i.name}|${i.origin}|${ownerCol}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`;
60
59
  }).join('\n'));
61
60
  });
62
- server.tool('workflow_read', 'Read a workflow as canonical JSON. Use this before editing — patches reference current step ids.', {
63
- id: z.string().describe('Builder id (e.g., cron:morning-briefing or workflow:daily-digest)'),
64
- }, async ({ id }) => {
65
- const wf = readWorkflow(id);
66
- if (!wf)
67
- return textResult(`Not found: ${id}`);
68
- return textResult(JSON.stringify(wf, null, 2));
69
- });
70
- server.tool('workflow_search', 'Search workflows + crons by name or content (substring, case-insensitive).', { query: z.string() }, async ({ query }) => {
71
- const q = query.toLowerCase();
72
- const items = listAllForBuilder();
73
- const matches = [];
74
- for (const i of items) {
75
- if (i.name.toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q)) {
76
- matches.push(`${i.id}|${i.name}|${i.origin}`);
77
- continue;
78
- }
79
- const wf = readWorkflow(i.id);
80
- if (wf && wf.steps.some(s => s.prompt.toLowerCase().includes(q))) {
81
- matches.push(`${i.id}|${i.name}|${i.origin} (matched in step prompt)`);
82
- }
83
- }
84
- return textResult(matches.length === 0 ? `(no matches for "${query}")` : matches.join('\n'));
85
- });
86
61
  server.tool('workflow_list_mcp_tools', 'List MCP servers and their tools. Use to fill in mcp-step configs (server + tool name).', {}, async () => {
87
62
  const servers = discoverMcpServers();
88
63
  const inv = loadToolInventory();
@@ -110,62 +85,7 @@ export function registerBuilderTools(server) {
110
85
  return textResult('OK — no issues');
111
86
  return textResult(formatIssues(result.issues));
112
87
  });
113
- server.tool('workflow_dry_run', 'Walk a workflow without executing. Shows what each step would do, in topological order, with rough cost estimate. Use for long-running jobs to preview safely.', { id: z.string() }, async ({ id }) => {
114
- const wf = readWorkflow(id);
115
- if (!wf)
116
- return textResult(`Not found: ${id}`);
117
- const r = dryRunWorkflow(wf);
118
- const lines = [];
119
- lines.push(r.ok ? `DRY RUN: ${wf.name}` : `DRY RUN (validation failed): ${wf.name}`);
120
- if (r.validationIssues.length)
121
- lines.push(formatIssues(r.validationIssues));
122
- for (const s of r.steps) {
123
- lines.push(`[wave ${s.wave}] ${s.description}`);
124
- for (const w of s.warnings)
125
- lines.push(` ⚠ ${w}`);
126
- }
127
- if (r.estimatedTokens) {
128
- lines.push(`Rough estimate: ~${r.estimatedTokens.total.toLocaleString()} tokens across ${r.estimatedTokens.promptSteps} prompt step(s).`);
129
- }
130
- for (const note of r.notes)
131
- lines.push(note);
132
- return textResult(lines.join('\n'));
133
- });
134
88
  // ── Mutations ──────────────────────────────────────────────────────────
135
- server.tool('workflow_save', 'Save a workflow (full replace). Validates before writing — rejects on errors unless `force: true`. Use this when you need to change many fields atomically; for small edits prefer the targeted tools (workflow_add_node etc.).', {
136
- id: z.string(),
137
- workflow: z.object({
138
- name: z.string(),
139
- description: z.string().default(''),
140
- enabled: z.boolean().default(true),
141
- trigger: z.object({ schedule: z.string().optional(), manual: z.boolean().optional() }).default({ manual: true }),
142
- inputs: z.record(z.string(), z.object({ type: z.enum(['string', 'number']), default: z.string().optional(), description: z.string().optional() })).default({}),
143
- steps: z.array(stepShape),
144
- synthesis: z.object({ prompt: z.string() }).optional(),
145
- agentSlug: z.string().optional(),
146
- project: z.string().optional().describe('Linked-project path; default cwd for CLI steps'),
147
- model: z.string().optional().describe('Default model for prompt steps without their own'),
148
- }),
149
- force: z.boolean().optional().describe('Bypass validation errors (warnings always ignored)'),
150
- }, async ({ id, workflow, force }) => {
151
- const existing = readWorkflow(id);
152
- if (!existing)
153
- return textResult(`Not found: ${id}`);
154
- const next = {
155
- ...workflow,
156
- steps: workflow.steps.map(s => normalizeStep(s)),
157
- sourceFile: existing.sourceFile,
158
- };
159
- const v = validateWorkflow(next);
160
- if (!v.ok && !force) {
161
- return textResult('Save rejected — validation errors:\n' + formatIssues(v.issues) + '\nPass force: true to override.');
162
- }
163
- const result = saveWorkflow(id, next);
164
- if (!result.ok)
165
- return textResult('Save failed: ' + result.error);
166
- emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: next } });
167
- return textResult(`Saved ${id}.${v.issues.length ? ' Warnings:\n' + formatIssues(v.issues.filter(i => i.severity === 'warning')) : ''}`);
168
- });
169
89
  server.tool('workflow_create', 'Create a new workflow file (multi-step) under vault/00-System/workflows/. Returns its builder id.', {
170
90
  name: z.string().describe('Workflow name (also derived as the file slug)'),
171
91
  description: z.string().default(''),
@@ -391,26 +311,6 @@ export function registerBuilderTools(server) {
391
311
  emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { restoredFrom: snapshotFilename } });
392
312
  return textResult(`Restored ${id} from ${snapshotFilename}`);
393
313
  });
394
- server.tool('workflow_delete', 'Delete a workflow file (multi-step workflows only). Cron entries are removed via workflow_set_enabled or by editing CRON.md directly.', { id: z.string() }, async ({ id }) => {
395
- const parsed = parseBuilderId(id);
396
- if (!parsed)
397
- return textResult(`Bad id: ${id}`);
398
- if (parsed.origin === 'cron')
399
- return textResult('Use workflow_set_enabled false to disable a cron, or delete the CRON.md entry manually.');
400
- const wf = readWorkflow(id);
401
- if (!wf)
402
- return textResult(`Not found: ${id}`);
403
- try {
404
- const { unlinkSync, existsSync } = await import('node:fs');
405
- if (existsSync(wf.sourceFile))
406
- unlinkSync(wf.sourceFile);
407
- }
408
- catch (err) {
409
- return textResult(`Delete failed: ${err.message}`);
410
- }
411
- emitBuilderEvent({ type: 'workflow:deleted', workflowId: id });
412
- return textResult(`Deleted ${id}`);
413
- });
414
314
  }
415
315
  // ── helpers ────────────────────────────────────────────────────────────
416
316
  function normalizeStep(s) {
@@ -10,7 +10,7 @@ import { execSync } from 'node:child_process';
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { z } from 'zod';
13
- import { ACTIVE_AGENT_SLUG, EXTERNAL_CONTENT_TAG, SYSTEM_DIR, env, externalResult, getStore, logger, textResult, } from './shared.js';
13
+ import { ACTIVE_AGENT_SLUG, SYSTEM_DIR, env, externalResult, getStore, logger, textResult, } from './shared.js';
14
14
  import { getToolDescription } from './tool-meta.js';
15
15
  export function registerExternalTools(server) {
16
16
  // ── 13. rss_fetch ──────────────────────────────────────────────────────
@@ -962,250 +962,6 @@ export function registerExternalTools(server) {
962
962
  function joinName(first, last) {
963
963
  return [first, last].filter(Boolean).join(' ') || 'Unknown';
964
964
  }
965
- // ── sf_lead_push ─────────────────────────────────────────────────────────
966
- server.tool('sf_lead_push', 'Push a local lead to Salesforce. Creates a new SF Lead or updates existing if the lead already has a Salesforce ID.', {
967
- leadId: z.number().describe('Local lead ID to push to Salesforce'),
968
- }, async ({ leadId }) => {
969
- if (!sfConfigured())
970
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
971
- const store = await getStore();
972
- const lead = store.getLeadById(leadId);
973
- if (!lead)
974
- return textResult(`Lead ID ${leadId} not found`);
975
- const { FirstName, LastName } = splitName(String(lead.name ?? ''));
976
- const sfData = {
977
- FirstName,
978
- LastName,
979
- Email: lead.email,
980
- Company: lead.company || '[Unknown]',
981
- Title: lead.title || undefined,
982
- Status: LOCAL_TO_SF_STATUS[String(lead.status ?? 'new')] || 'Open - Not Contacted',
983
- LeadSource: lead.source || undefined,
984
- };
985
- // Remove undefined values
986
- for (const k of Object.keys(sfData)) {
987
- if (sfData[k] === undefined)
988
- delete sfData[k];
989
- }
990
- try {
991
- if (lead.sf_id) {
992
- await sfPatch(`/sobjects/Lead/${lead.sf_id}`, sfData);
993
- store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id), syncDirection: 'push' });
994
- return textResult(`Updated Salesforce Lead ${lead.sf_id} for ${lead.name} <${lead.email}>`);
995
- }
996
- else {
997
- const result = await sfPost('/sobjects/Lead', sfData);
998
- const sfId = result.id;
999
- store.upsertLead({ agentSlug: String(lead.agent_slug), email: String(lead.email), name: String(lead.name), sfId });
1000
- store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId, syncDirection: 'push' });
1001
- return textResult(`Created Salesforce Lead ${sfId} for ${lead.name} <${lead.email}>`);
1002
- }
1003
- }
1004
- catch (err) {
1005
- store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id ?? ''), syncDirection: 'push', syncStatus: 'error', errorMessage: String(err) });
1006
- return textResult(`Error pushing lead to Salesforce: ${err}`);
1007
- }
1008
- });
1009
- // ── sf_lead_pull ─────────────────────────────────────────────────────────
1010
- server.tool('sf_lead_pull', 'Pull a Salesforce Lead or Contact into the local lead database by Salesforce ID or email address.', {
1011
- sfId: z.string().optional().describe('Salesforce Lead/Contact ID'),
1012
- email: z.string().optional().describe('Email address to search in Salesforce'),
1013
- }, async ({ sfId, email }) => {
1014
- if (!sfConfigured())
1015
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1016
- if (!sfId && !email)
1017
- return textResult('Provide either sfId or email');
1018
- try {
1019
- let record = null;
1020
- let objectType = 'Lead';
1021
- if (sfId) {
1022
- // Try Lead first, then Contact
1023
- try {
1024
- record = await sfGet(`/sobjects/Lead/${sfId}`);
1025
- }
1026
- catch {
1027
- record = await sfGet(`/sobjects/Contact/${sfId}`);
1028
- objectType = 'Contact';
1029
- }
1030
- }
1031
- else if (email) {
1032
- const soql = `SELECT Id, FirstName, LastName, Email, Company, Title, Status, LeadSource FROM Lead WHERE Email = '${email.replace(/'/g, "\\'")}' LIMIT 1`;
1033
- const result = await sfQuery(soql);
1034
- if (result.records?.length > 0) {
1035
- record = result.records[0];
1036
- }
1037
- else {
1038
- const contactSoql = `SELECT Id, FirstName, LastName, Email, Account.Name, Title FROM Contact WHERE Email = '${email.replace(/'/g, "\\'")}' LIMIT 1`;
1039
- const contactResult = await sfQuery(contactSoql);
1040
- if (contactResult.records?.length > 0) {
1041
- record = contactResult.records[0];
1042
- objectType = 'Contact';
1043
- }
1044
- }
1045
- }
1046
- if (!record)
1047
- return textResult(`No Salesforce Lead or Contact found for ${sfId || email}`);
1048
- const store = await getStore();
1049
- const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
1050
- const name = joinName(record.FirstName, record.LastName);
1051
- const company = objectType === 'Contact'
1052
- ? record.Account?.Name ?? ''
1053
- : record.Company ?? '';
1054
- const localStatus = SF_TO_LOCAL_STATUS[String(record.Status ?? '')] || 'new';
1055
- const upsertResult = store.upsertLead({
1056
- agentSlug,
1057
- email: String(record.Email ?? email ?? ''),
1058
- name,
1059
- company,
1060
- title: record.Title,
1061
- status: localStatus,
1062
- source: record.LeadSource,
1063
- sfId: String(record.Id),
1064
- });
1065
- store.logSfSync({
1066
- localTable: 'leads', localId: upsertResult.id,
1067
- sfObjectType: objectType, sfId: String(record.Id), syncDirection: 'pull',
1068
- });
1069
- return textResult(`${upsertResult.created ? 'Created' : 'Updated'} local lead from Salesforce ${objectType} ${record.Id}:\n` +
1070
- ` Name: ${name}\n Email: ${record.Email}\n Company: ${company}\n Status: ${localStatus}`);
1071
- }
1072
- catch (err) {
1073
- return textResult(`Error pulling from Salesforce: ${err}`);
1074
- }
1075
- });
1076
- // ── sf_contact_search ────────────────────────────────────────────────────
1077
- server.tool('sf_contact_search', 'Search Salesforce Leads and/or Contacts by name, email, or company.', {
1078
- query: z.string().describe('Search keyword (name, email, or company)'),
1079
- objectType: z.enum(['Lead', 'Contact', 'Both']).optional().default('Both').describe('Which SF object type to search'),
1080
- limit: z.number().optional().default(10).describe('Max results to return'),
1081
- }, async ({ query, objectType, limit }) => {
1082
- if (!sfConfigured())
1083
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1084
- try {
1085
- const safeQuery = query.replace(/'/g, "\\'");
1086
- const results = [];
1087
- const maxResults = Math.min(limit, 50);
1088
- if (objectType === 'Lead' || objectType === 'Both') {
1089
- const soql = `SELECT Id, FirstName, LastName, Email, Company, Title, Status, LeadSource FROM Lead WHERE Name LIKE '%${safeQuery}%' OR Email LIKE '%${safeQuery}%' OR Company LIKE '%${safeQuery}%' LIMIT ${maxResults}`;
1090
- const data = await sfQuery(soql);
1091
- for (const r of data.records ?? []) {
1092
- results.push(`[Lead] ${joinName(r.FirstName, r.LastName)} <${r.Email ?? 'no email'}> | ${r.Company ?? ''} | ${r.Title ?? ''} | Status: ${r.Status ?? ''} | ID: ${r.Id}`);
1093
- }
1094
- }
1095
- if (objectType === 'Contact' || objectType === 'Both') {
1096
- const soql = `SELECT Id, FirstName, LastName, Email, Account.Name, Title FROM Contact WHERE Name LIKE '%${safeQuery}%' OR Email LIKE '%${safeQuery}%' OR Account.Name LIKE '%${safeQuery}%' LIMIT ${maxResults}`;
1097
- const data = await sfQuery(soql);
1098
- for (const r of data.records ?? []) {
1099
- const acct = r.Account?.Name ?? '';
1100
- results.push(`[Contact] ${joinName(r.FirstName, r.LastName)} <${r.Email ?? 'no email'}> | ${acct} | ${r.Title ?? ''} | ID: ${r.Id}`);
1101
- }
1102
- }
1103
- if (results.length === 0)
1104
- return textResult(`No Salesforce records found matching "${query}"`);
1105
- return textResult(`${EXTERNAL_CONTENT_TAG}\n\nSalesforce search results for "${query}" (${results.length} found):\n\n${results.join('\n')}`);
1106
- }
1107
- catch (err) {
1108
- return textResult(`Error searching Salesforce: ${err}`);
1109
- }
1110
- });
1111
- // ── sf_opportunity_create ────────────────────────────────────────────────
1112
- server.tool('sf_opportunity_create', 'Create a new Opportunity in Salesforce, optionally linked to an Account or Contact.', {
1113
- name: z.string().describe('Opportunity name'),
1114
- stageName: z.string().describe('Sales stage (e.g., "Prospecting", "Qualification", "Closed Won")'),
1115
- closeDate: z.string().describe('Expected close date (YYYY-MM-DD)'),
1116
- amount: z.number().optional().describe('Deal amount in dollars'),
1117
- accountId: z.string().optional().describe('Salesforce Account ID to link'),
1118
- contactId: z.string().optional().describe('Salesforce Contact ID to add as Contact Role'),
1119
- }, async ({ name, stageName, closeDate, amount, accountId, contactId }) => {
1120
- if (!sfConfigured())
1121
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1122
- try {
1123
- const oppData = { Name: name, StageName: stageName, CloseDate: closeDate };
1124
- if (amount !== undefined)
1125
- oppData.Amount = amount;
1126
- if (accountId)
1127
- oppData.AccountId = accountId;
1128
- const result = await sfPost('/sobjects/Opportunity', oppData);
1129
- const oppId = result.id;
1130
- let contactRoleMsg = '';
1131
- // Link contact role if provided
1132
- if (contactId) {
1133
- try {
1134
- await sfPost('/sobjects/OpportunityContactRole', {
1135
- OpportunityId: oppId,
1136
- ContactId: contactId,
1137
- Role: 'Decision Maker',
1138
- });
1139
- contactRoleMsg = `\nLinked Contact ${contactId} as Decision Maker`;
1140
- }
1141
- catch (err) {
1142
- contactRoleMsg = `\nWarning: Could not link Contact Role: ${err}`;
1143
- }
1144
- }
1145
- return textResult(`Created Opportunity ${oppId}: "${name}" (${stageName}, close: ${closeDate}${amount ? `, $${amount}` : ''})${contactRoleMsg}`);
1146
- }
1147
- catch (err) {
1148
- return textResult(`Error creating Opportunity: ${err}`);
1149
- }
1150
- });
1151
- // ── sf_opportunity_update ────────────────────────────────────────────────
1152
- server.tool('sf_opportunity_update', 'Update an existing Salesforce Opportunity (stage, amount, close date, etc.).', {
1153
- sfId: z.string().describe('Salesforce Opportunity ID'),
1154
- stageName: z.string().optional().describe('New sales stage'),
1155
- amount: z.number().optional().describe('Updated deal amount'),
1156
- closeDate: z.string().optional().describe('Updated close date (YYYY-MM-DD)'),
1157
- description: z.string().optional().describe('Opportunity description/notes'),
1158
- }, async ({ sfId, stageName, amount, closeDate, description }) => {
1159
- if (!sfConfigured())
1160
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1161
- try {
1162
- const updates = {};
1163
- if (stageName)
1164
- updates.StageName = stageName;
1165
- if (amount !== undefined)
1166
- updates.Amount = amount;
1167
- if (closeDate)
1168
- updates.CloseDate = closeDate;
1169
- if (description)
1170
- updates.Description = description;
1171
- if (Object.keys(updates).length === 0)
1172
- return textResult('No fields to update');
1173
- await sfPatch(`/sobjects/Opportunity/${sfId}`, updates);
1174
- const fields = Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join(', ');
1175
- return textResult(`Updated Opportunity ${sfId}: ${fields}`);
1176
- }
1177
- catch (err) {
1178
- return textResult(`Error updating Opportunity: ${err}`);
1179
- }
1180
- });
1181
- // ── sf_activity_log ──────────────────────────────────────────────────────
1182
- server.tool('sf_activity_log', 'Log an activity (Task) to Salesforce linked to a Lead or Contact. Use this to record calls, emails, meetings, etc.', {
1183
- sfWhoId: z.string().describe('Salesforce Lead or Contact ID to link the activity to'),
1184
- subject: z.string().describe('Activity subject line'),
1185
- description: z.string().optional().describe('Activity description/notes'),
1186
- type: z.enum(['Call', 'Email', 'Meeting', 'Other']).optional().default('Other').describe('Activity type'),
1187
- status: z.enum(['Completed', 'Not Started', 'In Progress']).optional().default('Completed').describe('Task status'),
1188
- activityDate: z.string().optional().describe('Activity date (YYYY-MM-DD, defaults to today)'),
1189
- }, async ({ sfWhoId, subject, description, type, status, activityDate }) => {
1190
- if (!sfConfigured())
1191
- return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1192
- try {
1193
- const taskData = {
1194
- WhoId: sfWhoId,
1195
- Subject: subject,
1196
- Status: status,
1197
- Type: type,
1198
- ActivityDate: activityDate || new Date().toISOString().slice(0, 10),
1199
- };
1200
- if (description)
1201
- taskData.Description = description;
1202
- const result = await sfPost('/sobjects/Task', taskData);
1203
- return textResult(`Logged ${type} activity to Salesforce (Task ID: ${result.id}): "${subject}" for ${sfWhoId}`);
1204
- }
1205
- catch (err) {
1206
- return textResult(`Error logging activity to Salesforce: ${err}`);
1207
- }
1208
- });
1209
965
  // ── sf_sync ──────────────────────────────────────────────────────────────
1210
966
  server.tool('sf_sync', 'Run a bidirectional sync between local leads and Salesforce. Pushes unsynced/modified local leads to SF and pulls recently modified SF leads into the local database.', {
1211
967
  direction: z.enum(['push', 'pull', 'both']).optional().default('both').describe('Sync direction'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.148",
3
+ "version": "1.18.150",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",