clementine-agent 1.18.139 → 1.18.141

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.
@@ -20,10 +20,4 @@ export declare function assessActionResponse(input: {
20
20
  backgroundStarted?: boolean;
21
21
  delegated?: boolean;
22
22
  }): ActionResponseAssessment;
23
- export declare function buildActionEnforcementPrompt(input: {
24
- userText: string;
25
- previousResponse: string;
26
- reason: string;
27
- }): string;
28
- export declare function fallbackUnverifiedActionResponse(reason: string): string;
29
23
  //# sourceMappingURL=action-enforcer.d.ts.map
@@ -92,29 +92,4 @@ export function assessActionResponse(input) {
92
92
  }
93
93
  return { violation: false, reason: 'no unsupported action claim detected' };
94
94
  }
95
- export function buildActionEnforcementPrompt(input) {
96
- return [
97
- '[SYSTEM ACTION ENFORCEMENT]',
98
- 'Your previous response was not allowed because it implied action without verified tool activity.',
99
- `Reason: ${input.reason}`,
100
- '',
101
- 'Original user request:',
102
- input.userText.slice(0, 1200),
103
- '',
104
- 'Previous response:',
105
- input.previousResponse.slice(0, 1200),
106
- '',
107
- 'Now correct this in the same turn:',
108
- '- If the action is possible, use the appropriate tool now.',
109
- '- If the action is blocked, say exactly what is blocking it.',
110
- '- Do not say "done", "sent", "queued", "checked", or similar unless a tool call actually verifies it.',
111
- ].join('\n');
112
- }
113
- export function fallbackUnverifiedActionResponse(reason) {
114
- return [
115
- "I didn't complete that yet.",
116
- `I caught an action-verification issue: ${reason}.`,
117
- "I won't call it done without a tool confirmation. Please resend the request and I'll retry from a clean turn.",
118
- ].join(' ');
119
- }
120
95
  //# sourceMappingURL=action-enforcer.js.map
@@ -5,7 +5,7 @@
5
5
  * cron job execution parameters: turn limits, models, timeouts, prompt
6
6
  * enrichment, escalation, and circuit-breaking.
7
7
  */
8
- import { CronRunLog } from '../gateway/heartbeat.js';
8
+ import { CronRunLog } from '../gateway/cron-scheduler.js';
9
9
  import type { CronJobDefinition, ExecutionAdvice } from '../types.js';
10
10
  export declare const TIER_MAX_TURNS: Record<number, number>;
11
11
  export declare const DEFAULT_TIMEOUT_MS = 600000;
@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
11
  import { ADVISOR_RULES_LOADER, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH } from '../config.js';
12
- import { CronRunLog } from '../gateway/heartbeat.js';
12
+ import { CronRunLog } from '../gateway/cron-scheduler.js';
13
13
  import { evolvePrompt } from './prompt-evolver.js';
14
14
  import { loadAdvisorRules, getLoadedRules, watchUserRulesDir, } from './advisor-rules/loader.js';
15
15
  import { buildRuleContext } from './advisor-rules/context.js';
@@ -688,7 +688,7 @@ export class SelfImproveLoop {
688
688
  .filter(f => f.rating === 'negative');
689
689
  store.close();
690
690
  // Gather cron errors from run logs
691
- const { CronRunLog } = await import('../gateway/heartbeat.js');
691
+ const { CronRunLog } = await import('../gateway/cron-scheduler.js');
692
692
  const runLog = new CronRunLog();
693
693
  const cronErrors = [];
694
694
  let cronTotal = 0;
@@ -15,7 +15,7 @@
15
15
  import { EmbedBuilder } from 'discord.js';
16
16
  import type { AgentProfile } from '../types.js';
17
17
  import type { Gateway } from '../gateway/router.js';
18
- import type { CronScheduler } from '../gateway/heartbeat.js';
18
+ import type { CronScheduler } from '../gateway/cron-scheduler.js';
19
19
  export interface AgentBotConfig {
20
20
  slug: string;
21
21
  token: string;
@@ -7,7 +7,7 @@
7
7
  * to all visible).
8
8
  */
9
9
  import type { Gateway } from '../gateway/router.js';
10
- import type { CronScheduler } from '../gateway/heartbeat.js';
10
+ import type { CronScheduler } from '../gateway/cron-scheduler.js';
11
11
  import { type AgentBotStatus } from './discord-agent-bot.js';
12
12
  export interface BotManagerConfig {
13
13
  gateway: Gateway;
@@ -5,7 +5,8 @@
5
5
  * Features: streaming responses, message chunking, model switching,
6
6
  * heartbeat/cron commands, slash commands, and autonomous notifications.
7
7
  */
8
- import type { HeartbeatScheduler, CronScheduler } from '../gateway/heartbeat.js';
8
+ import type { HeartbeatScheduler } from '../gateway/heartbeat-scheduler.js';
9
+ import type { CronScheduler } from '../gateway/cron-scheduler.js';
9
10
  import type { NotificationDispatcher } from '../gateway/notifications.js';
10
11
  import type { Gateway } from '../gateway/router.js';
11
12
  export declare function startDiscord(gateway: Gateway, heartbeat: HeartbeatScheduler, cronScheduler: CronScheduler, dispatcher: NotificationDispatcher, botManager?: import('./discord-bot-manager.js').BotManager): Promise<void>;
package/dist/cli/cron.js CHANGED
@@ -11,7 +11,8 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import cron from 'node-cron';
13
13
  import matter from 'gray-matter';
14
- import { parseCronJobs, HeartbeatScheduler, CronRunLog, classifyError, } from '../gateway/heartbeat.js';
14
+ import { HeartbeatScheduler } from '../gateway/heartbeat-scheduler.js';
15
+ import { parseCronJobs, CronRunLog, classifyError } from '../gateway/cron-scheduler.js';
15
16
  const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
16
17
  const LAST_RUN_FILE = path.join(BASE_DIR, '.cron_last_run.json');
17
18
  /** Exponential backoff schedule in ms: 30s, 1m, 5m, 15m, 60m */
@@ -1485,17 +1485,6 @@ async function getBuildUsageForOperations(hoursInput = 168, limitInput = 50) {
1485
1485
  db.close();
1486
1486
  }
1487
1487
  }
1488
- function getTimers() {
1489
- const timersFile = path.join(BASE_DIR, '.timers.json');
1490
- if (!existsSync(timersFile))
1491
- return [];
1492
- try {
1493
- return JSON.parse(readFileSync(timersFile, 'utf-8'));
1494
- }
1495
- catch {
1496
- return [];
1497
- }
1498
- }
1499
1488
  function getHeartbeat() {
1500
1489
  const hbFile = path.join(BASE_DIR, '.heartbeat_state.json');
1501
1490
  if (!existsSync(hbFile))
@@ -2128,8 +2117,6 @@ export async function cmdDashboard(opts) {
2128
2117
  }
2129
2118
  persistSessions();
2130
2119
  }, 10 * 60 * 1000);
2131
- // Quick ping — bypasses all middleware, tests /api path routing
2132
- app.get('/api/ping', (_req, res) => { res.json({ pong: true }); });
2133
2120
  // ── Background project scanner ───────────────────────────────────────
2134
2121
  // scanProjects() does heavy synchronous filesystem I/O (statSync across
2135
2122
  // hundreds of Desktop/Documents entries) which permanently blocks the
@@ -2661,9 +2648,6 @@ export async function cmdDashboard(opts) {
2661
2648
  app.get('/api/cron', (_req, res) => {
2662
2649
  res.json(getCronJobs());
2663
2650
  });
2664
- app.get('/api/timers', (_req, res) => {
2665
- res.json(getTimers());
2666
- });
2667
2651
  app.get('/api/heartbeat', (_req, res) => {
2668
2652
  res.json(getHeartbeat());
2669
2653
  });
@@ -2846,23 +2830,6 @@ export async function cmdDashboard(opts) {
2846
2830
  res.status(500).json({ error: String(err).slice(0, 200) });
2847
2831
  }
2848
2832
  });
2849
- app.get('/api/autonomy', async (req, res) => {
2850
- try {
2851
- const { getStore } = await import('../tools/shared.js');
2852
- const store = await getStore();
2853
- const logs = store.queryAutonomyLog({
2854
- component: typeof req.query.component === 'string' ? req.query.component : undefined,
2855
- event: typeof req.query.event === 'string' ? req.query.event : undefined,
2856
- agentSlug: typeof req.query.agentSlug === 'string' ? req.query.agentSlug : undefined,
2857
- limit: typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 100,
2858
- since: typeof req.query.since === 'string' ? req.query.since : undefined,
2859
- });
2860
- res.json({ logs });
2861
- }
2862
- catch (err) {
2863
- res.status(500).json({ error: String(err).slice(0, 200) });
2864
- }
2865
- });
2866
2833
  app.get('/api/heartbeat/agent/:slug', (req, res) => {
2867
2834
  const slug = req.params.slug;
2868
2835
  const state = getHeartbeat();
@@ -6964,28 +6931,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6964
6931
  res.status(500).json({ error: String(err) });
6965
6932
  }
6966
6933
  });
6967
- app.post('/api/timers/:id/cancel', (req, res) => {
6968
- const timerId = req.params.id;
6969
- const timersFile = path.join(BASE_DIR, '.timers.json');
6970
- try {
6971
- if (!existsSync(timersFile)) {
6972
- res.status(404).json({ error: 'No timers file' });
6973
- return;
6974
- }
6975
- const timers = JSON.parse(readFileSync(timersFile, 'utf-8'));
6976
- const idx = timers.findIndex((t) => String(t.id) === timerId);
6977
- if (idx === -1) {
6978
- res.status(404).json({ error: `Timer "${timerId}" not found` });
6979
- return;
6980
- }
6981
- timers.splice(idx, 1);
6982
- writeFileSync(timersFile, JSON.stringify(timers, null, 2));
6983
- res.json({ ok: true, message: `Cancelled timer: ${timerId}` });
6984
- }
6985
- catch (err) {
6986
- res.status(500).json({ error: String(err) });
6987
- }
6988
- });
6989
6934
  // ── Projects ─────────────────────────────────────────────────
6990
6935
  // Returns the project list from $CLEMENTINE_HOME/projects.json.
6991
6936
  // Used by the trick builder's Quick Add Step picker so users can
@@ -12854,26 +12799,6 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
12854
12799
  });
12855
12800
  // ── Advisor Decision Analytics API ─────────────────────────────
12856
12801
  const ADVISOR_LOG = path.join(BASE_DIR, 'cron', 'advisor-decisions.jsonl');
12857
- app.get('/api/advisor/decisions', (req, res) => {
12858
- const limit = parseInt(String(req.query.limit ?? '100'), 10);
12859
- if (!existsSync(ADVISOR_LOG)) {
12860
- res.json({ decisions: [] });
12861
- return;
12862
- }
12863
- try {
12864
- const lines = readFileSync(ADVISOR_LOG, 'utf-8').trim().split('\n').filter(Boolean);
12865
- const decisions = lines.slice(-limit).map(l => { try {
12866
- return JSON.parse(l);
12867
- }
12868
- catch {
12869
- return null;
12870
- } }).filter(Boolean).reverse();
12871
- res.json({ decisions });
12872
- }
12873
- catch {
12874
- res.json({ decisions: [] });
12875
- }
12876
- });
12877
12802
  app.get('/api/advisor/analytics', (_req, res) => {
12878
12803
  // Build analytics from run logs + advisor decisions
12879
12804
  const runsDir = path.join(BASE_DIR, 'cron', 'runs');
@@ -19205,7 +19130,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19205
19130
  <button class="active" data-icon="layoutDashboard" onclick="switchTab('intelligence','overview')"><span class="icon-slot"></span> Overview</button>
19206
19131
  <button data-icon="database" onclick="switchTab('intelligence','search')"><span class="icon-slot"></span> Chunks</button>
19207
19132
  <button data-icon="upload" onclick="switchTab('intelligence','seed')"><span class="icon-slot"></span> Seed</button>
19208
- <button data-icon="repeat" onclick="switchTab('intelligence','sources')"><span class="icon-slot"></span> Automate</button>
19133
+ <button data-icon="repeat" onclick="switchTab('intelligence','sources')"><span class="icon-slot"></span> Feeds</button>
19209
19134
  <button data-icon="listChecks" onclick="switchTab('intelligence','runs')"><span class="icon-slot"></span> Runs</button>
19210
19135
  <button data-icon="sparkles" onclick="switchTab('intelligence','graph')"><span class="icon-slot"></span> Knowledge</button>
19211
19136
  <button data-icon="fileText" onclick="switchTab('intelligence','files')"><span class="icon-slot"></span> Memory</button>
@@ -19225,7 +19150,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19225
19150
  </div>
19226
19151
  <div style="display:flex;gap:8px;flex-wrap:wrap">
19227
19152
  <button class="btn-primary btn-sm" onclick="switchTab('intelligence','seed')"><span class="icon-slot" data-icon="upload"></span> Seed local data</button>
19228
- <button class="btn-sm" onclick="switchTab('intelligence','sources')"><span class="icon-slot" data-icon="repeat"></span> Add scheduled feed</button>
19153
+ <button class="btn-sm" onclick="switchTab('intelligence','sources')"><span class="icon-slot" data-icon="repeat"></span> Add a feed</button>
19229
19154
  <button class="btn-sm" onclick="switchTab('intelligence','health')"><span class="icon-slot" data-icon="activity"></span> Verify health</button>
19230
19155
  </div>
19231
19156
  </div>
@@ -19486,9 +19411,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19486
19411
  </div>
19487
19412
  </div>
19488
19413
 
19489
- <!-- Sources -->
19414
+ <!-- Feeds (formerly "Sources" tab — renamed 1.18.141 to match user mental model) -->
19490
19415
  <div class="tab-pane" id="tab-intelligence-sources">
19491
19416
 
19417
+ <!-- Section header — explains what Feeds are vs. one-shot Seed uploads -->
19418
+ <div style="margin-bottom:14px">
19419
+ <div style="font-size:18px;font-weight:600;margin-bottom:4px">Feeds</div>
19420
+ <div style="color:var(--muted);font-size:13px;line-height:1.5">
19421
+ Auto-watched external sources Clementine polls on a schedule — Google Drive folders, connected apps via Composio, Claude Desktop connectors, REST endpoints. Each feed fetches records, dedupes against existing memory, and writes distilled notes to the brain.<br>
19422
+ <span style="opacity:0.85">For one-shot uploads (drop a file, choose a folder), use the <a href="#" onclick="switchTab('intelligence','seed');return false" style="text-decoration:underline">Seed</a> tab instead.</span>
19423
+ </div>
19424
+ </div>
19425
+
19492
19426
  <!-- ═══ Auto-seed feeds (connected tools → cron → brain) ═══ -->
19493
19427
  <div class="card" style="padding:16px;margin-bottom:16px">
19494
19428
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
@@ -19515,11 +19449,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19515
19449
  </div>
19516
19450
  </div>
19517
19451
 
19518
- <div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
19519
- <button class="btn-primary" onclick="brainShowPollForm()">+ Scheduled REST poll</button>
19520
- <button class="btn-primary" onclick="brainShowWebhookForm()">+ Inbound webhook</button>
19521
- <button class="btn" onclick="brainShowCredsForm()">🔑 Credentials</button>
19522
- </div>
19452
+ <!-- Legacy direct-integration paths (REST polls + webhooks). De-emphasized 1.18.141 -->
19453
+ <!-- so the modern "Add feed" flow above stays the primary surface. The forms below -->
19454
+ <!-- are still wired (write to /api/brain/sources) for users who need raw HTTP plumbing. -->
19455
+ <details style="margin-bottom:16px">
19456
+ <summary style="color:var(--muted);font-size:13px;cursor:pointer;padding:8px 0">Advanced — manual integrations (REST polls, webhooks, credentials)</summary>
19457
+ <div style="display:flex;gap:8px;margin-top:8px;margin-bottom:8px;flex-wrap:wrap">
19458
+ <button class="btn-primary" onclick="brainShowPollForm()">+ Scheduled REST poll</button>
19459
+ <button class="btn-primary" onclick="brainShowWebhookForm()">+ Inbound webhook</button>
19460
+ <button class="btn" onclick="brainShowCredsForm()">🔑 Credentials</button>
19461
+ </div>
19462
+ </details>
19523
19463
 
19524
19464
  <!-- Webhook form -->
19525
19465
  <div id="brain-webhook-form" class="card" style="display:none;padding:16px;margin-bottom:16px">
package/dist/cli/index.js CHANGED
@@ -1417,155 +1417,7 @@ async function cmdConfigDoctor(opts) {
1417
1417
  console.log();
1418
1418
  process.exit(report.exitCode);
1419
1419
  }
1420
- // ── Config migrate-to-keychain ───────────────────────────────────────
1421
- async function cmdConfigMigrateToKeychain(opts) {
1422
- const { planMigration, applyMigration } = await import('../config/migrate-keychain.js');
1423
- const DIM = '\x1b[0;90m';
1424
- const BOLD = '\x1b[1m';
1425
- const GREEN = '\x1b[0;32m';
1426
- const YELLOW = '\x1b[0;33m';
1427
- const RED = '\x1b[0;31m';
1428
- const CYAN = '\x1b[0;36m';
1429
- const RESET = '\x1b[0m';
1430
- // Commander gives us either ['a', 'b'] or ['a,b'] depending on how the
1431
- // user passed the flag — normalize.
1432
- const only = opts.key
1433
- ? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
1434
- : undefined;
1435
- const plan = planMigration(BASE_DIR, only ? { only } : {});
1436
- console.log();
1437
- console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
1438
- console.log();
1439
- if (plan.candidates.length === 0) {
1440
- console.log(` ${DIM}No env entries found (.env may be empty or missing).${RESET}`);
1441
- console.log();
1442
- return;
1443
- }
1444
- // Group by status for readable output
1445
- const groups = {};
1446
- for (const c of plan.candidates) {
1447
- (groups[c.status] ??= []).push(c);
1448
- }
1449
- const renderGroup = (label, color, items) => {
1450
- if (!items || items.length === 0)
1451
- return;
1452
- console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
1453
- for (const c of items) {
1454
- console.log(` ${c.key} ${DIM}(${c.valueLength} chars)${RESET}`);
1455
- }
1456
- console.log();
1457
- };
1458
- renderGroup('Will migrate to keychain', CYAN, groups.migrated);
1459
- renderGroup('Already in keychain (skipped)', DIM, groups['already-keychain']);
1460
- renderGroup('Not credential-shaped (skipped)', DIM, groups['not-sensitive']);
1461
- renderGroup('Too short to be a credential (skipped)', DIM, groups['too-short']);
1462
- if (plan.toMigrate.length === 0) {
1463
- console.log(` ${GREEN}Nothing to migrate.${RESET}`);
1464
- console.log();
1465
- return;
1466
- }
1467
- if (opts.dryRun) {
1468
- console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
1469
- console.log(` ${DIM}Re-run without --dry-run to apply.${RESET}`);
1470
- console.log();
1471
- return;
1472
- }
1473
- console.log(` ${BOLD}Applying...${RESET}`);
1474
- let result;
1475
- try {
1476
- result = applyMigration(BASE_DIR, only ? { only } : {});
1477
- }
1478
- catch (err) {
1479
- console.error(` ${RED}Failed:${RESET} ${err.message}`);
1480
- process.exit(1);
1481
- }
1482
- if (result.failed.length > 0) {
1483
- console.log(` ${RED}Some keychain writes failed — .env was NOT modified:${RESET}`);
1484
- for (const f of result.failed) {
1485
- console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
1486
- }
1487
- console.log();
1488
- process.exit(1);
1489
- }
1490
- for (const key of result.migrated) {
1491
- console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ keychain${RESET}`);
1492
- }
1493
- console.log();
1494
- console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'}.${RESET}`);
1495
- console.log(` ${DIM}First daemon read of each ref will trigger a one-time keychain prompt;${RESET}`);
1496
- console.log(` ${DIM}choose Always Allow to make the prompt permanent.${RESET}`);
1497
- console.log();
1498
- }
1499
- // ── Config migrate-from-keychain (inverse of migrate-to-keychain) ───
1500
- async function cmdConfigMigrateFromKeychain(opts) {
1501
- const { planReverseMigration, applyReverseMigration } = await import('../config/migrate-from-keychain.js');
1502
- const DIM = '\x1b[0;90m';
1503
- const BOLD = '\x1b[1m';
1504
- const GREEN = '\x1b[0;32m';
1505
- const YELLOW = '\x1b[0;33m';
1506
- const RED = '\x1b[0;31m';
1507
- const CYAN = '\x1b[0;36m';
1508
- const RESET = '\x1b[0m';
1509
- const only = opts.key
1510
- ? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
1511
- : undefined;
1512
- const plan = planReverseMigration(BASE_DIR, only ? { only } : {});
1513
- console.log();
1514
- console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
1515
- console.log();
1516
- const groups = {};
1517
- for (const c of plan.candidates)
1518
- (groups[c.status] ??= []).push(c);
1519
- const renderGroup = (label, color, items, showRef = false) => {
1520
- if (!items || items.length === 0)
1521
- return;
1522
- console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
1523
- for (const c of items) {
1524
- const refTag = showRef && c.ref ? ` ${DIM}${c.ref}${RESET}` : '';
1525
- console.log(` ${c.key}${refTag}`);
1526
- }
1527
- console.log();
1528
- };
1529
- renderGroup('Will migrate from keychain → .env', CYAN, groups.migrated);
1530
- renderGroup('Sensitive — left in keychain (correct)', DIM, groups['sensitive-skipped']);
1531
- renderGroup('Unresolvable refs (keychain entry missing)', RED, groups.unresolvable, true);
1532
- if (plan.toMigrate.length === 0) {
1533
- console.log(` ${GREEN}Nothing to migrate.${RESET}`);
1534
- if (plan.unresolvable.length > 0) {
1535
- console.log(` ${YELLOW}${plan.unresolvable.length} unresolvable ref(s) above — fix or delete by hand.${RESET}`);
1536
- }
1537
- console.log();
1538
- return;
1539
- }
1540
- if (opts.dryRun) {
1541
- console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
1542
- console.log();
1543
- return;
1544
- }
1545
- console.log(` ${BOLD}Applying...${RESET}`);
1546
- let result;
1547
- try {
1548
- result = applyReverseMigration(BASE_DIR, only ? { only } : {});
1549
- }
1550
- catch (err) {
1551
- console.error(` ${RED}Failed:${RESET} ${err.message}`);
1552
- process.exit(1);
1553
- }
1554
- if (result.failed.length > 0) {
1555
- console.log(` ${RED}Some keychain reads failed — .env was NOT modified:${RESET}`);
1556
- for (const f of result.failed)
1557
- console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
1558
- console.log();
1559
- process.exit(1);
1560
- }
1561
- for (const key of result.migrated) {
1562
- console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ .env (keychain entry deleted)${RESET}`);
1563
- }
1564
- console.log();
1565
- console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'} out of keychain.${RESET}`);
1566
- console.log();
1567
- }
1568
- // ── Config harden-permissions ────────────────────────────────────────
1420
+ // ── Config harden-permissions ───────────────────────────────────────
1569
1421
  async function cmdConfigHardenPermissions(opts) {
1570
1422
  const { hardenPermissions } = await import('../config/harden-permissions.js');
1571
1423
  const report = hardenPermissions(BASE_DIR, { dryRun: opts.dryRun });
@@ -1624,84 +1476,6 @@ async function cmdConfigHardenPermissions(opts) {
1624
1476
  }
1625
1477
  process.exit(report.failed > 0 ? 1 : 0);
1626
1478
  }
1627
- // ── Config keychain-fix-acl ─────────────────────────────────────────
1628
- async function cmdConfigKeychainFixAcl(opts) {
1629
- const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
1630
- const { markKeychainWizardDone, readPasswordFromTty } = await import('../config/keychain-first-run-wizard.js');
1631
- const DIM = '\x1b[0;90m';
1632
- const BOLD = '\x1b[1m';
1633
- const GREEN = '\x1b[0;32m';
1634
- const YELLOW = '\x1b[0;33m';
1635
- const RED = '\x1b[0;31m';
1636
- const RESET = '\x1b[0m';
1637
- const entries = listClementineKeychainEntries();
1638
- console.log();
1639
- console.log(` ${BOLD}Found ${entries.length} clementine-agent keychain entr${entries.length === 1 ? 'y' : 'ies'}.${RESET}`);
1640
- for (const e of entries)
1641
- console.log(` ${DIM}${e.account}${RESET}`);
1642
- console.log();
1643
- if (entries.length === 0) {
1644
- console.log(` ${GREEN}Nothing to fix.${RESET}`);
1645
- console.log();
1646
- // No entries means the launch wizard has nothing to do either.
1647
- markKeychainWizardDone(BASE_DIR);
1648
- return;
1649
- }
1650
- if (opts.list) {
1651
- console.log(` ${DIM}--list mode — no changes made. Drop the flag to apply.${RESET}`);
1652
- console.log();
1653
- return;
1654
- }
1655
- console.log(` ${DIM}Enter your macOS login password ONCE — it authorizes${RESET}`);
1656
- console.log(` ${DIM}all ${entries.length} ACL update${entries.length === 1 ? '' : 's'} in a single pass. Not stored.${RESET}`);
1657
- console.log();
1658
- const password = await readPasswordFromTty(` ${BOLD}macOS login password:${RESET} `);
1659
- if (!password) {
1660
- console.log();
1661
- console.log(` ${YELLOW}Empty password — aborted.${RESET}`);
1662
- console.log();
1663
- return;
1664
- }
1665
- console.log();
1666
- console.log(` ${BOLD}Fixing ACLs...${RESET}`);
1667
- console.log();
1668
- const results = fixAllClementineEntries({ keychainPassword: password });
1669
- let okCount = 0;
1670
- let failCount = 0;
1671
- let wrongPasswordHit = false;
1672
- for (const r of results) {
1673
- if (r.status === 'fixed') {
1674
- console.log(` ${GREEN}✓${RESET} ${r.account}`);
1675
- okCount++;
1676
- }
1677
- else if (r.status === 'failed') {
1678
- console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error}${RESET}`);
1679
- failCount++;
1680
- if (r.error && /MAC verification|AuthFailure|UserCanceled|-25293/i.test(r.error)) {
1681
- wrongPasswordHit = true;
1682
- }
1683
- }
1684
- }
1685
- console.log();
1686
- if (failCount === 0) {
1687
- console.log(` ${GREEN}All ${okCount} entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
1688
- }
1689
- else if (wrongPasswordHit && okCount === 0) {
1690
- console.log(` ${RED}Wrong password — no entries repaired.${RESET}`);
1691
- console.log(` ${DIM}Re-run: clementine config keychain-fix-acl${RESET}`);
1692
- }
1693
- else {
1694
- console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
1695
- console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app:${RESET}`);
1696
- console.log(` ${DIM} search "clementine-agent" → double-click → Access Control → Allow all applications.${RESET}`);
1697
- }
1698
- // Mark the launch wizard satisfied unless the password was clearly wrong —
1699
- // user has explicitly decided to deal with this via the manual command.
1700
- if (!(wrongPasswordHit && okCount === 0)) {
1701
- markKeychainWizardDone(BASE_DIR);
1702
- }
1703
- console.log();
1704
- }
1705
1479
  // ── Analytics ────────────────────────────────────────────────────────
1706
1480
  async function cmdAnalyticsToolUsage(opts) {
1707
1481
  const { buildToolUsageReport, defaultAuditLogPath } = await import('../analytics/tool-usage.js');
@@ -2634,29 +2408,6 @@ configCmd
2634
2408
  .action(async (opts) => {
2635
2409
  await cmdConfigDoctor(opts);
2636
2410
  });
2637
- configCmd
2638
- .command('migrate-to-keychain')
2639
- .description('Move plaintext credentials in .env into the macOS keychain (NOT recommended in v1.1.4+ — keychain entries can produce per-process approval prompts; plain .env at mode 0600 is the supported default)')
2640
- .option('--dry-run', 'Show what would migrate without writing anything')
2641
- .option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
2642
- .action(async (opts) => {
2643
- await cmdConfigMigrateToKeychain(opts);
2644
- });
2645
- configCmd
2646
- .command('migrate-from-keychain')
2647
- .description('Pull non-credential values OUT of keychain back to plaintext .env (only API keys belong in keychain)')
2648
- .option('--dry-run', 'Show what would migrate without writing anything')
2649
- .option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
2650
- .action(async (opts) => {
2651
- await cmdConfigMigrateFromKeychain(opts);
2652
- });
2653
- configCmd
2654
- .command('keychain-fix-acl')
2655
- .description('One-shot fix for clementine-agent keychain entries that prompt on every read (one master prompt then no more)')
2656
- .option('--list', 'List entries without changing anything')
2657
- .action(async (opts) => {
2658
- await cmdConfigKeychainFixAcl(opts);
2659
- });
2660
2411
  configCmd
2661
2412
  .command('harden-permissions')
2662
2413
  .description('Tighten file modes in ~/.clementine/ — files to 0600, directories to 0700')
package/dist/index.js CHANGED
@@ -717,7 +717,8 @@ async function asyncMain() {
717
717
  }
718
718
  })();
719
719
  // Heartbeat + Cron schedulers
720
- const { HeartbeatScheduler, CronScheduler } = await import('./gateway/heartbeat.js');
720
+ const { HeartbeatScheduler } = await import('./gateway/heartbeat-scheduler.js');
721
+ const { CronScheduler } = await import('./gateway/cron-scheduler.js');
721
722
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
722
723
  const cronScheduler = new CronScheduler(gateway, dispatcher);
723
724
  heartbeat.setCronScheduler(cronScheduler);
@@ -1095,7 +1095,7 @@ export function registerAdminTools(server) {
1095
1095
  parsed.data.jobs = jobs;
1096
1096
  // Write back preserving body content — validate first to prevent daemon crash
1097
1097
  const output = matterMod.default.stringify(parsed.content, parsed.data);
1098
- const { validateCronYaml } = await import('../gateway/heartbeat.js');
1098
+ const { validateCronYaml } = await import('../gateway/cron-scheduler.js');
1099
1099
  const yamlErr = validateCronYaml(output);
1100
1100
  if (yamlErr) {
1101
1101
  logger.error({ yamlErr, jobName }, 'Generated CRON.md has invalid YAML — aborting write');
@@ -1268,7 +1268,7 @@ export function registerAdminTools(server) {
1268
1268
  }
1269
1269
  parsed.data.jobs = jobs;
1270
1270
  const output = matterMod.default.stringify(parsed.content, parsed.data);
1271
- const { validateCronYaml } = await import('../gateway/heartbeat.js');
1271
+ const { validateCronYaml } = await import('../gateway/cron-scheduler.js');
1272
1272
  const yamlErr = validateCronYaml(output);
1273
1273
  if (yamlErr) {
1274
1274
  logger.error({ yamlErr, jobName }, 'Generated CRON.md has invalid YAML — aborting write');
@@ -1976,14 +1976,6 @@ export function registerAdminTools(server) {
1976
1976
  logger.info({ jobName: job_name, runCount: updated.runCount }, 'Cron progress saved');
1977
1977
  return textResult(`Progress saved for "${job_name}" (run #${updated.runCount}). ${(completedItems?.length ?? 0)} items completed, ${(updated.pendingItems?.length ?? 0)} pending.`);
1978
1978
  });
1979
- // ── Browser harness — chat-driven Chrome connect ────────────────────
1980
- server.tool('browser_connect', 'Connect Chrome to the browser harness via CDP. Idempotent — if Chrome is already running with remote debugging on :9222 this is a no-op. If no Chrome is running, launches Chrome with --remote-debugging-port=9222. If Chrome is running normally without the flag, refuses unless force_quit=true (which closes the user\'s open tabs). Use this so the user can connect from any chat channel without dropping to the terminal.', {
1981
- force_quit: z.boolean().optional().describe('If true, quit any running Chrome before relaunching with the debug flag. DESTRUCTIVE — closes the user\'s open tabs. Only set after the user has explicitly confirmed they want this. Defaults to false.'),
1982
- }, async ({ force_quit }) => {
1983
- const { runConnectNonInteractive } = await import('../cli/browser.js');
1984
- const result = await runConnectNonInteractive({ allowQuitChrome: !!force_quit });
1985
- return textResult(result.message);
1986
- });
1987
1979
  // ── Broken-job diagnosis + fix-application (chat-equivalent of dashboard buttons) ──
1988
1980
  //
1989
1981
  // Before this, when the user asked "fix audit-inbox-check" in chat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.139",
3
+ "version": "1.18.141",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,70 +0,0 @@
1
- /**
2
- * Post-turn contradiction validator.
3
- *
4
- * After a chat turn's SDK stream completes, compares the assistant's outgoing
5
- * reply against the actual tool_use/tool_result pairs from that turn. If a
6
- * claude_ai_* connector succeeded (or returned an argument error — a fixable
7
- * per-call failure) but the reply claims the connector is broken, missing from
8
- * the schema, or otherwise generalizes a single failure into connector-level
9
- * "deadness," we flag it.
10
- *
11
- * This is deterministic: it does NOT rely on the model obeying prompt rules.
12
- * It's the load-bearing guardrail that replaces the forbidden-phrase list we
13
- * used to patch into the system prompt.
14
- */
15
- export type ToolResultClass = 'success' | 'arg_error' | 'auth_error' | 'other_error';
16
- export interface ToolCallRecord {
17
- /** Tool name, e.g. mcp__claude_ai_Google_Drive__search_files */
18
- name: string;
19
- /** tool_use_id from the assistant's request */
20
- id: string;
21
- /** Classification of the paired tool_result */
22
- resultClass: ToolResultClass;
23
- /** First ~200 chars of the literal result content (or error text) */
24
- resultPreview: string;
25
- }
26
- /**
27
- * Regex matching reply phrasings that claim a connector-wide failure.
28
- *
29
- * Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
30
- * claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
31
- * removed the upstream need for ~15 defensive phrasings. We keep three
32
- * core patterns as a cheap safety net — anything else means the model
33
- * invented a new way to confabulate, which we'd rather see raw in the
34
- * audit log than silently paper over.
35
- */
36
- export declare const CONTRADICTION_RE: RegExp;
37
- export declare function classifyResult(content: string, isError: boolean): ToolResultClass;
38
- /**
39
- * Walk collected SDK messages (assistant + user) and pair every tool_use with
40
- * its tool_result. Returns one record per tool_use; unpaired ones (still
41
- * running at end of stream) are skipped.
42
- */
43
- export declare function collectToolCalls(messages: Array<{
44
- type: string;
45
- message?: any;
46
- }>): ToolCallRecord[];
47
- export interface ContradictionFinding {
48
- /** The tool call whose result contradicts the reply */
49
- tool: ToolCallRecord;
50
- /** The exact phrase from the reply that triggered detection */
51
- matchedPhrase: string;
52
- }
53
- /**
54
- * Check a reply against a set of tool-call records. Returns the first
55
- * contradiction found, or null if the reply is consistent with tool results.
56
- *
57
- * Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
58
- * mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
59
- * `auth_error` and `other_error` are legitimate failures that can support
60
- * those reply phrasings.
61
- */
62
- export declare function detectContradiction(reply: string, calls: ToolCallRecord[]): ContradictionFinding | null;
63
- /**
64
- * Build the system-follow-up message we inject when a contradiction fires.
65
- * The SDK will run one more turn with this as a user-role message (using
66
- * `canUseTool` or similar hook), and the model's next reply replaces the
67
- * bad one.
68
- */
69
- export declare function buildCorrectionPrompt(finding: ContradictionFinding): string;
70
- //# sourceMappingURL=contradiction-validator.d.ts.map
@@ -1,143 +0,0 @@
1
- /**
2
- * Post-turn contradiction validator.
3
- *
4
- * After a chat turn's SDK stream completes, compares the assistant's outgoing
5
- * reply against the actual tool_use/tool_result pairs from that turn. If a
6
- * claude_ai_* connector succeeded (or returned an argument error — a fixable
7
- * per-call failure) but the reply claims the connector is broken, missing from
8
- * the schema, or otherwise generalizes a single failure into connector-level
9
- * "deadness," we flag it.
10
- *
11
- * This is deterministic: it does NOT rely on the model obeying prompt rules.
12
- * It's the load-bearing guardrail that replaces the forbidden-phrase list we
13
- * used to patch into the system prompt.
14
- */
15
- const ARG_ERROR_RE = /\b(invalid|unknown field|required|missing parameter|schema|unrecognized|unexpected property)\b/i;
16
- const AUTH_ERROR_RE = /\b(unauthori[sz]ed|401|not authenticated|token expired|token has expired|invalid[_ ]?token|access denied)\b/i;
17
- /**
18
- * Regex matching reply phrasings that claim a connector-wide failure.
19
- *
20
- * Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
21
- * claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
22
- * removed the upstream need for ~15 defensive phrasings. We keep three
23
- * core patterns as a cheap safety net — anything else means the model
24
- * invented a new way to confabulate, which we'd rather see raw in the
25
- * audit log than silently paper over.
26
- */
27
- export const CONTRADICTION_RE = /(dead\s*end|not in (the |my )?schema|no such tool available)/i;
28
- export function classifyResult(content, isError) {
29
- if (!isError)
30
- return 'success';
31
- if (ARG_ERROR_RE.test(content))
32
- return 'arg_error';
33
- if (AUTH_ERROR_RE.test(content))
34
- return 'auth_error';
35
- return 'other_error';
36
- }
37
- /** Extract string content from a tool_result block (which can be string or array of content blocks). */
38
- function stringifyResultContent(content) {
39
- if (typeof content === 'string')
40
- return content;
41
- if (Array.isArray(content)) {
42
- return content
43
- .map((b) => (typeof b === 'string' ? b : (b?.text ?? b?.content ?? JSON.stringify(b))))
44
- .join('\n');
45
- }
46
- if (content == null)
47
- return '';
48
- try {
49
- return JSON.stringify(content);
50
- }
51
- catch {
52
- return String(content);
53
- }
54
- }
55
- /**
56
- * Walk collected SDK messages (assistant + user) and pair every tool_use with
57
- * its tool_result. Returns one record per tool_use; unpaired ones (still
58
- * running at end of stream) are skipped.
59
- */
60
- export function collectToolCalls(messages) {
61
- const toolUses = new Map();
62
- const results = new Map();
63
- for (const msg of messages) {
64
- if (msg.type === 'assistant' && msg.message?.content) {
65
- const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
66
- for (const b of blocks) {
67
- if (b?.type === 'tool_use' && b.id && b.name) {
68
- toolUses.set(b.id, { name: b.name, id: b.id });
69
- }
70
- }
71
- }
72
- else if (msg.type === 'user' && msg.message?.content) {
73
- const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
74
- for (const b of blocks) {
75
- if (b?.type === 'tool_result' && b.tool_use_id) {
76
- results.set(b.tool_use_id, {
77
- content: stringifyResultContent(b.content),
78
- isError: !!b.is_error,
79
- });
80
- }
81
- }
82
- }
83
- }
84
- const records = [];
85
- for (const [id, tu] of toolUses) {
86
- const r = results.get(id);
87
- if (!r)
88
- continue;
89
- records.push({
90
- name: tu.name,
91
- id,
92
- resultClass: classifyResult(r.content, r.isError),
93
- resultPreview: r.content.slice(0, 200),
94
- });
95
- }
96
- return records;
97
- }
98
- /**
99
- * Check a reply against a set of tool-call records. Returns the first
100
- * contradiction found, or null if the reply is consistent with tool results.
101
- *
102
- * Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
103
- * mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
104
- * `auth_error` and `other_error` are legitimate failures that can support
105
- * those reply phrasings.
106
- */
107
- export function detectContradiction(reply, calls) {
108
- if (!reply)
109
- return null;
110
- const match = reply.match(CONTRADICTION_RE);
111
- if (!match)
112
- return null;
113
- // Cover every connector — claude_ai_* (remote), imessage/figma/hostinger/etc.
114
- // (Desktop Extensions + stdio servers), everything except Clementine's own
115
- // tools server and plugins. Earlier versions only filtered claude_ai_*,
116
- // which let "isn't loaded" replies slip through for iMessage etc.
117
- const connectorCalls = calls.filter(c => c.name.startsWith('mcp__') &&
118
- !c.name.startsWith('mcp__clementine-tools__') &&
119
- !c.name.startsWith('mcp__plugin_'));
120
- const recoverable = connectorCalls.find(c => c.resultClass === 'success' || c.resultClass === 'arg_error');
121
- if (!recoverable)
122
- return null;
123
- return { tool: recoverable, matchedPhrase: match[0] };
124
- }
125
- /**
126
- * Build the system-follow-up message we inject when a contradiction fires.
127
- * The SDK will run one more turn with this as a user-role message (using
128
- * `canUseTool` or similar hook), and the model's next reply replaces the
129
- * bad one.
130
- */
131
- export function buildCorrectionPrompt(finding) {
132
- const { tool, matchedPhrase } = finding;
133
- const classLabel = tool.resultClass === 'success' ? 'returned successful content' :
134
- tool.resultClass === 'arg_error' ? 'returned an argument error (fixable by correcting the args — the connector itself works)' :
135
- tool.resultClass;
136
- return (`Your previous reply contained "${matchedPhrase}" but ${tool.name} ${classLabel}.\n\n` +
137
- `Literal tool result (first 200 chars):\n${tool.resultPreview}\n\n` +
138
- `Rewrite your reply using the actual tool result. ` +
139
- (tool.resultClass === 'arg_error'
140
- ? `This was an argument error for one call — the connector is NOT broken. Re-read the tool's schema (the rejected argument names are in the error above), retry the call with correct args, and report what comes back.`
141
- : `Do not generalize this to "the connector is broken" or "the tool doesn't exist" — those claims contradict the tool's actual return value.`));
142
- }
143
- //# sourceMappingURL=contradiction-validator.js.map
@@ -1,70 +0,0 @@
1
- /**
2
- * Inverse of migrate-keychain.ts: pulls non-credential values OUT of the
3
- * macOS keychain and back into plaintext .env entries.
4
- *
5
- * Why this exists: an earlier env_set bug routed every value to keychain
6
- * regardless of whether the key looked like a credential, producing stale
7
- * keychain entries for things like TASK_BUDGET_HEARTBEAT (a token-count
8
- * config knob, not a secret). Each one costs the user a keychain prompt
9
- * for no benefit. This module reverses that mistake — and the user-rule
10
- * "only actual API keys belong in keychain" stays enforced going forward
11
- * by the env_set classifier fix in 897bb97.
12
- *
13
- * For each line in .env that holds a `keychain:` ref AND whose key does
14
- * NOT match the sensitivity classifier:
15
- * 1. Resolve via `security find-generic-password`
16
- * 2. Replace the .env line with `KEY=<plaintext value>`
17
- * 3. Delete the keychain entry
18
- *
19
- * Atomic: phase-1 reads + writes succeed in a temp file before the original
20
- * .env is replaced via rename. Keychain deletes happen last, so a partial
21
- * failure leaves the keychain entry intact (no data loss).
22
- *
23
- * Idempotent + opt-in: lines whose key IS credential-shaped pass through
24
- * untouched even when stored as a keychain ref — we don't undo legitimate
25
- * keychain storage. --key filter for surgical migrations.
26
- */
27
- export type ReverseMigrationStatus = 'migrated' | 'sensitive-skipped' | 'not-keychain' | 'unresolvable' | 'skipped';
28
- export interface ReverseMigrationCandidate {
29
- key: string;
30
- status: ReverseMigrationStatus;
31
- /** The keychain stub itself (always safe to log — it's just an account name). */
32
- ref?: string;
33
- }
34
- export interface ReverseMigrationPlan {
35
- envPath: string;
36
- candidates: ReverseMigrationCandidate[];
37
- /** Keys this run would migrate out of keychain. */
38
- toMigrate: string[];
39
- /** Refs that look bad — surfaced separately so doctor can flag them. */
40
- unresolvable: string[];
41
- }
42
- export interface ReverseMigrationResult {
43
- envPath: string;
44
- migrated: string[];
45
- failed: Array<{
46
- key: string;
47
- error: string;
48
- }>;
49
- }
50
- /**
51
- * Pure read + classify pass — no .env writes, no keychain deletes, but
52
- * DOES make read-only `security find-generic-password` calls to detect
53
- * unresolvable refs (and to verify resolvable ones won't fail later).
54
- */
55
- export declare function planReverseMigration(baseDir: string, opts?: {
56
- only?: string[];
57
- }): ReverseMigrationPlan;
58
- /**
59
- * Apply the migration. Two phases:
60
- * 1. Rewrite .env in a temp file, swapping each migrated ref for plaintext.
61
- * If anything throws, the original .env is untouched.
62
- * 2. Atomically rename the temp file over .env.
63
- * 3. Delete each migrated key's keychain entry. Best-effort — failure to
64
- * delete is logged but doesn't roll back the .env update (the value
65
- * is now safely in .env regardless).
66
- */
67
- export declare function applyReverseMigration(baseDir: string, opts?: {
68
- only?: string[];
69
- }): ReverseMigrationResult;
70
- //# sourceMappingURL=migrate-from-keychain.d.ts.map
@@ -1,173 +0,0 @@
1
- /**
2
- * Inverse of migrate-keychain.ts: pulls non-credential values OUT of the
3
- * macOS keychain and back into plaintext .env entries.
4
- *
5
- * Why this exists: an earlier env_set bug routed every value to keychain
6
- * regardless of whether the key looked like a credential, producing stale
7
- * keychain entries for things like TASK_BUDGET_HEARTBEAT (a token-count
8
- * config knob, not a secret). Each one costs the user a keychain prompt
9
- * for no benefit. This module reverses that mistake — and the user-rule
10
- * "only actual API keys belong in keychain" stays enforced going forward
11
- * by the env_set classifier fix in 897bb97.
12
- *
13
- * For each line in .env that holds a `keychain:` ref AND whose key does
14
- * NOT match the sensitivity classifier:
15
- * 1. Resolve via `security find-generic-password`
16
- * 2. Replace the .env line with `KEY=<plaintext value>`
17
- * 3. Delete the keychain entry
18
- *
19
- * Atomic: phase-1 reads + writes succeed in a temp file before the original
20
- * .env is replaced via rename. Keychain deletes happen last, so a partial
21
- * failure leaves the keychain entry intact (no data loss).
22
- *
23
- * Idempotent + opt-in: lines whose key IS credential-shaped pass through
24
- * untouched even when stored as a keychain ref — we don't undo legitimate
25
- * keychain storage. --key filter for surgical migrations.
26
- */
27
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
28
- import path from 'node:path';
29
- import * as keychain from '../secrets/keychain.js';
30
- import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
31
- const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
32
- function parseLine(line) {
33
- const trimmed = line.trim();
34
- if (!trimmed || trimmed.startsWith('#'))
35
- return { raw: line, passthrough: true };
36
- const eq = trimmed.indexOf('=');
37
- if (eq === -1)
38
- return { raw: line, passthrough: true };
39
- const key = trimmed.slice(0, eq);
40
- let value = trimmed.slice(eq + 1);
41
- if ((value.startsWith('"') && value.endsWith('"')) ||
42
- (value.startsWith("'") && value.endsWith("'"))) {
43
- value = value.slice(1, -1);
44
- }
45
- return { raw: line, key, value, passthrough: false };
46
- }
47
- function refAccount(stub) {
48
- return stub.slice(REF_PREFIX.length);
49
- }
50
- function tryResolveRef(stub) {
51
- // Account names are stored under the well-known service "clementine-agent",
52
- // and the stub is encoded as keychain:<service>-<envVar>. The actual
53
- // keychain lookup uses the env-var name as the account label, which is
54
- // also the suffix of the stub past the service prefix.
55
- const account = refAccount(stub);
56
- // The keychain `get(envVar)` helper expects just the env-var name; our
57
- // stub format is `keychain:clementine-agent-<envVar>`, so strip the
58
- // service prefix before delegating.
59
- const SERVICE_PREFIX = 'clementine-agent-';
60
- const envVar = account.startsWith(SERVICE_PREFIX) ? account.slice(SERVICE_PREFIX.length) : account;
61
- return keychain.get(envVar);
62
- }
63
- /**
64
- * Pure read + classify pass — no .env writes, no keychain deletes, but
65
- * DOES make read-only `security find-generic-password` calls to detect
66
- * unresolvable refs (and to verify resolvable ones won't fail later).
67
- */
68
- export function planReverseMigration(baseDir, opts = {}) {
69
- const envPath = path.join(baseDir, '.env');
70
- if (!existsSync(envPath)) {
71
- return { envPath, candidates: [], toMigrate: [], unresolvable: [] };
72
- }
73
- const raw = readFileSync(envPath, 'utf-8');
74
- const onlySet = opts.only ? new Set(opts.only) : undefined;
75
- const candidates = [];
76
- const toMigrate = [];
77
- const unresolvable = [];
78
- for (const line of raw.split('\n')) {
79
- const parsed = parseLine(line);
80
- if (parsed.passthrough || !parsed.key || parsed.value === undefined)
81
- continue;
82
- if (onlySet && !onlySet.has(parsed.key)) {
83
- candidates.push({ key: parsed.key, status: 'skipped' });
84
- continue;
85
- }
86
- if (!parsed.value.startsWith(REF_PREFIX)) {
87
- candidates.push({ key: parsed.key, status: 'not-keychain' });
88
- continue;
89
- }
90
- if (isSensitiveEnvKey(parsed.key)) {
91
- candidates.push({ key: parsed.key, status: 'sensitive-skipped', ref: parsed.value });
92
- continue;
93
- }
94
- // Try to resolve. If unresolvable, surface separately — caller decides
95
- // whether to delete the orphan stub.
96
- const resolved = tryResolveRef(parsed.value);
97
- if (resolved === undefined) {
98
- candidates.push({ key: parsed.key, status: 'unresolvable', ref: parsed.value });
99
- unresolvable.push(parsed.key);
100
- continue;
101
- }
102
- candidates.push({ key: parsed.key, status: 'migrated', ref: parsed.value });
103
- toMigrate.push(parsed.key);
104
- }
105
- return { envPath, candidates, toMigrate, unresolvable };
106
- }
107
- /**
108
- * Apply the migration. Two phases:
109
- * 1. Rewrite .env in a temp file, swapping each migrated ref for plaintext.
110
- * If anything throws, the original .env is untouched.
111
- * 2. Atomically rename the temp file over .env.
112
- * 3. Delete each migrated key's keychain entry. Best-effort — failure to
113
- * delete is logged but doesn't roll back the .env update (the value
114
- * is now safely in .env regardless).
115
- */
116
- export function applyReverseMigration(baseDir, opts = {}) {
117
- const envPath = path.join(baseDir, '.env');
118
- const result = { envPath, migrated: [], failed: [] };
119
- if (!existsSync(envPath))
120
- return result;
121
- const raw = readFileSync(envPath, 'utf-8');
122
- const onlySet = opts.only ? new Set(opts.only) : undefined;
123
- const lines = raw.split('\n');
124
- const parsedLines = lines.map(parseLine);
125
- // Phase 1: resolve every target via keychain (read-only). Bail before
126
- // touching anything if any target is unresolvable — caller can rerun
127
- // with --key to skip the bad ones.
128
- const newValues = new Map();
129
- for (const parsed of parsedLines) {
130
- if (parsed.passthrough || !parsed.key || parsed.value === undefined)
131
- continue;
132
- if (onlySet && !onlySet.has(parsed.key))
133
- continue;
134
- if (!parsed.value.startsWith(REF_PREFIX))
135
- continue;
136
- if (isSensitiveEnvKey(parsed.key))
137
- continue;
138
- const resolved = tryResolveRef(parsed.value);
139
- if (resolved === undefined) {
140
- result.failed.push({ key: parsed.key, error: `keychain entry missing or unreadable for ${parsed.value}` });
141
- continue;
142
- }
143
- newValues.set(parsed.key, resolved);
144
- }
145
- if (result.failed.length > 0)
146
- return result;
147
- if (newValues.size === 0)
148
- return result;
149
- // Phase 2: rewrite .env atomically.
150
- const newLines = parsedLines.map((parsed) => {
151
- if (parsed.passthrough || !parsed.key)
152
- return parsed.raw;
153
- const newValue = newValues.get(parsed.key);
154
- if (newValue === undefined)
155
- return parsed.raw;
156
- return `${parsed.key}=${newValue}`;
157
- });
158
- const tmp = `${envPath}.${process.pid}.${Date.now()}.tmp`;
159
- writeFileSync(tmp, newLines.join('\n'));
160
- renameSync(tmp, envPath);
161
- // Phase 3: best-effort keychain deletes.
162
- for (const key of newValues.keys()) {
163
- try {
164
- keychain.remove(key);
165
- }
166
- catch {
167
- /* keychain delete failure is non-fatal — the value is in .env now */
168
- }
169
- result.migrated.push(key);
170
- }
171
- return result;
172
- }
173
- //# sourceMappingURL=migrate-from-keychain.js.map
@@ -1,55 +0,0 @@
1
- /**
2
- * Migrate plaintext credential values from .env into the macOS keychain.
3
- *
4
- * For each line in .env whose key looks like a credential (per the
5
- * sensitivity classifier) and whose value is plaintext (not a `keychain:`
6
- * stub), writes the value into the keychain under the well-known
7
- * `clementine-agent` service and replaces the .env line with a stub ref.
8
- *
9
- * Atomic: all keychain writes complete first, then a single temp-file +
10
- * rename rewrites .env. If any keychain write fails, the .env is untouched.
11
- *
12
- * Idempotent: lines already holding a keychain ref are skipped. Lines
13
- * that don't match the sensitivity classifier are passed through verbatim.
14
- *
15
- * Pure: never reads .env from process.env, never mutates ambient state.
16
- */
17
- export type MigrationStatus = 'migrated' | 'already-keychain' | 'not-sensitive' | 'too-short' | 'skipped';
18
- export interface MigrationCandidate {
19
- key: string;
20
- status: MigrationStatus;
21
- /** Length of the original value (never the value itself — avoid leaking via logs). */
22
- valueLength: number;
23
- }
24
- export interface MigrationPlan {
25
- envPath: string;
26
- candidates: MigrationCandidate[];
27
- /** Keys this run would actually migrate (status === 'migrated' after apply). */
28
- toMigrate: string[];
29
- }
30
- export interface MigrationResult {
31
- envPath: string;
32
- migrated: string[];
33
- failed: Array<{
34
- key: string;
35
- error: string;
36
- }>;
37
- unchanged: number;
38
- }
39
- /**
40
- * Compute what would happen — pure read, no .env write, no keychain write.
41
- * Use --dry-run paths or pre-flight UX in front of apply().
42
- */
43
- export declare function planMigration(baseDir: string, opts?: {
44
- only?: string[];
45
- }): MigrationPlan;
46
- /**
47
- * Execute the migration. Two-phase to avoid leaving .env in an inconsistent
48
- * state: (1) write every value to keychain, (2) atomically rewrite .env
49
- * replacing each migrated value with its stub ref. Any keychain write
50
- * failure aborts before the .env rewrite.
51
- */
52
- export declare function applyMigration(baseDir: string, opts?: {
53
- only?: string[];
54
- }): MigrationResult;
55
- //# sourceMappingURL=migrate-keychain.d.ts.map
@@ -1,144 +0,0 @@
1
- /**
2
- * Migrate plaintext credential values from .env into the macOS keychain.
3
- *
4
- * For each line in .env whose key looks like a credential (per the
5
- * sensitivity classifier) and whose value is plaintext (not a `keychain:`
6
- * stub), writes the value into the keychain under the well-known
7
- * `clementine-agent` service and replaces the .env line with a stub ref.
8
- *
9
- * Atomic: all keychain writes complete first, then a single temp-file +
10
- * rename rewrites .env. If any keychain write fails, the .env is untouched.
11
- *
12
- * Idempotent: lines already holding a keychain ref are skipped. Lines
13
- * that don't match the sensitivity classifier are passed through verbatim.
14
- *
15
- * Pure: never reads .env from process.env, never mutates ambient state.
16
- */
17
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
18
- import path from 'node:path';
19
- import * as keychain from '../secrets/keychain.js';
20
- import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
21
- const REF_PREFIX = 'keychain:'; // pragma: allowlist secret
22
- const MIN_VALUE_LENGTH = 16;
23
- function parseLine(line) {
24
- const trimmed = line.trim();
25
- if (!trimmed || trimmed.startsWith('#')) {
26
- return { raw: line, passthrough: true };
27
- }
28
- const eq = trimmed.indexOf('=');
29
- if (eq === -1)
30
- return { raw: line, passthrough: true };
31
- const key = trimmed.slice(0, eq);
32
- let value = trimmed.slice(eq + 1);
33
- if ((value.startsWith('"') && value.endsWith('"')) ||
34
- (value.startsWith("'") && value.endsWith("'"))) {
35
- value = value.slice(1, -1);
36
- }
37
- return { raw: line, key, value, passthrough: false };
38
- }
39
- function classify(parsed, opts) {
40
- if (parsed.passthrough || !parsed.key || parsed.value === undefined)
41
- return 'skipped';
42
- if (opts.only && !opts.only.has(parsed.key))
43
- return 'skipped';
44
- if (parsed.value.startsWith(REF_PREFIX))
45
- return 'already-keychain';
46
- if (!isSensitiveEnvKey(parsed.key))
47
- return 'not-sensitive';
48
- if (parsed.value.length < MIN_VALUE_LENGTH)
49
- return 'too-short';
50
- return 'migrated';
51
- }
52
- /**
53
- * Compute what would happen — pure read, no .env write, no keychain write.
54
- * Use --dry-run paths or pre-flight UX in front of apply().
55
- */
56
- export function planMigration(baseDir, opts = {}) {
57
- const envPath = path.join(baseDir, '.env');
58
- if (!existsSync(envPath)) {
59
- return { envPath, candidates: [], toMigrate: [] };
60
- }
61
- const raw = readFileSync(envPath, 'utf-8');
62
- const onlySet = opts.only ? new Set(opts.only) : undefined;
63
- const candidates = [];
64
- const toMigrate = [];
65
- for (const line of raw.split('\n')) {
66
- const parsed = parseLine(line);
67
- if (parsed.passthrough || !parsed.key)
68
- continue;
69
- const status = classify(parsed, { only: onlySet });
70
- candidates.push({
71
- key: parsed.key,
72
- status,
73
- valueLength: parsed.value?.length ?? 0,
74
- });
75
- if (status === 'migrated')
76
- toMigrate.push(parsed.key);
77
- }
78
- return { envPath, candidates, toMigrate };
79
- }
80
- /**
81
- * Execute the migration. Two-phase to avoid leaving .env in an inconsistent
82
- * state: (1) write every value to keychain, (2) atomically rewrite .env
83
- * replacing each migrated value with its stub ref. Any keychain write
84
- * failure aborts before the .env rewrite.
85
- */
86
- export function applyMigration(baseDir, opts = {}) {
87
- const envPath = path.join(baseDir, '.env');
88
- const result = { envPath, migrated: [], failed: [], unchanged: 0 };
89
- if (!existsSync(envPath))
90
- return result;
91
- if (!keychain.isAvailable()) {
92
- throw new Error('macOS keychain is not available on this system');
93
- }
94
- const raw = readFileSync(envPath, 'utf-8');
95
- const onlySet = opts.only ? new Set(opts.only) : undefined;
96
- const lines = raw.split('\n');
97
- const parsedLines = lines.map(parseLine);
98
- // Phase 1: write each migration target into the keychain. Build a map
99
- // key → newStubValue. Bail on first keychain write failure (no .env touch).
100
- const newValues = new Map();
101
- for (const parsed of parsedLines) {
102
- if (parsed.passthrough || !parsed.key)
103
- continue;
104
- const status = classify(parsed, { only: onlySet });
105
- if (status !== 'migrated') {
106
- if (status !== 'skipped' && status !== 'already-keychain')
107
- result.unchanged++;
108
- continue;
109
- }
110
- try {
111
- const stub = keychain.set(parsed.key, parsed.value);
112
- newValues.set(parsed.key, stub);
113
- }
114
- catch (err) {
115
- result.failed.push({ key: parsed.key, error: String(err).slice(0, 200) });
116
- }
117
- }
118
- if (result.failed.length > 0) {
119
- // Don't touch .env if any keychain write failed — keeps the original
120
- // plaintext intact rather than half-migrating.
121
- return result;
122
- }
123
- if (newValues.size === 0)
124
- return result;
125
- // Phase 2: rewrite .env in place, line-by-line, swapping values for stubs.
126
- const newLines = parsedLines.map((parsed) => {
127
- if (parsed.passthrough || !parsed.key)
128
- return parsed.raw;
129
- const newStub = newValues.get(parsed.key);
130
- if (!newStub)
131
- return parsed.raw;
132
- // Match the original line's leading whitespace and trailing comment-noise
133
- // by reconstructing key=stub from scratch — keys are uppercase identifiers,
134
- // so no whitespace ambiguity.
135
- return `${parsed.key}=${newStub}`;
136
- });
137
- const tmp = `${envPath}.${process.pid}.${Date.now()}.tmp`;
138
- writeFileSync(tmp, newLines.join('\n'));
139
- renameSync(tmp, envPath);
140
- for (const key of newValues.keys())
141
- result.migrated.push(key);
142
- return result;
143
- }
144
- //# sourceMappingURL=migrate-keychain.js.map
@@ -1,3 +0,0 @@
1
- export { HeartbeatScheduler } from './heartbeat-scheduler.js';
2
- export { CronScheduler, CronRunLog, parseCronJobs, parseAgentCronJobs, validateCronYaml, classifyError, logToDailyNote } from './cron-scheduler.js';
3
- //# sourceMappingURL=heartbeat.d.ts.map
@@ -1,3 +0,0 @@
1
- export { HeartbeatScheduler } from './heartbeat-scheduler.js';
2
- export { CronScheduler, CronRunLog, parseCronJobs, parseAgentCronJobs, validateCronYaml, classifyError, logToDailyNote } from './cron-scheduler.js';
3
- //# sourceMappingURL=heartbeat.js.map