clementine-agent 1.18.168 → 1.18.170

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * chat-skill-resolver — match a chat message against the skill catalog,
3
+ * extract the MCP servers / toolkits the matched skills imply, and
4
+ * produce a system-prompt block listing them as "Relevant Skills."
5
+ *
6
+ * Why this exists (1.18.170)
7
+ * ──────────────────────────
8
+ * Before this module, the modern chat path (`router.ts → runAgent`) did
9
+ * three things in order:
10
+ * 1. Build extra MCP servers from `routeToolSurface(userText)` — a
11
+ * regex-bundle matcher over 19 fixed bundles in `tool-router.ts`.
12
+ * 2. Build vault context (SOUL.md / MEMORY.md / AGENTS.md).
13
+ * 3. Call runAgent with the assembled pieces.
14
+ *
15
+ * Step 1 has a fundamental gap: anything outside the 19 hardcoded
16
+ * bundles (Salesforce, HubSpot, Asana, ClickUp, Airtable, an installed
17
+ * CLI like `sf`) silently fails to route. The model gets no Salesforce
18
+ * tools loaded even when the user just connected the Salesforce MCP.
19
+ *
20
+ * Clementine already auto-generates one skill per MCP tool whenever a
21
+ * server's schema is fetched (`auto-skills.ts` → `~/.clementine/vault/
22
+ * 00-System/skills/auto/<server>/<tool>.md`). Those auto-skills include
23
+ * server-aware triggers ("salesforce", "my salesforce", "query records
24
+ * salesforce", …) and a `mcp__<server>__<tool>` reference in their body.
25
+ *
26
+ * The legacy chat path (`assistant.ts:1487-1538`) already searched the
27
+ * skill catalog and injected a "## Relevant Skill" block — but that
28
+ * code lives in the deprecated PersonalAssistant.query() path. The
29
+ * modern runAgent path skipped it entirely.
30
+ *
31
+ * This module ports + extends the legacy behavior:
32
+ * • Top-3 match aggregation (not just top-1) — handles category queries
33
+ * like "salesforce" where many tool-specific auto-skills match
34
+ * similarly. The parent sees all 3 bodies and picks the right tool.
35
+ * • Extracts `mcp__<server>__<tool>` references from every matched
36
+ * skill's body. Used to widen `buildExtraMcpForRunAgent` so the
37
+ * right Composio toolkits / claude.ai integrations load even though
38
+ * "Salesforce" isn't in the regex-bundle list.
39
+ * • Extracts `clementine.tools.allow` from user-authored skills (the
40
+ * auto-skills don't set this field but human-authored ones often do).
41
+ * • Auto-skills get a small penalty in `searchSkills` already (-0.5)
42
+ * so user-authored skills outrank them at parity — preserved here.
43
+ *
44
+ * Not a sandbox: we never *restrict* tools based on matched skills (the
45
+ * SDK still receives the parent's full allowedTools). We only widen the
46
+ * MCP server selection. Hard tool-scope enforcement happens via the
47
+ * `runSkill` primitive (`run-skill.ts:300`), not chat.
48
+ *
49
+ * Failure mode: this module never throws. A match search that errors
50
+ * (corrupt skill file, missing dir, etc.) returns empty results and the
51
+ * caller proceeds with `routeToolSurface` alone — i.e. today's behavior.
52
+ */
53
+ import { type SkillMatch } from './skill-extractor.js';
54
+ import type { AgentProfile } from '../types.js';
55
+ import type { MemoryStore } from '../memory/store.js';
56
+ export interface ResolveSkillsOptions {
57
+ /** Active hired-agent profile, when set. Used for agent-scoped skill
58
+ * priority (matches the legacy boost). */
59
+ profile?: AgentProfile | null;
60
+ /** Optional memory store — used to read the user's suppression list
61
+ * ("never auto-match this skill again"). */
62
+ memoryStore?: MemoryStore | null;
63
+ /** Override the top-K aggregation cap. Defaults to 3. */
64
+ limit?: number;
65
+ /** Override the minimum match score. Defaults to 4. */
66
+ minScore?: number;
67
+ }
68
+ export interface ResolvedSkillContext {
69
+ /** Match records from searchSkills, filtered to those above minScore
70
+ * and capped at `limit`. Length 0 means no skill matched — the
71
+ * caller should fall back to today's behavior (regex bundles only). */
72
+ matches: SkillMatch[];
73
+ /** MCP server slugs referenced by any matched skill's body. Caller
74
+ * unions this with `route.externalMcpServers` and `route.composioToolkits`. */
75
+ hintedMcpServers: string[];
76
+ /** Tools declared under `clementine.tools.allow` on matched skills.
77
+ * Caller can use these to widen the SDK's `allowedTools` if the
78
+ * matched skill expects access beyond the chat default. */
79
+ hintedTools: string[];
80
+ /** Pre-rendered "## Relevant Skills" markdown block, ready to append
81
+ * to the system prompt. Empty when `matches.length === 0`. */
82
+ promptBlock: string;
83
+ /** Diagnostics for log/telemetry. */
84
+ diagnostics: {
85
+ queryChars: number;
86
+ candidatesConsidered: number;
87
+ matchesAboveThreshold: number;
88
+ topScore: number;
89
+ mcpRefsExtracted: number;
90
+ };
91
+ }
92
+ /** Extract the `mcp__<server>__<tool>` server-name component from a
93
+ * match's body content + frontmatter. Returns the set of server slugs. */
94
+ declare function extractMcpServersFromMatch(match: SkillMatch): string[];
95
+ /** Extract `clementine.tools.allow` from a match's frontmatter.
96
+ * Returns the list of declared tool names (or [] if none). */
97
+ declare function extractAllowedToolsFromMatch(match: SkillMatch): string[];
98
+ /**
99
+ * Render the system-prompt block injected for the matched skills.
100
+ * Single-match keeps the legacy `## Relevant Skill: <title>` shape;
101
+ * multi-match nests them under `## Relevant Skills` with per-skill
102
+ * subheadings. Tools-required warnings (legacy `assistant.ts:1504-1513`)
103
+ * are NOT included here — they belong to the run-time tool policy
104
+ * check, not the prompt context.
105
+ */
106
+ declare function renderPromptBlock(matches: SkillMatch[]): string;
107
+ /**
108
+ * Match a user message against the skill catalog and return the routing
109
+ * hints + prompt block the chat path should layer on top of the static
110
+ * `routeToolSurface` decision.
111
+ *
112
+ * Never throws — telemetry / matching errors degrade gracefully to
113
+ * empty hints, and the caller proceeds with today's behavior.
114
+ */
115
+ export declare function resolveSkillsForChat(userMessage: string, opts?: ResolveSkillsOptions): ResolvedSkillContext;
116
+ export { extractMcpServersFromMatch, extractAllowedToolsFromMatch, renderPromptBlock };
117
+ //# sourceMappingURL=chat-skill-resolver.d.ts.map
@@ -0,0 +1,282 @@
1
+ /**
2
+ * chat-skill-resolver — match a chat message against the skill catalog,
3
+ * extract the MCP servers / toolkits the matched skills imply, and
4
+ * produce a system-prompt block listing them as "Relevant Skills."
5
+ *
6
+ * Why this exists (1.18.170)
7
+ * ──────────────────────────
8
+ * Before this module, the modern chat path (`router.ts → runAgent`) did
9
+ * three things in order:
10
+ * 1. Build extra MCP servers from `routeToolSurface(userText)` — a
11
+ * regex-bundle matcher over 19 fixed bundles in `tool-router.ts`.
12
+ * 2. Build vault context (SOUL.md / MEMORY.md / AGENTS.md).
13
+ * 3. Call runAgent with the assembled pieces.
14
+ *
15
+ * Step 1 has a fundamental gap: anything outside the 19 hardcoded
16
+ * bundles (Salesforce, HubSpot, Asana, ClickUp, Airtable, an installed
17
+ * CLI like `sf`) silently fails to route. The model gets no Salesforce
18
+ * tools loaded even when the user just connected the Salesforce MCP.
19
+ *
20
+ * Clementine already auto-generates one skill per MCP tool whenever a
21
+ * server's schema is fetched (`auto-skills.ts` → `~/.clementine/vault/
22
+ * 00-System/skills/auto/<server>/<tool>.md`). Those auto-skills include
23
+ * server-aware triggers ("salesforce", "my salesforce", "query records
24
+ * salesforce", …) and a `mcp__<server>__<tool>` reference in their body.
25
+ *
26
+ * The legacy chat path (`assistant.ts:1487-1538`) already searched the
27
+ * skill catalog and injected a "## Relevant Skill" block — but that
28
+ * code lives in the deprecated PersonalAssistant.query() path. The
29
+ * modern runAgent path skipped it entirely.
30
+ *
31
+ * This module ports + extends the legacy behavior:
32
+ * • Top-3 match aggregation (not just top-1) — handles category queries
33
+ * like "salesforce" where many tool-specific auto-skills match
34
+ * similarly. The parent sees all 3 bodies and picks the right tool.
35
+ * • Extracts `mcp__<server>__<tool>` references from every matched
36
+ * skill's body. Used to widen `buildExtraMcpForRunAgent` so the
37
+ * right Composio toolkits / claude.ai integrations load even though
38
+ * "Salesforce" isn't in the regex-bundle list.
39
+ * • Extracts `clementine.tools.allow` from user-authored skills (the
40
+ * auto-skills don't set this field but human-authored ones often do).
41
+ * • Auto-skills get a small penalty in `searchSkills` already (-0.5)
42
+ * so user-authored skills outrank them at parity — preserved here.
43
+ *
44
+ * Not a sandbox: we never *restrict* tools based on matched skills (the
45
+ * SDK still receives the parent's full allowedTools). We only widen the
46
+ * MCP server selection. Hard tool-scope enforcement happens via the
47
+ * `runSkill` primitive (`run-skill.ts:300`), not chat.
48
+ *
49
+ * Failure mode: this module never throws. A match search that errors
50
+ * (corrupt skill file, missing dir, etc.) returns empty results and the
51
+ * caller proceeds with `routeToolSurface` alone — i.e. today's behavior.
52
+ */
53
+ import { readFileSync } from 'node:fs';
54
+ import { join, dirname } from 'node:path';
55
+ import { existsSync } from 'node:fs';
56
+ import matter from 'gray-matter';
57
+ import pino from 'pino';
58
+ import { searchSkills } from './skill-extractor.js';
59
+ const logger = pino({ name: 'clementine.chat-skill-resolver' });
60
+ // ── Tunables ──────────────────────────────────────────────────────────
61
+ /** Default minimum score to consider a skill match real. Mirrors the
62
+ * legacy `assistant.ts:1492` threshold. Skill auto-match is heuristic;
63
+ * this filter keeps weak matches from injecting unrelated tooling. */
64
+ const DEFAULT_MIN_SCORE = 4;
65
+ /** Default top-K matches to aggregate. Single-tool requests usually
66
+ * return one strong match; category requests ("salesforce") return
67
+ * several similarly-scored auto-skills. Top-3 covers both. Raising
68
+ * this bloats the system prompt without much routing benefit. */
69
+ const DEFAULT_TOP_K = 3;
70
+ /** Cap per-skill body excerpt in the injected prompt. The full body is
71
+ * often a multi-KB args table for an MCP tool — fine if the user
72
+ * needs to look at it, wasteful for routing context. Mirrors
73
+ * `assistant.ts:1501` which sliced at 800 chars. */
74
+ const PER_SKILL_BODY_CHARS = 1000;
75
+ /** Same MCP_TOOL_REF pattern used in `run-skill.ts:153` / `tool-router.ts:172`.
76
+ * Matches `mcp__<server>__<tool>` references inside skill body text. */
77
+ const MCP_TOOL_REF = /mcp__([A-Za-z0-9-]+(?:_[A-Za-z0-9-]+)*)__[A-Za-z0-9_-]+/g;
78
+ // ── Skill discovery support ──────────────────────────────────────────
79
+ /**
80
+ * Find a matched skill's frontmatter on disk. `SkillMatch` carries
81
+ * `name` + `skillDir` but not the full frontmatter — and we need
82
+ * `clementine.tools.allow` + the explicit `tool:` field from auto-skills.
83
+ *
84
+ * Walks `<skillDir>` looking for any file whose basename or folder
85
+ * matches `name`. Returns null on any failure; the caller already has
86
+ * the matched body content from searchSkills and that's enough for
87
+ * MCP-ref extraction. The frontmatter read here is opportunistic.
88
+ */
89
+ function readMatchFrontmatter(match) {
90
+ // The match's `skillDir` is the SEARCH root. Auto-skills live nested
91
+ // under `<skillDir>/auto/<server>/<tool>.md`; user skills can be flat
92
+ // (`<skillDir>/<slug>.md`) or folder-form (`<skillDir>/<slug>/SKILL.md`).
93
+ const slug = match.name;
94
+ const candidates = [
95
+ join(match.skillDir, `${slug}.md`),
96
+ join(match.skillDir, slug, 'SKILL.md'),
97
+ ];
98
+ // For auto-skill slugs like `auto-dataforseo-…`, also try the nested
99
+ // path that the slug encodes. This is a best-effort lookup; on miss
100
+ // we just skip frontmatter and use the body alone.
101
+ if (slug.startsWith('auto-')) {
102
+ const parts = slug.slice(5).split('-');
103
+ if (parts.length >= 2) {
104
+ const server = parts[0];
105
+ const rest = parts.slice(1).join('-');
106
+ candidates.push(join(match.skillDir, 'auto', server, `${rest}.md`));
107
+ }
108
+ }
109
+ for (const p of candidates) {
110
+ try {
111
+ if (!existsSync(p))
112
+ continue;
113
+ const raw = readFileSync(p, 'utf-8');
114
+ const parsed = matter(raw);
115
+ return parsed.data;
116
+ }
117
+ catch {
118
+ // try next candidate
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ /** Extract the `mcp__<server>__<tool>` server-name component from a
124
+ * match's body content + frontmatter. Returns the set of server slugs. */
125
+ function extractMcpServersFromMatch(match) {
126
+ const servers = new Set();
127
+ // From body text — works for auto-skills (which list the tool name
128
+ // in their "## Tool call" section) and user skills that paste tool
129
+ // refs into their instructions.
130
+ MCP_TOOL_REF.lastIndex = 0;
131
+ let m;
132
+ while ((m = MCP_TOOL_REF.exec(match.content)) !== null) {
133
+ servers.add(m[1]);
134
+ }
135
+ // From frontmatter — auto-skills have `tool: mcp__<server>__<name>` AND
136
+ // `server: <name>` set explicitly. Either is authoritative.
137
+ const fm = readMatchFrontmatter(match);
138
+ if (fm) {
139
+ if (typeof fm.server === 'string' && fm.server.trim()) {
140
+ servers.add(fm.server.trim());
141
+ }
142
+ if (typeof fm.tool === 'string') {
143
+ const mm = MCP_TOOL_REF.exec(fm.tool);
144
+ if (mm)
145
+ servers.add(mm[1]);
146
+ MCP_TOOL_REF.lastIndex = 0;
147
+ }
148
+ }
149
+ return [...servers];
150
+ }
151
+ /** Extract `clementine.tools.allow` from a match's frontmatter.
152
+ * Returns the list of declared tool names (or [] if none). */
153
+ function extractAllowedToolsFromMatch(match) {
154
+ const fm = readMatchFrontmatter(match);
155
+ if (!fm)
156
+ return [];
157
+ const clementine = fm.clementine;
158
+ const allow = clementine?.tools?.allow;
159
+ if (!Array.isArray(allow))
160
+ return [];
161
+ return allow.filter((t) => typeof t === 'string' && t.length > 0);
162
+ }
163
+ // ── Prompt rendering ──────────────────────────────────────────────────
164
+ /**
165
+ * Render the system-prompt block injected for the matched skills.
166
+ * Single-match keeps the legacy `## Relevant Skill: <title>` shape;
167
+ * multi-match nests them under `## Relevant Skills` with per-skill
168
+ * subheadings. Tools-required warnings (legacy `assistant.ts:1504-1513`)
169
+ * are NOT included here — they belong to the run-time tool policy
170
+ * check, not the prompt context.
171
+ */
172
+ function renderPromptBlock(matches) {
173
+ if (matches.length === 0)
174
+ return '';
175
+ if (matches.length === 1) {
176
+ const s = matches[0];
177
+ return `## Relevant Skill: ${s.title}\n\n${s.content.slice(0, PER_SKILL_BODY_CHARS)}`;
178
+ }
179
+ const parts = ['## Relevant Skills', ''];
180
+ parts.push(`Top ${matches.length} skill matches for this request, ordered by relevance. ` +
181
+ `Use these as a guide for which tools to call. If multiple skills suggest the same MCP server, ` +
182
+ `prefer the highest-scored one's procedure.\n`);
183
+ for (let i = 0; i < matches.length; i++) {
184
+ const s = matches[i];
185
+ parts.push(`### ${i + 1}. ${s.title}`, '');
186
+ parts.push(s.content.slice(0, PER_SKILL_BODY_CHARS));
187
+ parts.push('');
188
+ }
189
+ return parts.join('\n');
190
+ }
191
+ // ── Public entry point ────────────────────────────────────────────────
192
+ /**
193
+ * Match a user message against the skill catalog and return the routing
194
+ * hints + prompt block the chat path should layer on top of the static
195
+ * `routeToolSurface` decision.
196
+ *
197
+ * Never throws — telemetry / matching errors degrade gracefully to
198
+ * empty hints, and the caller proceeds with today's behavior.
199
+ */
200
+ export function resolveSkillsForChat(userMessage, opts = {}) {
201
+ const queryChars = userMessage.length;
202
+ const empty = {
203
+ matches: [],
204
+ hintedMcpServers: [],
205
+ hintedTools: [],
206
+ promptBlock: '',
207
+ diagnostics: {
208
+ queryChars,
209
+ candidatesConsidered: 0,
210
+ matchesAboveThreshold: 0,
211
+ topScore: 0,
212
+ mcpRefsExtracted: 0,
213
+ },
214
+ };
215
+ if (!userMessage || !userMessage.trim())
216
+ return empty;
217
+ const limit = Math.max(1, opts.limit ?? DEFAULT_TOP_K);
218
+ const minScore = opts.minScore ?? DEFAULT_MIN_SCORE;
219
+ const suppressedNames = opts.memoryStore?.getSkillsToSuppress?.(opts.profile?.slug);
220
+ let candidates = [];
221
+ try {
222
+ candidates = searchSkills(userMessage,
223
+ // Ask for a bit more than `limit` so we can filter by minScore and
224
+ // still hit the cap when the distribution has a tail of weak hits.
225
+ Math.max(limit + 2, 5), opts.profile?.slug, { ...(suppressedNames ? { suppressedNames } : {}) });
226
+ }
227
+ catch (err) {
228
+ logger.debug({ err }, 'chat-skill-resolver: searchSkills failed (non-fatal)');
229
+ return empty;
230
+ }
231
+ const matches = candidates
232
+ .filter((m) => m.score >= minScore)
233
+ .slice(0, limit);
234
+ if (matches.length === 0) {
235
+ return {
236
+ ...empty,
237
+ diagnostics: {
238
+ queryChars,
239
+ candidatesConsidered: candidates.length,
240
+ matchesAboveThreshold: 0,
241
+ topScore: candidates[0]?.score ?? 0,
242
+ mcpRefsExtracted: 0,
243
+ },
244
+ };
245
+ }
246
+ const mcpServerSet = new Set();
247
+ const toolSet = new Set();
248
+ for (const m of matches) {
249
+ for (const s of extractMcpServersFromMatch(m))
250
+ mcpServerSet.add(s);
251
+ for (const t of extractAllowedToolsFromMatch(m))
252
+ toolSet.add(t);
253
+ }
254
+ const result = {
255
+ matches,
256
+ hintedMcpServers: [...mcpServerSet],
257
+ hintedTools: [...toolSet],
258
+ promptBlock: renderPromptBlock(matches),
259
+ diagnostics: {
260
+ queryChars,
261
+ candidatesConsidered: candidates.length,
262
+ matchesAboveThreshold: matches.length,
263
+ topScore: matches[0].score,
264
+ mcpRefsExtracted: mcpServerSet.size,
265
+ },
266
+ };
267
+ logger.info({
268
+ matches: matches.map(m => ({ name: m.name, score: Number(m.score.toFixed(2)) })),
269
+ hintedMcpServers: result.hintedMcpServers,
270
+ hintedToolCount: result.hintedTools.length,
271
+ queryChars,
272
+ }, 'chat-skill-resolver: skills matched');
273
+ return result;
274
+ }
275
+ // Re-export the helpers so tests can target them directly.
276
+ export { extractMcpServersFromMatch, extractAllowedToolsFromMatch, renderPromptBlock };
277
+ // Silence lint for the dirname import — kept for future use when the
278
+ // matcher needs the parent folder of a matched skill (e.g. to surface
279
+ // bundled attachments in the prompt). Removing the import would be a
280
+ // pre-emptive cleanup but the file is already tagged for follow-up work.
281
+ void dirname;
282
+ //# sourceMappingURL=chat-skill-resolver.js.map
@@ -24,6 +24,19 @@ export interface BuildExtraMcpOptions {
24
24
  /** When true, build the FULL surface (no bundle filtering, no dedup).
25
25
  * Used by admin/debug callers; not the cron-path default. */
26
26
  fullSurface?: boolean;
27
+ /**
28
+ * Additional MCP server slugs that should be considered "in scope" beyond
29
+ * what `routeToolSurface(scopeText)` produced. The chat path (1.18.170)
30
+ * derives these from `mcp__<server>__<tool>` references on auto-matched
31
+ * skills — see `chat-skill-resolver.ts`. The same slug is tried as both a
32
+ * Composio toolkit and an external MCP server name, mirroring the
33
+ * `explicit_mcp` handling in `tool-router.ts:223-234` so whichever source
34
+ * is actually connected mounts and the other no-ops.
35
+ *
36
+ * Profile-level allowlists still WIN over these hints — a hint cannot
37
+ * loosen a profile-restricted agent's tool surface.
38
+ */
39
+ skillHintedMcpServers?: string[];
27
40
  }
28
41
  export interface BuildExtraMcpResult {
29
42
  /** Map of additional MCP servers to merge into runAgent's mcpServers. */
@@ -44,6 +44,36 @@ export async function buildExtraMcpForRunAgent(opts = {}) {
44
44
  reason: 'full_surface',
45
45
  }
46
46
  : routeToolSurface(opts.scopeText ?? '');
47
+ // 1b. Widen with skill-hinted MCP servers (1.18.170). Each hint slug is
48
+ // tried as both an external MCP server name and a Composio toolkit;
49
+ // unmatched/disconnected ones no-op below at server-construction time.
50
+ // We don't touch `route` when `fullSurface` is set — nothing to widen.
51
+ if (!opts.fullSurface && opts.skillHintedMcpServers && opts.skillHintedMcpServers.length > 0) {
52
+ const ext = new Set(Array.isArray(route.externalMcpServers) ? route.externalMcpServers : []);
53
+ const com = new Set(Array.isArray(route.composioToolkits) ? route.composioToolkits : []);
54
+ for (const slug of opts.skillHintedMcpServers) {
55
+ const trimmed = slug.trim();
56
+ if (!trimmed)
57
+ continue;
58
+ if (trimmed.startsWith('claude_ai_')) {
59
+ ext.add(trimmed.slice('claude_ai_'.length));
60
+ }
61
+ else {
62
+ ext.add(trimmed);
63
+ com.add(trimmed);
64
+ }
65
+ }
66
+ // Keep the existing reason union ('empty' | 'matched' | 'full_surface').
67
+ // The widening just lifts an 'empty' route into 'matched' once we've
68
+ // added any skill-derived server. Diagnostics about skill_hint
69
+ // contribution flow via the caller's log line.
70
+ route = {
71
+ ...route,
72
+ externalMcpServers: [...ext],
73
+ composioToolkits: [...com],
74
+ reason: ext.size > 0 || com.size > 0 ? 'matched' : route.reason,
75
+ };
76
+ }
47
77
  // 2. Build Composio MCP servers, honoring profile allowlist when set.
48
78
  let composioMcpServers = {};
49
79
  try {
@@ -111,7 +111,7 @@ export interface RunAgentOptions {
111
111
  * team-task) keep the prompt small. When unset, falls back to
112
112
  * profile.systemPromptBody (legacy single-source behavior). */
113
113
  systemPromptAppend?: string;
114
- /** Per-run override for the tool-output-guard config (1.18.168).
114
+ /** Per-run override for the tool-output-guard config (1.18.169).
115
115
  * Defaults come from src/config.ts TOOL_OUTPUT_GUARD (env +
116
116
  * clementine.json). Pass null to disable the guard for this run
117
117
  * (rarely needed — almost always a sign that perTool overrides
@@ -279,7 +279,7 @@ export async function runAgent(prompt, opts) {
279
279
  }
280
280
  }
281
281
  // PRD §6 / 1.18.85: stable run id created before sdkOptions so the
282
- // tool-output guard (1.18.168) can namespace its on-disk archive by
282
+ // tool-output guard (1.18.169) can namespace its on-disk archive by
283
283
  // runId. EventLog writers below also reference this id.
284
284
  const runId = randomUUID();
285
285
  const eventLog = new EventLog();
@@ -294,7 +294,7 @@ export async function runAgent(prompt, opts) {
294
294
  }
295
295
  catch { /* never block */ }
296
296
  };
297
- // ── Tool-output guard hooks (1.18.168) ─────────────────────────────
297
+ // ── Tool-output guard hooks (1.18.169) ─────────────────────────────
298
298
  // Bounds the per-tool-call output that reaches the model so SDK
299
299
  // auto-compaction never thrashes on a runaway MCP result. The hook
300
300
  // ALSO mirrors compression events into the run's EventLog so the Run
@@ -380,7 +380,7 @@ export async function runAgent(prompt, opts) {
380
380
  ...(opts.additionalDirectories && opts.additionalDirectories.length > 0
381
381
  ? { additionalDirectories: opts.additionalDirectories }
382
382
  : {}),
383
- // 1.18.168 — install the tool-output guard hooks. SDK types accept
383
+ // 1.18.169 — install the tool-output guard hooks. SDK types accept
384
384
  // `hooks` keyed by HookEvent; the empty object is a no-op when the
385
385
  // guard is disabled.
386
386
  ...(Object.keys(guard.hooks).length > 0 ? { hooks: guard.hooks } : {}),
@@ -443,7 +443,7 @@ export async function runAgent(prompt, opts) {
443
443
  }
444
444
  if (message.type === 'assistant') {
445
445
  const am = message;
446
- // 1.18.168 — capture this turn's usage so the tool-output guard can
446
+ // 1.18.169 — capture this turn's usage so the tool-output guard can
447
447
  // adaptively tighten its cap as cumulative context climbs. We sum
448
448
  // input + cache_read + cache_creation because all three count
449
449
  // against the model's window for the NEXT turn. Output_tokens isn't
@@ -631,7 +631,7 @@ export async function runAgent(prompt, opts) {
631
631
  totalCostUsd: Number(totalCostUsd.toFixed(4)),
632
632
  durationMs: Date.now() - startedAt,
633
633
  finalTextChars: finalText.length,
634
- // 1.18.168 — tool-output guard summary, surfaced for observability.
634
+ // 1.18.169 — tool-output guard summary, surfaced for observability.
635
635
  // Non-zero `compressed` means the guard kept the SDK from thrashing.
636
636
  guard: guard.stats.inspected > 0 ? {
637
637
  inspected: guard.stats.inspected,
@@ -141,6 +141,8 @@ export interface GuardHookOptions {
141
141
  * guard calls this once per tool result to adapt the cap. When
142
142
  * absent, ratio is assumed 0 (full soft cap is always used). */
143
143
  usageRatio?: () => number;
144
+ /** Optional archive root override for tests. Defaults to Clementine home. */
145
+ archiveBaseDir?: string;
144
146
  }
145
147
  export interface GuardHookHandles {
146
148
  /** Hook map suitable for SDK `query({ options: { hooks } })`. */
@@ -184,9 +184,9 @@ function formatBytes(n) {
184
184
  /** Persist the full payload so the agent can `Read` it later if needed.
185
185
  * Returns the absolute path, or null on any failure (archive is opt-in
186
186
  * convenience — never blocks compression). */
187
- function archivePayload(runId, toolUseId, toolName, payload) {
187
+ function archivePayload(baseDir, runId, toolUseId, toolName, payload) {
188
188
  try {
189
- const dir = join(BASE_DIR, 'tool-archive', runId);
189
+ const dir = join(baseDir, 'tool-archive', runId);
190
190
  mkdirSync(dir, { recursive: true });
191
191
  const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
192
192
  const file = join(dir, `${safeName}__${toolUseId}.json`);
@@ -304,7 +304,7 @@ export function buildGuardHooks(opts) {
304
304
  // a real file. We don't fail compression if archive fails — the
305
305
  // payload just becomes irretrievable; the model still gets a
306
306
  // truncation marker and can re-call the tool.
307
- const archivePath = archivePayload(opts.runId, toolUseId, toolName, rawOutput);
307
+ const archivePath = archivePayload(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, rawOutput);
308
308
  const outcome = compressToolOutput(toolName, rawOutput, {
309
309
  toolName,
310
310
  toolUseId,
@@ -68,7 +68,7 @@ export const clementineJsonSchema = z.object({
68
68
  chat: z.number().nonnegative().optional(),
69
69
  }).optional(),
70
70
  /**
71
- * Tool-output context-protection caps (1.18.168).
71
+ * Tool-output context-protection caps (1.18.169).
72
72
  *
73
73
  * Every SDK `tool_result` larger than the limit gets compressed in a
74
74
  * PostToolUse hook before the model sees it. The full result is archived
package/dist/config.js CHANGED
@@ -428,7 +428,7 @@ export const TASK_BUDGET_TOKENS = {
428
428
  // Interactive chat: off by default — let the user and maxTurns drive it.
429
429
  chat: optionalTokenEnv('TASK_BUDGET_CHAT', undefined),
430
430
  };
431
- // ── Tool-output context guard (1.18.168) ─────────────────────────────
431
+ // ── Tool-output context guard (1.18.169) ─────────────────────────────
432
432
  // Caps the per-tool-call size that reaches the model's context window.
433
433
  // Implements Anthropic's recommended PostToolUse hook pattern to prevent
434
434
  // runaway MCP-tool outputs (Outlook inbox dumps, iMessage history,
@@ -1796,6 +1796,7 @@ export class Gateway {
1796
1796
  const { runAgent } = await import('../agent/run-agent.js');
1797
1797
  const { buildExtraMcpForRunAgent } = await import('../agent/run-agent-mcp.js');
1798
1798
  const { buildChatSystemAppend } = await import('../agent/run-agent-context.js');
1799
+ const { resolveSkillsForChat } = await import('../agent/chat-skill-resolver.js');
1799
1800
  // Builder sessions (dashboard trick/skill/cron/agent builder)
1800
1801
  // are conversational JSON-drafting flows, not real chat. They
1801
1802
  // don't need vault context, MCP tools, recall, or auto-memory
@@ -1804,6 +1805,23 @@ export class Gateway {
1804
1805
  // expensive; keep just SDK session resume so multi-turn
1805
1806
  // artifact iteration sees its own prior turns.
1806
1807
  const isBuilderSession = sessionKey.startsWith('dashboard:builder:');
1808
+ // ── Skill auto-match (1.18.170) ─────────────────────────────
1809
+ // Match the user's message against the skill catalog (auto-
1810
+ // skills + user-authored). Top-3 matches above score ≥ 4 inform:
1811
+ // (a) MCP routing — every matched skill's `mcp__<server>__<tool>`
1812
+ // references widen `buildExtraMcpForRunAgent`'s server set
1813
+ // beyond the 19 regex bundles. Closes the "Salesforce
1814
+ // connected but no bundle exists" gap end-to-end.
1815
+ // (b) System prompt — matched skill bodies are appended as a
1816
+ // "## Relevant Skills" block so the model knows the canonical
1817
+ // procedure + arg names.
1818
+ // Builder sessions skip this — they don't call tools.
1819
+ const resolvedSkills = isBuilderSession
1820
+ ? null
1821
+ : resolveSkillsForChat(originalText, {
1822
+ profile: resolvedProfile,
1823
+ memoryStore: this.assistant.getMemoryStore?.() ?? null,
1824
+ });
1807
1825
  // Wire Composio + external MCP only for real chat. Builder
1808
1826
  // skips entirely — builder turns never call tools.
1809
1827
  const chatMcp = isBuilderSession
@@ -1811,16 +1829,26 @@ export class Gateway {
1811
1829
  : await buildExtraMcpForRunAgent({
1812
1830
  scopeText: originalText,
1813
1831
  profile: resolvedProfile,
1832
+ ...(resolvedSkills && resolvedSkills.hintedMcpServers.length > 0
1833
+ ? { skillHintedMcpServers: resolvedSkills.hintedMcpServers }
1834
+ : {}),
1814
1835
  });
1815
1836
  // Vault context (SOUL.md / MEMORY.md / AGENTS.md + optional
1816
1837
  // profile body) — real chat only. Builder gets just its own
1817
1838
  // prefix as the system prompt.
1818
- const chatSystemAppend = isBuilderSession
1839
+ const baseSystemAppend = isBuilderSession
1819
1840
  ? ''
1820
1841
  : buildChatSystemAppend({
1821
1842
  profile: resolvedProfile,
1822
1843
  profileAppend: resolvedProfile?.systemPromptBody,
1823
1844
  });
1845
+ // Append the matched-skill block AFTER the vault context so the
1846
+ // skill instructions are the last (most recent) frame the model
1847
+ // sees in the system prompt — a small recency boost without
1848
+ // disturbing personality / memory ordering.
1849
+ const chatSystemAppend = resolvedSkills && resolvedSkills.promptBlock
1850
+ ? (baseSystemAppend ? `${baseSystemAppend}\n\n${resolvedSkills.promptBlock}` : resolvedSkills.promptBlock)
1851
+ : baseSystemAppend;
1824
1852
  // Per-turn context (recall + persistent learnings + silent
1825
1853
  // blocks + security/toolset directives) — real chat only.
1826
1854
  // Builder doesn't need recall of unrelated transcripts.
@@ -1854,6 +1882,11 @@ export class Gateway {
1854
1882
  turnContextChars: turnContextPrefix.length,
1855
1883
  resumingSdkSessionId: priorSdkSessionId || null,
1856
1884
  isBuilderSession,
1885
+ // 1.18.170 — surface skill matches so the dashboard's Run
1886
+ // detail page can render which skills informed routing.
1887
+ skillMatches: resolvedSkills?.matches.length ?? 0,
1888
+ skillMatchNames: resolvedSkills?.matches.map(m => m.name) ?? [],
1889
+ skillHintedMcpServers: resolvedSkills?.hintedMcpServers ?? [],
1857
1890
  }, 'Routing chat through runAgent');
1858
1891
  const runAgentResult = await runAgent(finalPrompt, {
1859
1892
  sessionKey: effectiveSessionKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.168",
3
+ "version": "1.18.170",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",