clementine-agent 1.0.78 → 1.0.80

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.
package/dist/cli/index.js CHANGED
@@ -1074,10 +1074,16 @@ function cmdTools() {
1074
1074
  }
1075
1075
  // ── Program ──────────────────────────────────────────────────────────
1076
1076
  const program = new Command();
1077
+ let pkgVersion = '0.0.0';
1078
+ try {
1079
+ const pkgRaw = readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8');
1080
+ pkgVersion = String(JSON.parse(pkgRaw).version ?? '0.0.0');
1081
+ }
1082
+ catch { /* fall back to placeholder */ }
1077
1083
  program
1078
1084
  .name('clementine')
1079
1085
  .description('Clementine Personal AI Assistant')
1080
- .version('1.0.0');
1086
+ .version(pkgVersion);
1081
1087
  program
1082
1088
  .command('launch')
1083
1089
  .description('Start the assistant (daemon by default)')
@@ -1609,11 +1615,46 @@ async function cmdUpdate(options) {
1609
1615
  console.log();
1610
1616
  console.log(` ${DIM}Updating ${getAssistantName()}...${RESET}`);
1611
1617
  console.log();
1612
- // 1. Check we're in a git repo
1613
- if (!existsSync(path.join(PACKAGE_ROOT, '.git'))) {
1614
- console.error(` ${RED}FAIL${RESET} Package root is not a git repository: ${PACKAGE_ROOT}`);
1615
- console.error(' Update requires a git-cloned installation.');
1616
- process.exit(1);
1618
+ // 1. Detect install flavor. Two valid paths:
1619
+ // - git-clone install (PACKAGE_ROOT has .git) → pull + rebuild path below
1620
+ // - npm-global install (no .git) delegate to `npm install -g clementine-agent@latest`
1621
+ const isGitInstall = existsSync(path.join(PACKAGE_ROOT, '.git'));
1622
+ if (!isGitInstall) {
1623
+ if (options.dryRun) {
1624
+ console.log(` ${DIM}[dry-run]${RESET} Would run: npm install -g clementine-agent@latest`);
1625
+ if (options.restart)
1626
+ console.log(` ${DIM}[dry-run]${RESET} Would restart the daemon`);
1627
+ return;
1628
+ }
1629
+ console.log(` ${DIM}npm-global install detected at ${PACKAGE_ROOT}${RESET}`);
1630
+ console.log(` ${DIM}Running: npm install -g clementine-agent@latest${RESET}`);
1631
+ console.log();
1632
+ try {
1633
+ execSync('npm install -g clementine-agent@latest', { stdio: 'inherit' });
1634
+ console.log();
1635
+ console.log(` ${GREEN}OK${RESET} Updated via npm`);
1636
+ }
1637
+ catch (err) {
1638
+ console.error(` ${RED}FAIL${RESET} npm update failed: ${String(err).slice(0, 200)}`);
1639
+ console.error(` ${YELLOW}Hint${RESET} If you see EACCES, see README "Troubleshooting" for npm prefix setup.`);
1640
+ process.exit(1);
1641
+ }
1642
+ if (options.restart) {
1643
+ try {
1644
+ console.log(` ${DIM}Restarting daemon...${RESET}`);
1645
+ execSync('clementine restart', { stdio: 'inherit' });
1646
+ console.log(` ${GREEN}OK${RESET} Daemon restarted`);
1647
+ }
1648
+ catch (err) {
1649
+ console.error(` ${YELLOW}WARN${RESET} Restart failed: ${String(err).slice(0, 200)}. Run \`clementine restart\` manually.`);
1650
+ }
1651
+ }
1652
+ else {
1653
+ console.log();
1654
+ console.log(` ${DIM}Restart your daemon to pick up the new code:${RESET}`);
1655
+ console.log(` clementine restart`);
1656
+ }
1657
+ return;
1617
1658
  }
1618
1659
  let step = 0;
1619
1660
  const S = () => `[${++step}]`;
@@ -1643,11 +1684,16 @@ async function cmdUpdate(options) {
1643
1684
  try {
1644
1685
  const status = execSync('git status --porcelain', { cwd: PACKAGE_ROOT, encoding: 'utf-8' }).trim();
1645
1686
  if (status) {
1646
- console.log(` ${S()} Stashing local changes...`);
1647
- const stashOut = execSync('git stash', { cwd: PACKAGE_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1648
- didStash = !stashOut.includes('No local changes');
1649
- if (didStash) {
1650
- console.log(` ${GREEN}OK${RESET} Stashed local changes`);
1687
+ if (options.dryRun) {
1688
+ console.log(` ${S()} Would stash local changes`);
1689
+ }
1690
+ else {
1691
+ console.log(` ${S()} Stashing local changes...`);
1692
+ const stashOut = execSync('git stash', { cwd: PACKAGE_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1693
+ didStash = !stashOut.includes('No local changes');
1694
+ if (didStash) {
1695
+ console.log(` ${GREEN}OK${RESET} Stashed local changes`);
1696
+ }
1651
1697
  }
1652
1698
  }
1653
1699
  }
@@ -1688,15 +1734,18 @@ async function cmdUpdate(options) {
1688
1734
  const pid = readPid();
1689
1735
  const wasRunning = pid && isProcessAlive(pid);
1690
1736
  if (wasRunning) {
1691
- console.log(` ${S()} Stopping daemon (PID ${pid})...`);
1692
- if (!options.dryRun) {
1737
+ if (options.dryRun) {
1738
+ console.log(` ${S()} Would stop daemon (PID ${pid})`);
1739
+ }
1740
+ else {
1741
+ console.log(` ${S()} Stopping daemon (PID ${pid})...`);
1693
1742
  stopDaemon(pid);
1694
1743
  try {
1695
1744
  unlinkSync(getPidFilePath());
1696
1745
  }
1697
1746
  catch { /* ignore */ }
1747
+ console.log(` ${GREEN}OK${RESET} Daemon stopped`);
1698
1748
  }
1699
- console.log(` ${GREEN}OK${RESET} Daemon stopped`);
1700
1749
  }
1701
1750
  // Helper: if update fails after stopping daemon, relaunch before exiting
1702
1751
  function failAndRestart(backupDir) {
@@ -11,14 +11,15 @@
11
11
  * daemon or stall others.
12
12
  */
13
13
  import type { AgentManager } from '../agent/agent-manager.js';
14
- import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
14
+ import { AgentHeartbeatScheduler, type AgentHeartbeatGateway } from './agent-heartbeat-scheduler.js';
15
15
  export declare class AgentHeartbeatManager {
16
16
  private readonly agentManager;
17
+ private readonly gateway;
17
18
  private readonly schedulers;
18
19
  private timer;
19
20
  private running;
20
21
  private ticking;
21
- constructor(agentManager: AgentManager);
22
+ constructor(agentManager: AgentManager, gateway?: AgentHeartbeatGateway);
22
23
  start(): void;
23
24
  stop(): void;
24
25
  /** Add/remove schedulers to match the current AgentManager listing. */
@@ -16,12 +16,14 @@ const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
16
16
  const OUTER_TICK_MS = 60_000;
17
17
  export class AgentHeartbeatManager {
18
18
  agentManager;
19
+ gateway;
19
20
  schedulers = new Map();
20
21
  timer = null;
21
22
  running = false;
22
23
  ticking = false;
23
- constructor(agentManager) {
24
+ constructor(agentManager, gateway) {
24
25
  this.agentManager = agentManager;
26
+ this.gateway = gateway ?? null;
25
27
  }
26
28
  start() {
27
29
  if (this.running)
@@ -63,7 +65,7 @@ export class AgentHeartbeatManager {
63
65
  // Add new
64
66
  for (const slug of active) {
65
67
  if (!this.schedulers.has(slug)) {
66
- this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager));
68
+ this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager, this.gateway ? { gateway: this.gateway } : {}));
67
69
  logger.info({ slug }, 'Agent heartbeat: registered scheduler');
68
70
  }
69
71
  }
@@ -12,11 +12,24 @@
12
12
  */
13
13
  import type { AgentHeartbeatState } from '../types.js';
14
14
  import type { AgentManager } from '../agent/agent-manager.js';
15
+ /**
16
+ * Minimal gateway surface the scheduler needs for the LLM tick path.
17
+ * Kept narrow so tests can mock it without pulling in the full Gateway.
18
+ */
19
+ export interface AgentHeartbeatGateway {
20
+ handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
21
+ }
15
22
  export interface AgentHeartbeatOptions {
16
23
  /** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
17
24
  baseDir?: string;
18
25
  /** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
19
26
  agentsDir?: string;
27
+ /**
28
+ * Gateway used for the LLM tick path. When omitted, the scheduler runs in
29
+ * cheap-path-only mode (observation + logging, no LLM call). Tests pass
30
+ * mocks here; production passes the real Gateway.
31
+ */
32
+ gateway?: AgentHeartbeatGateway;
20
33
  }
21
34
  export declare class AgentHeartbeatScheduler {
22
35
  private readonly slug;
@@ -24,6 +37,7 @@ export declare class AgentHeartbeatScheduler {
24
37
  private readonly baseDir;
25
38
  private readonly agentsDir;
26
39
  private readonly stateFile;
40
+ private readonly gateway;
27
41
  constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
28
42
  /** Read persisted state, or return a fresh state ready to tick now. */
29
43
  loadState(): AgentHeartbeatState;
@@ -37,10 +51,26 @@ export declare class AgentHeartbeatScheduler {
37
51
  */
38
52
  private buildFingerprint;
39
53
  /**
40
- * Cheap-path tick. Returns the new state. P3 will branch into an LLM
41
- * call when the fingerprint changed; for now we just observe and log.
54
+ * Tick. Loads state, builds fingerprint, decides whether to invoke the
55
+ * LLM path, persists the new state. The LLM call is only made when:
56
+ *
57
+ * 1. The fingerprint changed (something material moved since last tick), AND
58
+ * 2. The prior fingerprint was non-empty (we don't fire LLM on the very
59
+ * first tick after daemon start — those are noisy and not signal), AND
60
+ * 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
42
61
  */
43
62
  tick(now?: Date): Promise<AgentHeartbeatState>;
63
+ /**
64
+ * Build and dispatch the LLM tick prompt via gateway.handleCronJob.
65
+ * Output already routes to the agent's Discord channel (dispatcher.send
66
+ * is called inside the cron path with the agentSlug).
67
+ */
68
+ private runLlmTick;
69
+ /** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
70
+ static parseLlmTickOutput(output: string): {
71
+ nextCheckMinutes: number | undefined;
72
+ summary: string;
73
+ };
44
74
  /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
45
75
  setNextCheckIn(minutes: number, now?: Date): void;
46
76
  getSlug(): string;
@@ -26,12 +26,14 @@ export class AgentHeartbeatScheduler {
26
26
  baseDir;
27
27
  agentsDir;
28
28
  stateFile;
29
+ gateway;
29
30
  constructor(slug, agentManager, opts = {}) {
30
31
  this.slug = slug;
31
32
  this.agentManager = agentManager;
32
33
  this.baseDir = opts.baseDir ?? BASE_DIR;
33
34
  this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
34
35
  this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
36
+ this.gateway = opts.gateway ?? null;
35
37
  }
36
38
  /** Read persisted state, or return a fresh state ready to tick now. */
37
39
  loadState() {
@@ -151,8 +153,13 @@ export class AgentHeartbeatScheduler {
151
153
  return { fingerprint, signals };
152
154
  }
153
155
  /**
154
- * Cheap-path tick. Returns the new state. P3 will branch into an LLM
155
- * call when the fingerprint changed; for now we just observe and log.
156
+ * Tick. Loads state, builds fingerprint, decides whether to invoke the
157
+ * LLM path, persists the new state. The LLM call is only made when:
158
+ *
159
+ * 1. The fingerprint changed (something material moved since last tick), AND
160
+ * 2. The prior fingerprint was non-empty (we don't fire LLM on the very
161
+ * first tick after daemon start — those are noisy and not signal), AND
162
+ * 3. A gateway is wired (opts.gateway). Tests run cheap-path-only.
156
163
  */
157
164
  async tick(now = new Date()) {
158
165
  const profile = this.agentManager.get(this.slug);
@@ -183,28 +190,91 @@ export class AgentHeartbeatScheduler {
183
190
  const prior = this.loadState();
184
191
  const { fingerprint, signals } = this.buildFingerprint();
185
192
  const changed = fingerprint !== prior.fingerprint;
186
- const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
193
+ let nextCheckMinutes = DEFAULT_INTERVAL_MIN;
194
+ let lastSignalSummary;
195
+ const shouldRunLlm = changed && prior.fingerprint !== '' && this.gateway !== null;
196
+ if (shouldRunLlm) {
197
+ try {
198
+ const result = await this.runLlmTick(profile, signals, prior, now);
199
+ nextCheckMinutes = result.nextCheckMinutes ?? DEFAULT_INTERVAL_MIN;
200
+ lastSignalSummary = result.summary?.slice(0, 240);
201
+ }
202
+ catch (err) {
203
+ logger.warn({ err, slug: this.slug }, 'Agent LLM tick failed — using default cadence');
204
+ lastSignalSummary = `llm tick error: ${String(err).slice(0, 200)}`;
205
+ }
206
+ }
207
+ else if (changed) {
208
+ lastSignalSummary = `signal change: ${JSON.stringify(signals)}`.slice(0, 240);
209
+ }
210
+ else {
211
+ lastSignalSummary = prior.lastSignalSummary;
212
+ }
213
+ const clampedMin = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(nextCheckMinutes)));
214
+ const next = new Date(now.getTime() + clampedMin * 60_000);
187
215
  const state = {
188
216
  slug: this.slug,
189
217
  lastTickAt: now.toISOString(),
190
218
  nextCheckAt: next.toISOString(),
191
219
  silentTickCount: changed ? 0 : prior.silentTickCount + 1,
192
220
  fingerprint,
193
- ...(changed
194
- ? { lastSignalSummary: `signal change: ${JSON.stringify(signals)}`.slice(0, 240) }
195
- : prior.lastSignalSummary
196
- ? { lastSignalSummary: prior.lastSignalSummary }
197
- : {}),
221
+ ...(lastSignalSummary ? { lastSignalSummary } : {}),
198
222
  };
199
223
  this.saveState(state);
200
224
  if (changed) {
201
- logger.info({ slug: this.slug, signals, fingerprint }, 'Agent heartbeat: signal change detected (LLM path is P3)');
225
+ logger.info({ slug: this.slug, signals, fingerprint, ranLlm: shouldRunLlm, nextCheckMin: clampedMin }, 'Agent heartbeat tick');
202
226
  }
203
227
  else {
204
228
  logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
205
229
  }
206
230
  return state;
207
231
  }
232
+ /**
233
+ * Build and dispatch the LLM tick prompt via gateway.handleCronJob.
234
+ * Output already routes to the agent's Discord channel (dispatcher.send
235
+ * is called inside the cron path with the agentSlug).
236
+ */
237
+ async runLlmTick(profile, signals, prior, now) {
238
+ if (!this.gateway) {
239
+ return { nextCheckMinutes: undefined, summary: '' };
240
+ }
241
+ const sinceLastMin = prior.lastTickAt
242
+ ? Math.max(0, Math.round((now.getTime() - new Date(prior.lastTickAt).getTime()) / 60_000))
243
+ : 0;
244
+ const prompt = [
245
+ `[Heartbeat check-in: ${profile.slug}]`,
246
+ '',
247
+ `You are ${profile.name}. ${profile.description}`,
248
+ '',
249
+ `## Routine check-in`,
250
+ `This is your scheduled heartbeat tick (${sinceLastMin}min since last).`,
251
+ `Something in your scope has changed since you last checked in.`,
252
+ '',
253
+ `### Signals`,
254
+ `- Pending delegated tasks: ${signals.pendingTasks ?? 0}`,
255
+ `- Latest goal update: ${signals.latestGoalUpdate || 'none'}`,
256
+ `- Latest cron run: ${signals.latestCronRunMs ? new Date(Number(signals.latestCronRunMs)).toISOString() : 'none'}`,
257
+ '',
258
+ `### Instructions`,
259
+ `1. Quickly scan TASKS.md, your goals, and recent cron output for anything that needs action right now.`,
260
+ `2. If there's a clear next action you can take in 1–2 turns, do it.`,
261
+ `3. If you're blocked, waiting on someone, or it's all-quiet, say so concisely.`,
262
+ `4. End your response with \`[NEXT_CHECK: Xm]\` to set when to check in next (5–720 min). Default 30m. Use shorter intervals during active work, longer during quiet hours.`,
263
+ `5. Keep your response under 3 sentences unless you actually took action.`,
264
+ ].join('\n');
265
+ const jobName = `heartbeat:${this.slug}`;
266
+ const result = await this.gateway.handleCronJob(jobName, prompt, 1, 5, undefined, undefined, 'standard', undefined, undefined, undefined, this.slug);
267
+ const parsed = AgentHeartbeatScheduler.parseLlmTickOutput(result);
268
+ return { nextCheckMinutes: parsed.nextCheckMinutes, summary: parsed.summary };
269
+ }
270
+ /** Parse `[NEXT_CHECK: Xm]` directive from the agent's output. Public for tests. */
271
+ static parseLlmTickOutput(output) {
272
+ const match = output.match(/\[NEXT_CHECK:\s*(\d+)\s*m?\]/i);
273
+ const nextCheckMinutes = match ? parseInt(match[1], 10) : undefined;
274
+ // Strip the directive from the summary so logs don't echo it back
275
+ const summary = output.replace(/\[NEXT_CHECK:[^\]]*\]/gi, '').trim();
276
+ return { nextCheckMinutes, summary };
277
+ }
208
278
  /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
209
279
  setNextCheckIn(minutes, now = new Date()) {
210
280
  const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
package/dist/index.js CHANGED
@@ -661,9 +661,10 @@ async function asyncMain() {
661
661
  const cronScheduler = new CronScheduler(gateway, dispatcher);
662
662
  heartbeat.setCronScheduler(cronScheduler);
663
663
  // Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
664
- // observation only in P2 LLM ticks land in P3.
664
+ // observation on every tick; LLM tick fires on signal change with the
665
+ // agent's profile and routes output to their Discord channel.
665
666
  const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
666
- const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
667
+ const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager(), gateway);
667
668
  // ── Build channel tasks ──────────────────────────────────────────
668
669
  const channelTasks = [];
669
670
  const activeChannels = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.78",
3
+ "version": "1.0.80",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",