clementine-agent 1.1.8 → 1.1.9

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.
@@ -1506,6 +1506,33 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1506
1506
  }
1507
1507
  catch { /* non-fatal */ }
1508
1508
  }
1509
+ // Conversational context — same signals the insight engine surfaces
1510
+ // proactively (Phase 10), but injected directly into the agent's prompt
1511
+ // so it can adjust its own approach. Scoped to chat sessions because
1512
+ // cron/heartbeat don't have a "user feeling frustrated" axis to react to,
1513
+ // and inflating their prompt doesn't help. Only injected when at least
1514
+ // one signal fires — keeps the prompt clean during normal sessions.
1515
+ if (!isAutonomous) {
1516
+ try {
1517
+ const { detectFrustrationSignals, detectRepeatedTopics } = require('./insight-engine.js');
1518
+ const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
1519
+ const since7d = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
1520
+ const recent = this.getRecentActivity(since24h, 50);
1521
+ const week = this.getRecentActivity(since7d, 200);
1522
+ const frustration = detectFrustrationSignals(recent);
1523
+ const topics = detectRepeatedTopics(week);
1524
+ const allSignals = [...frustration, ...topics];
1525
+ if (allSignals.length > 0) {
1526
+ const guidance = frustration.length > 0
1527
+ ? '\n\n**Adjust your approach:** When friction signals are present, lead with a clarifying question instead of assuming. Acknowledge the prior misunderstanding briefly without over-apologizing. Confirm understanding before acting.'
1528
+ : '\n\n**Use this context naturally:** Recurring topics may indicate an unresolved thread — if relevant, offer to close the loop or summarize current state. Do not force callbacks if not directly applicable.';
1529
+ volatileParts.push(`## Conversational Context\n\nSignals from recent sessions:\n` +
1530
+ allSignals.map(s => `- ${s}`).join('\n') +
1531
+ guidance);
1532
+ }
1533
+ }
1534
+ catch { /* non-fatal — insight-engine optional */ }
1535
+ }
1509
1536
  // Current context — date/time changes every minute, so it's volatile.
1510
1537
  const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
1511
1538
  const resolvedModel = resolveModel(model) ?? MODEL;
@@ -3786,6 +3813,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3786
3813
  }
3787
3814
  async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug) {
3788
3815
  setInteractionSource('autonomous');
3816
+ // Tag every tool_use audit event with the cron job name + agent so
3817
+ // analytics tool-usage can show "Bash×893 driven by market-leader-followup"
3818
+ // instead of "driven by: unknown". Cleared on next setInteractionSource
3819
+ // (cron/heartbeat boundary or interactive chat takeover).
3820
+ const { setActiveQueryContext } = await import('./hooks.js');
3821
+ setActiveQueryContext({ job: jobName, source: 'cron', agentSlug });
3789
3822
  const cronProfile = agentSlug && agentSlug !== 'clementine'
3790
3823
  ? this.profileManager.get(agentSlug)
3791
3824
  : null;
@@ -4274,6 +4307,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4274
4307
  logger.info(`Unleashed task ${jobName}: starting phase ${phase}`);
4275
4308
  // Re-assert autonomous source — a chat message may have changed it between phases
4276
4309
  setInteractionSource('autonomous');
4310
+ // Tag tool_use audit events with the unleashed job name (Phase 11).
4311
+ // Re-asserted each phase since setInteractionSource clears the context.
4312
+ const { setActiveQueryContext: _setActiveQueryContext } = await import('./hooks.js');
4313
+ _setActiveQueryContext({ job: jobName, source: 'unleashed', agentSlug });
4277
4314
  // Unleashed phases run side-effect-heavy work; same logic as cron mode.
4278
4315
  const phaseGuard = new StallGuard('unleashed');
4279
4316
  const sdkOptions = this.buildOptions({
@@ -62,6 +62,12 @@ export declare function getInteractionSource(): 'owner-dm' | 'owner-channel' | '
62
62
  export declare function getProfileTier(): number | null;
63
63
  export declare function getAuditLog(): string[];
64
64
  export declare function clearAuditLog(): void;
65
+ export declare function setActiveQueryContext(ctx: {
66
+ job?: string | null;
67
+ source?: string | null;
68
+ agentSlug?: string | null;
69
+ }): void;
70
+ export declare function clearActiveQueryContext(): void;
65
71
  export declare function logToolUse(toolName: string, toolInput: Record<string, unknown>): void;
66
72
  export declare function getHeartbeatDisallowedTools(): string[];
67
73
  export declare const PRIVATE_URL_PATTERNS: RegExp[];
@@ -141,6 +141,11 @@ export function setSendPolicyChecker(checker) {
141
141
  }
142
142
  export function setInteractionSource(source) {
143
143
  interactionSource = source;
144
+ // Clear any leftover query attribution context. Cron / unleashed paths
145
+ // immediately call setActiveQueryContext after this; interactive chat
146
+ // doesn't, so anything still set from a prior cron run gets reset.
147
+ activeJob = null;
148
+ activeSource = null;
144
149
  }
145
150
  export function getInteractionSource() {
146
151
  return interactionSource;
@@ -154,6 +159,25 @@ export function getAuditLog() {
154
159
  export function clearAuditLog() {
155
160
  auditLog.length = 0;
156
161
  }
162
+ // Ambient job/source context so audit tool_use events carry attribution.
163
+ // Set by the assistant before running a query; cleared after. Without this
164
+ // the analytics view shows everything as "driven by: unknown". The
165
+ // activeAgentSlug field is already declared above (line ~27) for the
166
+ // existing send-policy infrastructure — we read but don't redeclare it.
167
+ let activeJob = null;
168
+ let activeSource = null;
169
+ export function setActiveQueryContext(ctx) {
170
+ activeJob = ctx.job ?? null;
171
+ activeSource = ctx.source ?? null;
172
+ if (ctx.agentSlug !== undefined)
173
+ activeAgentSlug = ctx.agentSlug;
174
+ }
175
+ export function clearActiveQueryContext() {
176
+ activeJob = null;
177
+ activeSource = null;
178
+ // Don't clear activeAgentSlug — it's owned by the send-policy path,
179
+ // not by us. setInteractionSource resets it in the relevant transitions.
180
+ }
157
181
  export function logToolUse(toolName, toolInput) {
158
182
  const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
159
183
  const summary = summarizeToolCall(toolName, toolInput);
@@ -164,6 +188,9 @@ export function logToolUse(toolName, toolInput) {
164
188
  event_type: 'tool_use',
165
189
  tool_name: toolName,
166
190
  summary,
191
+ ...(activeJob ? { job: activeJob } : {}),
192
+ ...(activeSource ? { source: activeSource } : {}),
193
+ ...(activeAgentSlug ? { agent_slug: activeAgentSlug } : {}),
167
194
  });
168
195
  }
169
196
  // ── Heartbeat tool restrictions ─────────────────────────────────────
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tool-usage analytics.
3
+ *
4
+ * Reads ~/.clementine/logs/audit.jsonl and aggregates tool_use events by
5
+ * family + name + source so a CLI report can answer:
6
+ *
7
+ * - "What is the agent spending its tool calls on?"
8
+ * - "Which integration (mcp__ family) is hottest?"
9
+ * - "Which job/source is the biggest tool consumer?"
10
+ *
11
+ * Pure file read + in-memory aggregation — no daemon access required.
12
+ * Designed to run on multi-MB audit logs without buffering everything;
13
+ * we stream line-by-line.
14
+ */
15
+ export interface ToolFamilyStats {
16
+ /** Family label — collapses mcp__ subnames into one bucket per server. */
17
+ family: string;
18
+ totalCalls: number;
19
+ /** Per-tool breakdown within the family, sorted by count desc. */
20
+ byTool: Array<{
21
+ tool: string;
22
+ count: number;
23
+ }>;
24
+ /** Per-source breakdown — which job/context drives this family. */
25
+ bySource: Array<{
26
+ source: string;
27
+ count: number;
28
+ }>;
29
+ }
30
+ export interface ToolUsageReport {
31
+ windowStart: string;
32
+ windowEnd: string;
33
+ totalToolCalls: number;
34
+ totalQueries: number;
35
+ families: ToolFamilyStats[];
36
+ /** Total cost (sum of query_complete events) over the window — context for tool counts. */
37
+ totalCostUsd: number;
38
+ }
39
+ /**
40
+ * Family normalization. Built-in SDK tools keep their name; MCP tools are
41
+ * grouped by server (mcp__<server>__<tool> → "mcp:<server>"). Anything
42
+ * else falls into "other".
43
+ */
44
+ export declare function classifyToolFamily(toolName: string): string;
45
+ /**
46
+ * Aggregate tool_use + query_complete events from audit.jsonl over the
47
+ * given window. Window bounds are ISO strings; entries outside are ignored.
48
+ *
49
+ * The function is forgiving: malformed lines are skipped, missing fields
50
+ * default to 'unknown'. Audit logs are append-only so we never need to
51
+ * worry about ordering.
52
+ */
53
+ export declare function buildToolUsageReport(auditLogPath: string, windowStart: string, windowEnd: string): ToolUsageReport;
54
+ /** Default audit log path — passed-through for CLI default + tests. */
55
+ export declare function defaultAuditLogPath(baseDir: string): string;
56
+ //# sourceMappingURL=tool-usage.d.ts.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tool-usage analytics.
3
+ *
4
+ * Reads ~/.clementine/logs/audit.jsonl and aggregates tool_use events by
5
+ * family + name + source so a CLI report can answer:
6
+ *
7
+ * - "What is the agent spending its tool calls on?"
8
+ * - "Which integration (mcp__ family) is hottest?"
9
+ * - "Which job/source is the biggest tool consumer?"
10
+ *
11
+ * Pure file read + in-memory aggregation — no daemon access required.
12
+ * Designed to run on multi-MB audit logs without buffering everything;
13
+ * we stream line-by-line.
14
+ */
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ /**
18
+ * Family normalization. Built-in SDK tools keep their name; MCP tools are
19
+ * grouped by server (mcp__<server>__<tool> → "mcp:<server>"). Anything
20
+ * else falls into "other".
21
+ */
22
+ export function classifyToolFamily(toolName) {
23
+ if (!toolName)
24
+ return 'other';
25
+ // mcp__server-name__tool_name → mcp:server-name
26
+ const mcpMatch = toolName.match(/^mcp__([^_]+(?:[-_][^_]+)*)__/);
27
+ if (mcpMatch)
28
+ return `mcp:${mcpMatch[1]}`;
29
+ // Built-ins kept as their own families
30
+ const BUILTIN_FAMILIES = {
31
+ Bash: 'shell',
32
+ Read: 'fs-read',
33
+ Glob: 'fs-read',
34
+ Grep: 'fs-read',
35
+ Edit: 'fs-write',
36
+ Write: 'fs-write',
37
+ NotebookEdit: 'fs-write',
38
+ WebFetch: 'web',
39
+ WebSearch: 'web',
40
+ Agent: 'subagent',
41
+ Task: 'subagent',
42
+ };
43
+ return BUILTIN_FAMILIES[toolName] ?? toolName;
44
+ }
45
+ /**
46
+ * Aggregate tool_use + query_complete events from audit.jsonl over the
47
+ * given window. Window bounds are ISO strings; entries outside are ignored.
48
+ *
49
+ * The function is forgiving: malformed lines are skipped, missing fields
50
+ * default to 'unknown'. Audit logs are append-only so we never need to
51
+ * worry about ordering.
52
+ */
53
+ export function buildToolUsageReport(auditLogPath, windowStart, windowEnd) {
54
+ const startMs = Date.parse(windowStart);
55
+ const endMs = Date.parse(windowEnd);
56
+ // family → { totalCalls, perTool: Map<string,count>, perSource: Map<string,count> }
57
+ const families = new Map();
58
+ let totalToolCalls = 0;
59
+ let totalQueries = 0;
60
+ let totalCost = 0;
61
+ if (!existsSync(auditLogPath)) {
62
+ return { windowStart, windowEnd, totalToolCalls: 0, totalQueries: 0, families: [], totalCostUsd: 0 };
63
+ }
64
+ // Stream-friendly read — each line is independent JSON. Audit logs are
65
+ // typically a few MB; readFileSync is fine at that scale.
66
+ const raw = readFileSync(auditLogPath, 'utf-8');
67
+ for (const line of raw.split('\n')) {
68
+ if (!line)
69
+ continue;
70
+ let entry;
71
+ try {
72
+ entry = JSON.parse(line);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (!entry.ts)
78
+ continue;
79
+ const tsMs = Date.parse(entry.ts);
80
+ if (Number.isNaN(tsMs))
81
+ continue;
82
+ if (tsMs < startMs || tsMs > endMs)
83
+ continue;
84
+ if (entry.event_type === 'tool_use' && entry.tool_name) {
85
+ const family = classifyToolFamily(entry.tool_name);
86
+ const source = entry.job || entry.source || 'unknown';
87
+ let bucket = families.get(family);
88
+ if (!bucket) {
89
+ bucket = { totalCalls: 0, perTool: new Map(), perSource: new Map() };
90
+ families.set(family, bucket);
91
+ }
92
+ bucket.totalCalls++;
93
+ bucket.perTool.set(entry.tool_name, (bucket.perTool.get(entry.tool_name) ?? 0) + 1);
94
+ bucket.perSource.set(source, (bucket.perSource.get(source) ?? 0) + 1);
95
+ totalToolCalls++;
96
+ }
97
+ else if (entry.event_type === 'query_complete') {
98
+ totalQueries++;
99
+ if (typeof entry.cost_usd === 'number' && Number.isFinite(entry.cost_usd)) {
100
+ totalCost += entry.cost_usd;
101
+ }
102
+ }
103
+ }
104
+ const familyStats = [...families.entries()]
105
+ .map(([family, b]) => ({
106
+ family,
107
+ totalCalls: b.totalCalls,
108
+ byTool: [...b.perTool.entries()]
109
+ .map(([tool, count]) => ({ tool, count }))
110
+ .sort((a, c) => c.count - a.count),
111
+ bySource: [...b.perSource.entries()]
112
+ .map(([source, count]) => ({ source, count }))
113
+ .sort((a, c) => c.count - a.count),
114
+ }))
115
+ .sort((a, b) => b.totalCalls - a.totalCalls);
116
+ return {
117
+ windowStart,
118
+ windowEnd,
119
+ totalToolCalls,
120
+ totalQueries,
121
+ families: familyStats,
122
+ totalCostUsd: Number(totalCost.toFixed(4)),
123
+ };
124
+ }
125
+ /** Default audit log path — passed-through for CLI default + tests. */
126
+ export function defaultAuditLogPath(baseDir) {
127
+ return path.join(baseDir, 'logs', 'audit.jsonl');
128
+ }
129
+ //# sourceMappingURL=tool-usage.js.map
package/dist/cli/index.js CHANGED
@@ -1270,6 +1270,58 @@ async function cmdConfigKeychainFixAcl(opts) {
1270
1270
  }
1271
1271
  console.log();
1272
1272
  }
1273
+ // ── Analytics ────────────────────────────────────────────────────────
1274
+ async function cmdAnalyticsToolUsage(opts) {
1275
+ const { buildToolUsageReport, defaultAuditLogPath } = await import('../analytics/tool-usage.js');
1276
+ const hours = Math.max(1, parseInt(opts.hours ?? '24', 10) || 24);
1277
+ const limit = Math.max(1, parseInt(opts.limit ?? '10', 10) || 10);
1278
+ const end = new Date();
1279
+ const start = new Date(end.getTime() - hours * 60 * 60 * 1000);
1280
+ const report = buildToolUsageReport(defaultAuditLogPath(BASE_DIR), start.toISOString(), end.toISOString());
1281
+ if (opts.json) {
1282
+ console.log(JSON.stringify(report, null, 2));
1283
+ return;
1284
+ }
1285
+ const DIM = '\x1b[0;90m';
1286
+ const BOLD = '\x1b[1m';
1287
+ const CYAN = '\x1b[0;36m';
1288
+ const GREEN = '\x1b[0;32m';
1289
+ const YELLOW = '\x1b[0;33m';
1290
+ const RESET = '\x1b[0m';
1291
+ console.log();
1292
+ console.log(` ${BOLD}Window:${RESET} last ${hours}h ${DIM}(${start.toISOString()} → ${end.toISOString()})${RESET}`);
1293
+ console.log(` ${BOLD}Total tool calls:${RESET} ${report.totalToolCalls.toLocaleString()}`);
1294
+ console.log(` ${BOLD}Total queries:${RESET} ${report.totalQueries.toLocaleString()}`);
1295
+ console.log(` ${BOLD}Total cost:${RESET} ${GREEN}$${report.totalCostUsd.toFixed(4)}${RESET}`);
1296
+ console.log();
1297
+ if (report.families.length === 0) {
1298
+ console.log(` ${DIM}No tool_use events in window.${RESET}`);
1299
+ console.log();
1300
+ return;
1301
+ }
1302
+ const top = report.families.slice(0, limit);
1303
+ const maxCalls = Math.max(...top.map(f => f.totalCalls));
1304
+ const familyWidth = Math.max(...top.map(f => f.family.length), 12);
1305
+ console.log(` ${BOLD}Top ${top.length} tool families${RESET}`);
1306
+ for (const f of top) {
1307
+ const pct = report.totalToolCalls > 0
1308
+ ? ((f.totalCalls / report.totalToolCalls) * 100).toFixed(1)
1309
+ : '0.0';
1310
+ const barLen = Math.round((f.totalCalls / maxCalls) * 28);
1311
+ const bar = '█'.repeat(barLen).padEnd(28);
1312
+ console.log(` ${CYAN}${f.family.padEnd(familyWidth)}${RESET} ` +
1313
+ `${String(f.totalCalls).padStart(5)} ${DIM}calls${RESET} ` +
1314
+ `${pct.padStart(5)}% ${YELLOW}${bar}${RESET}`);
1315
+ // Top 2 individual tools within each family + top source
1316
+ const topTools = f.byTool.slice(0, 2).map(t => `${t.tool}×${t.count}`).join(', ');
1317
+ const topSource = f.bySource[0];
1318
+ console.log(` ${DIM}top tools: ${topTools}${RESET}`);
1319
+ if (topSource) {
1320
+ console.log(` ${DIM}driven by: ${topSource.source} (${topSource.count} calls)${RESET}`);
1321
+ }
1322
+ }
1323
+ console.log();
1324
+ }
1273
1325
  // ── Advisor commands ────────────────────────────────────────────────
1274
1326
  const ADVISOR_MODES = ['off', 'shadow', 'primary'];
1275
1327
  function readAdvisorMode() {
@@ -1817,6 +1869,18 @@ advisorCmd
1817
1869
  .command('rules')
1818
1870
  .description('List loaded advisor rules')
1819
1871
  .action(cmdAdvisorRules);
1872
+ const analyticsCmd = program
1873
+ .command('analytics')
1874
+ .description('Production telemetry: tool usage, cost breakdowns');
1875
+ analyticsCmd
1876
+ .command('tool-usage')
1877
+ .description('Show which tool families are firing most over a time window')
1878
+ .option('-h, --hours <n>', 'Window size in hours (default 24)', '24')
1879
+ .option('--json', 'Emit machine-readable JSON')
1880
+ .option('-l, --limit <n>', 'Show top N families (default 10)', '10')
1881
+ .action(async (opts) => {
1882
+ await cmdAnalyticsToolUsage(opts);
1883
+ });
1820
1884
  const dashCmd = program
1821
1885
  .command('dashboard')
1822
1886
  .description('Launch local command center')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",