clementine-agent 1.18.148 → 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.
- package/dist/agent/assistant.d.ts +1 -1
- package/dist/agent/assistant.js +4 -5
- package/dist/brain/llm-client.d.ts +2 -2
- package/dist/brain/llm-client.js +4 -4
- package/dist/cli/index.js +2 -11
- package/dist/cli/ingest.js +2 -9
- package/dist/config.d.ts +0 -1
- package/dist/config.js +5 -1
- package/dist/gateway/turn-ledger.d.ts +2 -1
- package/dist/gateway/turn-ledger.js +4 -3
- package/dist/index.js +0 -4
- package/dist/integrations/composio/client.d.ts +0 -1
- package/dist/integrations/composio/client.js +0 -51
- package/dist/lib/format.d.ts +28 -0
- package/dist/lib/format.js +42 -0
- package/dist/memory/store.d.ts +0 -1
- package/dist/memory/store.js +3 -12
- package/dist/tools/admin-tools.js +0 -93
- package/dist/tools/builder-tools.js +0 -100
- package/dist/tools/external-tools.js +1 -245
- package/package.json +1 -1
|
@@ -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
|
|
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;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
@@ -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
|
-
|
|
32
|
-
export declare
|
|
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;
|
package/dist/brain/llm-client.js
CHANGED
|
@@ -86,10 +86,10 @@ export function parseJsonResponse(raw) {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// 1.18.149 — estTokens 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
|
-
|
|
71
|
-
|
|
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;
|
package/dist/cli/ingest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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.
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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,
|
|
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'),
|