clementine-agent 1.18.164 → 1.18.167

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.
Files changed (61) hide show
  1. package/dist/agent/action-enforcer.js +4 -4
  2. package/dist/agent/advisor-rules/loader.js +19 -1
  3. package/dist/agent/assistant.js +18 -22
  4. package/dist/agent/execution-policy.d.ts +41 -0
  5. package/dist/agent/execution-policy.js +217 -0
  6. package/dist/agent/failure-fix-consumer.d.ts +1 -0
  7. package/dist/agent/failure-fix-consumer.js +22 -2
  8. package/dist/agent/hooks.js +2 -1
  9. package/dist/agent/insight-engine.js +3 -2
  10. package/dist/agent/mcp-bridge.js +1 -2
  11. package/dist/agent/prompt-cache.js +7 -0
  12. package/dist/agent/prompt-overrides/loader.js +19 -1
  13. package/dist/agent/run-agent-cron.d.ts +1 -2
  14. package/dist/agent/run-agent-cron.js +1 -1
  15. package/dist/agent/run-agent-heartbeat.js +5 -6
  16. package/dist/agent/run-agent.d.ts +19 -9
  17. package/dist/agent/run-agent.js +34 -17
  18. package/dist/agent/run-skill.d.ts +29 -11
  19. package/dist/agent/run-skill.js +46 -12
  20. package/dist/agent/schedule-registry.d.ts +1 -1
  21. package/dist/agent/self-improve.js +22 -0
  22. package/dist/agent/skill-store.d.ts +22 -8
  23. package/dist/agent/skill-store.js +181 -43
  24. package/dist/agent/team-bus.js +3 -1
  25. package/dist/agent/workflow-variables.js +4 -3
  26. package/dist/brain/adapters/pdf.js +2 -1
  27. package/dist/brain/llm-client.js +1 -1
  28. package/dist/brain/local-seed.d.ts +32 -0
  29. package/dist/brain/local-seed.js +101 -0
  30. package/dist/cli/cron.js +3 -1
  31. package/dist/cli/dashboard.d.ts +11 -0
  32. package/dist/cli/dashboard.js +1078 -436
  33. package/dist/cli/index.js +174 -8
  34. package/dist/cli/routes/digest.js +5 -3
  35. package/dist/cli/setup.js +3 -2
  36. package/dist/config.js +3 -1
  37. package/dist/dashboard/builder/prompt.js +8 -6
  38. package/dist/gateway/agent-heartbeat-manager.js +24 -0
  39. package/dist/gateway/cron-scheduler.js +4 -2
  40. package/dist/gateway/episodic-consolidation.d.ts +9 -0
  41. package/dist/gateway/episodic-consolidation.js +70 -10
  42. package/dist/gateway/failure-monitor.js +1 -1
  43. package/dist/gateway/fix-verification.js +10 -6
  44. package/dist/gateway/heartbeat-scheduler.js +6 -7
  45. package/dist/gateway/router.d.ts +1 -0
  46. package/dist/gateway/router.js +108 -5
  47. package/dist/gateway/turn-ledger.d.ts +5 -0
  48. package/dist/gateway/turn-ledger.js +5 -1
  49. package/dist/index.js +2 -2
  50. package/dist/lib/time.d.ts +32 -0
  51. package/dist/lib/time.js +150 -0
  52. package/dist/tools/brain-tools.d.ts +3 -0
  53. package/dist/tools/brain-tools.js +48 -5
  54. package/dist/tools/decision-reflection-tools.js +2 -2
  55. package/dist/tools/external-tools.js +3 -2
  56. package/dist/tools/schedule-tools.js +2 -2
  57. package/dist/tools/shared.js +6 -7
  58. package/dist/tools/skill-tools.js +163 -115
  59. package/dist/tools/vault-tools.js +1 -1
  60. package/dist/types.d.ts +14 -7
  61. package/package.json +1 -1
@@ -2,11 +2,11 @@ import { looksLikeApprovalPrompt } from './local-turn.js';
2
2
  function normalize(text) {
3
3
  return text.trim().toLowerCase().replace(/\s+/g, ' ');
4
4
  }
5
- const ACTION_REQUEST_RE = /\b(can you|could you|would you|please|pls|i need you to|i want you to|let'?s|go ahead and|do it|handle this|take care of)\b[\s\S]{0,160}\b(send|email|message|post|publish|delete|change|update|run|execute|check|look(?:\s+into)?|diagnose|investigate|figure(?:\s+it)?\s*out|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
6
- const DIRECT_ACTION_RE = /^(send|email|message|post|publish|delete|change|update|run|execute|check|look(?:\s+into)?|diagnose|investigate|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
5
+ const ACTION_REQUEST_RE = /\b(can you|can we|could you|would you|please|pls|i need you to|i want you to|i would like (?:for you )?to|i'?d like (?:for you )?to|let'?s|go ahead and|do it|handle this|take care of)\b[\s\S]{0,160}\b(send|email|message|post|publish|delete|change|update|run|execute|start|fire(?:\s+off)?|add|append|insert|check|look(?:\s+into)?|diagnose|investigate|figure(?:\s+it)?\s*out|find|search|research|scrape|collect|analyze|read|write|create|build|generate|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
6
+ const DIRECT_ACTION_RE = /^(send|email|message|post|publish|delete|change|update|run|execute|start|fire(?:\s+off)?|add|append|insert|check|look(?:\s+into)?|diagnose|investigate|find|search|research|scrape|collect|analyze|read|write|create|build|generate|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
7
7
  const DIAGNOSTIC_RE = /\b(log|logs|crash|crashing|error|failing|failure|broken|diagnose|debug|investigate|look into|figure it out|what'?s causing|why is|why did)\b/i;
8
- const DONE_CLAIM_RE = /\b(done|sent|emailed|queued|accepted|completed|finished|fixed|updated|changed|deleted|posted|published|scheduled|rescheduled|created|saved|uploaded|downloaded|tagged|checked|reviewed|found|read|ran|executed)\b/i;
9
- const PROMISE_RE = /\b(i'?ll|i will|i am going to|i'?m going to|let me|i'?m checking|i'?m sending|i'?m running|i'?m looking|working on it|on it)\b[\s\S]{0,120}\b(send|email|message|post|publish|delete|change|update|run|execute|check|look|diagnose|investigate|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download|now)\b/i;
8
+ const DONE_CLAIM_RE = /\b(done|sent|emailed|queued|accepted|completed|finished|fixed|updated|changed|deleted|posted|published|scheduled|rescheduled|created|built|generated|saved|uploaded|downloaded|tagged|checked|reviewed|found|scraped|researched|analyzed|read|ran|executed|fired)\b/i;
9
+ const PROMISE_RE = /\b(i'?ll|i will|i am going to|i'?m going to|let me|i'?m checking|i'?m sending|i'?m running|i'?m looking|working on it|on it)\b[\s\S]{0,120}\b(send|email|message|post|publish|delete|change|update|run|execute|start|fire(?:\s+off)?|add|append|insert|check|look|diagnose|investigate|find|search|research|scrape|collect|analyze|read|write|create|build|generate|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download|now)\b/i;
10
10
  const VACUOUS_ACK_RE = /^(got it|okay|ok|sure|perfect|sounds good|on it|will do|yep|yeah)[.! ]*$/i;
11
11
  const BLOCKED_OR_ASKING_RE = /\b(i can'?t|i cannot|unable to|blocked|need you to|need a|need the|please provide|please send|can you send|can you share|which|what should|who should|before i|confirm|approve|good to go|okay to)\b/i;
12
12
  const DIAGNOSTIC_DEFLECTION_RE = /\b(what are you seeing|what do you see|send (me )?the logs|share (the )?logs|provide (the )?logs|can you paste|can you send me)\b/i;
@@ -83,6 +83,7 @@ const ruleSchema = z.object({
83
83
  // ── Loader ──────────────────────────────────────────────────────────
84
84
  let cachedRules = [];
85
85
  let watcherInstalled = false;
86
+ let watcher = null;
86
87
  let watchDebounce = null;
87
88
  function readYamlFile(filePath) {
88
89
  try {
@@ -171,7 +172,7 @@ export function watchUserRulesDir(opts) {
171
172
  mkdirSync(userDir, { recursive: true });
172
173
  }
173
174
  try {
174
- fsWatch(userDir, () => {
175
+ watcher = fsWatch(userDir, () => {
175
176
  if (watchDebounce)
176
177
  clearTimeout(watchDebounce);
177
178
  watchDebounce = setTimeout(() => {
@@ -183,6 +184,16 @@ export function watchUserRulesDir(opts) {
183
184
  }
184
185
  }, 250);
185
186
  });
187
+ watcher.on('error', (err) => {
188
+ logger.warn({ err, userDir }, 'Rule watcher failed — hot reload disabled');
189
+ try {
190
+ watcher?.close();
191
+ }
192
+ catch { /* ignore */ }
193
+ watcher = null;
194
+ watcherInstalled = false;
195
+ });
196
+ watcher.unref();
186
197
  watcherInstalled = true;
187
198
  logger.debug({ dir: userDir }, 'Watching user rules dir for hot reload');
188
199
  }
@@ -194,6 +205,13 @@ export function watchUserRulesDir(opts) {
194
205
  export function _resetLoaderState() {
195
206
  cachedRules = [];
196
207
  watcherInstalled = false;
208
+ if (watcher) {
209
+ try {
210
+ watcher.close();
211
+ }
212
+ catch { /* ignore */ }
213
+ watcher = null;
214
+ }
197
215
  if (watchDebounce) {
198
216
  clearTimeout(watchDebounce);
199
217
  watchDebounce = null;
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, BUDGET, TASK_BUDGET_TOKENS, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, looksLikeClaudeOneMillionContextError, envSnapshot, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, BUDGET, TASK_BUDGET_TOKENS, TIMEZONE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, looksLikeClaudeOneMillionContextError, envSnapshot, } from '../config.js';
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
18
  import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, KNOWN_SERVICES, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
@@ -33,6 +33,7 @@ import { applyServiceDedup, routeToolSurface, TOOL_SURFACE_HARD_LIMIT, TOOL_SURF
33
33
  import { isRestrictedToolset, toolsetAllowsLocalWrites, toolsetDisablesAllTools } from './toolsets.js';
34
34
  import { looksLikeApprovalPrompt } from './local-turn.js';
35
35
  import { loadClementineJson } from '../config/clementine-json.js';
36
+ import { dateKeyInTimeZone, formatDateInTimeZone, formatTimeInTimeZone, hourInTimeZone } from '../lib/time.js';
36
37
  // ── Channel capabilities ────────────────────────────────────────────
37
38
  /** Map channel label to its capabilities so the agent adapts its responses. */
38
39
  function getChannelCapabilities(channel) {
@@ -562,22 +563,17 @@ function extractText(blocks) {
562
563
  }
563
564
  // ── Date Helpers ────────────────────────────────────────────────────
564
565
  function formatDate(d) {
565
- return d.toLocaleDateString('en-US', {
566
- weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
567
- });
566
+ return formatDateInTimeZone(d, TIMEZONE);
568
567
  }
569
568
  function formatTime(d) {
570
- return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
569
+ return formatTimeInTimeZone(d, TIMEZONE);
571
570
  }
572
571
  /** Local-time YYYY-MM-DD (avoids UTC date mismatch late at night). */
573
572
  function todayISO() {
574
- const d = new Date();
575
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
573
+ return dateKeyInTimeZone(new Date(), TIMEZONE);
576
574
  }
577
575
  function yesterdayISO() {
578
- const d = new Date();
579
- d.setDate(d.getDate() - 1);
580
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
576
+ return dateKeyInTimeZone(new Date(Date.now() - 24 * 60 * 60 * 1000), TIMEZONE);
581
577
  }
582
578
  // ── Cron Output Extraction ──────────────────────────────────────────
583
579
  /** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
@@ -1143,7 +1139,7 @@ Large tool outputs blow the context window and rotate your session mid-task —
1143
1139
  // Skip yesterday's notes and recent conversation summaries for autonomous runs
1144
1140
  if (!isAutonomous && !skipAmbientContext) {
1145
1141
  if (!retrievalContext) {
1146
- const hour = new Date().getHours();
1142
+ const hour = hourInTimeZone(new Date(), TIMEZONE);
1147
1143
  const mentionsYesterday = this._lastUserMessage?.toLowerCase().includes('yesterday');
1148
1144
  if (hour < 12 || mentionsYesterday) {
1149
1145
  const yPath = path.join(DAILY_NOTES_DIR, `${yesterdayISO()}.md`);
@@ -1638,7 +1634,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1638
1634
 
1639
1635
  - **Date:** ${formatDate(now)}
1640
1636
  - **Time:** ${formatTime(now)}
1641
- - **Timezone:** ${Intl.DateTimeFormat().resolvedOptions().timeZone}
1637
+ - **Timezone:** ${TIMEZONE}
1642
1638
  - **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
1643
1639
  - **Model:** ${modelLabel} (${resolvedModel})
1644
1640
  - **Vault:** ${vault}
@@ -2237,9 +2233,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2237
2233
  }
2238
2234
  }
2239
2235
  }
2240
- // Permission mode: always 'bypassPermissions' this is a daemon/harness with no interactive
2241
- // terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
2242
- const effectivePermissionMode = 'bypassPermissions';
2236
+ // Headless daemon runs cannot prompt for approval. `dontAsk` auto-allows
2237
+ // only the explicit allowedTools surface and denies everything else.
2238
+ const effectivePermissionMode = 'dontAsk';
2243
2239
  // SessionStore adapter: mirror SDK transcripts into our SQLite store.
2244
2240
  // Resume then works from the durable store, not just local JSONL.
2245
2241
  const sessionStore = this.getSessionStore();
@@ -2313,7 +2309,6 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2313
2309
  ...(fallback ? { fallbackModel: fallback } : {}),
2314
2310
  ...(oneMillionDisableValue === '1' ? { betas: [] } : {}),
2315
2311
  permissionMode: effectivePermissionMode,
2316
- allowDangerouslySkipPermissions: true,
2317
2312
  ...(sessionStore ? { sessionStore } : {}),
2318
2313
  ...(computedTaskBudget && supportsTaskBudget ? { taskBudget: { total: computedTaskBudget } } : {}),
2319
2314
  // SDK field semantics (per node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts):
@@ -2323,8 +2318,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2323
2318
  // CLEMENTINE_TOOL_ALLOWLIST so unneeded internal tools
2324
2319
  // are never registered for lightweight turns.
2325
2320
  // - `allowedTools` → auto-allow list covering both built-ins AND MCP tool
2326
- // names; MCP names stay here so bypassPermissions has
2327
- // explicit grants for every exposed external server.
2321
+ // names. With permissionMode=dontAsk, anything not
2322
+ // present here is denied instead of prompting.
2328
2323
  // - `disallowedTools` → blocklist, takes precedence.
2329
2324
  tools: toolsDisabledForCall ? [] : allowedTools.filter(t => !t.startsWith('mcp__')),
2330
2325
  allowedTools: toolsDisabledForCall ? [] : allowedTools,
@@ -2831,8 +2826,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2831
2826
  options: {
2832
2827
  systemPrompt: 'You are a silent memory extraction agent. Save facts to the vault and exit.',
2833
2828
  model: AUTO_MEMORY_MODEL,
2834
- permissionMode: 'bypassPermissions',
2835
- allowDangerouslySkipPermissions: true,
2829
+ permissionMode: 'dontAsk',
2836
2830
  // MCP tool names live in allowedTools, not tools. See note at
2837
2831
  // buildOptions — `tools` is for built-ins only.
2838
2832
  tools: [],
@@ -2862,6 +2856,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2862
2856
  CLEMENTINE_TEAM_AGENT: profile?.slug ?? 'clementine',
2863
2857
  // Auto-memory extractor runs autonomously.
2864
2858
  CLEMENTINE_INTERACTION_SOURCE: 'autonomous',
2859
+ CLEMENTINE_TOOL_ALLOWLIST: 'memory_write,memory_search,note_create,task_add,note_take,memory_read,user_model',
2865
2860
  },
2866
2861
  },
2867
2862
  },
@@ -3082,8 +3077,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3082
3077
  options: {
3083
3078
  systemPrompt: 'You are a task output verifier. Assess the output quality.',
3084
3079
  model: MODELS.haiku,
3085
- permissionMode: 'bypassPermissions',
3086
- allowDangerouslySkipPermissions: true,
3080
+ permissionMode: 'dontAsk',
3081
+ tools: [],
3082
+ allowedTools: [],
3087
3083
  maxTurns: 1,
3088
3084
  cwd: BASE_DIR,
3089
3085
  env: SAFE_ENV,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared Claude Agent SDK execution policy.
3
+ *
4
+ * The SDK permission model has two separate layers:
5
+ * - `tools` controls which built-in tools are visible.
6
+ * - `allowedTools` pre-approves built-ins and MCP tools.
7
+ *
8
+ * Critically, `allowedTools` does not constrain `bypassPermissions`.
9
+ * Headless Clementine runs should therefore default to `dontAsk` and
10
+ * explicitly allow the built-ins/MCP servers they need.
11
+ */
12
+ export type ExecutionPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk' | 'auto';
13
+ export declare const SDK_BUILTIN_TOOLS: Set<string>;
14
+ export interface BuildExecutionPolicyOptions {
15
+ /** Caller-provided allowedTools. Undefined means use defaults; [] means deny all. */
16
+ requestedTools?: string[];
17
+ /** Built-ins used when requestedTools is undefined. */
18
+ defaultBuiltins: readonly string[];
19
+ /** Names of MCP servers mounted for this query. */
20
+ mcpServerNames: string[];
21
+ /** Clementine's own MCP server name, e.g. "clementine-tools". */
22
+ clementineServerName: string;
23
+ /** Optional override. Defaults to dontAsk for headless auto-execution. */
24
+ permissionMode?: ExecutionPermissionMode;
25
+ }
26
+ export interface ExecutionToolPolicy {
27
+ permissionMode: ExecutionPermissionMode;
28
+ allowDangerouslySkipPermissions?: true;
29
+ /** SDK `tools` option: visible built-ins only. */
30
+ builtinTools: string[];
31
+ /** SDK `allowedTools` option: built-ins + MCP permissions. */
32
+ allowedTools: string[];
33
+ /** Value for CLEMENTINE_TOOL_ALLOWLIST inside the Clementine MCP subprocess. */
34
+ clementineToolAllowlist: string;
35
+ /** Env vars for the SDK subprocess. */
36
+ env: Record<string, string>;
37
+ }
38
+ /** Best-effort registry of first-party Clementine MCP tool names. */
39
+ export declare function listClementineMcpToolNames(): Set<string>;
40
+ export declare function buildExecutionToolPolicy(opts: BuildExecutionPolicyOptions): ExecutionToolPolicy;
41
+ //# sourceMappingURL=execution-policy.d.ts.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Shared Claude Agent SDK execution policy.
3
+ *
4
+ * The SDK permission model has two separate layers:
5
+ * - `tools` controls which built-in tools are visible.
6
+ * - `allowedTools` pre-approves built-ins and MCP tools.
7
+ *
8
+ * Critically, `allowedTools` does not constrain `bypassPermissions`.
9
+ * Headless Clementine runs should therefore default to `dontAsk` and
10
+ * explicitly allow the built-ins/MCP servers they need.
11
+ */
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { BASE_DIR, PKG_DIR } from '../config.js';
15
+ export const SDK_BUILTIN_TOOLS = new Set([
16
+ 'Agent',
17
+ 'AskUserQuestion',
18
+ 'Bash',
19
+ 'Edit',
20
+ 'Glob',
21
+ 'Grep',
22
+ 'Monitor',
23
+ 'Read',
24
+ 'TodoWrite',
25
+ 'WebFetch',
26
+ 'WebSearch',
27
+ 'Write',
28
+ ]);
29
+ let _cachedClementineToolNames = null;
30
+ function discoverToolNamesInDir(dir, out) {
31
+ if (!existsSync(dir))
32
+ return;
33
+ let files = [];
34
+ try {
35
+ files = readdirSync(dir).filter((f) => /\.(ts|js|mjs)$/.test(f));
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ const re = /\.tool\(\s*['"`]([A-Za-z0-9_.:-]+)['"`]/g;
41
+ for (const file of files) {
42
+ let text = '';
43
+ try {
44
+ text = readFileSync(path.join(dir, file), 'utf-8');
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ re.lastIndex = 0;
50
+ let m;
51
+ while ((m = re.exec(text)) !== null)
52
+ out.add(m[1]);
53
+ }
54
+ }
55
+ function discoverUserToolNames(dir, out) {
56
+ if (!existsSync(dir))
57
+ return;
58
+ let files = [];
59
+ try {
60
+ files = readdirSync(dir).filter((f) => /\.(sh|py)$/.test(f));
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const file of files) {
66
+ const name = file.replace(/\.(sh|py)$/, '').replace(/[^a-z0-9_]/gi, '_');
67
+ if (name)
68
+ out.add(name);
69
+ }
70
+ }
71
+ /** Best-effort registry of first-party Clementine MCP tool names. */
72
+ export function listClementineMcpToolNames() {
73
+ if (_cachedClementineToolNames)
74
+ return _cachedClementineToolNames;
75
+ const out = new Set();
76
+ discoverToolNamesInDir(path.join(PKG_DIR, 'src', 'tools'), out);
77
+ discoverToolNamesInDir(path.join(PKG_DIR, 'dist', 'tools'), out);
78
+ discoverUserToolNames(path.join(BASE_DIR, 'tools'), out);
79
+ _cachedClementineToolNames = out;
80
+ return out;
81
+ }
82
+ function mcpWildcard(serverName) {
83
+ return `mcp__${serverName}__*`;
84
+ }
85
+ function clementineMcpTool(serverName, toolName) {
86
+ return `mcp__${serverName}__${toolName}`;
87
+ }
88
+ function commandExistsOnPath(command) {
89
+ if (!/^[A-Za-z0-9._-]+$/.test(command))
90
+ return false;
91
+ const pathValue = process.env.PATH ?? '';
92
+ for (const dir of pathValue.split(path.delimiter)) {
93
+ if (!dir)
94
+ continue;
95
+ if (existsSync(path.join(dir, command)))
96
+ return true;
97
+ }
98
+ return false;
99
+ }
100
+ function normalizeRequestedToolName(raw, clementineServerName, clementineTools) {
101
+ const tool = String(raw ?? '').trim();
102
+ if (!tool)
103
+ return { sdkTool: '' };
104
+ if (/^Bash\(.+\)$/.test(tool)) {
105
+ return { sdkTool: tool, builtinTool: 'Bash', scopedBash: tool !== 'Bash(*)' };
106
+ }
107
+ if (tool.startsWith('mcp__')) {
108
+ const prefix = `mcp__${clementineServerName}__`;
109
+ if (tool === mcpWildcard(clementineServerName)) {
110
+ return { sdkTool: tool, clementineTool: '*' };
111
+ }
112
+ if (tool.startsWith(prefix)) {
113
+ const name = tool.slice(prefix.length);
114
+ if (name && name !== '*')
115
+ return { sdkTool: tool, clementineTool: name };
116
+ }
117
+ return { sdkTool: tool };
118
+ }
119
+ if (SDK_BUILTIN_TOOLS.has(tool))
120
+ return { sdkTool: tool, builtinTool: tool };
121
+ if (clementineTools.has(tool)) {
122
+ return { sdkTool: clementineMcpTool(clementineServerName, tool), clementineTool: tool };
123
+ }
124
+ if (commandExistsOnPath(tool)) {
125
+ return { sdkTool: `Bash(${tool}:*)`, builtinTool: 'Bash', scopedBash: true };
126
+ }
127
+ // Unknown names are preserved for forward compatibility with SDK/plugins.
128
+ return { sdkTool: tool };
129
+ }
130
+ function applyNormalizedTool(normalized, builtins, allowed, clementineAllow, opts = {}) {
131
+ const { sdkTool, clementineTool, builtinTool } = normalized;
132
+ if (!sdkTool)
133
+ return;
134
+ if (builtinTool && SDK_BUILTIN_TOOLS.has(builtinTool))
135
+ builtins.add(builtinTool);
136
+ if (!(opts.skipBroadBash && sdkTool === 'Bash'))
137
+ allowed.add(sdkTool);
138
+ if (clementineTool === '*') {
139
+ clementineAllow.clear();
140
+ clementineAllow.add('*');
141
+ }
142
+ else if (clementineTool && !clementineAllow.has('*')) {
143
+ clementineAllow.add(clementineTool);
144
+ }
145
+ }
146
+ function applyMemoryCompanionTools(normalizedTools, clementineTools, clementineServerName, builtins, allowed, clementineAllow) {
147
+ if (clementineAllow.has('*'))
148
+ return;
149
+ const requestedClementine = new Set(normalizedTools
150
+ .map(tool => tool.clementineTool)
151
+ .filter((tool) => Boolean(tool) && tool !== '*'));
152
+ if (!requestedClementine.has('memory_write'))
153
+ return;
154
+ // Existing agent profiles predate the brain ingestion tools. If a profile
155
+ // already grants durable memory writes, keep the newer ingestion write path
156
+ // available without widening to the full Clementine MCP surface.
157
+ const companionTools = ['brain_save'];
158
+ const canReadLocalData = normalizedTools.some(tool => tool.builtinTool === 'Read' || tool.builtinTool === 'Bash');
159
+ if (canReadLocalData)
160
+ companionTools.push('brain_ingest_folder');
161
+ for (const toolName of companionTools) {
162
+ if (!clementineTools.has(toolName) || requestedClementine.has(toolName))
163
+ continue;
164
+ const normalized = normalizeRequestedToolName(toolName, clementineServerName, clementineTools);
165
+ applyNormalizedTool(normalized, builtins, allowed, clementineAllow);
166
+ }
167
+ }
168
+ export function buildExecutionToolPolicy(opts) {
169
+ const permissionMode = opts.permissionMode ?? 'dontAsk';
170
+ const clementineTools = listClementineMcpToolNames();
171
+ const allowed = new Set();
172
+ const builtins = new Set();
173
+ const clementineAllow = new Set();
174
+ if (opts.requestedTools === undefined) {
175
+ for (const raw of opts.defaultBuiltins) {
176
+ const normalized = normalizeRequestedToolName(raw, opts.clementineServerName, clementineTools);
177
+ applyNormalizedTool(normalized, builtins, allowed, clementineAllow);
178
+ }
179
+ for (const server of opts.mcpServerNames) {
180
+ if (!server)
181
+ continue;
182
+ allowed.add(mcpWildcard(server));
183
+ if (server === opts.clementineServerName)
184
+ clementineAllow.add('*');
185
+ }
186
+ }
187
+ else {
188
+ const normalizedTools = opts.requestedTools.map((raw) => normalizeRequestedToolName(raw, opts.clementineServerName, clementineTools));
189
+ const hasScopedBash = normalizedTools.some((tool) => tool.scopedBash);
190
+ for (const normalized of normalizedTools) {
191
+ applyNormalizedTool(normalized, builtins, allowed, clementineAllow, {
192
+ // In explicit skill/tool scopes, `Bash` plus `some-cli` usually means
193
+ // "make Bash visible and approve that CLI", not "approve all shell".
194
+ skipBroadBash: hasScopedBash,
195
+ });
196
+ }
197
+ if (opts.requestedTools.length > 0) {
198
+ applyMemoryCompanionTools(normalizedTools, clementineTools, opts.clementineServerName, builtins, allowed, clementineAllow);
199
+ }
200
+ }
201
+ const clementineToolAllowlist = clementineAllow.has('*')
202
+ ? '*'
203
+ : [...clementineAllow].sort().join(',');
204
+ return {
205
+ permissionMode,
206
+ ...(permissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {}),
207
+ builtinTools: [...builtins].sort(),
208
+ allowedTools: [...allowed].sort(),
209
+ clementineToolAllowlist,
210
+ env: {
211
+ // Tool search is default in current SDKs, but setting this makes the
212
+ // intent explicit and protects large Composio/MCP catalogs.
213
+ ENABLE_TOOL_SEARCH: process.env.ENABLE_TOOL_SEARCH || 'auto:5',
214
+ },
215
+ };
216
+ }
217
+ //# sourceMappingURL=execution-policy.js.map
@@ -114,6 +114,7 @@ export declare class FailureFixConsumer {
114
114
  private ticking;
115
115
  constructor(dispatcher: SelfImproveDispatcher, opts?: SelfImproveLoopOptions);
116
116
  start(): void;
117
+ private startFallbackTimer;
117
118
  stop(): void;
118
119
  /** Coalesce a burst of fs.watch events (multiple triggers landing in
119
120
  * quick succession) into a single tick. */
@@ -43,6 +43,7 @@ const logger = pino({ name: 'clementine.failure-fix-consumer' });
43
43
  const FALLBACK_TICK_MS = 60 * 60 * 1000;
44
44
  /** Coalesce a burst of fs.watch events into a single tick. */
45
45
  const WATCH_DEBOUNCE_MS = 2000;
46
+ const WATCH_FAILURE_FALLBACK_MS = 1000;
46
47
  const TRIGGERS_DIR = path.join(BASE_DIR, 'self-improve', 'triggers');
47
48
  const PENDING_CHANGES_DIR = path.join(BASE_DIR, 'self-improve', 'pending-changes');
48
49
  const CRON_PATH = path.join(SYSTEM_DIR, 'CRON.md');
@@ -274,16 +275,35 @@ export class FailureFixConsumer {
274
275
  return;
275
276
  this.scheduleDebouncedTick();
276
277
  });
278
+ this.watcher.on('error', (err) => {
279
+ logger.warn({ err, dir: this.triggersDir }, 'Triggers dir watcher failed — falling back to polling');
280
+ try {
281
+ this.watcher?.close();
282
+ }
283
+ catch { /* ignore */ }
284
+ this.watcher = null;
285
+ this.startFallbackTimer(Math.min(this.tickMs, WATCH_FAILURE_FALLBACK_MS));
286
+ this.scheduleDebouncedTick();
287
+ });
277
288
  }
278
289
  catch (err) {
279
290
  logger.warn({ err, dir: this.triggersDir }, 'Failed to watch triggers dir — falling back to polling only');
291
+ this.startFallbackTimer(Math.min(this.tickMs, WATCH_FAILURE_FALLBACK_MS));
280
292
  }
281
293
  }
282
294
  // Slow fallback safety net — covers fs.watch event drops + boot-with-backlog.
295
+ if (!this.timer)
296
+ this.startFallbackTimer(this.tickMs);
297
+ logger.info({ fallbackTickMs: this.tickMs, watchEnabled: this.watchEnabled && this.watcher !== null }, 'Self-improve loop started');
298
+ }
299
+ startFallbackTimer(intervalMs) {
300
+ if (!this.running || !Number.isFinite(intervalMs) || intervalMs <= 0)
301
+ return;
302
+ if (this.timer)
303
+ clearInterval(this.timer);
283
304
  this.timer = setInterval(() => {
284
305
  this.tick().catch((err) => logger.error({ err }, 'Self-improve fallback tick failed'));
285
- }, this.tickMs);
286
- logger.info({ fallbackTickMs: this.tickMs, watchEnabled: this.watchEnabled && this.watcher !== null }, 'Self-improve loop started');
306
+ }, intervalMs);
287
307
  }
288
308
  stop() {
289
309
  if (!this.running)
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import { AsyncLocalStorage } from 'node:async_hooks';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { OWNER_NAME, BASE_DIR, TIMEZONE } from '../config.js';
16
+ import { formatTime24InTimeZone } from '../lib/time.js';
16
17
  // ── Shared state ───────────────────────────────────────────────────────
17
18
  let heartbeatActive = false;
18
19
  let heartbeatTier2Allowed = false;
@@ -189,7 +190,7 @@ export function clearActiveQueryContext() {
189
190
  // not by us. setInteractionSource resets it in the relevant transitions.
190
191
  }
191
192
  export function logToolUse(toolName, toolInput) {
192
- const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
193
+ const timestamp = formatTime24InTimeZone(new Date(), TIMEZONE);
193
194
  const summary = summarizeToolCall(toolName, toolInput);
194
195
  const entry = `- \`${timestamp}\` **${toolName}** — ${summary}`;
195
196
  auditLog.push(entry);
@@ -11,10 +11,11 @@
11
11
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import pino from 'pino';
14
- import { GOALS_DIR, BASE_DIR, MEMORY_DB_PATH, VAULT_DIR } from '../config.js';
14
+ import { GOALS_DIR, BASE_DIR, MEMORY_DB_PATH, VAULT_DIR, TIMEZONE } from '../config.js';
15
15
  import { listAllGoals } from '../tools/shared.js';
16
16
  import { computeBrokenJobs } from '../gateway/failure-monitor.js';
17
17
  import { MemoryStore } from '../memory/store.js';
18
+ import { dateKeyInTimeZone } from '../lib/time.js';
18
19
  const logger = pino({ name: 'clementine.insight-engine' });
19
20
  const BASE_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
20
21
  const MAX_DAILY_INSIGHTS = 3;
@@ -23,7 +24,7 @@ const UNACKED_THRESHOLD = 3; // double cooldown after this many ignored
23
24
  * Check if it's too soon to send another proactive message.
24
25
  */
25
26
  export function canSendInsight(state) {
26
- const today = new Date().toISOString().slice(0, 10);
27
+ const today = dateKeyInTimeZone(new Date(), TIMEZONE);
27
28
  // Reset daily count on new day
28
29
  if (state.currentDate !== today) {
29
30
  state.sentToday = [];
@@ -453,8 +453,7 @@ export async function probeAvailableTools(force = false) {
453
453
  options: normalizeClaudeSdkOptionsForOneMillionContext({
454
454
  systemPrompt: 'Reply ok.',
455
455
  model: 'claude-haiku-4-5',
456
- permissionMode: 'bypassPermissions',
457
- allowDangerouslySkipPermissions: true,
456
+ permissionMode: 'dontAsk',
458
457
  mcpServers: externalMcpServers,
459
458
  }),
460
459
  });
@@ -24,6 +24,13 @@ export class PromptCache {
24
24
  return;
25
25
  try {
26
26
  const w = watch(filePath, () => { this.cache.delete(filePath); });
27
+ w.on('error', () => {
28
+ try {
29
+ w.close();
30
+ }
31
+ catch { /* ignore */ }
32
+ this.watchers.delete(filePath);
33
+ });
27
34
  w.unref(); // Don't keep daemon alive
28
35
  this.watchers.set(filePath, w);
29
36
  }
@@ -20,6 +20,7 @@ function rootDir(baseDir) {
20
20
  // ── State ────────────────────────────────────────────────────────────
21
21
  let cached = [];
22
22
  let watcherInstalled = false;
23
+ let watcher = null;
23
24
  let watchDebounce = null;
24
25
  // ── Parse one file ───────────────────────────────────────────────────
25
26
  function parseOverride(filePath, scope, scopeKey) {
@@ -132,7 +133,7 @@ export function watchPromptOverrides(opts) {
132
133
  mkdirSync(p, { recursive: true });
133
134
  }
134
135
  try {
135
- fsWatch(root, { recursive: true }, () => {
136
+ watcher = fsWatch(root, { recursive: true }, () => {
136
137
  if (watchDebounce)
137
138
  clearTimeout(watchDebounce);
138
139
  watchDebounce = setTimeout(() => {
@@ -144,6 +145,16 @@ export function watchPromptOverrides(opts) {
144
145
  }
145
146
  }, 250);
146
147
  });
148
+ watcher.on('error', (err) => {
149
+ logger.warn({ err, root }, 'Prompt-overrides watcher failed — hot reload disabled');
150
+ try {
151
+ watcher?.close();
152
+ }
153
+ catch { /* ignore */ }
154
+ watcher = null;
155
+ watcherInstalled = false;
156
+ });
157
+ watcher.unref();
147
158
  watcherInstalled = true;
148
159
  logger.debug({ root }, 'Watching prompt-overrides for hot reload');
149
160
  }
@@ -155,6 +166,13 @@ export function watchPromptOverrides(opts) {
155
166
  export function _resetLoaderState() {
156
167
  cached = [];
157
168
  watcherInstalled = false;
169
+ if (watcher) {
170
+ try {
171
+ watcher.close();
172
+ }
173
+ catch { /* ignore */ }
174
+ watcher = null;
175
+ }
158
176
  if (watchDebounce) {
159
177
  clearTimeout(watchDebounce);
160
178
  watchDebounce = null;
@@ -210,8 +210,7 @@ export interface RunAgentCronResult extends RunAgentResult {
210
210
  /** Pinned skills that didn't resolve (bad slug / suppressed). Empty array
211
211
  * is fine; only populated when the trick had pins that failed to load. */
212
212
  skillsMissing: string[];
213
- /** Effective tool allowlist passed to runAgent (post-intersection). Undefined
214
- * means the trick didn't override — runAgent fell through to profile/default. */
213
+ /** Effective SDK allowedTools after defaults/MCP mapping were applied. */
215
214
  allowedToolsApplied?: string[];
216
215
  /** MCP servers live for this run after profile + trick intersection. */
217
216
  mcpServersApplied: string[];
@@ -809,7 +809,7 @@ export async function runAgentCron(opts) {
809
809
  externalConnected,
810
810
  skillsApplied: plan.skillsApplied,
811
811
  skillsMissing: plan.skillsMissing,
812
- allowedToolsApplied: effectiveAllowedTools,
812
+ allowedToolsApplied: result.allowedToolsApplied ?? effectiveAllowedTools,
813
813
  mcpServersApplied,
814
814
  };
815
815
  }