clementine-agent 1.0.10 → 1.0.11

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.
@@ -2041,11 +2041,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2041
2041
  catch (e) {
2042
2042
  const errStr = String(e).toLowerCase();
2043
2043
  if (errStr.includes('abort') || errStr.includes('cancel')) {
2044
- // Query was aborted. Three sources: timeout, user cancel, or
2045
- // StallGuard tripped (runaway loop detected).
2044
+ // Query was aborted. Four sources: timeout, user cancel, StallGuard
2045
+ // tripped (runaway loop), or interrupted by a new user message.
2046
2046
  const stallAbort = !!stallGuard?.isBreakerActive();
2047
- logger.warn({ sessionKey, stallAbort }, 'Chat query aborted');
2048
- if (stallAbort) {
2047
+ const abortReason = abortController?.signal.reason;
2048
+ const interruptAbort = abortReason === 'interrupted-by-new-message';
2049
+ logger.warn({ sessionKey, stallAbort, interruptAbort }, 'Chat query aborted');
2050
+ if (interruptAbort) {
2051
+ // New message came in — let the next query answer. Just mark
2052
+ // the partial response so the user knows this one was cut off.
2053
+ // (The next handleMessage call will fold this partial into its prompt.)
2054
+ responseText = responseText
2055
+ ? responseText + '\n\n*(interrupted — answering your new message…)*'
2056
+ : '*(interrupted — switching to your new message…)*';
2057
+ }
2058
+ else if (stallAbort) {
2049
2059
  const reason = stallGuard?.getBreakerReason() ?? 'runaway loop';
2050
2060
  const stallMsg = `I got stuck in a loop — ${reason} ` +
2051
2061
  `I stopped to save budget. Options:\n` +
@@ -161,20 +161,37 @@ export class DiscordStreamingMessage {
161
161
  this.progressTimer = null;
162
162
  }
163
163
  if (!text)
164
- text = '*(no response)*';
164
+ text = "*(I didn't have anything to respond with — try rephrasing or giving me more context.)*";
165
165
  text = sanitizeResponse(text);
166
- if (this.message) {
167
- if (text.length <= 1900) {
168
- await this.message.edit(text);
169
- this.messageId = this.message.id;
166
+ try {
167
+ if (this.message) {
168
+ if (text.length <= 1900) {
169
+ await this.message.edit(text);
170
+ this.messageId = this.message.id;
171
+ }
172
+ else {
173
+ await this.message.delete().catch(() => { });
174
+ await sendChunked(this.channel, text);
175
+ }
170
176
  }
171
177
  else {
172
- await this.message.delete().catch(() => { });
173
178
  await sendChunked(this.channel, text);
174
179
  }
175
180
  }
176
- else {
177
- await sendChunked(this.channel, text);
181
+ catch (err) {
182
+ // Delivery failed after the agent already generated a response.
183
+ // Log loudly + persist the response text to the daily note so it isn't
184
+ // lost silently. Don't re-throw — the callers don't have try/catch
185
+ // around finalize() and we don't want to introduce crashes.
186
+ const errMsg = err instanceof Error ? err.message : String(err);
187
+ try {
188
+ const pino = (await import('pino')).default;
189
+ pino({ name: 'clementine.discord' }).warn({ err: errMsg, channelId: this.channel.id }, 'Discord delivery failed — response text saved to daily note');
190
+ const { logToDailyNote } = await import('../gateway/cron-scheduler.js');
191
+ const preview = text.slice(0, 1500);
192
+ logToDailyNote(`**[Discord delivery failed]** Channel \`${this.channel.id ?? 'unknown'}\` — response was:\n\n${preview}`);
193
+ }
194
+ catch { /* best-effort */ }
178
195
  }
179
196
  }
180
197
  /** Format elapsed milliseconds as human-readable duration. */
@@ -135,7 +135,16 @@ export class HeartbeatScheduler {
135
135
  this.dispatcher.send(`**Self-Improvement Failed (nightly)**\n` +
136
136
  `The self-improvement loop crashed: ${String(err).slice(0, 200)}\n\n` +
137
137
  `This will keep failing every night until the root cause is fixed. ` +
138
- `Ask me to check the self-improvement status for details.`, {}).catch(() => { });
138
+ `Ask me to check the self-improvement status for details.`, {}).catch(async (sendErr) => {
139
+ // If the notification about the failure also failed, surface it to the daily note
140
+ // so the user sees it on their next check-in instead of it vanishing into logs.
141
+ logger.warn({ err: sendErr }, 'Failed to notify about self-improvement failure — writing to daily note');
142
+ try {
143
+ const { logToDailyNote } = await import('./cron-scheduler.js');
144
+ logToDailyNote(`**[Self-improvement crashed]** ${String(err).slice(0, 400)}`);
145
+ }
146
+ catch { /* best-effort */ }
147
+ });
139
148
  });
140
149
  }
141
150
  // Weekly per-agent improvement: one agent per day at 2 AM, cycling through
@@ -121,8 +121,11 @@ export declare class Gateway {
121
121
  */
122
122
  stopSession(sessionKey: string): boolean;
123
123
  /**
124
- * Serialize access to a session. Returns a function to call when done,
125
- * or waits for the current holder to finish first.
124
+ * Serialize access to a session. If a query is already in-flight when a new
125
+ * message arrives, we interrupt it — abort the running query, capture its
126
+ * partial output so the next handler can fold it into the new prompt, then
127
+ * wait for the aborted handler to release the lock. This lets users redirect
128
+ * or correct the agent mid-response instead of queuing behind a long query.
126
129
  */
127
130
  private acquireSessionLock;
128
131
  handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback): Promise<string>;
@@ -193,7 +193,15 @@ export class Gateway {
193
193
  logger.warn({ err, sessionKey }, 'Deep mode agent follow-up failed — using raw fallback');
194
194
  if (rawFallback.trim()) {
195
195
  await this._dispatcher?.send(rawFallback.slice(0, 1500))
196
- .catch(e => logger.debug({ err: e }, 'Failed to push deep mode fallback'));
196
+ .catch(async (e) => {
197
+ // Both paths failed — surface it instead of swallowing at debug level.
198
+ logger.warn({ err: e, sessionKey }, 'Deep mode fallback delivery failed — persisting to daily note');
199
+ try {
200
+ const { logToDailyNote } = await import('./cron-scheduler.js');
201
+ logToDailyNote(`**[Deep mode delivery failed]** Session ${sessionKey} — result was:\n\n${rawFallback.slice(0, 1500)}`);
202
+ }
203
+ catch { /* best-effort */ }
204
+ });
197
205
  }
198
206
  }
199
207
  }
@@ -524,16 +532,30 @@ export class Gateway {
524
532
  return false;
525
533
  }
526
534
  /**
527
- * Serialize access to a session. Returns a function to call when done,
528
- * or waits for the current holder to finish first.
535
+ * Serialize access to a session. If a query is already in-flight when a new
536
+ * message arrives, we interrupt it — abort the running query, capture its
537
+ * partial output so the next handler can fold it into the new prompt, then
538
+ * wait for the aborted handler to release the lock. This lets users redirect
539
+ * or correct the agent mid-response instead of queuing behind a long query.
529
540
  */
530
541
  async acquireSessionLock(sessionKey) {
531
- // Wait for any existing lock to resolve
532
542
  let s = this.getSession(sessionKey);
533
- while (s.lock) {
534
- logger.info(`Session ${sessionKey} is busy — queuing message`);
535
- await s.lock;
536
- s = this.getSession(sessionKey);
543
+ // If a query is in-flight, interrupt it rather than wait indefinitely.
544
+ if (s.lock) {
545
+ if (s.abortController && !s.abortController.signal.aborted) {
546
+ const partial = s.lastStreamedText ?? '';
547
+ s.pendingInterrupt = { partial, interruptedAt: Date.now() };
548
+ logger.info({ sessionKey, partialLen: partial.length }, 'New message arrived — interrupting in-flight query');
549
+ // Pass a reason string so assistant.ts can distinguish this from a
550
+ // timeout abort and show the right final message.
551
+ s.abortController.abort('interrupted-by-new-message');
552
+ }
553
+ // Drain any remaining lock promises (the aborted handler still needs to
554
+ // finish its finally block before we can proceed).
555
+ while (s.lock) {
556
+ await s.lock;
557
+ s = this.getSession(sessionKey);
558
+ }
537
559
  }
538
560
  // Create a new lock (a promise + its resolver)
539
561
  let releaseFn;
@@ -725,8 +747,17 @@ export class Gateway {
725
747
  let toolActivityCount = 0;
726
748
  let lastStreamedText = '';
727
749
  let lastProgressEmitAt = Date.now();
750
+ const sessState = this.getSession(sessionKey);
728
751
  const wrappedOnText = onText
729
- ? async (token) => { resetIdleTimer(); lastStreamedText = token; lastProgressEmitAt = Date.now(); return onText(token); }
752
+ ? async (token) => {
753
+ resetIdleTimer();
754
+ lastStreamedText = token;
755
+ // Mirror to session state so a concurrent acquireSessionLock()
756
+ // can capture the partial output on interrupt.
757
+ sessState.lastStreamedText = token;
758
+ lastProgressEmitAt = Date.now();
759
+ return onText(token);
760
+ }
730
761
  : undefined;
731
762
  // Progress streaming: emit brief status indicators during long tool chains
732
763
  // so the user doesn't see silence while the agent works
@@ -762,6 +793,24 @@ export class Gateway {
762
793
  ]);
763
794
  }, CHAT_MAX_WALL_MS);
764
795
  });
796
+ // If the previous query on this session was interrupted by this
797
+ // incoming message, fold the partial output in so the agent can pivot
798
+ // smoothly instead of re-planning from scratch.
799
+ let chatPrompt = text;
800
+ const interrupt = sessState.pendingInterrupt;
801
+ if (interrupt && interrupt.partial.trim()) {
802
+ delete sessState.pendingInterrupt;
803
+ const partialPreview = interrupt.partial.slice(0, 1500);
804
+ chatPrompt =
805
+ `[You were mid-response when the user sent a new message — they chose not to wait. ` +
806
+ `Here's what you had said so far (may be mid-sentence):\n---\n${partialPreview}\n---\n` +
807
+ `New message from user:]\n\n${text}`;
808
+ logger.info({ sessionKey, partialLen: interrupt.partial.length }, 'Folding interrupted partial into new prompt');
809
+ }
810
+ else if (interrupt) {
811
+ // Interrupt flag was set but no useful partial text — just clear it.
812
+ delete sessState.pendingInterrupt;
813
+ }
765
814
  try {
766
815
  // No artificial turn cap — let the agent work until done.
767
816
  // Primary guardrail is cost budget (maxBudgetUsd in buildOptions).
@@ -769,7 +818,7 @@ export class Gateway {
769
818
  events.emit('query:start', { sessionKey, model: effectiveModel, maxTurns: maxTurns, timestamp: Date.now() });
770
819
  const queryStartMs = Date.now();
771
820
  const [response] = await Promise.race([
772
- this.assistant.chat(text, effectiveSessionKey, { onText: wrappedOnText, onToolActivity: wrappedOnToolActivity, model: effectiveModel, maxTurns: maxTurns, securityAnnotation, projectOverride, profile: resolvedProfile, verboseLevel, abortController: chatAc }),
821
+ this.assistant.chat(chatPrompt, effectiveSessionKey, { onText: wrappedOnText, onToolActivity: wrappedOnToolActivity, model: effectiveModel, maxTurns: maxTurns, securityAnnotation, projectOverride, profile: resolvedProfile, verboseLevel, abortController: chatAc }),
773
822
  hardWallPromise,
774
823
  ]);
775
824
  clearTimeout(chatTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",