clementine-agent 1.18.147 → 1.18.149

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
@@ -351,7 +351,15 @@ export async function classifyRoute(userMessage, agents, gateway) {
351
351
  undefined, // maxHours
352
352
  undefined, // timeoutMs
353
353
  undefined, // successCriteria
354
- undefined);
354
+ undefined, // agentSlug
355
+ // 1.18.148 — F1/F2 pattern: meta-jobs run predictable to keep the
356
+ // prompt under Claude's input limit. Without these the classifier
357
+ // inherited MEMORY.md + team comms + auto-matched skills, blew up
358
+ // 12+ times in 8 hours (silent fallback to default route).
359
+ undefined, // pinnedSkills
360
+ [], // allowedTools
361
+ [], // allowedMcpServers
362
+ true);
355
363
  }
356
364
  catch (err) {
357
365
  logger.warn({ err }, 'Route classifier call failed');
@@ -45,8 +45,17 @@ const MAX_INJECTED_SKILLS = 4;
45
45
  * delegation is the one thing every cron must always be able to do).
46
46
  */
47
47
  export function computeEffectiveAllowedTools(jobAllow, profileAllow) {
48
- if (!jobAllow?.length)
48
+ // 1.18.148 — distinguish "no allowlist" (undefined → unrestricted) from
49
+ // "explicitly empty allowlist" (`[]` → deny all). Before this, both
50
+ // collapsed to `undefined` because of the `?.length` check, which meant
51
+ // meta-jobs passing `allowedTools: []` actually got the FULL tool set
52
+ // injected (and blew past the prompt limit when Composio toolkits piled
53
+ // on tool schemas). The fix: an explicit `[]` returns `['Agent']` only
54
+ // (just the SDK's required spawn-subagent tool).
55
+ if (jobAllow === undefined)
49
56
  return undefined;
57
+ if (jobAllow.length === 0)
58
+ return ['Agent']; // explicitly empty → minimal
50
59
  let result;
51
60
  if (profileAllow?.length) {
52
61
  const jobSet = new Set(jobAllow);
@@ -67,8 +76,16 @@ export function computeEffectiveAllowedTools(jobAllow, profileAllow) {
67
76
  * MCP allowlist set.
68
77
  */
69
78
  export function applyMcpAllowlist(servers, jobAllowedMcpServers) {
70
- if (!jobAllowedMcpServers?.length)
79
+ // 1.18.148 — empty array means "deny all MCP servers", not "no
80
+ // restriction". Before this, passing `[]` collapsed to `?.length === 0`
81
+ // and returned the unfiltered server map — so meta-jobs (insight-check,
82
+ // grade:*, route-classify, diagnose:*) got every Composio toolkit's
83
+ // tool schemas wired into their prompt and blew past Claude's input
84
+ // limit. 110+ "Prompt is too long" errors per 8 hours.
85
+ if (jobAllowedMcpServers === undefined)
71
86
  return servers;
87
+ if (jobAllowedMcpServers.length === 0)
88
+ return {};
72
89
  const allow = new Set(jobAllowedMcpServers);
73
90
  return Object.fromEntries(Object.entries(servers).filter(([name]) => allow.has(name)));
74
91
  }
@@ -94,8 +111,13 @@ export function applyMcpAllowlist(servers, jobAllowedMcpServers) {
94
111
  * unchanged.
95
112
  */
96
113
  export function widenAllowlistWithSkillTools(jobAllow, pinnedSkillTools) {
97
- if (!jobAllow?.length)
114
+ // 1.18.148 — preserve "explicitly empty" semantics. An empty array is a
115
+ // contract: "I want no tools." Skill-pin widening doesn't apply when no
116
+ // skills are pinned (which is the case for meta-jobs).
117
+ if (jobAllow === undefined)
98
118
  return undefined;
119
+ if (jobAllow.length === 0 && !pinnedSkillTools?.length)
120
+ return [];
99
121
  if (!pinnedSkillTools?.length)
100
122
  return [...jobAllow];
101
123
  return [...new Set([...jobAllow, ...pinnedSkillTools])];
@@ -130,8 +152,12 @@ export function extractMcpServersFromSkillBodies(bodies) {
130
152
  * allowlist; doesn't synthesize one when the cron is unrestricted.
131
153
  */
132
154
  export function widenMcpAllowlistWithSkillRefs(jobMcpAllow, skillReferencedServers) {
133
- if (!jobMcpAllow?.length)
155
+ // 1.18.148 — preserve "explicitly empty" semantics. See note on
156
+ // applyMcpAllowlist + widenAllowlistWithSkillTools above.
157
+ if (jobMcpAllow === undefined)
134
158
  return undefined;
159
+ if (jobMcpAllow.length === 0 && !skillReferencedServers.length)
160
+ return [];
135
161
  if (!skillReferencedServers.length)
136
162
  return [...jobMcpAllow];
137
163
  return [...new Set([...jobMcpAllow, ...skillReferencedServers])];
@@ -572,8 +598,10 @@ export async function buildCronExecutionPlan(opts) {
572
598
  const widenedJobMcpAllowlist = widenMcpAllowlistWithSkillRefs(opts.allowedMcpServers, skillReferencedMcpServers);
573
599
  // Per-trick MCP allowlist: post-filter on the profile-narrowed map.
574
600
  // Effective set = profile ∩ trick (widened).
601
+ // 1.18.148 — empty array means "deny all" not "no restriction" (was a
602
+ // silent prompt-bloat bug — see applyMcpAllowlist note above).
575
603
  const mcpServerMap = applyMcpAllowlist(mcp.servers, widenedJobMcpAllowlist);
576
- const allowSet = widenedJobMcpAllowlist?.length ? new Set(widenedJobMcpAllowlist) : null;
604
+ const allowSet = widenedJobMcpAllowlist === undefined ? null : new Set(widenedJobMcpAllowlist);
577
605
  const composioConnected = allowSet ? mcp.composioConnected.filter(n => allowSet.has(n)) : mcp.composioConnected;
578
606
  const externalConnected = allowSet ? mcp.externalConnected.filter(n => allowSet.has(n)) : mcp.externalConnected;
579
607
  const mcpServersApplied = Object.keys(mcpServerMap);
@@ -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' },
@@ -546,7 +546,15 @@ export async function diagnoseBrokenJob(broken, gateway) {
546
546
  undefined, // maxHours
547
547
  undefined, // timeoutMs
548
548
  undefined, // successCriteria
549
- undefined);
549
+ undefined, // agentSlug
550
+ // 1.18.148 — F1/F2 pattern: diagnostics are pure analysis of an
551
+ // existing run, no need for MEMORY.md / team / auto-skills. Without
552
+ // these flags the diagnostic prompt blew past Claude's input limit
553
+ // and broken jobs went undiagnosed silently.
554
+ undefined, // pinnedSkills
555
+ [], // allowedTools
556
+ [], // allowedMcpServers
557
+ true);
550
558
  }
551
559
  catch (err) {
552
560
  logger.warn({ err, job: broken.jobName }, 'Diagnostic LLM call failed');
@@ -140,7 +140,16 @@ export async function gradeRun(entry, gateway, jobPrompt) {
140
140
  undefined, // maxHours
141
141
  undefined, // timeoutMs
142
142
  undefined, // successCriteria
143
- undefined);
143
+ undefined, // agentSlug
144
+ // 1.18.148 — F1/F2 pattern: meta-jobs don't get user MEMORY.md /
145
+ // team comms / auto-matched skills, otherwise the prompt blows
146
+ // past Claude's input limit (110+ "Prompt is too long" errors/8h
147
+ // before this fix). Same shape applied to insight-check (1.18.132)
148
+ // and route-classify / failure-diagnostics in this same ship.
149
+ undefined, // pinnedSkills
150
+ [], // allowedTools — empty = no MCP injection
151
+ [], // allowedMcpServers — empty = no MCP servers wired
152
+ true);
144
153
  }
145
154
  catch (err) {
146
155
  logger.warn({ err, jobName: entry.jobName }, 'Outcome grader LLM call failed');
@@ -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
  })(),
@@ -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.147",
3
+ "version": "1.18.149",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",