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