clementine-agent 1.18.44 → 1.18.46

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.
@@ -277,6 +277,13 @@ export declare class PersonalAssistant {
277
277
  private memoryExtractionKey;
278
278
  private assessMemoryExtraction;
279
279
  private logMemoryExtractionSkip;
280
+ /**
281
+ * Public entry point for triggering auto-memory extraction after an
282
+ * exchange. Used by the new runAgent chat path (Phase 2 migration)
283
+ * so it can keep the existing memory-extraction behavior without
284
+ * having to recreate the surrounding plumbing.
285
+ */
286
+ triggerMemoryExtractionPostExchange(userMessage: string, assistantResponse: string, sessionKey?: string, profile?: AgentProfile): Promise<void>;
280
287
  private spawnMemoryExtraction;
281
288
  private static readonly MEMORY_TOOL_NAMES;
282
289
  private extractMemory;
@@ -4883,6 +4883,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4883
4883
  }
4884
4884
  catch { /* telemetry only */ }
4885
4885
  }
4886
+ /**
4887
+ * Public entry point for triggering auto-memory extraction after an
4888
+ * exchange. Used by the new runAgent chat path (Phase 2 migration)
4889
+ * so it can keep the existing memory-extraction behavior without
4890
+ * having to recreate the surrounding plumbing.
4891
+ */
4892
+ async triggerMemoryExtractionPostExchange(userMessage, assistantResponse, sessionKey, profile) {
4893
+ return this.spawnMemoryExtraction(userMessage, assistantResponse, sessionKey, profile);
4894
+ }
4886
4895
  async spawnMemoryExtraction(userMessage, assistantResponse, sessionKey, profile) {
4887
4896
  // Guard: skip memory extraction if the user message looks like injection
4888
4897
  const memScan = scanner.scan(userMessage);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Clementine TypeScript — runAgent cron wrapper.
3
+ *
4
+ * Phase 3 of the SDK-canonical migration (see
5
+ * /Users/nathan.reynolds/.claude/plans/sdk-canonical-migration.md).
6
+ *
7
+ * Cron jobs need more than a bare runAgent() call: they get progress
8
+ * continuity, linked goals, delegated tasks, team context, success
9
+ * criteria, and matched skills injected ahead of the job prompt. This
10
+ * file owns that composition for the new (canonical SDK) path. The
11
+ * legacy assistant.ts:runCronJob keeps its inline equivalents so we
12
+ * can ship Phase 3 without touching legacy code.
13
+ *
14
+ * After Phase 3 verifies and we collapse the legacy path, the
15
+ * duplicated helpers here become the single source of truth.
16
+ */
17
+ import type { AgentProfile } from '../types.js';
18
+ import type { AgentManager } from './agent-manager.js';
19
+ import type { MemoryStore } from '../memory/store.js';
20
+ import { type RunAgentResult } from './run-agent.js';
21
+ export interface RunAgentCronOptions {
22
+ /** Job name from CRON.md. Used for telemetry, progress lookup, skill match. */
23
+ jobName: string;
24
+ /** Job prompt body (the user-defined "do this" text). */
25
+ jobPrompt: string;
26
+ /** Cron tier. Drives effort + budget. */
27
+ tier?: number;
28
+ /** Optional max-turns cap (the SDK runs until done otherwise, bounded by maxBudget). */
29
+ maxTurns?: number;
30
+ /** Profile of the hired agent running this job (Sasha/Ross/Nora/etc). null = Clementine. */
31
+ profile?: AgentProfile | null;
32
+ /** Hired-agent registry — passed through to runAgent so subagent delegation works. */
33
+ agentManager?: AgentManager | null;
34
+ /** Memory store for cost logging + skill use tracking. */
35
+ memoryStore?: MemoryStore | null;
36
+ /** Per-job success criteria from CRON.md frontmatter. */
37
+ successCriteria?: string[];
38
+ /** Optional model override (rare — most jobs let the SDK default decide). */
39
+ model?: string;
40
+ /** Optional max-budget override. Default: tier-1 = $1, tier-2+ = $3. */
41
+ maxBudgetUsd?: number;
42
+ /** Optional working directory override (project-scoped jobs). */
43
+ workDir?: string;
44
+ /** Abort signal for cancellation. */
45
+ abortSignal?: AbortSignal;
46
+ }
47
+ export interface RunAgentCronResult extends RunAgentResult {
48
+ /** The final prompt that was sent to the agent (after context injection).
49
+ * Useful for cron diagnostics + debugging. */
50
+ builtPrompt: string;
51
+ /** Diagnostics: which Composio + external servers were live for this run. */
52
+ composioConnected: string[];
53
+ externalConnected: string[];
54
+ }
55
+ /**
56
+ * Run a cron job via the canonical SDK runAgent path.
57
+ *
58
+ * Composes the same context blocks the legacy runCronJob injects
59
+ * (progress, goals, delegation, team, criteria, skills, fanout
60
+ * directive), wires Composio + external MCP via the dedup-aware
61
+ * helper, then calls runAgent.
62
+ *
63
+ * The SDK handles the loop, compaction, subagent fanout, prompt
64
+ * caching, retries — none of which we wrap manually anymore.
65
+ */
66
+ export declare function runAgentCron(opts: RunAgentCronOptions): Promise<RunAgentCronResult>;
67
+ //# sourceMappingURL=run-agent-cron.d.ts.map
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Clementine TypeScript — runAgent cron wrapper.
3
+ *
4
+ * Phase 3 of the SDK-canonical migration (see
5
+ * /Users/nathan.reynolds/.claude/plans/sdk-canonical-migration.md).
6
+ *
7
+ * Cron jobs need more than a bare runAgent() call: they get progress
8
+ * continuity, linked goals, delegated tasks, team context, success
9
+ * criteria, and matched skills injected ahead of the job prompt. This
10
+ * file owns that composition for the new (canonical SDK) path. The
11
+ * legacy assistant.ts:runCronJob keeps its inline equivalents so we
12
+ * can ship Phase 3 without touching legacy code.
13
+ *
14
+ * After Phase 3 verifies and we collapse the legacy path, the
15
+ * duplicated helpers here become the single source of truth.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import pino from 'pino';
20
+ import { BASE_DIR, VAULT_DIR, CRON_PROGRESS_DIR, } from '../config.js';
21
+ import { runAgent } from './run-agent.js';
22
+ import { buildExtraMcpForRunAgent } from './run-agent-mcp.js';
23
+ import { buildFanoutDirectiveForText, buildAlwaysOnParallelizationHint } from './fanout-policy.js';
24
+ import { listAllGoals } from '../tools/shared.js';
25
+ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
26
+ const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
27
+ const logger = pino({ name: 'clementine.run-agent-cron' });
28
+ const CRON_CONTEXT_ITEM_MAX = 80;
29
+ function capContextItem(s) {
30
+ if (!s)
31
+ return '';
32
+ return s.length <= CRON_CONTEXT_ITEM_MAX ? s : s.slice(0, CRON_CONTEXT_ITEM_MAX - 3) + '...';
33
+ }
34
+ function capContextBlock(s, max) {
35
+ if (!s)
36
+ return '';
37
+ return s.length <= max ? s : s.slice(0, max - 3) + '...';
38
+ }
39
+ /**
40
+ * Build the previous-progress block from the cron progress JSON file.
41
+ * Lets the agent continue where the prior run left off without re-doing
42
+ * work it already completed.
43
+ */
44
+ function buildProgressContext(jobName) {
45
+ try {
46
+ const safeJob = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
47
+ const progressFile = path.join(CRON_PROGRESS_DIR, `${safeJob}.json`);
48
+ if (!fs.existsSync(progressFile))
49
+ return '';
50
+ const progress = JSON.parse(fs.readFileSync(progressFile, 'utf-8'));
51
+ const parts = [`## Previous Progress (run #${progress.runCount}, ${progress.lastRunAt})`];
52
+ if (progress.completedItems?.length > 0) {
53
+ parts.push(`Completed: ${progress.completedItems.slice(-10).map(capContextItem).join(', ')}`);
54
+ }
55
+ if (progress.pendingItems?.length > 0) {
56
+ const pendingItems = progress.pendingItems.slice(0, CRON_PROGRESS_PENDING_MAX_ITEMS).map(capContextItem);
57
+ const suffix = progress.pendingItems.length > CRON_PROGRESS_PENDING_MAX_ITEMS
58
+ ? ` (${progress.pendingItems.length - CRON_PROGRESS_PENDING_MAX_ITEMS} more omitted)`
59
+ : '';
60
+ parts.push(`Pending: ${pendingItems.join(', ')}${suffix}`);
61
+ }
62
+ if (progress.notes) {
63
+ parts.push(`Notes: ${capContextBlock(progress.notes, CRON_PROGRESS_NOTES_MAX_CHARS)}`);
64
+ }
65
+ return parts.join('\n') + '\n\n' +
66
+ 'Continue from where you left off. Use `cron_progress_write` at the end to save what you completed and what\'s pending.\n\n';
67
+ }
68
+ catch {
69
+ return '';
70
+ }
71
+ }
72
+ /** Build the linked-goals block for jobs that contribute to active goals. */
73
+ function buildGoalContext(jobName) {
74
+ try {
75
+ const linkedGoals = listAllGoals()
76
+ .map(({ goal }) => goal)
77
+ .filter(g => g && g.status === 'active' && g.linkedCronJobs?.includes(jobName));
78
+ if (linkedGoals.length === 0)
79
+ return '';
80
+ const goalLines = linkedGoals.map(g => {
81
+ const goalRecord = g;
82
+ const nextAct = goalRecord.nextActions?.length ? ` Next: ${goalRecord.nextActions[0]}` : '';
83
+ const recentProgress = goalRecord.progressNotes?.length
84
+ ? ` Last progress: ${goalRecord.progressNotes[goalRecord.progressNotes.length - 1]}`
85
+ : '';
86
+ return `- **${goalRecord.title}** (${goalRecord.id}): ${goalRecord.description.slice(0, 100)}${nextAct}${recentProgress}`;
87
+ });
88
+ return `## Active Goals Linked to This Job\n${goalLines.join('\n')}\n\n` +
89
+ 'After completing your work, update goal progress with `goal_update` if you made meaningful progress.\n\n';
90
+ }
91
+ catch {
92
+ return '';
93
+ }
94
+ }
95
+ /** Build the delegated-tasks block for hired agents that have pending team requests. */
96
+ function buildDelegationContext(agentSlug) {
97
+ if (!agentSlug)
98
+ return '';
99
+ try {
100
+ const tasksDir = path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'tasks');
101
+ if (!fs.existsSync(tasksDir))
102
+ return '';
103
+ const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
104
+ const pendingTasks = taskFiles
105
+ .map(f => {
106
+ try {
107
+ return JSON.parse(fs.readFileSync(path.join(tasksDir, f), 'utf-8'));
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ })
113
+ .filter((t) => t !== null && t.status === 'pending');
114
+ if (pendingTasks.length === 0)
115
+ return '';
116
+ const taskLines = pendingTasks.map(t => `- [${t.id}] From ${t.fromAgent}: ${t.task.slice(0, 150)} (expected: ${t.expectedOutput.slice(0, 80)})`);
117
+ return `## Delegated Tasks Waiting\n${taskLines.join('\n')}\n\n` +
118
+ 'Work on these delegated tasks in addition to your scheduled task. ' +
119
+ 'Mark them in_progress/completed by editing the task JSON when done.\n\n';
120
+ }
121
+ catch {
122
+ return '';
123
+ }
124
+ }
125
+ /** Build the team-comms block: pending requests + recent messages for this agent. */
126
+ function buildTeamContext(agentSlug) {
127
+ if (!agentSlug)
128
+ return '';
129
+ try {
130
+ const teamLogPath = path.join(BASE_DIR, 'logs', 'team-comms.jsonl');
131
+ if (!fs.existsSync(teamLogPath))
132
+ return '';
133
+ const teamLines = fs.readFileSync(teamLogPath, 'utf-8').trim().split('\n').filter(Boolean);
134
+ const recentForAgent = teamLines
135
+ .slice(-50)
136
+ .map(l => { try {
137
+ return JSON.parse(l);
138
+ }
139
+ catch {
140
+ return null;
141
+ } })
142
+ .filter((m) => m !== null && (m.toAgent === agentSlug || m.fromAgent === agentSlug))
143
+ .slice(-5);
144
+ const pendingRequests = recentForAgent.filter(m => m.protocol === 'request' && m.toAgent === agentSlug && !m.response);
145
+ if (pendingRequests.length === 0 && recentForAgent.length === 0)
146
+ return '';
147
+ const parts = ['## Team Context'];
148
+ if (pendingRequests.length > 0) {
149
+ parts.push('### REPLY NEEDED — Pending Requests');
150
+ for (const r of pendingRequests) {
151
+ parts.push(`- From ${r.fromAgent}: ${r.content.slice(0, 200)}`);
152
+ }
153
+ parts.push('Address these requests before your main task.');
154
+ }
155
+ if (recentForAgent.length > 0) {
156
+ parts.push('### Recent Team Messages');
157
+ for (const m of recentForAgent) {
158
+ const dir = m.fromAgent === agentSlug ? 'sent to' : 'from';
159
+ const other = m.fromAgent === agentSlug ? m.toAgent : m.fromAgent;
160
+ parts.push(`- ${dir} ${other}: ${m.content.slice(0, 100)}`);
161
+ }
162
+ }
163
+ return parts.join('\n') + '\n\n';
164
+ }
165
+ catch {
166
+ return '';
167
+ }
168
+ }
169
+ /** Build the success-criteria block from job spec. */
170
+ function buildCriteriaContext(successCriteria) {
171
+ if (!successCriteria?.length)
172
+ return '';
173
+ return `## Success Criteria\nYour output will be verified against these criteria:\n` +
174
+ successCriteria.map(c => `- ${c}`).join('\n') + '\n\n';
175
+ }
176
+ /** Build the matched-skills block (procedures learned from prior successful runs). */
177
+ async function buildSkillContext(jobName, jobPrompt, agentSlug, memoryStore) {
178
+ try {
179
+ const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
180
+ const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
181
+ const suppressedNamesRaw = memoryStore
182
+ ?.getSkillsToSuppress?.(agentSlug);
183
+ const suppressedNames = Array.isArray(suppressedNamesRaw)
184
+ ? new Set(suppressedNamesRaw)
185
+ : (suppressedNamesRaw ?? undefined);
186
+ const matchedSkills = searchSkills(skillQuery, 2, agentSlug, { suppressedNames });
187
+ if (matchedSkills.length === 0)
188
+ return '';
189
+ const skillLines = matchedSkills.map(s => {
190
+ recordSkillUse(s.name);
191
+ memoryStore?.logSkillUse?.({
192
+ skillName: s.name,
193
+ sessionKey: `cron:${agentSlug ?? 'clementine'}:${jobName}`,
194
+ queryText: skillQuery,
195
+ score: s.score,
196
+ agentSlug: agentSlug ?? null,
197
+ });
198
+ let block = `### ${s.title}\n${s.content}`;
199
+ if (s.toolsUsed.length > 0)
200
+ block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
201
+ if (s.attachments.length > 0) {
202
+ const attDir = path.join(s.skillDir, s.name + '.files');
203
+ for (const attName of s.attachments.slice(0, 3)) {
204
+ const attPath = path.join(attDir, attName);
205
+ if (fs.existsSync(attPath)) {
206
+ try {
207
+ const content = fs.readFileSync(attPath, 'utf-8').slice(0, 2000);
208
+ block += `\n#### ${attName}\n\`\`\`\n${content}\n\`\`\``;
209
+ }
210
+ catch { /* skip */ }
211
+ }
212
+ }
213
+ }
214
+ return block;
215
+ });
216
+ return `## Learned Procedures (from past successful executions)\nFollow these proven approaches when applicable:\n\n${skillLines.join('\n\n')}\n\n`;
217
+ }
218
+ catch {
219
+ return '';
220
+ }
221
+ }
222
+ /**
223
+ * Run a cron job via the canonical SDK runAgent path.
224
+ *
225
+ * Composes the same context blocks the legacy runCronJob injects
226
+ * (progress, goals, delegation, team, criteria, skills, fanout
227
+ * directive), wires Composio + external MCP via the dedup-aware
228
+ * helper, then calls runAgent.
229
+ *
230
+ * The SDK handles the loop, compaction, subagent fanout, prompt
231
+ * caching, retries — none of which we wrap manually anymore.
232
+ */
233
+ export async function runAgentCron(opts) {
234
+ const tier = opts.tier ?? 1;
235
+ const agentSlug = opts.profile?.slug;
236
+ const ownerName = process.env.OWNER_NAME ?? 'the user';
237
+ // ── Compose context blocks (mirrors legacy runCronJob) ─────────────
238
+ const progressContext = buildProgressContext(opts.jobName);
239
+ const goalContext = buildGoalContext(opts.jobName);
240
+ const delegationContext = buildDelegationContext(agentSlug);
241
+ const teamContext = buildTeamContext(agentSlug);
242
+ const criteriaContext = buildCriteriaContext(opts.successCriteria);
243
+ const skillContext = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.memoryStore);
244
+ // Sub-agent fanout directive — when the job description suggests
245
+ // multi-item work, prepend a hard-line fanout mandate.
246
+ const fanoutScope = `${opts.jobName}\n${opts.jobPrompt}\n${opts.profile?.description ?? ''}\n${opts.profile?.systemPromptBody ?? ''}`;
247
+ const { directive: fanoutDirective, report: fanoutReport } = buildFanoutDirectiveForText(fanoutScope);
248
+ if (fanoutReport.needsFanout) {
249
+ logger.info({
250
+ job: opts.jobName,
251
+ signals: fanoutReport.signals.map(s => s.pattern),
252
+ }, 'runAgentCron: fanout directive injected');
253
+ }
254
+ // Final prompt (matches legacy runCronJob's structure).
255
+ const builtPrompt = `[Scheduled task: ${opts.jobName}]\n\n` +
256
+ (fanoutDirective ? fanoutDirective + '\n\n' : '') +
257
+ buildAlwaysOnParallelizationHint() + '\n\n' +
258
+ progressContext +
259
+ goalContext +
260
+ skillContext +
261
+ delegationContext +
262
+ teamContext +
263
+ criteriaContext +
264
+ `${opts.jobPrompt}\n\n` +
265
+ `## How to respond\n` +
266
+ `You're sending this directly to ${ownerName} as a DM. ` +
267
+ `Write like you're texting a friend — casual, warm, concise. ` +
268
+ `Use their name naturally. No headers, bullet lists, or formal structure unless the content genuinely needs it. ` +
269
+ `Skip narrating your process ("I checked X, then Y..."). Just share the interesting stuff.\n\n` +
270
+ `If there's genuinely nothing worth mentioning (no new data, no changes, no alerts), ` +
271
+ `output ONLY: __NOTHING__\n` +
272
+ `But lean toward sharing something — a one-liner is better than silence. ` +
273
+ `"Quiet morning, inbox is clean" beats __NOTHING__ if you did check things.\n\n` +
274
+ `After finishing your work, you MUST write a final text response with your findings — ` +
275
+ `only that final message gets delivered.`;
276
+ // ── Wire Composio + external MCP servers (same dedup as legacy) ───
277
+ const mcp = await buildExtraMcpForRunAgent({
278
+ scopeText: [
279
+ opts.jobName,
280
+ opts.jobPrompt,
281
+ opts.profile?.description,
282
+ opts.profile?.systemPromptBody,
283
+ ].filter(Boolean).join('\n\n'),
284
+ profile: opts.profile,
285
+ });
286
+ // ── Run via canonical runAgent ────────────────────────────────────
287
+ const maxBudget = opts.maxBudgetUsd ?? (tier >= 2 ? 3.0 : 1.0);
288
+ const effort = tier >= 2 ? 'high' : 'medium';
289
+ logger.info({
290
+ job: opts.jobName,
291
+ tier,
292
+ profile: agentSlug,
293
+ composioConnected: mcp.composioConnected,
294
+ externalConnected: mcp.externalConnected,
295
+ droppedClaudeAi: mcp.droppedClaudeAi,
296
+ droppedComposio: mcp.droppedComposio,
297
+ promptChars: builtPrompt.length,
298
+ }, 'runAgentCron: dispatching to runAgent');
299
+ const result = await runAgent(builtPrompt, {
300
+ sessionKey: `cron:${opts.jobName}`,
301
+ source: 'cron',
302
+ profile: opts.profile,
303
+ agentManager: opts.agentManager,
304
+ memoryStore: opts.memoryStore,
305
+ model: opts.model,
306
+ effort,
307
+ maxBudgetUsd: maxBudget,
308
+ maxTurns: opts.maxTurns,
309
+ abortSignal: opts.abortSignal,
310
+ extraMcpServers: mcp.servers,
311
+ });
312
+ return {
313
+ ...result,
314
+ builtPrompt,
315
+ composioConnected: mcp.composioConnected,
316
+ externalConnected: mcp.externalConnected,
317
+ };
318
+ }
319
+ //# sourceMappingURL=run-agent-cron.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Clementine TypeScript — MCP server wiring helper for runAgent.
3
+ *
4
+ * Phase 3 of the SDK-canonical migration (see
5
+ * /Users/nathan.reynolds/.claude/plans/sdk-canonical-migration.md).
6
+ *
7
+ * runAgent always wires the Clementine MCP server. This helper builds
8
+ * the EXTRA MCP servers callers may need (Composio toolkits, external
9
+ * claude.ai integrations, browser-harness, etc.), applying the same
10
+ * service dedup logic from 1.18.34 so we don't load duplicates of the
11
+ * same service from two providers.
12
+ *
13
+ * The legacy assistant.ts:buildOptions path does roughly the same
14
+ * thing for chat/cron — this is the standalone equivalent.
15
+ */
16
+ import type { AgentProfile } from '../types.js';
17
+ export interface BuildExtraMcpOptions {
18
+ /** The text we're routing on (job prompt, user message, etc). Used to pick which
19
+ * Composio toolkits + external servers are relevant via the existing bundle router. */
20
+ scopeText?: string;
21
+ /** Active agent profile. When set, profile.allowedMcpServers and
22
+ * profile.allowedComposioToolkits override the bundle router's choices. */
23
+ profile?: AgentProfile | null;
24
+ /** When true, build the FULL surface (no bundle filtering, no dedup).
25
+ * Used by admin/debug callers; not the cron-path default. */
26
+ fullSurface?: boolean;
27
+ }
28
+ export interface BuildExtraMcpResult {
29
+ /** Map of additional MCP servers to merge into runAgent's mcpServers. */
30
+ servers: Record<string, Record<string, unknown>>;
31
+ /** Diagnostics: which Composio toolkits / external servers ended up live. */
32
+ composioConnected: string[];
33
+ externalConnected: string[];
34
+ /** Diagnostics: which services were de-duped (we kept Composio over claude.ai etc). */
35
+ droppedClaudeAi: string[];
36
+ droppedComposio: string[];
37
+ }
38
+ /**
39
+ * Build the extra MCP servers (Composio + external) for a runAgent call.
40
+ *
41
+ * Mirrors the legacy assistant.ts:buildOptions chain but as a standalone
42
+ * function so the runAgent path doesn't depend on PersonalAssistant
43
+ * instance state.
44
+ */
45
+ export declare function buildExtraMcpForRunAgent(opts?: BuildExtraMcpOptions): Promise<BuildExtraMcpResult>;
46
+ //# sourceMappingURL=run-agent-mcp.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Clementine TypeScript — MCP server wiring helper for runAgent.
3
+ *
4
+ * Phase 3 of the SDK-canonical migration (see
5
+ * /Users/nathan.reynolds/.claude/plans/sdk-canonical-migration.md).
6
+ *
7
+ * runAgent always wires the Clementine MCP server. This helper builds
8
+ * the EXTRA MCP servers callers may need (Composio toolkits, external
9
+ * claude.ai integrations, browser-harness, etc.), applying the same
10
+ * service dedup logic from 1.18.34 so we don't load duplicates of the
11
+ * same service from two providers.
12
+ *
13
+ * The legacy assistant.ts:buildOptions path does roughly the same
14
+ * thing for chat/cron — this is the standalone equivalent.
15
+ */
16
+ import pino from 'pino';
17
+ import { routeToolSurface, applyServiceDedup } from './tool-router.js';
18
+ import { loadClaudeIntegrations, getMcpServersForAgent } from './mcp-bridge.js';
19
+ import { loadToolPreferences, KNOWN_SERVICES } from '../integrations/tool-preferences.js';
20
+ const logger = pino({ name: 'clementine.run-agent-mcp' });
21
+ /**
22
+ * Build the extra MCP servers (Composio + external) for a runAgent call.
23
+ *
24
+ * Mirrors the legacy assistant.ts:buildOptions chain but as a standalone
25
+ * function so the runAgent path doesn't depend on PersonalAssistant
26
+ * instance state.
27
+ */
28
+ export async function buildExtraMcpForRunAgent(opts = {}) {
29
+ const result = {
30
+ servers: {},
31
+ composioConnected: [],
32
+ externalConnected: [],
33
+ droppedClaudeAi: [],
34
+ droppedComposio: [],
35
+ };
36
+ // 1. Route the tool surface based on scope text.
37
+ let route = opts.fullSurface
38
+ ? {
39
+ bundles: [],
40
+ externalMcpServers: undefined,
41
+ composioToolkits: undefined,
42
+ inheritFullClaudeEnv: true,
43
+ fullSurface: true,
44
+ reason: 'full_surface',
45
+ }
46
+ : routeToolSurface(opts.scopeText ?? '');
47
+ // 2. Build Composio MCP servers, honoring profile allowlist when set.
48
+ let composioMcpServers = {};
49
+ try {
50
+ const { buildComposioMcpServers } = await import('../integrations/composio/mcp-bridge.js');
51
+ const profileAllowList = opts.profile?.allowedComposioToolkits;
52
+ const composioAllow = Array.isArray(profileAllowList)
53
+ ? profileAllowList
54
+ : route.composioToolkits;
55
+ composioMcpServers = await buildComposioMcpServers(composioAllow);
56
+ }
57
+ catch (err) {
58
+ logger.debug({ err }, 'Composio MCP servers unavailable (non-fatal)');
59
+ }
60
+ // 3. Build external MCP servers (claude.ai integrations, etc).
61
+ let externalMcpServers = {};
62
+ try {
63
+ const profileAllowList = Array.isArray(opts.profile?.allowedMcpServers)
64
+ ? opts.profile?.allowedMcpServers
65
+ : undefined;
66
+ const externalAllow = profileAllowList ?? route.externalMcpServers;
67
+ externalMcpServers = getMcpServersForAgent(externalAllow);
68
+ }
69
+ catch (err) {
70
+ logger.debug({ err }, 'External MCP servers unavailable (non-fatal)');
71
+ }
72
+ // 4. Apply service dedup so Composio outlook + claude.ai Microsoft 365
73
+ // don't both load (same logic as 1.18.34).
74
+ if (!opts.fullSurface) {
75
+ try {
76
+ const composioConnected = new Set(Object.keys(composioMcpServers));
77
+ const cdIntegrations = loadClaudeIntegrations();
78
+ const claudeDesktopActive = new Set(Object.values(cdIntegrations).filter(i => i.connected).map(i => i.name));
79
+ const prefs = loadToolPreferences();
80
+ const dedup = applyServiceDedup(route, {
81
+ composioConnected,
82
+ claudeDesktopActive,
83
+ preferences: prefs.preferences,
84
+ knownServices: KNOWN_SERVICES,
85
+ });
86
+ route = dedup.route;
87
+ result.droppedClaudeAi = dedup.droppedClaudeAi;
88
+ result.droppedComposio = dedup.droppedComposio;
89
+ for (const name of dedup.droppedClaudeAi)
90
+ delete externalMcpServers[name];
91
+ for (const slug of dedup.droppedComposio)
92
+ delete composioMcpServers[slug];
93
+ }
94
+ catch (err) {
95
+ logger.debug({ err }, 'Service dedup failed (non-fatal — using full surface)');
96
+ }
97
+ }
98
+ // 5. Merge into one map.
99
+ result.servers = { ...externalMcpServers, ...composioMcpServers };
100
+ result.composioConnected = Object.keys(composioMcpServers);
101
+ result.externalConnected = Object.keys(externalMcpServers);
102
+ return result;
103
+ }
104
+ //# sourceMappingURL=run-agent-mcp.js.map
@@ -66,6 +66,17 @@ export interface RunAgentOptions {
66
66
  allowedTools?: string[];
67
67
  /** Optional CLAUDE.md / project setting source. Defaults to ['project']. */
68
68
  settingSources?: ('project' | 'user' | 'local')[];
69
+ /** Additional MCP servers to merge with the always-on clementine-tools
70
+ * server. Use to wire Composio + claude.ai integrations on chat-path
71
+ * invocations that need Outlook/Salesforce/etc. */
72
+ extraMcpServers?: Record<string, {
73
+ type: 'stdio' | 'http' | 'sse';
74
+ command?: string;
75
+ args?: string[];
76
+ env?: Record<string, string>;
77
+ url?: string;
78
+ headers?: Record<string, string>;
79
+ }>;
69
80
  }
70
81
  export interface RunAgentResult {
71
82
  /** Final text response from the agent. */
@@ -124,6 +124,9 @@ export async function runAgent(prompt, opts) {
124
124
  // list references mcp__clementine-tools__* that don't exist in the
125
125
  // session, and the agent falls back to reading raw JSON files.
126
126
  const subprocessEnv = buildRunAgentEnv();
127
+ // SDK accepts a Record<string, McpServerConfig> here. We cast on
128
+ // assignment because we mix the always-on Clementine stdio server
129
+ // with caller-supplied servers of various types.
127
130
  const mcpServers = {
128
131
  [TOOLS_SERVER]: {
129
132
  type: 'stdio',
@@ -136,6 +139,7 @@ export async function runAgent(prompt, opts) {
136
139
  CLEMENTINE_INTERACTION_SOURCE: source === 'cron' || source === 'heartbeat' ? 'autonomous' : 'interactive',
137
140
  },
138
141
  },
142
+ ...(opts.extraMcpServers ?? {}),
139
143
  };
140
144
  // Apply 1M-context env normalization (existing infra)
141
145
  const sdkOptionsRaw = {
@@ -144,7 +148,9 @@ export async function runAgent(prompt, opts) {
144
148
  : { type: 'preset', preset: 'claude_code' },
145
149
  settingSources: opts.settingSources ?? ['project'],
146
150
  agents,
147
- mcpServers,
151
+ // SDK's McpServerConfig is a union; cast at the boundary since
152
+ // callers can mix stdio + http + sse server shapes.
153
+ mcpServers: mcpServers,
148
154
  allowedTools,
149
155
  permissionMode: 'bypassPermissions',
150
156
  cwd: BASE_DIR,
@@ -2088,6 +2088,79 @@ export class Gateway {
2088
2088
  delete sessState.pendingInterrupt;
2089
2089
  }
2090
2090
  try {
2091
+ // ── Phase 2: opt-in canonical SDK chat path ──────────────────
2092
+ // When CLEMENTINE_USE_RUNAGENT_CHAT=1 is set, route through
2093
+ // the new runAgent() wrapper instead of the legacy
2094
+ // assistant.chat path. This is the SDK-canonical pattern
2095
+ // (one query() call, agents map for subagents, no
2096
+ // wrapper layers). Today's Phase 2 connects only the
2097
+ // Clementine MCP server — Composio/external integrations
2098
+ // come in Phase 3. Useful for testing the new path on
2099
+ // tool-light sessions like cron-fix or memory queries.
2100
+ //
2101
+ // The legacy path (default) keeps full Composio/external
2102
+ // routing + all post-response handlers, so this flag is
2103
+ // safe to leave off until we're ready.
2104
+ if (process.env.CLEMENTINE_USE_RUNAGENT_CHAT === '1'
2105
+ && this.isTrustedPersonalSession(sessionKey)
2106
+ && !sessState.pendingInterrupt) {
2107
+ const { runAgent } = await import('../agent/run-agent.js');
2108
+ logger.info({
2109
+ sessionKey: effectiveSessionKey,
2110
+ profile: resolvedProfile?.slug,
2111
+ path: 'runagent_chat',
2112
+ }, 'Phase 2: routing chat through runAgent');
2113
+ try {
2114
+ const runAgentResult = await runAgent(originalText, {
2115
+ sessionKey: effectiveSessionKey,
2116
+ source: 'chat',
2117
+ profile: resolvedProfile,
2118
+ agentManager: this.getAgentManager(),
2119
+ memoryStore: this.assistant.getMemoryStore?.() ?? null,
2120
+ onText: wrappedOnText,
2121
+ onToolActivity: ({ tool, input }) => {
2122
+ toolActivityCount++;
2123
+ if (wrappedOnToolActivity) {
2124
+ return wrappedOnToolActivity(tool, input);
2125
+ }
2126
+ return undefined;
2127
+ },
2128
+ abortSignal: chatAc.signal,
2129
+ });
2130
+ clearTimeout(chatTimer);
2131
+ clearTimeout(hardWallTimer);
2132
+ // Mirror transcript so memory + recall continue working.
2133
+ const memoryStore = this.assistant.getMemoryStore?.();
2134
+ if (memoryStore) {
2135
+ try {
2136
+ memoryStore.saveTurn(effectiveSessionKey, 'user', originalText);
2137
+ memoryStore.saveTurn(effectiveSessionKey, 'assistant', runAgentResult.text);
2138
+ }
2139
+ catch (err) {
2140
+ logger.debug({ err }, 'runAgent chat: transcript mirror failed (non-fatal)');
2141
+ }
2142
+ }
2143
+ // Fire auto-memory extraction in the background so
2144
+ // MEMORY.md continues to update like the legacy path.
2145
+ this.assistant
2146
+ .triggerMemoryExtractionPostExchange(originalText, runAgentResult.text, effectiveSessionKey, resolvedProfile)
2147
+ .catch(err => logger.debug({ err, sessionKey: effectiveSessionKey }, 'runAgent chat: auto-memory failed (non-fatal)'));
2148
+ logger.info({
2149
+ sessionKey: effectiveSessionKey,
2150
+ totalMs: Date.now() - tInnerStart,
2151
+ routedVia: 'runagent_chat',
2152
+ numTurns: runAgentResult.numTurns,
2153
+ cost: Number(runAgentResult.totalCostUsd.toFixed(4)),
2154
+ responseLen: runAgentResult.text.length,
2155
+ }, 'chat:latency');
2156
+ return runAgentResult.text;
2157
+ }
2158
+ catch (err) {
2159
+ logger.warn({ err, sessionKey: effectiveSessionKey }, 'runAgent chat path failed — falling back to legacy chat');
2160
+ // Fall through to the legacy chat path so the user
2161
+ // still gets a response.
2162
+ }
2163
+ }
2091
2164
  // ── Pre-LLM plan routing (Gap #3 from orchestration audit) ──
2092
2165
  // When the user's text clearly maps to multi-step parallel
2093
2166
  // work, route through the orchestrator BEFORE the main agent
@@ -2529,6 +2602,53 @@ export class Gateway {
2529
2602
  const cronStart = Date.now();
2530
2603
  try {
2531
2604
  let response;
2605
+ // ── Phase 3: opt-in canonical SDK cron path ──────────────────
2606
+ // CLEMENTINE_USE_RUNAGENT_CRON=1 routes the job through
2607
+ // runAgentCron() — the canonical SDK pattern. Default OFF.
2608
+ // Falls back to legacy on error so the job always completes.
2609
+ const useRunAgentCron = process.env.CLEMENTINE_USE_RUNAGENT_CRON === '1';
2610
+ if (useRunAgentCron && !opts?.disableAllTools) {
2611
+ try {
2612
+ const { runAgentCron } = await import('../agent/run-agent-cron.js');
2613
+ const profile = agentSlug && agentSlug !== 'clementine'
2614
+ ? this.getAgentManager().get(agentSlug) ?? null
2615
+ : null;
2616
+ logger.info({ jobName, agentSlug, tier, path: 'runagent_cron' }, 'Phase 3: routing cron through runAgentCron');
2617
+ const cronResult = await runAgentCron({
2618
+ jobName,
2619
+ jobPrompt,
2620
+ tier,
2621
+ maxTurns,
2622
+ profile,
2623
+ agentManager: this.getAgentManager(),
2624
+ memoryStore: this.assistant.getMemoryStore?.() ?? null,
2625
+ successCriteria,
2626
+ model,
2627
+ workDir,
2628
+ });
2629
+ response = cronResult.text;
2630
+ scanner.refreshIntegrity();
2631
+ events.emit('cron:complete', {
2632
+ jobName,
2633
+ mode: 'runagent',
2634
+ durationMs: Date.now() - cronStart,
2635
+ responseLength: response?.length ?? 0,
2636
+ });
2637
+ logger.info({
2638
+ jobName,
2639
+ cost: Number(cronResult.totalCostUsd.toFixed(4)),
2640
+ numTurns: cronResult.numTurns,
2641
+ composioConnected: cronResult.composioConnected.length,
2642
+ externalConnected: cronResult.externalConnected.length,
2643
+ durationMs: Date.now() - cronStart,
2644
+ }, 'runAgentCron: cron job complete');
2645
+ return response;
2646
+ }
2647
+ catch (err) {
2648
+ logger.warn({ err, jobName }, 'runAgentCron path failed — falling back to legacy cron path');
2649
+ // Fall through to legacy below.
2650
+ }
2651
+ }
2532
2652
  if (mode === 'unleashed') {
2533
2653
  response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours, agentSlug);
2534
2654
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.44",
3
+ "version": "1.18.46",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",